Drain document lifecycle events through a durable processor

Before this commit `createDocumentWithLifecycle` enqueued ledger rows
inside the save transaction but consumed them from an in-memory
`pendingSideEffects` array straight after commit. A crash between the
commit and the drain stranded rows as `pending` forever; a crash mid
handler left them as `running`; and there was no retry budget.

The new `DocumentLifecycleProcessor` is the single drainer. It:

  - claims work via `claimPendingBatch`, an atomic `UPDATE … RETURNING`
    that flips rows from `pending` to `running` while incrementing
    `attempts`, so two drainers cannot race onto the same row;
  - dispatches each row through a handler registered by `hookType`,
    parsing the frozen `payload_json` back into the call arguments;
  - records `succeeded` / `failed` / `retryable` outcomes through the
    ledger, so a transient failure stays on the queue until the attempt
    cap expires, after which it is marked `failed` for operators;
  - reclaims rows stuck in `running` past `staleRunningMs` back to
    `pending` on every sweep tick plus at startup, so a crashed process
    never leaves a row orphaned;
  - runs a periodic sweep so every document — including those whose
    immediate post-commit drain failed — is eventually advanced.

`createDocumentWithLifecycle` now only enqueues and then calls
`lifecycleProcessor.drainForDocument` for the immediate happy-path pass.
Handlers live in `document-lifecycle-handlers.ts` so the processor stays
free of service-graph knowledge. The processor is constructed in
`bootstrap-application`, started there, and stopped on `before-quit`.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 16:14:51 -04:00
1 parent ac9be2b639
commit b1d1fbc54f
11 files changed
+487 -99

No files matched your search

+5 -1
View File
@@ -6,6 +6,7 @@ import { AppDatabase } from "./database";
import { createMainRuntimeSupport } from "./main-runtime-support";
import type { ThreadIntegrationService } from "./thread-integration";
import type { createCheckpointEngine } from "./checkpoint-engine";
import type { DocumentLifecycleProcessor } from "./document-lifecycle-processor";
interface CreateMainRuntimeDependencies {
hasSingleInstanceLock: boolean;
@@ -39,6 +40,7 @@ export function createMainRuntime({
ipcSupport: Awaited<ReturnType<typeof bootstrapApplication>>["ipcSupport"] | null;
threadIntegration: ThreadIntegrationService | null;
checkpointEngine: ReturnType<typeof createCheckpointEngine> | null;
lifecycleProcessor: DocumentLifecycleProcessor | null;
isQuitting: boolean;
} = {
db: null,
@@ -47,6 +49,7 @@ export function createMainRuntime({
ipcSupport: null,
threadIntegration: null,
checkpointEngine: null,
lifecycleProcessor: null,
isQuitting: false,
};
const support = createMainRuntimeSupport(state);
@@ -83,13 +86,14 @@ export function createMainRuntime({
state.isQuitting = value;
},
},
onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport, threadIntegration, checkpointEngine }) => {
onRuntimeReady: ({ db, apiServer, serviceGraph, ipcSupport, threadIntegration, checkpointEngine, lifecycleProcessor }) => {
state.db = db;
state.apiServer = apiServer;
state.serviceGraph = serviceGraph;
state.ipcSupport = ipcSupport;
state.threadIntegration = threadIntegration;
state.checkpointEngine = checkpointEngine;
state.lifecycleProcessor = lifecycleProcessor;
// Register IPC handlers immediately so the renderer can call loadSnapshot()
// as soon as the window appears — before connector health checks finish.
support.registerHandlers();