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:
+85
-8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user