@executablemd/durable-streams 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/_dnt.polyfills.js +1 -0
- package/esm/combinators.js +54 -11
- package/esm/context.js +1 -1
- package/esm/durability.js +118 -0
- package/esm/effect.js +64 -24
- package/esm/errors.js +54 -7
- package/esm/guard.js +107 -0
- package/esm/live-coordinator.js +16 -0
- package/esm/mod.js +17 -3
- package/esm/parse.js +206 -0
- package/esm/replay-guard.js +21 -3
- package/esm/replay-index.js +53 -17
- package/esm/retained.js +390 -0
- package/esm/run.js +74 -29
- package/esm/serialize.js +29 -0
- package/package.json +2 -2
- package/types/_dnt.polyfills.d.ts +6 -0
- package/types/context.d.ts +11 -2
- package/types/durability.d.ts +7 -0
- package/types/effect.d.ts +6 -1
- package/types/errors.d.ts +42 -3
- package/types/guard.d.ts +76 -0
- package/types/live-coordinator.d.ts +11 -0
- package/types/mod.d.ts +12 -5
- package/types/parse.d.ts +23 -0
- package/types/replay-guard.d.ts +39 -3
- package/types/replay-index.d.ts +24 -12
- package/types/retained.d.ts +83 -0
- package/types/run.d.ts +2 -1
- package/types/serialize.d.ts +22 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/esm/combinators.js
CHANGED
|
@@ -18,8 +18,10 @@
|
|
|
18
18
|
* See protocol spec §7 (structured concurrency), §10 (race semantics).
|
|
19
19
|
*/
|
|
20
20
|
import { all as effectionAll, ensure, race as effectionRace, spawn, suspend, useScope, } from "effection";
|
|
21
|
-
import {
|
|
21
|
+
import { DurableContext } from "./context.js";
|
|
22
|
+
import { activeDurabilityFailure, appendDurableEvent, rememberDurabilityFailure, } from "./durability.js";
|
|
22
23
|
import { ephemeral } from "./ephemeral.js";
|
|
24
|
+
import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.js";
|
|
23
25
|
import { deserializeError, serializeError } from "./serialize.js";
|
|
24
26
|
/**
|
|
25
27
|
* Run a child workflow within a spawned scope, setting up its own
|
|
@@ -29,7 +31,7 @@ import { deserializeError, serializeError } from "./serialize.js";
|
|
|
29
31
|
*
|
|
30
32
|
* It:
|
|
31
33
|
* 1. Checks if the child already completed (has Close event) — short-circuits
|
|
32
|
-
* 2. Sets
|
|
34
|
+
* 2. Sets DurableContext on the child's scope with the child's coroutineId
|
|
33
35
|
* 3. Runs the child workflow (its DurableEffects use the child's coroutineId)
|
|
34
36
|
* 4. Appends Close(ok|err) when the child terminates
|
|
35
37
|
*
|
|
@@ -38,6 +40,7 @@ import { deserializeError, serializeError } from "./serialize.js";
|
|
|
38
40
|
*/
|
|
39
41
|
function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
40
42
|
const { replayIndex, stream } = parentCtx;
|
|
43
|
+
replayIndex.claim(childId);
|
|
41
44
|
// Short-circuit: child already completed in a previous run.
|
|
42
45
|
// NOTE: Replay guard validation is not bypassed here — the check phase
|
|
43
46
|
// (runCheckPhase in durableRun) already iterated ALL Yield events
|
|
@@ -72,17 +75,32 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
72
75
|
}
|
|
73
76
|
// Set child's DurableContext on this scope
|
|
74
77
|
const scope = yield* useScope();
|
|
75
|
-
|
|
78
|
+
parentCtx.durability ??= {};
|
|
79
|
+
const childCtx = {
|
|
76
80
|
replayIndex,
|
|
77
81
|
stream,
|
|
78
82
|
coroutineId: childId,
|
|
79
83
|
childCounter: 0,
|
|
80
|
-
|
|
84
|
+
durability: parentCtx.durability,
|
|
85
|
+
};
|
|
86
|
+
scope.set(DurableContext, childCtx);
|
|
81
87
|
let closeEvent;
|
|
88
|
+
let suppressClose = false;
|
|
82
89
|
yield* ensure(function* () {
|
|
90
|
+
if (suppressClose || activeDurabilityFailure(childCtx)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
83
93
|
// closeEvent still undefined means the child was cancelled before the
|
|
84
94
|
// normal-return or catch path ran.
|
|
85
95
|
if (!closeEvent) {
|
|
96
|
+
const unaligned = replayIndex.firstUnaligned(childId);
|
|
97
|
+
if (unaligned) {
|
|
98
|
+
const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, {
|
|
99
|
+
message: `Divergence: coroutine ${childId} was cancelled before retained history was exhausted`,
|
|
100
|
+
});
|
|
101
|
+
rememberDurabilityFailure(childCtx, failure);
|
|
102
|
+
throw failure;
|
|
103
|
+
}
|
|
86
104
|
closeEvent = {
|
|
87
105
|
type: "close",
|
|
88
106
|
coroutineId: childId,
|
|
@@ -92,13 +110,25 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
92
110
|
// Don't re-emit a Close event if one already exists in the journal
|
|
93
111
|
// (e.g., a cancelled child being replayed via suspend()).
|
|
94
112
|
if (!replayIndex.hasClose(childId)) {
|
|
95
|
-
yield*
|
|
113
|
+
yield* appendDurableEvent(childCtx, closeEvent);
|
|
96
114
|
}
|
|
97
115
|
});
|
|
98
116
|
try {
|
|
99
117
|
// Run the child workflow. DurableEffects inside the child read
|
|
100
|
-
//
|
|
118
|
+
// DurableContext from the scope, so they'll use childId.
|
|
101
119
|
const result = yield* childWorkflow();
|
|
120
|
+
const durabilityFailure = activeDurabilityFailure(childCtx);
|
|
121
|
+
if (durabilityFailure) {
|
|
122
|
+
suppressClose = true;
|
|
123
|
+
throw durabilityFailure;
|
|
124
|
+
}
|
|
125
|
+
const unaligned = replayIndex.firstUnaligned(childId);
|
|
126
|
+
if (unaligned) {
|
|
127
|
+
suppressClose = true;
|
|
128
|
+
const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields);
|
|
129
|
+
rememberDurabilityFailure(childCtx, failure);
|
|
130
|
+
throw failure;
|
|
131
|
+
}
|
|
102
132
|
closeEvent = {
|
|
103
133
|
type: "close",
|
|
104
134
|
coroutineId: childId,
|
|
@@ -107,15 +137,28 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
107
137
|
return result;
|
|
108
138
|
}
|
|
109
139
|
catch (error) {
|
|
140
|
+
const primary = error instanceof Error ? error : new Error(String(error));
|
|
141
|
+
const durabilityFailure = activeDurabilityFailure(childCtx, primary);
|
|
142
|
+
if (durabilityFailure) {
|
|
143
|
+
suppressClose = true;
|
|
144
|
+
throw durabilityFailure;
|
|
145
|
+
}
|
|
146
|
+
const unaligned = replayIndex.firstUnaligned(childId);
|
|
147
|
+
if (unaligned) {
|
|
148
|
+
suppressClose = true;
|
|
149
|
+
const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary });
|
|
150
|
+
rememberDurabilityFailure(childCtx, failure);
|
|
151
|
+
throw failure;
|
|
152
|
+
}
|
|
110
153
|
closeEvent = {
|
|
111
154
|
type: "close",
|
|
112
155
|
coroutineId: childId,
|
|
113
156
|
result: {
|
|
114
157
|
status: "err",
|
|
115
|
-
error: serializeError(
|
|
158
|
+
error: serializeError(primary),
|
|
116
159
|
},
|
|
117
160
|
};
|
|
118
|
-
throw
|
|
161
|
+
throw primary;
|
|
119
162
|
}
|
|
120
163
|
}
|
|
121
164
|
/**
|
|
@@ -133,7 +176,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
133
176
|
export function durableSpawn(childWorkflow) {
|
|
134
177
|
return ephemeral((function* () {
|
|
135
178
|
const scope = yield* useScope();
|
|
136
|
-
const ctx = scope.expect(
|
|
179
|
+
const ctx = scope.expect(DurableContext);
|
|
137
180
|
// Assign deterministic child ID
|
|
138
181
|
const childIndex = ctx.childCounter++;
|
|
139
182
|
const childId = `${ctx.coroutineId}.${childIndex}`;
|
|
@@ -158,7 +201,7 @@ export function durableSpawn(childWorkflow) {
|
|
|
158
201
|
export function durableAll(workflows) {
|
|
159
202
|
return ephemeral((function* () {
|
|
160
203
|
const scope = yield* useScope();
|
|
161
|
-
const ctx = scope.expect(
|
|
204
|
+
const ctx = scope.expect(DurableContext);
|
|
162
205
|
// Build child Operations, one per workflow. Each gets its own
|
|
163
206
|
// deterministic coroutineId and Close event handling.
|
|
164
207
|
const childOps = workflows.map((workflow) => {
|
|
@@ -197,7 +240,7 @@ export function durableAll(workflows) {
|
|
|
197
240
|
export function durableRace(workflows) {
|
|
198
241
|
return ephemeral((function* () {
|
|
199
242
|
const scope = yield* useScope();
|
|
200
|
-
const ctx = scope.expect(
|
|
243
|
+
const ctx = scope.expect(DurableContext);
|
|
201
244
|
// Build Operations for each child — each gets its own coroutineId
|
|
202
245
|
// and Close event handling via runDurableChild.
|
|
203
246
|
const childOps = workflows.map((workflow) => {
|
package/esm/context.js
CHANGED
|
@@ -10,4 +10,4 @@ import { createContext } from "effection";
|
|
|
10
10
|
* Effection Context for durable execution state.
|
|
11
11
|
* Set on the root scope by durableRun(); inherited by child scopes.
|
|
12
12
|
*/
|
|
13
|
-
export const
|
|
13
|
+
export const DurableContext = createContext("@effection/durable");
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ensure, resource, withResolvers } from "effection";
|
|
2
|
+
import { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
|
|
3
|
+
import { withDurableEventRejectionOccurrence, } from "./guard.js";
|
|
4
|
+
function createAppendFence() {
|
|
5
|
+
let held = false;
|
|
6
|
+
const waiting = [];
|
|
7
|
+
function release() {
|
|
8
|
+
const next = waiting.shift();
|
|
9
|
+
if (next === undefined) {
|
|
10
|
+
held = false;
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
next.granted = true;
|
|
14
|
+
next.gate.resolve();
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
hold: () => resource(function* (provide) {
|
|
18
|
+
const turn = { gate: withResolvers(), granted: false };
|
|
19
|
+
yield* ensure(() => {
|
|
20
|
+
if (turn.granted) {
|
|
21
|
+
release();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const index = waiting.indexOf(turn);
|
|
25
|
+
if (index >= 0) {
|
|
26
|
+
waiting.splice(index, 1);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
if (held) {
|
|
30
|
+
waiting.push(turn);
|
|
31
|
+
yield* turn.gate.operation;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
held = true;
|
|
35
|
+
turn.granted = true;
|
|
36
|
+
}
|
|
37
|
+
yield* provide();
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function durabilityState(ctx) {
|
|
42
|
+
ctx.durability ??= {};
|
|
43
|
+
return ctx.durability;
|
|
44
|
+
}
|
|
45
|
+
function appendFence(ctx) {
|
|
46
|
+
const state = durabilityState(ctx);
|
|
47
|
+
state.appendFence ??= createAppendFence();
|
|
48
|
+
return state.appendFence;
|
|
49
|
+
}
|
|
50
|
+
export function findDurabilityFailure(error) {
|
|
51
|
+
const visited = new Set();
|
|
52
|
+
const pending = [error];
|
|
53
|
+
while (pending.length > 0) {
|
|
54
|
+
const current = pending.shift();
|
|
55
|
+
if (visited.has(current)) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
visited.add(current);
|
|
59
|
+
if (current instanceof DurablePersistenceError ||
|
|
60
|
+
current instanceof StaleInputError ||
|
|
61
|
+
current instanceof DivergenceError ||
|
|
62
|
+
current instanceof TerminalDivergenceError ||
|
|
63
|
+
current instanceof ContinuePastCloseDivergenceError) {
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
if (current instanceof AggregateError) {
|
|
67
|
+
pending.push(...current.errors);
|
|
68
|
+
}
|
|
69
|
+
if (current instanceof Error && current.cause !== undefined) {
|
|
70
|
+
pending.push(current.cause);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
export function rememberDurabilityFailure(ctx, error) {
|
|
76
|
+
const state = durabilityState(ctx);
|
|
77
|
+
state.failure ??= error;
|
|
78
|
+
return state.failure;
|
|
79
|
+
}
|
|
80
|
+
export function activeDurabilityFailure(ctx, error) {
|
|
81
|
+
if (ctx.durability?.failure) {
|
|
82
|
+
return ctx.durability.failure;
|
|
83
|
+
}
|
|
84
|
+
const failure = findDurabilityFailure(error);
|
|
85
|
+
if (failure) {
|
|
86
|
+
return rememberDurabilityFailure(ctx, failure);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
export function* appendDurableEvent(ctx, event) {
|
|
91
|
+
const existing = activeDurabilityFailure(ctx);
|
|
92
|
+
if (existing) {
|
|
93
|
+
throw existing;
|
|
94
|
+
}
|
|
95
|
+
yield* appendFence(ctx).hold();
|
|
96
|
+
const admitted = activeDurabilityFailure(ctx);
|
|
97
|
+
if (admitted) {
|
|
98
|
+
throw admitted;
|
|
99
|
+
}
|
|
100
|
+
const occurrence = { rejected: false };
|
|
101
|
+
try {
|
|
102
|
+
yield* withDurableEventRejectionOccurrence(occurrence, () => ctx.stream.append(event));
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (occurrence.rejected && Object.is(error, occurrence.error)) {
|
|
106
|
+
const failure = activeDurabilityFailure(ctx);
|
|
107
|
+
if (failure) {
|
|
108
|
+
throw failure;
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
const active = activeDurabilityFailure(ctx);
|
|
113
|
+
if (active) {
|
|
114
|
+
throw active;
|
|
115
|
+
}
|
|
116
|
+
throw rememberDurabilityFailure(ctx, new DurablePersistenceError(event.type, error));
|
|
117
|
+
}
|
|
118
|
+
}
|
package/esm/effect.js
CHANGED
|
@@ -19,10 +19,14 @@
|
|
|
19
19
|
*
|
|
20
20
|
* See integration doc §5.1, protocol spec §4.2, §5, §6.
|
|
21
21
|
*/
|
|
22
|
-
import {
|
|
22
|
+
import { DurableContext } from "./context.js";
|
|
23
23
|
import { Divergence } from "./divergence.js";
|
|
24
|
+
import { activeDurabilityFailure, appendDurableEvent, rememberDurabilityFailure, } from "./durability.js";
|
|
24
25
|
import { StaleInputError } from "./errors.js";
|
|
26
|
+
import { getJournalProvenance } from "./guard.js";
|
|
27
|
+
import { defaultLiveDurableOperationCoordinator, } from "./live-coordinator.js";
|
|
25
28
|
import { ReplayGuard } from "./replay-guard.js";
|
|
29
|
+
import { consumable, observeEvent } from "./retained.js";
|
|
26
30
|
import { protocolToEffection, serializeError } from "./serialize.js";
|
|
27
31
|
/** Effection void-ok result, used for no-op teardowns. */
|
|
28
32
|
const VOID_OK = {
|
|
@@ -64,6 +68,7 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
64
68
|
},
|
|
65
69
|
]);
|
|
66
70
|
if (decision.type === "throw") {
|
|
71
|
+
rememberDurabilityFailure(ctx, decision.error);
|
|
67
72
|
resolve({ ok: false, error: decision.error });
|
|
68
73
|
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
69
74
|
}
|
|
@@ -73,12 +78,23 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
73
78
|
}
|
|
74
79
|
// Description matches — now check replay guards before replaying.
|
|
75
80
|
// ── REPLAY GUARD: Decide phase ──
|
|
76
|
-
|
|
81
|
+
// An isolated observation, like the check and admit phases: a decision is
|
|
82
|
+
// policy, and policy reads. Handing the retained description or result
|
|
83
|
+
// here would let a guard rewrite what replay is about to consume.
|
|
84
|
+
const observed = observeEvent({
|
|
77
85
|
type: "yield",
|
|
78
86
|
coroutineId: ctx.coroutineId,
|
|
79
87
|
description: entry.description,
|
|
80
88
|
result: entry.result,
|
|
81
|
-
};
|
|
89
|
+
});
|
|
90
|
+
const yieldEvent = observed.type === "yield"
|
|
91
|
+
? observed
|
|
92
|
+
: {
|
|
93
|
+
type: "yield",
|
|
94
|
+
coroutineId: ctx.coroutineId,
|
|
95
|
+
description: desc,
|
|
96
|
+
result: entry.result,
|
|
97
|
+
};
|
|
82
98
|
const outcome = ReplayGuard.invoke(routine.scope, "decide", [yieldEvent]);
|
|
83
99
|
if (outcome.outcome === "error") {
|
|
84
100
|
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
@@ -87,13 +103,17 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
87
103
|
coroutineId: ctx.coroutineId,
|
|
88
104
|
description: desc,
|
|
89
105
|
});
|
|
106
|
+
rememberDurabilityFailure(ctx, error);
|
|
90
107
|
resolve({ ok: false, error });
|
|
91
108
|
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
92
109
|
}
|
|
93
110
|
// All guards approved — consume the entry and advance cursor
|
|
94
111
|
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
95
|
-
// Feed stored result synchronously
|
|
96
|
-
|
|
112
|
+
// Feed stored result synchronously, as a fresh mutable copy: the
|
|
113
|
+
// authoritative result stays frozen so policy cannot rewrite it, while a
|
|
114
|
+
// document that resumes on a restored binding still writes to what it
|
|
115
|
+
// receives.
|
|
116
|
+
resolve(protocolToEffection(consumable(entry.result)));
|
|
97
117
|
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
98
118
|
}
|
|
99
119
|
// No replay entry. Check for continue-past-close divergence (§6.3).
|
|
@@ -107,6 +127,7 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
107
127
|
},
|
|
108
128
|
]);
|
|
109
129
|
if (decision.type === "throw") {
|
|
130
|
+
rememberDurabilityFailure(ctx, decision.error);
|
|
110
131
|
resolve({ ok: false, error: decision.error });
|
|
111
132
|
return { path: "replayed", teardown: (exit) => exit(VOID_OK) };
|
|
112
133
|
}
|
|
@@ -133,7 +154,12 @@ export function createDurableEffect(desc, execute) {
|
|
|
133
154
|
description: `${desc.type}(${desc.name})`,
|
|
134
155
|
effectDescription: desc,
|
|
135
156
|
enter(resolve, routine) {
|
|
136
|
-
const ctx = routine.scope.expect(
|
|
157
|
+
const ctx = routine.scope.expect(DurableContext);
|
|
158
|
+
const durabilityFailure = activeDurabilityFailure(ctx);
|
|
159
|
+
if (durabilityFailure) {
|
|
160
|
+
resolve({ ok: false, error: durabilityFailure });
|
|
161
|
+
return (exit) => exit(VOID_OK);
|
|
162
|
+
}
|
|
137
163
|
const replay = checkReplay(desc, resolve, routine, ctx);
|
|
138
164
|
if (replay.path === "replayed") {
|
|
139
165
|
return replay.teardown;
|
|
@@ -160,7 +186,7 @@ export function createDurableEffect(desc, execute) {
|
|
|
160
186
|
// down, the append is cancelled.
|
|
161
187
|
routine.scope.run(function* () {
|
|
162
188
|
try {
|
|
163
|
-
yield* ctx
|
|
189
|
+
yield* appendDurableEvent(ctx, event);
|
|
164
190
|
resolve(protocolToEffection(result));
|
|
165
191
|
}
|
|
166
192
|
catch (err) {
|
|
@@ -221,13 +247,20 @@ export function createDurableEffect(desc, execute) {
|
|
|
221
247
|
*
|
|
222
248
|
* @param desc Structured description for the journal and divergence detection
|
|
223
249
|
* @param execute Returns an Operation to run during live execution
|
|
250
|
+
* @param options.coordinator Selects the live execution/publication boundary;
|
|
251
|
+
* replay never invokes it
|
|
224
252
|
*/
|
|
225
|
-
export function createDurableOperation(desc, execute) {
|
|
253
|
+
export function createDurableOperation(desc, execute, options = {}) {
|
|
226
254
|
return {
|
|
227
255
|
description: `${desc.type}(${desc.name})`,
|
|
228
256
|
effectDescription: desc,
|
|
229
257
|
enter(resolve, routine) {
|
|
230
|
-
const ctx = routine.scope.expect(
|
|
258
|
+
const ctx = routine.scope.expect(DurableContext);
|
|
259
|
+
const durabilityFailure = activeDurabilityFailure(ctx);
|
|
260
|
+
if (durabilityFailure) {
|
|
261
|
+
resolve({ ok: false, error: durabilityFailure });
|
|
262
|
+
return (exit) => exit(VOID_OK);
|
|
263
|
+
}
|
|
231
264
|
const replay = checkReplay(desc, resolve, routine, ctx);
|
|
232
265
|
if (replay.path === "replayed") {
|
|
233
266
|
return replay.teardown;
|
|
@@ -236,23 +269,30 @@ export function createDurableOperation(desc, execute) {
|
|
|
236
269
|
// Run the entire execute → capture → persist → resolve sequence
|
|
237
270
|
// as a structured operation in the routine's scope.
|
|
238
271
|
routine.scope.run(function* () {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
catch (e) {
|
|
245
|
-
const error = e instanceof Error ? e : new Error(String(e));
|
|
246
|
-
result = { status: "err", error: serializeError(error) };
|
|
272
|
+
const active = activeDurabilityFailure(ctx);
|
|
273
|
+
if (active) {
|
|
274
|
+
resolve({ ok: false, error: active });
|
|
275
|
+
return;
|
|
247
276
|
}
|
|
248
|
-
const event = {
|
|
249
|
-
type: "yield",
|
|
250
|
-
coroutineId: ctx.coroutineId,
|
|
251
|
-
description: desc,
|
|
252
|
-
result,
|
|
253
|
-
};
|
|
254
277
|
try {
|
|
255
|
-
|
|
278
|
+
const coordinator = options.coordinator ?? defaultLiveDurableOperationCoordinator;
|
|
279
|
+
const activateFailure = (failure) => {
|
|
280
|
+
const existing = activeDurabilityFailure(ctx);
|
|
281
|
+
if (existing) {
|
|
282
|
+
return existing;
|
|
283
|
+
}
|
|
284
|
+
const error = failure instanceof Error ? failure : new Error(String(failure));
|
|
285
|
+
return rememberDurabilityFailure(ctx, error);
|
|
286
|
+
};
|
|
287
|
+
const result = yield* coordinator.run(execute, function* (published) {
|
|
288
|
+
const event = {
|
|
289
|
+
type: "yield",
|
|
290
|
+
coroutineId: ctx.coroutineId,
|
|
291
|
+
description: desc,
|
|
292
|
+
result: published,
|
|
293
|
+
};
|
|
294
|
+
yield* appendDurableEvent(ctx, event);
|
|
295
|
+
}, activateFailure, getJournalProvenance(ctx.stream));
|
|
256
296
|
resolve(protocolToEffection(result));
|
|
257
297
|
}
|
|
258
298
|
catch (err) {
|
package/esm/errors.js
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error types for the durable execution protocol.
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* Raised when a durable event cannot be persisted.
|
|
6
|
+
*
|
|
7
|
+
* Persistence failures are protocol failures, not workflow outcomes. The
|
|
8
|
+
* adapter error remains available as the cause, and no compensating Close is
|
|
9
|
+
* written over the unpersisted event.
|
|
10
|
+
*/
|
|
11
|
+
export class DurablePersistenceError extends Error {
|
|
12
|
+
name = "DurablePersistenceError";
|
|
13
|
+
constructor(eventType, cause) {
|
|
14
|
+
super(`Failed to persist durable ${eventType} event`, { cause });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Raised when a persisted record does not describe a `DurableEvent`.
|
|
19
|
+
*
|
|
20
|
+
* `path` locates the offending member within the record, such as
|
|
21
|
+
* `$.result.error.message`. Members the protocol does not name appear as `*`,
|
|
22
|
+
* because a record's own member names are as much retained content as its
|
|
23
|
+
* values. Neither the path nor the message repeats anything from the record: a
|
|
24
|
+
* journal is retained, filtered history, and a parse failure is not a reason to
|
|
25
|
+
* copy its contents into an error that travels to logs and terminals.
|
|
26
|
+
*/
|
|
27
|
+
export class MalformedDurableEventError extends Error {
|
|
28
|
+
name = "MalformedDurableEventError";
|
|
29
|
+
/** Location of the offending member within the record. */
|
|
30
|
+
path;
|
|
31
|
+
constructor(reason, path) {
|
|
32
|
+
super(`${reason} at ${path}`);
|
|
33
|
+
this.path = path;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
4
36
|
/**
|
|
5
37
|
* Raised when the replay index entry at the current cursor position
|
|
6
38
|
* does not match the effect yielded by the generator. See spec §6.2.
|
|
@@ -30,22 +62,37 @@ export class DivergenceError extends Error {
|
|
|
30
62
|
}
|
|
31
63
|
}
|
|
32
64
|
/**
|
|
33
|
-
* Raised when
|
|
34
|
-
*
|
|
65
|
+
* Raised when a workflow terminates while replay still has unconsumed entries.
|
|
66
|
+
* The retained journal describes effects that the current execution did not
|
|
67
|
+
* reach, so no terminal Close may be appended over that history.
|
|
35
68
|
*/
|
|
36
|
-
export class
|
|
37
|
-
name = "
|
|
69
|
+
export class TerminalDivergenceError extends Error {
|
|
70
|
+
name = "TerminalDivergenceError";
|
|
38
71
|
coroutineId;
|
|
39
72
|
consumedCount;
|
|
40
73
|
totalCount;
|
|
41
|
-
constructor(coroutineId, consumedCount, totalCount) {
|
|
42
|
-
super(
|
|
43
|
-
`
|
|
74
|
+
constructor(coroutineId, consumedCount, totalCount, options = {}) {
|
|
75
|
+
super(options.message ??
|
|
76
|
+
`Divergence: workflow ${coroutineId} terminated after ${consumedCount} yields, ` +
|
|
77
|
+
`but journal has ${totalCount} yield entries`, { cause: options.cause });
|
|
44
78
|
this.coroutineId = coroutineId;
|
|
45
79
|
this.consumedCount = consumedCount;
|
|
46
80
|
this.totalCount = totalCount;
|
|
47
81
|
}
|
|
48
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Raised when the generator finishes (returns) while the replay index
|
|
85
|
+
* still has unconsumed entries for this coroutine. See spec §6.3.
|
|
86
|
+
*/
|
|
87
|
+
export class EarlyReturnDivergenceError extends TerminalDivergenceError {
|
|
88
|
+
name = "EarlyReturnDivergenceError";
|
|
89
|
+
constructor(coroutineId, consumedCount, totalCount) {
|
|
90
|
+
super(coroutineId, consumedCount, totalCount, {
|
|
91
|
+
message: `Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
|
|
92
|
+
`but journal has ${totalCount} yield entries`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
49
96
|
/**
|
|
50
97
|
* Raised when the journal has a Close event for a coroutine but the
|
|
51
98
|
* generator has not finished after consuming all recorded yields.
|
package/esm/guard.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* guardDurableStream — a host-side pre-persistence boundary.
|
|
3
|
+
*
|
|
4
|
+
* A gate runs once per live append, before the event reaches the backend.
|
|
5
|
+
* It receives a copy and returns nothing, so it can inspect or reject but
|
|
6
|
+
* never rewrite. When the gate completes, the original event is handed to
|
|
7
|
+
* the underlying stream exactly once. When the gate fails or is cancelled,
|
|
8
|
+
* the backend is never invoked and the failure propagates to the durable
|
|
9
|
+
* effect that produced the event.
|
|
10
|
+
*
|
|
11
|
+
* The backend append is a statement after the gate rather than a
|
|
12
|
+
* continuation passed to it. A gate therefore has nothing it can invoke
|
|
13
|
+
* twice, which preserves the protocol invariant that one durable yield
|
|
14
|
+
* produces at most one journal event.
|
|
15
|
+
*/
|
|
16
|
+
import { createContext } from "effection";
|
|
17
|
+
class JournalProvenance {
|
|
18
|
+
#opaque = undefined;
|
|
19
|
+
}
|
|
20
|
+
const journalProvenances = (() => {
|
|
21
|
+
// This security witness is deliberately canonical-module-local. A loaded
|
|
22
|
+
// copy cannot read this copy's association or enroll a stream into it.
|
|
23
|
+
const provenances = new WeakMap();
|
|
24
|
+
return {
|
|
25
|
+
establish(stream) {
|
|
26
|
+
if (provenances.has(stream)) {
|
|
27
|
+
throw new Error("this durable stream already has journal provenance");
|
|
28
|
+
}
|
|
29
|
+
const provenance = new JournalProvenance();
|
|
30
|
+
provenances.set(stream, provenance);
|
|
31
|
+
return provenance;
|
|
32
|
+
},
|
|
33
|
+
preserve(source, target) {
|
|
34
|
+
const provenance = provenances.get(source);
|
|
35
|
+
if (provenance !== undefined) {
|
|
36
|
+
provenances.set(target, provenance);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
get(stream) {
|
|
40
|
+
return provenances.get(stream);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
/** Establish the provenance a provider retains for one exact journal backend. */
|
|
45
|
+
export function establishJournalProvenance(stream) {
|
|
46
|
+
return journalProvenances.establish(stream);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Carry an exact source stream's provenance onto a trusted wrapper of it.
|
|
50
|
+
*
|
|
51
|
+
* Preservation is visible composition rather than new authority: it transfers
|
|
52
|
+
* only the witness already associated with that exact source, so an unproven
|
|
53
|
+
* source leaves the target unproven. The target is returned so the wrapping
|
|
54
|
+
* site reads as one expression.
|
|
55
|
+
*/
|
|
56
|
+
export function preserveJournalProvenance(source, target) {
|
|
57
|
+
journalProvenances.preserve(source, target);
|
|
58
|
+
return target;
|
|
59
|
+
}
|
|
60
|
+
/** @internal The live durable path reads provenance without receiving stream authority. */
|
|
61
|
+
export function getJournalProvenance(stream) {
|
|
62
|
+
return journalProvenances.get(stream);
|
|
63
|
+
}
|
|
64
|
+
const EventRejectionOccurrence = createContext("effectionx.durable-streams.event-rejection-occurrence", undefined);
|
|
65
|
+
export function withDurableEventRejectionOccurrence(occurrence, operation) {
|
|
66
|
+
return EventRejectionOccurrence.with(occurrence, operation);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Wrap a durable stream so every live append passes through `gate` first.
|
|
70
|
+
*
|
|
71
|
+
* `readAll()` delegates straight to the underlying stream, so replaying a
|
|
72
|
+
* journal restores existing entries without gating them.
|
|
73
|
+
*
|
|
74
|
+
* Wrap the stream before execution begins to cover the complete live
|
|
75
|
+
* journal — root component imports, yields, child closes, and the root
|
|
76
|
+
* close.
|
|
77
|
+
*
|
|
78
|
+
* Rejection is per event. The rejected event never reaches the backend, but
|
|
79
|
+
* the resulting failure may lead the workflow to append a later `Close`
|
|
80
|
+
* event with an `err` result, and that close crosses the gate on its own.
|
|
81
|
+
*
|
|
82
|
+
* The guard is policy-neutral, so the wrapper it returns is unproven. An
|
|
83
|
+
* authorized wrapping site preserves journal provenance explicitly through
|
|
84
|
+
* {@link preserveJournalProvenance}.
|
|
85
|
+
*/
|
|
86
|
+
export function guardDurableStream(stream, gate) {
|
|
87
|
+
return {
|
|
88
|
+
readAll: () => stream.readAll(),
|
|
89
|
+
*append(event) {
|
|
90
|
+
// The gate sees a copy so "inspect or reject" is enforced rather than
|
|
91
|
+
// merely documented: the backend always receives the event the effect
|
|
92
|
+
// produced, whatever the gate did to the one it was handed.
|
|
93
|
+
try {
|
|
94
|
+
yield* gate(structuredClone(event));
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const occurrence = yield* EventRejectionOccurrence.get();
|
|
98
|
+
if (occurrence !== undefined) {
|
|
99
|
+
occurrence.rejected = true;
|
|
100
|
+
occurrence.error = error;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
yield* stream.append(event);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { serializeError } from "./serialize.js";
|
|
2
|
+
/** The ordinary live path: execute once, publish once, then return the same result. */
|
|
3
|
+
export const defaultLiveDurableOperationCoordinator = {
|
|
4
|
+
*run(execute, publish, _activateFailure, _journalProvenance) {
|
|
5
|
+
let result;
|
|
6
|
+
try {
|
|
7
|
+
result = { status: "ok", value: yield* execute() };
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
11
|
+
result = { status: "err", error: serializeError(failure) };
|
|
12
|
+
}
|
|
13
|
+
yield* publish(result);
|
|
14
|
+
return result;
|
|
15
|
+
},
|
|
16
|
+
};
|