@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.
- package/dist/internal/__tests__/app-callback-partition-key.test.js +7 -20
- package/dist/internal/__tests__/app-callback-partition-key.test.js.map +1 -1
- package/dist/internal/__tests__/app-callback-partition-worker.test.js +3 -3
- package/dist/internal/__tests__/app-callback-partition-worker.test.js.map +1 -1
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js +84 -394
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher.test.js.map +1 -1
- package/dist/internal/__tests__/app-callback-test-requests.d.ts +12 -37
- package/dist/internal/__tests__/app-callback-test-requests.d.ts.map +1 -1
- package/dist/internal/__tests__/app-callback-test-requests.js +15 -72
- package/dist/internal/__tests__/app-callback-test-requests.js.map +1 -1
- package/dist/internal/app-callback-partition-key.d.ts +4 -14
- package/dist/internal/app-callback-partition-key.d.ts.map +1 -1
- package/dist/internal/app-callback-partition-key.js +1 -11
- package/dist/internal/app-callback-partition-key.js.map +1 -1
- package/dist/runtime/errors.d.ts +2 -2
- package/dist/runtime/errors.d.ts.map +1 -1
- package/dist/runtime/frame.d.ts +10 -5
- package/dist/runtime/frame.d.ts.map +1 -1
- package/dist/runtime/frame.js +5 -21
- package/dist/runtime/frame.js.map +1 -1
- package/dist/ws-client.d.ts +9 -7
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +11 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +11 -11
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +3 -3
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts +0 -2
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.d.ts.map +0 -1
- package/dist/internal/__tests__/app-callback-partitioned-dispatcher-real-ws.test.js +0 -230
- 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
|
|
2
|
+
* Unit tests for the partitioned task-callback dispatcher.
|
|
3
3
|
*
|
|
4
4
|
* Spec: moltzap#356 §8 (test plan, file 3).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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(
|
|
40
|
-
yield* dispatcher.offer(
|
|
41
|
-
yield* dispatcher.offer(
|
|
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("
|
|
48
|
-
//
|
|
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
|
|
54
|
-
const
|
|
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(
|
|
68
|
-
yield* dispatcher.offer(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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("
|
|
74
|
+
expect(result).toBe("Y-ran");
|
|
78
75
|
});
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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:
|
|
83
|
+
config: { maxPartitions: cap },
|
|
88
84
|
});
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
92
|
+
return succeeded;
|
|
97
93
|
})));
|
|
98
|
-
expect(
|
|
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.
|
|
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(
|
|
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.
|
|
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(
|
|
153
|
-
// Partition A is now at capacity. A
|
|
154
|
-
//
|
|
155
|
-
|
|
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(
|
|
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(
|
|
183
|
-
const overflow = yield* Effect.either(dispatcher.offer(
|
|
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("
|
|
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:
|
|
257
|
-
idleReaperIntervalMs:
|
|
179
|
+
idlePartitionTtlMs: 50,
|
|
180
|
+
idleReaperIntervalMs: 25,
|
|
258
181
|
},
|
|
259
182
|
});
|
|
260
|
-
yield* dispatcher.offer(
|
|
261
|
-
//
|
|
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
|
-
|
|
266
|
-
|
|
187
|
+
if (stats.activePartitions === 0 ||
|
|
188
|
+
stats.partitions.every((p) => p.idle && p.queueSize === 0)) {
|
|
267
189
|
break;
|
|
268
|
-
|
|
190
|
+
}
|
|
191
|
+
yield* Effect.sleep("10 millis");
|
|
269
192
|
}
|
|
270
|
-
//
|
|
271
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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(
|
|
203
|
+
expect(verdict).toBe(0);
|
|
313
204
|
});
|
|
314
205
|
});
|
|
315
206
|
describe("makePartitionedDispatcher — scope teardown", () => {
|
|
316
|
-
it("
|
|
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
|
|
210
|
+
const releaseAll = yield* Deferred.make();
|
|
320
211
|
const dispatcher = yield* makePartitionedDispatcher({
|
|
321
|
-
handle: () => Deferred.await(
|
|
212
|
+
handle: () => Deferred.await(releaseAll),
|
|
322
213
|
scope,
|
|
323
214
|
});
|
|
324
|
-
yield* dispatcher.offer(
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
//
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
yield* Deferred.succeed(
|
|
332
|
-
return "
|
|
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("
|
|
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
|