Migrate request-response facade to Effect runtime

This commit is contained in:
elpresidank 2026-06-01 22:11:03 -05:00
parent 7f81c56c80
commit a0d2575273
5 changed files with 306 additions and 47 deletions

View file

@ -33,6 +33,44 @@ Signal counts from `ts/packages`:
| `while (` | 10 |
| `localStorage` | 8 |
## Loop Passes
### 2026-06-02: Base Request/Response Facade
- Status: migrated and verified.
- Completed:
- `ts/packages/base/src/messaging/request-response.ts:50` now creates an
explicit `Scope.Closeable` and `:55` builds the existing
`EffectRequestResponse` runtime.
- `ts/packages/base/src/messaging/request-response.ts:91` rejects
not-started calls with `MessagingLifecycleError`, and `:108` maps
recipient callback failures into `MessagingDeliveryError`. It no longer
constructs normal `Error` values.
- `ts/packages/base/src/messaging/runtime.ts:427` now lets
request/response own its producer directly, `:442` runs the response
dispatcher with `Effect.forkScoped`, and `:445` makes shutdown idempotent.
- `ts/packages/base/src/__tests__/request-response.test.ts:115` covers the
Promise facade over the Effect runtime, `:143` asserts tagged timeout
errors, and `:164` asserts tagged lifecycle errors.
- Verification:
- `bun run --cwd ts/packages/base test`
- `bun run --cwd ts/packages/base build`
- `bun run --cwd ts check:tsgo`
- `bun run --cwd ts build`
- `bun run --cwd ts test`
- Remaining base evidence:
- `makeSubscriber(` has no current `ts/packages` call sites after this slice,
but `ts/packages/base/src/messaging/index.ts` still exports
`makeAsyncQueue`, `makeSubscriber`, and related types.
- `ts/packages/base/src/messaging/consumer.ts` still has a Promise polling
loop and a normal `Error` constructor.
- `ts/packages/base/src/messaging/producer.ts` still throws a normal
not-started `Error`.
- Decision:
- Normal `Error` construction in library internals is migration evidence.
Prefer existing `S.TaggedErrorClass` errors from
`ts/packages/base/src/errors.ts`, adding new tagged errors when needed.
## Ranked Findings
### P0: Collapse Base Messaging Promise Facades
@ -56,7 +94,8 @@ Signal counts from `ts/packages`:
`Scope.ts`, `Layer.ts`, `Schedule.ts`, `Ref.ts`.
- Rewrite shape:
- Make the Effect runtime factories the canonical internal surface.
- Keep Promise adapters only at external compatibility boundaries.
- Keep Promise adapters only at external compatibility boundaries. Rejected
values at those boundaries should still be tagged TrustGraph errors.
- Replace polling sleep loops with scheduled scoped consumers where possible.
- Replace resolver maps with `Queue`, `Deferred`, or `PubSub`-backed routing.
- Tests:
@ -66,6 +105,9 @@ Signal counts from `ts/packages`:
- Blockers:
- Public package exports may still expect Promise-shaped producer, consumer,
and request/response handles. Inventory callers before changing exports.
- First slice completed request/response facade migration. Next base follow-up
is either an Effect-backed consumer facade or a public export decision for
`subscriber.ts`.
### P0: Convert Stateful Flow Services To Scoped Effect Services
@ -274,10 +316,10 @@ Signal counts from `ts/packages`:
## Recommended PR Order
1. Base messaging/runtime convergence design and tests.
2. Gateway dispatcher internal Effect conversion.
1. Gateway dispatcher requestor-cache and streaming-completion migration.
2. Config service scoped state migration.
3. RAG and agent requestor bridge removal.
4. One stateful Flow service conversion, starting with config or cores.
4. Base consumer facade and subscriber export cleanup.
5. Client compatibility facade tightening.
6. Storage/provider managed resource cleanup.
7. MCP canonicalization and Workbench polish.
@ -287,7 +329,8 @@ Signal counts from `ts/packages`:
Do not flag these as rewrite blockers without additional proof:
- Promise-returning CLI actions and Fastify route handlers at external
boundaries.
boundaries. This does not exempt normal `Error` construction inside shared
library code.
- `S.Class`, `S.TaggedErrorClass`, `Context.Service`, `Rpc.make`, and
`HttpApi.make` when they are required or idiomatic for the Effect API.
- Plain `Map` usage for local pure transformations, such as graph utility

View file

@ -51,6 +51,7 @@ primitive exists.
| AI tools, MCP, and model calls | `Tool`, `Toolkit`, `McpServer`, `McpSchema`, `LanguageModel`, provider layers | `effect/unstable/ai`, provider packages such as `@effect/ai-openai` | `ts/node_modules/effect/src/unstable/ai/*.ts`, `packages/ai/ai/src/*.ts` |
| Workbench async state | `Atom`, `AtomRpc`, `AtomHttpApi`, `AsyncResult`, `AtomRegistry`, `Reactivity` | `effect/unstable/reactivity`, `@effect/atom-react` | `ts/node_modules/effect/src/unstable/reactivity/*.ts`, `ts/packages/workbench/node_modules/@effect/atom-react/src/*.ts` |
| Metrics and logs | `Metric`, `Logger`, `Effect.log*` | `effect`, `@effect/opentelemetry` | `packages/effect/src/Metric.ts`, `packages/effect/src/Logger.ts` |
| Normal internal errors | `S.TaggedErrorClass` and existing TrustGraph tagged errors | `effect/Schema`, `@trustgraph/base/errors` | `packages/effect/src/Schema.ts`, `ts/packages/base/src/errors.ts` |
Known concrete exports useful to scouts:
@ -69,6 +70,9 @@ Known concrete exports useful to scouts:
`Reactivity.query`, `Reactivity.stream`, `Reactivity.mutation`.
- `Tool.make`, `Toolkit.make`, `McpServer.registerToolkit`,
`LanguageModel.generateText`, `LanguageModel.streamText`.
- `S.TaggedErrorClass` for internal/library errors. Treat `new Error` inside
library internals as migration evidence unless it is a host/tool boundary,
test-only helper, or externally mandated error shape.
## Scout Workflow
@ -79,7 +83,7 @@ Known concrete exports useful to scouts:
3. Run quick signal scans:
```sh
rg -n "new Promise|setTimeout|while \\(|receive\\(|Effect\\.runPromise|toPromiseRequestor|makeAsyncProcessor|process\\.env|JSON\\.parse|JSON\\.stringify|localStorage|new Map|WebSocket" ts/packages --glob '*.ts' --glob '*.tsx'
rg -n "new Error|new Promise|setTimeout|while \\(|receive\\(|Effect\\.runPromise|toPromiseRequestor|makeAsyncProcessor|process\\.env|JSON\\.parse|JSON\\.stringify|localStorage|new Map|WebSocket" ts/packages --glob '*.ts' --glob '*.tsx'
```
4. Split scouts by lane. If the thread cannot spawn every scout in parallel,

View file

