Skip to content

Commit 4381fa5

Browse files
Write dev ingestion directly to DuckDB cache
1 parent 51f09ff commit 4381fa5

20 files changed

Lines changed: 526 additions & 39 deletions

File tree

.changeset/direct-duckdb-dev.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agentpond": minor
3+
---
4+
5+
Write `agentpond dev` ingestion directly to the dev DuckDB cache and make dev sync a no-op while blocking competing dev writes.

apps/cli/src/commands/dev.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import {
2+
acquireDevServerLock,
23
configFromEnv,
3-
FileSystemObjectStore,
44
initAgentPondEnvironment,
55
selectAgentPondEnvironment,
66
} from "@agentpond/core";
7+
import { AgentPondCache, DuckDbIngestionWriter } from "@agentpond/duckdb";
78
import { buildServer } from "@agentpond/ingest";
89
import {
910
CliError,
@@ -19,18 +20,37 @@ export async function startDevServer(parsed: ParsedArgs): Promise<void> {
1920
selectAgentPondEnvironment(environment.name);
2021
const devConfig = configFromEnv({
2122
envName: environment.name,
22-
eventStorePath: stringFlag(parsed, "event-store"),
2323
storeType: "local",
2424
});
2525
const devEnvironment = devConfig.environment;
2626
if (!devEnvironment)
2727
throw new CliError("Missing dev environment configuration");
28+
const lock = acquireDevServerLock(devEnvironment);
29+
const db = new AgentPondCache(devConfig.dbPath);
2830
const server = buildServer({
2931
config: devConfig,
30-
store: new FileSystemObjectStore(devEnvironment.eventStorePath),
32+
handlers: new DuckDbIngestionWriter(db.directIngestion()),
3133
authMode: "disabled",
3234
});
33-
await server.listen({ host, port });
35+
const shutdown = async () => {
36+
await server.close();
37+
};
38+
server.addHook("onClose", async () => {
39+
await db.close();
40+
lock.release();
41+
});
42+
process.once("SIGINT", shutdown);
43+
process.once("SIGTERM", shutdown);
44+
try {
45+
await db.init();
46+
await server.listen({ host, port });
47+
} catch (error) {
48+
process.off("SIGINT", shutdown);
49+
process.off("SIGTERM", shutdown);
50+
await db.close();
51+
lock.release();
52+
throw error;
53+
}
3454
const baseUrl = `http://${host}:${port}`;
3555
console.log(`AgentPond dev server listening at ${baseUrl}`);
3656
console.log("");
@@ -40,6 +60,5 @@ export async function startDevServer(parsed: ParsedArgs): Promise<void> {
4060
console.log("LANGFUSE_SECRET_KEY=sk-agentpond-dev");
4161
console.log("");
4262
console.log(`Environment: ${devEnvironment.name}`);
43-
console.log(`Event store: ${devEnvironment.eventStorePath}`);
4463
console.log(`DuckDB cache: ${devConfig.dbPath}`);
4564
}

apps/cli/src/commands/environment.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import { existsSync } from "node:fs";
12
import {
3+
configFromEnv,
4+
DEV_SERVER_RUNNING_MESSAGE,
25
initAgentPondEnvironment,
6+
isDevServerRunning,
37
listAgentPondEnvironments,
48
resolveAgentPondEnvironment,
59
selectAgentPondEnvironment,
610
} from "@agentpond/core";
11+
import { AgentPondCache } from "@agentpond/duckdb";
712
import {
813
CliError,
914
type ParsedArgs,
@@ -58,6 +63,34 @@ export async function handleEnvironmentCommand(
5863
throw new CliError(`Unknown command: env ${action}`);
5964
}
6065

66+
export function isDevEnvironment(
67+
config: ReturnType<typeof configFromEnv>,
68+
): boolean {
69+
return config.environment?.name === "dev";
70+
}
71+
72+
export function cacheForRead(
73+
config: ReturnType<typeof configFromEnv>,
74+
): AgentPondCache {
75+
if (config.environment && isDevServerRunning(config.environment)) {
76+
if (!existsSync(config.dbPath)) {
77+
throw new CliError(
78+
"dev cache is not initialized yet; ingest a trace or stop the dev server",
79+
);
80+
}
81+
return new AgentPondCache(config.dbPath, { accessMode: "readonly" });
82+
}
83+
return new AgentPondCache(config.dbPath);
84+
}
85+
86+
export function assertDevServerNotRunning(
87+
config: ReturnType<typeof configFromEnv>,
88+
): void {
89+
if (config.environment && isDevServerRunning(config.environment)) {
90+
throw new CliError(DEV_SERVER_RUNNING_MESSAGE);
91+
}
92+
}
93+
6194
function printEnvironmentHelp(): void {
6295
console.log(`agentpond env - manage local AgentPond environments
6396

apps/cli/src/index.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ import {
1818
stringFlag,
1919
} from "./cli-support.js";
2020
import { startDevServer } from "./commands/dev.js";
21-
import { handleEnvironmentCommand } from "./commands/environment.js";
21+
import {
22+
assertDevServerNotRunning,
23+
cacheForRead,
24+
handleEnvironmentCommand,
25+
isDevEnvironment,
26+
} from "./commands/environment.js";
2227
import { objectStoreForConfig } from "./object-store.js";
2328
import { manualTraceResourceSpans } from "./otel-trace.js";
2429
import {
@@ -36,7 +41,7 @@ export async function main(argv = process.argv): Promise<void> {
3641
return handleEnvironmentCommand(action, rest, parsed);
3742
if (parsed.flags.help || parsed.flags.h) return printHelp();
3843
if (resource === "dev") {
39-
return startDevServer(parsed);
44+
return await startDevServer(parsed);
4045
}
4146
const config = configFromEnv({
4247
envName: stringFlag(parsed, "env"),
@@ -49,6 +54,15 @@ export async function main(argv = process.argv): Promise<void> {
4954
const json = Boolean(parsed.flags.json);
5055
logImplicitEnvironment(parsed, config, json);
5156
if (resource === "sync") {
57+
if (isDevEnvironment(config)) {
58+
return print(
59+
{
60+
skipped: true,
61+
reason: "dev environment is written directly by agentpond dev",
62+
},
63+
json,
64+
);
65+
}
5266
const db = new AgentPondCache(config.dbPath);
5367
const result = await db.syncFromStore({
5468
store: objectStoreForConfig(config),
@@ -61,19 +75,19 @@ export async function main(argv = process.argv): Promise<void> {
6175
if (resource === "sql") {
6276
const query = rest.length > 0 ? [action, ...rest].join(" ") : action;
6377
if (!query) throw new CliError("Missing SQL query");
64-
const db = new AgentPondCache(config.dbPath);
78+
const db = cacheForRead(config);
6579
const rows = await db.query(query);
6680
await db.close();
6781
return print(rows, json);
6882
}
6983
if (resource === "scores" && action === "create") {
70-
return createScore(parsed, config, json);
84+
return await createScore(parsed, config, json);
7185
}
7286
if (resource === "traces" && action === "create") {
73-
return createTrace(parsed, config, json);
87+
return await createTrace(parsed, config, json);
7488
}
7589

76-
const db = new AgentPondCache(config.dbPath);
90+
const db = cacheForRead(config);
7791
const rows = await runReadCommand(db, resource, action, rest, parsed);
7892
await db.close();
7993
return print(rows, json);
@@ -98,6 +112,7 @@ async function createScore(
98112
config: ReturnType<typeof configFromEnv>,
99113
json: boolean,
100114
): Promise<void> {
115+
assertDevServerNotRunning(config);
101116
const name = requiredFlag(parsed, "name");
102117
const value = requiredFlag(parsed, "value");
103118
const source = stringFlag(parsed, "source") ?? "API";
@@ -148,6 +163,7 @@ async function createTrace(
148163
config: ReturnType<typeof configFromEnv>,
149164
json: boolean,
150165
): Promise<void> {
166+
assertDevServerNotRunning(config);
151167
const now = new Date().toISOString();
152168
const traceId = stringFlag(parsed, "id") ?? createOtelTraceId();
153169
const store = objectStoreForConfig(config);

apps/cli/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
},
88
"references": [
99
{ "path": "../../packages/core" },
10-
{ "path": "../../packages/duckdb" }
10+
{ "path": "../../packages/duckdb" },
11+
{ "path": "../../packages/ingest" }
1112
],
1213
"include": ["src/**/*.ts"]
1314
}

packages/core/src/dev-lock.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
closeSync,
3+
existsSync,
4+
mkdirSync,
5+
openSync,
6+
readFileSync,
7+
unlinkSync,
8+
writeFileSync,
9+
} from "node:fs";
10+
import { dirname, join } from "node:path";
11+
import type { AgentPondEnvironment } from "./environment.js";
12+
13+
export const DEV_SERVER_RUNNING_MESSAGE =
14+
"dev server is running; stop it or use the dev ingestion endpoint";
15+
16+
export type DevServerLock = {
17+
pid: number;
18+
startedAt: string;
19+
dbPath: string;
20+
command: string;
21+
};
22+
23+
export type AcquiredDevServerLock = {
24+
path: string;
25+
release: () => void;
26+
};
27+
28+
export function devServerLockPath(environment: AgentPondEnvironment): string {
29+
return join(environment.envDir, "dev-server.lock");
30+
}
31+
32+
export function acquireDevServerLock(
33+
environment: AgentPondEnvironment,
34+
): AcquiredDevServerLock {
35+
const path = devServerLockPath(environment);
36+
removeStaleDevServerLock(path);
37+
mkdirSync(dirname(path), { recursive: true });
38+
const fd = openSync(path, "wx");
39+
closeSync(fd);
40+
const lock: DevServerLock = {
41+
pid: process.pid,
42+
startedAt: new Date().toISOString(),
43+
dbPath: environment.dbPath,
44+
command: process.argv.join(" "),
45+
};
46+
writeFileSync(path, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
47+
let released = false;
48+
return {
49+
path,
50+
release: () => {
51+
if (released) return;
52+
released = true;
53+
try {
54+
const current = readDevServerLock(path);
55+
if (current?.pid === process.pid) unlinkSync(path);
56+
} catch {
57+
// Best-effort cleanup during process shutdown.
58+
}
59+
},
60+
};
61+
}
62+
63+
export function isDevServerRunning(environment: AgentPondEnvironment): boolean {
64+
const path = devServerLockPath(environment);
65+
removeStaleDevServerLock(path);
66+
return readDevServerLock(path) !== undefined;
67+
}
68+
69+
function removeStaleDevServerLock(path: string): void {
70+
const lock = readDevServerLock(path);
71+
if (!lock) {
72+
if (existsSync(path)) {
73+
try {
74+
unlinkSync(path);
75+
} catch {
76+
// Another process may have removed or replaced it.
77+
}
78+
}
79+
return;
80+
}
81+
if (isProcessAlive(lock.pid)) return;
82+
try {
83+
unlinkSync(path);
84+
} catch {
85+
// Another process may have removed or replaced it.
86+
}
87+
}
88+
89+
function readDevServerLock(path: string): DevServerLock | undefined {
90+
if (!existsSync(path)) return undefined;
91+
try {
92+
const parsed = JSON.parse(
93+
readFileSync(path, "utf8"),
94+
) as Partial<DevServerLock>;
95+
if (typeof parsed.pid !== "number") return undefined;
96+
return {
97+
pid: parsed.pid,
98+
startedAt: String(parsed.startedAt ?? ""),
99+
dbPath: String(parsed.dbPath ?? ""),
100+
command: String(parsed.command ?? ""),
101+
};
102+
} catch {
103+
return undefined;
104+
}
105+
}
106+
107+
function isProcessAlive(pid: number): boolean {
108+
if (!Number.isInteger(pid) || pid <= 0) return false;
109+
try {
110+
process.kill(pid, 0);
111+
return true;
112+
} catch (error) {
113+
const code = (error as NodeJS.ErrnoException).code;
114+
return code === "EPERM";
115+
}
116+
}

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./auth.js";
22
export * from "./config.js";
3+
export * from "./dev-lock.js";
34
export * from "./environment.js";
45
export * from "./object-store.js";
56
export * from "./otel.js";
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./filesystem.js";
2+
export * from "./ingestion-handler.js";
23
export * from "./memory.js";
34
export * from "./s3.js";
45
export * from "./types.js";
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { AcceptedEventWriter, type BatchResult } from "../writer.js";
2+
import type { ObjectStore } from "./types.js";
3+
4+
export class ObjectStoreIngestionHandler {
5+
constructor(private readonly store: ObjectStore) {}
6+
7+
async processBatch(params: {
8+
projectId: string;
9+
prefix: string;
10+
batch: unknown[];
11+
}): Promise<BatchResult> {
12+
return new AcceptedEventWriter({
13+
store: this.store,
14+
projectId: params.projectId,
15+
prefix: params.prefix,
16+
}).processBatch(params.batch);
17+
}
18+
19+
async writeOtelResourceSpans(params: {
20+
projectId: string;
21+
prefix: string;
22+
resourceSpans: unknown[];
23+
}): Promise<void> {
24+
await new AcceptedEventWriter({
25+
store: this.store,
26+
projectId: params.projectId,
27+
prefix: params.prefix,
28+
}).writeOtelResourceSpans(params.resourceSpans);
29+
}
30+
}

0 commit comments

Comments
 (0)