@telorun/sdk 0.77.0 → 0.80.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/dist/cancellation.d.ts +50 -1
- package/dist/cancellation.d.ts.map +1 -1
- package/dist/contract-errors.d.ts +8 -1
- package/dist/contract-errors.d.ts.map +1 -1
- package/dist/contract-errors.js +8 -0
- package/dist/durable-run.d.ts +310 -0
- package/dist/durable-run.d.ts.map +1 -0
- package/dist/durable-run.js +223 -0
- package/dist/durable-suspension.d.ts +143 -0
- package/dist/durable-suspension.d.ts.map +1 -0
- package/dist/durable-suspension.js +153 -0
- package/dist/durable-target-encoding.d.ts +49 -0
- package/dist/durable-target-encoding.d.ts.map +1 -0
- package/dist/durable-target-encoding.js +121 -0
- package/dist/duration.d.ts +1 -1
- package/dist/duration.js +5 -5
- package/dist/evaluation-context.d.ts +16 -0
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/invoke-step.d.ts +86 -1
- package/dist/invoke-step.d.ts.map +1 -1
- package/dist/invoke-step.js +261 -18
- package/dist/resource-context.d.ts +37 -0
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-instance.d.ts +21 -1
- package/dist/resource-instance.d.ts.map +1 -1
- package/dist/resource-instance.js +6 -2
- package/dist/step-engine.d.ts +170 -0
- package/dist/step-engine.d.ts.map +1 -0
- package/dist/step-engine.js +365 -0
- package/dist/zone-attribute.d.ts +101 -0
- package/dist/zone-attribute.d.ts.map +1 -0
- package/dist/zone-attribute.js +130 -0
- package/dist/zone-attributes/entries/atomic.json +7 -0
- package/dist/zone-attributes/entries/idempotent.json +6 -0
- package/dist/zone-attributes/entries/index.d.ts +3 -0
- package/dist/zone-attributes/entries/index.d.ts.map +1 -0
- package/dist/zone-attributes/entries/index.js +13 -0
- package/dist/zone-attributes/entries/no-suspend.json +6 -0
- package/dist/zone-attributes/entries/replayed.json +6 -0
- package/package.json +1 -1
- package/src/cancellation.ts +50 -1
- package/src/contract-errors.ts +9 -0
- package/src/durable-run.ts +450 -0
- package/src/durable-suspension.ts +188 -0
- package/src/durable-target-encoding.ts +181 -0
- package/src/duration.ts +5 -5
- package/src/evaluation-context.ts +17 -0
- package/src/index.ts +5 -1
- package/src/invoke-step.ts +378 -24
- package/src/resource-context.ts +37 -0
- package/src/resource-instance.ts +32 -2
- package/src/step-engine.ts +627 -0
- package/src/zone-attribute.ts +208 -0
- package/src/zone-attributes/entries/atomic.json +7 -0
- package/src/zone-attributes/entries/idempotent.json +6 -0
- package/src/zone-attributes/entries/index.ts +14 -0
- package/src/zone-attributes/entries/no-suspend.json +6 -0
- package/src/zone-attributes/entries/replayed.json +6 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable-execution replay seam — `kernel/specs/durable-execution.md`.
|
|
3
|
+
*
|
|
4
|
+
* Durable execution is **journal plus deterministic replay**, and Telo can have
|
|
5
|
+
* it because of a property that fell out of `Run.Sequence`'s design: control
|
|
6
|
+
* flow is a finite, DECLARED set of CEL expressions over run state, not
|
|
7
|
+
* arbitrary code. So replay is re-running the step list while returning recorded
|
|
8
|
+
* values instead of computing them. No continuation capture.
|
|
9
|
+
*
|
|
10
|
+
* **What is shared is this narrow seam, not a portable engine vocabulary.** The
|
|
11
|
+
* temptation is to abstract durable execution itself — one surface every backend
|
|
12
|
+
* implements — and it fails the same way twice: the abstraction becomes the
|
|
13
|
+
* union of every engine's lifecycle model (identity policy, schedule overlap,
|
|
14
|
+
* cancel-versus-terminate, deployment pinning), and each backend still loses the
|
|
15
|
+
* half of its own model that did not generalize. Portability is close to
|
|
16
|
+
* worthless here — in-flight runs do not migrate between engines, the
|
|
17
|
+
* configuration shares nothing, and nobody switches durable engines twice.
|
|
18
|
+
*
|
|
19
|
+
* The real constraint is that **the step engine must not fork**. It lives in
|
|
20
|
+
* this package, which is symlinked into every controller bundle rather than
|
|
21
|
+
* inlined, so there is exactly one implementation however many backends exist.
|
|
22
|
+
* What still needs a seam is the other half — *whether and where a step
|
|
23
|
+
* executes* — and that is all this file is.
|
|
24
|
+
*
|
|
25
|
+
* **The kernel is a pure conduit**: it carries the handle on `InvokeContext` and
|
|
26
|
+
* never calls it, which is why the contract lives here rather than in the kernel
|
|
27
|
+
* — the split logging already makes between `Logger` / `RecordBuffer` and the
|
|
28
|
+
* `Telo.LogSink` abstract. The consequence is stated rather than implied: unlike
|
|
29
|
+
* `zones`, this member does NOT cross the ABI, so a second runtime threads a
|
|
30
|
+
* handle it owns.
|
|
31
|
+
*/
|
|
32
|
+
import { InvokeError } from "./invoke-error.js";
|
|
33
|
+
import { VALUE_TYPES, VALUE_TYPE_BINDINGS } from "./value-type.js";
|
|
34
|
+
/**
|
|
35
|
+
* Compose a step path — the journal's key, and the reason journaling lives in
|
|
36
|
+
* the step engine at all.
|
|
37
|
+
*
|
|
38
|
+
* A step path is the only naturally deterministic key available, and it survives
|
|
39
|
+
* CONCURRENCY where a per-run call ordinal would not: two branches of a
|
|
40
|
+
* concurrent fan-out interleave their dispatches, so an ordinal would number
|
|
41
|
+
* them differently on every run while their paths stay fixed. It is also what
|
|
42
|
+
* makes each branch of a fan-out an independently resumable subtree.
|
|
43
|
+
*
|
|
44
|
+
* Segments are joined with `/`; a repetition (a loop turn, an iteration element)
|
|
45
|
+
* qualifies its segment with `[index]`. Both are properties of the WRITTEN
|
|
46
|
+
* structure plus the run's own decisions, never of wall-clock order.
|
|
47
|
+
*/
|
|
48
|
+
export function stepPath(...segments) {
|
|
49
|
+
return segments
|
|
50
|
+
.map((s) => (typeof s === "number" ? `[${s}]` : s))
|
|
51
|
+
.join("/")
|
|
52
|
+
.replace(/\/\[/g, "[");
|
|
53
|
+
}
|
|
54
|
+
/** True when a run handle is ambient — the test the step engine makes before it
|
|
55
|
+
* journals anything, so a non-durable sequence pays nothing. */
|
|
56
|
+
export function durableHandleOf(ctx) {
|
|
57
|
+
return ctx?.durable;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What the step engine consults before journaling anything — the collapse rule,
|
|
61
|
+
* read off the ambient zone stack.
|
|
62
|
+
*
|
|
63
|
+
* > A region collapses to one entry when **re-running it is safe** — because its
|
|
64
|
+
* > effects are discarded together, or because re-running is a no-op.
|
|
65
|
+
*
|
|
66
|
+
* The rule has no fields in it, at either end. It used to be a caller-side
|
|
67
|
+
* `checkpoint: collapse` plus a callee-side `requireCheckpoints:` veto, which
|
|
68
|
+
* was wrong four ways at once: a boolean where every neighbouring annotation
|
|
69
|
+
* carries a reason; the opposite polarity from *everything journaled by
|
|
70
|
+
* default*, so forgetting to veto was silent; a veto available only on
|
|
71
|
+
* `Run.Sequence`, leaving a collapsed script or imported invocable unprotected;
|
|
72
|
+
* and a contradiction check to reconcile it with atomicity. Underneath all four,
|
|
73
|
+
* collapse was sold as a cost lever while being a CORRECTNESS decision — it
|
|
74
|
+
* silently converts exactly-once into at-least-once.
|
|
75
|
+
*
|
|
76
|
+
* A region with a property is a zone, so it is declared the way every other
|
|
77
|
+
* region property is. Nothing collapses a sequence because nothing wrapped it.
|
|
78
|
+
*/
|
|
79
|
+
export function collapsesJournalEntries(zones, handle) {
|
|
80
|
+
// Every region carrying one of the two attributes is resolved and REPORTED,
|
|
81
|
+
// including the ones walked past before a collapsing one was found: a
|
|
82
|
+
// transaction that attested its writes is an exactly-once region whether or
|
|
83
|
+
// not an idempotent zone further out collapses the whole thing, and reporting
|
|
84
|
+
// only the verdict would lose it.
|
|
85
|
+
const resolutions = [];
|
|
86
|
+
for (const zone of zones) {
|
|
87
|
+
// `idempotent` collapses FULL STOP: there is nothing for the journal to be
|
|
88
|
+
// inside, and re-running is a no-op either way.
|
|
89
|
+
if (zone.attributes.idempotent) {
|
|
90
|
+
resolutions.push({
|
|
91
|
+
zone: zone.kind,
|
|
92
|
+
attribute: "idempotent",
|
|
93
|
+
mode: "collapsed",
|
|
94
|
+
reason: zone.attributes.idempotent,
|
|
95
|
+
});
|
|
96
|
+
return { collapsed: true, resolutions };
|
|
97
|
+
}
|
|
98
|
+
// `atomic` collapses UNLESS the handle attests its own writes land inside
|
|
99
|
+
// that atomicity. This is not an override of the attribute; it is the
|
|
100
|
+
// attribute read correctly — *effects inside are discarded together*, so
|
|
101
|
+
// collapse follows only when the journal's writes are not among them. When
|
|
102
|
+
// they are, per-step journaling is consistent by construction AND strictly
|
|
103
|
+
// better: finer replay granularity, and no re-running a committed
|
|
104
|
+
// transaction.
|
|
105
|
+
if (zone.attributes.atomic) {
|
|
106
|
+
if (handle.writesInside(zone.entry)) {
|
|
107
|
+
resolutions.push({
|
|
108
|
+
zone: zone.kind,
|
|
109
|
+
attribute: "atomic",
|
|
110
|
+
mode: "perStep",
|
|
111
|
+
reason: zone.attributes.atomic,
|
|
112
|
+
attestation: "writesInside",
|
|
113
|
+
});
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
resolutions.push({
|
|
117
|
+
zone: zone.kind,
|
|
118
|
+
attribute: "atomic",
|
|
119
|
+
mode: "collapsed",
|
|
120
|
+
reason: zone.attributes.atomic,
|
|
121
|
+
});
|
|
122
|
+
return { collapsed: true, resolutions };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { collapsed: false, resolutions };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Should the step engine record nothing of its own right here?
|
|
129
|
+
*
|
|
130
|
+
* True inside a collapsed region, where per-step entries would describe work
|
|
131
|
+
* that is about to happen again — the region re-runs whole on resume, which is
|
|
132
|
+
* exactly what its author's attribute claims is safe.
|
|
133
|
+
*
|
|
134
|
+
* A host with no zone machinery reads as "no zone open", which journals MORE
|
|
135
|
+
* rather than less: the safe direction, since an unjournaled effect re-executes
|
|
136
|
+
* silently while a redundant entry costs a write.
|
|
137
|
+
*/
|
|
138
|
+
export function journalingSuppressed(ctx, invokeCtx, handle) {
|
|
139
|
+
const zones = ctx.zoneAttributes?.(invokeCtx);
|
|
140
|
+
if (!zones || zones.length === 0)
|
|
141
|
+
return false;
|
|
142
|
+
const verdict = collapsesJournalEntries(zones, handle);
|
|
143
|
+
for (const resolution of verdict.resolutions)
|
|
144
|
+
handle.noteZoneMode?.(resolution);
|
|
145
|
+
return verdict.collapsed;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Reject a value that cannot survive being recorded and read back.
|
|
149
|
+
*
|
|
150
|
+
* **`JSON.stringify` is not the test, and believing it was left the gate open.**
|
|
151
|
+
* A live handle has no enumerable state, so `JSON.stringify(stream)` returns
|
|
152
|
+
* `{}` and throws nothing — the one case the spec names first would have been
|
|
153
|
+
* recorded as an empty object and replayed as one, which is silent corruption
|
|
154
|
+
* rather than the loud failure §6 requires. The static half is deliberately only
|
|
155
|
+
* a warning ("the runtime is the gate"), so a gate that cannot see the case
|
|
156
|
+
* leaves it unenforced end to end.
|
|
157
|
+
*
|
|
158
|
+
* So a live value is detected STRUCTURALLY, by the value-type vocabulary's own
|
|
159
|
+
* binding table: an entry declaring `live: true` names a binding, and the host's
|
|
160
|
+
* table maps that binding to the constructor an assertion tests against. No type
|
|
161
|
+
* name is written here, so a live type added later is covered by its entry
|
|
162
|
+
* alone — the same reason the static rule reads the `live` field rather than
|
|
163
|
+
* naming `Telo.Stream`.
|
|
164
|
+
*
|
|
165
|
+
* Lives in the SDK rather than in a backend because it is a property of the
|
|
166
|
+
* CONTRACT (spec §6), not of one journal: a backend that skipped it would be
|
|
167
|
+
* non-conforming in a way nothing else could catch.
|
|
168
|
+
*/
|
|
169
|
+
export function assertJournalable(value, where) {
|
|
170
|
+
const live = findLiveValue(value, new Set());
|
|
171
|
+
if (live) {
|
|
172
|
+
throw new InvokeError("ERR_DURABLE_UNJOURNALABLE_VALUE", `Run '${where.run}': the value produced at '${where.path}' contains a ${live} handle, ` +
|
|
173
|
+
`which cannot be recorded. A live value is produced by CONSUMING it, so it exists ` +
|
|
174
|
+
`exactly once and a record of it would be a record of nothing — a replay would hand ` +
|
|
175
|
+
`the next step an empty value instead of the data. Read what you need out of it ` +
|
|
176
|
+
`inside the step and return that, or move the streaming work outside the durable body.`, { run: where.run, path: where.path, valueType: live });
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
JSON.stringify(value);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
throw new InvokeError("ERR_DURABLE_UNJOURNALABLE_VALUE", `Run '${where.run}': the value produced at '${where.path}' cannot be serialized ` +
|
|
183
|
+
`(${err.message}). A durable step's result and every decision it reaches ` +
|
|
184
|
+
`must survive being written and read back.`, { run: where.run, path: where.path }, { cause: err });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** The name of the first live value type found anywhere in `value`, or
|
|
188
|
+
* undefined. Walks plain containers only — a class instance that is not a live
|
|
189
|
+
* type is left to the serializer to judge, since `toJSON` may well make it
|
|
190
|
+
* recordable. */
|
|
191
|
+
function findLiveValue(value, seen) {
|
|
192
|
+
if (!value || typeof value !== "object")
|
|
193
|
+
return undefined;
|
|
194
|
+
if (seen.has(value))
|
|
195
|
+
return undefined;
|
|
196
|
+
seen.add(value);
|
|
197
|
+
for (const entry of VALUE_TYPES.values()) {
|
|
198
|
+
if (!entry.live || !entry.binding)
|
|
199
|
+
continue;
|
|
200
|
+
const binding = VALUE_TYPE_BINDINGS[entry.binding];
|
|
201
|
+
if (binding && value instanceof binding.constructor)
|
|
202
|
+
return entry.name;
|
|
203
|
+
}
|
|
204
|
+
if (Array.isArray(value)) {
|
|
205
|
+
for (const item of value) {
|
|
206
|
+
const found = findLiveValue(item, seen);
|
|
207
|
+
if (found)
|
|
208
|
+
return found;
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
// Only plain objects are descended into: walking an arbitrary instance's
|
|
213
|
+
// fields would report a live handle a `toJSON` was about to drop anyway.
|
|
214
|
+
const proto = Object.getPrototypeOf(value);
|
|
215
|
+
if (proto !== Object.prototype && proto !== null)
|
|
216
|
+
return undefined;
|
|
217
|
+
for (const item of Object.values(value)) {
|
|
218
|
+
const found = findLiveValue(item, seen);
|
|
219
|
+
if (found)
|
|
220
|
+
return found;
|
|
221
|
+
}
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suspension — the signal a parking kind raises, and the latch that catches a
|
|
3
|
+
* swallowed one. Slice 4 of `kernel/specs/durable-execution.md`.
|
|
4
|
+
*
|
|
5
|
+
* **Suspension is a distinct signal, not an error.** It unwinds the stack to the
|
|
6
|
+
* workflow boundary, so a `try:` step must not catch it, a composer's
|
|
7
|
+
* `catches:` must not map it, and a retry policy must not re-attempt it. Those
|
|
8
|
+
* three sites rethrow on {@link isSuspension} before they convert anything.
|
|
9
|
+
*
|
|
10
|
+
* **But naming the known swallowers is not a defence.** The signal passes
|
|
11
|
+
* through every controller between the parking kind and the workflow — HTTP
|
|
12
|
+
* handlers, agent tool loops, a cache view, and any third-party controller with
|
|
13
|
+
* a `catch (e)` in it. A swallowed suspension silently converts a park into a
|
|
14
|
+
* completed step and duplicates every effect after it, and a pure-conduit kernel
|
|
15
|
+
* cannot see that happen. Enumerating the swallowers is unbounded and would go
|
|
16
|
+
* stale on the next module.
|
|
17
|
+
*
|
|
18
|
+
* So it is **latched, not just thrown**: {@link parkRun} records the signal
|
|
19
|
+
* against the run handle at the moment it raises, and the workflow kind treats
|
|
20
|
+
* *an invocation that returned normally while a suspension is latched* as a hard
|
|
21
|
+
* error ({@link assertNotSwallowed}). Detection is O(1), needs no cooperation
|
|
22
|
+
* from the swallower, and turns an unbounded-surface silent corruption into one
|
|
23
|
+
* loud failure at the boundary that owns the run.
|
|
24
|
+
*
|
|
25
|
+
* **The latch lives here rather than on the backend's handle**, and the
|
|
26
|
+
* distinction is what makes it a guarantee: a backend that had to set it could
|
|
27
|
+
* forget to, and the failure of forgetting is the silent corruption this exists
|
|
28
|
+
* to catch. It is a `WeakMap` in the SDK — the one package with a single scope
|
|
29
|
+
* per process (`REALM_COLLAPSE_NAMES` symlinks it onto the kernel's copy), so
|
|
30
|
+
* the payload rule's "provider-private state hangs off an injected instance"
|
|
31
|
+
* does not bite: there is exactly one map however many controller bundles are
|
|
32
|
+
* loaded.
|
|
33
|
+
*/
|
|
34
|
+
import type { InvokeContext } from "./cancellation.js";
|
|
35
|
+
import type { DurableRunHandle } from "./durable-run.js";
|
|
36
|
+
/**
|
|
37
|
+
* The backoff above which a retry PARKS instead of sleeping.
|
|
38
|
+
*
|
|
39
|
+
* A threshold is unavoidable and so is stating where it came from. Below it,
|
|
40
|
+
* sleeping in process is cheaper than a park: a park is a journal write, a
|
|
41
|
+
* process exit and a poller pass, and paying that to save a few seconds of an
|
|
42
|
+
* idle timer is worse on both counts. Above it, holding a process open to wait
|
|
43
|
+
* is precisely what durability exists to stop — and the plan's own retry
|
|
44
|
+
* example (`delay: 10s`, three attempts) sits deliberately below it, while a
|
|
45
|
+
* policy backing off to minutes sits above.
|
|
46
|
+
*
|
|
47
|
+
* Shared with the analyzer, which decides statically whether a step's declared
|
|
48
|
+
* policy COULD suspend, so the check and the behaviour cannot disagree about
|
|
49
|
+
* where the line is.
|
|
50
|
+
*/
|
|
51
|
+
export declare const SUSPENDING_BACKOFF_MS = 30000;
|
|
52
|
+
/** The code every suspension carries, so a runtime that only sees a shape can
|
|
53
|
+
* still recognise one. */
|
|
54
|
+
export declare const ERR_DURABLE_SUSPENDED = "ERR_DURABLE_SUSPENDED";
|
|
55
|
+
/** Where a parked run is waiting for. Exactly one half is meaningful to a
|
|
56
|
+
* backend at a time, but both may be present: an await with a deadline parks on
|
|
57
|
+
* its token AND is due at a time. */
|
|
58
|
+
export interface ParkUntil {
|
|
59
|
+
/** Epoch milliseconds at which the run becomes due. */
|
|
60
|
+
readonly at?: number;
|
|
61
|
+
/** The token a delivery must carry to wake this run. */
|
|
62
|
+
readonly token?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The signal itself.
|
|
66
|
+
*
|
|
67
|
+
* An `Error` so it unwinds, and deliberately **not** an {@link InvokeError}: an
|
|
68
|
+
* `InvokeError` is the channel a `catches:` list maps by code, and a suspension
|
|
69
|
+
* that could be named there would be catchable by configuration — which is the
|
|
70
|
+
* corruption, spelled as a feature.
|
|
71
|
+
*/
|
|
72
|
+
export declare class DurableSuspension extends Error {
|
|
73
|
+
/** The run that parked. */
|
|
74
|
+
readonly runId: string;
|
|
75
|
+
/** The step path it parked at — the journal key its park is recorded under. */
|
|
76
|
+
readonly path: string;
|
|
77
|
+
/** The parking resource's `metadata.name`, so the diagnostic names what
|
|
78
|
+
* waited rather than only where. */
|
|
79
|
+
readonly resource: string;
|
|
80
|
+
readonly until: ParkUntil;
|
|
81
|
+
readonly code = "ERR_DURABLE_SUSPENDED";
|
|
82
|
+
constructor(
|
|
83
|
+
/** The run that parked. */
|
|
84
|
+
runId: string,
|
|
85
|
+
/** The step path it parked at — the journal key its park is recorded under. */
|
|
86
|
+
path: string,
|
|
87
|
+
/** The parking resource's `metadata.name`, so the diagnostic names what
|
|
88
|
+
* waited rather than only where. */
|
|
89
|
+
resource: string, until: ParkUntil);
|
|
90
|
+
}
|
|
91
|
+
/** Is this the suspension signal? Structural rather than `instanceof`, for the
|
|
92
|
+
* same reason every other cross-boundary test here is: the SDK is one scope per
|
|
93
|
+
* process today, and a runtime that threads a handle it owns should still be
|
|
94
|
+
* recognised. */
|
|
95
|
+
export declare function isSuspension(err: unknown): err is DurableSuspension;
|
|
96
|
+
/**
|
|
97
|
+
* Park the run — the one way a parking kind suspends.
|
|
98
|
+
*
|
|
99
|
+
* Latches first, then asks the backend to record the park, then throws. Each of
|
|
100
|
+
* the three is load-bearing. Latching FIRST means a backend whose `park()`
|
|
101
|
+
* itself throws something else still leaves the evidence behind. Throwing here
|
|
102
|
+
* rather than trusting `park()`'s `Promise<never>` means a backend that returns
|
|
103
|
+
* — the one bug that would convert a wait into a completed step — is caught by
|
|
104
|
+
* the seam instead of by whatever ran next.
|
|
105
|
+
*/
|
|
106
|
+
export declare function parkRun(handle: DurableRunHandle, where: {
|
|
107
|
+
readonly path: string;
|
|
108
|
+
readonly resource: string;
|
|
109
|
+
}, until: ParkUntil): Promise<never>;
|
|
110
|
+
/** The suspension latched against this run, if one was raised during this
|
|
111
|
+
* execution. */
|
|
112
|
+
export declare function latchedSuspension(handle: DurableRunHandle): DurableSuspension | undefined;
|
|
113
|
+
/**
|
|
114
|
+
* The workflow boundary's check: a body that returned normally while a
|
|
115
|
+
* suspension is latched swallowed one.
|
|
116
|
+
*
|
|
117
|
+
* Raised rather than repaired, because there is nothing to repair: every effect
|
|
118
|
+
* after the swallow already ran, un-parked and un-recorded, and the run's own
|
|
119
|
+
* record would say it completed. What is actionable is the pair of names — the
|
|
120
|
+
* resource that parked and the step path — since the swallower is somewhere
|
|
121
|
+
* between them.
|
|
122
|
+
*/
|
|
123
|
+
export declare function assertNotSwallowed(handle: DurableRunHandle): void;
|
|
124
|
+
/**
|
|
125
|
+
* Refuse to park inside a zone that forbids it.
|
|
126
|
+
*
|
|
127
|
+
* The runtime half of `noSuspend` — one rule rather than an enumeration of
|
|
128
|
+
* forbidden kinds, so a parking kind added later is covered without touching
|
|
129
|
+
* anything here. The zone's own declared sentence is printed verbatim: it is the
|
|
130
|
+
* author's statement of what is being held open, and nothing generated says it
|
|
131
|
+
* better.
|
|
132
|
+
*/
|
|
133
|
+
export declare function assertMaySuspend(ctx: {
|
|
134
|
+
zoneAttributes?(ctx?: InvokeContext): readonly {
|
|
135
|
+
kind: string;
|
|
136
|
+
attributes: {
|
|
137
|
+
noSuspend?: string;
|
|
138
|
+
};
|
|
139
|
+
}[];
|
|
140
|
+
}, invokeCtx: InvokeContext | undefined, where: {
|
|
141
|
+
readonly resource: string;
|
|
142
|
+
}): void;
|
|
143
|
+
//# sourceMappingURL=durable-suspension.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durable-suspension.d.ts","sourceRoot":"","sources":["../src/durable-suspension.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAGzD;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C;2BAC2B;AAC3B,eAAO,MAAM,qBAAqB,0BAA0B,CAAC;AAE7D;;sCAEsC;AACtC,MAAM,WAAW,SAAS;IACxB,uDAAuD;IACvD,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,wDAAwD;IACxD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;GAOG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAIxC,2BAA2B;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM;IACtB,+EAA+E;IAC/E,QAAQ,CAAC,IAAI,EAAE,MAAM;IACrB;yCACqC;IACrC,QAAQ,CAAC,QAAQ,EAAE,MAAM;IACzB,QAAQ,CAAC,KAAK,EAAE,SAAS;IAV3B,QAAQ,CAAC,IAAI,2BAAyB;;IAGpC,2BAA2B;IAClB,KAAK,EAAE,MAAM;IACtB,+EAA+E;IACtE,IAAI,EAAE,MAAM;IACrB;yCACqC;IAC5B,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,SAAS;CAQ5B;AAED;;;kBAGkB;AAClB,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,iBAAiB,CAMnE;AAID;;;;;;;;;GASG;AACH,wBAAsB,OAAO,CAC3B,MAAM,EAAE,gBAAgB,EACxB,KAAK,EAAE;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EAC3D,KAAK,EAAE,SAAS,GACf,OAAO,CAAC,KAAK,CAAC,CAKhB;AAED;iBACiB;AACjB,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,gBAAgB,GAAG,iBAAiB,GAAG,SAAS,CAEzF;AAED;;;;;;;;;GASG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,gBAAgB,GAAG,IAAI,CAYjE;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE;IAAE,cAAc,CAAC,CAAC,GAAG,CAAC,EAAE,aAAa,GAAG,SAAS;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE;YAAE,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,EAAE,CAAA;CAAE,EAC9G,SAAS,EAAE,aAAa,GAAG,SAAS,EACpC,KAAK,EAAE;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACnC,IAAI,CAWN"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Suspension — the signal a parking kind raises, and the latch that catches a
|
|
3
|
+
* swallowed one. Slice 4 of `kernel/specs/durable-execution.md`.
|
|
4
|
+
*
|
|
5
|
+
* **Suspension is a distinct signal, not an error.** It unwinds the stack to the
|
|
6
|
+
* workflow boundary, so a `try:` step must not catch it, a composer's
|
|
7
|
+
* `catches:` must not map it, and a retry policy must not re-attempt it. Those
|
|
8
|
+
* three sites rethrow on {@link isSuspension} before they convert anything.
|
|
9
|
+
*
|
|
10
|
+
* **But naming the known swallowers is not a defence.** The signal passes
|
|
11
|
+
* through every controller between the parking kind and the workflow — HTTP
|
|
12
|
+
* handlers, agent tool loops, a cache view, and any third-party controller with
|
|
13
|
+
* a `catch (e)` in it. A swallowed suspension silently converts a park into a
|
|
14
|
+
* completed step and duplicates every effect after it, and a pure-conduit kernel
|
|
15
|
+
* cannot see that happen. Enumerating the swallowers is unbounded and would go
|
|
16
|
+
* stale on the next module.
|
|
17
|
+
*
|
|
18
|
+
* So it is **latched, not just thrown**: {@link parkRun} records the signal
|
|
19
|
+
* against the run handle at the moment it raises, and the workflow kind treats
|
|
20
|
+
* *an invocation that returned normally while a suspension is latched* as a hard
|
|
21
|
+
* error ({@link assertNotSwallowed}). Detection is O(1), needs no cooperation
|
|
22
|
+
* from the swallower, and turns an unbounded-surface silent corruption into one
|
|
23
|
+
* loud failure at the boundary that owns the run.
|
|
24
|
+
*
|
|
25
|
+
* **The latch lives here rather than on the backend's handle**, and the
|
|
26
|
+
* distinction is what makes it a guarantee: a backend that had to set it could
|
|
27
|
+
* forget to, and the failure of forgetting is the silent corruption this exists
|
|
28
|
+
* to catch. It is a `WeakMap` in the SDK — the one package with a single scope
|
|
29
|
+
* per process (`REALM_COLLAPSE_NAMES` symlinks it onto the kernel's copy), so
|
|
30
|
+
* the payload rule's "provider-private state hangs off an injected instance"
|
|
31
|
+
* does not bite: there is exactly one map however many controller bundles are
|
|
32
|
+
* loaded.
|
|
33
|
+
*/
|
|
34
|
+
import { InvokeError } from "./invoke-error.js";
|
|
35
|
+
/**
|
|
36
|
+
* The backoff above which a retry PARKS instead of sleeping.
|
|
37
|
+
*
|
|
38
|
+
* A threshold is unavoidable and so is stating where it came from. Below it,
|
|
39
|
+
* sleeping in process is cheaper than a park: a park is a journal write, a
|
|
40
|
+
* process exit and a poller pass, and paying that to save a few seconds of an
|
|
41
|
+
* idle timer is worse on both counts. Above it, holding a process open to wait
|
|
42
|
+
* is precisely what durability exists to stop — and the plan's own retry
|
|
43
|
+
* example (`delay: 10s`, three attempts) sits deliberately below it, while a
|
|
44
|
+
* policy backing off to minutes sits above.
|
|
45
|
+
*
|
|
46
|
+
* Shared with the analyzer, which decides statically whether a step's declared
|
|
47
|
+
* policy COULD suspend, so the check and the behaviour cannot disagree about
|
|
48
|
+
* where the line is.
|
|
49
|
+
*/
|
|
50
|
+
export const SUSPENDING_BACKOFF_MS = 30_000;
|
|
51
|
+
/** The code every suspension carries, so a runtime that only sees a shape can
|
|
52
|
+
* still recognise one. */
|
|
53
|
+
export const ERR_DURABLE_SUSPENDED = "ERR_DURABLE_SUSPENDED";
|
|
54
|
+
/**
|
|
55
|
+
* The signal itself.
|
|
56
|
+
*
|
|
57
|
+
* An `Error` so it unwinds, and deliberately **not** an {@link InvokeError}: an
|
|
58
|
+
* `InvokeError` is the channel a `catches:` list maps by code, and a suspension
|
|
59
|
+
* that could be named there would be catchable by configuration — which is the
|
|
60
|
+
* corruption, spelled as a feature.
|
|
61
|
+
*/
|
|
62
|
+
export class DurableSuspension extends Error {
|
|
63
|
+
runId;
|
|
64
|
+
path;
|
|
65
|
+
resource;
|
|
66
|
+
until;
|
|
67
|
+
code = ERR_DURABLE_SUSPENDED;
|
|
68
|
+
constructor(
|
|
69
|
+
/** The run that parked. */
|
|
70
|
+
runId,
|
|
71
|
+
/** The step path it parked at — the journal key its park is recorded under. */
|
|
72
|
+
path,
|
|
73
|
+
/** The parking resource's `metadata.name`, so the diagnostic names what
|
|
74
|
+
* waited rather than only where. */
|
|
75
|
+
resource, until) {
|
|
76
|
+
super(`Run '${runId}' parked at '${path}' (${resource}). This is a suspension signal, not a ` +
|
|
77
|
+
`failure: it must reach the workflow that owns the run.`);
|
|
78
|
+
this.runId = runId;
|
|
79
|
+
this.path = path;
|
|
80
|
+
this.resource = resource;
|
|
81
|
+
this.until = until;
|
|
82
|
+
this.name = "DurableSuspension";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Is this the suspension signal? Structural rather than `instanceof`, for the
|
|
86
|
+
* same reason every other cross-boundary test here is: the SDK is one scope per
|
|
87
|
+
* process today, and a runtime that threads a handle it owns should still be
|
|
88
|
+
* recognised. */
|
|
89
|
+
export function isSuspension(err) {
|
|
90
|
+
return (typeof err === "object" &&
|
|
91
|
+
err !== null &&
|
|
92
|
+
err.code === ERR_DURABLE_SUSPENDED);
|
|
93
|
+
}
|
|
94
|
+
const LATCHED = new WeakMap();
|
|
95
|
+
/**
|
|
96
|
+
* Park the run — the one way a parking kind suspends.
|
|
97
|
+
*
|
|
98
|
+
* Latches first, then asks the backend to record the park, then throws. Each of
|
|
99
|
+
* the three is load-bearing. Latching FIRST means a backend whose `park()`
|
|
100
|
+
* itself throws something else still leaves the evidence behind. Throwing here
|
|
101
|
+
* rather than trusting `park()`'s `Promise<never>` means a backend that returns
|
|
102
|
+
* — the one bug that would convert a wait into a completed step — is caught by
|
|
103
|
+
* the seam instead of by whatever ran next.
|
|
104
|
+
*/
|
|
105
|
+
export async function parkRun(handle, where, until) {
|
|
106
|
+
const signal = new DurableSuspension(handle.runId, where.path, where.resource, until);
|
|
107
|
+
LATCHED.set(handle, signal);
|
|
108
|
+
await handle.park(where, until);
|
|
109
|
+
throw signal;
|
|
110
|
+
}
|
|
111
|
+
/** The suspension latched against this run, if one was raised during this
|
|
112
|
+
* execution. */
|
|
113
|
+
export function latchedSuspension(handle) {
|
|
114
|
+
return LATCHED.get(handle);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The workflow boundary's check: a body that returned normally while a
|
|
118
|
+
* suspension is latched swallowed one.
|
|
119
|
+
*
|
|
120
|
+
* Raised rather than repaired, because there is nothing to repair: every effect
|
|
121
|
+
* after the swallow already ran, un-parked and un-recorded, and the run's own
|
|
122
|
+
* record would say it completed. What is actionable is the pair of names — the
|
|
123
|
+
* resource that parked and the step path — since the swallower is somewhere
|
|
124
|
+
* between them.
|
|
125
|
+
*/
|
|
126
|
+
export function assertNotSwallowed(handle) {
|
|
127
|
+
const signal = LATCHED.get(handle);
|
|
128
|
+
if (!signal)
|
|
129
|
+
return;
|
|
130
|
+
throw new InvokeError("ERR_DURABLE_SUSPENSION_SWALLOWED", `Run '${signal.runId}': '${signal.resource}' parked the run at step '${signal.path}', but ` +
|
|
131
|
+
`the body returned normally — something between them caught the suspension signal and ` +
|
|
132
|
+
`continued. Every step after the park has now run outside the journal, and the run would ` +
|
|
133
|
+
`have been recorded as completed. A controller in that path has a 'catch' that swallows ` +
|
|
134
|
+
`unknown errors; it must rethrow anything it did not recognise.`, { run: signal.runId, path: signal.path, resource: signal.resource });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Refuse to park inside a zone that forbids it.
|
|
138
|
+
*
|
|
139
|
+
* The runtime half of `noSuspend` — one rule rather than an enumeration of
|
|
140
|
+
* forbidden kinds, so a parking kind added later is covered without touching
|
|
141
|
+
* anything here. The zone's own declared sentence is printed verbatim: it is the
|
|
142
|
+
* author's statement of what is being held open, and nothing generated says it
|
|
143
|
+
* better.
|
|
144
|
+
*/
|
|
145
|
+
export function assertMaySuspend(ctx, invokeCtx, where) {
|
|
146
|
+
for (const zone of ctx.zoneAttributes?.(invokeCtx) ?? []) {
|
|
147
|
+
const reason = zone.attributes.noSuspend;
|
|
148
|
+
if (!reason)
|
|
149
|
+
continue;
|
|
150
|
+
throw new InvokeError("ERR_DURABLE_SUSPEND_FORBIDDEN", `'${where.resource}' would park the run, but it is inside a ${zone.kind} zone that ` +
|
|
151
|
+
`cannot be held open across a suspension: ${reason}`, { resource: where.resource, zone: zone.kind, reason });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire form of a {@link DurableTarget} — spec §5.3.
|
|
3
|
+
*
|
|
4
|
+
* Written in the slice where something first sends one across a process
|
|
5
|
+
* boundary, and not before: a normative format frozen with no consumer is the
|
|
6
|
+
* failure the sequencing rule exists to prevent. What crosses first is a child
|
|
7
|
+
* kernel, which is a real boundary — it shares no instance graph with its
|
|
8
|
+
* parent, so nothing about a target can survive by accident.
|
|
9
|
+
*
|
|
10
|
+
* **JSON, not a URI.** A journal entry already carries its target as JSON, and a
|
|
11
|
+
* second serialization vocabulary for one value is a second thing to keep
|
|
12
|
+
* agreeing. What this adds over `JSON.stringify` is what a FORMAT has to add:
|
|
13
|
+
* a canonical key order, so two runtimes producing the same identity produce the
|
|
14
|
+
* same bytes and an equality check needs no parser; a version tag, so a later
|
|
15
|
+
* form is refused rather than misread; and a stated set of required fields per
|
|
16
|
+
* form, so an incomplete identity is refused at the encoder rather than
|
|
17
|
+
* resolving to the wrong resource at the far end.
|
|
18
|
+
*
|
|
19
|
+
* **Three forms, discriminated by shape rather than by a tag.** Which one a
|
|
20
|
+
* target is follows from which fields it carries — `scope` makes it scoped,
|
|
21
|
+
* `pointer` makes it inline, neither makes it module-level — and the encoder
|
|
22
|
+
* refuses a value carrying both, since that is not a fourth form but a
|
|
23
|
+
* contradiction.
|
|
24
|
+
*/
|
|
25
|
+
import type { DurableTarget } from "./durable-run.js";
|
|
26
|
+
/** Bumped only for a change a previous reader would MISREAD. A new optional
|
|
27
|
+
* field a reader can ignore is not one; a change to what an existing field
|
|
28
|
+
* means is. */
|
|
29
|
+
export declare const DURABLE_TARGET_ENCODING_VERSION = 1;
|
|
30
|
+
/**
|
|
31
|
+
* Encode a target for transport.
|
|
32
|
+
*
|
|
33
|
+
* Refuses rather than guesses. An identity missing what its form requires would
|
|
34
|
+
* decode at the far end into a resource that is merely *similar*, and a step
|
|
35
|
+
* executed against the wrong resource is the one failure durable execution must
|
|
36
|
+
* never produce quietly — so an incomplete target is an error at the sender,
|
|
37
|
+
* where the manifest that produced it is still in reach.
|
|
38
|
+
*/
|
|
39
|
+
export declare function encodeDurableTarget(target: DurableTarget): string;
|
|
40
|
+
/**
|
|
41
|
+
* Decode a target received from another process.
|
|
42
|
+
*
|
|
43
|
+
* A version this reader does not know is REFUSED, never read as far as it
|
|
44
|
+
* understands: the fields it recognizes may mean something else in a form it has
|
|
45
|
+
* never seen, and resolving anyway is how a step gets executed against a
|
|
46
|
+
* resource nobody named.
|
|
47
|
+
*/
|
|
48
|
+
export declare function decodeDurableTarget(encoded: string): DurableTarget;
|
|
49
|
+
//# sourceMappingURL=durable-target-encoding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"durable-target-encoding.d.ts","sourceRoot":"","sources":["../src/durable-target-encoding.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD;;gBAEgB;AAChB,eAAO,MAAM,+BAA+B,IAAI,CAAC;AAiBjD;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAiEjE;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,aAAa,CAiDlE"}
|