mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 01:19:38 +02:00
Isolate concurrent Effect consumers
This commit is contained in:
parent
eaa7921314
commit
0fb943c0ef
3 changed files with 120 additions and 16 deletions
|
|
@ -13,7 +13,7 @@ Verified source roots:
|
||||||
- Installed Effect beta used by this workspace: `ts/node_modules/effect`
|
- Installed Effect beta used by this workspace: `ts/node_modules/effect`
|
||||||
|
|
||||||
Current signal counts from `ts/packages` after the 2026-06-02 consumer
|
Current signal counts from `ts/packages` after the 2026-06-02 consumer
|
||||||
rate-limit retry slice:
|
concurrency ownership slice:
|
||||||
|
|
||||||
| Signal | Count |
|
| Signal | Count |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
|
|
@ -116,6 +116,11 @@ Notes:
|
||||||
now retry with `Schedule.spaced` until success or a tagged rate-limit timeout.
|
now retry with `Schedule.spaced` until success or a tagged rate-limit timeout.
|
||||||
The `new Error` count dropped by one because a touched consumer test fixture
|
The `new Error` count dropped by one because a touched consumer test fixture
|
||||||
no longer uses a normal `Error`.
|
no longer uses a normal `Error`.
|
||||||
|
- The consumer concurrency ownership slice changed the Effect-native consumer
|
||||||
|
runtime so `concurrency > 1` allocates one backend consumer per worker instead
|
||||||
|
of sharing a single `BackendConsumer.receive()` handle. `stop` is now
|
||||||
|
idempotent through `Ref`, so explicit stop and scoped finalizers do not close
|
||||||
|
workers twice.
|
||||||
- The gateway streaming callback slice added Effect-returning dispatcher
|
- The gateway streaming callback slice added Effect-returning dispatcher
|
||||||
streaming methods, switched the RPC stream server off nested
|
streaming methods, switched the RPC stream server off nested
|
||||||
`Effect.runPromiseWith(context)` queue offers, and replaced the client
|
`Effect.runPromiseWith(context)` queue offers, and replaced the client
|
||||||
|
|
@ -1238,6 +1243,26 @@ Notes:
|
||||||
- `cd ts && bun run build`
|
- `cd ts && bun run build`
|
||||||
- `cd ts && bun run test`
|
- `cd ts && bun run test`
|
||||||
|
|
||||||
|
### 2026-06-02: Consumer Concurrency Ownership Slice
|
||||||
|
|
||||||
|
- Status: migrated and root-verified.
|
||||||
|
- Completed:
|
||||||
|
- `makeEffectConsumerFromPubSub` now creates one backend consumer per
|
||||||
|
concurrency worker rather than sharing a single backend consumer across
|
||||||
|
parallel receive loops.
|
||||||
|
- Consumer runtime `stop` is idempotent via `Ref.getAndSet`, so explicit
|
||||||
|
`consumer.stop` and scope finalization do not double-close worker handles.
|
||||||
|
- Added Effect-native runtime coverage proving `concurrency: 3` creates and
|
||||||
|
closes three independent backend consumers exactly once.
|
||||||
|
- Verification:
|
||||||
|
- `cd ts && bun run check:tsgo`
|
||||||
|
- `cd ts/packages/base && bunx --bun vitest run src/__tests__/messaging-runtime.test.ts`
|
||||||
|
- `bun run --cwd ts/packages/base build`
|
||||||
|
- `bun run --cwd ts/packages/base test`
|
||||||
|
- `cd ts && bun run check`
|
||||||
|
- `cd ts && bun run build`
|
||||||
|
- `cd ts && bun run test`
|
||||||
|
|
||||||
## Subagent Findings To Preserve
|
## Subagent Findings To Preserve
|
||||||
|
|
||||||
- MCP/workbench:
|
- MCP/workbench:
|
||||||
|
|
@ -1273,9 +1298,10 @@ Notes:
|
||||||
behavior now stay typed. Remaining NATS work is scoped backend/layer
|
behavior now stay typed. Remaining NATS work is scoped backend/layer
|
||||||
construction and stream/consumer state ownership.
|
construction and stream/consumer state ownership.
|
||||||
- Consumer rate-limit retry timeout behavior is now wired in both legacy and
|
- Consumer rate-limit retry timeout behavior is now wired in both legacy and
|
||||||
Effect-native consumer paths. Remaining consumer runtime work should focus
|
Effect-native consumer paths. Effect-native consumer concurrency now owns
|
||||||
on per-worker backend consumer ownership and request/response pending
|
one backend consumer per worker. Remaining consumer runtime work should
|
||||||
shutdown semantics.
|
focus on request/response pending shutdown semantics and the legacy
|
||||||
|
consumer facade's blocking compatibility shape.
|
||||||
- Existing constructor shims preserve callable-plus-newable public exports;
|
- Existing constructor shims preserve callable-plus-newable public exports;
|
||||||
removing them needs a public API split or real class redesign.
|
removing them needs a public API split or real class redesign.
|
||||||
- Typed string registries in `Flow` now have Schema-backed parameter specs
|
- Typed string registries in `Flow` now have Schema-backed parameter specs
|
||||||
|
|
@ -1350,7 +1376,10 @@ Notes:
|
||||||
create-on-failure behavior. Future backend slices should move
|
create-on-failure behavior. Future backend slices should move
|
||||||
connection/stream state into scoped Effect services.
|
connection/stream state into scoped Effect services.
|
||||||
- Treat rate-limit retry timeout semantics as complete; next consumer slices
|
- Treat rate-limit retry timeout semantics as complete; next consumer slices
|
||||||
should focus on concurrency ownership and shutdown, not retry policy.
|
should focus on shutdown, not retry policy.
|
||||||
|
- Treat Effect-native per-worker consumer ownership as complete; do not flag
|
||||||
|
`makeEffectConsumerFromPubSub` concurrency for shared backend receive
|
||||||
|
handles.
|
||||||
- Tests:
|
- Tests:
|
||||||
- Fake backend ack/nak/backoff/stop tests, NATS close finalizer tests, and
|
- Fake backend ack/nak/backoff/stop tests, NATS close finalizer tests, and
|
||||||
config-push stream tests.
|
config-push stream tests.
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,41 @@ class RuntimeBackend implements PubSubBackend {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ConsumerHandle {
|
||||||
|
closeCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConcurrentConsumerBackend implements PubSubBackend {
|
||||||
|
readonly consumerOptions: Array<CreateConsumerOptions> = [];
|
||||||
|
readonly consumers: Array<ConsumerHandle> = [];
|
||||||
|
|
||||||
|
async createProducer<T>(_options: CreateProducerOptions<T>): Promise<BackendProducer<T>> {
|
||||||
|
return {
|
||||||
|
send: async () => {},
|
||||||
|
flush: async () => {},
|
||||||
|
close: async () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConsumer<T>(options: CreateConsumerOptions<T>): Promise<BackendConsumer<T>> {
|
||||||
|
const handle = new ConsumerHandle();
|
||||||
|
this.consumerOptions.push(options);
|
||||||
|
this.consumers.push(handle);
|
||||||
|
|
||||||
|
return {
|
||||||
|
receive: async () => null,
|
||||||
|
acknowledge: async () => {},
|
||||||
|
negativeAcknowledge: async () => {},
|
||||||
|
unsubscribe: async () => {},
|
||||||
|
close: async () => {
|
||||||
|
handle.closeCount += 1;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
const flowContext: FlowContext = {
|
const flowContext: FlowContext = {
|
||||||
id: "processor",
|
id: "processor",
|
||||||
name: "default",
|
name: "default",
|
||||||
|
|
@ -179,6 +214,34 @@ describe("Effect-native messaging runtime", () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.effect(
|
||||||
|
"creates and closes one backend consumer per concurrency worker",
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
const backend = new ConcurrentConsumerBackend();
|
||||||
|
|
||||||
|
yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const consumer = yield* runEffectConsumerScoped<string>(
|
||||||
|
{
|
||||||
|
topic: "tg.test.consumer",
|
||||||
|
subscription: "sub",
|
||||||
|
concurrency: 3,
|
||||||
|
receiveTimeoutMs: 1,
|
||||||
|
errorBackoffMs: 1,
|
||||||
|
handler: () => Effect.void,
|
||||||
|
},
|
||||||
|
flowContext,
|
||||||
|
);
|
||||||
|
yield* consumer.stop;
|
||||||
|
yield* consumer.stop;
|
||||||
|
}).pipe(Effect.provide(PubSub.layer(backend))),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(backend.consumerOptions).toHaveLength(3);
|
||||||
|
expect(backend.consumers.map((consumer) => consumer.closeCount)).toEqual([1, 1, 1]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
it.effect(
|
it.effect(
|
||||||
"retries rate-limited Effect handlers until success within the timeout",
|
"retries rate-limited Effect handlers until success within the timeout",
|
||||||
Effect.fnUntraced(function* () {
|
Effect.fnUntraced(function* () {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { Context, Duration, Effect, Fiber, Layer, Queue, Result, Schedule, Scope, Stream } from "effect";
|
import { Context, Duration, Effect, Fiber, Layer, Queue, Ref, Result, Schedule, Scope, Stream } from "effect";
|
||||||
import * as O from "effect/Option";
|
import * as O from "effect/Option";
|
||||||
import * as S from "effect/Schema";
|
import * as S from "effect/Schema";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -346,20 +346,32 @@ export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPub
|
||||||
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
|
...(options.initialPosition === undefined ? {} : { initialPosition: options.initialPosition }),
|
||||||
...(options.schema === undefined ? {} : { schema: options.schema }),
|
...(options.schema === undefined ? {} : { schema: options.schema }),
|
||||||
};
|
};
|
||||||
const backend = yield* pubsub.createConsumer<T>(createOptions);
|
|
||||||
const concurrency = Math.max(1, options.concurrency ?? 1);
|
const concurrency = Math.max(1, options.concurrency ?? 1);
|
||||||
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
|
const workerIndexes = Array.from({ length: concurrency }, (_value, index) => index);
|
||||||
const fibers = yield* Effect.forEach(workerIndexes, () =>
|
const workerConfig = {
|
||||||
consumerLoop(backend, options, flow, {
|
...config,
|
||||||
...config,
|
rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs,
|
||||||
rateLimitRetryMs: options.rateLimitRetryMs ?? config.rateLimitRetryMs,
|
rateLimitTimeoutMs: options.rateLimitTimeoutMs ?? config.rateLimitTimeoutMs,
|
||||||
rateLimitTimeoutMs: options.rateLimitTimeoutMs ?? config.rateLimitTimeoutMs,
|
};
|
||||||
}).pipe(Effect.forkChild),
|
const workers = yield* Effect.forEach(workerIndexes, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const backend = yield* pubsub.createConsumer<T>(createOptions);
|
||||||
|
const fiber = yield* consumerLoop(backend, options, flow, workerConfig).pipe(Effect.forkChild);
|
||||||
|
return { backend, fiber };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
const stopped = yield* Ref.make(false);
|
||||||
|
|
||||||
const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () {
|
const stop = Effect.fn(`Consumer.stop:${options.topic}`)(function* () {
|
||||||
yield* Effect.forEach(fibers, Fiber.interrupt, { discard: true });
|
const alreadyStopped = yield* Ref.getAndSet(stopped, true);
|
||||||
yield* closeConsumerBackend(backend, options.topic, options.subscription);
|
if (alreadyStopped) return;
|
||||||
|
|
||||||
|
yield* Effect.forEach(workers, (worker) => Fiber.interrupt(worker.fiber), { discard: true });
|
||||||
|
yield* Effect.forEach(
|
||||||
|
workers,
|
||||||
|
(worker) => closeConsumerBackend(worker.backend, options.topic, options.subscription),
|
||||||
|
{ discard: true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
|
|
@ -375,7 +387,7 @@ export const makeEffectConsumerFromPubSub = Effect.fn("makeEffectConsumerFromPub
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fibers,
|
fibers: workers.map((worker) => worker.fiber),
|
||||||
stop: stop(),
|
stop: stop(),
|
||||||
} satisfies EffectConsumer;
|
} satisfies EffectConsumer;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue