Harden gateway dispatcher effects

This commit is contained in:
elpresidank 2026-06-02 05:14:58 -05:00
parent fe4f5777c9
commit 89ef3dbbbf
4 changed files with 164 additions and 62 deletions

View file

@ -12,8 +12,8 @@ Verified source roots:
- Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4` - Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4`
- Installed Effect beta used by this workspace: `ts/node_modules/effect` - Installed Effect beta used by this workspace: `ts/node_modules/effect`
Current signal counts from `ts/packages` after the 2026-06-02 text completion Current signal counts from `ts/packages` after the 2026-06-02 gateway dispatcher
provider effectful layer slice: ownership and serialization slice:
| Signal | Count | | Signal | Count |
| --- | ---: | | --- | ---: |
@ -157,6 +157,11 @@ Notes:
`makeTextCompletionLayer(makeXProviderEffect(config))`. SDK construction and `makeTextCompletionLayer(makeXProviderEffect(config))`. SDK construction and
config lookup now live in Effect; sync `makeXProvider` exports remain config lookup now live in Effect; sync `makeXProvider` exports remain
compatibility facades. compatibility facades.
- The gateway dispatcher ownership and serialization slice did not change broad
signal counts. It stopped closing injected pubsub backends, brackets
one-shot publish producers with `Effect.acquireUseRelease`, and routes
gateway request/response translation through `Effect.try` wrappers returning
tagged `DispatchSerializationError` failures.
- `Record<string, any>` and `throwLibrarianServiceError` are now clean in - `Record<string, any>` and `throwLibrarianServiceError` are now clean in
`ts/packages`. `ts/packages`.
@ -1098,6 +1103,31 @@ Notes:
- `cd ts && bun run test` - `cd ts && bun run test`
- `git diff --check` - `git diff --check`
### 2026-06-02: Gateway Dispatcher Ownership And Serialization Slice
- Status: migrated and root-verified.
- Completed:
- `makeDispatcherManager` now tracks whether it owns the pubsub backend and
no longer closes injected `PubSubBackend` instances on `stop()`.
- `publishToTopic` now uses `Effect.acquireUseRelease` so the one-shot
producer is closed even when `send` fails.
- Gateway dispatch paths now call `translateRequestEffect` and
`translateResponseEffect`, which wrap serialization with `Effect.try` and
return tagged `DispatchSerializationError` failures.
- Streaming dispatch recipients are named `Effect.fn` callbacks, satisfying
strict Effect diagnostics while preserving responder behavior.
- Tests cover injected backend ownership, typed serialization failure before
requestor startup, and producer close on send failure.
- Verification:
- `bunx --bun vitest run src/__tests__/gateway-dispatcher.test.ts`
- `bun run --cwd ts/packages/flow build`
- `bun run --cwd ts/packages/flow test`
- `cd ts && bun run check:tsgo`
- `cd ts && bun run check`
- `cd ts && bun run build`
- `cd ts && bun run test`
- `git diff --check`
## Subagent Findings To Preserve ## Subagent Findings To Preserve
- MCP/workbench: - MCP/workbench:
@ -1140,6 +1170,10 @@ Notes:
callbacks instead of nested `Effect.runPromiseWith`, and client streaming callbacks instead of nested `Effect.runPromiseWith`, and client streaming
facade callbacks now decode the legacy envelope through Schema before facade callbacks now decode the legacy envelope through Schema before
applying service-specific public callback semantics. applying service-specific public callback semantics.
- Gateway dispatcher ownership and serialization cleanup is complete:
injected pubsub backends are not closed by the manager, one-shot producers
are acquire/use/release bracketed, and serialization failures are typed
Effect errors.
- Do not make `gateway/rpc-protocol.ts` the next cleanup target: it is a - Do not make `gateway/rpc-protocol.ts` the next cleanup target: it is a
Fastify socket compatibility bridge while the public Effect RPC server Fastify socket compatibility bridge while the public Effect RPC server
layers require SocketServer or Effect HTTP routing. layers require SocketServer or Effect HTTP routing.
@ -1192,28 +1226,6 @@ Notes:
- Fake backend ack/nak/backoff/stop tests, NATS close finalizer tests, and - Fake backend ack/nak/backoff/stop tests, NATS close finalizer tests, and
config-push stream tests. config-push stream tests.
### P1: Gateway Dispatcher Ownership And Serialization
- TrustGraph evidence:
- `ts/packages/flow/src/gateway/dispatch/manager.ts`
- `ts/packages/flow/src/gateway/dispatch/serialize.ts`
- `ts/packages/flow/src/gateway/server.ts`
- Effect primitives:
- `Layer`, `Scope`, `Effect.acquireUseRelease`, `Effect.try`, `Result.try`,
and typed dispatch errors.
- Rewrite shape:
- Track whether the dispatcher owns `PubSubBackend` so injected backends are
not closed.
- Use `Effect.acquireUseRelease` for one-shot gateway producers so producer
close runs even when send fails.
- Replace throwing gateway serialization helpers with Effect/Result-returning
helpers mapped to typed dispatch or wire errors.
- Longer term, move `createGateway` to a scoped `createGatewayEffect` while
keeping Fastify route `Effect.runPromise` calls as host boundaries.
- Tests:
- Injected pubsub is not closed, one-shot producer closes on send failure,
and malformed gateway payloads return typed dispatch errors.
### P2: Effect AI Provider Adapter Cleanup ### P2: Effect AI Provider Adapter Cleanup
- TrustGraph evidence: - TrustGraph evidence:
@ -1265,10 +1277,9 @@ Notes:
## Recommended PR Order ## Recommended PR Order
1. Gateway dispatcher ownership and serialization. 1. Broker backend Effect-native runtime.
2. Broker backend Effect-native runtime. 2. Effect AI provider adapter cleanup.
3. Effect AI provider adapter cleanup. 3. MCP parity/deletion decision and workbench platform polish.
4. MCP parity/deletion decision and workbench platform polish.
## No-Op Rules ## No-Op Rules

