first commit
This commit is contained in:
95
docs/api-stack/api/api-saga/src/idempotency/idempotency.md
Normal file
95
docs/api-stack/api/api-saga/src/idempotency/idempotency.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# `idempotency`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Per design doc §9.1 the saga orchestrator MUST wrap every step
|
||||
invocation in an idempotency guard keyed by `{sagaId, stepName}` so a
|
||||
replayed step (e.g. after a host restart or a transient
|
||||
network-induced retry) returns the cached outcome without re-entering
|
||||
the handler. This module is the thin wrapper layered over the
|
||||
foundation `@suaveplan/idempotency` package so the orchestrator does
|
||||
not have to know the underlying guard's API.
|
||||
|
||||
The wrapper also honours the per-action `idempotency: "manual"`
|
||||
opt-out — the participant registration can declare that the action
|
||||
handles its own dedup, in which case the wrapper invokes the handler
|
||||
directly and the foundation guard is bypassed. This matches the
|
||||
"Idempotency contract" bullet under design §9.1.
|
||||
|
||||
## Features
|
||||
|
||||
- `stepIdempotencyKey(sagaId, stepName)` — stable composite key
|
||||
shared between orchestrator and recovery so a replayed step keeps
|
||||
hashing to the same record.
|
||||
- `SagaIdempotencyWrapper.run(...)` — single entry point the
|
||||
orchestrator funnels every step invocation through.
|
||||
- Honours the per-action `idempotency: "manual"` opt-out: the
|
||||
wrapper short-circuits before the guard runs.
|
||||
- Composable with any `IdempotencyGuard` shape — the foundation
|
||||
package ships memory, database, and Redis store adapters; the
|
||||
saga wrapper picks whichever one the host has wired.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { IdempotencyGuard, MemoryIdempotencyStore } from "@suaveplan/idempotency";
|
||||
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga/idempotency/idempotency";
|
||||
|
||||
const guard = new IdempotencyGuard({ store: new MemoryIdempotencyStore() });
|
||||
const wrapper = new SagaIdempotencyWrapper(guard);
|
||||
|
||||
const output = await wrapper.run("s_1", "charge", "auto", async () => {
|
||||
return { chargeId: "ch_1" };
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Compose the wrapper with the orchestrator step-dispatch path. The
|
||||
wrapper is intentionally generic over the result type so a typed
|
||||
step handler keeps its return type through the wrap:
|
||||
|
||||
```ts
|
||||
import { SagaIdempotencyWrapper } from "@suaveplan/api-saga";
|
||||
|
||||
async function dispatchStep<T extends Readonly<Record<string, unknown>>>(
|
||||
sagaId: string,
|
||||
step: StepDeclaration,
|
||||
wrapper: SagaIdempotencyWrapper,
|
||||
handler: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return wrapper.run(sagaId, step.action, registration.idempotency, handler);
|
||||
}
|
||||
```
|
||||
|
||||
A retried step whose first attempt completed under the same
|
||||
`{sagaId, stepName}` key returns the cached outcome — the handler
|
||||
body is not re-entered. A retried step whose previous attempt is
|
||||
still `pending` (lock not timed out) throws
|
||||
`IdempotencyConflictError`; the orchestrator interprets this as "let
|
||||
the live attempt finish" and does not re-fire the step.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `stepIdempotencyKey(sagaId, stepName): string` — `saga:<sagaId>:<stepName>`.
|
||||
- `SagaIdempotencyWrapper(guard: IdempotencyGuard)` — constructor.
|
||||
- `wrapper.run<T>(sagaId, stepName, mode, fn): Promise<T>` — returns
|
||||
the cached or newly produced outcome.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper is intentionally NOT a subclass of
|
||||
`IdempotencyGuard` — it composes one. The foundation guard owns
|
||||
fingerprinting and TTL; the saga wrapper only needs the
|
||||
`execute(key, fn)` entry point. Subclassing would expose internal
|
||||
state through `protected` slots and turn this module into a tight
|
||||
binding to the guard's class hierarchy.
|
||||
|
||||
Manual mode is deliberately a no-op rather than a "bypass + warn" —
|
||||
some saga steps (e.g. external API calls whose dedup is already
|
||||
handled by the API's request-id contract) genuinely don't want a
|
||||
second layer of idempotency.
|
||||
Reference in New Issue
Block a user