mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-06 03:42:11 +02:00
Add Effect-native gateway streaming path
This commit is contained in:
parent
df0a0c068e
commit
ce5838db1d
5 changed files with 315 additions and 189 deletions
|
|
@ -12,12 +12,13 @@ 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 native PubSub
|
Current signal counts from `ts/packages` after the 2026-06-02 gateway
|
||||||
boundary slice:
|
streaming callback slice:
|
||||||
|
|
||||||
| Signal | Count |
|
| Signal | Count |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
| `Effect.runPromise` | 165 |
|
| `Effect.runPromise` | 163 |
|
||||||
|
| `Effect.runPromiseWith` | 0 |
|
||||||
| `Map<` | 82 |
|
| `Map<` | 82 |
|
||||||
| `WebSocket` | 62 |
|
| `WebSocket` | 62 |
|
||||||
| `new Map` | 60 |
|
| `new Map` | 60 |
|
||||||
|
|
@ -93,6 +94,10 @@ Notes:
|
||||||
`PubSub` is an in-process hub and does not replace the broker-backed
|
`PubSub` is an in-process hub and does not replace the broker-backed
|
||||||
`PubSubBackend`/NATS boundary, but it should be preferred for future
|
`PubSubBackend`/NATS boundary, but it should be preferred for future
|
||||||
in-process broadcast/fanout needs.
|
in-process broadcast/fanout needs.
|
||||||
|
- The gateway streaming callback slice added Effect-returning dispatcher
|
||||||
|
streaming methods, switched the RPC stream server off nested
|
||||||
|
`Effect.runPromiseWith(context)` queue offers, and replaced the client
|
||||||
|
`StopStreaming` sentinel error with `Stream.runForEachWhile`.
|
||||||
- `Record<string, any>` and `throwLibrarianServiceError` are now clean in
|
- `Record<string, any>` and `throwLibrarianServiceError` are now clean in
|
||||||
`ts/packages`.
|
`ts/packages`.
|
||||||
|
|
||||||
|
|
@ -718,6 +723,41 @@ Notes:
|
||||||
- `bun run --cwd ts/packages/base test`
|
- `bun run --cwd ts/packages/base test`
|
||||||
- `cd ts && bun run check`
|
- `cd ts && bun run check`
|
||||||
|
|
||||||
|
### 2026-06-02: Gateway Streaming Callback Slice
|
||||||
|
|
||||||
|
- Status: migrated and root-verified.
|
||||||
|
- Completed:
|
||||||
|
- `ts/packages/flow/src/gateway/dispatch/manager.ts` now exposes
|
||||||
|
`dispatchGlobalServiceStreamingEffect` and
|
||||||
|
`dispatchFlowServiceStreamingEffect` so Effect callers can handle stream
|
||||||
|
chunks without Promise callback re-entry.
|
||||||
|
- The existing Promise-returning streaming methods remain as compatibility
|
||||||
|
facades and wrap responders with `Effect.tryPromise`.
|
||||||
|
- `ts/packages/flow/src/gateway/rpc-server.ts` now writes stream chunks into
|
||||||
|
the RPC queue through the dispatcher Effect path, removing the prior
|
||||||
|
`Effect.context` plus `Effect.runPromiseWith(context)` bridge.
|
||||||
|
- `ts/packages/client/src/socket/effect-rpc-client.ts` now uses
|
||||||
|
`Stream.runForEachWhile` for early stream termination instead of throwing a
|
||||||
|
synthetic `StopStreaming` tagged error.
|
||||||
|
- Gateway dispatcher tests now exercise both the Promise compatibility
|
||||||
|
streaming path and the Effect-native responder path.
|
||||||
|
- Remaining:
|
||||||
|
- Client facade methods still duplicate some per-service streaming envelope
|
||||||
|
completion checks. Centralize these around `DispatchStreamChunk.complete`
|
||||||
|
in a later client API cleanup.
|
||||||
|
- `ts/packages/flow/src/gateway/rpc-protocol.ts` remains a Fastify socket
|
||||||
|
compatibility bridge, not a direct replacement target for Effect RPC
|
||||||
|
server layers yet.
|
||||||
|
- Verification:
|
||||||
|
- `bun run --cwd ts/packages/flow build`
|
||||||
|
- `bun run --cwd ts/packages/client build`
|
||||||
|
- `bunx --bun vitest run src/__tests__/gateway-dispatcher.test.ts`
|
||||||
|
- `bunx --bun vitest run src/__tests__/rpc-timeout.test.ts`
|
||||||
|
- `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:
|
||||||
|
|
@ -756,7 +796,13 @@ Notes:
|
||||||
Socket errors/JSON parsing now use tagged errors and Schema decoding.
|
Socket errors/JSON parsing now use tagged errors and Schema decoding.
|
||||||
The remaining client `newableFactory` assertions are documented as public
|
The remaining client `newableFactory` assertions are documented as public
|
||||||
API compatibility boundaries for this loop.
|
API compatibility boundaries for this loop.
|
||||||
- Knowledge streams still duplicate legacy end-of-stream handling.
|
- Gateway `DispatchStream` now uses Effect-native dispatcher streaming
|
||||||
|
callbacks instead of nested `Effect.runPromiseWith`; the remaining client
|
||||||
|
streaming cleanup is facade-level completion normalization around
|
||||||
|
`DispatchStreamChunk.complete`.
|
||||||
|
- Do not make `gateway/rpc-protocol.ts` the next cleanup target: it is a
|
||||||
|
Fastify socket compatibility bridge while the public Effect RPC server
|
||||||
|
layers require SocketServer or Effect HTTP routing.
|
||||||
- WebSocket adapter host fallbacks now use `Result.try` and tagged adapter
|
- WebSocket adapter host fallbacks now use `Result.try` and tagged adapter
|
||||||
errors while preserving sync exports.
|
errors while preserving sync exports.
|
||||||
- RAG/providers/storage:
|
- RAG/providers/storage:
|
||||||
|
|
@ -764,8 +810,14 @@ Notes:
|
||||||
remaining `ts/packages` matches.
|
remaining `ts/packages` matches.
|
||||||
- Provider SDKs and storage clients should become managed resources where
|
- Provider SDKs and storage clients should become managed resources where
|
||||||
they have meaningful lifecycle.
|
they have meaningful lifecycle.
|
||||||
- FalkorDB/Qdrant/Ollama/OpenAI-compatible surfaces still need config,
|
- FalkorDB should be the next P1 storage slice: both triples query and store
|
||||||
schema, and scope audits.
|
connect Redis clients, cache them with mutable `Effect.cached` slots, and
|
||||||
|
expose `Layer.succeed` services without a scoped client finalizer.
|
||||||
|
- Qdrant has no close/disconnect surface in the installed client, so treat it
|
||||||
|
as a config/schema/fakeability slice rather than an `acquireRelease` close
|
||||||
|
slice.
|
||||||
|
- Ollama/OpenAI-compatible/provider surfaces still need config, schema, and
|
||||||
|
provider-layer audits.
|
||||||
|
|
||||||
## Ranked Findings
|
## Ranked Findings
|
||||||
|
|
||||||
|
|
@ -773,19 +825,29 @@ Notes:
|
||||||
|
|
||||||
- TrustGraph evidence:
|
- TrustGraph evidence:
|
||||||
- `ts/packages/flow/src/storage/triples/falkordb.ts`
|
- `ts/packages/flow/src/storage/triples/falkordb.ts`
|
||||||
|
- `ts/packages/flow/src/query/triples/falkordb.ts`
|
||||||
- `ts/packages/flow/src/storage/embeddings/qdrant-graph.ts`
|
- `ts/packages/flow/src/storage/embeddings/qdrant-graph.ts`
|
||||||
- `ts/packages/flow/src/storage/embeddings/qdrant-doc.ts`
|
- `ts/packages/flow/src/storage/embeddings/qdrant-doc.ts`
|
||||||
|
- `ts/packages/flow/src/query/embeddings/qdrant-graph.ts`
|
||||||
|
- `ts/packages/flow/src/query/embeddings/qdrant-doc.ts`
|
||||||
- `ts/packages/flow/src/model/text-completion/*.ts`
|
- `ts/packages/flow/src/model/text-completion/*.ts`
|
||||||
- `ts/packages/flow/src/embeddings/ollama.ts`
|
- `ts/packages/flow/src/embeddings/ollama.ts`
|
||||||
- Effect primitives:
|
- Effect primitives:
|
||||||
- `Effect.acquireRelease`, `Layer.scoped`, `Config`, `ConfigProvider`,
|
- `Effect.acquireRelease`, `Layer.effect`/`Layer.scoped`, `Config`,
|
||||||
`Metric`, `Logger`, Effect AI provider layers.
|
`ConfigProvider`, `Metric`, `Logger`, Effect AI provider layers.
|
||||||
- Rewrite shape:
|
- Rewrite shape:
|
||||||
|
- First migrate FalkorDB triples store/query so Redis client connect and
|
||||||
|
disconnect/quit are owned by the service layer scope instead of mutable
|
||||||
|
cached effects hidden inside a `Layer.succeed` service.
|
||||||
- Move env/config reading into `Config` loaders and provider-specific layers.
|
- Move env/config reading into `Config` loaders and provider-specific layers.
|
||||||
- Scope SDK clients that need explicit close/disconnect.
|
- Scope SDK clients that need explicit close/disconnect; for clients without
|
||||||
|
close APIs, prefer config/schema/fakeable construction work instead.
|
||||||
- Tests:
|
- Tests:
|
||||||
- Provider config tests with `ConfigProvider.fromMap`.
|
- FalkorDB tests with fake client factories proving connect on acquire and
|
||||||
- Storage tests with fake clients before changing real resource lifetimes.
|
disconnect/quit on scope close.
|
||||||
|
- Provider/config tests with `ConfigProvider.fromUnknown`.
|
||||||
|
- Storage/query tests with fake clients before changing real resource
|
||||||
|
lifetimes.
|
||||||
|
|
||||||
### P2: Canonicalize MCP Around The Effect Server
|
### P2: Canonicalize MCP Around The Effect Server
|
||||||
|
|
||||||
|
|
@ -819,9 +881,10 @@ Notes:
|
||||||
|
|
||||||
## Recommended PR Order
|
## Recommended PR Order
|
||||||
|
|
||||||
1. Gateway RPC callback and client streaming completion cleanup.
|
1. FalkorDB triples store/query scoped client lifecycle.
|
||||||
2. Storage/provider managed resource cleanup.
|
2. Qdrant config/schema/fakeable construction cleanup.
|
||||||
3. MCP parity/deletion decision and workbench platform polish.
|
3. Client streaming facade completion normalization.
|
||||||
|
4. MCP parity/deletion decision and workbench platform polish.
|
||||||
|
|
||||||
## No-Op Rules
|
## No-Op Rules
|
||||||
|
|
||||||
|
|
@ -850,6 +913,10 @@ Do not flag these as rewrite blockers without additional proof:
|
||||||
boundary for NATS/Pulsar-style topics, acknowledgement, schema codecs, and
|
boundary for NATS/Pulsar-style topics, acknowledgement, schema codecs, and
|
||||||
backend lifecycle. Effect's native `PubSub` can replace in-process fanout
|
backend lifecycle. Effect's native `PubSub` can replace in-process fanout
|
||||||
helpers, but not the distributed broker abstraction by itself.
|
helpers, but not the distributed broker abstraction by itself.
|
||||||
|
- `ts/packages/flow/src/gateway/rpc-protocol.ts` is a Fastify socket
|
||||||
|
compatibility bridge. Do not flag its internal connection maps/sets as a
|
||||||
|
standalone replacement target until the gateway is ready to move onto Effect
|
||||||
|
SocketServer or Effect HTTP routing.
|
||||||
|
|
||||||
## Acceptance For Final Loop Completion
|
## Acceptance For Final Loop Completion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Context, Data, Effect, Layer, ManagedRuntime, Stream } from "effect";
|
import { Context, Effect, Layer, ManagedRuntime, Stream } from "effect";
|
||||||
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
import type * as RpcGroup from "effect/unstable/rpc/RpcGroup";
|
||||||
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
import * as RpcClient from "effect/unstable/rpc/RpcClient";
|
||||||
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
|
import type { RpcClientError } from "effect/unstable/rpc/RpcClientError";
|
||||||
|
|
@ -156,17 +156,12 @@ export function makeEffectRpcClient(
|
||||||
runtime.runPromise(
|
runtime.runPromise(
|
||||||
withDispatchRequestPolicy(
|
withDispatchRequestPolicy(
|
||||||
client.DispatchStream(DispatchPayload.make(input)).pipe(
|
client.DispatchStream(DispatchPayload.make(input)).pipe(
|
||||||
Stream.runForEach((chunk) =>
|
Stream.runForEachWhile((chunk) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
last = chunk;
|
last = chunk;
|
||||||
if (receiver(chunk)) return Effect.fail(new StopStreaming());
|
return Effect.succeed(!receiver(chunk));
|
||||||
return Effect.void;
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Effect.catchIf(
|
|
||||||
(cause): cause is StopStreaming => cause instanceof StopStreaming,
|
|
||||||
() => Effect.void,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
options,
|
options,
|
||||||
),
|
),
|
||||||
|
|
@ -205,8 +200,6 @@ export function withDispatchRequestPolicy<A, E, R>(
|
||||||
return retryTimes > 0 ? timed.pipe(Effect.retry({ times: retryTimes })) : timed;
|
return retryTimes > 0 ? timed.pipe(Effect.retry({ times: retryTimes })) : timed;
|
||||||
}
|
}
|
||||||
|
|
||||||
class StopStreaming extends Data.TaggedError("StopStreaming")<{}> {}
|
|
||||||
|
|
||||||
function errorMessage(cause: unknown): string {
|
function errorMessage(cause: unknown): string {
|
||||||
if (cause instanceof Error) return cause.message;
|
if (cause instanceof Error) return cause.message;
|
||||||
if (typeof cause === "string") return cause;
|
if (typeof cause === "string") return cause;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { Effect } from "effect";
|
||||||
import {
|
import {
|
||||||
dispatcherManagerIsCompleteResponse,
|
dispatcherManagerIsCompleteResponse,
|
||||||
makeDispatcherManager,
|
makeDispatcherManager,
|
||||||
|
|
@ -189,6 +190,30 @@ describe("gateway dispatcher manager", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("streams responses through the Effect-native responder path", async () => {
|
||||||
|
const backend = new DispatchBackend();
|
||||||
|
const manager = makeDispatcherManager({
|
||||||
|
port: 0,
|
||||||
|
metricsPort: 0,
|
||||||
|
pubsub: backend,
|
||||||
|
});
|
||||||
|
const chunks: Array<{ readonly response: unknown; readonly complete: boolean }> = [];
|
||||||
|
|
||||||
|
await Effect.runPromise(
|
||||||
|
manager.dispatchGlobalServiceStreamingEffect("knowledge", { query: "hello" }, (response, complete) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
chunks.push({ response, complete });
|
||||||
|
})
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await manager.stop();
|
||||||
|
|
||||||
|
expect(chunks).toEqual([
|
||||||
|
{ response: { chunk: 1 }, complete: false },
|
||||||
|
{ response: { chunk: 2, endOfStream: true }, complete: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{ complete: true }],
|
[{ complete: true }],
|
||||||
[{ endOfStream: true }],
|
[{ endOfStream: true }],
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,27 @@ import {
|
||||||
messagingDeliveryError,
|
messagingDeliveryError,
|
||||||
messagingLifecycleError,
|
messagingLifecycleError,
|
||||||
type EffectRequestResponse,
|
type EffectRequestResponse,
|
||||||
|
type MessagingDeliveryError,
|
||||||
|
type MessagingLifecycleError,
|
||||||
|
type MessagingTimeoutError,
|
||||||
type PubSubBackend,
|
type PubSubBackend,
|
||||||
|
type PubSubError,
|
||||||
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 { translateRequest, translateResponse } 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> = (
|
||||||
|
response: unknown,
|
||||||
|
complete: boolean,
|
||||||
|
) => Effect.Effect<void, E, R>;
|
||||||
|
export type DispatcherStreamError<E = never> =
|
||||||
|
| PubSubError
|
||||||
|
| MessagingLifecycleError
|
||||||
|
| MessagingDeliveryError
|
||||||
|
| MessagingTimeoutError
|
||||||
|
| E;
|
||||||
|
|
||||||
// ---------- Service registry ----------
|
// ---------- Service registry ----------
|
||||||
|
|
||||||
|
|
@ -89,6 +103,11 @@ export interface DispatcherManager {
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
responder: Responder,
|
responder: Responder,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
readonly dispatchGlobalServiceStreamingEffect: <E = never, R = never>(
|
||||||
|
kind: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
responder: EffectResponder<E, R>,
|
||||||
|
) => Effect.Effect<void, DispatcherStreamError<E>, R>;
|
||||||
readonly dispatchFlowService: (
|
readonly dispatchFlowService: (
|
||||||
flow: string,
|
flow: string,
|
||||||
kind: string,
|
kind: string,
|
||||||
|
|
@ -100,6 +119,12 @@ export interface DispatcherManager {
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
responder: Responder,
|
responder: Responder,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
readonly dispatchFlowServiceStreamingEffect: <E = never, R = never>(
|
||||||
|
flow: string,
|
||||||
|
kind: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
responder: EffectResponder<E, R>,
|
||||||
|
) => Effect.Effect<void, DispatcherStreamError<E>, R>;
|
||||||
readonly publishToTopic: (
|
readonly publishToTopic: (
|
||||||
topic: string,
|
topic: string,
|
||||||
message: unknown,
|
message: unknown,
|
||||||
|
|
@ -146,96 +171,93 @@ 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");
|
||||||
let runtime: DispatcherRuntime | null = null;
|
let runtime: DispatcherRuntime | null = null;
|
||||||
|
|
||||||
const start = (): Promise<void> => {
|
const startEffect = Effect.fn("DispatcherManager.start")(function* () {
|
||||||
if (runtime !== null) return Promise.resolve();
|
if (runtime !== null) return;
|
||||||
|
|
||||||
return Effect.runPromise(
|
const scope = yield* Scope.make();
|
||||||
Effect.gen(function* () {
|
const nextRuntime = yield* Effect.gen(function* () {
|
||||||
const scope = yield* Scope.make();
|
const messagingConfig = yield* loadMessagingRuntimeConfig().pipe(
|
||||||
const nextRuntime = yield* Effect.gen(function* () {
|
Effect.mapError((cause) =>
|
||||||
const messagingConfig = yield* loadMessagingRuntimeConfig();
|
messagingLifecycleError(
|
||||||
const requestors = yield* SynchronizedRef.make<RequestorMap>(new Map());
|
"gateway-dispatcher",
|
||||||
return {
|
"load-messaging-config",
|
||||||
scope,
|
cause,
|
||||||
requestors,
|
)
|
||||||
factory: makeRequestResponseFactoryService(makePubSubService(pubsub), messagingConfig),
|
),
|
||||||
} satisfies DispatcherRuntime;
|
);
|
||||||
}).pipe(
|
const requestors = yield* SynchronizedRef.make<RequestorMap>(new Map());
|
||||||
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
return {
|
||||||
);
|
scope,
|
||||||
runtime = nextRuntime;
|
requestors,
|
||||||
}),
|
factory: makeRequestResponseFactoryService(makePubSubService(pubsub), messagingConfig),
|
||||||
|
} satisfies DispatcherRuntime;
|
||||||
|
}).pipe(
|
||||||
|
Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))),
|
||||||
);
|
);
|
||||||
};
|
runtime = nextRuntime;
|
||||||
|
});
|
||||||
|
|
||||||
const stop = (): Promise<void> =>
|
const start = (): Promise<void> => Effect.runPromise(startEffect());
|
||||||
Effect.runPromise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = runtime;
|
|
||||||
runtime = null;
|
|
||||||
|
|
||||||
if (current !== null) {
|
const stopEffect = Effect.fn("DispatcherManager.stop")(function* () {
|
||||||
yield* Scope.close(current.scope, Exit.void);
|
const current = runtime;
|
||||||
}
|
runtime = null;
|
||||||
|
|
||||||
yield* Effect.tryPromise({
|
if (current !== null) {
|
||||||
try: () => pubsub.close(),
|
yield* Scope.close(current.scope, Exit.void);
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause),
|
}
|
||||||
});
|
|
||||||
}),
|
yield* Effect.tryPromise({
|
||||||
);
|
try: () => pubsub.close(),
|
||||||
|
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "close-pubsub", cause),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stop = (): Promise<void> => Effect.runPromise(stopEffect());
|
||||||
|
|
||||||
// ---------- Internal helpers ----------
|
// ---------- Internal helpers ----------
|
||||||
|
|
||||||
const ensureRuntime = (): Promise<DispatcherRuntime> =>
|
const ensureRuntimeEffect = Effect.fn("DispatcherManager.ensureRuntime")(function* () {
|
||||||
Effect.runPromise(
|
if (runtime === null) {
|
||||||
Effect.gen(function* () {
|
yield* startEffect();
|
||||||
if (runtime === null) {
|
}
|
||||||
yield* Effect.tryPromise({
|
if (runtime === null) {
|
||||||
try: () => start(),
|
return yield* messagingLifecycleError(
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "start", cause),
|
"gateway-dispatcher",
|
||||||
});
|
"start",
|
||||||
}
|
"Dispatcher manager failed to start",
|
||||||
if (runtime === null) {
|
);
|
||||||
return yield* messagingLifecycleError("gateway-dispatcher", "start", "Dispatcher manager failed to start");
|
}
|
||||||
}
|
return runtime;
|
||||||
return runtime;
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const getRequestor = (
|
const getRequestorEffect = Effect.fn("DispatcherManager.getRequestor")(function* (
|
||||||
requestTopic: string,
|
requestTopic: string,
|
||||||
responseTopic: string,
|
responseTopic: string,
|
||||||
key: string,
|
key: string,
|
||||||
): Promise<EffectRequestResponse<unknown, unknown>> =>
|
) {
|
||||||
Effect.runPromise(
|
const current = yield* ensureRuntimeEffect();
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* Effect.tryPromise({
|
|
||||||
try: () => ensureRuntime(),
|
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "ensure-runtime", cause),
|
|
||||||
});
|
|
||||||
|
|
||||||
return yield* SynchronizedRef.modifyEffect(current.requestors, (requestors) => {
|
return yield* SynchronizedRef.modifyEffect(current.requestors, (requestors) => {
|
||||||
const cached = requestors.get(key);
|
const cached = requestors.get(key);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return Effect.succeed([cached, requestors] as const);
|
return Effect.succeed([cached, requestors] as const);
|
||||||
}
|
}
|
||||||
|
|
||||||
return current.factory.make<unknown, unknown>({
|
return current.factory.make<unknown, unknown>({
|
||||||
requestTopic,
|
requestTopic,
|
||||||
responseTopic,
|
responseTopic,
|
||||||
subscription: `gateway-${key}`,
|
subscription: `gateway-${key}`,
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Scope.provide(current.scope),
|
Scope.provide(current.scope),
|
||||||
Effect.map((requestor) => {
|
Effect.map((requestor) => {
|
||||||
const next = new Map(requestors);
|
const next = new Map(requestors);
|
||||||
next.set(key, requestor);
|
next.set(key, requestor);
|
||||||
return [requestor, next] as const;
|
return [requestor, next] as const;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const resolveGlobalTopics = (
|
const resolveGlobalTopics = (
|
||||||
kind: string,
|
kind: string,
|
||||||
|
|
@ -277,19 +299,40 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
kind: string,
|
kind: string,
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
): Promise<unknown> =>
|
): Promise<unknown> =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(dispatchGlobalServiceEffect(kind, request));
|
||||||
Effect.gen(function* () {
|
|
||||||
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
|
|
||||||
const rr = yield* Effect.tryPromise({
|
|
||||||
try: () => getRequestor(requestTopic, responseTopic, `global:${kind}`),
|
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
|
|
||||||
});
|
|
||||||
|
|
||||||
const translated = translateRequest(kind, request);
|
const dispatchGlobalServiceEffect = Effect.fn("DispatcherManager.dispatchGlobalService")(function* (
|
||||||
const response = yield* rr.request(translated);
|
kind: string,
|
||||||
return translateResponse(kind, response);
|
request: Record<string, unknown>,
|
||||||
}),
|
) {
|
||||||
);
|
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
|
||||||
|
const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`);
|
||||||
|
|
||||||
|
const translated = translateRequest(kind, request);
|
||||||
|
const response = yield* rr.request(translated);
|
||||||
|
return translateResponse(kind, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatchGlobalServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchGlobalServiceStreaming")(function* <
|
||||||
|
E,
|
||||||
|
R,
|
||||||
|
>(
|
||||||
|
kind: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
responder: EffectResponder<E, R>,
|
||||||
|
) {
|
||||||
|
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
|
||||||
|
const rr = yield* getRequestorEffect(requestTopic, responseTopic, `global:${kind}`);
|
||||||
|
const translated = translateRequest(kind, request);
|
||||||
|
|
||||||
|
yield* rr.request(translated, {
|
||||||
|
recipient: (response) => {
|
||||||
|
const translatedRes = translateResponse(kind, response);
|
||||||
|
const complete = dispatcherManagerIsCompleteResponse(translatedRes);
|
||||||
|
return responder(translatedRes, complete).pipe(Effect.as(complete));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const dispatchGlobalServiceStreaming = (
|
const dispatchGlobalServiceStreaming = (
|
||||||
kind: string,
|
kind: string,
|
||||||
|
|
@ -297,25 +340,16 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
responder: Responder,
|
responder: Responder,
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
dispatchGlobalServiceStreamingEffect(kind, request, (response, complete) =>
|
||||||
const { requestTopic, responseTopic } = resolveGlobalTopics(kind);
|
Effect.tryPromise({
|
||||||
const rr = yield* Effect.tryPromise({
|
try: () => responder(response, complete),
|
||||||
try: () => getRequestor(requestTopic, responseTopic, `global:${kind}`),
|
catch: (error) => messagingDeliveryError(
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
|
resolveGlobalTopics(kind).responseTopic,
|
||||||
});
|
"stream-responder",
|
||||||
const translated = translateRequest(kind, request);
|
error,
|
||||||
|
),
|
||||||
yield* rr.request(translated, {
|
})
|
||||||
recipient: (response) => {
|
),
|
||||||
const translatedRes = translateResponse(kind, response);
|
|
||||||
const complete = dispatcherManagerIsCompleteResponse(translatedRes);
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: () => responder(translatedRes, complete).then(() => complete),
|
|
||||||
catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- Flow-scoped service dispatch ----------
|
// ---------- Flow-scoped service dispatch ----------
|
||||||
|
|
@ -325,24 +359,51 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
kind: string,
|
kind: string,
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
): Promise<unknown> =>
|
): Promise<unknown> =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(dispatchFlowServiceEffect(flow, kind, request));
|
||||||
Effect.gen(function* () {
|
|
||||||
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
|
|
||||||
const rr = yield* Effect.tryPromise({
|
|
||||||
try: () => getRequestor(
|
|
||||||
requestTopic,
|
|
||||||
responseTopic,
|
|
||||||
`flow:${flow}:${kind}`,
|
|
||||||
),
|
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
|
|
||||||
});
|
|
||||||
|
|
||||||
const translated = translateRequest(kind, request);
|
const dispatchFlowServiceEffect = Effect.fn("DispatcherManager.dispatchFlowService")(function* (
|
||||||
const response = yield* rr.request(translated);
|
flow: string,
|
||||||
return translateResponse(kind, response);
|
kind: string,
|
||||||
}),
|
request: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
|
||||||
|
const rr = yield* getRequestorEffect(
|
||||||
|
requestTopic,
|
||||||
|
responseTopic,
|
||||||
|
`flow:${flow}:${kind}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const translated = translateRequest(kind, request);
|
||||||
|
const response = yield* rr.request(translated);
|
||||||
|
return translateResponse(kind, response);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatchFlowServiceStreamingEffect = Effect.fn("DispatcherManager.dispatchFlowServiceStreaming")(function* <
|
||||||
|
E,
|
||||||
|
R,
|
||||||
|
>(
|
||||||
|
flow: string,
|
||||||
|
kind: string,
|
||||||
|
request: Record<string, unknown>,
|
||||||
|
responder: EffectResponder<E, R>,
|
||||||
|
) {
|
||||||
|
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
|
||||||
|
const rr = yield* getRequestorEffect(
|
||||||
|
requestTopic,
|
||||||
|
responseTopic,
|
||||||
|
`flow:${flow}:${kind}`,
|
||||||
|
);
|
||||||
|
const translated = translateRequest(kind, request);
|
||||||
|
|
||||||
|
yield* rr.request(translated, {
|
||||||
|
recipient: (response) => {
|
||||||
|
const translatedRes = translateResponse(kind, response);
|
||||||
|
const complete = dispatcherManagerIsCompleteResponse(translatedRes);
|
||||||
|
return responder(translatedRes, complete).pipe(Effect.as(complete));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const dispatchFlowServiceStreaming = (
|
const dispatchFlowServiceStreaming = (
|
||||||
flow: string,
|
flow: string,
|
||||||
kind: string,
|
kind: string,
|
||||||
|
|
@ -350,29 +411,16 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
responder: Responder,
|
responder: Responder,
|
||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
dispatchFlowServiceStreamingEffect(flow, kind, request, (response, complete) =>
|
||||||
const { requestTopic, responseTopic } = resolveFlowTopics(kind);
|
Effect.tryPromise({
|
||||||
const rr = yield* Effect.tryPromise({
|
try: () => responder(response, complete),
|
||||||
try: () => getRequestor(
|
catch: (error) => messagingDeliveryError(
|
||||||
requestTopic,
|
resolveFlowTopics(kind).responseTopic,
|
||||||
responseTopic,
|
"stream-responder",
|
||||||
`flow:${flow}:${kind}`,
|
error,
|
||||||
),
|
),
|
||||||
catch: (cause) => messagingLifecycleError("gateway-dispatcher", "get-requestor", cause),
|
})
|
||||||
});
|
),
|
||||||
const translated = translateRequest(kind, request);
|
|
||||||
|
|
||||||
yield* rr.request(translated, {
|
|
||||||
recipient: (response) => {
|
|
||||||
const translatedRes = translateResponse(kind, response);
|
|
||||||
const complete = dispatcherManagerIsCompleteResponse(translatedRes);
|
|
||||||
return Effect.tryPromise({
|
|
||||||
try: () => responder(translatedRes, complete).then(() => complete),
|
|
||||||
catch: (error) => messagingDeliveryError(responseTopic, "stream-responder", error),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------- Fire-and-forget publish ----------
|
// ---------- Fire-and-forget publish ----------
|
||||||
|
|
@ -408,8 +456,10 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
stop,
|
stop,
|
||||||
dispatchGlobalService,
|
dispatchGlobalService,
|
||||||
dispatchGlobalServiceStreaming,
|
dispatchGlobalServiceStreaming,
|
||||||
|
dispatchGlobalServiceStreamingEffect,
|
||||||
dispatchFlowService,
|
dispatchFlowService,
|
||||||
dispatchFlowServiceStreaming,
|
dispatchFlowServiceStreaming,
|
||||||
|
dispatchFlowServiceStreamingEffect,
|
||||||
publishToTopic,
|
publishToTopic,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
|
||||||
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
import * as RpcServer from "effect/unstable/rpc/RpcServer";
|
||||||
import type * as Socket from "effect/unstable/socket/Socket";
|
import type * as Socket from "effect/unstable/socket/Socket";
|
||||||
import { errorMessage } from "@trustgraph/base";
|
import { errorMessage } from "@trustgraph/base";
|
||||||
import type { DispatcherManager } from "./dispatch/manager.js";
|
import type { DispatcherManager, DispatcherStreamError } from "./dispatch/manager.js";
|
||||||
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
import { DispatchError, DispatchPayload, DispatchStreamChunk, TrustGraphRpcs } from "./rpc-contract.js";
|
||||||
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
import { makeSocketRpcProtocol } from "./rpc-protocol.js";
|
||||||
|
|
||||||
|
|
@ -45,20 +45,14 @@ const makeGatewayRpcHandlers = (dispatcher: DispatcherManager) =>
|
||||||
catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
|
catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
|
||||||
}),
|
}),
|
||||||
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
|
DispatchStream: Effect.fn("GatewayRpc.DispatchStream")(function* (payload) {
|
||||||
const context = yield* Effect.context<never>();
|
|
||||||
const runPromise = Effect.runPromiseWith(context);
|
|
||||||
const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16);
|
const queue = yield* Queue.bounded<DispatchStreamChunk, DispatchError | Cause.Done>(16);
|
||||||
yield* Effect.addFinalizer(() => Queue.shutdown(queue));
|
yield* Effect.addFinalizer(() => Queue.shutdown(queue));
|
||||||
|
|
||||||
yield* Effect.tryPromise({
|
yield* dispatchStreamEffect(dispatcher, payload, (response, complete) =>
|
||||||
try: () =>
|
Queue.offer(queue, DispatchStreamChunk.make({ response, complete })),
|
||||||
dispatchStream(dispatcher, payload, (response, complete) =>
|
).pipe(
|
||||||
runPromise(Queue.offer(queue, DispatchStreamChunk.make({ response, complete }))).then(() => complete),
|
|
||||||
),
|
|
||||||
catch: (cause) => DispatchError.make({ message: errorMessage(cause) }),
|
|
||||||
}).pipe(
|
|
||||||
Effect.flatMap(() => Queue.end(queue)),
|
Effect.flatMap(() => Queue.end(queue)),
|
||||||
Effect.catch((error) => Queue.fail(queue, error)),
|
Effect.catch((cause) => Queue.fail(queue, DispatchError.make({ message: errorMessage(cause) }))),
|
||||||
Effect.forkScoped,
|
Effect.forkScoped,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -81,26 +75,23 @@ function dispatchOne(
|
||||||
return dispatcher.dispatchGlobalService(payload.service, payload.request);
|
return dispatcher.dispatchGlobalService(payload.service, payload.request);
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchStream(
|
function dispatchStreamEffect(
|
||||||
dispatcher: DispatcherManager,
|
dispatcher: DispatcherManager,
|
||||||
payload: DispatchPayload,
|
payload: DispatchPayload,
|
||||||
responder: (response: unknown, complete: boolean) => Promise<boolean>,
|
responder: (response: unknown, complete: boolean) => Effect.Effect<void>,
|
||||||
): Promise<void> {
|
): Effect.Effect<void, DispatcherStreamError> {
|
||||||
const send = (response: unknown, complete: boolean): Promise<void> =>
|
|
||||||
responder(response, complete).then(() => undefined);
|
|
||||||
|
|
||||||
if (payload.scope === "flow") {
|
if (payload.scope === "flow") {
|
||||||
return dispatcher.dispatchFlowServiceStreaming(
|
return dispatcher.dispatchFlowServiceStreamingEffect(
|
||||||
payload.flow ?? "default",
|
payload.flow ?? "default",
|
||||||
payload.service,
|
payload.service,
|
||||||
payload.request,
|
payload.request,
|
||||||
send,
|
responder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return dispatcher.dispatchGlobalServiceStreaming(
|
return dispatcher.dispatchGlobalServiceStreamingEffect(
|
||||||
payload.service,
|
payload.service,
|
||||||
payload.request,
|
payload.request,
|
||||||
send,
|
responder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue