perf: cheaper ingest — no re-parse of live hooked transcripts, delta hook writes, lighter pollers

- Session sync leaves a transcript alone while hooks feed its session live
  (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS,
  default 5 min) and catches it up once quiet; a hook-created session (which
  importSession skips anyway) is only snapshotted, never parsed.
- Hook writer keeps a per-transcript mark (applied after commit): an
  unchanged TranscriptCache result skips token/compaction/API-error/turn
  writes, a grown one walks only the entries after the last one handled.
- Codex usage: one stamp query per pass instead of one row per journal;
  between full passes (first + every 5 min) only the newest day folders and
  journals written in the last hour are statted; fork parents resolved from
  the table or a full walk.
- Cursor/Antigravity polls back off to 60 s after idle passes
  (server/lib/backoff-poll.js); lane sync runs only while another dashboard
  shares the data dir (checked every 30 s).
This commit is contained in:
2026-10-16 10:20:09 +07:00
parent ecbbb4dd8f
commit 5cb70d702b
15 changed files with 734 additions and 51 deletions
+85 -8
View File
@@ -294,6 +294,46 @@ const turnDurationStampsStmt = db.prepare(
"SELECT created_at FROM events WHERE session_id = ? AND event_type = 'TurnDuration'"
);
// What this process already handled of each session's transcript parse.
// `transcriptCache.extract` returns the SAME result object while the file is
// unchanged, and a grown file's result is the old arrays plus the new tail
// (errors/turnDurations keep their element objects, compaction entries their
// uuid). So an identical result skips the token, compaction, API-error and
// turn-duration writes outright — each is idempotent against the DB, so they
// would have written nothing new — and a grown one walks only the entries
// after the last one handled. Anything that does not line up (a full re-read,
// the session re-created, the head trimmed off a capped array) falls back to
// the whole array, exactly as before. Marks are applied only once the hook's
// transaction committed; bounded, oldest dropped first.
const TRANSCRIPT_MARKS_MAX = 500;
const transcriptMarks = new Map();
let pendingMarks = null;
function transcriptMarkFor(sessionId, transcriptPath, startedAt) {
const m = transcriptMarks.get(`${sessionId}\0${transcriptPath}`);
return m && m.startedAt === startedAt ? m : null;
}
function queueTranscriptMark(sessionId, transcriptPath, mark) {
if (pendingMarks) pendingMarks.push([`${sessionId}\0${transcriptPath}`, mark]);
}
function applyTranscriptMarks(marks) {
for (const [key, mark] of marks) {
transcriptMarks.delete(key);
transcriptMarks.set(key, mark);
if (transcriptMarks.size > TRANSCRIPT_MARKS_MAX)
transcriptMarks.delete(transcriptMarks.keys().next().value);
}
}
/** The entries of `arr` after `last` (found by `same`), or all of `arr`. */
function tailAfter(arr, last, same) {
if (!arr || last == null) return arr;
for (let i = arr.length - 1; i >= 0; i--) if (same(arr[i], last)) return arr.slice(i + 1);
return arr;
}
/** The session's `<id>-main` agent, created and broadcast. */
function createMainAgent(session) {
const mainAgentId = `${session.id}-main`;
@@ -944,6 +984,11 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
const result = transcriptCache.extract(transcriptPath);
if (result) {
const { tokensByModel, compaction, latestModel } = result;
// Already handled (see transcriptMarks): the same result skips the
// idempotent writes below, a grown one handles only its new entries.
const mark = transcriptMarkFor(sessionId, transcriptPath, session.started_at);
const unchanged = !!mark && mark.result === result;
const lastOf = (arr) => (arr && arr.length ? arr[arr.length - 1] : null);
// Keep session.model in sync with the user's *current* model — the
// transcript's most recent assistant entry is the source of truth, since
@@ -972,8 +1017,13 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
// Register compaction agents and events.
// Each isCompactSummary entry in the JSONL = one compaction that occurred.
// Deduplicate by uuid so we only create once per compaction.
if (compaction) {
for (const entry of compaction.entries) {
if (compaction && !unchanged) {
const fresh = tailAfter(
compaction.entries,
mark?.compactionUuid,
(e, uuid) => e.uuid === uuid
);
for (const entry of fresh) {
const compactId = `${sessionId}-compact-${entry.uuid}`;
if (stmts.getAgent.get(compactId)) continue;
@@ -1024,7 +1074,7 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
if (tokensByModel) {
if (tokensByModel && !unchanged) {
// Each bucket carries its pricing dimensions (model/speed/geo/tier) plus
// the 1h cache-write split and server-tool request counts.
for (const tokens of Object.values(tokensByModel)) {
@@ -1059,12 +1109,15 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register API errors from transcript (quota limits, rate limits, overloaded, etc.)
if (result.errors) {
const freshErrors = unchanged
? null
: tailAfter(result.errors, mark?.lastError, (a, b) => a === b);
if (freshErrors && freshErrors.length) {
let newErrorRecorded = false;
// Deduplicate against the errors already recorded, read ONCE per hook:
// `result.errors` is the transcript's whole history, not just new ones.
const recorded = new Set(apiErrorSummariesStmt.all(sessionId).map((r) => r.summary));
for (const apiErr of result.errors) {
for (const apiErr of freshErrors) {
const errSummary = `${apiErr.type}: ${apiErr.message}`;
if (recorded.has(errSummary)) continue;
recorded.add(errSummary);
@@ -1136,13 +1189,16 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
// Register turn duration events from transcript
if (result.turnDurations) {
const freshTurns = unchanged
? null
: tailAfter(result.turnDurations, mark?.lastTurn, (a, b) => a === b);
if (freshTurns && freshTurns.length) {
// `turnDurations` is every turn in the transcript (up to 1000), on
// every hook: dedupe against ONE read of the recorded timestamps (a
// covering-index scan) instead of a query per turn — 23 ms per hook on
// a 763-turn session before.
const recorded = new Set(turnDurationStampsStmt.all(sessionId).map((r) => r.created_at));
for (const td of result.turnDurations) {
for (const td of freshTurns) {
const tdTs = td.timestamp || new Date().toISOString();
if (recorded.has(tdTs)) continue;
recorded.add(tdTs);
@@ -1171,6 +1227,14 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
}
}
queueTranscriptMark(sessionId, transcriptPath, {
result,
startedAt: session.started_at,
compactionUuid: lastOf(compaction?.entries)?.uuid ?? null,
lastError: lastOf(result.errors),
lastTurn: lastOf(result.turnDurations),
});
// Update session metadata with enriched data (thinking blocks, usage extras)
if (result.usageExtras || result.thinkingBlockCount > 0) {
const session = stmts.getSession.get(sessionId);
@@ -1250,7 +1314,19 @@ const processEventTx = db.transaction((hookType, data, tool = "claude") => {
* inside it described a row a later throw could still roll back.
*/
function processEvent(hookType, data, tool) {
return holdFrames(() => processEventTx(hookType, data, tool));
const outer = pendingMarks;
pendingMarks = [];
let marks = null;
try {
const out = holdFrames(() => processEventTx(hookType, data, tool));
marks = pendingMarks;
return out;
} finally {
pendingMarks = outer;
// Only what COMMITTED is marked handled: a rolled-back hook's entries are
// walked again by the next one.
if (marks && !db.inTransaction) applyTranscriptMarks(marks);
}
}
/** Run `fn` with frames held, then send them if it returned (nested: the
@@ -2176,6 +2252,7 @@ const watchdogTimer = setInterval(watchdogCheck, WATCHDOG_INTERVAL_MS);
if (watchdogTimer.unref) watchdogTimer.unref();
router.transcriptCache = transcriptCache;
router.transcriptMarks = transcriptMarks;
router.watchdogCheck = watchdogCheck;
router.livenessReap = livenessReap;
module.exports = router;