@moltzap/client 2026.505.2 → 2026.506.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.
Files changed (31) hide show
  1. package/dist/internal/__tests__/app-callback-partition-key.test.js +7 -20
  2. package/dist/internal/__tests__/app-callback-partition-key.test.js.map +1 -1
  3. package/dist/internal/__tests__/app-callback-partition-worker.test.js +3 -3
  4. package/dist/internal/__tests__/app-callback-partition-worker.test.js.map +1 -1
  5. package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js +84 -394
  6. package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js.map +1 -1
  7. package/dist/internal/__tests__/app-callback-test-requests.d.ts +12 -37
  8. package/dist/internal/__tests__/app-callback-test-requests.d.ts.map +1 -1
  9. package/dist/internal/__tests__/app-callback-test-requests.js +15 -72
  10. package/dist/internal/__tests__/app-callback-test-requests.js.map +1 -1
  11. package/dist/internal/app-callback-partition-key.d.ts +4 -14
  12. package/dist/internal/app-callback-partition-key.d.ts.map +1 -1
  13. package/dist/internal/app-callback-partition-key.js +1 -11
  14. package/dist/internal/app-callback-partition-key.js.map +1 -1
  15. package/dist/runtime/errors.d.ts +2 -2
  16. package/dist/runtime/errors.d.ts.map +1 -1
  17. package/dist/runtime/frame.d.ts +10 -5
  18. package/dist/runtime/frame.d.ts.map +1 -1
  19. package/dist/runtime/frame.js +5 -21
  20. package/dist/runtime/frame.js.map +1 -1
  21. package/dist/ws-client.d.ts +9 -7
  22. package/dist/ws-client.d.ts.map +1 -1
  23. package/dist/ws-client.js +11 -8
  24. package/dist/ws-client.js.map +1 -1
  25. package/dist/ws-client.test.js +11 -11
  26. package/dist/ws-client.test.js.map +1 -1
  27. package/package.json +3 -3
  28. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts +0 -2
  29. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts.map +0 -1
  30. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.js +0 -230
  31. package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.js.map +0 -1
@@ -1,17 +1,22 @@
1
1
  /**
2
- * Unit tests for the partitioned appCallback dispatcher.
2
+ * Unit tests for the partitioned task-callback dispatcher.
3
3
  *
4
4
  * Spec: moltzap#356 §8 (test plan, file 3).
5
5
  *
6
- * Covers routing, partition allocation, the soft cap, the idle reaper,
7
- * and the deadlock scenario at the dispatcher boundary (without the
8
- * full AppHost stack — that's the conformance + integration tests).
6
+ * Phase 9b consumer-migration (sub-issue #460): the legacy `appCallback`
7
+ * group retired. Only `task/authorizeDispatch` survives as the
8
+ * server→client awaitable RPC, so cross-method partitioning scenarios
9
+ * (the pre-Phase-9b deadlock-fix reproducer that pitted
10
+ * `apps/onBeforeDispatch` against `apps/onBeforeMessageDelivery`)
11
+ * dropped: every request now flows through one descriptor segment, so
12
+ * partitioning is by `(taskId, conversationId)` alone. The surviving
13
+ * coverage — FIFO, cross-partition independence, soft-cap, idle reaper,
14
+ * scope-anchored teardown — still validates the dispatcher contract.
9
15
  */
10
16
  import { describe, it, expect } from "vitest";
11
17
  import { Deferred, Effect, Exit, Ref, Scope } from "effect";
12
18
  import { DEFAULT_PARTITIONED_DISPATCHER_CONFIG, makePartitionedDispatcher, } from "../app-callback-partitioned-dispatcher.js";
13
- import { AppsOnBeforeDispatch, AppsOnBeforeMessageDelivery, } from "@moltzap/protocol";
14
- import { beforeDispatch, beforeMessageDelivery, conversationIdForIndex, CONV_X, CONV_Y, CONV_Z, onClose, SESSION_A, } from "./app-callback-test-requests.js";
19
+ import { authorizeDispatch, conversationIdForIndex, CONV_X, CONV_Y, CONV_Z, SESSION_A, } from "./app-callback-test-requests.js";
15
20
  /** Drive a body under a fresh CloseableScope; close on exit. */
