Skip to content

Commit 7543014

Browse files
authored
Optimize live output storage and incremental delivery
1 parent c383d6e commit 7543014

24 files changed

Lines changed: 570 additions & 59 deletions

backend/src/api.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1005,16 +1005,53 @@ function taskFromBrief(ctx: ApiContext, brief: Row): Task {
10051005
});
10061006
}
10071007

1008-
function taskOutputPayload(ctx: ApiContext, taskId: number): Row {
1008+
function taskOutputPayload(
1009+
ctx: ApiContext,
1010+
taskId: number,
1011+
requestedOffset: number | null = null,
1012+
offsetUnit: "characters" | "bytes" = "characters",
1013+
): Row {
10091014
const isRunning = ctx.scheduler._live_output.has(taskId);
1010-
if (isRunning) {
1015+
const output = isRunning
1016+
? (ctx.scheduler._live_output.get(taskId) ?? "")
1017+
: (ctx.db.get_task_runs(taskId, 1)[0]?.["raw_output"] ?? "");
1018+
1019+
// Preserve the response shape used by clients that predate incremental
1020+
// output polling.
1021+
if (requestedOffset === null) {
1022+
return { output, is_running: isRunning };
1023+
}
1024+
1025+
if (offsetUnit === "bytes") {
1026+
const encoded = new TextEncoder().encode(output);
1027+
let reset = requestedOffset > encoded.byteLength;
1028+
let chunk = output;
1029+
if (!reset) {
1030+
try {
1031+
chunk = new TextDecoder("utf-8", { fatal: true }).decode(
1032+
encoded.slice(requestedOffset),
1033+
);
1034+
} catch {
1035+
// An arbitrary byte offset can split a UTF-8 code point. Send a clean
1036+
// full snapshot so the client never appends replacement characters.
1037+
reset = true;
1038+
}
1039+
}
10111040
return {
1012-
output: ctx.scheduler._live_output.get(taskId) ?? "",
1013-
is_running: true,
1041+
output: reset ? output : chunk,
1042+
is_running: isRunning,
1043+
next_offset: encoded.byteLength,
1044+
reset,
10141045
};
10151046
}
1016-
const runs = ctx.db.get_task_runs(taskId, 1);
1017-
return { output: runs[0]?.["raw_output"] ?? "", is_running: false };
1047+
1048+
const reset = requestedOffset > output.length;
1049+
return {
1050+
output: reset ? output : output.slice(requestedOffset),
1051+
is_running: isRunning,
1052+
next_offset: output.length,
1053+
reset,
1054+
};
10181055
}
10191056

10201057
function taskMessages(ctx: ApiContext, taskId: number): Row[] {
@@ -1438,9 +1475,21 @@ async function handleGet(
14381475
}
14391476
if (path.startsWith("/api/tasks/") && path.endsWith("/output")) {
14401477
const tid = idAt(path);
1478+
const offsetParam = url.searchParams.get("offset");
1479+
const requestedOffset =
1480+
offsetParam === null
1481+
? null
1482+
: Math.max(0, Number.parseInt(offsetParam, 10) || 0);
1483+
const requestedUnit =
1484+
url.searchParams.get("unit") ?? url.searchParams.get("offset_unit");
1485+
const offsetUnit = requestedUnit === "bytes" ? "bytes" : "characters";
14411486
return tid === null
14421487
? jsonResponse({ error: "not found" }, 404, origin)
1443-
: jsonResponse(taskOutputPayload(ctx, tid), 200, origin);
1488+
: jsonResponse(
1489+
taskOutputPayload(ctx, tid, requestedOffset, offsetUnit),
1490+
200,
1491+
origin,
1492+
);
14441493
}
14451494
if (path.startsWith("/api/tasks/") && path.endsWith("/events")) {
14461495
const tid = idAt(path);

backend/src/db.ts

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,49 @@ export class TaskBriefConflictError extends Error {
3939
}
4040
}
4141

42+
interface PendingOutputEvent {
43+
task_id: number;
44+
run_id: number;
45+
event_type: string;
46+
content: string;
47+
}
48+
4249
function expandUser(p: string): string {
4350
return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p;
4451
}
4552

4653
export class TaskDB {
54+
static readonly OUTPUT_EVENT_BATCH_SIZE = 32;
55+
static readonly OUTPUT_EVENT_FLUSH_MS = 50;
56+
4757
db_path: string;
4858
conn: Database;
4959
private transaction_depth = 0;
60+
private _pending_output_events: PendingOutputEvent[] = [];
61+
private _output_event_flush_timer: ReturnType<typeof setTimeout> | null =
62+
null;
63+
private _output_event_insert: ReturnType<Database["query"]>;
64+
private _closed = false;
5065

5166
constructor(db_path: string = "~/.agentforge/tasks.db") {
5267
this.db_path = expandUser(db_path);
5368
fs.mkdirSync(path.dirname(this.db_path), { recursive: true });
5469
this.conn = new Database(this.db_path, { create: true });
5570
this._init_db();
71+
this._output_event_insert = this.conn.query(`
72+
INSERT INTO task_output_events (task_id, run_id, event_type, content)
73+
VALUES (?, ?, ?, ?)
74+
`);
75+
}
76+
77+
/** Flush pending output events and close SQLite exactly once. */
78+
close(): void {
79+
if (this._closed) {
80+
return;
81+
}
82+
this.flush_output_events();
83+
this._closed = true;
84+
this.conn.close();
5685
}
5786

5887
/** Run a migration statement, ignoring "column already exists" errors. */
@@ -277,6 +306,14 @@ export class TaskDB {
277306
CREATE INDEX IF NOT EXISTS idx_task_output_events_task_timestamp
278307
ON task_output_events(task_id, timestamp DESC)
279308
`);
309+
this.conn.run(`
310+
CREATE INDEX IF NOT EXISTS idx_task_output_events_task_id_id
311+
ON task_output_events(task_id, id DESC)
312+
`);
313+
this.conn.run(`
314+
CREATE INDEX IF NOT EXISTS idx_task_output_events_run_id_id
315+
ON task_output_events(run_id, id ASC)
316+
`);
280317

281318
// DAG dependency table
282319
this.conn.run(`
@@ -1405,6 +1442,7 @@ export class TaskDB {
14051442
error: string | null = null,
14061443
raw_output: string | null = null,
14071444
): void {
1445+
this.flush_output_events();
14081446
this.conn
14091447
.query(
14101448
`
@@ -1426,6 +1464,7 @@ export class TaskDB {
14261464
run_error: string | null = null,
14271465
raw_output: string | null = null,
14281466
): void {
1467+
this.flush_output_events();
14291468
const updates: Record<string, unknown> = { ...task_updates };
14301469
updates["updated_at"] = nowIso();
14311470
const sets = Object.keys(updates)
@@ -1460,35 +1499,95 @@ export class TaskDB {
14601499
return rows.map((r) => ({ ...r }));
14611500
}
14621501

1463-
/** Add a new output event to the database. */
1502+
/**
1503+
* Queue an output event for a small batched insert.
1504+
*
1505+
* A full batch is written synchronously. Partial batches are written within
1506+
* OUTPUT_EVENT_FLUSH_MS, bounding API visibility latency while avoiding one
1507+
* SQLite transaction and statement preparation per NDJSON line.
1508+
*/
14641509
add_output_event(
14651510
task_id: number,
14661511
run_id: number,
14671512
event_type: string,
14681513
content: string,
14691514
): void {
1470-
this.conn
1471-
.query(
1472-
`
1473-
INSERT INTO task_output_events (task_id, run_id, event_type, content)
1474-
VALUES (?, ?, ?, ?)
1475-
`,
1476-
)
1477-
.run(task_id, run_id, event_type, content);
1515+
if (this._closed) {
1516+
throw new Error("TaskDB is closed");
1517+
}
1518+
this._pending_output_events.push({
1519+
task_id,
1520+
run_id,
1521+
event_type,
1522+
content,
1523+
});
1524+
if (this._pending_output_events.length >= TaskDB.OUTPUT_EVENT_BATCH_SIZE) {
1525+
this.flush_output_events();
1526+
return;
1527+
}
1528+
this._schedule_output_event_flush();
1529+
}
1530+
1531+
private _schedule_output_event_flush(): void {
1532+
if (
1533+
this._pending_output_events.length === 0 ||
1534+
this._output_event_flush_timer !== null
1535+
) {
1536+
return;
1537+
}
1538+
this._output_event_flush_timer = setTimeout(() => {
1539+
this._output_event_flush_timer = null;
1540+
try {
1541+
this.flush_output_events();
1542+
} catch (e) {
1543+
logger.error(`Failed to flush output events: ${String(e)}`);
1544+
this._schedule_output_event_flush();
1545+
}
1546+
}, TaskDB.OUTPUT_EVENT_FLUSH_MS);
1547+
}
1548+
1549+
/** Persist every queued output event in insertion order. */
1550+
flush_output_events(): void {
1551+
if (this._output_event_flush_timer !== null) {
1552+
clearTimeout(this._output_event_flush_timer);
1553+
this._output_event_flush_timer = null;
1554+
}
1555+
if (this._pending_output_events.length === 0) {
1556+
return;
1557+
}
1558+
1559+
const batch = this._pending_output_events;
1560+
this._pending_output_events = [];
1561+
try {
1562+
this.transaction(() => {
1563+
for (const event of batch) {
1564+
this._output_event_insert.run(
1565+
event.task_id,
1566+
event.run_id,
1567+
event.event_type,
1568+
event.content,
1569+
);
1570+
}
1571+
});
1572+
} catch (e) {
1573+
this._pending_output_events = [...batch, ...this._pending_output_events];
1574+
throw e;
1575+
}
14781576
}
14791577

1480-
/** Get output events for a task, ordered by timestamp. */
1578+
/** Get output events for a task, newest insertion first. */
14811579
get_output_events(
14821580
task_id: number,
14831581
limit: number = 1000,
14841582
offset: number = 0,
14851583
): Row[] {
1584+
this.flush_output_events();
14861585
const rows = this.conn
14871586
.query(
14881587
`
14891588
SELECT * FROM task_output_events
14901589
WHERE task_id = ?
1491-
ORDER BY timestamp DESC
1590+
ORDER BY id DESC
14921591
LIMIT ? OFFSET ?
14931592
`,
14941593
)
@@ -1498,12 +1597,13 @@ export class TaskDB {
14981597

14991598
/** Get output events for a specific run. */
15001599
get_run_output_events(run_id: number, limit: number = 1000): Row[] {
1600+
this.flush_output_events();
15011601
const rows = this.conn
15021602
.query(
15031603
`
15041604
SELECT * FROM task_output_events
15051605
WHERE run_id = ?
1506-
ORDER BY timestamp ASC
1606+
ORDER BY id ASC
15071607
LIMIT ?
15081608
`,
15091609
)
@@ -2239,6 +2339,9 @@ export class TaskDB {
22392339
}
22402340

22412341
delete_task(task_id: number): void {
2342+
// Do not let a delayed insert recreate output rows after their task/run
2343+
// records have been deleted.
2344+
this.flush_output_events();
22422345
this.transaction(() => {
22432346
this.conn
22442347
.query("DELETE FROM task_output_events WHERE task_id = ?")

0 commit comments

Comments
 (0)