@ -0,0 +1,182 @@
import { describe, expect, it } from "vitest";
import {
makeRequestResponse,
type BackendConsumer,
type BackendProducer,
type CreateConsumerOptions,
type CreateProducerOptions,
type Message,
type PubSubBackend,
} from "../index.js";
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
return {
value: () => value,
properties: () => properties,
};
}
class RecordingProducer<T> implements BackendProducer<T> {
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
closeCount = 0;
flushCount = 0;
constructor(private readonly onSend?: (message: T, properties?: Record<string, string>) => void) {}
async send(message: T, properties?: Record<string, string>): Promise<void> {
this.sent.push(properties === undefined ? { message } : { message, properties });
this.onSend?.(message, properties);
}
async flush(): Promise<void> {
this.flushCount += 1;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
class WaitingConsumer<T> implements BackendConsumer<T> {
readonly acknowledged: Array<Message<T>> = [];
readonly nacked: Array<Message<T>> = [];
closeCount = 0;
private readonly messages: Array<Message<T>> = [];
private readonly waiters: Array<(message: Message<T> | null) => void> = [];
private closed = false;
push(message: Message<T>): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter(message);
return;
}
this.messages.push(message);
}
async receive(): Promise<Message<T> | null> {
const message = this.messages.shift();
if (message !== undefined || this.closed) return message ?? null;
return await new Promise((resolve) => {
this.waiters.push(resolve);
});
}
async acknowledge(message: Message<T>): Promise<void> {
this.acknowledged.push(message);
}
async negativeAcknowledge(message: Message<T>): Promise<void> {
this.nacked.push(message);
}
async unsubscribe(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
for (const waiter of this.waiters.splice(0)) {
waiter(null);
}
this.closeCount += 1;
}
}
class RuntimeBackend implements PubSubBackend {
closeCount = 0;
producerOptions: CreateProducerOptions | null = null;
consumerOptions: CreateConsumerOptions | null = null;
readonly producer: RecordingProducer<unknown>;
constructor(
private readonly consumer: BackendConsumer<unknown>,
onSend?: (message: unknown, properties?: Record<string, string>) => void,
) {
this.producer = new RecordingProducer<unknown>(onSend);
}
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
this.producerOptions = options;
return this.producer as BackendProducer<T>;
}
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
this.consumerOptions = options;
return this.consumer as BackendConsumer<T>;
}
async close(): Promise<void> {
this.closeCount += 1;
}
}
describe("RequestResponse compatibility facade", () => {
it("routes requests through the Effect-native request-response runtime", async () => {
const consumer = new WaitingConsumer<string>();
const backend = new RuntimeBackend(
consumer as BackendConsumer<unknown>,
(_message, properties) => {
consumer.push(createMessage("response", { id: properties?.id ?? "" }));
},
);
const requestor = makeRequestResponse<string, string>({
pubsub: backend,
requestTopic: "request-topic",
responseTopic: "response-topic",
subscription: "sub",
});
await requestor.start();
const response = await requestor.request("request", { timeoutMs: 250 });
await requestor.stop();
expect(response).toBe("response");
expect(backend.producerOptions).toEqual({ topic: "request-topic" });
expect(backend.consumerOptions).toEqual({ topic: "response-topic", subscription: "sub" });
expect(backend.producer.sent[0]?.message).toBe("request");
expect(consumer.acknowledged.length).toBe(1);
expect(backend.producer.closeCount).toBe(1);
expect(consumer.closeCount).toBe(1);
});
it("rejects with a tagged timeout error instead of a normal Error", async () => {
const consumer = new WaitingConsumer<string>();
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
const requestor = makeRequestResponse<string, string>({
pubsub: backend,
requestTopic: "request-topic",
responseTopic: "response-topic",
subscription: "sub",
});
await requestor.start();
const error = await requestor.request("request", { timeoutMs: 5 }).catch((caught: unknown) => caught);
await requestor.stop();
expect(error).toMatchObject({
_tag: "MessagingTimeoutError",
operation: "request-response",
timeoutMs: 5,
});
});
it("rejects with a tagged lifecycle error when requested before start", async () => {
const consumer = new WaitingConsumer<string>();
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
const requestor = makeRequestResponse<string, string>({
pubsub: backend,
requestTopic: "request-topic",
responseTopic: "response-topic",
subscription: "sub",
});
const error = await requestor.request("request").catch((caught: unknown) => caught);
expect(error).toMatchObject({
_tag: "MessagingLifecycleError",
operation: "request",
resource: "request-topic:response-topic",
});
});
});

View file