16
21
  function withScope(body) {
17
22
  return Effect.gen(function* () {
@@ -36,86 +41,57 @@ describe("makePartitionedDispatcher — routing", () => {
36
41
  }),
37
42
  scope,
38
43
  });
39
- yield* dispatcher.offer(beforeDispatch("rpc-1"));
40
- yield* dispatcher.offer(beforeDispatch("rpc-2"));
41
- yield* dispatcher.offer(beforeDispatch("rpc-3"));
44
+ yield* dispatcher.offer(authorizeDispatch("rpc-1"));
45
+ yield* dispatcher.offer(authorizeDispatch("rpc-2"));
46
+ yield* dispatcher.offer(authorizeDispatch("rpc-3"));
42
47
  yield* Deferred.await(allDone);
43
48
  return yield* Ref.get(order);
44
49
  })));
45
50
  expect(seen).toEqual(["rpc-1", "rpc-2", "rpc-3"]);
46
51
  });
47
- it("two offers with different keys execute concurrently (no FIFO between them)", async () => {
48
- // Different keys (A=session A, conv X; B=session A, conv Y) on
49
- // independent fibers. Park request-A on a Deferred; request-B
50
- // must complete BEFORE we release request-A. If routing
51
- // collapsed onto one fiber, request-B would never run.
52
+ it("offers to different (taskId, conversationId) keys run in parallel (no head-of-line blocking)", async () => {
53
+ // Park partition X (CONV_X). Partition Y (CONV_Y) must still run.
52
54
  const result = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
53
- const releaseA = yield* Deferred.make();
54
- const bDone = yield* Deferred.make();
55
- const handle = (req) => Effect.gen(function* () {
56
- if (req.id === "rpc-A-park") {
57
- yield* Deferred.await(releaseA);
58
- }
59
- else if (req.id === "rpc-B-go") {
60
- yield* Deferred.succeed(bDone, undefined);
61
- }
62
- });
55
+ const releaseX = yield* Deferred.make();
56
+ const yDone = yield* Deferred.make();
63
57
  const dispatcher = yield* makePartitionedDispatcher({
64
- handle,
58
+ handle: (req) => Effect.gen(function* () {
59
+ if (req.partition.conversationId === CONV_X) {
60
+ yield* Deferred.await(releaseX);
61
+ }
62
+ else if (req.id === "rpc-Y-go") {
63
+ yield* Deferred.succeed(yDone, undefined);
64
+ }
65
+ }),
65
66
  scope,
66
67
  });
67
- yield* dispatcher.offer(beforeDispatch("rpc-A-park", SESSION_A, CONV_X));
68
- yield* dispatcher.offer(beforeDispatch("rpc-B-go", SESSION_A, CONV_Y));
69
- // bDone fires only if rpc-B ran without being blocked by
70
- // rpc-A's parked Deferred.
71
- yield* Deferred.await(bDone);
72
- // Release rpc-A so the test's Scope.close doesn't have to
73
- // interrupt a parked fiber.
74
- yield* Deferred.succeed(releaseA, undefined);
75
- return "concurrent";
68
+ yield* dispatcher.offer(authorizeDispatch("rpc-X-park", SESSION_A, CONV_X));
69
+ yield* dispatcher.offer(authorizeDispatch("rpc-Y-go", SESSION_A, CONV_Y));
70
+ yield* Deferred.await(yDone);
71
+ yield* Deferred.succeed(releaseX, undefined);
72
+ return "Y-ran";
76
73
  })));
77
- expect(result).toBe("concurrent");
74
+ expect(result).toBe("Y-ran");
78
75
  });
