@moltzap/client 2026.501.0 → 2026.501.2
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/channel-core.d.ts +9 -0
- package/dist/channel-core.d.ts.map +1 -1
- package/dist/channel-core.js +44 -0
- package/dist/channel-core.js.map +1 -1
- package/dist/channel-core.test.js +37 -0
- package/dist/channel-core.test.js.map +1 -1
- package/dist/cli/commands/apps.d.ts +1 -1
- package/dist/internal/__tests__/s2c-partition-key.test.d.ts +2 -0
- package/dist/internal/__tests__/s2c-partition-key.test.d.ts.map +1 -0
- package/dist/internal/__tests__/s2c-partition-key.test.js +230 -0
- package/dist/internal/__tests__/s2c-partition-key.test.js.map +1 -0
- package/dist/internal/__tests__/s2c-partition-worker.test.d.ts +2 -0
- package/dist/internal/__tests__/s2c-partition-worker.test.d.ts.map +1 -0
- package/dist/internal/__tests__/s2c-partition-worker.test.js +267 -0
- package/dist/internal/__tests__/s2c-partition-worker.test.js.map +1 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts +2 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.d.ts.map +1 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.js +254 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher-real-ws.test.js.map +1 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts +2 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.d.ts.map +1 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.js +571 -0
- package/dist/internal/__tests__/s2c-partitioned-dispatcher.test.js.map +1 -0
- package/dist/internal/s2c-dispatcher-errors.d.ts +66 -0
- package/dist/internal/s2c-dispatcher-errors.d.ts.map +1 -0
- package/dist/internal/s2c-dispatcher-errors.js +46 -0
- package/dist/internal/s2c-dispatcher-errors.js.map +1 -0
- package/dist/internal/s2c-partition-key.d.ts +70 -0
- package/dist/internal/s2c-partition-key.d.ts.map +1 -0
- package/dist/internal/s2c-partition-key.js +140 -0
- package/dist/internal/s2c-partition-key.js.map +1 -0
- package/dist/internal/s2c-partition-worker.d.ts +160 -0
- package/dist/internal/s2c-partition-worker.d.ts.map +1 -0
- package/dist/internal/s2c-partition-worker.js +141 -0
- package/dist/internal/s2c-partition-worker.js.map +1 -0
- package/dist/internal/s2c-partitioned-dispatcher.d.ts +181 -0
- package/dist/internal/s2c-partitioned-dispatcher.d.ts.map +1 -0
- package/dist/internal/s2c-partitioned-dispatcher.js +290 -0
- package/dist/internal/s2c-partitioned-dispatcher.js.map +1 -0
- package/dist/service.d.ts +8 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +85 -12
- package/dist/service.js.map +1 -1
- package/dist/service.test.js +76 -4
- package/dist/service.test.js.map +1 -1
- package/dist/test-utils/channel-service-fixture.d.ts +6 -0
- package/dist/test-utils/channel-service-fixture.d.ts.map +1 -1
- package/dist/test-utils/channel-service-fixture.js +23 -0
- package/dist/test-utils/channel-service-fixture.js.map +1 -1
- package/dist/test-utils/fake-service.d.ts +3 -1
- package/dist/test-utils/fake-service.d.ts.map +1 -1
- package/dist/test-utils/fake-service.js +4 -0
- package/dist/test-utils/fake-service.js.map +1 -1
- package/dist/ws-client.d.ts +27 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +107 -57
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-client.test.js +7 -1
- package/dist/ws-client.test.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
declare const MalformedPartitionKeyError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
2
|
+
readonly _tag: "MalformedPartitionKeyError";
|
|
3
|
+
} & Readonly<A>;
|
|
4
|
+
/**
|
|
5
|
+
* The decoded server-request frame did not carry a usable partition key.
|
|
6
|
+
* Examples: required `params.sessionId` missing or not a UUID; method
|
|
7
|
+
* name absent from the s2c registry.
|
|
8
|
+
*
|
|
9
|
+
* Failure mode is structural — the request cannot be routed. The reader
|
|
10
|
+
* synthesizes a `-32602 Invalid params` error response.
|
|
11
|
+
*/
|
|
12
|
+
export declare class MalformedPartitionKeyError extends MalformedPartitionKeyError_base<{
|
|
13
|
+
readonly method: string;
|
|
14
|
+
readonly reason: "missing-session-id" | "missing-conversation-id" | "unknown-method" | "params-shape";
|
|
15
|
+
readonly requestId: string;
|
|
16
|
+
}> {
|
|
17
|
+
}
|
|
18
|
+
declare const PartitionLimitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
19
|
+
readonly _tag: "PartitionLimitError";
|
|
20
|
+
} & Readonly<A>;
|
|
21
|
+
/**
|
|
22
|
+
* The active-partition count has reached `maxPartitions` (soft cap, default
|
|
23
|
+
* 256). New partitions cannot be allocated until an existing partition is
|
|
24
|
+
* reaped by the idle reaper or the connection scope closes. Per-tenant DoS
|
|
25
|
+
* guard.
|
|
26
|
+
*
|
|
27
|
+
* Failure mode is liveness — the request is rejected without contention
|
|
28
|
+
* with existing partitions. Reader synthesizes a `-32000 Server busy`
|
|
29
|
+
* error response so the server retries / falls through.
|
|
30
|
+
*/
|
|
31
|
+
export declare class PartitionLimitError extends PartitionLimitError_base<{
|
|
32
|
+
readonly key: string;
|
|
33
|
+
readonly activePartitions: number;
|
|
34
|
+
readonly maxPartitions: number;
|
|
35
|
+
readonly requestId: string;
|
|
36
|
+
}> {
|
|
37
|
+
}
|
|
38
|
+
declare const PartitionQueueFullError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
39
|
+
readonly _tag: "PartitionQueueFullError";
|
|
40
|
+
} & Readonly<A>;
|
|
41
|
+
/**
|
|
42
|
+
* The target partition's bounded queue is at capacity (default 32). Slow
|
|
43
|
+
* handler is backpressuring its own partition. Other partitions are
|
|
44
|
+
* unaffected.
|
|
45
|
+
*
|
|
46
|
+
* Failure mode is per-partition liveness — the queue is full ONLY for this
|
|
47
|
+
* tuple. Reader synthesizes a `-32000 Partition busy` error response;
|
|
48
|
+
* server may retry or surface the failure to the originator (AppHost
|
|
49
|
+
* fail-CLOSED verdict for hooks).
|
|
50
|
+
*/
|
|
51
|
+
export declare class PartitionQueueFullError extends PartitionQueueFullError_base<{
|
|
52
|
+
readonly key: string;
|
|
53
|
+
readonly capacity: number;
|
|
54
|
+
readonly requestId: string;
|
|
55
|
+
}> {
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Discriminated union over every reason `PartitionedDispatcher.offer` can
|
|
59
|
+
* reject an inbound request. Exhaustive: adding a new branch fails every
|
|
60
|
+
* `match` site at compile time. Branch handlers in the reader's
|
|
61
|
+
* frame-routing path discriminate on `_tag` to pick the right wire-error
|
|
62
|
+
* code.
|
|
63
|
+
*/
|
|
64
|
+
export type OfferRejected = MalformedPartitionKeyError | PartitionLimitError | PartitionQueueFullError;
|
|
65
|
+
export {};
|
|
66
|
+
//# sourceMappingURL=s2c-dispatcher-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-dispatcher-errors.d.ts","sourceRoot":"","sources":["../../src/internal/s2c-dispatcher-errors.ts"],"names":[],"mappings":";;;AAYA;;;;;;;GAOG;AACH,qBAAa,0BAA2B,SAAQ,gCAE9C;IACA,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EACX,oBAAoB,GACpB,yBAAyB,GACzB,gBAAgB,GAChB,cAAc,CAAC;IACnB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;;;;AAEL;;;;;;;;;GASG;AACH,qBAAa,mBAAoB,SAAQ,yBAEvC;IACA,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;;;;AAEL;;;;;;;;;GASG;AACH,qBAAa,uBAAwB,SAAQ,6BAE3C;IACA,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B,CAAC;CAAG;AAEL;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GACrB,0BAA0B,GAC1B,mBAAmB,GACnB,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error types for the partitioned s2c dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Spec: moltzap#356 — partitioned dispatch keyed on
|
|
5
|
+
* `(sessionId, conversationId, hookKind)`.
|
|
6
|
+
*
|
|
7
|
+
* All errors are surfaced on `PartitionedDispatcher.offer`'s typed channel.
|
|
8
|
+
* The reader fiber translates each tag into an immediate s2c error response
|
|
9
|
+
* so the server's `Deferred.await` resolves deterministically (no hangs).
|
|
10
|
+
*/
|
|
11
|
+
import { Data } from "effect";
|
|
12
|
+
/**
|
|
13
|
+
* The decoded server-request frame did not carry a usable partition key.
|
|
14
|
+
* Examples: required `params.sessionId` missing or not a UUID; method
|
|
15
|
+
* name absent from the s2c registry.
|
|
16
|
+
*
|
|
17
|
+
* Failure mode is structural — the request cannot be routed. The reader
|
|
18
|
+
* synthesizes a `-32602 Invalid params` error response.
|
|
19
|
+
*/
|
|
20
|
+
export class MalformedPartitionKeyError extends Data.TaggedError("MalformedPartitionKeyError") {
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The active-partition count has reached `maxPartitions` (soft cap, default
|
|
24
|
+
* 256). New partitions cannot be allocated until an existing partition is
|
|
25
|
+
* reaped by the idle reaper or the connection scope closes. Per-tenant DoS
|
|
26
|
+
* guard.
|
|
27
|
+
*
|
|
28
|
+
* Failure mode is liveness — the request is rejected without contention
|
|
29
|
+
* with existing partitions. Reader synthesizes a `-32000 Server busy`
|
|
30
|
+
* error response so the server retries / falls through.
|
|
31
|
+
*/
|
|
32
|
+
export class PartitionLimitError extends Data.TaggedError("PartitionLimitError") {
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The target partition's bounded queue is at capacity (default 32). Slow
|
|
36
|
+
* handler is backpressuring its own partition. Other partitions are
|
|
37
|
+
* unaffected.
|
|
38
|
+
*
|
|
39
|
+
* Failure mode is per-partition liveness — the queue is full ONLY for this
|
|
40
|
+
* tuple. Reader synthesizes a `-32000 Partition busy` error response;
|
|
41
|
+
* server may retry or surface the failure to the originator (AppHost
|
|
42
|
+
* fail-CLOSED verdict for hooks).
|
|
43
|
+
*/
|
|
44
|
+
export class PartitionQueueFullError extends Data.TaggedError("PartitionQueueFullError") {
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=s2c-dispatcher-errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-dispatcher-errors.js","sourceRoot":"","sources":["../../src/internal/s2c-dispatcher-errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE9B;;;;;;;GAOG;AACH,MAAM,OAAO,0BAA2B,SAAQ,IAAI,CAAC,WAAW,CAC9D,4BAA4B,CAS5B;CAAG;AAEL;;;;;;;;;GASG;AACH,MAAM,OAAO,mBAAoB,SAAQ,IAAI,CAAC,WAAW,CACvD,qBAAqB,CAMrB;CAAG;AAEL;;;;;;;;;GASG;AACH,MAAM,OAAO,uBAAwB,SAAQ,IAAI,CAAC,WAAW,CAC3D,yBAAyB,CAKzB;CAAG"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure partition-key extractor for the partitioned s2c dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Spec: moltzap#356.
|
|
5
|
+
*
|
|
6
|
+
* Decode rules — `decodeFrames` validates the **frame envelope only**
|
|
7
|
+
* (`packages/client/src/runtime/frame.ts:29-34@f0df363`); the inbound
|
|
8
|
+
* `params` field arrives as `unknown`. Per-method param-shape decode
|
|
9
|
+
* (`S2cRpcMap[method]['params']`) is the AppHost layer's job
|
|
10
|
+
* (`packages/app-sdk/src/app.ts:489-491@f0df363`) and is unchanged
|
|
11
|
+
* by this refactor.
|
|
12
|
+
*
|
|
13
|
+
* This module performs **narrow validation of routing fields only**:
|
|
14
|
+
* confirm `params` is an object, then extract three fields with
|
|
15
|
+
* the same tolerance as the AppHost cast:
|
|
16
|
+
*
|
|
17
|
+
* - `sessionId` (always required for every entry in `s2cRpcMethods`)
|
|
18
|
+
* - `conversationId` (present on `apps/onBeforeDispatch` and
|
|
19
|
+
* `apps/onBeforeMessageDelivery`; absent on lifecycle methods —
|
|
20
|
+
* synthesized as the literal sentinel `*lifecycle*`)
|
|
21
|
+
* - `method` — the wire method name doubles as `hookKind`.
|
|
22
|
+
*
|
|
23
|
+
* The result is a branded string `PartitionKey`. Branding prevents
|
|
24
|
+
* accidental reuse of raw `string` keys in the worker map.
|
|
25
|
+
*/
|
|
26
|
+
import { Either } from "effect";
|
|
27
|
+
import { MalformedPartitionKeyError } from "./s2c-dispatcher-errors.js";
|
|
28
|
+
/**
|
|
29
|
+
* Branded partition-key string. Constructed only by `extractPartitionKey`.
|
|
30
|
+
* Format is opaque to consumers; equality is value equality.
|
|
31
|
+
*/
|
|
32
|
+
export type PartitionKey = string & {
|
|
33
|
+
readonly __brand: "PartitionKey";
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Sentinel placeholder for s2c methods that carry no `conversationId`
|
|
37
|
+
* (lifecycle: `apps/onJoin`, `apps/onClose`, `apps/onSessionActive`).
|
|
38
|
+
* All lifecycle calls for the same `(sessionId, method)` share one
|
|
39
|
+
* partition; cross-method lifecycle ordering is preserved by the server,
|
|
40
|
+
* not the client.
|
|
41
|
+
*/
|
|
42
|
+
export declare const LIFECYCLE_CONVERSATION_SENTINEL: "*lifecycle*";
|
|
43
|
+
/**
|
|
44
|
+
* Shape the dispatcher hands the extractor. Mirrors `DecodedServerRequest`
|
|
45
|
+
* in `ws-client.ts` but stays import-cycle-free for the internal modules.
|
|
46
|
+
*/
|
|
47
|
+
export interface PartitionableRequest {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly method: string;
|
|
50
|
+
readonly params: unknown;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Decode a partition key from an inbound s2c request. Does NOT mutate the
|
|
54
|
+
* request. On any structural failure, returns
|
|
55
|
+
* `MalformedPartitionKeyError` with a typed `reason` tag the reader fiber
|
|
56
|
+
* uses to pick an error response code.
|
|
57
|
+
*/
|
|
58
|
+
export declare function extractPartitionKey(request: PartitionableRequest): Either.Either<PartitionKey, MalformedPartitionKeyError>;
|
|
59
|
+
/**
|
|
60
|
+
* Inverse projection for tests + observability. Pure deconstruction of a
|
|
61
|
+
* `PartitionKey` back into its three components. Returning a structured
|
|
62
|
+
* record (rather than a tuple) lets callers discriminate on
|
|
63
|
+
* `conversationId === LIFECYCLE_CONVERSATION_SENTINEL`.
|
|
64
|
+
*/
|
|
65
|
+
export declare function describePartitionKey(key: PartitionKey): {
|
|
66
|
+
readonly sessionId: string;
|
|
67
|
+
readonly conversationId: string;
|
|
68
|
+
readonly method: string;
|
|
69
|
+
};
|
|
70
|
+
//# sourceMappingURL=s2c-partition-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-partition-key.d.ts","sourceRoot":"","sources":["../../src/internal/s2c-partition-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,0BAA0B,EAAE,MAAM,4BAA4B,CAAC;AAExE;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG;IAAE,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;CAAE,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,EAAG,aAAsB,CAAC;AA4BtE;;;GAGG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAMD;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,oBAAoB,GAC5B,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,0BAA0B,CAAC,CA0DzD;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,YAAY,GAAG;IACvD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,CAmBA"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure partition-key extractor for the partitioned s2c dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* Spec: moltzap#356.
|
|
5
|
+
*
|
|
6
|
+
* Decode rules — `decodeFrames` validates the **frame envelope only**
|
|
7
|
+
* (`packages/client/src/runtime/frame.ts:29-34@f0df363`); the inbound
|
|
8
|
+
* `params` field arrives as `unknown`. Per-method param-shape decode
|
|
9
|
+
* (`S2cRpcMap[method]['params']`) is the AppHost layer's job
|
|
10
|
+
* (`packages/app-sdk/src/app.ts:489-491@f0df363`) and is unchanged
|
|
11
|
+
* by this refactor.
|
|
12
|
+
*
|
|
13
|
+
* This module performs **narrow validation of routing fields only**:
|
|
14
|
+
* confirm `params` is an object, then extract three fields with
|
|
15
|
+
* the same tolerance as the AppHost cast:
|
|
16
|
+
*
|
|
17
|
+
* - `sessionId` (always required for every entry in `s2cRpcMethods`)
|
|
18
|
+
* - `conversationId` (present on `apps/onBeforeDispatch` and
|
|
19
|
+
* `apps/onBeforeMessageDelivery`; absent on lifecycle methods —
|
|
20
|
+
* synthesized as the literal sentinel `*lifecycle*`)
|
|
21
|
+
* - `method` — the wire method name doubles as `hookKind`.
|
|
22
|
+
*
|
|
23
|
+
* The result is a branded string `PartitionKey`. Branding prevents
|
|
24
|
+
* accidental reuse of raw `string` keys in the worker map.
|
|
25
|
+
*/
|
|
26
|
+
import { Either } from "effect";
|
|
27
|
+
import { MalformedPartitionKeyError } from "./s2c-dispatcher-errors.js";
|
|
28
|
+
/**
|
|
29
|
+
* Sentinel placeholder for s2c methods that carry no `conversationId`
|
|
30
|
+
* (lifecycle: `apps/onJoin`, `apps/onClose`, `apps/onSessionActive`).
|
|
31
|
+
* All lifecycle calls for the same `(sessionId, method)` share one
|
|
32
|
+
* partition; cross-method lifecycle ordering is preserved by the server,
|
|
33
|
+
* not the client.
|
|
34
|
+
*/
|
|
35
|
+
export const LIFECYCLE_CONVERSATION_SENTINEL = "*lifecycle*";
|
|
36
|
+
/**
|
|
37
|
+
* Methods that carry `conversationId` on `params`. Disjoint from the
|
|
38
|
+
* lifecycle methods, which fall back to `LIFECYCLE_CONVERSATION_SENTINEL`.
|
|
39
|
+
*
|
|
40
|
+
* Kept as a literal `Set` (not derived from `s2cRpcMethods`) so this
|
|
41
|
+
* module has no schema-package import beyond the error type — keeping
|
|
42
|
+
* the routing layer pure and import-cycle-free per architect §3.2.
|
|
43
|
+
*/
|
|
44
|
+
const CONVERSATION_BEARING_METHODS = new Set([
|
|
45
|
+
"apps/onBeforeDispatch",
|
|
46
|
+
"apps/onBeforeMessageDelivery",
|
|
47
|
+
]);
|
|
48
|
+
const LIFECYCLE_METHODS = new Set([
|
|
49
|
+
"apps/onJoin",
|
|
50
|
+
"apps/onClose",
|
|
51
|
+
"apps/onSessionActive",
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* Field separator inside a `PartitionKey`. ASCII vertical bar; not legal
|
|
55
|
+
* in a UUID and not legal in any of our `appId` values, so the encoded
|
|
56
|
+
* key parses unambiguously.
|
|
57
|
+
*/
|
|
58
|
+
const KEY_SEP = "|";
|
|
59
|
+
function isPlainObject(x) {
|
|
60
|
+
return typeof x === "object" && x !== null && !Array.isArray(x);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Decode a partition key from an inbound s2c request. Does NOT mutate the
|
|
64
|
+
* request. On any structural failure, returns
|
|
65
|
+
* `MalformedPartitionKeyError` with a typed `reason` tag the reader fiber
|
|
66
|
+
* uses to pick an error response code.
|
|
67
|
+
*/
|
|
68
|
+
export function extractPartitionKey(request) {
|
|
69
|
+
const { id: requestId, method, params } = request;
|
|
70
|
+
const isLifecycle = LIFECYCLE_METHODS.has(method);
|
|
71
|
+
const isConversationBearing = CONVERSATION_BEARING_METHODS.has(method);
|
|
72
|
+
if (!isLifecycle && !isConversationBearing) {
|
|
73
|
+
return Either.left(new MalformedPartitionKeyError({
|
|
74
|
+
method,
|
|
75
|
+
reason: "unknown-method",
|
|
76
|
+
requestId,
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
if (!isPlainObject(params)) {
|
|
80
|
+
return Either.left(new MalformedPartitionKeyError({
|
|
81
|
+
method,
|
|
82
|
+
reason: "params-shape",
|
|
83
|
+
requestId,
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
const sessionIdRaw = params["sessionId"];
|
|
87
|
+
if (typeof sessionIdRaw !== "string" || sessionIdRaw.length === 0) {
|
|
88
|
+
return Either.left(new MalformedPartitionKeyError({
|
|
89
|
+
method,
|
|
90
|
+
reason: "missing-session-id",
|
|
91
|
+
requestId,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
let conversationId;
|
|
95
|
+
if (isConversationBearing) {
|
|
96
|
+
const convRaw = params["conversationId"];
|
|
97
|
+
if (typeof convRaw !== "string" || convRaw.length === 0) {
|
|
98
|
+
return Either.left(new MalformedPartitionKeyError({
|
|
99
|
+
method,
|
|
100
|
+
reason: "missing-conversation-id",
|
|
101
|
+
requestId,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
conversationId = convRaw;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// Lifecycle: synthesize sentinel. (S, "*lifecycle*", method) keeps
|
|
108
|
+
// distinct lifecycle methods on distinct keys per OQ-1 default.
|
|
109
|
+
conversationId = LIFECYCLE_CONVERSATION_SENTINEL;
|
|
110
|
+
}
|
|
111
|
+
const encoded = `${sessionIdRaw}${KEY_SEP}${conversationId}${KEY_SEP}${method}`;
|
|
112
|
+
return Either.right(encoded);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Inverse projection for tests + observability. Pure deconstruction of a
|
|
116
|
+
* `PartitionKey` back into its three components. Returning a structured
|
|
117
|
+
* record (rather than a tuple) lets callers discriminate on
|
|
118
|
+
* `conversationId === LIFECYCLE_CONVERSATION_SENTINEL`.
|
|
119
|
+
*/
|
|
120
|
+
export function describePartitionKey(key) {
|
|
121
|
+
// Method is the only field that can contain a slash; sessionId is a
|
|
122
|
+
// UUID and conversationId is a UUID or the lifecycle sentinel. Split
|
|
123
|
+
// on the first two `KEY_SEP` occurrences only so a hypothetical
|
|
124
|
+
// future method name with `KEY_SEP` (impossible today; ASCII `|`)
|
|
125
|
+
// would still round-trip cleanly.
|
|
126
|
+
const first = key.indexOf(KEY_SEP);
|
|
127
|
+
const second = first === -1 ? -1 : key.indexOf(KEY_SEP, first + 1);
|
|
128
|
+
if (first === -1 || second === -1) {
|
|
129
|
+
// `extractPartitionKey` is the only constructor; an unparsable key
|
|
130
|
+
// means a caller bypassed branding. This is a programmer error,
|
|
131
|
+
// not a runtime user-input failure — fail loudly.
|
|
132
|
+
throw new Error(`describePartitionKey: malformed PartitionKey: ${key}`);
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
sessionId: key.slice(0, first),
|
|
136
|
+
conversationId: key.slice(first + 1, second),
|
|
137
|
+
method: key.slice(second + 1),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=s2c-partition-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-partition-key.js","sourceRoot":"","sources":["../../src/internal/s2c-partition-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,0BAA0B,EAAE,MAAM,4BAA4B,CAAC;AAQxE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,aAAsB,CAAC;AAEtE;;;;;;;GAOG;AACH,MAAM,4BAA4B,GAAG,IAAI,GAAG,CAAS;IACnD,uBAAuB;IACvB,8BAA8B;CAC/B,CAAC,CAAC;AAEH,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAS;IACxC,aAAa;IACb,cAAc;IACd,sBAAsB;CACvB,CAAC,CAAC;AAEH;;;;GAIG;AACH,MAAM,OAAO,GAAG,GAAG,CAAC;AAYpB,SAAS,aAAa,CAAC,CAAU;IAC/B,OAAO,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AAClE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,OAA6B;IAE7B,MAAM,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAElD,MAAM,WAAW,GAAG,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IAClD,MAAM,qBAAqB,GAAG,4BAA4B,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACvE,IAAI,CAAC,WAAW,IAAI,CAAC,qBAAqB,EAAE,CAAC;QAC3C,OAAO,MAAM,CAAC,IAAI,CAChB,IAAI,0BAA0B,CAAC;YAC7B,MAAM;YACN,MAAM,EAAE,gBAAgB;YACxB,SAAS;SACV,CAAC,CACH,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;QAC3B,OAAO,MAAM,CAAC,IAAI,CAChB,IAAI,0BAA0B,CAAC;YAC7B,MAAM;YACN,MAAM,EAAE,cAAc;YACtB,SAAS;SACV,CAAC,CACH,CAAC;IACJ,CAAC;IAED,MAAM,YAAY,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC;IACzC,IAAI,OAAO,YAAY,KAAK,QAAQ,IAAI,YAAY,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClE,OAAO,MAAM,CAAC,IAAI,CAChB,IAAI,0BAA0B,CAAC;YAC7B,MAAM;YACN,MAAM,EAAE,oBAAoB;YAC5B,SAAS;SACV,CAAC,CACH,CAAC;IACJ,CAAC;IAED,IAAI,cAAsB,CAAC;IAC3B,IAAI,qBAAqB,EAAE,CAAC;QAC1B,MAAM,OAAO,GAAG,MAAM,CAAC,gBAAgB,CAAC,CAAC;QACzC,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxD,OAAO,MAAM,CAAC,IAAI,CAChB,IAAI,0BAA0B,CAAC;gBAC7B,MAAM;gBACN,MAAM,EAAE,yBAAyB;gBACjC,SAAS;aACV,CAAC,CACH,CAAC;QACJ,CAAC;QACD,cAAc,GAAG,OAAO,CAAC;IAC3B,CAAC;SAAM,CAAC;QACN,mEAAmE;QACnE,gEAAgE;QAChE,cAAc,GAAG,+BAA+B,CAAC;IACnD,CAAC;IAED,MAAM,OAAO,GACX,GAAG,YAAY,GAAG,OAAO,GAAG,cAAc,GAAG,OAAO,GAAG,MAAM,EAAkB,CAAC;IAClF,OAAO,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;AAC/B,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAiB;IAKpD,oEAAoE;IACpE,qEAAqE;IACrE,gEAAgE;IAChE,kEAAkE;IAClE,kCAAkC;IAClC,MAAM,KAAK,GAAG,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,MAAM,MAAM,GAAG,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,OAAO,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;IACnE,IAAI,KAAK,KAAK,CAAC,CAAC,IAAI,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;QAClC,mEAAmE;QACnE,gEAAgE;QAChE,kDAAkD;QAClD,MAAM,IAAI,KAAK,CAAC,iDAAiD,GAAG,EAAE,CAAC,CAAC;IAC1E,CAAC;IACD,OAAO;QACL,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,CAAC;QAC9B,cAAc,EAAE,GAAG,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,EAAE,MAAM,CAAC;QAC5C,MAAM,EAAE,GAAG,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;KAC9B,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-partition worker — bounded queue + forked fiber, owning ordering
|
|
3
|
+
* within one `(sessionId, conversationId, hookKind)` tuple.
|
|
4
|
+
*
|
|
5
|
+
* Spec: moltzap#356.
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle is anchored in a per-worker `Scope.CloseableScope` provided
|
|
8
|
+
* by the dispatcher (`s2c-partitioned-dispatcher.ts`'s
|
|
9
|
+
* `getOrCreatePartitionWorker` calls `Scope.fork(dispatcherScope, …)`
|
|
10
|
+
* for each new worker and hands the child scope in via the config).
|
|
11
|
+
* Closing the worker's scope shuts the queue, runs registered
|
|
12
|
+
* finalizers, and interrupts the drain fiber — cascading finalization
|
|
13
|
+
* with no orphan references on the dispatcher scope. Closing the
|
|
14
|
+
* dispatcher scope still cascades to every worker via the parent-child
|
|
15
|
+
* scope link from `Scope.fork`.
|
|
16
|
+
*
|
|
17
|
+
* Backpressure: the queue is `Queue.bounded(capacity)`. `offer` is
|
|
18
|
+
* **non-blocking** — if the queue is full it fails with
|
|
19
|
+
* `PartitionQueueFullError` rather than suspending the caller. The
|
|
20
|
+
* reader fiber must NOT block; per-partition fullness is surfaced as
|
|
21
|
+
* a typed error and translated to a wire-level error response.
|
|
22
|
+
*
|
|
23
|
+
* Single-producer guarantee: only the dispatcher's reader-fiber path
|
|
24
|
+
* calls `offer` for any given partition. That makes the
|
|
25
|
+
* `Queue.size + offer` pre-check race-free for "full" detection: if
|
|
26
|
+
* size < capacity at the check, the subsequent `offer` cannot suspend
|
|
27
|
+
* because no other producer can fill the queue between check and
|
|
28
|
+
* offer. The drain fiber only consumes; it cannot push us past
|
|
29
|
+
* capacity.
|
|
30
|
+
*
|
|
31
|
+
* Why `idleSince` is a `MutableRef` (synchronous cell) rather than a
|
|
32
|
+
* `Ref`: the dispatcher's reaper inspects `idleSince._tag === "Idle"`
|
|
33
|
+
* inside a `Ref.modify` lambda over the partition map (synchronous
|
|
34
|
+
* lambda body — no `yield`). A `Ref.get` would require an Effect step
|
|
35
|
+
* we cannot take inside the lambda; `MutableRef.get` is synchronous.
|
|
36
|
+
* Producer (offer) and drain-loop sites use `MutableRef.set`. JS's
|
|
37
|
+
* single-threaded execution model makes the `Idle ↔ Busy` transitions
|
|
38
|
+
* race-free between fibers, and the dispatcher's atomic
|
|
39
|
+
* `Ref.modify(partitionsRef)` is the synchronization point that
|
|
40
|
+
* ensures snapshot+remove on idle partitions runs without an offer
|
|
41
|
+
* landing in between.
|
|
42
|
+
*/
|
|
43
|
+
import { Effect, Fiber, MutableRef, Scope } from "effect";
|
|
44
|
+
import { PartitionQueueFullError } from "./s2c-dispatcher-errors.js";
|
|
45
|
+
import type { PartitionKey, PartitionableRequest } from "./s2c-partition-key.js";
|
|
46
|
+
import type { WsClientLogger } from "../ws-client.js";
|
|
47
|
+
/**
|
|
48
|
+
* The handler the worker drains its queue against. Mirrors the existing
|
|
49
|
+
* `dispatchInboundServerRequest` signature in `ws-client.ts:912-965` —
|
|
50
|
+
* the dispatcher passes an already-bound function so the worker has no
|
|
51
|
+
* direct knowledge of the registered s2c handler registry.
|
|
52
|
+
*
|
|
53
|
+
* Defects inside `handle` are caught and logged by the worker; they
|
|
54
|
+
* never escape into the per-connection scope's failure channel.
|
|
55
|
+
*/
|
|
56
|
+
export type PartitionHandler = (request: PartitionableRequest) => Effect.Effect<void, never>;
|
|
57
|
+
/**
|
|
58
|
+
* Idle / busy / retiring sum-type tracked per worker. See
|
|
59
|
+
* `PartitionWorker.idleSince`.
|
|
60
|
+
*
|
|
61
|
+
* - `Idle` — drain queue empty, last handler returned at `sinceMs`.
|
|
62
|
+
* Eligible for reap once `sinceMs` ages past `idlePartitionTtlMs`.
|
|
63
|
+
* - `Busy` — handler running OR producer has reserved this worker
|
|
64
|
+
* for a pending `Queue.offer`. Reaper skips Busy workers.
|
|
65
|
+
* - `Retiring` — reaper has claimed this worker for teardown
|
|
66
|
+
* (transition done atomically inside the reaper's `Ref.modify`
|
|
67
|
+
* lambda over the partition map). Producer's `worker.offer` MUST
|
|
68
|
+
* refuse to enqueue when it observes `Retiring`; otherwise the
|
|
69
|
+
* reaper's `Scope.close` would silently drop the just-enqueued
|
|
70
|
+
* request, leaving the server-side `Deferred.await` to time out.
|
|
71
|
+
*/
|
|
72
|
+
export type IdleSince = {
|
|
73
|
+
readonly _tag: "Idle";
|
|
74
|
+
readonly sinceMs: number;
|
|
75
|
+
} | {
|
|
76
|
+
readonly _tag: "Busy";
|
|
77
|
+
} | {
|
|
78
|
+
readonly _tag: "Retiring";
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* One partition's runtime state. Constructed by `makePartitionWorker`;
|
|
82
|
+
* owned by the dispatcher's partition map.
|
|
83
|
+
*/
|
|
84
|
+
export interface PartitionWorker {
|
|
85
|
+
/** Identity. Read-only after construction. */
|
|
86
|
+
readonly key: PartitionKey;
|
|
87
|
+
/**
|
|
88
|
+
* Non-blocking offer. Fails with `PartitionQueueFullError` if the
|
|
89
|
+
* bounded queue is at capacity; never suspends. Idempotent under
|
|
90
|
+
* shutdown — post-shutdown offers fail with the same tag (queue is
|
|
91
|
+
* shut down, equivalent to "no capacity").
|
|
92
|
+
*/
|
|
93
|
+
readonly offer: (request: PartitionableRequest) => Effect.Effect<void, PartitionQueueFullError>;
|
|
94
|
+
/**
|
|
95
|
+
* Worker fiber draining the queue via `Stream.runForEach(handle)`.
|
|
96
|
+
* Surfaced for observability + tests; the worker's `Scope.close` is
|
|
97
|
+
* the canonical teardown path (it triggers the queue.shutdown
|
|
98
|
+
* finalizer and interrupts the fiber via the scope cascade).
|
|
99
|
+
*/
|
|
100
|
+
readonly fiber: Fiber.RuntimeFiber<void, never>;
|
|
101
|
+
/**
|
|
102
|
+
* Idle-since tracker. `Busy` while the worker has a request queued
|
|
103
|
+
* or a handler running; `Idle{sinceMs}` once the queue drains and
|
|
104
|
+
* the handler returns. Stored in a `MutableRef` so the dispatcher's
|
|
105
|
+
* reaper can read it synchronously inside its `Ref.modify` lambda
|
|
106
|
+
* over the partition map (the atomic check that prevents a reaped
|
|
107
|
+
* worker from receiving a concurrent offer between snapshot and
|
|
108
|
+
* removal).
|
|
109
|
+
*/
|
|
110
|
+
readonly idleSince: MutableRef.MutableRef<IdleSince>;
|
|
111
|
+
/**
|
|
112
|
+
* Live queue size for `DispatcherStats`. Reaper does not consult
|
|
113
|
+
* this — it gates strictly on `idleSince._tag === "Idle"` to avoid
|
|
114
|
+
* races where the queue snapshot misses an in-flight item.
|
|
115
|
+
*/
|
|
116
|
+
readonly queueSize: Effect.Effect<number>;
|
|
117
|
+
/**
|
|
118
|
+
* The closeable scope owning this worker's finalizers (queue
|
|
119
|
+
* shutdown, drain fiber). Forked from the dispatcher's scope by
|
|
120
|
+
* `getOrCreatePartitionWorker`; closing it cleanly retires the
|
|
121
|
+
* worker without orphaning finalizers on the parent dispatcher
|
|
122
|
+
* scope.
|
|
123
|
+
*/
|
|
124
|
+
readonly scope: Scope.CloseableScope;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Construction parameters for one worker. Capacity and the handler are
|
|
128
|
+
* dispatcher-level config; `scope` is the per-worker closeable scope
|
|
129
|
+
* the dispatcher allocated via `Scope.fork`; logger is optional and
|
|
130
|
+
* matches the existing `WsClientLogger` shape used elsewhere in the
|
|
131
|
+
* client package.
|
|
132
|
+
*/
|
|
133
|
+
export interface PartitionWorkerConfig {
|
|
134
|
+
readonly key: PartitionKey;
|
|
135
|
+
readonly capacity: number;
|
|
136
|
+
readonly handle: PartitionHandler;
|
|
137
|
+
/**
|
|
138
|
+
* Per-worker scope owning every finalizer this worker registers
|
|
139
|
+
* (queue shutdown + drain fiber). The dispatcher's reaper closes
|
|
140
|
+
* this scope to retire the worker. Closing the parent dispatcher
|
|
141
|
+
* scope cascades through it via `Scope.fork`.
|
|
142
|
+
*/
|
|
143
|
+
readonly scope: Scope.CloseableScope;
|
|
144
|
+
readonly logger?: WsClientLogger;
|
|
145
|
+
/** Monotonic clock for tests; defaults to `Date.now`. */
|
|
146
|
+
readonly clock?: () => number;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Build one worker. Allocates a bounded queue, forks a draining fiber
|
|
150
|
+
* via `Stream.fromQueue` + `Stream.runForEach`, and registers the
|
|
151
|
+
* scope finalizer that shuts the queue. Defects in `handle` are
|
|
152
|
+
* caught + logged; the fiber never fails.
|
|
153
|
+
*
|
|
154
|
+
* Returns `Effect<PartitionWorker, never>` — no `Scope.Scope`
|
|
155
|
+
* requirement. All scoped allocations (queue finalizer, forked drain
|
|
156
|
+
* fiber) are bound to the caller-provided `config.scope` via
|
|
157
|
+
* `Scope.extend`.
|
|
158
|
+
*/
|
|
159
|
+
export declare function makePartitionWorker(config: PartitionWorkerConfig): Effect.Effect<PartitionWorker, never>;
|
|
160
|
+
//# sourceMappingURL=s2c-partition-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-partition-worker.d.ts","sourceRoot":"","sources":["../../src/internal/s2c-partition-worker.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,OAAO,EAAS,MAAM,EAAE,KAAK,EAAE,UAAU,EAAS,KAAK,EAAU,MAAM,QAAQ,CAAC;AAChF,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AACrE,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EACrB,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,GAAG,CAC7B,OAAO,EAAE,oBAAoB,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAEhC;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,SAAS,GACjB;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;CAAE,CAAC;AAElC;;;GAGG;AACH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;IAC3B;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,CACd,OAAO,EAAE,oBAAoB,KAC1B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,uBAAuB,CAAC,CAAC;IAClD;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAChD;;;;;;;;OAQG;IACH,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;IACrD;;;;OAIG;IACH,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC1C;;;;;;OAMG;IACH,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC;CACtC;AAED;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,QAAQ,CAAC,KAAK,EAAE,KAAK,CAAC,cAAc,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,cAAc,CAAC;IACjC,yDAAyD;IACzD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CAC/B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,qBAAqB,GAC5B,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,KAAK,CAAC,CAmIvC"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-partition worker — bounded queue + forked fiber, owning ordering
|
|
3
|
+
* within one `(sessionId, conversationId, hookKind)` tuple.
|
|
4
|
+
*
|
|
5
|
+
* Spec: moltzap#356.
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle is anchored in a per-worker `Scope.CloseableScope` provided
|
|
8
|
+
* by the dispatcher (`s2c-partitioned-dispatcher.ts`'s
|
|
9
|
+
* `getOrCreatePartitionWorker` calls `Scope.fork(dispatcherScope, …)`
|
|
10
|
+
* for each new worker and hands the child scope in via the config).
|
|
11
|
+
* Closing the worker's scope shuts the queue, runs registered
|
|
12
|
+
* finalizers, and interrupts the drain fiber — cascading finalization
|
|
13
|
+
* with no orphan references on the dispatcher scope. Closing the
|
|
14
|
+
* dispatcher scope still cascades to every worker via the parent-child
|
|
15
|
+
* scope link from `Scope.fork`.
|
|
16
|
+
*
|
|
17
|
+
* Backpressure: the queue is `Queue.bounded(capacity)`. `offer` is
|
|
18
|
+
* **non-blocking** — if the queue is full it fails with
|
|
19
|
+
* `PartitionQueueFullError` rather than suspending the caller. The
|
|
20
|
+
* reader fiber must NOT block; per-partition fullness is surfaced as
|
|
21
|
+
* a typed error and translated to a wire-level error response.
|
|
22
|
+
*
|
|
23
|
+
* Single-producer guarantee: only the dispatcher's reader-fiber path
|
|
24
|
+
* calls `offer` for any given partition. That makes the
|
|
25
|
+
* `Queue.size + offer` pre-check race-free for "full" detection: if
|
|
26
|
+
* size < capacity at the check, the subsequent `offer` cannot suspend
|
|
27
|
+
* because no other producer can fill the queue between check and
|
|
28
|
+
* offer. The drain fiber only consumes; it cannot push us past
|
|
29
|
+
* capacity.
|
|
30
|
+
*
|
|
31
|
+
* Why `idleSince` is a `MutableRef` (synchronous cell) rather than a
|
|
32
|
+
* `Ref`: the dispatcher's reaper inspects `idleSince._tag === "Idle"`
|
|
33
|
+
* inside a `Ref.modify` lambda over the partition map (synchronous
|
|
34
|
+
* lambda body — no `yield`). A `Ref.get` would require an Effect step
|
|
35
|
+
* we cannot take inside the lambda; `MutableRef.get` is synchronous.
|
|
36
|
+
* Producer (offer) and drain-loop sites use `MutableRef.set`. JS's
|
|
37
|
+
* single-threaded execution model makes the `Idle ↔ Busy` transitions
|
|
38
|
+
* race-free between fibers, and the dispatcher's atomic
|
|
39
|
+
* `Ref.modify(partitionsRef)` is the synchronization point that
|
|
40
|
+
* ensures snapshot+remove on idle partitions runs without an offer
|
|
41
|
+
* landing in between.
|
|
42
|
+
*/
|
|
43
|
+
import { Cause, Effect, Fiber, MutableRef, Queue, Scope, Stream } from "effect";
|
|
44
|
+
import { PartitionQueueFullError } from "./s2c-dispatcher-errors.js";
|
|
45
|
+
/**
|
|
46
|
+
* Build one worker. Allocates a bounded queue, forks a draining fiber
|
|
47
|
+
* via `Stream.fromQueue` + `Stream.runForEach`, and registers the
|
|
48
|
+
* scope finalizer that shuts the queue. Defects in `handle` are
|
|
49
|
+
* caught + logged; the fiber never fails.
|
|
50
|
+
*
|
|
51
|
+
* Returns `Effect<PartitionWorker, never>` — no `Scope.Scope`
|
|
52
|
+
* requirement. All scoped allocations (queue finalizer, forked drain
|
|
53
|
+
* fiber) are bound to the caller-provided `config.scope` via
|
|
54
|
+
* `Scope.extend`.
|
|
55
|
+
*/
|
|
56
|
+
export function makePartitionWorker(config) {
|
|
57
|
+
const clock = config.clock ?? Date.now;
|
|
58
|
+
return Scope.extend(Effect.gen(function* () {
|
|
59
|
+
const queue = yield* Queue.bounded(config.capacity);
|
|
60
|
+
const idleSince = MutableRef.make({
|
|
61
|
+
_tag: "Idle",
|
|
62
|
+
sinceMs: clock(),
|
|
63
|
+
});
|
|
64
|
+
// Drain loop: take → mark Busy → run handler (catching defects)
|
|
65
|
+
// → mark Idle if queue is now empty. `Stream.fromQueue` ends
|
|
66
|
+
// naturally when the queue is shut down (Scope finalizer below),
|
|
67
|
+
// so the fiber returns cleanly without an explicit interrupt for
|
|
68
|
+
// the happy path.
|
|
69
|
+
const drainEffect = Stream.fromQueue(queue).pipe(Stream.runForEach((request) => Effect.gen(function* () {
|
|
70
|
+
MutableRef.set(idleSince, { _tag: "Busy" });
|
|
71
|
+
yield* config.handle(request).pipe(Effect.catchAllCause((cause) => Effect.sync(() => {
|
|
72
|
+
config.logger?.warn(`s2c partition worker handler defected (key=${config.key})`, Cause.pretty(cause));
|
|
73
|
+
})));
|
|
74
|
+
const remaining = yield* Queue.size(queue).pipe(Effect.catchAllCause(() => Effect.succeed(0)));
|
|
75
|
+
if (remaining === 0) {
|
|
76
|
+
MutableRef.set(idleSince, {
|
|
77
|
+
_tag: "Idle",
|
|
78
|
+
sinceMs: clock(),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
})), Effect.catchAllCause((cause) => Effect.sync(() => {
|
|
82
|
+
config.logger?.warn(`s2c partition worker fiber exited (key=${config.key})`, Cause.pretty(cause));
|
|
83
|
+
})), Effect.asVoid);
|
|
84
|
+
const fiber = yield* Effect.forkScoped(drainEffect);
|
|
85
|
+
// Shut the queue when the worker scope closes. `Effect.forkScoped`
|
|
86
|
+
// already covers fiber interruption; queue.shutdown is the
|
|
87
|
+
// belt-and-braces guarantee that the running stream observes the
|
|
88
|
+
// shutdown and returns even if the fiber is mid-handler.
|
|
89
|
+
yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.catchAllCause(() => Effect.void)));
|
|
90
|
+
const offer = (request) => Effect.gen(function* () {
|
|
91
|
+
const size = yield* Queue.size(queue).pipe(
|
|
92
|
+
// `Queue.size` on a shut-down queue surfaces interrupt; treat
|
|
93
|
+
// every failure as "queue unavailable" → full-error tag.
|
|
94
|
+
Effect.catchAllCause(() => Effect.succeed(config.capacity)));
|
|
95
|
+
if (size >= config.capacity) {
|
|
96
|
+
return yield* Effect.fail(new PartitionQueueFullError({
|
|
97
|
+
key: config.key,
|
|
98
|
+
capacity: config.capacity,
|
|
99
|
+
requestId: request.id,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
// Atomic CAS: transition Idle/Busy → Busy, but refuse if the
|
|
103
|
+
// dispatcher's reaper has already claimed this worker for
|
|
104
|
+
// teardown (`Retiring`). The reaper sets `Retiring`
|
|
105
|
+
// synchronously inside its `Ref.modify(partitionsRef, …)`
|
|
106
|
+
// lambda, so once we observe `Retiring` here, the worker has
|
|
107
|
+
// already been removed from the partition map and its scope
|
|
108
|
+
// is being closed. Failing without enqueueing is the correct
|
|
109
|
+
// and only safe move — a queued item would be silently
|
|
110
|
+
// dropped when `Scope.close` fires `Queue.shutdown`.
|
|
111
|
+
const claim = MutableRef.updateAndGet(idleSince, (current) => current._tag === "Retiring" ? current : { _tag: "Busy" });
|
|
112
|
+
if (claim._tag === "Retiring") {
|
|
113
|
+
return yield* Effect.fail(new PartitionQueueFullError({
|
|
114
|
+
key: config.key,
|
|
115
|
+
capacity: config.capacity,
|
|
116
|
+
requestId: request.id,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
const accepted = yield* Queue.offer(queue, request).pipe(Effect.catchAllCause(() => Effect.succeed(false)));
|
|
120
|
+
if (!accepted) {
|
|
121
|
+
// Queue shut down between size-check and offer — surface as
|
|
122
|
+
// `PartitionQueueFullError` so the reader's tag-discrimination
|
|
123
|
+
// path treats it identically to "queue full".
|
|
124
|
+
return yield* Effect.fail(new PartitionQueueFullError({
|
|
125
|
+
key: config.key,
|
|
126
|
+
capacity: config.capacity,
|
|
127
|
+
requestId: request.id,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
key: config.key,
|
|
133
|
+
offer,
|
|
134
|
+
fiber,
|
|
135
|
+
idleSince,
|
|
136
|
+
queueSize: Queue.size(queue).pipe(Effect.catchAllCause(() => Effect.succeed(0))),
|
|
137
|
+
scope: config.scope,
|
|
138
|
+
};
|
|
139
|
+
}), config.scope);
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=s2c-partition-worker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"s2c-partition-worker.js","sourceRoot":"","sources":["../../src/internal/s2c-partition-worker.ts"],"names":[],"mappings":"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"}
|