@porulle/jobs-cloudflare 0.30.0 → 0.32.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/coordinator.d.ts +48 -17
- package/dist/coordinator.js +150 -57
- package/dist/index.d.ts +8 -0
- package/dist/index.js +25 -0
- package/package.json +2 -2
- package/src/coordinator.ts +209 -66
- package/src/index.ts +25 -0
package/dist/coordinator.d.ts
CHANGED
|
@@ -10,17 +10,44 @@ export interface CoordinatorStorage {
|
|
|
10
10
|
*/
|
|
11
11
|
export declare class JobCoordinatorLogic {
|
|
12
12
|
private readonly storage;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
13
|
+
constructor(storage: CoordinatorStorage);
|
|
14
|
+
/** First enqueue phase: storage only. Registers `instanceId` as pending for `key` before the
|
|
15
|
+
* caller creates it, so a later supersede can see it even if it has not started running yet;
|
|
16
|
+
* under `supersedes` the previously pending ids are cleared and returned for the caller to
|
|
17
|
+
* terminate. Never touches the currently running instance — matching the drizzle adapter,
|
|
18
|
+
* supersede only drops jobs that have not started. When a pending instance carries the same input its id
|
|
19
|
+
* comes back as a CANDIDATE rather than a decision, so the Durable Object can check outside
|
|
20
|
+
* the gate whether that instance still exists. */
|
|
21
|
+
enqueueRead(key: string, supersedes: boolean, instanceId: string, inputHash?: string): Promise<{
|
|
21
22
|
terminated: string[];
|
|
23
|
+
} | {
|
|
24
|
+
coalesceCandidate: string;
|
|
22
25
|
}>;
|
|
23
|
-
|
|
26
|
+
/** Re-enter after the candidate was confirmed LIVE outside the gate. Coalesces only if the
|
|
27
|
+
* candidate is STILL pending under the same hash — if it started running meanwhile it has
|
|
28
|
+
* already read its input and the caller needs its own instance. */
|
|
29
|
+
enqueueAfterLiveCandidate(key: string, candidate: string, supersedes: boolean, instanceId: string, inputHash?: string): Promise<{
|
|
30
|
+
terminated: string[];
|
|
31
|
+
coalescedInto?: string;
|
|
32
|
+
}>;
|
|
33
|
+
/** Re-enter after the candidate was found STALE outside the gate: enqueue normally, which
|
|
34
|
+
* under `supersedes` drops every pending id including the dead candidate. */
|
|
35
|
+
enqueueAfterStaleCandidate(key: string, supersedes: boolean, instanceId: string, inputHash?: string): Promise<{
|
|
36
|
+
terminated: string[];
|
|
37
|
+
}>;
|
|
38
|
+
private commitEnqueue;
|
|
39
|
+
/** First gate phase: storage only. Returns `needsStaleCheck` when another instance
|
|
40
|
+
* holds the key so the Durable Object can ask the Workflow binding outside the gate. */
|
|
41
|
+
acquireRead(key: string, instanceId: string): Promise<"granted" | {
|
|
42
|
+
needsStaleCheck: string;
|
|
43
|
+
}>;
|
|
44
|
+
/** Re-enter after a live holder was confirmed outside the gate. */
|
|
45
|
+
acquireWhenHolderLive(key: string, instanceId: string): Promise<"granted" | "pending">;
|
|
46
|
+
/** Re-enter after the holder was stale outside the gate — only grants when the
|
|
47
|
+
* same id still holds the key, so a concurrent acquirer cannot be raced. */
|
|
48
|
+
acquireAfterStale(key: string, instanceId: string, checkedHolderId: string): Promise<"granted" | "pending">;
|
|
49
|
+
private grantKey;
|
|
50
|
+
private enqueuePending;
|
|
24
51
|
/** Releases the lock if `instanceId` holds it and hands it to the next
|
|
25
52
|
* pending instance (if any), returning that instance's id so the caller can
|
|
26
53
|
* wake it. A release from an instance that does not hold the lock is a no-op. */
|
|
@@ -66,32 +93,36 @@ type DurableObjectConstructor = abstract new (...args: any[]) => object;
|
|
|
66
93
|
* export class PorulleJobCoordinator extends porulleJobCoordinator(DurableObject) {}
|
|
67
94
|
* ```
|
|
68
95
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
96
|
+
* State mutations run under `blockConcurrencyWhile`; Workflow binding calls
|
|
97
|
+
* (stale-holder checks and turn events) run outside it so a long release loop
|
|
98
|
+
* cannot block every other RPC on the object. On `enqueue` with `supersedes`
|
|
99
|
+
* the object reports the pending instances the caller must terminate. It needs
|
|
100
|
+
* a `PORULLE_WORKFLOW` binding on its environment to detect dead lock holders
|
|
101
|
+
* and to wake the next waiting instance.
|
|
75
102
|
*/
|
|
76
103
|
export declare function porulleJobCoordinator<TBase extends DurableObjectConstructor>(Base: TBase): (abstract new (...args: any[]) => {
|
|
77
104
|
readonly #logic: JobCoordinatorLogic;
|
|
78
105
|
readonly #workflow: CoordinatorWorkflowBinding;
|
|
79
106
|
readonly #state: DurableObjectStateLike;
|
|
80
|
-
|
|
107
|
+
#isStale(instanceId: string): Promise<boolean>;
|
|
108
|
+
enqueue(key: string, supersedes: boolean, instanceId: string, inputHash?: string): Promise<{
|
|
81
109
|
terminated: string[];
|
|
110
|
+
coalescedInto?: string;
|
|
82
111
|
}>;
|
|
83
112
|
acquire(key: string, instanceId: string): Promise<"granted" | "pending">;
|
|
84
113
|
/** Hands the key to the next pending instance that can still be woken; a
|
|
85
114
|
* pending instance that died or was terminated meanwhile is skipped so the
|
|
86
115
|
* key never ends up held by an instance that will never release it. */
|
|
87
116
|
release(key: string, instanceId: string): Promise<void>;
|
|
117
|
+
#releaseAndWake(key: string, holder: string): Promise<void>;
|
|
88
118
|
}) & TBase;
|
|
89
119
|
/** The RPC surface `DurableObjectConcurrencyCoordinator` calls on a
|
|
90
120
|
* `PorulleJobCoordinator` stub — the subset of `DurableObjectStub<PorulleJobCoordinator>`
|
|
91
121
|
* this package needs, so callers can inject a fake in tests without the Workers runtime. */
|
|
92
122
|
export interface CoordinatorStub {
|
|
93
|
-
enqueue(key: string, supersedes: boolean, instanceId: string): Promise<{
|
|
123
|
+
enqueue(key: string, supersedes: boolean, instanceId: string, inputHash?: string): Promise<{
|
|
94
124
|
terminated: string[];
|
|
125
|
+
coalescedInto?: string;
|
|
95
126
|
}>;
|
|
96
127
|
acquire(key: string, instanceId: string): Promise<"granted" | "pending">;
|
|
97
128
|
release(key: string, instanceId: string): Promise<void>;
|
package/dist/coordinator.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hashJobInput } from "./index.js";
|
|
1
2
|
const STALE_INSTANCE_STATUSES = new Set(["complete", "errored", "terminated"]);
|
|
2
3
|
const TURN_EVENT_TYPE = "porulle-turn";
|
|
3
4
|
const FIRST_TURN_WAIT_MS = 60_000;
|
|
@@ -22,37 +23,106 @@ function coordinatorKey(payload) {
|
|
|
22
23
|
*/
|
|
23
24
|
export class JobCoordinatorLogic {
|
|
24
25
|
storage;
|
|
25
|
-
|
|
26
|
-
constructor(storage, isStale) {
|
|
26
|
+
constructor(storage) {
|
|
27
27
|
this.storage = storage;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
28
|
+
}
|
|
29
|
+
/** First enqueue phase: storage only. Registers `instanceId` as pending for `key` before the
|
|
30
|
+
* caller creates it, so a later supersede can see it even if it has not started running yet;
|
|
31
|
+
* under `supersedes` the previously pending ids are cleared and returned for the caller to
|
|
32
|
+
* terminate. Never touches the currently running instance — matching the drizzle adapter,
|
|
33
|
+
* supersede only drops jobs that have not started. When a pending instance carries the same input its id
|
|
34
|
+
* comes back as a CANDIDATE rather than a decision, so the Durable Object can check outside
|
|
35
|
+
* the gate whether that instance still exists. */
|
|
36
|
+
async enqueueRead(key, supersedes, instanceId, inputHash) {
|
|
37
|
+
const state = await this.getState(key);
|
|
38
|
+
if (supersedes && inputHash !== undefined) {
|
|
39
|
+
for (const pendingId of state.pending) {
|
|
40
|
+
if (state.pendingHashes[pendingId] === inputHash) {
|
|
41
|
+
return { coalesceCandidate: pendingId };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
46
|
+
}
|
|
47
|
+
/** Re-enter after the candidate was confirmed LIVE outside the gate. Coalesces only if the
|
|
48
|
+
* candidate is STILL pending under the same hash — if it started running meanwhile it has
|
|
49
|
+
* already read its input and the caller needs its own instance. */
|
|
50
|
+
async enqueueAfterLiveCandidate(key, candidate, supersedes, instanceId, inputHash) {
|
|
51
|
+
const state = await this.getState(key);
|
|
52
|
+
if (supersedes &&
|
|
53
|
+
inputHash !== undefined &&
|
|
54
|
+
state.pending.includes(candidate) &&
|
|
55
|
+
state.pendingHashes[candidate] === inputHash) {
|
|
56
|
+
return { terminated: [], coalescedInto: candidate };
|
|
57
|
+
}
|
|
58
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
59
|
+
}
|
|
60
|
+
/** Re-enter after the candidate was found STALE outside the gate: enqueue normally, which
|
|
61
|
+
* under `supersedes` drops every pending id including the dead candidate. */
|
|
62
|
+
async enqueueAfterStaleCandidate(key, supersedes, instanceId, inputHash) {
|
|
36
63
|
const state = await this.getState(key);
|
|
64
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
65
|
+
}
|
|
66
|
+
async commitEnqueue(key, supersedes, instanceId, inputHash, state) {
|
|
37
67
|
const terminated = supersedes ? state.pending.filter((id) => id !== instanceId) : [];
|
|
38
68
|
const kept = supersedes ? [] : state.pending.filter((id) => id !== instanceId);
|
|
39
|
-
|
|
69
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
70
|
+
for (const id of terminated)
|
|
71
|
+
delete pendingHashes[id];
|
|
72
|
+
if (inputHash !== undefined)
|
|
73
|
+
pendingHashes[instanceId] = inputHash;
|
|
74
|
+
await this.putState(key, {
|
|
75
|
+
...state,
|
|
76
|
+
pending: [...kept, instanceId],
|
|
77
|
+
pendingHashes,
|
|
78
|
+
});
|
|
40
79
|
return { terminated };
|
|
41
80
|
}
|
|
42
|
-
|
|
43
|
-
|
|
81
|
+
/** First gate phase: storage only. Returns `needsStaleCheck` when another instance
|
|
82
|
+
* holds the key so the Durable Object can ask the Workflow binding outside the gate. */
|
|
83
|
+
async acquireRead(key, instanceId) {
|
|
84
|
+
const state = await this.getState(key);
|
|
44
85
|
if (state.running === instanceId)
|
|
45
86
|
return "granted";
|
|
46
|
-
if (state.running
|
|
47
|
-
|
|
87
|
+
if (state.running === null) {
|
|
88
|
+
await this.grantKey(key, instanceId, state);
|
|
89
|
+
return "granted";
|
|
48
90
|
}
|
|
91
|
+
return { needsStaleCheck: state.running };
|
|
92
|
+
}
|
|
93
|
+
/** Re-enter after a live holder was confirmed outside the gate. */
|
|
94
|
+
async acquireWhenHolderLive(key, instanceId) {
|
|
95
|
+
const state = await this.getState(key);
|
|
96
|
+
if (state.running === instanceId)
|
|
97
|
+
return "granted";
|
|
49
98
|
if (state.running === null) {
|
|
50
|
-
await this.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
99
|
+
await this.grantKey(key, instanceId, state);
|
|
100
|
+
return "granted";
|
|
101
|
+
}
|
|
102
|
+
return this.enqueuePending(key, instanceId, state);
|
|
103
|
+
}
|
|
104
|
+
/** Re-enter after the holder was stale outside the gate — only grants when the
|
|
105
|
+
* same id still holds the key, so a concurrent acquirer cannot be raced. */
|
|
106
|
+
async acquireAfterStale(key, instanceId, checkedHolderId) {
|
|
107
|
+
const state = await this.getState(key);
|
|
108
|
+
if (state.running === instanceId)
|
|
109
|
+
return "granted";
|
|
110
|
+
if (state.running === checkedHolderId) {
|
|
111
|
+
await this.grantKey(key, instanceId, state);
|
|
54
112
|
return "granted";
|
|
55
113
|
}
|
|
114
|
+
return this.acquireWhenHolderLive(key, instanceId);
|
|
115
|
+
}
|
|
116
|
+
async grantKey(key, instanceId, state) {
|
|
117
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
118
|
+
delete pendingHashes[instanceId];
|
|
119
|
+
await this.putState(key, {
|
|
120
|
+
pending: state.pending.filter((id) => id !== instanceId),
|
|
121
|
+
running: instanceId,
|
|
122
|
+
pendingHashes,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async enqueuePending(key, instanceId, state) {
|
|
56
126
|
if (!state.pending.includes(instanceId)) {
|
|
57
127
|
await this.putState(key, {
|
|
58
128
|
...state,
|
|
@@ -69,12 +139,21 @@ export class JobCoordinatorLogic {
|
|
|
69
139
|
if (state.running !== instanceId)
|
|
70
140
|
return { next: null };
|
|
71
141
|
const [next, ...rest] = state.pending;
|
|
72
|
-
|
|
142
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
143
|
+
if (next)
|
|
144
|
+
delete pendingHashes[next];
|
|
145
|
+
await this.putState(key, { pending: rest, running: next ?? null, pendingHashes });
|
|
73
146
|
return { next: next ?? null };
|
|
74
147
|
}
|
|
75
148
|
async getState(key) {
|
|
76
149
|
const existing = await this.storage.get(this.storageKey(key));
|
|
77
|
-
|
|
150
|
+
if (!existing)
|
|
151
|
+
return { pending: [], running: null, pendingHashes: {} };
|
|
152
|
+
// `pendingHashes` arrived after this object was already storing state in production, so a row
|
|
153
|
+
// written by an earlier release has no such key. Backfilling on READ rather than migrating
|
|
154
|
+
// keeps a coordinator from crashing on its own history — and a crash here takes every job on
|
|
155
|
+
// that key with it.
|
|
156
|
+
return { ...existing, pendingHashes: existing.pendingHashes ?? {} };
|
|
78
157
|
}
|
|
79
158
|
async putState(key, state) {
|
|
80
159
|
await this.storage.put(this.storageKey(key), state);
|
|
@@ -93,12 +172,12 @@ export class JobCoordinatorLogic {
|
|
|
93
172
|
* export class PorulleJobCoordinator extends porulleJobCoordinator(DurableObject) {}
|
|
94
173
|
* ```
|
|
95
174
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
175
|
+
* State mutations run under `blockConcurrencyWhile`; Workflow binding calls
|
|
176
|
+
* (stale-holder checks and turn events) run outside it so a long release loop
|
|
177
|
+
* cannot block every other RPC on the object. On `enqueue` with `supersedes`
|
|
178
|
+
* the object reports the pending instances the caller must terminate. It needs
|
|
179
|
+
* a `PORULLE_WORKFLOW` binding on its environment to detect dead lock holders
|
|
180
|
+
* and to wake the next waiting instance.
|
|
102
181
|
*/
|
|
103
182
|
export function porulleJobCoordinator(Base) {
|
|
104
183
|
class PorulleJobCoordinator extends Base {
|
|
@@ -117,42 +196,53 @@ export function porulleJobCoordinator(Base) {
|
|
|
117
196
|
put(key, value) {
|
|
118
197
|
return ctx.storage.put(key, value);
|
|
119
198
|
},
|
|
120
|
-
}, async (instanceId) => {
|
|
121
|
-
try {
|
|
122
|
-
const handle = await env.PORULLE_WORKFLOW.get(instanceId);
|
|
123
|
-
const { status } = await handle.status();
|
|
124
|
-
return STALE_INSTANCE_STATUSES.has(status);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
199
|
});
|
|
130
200
|
}
|
|
131
|
-
|
|
132
|
-
|
|
201
|
+
async #isStale(instanceId) {
|
|
202
|
+
try {
|
|
203
|
+
const handle = await this.#workflow.get(instanceId);
|
|
204
|
+
const { status } = await handle.status();
|
|
205
|
+
return STALE_INSTANCE_STATUSES.has(status);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
133
210
|
}
|
|
134
|
-
|
|
135
|
-
|
|
211
|
+
async enqueue(key, supersedes, instanceId, inputHash) {
|
|
212
|
+
const first = await this.#state.blockConcurrencyWhile(() => this.#logic.enqueueRead(key, supersedes, instanceId, inputHash));
|
|
213
|
+
if (!("coalesceCandidate" in first))
|
|
214
|
+
return first;
|
|
215
|
+
const stale = await this.#isStale(first.coalesceCandidate);
|
|
216
|
+
return this.#state.blockConcurrencyWhile(() => stale
|
|
217
|
+
? this.#logic.enqueueAfterStaleCandidate(key, supersedes, instanceId, inputHash)
|
|
218
|
+
: this.#logic.enqueueAfterLiveCandidate(key, first.coalesceCandidate, supersedes, instanceId, inputHash));
|
|
219
|
+
}
|
|
220
|
+
async acquire(key, instanceId) {
|
|
221
|
+
const first = await this.#state.blockConcurrencyWhile(() => this.#logic.acquireRead(key, instanceId));
|
|
222
|
+
if (first === "granted")
|
|
223
|
+
return "granted";
|
|
224
|
+
const stale = await this.#isStale(first.needsStaleCheck);
|
|
225
|
+
return this.#state.blockConcurrencyWhile(() => stale
|
|
226
|
+
? this.#logic.acquireAfterStale(key, instanceId, first.needsStaleCheck)
|
|
227
|
+
: this.#logic.acquireWhenHolderLive(key, instanceId));
|
|
136
228
|
}
|
|
137
229
|
/** Hands the key to the next pending instance that can still be woken; a
|
|
138
230
|
* pending instance that died or was terminated meanwhile is skipped so the
|
|
139
231
|
* key never ends up held by an instance that will never release it. */
|
|
140
232
|
release(key, instanceId) {
|
|
141
|
-
return this.#
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
});
|
|
233
|
+
return this.#releaseAndWake(key, instanceId);
|
|
234
|
+
}
|
|
235
|
+
async #releaseAndWake(key, holder) {
|
|
236
|
+
const { next } = await this.#state.blockConcurrencyWhile(() => this.#logic.release(key, holder));
|
|
237
|
+
if (!next)
|
|
238
|
+
return;
|
|
239
|
+
const woken = await this.#workflow
|
|
240
|
+
.get(next)
|
|
241
|
+
.then((handle) => handle.sendEvent({ type: TURN_EVENT_TYPE }))
|
|
242
|
+
.then(() => true, () => false);
|
|
243
|
+
if (woken)
|
|
244
|
+
return;
|
|
245
|
+
return this.#releaseAndWake(key, next);
|
|
156
246
|
}
|
|
157
247
|
}
|
|
158
248
|
return PorulleJobCoordinator;
|
|
@@ -170,9 +260,12 @@ export class DurableObjectConcurrencyCoordinator {
|
|
|
170
260
|
if (!payload.concurrencyKey)
|
|
171
261
|
return create();
|
|
172
262
|
const key = coordinatorKey(payload);
|
|
173
|
-
const
|
|
263
|
+
const inputHash = hashJobInput(payload.input);
|
|
264
|
+
const { terminated, coalescedInto } = await this.options
|
|
174
265
|
.stub(key)
|
|
175
|
-
.enqueue(key, payload.supersedes, payload.jobId);
|
|
266
|
+
.enqueue(key, payload.supersedes, payload.jobId, inputHash);
|
|
267
|
+
if (coalescedInto)
|
|
268
|
+
return { id: coalescedInto };
|
|
176
269
|
await Promise.all(terminated.map((id) => this.options.workflow
|
|
177
270
|
.get(id)
|
|
178
271
|
.then((handle) => handle.terminate())
|
package/dist/index.d.ts
CHANGED
|
@@ -72,6 +72,8 @@ export interface RawWorkflowBinding {
|
|
|
72
72
|
}): Promise<void>;
|
|
73
73
|
}>;
|
|
74
74
|
}
|
|
75
|
+
/** Short, dependency-free hash of task input for coordinator coalescing. */
|
|
76
|
+
export declare function hashJobInput(input: Record<string, unknown>): string;
|
|
75
77
|
/** Wraps the real Workflows binding: Cloudflare's richer instance status folds
|
|
76
78
|
* into `JobInstanceStatus` (`paused`/`waitingForPause` → `waiting`, anything
|
|
77
79
|
* unknown → `errored`) and the error becomes its message. */
|
|
@@ -101,6 +103,12 @@ export declare class CloudflareExecutionEngine implements ExecutionEngine {
|
|
|
101
103
|
private setup;
|
|
102
104
|
constructor(options: CloudflareExecutionEngineOptions);
|
|
103
105
|
register(setup: ExecutionEngineSetup): void;
|
|
106
|
+
/** Enqueues a Workflow instance for `taskSlug`.
|
|
107
|
+
*
|
|
108
|
+
* When a superseding enqueue coalesces into an existing pending instance with identical input,
|
|
109
|
+
* instance creation is skipped. That loses nothing: a job derives its work from state read when
|
|
110
|
+
* it **starts**; the enqueue payload is identity, not data. A pending instance created before a
|
|
111
|
+
* later write will still observe that write when it runs. */
|
|
104
112
|
enqueue(taskSlug: string, input: Record<string, unknown>, options: EnqueueOptions): Promise<string>;
|
|
105
113
|
run(payload: CloudflareJobPayload, step: WorkflowStep): Promise<Record<string, unknown>>;
|
|
106
114
|
status(jobId: string): Promise<{
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import { TaskNonRetryableError, createPassThroughTaskStep, } from "@porulle/core/jobs";
|
|
2
|
+
/** Deterministic JSON with sorted object keys — key order must not change the hash. */
|
|
3
|
+
function stableStringify(value) {
|
|
4
|
+
if (value === null || typeof value !== "object")
|
|
5
|
+
return JSON.stringify(value);
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
8
|
+
const obj = value;
|
|
9
|
+
const keys = Object.keys(obj).sort();
|
|
10
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
|
|
11
|
+
}
|
|
12
|
+
/** Short, dependency-free hash of task input for coordinator coalescing. */
|
|
13
|
+
export function hashJobInput(input) {
|
|
14
|
+
const serialized = stableStringify(input);
|
|
15
|
+
let hash = 5381;
|
|
16
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
17
|
+
hash = ((hash << 5) + hash) ^ serialized.charCodeAt(index);
|
|
18
|
+
}
|
|
19
|
+
return (hash >>> 0).toString(36);
|
|
20
|
+
}
|
|
2
21
|
const INSTANCE_STATUS_MAP = {
|
|
3
22
|
queued: "queued",
|
|
4
23
|
running: "running",
|
|
@@ -88,6 +107,12 @@ export class CloudflareExecutionEngine {
|
|
|
88
107
|
register(setup) {
|
|
89
108
|
this.setup = setup;
|
|
90
109
|
}
|
|
110
|
+
/** Enqueues a Workflow instance for `taskSlug`.
|
|
111
|
+
*
|
|
112
|
+
* When a superseding enqueue coalesces into an existing pending instance with identical input,
|
|
113
|
+
* instance creation is skipped. That loses nothing: a job derives its work from state read when
|
|
114
|
+
* it **starts**; the enqueue payload is identity, not data. A pending instance created before a
|
|
115
|
+
* later write will still observe that write when it runs. */
|
|
91
116
|
async enqueue(taskSlug, input, options) {
|
|
92
117
|
const task = this.requireSetup().tasks.get(taskSlug);
|
|
93
118
|
if (!task)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/jobs-cloudflare",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@porulle/core": "0.
|
|
14
|
+
"@porulle/core": "0.32.0"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
17
|
"@types/node": "^24.5.2",
|
package/src/coordinator.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
WorkflowBinding,
|
|
5
5
|
WorkflowStep,
|
|
6
6
|
} from "./index.js";
|
|
7
|
+
import { hashJobInput } from "./index.js";
|
|
7
8
|
|
|
8
9
|
const STALE_INSTANCE_STATUSES = new Set(["complete", "errored", "terminated"]);
|
|
9
10
|
const TURN_EVENT_TYPE = "porulle-turn";
|
|
@@ -33,6 +34,8 @@ export interface CoordinatorStorage {
|
|
|
33
34
|
interface CoordinatorKeyState {
|
|
34
35
|
pending: string[];
|
|
35
36
|
running: string | null;
|
|
37
|
+
/** Hash of `payload.input` for each pending instance — pruned whenever the id leaves `pending`. */
|
|
38
|
+
pendingHashes: Record<string, string>;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -41,41 +44,150 @@ interface CoordinatorKeyState {
|
|
|
41
44
|
* with an in-memory `CoordinatorStorage` and a fake `isStale` check.
|
|
42
45
|
*/
|
|
43
46
|
export class JobCoordinatorLogic {
|
|
44
|
-
constructor(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* the
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
constructor(private readonly storage: CoordinatorStorage) {}
|
|
48
|
+
|
|
49
|
+
/** First enqueue phase: storage only. Registers `instanceId` as pending for `key` before the
|
|
50
|
+
* caller creates it, so a later supersede can see it even if it has not started running yet;
|
|
51
|
+
* under `supersedes` the previously pending ids are cleared and returned for the caller to
|
|
52
|
+
* terminate. Never touches the currently running instance — matching the drizzle adapter,
|
|
53
|
+
* supersede only drops jobs that have not started. When a pending instance carries the same input its id
|
|
54
|
+
* comes back as a CANDIDATE rather than a decision, so the Durable Object can check outside
|
|
55
|
+
* the gate whether that instance still exists. */
|
|
56
|
+
async enqueueRead(
|
|
57
|
+
key: string,
|
|
58
|
+
supersedes: boolean,
|
|
59
|
+
instanceId: string,
|
|
60
|
+
inputHash?: string,
|
|
61
|
+
): Promise<{ terminated: string[] } | { coalesceCandidate: string }> {
|
|
62
|
+
const state = await this.getState(key);
|
|
63
|
+
if (supersedes && inputHash !== undefined) {
|
|
64
|
+
for (const pendingId of state.pending) {
|
|
65
|
+
if (state.pendingHashes[pendingId] === inputHash) {
|
|
66
|
+
return { coalesceCandidate: pendingId };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Re-enter after the candidate was confirmed LIVE outside the gate. Coalesces only if the
|
|
74
|
+
* candidate is STILL pending under the same hash — if it started running meanwhile it has
|
|
75
|
+
* already read its input and the caller needs its own instance. */
|
|
76
|
+
async enqueueAfterLiveCandidate(
|
|
77
|
+
key: string,
|
|
78
|
+
candidate: string,
|
|
79
|
+
supersedes: boolean,
|
|
80
|
+
instanceId: string,
|
|
81
|
+
inputHash?: string,
|
|
82
|
+
): Promise<{ terminated: string[]; coalescedInto?: string }> {
|
|
83
|
+
const state = await this.getState(key);
|
|
84
|
+
if (
|
|
85
|
+
supersedes &&
|
|
86
|
+
inputHash !== undefined &&
|
|
87
|
+
state.pending.includes(candidate) &&
|
|
88
|
+
state.pendingHashes[candidate] === inputHash
|
|
89
|
+
) {
|
|
90
|
+
return { terminated: [], coalescedInto: candidate };
|
|
91
|
+
}
|
|
92
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Re-enter after the candidate was found STALE outside the gate: enqueue normally, which
|
|
96
|
+
* under `supersedes` drops every pending id including the dead candidate. */
|
|
97
|
+
async enqueueAfterStaleCandidate(
|
|
55
98
|
key: string,
|
|
56
99
|
supersedes: boolean,
|
|
57
100
|
instanceId: string,
|
|
101
|
+
inputHash?: string,
|
|
58
102
|
): Promise<{ terminated: string[] }> {
|
|
59
103
|
const state = await this.getState(key);
|
|
104
|
+
return this.commitEnqueue(key, supersedes, instanceId, inputHash, state);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async commitEnqueue(
|
|
108
|
+
key: string,
|
|
109
|
+
supersedes: boolean,
|
|
110
|
+
instanceId: string,
|
|
111
|
+
inputHash: string | undefined,
|
|
112
|
+
state: CoordinatorKeyState,
|
|
113
|
+
): Promise<{ terminated: string[] }> {
|
|
60
114
|
const terminated = supersedes ? state.pending.filter((id) => id !== instanceId) : [];
|
|
61
115
|
const kept = supersedes ? [] : state.pending.filter((id) => id !== instanceId);
|
|
62
|
-
|
|
116
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
117
|
+
for (const id of terminated) delete pendingHashes[id];
|
|
118
|
+
if (inputHash !== undefined) pendingHashes[instanceId] = inputHash;
|
|
119
|
+
await this.putState(key, {
|
|
120
|
+
...state,
|
|
121
|
+
pending: [...kept, instanceId],
|
|
122
|
+
pendingHashes,
|
|
123
|
+
});
|
|
63
124
|
return { terminated };
|
|
64
125
|
}
|
|
65
126
|
|
|
66
|
-
|
|
67
|
-
|
|
127
|
+
/** First gate phase: storage only. Returns `needsStaleCheck` when another instance
|
|
128
|
+
* holds the key so the Durable Object can ask the Workflow binding outside the gate. */
|
|
129
|
+
async acquireRead(
|
|
130
|
+
key: string,
|
|
131
|
+
instanceId: string,
|
|
132
|
+
): Promise<"granted" | { needsStaleCheck: string }> {
|
|
133
|
+
const state = await this.getState(key);
|
|
68
134
|
if (state.running === instanceId) return "granted";
|
|
69
|
-
if (state.running
|
|
70
|
-
|
|
135
|
+
if (state.running === null) {
|
|
136
|
+
await this.grantKey(key, instanceId, state);
|
|
137
|
+
return "granted";
|
|
71
138
|
}
|
|
139
|
+
return { needsStaleCheck: state.running };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Re-enter after a live holder was confirmed outside the gate. */
|
|
143
|
+
async acquireWhenHolderLive(
|
|
144
|
+
key: string,
|
|
145
|
+
instanceId: string,
|
|
146
|
+
): Promise<"granted" | "pending"> {
|
|
147
|
+
const state = await this.getState(key);
|
|
148
|
+
if (state.running === instanceId) return "granted";
|
|
72
149
|
if (state.running === null) {
|
|
73
|
-
await this.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
150
|
+
await this.grantKey(key, instanceId, state);
|
|
151
|
+
return "granted";
|
|
152
|
+
}
|
|
153
|
+
return this.enqueuePending(key, instanceId, state);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Re-enter after the holder was stale outside the gate — only grants when the
|
|
157
|
+
* same id still holds the key, so a concurrent acquirer cannot be raced. */
|
|
158
|
+
async acquireAfterStale(
|
|
159
|
+
key: string,
|
|
160
|
+
instanceId: string,
|
|
161
|
+
checkedHolderId: string,
|
|
162
|
+
): Promise<"granted" | "pending"> {
|
|
163
|
+
const state = await this.getState(key);
|
|
164
|
+
if (state.running === instanceId) return "granted";
|
|
165
|
+
if (state.running === checkedHolderId) {
|
|
166
|
+
await this.grantKey(key, instanceId, state);
|
|
77
167
|
return "granted";
|
|
78
168
|
}
|
|
169
|
+
return this.acquireWhenHolderLive(key, instanceId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private async grantKey(
|
|
173
|
+
key: string,
|
|
174
|
+
instanceId: string,
|
|
175
|
+
state: CoordinatorKeyState,
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
178
|
+
delete pendingHashes[instanceId];
|
|
179
|
+
await this.putState(key, {
|
|
180
|
+
pending: state.pending.filter((id) => id !== instanceId),
|
|
181
|
+
running: instanceId,
|
|
182
|
+
pendingHashes,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async enqueuePending(
|
|
187
|
+
key: string,
|
|
188
|
+
instanceId: string,
|
|
189
|
+
state: CoordinatorKeyState,
|
|
190
|
+
): Promise<"pending"> {
|
|
79
191
|
if (!state.pending.includes(instanceId)) {
|
|
80
192
|
await this.putState(key, {
|
|
81
193
|
...state,
|
|
@@ -92,13 +204,20 @@ export class JobCoordinatorLogic {
|
|
|
92
204
|
const state = await this.getState(key);
|
|
93
205
|
if (state.running !== instanceId) return { next: null };
|
|
94
206
|
const [next, ...rest] = state.pending;
|
|
95
|
-
|
|
207
|
+
const pendingHashes = { ...state.pendingHashes };
|
|
208
|
+
if (next) delete pendingHashes[next];
|
|
209
|
+
await this.putState(key, { pending: rest, running: next ?? null, pendingHashes });
|
|
96
210
|
return { next: next ?? null };
|
|
97
211
|
}
|
|
98
212
|
|
|
99
213
|
private async getState(key: string): Promise<CoordinatorKeyState> {
|
|
100
214
|
const existing = await this.storage.get<CoordinatorKeyState>(this.storageKey(key));
|
|
101
|
-
|
|
215
|
+
if (!existing) return { pending: [], running: null, pendingHashes: {} };
|
|
216
|
+
// `pendingHashes` arrived after this object was already storing state in production, so a row
|
|
217
|
+
// written by an earlier release has no such key. Backfilling on READ rather than migrating
|
|
218
|
+
// keeps a coordinator from crashing on its own history — and a crash here takes every job on
|
|
219
|
+
// that key with it.
|
|
220
|
+
return { ...existing, pendingHashes: existing.pendingHashes ?? {} };
|
|
102
221
|
}
|
|
103
222
|
|
|
104
223
|
private async putState(key: string, state: CoordinatorKeyState): Promise<void> {
|
|
@@ -144,12 +263,12 @@ type DurableObjectConstructor = abstract new (...args: any[]) => object;
|
|
|
144
263
|
* export class PorulleJobCoordinator extends porulleJobCoordinator(DurableObject) {}
|
|
145
264
|
* ```
|
|
146
265
|
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
266
|
+
* State mutations run under `blockConcurrencyWhile`; Workflow binding calls
|
|
267
|
+
* (stale-holder checks and turn events) run outside it so a long release loop
|
|
268
|
+
* cannot block every other RPC on the object. On `enqueue` with `supersedes`
|
|
269
|
+
* the object reports the pending instances the caller must terminate. It needs
|
|
270
|
+
* a `PORULLE_WORKFLOW` binding on its environment to detect dead lock holders
|
|
271
|
+
* and to wake the next waiting instance.
|
|
153
272
|
*/
|
|
154
273
|
export function porulleJobCoordinator<TBase extends DurableObjectConstructor>(
|
|
155
274
|
Base: TBase,
|
|
@@ -164,40 +283,60 @@ export function porulleJobCoordinator<TBase extends DurableObjectConstructor>(
|
|
|
164
283
|
const [ctx, env] = args as [DurableObjectStateLike, PorulleJobCoordinatorEnv];
|
|
165
284
|
this.#state = ctx;
|
|
166
285
|
this.#workflow = env.PORULLE_WORKFLOW;
|
|
167
|
-
this.#logic = new JobCoordinatorLogic(
|
|
168
|
-
{
|
|
169
|
-
get<T>(key
|
|
170
|
-
return ctx.storage.get<T>(key);
|
|
171
|
-
},
|
|
172
|
-
put<T>(key: string, value: T) {
|
|
173
|
-
return ctx.storage.put(key, value);
|
|
174
|
-
},
|
|
286
|
+
this.#logic = new JobCoordinatorLogic({
|
|
287
|
+
get<T>(key: string) {
|
|
288
|
+
return ctx.storage.get<T>(key);
|
|
175
289
|
},
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const handle = await env.PORULLE_WORKFLOW.get(instanceId);
|
|
179
|
-
const { status } = await handle.status();
|
|
180
|
-
return STALE_INSTANCE_STATUSES.has(status);
|
|
181
|
-
} catch {
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
290
|
+
put<T>(key: string, value: T) {
|
|
291
|
+
return ctx.storage.put(key, value);
|
|
184
292
|
},
|
|
185
|
-
);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async #isStale(instanceId: string): Promise<boolean> {
|
|
297
|
+
try {
|
|
298
|
+
const handle = await this.#workflow.get(instanceId);
|
|
299
|
+
const { status } = await handle.status();
|
|
300
|
+
return STALE_INSTANCE_STATUSES.has(status);
|
|
301
|
+
} catch {
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
186
304
|
}
|
|
187
305
|
|
|
188
|
-
enqueue(
|
|
306
|
+
async enqueue(
|
|
189
307
|
key: string,
|
|
190
308
|
supersedes: boolean,
|
|
191
309
|
instanceId: string,
|
|
192
|
-
|
|
310
|
+
inputHash?: string,
|
|
311
|
+
): Promise<{ terminated: string[]; coalescedInto?: string }> {
|
|
312
|
+
const first = await this.#state.blockConcurrencyWhile(() =>
|
|
313
|
+
this.#logic.enqueueRead(key, supersedes, instanceId, inputHash),
|
|
314
|
+
);
|
|
315
|
+
if (!("coalesceCandidate" in first)) return first;
|
|
316
|
+
const stale = await this.#isStale(first.coalesceCandidate);
|
|
193
317
|
return this.#state.blockConcurrencyWhile(() =>
|
|
194
|
-
|
|
318
|
+
stale
|
|
319
|
+
? this.#logic.enqueueAfterStaleCandidate(key, supersedes, instanceId, inputHash)
|
|
320
|
+
: this.#logic.enqueueAfterLiveCandidate(
|
|
321
|
+
key,
|
|
322
|
+
first.coalesceCandidate,
|
|
323
|
+
supersedes,
|
|
324
|
+
instanceId,
|
|
325
|
+
inputHash,
|
|
326
|
+
),
|
|
195
327
|
);
|
|
196
328
|
}
|
|
197
329
|
|
|
198
|
-
acquire(key: string, instanceId: string): Promise<"granted" | "pending"> {
|
|
330
|
+
async acquire(key: string, instanceId: string): Promise<"granted" | "pending"> {
|
|
331
|
+
const first = await this.#state.blockConcurrencyWhile(() =>
|
|
332
|
+
this.#logic.acquireRead(key, instanceId),
|
|
333
|
+
);
|
|
334
|
+
if (first === "granted") return "granted";
|
|
335
|
+
const stale = await this.#isStale(first.needsStaleCheck);
|
|
199
336
|
return this.#state.blockConcurrencyWhile(() =>
|
|
200
|
-
|
|
337
|
+
stale
|
|
338
|
+
? this.#logic.acquireAfterStale(key, instanceId, first.needsStaleCheck)
|
|
339
|
+
: this.#logic.acquireWhenHolderLive(key, instanceId),
|
|
201
340
|
);
|
|
202
341
|
}
|
|
203
342
|
|
|
@@ -205,19 +344,20 @@ export function porulleJobCoordinator<TBase extends DurableObjectConstructor>(
|
|
|
205
344
|
* pending instance that died or was terminated meanwhile is skipped so the
|
|
206
345
|
* key never ends up held by an instance that will never release it. */
|
|
207
346
|
release(key: string, instanceId: string): Promise<void> {
|
|
208
|
-
return this.#
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
347
|
+
return this.#releaseAndWake(key, instanceId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async #releaseAndWake(key: string, holder: string): Promise<void> {
|
|
351
|
+
const { next } = await this.#state.blockConcurrencyWhile(() =>
|
|
352
|
+
this.#logic.release(key, holder),
|
|
353
|
+
);
|
|
354
|
+
if (!next) return;
|
|
355
|
+
const woken = await this.#workflow
|
|
356
|
+
.get(next)
|
|
357
|
+
.then((handle) => handle.sendEvent({ type: TURN_EVENT_TYPE }))
|
|
358
|
+
.then(() => true, () => false);
|
|
359
|
+
if (woken) return;
|
|
360
|
+
return this.#releaseAndWake(key, next);
|
|
221
361
|
}
|
|
222
362
|
}
|
|
223
363
|
return PorulleJobCoordinator;
|
|
@@ -231,7 +371,8 @@ export interface CoordinatorStub {
|
|
|
231
371
|
key: string,
|
|
232
372
|
supersedes: boolean,
|
|
233
373
|
instanceId: string,
|
|
234
|
-
|
|
374
|
+
inputHash?: string,
|
|
375
|
+
): Promise<{ terminated: string[]; coalescedInto?: string }>;
|
|
235
376
|
acquire(key: string, instanceId: string): Promise<"granted" | "pending">;
|
|
236
377
|
release(key: string, instanceId: string): Promise<void>;
|
|
237
378
|
}
|
|
@@ -259,9 +400,11 @@ export class DurableObjectConcurrencyCoordinator
|
|
|
259
400
|
): Promise<{ id: string }> {
|
|
260
401
|
if (!payload.concurrencyKey) return create();
|
|
261
402
|
const key = coordinatorKey(payload);
|
|
262
|
-
const
|
|
403
|
+
const inputHash = hashJobInput(payload.input);
|
|
404
|
+
const { terminated, coalescedInto } = await this.options
|
|
263
405
|
.stub(key)
|
|
264
|
-
.enqueue(key, payload.supersedes, payload.jobId);
|
|
406
|
+
.enqueue(key, payload.supersedes, payload.jobId, inputHash);
|
|
407
|
+
if (coalescedInto) return { id: coalescedInto };
|
|
265
408
|
await Promise.all(
|
|
266
409
|
terminated.map((id) =>
|
|
267
410
|
this.options.workflow
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,25 @@ export interface RawWorkflowBinding {
|
|
|
67
67
|
}>;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Deterministic JSON with sorted object keys — key order must not change the hash. */
|
|
71
|
+
function stableStringify(value: unknown): string {
|
|
72
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
73
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
74
|
+
const obj = value as Record<string, unknown>;
|
|
75
|
+
const keys = Object.keys(obj).sort();
|
|
76
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(obj[key])}`).join(",")}}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Short, dependency-free hash of task input for coordinator coalescing. */
|
|
80
|
+
export function hashJobInput(input: Record<string, unknown>): string {
|
|
81
|
+
const serialized = stableStringify(input);
|
|
82
|
+
let hash = 5381;
|
|
83
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
84
|
+
hash = ((hash << 5) + hash) ^ serialized.charCodeAt(index);
|
|
85
|
+
}
|
|
86
|
+
return (hash >>> 0).toString(36);
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
const INSTANCE_STATUS_MAP: Record<string, JobInstanceStatus> = {
|
|
71
90
|
queued: "queued",
|
|
72
91
|
running: "running",
|
|
@@ -200,6 +219,12 @@ export class CloudflareExecutionEngine implements ExecutionEngine {
|
|
|
200
219
|
this.setup = setup;
|
|
201
220
|
}
|
|
202
221
|
|
|
222
|
+
/** Enqueues a Workflow instance for `taskSlug`.
|
|
223
|
+
*
|
|
224
|
+
* When a superseding enqueue coalesces into an existing pending instance with identical input,
|
|
225
|
+
* instance creation is skipped. That loses nothing: a job derives its work from state read when
|
|
226
|
+
* it **starts**; the enqueue payload is identity, not data. A pending instance created before a
|
|
227
|
+
* later write will still observe that write when it runs. */
|
|
203
228
|
async enqueue(
|
|
204
229
|
taskSlug: string,
|
|
205
230
|
input: Record<string, unknown>,
|