@ -7,10 +7,12 @@
* Python reference: trustgraph-base/trustgraph/base/request_response_spec.py
*/
import { randomUUID } from "node:crypto";
import { makeProducer, type Producer } from "./producer.js";
import { makeSubscriber, type Subscriber } from "./subscriber.js";
import { Effect, Exit, Scope } from "effect";
import type { PubSubBackend } from "../backend/types.js";
import { PubSub } from "../backend/pubsub.js";
import { messagingDeliveryError, messagingLifecycleError } from "../errors.js";
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
import { makeEffectRequestResponseFromPubSub, type EffectRequestResponse } from "./runtime.js";
export interface RequestResponseOptions {
pubsub: PubSubBackend;
@ -31,24 +33,48 @@ export interface RequestResponse<TReq, TRes> {
) => Promise<TRes>;
}
interface RequestResponseRuntime<TReq, TRes> {
readonly scope: Scope.Closeable;
readonly requestor: EffectRequestResponse<TReq, TRes>;
}
export function makeRequestResponse<TReq, TRes>(
options: RequestResponseOptions,
): RequestResponse<TReq, TRes> {
const producer: Producer<TReq> = makeProducer<TReq>(options.pubsub, options.requestTopic);
const subscriber: Subscriber<TRes> = makeSubscriber<TRes>(
options.pubsub,
options.responseTopic,
options.subscription,
);
let runtime: RequestResponseRuntime<TReq, TRes> | null = null;
return {
start: async () => {
await producer.start();
await subscriber.start();
if (runtime !== null) return;
const scope = await Effect.runPromise(Scope.make());
try {
const config = await Effect.runPromise(loadMessagingRuntimeConfig());
const requestor = await Effect.runPromise(
makeEffectRequestResponseFromPubSub<TReq, TRes>(
PubSub.fromBackend(options.pubsub),
config,
{
requestTopic: options.requestTopic,
responseTopic: options.responseTopic,
subscription: options.subscription,
},
).pipe(Scope.provide(scope)),
);
runtime = { scope, requestor };
} catch (error) {
await Effect.runPromise(Scope.close(scope, Exit.fail(error))).catch(() => undefined);
throw error;
}
},
stop: async () => {
await producer.stop();
await subscriber.stop();
const current = runtime;
runtime = null;
if (current === null) return;
await Effect.runPromise(Scope.close(current.scope, Exit.void));
},
/**
* Send a request and wait for responses.
@ -60,35 +86,32 @@ export function makeRequestResponse<TReq, TRes>(
* If omitted, returns the first response.
*/
request: async (request, requestOptions) => {
const id = randomUUID();
const current = runtime;
if (current === null) {
throw messagingLifecycleError(
`${options.requestTopic}:${options.responseTopic}`,
"request",
"RequestResponse not started",
);
}
const timeoutMs = requestOptions?.timeoutMs ?? 300_000;
const recipient = requestOptions?.recipient;
const queue = subscriber.subscribe(id);
try {
await producer.send(id, request);
const deadline = Date.now() + timeoutMs;
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
const response = await queue.pop(remaining);
if (recipient !== undefined) {
const isFinal = await recipient(response);
if (isFinal) return response;
} else {
return response;
}
}
} finally {
subscriber.unsubscribe(id);
}
return await Effect.runPromise(
current.requestor.request(request, {
timeoutMs,
...(recipient === undefined
? {}
: {
recipient: (response) =>
Effect.tryPromise({
try: () => recipient(response),
catch: (error) => messagingDeliveryError(options.responseTopic, "recipient", error),
}),
}),
}),
);
},
};
}

View file

@ -424,7 +424,11 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR
config: MessagingRuntimeConfig,
options: EffectRequestResponseOptions,
) {
const producer = yield* makeEffectProducerFromPubSub<TReq>(pubsub, {
const producerOptions: CreateProducerOptions = options.requestSchema === undefined
? { topic: options.requestTopic }
: { topic: options.requestTopic, schema: options.requestSchema };
const producerBackend = yield* pubsub.createProducer<TReq>(producerOptions);
const producer = makeEffectProducerHandle<TReq>(producerBackend, {
topic: options.requestTopic,
...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }),
});
@ -435,9 +439,12 @@ export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestR
};
const backend = yield* pubsub.createConsumer<TRes>(createOptions);
const subscribers = new Map<string, Queue.Queue<TRes>>();
const fiber = yield* dispatchResponseLoop(backend, options.responseTopic, subscribers, config).pipe(Effect.forkChild);
const fiber = yield* dispatchResponseLoop(backend, options.responseTopic, subscribers, config).pipe(Effect.forkScoped);
let stopped = false;
const stop = Effect.fn(`RequestResponse.stop:${options.requestTopic}`)(function* () {
if (stopped) return;
stopped = true;
yield* Fiber.interrupt(fiber);
yield* producer.close;
yield* closeConsumerBackend(backend, options.responseTopic, options.subscription);