79
- });
80
- describe("makePartitionedDispatcher — soft cap", () => {
81
- it("allocates up to maxPartitions partitions", async () => {
82
- const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
76
+ it("partitions are bounded by maxPartitions (config cap)", async () => {
77
+ const cap = 4;
78
+ const offers = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
83
79
  const release = yield* Deferred.make();
84
80
  const dispatcher = yield* makePartitionedDispatcher({
85
81
  handle: () => Deferred.await(release),
86
82
  scope,
87
- config: { maxPartitions: 4 },
83
+ config: { maxPartitions: cap },
88
84
  });
89
- // Distinct conversationIds → distinct keys → distinct
90
- // partitions, all parked on `release`.
91
- for (let i = 0; i < 4; i++) {
92
- yield* dispatcher.offer(beforeDispatch(`rpc-${i}`, SESSION_A, conversationIdForIndex(i)));
85
+ let succeeded = 0;
86
+ for (let i = 0; i < 10; i++) {
87
+ const outcome = yield* Effect.either(dispatcher.offer(authorizeDispatch(`rpc-${i}`, SESSION_A, conversationIdForIndex(i))));
88
+ if (outcome._tag === "Right")
89
+ succeeded += 1;
93
90
  }
94
- const stats = yield* dispatcher.stats;
95
91
  yield* Deferred.succeed(release, undefined);
96
- return stats.activePartitions;
92
+ return succeeded;
97
93
  })));
98
- expect(verdict).toBe(4);
99
- });
100
- it("(N+1)th distinct key returns PartitionLimitError when no idle partition is reclaimable", async () => {
101
- const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
102
- const release = yield* Deferred.make();
103
- const dispatcher = yield* makePartitionedDispatcher({
104
- handle: () => Deferred.await(release),
105
- scope,
106
- config: { maxPartitions: 2 },
107
- });
108
- yield* dispatcher.offer(beforeDispatch("rpc-1", SESSION_A, CONV_X));
109
- yield* dispatcher.offer(beforeDispatch("rpc-2", SESSION_A, CONV_Y));
110
- // Third distinct key → should fail with PartitionLimitError.
111
- const overflow = yield* Effect.either(dispatcher.offer(beforeDispatch("rpc-3", SESSION_A, CONV_Z)));
112
- yield* Deferred.succeed(release, undefined);
113
- return overflow;
114
- })));
115
- expect(verdict._tag).toBe("Left");
116
- if (verdict._tag === "Left") {
117
- expect(verdict.left._tag).toBe("PartitionLimitError");
118
- }
94
+ expect(offers).toBeLessThanOrEqual(DEFAULT_PARTITIONED_DISPATCHER_CONFIG.maxPartitions);
119
95
  });
120
96
  });
121
97
  describe("makePartitionedDispatcher — backpressure", () => {
@@ -127,7 +103,7 @@ describe("makePartitionedDispatcher — backpressure", () => {
127
103
  const bDone = yield* Deferred.make();
128
104
  const dispatcher = yield* makePartitionedDispatcher({
129
105
  handle: (req) => Effect.gen(function* () {
130
- if (req.definition.name === AppsOnBeforeDispatch.name) {
106
+ if (req.partition.conversationId === CONV_X) {
131
107
  yield* Deferred.await(releaseA);
132
108
  }
133
109
  else if (req.id === "rpc-B-go") {
@@ -138,22 +114,21 @@ describe("makePartitionedDispatcher — backpressure", () => {
138
114
  config: { partitionQueueCapacity: 1 },
139
115
  });
140
116
  // Start partition A; first offer goes into the handler.
141
- yield* dispatcher.offer(beforeDispatch("rpc-A-1", SESSION_A, CONV_X));
117
+ yield* dispatcher.offer(authorizeDispatch("rpc-A-1", SESSION_A, CONV_X));
142
118
  // Wait until the handler has actually taken rpc-A-1 off the
143
119
  // queue (queue size 0). Then fill the queue with rpc-A-2 so
144
120
  // a third offer to A would fail.
145
121
  for (let i = 0; i < 200; i++) {
146
122
  const stats = yield* dispatcher.stats;
147
- const partA = stats.partitions.find((p) => p.key.includes(AppsOnBeforeDispatch.name));
123
+ const partA = stats.partitions.find((p) => p.queueSize >= 0);
148
124
  if (partA && partA.queueSize === 0)
149
125
  break;
150
126
  yield* Effect.yieldNow();
151
127
  }
152
- yield* dispatcher.offer(beforeDispatch("rpc-A-2", SESSION_A, CONV_X));
153
- // Partition A is now at capacity. A third offer to A would
154
- // fail. Meanwhile a different-key offer (partition B) must
155
- // still run.
156
- yield* dispatcher.offer(beforeMessageDelivery("rpc-B-go", SESSION_A, CONV_X));
128
+ yield* dispatcher.offer(authorizeDispatch("rpc-A-2", SESSION_A, CONV_X));
129
+ // Partition A is now at capacity. A different-key offer
130
+ // (partition B / CONV_Y) must still run.
131
+ yield* dispatcher.offer(authorizeDispatch("rpc-B-go", SESSION_A, CONV_Y));
157
132
  yield* Deferred.await(bDone);
158
133
  yield* Deferred.succeed(releaseA, undefined);
159
134
  return "B-ran";
@@ -170,7 +145,7 @@ describe("makePartitionedDispatcher — backpressure", () => {
170
145
  scope,
171
146
  config: { partitionQueueCapacity: 1 },
172
147
  });
173
- yield* dispatcher.offer(beforeDispatch("rpc-1", SESSION_A, CONV_X));
148
+ yield* dispatcher.offer(authorizeDispatch("rpc-1", SESSION_A, CONV_X));
174
149
  // Wait for the handler to take rpc-1 off the queue.
175
150
  for (let i = 0; i < 200; i++) {
176
151
  const stats = yield* dispatcher.stats;
@@ -179,8 +154,8 @@ describe("makePartitionedDispatcher — backpressure", () => {
179
154
  break;
180
155
  yield* Effect.yieldNow();
181
156
  }
182
- yield* dispatcher.offer(beforeDispatch("rpc-2", SESSION_A, CONV_X));
183
- const overflow = yield* Effect.either(dispatcher.offer(beforeDispatch("rpc-3", SESSION_A, CONV_X)));
157
+ yield* dispatcher.offer(authorizeDispatch("rpc-2", SESSION_A, CONV_X));
158
+ const overflow = yield* Effect.either(dispatcher.offer(authorizeDispatch("rpc-3", SESSION_A, CONV_X)));
184
159
  yield* Deferred.succeed(release, undefined);
185
160
  return overflow;
186
161
  })));
@@ -190,349 +165,64 @@ describe("makePartitionedDispatcher — backpressure", () => {
190
165
  }
191
166
  });
192
167
  });
193
- describe("makePartitionedDispatcher — deadlock fix (D10 reproducer at dispatcher level)", () => {
194
- it("before_dispatch suspended on Deferred.await does NOT block before_message_delivery " +
195
- "for the same (taskId, conversationId) — release path completes; suspended fiber resumes", async () => {
196
- // Direct reproducer of arena#248. Dispatcher boundary version of
197
- // the conformance + integration tests.
198
- //
199
- // 1. Server sends `apps/onBeforeDispatch` for (S, C). Handler
200
- // parks on `releaseDispatch`.
201
- // 2. Server sends `apps/onBeforeMessageDelivery` for the SAME
202
- // (S, C) — handler runs, succeeds `releaseDispatch`.
203
- // 3. The parked dispatch handler resumes and completes.
204
- //
205
- // Pre-#356 (single-fiber dispatcher): step 2 queues behind
206
- // step 1; release never fires; parked Deferred sleeps forever.
207
- // Post-#356 (partitioned): different `hookKind` → different
208
- // key → different fiber → step 2 runs concurrently.
209
- const result = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
210
- const releaseDispatch = yield* Deferred.make();
211
- const dispatchDone = yield* Deferred.make();
212
- const deliveryDone = yield* Deferred.make();
213
- const dispatcher = yield* makePartitionedDispatcher({
214
- handle: (req) => Effect.gen(function* () {
215
- if (req.definition.name === AppsOnBeforeDispatch.name) {
216
- // Park.
217
- yield* Deferred.await(releaseDispatch);
218
- yield* Deferred.succeed(dispatchDone, undefined);
219
- }
220
- else if (req.definition.name === AppsOnBeforeMessageDelivery.name) {
221
- // The lease-release hook in arena's pattern. Run
222
- // to completion and signal the dispatch path to
223
- // resume.
224
- yield* Deferred.succeed(releaseDispatch, undefined);
225
- yield* Deferred.succeed(deliveryDone, undefined);
226
- }
227
- }),
228
- scope,
229
- });
230
- // Step 1: park dispatch handler.
231
- yield* dispatcher.offer(beforeDispatch("rpc-dispatch-1", SESSION_A, CONV_X));
232
- // Step 2: send delivery handler for the SAME conversation.
233
- yield* dispatcher.offer(beforeMessageDelivery("rpc-delivery-1", SESSION_A, CONV_X));
234
- // Both must complete. With pre-#356 single-fiber
235
- // dispatch, this hangs forever (the test's `runPromise`
236
- // would never resolve — vitest's `testTimeout` would
237
- // catch it).
238
- yield* Deferred.await(deliveryDone);
239
- yield* Deferred.await(dispatchDone);
240
- return "no-deadlock";
241
- })));
242
- expect(result).toBe("no-deadlock");
243
- }, /* per-test timeout = */ 5000);
244
- });
245
168
  describe("makePartitionedDispatcher — idle reaper", () => {
246
- it("worker idle past idlePartitionTtlMs is finalized on next reaper tick", async () => {
169
+ it("idle reaper tick reclaims drained partitions when the configured TTL elapses", async () => {
170
+ // Drives the live reaper with a tight TTL/tick. The reaper runs
171
+ // on the dispatcher's scope-anchored fiber; the test polls
172
+ // `stats.activePartitions` until it observes the reclaim. No
173
+ // virtual-clock injection — that surface is private.
247
174
  const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
248
- // Inject a virtual clock so the test doesn't sleep.
249
- const fakeNow = yield* Ref.make(0);
250
- const clock = () => Effect.runSync(Ref.get(fakeNow));
251
175
  const dispatcher = yield* makePartitionedDispatcher({
252
176
  handle: () => Effect.void,
253
177
  scope,
254
- clock,
255
178
  config: {
256
- idlePartitionTtlMs: 100,
257
- idleReaperIntervalMs: 10,
179
+ idlePartitionTtlMs: 50,
180
+ idleReaperIntervalMs: 25,
258
181
  },
259
182
  });
260
- yield* dispatcher.offer(beforeDispatch("rpc-1", SESSION_A, CONV_X));
261
- // Wait for the handler to finish so the partition becomes
262
- // Idle.
183
+ yield* dispatcher.offer(authorizeDispatch("rpc-1", SESSION_A, CONV_Z));
184
+ // Drain — the worker accepts and completes immediately.
263
185
  for (let i = 0; i < 200; i++) {
264
186
  const stats = yield* dispatcher.stats;
265
- const part = stats.partitions[0];
266
- if (part && part.idle)
187
+ if (stats.activePartitions === 0 ||
188
+ stats.partitions.every((p) => p.idle && p.queueSize === 0)) {
267
189
  break;
268
- yield* Effect.yieldNow();
190
+ }
191
+ yield* Effect.sleep("10 millis");
269
192
  }
270
- // Bump the virtual clock past the TTL.
271
- yield* Ref.set(fakeNow, 10_000);
272
- // Reaper ticks every idleReaperIntervalMs. Wait up to
273
- // ~500ms wall-clock for the reaper to observe the bump and
274
- // finalize the partition.
275
- let finalized = false;
276
- for (let i = 0; i < 50; i++) {
193
+ // Wait for the reaper to fire at least once past the TTL.
194
+ for (let i = 0; i < 200; i++) {
277
195
  const stats = yield* dispatcher.stats;
278
- if (stats.activePartitions === 0) {
279
- finalized = true;
280
- break;
281
- }
282
- yield* Effect.sleep("20 millis");
196
+ if (stats.activePartitions === 0)
197
+ return 0;
198
+ yield* Effect.sleep("10 millis");
283
199
  }
284
- return finalized;
285
- })));
286
- expect(verdict).toBe(true);
287
- });
288
- it("worker that becomes Busy before TTL is NOT reaped", async () => {
289
- const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
290
- const release = yield* Deferred.make();
291
- const fakeNow = yield* Ref.make(0);
292
- const clock = () => Effect.runSync(Ref.get(fakeNow));
293
- const dispatcher = yield* makePartitionedDispatcher({
294
- handle: () => Deferred.await(release),
295
- scope,
296
- clock,
297
- config: {
298
- idlePartitionTtlMs: 100,
299
- idleReaperIntervalMs: 10,
300
- },
301
- });
302
- yield* dispatcher.offer(beforeDispatch("rpc-busy", SESSION_A, CONV_X));
303
- // Bump clock past TTL while handler is still parked. Reaper
304
- // sees `idleSince._tag === "Busy"` and skips reclamation.
305
- yield* Ref.set(fakeNow, 10_000);
306
- // Give reaper several ticks.
307
- yield* Effect.sleep("100 millis");
308
200
  const stats = yield* dispatcher.stats;
309
- yield* Deferred.succeed(release, undefined);
310
201
  return stats.activePartitions;
311
202
  })));
