mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-06 11:52:10 +02:00
Create NATS resources only on missing lookups
This commit is contained in:
parent
00a26b7deb
commit
46ae1dca82
3 changed files with 190 additions and 41 deletions
|
|
@ -12,8 +12,8 @@ Verified source roots:
|
||||||
- Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4`
|
- Effect v4 subtree: `/home/elpresidank/YeeBois/projects/beep-effect2/.repos/effect-v4`
|
||||||
- Installed Effect beta used by this workspace: `ts/node_modules/effect`
|
- Installed Effect beta used by this workspace: `ts/node_modules/effect`
|
||||||
|
|
||||||
Current signal counts from `ts/packages` after the 2026-06-02 NATS typed
|
Current signal counts from `ts/packages` after the 2026-06-02 NATS selective
|
||||||
boundary slice:
|
404 slice:
|
||||||
|
|
||||||
| Signal | Count |
|
| Signal | Count |
|
||||||
| --- | ---: |
|
| --- | ---: |
|
||||||
|
|
@ -105,6 +105,11 @@ Notes:
|
||||||
path and maps header construction plus `ack()`/`nak()` failures into tagged
|
path and maps header construction plus `ack()`/`nak()` failures into tagged
|
||||||
`PubSubError`s with `Effect.try`. The `receive(` and `JSON.stringify` count
|
`PubSubError`s with `Effect.try`. The `receive(` and `JSON.stringify` count
|
||||||
increases are from the new mocked NATS backend test, not production code.
|
increases are from the new mocked NATS backend test, not production code.
|
||||||
|
- The NATS selective 404 slice replaced catch-all stream/consumer create
|
||||||
|
fallbacks with an internal `S.TaggedErrorClass` lookup wrapper plus
|
||||||
|
`Effect.catchIf` recovery only for NATS JetStream missing-resource errors.
|
||||||
|
Non-missing lookup failures now stay on the typed failure path without
|
||||||
|
attempting to create streams or durable consumers.
|
||||||
- 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
|
||||||
|
|
@ -1182,6 +1187,27 @@ Notes:
|
||||||
- `cd ts && bun run build`
|
- `cd ts && bun run build`
|
||||||
- `cd ts && bun run test`
|
- `cd ts && bun run test`
|
||||||
|
|
||||||
|
### 2026-06-02: NATS Selective 404 Slice
|
||||||
|
|
||||||
|
- Status: migrated and root-verified.
|
||||||
|
- Completed:
|
||||||
|
- Added an internal `NatsLookupError` tagged error to preserve lookup causes
|
||||||
|
without leaving `unknown` in Effect error channels.
|
||||||
|
- Stream creation now happens only when `manager.streams.info()` fails with
|
||||||
|
a NATS JetStream 404/missing-resource error.
|
||||||
|
- Durable consumer creation now happens only when `js.consumers.get()` fails
|
||||||
|
with a NATS JetStream 404/missing-resource error.
|
||||||
|
- Added mocked NATS tests proving 404 lookups create resources while
|
||||||
|
permission-style lookup failures do not.
|
||||||
|
- Verification:
|
||||||
|
- `cd ts && bun run check:tsgo`
|
||||||
|
- `cd ts/packages/base && bunx --bun vitest run src/__tests__/nats-backend.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:
|
||||||
|
|
@ -1213,10 +1239,9 @@ Notes:
|
||||||
runtime. Remaining broker P0 work should focus on native backend/NATS
|
runtime. Remaining broker P0 work should focus on native backend/NATS
|
||||||
runtime shape and consumer polling, not replacing `PubSubBackend` with
|
runtime shape and consumer polling, not replacing `PubSubBackend` with
|
||||||
`effect/PubSub`.
|
`effect/PubSub`.
|
||||||
- NATS header construction and ack/nak operations now map thrown SDK
|
- NATS header construction, ack/nak operations, and lookup create-on-missing
|
||||||
failures into tagged `PubSubError`s. Remaining NATS work is selective
|
behavior now stay typed. Remaining NATS work is scoped backend/layer
|
||||||
404 handling, scoped backend/layer construction, and stream/consumer state
|
construction and stream/consumer state ownership.
|
||||||
ownership.
|
|
||||||
- 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
|
||||||
|
|
@ -1287,9 +1312,9 @@ Notes:
|
||||||
- Treat the producer Promise facade as a completed compatibility wrapper;
|
- Treat the producer Promise facade as a completed compatibility wrapper;
|
||||||
avoid reopening it unless backend runtime changes require a narrower
|
avoid reopening it unless backend runtime changes require a narrower
|
||||||
adapter.
|
adapter.
|
||||||
- Keep NATS SDK boundary failures typed; future backend slices should avoid
|
- Keep NATS SDK boundary failures typed and avoid catch-all
|
||||||
catch-all create-on-failure behavior and move connection/stream state into
|
create-on-failure behavior. Future backend slices should move
|
||||||
scoped Effect services.
|
connection/stream state into scoped Effect services.
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,27 @@ const natsMock = vi.hoisted(() => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
class MockNatsError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
private readonly apiCode: number | undefined;
|
||||||
|
|
||||||
|
constructor(code: string, apiCode?: number) {
|
||||||
|
super(code);
|
||||||
|
this.name = "NatsError";
|
||||||
|
this.code = code;
|
||||||
|
this.apiCode = apiCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsError() {
|
||||||
|
return this.apiCode === undefined
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
code: this.apiCode,
|
||||||
|
description: this.code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const publish = vi.fn();
|
const publish = vi.fn();
|
||||||
const consumersGet = vi.fn();
|
const consumersGet = vi.fn();
|
||||||
const consumersAdd = vi.fn();
|
const consumersAdd = vi.fn();
|
||||||
|
|
@ -28,6 +49,7 @@ const natsMock = vi.hoisted(() => {
|
||||||
encoder,
|
encoder,
|
||||||
headerAppend,
|
headerAppend,
|
||||||
headers,
|
headers,
|
||||||
|
NatsError: MockNatsError,
|
||||||
nak,
|
nak,
|
||||||
next,
|
next,
|
||||||
publish,
|
publish,
|
||||||
|
|
@ -39,14 +61,20 @@ const natsMock = vi.hoisted(() => {
|
||||||
vi.mock("nats", () => ({
|
vi.mock("nats", () => ({
|
||||||
AckPolicy: { Explicit: "explicit" },
|
AckPolicy: { Explicit: "explicit" },
|
||||||
DeliverPolicy: { All: "all", New: "new" },
|
DeliverPolicy: { All: "all", New: "new" },
|
||||||
|
ErrorCode: { JetStream404NoMessages: "404" },
|
||||||
StringCodec: () => ({
|
StringCodec: () => ({
|
||||||
decode: (input: Uint8Array) => natsMock.decoder.decode(input),
|
decode: (input: Uint8Array) => natsMock.decoder.decode(input),
|
||||||
encode: (input: string) => natsMock.encoder.encode(input),
|
encode: (input: string) => natsMock.encoder.encode(input),
|
||||||
}),
|
}),
|
||||||
connect: natsMock.connect,
|
connect: natsMock.connect,
|
||||||
headers: natsMock.headers,
|
headers: natsMock.headers,
|
||||||
|
NatsError: natsMock.NatsError,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function makeNatsError(code: string, apiCode?: number) {
|
||||||
|
return new natsMock.NatsError(code, apiCode);
|
||||||
|
}
|
||||||
|
|
||||||
function resetNatsMock(): void {
|
function resetNatsMock(): void {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
|
@ -88,6 +116,66 @@ describe("NATS backend", () => {
|
||||||
resetNatsMock();
|
resetNatsMock();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates streams only when stream lookup returns a JetStream 404", async () => {
|
||||||
|
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("404", 404));
|
||||||
|
const backend = makeNatsBackend("nats://test");
|
||||||
|
|
||||||
|
await backend.createProducer<string>({ topic: "tg.test.topic" });
|
||||||
|
|
||||||
|
expect(natsMock.streamsAdd).toHaveBeenCalledWith({
|
||||||
|
name: "tg_test",
|
||||||
|
subjects: ["tg.test.>"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create streams for non-missing lookup failures", async () => {
|
||||||
|
natsMock.streamsInfo.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
|
||||||
|
const backend = makeNatsBackend("nats://test");
|
||||||
|
|
||||||
|
const error = await backend.createProducer<string>({ topic: "tg.test.topic" }).catch((caught: unknown) => caught);
|
||||||
|
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
_tag: "PubSubError",
|
||||||
|
operation: "stream-info:tg_test",
|
||||||
|
});
|
||||||
|
expect(natsMock.streamsAdd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates durable consumers only when consumer lookup returns a JetStream 404", async () => {
|
||||||
|
natsMock.consumersGet
|
||||||
|
.mockRejectedValueOnce(makeNatsError("404", 404))
|
||||||
|
.mockResolvedValueOnce({ next: natsMock.next });
|
||||||
|
const backend = makeNatsBackend("nats://test");
|
||||||
|
|
||||||
|
await backend.createConsumer<string>({
|
||||||
|
topic: "tg.test.topic",
|
||||||
|
subscription: "worker",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(natsMock.consumersAdd).toHaveBeenCalledWith("tg_test", {
|
||||||
|
ack_policy: "explicit",
|
||||||
|
deliver_policy: "new",
|
||||||
|
durable_name: "worker",
|
||||||
|
filter_subject: "tg.test.topic",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create durable consumers for non-missing lookup failures", async () => {
|
||||||
|
natsMock.consumersGet.mockRejectedValueOnce(makeNatsError("PERMISSIONS_VIOLATION"));
|
||||||
|
const backend = makeNatsBackend("nats://test");
|
||||||
|
|
||||||
|
const error = await backend.createConsumer<string>({
|
||||||
|
topic: "tg.test.topic",
|
||||||
|
subscription: "worker",
|
||||||
|
}).catch((caught: unknown) => caught);
|
||||||
|
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
_tag: "PubSubError",
|
||||||
|
operation: "init-consumer:tg.test.topic",
|
||||||
|
});
|
||||||
|
expect(natsMock.consumersAdd).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("maps invalid publish headers to tagged PubSubError", async () => {
|
it("maps invalid publish headers to tagged PubSubError", async () => {
|
||||||
natsMock.headerAppend.mockImplementation(() => {
|
natsMock.headerAppend.mockImplementation(() => {
|
||||||
throw "invalid header";
|
throw "invalid header";
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import {
|
import {
|
||||||
connect,
|
connect,
|
||||||
|
ErrorCode,
|
||||||
type NatsConnection,
|
type NatsConnection,
|
||||||
type JetStreamClient,
|
type JetStreamClient,
|
||||||
type JetStreamManager,
|
type JetStreamManager,
|
||||||
|
|
@ -17,6 +18,7 @@ import {
|
||||||
headers,
|
headers,
|
||||||
type JsMsg,
|
type JsMsg,
|
||||||
type JetStreamPublishOptions,
|
type JetStreamPublishOptions,
|
||||||
|
NatsError,
|
||||||
StringCodec,
|
StringCodec,
|
||||||
AckPolicy,
|
AckPolicy,
|
||||||
DeliverPolicy,
|
DeliverPolicy,
|
||||||
|
|
@ -64,6 +66,18 @@ function makeNatsMessage<T>(msg: JsMsg, decoded: T): NatsMessage<T> {
|
||||||
|
|
||||||
const hasJsMsg = Predicate.hasProperty("_jsMsg");
|
const hasJsMsg = Predicate.hasProperty("_jsMsg");
|
||||||
|
|
||||||
|
class NatsLookupError extends S.TaggedErrorClass<NatsLookupError>()(
|
||||||
|
"NatsLookupError",
|
||||||
|
{
|
||||||
|
cause: S.Unknown,
|
||||||
|
operation: S.String,
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
function natsLookupError(operation: string, cause: unknown): NatsLookupError {
|
||||||
|
return NatsLookupError.make({ cause, operation });
|
||||||
|
}
|
||||||
|
|
||||||
function isAckableJsMsg(value: unknown): value is Pick<JsMsg, "ack" | "nak"> {
|
function isAckableJsMsg(value: unknown): value is Pick<JsMsg, "ack" | "nak"> {
|
||||||
if (!Predicate.isObject(value)) return false;
|
if (!Predicate.isObject(value)) return false;
|
||||||
if (!Predicate.hasProperty(value, "ack")) return false;
|
if (!Predicate.hasProperty(value, "ack")) return false;
|
||||||
|
|
@ -75,6 +89,22 @@ function isNatsMessage<T>(message: Message<T>): message is NatsMessage<T> {
|
||||||
return hasJsMsg(message) && isAckableJsMsg(message._jsMsg);
|
return hasJsMsg(message) && isAckableJsMsg(message._jsMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isJetStreamMissingResource(error: unknown): boolean {
|
||||||
|
if (!(error instanceof NatsError)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (error.code === ErrorCode.JetStream404NoMessages) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsError = error.jsError();
|
||||||
|
return jsError?.code === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingLookupError(error: NatsLookupError): boolean {
|
||||||
|
return isJetStreamMissingResource(error.cause);
|
||||||
|
}
|
||||||
|
|
||||||
function makeNatsProducer<T>(
|
function makeNatsProducer<T>(
|
||||||
js: JetStreamClient,
|
js: JetStreamClient,
|
||||||
subject: string,
|
subject: string,
|
||||||
|
|
@ -148,31 +178,34 @@ function makeNatsConsumer<T>(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const existing = yield* Effect.tryPromise({
|
const existing = yield* Effect.tryPromise({
|
||||||
try: () => js.consumers.get(streamName, subscription),
|
try: () => js.consumers.get(streamName, subscription),
|
||||||
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
|
catch: (error) => natsLookupError(`get-consumer:${streamName}:${subscription}`, error),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch(() =>
|
Effect.catchIf(
|
||||||
Effect.gen(function* () {
|
isMissingLookupError,
|
||||||
const deliverPolicy =
|
() =>
|
||||||
initialPosition === "earliest"
|
Effect.gen(function* () {
|
||||||
? DeliverPolicy.All
|
const deliverPolicy =
|
||||||
: DeliverPolicy.New;
|
initialPosition === "earliest"
|
||||||
|
? DeliverPolicy.All
|
||||||
|
: DeliverPolicy.New;
|
||||||
|
|
||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: () =>
|
try: () =>
|
||||||
jsm.consumers.add(streamName, {
|
jsm.consumers.add(streamName, {
|
||||||
durable_name: subscription,
|
durable_name: subscription,
|
||||||
ack_policy: AckPolicy.Explicit,
|
ack_policy: AckPolicy.Explicit,
|
||||||
deliver_policy: deliverPolicy,
|
deliver_policy: deliverPolicy,
|
||||||
filter_subject: subject,
|
filter_subject: subject,
|
||||||
}),
|
}),
|
||||||
catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error),
|
catch: (error) => pubSubError(`add-consumer:${streamName}:${subscription}`, error),
|
||||||
});
|
});
|
||||||
|
|
||||||
return yield* Effect.tryPromise({
|
return yield* Effect.tryPromise({
|
||||||
try: () => js.consumers.get(streamName, subscription),
|
try: () => js.consumers.get(streamName, subscription),
|
||||||
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
|
catch: (error) => pubSubError(`get-consumer:${streamName}:${subscription}`, error),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
consumer = existing;
|
consumer = existing;
|
||||||
|
|
@ -289,17 +322,20 @@ export function makeNatsBackend(url = "nats://localhost:4222"): PubSubBackend {
|
||||||
|
|
||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: () => manager.streams.info(streamName),
|
try: () => manager.streams.info(streamName),
|
||||||
catch: (error) => pubSubError(`stream-info:${streamName}`, error),
|
catch: (error) => natsLookupError(`stream-info:${streamName}`, error),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch(() =>
|
Effect.catchIf(
|
||||||
Effect.tryPromise({
|
isMissingLookupError,
|
||||||
try: () =>
|
() =>
|
||||||
manager.streams.add({
|
Effect.tryPromise({
|
||||||
name: streamName,
|
try: () =>
|
||||||
subjects: [wildcardSubject],
|
manager.streams.add({
|
||||||
}),
|
name: streamName,
|
||||||
catch: (error) => pubSubError(`stream-add:${streamName}`, error),
|
subjects: [wildcardSubject],
|
||||||
}),
|
}),
|
||||||
|
catch: (error) => pubSubError(`stream-add:${streamName}`, error),
|
||||||
|
}),
|
||||||
|
(error) => Effect.fail(pubSubError(error.operation, error.cause)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
initializedStreams.add(streamName);
|
initializedStreams.add(streamName);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue