Files
suaveplan/packages/api-stack/api/api-saga/src/recovery/recovery.ts
2026-05-27 23:29:53 +01:00

77 lines
2.7 KiB
TypeScript

import { SAGA_CODES, SagaError } from "../error-codes/error-codes.js";
import type { SagaOrchestrator } from "../orchestrator/orchestrator.types.js";
import type { SagaPersistence } from "../persistence/persistence.js";
import type {
SagaInstanceListFilter,
SagaInstanceRow,
} from "../persistence/persistence.types.js";
import { isTerminalInstance } from "../state/state-machine.js";
/** Outcome of a recovery sweep. */
export interface RecoveryResult {
readonly scanned: number;
readonly resumed: number;
readonly skipped: number;
readonly failures: ReadonlyArray<{
readonly sagaId: string;
readonly cause: Error;
}>;
}
/**
* Walk every non-terminal saga in the persistence layer and ask the
* orchestrator to resume each. Designed to be invoked at host boot
* before the host starts serving requests so a saga that was
* `running` at the previous host's death continues from its
* persisted state.
*
* Failures during resume are collected into `result.failures` rather
* than thrown — a single broken saga shouldn't prevent the host from
* starting; the operator can inspect the failure list and take
* targeted action.
*/
export async function recoverInflightSagas(args: {
readonly orchestrator: SagaOrchestrator;
readonly persistence: SagaPersistence;
readonly filter?: SagaInstanceListFilter;
}): Promise<RecoveryResult> {
const { orchestrator, persistence, filter } = args;
const all = persistence.listInstances(filter ?? {});
let resumed = 0;
let skipped = 0;
const failures: Array<{ sagaId: string; cause: Error }> = [];
for (const inst of all) {
if (isTerminalInstance(inst.state)) {
skipped += 1;
continue;
}
try {
await orchestrator.resume(inst.id);
resumed += 1;
} catch (cause) {
failures.push({
sagaId: inst.id,
cause:
cause instanceof Error ? cause : new Error(String(cause)),
});
}
}
return { scanned: all.length, resumed, skipped, failures };
}
/**
* Convenience guard that raises a `SAGA.PERSISTENCE_FAILED` error
* when an attempted recovery target is not in a resumable state.
* Callers that want strict semantics (admin-triggered "resume this
* specific saga") call this before delegating to `orchestrator.resume`.
*/
export function assertResumable(instance: SagaInstanceRow): void {
if (isTerminalInstance(instance.state)) {
throw new SagaError(
SAGA_CODES.PERSISTENCE_FAILED,
`saga: instance '${instance.id}' is already in terminal state '${instance.state}'`,
{ context: { sagaId: instance.id, state: instance.state } }
);
}
}