View file

@ -96,6 +96,7 @@ class DispatchBackend implements PubSubBackend {
readonly consumerOptions: CreateConsumerOptions[] = []; readonly consumerOptions: CreateConsumerOptions[] = [];
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>(); readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
readonly consumersByTopic = new Map<string, TopicConsumer<unknown>>(); readonly consumersByTopic = new Map<string, TopicConsumer<unknown>>();
readonly failSendTopics = new Set<string>();
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> { async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions.push(options); this.producerOptions.push(options);
@ -124,6 +125,10 @@ class DispatchBackend implements PubSubBackend {
} }
private handleSend(topic: string, message: unknown, properties?: Record<string, string>): void { private handleSend(topic: string, message: unknown, properties?: Record<string, string>): void {
if (this.failSendTopics.has(topic)) {
throw "send failed";
}
const id = properties?.id ?? ""; const id = properties?.id ?? "";
if (topic === "tg.flow.config-request") { if (topic === "tg.flow.config-request") {
this.push("tg.flow.config-response", { ok: true, echo: message }, id); this.push("tg.flow.config-response", { ok: true, echo: message }, id);
@ -167,7 +172,49 @@ describe("gateway dispatcher manager", () => {
expect(backend.consumerOptions.filter((options) => options.topic === "tg.flow.config-response")).toHaveLength(1); expect(backend.consumerOptions.filter((options) => options.topic === "tg.flow.config-response")).toHaveLength(1);
expect(backend.producersByTopic.get("tg.flow.config-request")?.closeCount).toBe(1); expect(backend.producersByTopic.get("tg.flow.config-request")?.closeCount).toBe(1);
expect(backend.consumersByTopic.get("tg.flow.config-response")?.closeCount).toBe(1); expect(backend.consumersByTopic.get("tg.flow.config-response")?.closeCount).toBe(1);
expect(backend.closeCount).toBe(1); expect(backend.closeCount).toBe(0);
});
it("does not start requestors when request serialization fails", async () => {
const backend = new DispatchBackend();
const manager = makeDispatcherManager({
port: 0,
metricsPort: 0,
pubsub: backend,
});
await expect(
manager.dispatchGlobalService("knowledge", { term: { t: "t" } }),
).rejects.toMatchObject({
_tag: "DispatchSerializationError",
operation: "client-term-to-internal",
});
await manager.stop();
expect(backend.producerOptions).toHaveLength(0);
expect(backend.consumerOptions).toHaveLength(0);
expect(backend.closeCount).toBe(0);
});
it("closes one-shot publish producers when send fails", async () => {
const backend = new DispatchBackend();
backend.failSendTopics.add("tg.flow.ingest");
const manager = makeDispatcherManager({
port: 0,
metricsPort: 0,
pubsub: backend,
});
await expect(
manager.publishToTopic("tg.flow.ingest", { text: "hello" }, "msg-1"),
).rejects.toMatchObject({
_tag: "MessagingDeliveryError",
operation: "send",
});
await manager.stop();
expect(backend.producersByTopic.get("tg.flow.ingest")?.closeCount).toBe(1);
expect(backend.closeCount).toBe(0);
}); });
it("streams responses until the centralized completion predicate is true", async () => { it("streams responses until the centralized completion predicate is true", async () => {

View file

@ -25,7 +25,11 @@ import {
type RequestResponseFactoryService, type RequestResponseFactoryService,
} from "@trustgraph/base"; } from "@trustgraph/base";
import type { GatewayConfig } from "../server.js"; import type { GatewayConfig } from "../server.js";
import { translateRequest, translateResponse } from "./serialize.js"; import {
translateRequestEffect,
translateResponseEffect,
type DispatchSerializationError,
} from "./serialize.js";
export type Responder = (response: unknown, complete: boolean) => Promise<void>; export type Responder = (response: unknown, complete: boolean) => Promise<void>;
export type EffectResponder<E = never, R = never> = ( export type EffectResponder<E = never, R = never> = (
@ -37,6 +41,7 @@ export type DispatcherStreamError<E = never> =
| MessagingLifecycleError | MessagingLifecycleError
| MessagingDeliveryError | MessagingDeliveryError
| MessagingTimeoutError | MessagingTimeoutError
| DispatchSerializationError
| E; | E;
// ---------- Service registry ---------- // ---------- Service registry ----------
@ -169,6 +174,7 @@ interface DispatcherRuntime {
export function makeDispatcherManager(config: GatewayConfig): DispatcherManager { export function makeDispatcherManager(config: GatewayConfig): DispatcherManager {
const pubsub: PubSubBackend = config.pubsub ?? makeNatsBackend(config.natsUrl ?? "nats://localhost:4222"); const pubsub: PubSubBackend = config.pubsub ?? makeNatsBackend(config.natsUrl ?? "nats://localhost:4222");
const ownsPubSub = config.pubsub === undefined;
let runtime: DispatcherRuntime | null = null; let runtime: DispatcherRuntime | null = null;
const startEffect = Effect.fn("DispatcherManager.start")(function* () { const startEffect = Effect.fn("DispatcherManager.start")(function* () {
@ -207,10 +213,12 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
yield* Scope.close(current.scope, Exit.void); yield* Scope.close(current.scope, Exit.void);
} }
yield* Effect.tryPromise({ if (ownsPubSub) {
try: () => pubsub.close(), yield* Effect.tryPromise({
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause), try: () => pubsub.close(),
}); catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause),
});
}
}); });
const stop = (): Promise<void> => Effect.runPromise(stopEffect()); const stop = (): Promise<void> => Effect.runPromise(stopEffect());
@ -306,11 +314,11 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
request: Record<string, unknown>, request: Record<string, unknown>,
) { ) {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind); const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const translated = yield* translateRequestEffect(kind, request);
const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`); const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
const response = yield* rr.request(translated); const response = yield* rr.request(translated);
return translateResponse(kind, response); return yield* translateResponseEffect(kind, response);
}); });
const dispatchGlobalServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* < const dispatchGlobalServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* <
@ -322,15 +330,15 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
responder: EffectResponder<E, R>, responder: EffectResponder<E, R>,
) { ) {
const { requestTopic, responseTopic } = resolveGlobalTopics(kind); const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
const translated = yield* translateRequestEffect(kind, request);
const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`); const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`);
const translated = translateRequest(kind, request);
yield* rr.request(translated, { yield* rr.request(translated, {
recipient: (response) => { recipient: Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming.recipient")(function* (response) {
const translatedRes = translateResponse(kind, response); const translatedRes = yield* translateResponseEffect(kind, response);
const complete = dispatcherManagerIsCompleteResponse(translatedRes); const complete = dispatcherManagerIsCompleteResponse(translatedRes);
return responder(translatedRes, complete).pipe(Effect.as(complete)); return yield* responder(translatedRes, complete).pipe(Effect.as(complete));
}, }),
}); });
}); });
@ -367,15 +375,15 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
request: Record<string, unknown>, request: Record<string, unknown>,
) { ) {
const { requestTopic, responseTopic } = resolveFlowTopics(kind); const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const translated = yield* translateRequestEffect(kind, request);
const rr = yield* getRequestorEffect( const rr = yield* getRequestorEffect(
requestTopic, requestTopic,
responseTopic, responseTopic,
`flow:${flow}:${kind}`, `flow:${flow}:${kind}`,
); );
const translated = translateRequest(kind, request);
const response = yield* rr.request(translated); const response = yield* rr.request(translated);
return translateResponse(kind, response); return yield* translateResponseEffect(kind, response);
}); });
const dispatchFlowServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* < const dispatchFlowServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* <
@ -388,19 +396,19 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
responder: EffectResponder<E, R>, responder: EffectResponder<E, R>,
) { ) {
const { requestTopic, responseTopic } = resolveFlowTopics(kind); const { requestTopic, responseTopic } = resolveFlowTopics(kind);
const translated = yield* translateRequestEffect(kind, request);
const rr = yield* getRequestorEffect( const rr = yield* getRequestorEffect(
requestTopic, requestTopic,
responseTopic, responseTopic,
`flow:${flow}:${kind}`, `flow:${flow}:${kind}`,
); );
const translated = translateRequest(kind, request);
yield* rr.request(translated, { yield* rr.request(translated, {
recipient: (response) => { recipient: Effect.fn("DispatcherManager.dispatchFlowServiceStreaming.recipient")(function* (response) {
const translatedRes = translateResponse(kind, response); const translatedRes = yield* translateResponseEffect(kind, response);
const complete = dispatcherManagerIsCompleteResponse(translatedRes); const complete = dispatcherManagerIsCompleteResponse(translatedRes);
return responder(translatedRes, complete).pipe(Effect.as(complete)); return yield* responder(translatedRes, complete).pipe(Effect.as(complete));
}, }),
}); });
}); });
@ -431,24 +439,27 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
*/ */
const publishToTopic = (topic: string, message: unknown, id?: string): Promise<void> => const publishToTopic = (topic: string, message: unknown, id?: string): Promise<void> =>
Effect.runPromise( Effect.runPromise(
Effect.gen(function* () { Effect.acquireUseRelease(
const producer = yield* Effect.tryPromise({ Effect.tryPromise({
try: () => pubsub.createProducer<unknown>({ topic }), try: () => pubsub.createProducer<unknown>({ topic }),
catch: (cause) => messagingDeliveryError(topic, "create-producer", cause), catch: (cause) => messagingDeliveryError(topic, "create-producer", cause),
}); }),
const timestamp = yield* Clock.currentTimeMillis; (producer) =>
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true }); Effect.gen(function* () {
const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`; const timestamp = yield* Clock.currentTimeMillis;
const suffix = yield* Random.nextIntBetween(0, 36 ** 6, { halfOpen: true });
const messageId = id ?? `pub-${timestamp}-${suffix.toString(36).padStart(6, "0")}`;
yield* Effect.tryPromise({ yield* Effect.tryPromise({
try: () => producer.send(message, { id: messageId }), try: () => producer.send(message, { id: messageId }),
catch: (cause) => messagingDeliveryError(topic, "send", cause), catch: (cause) => messagingDeliveryError(topic, "send", cause),
}); });
yield* Effect.tryPromise({ }),
(producer) => Effect.tryPromise({
try: () => producer.close(), try: () => producer.close(),
catch: (cause) => messagingDeliveryError(topic, "close-producer", cause), catch: (cause) => messagingDeliveryError(topic, "close-producer", cause),
}); }),
}), ),
); );
return { return {

View file

@ -18,7 +18,8 @@
* Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py * Python reference: trustgraph-base/trustgraph/messaging/translators/primitives.py
*/ */
import type { Term, Triple } from "@trustgraph/base"; import { errorMessage, type Term, type Triple } from "@trustgraph/base";
import { Effect } from "effect";
import * as S from "effect/Schema"; import * as S from "effect/Schema";
// ---------- Client wire format type definitions ---------- // ---------- Client wire format type definitions ----------
@ -55,6 +56,8 @@ export class DispatchSerializationError extends S.TaggedErrorClass<DispatchSeria
}, },
) {} ) {}
const isDispatchSerializationError = S.is(DispatchSerializationError);
interface ClientTriple { interface ClientTriple {
s: ClientTerm; s: ClientTerm;
p: ClientTerm; p: ClientTerm;
@ -280,6 +283,21 @@ export function translateRequest(service: string, body: unknown): unknown {
return body; return body;
} }
export const translateRequestEffect = (
service: string,
body: unknown,
): Effect.Effect<unknown, DispatchSerializationError> =>
Effect.try({
try: () => translateRequest(service, body),
catch: (cause) =>
isDispatchSerializationError(cause)
? cause
: DispatchSerializationError.make({
operation: `translate-request:${service}`,
message: errorMessage(cause),
}),
});
/** /**
* Translate an internal response body to client wire format. * Translate an internal response body to client wire format.
* *
@ -293,3 +311,18 @@ export function translateResponse(service: string, response: unknown): unknown {
} }
return response; return response;
} }
export const translateResponseEffect = (
service: string,
response: unknown,
): Effect.Effect<unknown, DispatchSerializationError> =>
Effect.try({
try: () => translateResponse(service, response),
catch: (cause) =>
isDispatchSerializationError(cause)
? cause
: DispatchSerializationError.make({
operation: `translate-response:${service}`,
message: errorMessage(cause),
}),
});