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`.
228 lines
7.7 KiB
TypeScript
228 lines
7.7 KiB
TypeScript
/**
|
|
* Durable processor for `document_lifecycle_events`.
|
|
*
|
|
* `createDocumentWithLifecycle` enqueues hook rows inside the same
|
|
* transaction as `saveDocument`, so the ledger commit is the new
|
|
* durability boundary for cross-process side effects. This processor
|
|
* is what moves rows from `pending` to `succeeded` / `failed`:
|
|
*
|
|
* - `drainForDocument(documentId)` runs synchronously right after
|
|
* the save commits so the happy-path latency is unchanged.
|
|
* - `processBatch(limit)` runs on a periodic sweep so a crash
|
|
* between commit and the immediate drain is self-healing.
|
|
* - `reclaimStale()` rescues rows stuck in `running` past the
|
|
* configured cutoff — typically because the process died
|
|
* mid-handler — and puts them back on the pending queue so the
|
|
* next claim picks them up.
|
|
*
|
|
* Claim semantics are atomic: `claimPendingBatch` flips rows to
|
|
* `running` and increments `attempts` in a single UPDATE so two
|
|
* processors cannot race onto the same id. `maxAttempts` caps the
|
|
* retry budget; rows that exhaust it stay `pending` with their
|
|
* attempt count at the cap and surface to the operator through
|
|
* `listExhausted`.
|
|
*/
|
|
|
|
import type { AppDatabase } from "./database";
|
|
import type { DocumentLifecycleEventRow } from "./database/document-lifecycle-events";
|
|
|
|
export interface LifecycleHookContext {
|
|
companyId: string;
|
|
documentId: string;
|
|
eventId: string;
|
|
}
|
|
|
|
export type LifecycleHookOutcome =
|
|
| { kind: "succeeded" }
|
|
| { kind: "retryable"; reason: string }
|
|
| { kind: "failed"; reason: string };
|
|
|
|
export type LifecycleHookHandler = (
|
|
payload: Record<string, unknown>,
|
|
context: LifecycleHookContext,
|
|
) => LifecycleHookOutcome;
|
|
|
|
export interface LifecycleProcessorOptions {
|
|
db: AppDatabase;
|
|
logger: {
|
|
info: (...args: unknown[]) => void;
|
|
warn: (...args: unknown[]) => void;
|
|
error: (...args: unknown[]) => void;
|
|
};
|
|
/** Maximum `attempts` value allowed before a row becomes dead-letter. */
|
|
maxAttempts: number;
|
|
/** Cutoff for reclaiming rows stuck in `running`. */
|
|
staleRunningMs: number;
|
|
/** Batch size for a single sweep. */
|
|
batchSize: number;
|
|
/** Interval for the background sweep. 0 disables periodic work. */
|
|
sweepIntervalMs: number;
|
|
}
|
|
|
|
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
const DEFAULT_STALE_RUNNING_MS = 60_000;
|
|
const DEFAULT_BATCH_SIZE = 32;
|
|
const DEFAULT_SWEEP_INTERVAL_MS = 15_000;
|
|
|
|
export function makeLifecycleProcessorOptions(
|
|
overrides: Partial<LifecycleProcessorOptions> & Pick<LifecycleProcessorOptions, "db" | "logger">,
|
|
): LifecycleProcessorOptions {
|
|
return {
|
|
maxAttempts: DEFAULT_MAX_ATTEMPTS,
|
|
staleRunningMs: DEFAULT_STALE_RUNNING_MS,
|
|
batchSize: DEFAULT_BATCH_SIZE,
|
|
sweepIntervalMs: DEFAULT_SWEEP_INTERVAL_MS,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export class DocumentLifecycleProcessor {
|
|
private readonly handlers = new Map<string, LifecycleHookHandler>();
|
|
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
private stopped = false;
|
|
|
|
constructor(private readonly options: LifecycleProcessorOptions) {}
|
|
|
|
register(hookType: string, handler: LifecycleHookHandler): void {
|
|
this.handlers.set(hookType, handler);
|
|
}
|
|
|
|
/** Immediate drain scoped to one document, used right after commit. */
|
|
drainForDocument(documentId: string): void {
|
|
const batch = this.options.db.documentLifecycleEvents.claimPendingBatch({
|
|
limit: this.options.batchSize,
|
|
maxAttempts: this.options.maxAttempts,
|
|
documentId,
|
|
});
|
|
for (const row of batch) this.dispatch(row);
|
|
}
|
|
|
|
/** Periodic sweep: rescues stragglers across all documents. */
|
|
processBatch(): number {
|
|
const batch = this.options.db.documentLifecycleEvents.claimPendingBatch({
|
|
limit: this.options.batchSize,
|
|
maxAttempts: this.options.maxAttempts,
|
|
});
|
|
for (const row of batch) this.dispatch(row);
|
|
return batch.length;
|
|
}
|
|
|
|
/** Move rows stuck in `running` back to `pending`. */
|
|
reclaimStale(): number {
|
|
const cutoff = new Date(Date.now() - this.options.staleRunningMs).toISOString();
|
|
const reclaimed = this.options.db.documentLifecycleEvents.reclaimStaleRunning(cutoff);
|
|
if (reclaimed > 0) {
|
|
this.options.logger.info(
|
|
`[document-lifecycle] reclaimed ${reclaimed} stale running row(s) past ${this.options.staleRunningMs}ms cutoff`,
|
|
);
|
|
}
|
|
return reclaimed;
|
|
}
|
|
|
|
/**
|
|
* Start the periodic sweeper. Idempotent: calling twice is a no-op.
|
|
* `stop()` tears it down so the process can exit cleanly.
|
|
*/
|
|
start(): void {
|
|
if (this.sweepTimer || this.stopped) return;
|
|
if (this.options.sweepIntervalMs <= 0) return;
|
|
// Run a reclaim + sweep on startup so a crashed process's leftover
|
|
// `running` rows are rescued before the first new claim arrives.
|
|
this.tick();
|
|
this.sweepTimer = setInterval(() => this.tick(), this.options.sweepIntervalMs);
|
|
if (typeof this.sweepTimer.unref === "function") this.sweepTimer.unref();
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped = true;
|
|
if (this.sweepTimer) {
|
|
clearInterval(this.sweepTimer);
|
|
this.sweepTimer = null;
|
|
}
|
|
}
|
|
|
|
private tick(): void {
|
|
try {
|
|
this.reclaimStale();
|
|
} catch (error) {
|
|
this.options.logger.warn(
|
|
`[document-lifecycle] reclaimStale failed: ${describeError(error)}`,
|
|
);
|
|
}
|
|
try {
|
|
this.processBatch();
|
|
} catch (error) {
|
|
this.options.logger.warn(
|
|
`[document-lifecycle] processBatch failed: ${describeError(error)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private dispatch(row: DocumentLifecycleEventRow): void {
|
|
const handler = this.handlers.get(row.hookType);
|
|
if (!handler) {
|
|
const reason = `No handler registered for hook type "${row.hookType}"`;
|
|
this.options.db.documentLifecycleEvents.markFailed(row.id, reason);
|
|
this.options.logger.error(`[document-lifecycle] ${reason} (event ${row.id})`);
|
|
return;
|
|
}
|
|
|
|
let payload: Record<string, unknown>;
|
|
try {
|
|
payload = row.payloadJson ? (JSON.parse(row.payloadJson) as Record<string, unknown>) : {};
|
|
} catch (error) {
|
|
const reason = `payload_json parse failed: ${describeError(error)}`;
|
|
this.options.db.documentLifecycleEvents.markFailed(row.id, reason);
|
|
this.options.logger.error(`[document-lifecycle] ${reason} (event ${row.id})`);
|
|
return;
|
|
}
|
|
|
|
let outcome: LifecycleHookOutcome;
|
|
try {
|
|
outcome = handler(payload, {
|
|
companyId: row.companyId,
|
|
documentId: row.documentId,
|
|
eventId: row.id,
|
|
});
|
|
} catch (error) {
|
|
const message = describeError(error);
|
|
this.options.logger.error(
|
|
`[document-lifecycle] handler "${row.hookType}" threw for event ${row.id}: ${message}`,
|
|
);
|
|
this.recordOutcome(row, { kind: "retryable", reason: message });
|
|
return;
|
|
}
|
|
this.recordOutcome(row, outcome);
|
|
}
|
|
|
|
private recordOutcome(row: DocumentLifecycleEventRow, outcome: LifecycleHookOutcome): void {
|
|
try {
|
|
if (outcome.kind === "succeeded") {
|
|
this.options.db.documentLifecycleEvents.markSucceeded(row.id);
|
|
return;
|
|
}
|
|
if (outcome.kind === "failed") {
|
|
this.options.db.documentLifecycleEvents.markFailed(row.id, outcome.reason);
|
|
return;
|
|
}
|
|
if (row.attempts >= this.options.maxAttempts) {
|
|
this.options.db.documentLifecycleEvents.markFailed(
|
|
row.id,
|
|
`Retry budget exhausted (${row.attempts}/${this.options.maxAttempts}): ${outcome.reason}`,
|
|
);
|
|
return;
|
|
}
|
|
this.options.db.documentLifecycleEvents.markRetryable(row.id, outcome.reason);
|
|
} catch (error) {
|
|
this.options.logger.warn(
|
|
`[document-lifecycle] recordOutcome failed for ${row.id}: ${describeError(error)}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function describeError(error: unknown): string {
|
|
if (error instanceof Error) return error.message;
|
|
return String(error);
|
|
}
|