@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.
@@ -10,17 +10,44 @@ export interface CoordinatorStorage {
10
10
  */
11
11
  export declare class JobCoordinatorLogic {
12
12
  private readonly storage;
13
- private readonly isStale;
14
- constructor(storage: CoordinatorStorage, isStale: (instanceId: string) => Promise<boolean>);
15
- /** Registers `instanceId` as pending for `key` before the caller creates it, so
16
- * a later supersede can see it even if it has not started running yet. When
17
- * `supersedes` is set, the previously pending ids are cleared and returned for
18
- * the caller to terminate. Never touches the currently running instance —
19
- * matching the drizzle adapter, supersede only drops jobs that have not started. */
20
- enqueue(key: string, supersedes: boolean, instanceId: string): Promise<{
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
- acquire(key: string, instanceId: string): Promise<"granted" | "pending">;
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
- * Every RPC runs under `blockConcurrencyWhile`: the stale-holder check is a
70
- * Workflow subrequest, which would otherwise open the input gate between the
71
- * read and the write and let two acquirers both be granted. On `enqueue` with
72
- * `supersedes` the object reports the pending instances the caller must
73
- * terminate. It needs a `PORULLE_WORKFLOW` binding on its environment to detect
74
- * dead lock holders and to wake the next waiting instance.
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
- enqueue(key: string, supersedes: boolean, instanceId: string): Promise<{
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>;
@@ -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
- isStale;
26
- constructor(storage, isStale) {
26
+ constructor(storage) {
27
27
  this.storage = storage;
28
- this.isStale = isStale;
29
- }
30
- /** Registers `instanceId` as pending for `key` before the caller creates it, so
31
- * a later supersede can see it even if it has not started running yet. When
32
- * `supersedes` is set, the previously pending ids are cleared and returned for
33
- * the caller to terminate. Never touches the currently running instance —
34
- * matching the drizzle adapter, supersede only drops jobs that have not started. */
35
- async enqueue(key, supersedes, instanceId) {
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
- await this.putState(key, { ...state, pending: [...kept, instanceId] });
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
- async acquire(key, instanceId) {
43
- let state = await this.getState(key);
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 !== null && (await this.isStale(state.running))) {
47
- state = { ...state, running: null };
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.putState(key, {
51
- pending: state.pending.filter((id) => id !== instanceId),
52
- running: instanceId,
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
- await this.putState(key, { pending: rest, running: next ?? null });
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
- return existing ?? { pending: [], running: null };
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
- * Every RPC runs under `blockConcurrencyWhile`: the stale-holder check is a
97
- * Workflow subrequest, which would otherwise open the input gate between the
98
- * read and the write and let two acquirers both be granted. On `enqueue` with
99
- * `supersedes` the object reports the pending instances the caller must
100
- * terminate. It needs a `PORULLE_WORKFLOW` binding on its environment to detect
101
- * dead lock holders and to wake the next waiting instance.
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
- enqueue(key, supersedes, instanceId) {
132
- return this.#state.blockConcurrencyWhile(() => this.#logic.enqueue(key, supersedes, instanceId));
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
- acquire(key, instanceId) {
135
- return this.#state.blockConcurrencyWhile(() => this.#logic.acquire(key, instanceId));
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.#state.blockConcurrencyWhile(async () => {
142
- let holder = instanceId;
143
- for (;;) {
144
- const { next } = await this.#logic.release(key, holder);
145
- if (!next)
146
- return;
147
- const woken = await this.#workflow
148
- .get(next)
149
- .then((handle) => handle.sendEvent({ type: TURN_EVENT_TYPE }))
150
- .then(() => true, () => false);
151
- if (woken)
152
- return;
153
- holder = next;
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 { terminated } = await this.options
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.30.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.30.0"
14
+ "@porulle/core": "0.32.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@types/node": "^24.5.2",
@@ -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
- private readonly storage: CoordinatorStorage,
46
- private readonly isStale: (instanceId: string) => Promise<boolean>,
47
- ) {}
48
-
49
- /** Registers `instanceId` as pending for `key` before the caller creates it, so
50
- * a later supersede can see it even if it has not started running yet. When
51
- * `supersedes` is set, the previously pending ids are cleared and returned for
52
- * the caller to terminate. Never touches the currently running instance —
53
- * matching the drizzle adapter, supersede only drops jobs that have not started. */
54
- async enqueue(
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
- await this.putState(key, { ...state, pending: [...kept, instanceId] });
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
- async acquire(key: string, instanceId: string): Promise<"granted" | "pending"> {
67
- let state = await this.getState(key);
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 !== null && (await this.isStale(state.running))) {
70
- state = { ...state, running: null };
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.putState(key, {
74
- pending: state.pending.filter((id) => id !== instanceId),
75
- running: instanceId,
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
- await this.putState(key, { pending: rest, running: next ?? null });
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
- return existing ?? { pending: [], running: null };
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
- * Every RPC runs under `blockConcurrencyWhile`: the stale-holder check is a
148
- * Workflow subrequest, which would otherwise open the input gate between the
149
- * read and the write and let two acquirers both be granted. On `enqueue` with
150
- * `supersedes` the object reports the pending instances the caller must
151
- * terminate. It needs a `PORULLE_WORKFLOW` binding on its environment to detect
152
- * dead lock holders and to wake the next waiting instance.
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: string) {
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
- async (instanceId) => {
177
- try {
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
- ): Promise<{ terminated: string[] }> {
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
- this.#logic.enqueue(key, supersedes, instanceId),
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
- this.#logic.acquire(key, instanceId),
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.#state.blockConcurrencyWhile(async () => {
209
- let holder = instanceId;
210
- for (;;) {
211
- const { next } = await this.#logic.release(key, holder);
212
- if (!next) return;
213
- const woken = await this.#workflow
214
- .get(next)
215
- .then((handle) => handle.sendEvent({ type: TURN_EVENT_TYPE }))
216
- .then(() => true, () => false);
217
- if (woken) return;
218
- holder = next;
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
- ): Promise<{ terminated: string[] }>;
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 { terminated } = await this.options
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>,