mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
saving
This commit is contained in:
parent
e8c7a4f6e0
commit
ffd97375a8
160 changed files with 6704 additions and 1895 deletions
|
|
@ -4,19 +4,30 @@
|
|||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./backend": "./src/backend/index.ts",
|
||||
"./messaging": "./src/messaging/index.ts",
|
||||
"./processor": "./src/processor/index.ts",
|
||||
"./runtime": "./src/runtime/index.ts",
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "bunx --bun tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run"
|
||||
"test": "bunx --bun vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"effect": "4.0.0-beta.65",
|
||||
"nats": "^2.29.0",
|
||||
"prom-client": "^15.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/vitest": "4.0.0-beta.65",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.1.0"
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type {
|
|||
CreateProducerOptions,
|
||||
CreateConsumerOptions,
|
||||
} from "../backend/types.js";
|
||||
import { TooManyRequestsError } from "../errors.js";
|
||||
import { tooManyRequestsError } from "../errors.js";
|
||||
import type { Flow } from "../processor/flow.js";
|
||||
|
||||
// ── Mock Message ──────────────────────────────────────────────────────
|
||||
|
|
@ -202,7 +202,7 @@ describe("Consumer", () => {
|
|||
const handler = vi.fn().mockImplementation(async () => {
|
||||
handlerCalls++;
|
||||
if (handlerCalls === 1) {
|
||||
throw new TooManyRequestsError("rate limited");
|
||||
throw tooManyRequestsError("rate limited");
|
||||
}
|
||||
// Second call succeeds
|
||||
});
|
||||
|
|
|
|||
266
ts/packages/base/src/__tests__/embeddings-service.test.ts
Normal file
266
ts/packages/base/src/__tests__/embeddings-service.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { ConfigProvider, Effect, Fiber } from "effect";
|
||||
import {
|
||||
Embeddings,
|
||||
EmbeddingsService,
|
||||
MessagingRuntimeLive,
|
||||
PubSub,
|
||||
embeddingsError,
|
||||
runProcessorScoped,
|
||||
topics,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type EmbeddingsRequest,
|
||||
type EmbeddingsResponse,
|
||||
type Message,
|
||||
type PubSubBackend,
|
||||
} from "../index.js";
|
||||
|
||||
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
|
||||
return {
|
||||
value: () => value,
|
||||
properties: () => properties,
|
||||
};
|
||||
}
|
||||
|
||||
const waitFor = (condition: () => boolean, label: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const deadline = Date.now() + 1000;
|
||||
const check = () => {
|
||||
if (condition()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 5);
|
||||
};
|
||||
check();
|
||||
}),
|
||||
catch: (error) => error,
|
||||
});
|
||||
|
||||
class RecordingProducer<T> implements BackendProducer<T> {
|
||||
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
class PushConsumer<T> implements BackendConsumer<T> {
|
||||
readonly acknowledged: Array<Message<T>> = [];
|
||||
readonly nacked: Array<Message<T>> = [];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EmbeddingsBackend implements PubSubBackend {
|
||||
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
|
||||
readonly consumersByTopic = new Map<string, PushConsumer<unknown>>();
|
||||
readonly producersByTopic = new Map<string, RecordingProducer<unknown>>();
|
||||
closeCount = 0;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producersByTopic.set(options.topic, producer);
|
||||
return producer as BackendProducer<T>;
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
const consumer = new PushConsumer<unknown>();
|
||||
this.consumersByTopic.set(options.topic, consumer);
|
||||
return consumer as BackendConsumer<T>;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
|
||||
pushConfig(): void {
|
||||
this.configConsumer.push(
|
||||
createMessage({
|
||||
version: 1,
|
||||
config: {
|
||||
flows: {
|
||||
default: {
|
||||
topics: {
|
||||
"embeddings-request": "embeddings-request-topic",
|
||||
"embeddings-response": "embeddings-response-topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const fastMessagingConfig = ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({
|
||||
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
|
||||
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
|
||||
TG_RATE_LIMIT_RETRY_MS: "1",
|
||||
TG_REQUEST_TIMEOUT_MS: "250",
|
||||
}),
|
||||
);
|
||||
|
||||
describe("EmbeddingsService", () => {
|
||||
it.effect(
|
||||
"handles embeddings requests through the Embeddings Context service",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new EmbeddingsBackend();
|
||||
const embeddingCalls: Array<{ readonly texts: ReadonlyArray<string>; readonly model?: string }> = [];
|
||||
const embeddings = Embeddings.of({
|
||||
embed: Effect.fn("TestEmbeddings.embed")((texts: ReadonlyArray<string>, model?: string) => {
|
||||
embeddingCalls.push(model === undefined ? { texts } : { texts, model });
|
||||
return Effect.succeed(texts.map((text, index) => [text.length, model?.length ?? 0, index]));
|
||||
}),
|
||||
});
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild);
|
||||
|
||||
backend.pushConfig();
|
||||
yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer");
|
||||
yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer");
|
||||
|
||||
const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer<EmbeddingsRequest>;
|
||||
const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer<EmbeddingsResponse>;
|
||||
|
||||
input.push(createMessage({ text: ["alpha", "beta"], model: "model-a" }, { id: "request-1" }));
|
||||
yield* waitFor(() => output.sent.length === 1, "embeddings response");
|
||||
|
||||
expect(embeddingCalls).toEqual([{ texts: ["alpha", "beta"], model: "model-a" }]);
|
||||
expect(output.sent).toEqual([
|
||||
{
|
||||
message: { vectors: [[5, 7, 0], [4, 7, 1]] },
|
||||
properties: { id: "request-1" },
|
||||
},
|
||||
]);
|
||||
expect(input.acknowledged.length).toBe(1);
|
||||
expect(input.nacked).toEqual([]);
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"returns a wire error response when the Embeddings service fails",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new EmbeddingsBackend();
|
||||
const embeddings = Embeddings.of({
|
||||
embed: Effect.fn("FailingEmbeddings.embed")(() =>
|
||||
Effect.fail(embeddingsError("test.embed", new Error("provider unavailable"), "test")),
|
||||
),
|
||||
});
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runService(backend, embeddings).pipe(Effect.forkChild);
|
||||
|
||||
backend.pushConfig();
|
||||
yield* waitFor(() => backend.consumersByTopic.has("embeddings-request-topic"), "embeddings consumer");
|
||||
yield* waitFor(() => backend.producersByTopic.has("embeddings-response-topic"), "embeddings producer");
|
||||
|
||||
const input = backend.consumersByTopic.get("embeddings-request-topic") as PushConsumer<EmbeddingsRequest>;
|
||||
const output = backend.producersByTopic.get("embeddings-response-topic") as RecordingProducer<EmbeddingsResponse>;
|
||||
|
||||
input.push(createMessage({ text: ["alpha"] }, { id: "request-1" }));
|
||||
yield* waitFor(() => output.sent.length === 1, "embeddings error response");
|
||||
|
||||
expect(output.sent).toEqual([
|
||||
{
|
||||
message: {
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: "provider unavailable",
|
||||
},
|
||||
},
|
||||
properties: { id: "request-1" },
|
||||
},
|
||||
]);
|
||||
expect(input.acknowledged.length).toBe(1);
|
||||
expect(input.nacked).toEqual([]);
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runService = (
|
||||
backend: EmbeddingsBackend,
|
||||
embeddings: Embeddings,
|
||||
) =>
|
||||
runProcessorScoped(
|
||||
{
|
||||
id: "embeddings",
|
||||
pubsubUrl: "nats://unused:4222",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new EmbeddingsService(config),
|
||||
).pipe(
|
||||
Effect.provideService(Embeddings, embeddings),
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
);
|
||||
215
ts/packages/base/src/__tests__/flow-processor-runtime.test.ts
Normal file
215
ts/packages/base/src/__tests__/flow-processor-runtime.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { ConfigProvider, Effect, Fiber } from "effect";
|
||||
import {
|
||||
FlowProcessor,
|
||||
MessagingRuntimeLive,
|
||||
ProducerSpec,
|
||||
PubSub,
|
||||
runProcessorScoped,
|
||||
topics,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type Message,
|
||||
type ProcessorConfig,
|
||||
type PubSubBackend,
|
||||
} from "../index.js";
|
||||
|
||||
function createMessage<T>(value: T, properties: Record<string, string> = {}): Message<T> {
|
||||
return {
|
||||
value: () => value,
|
||||
properties: () => properties,
|
||||
};
|
||||
}
|
||||
|
||||
const waitFor = (condition: () => boolean, label: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const deadline = Date.now() + 1000;
|
||||
const check = () => {
|
||||
if (condition()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
reject(new Error(`Timed out waiting for ${label}`));
|
||||
return;
|
||||
}
|
||||
setTimeout(check, 5);
|
||||
};
|
||||
check();
|
||||
}),
|
||||
catch: (error) => error,
|
||||
});
|
||||
|
||||
class RecordingProducer<T> implements BackendProducer<T> {
|
||||
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||
closeCount = 0;
|
||||
flushCount = 0;
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(properties === undefined ? { message } : { message, properties });
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class PushConsumer<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 FlowProcessorBackend implements PubSubBackend {
|
||||
readonly configConsumer = new PushConsumer<{ readonly version: number; readonly config: Record<string, unknown> }>();
|
||||
readonly producerOptions: Array<CreateProducerOptions> = [];
|
||||
readonly consumerOptions: Array<CreateConsumerOptions> = [];
|
||||
readonly producers: Array<RecordingProducer<unknown>> = [];
|
||||
closeCount = 0;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions.push(options);
|
||||
const producer = new RecordingProducer<unknown>();
|
||||
this.producers.push(producer);
|
||||
return producer as BackendProducer<T>;
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions.push(options);
|
||||
if (options.topic === topics.configPush) {
|
||||
return this.configConsumer as unknown as BackendConsumer<T>;
|
||||
}
|
||||
return new PushConsumer<T>();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
|
||||
pushConfig(version: number, flows: Record<string, unknown>): void {
|
||||
this.configConsumer.push(createMessage({ version, config: { flows } }));
|
||||
}
|
||||
}
|
||||
|
||||
class TestFlowProcessor extends FlowProcessor {
|
||||
constructor(
|
||||
config: ProcessorConfig,
|
||||
private readonly events: Array<string>,
|
||||
) {
|
||||
super(config);
|
||||
this.registerSpecification(new ProducerSpec<string>("output"));
|
||||
this.registerConfigHandler(async (_config, version) => {
|
||||
this.events.push(`handler:${version}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fastMessagingConfig = ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({
|
||||
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
|
||||
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
|
||||
TG_RATE_LIMIT_RETRY_MS: "1",
|
||||
TG_REQUEST_TIMEOUT_MS: "250",
|
||||
}),
|
||||
);
|
||||
|
||||
describe("Effect-native FlowProcessor runtime", () => {
|
||||
it.effect(
|
||||
"starts, restarts, and removes flow scopes from config pushes",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FlowProcessorBackend();
|
||||
const events: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* runProcessorScoped(
|
||||
{
|
||||
id: "flow-processor-test",
|
||||
pubsubUrl: "nats://unused:4222",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new TestFlowProcessor(config, events),
|
||||
).pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
Effect.forkChild,
|
||||
);
|
||||
|
||||
yield* waitFor(() => backend.consumerOptions.length === 1, "config subscription");
|
||||
|
||||
backend.pushConfig(1, { default: { topics: { output: "topic-a" } } });
|
||||
yield* waitFor(() => backend.producers.length === 1, "first flow producer");
|
||||
yield* waitFor(() => backend.configConsumer.acknowledged.length === 1, "first config ack");
|
||||
|
||||
backend.pushConfig(2, { default: { topics: { output: "topic-a" } } });
|
||||
yield* waitFor(() => backend.configConsumer.acknowledged.length === 2, "unchanged config ack");
|
||||
expect(backend.producers.length).toBe(1);
|
||||
|
||||
backend.pushConfig(3, { default: { topics: { output: "topic-b" } } });
|
||||
yield* waitFor(() => backend.producers.length === 2, "restarted flow producer");
|
||||
yield* waitFor(() => backend.producers[0]?.closeCount === 1, "old flow close");
|
||||
|
||||
backend.pushConfig(4, {});
|
||||
yield* waitFor(() => backend.producers[1]?.closeCount === 1, "removed flow close");
|
||||
|
||||
yield* Fiber.interrupt(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(backend.producerOptions.map((options) => options.topic)).toEqual(["topic-a", "topic-b"]);
|
||||
expect(events).toEqual(["handler:1", "handler:2", "handler:3", "handler:4"]);
|
||||
expect(backend.configConsumer.closeCount).toBeGreaterThanOrEqual(1);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
298
ts/packages/base/src/__tests__/flow-spec-runtime.test.ts
Normal file
298
ts/packages/base/src/__tests__/flow-spec-runtime.test.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { ConfigProvider, Duration, Effect, Fiber } from "effect";
|
||||
import * as TestClock from "effect/testing/TestClock";
|
||||
import {
|
||||
ConsumerSpec,
|
||||
Flow,
|
||||
MessagingRuntimeLive,
|
||||
ParameterSpec,
|
||||
ProducerSpec,
|
||||
PubSub,
|
||||
RequestResponseSpec,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type FlowContext,
|
||||
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 ScriptedConsumer<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;
|
||||
|
||||
constructor(
|
||||
messages: Array<Message<T>> = [],
|
||||
private readonly waitForMessages = false,
|
||||
) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
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.waitForMessages || 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;
|
||||
}
|
||||
}
|
||||
|
||||
const fastMessagingConfig = ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({
|
||||
TG_CONSUMER_RECEIVE_TIMEOUT_MS: "1",
|
||||
TG_CONSUMER_ERROR_BACKOFF_MS: "1",
|
||||
TG_RATE_LIMIT_RETRY_MS: "1",
|
||||
TG_REQUEST_TIMEOUT_MS: "250",
|
||||
}),
|
||||
);
|
||||
|
||||
const provideRuntime = <A, E, R>(
|
||||
backend: RuntimeBackend,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
effect.pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provideService(PubSub, PubSub.fromBackend(backend)),
|
||||
Effect.provide(fastMessagingConfig),
|
||||
);
|
||||
|
||||
describe("Effect-native flow specifications", () => {
|
||||
it.effect(
|
||||
"starts producer specs through Effect factories and exposes typed accessors",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new RuntimeBackend(new ScriptedConsumer<unknown>());
|
||||
const flow = new Flow(
|
||||
"default",
|
||||
"processor",
|
||||
backend,
|
||||
{ topics: { output: "actual-output" } },
|
||||
[new ProducerSpec<string>("output")],
|
||||
);
|
||||
|
||||
yield* Effect.scoped(
|
||||
provideRuntime(
|
||||
backend,
|
||||
Effect.gen(function* () {
|
||||
yield* flow.startEffect();
|
||||
const producer = yield* flow.producerEffect<string>("output");
|
||||
yield* producer.send("request-1", "hello");
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(backend.producerOptions).toEqual({ topic: "actual-output" });
|
||||
expect(backend.producer.sent).toEqual([
|
||||
{ message: "hello", properties: { id: "request-1" } },
|
||||
]);
|
||||
expect(backend.producer.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs Promise handlers through the explicit ConsumerSpec compatibility helper",
|
||||
Effect.fnUntraced(function* () {
|
||||
const message = createMessage("payload", { id: "request-1" });
|
||||
const consumer = new ScriptedConsumer<string>([message]);
|
||||
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
|
||||
const handled: Array<string> = [];
|
||||
const flow = new Flow(
|
||||
"default",
|
||||
"processor",
|
||||
backend,
|
||||
{},
|
||||
[
|
||||
ConsumerSpec.fromPromise<string>(
|
||||
"input",
|
||||
async (value, properties, flowContext: FlowContext) => {
|
||||
handled.push(`${flowContext.name}:${properties.id}:${value}`);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
yield* Effect.scoped(
|
||||
provideRuntime(
|
||||
backend,
|
||||
Effect.gen(function* () {
|
||||
yield* flow.startEffect();
|
||||
yield* Effect.yieldNow;
|
||||
yield* TestClock.adjust(Duration.millis(5));
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(consumer.acknowledged).toEqual([message]);
|
||||
expect(consumer.nacked).toEqual([]);
|
||||
expect(handled).toEqual(["default:request-1:payload"]);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"registers request-response specs through Effect queues and keeps the Promise facade working",
|
||||
Effect.fnUntraced(function* () {
|
||||
const responseConsumer = new ScriptedConsumer<string>([], true);
|
||||
const backend = new RuntimeBackend(
|
||||
responseConsumer as BackendConsumer<unknown>,
|
||||
(_message, properties) => {
|
||||
responseConsumer.push(createMessage("response", { id: properties?.id ?? "" }));
|
||||
},
|
||||
);
|
||||
const flow = new Flow(
|
||||
"default",
|
||||
"processor",
|
||||
backend,
|
||||
{
|
||||
topics: {
|
||||
request: "actual-request",
|
||||
response: "actual-response",
|
||||
},
|
||||
},
|
||||
[new RequestResponseSpec<string, string>("rr", "request", "response")],
|
||||
);
|
||||
|
||||
const response = yield* Effect.scoped(
|
||||
provideRuntime(
|
||||
backend,
|
||||
Effect.gen(function* () {
|
||||
yield* flow.startEffect();
|
||||
const requestor = flow.requestor<string, string>("rr");
|
||||
const fiber = yield* Effect.promise(() =>
|
||||
requestor.request("request", { timeoutMs: 250 }),
|
||||
).pipe(Effect.forkChild);
|
||||
yield* TestClock.adjust(Duration.millis(5));
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(response).toBe("response");
|
||||
expect(backend.producerOptions).toEqual({ topic: "actual-request" });
|
||||
expect(responseConsumer.acknowledged.length).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"returns typed errors for missing flow resources",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new RuntimeBackend(new ScriptedConsumer<unknown>());
|
||||
const flow = new Flow(
|
||||
"default",
|
||||
"processor",
|
||||
backend,
|
||||
{ parameters: { present: 42 } },
|
||||
[new ParameterSpec("present")],
|
||||
);
|
||||
|
||||
const errors = yield* Effect.scoped(
|
||||
provideRuntime(
|
||||
backend,
|
||||
Effect.gen(function* () {
|
||||
yield* flow.startEffect();
|
||||
const producerError = yield* flow.producerEffect<string>("missing-producer").pipe(Effect.flip);
|
||||
const parameter = yield* flow.parameterEffect<number>("present");
|
||||
const parameterError = yield* flow.parameterEffect<number>("missing-parameter").pipe(Effect.flip);
|
||||
return { producerError, parameter, parameterError };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(errors.parameter).toBe(42);
|
||||
expect(errors.producerError._tag).toBe("FlowResourceNotFoundError");
|
||||
expect(errors.producerError.resourceType).toBe("producer");
|
||||
expect(errors.producerError.resourceName).toBe("missing-producer");
|
||||
expect(errors.parameterError._tag).toBe("FlowResourceNotFoundError");
|
||||
expect(errors.parameterError.resourceType).toBe("parameter");
|
||||
expect(() => flow.producer("missing-producer")).toThrow("not found");
|
||||
}),
|
||||
);
|
||||
});
|
||||
277
ts/packages/base/src/__tests__/messaging-runtime.test.ts
Normal file
277
ts/packages/base/src/__tests__/messaging-runtime.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Duration, Effect, Fiber } from "effect";
|
||||
import * as TestClock from "effect/testing/TestClock";
|
||||
import {
|
||||
PubSub,
|
||||
defaultMessagingRuntimeConfig,
|
||||
makeEffectRequestResponseFromPubSub,
|
||||
MessagingRuntimeLive,
|
||||
ProducerSpec,
|
||||
runEffectConsumerScoped,
|
||||
runEffectProducerScoped,
|
||||
runFlowScoped,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type FlowContext,
|
||||
type Message,
|
||||
type PubSubBackend,
|
||||
} from "../index.js";
|
||||
import type { Flow } from "../processor/flow.js";
|
||||
import { Flow as RuntimeFlow } from "../processor/flow.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 ScriptedConsumer<T> implements BackendConsumer<T> {
|
||||
readonly acknowledged: Array<Message<T>> = [];
|
||||
readonly nacked: Array<Message<T>> = [];
|
||||
closeCount = 0;
|
||||
private readonly messages: Array<Message<T>>;
|
||||
|
||||
constructor(messages: Array<Message<T>> = []) {
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
push(message: Message<T>): void {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
const message = this.messages.shift();
|
||||
if (message !== undefined) {
|
||||
return message;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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.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;
|
||||
}
|
||||
}
|
||||
|
||||
const flowContext: FlowContext = {
|
||||
id: "processor",
|
||||
name: "default",
|
||||
flow: {} as Flow,
|
||||
};
|
||||
|
||||
describe("Effect-native messaging runtime", () => {
|
||||
it.effect(
|
||||
"creates scoped producers through PubSub and translates send calls",
|
||||
Effect.fnUntraced(function* () {
|
||||
const consumer = new ScriptedConsumer<unknown>();
|
||||
const backend = new RuntimeBackend(consumer);
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const producer = yield* runEffectProducerScoped<string>({ topic: "tg.test.producer" });
|
||||
yield* producer.send("message-1", "hello");
|
||||
|
||||
expect(backend.producerOptions).toEqual({ topic: "tg.test.producer" });
|
||||
expect(backend.producer.sent).toEqual([
|
||||
{ message: "hello", properties: { id: "message-1" } },
|
||||
]);
|
||||
}).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(backend.producer.closeCount).toBe(1);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs consumers as scoped fibers and acknowledges handled messages",
|
||||
Effect.fnUntraced(function* () {
|
||||
const message = createMessage("payload", { id: "request-1" });
|
||||
const consumer = new ScriptedConsumer<string>([message]);
|
||||
const backend = new RuntimeBackend(consumer as BackendConsumer<unknown>);
|
||||
const handled: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
yield* runEffectConsumerScoped<string>(
|
||||
{
|
||||
topic: "tg.test.consumer",
|
||||
subscription: "sub",
|
||||
receiveTimeoutMs: 1,
|
||||
errorBackoffMs: 1,
|
||||
handler: (value, properties) =>
|
||||
Effect.sync(() => {
|
||||
handled.push(`${properties.id}:${value}`);
|
||||
}),
|
||||
},
|
||||
flowContext,
|
||||
);
|
||||
yield* TestClock.adjust(Duration.millis(20));
|
||||
}).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(handled).toEqual(["request-1:payload"]);
|
||||
expect(consumer.acknowledged).toEqual([message]);
|
||||
expect(consumer.nacked).toEqual([]);
|
||||
expect(consumer.closeCount).toBeGreaterThan(0);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"routes request-response replies through an Effect queue",
|
||||
Effect.fnUntraced(function* () {
|
||||
const responseConsumer = new ScriptedConsumer<string>();
|
||||
const backend = new RuntimeBackend(
|
||||
responseConsumer as BackendConsumer<unknown>,
|
||||
(_message, properties) => {
|
||||
responseConsumer.push(createMessage("response", { id: properties?.id ?? "" }));
|
||||
},
|
||||
);
|
||||
|
||||
const response = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const requestor = yield* makeEffectRequestResponseFromPubSub<string, string>(
|
||||
PubSub.fromBackend(backend),
|
||||
{
|
||||
...defaultMessagingRuntimeConfig,
|
||||
consumerReceiveTimeoutMs: 1,
|
||||
},
|
||||
{
|
||||
requestTopic: "tg.test.request",
|
||||
responseTopic: "tg.test.response",
|
||||
subscription: "sub",
|
||||
},
|
||||
);
|
||||
const fiber = yield* requestor.request("request", { timeoutMs: 250 }).pipe(Effect.forkChild);
|
||||
yield* TestClock.adjust(Duration.millis(5));
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response).toBe("response");
|
||||
expect(backend.producer.sent[0]?.message).toBe("request");
|
||||
expect(responseConsumer.acknowledged.length).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"fails request-response calls with a typed timeout",
|
||||
Effect.fnUntraced(function* () {
|
||||
const responseConsumer = new ScriptedConsumer<string>();
|
||||
const backend = new RuntimeBackend(responseConsumer as BackendConsumer<unknown>);
|
||||
|
||||
const error = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const requestor = yield* makeEffectRequestResponseFromPubSub<string, string>(
|
||||
PubSub.fromBackend(backend),
|
||||
{
|
||||
...defaultMessagingRuntimeConfig,
|
||||
consumerReceiveTimeoutMs: 1,
|
||||
},
|
||||
{
|
||||
requestTopic: "tg.test.request",
|
||||
responseTopic: "tg.test.response",
|
||||
subscription: "sub",
|
||||
},
|
||||
);
|
||||
const fiber = yield* requestor.request("request", { timeoutMs: 5 }).pipe(
|
||||
Effect.flip,
|
||||
Effect.forkChild,
|
||||
);
|
||||
yield* TestClock.adjust(Duration.millis(10));
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error._tag).toBe("MessagingTimeoutError");
|
||||
expect(error.operation).toBe("request-response");
|
||||
expect(error.timeoutMs).toBe(5);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"owns Flow lifecycle through a scoped Effect boundary",
|
||||
Effect.fnUntraced(function* () {
|
||||
const consumer = new ScriptedConsumer<unknown>();
|
||||
const backend = new RuntimeBackend(consumer);
|
||||
const flow = new RuntimeFlow(
|
||||
"flow-a",
|
||||
"processor",
|
||||
backend,
|
||||
{},
|
||||
[new ProducerSpec<string>("flow-output")],
|
||||
);
|
||||
|
||||
yield* Effect.scoped(
|
||||
runFlowScoped(flow).pipe(
|
||||
Effect.provide(MessagingRuntimeLive),
|
||||
Effect.provideService(PubSub, PubSub.fromBackend(backend)),
|
||||
),
|
||||
);
|
||||
|
||||
expect(backend.producerOptions).toEqual({ topic: "flow-output" });
|
||||
expect(backend.producer.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
});
|
||||
240
ts/packages/base/src/__tests__/runtime-services.test.ts
Normal file
240
ts/packages/base/src/__tests__/runtime-services.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { Effect } from "effect";
|
||||
import {
|
||||
AsyncProcessor,
|
||||
PubSub,
|
||||
runProcessorScoped,
|
||||
type BackendConsumer,
|
||||
type BackendProducer,
|
||||
type CreateConsumerOptions,
|
||||
type CreateProducerOptions,
|
||||
type Message,
|
||||
type ProcessorConfig,
|
||||
type PubSubBackend,
|
||||
} from "../index.js";
|
||||
|
||||
class FakeProducer<T> implements BackendProducer<T> {
|
||||
readonly sent: Array<{ readonly message: T; readonly properties?: Record<string, string> }> = [];
|
||||
closeCount = 0;
|
||||
flushCount = 0;
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
this.sent.push(
|
||||
properties === undefined
|
||||
? { message }
|
||||
: { message, properties },
|
||||
);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
this.flushCount += 1;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeConsumer<T> implements BackendConsumer<T> {
|
||||
closeCount = 0;
|
||||
|
||||
async receive(): Promise<Message<T> | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async acknowledge(): Promise<void> {}
|
||||
|
||||
async negativeAcknowledge(): Promise<void> {}
|
||||
|
||||
async unsubscribe(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakePubSubBackend implements PubSubBackend {
|
||||
closeCount = 0;
|
||||
producerOptions: CreateProducerOptions | null = null;
|
||||
consumerOptions: CreateConsumerOptions | null = null;
|
||||
|
||||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
this.producerOptions = options;
|
||||
return new FakeProducer<T>();
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
this.consumerOptions = options;
|
||||
return new FakeConsumer<T>();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FailingProducerBackend extends FakePubSubBackend {
|
||||
override async createProducer<T>(): Promise<BackendProducer<T>> {
|
||||
throw new Error("producer unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
class RecordingProcessor extends AsyncProcessor {
|
||||
constructor(
|
||||
config: ProcessorConfig,
|
||||
private readonly events: Array<string>,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
protected async run(): Promise<void> {
|
||||
this.events.push(`run:${this.config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
this.events.push("stop");
|
||||
await super.stop();
|
||||
}
|
||||
}
|
||||
|
||||
class FailingProcessor extends AsyncProcessor {
|
||||
protected async run(): Promise<void> {
|
||||
throw new Error("processor failed");
|
||||
}
|
||||
}
|
||||
|
||||
class NativeRecordingProcessor extends AsyncProcessor<never, PubSub> {
|
||||
constructor(
|
||||
config: ProcessorConfig,
|
||||
private readonly events: Array<string>,
|
||||
) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
protected override runEffect() {
|
||||
const events = this.events;
|
||||
const config = this.config;
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
events.push(`native:${config.manageProcessSignals === false ? "effect-signals" : "class-signals"}`);
|
||||
events.push(`pubsub:${pubsub.backend.constructor.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
override stopEffect() {
|
||||
this.events.push("native-stop");
|
||||
return super.stopEffect();
|
||||
}
|
||||
}
|
||||
|
||||
describe("Effect runtime services", () => {
|
||||
it.effect(
|
||||
"provides a compatibility backend through the PubSub service",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FakePubSubBackend();
|
||||
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
const producer = yield* pubsub.createProducer<string>({ topic: "tg.test.topic" });
|
||||
yield* Effect.promise(() => producer.send("hello", { id: "1" }));
|
||||
|
||||
expect(backend.producerOptions).toEqual({ topic: "tg.test.topic" });
|
||||
expect(pubsub.backend).toBe(backend);
|
||||
}).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"maps backend failures into PubSubError",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FailingProducerBackend();
|
||||
|
||||
const error = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
return yield* pubsub.createProducer<string>({ topic: "tg.test.failure" }).pipe(Effect.flip);
|
||||
}).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(error._tag).toBe("PubSubError");
|
||||
expect(error.operation).toBe("createProducer:tg.test.failure");
|
||||
expect(error.message).toBe("producer unavailable");
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs a processor with injected PubSub and scoped finalization",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FakePubSubBackend();
|
||||
const events: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
runProcessorScoped(
|
||||
{
|
||||
id: "recording",
|
||||
pubsubUrl: "nats://unused:4222",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new RecordingProcessor(config, events),
|
||||
).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(events).toEqual(["run:effect-signals", "stop"]);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"runs native processor lifecycle hooks with Effect requirements",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FakePubSubBackend();
|
||||
const events: Array<string> = [];
|
||||
|
||||
yield* Effect.scoped(
|
||||
runProcessorScoped(
|
||||
{
|
||||
id: "native-recording",
|
||||
pubsubUrl: "nats://unused:4222",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new NativeRecordingProcessor(config, events),
|
||||
).pipe(Effect.provide(PubSub.layer(backend))),
|
||||
);
|
||||
|
||||
expect(events).toEqual(["native:effect-signals", "pubsub:FakePubSubBackend", "native-stop"]);
|
||||
expect(backend.closeCount).toBe(1);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"maps processor start failures into ProcessorLifecycleError",
|
||||
Effect.fnUntraced(function* () {
|
||||
const backend = new FakePubSubBackend();
|
||||
|
||||
const error = yield* Effect.scoped(
|
||||
runProcessorScoped(
|
||||
{
|
||||
id: "failing",
|
||||
metricsPort: 8000,
|
||||
manageProcessSignals: true,
|
||||
},
|
||||
(config) => new FailingProcessor(config),
|
||||
).pipe(
|
||||
Effect.provide(PubSub.layer(backend)),
|
||||
Effect.flip,
|
||||
),
|
||||
);
|
||||
|
||||
expect(error._tag).toBe("ProcessorLifecycleError");
|
||||
expect(error.operation).toBe("start");
|
||||
expect(error.processorId).toBe("failing");
|
||||
expect(error.message).toBe("processor failed");
|
||||
}),
|
||||
);
|
||||
});
|
||||
95
ts/packages/base/src/__tests__/schema-effect.test.ts
Normal file
95
ts/packages/base/src/__tests__/schema-effect.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from "@effect/vitest";
|
||||
import { ConfigProvider, Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import {
|
||||
ConfigRequest,
|
||||
GraphRagResponse,
|
||||
Term,
|
||||
TextCompletionRequest,
|
||||
loadProcessorRuntimeConfig,
|
||||
} from "../index.js";
|
||||
|
||||
describe("Effect schemas", () => {
|
||||
it.effect(
|
||||
"decode existing text-completion wire payloads",
|
||||
Effect.fnUntraced(function* () {
|
||||
const request = yield* S.decodeUnknownEffect(TextCompletionRequest)({
|
||||
system: "system",
|
||||
prompt: "hello",
|
||||
streaming: true,
|
||||
});
|
||||
|
||||
expect(request.prompt).toBe("hello");
|
||||
expect(request.streaming).toBe(true);
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"decode recursive RDF terms",
|
||||
Effect.fnUntraced(function* () {
|
||||
const term = yield* S.decodeUnknownEffect(Term)({
|
||||
type: "TRIPLE",
|
||||
triple: {
|
||||
s: { type: "IRI", iri: "urn:s" },
|
||||
p: { type: "IRI", iri: "urn:p" },
|
||||
o: { type: "LITERAL", value: "object" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(term.type).toBe("TRIPLE");
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"preserve gateway response extension fields",
|
||||
Effect.fnUntraced(function* () {
|
||||
const response = yield* S.decodeUnknownEffect(GraphRagResponse)({
|
||||
response: "ok",
|
||||
message_type: "explain",
|
||||
explain_id: "e1",
|
||||
providerTrace: { kept: true },
|
||||
});
|
||||
|
||||
expect(response.providerTrace).toEqual({ kept: true });
|
||||
}),
|
||||
);
|
||||
|
||||
it.effect(
|
||||
"decode config requests",
|
||||
Effect.fnUntraced(function* () {
|
||||
const request = yield* S.decodeUnknownEffect(ConfigRequest)({
|
||||
operation: "put",
|
||||
keys: ["flows"],
|
||||
values: { default: { topics: {} } },
|
||||
});
|
||||
|
||||
expect(request.operation).toBe("put");
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Effect runtime config", () => {
|
||||
it.effect(
|
||||
"loads processor settings from existing env names",
|
||||
Effect.fnUntraced(function* () {
|
||||
const provider = ConfigProvider.fromEnv({
|
||||
env: {
|
||||
NATS_URL: "nats://example:4222",
|
||||
METRICS_PORT: "9000",
|
||||
},
|
||||
});
|
||||
|
||||
const config = yield* Effect.provide(
|
||||
loadProcessorRuntimeConfig("svc", { manageProcessSignals: false }),
|
||||
ConfigProvider.layer(provider),
|
||||
);
|
||||
|
||||
expect(config).toEqual({
|
||||
id: "svc",
|
||||
pubsubUrl: "nats://example:4222",
|
||||
metricsPort: 9000,
|
||||
manageProcessSignals: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -10,3 +10,11 @@ export type {
|
|||
} from "./types.js";
|
||||
|
||||
export { NatsBackend } from "./nats.js";
|
||||
export {
|
||||
PubSub,
|
||||
NatsPubSubLive,
|
||||
makeNatsPubSubLayer,
|
||||
makePubSubService,
|
||||
pubSubLayer,
|
||||
type PubSubService,
|
||||
} from "./pubsub.js";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
AckPolicy,
|
||||
DeliverPolicy,
|
||||
} from "nats";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
import type {
|
||||
PubSubBackend,
|
||||
|
|
@ -34,12 +35,11 @@ const sc = StringCodec();
|
|||
class NatsMessage<T> implements Message<T> {
|
||||
/** Exposed so acknowledge/negativeAcknowledge can access the raw JsMsg */
|
||||
readonly _jsMsg: JsMsg;
|
||||
private readonly decoded: T;
|
||||
|
||||
constructor(
|
||||
msg: JsMsg,
|
||||
private readonly decoded: T,
|
||||
) {
|
||||
constructor(msg: JsMsg, decoded: T) {
|
||||
this._jsMsg = msg;
|
||||
this.decoded = decoded;
|
||||
}
|
||||
|
||||
value(): T {
|
||||
|
|
@ -49,9 +49,12 @@ class NatsMessage<T> implements Message<T> {
|
|||
properties(): Record<string, string> {
|
||||
const headers = this._jsMsg.headers;
|
||||
const props: Record<string, string> = {};
|
||||
if (headers) {
|
||||
if (headers !== undefined) {
|
||||
for (const [key, values] of headers) {
|
||||
props[key] = values[0];
|
||||
const value = values[0];
|
||||
if (value !== undefined) {
|
||||
props[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return props;
|
||||
|
|
@ -59,16 +62,24 @@ class NatsMessage<T> implements Message<T> {
|
|||
}
|
||||
|
||||
class NatsProducer<T> implements BackendProducer<T> {
|
||||
constructor(
|
||||
private readonly js: JetStreamClient,
|
||||
private readonly subject: string,
|
||||
) {}
|
||||
private readonly js: JetStreamClient;
|
||||
private readonly subject: string;
|
||||
private readonly schema: S.Top | undefined;
|
||||
|
||||
constructor(js: JetStreamClient, subject: string, schema?: S.Top) {
|
||||
this.js = js;
|
||||
this.subject = subject;
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
async send(message: T, properties?: Record<string, string>): Promise<void> {
|
||||
const data = sc.encode(JSON.stringify(message));
|
||||
const encoded = this.schema !== undefined
|
||||
? S.encodeUnknownSync(this.schema as S.Codec<unknown, unknown>)(message)
|
||||
: message;
|
||||
const data = sc.encode(JSON.stringify(encoded));
|
||||
const opts: Record<string, unknown> = {};
|
||||
|
||||
if (properties && Object.keys(properties).length > 0) {
|
||||
if (properties !== undefined && Object.keys(properties).length > 0) {
|
||||
const { headers } = await import("nats");
|
||||
const hdrs = headers();
|
||||
for (const [key, val] of Object.entries(properties)) {
|
||||
|
|
@ -91,15 +102,31 @@ class NatsProducer<T> implements BackendProducer<T> {
|
|||
|
||||
class NatsConsumer<T> implements BackendConsumer<T> {
|
||||
private consumer: NatsJsConsumer | null = null;
|
||||
private readonly js: JetStreamClient;
|
||||
private readonly jsm: JetStreamManager;
|
||||
private readonly subject: string;
|
||||
private readonly subscription: string;
|
||||
private readonly initialPosition: "latest" | "earliest";
|
||||
private readonly streamName: string;
|
||||
private readonly schema: S.Top | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly js: JetStreamClient,
|
||||
private readonly jsm: JetStreamManager,
|
||||
private readonly subject: string,
|
||||
private readonly subscription: string,
|
||||
private readonly initialPosition: "latest" | "earliest",
|
||||
private readonly streamName: string,
|
||||
) {}
|
||||
js: JetStreamClient,
|
||||
jsm: JetStreamManager,
|
||||
subject: string,
|
||||
subscription: string,
|
||||
initialPosition: "latest" | "earliest",
|
||||
streamName: string,
|
||||
schema?: S.Top,
|
||||
) {
|
||||
this.js = js;
|
||||
this.jsm = jsm;
|
||||
this.subject = subject;
|
||||
this.subscription = subscription;
|
||||
this.initialPosition = initialPosition;
|
||||
this.streamName = streamName;
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
// Stream is already ensured by NatsBackend.ensureStream().
|
||||
|
|
@ -124,14 +151,17 @@ class NatsConsumer<T> implements BackendConsumer<T> {
|
|||
}
|
||||
|
||||
async receive(timeoutMs = 2000): Promise<Message<T> | null> {
|
||||
if (!this.consumer) throw new Error("Consumer not initialized");
|
||||
if (this.consumer === null) throw new Error("Consumer not initialized");
|
||||
|
||||
// Pull a single message with a timeout using the pull-based API.
|
||||
// consumer.next() returns a JsMsg or null when the timeout expires.
|
||||
const msg = await this.consumer.next({ expires: timeoutMs });
|
||||
if (!msg) return null;
|
||||
if (msg === null) return null;
|
||||
|
||||
const decoded = JSON.parse(sc.decode(msg.data)) as T;
|
||||
const parsed = JSON.parse(sc.decode(msg.data));
|
||||
const decoded = this.schema !== undefined
|
||||
? S.decodeUnknownSync(this.schema as S.Codec<unknown, unknown>)(parsed) as T
|
||||
: parsed as T;
|
||||
return new NatsMessage(msg, decoded);
|
||||
}
|
||||
|
||||
|
|
@ -161,11 +191,14 @@ export class NatsBackend implements PubSubBackend {
|
|||
private js: JetStreamClient | null = null;
|
||||
private jsm: JetStreamManager | null = null;
|
||||
private initializedStreams = new Set<string>();
|
||||
private readonly url: string;
|
||||
|
||||
constructor(private readonly url: string = "nats://localhost:4222") {}
|
||||
constructor(url = "nats://localhost:4222") {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
private async ensureConnected(): Promise<void> {
|
||||
if (!this.connection) {
|
||||
if (this.connection === null) {
|
||||
this.connection = await connect({ servers: this.url });
|
||||
this.js = this.connection.jetstream();
|
||||
this.jsm = await this.connection.jetstreamManager();
|
||||
|
|
@ -184,10 +217,13 @@ export class NatsBackend implements PubSubBackend {
|
|||
|
||||
const wildcardSubject = `${parts.slice(0, 2).join(".")}.>`;
|
||||
|
||||
const jsm = this.jsm;
|
||||
if (jsm === null) throw new Error("NATS backend not connected");
|
||||
|
||||
try {
|
||||
await this.jsm!.streams.info(streamName);
|
||||
await jsm.streams.info(streamName);
|
||||
} catch {
|
||||
await this.jsm!.streams.add({
|
||||
await jsm.streams.add({
|
||||
name: streamName,
|
||||
subjects: [wildcardSubject],
|
||||
});
|
||||
|
|
@ -199,26 +235,32 @@ export class NatsBackend implements PubSubBackend {
|
|||
async createProducer<T>(options: CreateProducerOptions): Promise<BackendProducer<T>> {
|
||||
await this.ensureConnected();
|
||||
await this.ensureStream(options.topic);
|
||||
return new NatsProducer<T>(this.js!, options.topic);
|
||||
const js = this.js;
|
||||
if (js === null) throw new Error("NATS backend not connected");
|
||||
return new NatsProducer<T>(js, options.topic, options.schema);
|
||||
}
|
||||
|
||||
async createConsumer<T>(options: CreateConsumerOptions): Promise<BackendConsumer<T>> {
|
||||
await this.ensureConnected();
|
||||
const streamName = await this.ensureStream(options.topic);
|
||||
const js = this.js;
|
||||
const jsm = this.jsm;
|
||||
if (js === null || jsm === null) throw new Error("NATS backend not connected");
|
||||
const consumer = new NatsConsumer<T>(
|
||||
this.js!,
|
||||
this.jsm!,
|
||||
js,
|
||||
jsm,
|
||||
options.topic,
|
||||
options.subscription,
|
||||
options.initialPosition ?? "latest",
|
||||
streamName,
|
||||
options.schema,
|
||||
);
|
||||
await consumer.init();
|
||||
return consumer;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.connection) {
|
||||
if (this.connection !== null) {
|
||||
await this.connection.drain();
|
||||
this.connection = null;
|
||||
this.js = null;
|
||||
|
|
|
|||
101
ts/packages/base/src/backend/pubsub.ts
Normal file
101
ts/packages/base/src/backend/pubsub.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Effect-native pub/sub capability for runtime composition.
|
||||
*
|
||||
* The existing Promise-based backend protocol stays available as the
|
||||
* compatibility bridge while service code moves to `Context.Service`/Layers.
|
||||
*/
|
||||
|
||||
import { Config, Context, Effect, Layer } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import type {
|
||||
BackendConsumer,
|
||||
BackendProducer,
|
||||
CreateConsumerOptions,
|
||||
CreateProducerOptions,
|
||||
PubSubBackend,
|
||||
} from "./types.js";
|
||||
import { NatsBackend } from "./nats.js";
|
||||
import { pubSubError } from "../errors.js";
|
||||
|
||||
export interface PubSubService {
|
||||
readonly backend: PubSubBackend;
|
||||
readonly createProducer: <T>(
|
||||
options: CreateProducerOptions,
|
||||
) => Effect.Effect<BackendProducer<T>, ReturnType<typeof pubSubError>>;
|
||||
readonly createConsumer: <T>(
|
||||
options: CreateConsumerOptions,
|
||||
) => Effect.Effect<BackendConsumer<T>, ReturnType<typeof pubSubError>>;
|
||||
readonly close: Effect.Effect<void, ReturnType<typeof pubSubError>>;
|
||||
}
|
||||
|
||||
export class PubSub extends Context.Service<PubSub, PubSubService>()("@trustgraph/base/backend/pubsub") {
|
||||
static fromBackend(backend: PubSubBackend): PubSubService {
|
||||
return makePubSubService(backend);
|
||||
}
|
||||
|
||||
static layer(backend: PubSubBackend): Layer.Layer<PubSub> {
|
||||
return pubSubLayer(backend);
|
||||
}
|
||||
}
|
||||
|
||||
export function makePubSubService(backend: PubSubBackend): PubSubService {
|
||||
return {
|
||||
backend,
|
||||
createProducer: <T>(options: CreateProducerOptions) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.createProducer<T>(options),
|
||||
catch: (error) => pubSubError(`createProducer:${options.topic}`, error),
|
||||
}),
|
||||
createConsumer: <T>(options: CreateConsumerOptions) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.createConsumer<T>(options),
|
||||
catch: (error) => pubSubError(`createConsumer:${options.topic}`, error),
|
||||
}),
|
||||
close: Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => pubSubError("close", error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function pubSubLayer(backend: PubSubBackend): Layer.Layer<PubSub> {
|
||||
return Layer.effect(PubSub)(
|
||||
Effect.gen(function* () {
|
||||
const service = makePubSubService(backend);
|
||||
yield* Effect.addFinalizer(() =>
|
||||
service.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PubSub] Failed to close backend", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return PubSub.of(service);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function makeNatsPubSubLayer(url = "nats://localhost:4222"): Layer.Layer<PubSub> {
|
||||
return pubSubLayer(new NatsBackend(url));
|
||||
}
|
||||
|
||||
export const NatsPubSubLive = Layer.effect(PubSub)(
|
||||
Effect.gen(function* () {
|
||||
const natsUrl = O.getOrUndefined(yield* Config.string("NATS_URL").pipe(Config.option));
|
||||
const pulsarHost = O.getOrUndefined(yield* Config.string("PULSAR_HOST").pipe(Config.option));
|
||||
const service = makePubSubService(new NatsBackend(natsUrl ?? pulsarHost ?? "nats://localhost:4222"));
|
||||
yield* Effect.addFinalizer(() =>
|
||||
service.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PubSub] Failed to close NATS backend", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return PubSub.of(service);
|
||||
}),
|
||||
);
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
* (NATS, Pulsar, Redis Streams) implements these interfaces.
|
||||
*/
|
||||
|
||||
import type * as S from "effect/Schema";
|
||||
|
||||
export interface Message<T = unknown> {
|
||||
value(): T;
|
||||
properties(): Record<string, string>;
|
||||
|
|
@ -29,6 +31,7 @@ export type InitialPosition = "latest" | "earliest";
|
|||
|
||||
export interface CreateProducerOptions {
|
||||
topic: string;
|
||||
schema?: S.Top;
|
||||
}
|
||||
|
||||
export interface CreateConsumerOptions {
|
||||
|
|
@ -36,6 +39,7 @@ export interface CreateConsumerOptions {
|
|||
subscription: string;
|
||||
initialPosition?: InitialPosition;
|
||||
consumerType?: ConsumerType;
|
||||
schema?: S.Top;
|
||||
}
|
||||
|
||||
export interface PubSubBackend {
|
||||
|
|
|
|||
|
|
@ -1,29 +1,310 @@
|
|||
/**
|
||||
* Custom error types.
|
||||
* Typed errors and wire-error translation helpers.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/exceptions.py
|
||||
*/
|
||||
|
||||
export class TooManyRequestsError extends Error {
|
||||
constructor(message = "Rate limit exceeded") {
|
||||
super(message);
|
||||
this.name = "TooManyRequestsError";
|
||||
}
|
||||
import * as S from "effect/Schema";
|
||||
import type { TgError } from "./schema/primitives.js";
|
||||
|
||||
export class TooManyRequestsError extends S.TaggedErrorClass<TooManyRequestsError>()(
|
||||
"TooManyRequestsError",
|
||||
{
|
||||
message: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class LlmError extends S.TaggedErrorClass<LlmError>()(
|
||||
"LlmError",
|
||||
{
|
||||
message: S.String,
|
||||
errorType: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class EmbeddingsError extends S.TaggedErrorClass<EmbeddingsError>()(
|
||||
"EmbeddingsError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
provider: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ParseError extends S.TaggedErrorClass<ParseError>()(
|
||||
"ParseError",
|
||||
{
|
||||
message: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class RuntimeConfigError extends S.TaggedErrorClass<RuntimeConfigError>()(
|
||||
"RuntimeConfigError",
|
||||
{
|
||||
message: S.String,
|
||||
key: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class WireDecodeError extends S.TaggedErrorClass<WireDecodeError>()(
|
||||
"WireDecodeError",
|
||||
{
|
||||
message: S.String,
|
||||
service: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class PubSubError extends S.TaggedErrorClass<PubSubError>()(
|
||||
"PubSubError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class ProcessorLifecycleError extends S.TaggedErrorClass<ProcessorLifecycleError>()(
|
||||
"ProcessorLifecycleError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
processorId: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessagingLifecycleError extends S.TaggedErrorClass<MessagingLifecycleError>()(
|
||||
"MessagingLifecycleError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
resource: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessagingDeliveryError extends S.TaggedErrorClass<MessagingDeliveryError>()(
|
||||
"MessagingDeliveryError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
topic: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessagingDecodeError extends S.TaggedErrorClass<MessagingDecodeError>()(
|
||||
"MessagingDecodeError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
topic: S.optionalKey(S.String),
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessagingTimeoutError extends S.TaggedErrorClass<MessagingTimeoutError>()(
|
||||
"MessagingTimeoutError",
|
||||
{
|
||||
message: S.String,
|
||||
operation: S.String,
|
||||
timeoutMs: S.Number,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class MessagingHandlerError extends S.TaggedErrorClass<MessagingHandlerError>()(
|
||||
"MessagingHandlerError",
|
||||
{
|
||||
message: S.String,
|
||||
topic: S.String,
|
||||
subscription: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class FlowRuntimeError extends S.TaggedErrorClass<FlowRuntimeError>()(
|
||||
"FlowRuntimeError",
|
||||
{
|
||||
message: S.String,
|
||||
flowName: S.String,
|
||||
operation: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class FlowResourceNotFoundError extends S.TaggedErrorClass<FlowResourceNotFoundError>()(
|
||||
"FlowResourceNotFoundError",
|
||||
{
|
||||
message: S.String,
|
||||
flowName: S.String,
|
||||
resourceType: S.Union([
|
||||
S.Literal("producer"),
|
||||
S.Literal("consumer"),
|
||||
S.Literal("requestor"),
|
||||
S.Literal("parameter"),
|
||||
]),
|
||||
resourceName: S.String,
|
||||
},
|
||||
) {}
|
||||
|
||||
export type TrustGraphError =
|
||||
| TooManyRequestsError
|
||||
| LlmError
|
||||
| EmbeddingsError
|
||||
| ParseError
|
||||
| RuntimeConfigError
|
||||
| WireDecodeError
|
||||
| PubSubError
|
||||
| ProcessorLifecycleError
|
||||
| MessagingLifecycleError
|
||||
| MessagingDeliveryError
|
||||
| MessagingDecodeError
|
||||
| MessagingTimeoutError
|
||||
| MessagingHandlerError
|
||||
| FlowRuntimeError
|
||||
| FlowResourceNotFoundError;
|
||||
|
||||
export type MessagingRuntimeError =
|
||||
| PubSubError
|
||||
| MessagingLifecycleError
|
||||
| MessagingDeliveryError
|
||||
| MessagingDecodeError
|
||||
| MessagingTimeoutError
|
||||
| MessagingHandlerError
|
||||
| FlowRuntimeError
|
||||
| FlowResourceNotFoundError;
|
||||
|
||||
export function tooManyRequestsError(message = "Rate limit exceeded"): TooManyRequestsError {
|
||||
return new TooManyRequestsError({ message });
|
||||
}
|
||||
|
||||
export class LlmError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorType: string = "llm-error",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LlmError";
|
||||
}
|
||||
export function llmError(message: string, errorType = "llm-error"): LlmError {
|
||||
return new LlmError({ message, errorType });
|
||||
}
|
||||
|
||||
export class ParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "ParseError";
|
||||
}
|
||||
export function embeddingsError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
provider?: string,
|
||||
): EmbeddingsError {
|
||||
return new EmbeddingsError({
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
...(provider === undefined ? {} : { provider }),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseError(message: string): ParseError {
|
||||
return new ParseError({ message });
|
||||
}
|
||||
|
||||
export function pubSubError(operation: string, error: unknown): PubSubError {
|
||||
return new PubSubError({ operation, message: errorMessage(error) });
|
||||
}
|
||||
|
||||
export function processorLifecycleError(
|
||||
processorId: string,
|
||||
operation: string,
|
||||
error: unknown,
|
||||
): ProcessorLifecycleError {
|
||||
return new ProcessorLifecycleError({
|
||||
processorId,
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function messagingLifecycleError(
|
||||
resource: string,
|
||||
operation: string,
|
||||
error: unknown,
|
||||
): MessagingLifecycleError {
|
||||
return new MessagingLifecycleError({
|
||||
resource,
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function messagingDeliveryError(
|
||||
topic: string,
|
||||
operation: string,
|
||||
error: unknown,
|
||||
): MessagingDeliveryError {
|
||||
return new MessagingDeliveryError({
|
||||
topic,
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function messagingDecodeError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
topic?: string,
|
||||
): MessagingDecodeError {
|
||||
return new MessagingDecodeError({
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
...(topic === undefined ? {} : { topic }),
|
||||
});
|
||||
}
|
||||
|
||||
export function messagingTimeoutError(
|
||||
operation: string,
|
||||
timeoutMs: number,
|
||||
): MessagingTimeoutError {
|
||||
return new MessagingTimeoutError({
|
||||
operation,
|
||||
timeoutMs,
|
||||
message: `${operation} timed out after ${timeoutMs}ms`,
|
||||
});
|
||||
}
|
||||
|
||||
export function messagingHandlerError(
|
||||
topic: string,
|
||||
subscription: string,
|
||||
error: unknown,
|
||||
): MessagingHandlerError {
|
||||
return new MessagingHandlerError({
|
||||
topic,
|
||||
subscription,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function flowRuntimeError(
|
||||
flowName: string,
|
||||
operation: string,
|
||||
error: unknown,
|
||||
): FlowRuntimeError {
|
||||
return new FlowRuntimeError({
|
||||
flowName,
|
||||
operation,
|
||||
message: errorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
export function flowResourceNotFoundError(
|
||||
flowName: string,
|
||||
resourceType: FlowResourceNotFoundError["resourceType"],
|
||||
resourceName: string,
|
||||
): FlowResourceNotFoundError {
|
||||
return new FlowResourceNotFoundError({
|
||||
flowName,
|
||||
resourceType,
|
||||
resourceName,
|
||||
message: `${resourceType} "${resourceName}" not found in flow "${flowName}"`,
|
||||
});
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
const message = (error as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function toTgError(error: unknown, fallbackType = "internal"): TgError {
|
||||
if (typeof error === "object" && error !== null && "_tag" in error) {
|
||||
const tag = (error as { _tag?: unknown })._tag;
|
||||
if (typeof tag === "string") {
|
||||
return { type: tag, message: errorMessage(error) };
|
||||
}
|
||||
}
|
||||
return { type: fallbackType, message: errorMessage(error) };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export * from "./schema/index.js";
|
|||
export * from "./spec/index.js";
|
||||
export * from "./services/index.js";
|
||||
export * from "./metrics/index.js";
|
||||
export * from "./runtime/index.js";
|
||||
export * from "./errors.js";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { PubSubBackend, BackendConsumer, Message } from "../backend/types.js";
|
||||
import type { Flow } from "../processor/flow.js";
|
||||
import { TooManyRequestsError } from "../errors.js";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
export type MessageHandler<T> = (
|
||||
message: T,
|
||||
|
|
@ -14,11 +15,11 @@ export type MessageHandler<T> = (
|
|||
flow: FlowContext,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface FlowContext {
|
||||
export interface FlowContext<Requirements = never> {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Reference to the owning Flow instance, giving handlers access to producers and parameters. */
|
||||
flow: Flow;
|
||||
flow: Flow<Requirements>;
|
||||
}
|
||||
|
||||
export interface ConsumerOptions<T> {
|
||||
|
|
@ -36,11 +37,13 @@ export class Consumer<T> {
|
|||
private backend: BackendConsumer<T> | null = null;
|
||||
private running = false;
|
||||
private abortController = new AbortController();
|
||||
private readonly options: ConsumerOptions<T>;
|
||||
|
||||
private readonly concurrency: number;
|
||||
private readonly rateLimitRetryMs: number;
|
||||
|
||||
constructor(private readonly options: ConsumerOptions<T>) {
|
||||
constructor(options: ConsumerOptions<T>) {
|
||||
this.options = options;
|
||||
this.concurrency = options.concurrency ?? 1;
|
||||
this.rateLimitRetryMs = options.rateLimitRetryMs ?? 10_000;
|
||||
}
|
||||
|
|
@ -65,7 +68,7 @@ export class Consumer<T> {
|
|||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
this.abortController.abort();
|
||||
if (this.backend) {
|
||||
if (this.backend !== null) {
|
||||
await this.backend.close();
|
||||
this.backend = null;
|
||||
}
|
||||
|
|
@ -75,17 +78,23 @@ export class Consumer<T> {
|
|||
while (this.running) {
|
||||
let msg: Message<T> | null = null;
|
||||
try {
|
||||
msg = await this.backend!.receive(2000);
|
||||
if (!msg) continue;
|
||||
const backend = this.backend;
|
||||
if (backend === null) throw new Error("Consumer backend not started");
|
||||
|
||||
msg = await backend.receive(2000);
|
||||
if (msg === null) continue;
|
||||
|
||||
await this.handleWithRetry(msg, flow);
|
||||
await this.backend!.acknowledge(msg);
|
||||
await backend.acknowledge(msg);
|
||||
} catch (err) {
|
||||
if (!this.running) break;
|
||||
console.error("[Consumer] Error in consume loop:", err);
|
||||
if (msg) {
|
||||
if (msg !== null) {
|
||||
try {
|
||||
await this.backend!.negativeAcknowledge(msg);
|
||||
const backend = this.backend;
|
||||
if (backend !== null) {
|
||||
await backend.negativeAcknowledge(msg);
|
||||
}
|
||||
} catch (nakErr) {
|
||||
console.error("[Consumer] Failed to nak message:", nakErr);
|
||||
}
|
||||
|
|
@ -99,7 +108,7 @@ export class Consumer<T> {
|
|||
try {
|
||||
await this.options.handler(msg.value(), msg.properties(), flow);
|
||||
} catch (err) {
|
||||
if (err instanceof TooManyRequestsError) {
|
||||
if (S.is(TooManyRequestsError)(err)) {
|
||||
console.warn(`[Consumer] Rate limited, retrying in ${this.rateLimitRetryMs}ms`);
|
||||
await sleep(this.rateLimitRetryMs);
|
||||
await this.options.handler(msg.value(), msg.properties(), flow);
|
||||
|
|
|
|||
|
|
@ -2,3 +2,37 @@ export { Producer } from "./producer.js";
|
|||
export { Consumer, type MessageHandler, type FlowContext, type ConsumerOptions } from "./consumer.js";
|
||||
export { Subscriber, AsyncQueue } from "./subscriber.js";
|
||||
export { RequestResponse, type RequestResponseOptions } from "./request-response.js";
|
||||
export {
|
||||
ConsumerFactory,
|
||||
ConsumerFactoryLive,
|
||||
FlowRuntime,
|
||||
FlowRuntimeLive,
|
||||
MessagingRuntimeLive,
|
||||
ProducerFactory,
|
||||
ProducerFactoryLive,
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactoryLive,
|
||||
makeEffectConsumerFromPubSub,
|
||||
makeEffectProducerFromPubSub,
|
||||
makeEffectProducerHandle,
|
||||
makeEffectRequestResponseFromPubSub,
|
||||
makeConsumerFactoryService,
|
||||
makeProducerFactoryService,
|
||||
makeRequestResponseFactoryService,
|
||||
runEffectConsumerScoped,
|
||||
runEffectProducerScoped,
|
||||
runEffectRequestResponseScoped,
|
||||
runFlowScoped,
|
||||
type ConsumerFactoryService,
|
||||
type EffectConsumer,
|
||||
type EffectConsumerOptions,
|
||||
type EffectMessageHandler,
|
||||
type EffectProducer,
|
||||
type EffectProducerOptions,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
type EffectRequestResponseOptions,
|
||||
type FlowRuntimeService,
|
||||
type ProducerFactoryService,
|
||||
type RequestResponseFactoryService,
|
||||
} from "./runtime.js";
|
||||
|
|
|
|||
|
|
@ -6,34 +6,44 @@
|
|||
|
||||
import type { PubSubBackend, BackendProducer } from "../backend/types.js";
|
||||
import type { ProducerMetrics } from "../metrics/prometheus.js";
|
||||
import { Effect } from "effect";
|
||||
import { makeEffectProducerHandle, type EffectProducer } from "./runtime.js";
|
||||
|
||||
export class Producer<T> {
|
||||
private backend: BackendProducer<T> | null = null;
|
||||
private running = false;
|
||||
private effectProducer: EffectProducer<T> | null = null;
|
||||
private readonly pubsub: PubSubBackend;
|
||||
private readonly topic: string;
|
||||
private readonly metrics: ProducerMetrics | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly pubsub: PubSubBackend,
|
||||
private readonly topic: string,
|
||||
private readonly metrics?: ProducerMetrics,
|
||||
) {}
|
||||
constructor(pubsub: PubSubBackend, topic: string, metrics?: ProducerMetrics) {
|
||||
this.pubsub = pubsub;
|
||||
this.topic = topic;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.backend = await this.pubsub.createProducer<T>({ topic: this.topic });
|
||||
this.running = true;
|
||||
this.effectProducer = makeEffectProducerHandle(this.backend, {
|
||||
topic: this.topic,
|
||||
...(this.metrics === undefined ? {} : { metrics: this.metrics }),
|
||||
});
|
||||
}
|
||||
|
||||
async send(id: string, message: T): Promise<void> {
|
||||
if (!this.backend) throw new Error("Producer not started");
|
||||
if (this.effectProducer === null) throw new Error("Producer not started");
|
||||
|
||||
await this.backend.send(message, { id });
|
||||
this.metrics?.inc();
|
||||
await Effect.runPromise(this.effectProducer.send(id, message));
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
if (this.backend) {
|
||||
await this.backend.flush();
|
||||
await this.backend.close();
|
||||
if (this.effectProducer !== null) {
|
||||
await Effect.runPromise(
|
||||
this.effectProducer.flush.pipe(
|
||||
Effect.flatMap(() => this.effectProducer === null ? Effect.void : this.effectProducer.close),
|
||||
),
|
||||
);
|
||||
this.effectProducer = null;
|
||||
this.backend = null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export class RequestResponse<TReq, TRes> {
|
|||
private producer: Producer<TReq>;
|
||||
private subscriber: Subscriber<TRes>;
|
||||
|
||||
constructor(private readonly options: RequestResponseOptions) {
|
||||
constructor(options: RequestResponseOptions) {
|
||||
this.producer = new Producer<TReq>(options.pubsub, options.requestTopic);
|
||||
this.subscriber = new Subscriber<TRes>(
|
||||
options.pubsub,
|
||||
|
|
@ -77,7 +77,7 @@ export class RequestResponse<TReq, TRes> {
|
|||
|
||||
const response = await queue.pop(remaining);
|
||||
|
||||
if (recipient) {
|
||||
if (recipient !== undefined) {
|
||||
const isFinal = await recipient(response);
|
||||
if (isFinal) return response;
|
||||
} else {
|
||||
|
|
|
|||
612
ts/packages/base/src/messaging/runtime.ts
Normal file
612
ts/packages/base/src/messaging/runtime.ts
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
/**
|
||||
* Effect-native messaging factories and scoped runtime helpers.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { Context, Duration, Effect, Fiber, Layer, Queue, Scope } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
import * as S from "effect/Schema";
|
||||
import type {
|
||||
BackendConsumer,
|
||||
BackendProducer,
|
||||
CreateConsumerOptions,
|
||||
CreateProducerOptions,
|
||||
Message,
|
||||
} from "../backend/types.js";
|
||||
import { PubSub, type PubSubService } from "../backend/pubsub.js";
|
||||
import {
|
||||
flowRuntimeError,
|
||||
messagingDeliveryError,
|
||||
messagingHandlerError,
|
||||
messagingLifecycleError,
|
||||
messagingTimeoutError,
|
||||
TooManyRequestsError,
|
||||
type FlowRuntimeError,
|
||||
type MessagingDeliveryError,
|
||||
type MessagingLifecycleError,
|
||||
type MessagingTimeoutError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import type { ProducerMetrics } from "../metrics/prometheus.js";
|
||||
import type { FlowContext } from "./consumer.js";
|
||||
import type { Flow } from "../processor/flow.js";
|
||||
import type { SpecRuntimeRequirements } from "../spec/types.js";
|
||||
import {
|
||||
loadMessagingRuntimeConfig,
|
||||
type MessagingRuntimeConfig,
|
||||
} from "../runtime/messaging-config.js";
|
||||
|
||||
const isTooManyRequestsError = S.is(TooManyRequestsError);
|
||||
|
||||
export type EffectMessageHandler<T, E = never, R = never> = (
|
||||
message: T,
|
||||
properties: Record<string, string>,
|
||||
flow: FlowContext<R>,
|
||||
) => Effect.Effect<void, E, R>;
|
||||
|
||||
export interface EffectProducerOptions {
|
||||
readonly topic: string;
|
||||
readonly schema?: S.Top;
|
||||
readonly metrics?: ProducerMetrics;
|
||||
}
|
||||
|
||||
export interface EffectProducer<T> {
|
||||
readonly send: (id: string, message: T) => Effect.Effect<void, MessagingDeliveryError>;
|
||||
readonly flush: Effect.Effect<void, MessagingDeliveryError>;
|
||||
readonly close: Effect.Effect<void, MessagingDeliveryError>;
|
||||
}
|
||||
|
||||
export interface EffectConsumerOptions<T, E = never, R = never> {
|
||||
readonly topic: string;
|
||||
readonly subscription: string;
|
||||
readonly handler: EffectMessageHandler<T, E, R>;
|
||||
readonly concurrency?: number;
|
||||
readonly initialPosition?: "latest" | "earliest";
|
||||
readonly schema?: S.Top;
|
||||
readonly receiveTimeoutMs?: number;
|
||||
readonly errorBackoffMs?: number;
|
||||
readonly rateLimitRetryMs?: number;
|
||||
}
|
||||
|
||||
export interface EffectConsumer {
|
||||
readonly stop: Effect.Effect<void, MessagingLifecycleError>;
|
||||
readonly fibers: ReadonlyArray<Fiber.Fiber<void, never>>;
|
||||
}
|
||||
|
||||
export interface EffectRequestResponseOptions {
|
||||
readonly requestTopic: string;
|
||||
readonly responseTopic: string;
|
||||
readonly subscription: string;
|
||||
readonly requestSchema?: S.Top;
|
||||
readonly responseSchema?: S.Top;
|
||||
}
|
||||
|
||||
export interface EffectRequestOptions<TRes, E = never, R = never> {
|
||||
readonly timeoutMs?: number;
|
||||
readonly recipient?: (response: TRes) => Effect.Effect<boolean, E, R>;
|
||||
}
|
||||
|
||||
export interface EffectRequestResponse<TReq, TRes> {
|
||||
readonly request: <E = never, R = never>(
|
||||
request: TReq,
|
||||
options?: EffectRequestOptions<TRes, E, R>,
|
||||
) => Effect.Effect<TRes, MessagingDeliveryError | MessagingTimeoutError | E, R>;
|
||||
readonly stop: Effect.Effect<void, MessagingLifecycleError | MessagingDeliveryError>;
|
||||
}
|
||||
|
||||
export interface ProducerFactoryService {
|
||||
readonly make: <T>(
|
||||
options: EffectProducerOptions,
|
||||
) => Effect.Effect<EffectProducer<T>, PubSubError, Scope.Scope>;
|
||||
}
|
||||
|
||||
export interface ConsumerFactoryService {
|
||||
readonly run: <T, E = never, R = never>(
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
) => Effect.Effect<EffectConsumer, PubSubError, Scope.Scope | R>;
|
||||
}
|
||||
|
||||
export interface RequestResponseFactoryService {
|
||||
readonly make: <TReq, TRes>(
|
||||
options: EffectRequestResponseOptions,
|
||||
) => Effect.Effect<EffectRequestResponse<TReq, TRes>, PubSubError, Scope.Scope>;
|
||||
}
|
||||
|
||||
export interface FlowRuntimeService {
|
||||
readonly run: <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
) => Effect.Effect<void, FlowRuntimeError, SpecRuntimeRequirements | Requirements>;
|
||||
}
|
||||
|
||||
export class ProducerFactory extends Context.Service<ProducerFactory, ProducerFactoryService>()(
|
||||
"@trustgraph/base/messaging/runtime/ProducerFactory",
|
||||
) {}
|
||||
|
||||
export class ConsumerFactory extends Context.Service<ConsumerFactory, ConsumerFactoryService>()(
|
||||
"@trustgraph/base/messaging/runtime/ConsumerFactory",
|
||||
) {}
|
||||
|
||||
export class RequestResponseFactory extends Context.Service<
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactoryService
|
||||
>()("@trustgraph/base/messaging/runtime/RequestResponseFactory") {}
|
||||
|
||||
export class FlowRuntime extends Context.Service<FlowRuntime, FlowRuntimeService>()(
|
||||
"@trustgraph/base/messaging/runtime/FlowRuntime",
|
||||
) {}
|
||||
|
||||
export function makeEffectProducerHandle<T>(
|
||||
backend: BackendProducer<T>,
|
||||
options: EffectProducerOptions,
|
||||
): EffectProducer<T> {
|
||||
return {
|
||||
send: Effect.fn(`Producer.send:${options.topic}`)((id: string, message: T) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.send(message, { id }),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "send", error),
|
||||
}).pipe(
|
||||
Effect.tap(() =>
|
||||
options.metrics === undefined
|
||||
? Effect.void
|
||||
: Effect.sync(() => {
|
||||
options.metrics?.inc();
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
flush: Effect.tryPromise({
|
||||
try: () => backend.flush(),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "flush", error),
|
||||
}),
|
||||
close: Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => messagingDeliveryError(options.topic, "close", error),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const makeEffectProducerFromPubSub = Effect.fn("makeEffectProducerFromPubSub")(function* <T>(
|
||||
pubsub: PubSubService,
|
||||
options: EffectProducerOptions,
|
||||
) {
|
||||
const createOptions: CreateProducerOptions = options.schema === undefined
|
||||
? { topic: options.topic }
|
||||
: { topic: options.topic, schema: options.schema };
|
||||
const backend = yield* pubsub.createProducer<T>(createOptions);
|
||||
const producer = makeEffectProducerHandle(backend, options);
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
producer.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[Producer] Failed to close producer", {
|
||||
error: error.message,
|
||||
topic: error.topic,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return producer;
|
||||
});
|
||||
|
||||
const closeConsumerBackend = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
topic: string,
|
||||
subscription: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.close(),
|
||||
catch: (error) => messagingLifecycleError(`${topic}:${subscription}`, "close-consumer", error),
|
||||
});
|
||||
|
||||
const acknowledgeMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
message: Message<T>,
|
||||
topic: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.acknowledge(message),
|
||||
catch: (error) => messagingDeliveryError(topic, "acknowledge", error),
|
||||
});
|
||||
|
||||
const negativeAcknowledgeMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
message: Message<T>,
|
||||
topic: string,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.negativeAcknowledge(message),
|
||||
catch: (error) => messagingDeliveryError(topic, "negative-acknowledge", error),
|
||||
});
|
||||
|
||||
const receiveMessage = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
topic: string,
|
||||
timeoutMs: number,
|
||||
) =>
|
||||
Effect.tryPromise({
|
||||
try: () => backend.receive(timeoutMs),
|
||||
catch: (error) => messagingDeliveryError(topic, "receive", error),
|
||||
});
|
||||
|
||||
const handleMessageWithRetry = Effect.fn("handleMessageWithRetry")(function* <T, E, R>(
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
message: Message<T>,
|
||||
config: MessagingRuntimeConfig,
|
||||
) {
|
||||
const runHandler = Effect.fn(`Consumer.handler:${options.topic}`)(() =>
|
||||
options.handler(message.value(), message.properties(), flow).pipe(
|
||||
Effect.mapError((error) => messagingHandlerError(options.topic, options.subscription, error)),
|
||||
),
|
||||
);
|
||||
|
||||
return yield* options.handler(message.value(), message.properties(), flow).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (isTooManyRequestsError(error)) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logWarning("[Consumer] Rate limited, retrying", {
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
retryMs: config.rateLimitRetryMs,
|
||||
});
|
||||
yield* Effect.sleep(Duration.millis(config.rateLimitRetryMs));
|
||||
yield* runHandler();
|
||||
});
|
||||
}
|
||||
|
||||
return Effect.fail(messagingHandlerError(options.topic, options.subscription, error));
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const processConsumerMessage = Effect.fn("processConsumerMessage")(function* <T, E, R>(
|
||||
backend: BackendConsumer<T>,
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
message: Message<T>,
|
||||
config: MessagingRuntimeConfig,
|
||||
) {
|
||||
yield* handleMessageWithRetry(options, flow, message, config).pipe(
|
||||
Effect.flatMap(() => acknowledgeMessage(backend, message, options.topic)),
|
||||
Effect.catch((error) =>
|
||||
negativeAcknowledgeMessage(backend, message, options.topic).pipe(
|
||||
Effect.catch((nakError) =>
|
||||
Effect.logError("[Consumer] Failed to negative-acknowledge message", {
|
||||
error: nakError.message,
|
||||
topic: nakError.topic,
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() =>
|
||||
Effect.logError("[Consumer] Message handling failed", {
|
||||
error: error.message,
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const consumerLoop = <T, E, R>(
|
||||
backend: BackendConsumer<T>,
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
config: MessagingRuntimeConfig,
|
||||
): Effect.Effect<void, never, R> =>
|
||||
Effect.whileLoop({
|
||||
while: () => true,
|
||||
body: () =>
|
||||
receiveMessage(backend, options.topic, options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs).pipe(
|
||||
Effect.flatMap((message) =>
|
||||
message === null
|
||||
? Effect.sleep(Duration.millis(options.receiveTimeoutMs ?? config.consumerReceiveTimeoutMs))
|
||||
: processConsumerMessage(backend, options, flow, message, config),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[Consumer] Receive loop failed", {
|
||||
error: error.message,
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
}).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.sleep(Duration.millis(options.errorBackoffMs ?? config.consumerErrorBackoffMs)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
step: () => undefined,
|
||||
});
|
||||
|
||||
export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPubSub")(function* <T, E, R>(
|
||||
pubsub: PubSubService,
|
||||
config: MessagingRuntimeConfig,
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
) {
|
||||
const createOptions: CreateConsumerOptions = {
|
||||
topic: options.topic,
|
||||
subscription: options.subscription,
|
||||
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
|
||||
...(options.schema === undefined ? {} : { schema: options.schema }),
|
||||
};
|
||||
const backend = yield* pubsub.createConsumer<T>(createOptions);
|
||||
const concurrency = Math.max(1, options.concurrency ?? 1);
|
||||
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
|
||||
const fibers = yield* Effect.forEach(workerIndexes, () =>
|
||||
consumerLoop(backend, options, flow, {
|
||||
...config,
|
||||
rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs,
|
||||
}).pipe(Effect.forkChild),
|
||||
);
|
||||
|
||||
const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () {
|
||||
yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true });
|
||||
yield* closeConsumerBackend(backend, options.topic, options.subscription);
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
stop().pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[Consumer] Failed to stop consumer", {
|
||||
error: error.message,
|
||||
resource: error.resource,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
fibers,
|
||||
stop: stop(),
|
||||
} satisfies EffectConsumer;
|
||||
});
|
||||
|
||||
const dispatchResponseLoop = <T>(
|
||||
backend: BackendConsumer<T>,
|
||||
responseTopic: string,
|
||||
subscribers: Map<string, Queue.Queue<T>>,
|
||||
config: MessagingRuntimeConfig,
|
||||
): Effect.Effect<void> =>
|
||||
Effect.whileLoop({
|
||||
while: () => true,
|
||||
body: () =>
|
||||
receiveMessage(backend, responseTopic, config.consumerReceiveTimeoutMs).pipe(
|
||||
Effect.flatMap((message) => {
|
||||
if (message === null) {
|
||||
return Effect.sleep(Duration.millis(config.consumerReceiveTimeoutMs));
|
||||
}
|
||||
|
||||
const id = message.properties().id;
|
||||
const queue = id === undefined ? undefined : subscribers.get(id);
|
||||
return Effect.gen(function* () {
|
||||
if (queue !== undefined) {
|
||||
yield* Queue.offer(queue, message.value());
|
||||
}
|
||||
yield* acknowledgeMessage(backend, message, responseTopic);
|
||||
});
|
||||
}),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[RequestResponse] Response dispatch failed", {
|
||||
error: error.message,
|
||||
topic: responseTopic,
|
||||
}).pipe(Effect.flatMap(() => Effect.sleep(Duration.millis(config.consumerErrorBackoffMs)))),
|
||||
),
|
||||
),
|
||||
step: () => undefined,
|
||||
});
|
||||
|
||||
const waitForResponse = Effect.fn("waitForResponse")(function* <TRes, E, R>(
|
||||
queue: Queue.Queue<TRes>,
|
||||
options: EffectRequestOptions<TRes, E, R> | undefined,
|
||||
) {
|
||||
while (true) {
|
||||
const response = yield* Queue.take(queue);
|
||||
if (options?.recipient === undefined) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const complete = yield* options.recipient(response);
|
||||
if (complete) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const makeEffectRequestResponseFromPubSub = Effect.fn("makeEffectRequestResponseFromPubSub")(function* <
|
||||
TReq,
|
||||
TRes,
|
||||
>(
|
||||
pubsub: PubSubService,
|
||||
config: MessagingRuntimeConfig,
|
||||
options: EffectRequestResponseOptions,
|
||||
) {
|
||||
const producer = yield* makeEffectProducerFromPubSub<TReq>(pubsub, {
|
||||
topic: options.requestTopic,
|
||||
...(options.requestSchema === undefined ? {} : { schema: options.requestSchema }),
|
||||
});
|
||||
const createOptions: CreateConsumerOptions = {
|
||||
topic: options.responseTopic,
|
||||
subscription: options.subscription,
|
||||
...(options.responseSchema === undefined ? {} : { schema: options.responseSchema }),
|
||||
};
|
||||
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 stop = Effect.fn(`RequestResponse.stop:${options.requestTopic}`)(function* () {
|
||||
yield* Fiber.interrupt(fiber);
|
||||
yield* producer.close;
|
||||
yield* closeConsumerBackend(backend, options.responseTopic, options.subscription);
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
stop().pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[RequestResponse] Failed to stop runtime", {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
request: <E = never, R = never>(
|
||||
request: TReq,
|
||||
requestOptions?: EffectRequestOptions<TRes, E, R>,
|
||||
) => {
|
||||
const id = randomUUID();
|
||||
const timeoutMs = requestOptions?.timeoutMs ?? config.requestTimeoutMs;
|
||||
|
||||
return Effect.acquireUseRelease(
|
||||
Queue.unbounded<TRes>().pipe(
|
||||
Effect.tap((queue) =>
|
||||
Effect.sync(() => {
|
||||
subscribers.set(id, queue);
|
||||
}),
|
||||
),
|
||||
),
|
||||
(queue) =>
|
||||
Effect.gen(function* () {
|
||||
yield* producer.send(id, request);
|
||||
const result = yield* waitForResponse(queue, requestOptions).pipe(
|
||||
Effect.timeoutOption(Duration.millis(timeoutMs)),
|
||||
);
|
||||
return yield* O.match(result, {
|
||||
onNone: () => Effect.fail(messagingTimeoutError("request-response", timeoutMs)),
|
||||
onSome: Effect.succeed,
|
||||
});
|
||||
}),
|
||||
(queue) =>
|
||||
Effect.sync(() => {
|
||||
subscribers.delete(id);
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Queue.shutdown(queue)),
|
||||
Effect.ignore,
|
||||
),
|
||||
);
|
||||
},
|
||||
stop: stop(),
|
||||
} satisfies EffectRequestResponse<TReq, TRes>;
|
||||
});
|
||||
|
||||
export function makeProducerFactoryService(pubsub: PubSubService): ProducerFactoryService {
|
||||
return {
|
||||
make: Effect.fn("ProducerFactory.make")(<T>(options: EffectProducerOptions) =>
|
||||
makeEffectProducerFromPubSub<T>(pubsub, options),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeConsumerFactoryService(
|
||||
pubsub: PubSubService,
|
||||
config: MessagingRuntimeConfig,
|
||||
): ConsumerFactoryService {
|
||||
return {
|
||||
run: Effect.fn("ConsumerFactory.run")(<T, E = never, R = never>(
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
) =>
|
||||
makeEffectConsumerFromPubSub(pubsub, config, options, flow),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeRequestResponseFactoryService(
|
||||
pubsub: PubSubService,
|
||||
config: MessagingRuntimeConfig,
|
||||
): RequestResponseFactoryService {
|
||||
const make = Effect.fn("RequestResponseFactory.make")(function* <TReq, TRes>(
|
||||
options: EffectRequestResponseOptions,
|
||||
) {
|
||||
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
|
||||
}) as RequestResponseFactoryService["make"];
|
||||
|
||||
return { make };
|
||||
}
|
||||
|
||||
export const ProducerFactoryLive = Layer.effect(
|
||||
ProducerFactory,
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
return ProducerFactory.of(makeProducerFactoryService(pubsub));
|
||||
}),
|
||||
);
|
||||
|
||||
export const ConsumerFactoryLive = Layer.effect(
|
||||
ConsumerFactory,
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
return ConsumerFactory.of(makeConsumerFactoryService(pubsub, config));
|
||||
}),
|
||||
);
|
||||
|
||||
export const RequestResponseFactoryLive = Layer.effect(
|
||||
RequestResponseFactory,
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
return RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, config));
|
||||
}),
|
||||
);
|
||||
|
||||
export const runFlowRuntimeScoped = Effect.fn("FlowRuntime.run")(function* <Requirements = never>(
|
||||
flow: Flow<Requirements>,
|
||||
) {
|
||||
yield* flow.startEffect().pipe(
|
||||
Effect.mapError((error) => flowRuntimeError(flow.name, "start", error)),
|
||||
);
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
flow.clearResources();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
export const FlowRuntimeLive = Layer.succeed(
|
||||
FlowRuntime,
|
||||
FlowRuntime.of({
|
||||
run: runFlowRuntimeScoped,
|
||||
}),
|
||||
);
|
||||
|
||||
export const MessagingRuntimeLive = Layer.mergeAll(
|
||||
ProducerFactoryLive,
|
||||
ConsumerFactoryLive,
|
||||
RequestResponseFactoryLive,
|
||||
FlowRuntimeLive,
|
||||
);
|
||||
|
||||
export const runEffectProducerScoped = Effect.fn("runEffectProducerScoped")(function* <T>(
|
||||
options: EffectProducerOptions,
|
||||
) {
|
||||
const pubsub = yield* PubSub;
|
||||
return yield* makeEffectProducerFromPubSub<T>(pubsub, options);
|
||||
});
|
||||
|
||||
export const runEffectConsumerScoped = Effect.fn("runEffectConsumerScoped")(function* <T, E = never, R = never>(
|
||||
options: EffectConsumerOptions<T, E, R>,
|
||||
flow: FlowContext<R>,
|
||||
) {
|
||||
const pubsub = yield* PubSub;
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
return yield* makeEffectConsumerFromPubSub(pubsub, config, options, flow);
|
||||
});
|
||||
|
||||
export const runEffectRequestResponseScoped = Effect.fn("runEffectRequestResponseScoped")(function* <TReq, TRes>(
|
||||
options: EffectRequestResponseOptions,
|
||||
) {
|
||||
const pubsub = yield* PubSub;
|
||||
const config = yield* loadMessagingRuntimeConfig();
|
||||
return yield* makeEffectRequestResponseFromPubSub<TReq, TRes>(pubsub, config, options);
|
||||
});
|
||||
|
||||
export const runFlowScoped = Effect.fn("runFlowScoped")(function* (
|
||||
flow: Flow,
|
||||
) {
|
||||
yield* runFlowRuntimeScoped(flow);
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ export class AsyncQueue<T> {
|
|||
|
||||
push(item: T): void {
|
||||
const waiter = this.waiters.shift();
|
||||
if (waiter) {
|
||||
if (waiter !== undefined) {
|
||||
waiter(item);
|
||||
} else {
|
||||
this.buffer.push(item);
|
||||
|
|
@ -34,7 +34,7 @@ export class AsyncQueue<T> {
|
|||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const waiter = (value: T) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
|
|
@ -58,17 +58,20 @@ export class AsyncQueue<T> {
|
|||
export class Subscriber<T> {
|
||||
private backend: BackendConsumer<T> | null = null;
|
||||
private running = false;
|
||||
private readonly pubsub: PubSubBackend;
|
||||
private readonly topic: string;
|
||||
private readonly subscription: string;
|
||||
|
||||
// ID-specific subscriptions (request/response correlation)
|
||||
private idSubscribers = new Map<string, Resolver<T>>();
|
||||
// Wildcard subscribers (receive all messages)
|
||||
private allSubscribers = new Map<string, Resolver<T>>();
|
||||
|
||||
constructor(
|
||||
private readonly pubsub: PubSubBackend,
|
||||
private readonly topic: string,
|
||||
private readonly subscription: string,
|
||||
) {}
|
||||
constructor(pubsub: PubSubBackend, topic: string, subscription: string) {
|
||||
this.pubsub = pubsub;
|
||||
this.topic = topic;
|
||||
this.subscription = subscription;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.backend = await this.pubsub.createConsumer<T>({
|
||||
|
|
@ -78,13 +81,13 @@ export class Subscriber<T> {
|
|||
this.running = true;
|
||||
// Start the dispatch loop (fire and forget — runs until stop)
|
||||
this.dispatchLoop().catch((err) => {
|
||||
if (this.running) console.error("[Subscriber] dispatch loop error:", err);
|
||||
if (this.running === true) console.error("[Subscriber] dispatch loop error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
if (this.backend) {
|
||||
if (this.backend !== null) {
|
||||
await this.backend.close();
|
||||
this.backend = null;
|
||||
}
|
||||
|
|
@ -114,8 +117,11 @@ export class Subscriber<T> {
|
|||
let consecutiveErrors = 0;
|
||||
while (this.running) {
|
||||
try {
|
||||
const msg = await this.backend!.receive(2000);
|
||||
if (!msg) continue;
|
||||
const backend = this.backend;
|
||||
if (backend === null) throw new Error("Subscriber backend not started");
|
||||
|
||||
const msg = await backend.receive(2000);
|
||||
if (msg === null) continue;
|
||||
|
||||
consecutiveErrors = 0;
|
||||
|
||||
|
|
@ -124,9 +130,9 @@ export class Subscriber<T> {
|
|||
const value = msg.value();
|
||||
|
||||
// Route to ID-specific subscriber
|
||||
if (id) {
|
||||
if (id !== undefined && id.length > 0) {
|
||||
const sub = this.idSubscribers.get(id);
|
||||
if (sub) {
|
||||
if (sub !== undefined) {
|
||||
sub.queue.push(value);
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +142,7 @@ export class Subscriber<T> {
|
|||
sub.queue.push(value);
|
||||
}
|
||||
|
||||
await this.backend!.acknowledge(msg);
|
||||
await backend.acknowledge(msg);
|
||||
} catch (err) {
|
||||
if (!this.running) break;
|
||||
consecutiveErrors++;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ export class ConsumerMetrics {
|
|||
private requestHistogram: Histogram;
|
||||
private processingCounter: Counter;
|
||||
private rateLimitCounter: Counter;
|
||||
private readonly labels: { processor: string; flow: string; name: string };
|
||||
|
||||
constructor(processor: string, flow: string, name: string) {
|
||||
this.labels = { processor, flow, name };
|
||||
this.requestHistogram = new Histogram({
|
||||
name: "tg_consumer_request_duration_seconds",
|
||||
help: "Consumer request processing time",
|
||||
|
|
@ -38,22 +40,24 @@ export class ConsumerMetrics {
|
|||
}
|
||||
|
||||
recordTime(seconds: number): void {
|
||||
this.requestHistogram.observe(seconds);
|
||||
this.requestHistogram.observe(this.labels, seconds);
|
||||
}
|
||||
|
||||
process(status: "success" | "error"): void {
|
||||
this.processingCounter.inc({ status });
|
||||
this.processingCounter.inc({ ...this.labels, status });
|
||||
}
|
||||
|
||||
rateLimit(): void {
|
||||
this.rateLimitCounter.inc();
|
||||
this.rateLimitCounter.inc(this.labels);
|
||||
}
|
||||
}
|
||||
|
||||
export class ProducerMetrics {
|
||||
private counter: Counter;
|
||||
private readonly labels: { processor: string; flow: string; name: string };
|
||||
|
||||
constructor(processor: string, flow: string, name: string) {
|
||||
this.labels = { processor, flow, name };
|
||||
this.counter = new Counter({
|
||||
name: "tg_producer_items_total",
|
||||
help: "Producer items sent",
|
||||
|
|
@ -63,6 +67,6 @@ export class ProducerMetrics {
|
|||
}
|
||||
|
||||
inc(): void {
|
||||
this.counter.inc();
|
||||
this.counter.inc(this.labels);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,16 @@
|
|||
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import { NatsBackend } from "../backend/nats.js";
|
||||
import { topics } from "../schema/topics.js";
|
||||
import { Effect } from "effect";
|
||||
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
|
||||
import { loadProcessorRuntimeConfig } from "../runtime/config.js";
|
||||
|
||||
export interface ProcessorConfig {
|
||||
id: string;
|
||||
pubsubUrl?: string;
|
||||
metricsPort?: number;
|
||||
manageProcessSignals?: boolean;
|
||||
pubsub?: PubSubBackend;
|
||||
}
|
||||
|
||||
export type ConfigHandler = (
|
||||
|
|
@ -21,14 +25,29 @@ export type ConfigHandler = (
|
|||
version: number,
|
||||
) => Promise<void>;
|
||||
|
||||
export abstract class AsyncProcessor {
|
||||
export type EffectConfigHandler<E = never, R = never> = (
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
) => Effect.Effect<void, E, R>;
|
||||
|
||||
interface RegisteredSignalHandler {
|
||||
readonly signal: NodeJS.Signals;
|
||||
readonly handler: () => void;
|
||||
}
|
||||
|
||||
export abstract class AsyncProcessor<RunError = ProcessorLifecycleError, RunRequirements = never> {
|
||||
protected pubsub: PubSubBackend;
|
||||
protected running = false;
|
||||
protected configHandlers: ConfigHandler[] = [];
|
||||
private shutdownCallbacks: Array<() => Promise<void>> = [];
|
||||
private signalHandlers: RegisteredSignalHandler[] = [];
|
||||
private readonly ownsPubSub: boolean;
|
||||
protected readonly config: ProcessorConfig;
|
||||
|
||||
constructor(protected readonly config: ProcessorConfig) {
|
||||
this.pubsub = new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
|
||||
constructor(config: ProcessorConfig) {
|
||||
this.config = config;
|
||||
this.pubsub = config.pubsub ?? new NatsBackend(config.pubsubUrl ?? "nats://localhost:4222");
|
||||
this.ownsPubSub = config.pubsub === undefined;
|
||||
}
|
||||
|
||||
registerConfigHandler(handler: ConfigHandler): void {
|
||||
|
|
@ -36,47 +55,107 @@ export abstract class AsyncProcessor {
|
|||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.running = true;
|
||||
// Set up graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log(`[${this.config.id}] Shutting down...`);
|
||||
await this.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
|
||||
await this.run();
|
||||
await Effect.runPromise(
|
||||
this.startEffect() as Effect.Effect<void, RunError | ProcessorLifecycleError>,
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
for (const cb of this.shutdownCallbacks) {
|
||||
await cb();
|
||||
}
|
||||
await this.pubsub.close();
|
||||
await Effect.runPromise(this.stopEffect());
|
||||
}
|
||||
|
||||
protected onShutdown(callback: () => Promise<void>): void {
|
||||
this.shutdownCallbacks.push(callback);
|
||||
}
|
||||
|
||||
protected abstract run(): Promise<void>;
|
||||
startEffect(): Effect.Effect<void, RunError | ProcessorLifecycleError, RunRequirements> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.sync(() => {
|
||||
processor.running = true;
|
||||
processor.registerProcessSignalHandlers();
|
||||
});
|
||||
|
||||
yield* processor.runEffect();
|
||||
}).pipe(
|
||||
Effect.withSpan("trustgraph.processor.start", {
|
||||
attributes: {
|
||||
"trustgraph.processor.id": processor.config.id,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.sync(() => {
|
||||
processor.running = false;
|
||||
processor.unregisterProcessSignalHandlers();
|
||||
});
|
||||
|
||||
for (const cb of processor.shutdownCallbacks) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => cb(),
|
||||
catch: (error) => processorLifecycleError(processor.config.id, "shutdown-callback", error),
|
||||
});
|
||||
}
|
||||
|
||||
if (processor.ownsPubSub) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => processor.pubsub.close(),
|
||||
catch: (error) => processorLifecycleError(processor.config.id, "close-pubsub", error),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected run(): Promise<void> {
|
||||
return Effect.runPromise(this.runEffect() as unknown as Effect.Effect<void, RunError>);
|
||||
}
|
||||
|
||||
protected runEffect(): Effect.Effect<void, RunError, RunRequirements> {
|
||||
return Effect.tryPromise({
|
||||
try: () => this.run(),
|
||||
catch: (error) => processorLifecycleError(this.config.id, "start", error),
|
||||
}) as unknown as Effect.Effect<void, RunError, RunRequirements>;
|
||||
}
|
||||
|
||||
private registerProcessSignalHandlers(): void {
|
||||
if (this.config.manageProcessSignals === false || this.signalHandlers.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shutdown = () => {
|
||||
console.log(`[${this.config.id}] Shutting down...`);
|
||||
void this.stop().then(() => process.exit(0));
|
||||
};
|
||||
const handlers: RegisteredSignalHandler[] = [
|
||||
{ signal: "SIGINT", handler: shutdown },
|
||||
{ signal: "SIGTERM", handler: shutdown },
|
||||
];
|
||||
for (const { signal, handler } of handlers) {
|
||||
process.once(signal, handler);
|
||||
}
|
||||
this.signalHandlers = handlers;
|
||||
}
|
||||
|
||||
private unregisterProcessSignalHandlers(): void {
|
||||
for (const { signal, handler } of this.signalHandlers) {
|
||||
process.off(signal, handler);
|
||||
}
|
||||
this.signalHandlers = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Static launch helper — parses env/args and starts the processor.
|
||||
* Subclasses call: `MyProcessor.launch("my-service")`
|
||||
*/
|
||||
static async launch<T extends AsyncProcessor>(
|
||||
static async launch<T extends AsyncProcessor<unknown, unknown>>(
|
||||
this: new (config: ProcessorConfig) => T,
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
const config: ProcessorConfig = {
|
||||
id,
|
||||
pubsubUrl: process.env.NATS_URL ?? process.env.PULSAR_HOST,
|
||||
metricsPort: parseInt(process.env.METRICS_PORT ?? "8000", 10),
|
||||
};
|
||||
|
||||
const config = await Effect.runPromise(loadProcessorRuntimeConfig(id));
|
||||
const processor = new this(config);
|
||||
await processor.start();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,15 +12,53 @@ import type { Spec } from "../spec/types.js";
|
|||
import type { BackendConsumer } from "../backend/types.js";
|
||||
import { Flow, type FlowDefinition } from "./flow.js";
|
||||
import { topics } from "../schema/topics.js";
|
||||
import {
|
||||
pubSubError,
|
||||
type FlowRuntimeError,
|
||||
type ProcessorLifecycleError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import {
|
||||
ConsumerFactory,
|
||||
FlowRuntime,
|
||||
ProducerFactory,
|
||||
RequestResponseFactory,
|
||||
makeConsumerFactoryService,
|
||||
makeProducerFactoryService,
|
||||
makeRequestResponseFactoryService,
|
||||
runFlowRuntimeScoped,
|
||||
} from "../messaging/runtime.js";
|
||||
import { makePubSubService, PubSub } from "../backend/pubsub.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
|
||||
import { Duration, Effect, Exit, Scope } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
interface ConfigPush {
|
||||
version: number;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export abstract class FlowProcessor extends AsyncProcessor {
|
||||
private specifications: Spec[] = [];
|
||||
private flows = new Map<string, Flow>();
|
||||
interface ActiveFlow {
|
||||
readonly scope: Scope.Closeable;
|
||||
}
|
||||
|
||||
const ConfigPushSchema = S.Struct({
|
||||
version: S.Number,
|
||||
config: S.Record(S.String, S.Unknown),
|
||||
});
|
||||
|
||||
export abstract class FlowProcessor<FlowRequirements = never> extends AsyncProcessor<
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
| PubSub
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| Scope.Scope
|
||||
| FlowRequirements
|
||||
> {
|
||||
private specifications: Array<Spec<FlowRequirements>> = [];
|
||||
private flows = new Map<string, ActiveFlow>();
|
||||
private configConsumer: BackendConsumer<ConfigPush> | null = null;
|
||||
private lastFlowsJson = "";
|
||||
|
||||
|
|
@ -28,110 +66,254 @@ export abstract class FlowProcessor extends AsyncProcessor {
|
|||
super(config);
|
||||
}
|
||||
|
||||
registerSpecification(spec: Spec): void {
|
||||
this.specifications.push(spec);
|
||||
registerSpecification<Requirements extends FlowRequirements>(
|
||||
spec: Spec<Requirements>,
|
||||
): void {
|
||||
this.specifications.push(spec as Spec<FlowRequirements>);
|
||||
}
|
||||
|
||||
protected async run(): Promise<void> {
|
||||
// Subscribe to config-push topic to receive flow definitions.
|
||||
// Use "earliest" to replay any config pushes that arrived before this service started.
|
||||
this.configConsumer = await this.pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${this.config.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
override async start(): Promise<void> {
|
||||
const pubsub = makePubSubService(this.pubsub);
|
||||
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
|
||||
const start = this.startEffect().pipe(
|
||||
Effect.provideService(PubSub, pubsub),
|
||||
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
|
||||
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
|
||||
Effect.provideService(
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
|
||||
),
|
||||
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
|
||||
) as Effect.Effect<void, PubSubError | FlowRuntimeError | ProcessorLifecycleError>;
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
start,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected override runEffect(): Effect.Effect<
|
||||
void,
|
||||
PubSubError | FlowRuntimeError | ProcessorLifecycleError,
|
||||
| PubSub
|
||||
| FlowRuntime
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory
|
||||
| Scope.Scope
|
||||
| FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
|
||||
// Subscribe to config-push topic to receive flow definitions.
|
||||
// Use "earliest" to replay any config pushes that arrived before this service started.
|
||||
processor.configConsumer = yield* pubsub.createConsumer<ConfigPush>({
|
||||
topic: topics.configPush,
|
||||
subscription: `${processor.config.id}-config-push`,
|
||||
initialPosition: "earliest",
|
||||
schema: ConfigPushSchema,
|
||||
});
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
processor.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => processor.closeAllFlowsEffect()),
|
||||
),
|
||||
);
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
|
||||
yield* Effect.whileLoop({
|
||||
while: () => processor.running,
|
||||
body: () => processor.processNextConfigPushEffect(),
|
||||
step: () => undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[${this.config.id}] Listening for config pushes on ${topics.configPush}`);
|
||||
private onConfigureFlowsEffect(
|
||||
config: Record<string, unknown>,
|
||||
_version: number,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (flowDefs === undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.running) {
|
||||
try {
|
||||
const msg = await this.configConsumer.receive(2000);
|
||||
if (!msg) continue;
|
||||
// Skip flow restart if the flow definitions haven't changed.
|
||||
// This prevents disrupting in-flight requests when non-flow config
|
||||
// sections (prompts, tools, mcp) are updated.
|
||||
const flowsJson = yield* S.encodeUnknownEffect(S.UnknownFromJsonString)(flowDefs).pipe(
|
||||
Effect.catch((error) => Effect.succeed(String(error))),
|
||||
);
|
||||
if (processor.lastFlowsJson.length > 0 && flowsJson === processor.lastFlowsJson && processor.flows.size > 0) {
|
||||
yield* Effect.log(`[${processor.config.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
processor.lastFlowsJson = flowsJson;
|
||||
|
||||
const push = msg.value();
|
||||
console.log(`[${this.config.id}] Received config push version=${push.version}`);
|
||||
// Stop removed flows
|
||||
for (const [name, activeFlow] of processor.flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
yield* Effect.log(`[${processor.config.id}] Stopping removed flow: ${name}`);
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
await this.onConfigureFlows(push.config, push.version);
|
||||
|
||||
// Also call any registered config handlers
|
||||
for (const handler of this.configHandlers) {
|
||||
await handler(push.config, push.version);
|
||||
// Start or update flows
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
// Skip invalid definitions (e.g., stringified JSON)
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
yield* Effect.logWarning(`[${processor.config.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.configConsumer.acknowledge(msg);
|
||||
} catch (err) {
|
||||
if (!this.running) break;
|
||||
console.error(`[${this.config.id}] Config consumer error:`, err);
|
||||
await sleep(1000);
|
||||
// Stop existing flow before (re)starting with new config
|
||||
const existing = processor.flows.get(name);
|
||||
if (existing !== undefined) {
|
||||
yield* Effect.log(`[${processor.config.id}] Restarting flow "${name}" with updated config`);
|
||||
yield* processor.closeFlowEffect(name, existing);
|
||||
processor.flows.delete(name);
|
||||
}
|
||||
|
||||
yield* Effect.log(`[${processor.config.id}] Starting flow "${name}"`);
|
||||
const activeFlow = yield* processor.startFlowEffect(name, defn);
|
||||
processor.flows.set(name, activeFlow);
|
||||
yield* Effect.log(`[${processor.config.id}] Flow "${name}" started`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onConfigureFlows(
|
||||
config: Record<string, unknown>,
|
||||
version: number,
|
||||
): Promise<void> {
|
||||
const flowDefs = config.flows as Record<string, FlowDefinition> | undefined;
|
||||
if (!flowDefs) {
|
||||
console.log(`[${this.config.id}] No flows in config push, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip flow restart if the flow definitions haven't changed.
|
||||
// This prevents disrupting in-flight requests when non-flow config
|
||||
// sections (prompts, tools, mcp) are updated.
|
||||
const flowsJson = JSON.stringify(flowDefs);
|
||||
if (this.lastFlowsJson && flowsJson === this.lastFlowsJson && this.flows.size > 0) {
|
||||
console.log(`[${this.config.id}] Flow definitions unchanged, skipping restart`);
|
||||
return;
|
||||
}
|
||||
this.lastFlowsJson = flowsJson;
|
||||
|
||||
// Stop removed flows
|
||||
for (const [name, flow] of this.flows) {
|
||||
if (!(name in flowDefs)) {
|
||||
console.log(`[${this.config.id}] Stopping removed flow: ${name}`);
|
||||
await flow.stop();
|
||||
this.flows.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Start or update flows
|
||||
for (const [name, defn] of Object.entries(flowDefs)) {
|
||||
// Skip invalid definitions (e.g., stringified JSON)
|
||||
if (typeof defn !== "object" || defn === null) {
|
||||
console.warn(`[${this.config.id}] Skipping flow "${name}": definition is not an object`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stop existing flow before (re)starting with new config
|
||||
if (this.flows.has(name)) {
|
||||
console.log(`[${this.config.id}] Restarting flow "${name}" with updated config`);
|
||||
await this.flows.get(name)!.stop();
|
||||
this.flows.delete(name);
|
||||
}
|
||||
|
||||
console.log(`[${this.config.id}] Starting flow "${name}" with topics:`, defn.topics);
|
||||
const flow = new Flow(name, this.config.id, this.pubsub, defn, this.specifications);
|
||||
await flow.start();
|
||||
this.flows.set(name, flow);
|
||||
console.log(`[${this.config.id}] Flow "${name}" started`);
|
||||
}
|
||||
override stopEffect(): Effect.Effect<void, ProcessorLifecycleError> {
|
||||
return this.closeConfigConsumerEffect().pipe(
|
||||
Effect.flatMap(() => this.closeAllFlowsEffect()),
|
||||
Effect.flatMap(() => super.stopEffect()),
|
||||
);
|
||||
}
|
||||
|
||||
override async stop(): Promise<void> {
|
||||
if (this.configConsumer) {
|
||||
await this.configConsumer.close();
|
||||
this.configConsumer = null;
|
||||
private processNextConfigPushEffect(): Effect.Effect<
|
||||
void,
|
||||
never,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const consumer = processor.configConsumer;
|
||||
if (consumer === null) {
|
||||
yield* Effect.sleep(Duration.millis(1000));
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = yield* Effect.tryPromise({
|
||||
try: () => consumer.receive(2000),
|
||||
catch: (error) => pubSubError("receive:config-push", error),
|
||||
});
|
||||
if (msg === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const push = msg.value();
|
||||
yield* Effect.log(`[${processor.config.id}] Received config push version=${push.version}`);
|
||||
|
||||
yield* processor.onConfigureFlowsEffect(push.config, push.version);
|
||||
|
||||
for (const handler of processor.configHandlers) {
|
||||
yield* Effect.tryPromise({
|
||||
try: () => handler(push.config, push.version),
|
||||
catch: (error) => pubSubError("config-handler", error),
|
||||
});
|
||||
}
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
try: () => consumer.acknowledge(msg),
|
||||
catch: (error) => pubSubError("acknowledge:config-push", error),
|
||||
});
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (!processor.running) {
|
||||
return Effect.void;
|
||||
}
|
||||
return Effect.logError(`[${processor.config.id}] Config consumer error`, {
|
||||
error: error.message,
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Effect.sleep(Duration.millis(1000))),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startFlowEffect(
|
||||
name: string,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<
|
||||
ActiveFlow,
|
||||
FlowRuntimeError,
|
||||
FlowRuntime | ProducerFactory | ConsumerFactory | RequestResponseFactory | FlowRequirements
|
||||
> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flowRuntime = yield* FlowRuntime;
|
||||
const scope = yield* Scope.make();
|
||||
const flow = new Flow<FlowRequirements>(
|
||||
name,
|
||||
processor.config.id,
|
||||
processor.pubsub,
|
||||
definition,
|
||||
processor.specifications,
|
||||
);
|
||||
return yield* flowRuntime.run(flow).pipe(
|
||||
Scope.provide(scope),
|
||||
Effect.as({ scope } satisfies ActiveFlow),
|
||||
Effect.catch((error) =>
|
||||
Scope.close(scope, Exit.void).pipe(
|
||||
Effect.flatMap(() => Effect.fail(error)),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private closeFlowEffect(name: string, activeFlow: ActiveFlow): Effect.Effect<void> {
|
||||
return Scope.close(activeFlow.scope, Exit.void).pipe(
|
||||
Effect.tap(() => Effect.log(`[${this.config.id}] Flow "${name}" stopped`)),
|
||||
);
|
||||
}
|
||||
|
||||
private closeAllFlowsEffect(): Effect.Effect<void> {
|
||||
const processor = this;
|
||||
return Effect.gen(function* () {
|
||||
const flows = Array.from(processor.flows.entries());
|
||||
for (const [name, activeFlow] of flows) {
|
||||
yield* processor.closeFlowEffect(name, activeFlow);
|
||||
}
|
||||
processor.flows.clear();
|
||||
});
|
||||
}
|
||||
|
||||
private closeConfigConsumerEffect(): Effect.Effect<void> {
|
||||
const consumer = this.configConsumer;
|
||||
this.configConsumer = null;
|
||||
if (consumer === null) {
|
||||
return Effect.void;
|
||||
}
|
||||
for (const flow of this.flows.values()) {
|
||||
await flow.stop();
|
||||
}
|
||||
this.flows.clear();
|
||||
await super.stop();
|
||||
return Effect.tryPromise({
|
||||
try: () => consumer.close(),
|
||||
catch: (error) => pubSubError("close:config-push", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError(`[${this.config.id}] Failed to close config consumer`, {
|
||||
error: error.message,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,28 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/flow.py
|
||||
*/
|
||||
|
||||
import { Effect, Exit, Scope } from "effect";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Spec } from "../spec/types.js";
|
||||
import type { Producer } from "../messaging/producer.js";
|
||||
import type { Consumer } from "../messaging/consumer.js";
|
||||
import type { RequestResponse } from "../messaging/request-response.js";
|
||||
import { makePubSubService } from "../backend/pubsub.js";
|
||||
import {
|
||||
flowResourceNotFoundError,
|
||||
type FlowResourceNotFoundError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
import {
|
||||
ConsumerFactory,
|
||||
ProducerFactory,
|
||||
RequestResponseFactory,
|
||||
type EffectConsumer,
|
||||
type EffectProducer,
|
||||
type EffectRequestOptions,
|
||||
type EffectRequestResponse,
|
||||
makeConsumerFactoryService,
|
||||
makeProducerFactoryService,
|
||||
makeRequestResponseFactoryService,
|
||||
} from "../messaging/runtime.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
|
||||
import type { Spec, SpecRuntimeRequirements } from "../spec/types.js";
|
||||
|
||||
export interface FlowDefinition {
|
||||
/** Topic overrides keyed by spec name */
|
||||
|
|
@ -17,54 +34,119 @@ export interface FlowDefinition {
|
|||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class Flow {
|
||||
private producers = new Map<string, Producer<unknown>>();
|
||||
private consumers = new Map<string, Consumer<unknown>>();
|
||||
private requestors = new Map<string, RequestResponse<unknown, unknown>>();
|
||||
export interface FlowProducer<T> {
|
||||
readonly send: (id: string, message: T) => Promise<void>;
|
||||
readonly flush: () => Promise<void>;
|
||||
readonly stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FlowConsumer {
|
||||
readonly stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FlowRequestOptions<TRes> {
|
||||
readonly timeoutMs?: number;
|
||||
readonly recipient?: (response: TRes) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FlowRequestor<TReq, TRes> {
|
||||
readonly request: (
|
||||
request: TReq,
|
||||
options?: FlowRequestOptions<TRes>,
|
||||
) => Promise<TRes>;
|
||||
readonly stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class Flow<Requirements = never> {
|
||||
private producers = new Map<string, EffectProducer<unknown>>();
|
||||
private consumers = new Map<string, EffectConsumer>();
|
||||
private requestors = new Map<string, EffectRequestResponse<unknown, unknown>>();
|
||||
private parameters = new Map<string, unknown>();
|
||||
private compatibilityScope: Scope.Closeable | null = null;
|
||||
public readonly name: string;
|
||||
public readonly processorId: string;
|
||||
private readonly pubsub: PubSubBackend;
|
||||
private readonly definition: FlowDefinition;
|
||||
private readonly specifications: ReadonlyArray<Spec<Requirements>>;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
public readonly processorId: string,
|
||||
private readonly pubsub: PubSubBackend,
|
||||
private readonly definition: FlowDefinition,
|
||||
private readonly specifications: Spec[],
|
||||
) {}
|
||||
name: string,
|
||||
processorId: string,
|
||||
pubsub: PubSubBackend,
|
||||
definition: FlowDefinition,
|
||||
specifications: ReadonlyArray<Spec<Requirements>>,
|
||||
) {
|
||||
this.name = name;
|
||||
this.processorId = processorId;
|
||||
this.pubsub = pubsub;
|
||||
this.definition = definition;
|
||||
this.specifications = specifications;
|
||||
}
|
||||
|
||||
startEffect(): Effect.Effect<void, PubSubError, SpecRuntimeRequirements | Requirements> {
|
||||
const flow = this;
|
||||
return Effect.gen(function* () {
|
||||
for (const spec of flow.specifications) {
|
||||
yield* spec.addEffect(flow, flow.definition);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
for (const spec of this.specifications) {
|
||||
await spec.add(this, this.pubsub, this.definition);
|
||||
}
|
||||
|
||||
// Start all consumers, passing this Flow instance via FlowContext
|
||||
for (const consumer of this.consumers.values()) {
|
||||
consumer.start({ id: this.processorId, name: this.name, flow: this }).catch((err) => {
|
||||
console.error(`[Flow:${this.name}] Consumer error:`, err);
|
||||
});
|
||||
if (this.compatibilityScope !== null) {
|
||||
await this.stop();
|
||||
}
|
||||
await this.runInCompatibilityScope(
|
||||
this.startEffect() as Effect.Effect<void, PubSubError, SpecRuntimeRequirements>,
|
||||
this.pubsub,
|
||||
);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const consumer of this.consumers.values()) {
|
||||
await consumer.stop();
|
||||
}
|
||||
for (const producer of this.producers.values()) {
|
||||
await producer.stop();
|
||||
}
|
||||
for (const rr of this.requestors.values()) {
|
||||
await rr.stop();
|
||||
const scope = this.compatibilityScope;
|
||||
this.compatibilityScope = null;
|
||||
if (scope !== null) {
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void));
|
||||
}
|
||||
this.clearResources();
|
||||
}
|
||||
|
||||
registerProducer(name: string, producer: Producer<unknown>): void {
|
||||
async runInCompatibilityScope<A, E>(
|
||||
effect: Effect.Effect<A, E, SpecRuntimeRequirements>,
|
||||
pubsub: PubSubBackend,
|
||||
): Promise<A> {
|
||||
const scope = await this.ensureCompatibilityScope();
|
||||
const pubsubService = makePubSubService(pubsub);
|
||||
const messagingConfig = await Effect.runPromise(loadMessagingRuntimeConfig());
|
||||
return await Effect.runPromise(
|
||||
effect.pipe(
|
||||
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsubService))),
|
||||
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsubService, messagingConfig))),
|
||||
Effect.provideService(
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsubService, messagingConfig)),
|
||||
),
|
||||
Scope.provide(scope),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
clearResources(): void {
|
||||
this.producers.clear();
|
||||
this.consumers.clear();
|
||||
this.requestors.clear();
|
||||
this.parameters.clear();
|
||||
}
|
||||
|
||||
registerProducer(name: string, producer: EffectProducer<unknown>): void {
|
||||
this.producers.set(name, producer);
|
||||
}
|
||||
|
||||
registerConsumer(name: string, consumer: Consumer<unknown>): void {
|
||||
registerConsumer(name: string, consumer: EffectConsumer): void {
|
||||
this.consumers.set(name, consumer);
|
||||
}
|
||||
|
||||
registerRequestor(name: string, rr: RequestResponse<unknown, unknown>): void {
|
||||
registerRequestor(name: string, rr: EffectRequestResponse<unknown, unknown>): void {
|
||||
this.requestors.set(name, rr);
|
||||
}
|
||||
|
||||
|
|
@ -72,27 +154,97 @@ export class Flow {
|
|||
this.parameters.set(name, value);
|
||||
}
|
||||
|
||||
producer<T>(name: string): Producer<T> {
|
||||
producerEffect<T>(name: string): Effect.Effect<EffectProducer<T>, FlowResourceNotFoundError> {
|
||||
const p = this.producers.get(name);
|
||||
if (!p) throw new Error(`Producer "${name}" not found in flow "${this.name}"`);
|
||||
return p as Producer<T>;
|
||||
return p === undefined
|
||||
? Effect.fail(flowResourceNotFoundError(this.name, "producer", name))
|
||||
: Effect.succeed(p as EffectProducer<T>);
|
||||
}
|
||||
|
||||
consumer<T>(name: string): Consumer<T> {
|
||||
consumerEffect(name: string): Effect.Effect<EffectConsumer, FlowResourceNotFoundError> {
|
||||
const c = this.consumers.get(name);
|
||||
if (!c) throw new Error(`Consumer "${name}" not found in flow "${this.name}"`);
|
||||
return c as Consumer<T>;
|
||||
return c === undefined
|
||||
? Effect.fail(flowResourceNotFoundError(this.name, "consumer", name))
|
||||
: Effect.succeed(c);
|
||||
}
|
||||
|
||||
requestor<TReq, TRes>(name: string): RequestResponse<TReq, TRes> {
|
||||
requestorEffect<TReq, TRes>(
|
||||
name: string,
|
||||
): Effect.Effect<EffectRequestResponse<TReq, TRes>, FlowResourceNotFoundError> {
|
||||
const rr = this.requestors.get(name);
|
||||
if (!rr) throw new Error(`Requestor "${name}" not found in flow "${this.name}"`);
|
||||
return rr as RequestResponse<TReq, TRes>;
|
||||
return rr === undefined
|
||||
? Effect.fail(flowResourceNotFoundError(this.name, "requestor", name))
|
||||
: Effect.succeed(rr as EffectRequestResponse<TReq, TRes>);
|
||||
}
|
||||
|
||||
parameterEffect<T>(name: string): Effect.Effect<T, FlowResourceNotFoundError> {
|
||||
const v = this.parameters.get(name);
|
||||
return v === undefined
|
||||
? Effect.fail(flowResourceNotFoundError(this.name, "parameter", name))
|
||||
: Effect.succeed(v as T);
|
||||
}
|
||||
|
||||
producer<T>(name: string): FlowProducer<T> {
|
||||
const p = this.producers.get(name);
|
||||
if (p === undefined) throw flowResourceNotFoundError(this.name, "producer", name);
|
||||
return {
|
||||
send: (id, message) => Effect.runPromise((p as EffectProducer<T>).send(id, message)),
|
||||
flush: () => Effect.runPromise(p.flush),
|
||||
stop: () => Effect.runPromise(p.flush.pipe(Effect.flatMap(() => p.close))),
|
||||
};
|
||||
}
|
||||
|
||||
consumer(name: string): FlowConsumer {
|
||||
const c = this.consumers.get(name);
|
||||
if (c === undefined) throw flowResourceNotFoundError(this.name, "consumer", name);
|
||||
return {
|
||||
stop: () => Effect.runPromise(c.stop),
|
||||
};
|
||||
}
|
||||
|
||||
requestor<TReq, TRes>(name: string): FlowRequestor<TReq, TRes> {
|
||||
const rr = this.requestors.get(name);
|
||||
if (rr === undefined) throw flowResourceNotFoundError(this.name, "requestor", name);
|
||||
return {
|
||||
request: (request, options) =>
|
||||
Effect.runPromise(
|
||||
(rr as EffectRequestResponse<TReq, TRes>).request(
|
||||
request,
|
||||
this.toEffectRequestOptions(options),
|
||||
),
|
||||
),
|
||||
stop: () => Effect.runPromise(rr.stop),
|
||||
};
|
||||
}
|
||||
|
||||
parameter<T>(name: string): T {
|
||||
const v = this.parameters.get(name);
|
||||
if (v === undefined) throw new Error(`Parameter "${name}" not found in flow "${this.name}"`);
|
||||
if (v === undefined) throw flowResourceNotFoundError(this.name, "parameter", name);
|
||||
return v as T;
|
||||
}
|
||||
|
||||
private async ensureCompatibilityScope(): Promise<Scope.Closeable> {
|
||||
if (this.compatibilityScope !== null) {
|
||||
return this.compatibilityScope;
|
||||
}
|
||||
this.compatibilityScope = await Effect.runPromise(Scope.make());
|
||||
return this.compatibilityScope;
|
||||
}
|
||||
|
||||
private toEffectRequestOptions<TRes>(
|
||||
options: FlowRequestOptions<TRes> | undefined,
|
||||
): EffectRequestOptions<TRes> | undefined {
|
||||
if (options === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const recipient = options.recipient;
|
||||
return {
|
||||
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
||||
...(recipient === undefined
|
||||
? {}
|
||||
: {
|
||||
recipient: (response: TRes) => Effect.promise(() => recipient(response)),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,22 @@
|
|||
export { AsyncProcessor, type ProcessorConfig, type ConfigHandler } from "./async-processor.js";
|
||||
export {
|
||||
AsyncProcessor,
|
||||
type ConfigHandler,
|
||||
type EffectConfigHandler,
|
||||
type ProcessorConfig,
|
||||
} from "./async-processor.js";
|
||||
export { FlowProcessor } from "./flow-processor.js";
|
||||
export { Flow, type FlowDefinition } from "./flow.js";
|
||||
export {
|
||||
Flow,
|
||||
type FlowConsumer,
|
||||
type FlowDefinition,
|
||||
type FlowProducer,
|
||||
type FlowRequestOptions,
|
||||
type FlowRequestor,
|
||||
} from "./flow.js";
|
||||
export {
|
||||
makeAsyncProcessorProgram,
|
||||
makeFlowProcessorProgram,
|
||||
makeProcessorProgram,
|
||||
runProcessorScoped,
|
||||
type ProcessorProgramOptions,
|
||||
} from "./program.js";
|
||||
|
|
|
|||
139
ts/packages/base/src/processor/program.ts
Normal file
139
ts/packages/base/src/processor/program.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* Scoped Effect runtime helpers for legacy processor classes.
|
||||
*
|
||||
* These helpers make `Context.Service`/Layer composition the canonical
|
||||
* executable path while the processor internals remain Promise-based.
|
||||
*/
|
||||
|
||||
import { Effect, Scope } from "effect";
|
||||
import { processorLifecycleError, type ProcessorLifecycleError } from "../errors.js";
|
||||
import { NatsBackend } from "../backend/nats.js";
|
||||
import { makePubSubService, PubSub } from "../backend/pubsub.js";
|
||||
import {
|
||||
ConsumerFactory,
|
||||
FlowRuntime,
|
||||
ProducerFactory,
|
||||
RequestResponseFactory,
|
||||
makeConsumerFactoryService,
|
||||
makeProducerFactoryService,
|
||||
makeRequestResponseFactoryService,
|
||||
runFlowRuntimeScoped,
|
||||
} from "../messaging/runtime.js";
|
||||
import {
|
||||
loadProcessorRuntimeConfig,
|
||||
type ProcessorRuntimeConfigOptions,
|
||||
} from "../runtime/config.js";
|
||||
import { loadMessagingRuntimeConfig } from "../runtime/messaging-config.js";
|
||||
import type { AsyncProcessor, ProcessorConfig } from "./async-processor.js";
|
||||
|
||||
type ProcessorRunError<Processor> = Processor extends AsyncProcessor<infer Error, unknown> ? Error : never;
|
||||
type ProcessorRunRequirements<Processor> = Processor extends AsyncProcessor<unknown, infer Requirements> ? Requirements : never;
|
||||
|
||||
export interface ProcessorProgramOptions<
|
||||
Config extends ProcessorConfig,
|
||||
Error,
|
||||
Requirements,
|
||||
Processor extends AsyncProcessor<unknown, unknown>,
|
||||
> {
|
||||
readonly id: string;
|
||||
readonly make: (config: Config) => Processor;
|
||||
readonly loadConfig?: Effect.Effect<Config, Error, Requirements>;
|
||||
}
|
||||
|
||||
export function runProcessorScoped<
|
||||
Config extends ProcessorConfig,
|
||||
Processor extends AsyncProcessor<unknown, unknown>,
|
||||
>(
|
||||
config: Config,
|
||||
make: (config: Config) => Processor,
|
||||
): Effect.Effect<
|
||||
void,
|
||||
ProcessorRunError<Processor> | ProcessorLifecycleError,
|
||||
PubSub | Scope.Scope | ProcessorRunRequirements<Processor>
|
||||
> {
|
||||
return Effect.gen(function* () {
|
||||
const pubsub = yield* PubSub;
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
manageProcessSignals: false,
|
||||
pubsub: pubsub.backend,
|
||||
} as Config;
|
||||
const processor = make(runtimeConfig);
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.tryPromise({
|
||||
try: () => processor.stop(),
|
||||
catch: (error) => processorLifecycleError(config.id, "stop", error),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[Processor] Failed to stop processor", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
processorId: error.processorId,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const typedProcessor = processor as unknown as AsyncProcessor<
|
||||
ProcessorRunError<Processor>,
|
||||
ProcessorRunRequirements<Processor>
|
||||
>;
|
||||
yield* typedProcessor.startEffect();
|
||||
});
|
||||
}
|
||||
|
||||
export function makeProcessorProgram<
|
||||
Config extends ProcessorConfig,
|
||||
Error = never,
|
||||
Requirements = never,
|
||||
Processor extends AsyncProcessor<unknown, unknown> = AsyncProcessor,
|
||||
>(
|
||||
options: ProcessorProgramOptions<Config, Error, Requirements, Processor>,
|
||||
) {
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* (
|
||||
options.loadConfig ??
|
||||
loadProcessorRuntimeConfig(options.id, {
|
||||
manageProcessSignals: false,
|
||||
} satisfies ProcessorRuntimeConfigOptions)
|
||||
);
|
||||
|
||||
const runtimeConfig = {
|
||||
...config,
|
||||
manageProcessSignals: false,
|
||||
} as Config;
|
||||
|
||||
const pubsub = makePubSubService(new NatsBackend(runtimeConfig.pubsubUrl ?? "nats://localhost:4222"));
|
||||
const messagingConfig = yield* loadMessagingRuntimeConfig();
|
||||
yield* Effect.addFinalizer(() =>
|
||||
pubsub.close.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[PubSub] Failed to close processor backend", {
|
||||
error: error.message,
|
||||
operation: error.operation,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
const processorEffect = runProcessorScoped<Config, Processor>(
|
||||
runtimeConfig,
|
||||
options.make,
|
||||
);
|
||||
yield* processorEffect.pipe(
|
||||
Effect.provideService(PubSub, pubsub),
|
||||
Effect.provideService(ProducerFactory, ProducerFactory.of(makeProducerFactoryService(pubsub))),
|
||||
Effect.provideService(ConsumerFactory, ConsumerFactory.of(makeConsumerFactoryService(pubsub, messagingConfig))),
|
||||
Effect.provideService(
|
||||
RequestResponseFactory,
|
||||
RequestResponseFactory.of(makeRequestResponseFactoryService(pubsub, messagingConfig)),
|
||||
),
|
||||
Effect.provideService(FlowRuntime, FlowRuntime.of({ run: runFlowRuntimeScoped })),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const makeAsyncProcessorProgram = makeProcessorProgram;
|
||||
export const makeFlowProcessorProgram = makeProcessorProgram;
|
||||
33
ts/packages/base/src/runtime/config.ts
Normal file
33
ts/packages/base/src/runtime/config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Effect Config contracts for process/runtime settings.
|
||||
*
|
||||
* These declarations preserve the existing environment variable names while
|
||||
* moving reads to a typed Effect boundary.
|
||||
*/
|
||||
|
||||
import { Config, Effect } from "effect";
|
||||
import * as O from "effect/Option";
|
||||
|
||||
export interface ProcessorRuntimeConfigOptions {
|
||||
readonly manageProcessSignals?: boolean;
|
||||
}
|
||||
|
||||
export const optionalStringConfig = Effect.fn("optionalStringConfig")(function* (name: string) {
|
||||
return O.getOrUndefined(yield* Config.string(name).pipe(Config.option));
|
||||
});
|
||||
|
||||
export const loadProcessorRuntimeConfig = Effect.fn("loadProcessorRuntimeConfig")(function* (
|
||||
id: string,
|
||||
options: ProcessorRuntimeConfigOptions = {},
|
||||
) {
|
||||
const natsUrl = yield* optionalStringConfig("NATS_URL");
|
||||
const pulsarHost = yield* optionalStringConfig("PULSAR_HOST");
|
||||
const metricsPort = yield* Config.number("METRICS_PORT").pipe(Config.withDefault(8000));
|
||||
|
||||
return {
|
||||
id,
|
||||
pubsubUrl: natsUrl ?? pulsarHost ?? "nats://localhost:4222",
|
||||
metricsPort,
|
||||
manageProcessSignals: options.manageProcessSignals ?? true,
|
||||
};
|
||||
});
|
||||
10
ts/packages/base/src/runtime/index.ts
Normal file
10
ts/packages/base/src/runtime/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {
|
||||
defaultMessagingRuntimeConfig,
|
||||
loadMessagingRuntimeConfig,
|
||||
type MessagingRuntimeConfig,
|
||||
} from "./messaging-config.js";
|
||||
export {
|
||||
loadProcessorRuntimeConfig,
|
||||
optionalStringConfig,
|
||||
type ProcessorRuntimeConfigOptions,
|
||||
} from "./config.js";
|
||||
41
ts/packages/base/src/runtime/messaging-config.ts
Normal file
41
ts/packages/base/src/runtime/messaging-config.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Effect Config contracts for messaging runtime behavior.
|
||||
*/
|
||||
|
||||
import { Config, Effect } from "effect";
|
||||
|
||||
export interface MessagingRuntimeConfig {
|
||||
readonly consumerReceiveTimeoutMs: number;
|
||||
readonly consumerErrorBackoffMs: number;
|
||||
readonly rateLimitRetryMs: number;
|
||||
readonly requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
export const defaultMessagingRuntimeConfig: MessagingRuntimeConfig = {
|
||||
consumerReceiveTimeoutMs: 2_000,
|
||||
consumerErrorBackoffMs: 1_000,
|
||||
rateLimitRetryMs: 10_000,
|
||||
requestTimeoutMs: 300_000,
|
||||
};
|
||||
|
||||
export const loadMessagingRuntimeConfig = Effect.fn("loadMessagingRuntimeConfig")(function* () {
|
||||
const consumerReceiveTimeoutMs = yield* Config.number("TG_CONSUMER_RECEIVE_TIMEOUT_MS").pipe(
|
||||
Config.withDefault(defaultMessagingRuntimeConfig.consumerReceiveTimeoutMs),
|
||||
);
|
||||
const consumerErrorBackoffMs = yield* Config.number("TG_CONSUMER_ERROR_BACKOFF_MS").pipe(
|
||||
Config.withDefault(defaultMessagingRuntimeConfig.consumerErrorBackoffMs),
|
||||
);
|
||||
const rateLimitRetryMs = yield* Config.number("TG_RATE_LIMIT_RETRY_MS").pipe(
|
||||
Config.withDefault(defaultMessagingRuntimeConfig.rateLimitRetryMs),
|
||||
);
|
||||
const requestTimeoutMs = yield* Config.number("TG_REQUEST_TIMEOUT_MS").pipe(
|
||||
Config.withDefault(defaultMessagingRuntimeConfig.requestTimeoutMs),
|
||||
);
|
||||
|
||||
return {
|
||||
consumerReceiveTimeoutMs,
|
||||
consumerErrorBackoffMs,
|
||||
rateLimitRetryMs,
|
||||
requestTimeoutMs,
|
||||
} satisfies MessagingRuntimeConfig;
|
||||
});
|
||||
|
|
@ -1,344 +1,455 @@
|
|||
/**
|
||||
* Message types for service communication.
|
||||
* Schema-backed message types for service communication.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/schema/services/
|
||||
*/
|
||||
|
||||
import type { TgError, Triple, Term, RowSchema } from "./primitives.js";
|
||||
import * as S from "effect/Schema";
|
||||
import { Term, TgError, Triple } from "./primitives.js";
|
||||
|
||||
const UnknownRecord = S.Record(S.String, S.Unknown);
|
||||
const MutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable);
|
||||
const OptionalMutableArray = <A extends S.Top>(schema: A) => schema.pipe(S.Array, S.mutable, S.optionalKey);
|
||||
const StringArray = MutableArray(S.String);
|
||||
const NumberArray = MutableArray(S.Number);
|
||||
const NumberArrays = MutableArray(NumberArray);
|
||||
|
||||
// Text completion
|
||||
export interface TextCompletionRequest {
|
||||
system: string;
|
||||
prompt: string;
|
||||
model?: string;
|
||||
temperature?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
export const TextCompletionRequest = S.Struct({
|
||||
system: S.String,
|
||||
prompt: S.String,
|
||||
model: S.optionalKey(S.String),
|
||||
temperature: S.optionalKey(S.Number),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type TextCompletionRequest = typeof TextCompletionRequest.Type;
|
||||
|
||||
export interface TextCompletionResponse {
|
||||
response: string;
|
||||
model?: string;
|
||||
inToken?: number;
|
||||
outToken?: number;
|
||||
error?: TgError;
|
||||
endOfStream?: boolean;
|
||||
}
|
||||
export const TextCompletionResponse = S.Struct({
|
||||
response: S.String,
|
||||
model: S.optionalKey(S.String),
|
||||
inToken: S.optionalKey(S.Number),
|
||||
outToken: S.optionalKey(S.Number),
|
||||
error: S.optionalKey(TgError),
|
||||
endOfStream: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type TextCompletionResponse = typeof TextCompletionResponse.Type;
|
||||
|
||||
// Embeddings
|
||||
export interface EmbeddingsRequest {
|
||||
text: string[];
|
||||
model?: string;
|
||||
}
|
||||
export const EmbeddingsRequest = S.Struct({
|
||||
text: StringArray,
|
||||
model: S.optionalKey(S.String),
|
||||
});
|
||||
export type EmbeddingsRequest = typeof EmbeddingsRequest.Type;
|
||||
|
||||
export interface EmbeddingsResponse {
|
||||
vectors: number[][];
|
||||
error?: TgError;
|
||||
}
|
||||
export const EmbeddingsResponse = S.Struct({
|
||||
vectors: NumberArrays,
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type EmbeddingsResponse = typeof EmbeddingsResponse.Type;
|
||||
|
||||
// Graph RAG
|
||||
export interface GraphRagRequest {
|
||||
query: string;
|
||||
collection?: string;
|
||||
entityLimit?: number;
|
||||
tripleLimit?: number;
|
||||
maxSubgraphSize?: number;
|
||||
maxPathLength?: number;
|
||||
streaming?: boolean;
|
||||
}
|
||||
export const GraphRagRequest = S.Struct({
|
||||
query: S.String,
|
||||
collection: S.optionalKey(S.String),
|
||||
entityLimit: S.optionalKey(S.Number),
|
||||
tripleLimit: S.optionalKey(S.Number),
|
||||
maxSubgraphSize: S.optionalKey(S.Number),
|
||||
maxPathLength: S.optionalKey(S.Number),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type GraphRagRequest = typeof GraphRagRequest.Type;
|
||||
|
||||
export interface GraphRagResponse {
|
||||
response: string;
|
||||
error?: TgError;
|
||||
endOfStream?: boolean;
|
||||
// Explainability: include retrieved subgraph triples
|
||||
message_type?: "chunk" | "explain";
|
||||
explain_id?: string;
|
||||
explain_triples?: Triple[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export const GraphRagResponse = S.StructWithRest(
|
||||
S.Struct({
|
||||
response: S.String,
|
||||
error: S.optionalKey(TgError),
|
||||
endOfStream: S.optionalKey(S.Boolean),
|
||||
message_type: S.optionalKey(S.Union([S.Literal("chunk"), S.Literal("explain")])),
|
||||
explain_id: S.optionalKey(S.String),
|
||||
explain_triples: OptionalMutableArray(Triple),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
export type GraphRagResponse = typeof GraphRagResponse.Type;
|
||||
|
||||
// Document RAG
|
||||
export interface DocumentRagRequest {
|
||||
query: string;
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
}
|
||||
export const DocumentRagRequest = S.Struct({
|
||||
query: S.String,
|
||||
collection: S.optionalKey(S.String),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type DocumentRagRequest = typeof DocumentRagRequest.Type;
|
||||
|
||||
export interface DocumentRagResponse {
|
||||
response: string;
|
||||
error?: TgError;
|
||||
endOfStream?: boolean;
|
||||
}
|
||||
export const DocumentRagResponse = S.Struct({
|
||||
response: S.String,
|
||||
error: S.optionalKey(TgError),
|
||||
endOfStream: S.optionalKey(S.Boolean),
|
||||
});
|
||||
export type DocumentRagResponse = typeof DocumentRagResponse.Type;
|
||||
|
||||
// Agent
|
||||
export interface AgentRequest {
|
||||
question: string;
|
||||
collection?: string;
|
||||
streaming?: boolean;
|
||||
group?: string[];
|
||||
state?: string;
|
||||
}
|
||||
export const AgentRequest = S.Struct({
|
||||
question: S.String,
|
||||
collection: S.optionalKey(S.String),
|
||||
streaming: S.optionalKey(S.Boolean),
|
||||
group: S.optionalKey(StringArray),
|
||||
state: S.optionalKey(S.String),
|
||||
});
|
||||
export type AgentRequest = typeof AgentRequest.Type;
|
||||
|
||||
export interface AgentResponse {
|
||||
/** Streaming chunk type */
|
||||
chunk_type?: "thought" | "observation" | "answer" | "error" | "explain";
|
||||
content?: string;
|
||||
end_of_message?: boolean;
|
||||
end_of_dialog?: boolean;
|
||||
/** Legacy non-streaming fields */
|
||||
answer?: string;
|
||||
error?: TgError;
|
||||
endOfStream?: boolean;
|
||||
endOfSession?: boolean;
|
||||
/** Explainability fields */
|
||||
explain_id?: string;
|
||||
explain_graph?: string;
|
||||
explain_triples?: unknown[];
|
||||
message_type?: string;
|
||||
}
|
||||
export const AgentResponse = S.Struct({
|
||||
chunk_type: S.optionalKey(S.Union([
|
||||
S.Literal("thought"),
|
||||
S.Literal("observation"),
|
||||
S.Literal("answer"),
|
||||
S.Literal("error"),
|
||||
S.Literal("explain"),
|
||||
])),
|
||||
content: S.optionalKey(S.String),
|
||||
end_of_message: S.optionalKey(S.Boolean),
|
||||
end_of_dialog: S.optionalKey(S.Boolean),
|
||||
answer: S.optionalKey(S.String),
|
||||
error: S.optionalKey(TgError),
|
||||
endOfStream: S.optionalKey(S.Boolean),
|
||||
endOfSession: S.optionalKey(S.Boolean),
|
||||
explain_id: S.optionalKey(S.String),
|
||||
explain_graph: S.optionalKey(S.String),
|
||||
explain_triples: OptionalMutableArray(S.Unknown),
|
||||
message_type: S.optionalKey(S.String),
|
||||
});
|
||||
export type AgentResponse = typeof AgentResponse.Type;
|
||||
|
||||
// Triples query
|
||||
export interface TriplesQueryRequest {
|
||||
s?: Term;
|
||||
p?: Term;
|
||||
o?: Term;
|
||||
collection?: string;
|
||||
limit?: number;
|
||||
}
|
||||
export const TriplesQueryRequest = S.Struct({
|
||||
s: S.optionalKey(Term),
|
||||
p: S.optionalKey(Term),
|
||||
o: S.optionalKey(Term),
|
||||
collection: S.optionalKey(S.String),
|
||||
limit: S.optionalKey(S.Number),
|
||||
});
|
||||
export type TriplesQueryRequest = typeof TriplesQueryRequest.Type;
|
||||
|
||||
export interface TriplesQueryResponse {
|
||||
triples: Triple[];
|
||||
error?: TgError;
|
||||
}
|
||||
export const TriplesQueryResponse = S.Struct({
|
||||
triples: MutableArray(Triple),
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type TriplesQueryResponse = typeof TriplesQueryResponse.Type;
|
||||
|
||||
// Graph embeddings query
|
||||
export interface GraphEmbeddingsRequest {
|
||||
vectors: number[][];
|
||||
user?: string;
|
||||
limit?: number;
|
||||
collection?: string;
|
||||
}
|
||||
export const GraphEmbeddingsRequest = S.Struct({
|
||||
vectors: NumberArrays,
|
||||
user: S.optionalKey(S.String),
|
||||
limit: S.optionalKey(S.Number),
|
||||
collection: S.optionalKey(S.String),
|
||||
});
|
||||
export type GraphEmbeddingsRequest = typeof GraphEmbeddingsRequest.Type;
|
||||
|
||||
export interface GraphEmbeddingsResponse {
|
||||
entities: Term[];
|
||||
error?: TgError;
|
||||
}
|
||||
export const GraphEmbeddingsResponse = S.Struct({
|
||||
entities: MutableArray(Term),
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type GraphEmbeddingsResponse = typeof GraphEmbeddingsResponse.Type;
|
||||
|
||||
// Document embeddings query
|
||||
export interface DocumentEmbeddingsRequest {
|
||||
vectors: number[][];
|
||||
limit?: number;
|
||||
user?: string;
|
||||
collection?: string;
|
||||
}
|
||||
export const DocumentEmbeddingsRequest = S.Struct({
|
||||
vectors: NumberArrays,
|
||||
limit: S.optionalKey(S.Number),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
});
|
||||
export type DocumentEmbeddingsRequest = typeof DocumentEmbeddingsRequest.Type;
|
||||
|
||||
export interface DocumentEmbeddingsResponse {
|
||||
chunks: Array<{ chunkId: string; score: number; content?: string }>;
|
||||
error?: TgError;
|
||||
}
|
||||
const DocumentEmbeddingChunk = S.Struct({
|
||||
chunkId: S.String,
|
||||
score: S.Number,
|
||||
content: S.optionalKey(S.String),
|
||||
});
|
||||
|
||||
export const DocumentEmbeddingsResponse = S.Struct({
|
||||
chunks: MutableArray(DocumentEmbeddingChunk),
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type DocumentEmbeddingsResponse = typeof DocumentEmbeddingsResponse.Type;
|
||||
|
||||
// Config
|
||||
export type ConfigOperation = "get" | "list" | "delete" | "put" | "config" | "getvalues";
|
||||
export const ConfigOperation = S.Union([
|
||||
S.Literal("get"),
|
||||
S.Literal("list"),
|
||||
S.Literal("delete"),
|
||||
S.Literal("put"),
|
||||
S.Literal("config"),
|
||||
S.Literal("getvalues"),
|
||||
]);
|
||||
export type ConfigOperation = typeof ConfigOperation.Type;
|
||||
|
||||
export interface ConfigRequest {
|
||||
operation: ConfigOperation;
|
||||
keys?: string[];
|
||||
values?: Record<string, unknown>;
|
||||
type?: string;
|
||||
}
|
||||
export const ConfigRequest = S.Struct({
|
||||
operation: ConfigOperation,
|
||||
keys: S.optionalKey(StringArray),
|
||||
values: S.optionalKey(UnknownRecord),
|
||||
type: S.optionalKey(S.String),
|
||||
});
|
||||
export type ConfigRequest = typeof ConfigRequest.Type;
|
||||
|
||||
export interface ConfigResponse {
|
||||
version?: number;
|
||||
values?: Record<string, unknown>;
|
||||
directory?: string[];
|
||||
config?: Record<string, unknown>;
|
||||
error?: TgError;
|
||||
}
|
||||
export const ConfigResponse = S.Struct({
|
||||
version: S.optionalKey(S.Number),
|
||||
values: S.optionalKey(S.Unknown),
|
||||
directory: S.optionalKey(StringArray),
|
||||
config: S.optionalKey(UnknownRecord),
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type ConfigResponse = typeof ConfigResponse.Type;
|
||||
|
||||
// Prompt
|
||||
export interface PromptRequest {
|
||||
name: string;
|
||||
variables?: Record<string, string>;
|
||||
}
|
||||
export const PromptRequest = S.Struct({
|
||||
name: S.String,
|
||||
variables: S.optionalKey(S.Record(S.String, S.String)),
|
||||
});
|
||||
export type PromptRequest = typeof PromptRequest.Type;
|
||||
|
||||
export interface PromptResponse {
|
||||
system: string;
|
||||
prompt: string;
|
||||
error?: TgError;
|
||||
}
|
||||
export const PromptResponse = S.Struct({
|
||||
system: S.String,
|
||||
prompt: S.String,
|
||||
error: S.optionalKey(TgError),
|
||||
});
|
||||
export type PromptResponse = typeof PromptResponse.Type;
|
||||
|
||||
// ---------- Pipeline types ----------
|
||||
// Pipeline types
|
||||
export const PipelineMetadata = S.Struct({
|
||||
id: S.String,
|
||||
root: S.String,
|
||||
user: S.String,
|
||||
collection: S.String,
|
||||
});
|
||||
export type PipelineMetadata = typeof PipelineMetadata.Type;
|
||||
|
||||
export interface PipelineMetadata {
|
||||
id: string;
|
||||
root: string;
|
||||
user: string;
|
||||
collection: string;
|
||||
}
|
||||
export const Document = S.Struct({
|
||||
metadata: PipelineMetadata,
|
||||
documentId: S.String,
|
||||
});
|
||||
export type Document = typeof Document.Type;
|
||||
|
||||
/** Document message — triggers the decode pipeline for a librarian document. */
|
||||
export interface Document {
|
||||
metadata: PipelineMetadata;
|
||||
documentId: string;
|
||||
}
|
||||
export const TextDocument = S.Struct({
|
||||
metadata: PipelineMetadata,
|
||||
text: S.String,
|
||||
documentId: S.String,
|
||||
});
|
||||
export type TextDocument = typeof TextDocument.Type;
|
||||
|
||||
export interface TextDocument {
|
||||
metadata: PipelineMetadata;
|
||||
text: string;
|
||||
documentId: string;
|
||||
}
|
||||
export const Chunk = S.Struct({
|
||||
metadata: PipelineMetadata,
|
||||
chunk: S.String,
|
||||
documentId: S.String,
|
||||
});
|
||||
export type Chunk = typeof Chunk.Type;
|
||||
|
||||
export interface Chunk {
|
||||
metadata: PipelineMetadata;
|
||||
chunk: string;
|
||||
documentId: string;
|
||||
}
|
||||
export const EntityContext = S.Struct({
|
||||
entity: Term,
|
||||
context: S.String,
|
||||
chunkId: S.String,
|
||||
});
|
||||
export type EntityContext = typeof EntityContext.Type;
|
||||
|
||||
export interface EntityContext {
|
||||
entity: Term;
|
||||
context: string;
|
||||
chunkId: string;
|
||||
}
|
||||
export const EntityContexts = S.Struct({
|
||||
metadata: PipelineMetadata,
|
||||
entities: MutableArray(EntityContext),
|
||||
});
|
||||
export type EntityContexts = typeof EntityContexts.Type;
|
||||
|
||||
export interface EntityContexts {
|
||||
metadata: PipelineMetadata;
|
||||
entities: EntityContext[];
|
||||
}
|
||||
export const Triples = S.Struct({
|
||||
metadata: PipelineMetadata,
|
||||
triples: MutableArray(Triple),
|
||||
});
|
||||
export type Triples = typeof Triples.Type;
|
||||
|
||||
export interface Triples {
|
||||
metadata: PipelineMetadata;
|
||||
triples: Triple[];
|
||||
}
|
||||
// Document metadata
|
||||
export const DocumentMetadata = S.Struct({
|
||||
id: S.String,
|
||||
time: S.Number,
|
||||
kind: S.String,
|
||||
title: S.String,
|
||||
comments: S.String,
|
||||
user: S.String,
|
||||
tags: StringArray,
|
||||
parentId: S.optionalKey(S.String),
|
||||
documentType: S.String,
|
||||
metadata: OptionalMutableArray(Triple),
|
||||
});
|
||||
export type DocumentMetadata = typeof DocumentMetadata.Type;
|
||||
|
||||
// ---------- Document metadata ----------
|
||||
export const ProcessingMetadata = S.Struct({
|
||||
id: S.String,
|
||||
documentId: S.String,
|
||||
time: S.Number,
|
||||
flow: S.String,
|
||||
user: S.String,
|
||||
collection: S.String,
|
||||
tags: StringArray,
|
||||
});
|
||||
export type ProcessingMetadata = typeof ProcessingMetadata.Type;
|
||||
|
||||
export interface DocumentMetadata {
|
||||
id: string;
|
||||
time: number;
|
||||
kind: string;
|
||||
title: string;
|
||||
comments: string;
|
||||
user: string;
|
||||
tags: string[];
|
||||
parentId?: string;
|
||||
documentType: string; // "source" | "page" | "chunk" | "extracted"
|
||||
metadata?: Triple[];
|
||||
}
|
||||
// Librarian
|
||||
export const LibrarianOperation = S.Literals([
|
||||
"add-document",
|
||||
"remove-document",
|
||||
"list-documents",
|
||||
"get-document-metadata",
|
||||
"get-document-content",
|
||||
"add-child-document",
|
||||
"list-children",
|
||||
"add-processing",
|
||||
"remove-processing",
|
||||
"list-processing",
|
||||
]);
|
||||
export type LibrarianOperation = typeof LibrarianOperation.Type;
|
||||
|
||||
export interface ProcessingMetadata {
|
||||
id: string;
|
||||
documentId: string;
|
||||
time: number;
|
||||
flow: string;
|
||||
user: string;
|
||||
collection: string;
|
||||
tags: string[];
|
||||
}
|
||||
export const LibrarianRequest = S.Struct({
|
||||
operation: LibrarianOperation,
|
||||
documentId: S.optionalKey(S.String),
|
||||
processingId: S.optionalKey(S.String),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
processingMetadata: S.optionalKey(ProcessingMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
});
|
||||
export type LibrarianRequest = typeof LibrarianRequest.Type;
|
||||
|
||||
// ---------- Librarian ----------
|
||||
export const LibrarianResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
documentMetadata: S.optionalKey(DocumentMetadata),
|
||||
content: S.optionalKey(S.String),
|
||||
documents: OptionalMutableArray(DocumentMetadata),
|
||||
processing: OptionalMutableArray(ProcessingMetadata),
|
||||
});
|
||||
export type LibrarianResponse = typeof LibrarianResponse.Type;
|
||||
|
||||
export type LibrarianOperation =
|
||||
| "add-document"
|
||||
| "remove-document"
|
||||
| "list-documents"
|
||||
| "get-document-metadata"
|
||||
| "get-document-content"
|
||||
| "add-child-document"
|
||||
| "list-children"
|
||||
| "add-processing"
|
||||
| "remove-processing"
|
||||
| "list-processing";
|
||||
// Knowledge core
|
||||
export const KnowledgeOperation = S.Literals([
|
||||
"list-kg-cores",
|
||||
"get-kg-core",
|
||||
"delete-kg-core",
|
||||
"put-kg-core",
|
||||
"load-kg-core",
|
||||
]);
|
||||
export type KnowledgeOperation = typeof KnowledgeOperation.Type;
|
||||
|
||||
export interface LibrarianRequest {
|
||||
operation: LibrarianOperation;
|
||||
documentId?: string;
|
||||
processingId?: string;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
processingMetadata?: ProcessingMetadata;
|
||||
content?: string; // base64
|
||||
user?: string;
|
||||
collection?: string;
|
||||
}
|
||||
const GraphEmbedding = S.Struct({
|
||||
entity: Term,
|
||||
vectors: NumberArrays,
|
||||
});
|
||||
|
||||
export interface LibrarianResponse {
|
||||
error?: TgError;
|
||||
documentMetadata?: DocumentMetadata;
|
||||
content?: string; // base64
|
||||
documents?: DocumentMetadata[];
|
||||
processing?: ProcessingMetadata[];
|
||||
}
|
||||
export const KnowledgeRequest = S.Struct({
|
||||
operation: KnowledgeOperation,
|
||||
user: S.optionalKey(S.String),
|
||||
id: S.optionalKey(S.String),
|
||||
flow: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
});
|
||||
export type KnowledgeRequest = typeof KnowledgeRequest.Type;
|
||||
|
||||
// ---------- Knowledge core ----------
|
||||
export const KnowledgeResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
ids: S.optionalKey(StringArray),
|
||||
eos: S.optionalKey(S.Boolean),
|
||||
triples: OptionalMutableArray(Triple),
|
||||
graphEmbeddings: OptionalMutableArray(GraphEmbedding),
|
||||
});
|
||||
export type KnowledgeResponse = typeof KnowledgeResponse.Type;
|
||||
|
||||
export type KnowledgeOperation =
|
||||
| "list-kg-cores"
|
||||
| "get-kg-core"
|
||||
| "delete-kg-core"
|
||||
| "put-kg-core"
|
||||
| "load-kg-core";
|
||||
// Collection management
|
||||
export const CollectionOperation = S.Literals([
|
||||
"list-collections",
|
||||
"update-collection",
|
||||
"delete-collection",
|
||||
]);
|
||||
export type CollectionOperation = typeof CollectionOperation.Type;
|
||||
|
||||
export interface KnowledgeRequest {
|
||||
operation: KnowledgeOperation;
|
||||
user?: string;
|
||||
id?: string;
|
||||
flow?: string;
|
||||
collection?: string;
|
||||
triples?: Triple[];
|
||||
graphEmbeddings?: { entity: Term; vectors: number[][] }[];
|
||||
}
|
||||
const CollectionEntry = S.Struct({
|
||||
user: S.String,
|
||||
collection: S.String,
|
||||
name: S.String,
|
||||
description: S.String,
|
||||
tags: StringArray,
|
||||
});
|
||||
|
||||
export interface KnowledgeResponse {
|
||||
error?: TgError;
|
||||
ids?: string[];
|
||||
eos?: boolean;
|
||||
triples?: Triple[];
|
||||
graphEmbeddings?: { entity: Term; vectors: number[][] }[];
|
||||
}
|
||||
export const CollectionManagementRequest = S.Struct({
|
||||
operation: CollectionOperation,
|
||||
user: S.optionalKey(S.String),
|
||||
collection: S.optionalKey(S.String),
|
||||
name: S.optionalKey(S.String),
|
||||
description: S.optionalKey(S.String),
|
||||
tags: S.optionalKey(StringArray),
|
||||
});
|
||||
export type CollectionManagementRequest = typeof CollectionManagementRequest.Type;
|
||||
|
||||
// ---------- Collection management ----------
|
||||
export const CollectionManagementResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
collections: OptionalMutableArray(CollectionEntry),
|
||||
});
|
||||
export type CollectionManagementResponse = typeof CollectionManagementResponse.Type;
|
||||
|
||||
export type CollectionOperation =
|
||||
| "list-collections"
|
||||
| "update-collection"
|
||||
| "delete-collection";
|
||||
// Tool invocation (MCP tools)
|
||||
export const ToolRequest = S.Struct({
|
||||
name: S.String,
|
||||
parameters: S.String,
|
||||
});
|
||||
export type ToolRequest = typeof ToolRequest.Type;
|
||||
|
||||
export interface CollectionManagementRequest {
|
||||
operation: CollectionOperation;
|
||||
user?: string;
|
||||
collection?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
export const ToolResponse = S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
text: S.optionalKey(S.String),
|
||||
object: S.optionalKey(S.String),
|
||||
});
|
||||
export type ToolResponse = typeof ToolResponse.Type;
|
||||
|
||||
export interface CollectionManagementResponse {
|
||||
error?: TgError;
|
||||
collections?: { user: string; collection: string; name: string; description: string; tags: string[] }[];
|
||||
}
|
||||
// Flow management
|
||||
export const FlowRequest = S.StructWithRest(
|
||||
S.Struct({
|
||||
operation: S.String,
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
export type FlowRequest = typeof FlowRequest.Type;
|
||||
|
||||
// ---------- Tool invocation (MCP tools) ----------
|
||||
export const FlowResponse = S.StructWithRest(
|
||||
S.Struct({
|
||||
error: S.optionalKey(TgError),
|
||||
}),
|
||||
[UnknownRecord],
|
||||
);
|
||||
export type FlowResponse = typeof FlowResponse.Type;
|
||||
|
||||
export interface ToolRequest {
|
||||
name: string;
|
||||
parameters: string; // JSON-encoded
|
||||
}
|
||||
|
||||
export interface ToolResponse {
|
||||
error?: TgError;
|
||||
text?: string; // Plain text response
|
||||
object?: string; // JSON-encoded structured response
|
||||
}
|
||||
|
||||
// ---------- Flow management ----------
|
||||
|
||||
// Flow request/response use kebab-case wire format to match the client.
|
||||
// Access fields via bracket notation: request["flow-id"]
|
||||
export interface FlowRequest {
|
||||
operation: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FlowResponse {
|
||||
error?: TgError;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
export const ServiceMessageSchemas = {
|
||||
TextCompletionRequest,
|
||||
TextCompletionResponse,
|
||||
EmbeddingsRequest,
|
||||
EmbeddingsResponse,
|
||||
GraphRagRequest,
|
||||
GraphRagResponse,
|
||||
DocumentRagRequest,
|
||||
DocumentRagResponse,
|
||||
AgentRequest,
|
||||
AgentResponse,
|
||||
TriplesQueryRequest,
|
||||
TriplesQueryResponse,
|
||||
GraphEmbeddingsRequest,
|
||||
GraphEmbeddingsResponse,
|
||||
DocumentEmbeddingsRequest,
|
||||
DocumentEmbeddingsResponse,
|
||||
ConfigRequest,
|
||||
ConfigResponse,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
LibrarianRequest,
|
||||
LibrarianResponse,
|
||||
KnowledgeRequest,
|
||||
KnowledgeResponse,
|
||||
CollectionManagementRequest,
|
||||
CollectionManagementResponse,
|
||||
ToolRequest,
|
||||
ToolResponse,
|
||||
FlowRequest,
|
||||
FlowResponse,
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -1,72 +1,102 @@
|
|||
/**
|
||||
* Core data types mirroring the Python schema primitives.
|
||||
* Schema-backed core data types mirroring the Python schema primitives.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/schema/core/primitives.py
|
||||
*/
|
||||
|
||||
export interface TgError {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
import * as S from "effect/Schema";
|
||||
|
||||
// RDF Term types — discriminated union
|
||||
export type TermType = "IRI" | "BLANK" | "LITERAL" | "TRIPLE";
|
||||
export const TgError = S.Struct({
|
||||
type: S.String,
|
||||
message: S.String,
|
||||
});
|
||||
export type TgError = typeof TgError.Type;
|
||||
|
||||
export interface IriTerm {
|
||||
type: "IRI";
|
||||
iri: string;
|
||||
}
|
||||
export const TermType = S.Literals([
|
||||
"IRI",
|
||||
"BLANK",
|
||||
"LITERAL",
|
||||
"TRIPLE",
|
||||
]);
|
||||
export type TermType = typeof TermType.Type;
|
||||
|
||||
export interface BlankTerm {
|
||||
type: "BLANK";
|
||||
id: string;
|
||||
}
|
||||
export const IriTerm = S.Struct({
|
||||
type: S.tag("IRI"),
|
||||
iri: S.String,
|
||||
});
|
||||
export type IriTerm = typeof IriTerm.Type;
|
||||
|
||||
export interface LiteralTerm {
|
||||
type: "LITERAL";
|
||||
value: string;
|
||||
datatype?: string;
|
||||
language?: string;
|
||||
}
|
||||
export const BlankTerm = S.Struct({
|
||||
type: S.tag("BLANK"),
|
||||
id: S.String,
|
||||
});
|
||||
export type BlankTerm = typeof BlankTerm.Type;
|
||||
|
||||
export interface TripleTerm {
|
||||
type: "TRIPLE";
|
||||
triple: Triple;
|
||||
}
|
||||
export const LiteralTerm = S.Struct({
|
||||
type: S.tag("LITERAL"),
|
||||
value: S.String,
|
||||
datatype: S.optionalKey(S.String),
|
||||
language: S.optionalKey(S.String),
|
||||
});
|
||||
export type LiteralTerm = typeof LiteralTerm.Type;
|
||||
|
||||
export type Term = IriTerm | BlankTerm | LiteralTerm | TripleTerm;
|
||||
export type Triple = {
|
||||
readonly s: Term;
|
||||
readonly p: Term;
|
||||
readonly o: Term;
|
||||
readonly g?: Term;
|
||||
};
|
||||
|
||||
export interface Triple {
|
||||
s: Term;
|
||||
p: Term;
|
||||
o: Term;
|
||||
g?: Term; // Named graph (optional quad)
|
||||
export const Triple: S.Codec<Triple, Triple> = S.suspend(() =>
|
||||
S.Struct({
|
||||
s: Term,
|
||||
p: Term,
|
||||
o: Term,
|
||||
g: S.optionalKey(Term),
|
||||
})
|
||||
);
|
||||
|
||||
export const TripleTerm: S.Codec<TripleTerm, TripleTerm> = S.suspend(() =>
|
||||
S.Struct({
|
||||
type: S.tag("TRIPLE"),
|
||||
triple: Triple,
|
||||
})
|
||||
);
|
||||
export interface TripleTerm {
|
||||
readonly type: "TRIPLE";
|
||||
readonly triple: Triple;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}
|
||||
export const Term: S.Codec<Term, Term> = S.suspend(() => S.Union([IriTerm, BlankTerm, LiteralTerm, TripleTerm]));
|
||||
|
||||
export interface RowSchema {
|
||||
name: string;
|
||||
description?: string;
|
||||
fields: Field[];
|
||||
}
|
||||
export const Field = S.Struct({
|
||||
name: S.String,
|
||||
type: S.String,
|
||||
description: S.optionalKey(S.String),
|
||||
});
|
||||
export type Field = typeof Field.Type;
|
||||
|
||||
// LLM-related types
|
||||
export interface LlmResult {
|
||||
text: string;
|
||||
inToken: number;
|
||||
outToken: number;
|
||||
model: string;
|
||||
}
|
||||
export const RowSchema = S.Struct({
|
||||
name: S.String,
|
||||
description: S.optionalKey(S.String),
|
||||
fields: S.Array(Field).pipe(S.mutable),
|
||||
});
|
||||
export type RowSchema = typeof RowSchema.Type;
|
||||
|
||||
export interface LlmChunk {
|
||||
text: string;
|
||||
inToken: number | null;
|
||||
outToken: number | null;
|
||||
model: string;
|
||||
isFinal: boolean;
|
||||
}
|
||||
export const LlmResult = S.Struct({
|
||||
text: S.String,
|
||||
inToken: S.Number,
|
||||
outToken: S.Number,
|
||||
model: S.String,
|
||||
});
|
||||
export type LlmResult = typeof LlmResult.Type;
|
||||
|
||||
export const LlmChunk = S.Struct({
|
||||
text: S.String,
|
||||
inToken: S.NullOr(S.Number),
|
||||
outToken: S.NullOr(S.Number),
|
||||
model: S.String,
|
||||
isFinal: S.Boolean,
|
||||
});
|
||||
export type LlmChunk = typeof LlmChunk.Type;
|
||||
|
|
|
|||
|
|
@ -1,54 +1,82 @@
|
|||
/**
|
||||
* Base embeddings service.
|
||||
* Embeddings capability contract and message-bus adapter.
|
||||
*
|
||||
* Python reference: trustgraph-base/trustgraph/base/embeddings_service.py
|
||||
*/
|
||||
|
||||
import { FlowProcessor } from "../processor/flow-processor.js";
|
||||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import type { ProcessorConfig } from "../processor/async-processor.js";
|
||||
import { Context, Effect } from "effect";
|
||||
import {
|
||||
errorMessage,
|
||||
type EmbeddingsError,
|
||||
type FlowResourceNotFoundError,
|
||||
type MessagingDeliveryError,
|
||||
} from "../errors.js";
|
||||
import type { FlowContext } from "../messaging/consumer.js";
|
||||
import { FlowProcessor } from "../processor/flow-processor.js";
|
||||
import type { ProcessorConfig } from "../processor/async-processor.js";
|
||||
import type { EmbeddingsRequest, EmbeddingsResponse } from "../schema/messages.js";
|
||||
import { ConsumerSpec } from "../spec/consumer-spec.js";
|
||||
import { ParameterSpec } from "../spec/parameter-spec.js";
|
||||
import { ProducerSpec } from "../spec/producer-spec.js";
|
||||
|
||||
export abstract class EmbeddingsService extends FlowProcessor {
|
||||
protected constructor(config: ProcessorConfig) {
|
||||
export interface EmbeddingsServiceShape {
|
||||
readonly embed: (
|
||||
texts: ReadonlyArray<string>,
|
||||
model?: string,
|
||||
) => Effect.Effect<number[][], EmbeddingsError>;
|
||||
}
|
||||
|
||||
export class Embeddings extends Context.Service<Embeddings, EmbeddingsServiceShape>()(
|
||||
"@trustgraph/base/services/embeddings-service/Embeddings",
|
||||
) {}
|
||||
|
||||
export class EmbeddingsService extends FlowProcessor<Embeddings> {
|
||||
constructor(config: ProcessorConfig) {
|
||||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<EmbeddingsRequest>(
|
||||
new ConsumerSpec<EmbeddingsRequest, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings>(
|
||||
"embeddings-request",
|
||||
this.onRequest.bind(this),
|
||||
this.onRequestEffect.bind(this),
|
||||
),
|
||||
);
|
||||
this.registerSpecification(new ProducerSpec<EmbeddingsResponse>("embeddings-response"));
|
||||
this.registerSpecification(new ParameterSpec("model"));
|
||||
}
|
||||
|
||||
private async onRequest(
|
||||
private onRequestEffect(
|
||||
msg: EmbeddingsRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
flowCtx: FlowContext<Embeddings>,
|
||||
): Effect.Effect<void, FlowResourceNotFoundError | MessagingDeliveryError, Embeddings> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<EmbeddingsResponse>("embeddings-response");
|
||||
|
||||
try {
|
||||
const vectors = await this.onEmbeddings(msg.text, msg.model);
|
||||
await responseProducer.send(requestId, { vectors });
|
||||
} catch (err) {
|
||||
console.error(`[EmbeddingsService] Error processing request:`, err);
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await responseProducer.send(requestId, {
|
||||
vectors: [],
|
||||
error: { type: "embeddings-error", message },
|
||||
});
|
||||
if (requestId === undefined || requestId.length === 0) {
|
||||
return Effect.void;
|
||||
}
|
||||
}
|
||||
|
||||
abstract onEmbeddings(texts: string[], model?: string): Promise<number[][]>;
|
||||
return Effect.gen(function* () {
|
||||
const responseProducer = yield* flowCtx.flow.producerEffect<EmbeddingsResponse>("embeddings-response");
|
||||
const embeddings = yield* Embeddings;
|
||||
const response = yield* embeddings.embed(msg.text, msg.model).pipe(
|
||||
Effect.map((vectors) => ({ vectors }) satisfies EmbeddingsResponse),
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("[EmbeddingsService] Error processing request", {
|
||||
error: errorMessage(error),
|
||||
operation: error.operation,
|
||||
provider: error.provider ?? "unknown",
|
||||
}).pipe(
|
||||
Effect.as({
|
||||
vectors: [],
|
||||
error: {
|
||||
type: "embeddings-error",
|
||||
message: errorMessage(error),
|
||||
},
|
||||
} satisfies EmbeddingsResponse),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield* responseProducer.send(requestId, response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,6 @@
|
|||
export { LlmService } from "./llm-service.js";
|
||||
export { EmbeddingsService } from "./embeddings-service.js";
|
||||
export {
|
||||
Embeddings,
|
||||
EmbeddingsService,
|
||||
type EmbeddingsServiceShape,
|
||||
} from "./embeddings-service.js";
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export abstract class LlmService extends FlowProcessor {
|
|||
super(config);
|
||||
|
||||
this.registerSpecification(
|
||||
new ConsumerSpec<TextCompletionRequest>(
|
||||
ConsumerSpec.fromPromise<TextCompletionRequest>(
|
||||
"text-completion-request",
|
||||
this.onRequest.bind(this),
|
||||
),
|
||||
|
|
@ -36,50 +36,52 @@ export abstract class LlmService extends FlowProcessor {
|
|||
msg: TextCompletionRequest,
|
||||
properties: Record<string, string>,
|
||||
flowCtx: FlowContext,
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (!requestId) return;
|
||||
): Promise<void> {
|
||||
const requestId = properties.id;
|
||||
if (requestId === undefined || requestId.length === 0) return;
|
||||
|
||||
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
|
||||
const responseProducer = flowCtx.flow.producer<TextCompletionResponse>("text-completion-response");
|
||||
|
||||
try {
|
||||
if (msg.streaming && this.supportsStreaming()) {
|
||||
for await (const chunk of this.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
{
|
||||
try {
|
||||
if (msg.streaming === true && this.supportsStreaming()) {
|
||||
for await (const chunk of this.generateContentStream(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
)) {
|
||||
const response = {
|
||||
response: chunk.text,
|
||||
model: chunk.model,
|
||||
inToken: chunk.inToken ?? undefined,
|
||||
outToken: chunk.outToken ?? undefined,
|
||||
...(chunk.model !== undefined ? { model: chunk.model } : {}),
|
||||
...(chunk.inToken !== null ? { inToken: chunk.inToken } : {}),
|
||||
...(chunk.outToken !== null ? { outToken: chunk.outToken } : {}),
|
||||
endOfStream: chunk.isFinal,
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
};
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const result = await this.generateContent(
|
||||
msg.system,
|
||||
msg.prompt,
|
||||
msg.model,
|
||||
msg.temperature,
|
||||
);
|
||||
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
{
|
||||
);
|
||||
const response = {
|
||||
response: result.text,
|
||||
model: result.model,
|
||||
inToken: result.inToken,
|
||||
outToken: result.outToken,
|
||||
...(result.model !== undefined ? { model: result.model } : {}),
|
||||
...(result.inToken !== undefined ? { inToken: result.inToken } : {}),
|
||||
...(result.outToken !== undefined ? { outToken: result.outToken } : {}),
|
||||
endOfStream: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
await responseProducer.send(
|
||||
requestId,
|
||||
response
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[LlmService] Error processing request:`,
|
||||
|
|
|
|||
|
|
@ -4,29 +4,84 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/consumer_spec.py
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import * as S from "effect/Schema";
|
||||
import type { Spec } from "./types.js";
|
||||
import type { SpecRuntimeRequirements } from "./types.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import { Consumer, type MessageHandler } from "../messaging/consumer.js";
|
||||
import { type MessageHandler } from "../messaging/consumer.js";
|
||||
import {
|
||||
ConsumerFactory,
|
||||
type EffectMessageHandler,
|
||||
} from "../messaging/runtime.js";
|
||||
import {
|
||||
messagingHandlerError,
|
||||
TooManyRequestsError,
|
||||
type MessagingHandlerError,
|
||||
type PubSubError,
|
||||
} from "../errors.js";
|
||||
|
||||
const isTooManyRequestsError = S.is(TooManyRequestsError);
|
||||
|
||||
export class ConsumerSpec<T, E = never, R = never> implements Spec<R> {
|
||||
public readonly name: string;
|
||||
private readonly handler: EffectMessageHandler<T, E, R>;
|
||||
private readonly concurrency: number;
|
||||
|
||||
export class ConsumerSpec<T> implements Spec {
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private readonly handler: MessageHandler<T>,
|
||||
private readonly concurrency = 1,
|
||||
) {}
|
||||
name: string,
|
||||
handler: EffectMessageHandler<T, E, R>,
|
||||
concurrency = 1,
|
||||
) {
|
||||
this.name = name;
|
||||
this.handler = handler;
|
||||
this.concurrency = concurrency;
|
||||
}
|
||||
|
||||
static fromPromise<T>(
|
||||
name: string,
|
||||
handler: MessageHandler<T>,
|
||||
concurrency = 1,
|
||||
): ConsumerSpec<T, TooManyRequestsError | MessagingHandlerError> {
|
||||
return new ConsumerSpec<T, TooManyRequestsError | MessagingHandlerError>(
|
||||
name,
|
||||
(message, properties, flow) =>
|
||||
Effect.tryPromise({
|
||||
try: () => handler(message, properties, flow),
|
||||
catch: (error) =>
|
||||
isTooManyRequestsError(error)
|
||||
? error
|
||||
: messagingHandlerError(name, `${flow.id}-${flow.name}-${name}`, error),
|
||||
}),
|
||||
concurrency,
|
||||
);
|
||||
}
|
||||
|
||||
addEffect(flow: Flow<R>, definition: FlowDefinition) {
|
||||
const spec = this;
|
||||
return Effect.gen(function* () {
|
||||
const topic = definition.topics?.[spec.name] ?? spec.name;
|
||||
const factory = yield* ConsumerFactory;
|
||||
const consumer = yield* factory.run<T, E, R>(
|
||||
{
|
||||
topic,
|
||||
subscription: `${flow.processorId}-${flow.name}-${spec.name}`,
|
||||
handler: spec.handler,
|
||||
concurrency: spec.concurrency,
|
||||
},
|
||||
{ id: flow.processorId, name: flow.name, flow },
|
||||
);
|
||||
flow.registerConsumer(spec.name, consumer);
|
||||
});
|
||||
}
|
||||
|
||||
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
|
||||
const topic = definition.topics?.[this.name] ?? this.name;
|
||||
|
||||
const consumer = new Consumer<T>({
|
||||
pubsub,
|
||||
topic,
|
||||
subscription: `${flow.processorId}-${flow.name}-${this.name}`,
|
||||
handler: this.handler,
|
||||
concurrency: this.concurrency,
|
||||
});
|
||||
|
||||
flow.registerConsumer(this.name, consumer as Consumer<unknown>);
|
||||
const effect = this.addEffect(flow, definition) as Effect.Effect<
|
||||
void,
|
||||
PubSubError,
|
||||
SpecRuntimeRequirements
|
||||
>;
|
||||
await flow.runInCompatibilityScope(effect, pubsub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type { Spec } from "./types.js";
|
||||
export type { Spec, SpecRuntimeError, SpecRuntimeRequirements } from "./types.js";
|
||||
export { ConsumerSpec } from "./consumer-spec.js";
|
||||
export { ProducerSpec } from "./producer-spec.js";
|
||||
export { ParameterSpec } from "./parameter-spec.js";
|
||||
|
|
|
|||
|
|
@ -4,15 +4,27 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/parameter_spec.py
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import type { Spec } from "./types.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
|
||||
export class ParameterSpec implements Spec {
|
||||
constructor(public readonly name: string) {}
|
||||
public readonly name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
addEffect(flow: Flow, definition: FlowDefinition) {
|
||||
const spec = this;
|
||||
return Effect.sync(() => {
|
||||
const value = definition.parameters?.[spec.name];
|
||||
flow.setParameter(spec.name, value);
|
||||
});
|
||||
}
|
||||
|
||||
async add(flow: Flow, _pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
|
||||
const value = definition.parameters?.[this.name];
|
||||
flow.setParameter(this.name, value);
|
||||
await Effect.runPromise(this.addEffect(flow, definition));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,33 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/producer_spec.py
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import type { Spec } from "./types.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import { Producer } from "../messaging/producer.js";
|
||||
import {
|
||||
ProducerFactory,
|
||||
type EffectProducer,
|
||||
} from "../messaging/runtime.js";
|
||||
|
||||
export class ProducerSpec<T> implements Spec {
|
||||
constructor(public readonly name: string) {}
|
||||
public readonly name: string;
|
||||
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
addEffect(flow: Flow, definition: FlowDefinition) {
|
||||
const spec = this;
|
||||
return Effect.gen(function* () {
|
||||
const topic = definition.topics?.[spec.name] ?? spec.name;
|
||||
const factory = yield* ProducerFactory;
|
||||
const producer = yield* factory.make<T>({ topic });
|
||||
flow.registerProducer(spec.name, producer as EffectProducer<unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
|
||||
const topic = definition.topics?.[this.name] ?? this.name;
|
||||
const producer = new Producer<T>(pubsub, topic);
|
||||
await producer.start();
|
||||
flow.registerProducer(this.name, producer as Producer<unknown>);
|
||||
await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,30 +7,46 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/prompt_client_spec.py
|
||||
*/
|
||||
|
||||
import { Effect } from "effect";
|
||||
import type { Spec } from "./types.js";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import { RequestResponse } from "../messaging/request-response.js";
|
||||
import {
|
||||
RequestResponseFactory,
|
||||
type EffectRequestResponse,
|
||||
} from "../messaging/runtime.js";
|
||||
|
||||
export class RequestResponseSpec<TReq, TRes> implements Spec {
|
||||
public readonly name: string;
|
||||
private readonly requestTopicName: string;
|
||||
private readonly responseTopicName: string;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
private readonly requestTopicName: string,
|
||||
private readonly responseTopicName: string,
|
||||
) {}
|
||||
name: string,
|
||||
requestTopicName: string,
|
||||
responseTopicName: string,
|
||||
) {
|
||||
this.name = name;
|
||||
this.requestTopicName = requestTopicName;
|
||||
this.responseTopicName = responseTopicName;
|
||||
}
|
||||
|
||||
addEffect(flow: Flow, definition: FlowDefinition) {
|
||||
const spec = this;
|
||||
return Effect.gen(function* () {
|
||||
const requestTopic = definition.topics?.[spec.requestTopicName] ?? spec.requestTopicName;
|
||||
const responseTopic = definition.topics?.[spec.responseTopicName] ?? spec.responseTopicName;
|
||||
const factory = yield* RequestResponseFactory;
|
||||
const requestor = yield* factory.make<TReq, TRes>({
|
||||
requestTopic,
|
||||
responseTopic,
|
||||
subscription: `${flow.processorId}-${flow.name}-${spec.name}`,
|
||||
});
|
||||
flow.registerRequestor(spec.name, requestor as EffectRequestResponse<unknown, unknown>);
|
||||
});
|
||||
}
|
||||
|
||||
async add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void> {
|
||||
const requestTopic = definition.topics?.[this.requestTopicName] ?? this.requestTopicName;
|
||||
const responseTopic = definition.topics?.[this.responseTopicName] ?? this.responseTopicName;
|
||||
|
||||
const rr = new RequestResponse<TReq, TRes>({
|
||||
pubsub,
|
||||
requestTopic,
|
||||
responseTopic,
|
||||
subscription: `${flow.processorId}-${flow.name}-${this.name}`,
|
||||
});
|
||||
await rr.start();
|
||||
|
||||
flow.registerRequestor(this.name, rr as RequestResponse<unknown, unknown>);
|
||||
await flow.runInCompatibilityScope(this.addEffect(flow, definition), pubsub);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,29 @@
|
|||
* Python reference: trustgraph-base/trustgraph/base/spec.py and siblings
|
||||
*/
|
||||
|
||||
import type { Effect, Scope } from "effect";
|
||||
import type { PubSubBackend } from "../backend/types.js";
|
||||
import type {
|
||||
ConsumerFactory,
|
||||
ProducerFactory,
|
||||
RequestResponseFactory,
|
||||
} from "../messaging/runtime.js";
|
||||
import type { Flow, FlowDefinition } from "../processor/flow.js";
|
||||
import type { PubSubError } from "../errors.js";
|
||||
|
||||
export interface Spec {
|
||||
export type SpecRuntimeRequirements =
|
||||
| Scope.Scope
|
||||
| ProducerFactory
|
||||
| ConsumerFactory
|
||||
| RequestResponseFactory;
|
||||
|
||||
export type SpecRuntimeError = PubSubError;
|
||||
|
||||
export interface Spec<Requirements = never> {
|
||||
name: string;
|
||||
addEffect(
|
||||
flow: Flow<Requirements>,
|
||||
definition: FlowDefinition,
|
||||
): Effect.Effect<void, SpecRuntimeError, SpecRuntimeRequirements | Requirements>;
|
||||
add(flow: Flow, pubsub: PubSubBackend, definition: FlowDefinition): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src"],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
exclude: ["dist/**", "node_modules/**"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue