@executablemd/durable-streams 0.8.1 → 0.9.1
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/combinators.js +3 -2
- package/esm/effect.js +19 -2
- package/esm/errors.js +68 -5
- package/esm/live-coordinator.js +1 -1
- package/esm/mod.js +2 -1
- package/esm/position.js +54 -0
- package/esm/replay-index.js +36 -4
- package/esm/run.js +10 -5
- package/package.json +1 -1
- package/types/context.d.ts +7 -0
- package/types/errors.d.ts +26 -2
- package/types/live-coordinator.d.ts +29 -2
- package/types/mod.d.ts +4 -1
- package/types/position.d.ts +61 -0
- package/types/replay-index.d.ts +22 -1
package/esm/combinators.js
CHANGED
|
@@ -97,6 +97,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
97
97
|
if (unaligned) {
|
|
98
98
|
const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, {
|
|
99
99
|
message: `Divergence: coroutine ${childId} was cancelled before retained history was exhausted`,
|
|
100
|
+
unconsumed: unaligned.entry,
|
|
100
101
|
});
|
|
101
102
|
rememberDurabilityFailure(childCtx, failure);
|
|
102
103
|
throw failure;
|
|
@@ -125,7 +126,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
125
126
|
const unaligned = replayIndex.firstUnaligned(childId);
|
|
126
127
|
if (unaligned) {
|
|
127
128
|
suppressClose = true;
|
|
128
|
-
const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields);
|
|
129
|
+
const failure = new EarlyReturnDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, unaligned.entry);
|
|
129
130
|
rememberDurabilityFailure(childCtx, failure);
|
|
130
131
|
throw failure;
|
|
131
132
|
}
|
|
@@ -146,7 +147,7 @@ function* runDurableChild(childWorkflow, childId, parentCtx) {
|
|
|
146
147
|
const unaligned = replayIndex.firstUnaligned(childId);
|
|
147
148
|
if (unaligned) {
|
|
148
149
|
suppressClose = true;
|
|
149
|
-
const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary });
|
|
150
|
+
const failure = new TerminalDivergenceError(unaligned.coroutineId, unaligned.cursor, unaligned.totalYields, { cause: primary, unconsumed: unaligned.entry });
|
|
150
151
|
rememberDurabilityFailure(childCtx, failure);
|
|
151
152
|
throw failure;
|
|
152
153
|
}
|
package/esm/effect.js
CHANGED
|
@@ -28,6 +28,7 @@ import { defaultLiveDurableOperationCoordinator, } from "./live-coordinator.js";
|
|
|
28
28
|
import { ReplayGuard } from "./replay-guard.js";
|
|
29
29
|
import { consumable, observeEvent } from "./retained.js";
|
|
30
30
|
import { protocolToEffection, serializeError } from "./serialize.js";
|
|
31
|
+
import { advanceDurablePosition } from "./position.js";
|
|
31
32
|
/** Effection void-ok result, used for no-op teardowns. */
|
|
32
33
|
const VOID_OK = {
|
|
33
34
|
ok: true,
|
|
@@ -43,6 +44,7 @@ const VOID_OK = {
|
|
|
43
44
|
*/
|
|
44
45
|
function checkReplay(desc, resolve, routine, ctx) {
|
|
45
46
|
const entry = ctx.replayIndex.peekYield(ctx.coroutineId);
|
|
47
|
+
let abandoned;
|
|
46
48
|
// ── REPLAY PATH ──
|
|
47
49
|
// Use a labeled block so that divergence decisions of type "run-live"
|
|
48
50
|
// can break out to fall through to the live execution path.
|
|
@@ -74,6 +76,7 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
74
76
|
}
|
|
75
77
|
// decision.type === "run-live"
|
|
76
78
|
ctx.replayIndex.disableReplay(ctx.coroutineId);
|
|
79
|
+
abandoned = entry;
|
|
77
80
|
break replay;
|
|
78
81
|
}
|
|
79
82
|
// Description matches — now check replay guards before replaying.
|
|
@@ -98,6 +101,7 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
98
101
|
const outcome = ReplayGuard.invoke(routine.scope, "decide", [yieldEvent]);
|
|
99
102
|
if (outcome.outcome === "error") {
|
|
100
103
|
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
104
|
+
advanceDurablePosition(ctx);
|
|
101
105
|
const error = outcome.error ??
|
|
102
106
|
new StaleInputError(`Stale input detected for ${desc.type}("${desc.name}")`, {
|
|
103
107
|
coroutineId: ctx.coroutineId,
|
|
@@ -109,6 +113,7 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
109
113
|
}
|
|
110
114
|
// All guards approved — consume the entry and advance cursor
|
|
111
115
|
ctx.replayIndex.consumeYield(ctx.coroutineId);
|
|
116
|
+
advanceDurablePosition(ctx);
|
|
112
117
|
// Feed stored result synchronously, as a fresh mutable copy: the
|
|
113
118
|
// authoritative result stays frozen so policy cannot rewrite it, while a
|
|
114
119
|
// document that resumes on a restored binding still writes to what it
|
|
@@ -136,7 +141,15 @@ function checkReplay(desc, resolve, routine, ctx) {
|
|
|
136
141
|
break replay;
|
|
137
142
|
}
|
|
138
143
|
} // end replay block
|
|
139
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
path: "live",
|
|
146
|
+
abandoned: {
|
|
147
|
+
...(abandoned === undefined ? {} : { entry: abandoned }),
|
|
148
|
+
// The index's own account, not this call's: it stays true for every
|
|
149
|
+
// operation after the one that diverged.
|
|
150
|
+
steppedOver: ctx.replayIndex.abandonedHistory(ctx.coroutineId),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
140
153
|
}
|
|
141
154
|
/**
|
|
142
155
|
* Creates a DurableEffect using a callback-based executor.
|
|
@@ -187,6 +200,9 @@ export function createDurableEffect(desc, execute) {
|
|
|
187
200
|
routine.scope.run(function* () {
|
|
188
201
|
try {
|
|
189
202
|
yield* appendDurableEvent(ctx, event);
|
|
203
|
+
// After the append commits, not before: a position counts what the
|
|
204
|
+
// journal holds, so an append that failed must not move it.
|
|
205
|
+
advanceDurablePosition(ctx);
|
|
190
206
|
resolve(protocolToEffection(result));
|
|
191
207
|
}
|
|
192
208
|
catch (err) {
|
|
@@ -292,7 +308,8 @@ export function createDurableOperation(desc, execute, options = {}) {
|
|
|
292
308
|
result: published,
|
|
293
309
|
};
|
|
294
310
|
yield* appendDurableEvent(ctx, event);
|
|
295
|
-
|
|
311
|
+
advanceDurablePosition(ctx);
|
|
312
|
+
}, activateFailure, getJournalProvenance(ctx.stream), replay.abandoned);
|
|
296
313
|
resolve(protocolToEffection(result));
|
|
297
314
|
}
|
|
298
315
|
catch (err) {
|
package/esm/errors.js
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error types for the durable execution protocol.
|
|
3
3
|
*/
|
|
4
|
+
/**
|
|
5
|
+
* The description field an authored source position occupies.
|
|
6
|
+
*
|
|
7
|
+
* A durable effect's identity is its `type` and `name` and nothing else, so a
|
|
8
|
+
* position travels beside them under this stable namespaced field. It is
|
|
9
|
+
* stored, filtered diagnostic data: never compared during divergence
|
|
10
|
+
* detection, never part of admission.
|
|
11
|
+
*/
|
|
12
|
+
export const SOURCE_POSITION_FIELD = "executablemd.source-position";
|
|
13
|
+
/**
|
|
14
|
+
* Render one effect description for a divergence diagnostic.
|
|
15
|
+
*
|
|
16
|
+
* The identity renders as `type("name")`. When the description retains the
|
|
17
|
+
* exact normalized source shape under `SOURCE_POSITION_FIELD` — an optional
|
|
18
|
+
* non-empty `path`, an integer `offset` of at least 0, integer `line` and
|
|
19
|
+
* `column` of at least 1, and no other member — its human spelling,
|
|
20
|
+
* `path:line:column` or `line:column` without a path, never the offset, is
|
|
21
|
+
* appended as ` at …`. Anything else renders nothing at all: formatting a
|
|
22
|
+
* diagnostic must not introduce a new failure.
|
|
23
|
+
*/
|
|
24
|
+
export function describeEffect(description) {
|
|
25
|
+
return `${description.type}("${description.name}")${renderedSource(description)}`;
|
|
26
|
+
}
|
|
27
|
+
const SOURCE_MEMBERS = ["path", "offset", "line", "column"];
|
|
28
|
+
function renderedSource(description) {
|
|
29
|
+
const field = description[SOURCE_POSITION_FIELD];
|
|
30
|
+
if (field === null || typeof field !== "object" || Array.isArray(field)) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
if (Object.keys(field).some((member) => !SOURCE_MEMBERS.includes(member))) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
const { path, offset, line, column } = field;
|
|
37
|
+
if (!isCoordinate(offset, 0) || !isCoordinate(line, 1) || !isCoordinate(column, 1)) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
if (path === undefined) {
|
|
41
|
+
return ` at ${line}:${column}`;
|
|
42
|
+
}
|
|
43
|
+
if (typeof path !== "string" || path === "") {
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
return ` at ${path}:${line}:${column}`;
|
|
47
|
+
}
|
|
48
|
+
function isCoordinate(value, least) {
|
|
49
|
+
return typeof value === "number" && Number.isInteger(value) && value >= least;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The terminal-divergence message, naming the first retained entry the
|
|
53
|
+
* terminating subtree did not reach when one was selected.
|
|
54
|
+
*/
|
|
55
|
+
function withUnreached(message, unconsumed) {
|
|
56
|
+
if (unconsumed === undefined) {
|
|
57
|
+
return message;
|
|
58
|
+
}
|
|
59
|
+
if (unconsumed.type === "close") {
|
|
60
|
+
return `${message}; first unreached entry is the Close of coroutine ${unconsumed.coroutineId}`;
|
|
61
|
+
}
|
|
62
|
+
return `${message}; first unreached entry is ${describeEffect(unconsumed.description)}`;
|
|
63
|
+
}
|
|
4
64
|
/**
|
|
5
65
|
* Raised when a durable event cannot be persisted.
|
|
6
66
|
*
|
|
@@ -53,8 +113,7 @@ export class DivergenceError extends Error {
|
|
|
53
113
|
constructor(coroutineId, position, expected, actual, message) {
|
|
54
114
|
super(message ??
|
|
55
115
|
`Divergence at ${coroutineId}[${position}]: ` +
|
|
56
|
-
`expected ${expected
|
|
57
|
-
`got ${actual.type}("${actual.name}")`);
|
|
116
|
+
`expected ${describeEffect(expected)}, got ${describeEffect(actual)}`);
|
|
58
117
|
this.coroutineId = coroutineId;
|
|
59
118
|
this.position = position;
|
|
60
119
|
this.expected = expected;
|
|
@@ -71,13 +130,16 @@ export class TerminalDivergenceError extends Error {
|
|
|
71
130
|
coroutineId;
|
|
72
131
|
consumedCount;
|
|
73
132
|
totalCount;
|
|
133
|
+
/** The first retained entry the terminating subtree did not reach. */
|
|
134
|
+
unconsumed;
|
|
74
135
|
constructor(coroutineId, consumedCount, totalCount, options = {}) {
|
|
75
|
-
super(options.message ??
|
|
136
|
+
super(withUnreached(options.message ??
|
|
76
137
|
`Divergence: workflow ${coroutineId} terminated after ${consumedCount} yields, ` +
|
|
77
|
-
`but journal has ${totalCount} yield entries`, { cause: options.cause });
|
|
138
|
+
`but journal has ${totalCount} yield entries`, options.unconsumed), { cause: options.cause });
|
|
78
139
|
this.coroutineId = coroutineId;
|
|
79
140
|
this.consumedCount = consumedCount;
|
|
80
141
|
this.totalCount = totalCount;
|
|
142
|
+
this.unconsumed = options.unconsumed;
|
|
81
143
|
}
|
|
82
144
|
}
|
|
83
145
|
/**
|
|
@@ -86,10 +148,11 @@ export class TerminalDivergenceError extends Error {
|
|
|
86
148
|
*/
|
|
87
149
|
export class EarlyReturnDivergenceError extends TerminalDivergenceError {
|
|
88
150
|
name = "EarlyReturnDivergenceError";
|
|
89
|
-
constructor(coroutineId, consumedCount, totalCount) {
|
|
151
|
+
constructor(coroutineId, consumedCount, totalCount, unconsumed) {
|
|
90
152
|
super(coroutineId, consumedCount, totalCount, {
|
|
91
153
|
message: `Divergence: generator ${coroutineId} returned after ${consumedCount} yields, ` +
|
|
92
154
|
`but journal has ${totalCount} yield entries`,
|
|
155
|
+
unconsumed,
|
|
93
156
|
});
|
|
94
157
|
}
|
|
95
158
|
}
|
package/esm/live-coordinator.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { serializeError } from "./serialize.js";
|
|
2
2
|
/** The ordinary live path: execute once, publish once, then return the same result. */
|
|
3
3
|
export const defaultLiveDurableOperationCoordinator = {
|
|
4
|
-
*run(execute, publish, _activateFailure, _journalProvenance) {
|
|
4
|
+
*run(execute, publish, _activateFailure, _journalProvenance, _abandoned) {
|
|
5
5
|
let result;
|
|
6
6
|
try {
|
|
7
7
|
result = { status: "ok", value: yield* execute() };
|
package/esm/mod.js
CHANGED
|
@@ -21,7 +21,7 @@ export { establishJournalProvenance, preserveJournalProvenance } from "./guard.j
|
|
|
21
21
|
// HTTP-backed stream adapter
|
|
22
22
|
export { useHttpDurableStream } from "./http-stream.js";
|
|
23
23
|
// Errors
|
|
24
|
-
export { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
|
|
24
|
+
export { ContinuePastCloseDivergenceError, describeEffect, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, SOURCE_POSITION_FIELD, StaleInputError, TerminalDivergenceError, } from "./errors.js";
|
|
25
25
|
// Divergence API — pluggable policy for replay mismatches (DEC-031)
|
|
26
26
|
export { Divergence } from "./divergence.js";
|
|
27
27
|
// ReplayGuard API — pluggable validation for replay staleness detection
|
|
@@ -34,6 +34,7 @@ export { deserializeError, effectionToProtocol, protocolToEffection, serializeDu
|
|
|
34
34
|
export { parseDurableEvent } from "./parse.js";
|
|
35
35
|
// Core effect factories
|
|
36
36
|
export { createDurableEffect, createDurableOperation } from "./effect.js";
|
|
37
|
+
export { durablePosition } from "./position.js";
|
|
37
38
|
// Structured live-operation coordination
|
|
38
39
|
export { defaultLiveDurableOperationCoordinator } from "./live-coordinator.js";
|
|
39
40
|
// Workflow-enabled effects
|
package/esm/position.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a coroutine is in its own durable history.
|
|
3
|
+
*
|
|
4
|
+
* A durable operation is identified by what it is and what it is called. Some
|
|
5
|
+
* operations have no name to give: a wait that a document reaches at one point
|
|
6
|
+
* in one procedure is that wait *because of where it is*, not because somebody
|
|
7
|
+
* named it. This is the coordinate such an operation names itself by — the
|
|
8
|
+
* coroutine it runs in, and how many durable yields that coroutine has already
|
|
9
|
+
* settled.
|
|
10
|
+
*
|
|
11
|
+
* ## Why it is not the replay cursor
|
|
12
|
+
*
|
|
13
|
+
* Replay already keeps a cursor per coroutine, and it looks like the same
|
|
14
|
+
* number. It is not. The cursor advances only while entries are being consumed
|
|
15
|
+
* from retained history; once a coroutine runs past the end of what was
|
|
16
|
+
* retained, the cursor stops while the coroutine keeps performing durable work.
|
|
17
|
+
* A position taken from it would be stable across replays of the retained
|
|
18
|
+
* prefix and would collide for every operation in the live suffix.
|
|
19
|
+
*
|
|
20
|
+
* So the position advances on both — a replayed yield and a committed live one
|
|
21
|
+
* — which is what makes the index a coroutine reaches at one point in its
|
|
22
|
+
* procedure the same index it reaches there next time.
|
|
23
|
+
*
|
|
24
|
+
* ## Observation, never authority
|
|
25
|
+
*
|
|
26
|
+
* A position describes where execution is. It authorizes nothing, proves
|
|
27
|
+
* nothing about who is executing, and is not durable state: it is derived by
|
|
28
|
+
* counting what the journal already holds. Anything that must be trusted is
|
|
29
|
+
* checked by whoever owns the authority to check it.
|
|
30
|
+
*/
|
|
31
|
+
import { DurableContext } from "./context.js";
|
|
32
|
+
/**
|
|
33
|
+
* The position this coroutine has reached.
|
|
34
|
+
*
|
|
35
|
+
* Read before performing a durable operation, it is that operation's
|
|
36
|
+
* coordinate. Read after, it is the next one's.
|
|
37
|
+
*/
|
|
38
|
+
export function* durablePosition() {
|
|
39
|
+
const context = yield* DurableContext.expect();
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
coroutineId: context.coroutineId,
|
|
42
|
+
index: context.position ?? 0,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Record that this coroutine settled one durable yield.
|
|
47
|
+
*
|
|
48
|
+
* Called from both settlement paths, which is the whole point of the field. A
|
|
49
|
+
* caller outside this package never advances a position: it is a count of what
|
|
50
|
+
* happened, and only the code that made it happen may say so.
|
|
51
|
+
*/
|
|
52
|
+
export function advanceDurablePosition(context) {
|
|
53
|
+
context.position = (context.position ?? 0) + 1;
|
|
54
|
+
}
|
package/esm/replay-index.js
CHANGED
|
@@ -92,6 +92,25 @@ export class ReplayIndex {
|
|
|
92
92
|
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
93
93
|
this.cursors.set(coroutineId, cursor + 1);
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether this coroutine walked away from retained history it never consumed.
|
|
97
|
+
*
|
|
98
|
+
* True once replay has been disabled for it while entries it had not reached
|
|
99
|
+
* are still there — which is what a run-live decision leaves behind. It stays
|
|
100
|
+
* true for every later operation in that coroutine, because the history is
|
|
101
|
+
* still unconsumed however far past it execution has gone.
|
|
102
|
+
*
|
|
103
|
+
* An effect whose live work reaches a service this journal does not enclose
|
|
104
|
+
* reads it to decide whether running live here is something it may do at all.
|
|
105
|
+
* It is the index's own account, so nothing a document supplies takes part.
|
|
106
|
+
*/
|
|
107
|
+
abandonedHistory(coroutineId) {
|
|
108
|
+
if (!this.disabled.has(coroutineId)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const list = this.yields.get(coroutineId);
|
|
112
|
+
return list !== undefined && (this.cursors.get(coroutineId) ?? 0) < list.length;
|
|
113
|
+
}
|
|
95
114
|
/** Returns the current cursor position for this coroutine. */
|
|
96
115
|
getCursor(coroutineId) {
|
|
97
116
|
return this.cursors.get(coroutineId) ?? 0;
|
|
@@ -128,7 +147,14 @@ export class ReplayIndex {
|
|
|
128
147
|
}
|
|
129
148
|
return false;
|
|
130
149
|
}
|
|
131
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Return the first retained coroutine not aligned with the current subtree.
|
|
152
|
+
*
|
|
153
|
+
* `entry` is the first unmatched retained protocol entry itself — the Yield
|
|
154
|
+
* at the cursor, or for an unclaimed completed child its first Yield when it
|
|
155
|
+
* has one and otherwise its Close — so a terminal diagnostic can name what
|
|
156
|
+
* the terminating subtree did not reach, not just where it stopped.
|
|
157
|
+
*/
|
|
132
158
|
firstUnaligned(subtreeId) {
|
|
133
159
|
if (this.disabled.has(subtreeId)) {
|
|
134
160
|
return undefined;
|
|
@@ -141,17 +167,23 @@ export class ReplayIndex {
|
|
|
141
167
|
if (this.disabled.has(coroutineId)) {
|
|
142
168
|
continue;
|
|
143
169
|
}
|
|
144
|
-
|
|
170
|
+
const close = this.closes.get(coroutineId);
|
|
171
|
+
if (close !== undefined) {
|
|
145
172
|
if (!this.claimed.has(coroutineId)) {
|
|
146
173
|
const entries = this.yields.get(coroutineId) ?? [];
|
|
147
|
-
return {
|
|
174
|
+
return {
|
|
175
|
+
coroutineId,
|
|
176
|
+
cursor: 0,
|
|
177
|
+
totalYields: entries.length,
|
|
178
|
+
entry: entries[0] ?? close,
|
|
179
|
+
};
|
|
148
180
|
}
|
|
149
181
|
continue;
|
|
150
182
|
}
|
|
151
183
|
const entries = this.yields.get(coroutineId) ?? [];
|
|
152
184
|
const cursor = this.cursors.get(coroutineId) ?? 0;
|
|
153
185
|
if (cursor < entries.length) {
|
|
154
|
-
return { coroutineId, cursor, totalYields: entries.length };
|
|
186
|
+
return { coroutineId, cursor, totalYields: entries.length, entry: entries[cursor] };
|
|
155
187
|
}
|
|
156
188
|
}
|
|
157
189
|
return undefined;
|
package/esm/run.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*
|
|
13
13
|
* See integration doc §10, protocol spec §4.
|
|
14
14
|
*/
|
|
15
|
-
import { useScope } from "effection";
|
|
15
|
+
import { scoped, useScope } from "effection";
|
|
16
16
|
import { DurableContext } from "./context.js";
|
|
17
17
|
import { activeDurabilityFailure, appendDurableEvent } from "./durability.js";
|
|
18
18
|
import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.js";
|
|
@@ -138,14 +138,19 @@ export function* durableRun(workflow, options) {
|
|
|
138
138
|
}
|
|
139
139
|
const unconsumed = unalignedReplay(replayIndex, coroutineId);
|
|
140
140
|
if (unconsumed) {
|
|
141
|
-
throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields);
|
|
141
|
+
throw new EarlyReturnDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, unconsumed.entry);
|
|
142
142
|
}
|
|
143
143
|
const closeEvent = {
|
|
144
144
|
type: "close",
|
|
145
145
|
coroutineId,
|
|
146
146
|
result: { status: "ok", value: result },
|
|
147
147
|
};
|
|
148
|
-
|
|
148
|
+
// The append's ordered turn is a resource of the scope it runs in, and
|
|
149
|
+
// this frame survives a rejected close: the catch below appends the
|
|
150
|
+
// compensating Close(err) from the same scope, so a turn still held here
|
|
151
|
+
// would make that append wait forever. A boundary around the attempt
|
|
152
|
+
// returns the turn as part of the outcome.
|
|
153
|
+
yield* scoped(() => appendDurableEvent(ctx, closeEvent));
|
|
149
154
|
return result;
|
|
150
155
|
}
|
|
151
156
|
catch (error) {
|
|
@@ -156,7 +161,7 @@ export function* durableRun(workflow, options) {
|
|
|
156
161
|
}
|
|
157
162
|
const unconsumed = unalignedReplay(replayIndex, coroutineId);
|
|
158
163
|
if (unconsumed) {
|
|
159
|
-
throw new TerminalDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, { cause: primary });
|
|
164
|
+
throw new TerminalDivergenceError(unconsumed.coroutineId, unconsumed.cursor, unconsumed.totalYields, { cause: primary, unconsumed: unconsumed.entry });
|
|
160
165
|
}
|
|
161
166
|
const closeEvent = {
|
|
162
167
|
type: "close",
|
|
@@ -167,7 +172,7 @@ export function* durableRun(workflow, options) {
|
|
|
167
172
|
},
|
|
168
173
|
};
|
|
169
174
|
try {
|
|
170
|
-
yield* appendDurableEvent(ctx, closeEvent);
|
|
175
|
+
yield* scoped(() => appendDurableEvent(ctx, closeEvent));
|
|
171
176
|
}
|
|
172
177
|
catch (closeError) {
|
|
173
178
|
const closeDurabilityFailure = activeDurabilityFailure(ctx, closeError);
|
package/package.json
CHANGED
package/types/context.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export interface DurableContext {
|
|
|
25
25
|
coroutineId: CoroutineId;
|
|
26
26
|
/** Counter for assigning child IDs. */
|
|
27
27
|
childCounter: number;
|
|
28
|
+
/**
|
|
29
|
+
* How many durable yields this coroutine has settled.
|
|
30
|
+
*
|
|
31
|
+
* Advanced by both settlement paths, so a coroutine running past its retained
|
|
32
|
+
* history keeps counting where replay left off. See `position.ts`.
|
|
33
|
+
*/
|
|
34
|
+
position?: number;
|
|
28
35
|
/** Protocol failure shared by the root and every durable child. */
|
|
29
36
|
durability?: DurabilityState;
|
|
30
37
|
}
|
package/types/errors.d.ts
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Error types for the durable execution protocol.
|
|
3
3
|
*/
|
|
4
|
-
import type { CoroutineId, EffectDescription } from "./types.js";
|
|
4
|
+
import type { Close, CoroutineId, EffectDescription, Yield } from "./types.js";
|
|
5
|
+
/**
|
|
6
|
+
* The description field an authored source position occupies.
|
|
7
|
+
*
|
|
8
|
+
* A durable effect's identity is its `type` and `name` and nothing else, so a
|
|
9
|
+
* position travels beside them under this stable namespaced field. It is
|
|
10
|
+
* stored, filtered diagnostic data: never compared during divergence
|
|
11
|
+
* detection, never part of admission.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SOURCE_POSITION_FIELD = "executablemd.source-position";
|
|
14
|
+
/**
|
|
15
|
+
* Render one effect description for a divergence diagnostic.
|
|
16
|
+
*
|
|
17
|
+
* The identity renders as `type("name")`. When the description retains the
|
|
18
|
+
* exact normalized source shape under `SOURCE_POSITION_FIELD` — an optional
|
|
19
|
+
* non-empty `path`, an integer `offset` of at least 0, integer `line` and
|
|
20
|
+
* `column` of at least 1, and no other member — its human spelling,
|
|
21
|
+
* `path:line:column` or `line:column` without a path, never the offset, is
|
|
22
|
+
* appended as ` at …`. Anything else renders nothing at all: formatting a
|
|
23
|
+
* diagnostic must not introduce a new failure.
|
|
24
|
+
*/
|
|
25
|
+
export declare function describeEffect(description: EffectDescription): string;
|
|
5
26
|
/**
|
|
6
27
|
* Raised when a durable event cannot be persisted.
|
|
7
28
|
*
|
|
@@ -58,9 +79,12 @@ export declare class TerminalDivergenceError extends Error {
|
|
|
58
79
|
coroutineId: CoroutineId;
|
|
59
80
|
consumedCount: number;
|
|
60
81
|
totalCount: number;
|
|
82
|
+
/** The first retained entry the terminating subtree did not reach. */
|
|
83
|
+
unconsumed?: Yield | Close;
|
|
61
84
|
constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number, options?: {
|
|
62
85
|
cause?: unknown;
|
|
63
86
|
message?: string;
|
|
87
|
+
unconsumed?: Yield | Close;
|
|
64
88
|
});
|
|
65
89
|
}
|
|
66
90
|
/**
|
|
@@ -69,7 +93,7 @@ export declare class TerminalDivergenceError extends Error {
|
|
|
69
93
|
*/
|
|
70
94
|
export declare class EarlyReturnDivergenceError extends TerminalDivergenceError {
|
|
71
95
|
name: string;
|
|
72
|
-
constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number);
|
|
96
|
+
constructor(coroutineId: CoroutineId, consumedCount: number, totalCount: number, unconsumed?: Yield | Close);
|
|
73
97
|
}
|
|
74
98
|
/**
|
|
75
99
|
* Raised when the journal has a Close event for a coroutine but the
|
|
@@ -1,11 +1,38 @@
|
|
|
1
1
|
import type { Operation } from "effection";
|
|
2
2
|
import type { JournalProvenance } from "./guard.js";
|
|
3
|
-
import type { Json, Result } from "./types.js";
|
|
3
|
+
import type { EffectDescription, Json, Result } from "./types.js";
|
|
4
4
|
/** Activates the first infrastructure failure for the enclosing durable run. */
|
|
5
5
|
export type ActivateDurabilityFailure = (failure: unknown) => Error;
|
|
6
|
+
/**
|
|
7
|
+
* What replay left behind at this position, when it left anything.
|
|
8
|
+
*
|
|
9
|
+
* Present only when a retained entry was there and divergence policy chose to
|
|
10
|
+
* run live rather than refuse. It is the engine's own account of the position,
|
|
11
|
+
* so an effect whose live work reaches outside this journal can decide whether
|
|
12
|
+
* running live here is something it may do — without asking anything a document
|
|
13
|
+
* supplies.
|
|
14
|
+
*/
|
|
15
|
+
export interface AbandonedRetainedEntry {
|
|
16
|
+
readonly description: EffectDescription;
|
|
17
|
+
readonly result: Result;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* What replay left behind in this coroutine, as the engine sees it.
|
|
21
|
+
*
|
|
22
|
+
* `entry` is the retained entry this very operation stepped over, when there
|
|
23
|
+
* was one. `steppedOver` is the wider fact and the load-bearing one: it stays
|
|
24
|
+
* true for every operation after the one that diverged, because disabling
|
|
25
|
+
* replay does not consume the history it walked away from. An effect that reads
|
|
26
|
+
* only `entry` would be safe at the position that diverged and unguarded at
|
|
27
|
+
* every position after it.
|
|
28
|
+
*/
|
|
29
|
+
export interface AbandonedRetainedHistory {
|
|
30
|
+
readonly entry?: AbandonedRetainedEntry;
|
|
31
|
+
readonly steppedOver: boolean;
|
|
32
|
+
}
|
|
6
33
|
/** Coordinates one live structured durable operation with its publication. */
|
|
7
34
|
export interface LiveDurableOperationCoordinator {
|
|
8
|
-
run<T extends Json>(execute: () => Operation<T>, publish: (result: Result) => Operation<void>, activateFailure: ActivateDurabilityFailure, journalProvenance: JournalProvenance | undefined): Operation<Result>;
|
|
35
|
+
run<T extends Json>(execute: () => Operation<T>, publish: (result: Result) => Operation<void>, activateFailure: ActivateDurabilityFailure, journalProvenance: JournalProvenance | undefined, abandoned: AbandonedRetainedHistory): Operation<Result>;
|
|
9
36
|
}
|
|
10
37
|
/** The ordinary live path: execute once, publish once, then return the same result. */
|
|
11
38
|
export declare const defaultLiveDurableOperationCoordinator: LiveDurableOperationCoordinator;
|
package/types/mod.d.ts
CHANGED
|
@@ -17,15 +17,18 @@ export { establishJournalProvenance, preserveJournalProvenance } from "./guard.j
|
|
|
17
17
|
export type { DurableEventGate, JournalProvenance } from "./guard.js";
|
|
18
18
|
export { useHttpDurableStream } from "./http-stream.js";
|
|
19
19
|
export type { HttpDurableStreamHandle, HttpDurableStreamOptions } from "./http-stream.js";
|
|
20
|
-
export { ContinuePastCloseDivergenceError, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, StaleInputError, TerminalDivergenceError, } from "./errors.js";
|
|
20
|
+
export { ContinuePastCloseDivergenceError, describeEffect, DivergenceError, DurablePersistenceError, EarlyReturnDivergenceError, MalformedDurableEventError, SOURCE_POSITION_FIELD, StaleInputError, TerminalDivergenceError, } from "./errors.js";
|
|
21
21
|
export { Divergence } from "./divergence.js";
|
|
22
22
|
export type { DivergenceDecision, DivergenceInfo, DivergenceKind } from "./divergence.js";
|
|
23
23
|
export { ReplayGuard } from "./replay-guard.js";
|
|
24
24
|
export type { ReplayOutcome, RetainedHistory } from "./replay-guard.js";
|
|
25
|
+
export type { AbandonedRetainedEntry, AbandonedRetainedHistory } from "./live-coordinator.js";
|
|
25
26
|
export { DurableContext } from "./context.js";
|
|
26
27
|
export { deserializeError, effectionToProtocol, protocolToEffection, serializeDurableEvent, serializeError, } from "./serialize.js";
|
|
27
28
|
export { parseDurableEvent } from "./parse.js";
|
|
28
29
|
export { createDurableEffect, createDurableOperation } from "./effect.js";
|
|
30
|
+
export { durablePosition } from "./position.js";
|
|
31
|
+
export type { DurablePosition } from "./position.js";
|
|
29
32
|
export type { Executor } from "./effect.js";
|
|
30
33
|
export { defaultLiveDurableOperationCoordinator } from "./live-coordinator.js";
|
|
31
34
|
export type { ActivateDurabilityFailure, LiveDurableOperationCoordinator, } from "./live-coordinator.js";
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a coroutine is in its own durable history.
|
|
3
|
+
*
|
|
4
|
+
* A durable operation is identified by what it is and what it is called. Some
|
|
5
|
+
* operations have no name to give: a wait that a document reaches at one point
|
|
6
|
+
* in one procedure is that wait *because of where it is*, not because somebody
|
|
7
|
+
* named it. This is the coordinate such an operation names itself by — the
|
|
8
|
+
* coroutine it runs in, and how many durable yields that coroutine has already
|
|
9
|
+
* settled.
|
|
10
|
+
*
|
|
11
|
+
* ## Why it is not the replay cursor
|
|
12
|
+
*
|
|
13
|
+
* Replay already keeps a cursor per coroutine, and it looks like the same
|
|
14
|
+
* number. It is not. The cursor advances only while entries are being consumed
|
|
15
|
+
* from retained history; once a coroutine runs past the end of what was
|
|
16
|
+
* retained, the cursor stops while the coroutine keeps performing durable work.
|
|
17
|
+
* A position taken from it would be stable across replays of the retained
|
|
18
|
+
* prefix and would collide for every operation in the live suffix.
|
|
19
|
+
*
|
|
20
|
+
* So the position advances on both — a replayed yield and a committed live one
|
|
21
|
+
* — which is what makes the index a coroutine reaches at one point in its
|
|
22
|
+
* procedure the same index it reaches there next time.
|
|
23
|
+
*
|
|
24
|
+
* ## Observation, never authority
|
|
25
|
+
*
|
|
26
|
+
* A position describes where execution is. It authorizes nothing, proves
|
|
27
|
+
* nothing about who is executing, and is not durable state: it is derived by
|
|
28
|
+
* counting what the journal already holds. Anything that must be trusted is
|
|
29
|
+
* checked by whoever owns the authority to check it.
|
|
30
|
+
*/
|
|
31
|
+
import { type Operation } from "effection";
|
|
32
|
+
import type { CoroutineId } from "./types.js";
|
|
33
|
+
/**
|
|
34
|
+
* One coroutine's coordinate: which coroutine, and how far along it is.
|
|
35
|
+
*
|
|
36
|
+
* The pair rather than the index alone, because two coroutines each reach index
|
|
37
|
+
* 0, and the two are different places. A child's coordinate is its own — a
|
|
38
|
+
* child coroutine counts its own yields from zero — and the coroutine id is
|
|
39
|
+
* what keeps that from colliding with its parent's.
|
|
40
|
+
*/
|
|
41
|
+
export interface DurablePosition {
|
|
42
|
+
readonly coroutineId: CoroutineId;
|
|
43
|
+
readonly index: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The position this coroutine has reached.
|
|
47
|
+
*
|
|
48
|
+
* Read before performing a durable operation, it is that operation's
|
|
49
|
+
* coordinate. Read after, it is the next one's.
|
|
50
|
+
*/
|
|
51
|
+
export declare function durablePosition(): Operation<DurablePosition>;
|
|
52
|
+
/**
|
|
53
|
+
* Record that this coroutine settled one durable yield.
|
|
54
|
+
*
|
|
55
|
+
* Called from both settlement paths, which is the whole point of the field. A
|
|
56
|
+
* caller outside this package never advances a position: it is a count of what
|
|
57
|
+
* happened, and only the code that made it happen may say so.
|
|
58
|
+
*/
|
|
59
|
+
export declare function advanceDurablePosition(context: {
|
|
60
|
+
position?: number;
|
|
61
|
+
}): void;
|
package/types/replay-index.d.ts
CHANGED
|
@@ -54,6 +54,19 @@ export declare class ReplayIndex {
|
|
|
54
54
|
peekYield(coroutineId: CoroutineId): YieldEntry | undefined;
|
|
55
55
|
/** Advances the cursor for this coroutine by one position. */
|
|
56
56
|
consumeYield(coroutineId: CoroutineId): void;
|
|
57
|
+
/**
|
|
58
|
+
* Whether this coroutine walked away from retained history it never consumed.
|
|
59
|
+
*
|
|
60
|
+
* True once replay has been disabled for it while entries it had not reached
|
|
61
|
+
* are still there — which is what a run-live decision leaves behind. It stays
|
|
62
|
+
* true for every later operation in that coroutine, because the history is
|
|
63
|
+
* still unconsumed however far past it execution has gone.
|
|
64
|
+
*
|
|
65
|
+
* An effect whose live work reaches a service this journal does not enclose
|
|
66
|
+
* reads it to decide whether running live here is something it may do at all.
|
|
67
|
+
* It is the index's own account, so nothing a document supplies takes part.
|
|
68
|
+
*/
|
|
69
|
+
abandonedHistory(coroutineId: CoroutineId): boolean;
|
|
57
70
|
/** Returns the current cursor position for this coroutine. */
|
|
58
71
|
getCursor(coroutineId: CoroutineId): number;
|
|
59
72
|
/** Returns true if a Close event exists for this coroutine (and replay is not disabled). */
|
|
@@ -67,11 +80,19 @@ export declare class ReplayIndex {
|
|
|
67
80
|
* unconsumed entries belong to child coroutines rather than the root.
|
|
68
81
|
*/
|
|
69
82
|
hasAnyUnconsumedYields(): boolean;
|
|
70
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Return the first retained coroutine not aligned with the current subtree.
|
|
85
|
+
*
|
|
86
|
+
* `entry` is the first unmatched retained protocol entry itself — the Yield
|
|
87
|
+
* at the cursor, or for an unclaimed completed child its first Yield when it
|
|
88
|
+
* has one and otherwise its Close — so a terminal diagnostic can name what
|
|
89
|
+
* the terminating subtree did not reach, not just where it stopped.
|
|
90
|
+
*/
|
|
71
91
|
firstUnaligned(subtreeId: CoroutineId): {
|
|
72
92
|
coroutineId: CoroutineId;
|
|
73
93
|
cursor: number;
|
|
74
94
|
totalYields: number;
|
|
95
|
+
entry: Yield | Close;
|
|
75
96
|
} | undefined;
|
|
76
97
|
/**
|
|
77
98
|
* Returns true if the cursor for this coroutine has been fully consumed
|