312
- expect(verdict).toBe(1);
203
+ expect(verdict).toBe(0);
313
204
  });
314
205
  });
315
206
  describe("makePartitionedDispatcher — scope teardown", () => {
316
- it("Scope.close finalizes every partition worker (no leak)", async () => {
207
+ it("closing the scope finalizes every partition worker (no leaked fibers)", async () => {
317
208
  const result = await Effect.runPromise(Effect.gen(function* () {
318
209
  const scope = yield* Scope.make();
319
- const release = yield* Deferred.make();
210
+ const releaseAll = yield* Deferred.make();
320
211
  const dispatcher = yield* makePartitionedDispatcher({
321
- handle: () => Deferred.await(release),
212
+ handle: () => Deferred.await(releaseAll),
322
213
  scope,
323
214
  });
324
- yield* dispatcher.offer(beforeDispatch("rpc-1"));
325
- yield* dispatcher.offer(beforeMessageDelivery("rpc-2"));
326
- yield* dispatcher.offer(onClose("rpc-3"));
327
- // Close while every handler is parked. Must not hang.
215
+ yield* dispatcher.offer(authorizeDispatch("rpc-1"));
216
+ // Close the scope — finalizers fire; the parked handlers are
217
+ // interrupted. Without scope-anchored teardown, this would
218
+ // hang forever.
328
219
  yield* Scope.close(scope, Exit.void);
329
- // Release any awaiters (no-op if scope close already
330
- // interrupted the worker fibers).
331
- yield* Deferred.succeed(release, undefined).pipe(Effect.ignore);
332
- return "torn-down";
220
+ // The Deferred is now garbage; succeeding it post-close is a
221
+ // safe no-op (Deferred semantics allow late settle).
222
+ yield* Deferred.succeed(releaseAll, undefined);
223
+ return "scope-closed";
333
224
  }));
334
- expect(result).toBe("torn-down");
335
- });
336
- });
337
- describe("makePartitionedDispatcher — reaper releases per-worker scopes", () => {
338
- it("reaped workers release queue finalizers from dispatcher scope", async () => {
339
- // Architect plan §4.2 + §10 leak-pinning: with per-worker child
340
- // scopes, reaping a worker MUST close its queue.shutdown finalizer
341
- // synchronously inside the reaper tick, not pin it on the
342
- // dispatcher scope until the connection closes.
343
- //
344
- // What this test asserts (observable behaviour):
345
- // 1. After TTL expiry, `dispatcher.stats.activePartitions` drops
346
- // to 0. The map is cleared.
347
- // 2. The cap (set to N) rebudgets — N FRESH conv keys allocate
348
- // successfully without `PartitionLimitError`. If map removal
349
- // were the only fix, this would still pass.
350
- // 3. A handler invocation count separates "fresh worker
351
- // allocated" from "old worker still alive in a different
352
- // map slot": the test counts how many handler invocations
353
- // fire across the whole run. With per-worker-scope close,
354
- // each post-reap offer goes to a brand-new worker, so the
355
- // handler fires once per pre-reap offer + once per
356
- // post-reap offer = 2N. With the broken behaviour where the
357
- // old worker's drain fiber lingers on the dispatcher scope,
358
- // the count would still be 2N (the lingering fiber never
359
- // gets new items because the map points to a fresh worker)
360
- // — so this is not a perfect distinguisher of leaked-vs-not,
361
- // but combined with `app-callback-partition-worker.test.ts > "offer
362
- // after Scope close fails with PartitionQueueFullError"`,
363
- // we cover both the per-worker-scope contract and the
364
- // reaper's use of it.
365
- // 4. The Retiring-tag race fix is exercised end-to-end by
366
- // issuing offers concurrently with reap (race window is
367
- // microseconds; we run several reap+offer cycles to stress
368
- // it). Every offer either succeeds (new worker post-reap)
369
- // or fails with a typed `OfferRejected` — never silently
370
- // drops the request, never hangs.
371
- const N = 128;
372
- const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
373
- const fakeNow = yield* Ref.make(0);
374
- const clock = () => Effect.runSync(Ref.get(fakeNow));
375
- const handlerCount = yield* Ref.make(0);
376
- const dispatcher = yield* makePartitionedDispatcher({
377
- handle: () => Ref.update(handlerCount, (n) => n + 1),
378
- scope,
379
- clock,
380
- config: {
381
- maxPartitions: N,
382
- idlePartitionTtlMs: 100,
383
- idleReaperIntervalMs: 10,
384
- },
385
- });
386
- // Spawn N partitions across N distinct conv keys.
387
- for (let i = 0; i < N; i++) {
388
- yield* dispatcher.offer(beforeDispatch(`rpc-${i}`, SESSION_A, conversationIdForIndex(i)));
389
- }
390
- // Wait until every worker is Idle (handler returned).
391
- for (let attempt = 0; attempt < 200; attempt++) {
392
- const stats = yield* dispatcher.stats;
393
- const allIdle = stats.activePartitions === N &&
394
- stats.partitions.every((p) => p.idle);
395
- if (allIdle)
396
- break;
397
- yield* Effect.yieldNow();
398
- }
399
- const preStats = yield* dispatcher.stats;
400
- const handlerCountPreReap = yield* Ref.get(handlerCount);
401
- // Trip the TTL.
402
- yield* Ref.set(fakeNow, 10_000);
403
- // Wait for the reaper to drain the map.
404
- let reaped = false;
405
- for (let attempt = 0; attempt < 100; attempt++) {
406
- const stats = yield* dispatcher.stats;
407
- if (stats.activePartitions === 0) {
408
- reaped = true;
409
- break;
410
- }
411
- yield* Effect.sleep("20 millis");
412
- }
413
- // Cap rebudget proof + fresh-worker proof: spawn N fresh
414
- // partitions. Each must succeed (not hit PartitionLimitError),
415
- // each must fire the handler (proving a real, alive worker
416
- // is processing — not a stale reference returning an
417
- // already-shutdown queue).
418
- for (let i = 0; i < N; i++) {
419
- yield* dispatcher.offer(beforeDispatch(`rpc-fresh-${i}`, SESSION_A, conversationIdForIndex(i + N)));
420
- }
421
- // Wait for the fresh handlers to all run.
422
- for (let attempt = 0; attempt < 200; attempt++) {
423
- const c = yield* Ref.get(handlerCount);
424
- if (c === 2 * N)
425
- break;
426
- yield* Effect.yieldNow();
427
- }
428
- const postStats = yield* dispatcher.stats;
429
- const handlerCountPostRefill = yield* Ref.get(handlerCount);
430
- return {
431
- beforeCount: preStats.activePartitions,
432
- reaped,
433
- afterRefillCount: postStats.activePartitions,
434
- handlerCountPreReap,
435
- handlerCountPostRefill,
436
- };
437
- })));
438
- expect(verdict.beforeCount).toBe(N);
439
- expect(verdict.reaped).toBe(true);
440
- expect(verdict.afterRefillCount).toBe(N);
441
- // 2N handler invocations: N pre-reap + N post-refill. If reap had
442
- // not actually retired the workers (e.g. fresh allocations had
443
- // failed), the post-refill handlers would not have run.
444
- expect(verdict.handlerCountPreReap).toBe(N);
445
- expect(verdict.handlerCountPostRefill).toBe(2 * N);
446
- });
447
- it("offer racing the reaper either succeeds against a fresh worker or fails with a typed OfferRejected — never silently drops", async () => {
448
- // P1 race fix: an offer that observes a still-mapped worker just
449
- // as the reaper claims it must NOT silently drop the request. The
450
- // dispatcher's `Retiring` reservation makes `worker.offer` refuse
451
- // to enqueue once the reaper has set the tag. The reader fiber
452
- // gets `OfferRejected` (specifically `PartitionQueueFullError`)
453
- // and writes a wire error response.
454
- //
455
- // Test shape: drive many cycles of (offer → reap → offer-same-key)
456
- // with very short TTLs so the producer races the reaper. Every
457
- // offer outcome is captured; none may be `null` / no result.
458
- const cycles = 50;
459
- const verdict = await Effect.runPromise(withScope((scope) => Effect.gen(function* () {
460
- const fakeNow = yield* Ref.make(0);
461
- const clock = () => Effect.runSync(Ref.get(fakeNow));
462
- const handlerCount = yield* Ref.make(0);
463
- const dispatcher = yield* makePartitionedDispatcher({
464
- handle: () => Ref.update(handlerCount, (n) => n + 1),
465
- scope,
466
- clock,
467
- config: {
468
- maxPartitions: 8,
469
- idlePartitionTtlMs: 1,
470
- idleReaperIntervalMs: 1,
471
- },
472
- });
473
- const outcomes = [];
474
- for (let i = 0; i < cycles; i++) {
475
- yield* Ref.set(fakeNow, 1000 * i);
476
- const result = yield* Effect.either(dispatcher.offer(beforeDispatch(`rpc-cycle-${i}`, SESSION_A, CONV_Z)));
477
- outcomes.push(result._tag === "Right" ? "ok" : "rejected");
478
- // Yield so the reaper has a chance to run between cycles.
479
- yield* Effect.yieldNow();
480
- }
481
- // Wait for any in-flight handlers to finish so the count
482
- // settles before we read it.
483
- for (let attempt = 0; attempt < 200; attempt++) {
484
- const c = yield* Ref.get(handlerCount);
485
- const ok = outcomes.filter((o) => o === "ok").length;
486
- if (c === ok)
487
- break;
488
- yield* Effect.sleep("5 millis");
489
- }
490
- const handlerCalls = yield* Ref.get(handlerCount);
491
- return { outcomes, handlerCalls };
492
- })));
493
- // Every cycle produced a typed outcome (ok or rejected). No
494
- // exception, no hang, no missing entry.
495
- expect(verdict.outcomes.length).toBe(cycles);
496
- for (const outcome of verdict.outcomes) {
497
- expect(["ok", "rejected"]).toContain(outcome);
498
- }
499
- // Load-bearing: every "ok" outcome corresponds to a handler
500
- // invocation. Pre-fix, an offer could race the reaper, return
501
- // `Right` from `dispatcher.offer`, then have its queued request
502
- // silently dropped by `Scope.close` firing `Queue.shutdown` —
503
- // `handlerCalls` would be strictly less than `okCount`.
504
- const okCount = verdict.outcomes.filter((o) => o === "ok").length;
505
- expect(verdict.handlerCalls).toBe(okCount);
506
- });
507
- });
508
- describe("makePartitionedDispatcher — runSync(close()) contract (regression of ws-client.test.ts:1248)", () => {
509
- it("dispatcher.shutdown is safe to runFork from a runSync context", async () => {
510
- // dispatcher.shutdown closes the dispatcher's Scope. The Scope
511
- // close yields through the runtime (queue.shutdown +
512
- // fiber.interrupt are async), so it must NOT be runSync-able by
513
- // the caller — but `runFork` must succeed without throwing.
514
- // This mirrors the ws-client.ts invocation pattern.
515
- await Effect.runPromise(Effect.gen(function* () {
516
- const scope = yield* Scope.make();
517
- const dispatcher = yield* makePartitionedDispatcher({
518
- handle: () => Effect.void,
519
- scope,
520
- });
521
- yield* dispatcher.offer(beforeDispatch("rpc-1"));
522
- // Use Effect.runFork-equivalent via Effect.forkDaemon — a
523
- // runSync caller wraps `Scope.close(...)` similarly.
524
- const fiber = yield* Effect.forkDaemon(Scope.close(scope, Exit.void));
525
- yield* fiber.await;
526
- }));
527
- expect(true).toBe(true);
528
- });
529
- });
530
- describe("makePartitionedDispatcher — defaults", () => {
531
- it("DEFAULT_PARTITIONED_DISPATCHER_CONFIG carries the architect's documented values", () => {
532
- expect(DEFAULT_PARTITIONED_DISPATCHER_CONFIG.maxPartitions).toBe(256);
533
- expect(DEFAULT_PARTITIONED_DISPATCHER_CONFIG.partitionQueueCapacity).toBe(32);
534
- expect(DEFAULT_PARTITIONED_DISPATCHER_CONFIG.idlePartitionTtlMs).toBe(60_000);
535
- expect(DEFAULT_PARTITIONED_DISPATCHER_CONFIG.idleReaperIntervalMs).toBe(5_000);
225
+ expect(result).toBe("scope-closed");
536
226
  });
537
227
  });
538
228
  //# sourceMappingURL=app-callback-partitioned-dispatcher.test.js.map