77 lines
2.7 KiB
TypeScript
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 } }
|
|
);
|
|
}
|
|
}
|