mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 01:19:38 +02:00
Use Effect collections in gateway dispatcher
This commit is contained in:
parent
d19167b566
commit
7f9541e4fa
3 changed files with 132 additions and 82 deletions
|
|
@ -12,18 +12,20 @@ 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 Effect AI
|
Current signal counts from `ts/packages` after the 2026-06-02 dispatcher
|
||||||
adapter and native request/response PubSub slices:
|
Effect collections slice:
|
||||||
|
|
||||||
| Signal | Count |
|
| Signal | Count |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
| `Effect.runPromise` | 169 |
|
| `Effect.runPromise` | 175 |
|
||||||
| `Effect.runPromiseWith` | 0 |
|
| `Effect.runPromiseWith` | 0 |
|
||||||
| `Effect.cached` | 0 |
|
| `Effect.cached` | 0 |
|
||||||
| `Layer.succeed` | 12 |
|
| `Layer.succeed` | 13 |
|
||||||
| `Map<` | 37 |
|
| `Map<` | 86 |
|
||||||
| `WebSocket` | 72 |
|
| `WebSocket` | 72 |
|
||||||
| `new Map` | 59 |
|
| `new Map` | 56 |
|
||||||
|
| `new Set` | 15 |
|
||||||
|
| `Set<` | 9 |
|
||||||
| `toPromiseRequestor` | 0 |
|
| `toPromiseRequestor` | 0 |
|
||||||
| `makeAsyncProcessor` | 19 |
|
| `makeAsyncProcessor` | 19 |
|
||||||
| `receive(` | 17 |
|
| `receive(` | 17 |
|
||||||
|
|
@ -31,7 +33,7 @@ adapter and native request/response PubSub slices:
|
||||||
| `new Error` | 7 |
|
| `new Error` | 7 |
|
||||||
| `new Promise` | 9 |
|
| `new Promise` | 9 |
|
||||||
| `JSON.parse` | 4 |
|
| `JSON.parse` | 4 |
|
||||||
| `localStorage` | 11 |
|
| `localStorage` | 9 |
|
||||||
| `JSON.stringify` | 8 |
|
| `JSON.stringify` | 8 |
|
||||||
| `setTimeout` | 3 |
|
| `setTimeout` | 3 |
|
||||||
| `process.env` | 3 |
|
| `process.env` | 3 |
|
||||||
|
|
@ -45,9 +47,12 @@ Notes:
|
||||||
- `Effect.runPromise` is expected at external Promise compatibility
|
- `Effect.runPromise` is expected at external Promise compatibility
|
||||||
boundaries, but each match should still be audited for avoidable internal
|
boundaries, but each match should still be audited for avoidable internal
|
||||||
runtime ownership.
|
runtime ownership.
|
||||||
- The `Map<` and `new Map` counts increased in this snapshot because the
|
- The dispatcher Effect collections slice removed native `Map`/`Set` from the
|
||||||
Librarian slice introduced explicit ref-backed state types and clone helpers
|
gateway service registries, streaming membership set, and scoped requestor
|
||||||
while removing the service object's direct mutable maps/handles.
|
cache. Remaining broad `Map`/`Set` matches include tests/fakes, WeakMap
|
||||||
|
compatibility caches, short-lived pure traversal collections, and larger
|
||||||
|
ref-backed service state that still needs focused `HashMap`/`MutableHashMap`
|
||||||
|
cleanup.
|
||||||
- The `Effect.runPromise` and `WebSocket` counts dropped in this snapshot
|
- The `Effect.runPromise` and `WebSocket` counts dropped in this snapshot
|
||||||
because `EffectRpcClient` now owns its RPC/socket layer with
|
because `EffectRpcClient` now owns its RPC/socket layer with
|
||||||
`ManagedRuntime` and uses Effect's WebSocket constructor layer.
|
`ManagedRuntime` and uses Effect's WebSocket constructor layer.
|
||||||
|
|
@ -260,6 +265,29 @@ Notes:
|
||||||
- `bun run --cwd ts/packages/flow build`
|
- `bun run --cwd ts/packages/flow build`
|
||||||
- `bun run --cwd ts check:tsgo`
|
- `bun run --cwd ts check:tsgo`
|
||||||
|
|
||||||
|
### 2026-06-02: Gateway Dispatcher Effect Collections Slice
|
||||||
|
|
||||||
|
- Status: migrated and package-verified.
|
||||||
|
- Completed:
|
||||||
|
- `ts/packages/flow/src/gateway/dispatch/manager.ts` now stores the
|
||||||
|
flow/global service registries in `effect/HashMap` instead of native
|
||||||
|
`ReadonlyMap`, while explicit entry arrays preserve the public service-name
|
||||||
|
ordering.
|
||||||
|
- Streaming service membership now uses `effect/HashSet` instead of native
|
||||||
|
`Set`.
|
||||||
|
- The scoped requestor cache now stores
|
||||||
|
`HashMap<string, EffectRequestResponse<unknown, unknown>>` in the existing
|
||||||
|
`SynchronizedRef`, replacing `new Map` cloning with immutable
|
||||||
|
`HashMap.set`.
|
||||||
|
- Cache hits and service topic lookups now use `HashMap.get` plus
|
||||||
|
`effect/Option`, and ref update tuples use `effect/Tuple.make` instead of
|
||||||
|
`as const` assertions.
|
||||||
|
- Gateway dispatcher tests now cover concurrent same-key dispatches so the
|
||||||
|
cache still creates exactly one scoped producer/consumer pair.
|
||||||
|
- Verification:
|
||||||
|
- `bun run --cwd ts/packages/flow test -- src/__tests__/gateway-dispatcher.test.ts`
|
||||||
|
- `cd ts && bun run check:tsgo`
|
||||||
|
|
||||||
### 2026-06-02: Strict Base, CLI, MCP, And tsgo Slice
|
### 2026-06-02: Strict Base, CLI, MCP, And tsgo Slice
|
||||||
|
|
||||||
- Status: migrated, root-verified, committed, and pushed.
|
- Status: migrated, root-verified, committed, and pushed.
|
||||||
|
|
@ -1720,12 +1748,13 @@ Notes:
|
||||||
broker receive/error payload boundaries remain numeric milliseconds.
|
broker receive/error payload boundaries remain numeric milliseconds.
|
||||||
- Qdrant graph/doc known-collection caches now use
|
- Qdrant graph/doc known-collection caches now use
|
||||||
`MutableHashSet<string>`. Short-lived local traversal sets remain no-ops.
|
`MutableHashSet<string>`. Short-lived local traversal sets remain no-ops.
|
||||||
|
- Gateway dispatcher static service registries, streaming membership, and
|
||||||
|
scoped requestor cache now use Effect `HashMap`/`HashSet`.
|
||||||
- FlowManager and sibling service `() => Effect.gen(...)` factories remain a
|
- FlowManager and sibling service `() => Effect.gen(...)` factories remain a
|
||||||
broad mechanical `Effect.fn` / `Effect.fnUntraced` cleanup, best handled
|
broad mechanical `Effect.fn` / `Effect.fnUntraced` cleanup, best handled
|
||||||
after Duration and small collection slices.
|
after Duration and small collection slices.
|
||||||
- Long-lived `Map` / `Set` state in ref-backed services can move toward
|
- Long-lived `Map` / `Set` state in ref-backed services can move toward
|
||||||
Effect collections later; static lookup tables and local pure traversal
|
Effect collections later; local pure traversal maps/sets remain no-ops.
|
||||||
maps/sets remain no-ops.
|
|
||||||
|
|
||||||
## Ranked Findings
|
## Ranked Findings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,27 @@ describe("gateway dispatcher manager", () => {
|
||||||
expect(backend.closeCount).toBe(0);
|
expect(backend.closeCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serializes concurrent requestor creation for the same service", async () => {
|
||||||
|
const backend = new DispatchBackend();
|
||||||
|
const manager = makeDispatcherManager({
|
||||||
|
port: 0,
|
||||||
|
metricsPort: 0,
|
||||||
|
pubsub: backend,
|
||||||
|
});
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
manager.dispatchGlobalService("config", { operation: "get" }),
|
||||||
|
manager.dispatchGlobalService("config", { operation: "list" }),
|
||||||
|
]);
|
||||||
|
await manager.stop();
|
||||||
|
|
||||||
|
expect(first).toEqual({ ok: true, echo: { operation: "get" } });
|
||||||
|
expect(second).toEqual({ ok: true, echo: { operation: "list" } });
|
||||||
|
expect(backend.producerOptions.filter((options) => options.topic === "tg.flow.config-request")).toHaveLength(1);
|
||||||
|
expect(backend.consumerOptions.filter((options) => options.topic === "tg.flow.config-response")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not start requestors when request serialization fails", async () => {
|
it("does not start requestors when request serialization fails", async () => {
|
||||||
const backend = new DispatchBackend();
|
const backend = new DispatchBackend();
|
||||||
const manager = makeDispatcherManager({
|
const manager = makeDispatcherManager({
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
|
* Python reference: trustgraph-flow/trustgraph/gateway/dispatch/manager.py
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Clock, Effect, Exit, Random, Scope, SynchronizedRef } from "effect";
|
import { Clock, Effect, Exit, HashMap, HashSet, Option, Random, Scope, SynchronizedRef, Tuple } from "effect";
|
||||||
import {
|
import {
|
||||||
loadMessagingRuntimeConfig,
|
loadMessagingRuntimeConfig,
|
||||||
makeNatsBackend,
|
makeNatsBackend,
|
||||||
|
|
@ -51,36 +51,45 @@ export type DispatcherStreamError<E = never> =
|
||||||
* These are resolved within a specific flow's interface definitions.
|
* These are resolved within a specific flow's interface definitions.
|
||||||
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
|
* Topic pattern: tg.flow.<name>-request / tg.flow.<name>-response
|
||||||
*/
|
*/
|
||||||
const FLOW_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
|
interface ServiceTopics {
|
||||||
["agent", { request: "agent-request", response: "agent-response" }],
|
readonly request: string;
|
||||||
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
|
readonly response: string;
|
||||||
["prompt", { request: "prompt-request", response: "prompt-response" }],
|
}
|
||||||
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
|
|
||||||
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
|
const FLOW_SERVICE_ENTRIES: ReadonlyArray<readonly [string, ServiceTopics]> = [
|
||||||
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
|
["agent", { request: "agent-request", response: "agent-response" }],
|
||||||
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
|
["text-completion", { request: "text-completion-request", response: "text-completion-response" }],
|
||||||
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
|
["prompt", { request: "prompt-request", response: "prompt-response" }],
|
||||||
["triples", { request: "triples-request", response: "triples-response" }],
|
["graph-rag", { request: "graph-rag-request", response: "graph-rag-response" }],
|
||||||
["mcp-tool", { request: "mcp-tool-request", response: "mcp-tool-response" }],
|
["document-rag", { request: "document-rag-request", response: "document-rag-response" }],
|
||||||
]);
|
["embeddings", { request: "embeddings-request", response: "embeddings-response" }],
|
||||||
|
["graph-embeddings", { request: "graph-embeddings-request", response: "graph-embeddings-response" }],
|
||||||
|
["document-embeddings", { request: "doc-embeddings-request", response: "doc-embeddings-response" }],
|
||||||
|
["triples", { request: "triples-request", response: "triples-response" }],
|
||||||
|
["mcp-tool", { request: "mcp-tool-request", response: "mcp-tool-response" }],
|
||||||
|
];
|
||||||
|
|
||||||
|
const FLOW_SERVICES: HashMap.HashMap<string, ServiceTopics> = HashMap.fromIterable(FLOW_SERVICE_ENTRIES);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global services (not flow-scoped).
|
* Global services (not flow-scoped).
|
||||||
* These always use fixed topics regardless of which flow is active.
|
* These always use fixed topics regardless of which flow is active.
|
||||||
*/
|
*/
|
||||||
const GLOBAL_SERVICES: ReadonlyMap<string, { request: string; response: string }> = new Map([
|
const GLOBAL_SERVICE_ENTRIES: ReadonlyArray<readonly [string, ServiceTopics]> = [
|
||||||
["config", { request: "config-request", response: "config-response" }],
|
["config", { request: "config-request", response: "config-response" }],
|
||||||
["flow", { request: "flow-request", response: "flow-response" }],
|
["flow", { request: "flow-request", response: "flow-response" }],
|
||||||
["librarian", { request: "librarian-request", response: "librarian-response" }],
|
["librarian", { request: "librarian-request", response: "librarian-response" }],
|
||||||
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
|
["knowledge", { request: "knowledge-request", response: "knowledge-response" }],
|
||||||
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
|
["collection-management", { request: "collection-management-request", response: "collection-management-response" }],
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
const GLOBAL_SERVICES: HashMap.HashMap<string, ServiceTopics> = HashMap.fromIterable(GLOBAL_SERVICE_ENTRIES);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Services that support streaming responses (multiple messages per request).
|
* Services that support streaming responses (multiple messages per request).
|
||||||
* The completion flag is determined by checking for end-of-stream markers.
|
* The completion flag is determined by checking for end-of-stream markers.
|
||||||
*/
|
*/
|
||||||
const STREAMING_SERVICES = new Set([
|
const STREAMING_SERVICES = HashSet.make(
|
||||||
"agent",
|
"agent",
|
||||||
"text-completion",
|
"text-completion",
|
||||||
"graph-rag",
|
"graph-rag",
|
||||||
|
|
@ -88,7 +97,7 @@ const STREAMING_SERVICES = new Set([
|
||||||
"triples",
|
"triples",
|
||||||
"knowledge",
|
"knowledge",
|
||||||
"librarian",
|
"librarian",
|
||||||
]);
|
);
|
||||||
|
|
||||||
function topicName(name: string): string {
|
function topicName(name: string): string {
|
||||||
return `tg.flow.${name}`;
|
return `tg.flow.${name}`;
|
||||||
|
|
@ -138,15 +147,15 @@ export interface DispatcherManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dispatcherManagerFlowServiceNames = (): readonly string[] => [
|
export const dispatcherManagerFlowServiceNames = (): readonly string[] => [
|
||||||
...FLOW_SERVICES.keys(),
|
...FLOW_SERVICE_ENTRIES.map(([name]) => name),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const dispatcherManagerGlobalServiceNames = (): readonly string[] => [
|
export const dispatcherManagerGlobalServiceNames = (): readonly string[] => [
|
||||||
...GLOBAL_SERVICES.keys(),
|
...GLOBAL_SERVICE_ENTRIES.map(([name]) => name),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const dispatcherManagerIsStreamingService = (kind: string): boolean =>
|
export const dispatcherManagerIsStreamingService = (kind: string): boolean =>
|
||||||
STREAMING_SERVICES.has(kind);
|
HashSet.has(STREAMING_SERVICES, kind);
|
||||||
|
|
||||||
export const dispatcherManagerIsCompleteResponse = (response: unknown): boolean => {
|
export const dispatcherManagerIsCompleteResponse = (response: unknown): boolean => {
|
||||||
if (typeof response !== "object" || response === null) return true;
|
if (typeof response !== "object" || response === null) return true;
|
||||||
|
|
@ -164,7 +173,7 @@ export const dispatcherManagerIsCompleteResponse = (response: unknown): boolean
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type RequestorMap = Map<string, EffectRequestResponse<unknown, unknown>>;
|
type RequestorMap = HashMap.HashMap<string, EffectRequestResponse<unknown, unknown>>;
|
||||||
|
|
||||||
interface DispatcherRuntime {
|
interface DispatcherRuntime {
|
||||||
readonly scope: Scope.Closeable;
|
readonly scope: Scope.Closeable;
|
||||||
|
|
@ -191,7 +200,9 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const requestors = yield* SynchronizedRef.make<RequestorMap>(new Map());
|
const requestors = yield* SynchronizedRef.make(
|
||||||
|
HashMap.empty<string, EffectRequestResponse<unknown, unknown>>(),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
scope,
|
scope,
|
||||||
requestors,
|
requestors,
|
||||||
|
|
@ -246,60 +257,49 @@ export function makeDispatcherManager(config: GatewayConfig): DispatcherManager
|
||||||
) {
|
) {
|
||||||
const current = yield* ensureRuntimeEffect();
|
const current = yield* ensureRuntimeEffect();
|
||||||
|
|
||||||
return yield* SynchronizedRef.modifyEffect(current.requestors, (requestors) => {
|
return yield* SynchronizedRef.modifyEffect(current.requestors, (requestors) =>
|
||||||
const cached = requestors.get(key);
|
Option.match(HashMap.get(requestors, key), {
|
||||||
if (cached !== undefined) {
|
onNone: () =>
|
||||||
return Effect.succeed([cached, requestors] as const);
|
current.factory.make<unknown, unknown>({
|
||||||
}
|
requestTopic,
|
||||||
|
responseTopic,
|
||||||
return current.factory.make<unknown, unknown>({
|
subscription: `gateway-${key}`,
|
||||||
requestTopic,
|
}).pipe(
|
||||||
responseTopic,
|
Scope.provide(current.scope),
|
||||||
subscription: `gateway-${key}`,
|
Effect.map((requestor) => Tuple.make(requestor, HashMap.set(requestors, key, requestor))),
|
||||||
}).pipe(
|
),
|
||||||
Scope.provide(current.scope),
|
onSome: (cached) => Effect.succeed(Tuple.make(cached, requestors)),
|
||||||
Effect.map((requestor) => {
|
})
|
||||||
const next = new Map(requestors);
|
);
|
||||||
next.set(key, requestor);
|
|
||||||
return [requestor, next] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveGlobalTopics = (
|
const resolveGlobalTopics = (
|
||||||
kind: string,
|
kind: string,
|
||||||
): { requestTopic: string; responseTopic: string } => {
|
): { requestTopic: string; responseTopic: string } =>
|
||||||
const entry = GLOBAL_SERVICES.get(kind);
|
Option.match(HashMap.get(GLOBAL_SERVICES, kind), {
|
||||||
if (entry !== undefined) {
|
onNone: () => ({
|
||||||
return {
|
requestTopic: topicName(`${kind}-request`),
|
||||||
|
responseTopic: topicName(`${kind}-response`),
|
||||||
|
}),
|
||||||
|
onSome: (entry) => ({
|
||||||
requestTopic: topicName(entry.request),
|
requestTopic: topicName(entry.request),
|
||||||
responseTopic: topicName(entry.response),
|
responseTopic: topicName(entry.response),
|
||||||
};
|
}),
|
||||||
}
|
});
|
||||||
// Fallback: derive from kind name directly
|
|
||||||
return {
|
|
||||||
requestTopic: topicName(`${kind}-request`),
|
|
||||||
responseTopic: topicName(`${kind}-response`),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveFlowTopics = (
|
const resolveFlowTopics = (
|
||||||
kind: string,
|
kind: string,
|
||||||
): { requestTopic: string; responseTopic: string } => {
|
): { requestTopic: string; responseTopic: string } =>
|
||||||
const entry = FLOW_SERVICES.get(kind);
|
Option.match(HashMap.get(FLOW_SERVICES, kind), {
|
||||||
if (entry !== undefined) {
|
onNone: () => ({
|
||||||
return {
|
requestTopic: topicName(`${kind}-request`),
|
||||||
|
responseTopic: topicName(`${kind}-response`),
|
||||||
|
}),
|
||||||
|
onSome: (entry) => ({
|
||||||
requestTopic: topicName(entry.request),
|
requestTopic: topicName(entry.request),
|
||||||
responseTopic: topicName(entry.response),
|
responseTopic: topicName(entry.response),
|
||||||
};
|
}),
|
||||||
}
|
});
|
||||||
// Fallback: derive from kind name directly
|
|
||||||
return {
|
|
||||||
requestTopic: topicName(`${kind}-request`),
|
|
||||||
responseTopic: topicName(`${kind}-response`),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------- Global service dispatch ----------
|
// ---------- Global service dispatch ----------
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue