@cosmicdrift/kumiko-framework 0.322.0 → 0.324.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ // fw#3360: stack.drainJobs() waits deterministically for every in-flight
2
+ // job AND its follow-up event-consumer / job-trigger cascade — replaces
3
+ // `waitFor` polling on a specific job's side effect.
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import * as z from "zod";
7
+ import { defineFeature } from "../../engine";
8
+ import { SYSTEM_ROLE } from "../../engine/system-user";
9
+ import { setupTestStack, type TestStack } from "../test-stack";
10
+ import { TestUsers } from "../test-users";
11
+
12
+ const chainResults: Array<{ value: string }> = [];
13
+
14
+ // Job A → write handler that appends an r.defineEvent event → the async
15
+ // job-trigger event-consumer → Job B. Exercises exactly the gap drainJobs
16
+ // exists for: a job-completion that only cascades into a follow-up job via
17
+ // the event-dispatcher, not the synchronous write-handler dispatch path.
18
+ const chainFeature = defineFeature("draintestchain", (r) => {
19
+ const relayed = r.defineEvent("relayed", z.object({ value: z.string() }), {
20
+ piiFields: "none",
21
+ });
22
+
23
+ r.writeHandler(
24
+ "start",
25
+ z.object({ value: z.string() }),
26
+ async () => ({ isSuccess: true as const, data: {} }),
27
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
28
+ );
29
+
30
+ r.writeHandler(
31
+ "emit-relayed",
32
+ z.object({ value: z.string() }),
33
+ async (event, ctx) => {
34
+ await ctx.unsafeAppendEvent({
35
+ aggregateId: crypto.randomUUID(),
36
+ aggregateType: "draintestchain",
37
+ type: relayed.name,
38
+ payload: { value: event.payload.value },
39
+ });
40
+ return { isSuccess: true as const, data: {} };
41
+ },
42
+ { access: { roles: [SYSTEM_ROLE] } },
43
+ );
44
+
45
+ r.job(
46
+ "job-a",
47
+ { trigger: { on: "draintestchain:write:start" }, retries: 0 },
48
+ async (payload, ctx) => {
49
+ await ctx.write("draintestchain:write:emit-relayed", { value: payload["value"] });
50
+ },
51
+ );
52
+
53
+ r.job("job-b", { trigger: { on: relayed.name }, retries: 0 }, async (payload) => {
54
+ chainResults.push({ value: payload["value"] as string }); // @cast-boundary test-fixture
55
+ });
56
+ });
57
+
58
+ const alwaysFailsRuns: string[] = [];
59
+ const retrySucceedsAttempts: number[] = [];
60
+
61
+ const failureFeature = defineFeature("draintestfailure", (r) => {
62
+ r.job("always-fails", { trigger: { manual: true }, retries: 0 }, async () => {
63
+ alwaysFailsRuns.push("ran");
64
+ throw new Error("job-c always fails");
65
+ });
66
+
67
+ r.job(
68
+ "retry-succeeds",
69
+ { trigger: { manual: true }, retries: 1, backoff: { type: "fixed", delayMs: 10 } },
70
+ async () => {
71
+ retrySucceedsAttempts.push(Date.now());
72
+ if (retrySucceedsAttempts.length === 1) throw new Error("first attempt fails on purpose");
73
+ },
74
+ );
75
+ });
76
+
77
+ let stack: TestStack | undefined;
78
+
79
+ afterEach(async () => {
80
+ if (stack) await stack.cleanup();
81
+ stack = undefined;
82
+ });
83
+
84
+ describe("stack.drainJobs() waits for a job's async event-trigger cascade", () => {
85
+ test("job A → event → job B settles before drainJobs() resolves", async () => {
86
+ chainResults.length = 0;
87
+ stack = await setupTestStack({
88
+ features: [chainFeature],
89
+ jobs: { consumerLane: "worker" },
90
+ });
91
+
92
+ await stack.http.writeOk("draintestchain:write:start", { value: "hi" }, TestUsers.admin);
93
+ await stack.drainJobs();
94
+
95
+ expect(chainResults).toEqual([{ value: "hi" }]);
96
+ });
97
+
98
+ test("a huge event-dispatcher poll interval doesn't stop drainJobs() from driving the cascade itself", async () => {
99
+ chainResults.length = 0;
100
+ stack = await setupTestStack({
101
+ features: [chainFeature],
102
+ jobs: { consumerLane: "worker" },
103
+ });
104
+ // stop() tears down both the poll timer AND the LISTEN/NOTIFY
105
+ // subscription (idempotent — cleanup()'s own stop() later is a no-op).
106
+ // runOnce() still works afterward (event-dispatcher.ts's own contract),
107
+ // so this leaves drainJobs() as the ONLY thing that can advance the
108
+ // job-trigger consumer past job A's emitted event — without this, the
109
+ // always-on pgClient wake-up races the assertion regardless of
110
+ // eventDispatcherPollIntervalMs.
111
+ await stack.eventDispatcher?.stop();
112
+
113
+ await stack.http.writeOk("draintestchain:write:start", { value: "race" }, TestUsers.admin);
114
+ await stack.drainJobs();
115
+
116
+ expect(chainResults).toEqual([{ value: "race" }]);
117
+ });
118
+ });
119
+
120
+ describe("stack.drainJobs() surfaces final job failures", () => {
121
+ test("a job that always throws rejects drainJobs() with its name and error", async () => {
122
+ alwaysFailsRuns.length = 0;
123
+ stack = await setupTestStack({
124
+ features: [failureFeature],
125
+ jobs: { consumerLane: "worker" },
126
+ });
127
+
128
+ await stack.jobRunner?.dispatch("draintestfailure:job:always-fails");
129
+
130
+ await expect(stack.drainJobs()).rejects.toThrow(
131
+ /draintestfailure:job:always-fails.*job-c always fails/s,
132
+ );
133
+
134
+ // The tracker is cleared after a rejection — a later drainJobs() call
135
+ // (e.g. the next test's setup) must not still see this failure.
136
+ await expect(stack.drainJobs()).resolves.toBeUndefined();
137
+ });
138
+
139
+ test("a job with retries:1 that fails once then succeeds resolves drainJobs()", async () => {
140
+ retrySucceedsAttempts.length = 0;
141
+ stack = await setupTestStack({
142
+ features: [failureFeature],
143
+ jobs: { consumerLane: "worker" },
144
+ });
145
+
146
+ await stack.jobRunner?.dispatch("draintestfailure:job:retry-succeeds");
147
+
148
+ await expect(stack.drainJobs()).resolves.toBeUndefined();
149
+ expect(retrySucceedsAttempts).toHaveLength(2);
150
+ });
151
+ });
152
+
153
+ describe("stack.drainJobs() without a jobRunner", () => {
154
+ test("rejects with a clear message when the stack has no `jobs` opt-in", async () => {
155
+ stack = await setupTestStack({ features: [chainFeature] });
156
+
157
+ await expect(stack.drainJobs()).rejects.toThrow(/jobRunner is undefined/);
158
+ });
159
+ });
@@ -0,0 +1,129 @@
1
+ import type { DbConnection } from "../db";
2
+ import { getEventsHighWaterMark } from "../event-store";
3
+ import type { JobRunner } from "../jobs";
4
+ import { type EventDispatcher, getConsumerState } from "../pipeline";
5
+
6
+ // Gap between idle-checks while waiting for a job hook to fire. Short enough
7
+ // that a test never feels it, long enough that consecutive idle passes don't
8
+ // hammer Postgres/Redis with pointless polling between hook-driven wakeups.
9
+ const DRAIN_IDLE_POLL_MS = 25;
10
+ // Two consecutive idle passes (not one) before declaring the stack drained —
11
+ // a single "pending===0 and consumers caught up" pass can be a race: a job
12
+ // hook fires and enqueues a follow-up between the countPendingJobs() read
13
+ // and the consumer check of the SAME pass. A DRAIN_IDLE_POLL_MS wait sits
14
+ // between every pass, including the first and second idle one, so an
15
+ // enqueue that lands just after the first idle read still gets picked up
16
+ // by the second pass instead of racing straight past it.
17
+ const REQUIRED_IDLE_PASSES = 2;
18
+
19
+ function sleep(ms: number): Promise<void> {
20
+ return new Promise((resolve) => setTimeout(resolve, ms));
21
+ }
22
+
23
+ export type JobFailureTracker = {
24
+ readonly onJobComplete: (jobName: string, jobId: string) => void;
25
+ readonly onJobFailed: (jobName: string, jobId: string, error: string) => void;
26
+ waitForActivity(): Promise<void>;
27
+ readonly failures: ReadonlyMap<string, { readonly jobName: string; readonly error: string }>;
28
+ clear(): void;
29
+ };
30
+
31
+ export function createJobFailureTracker(): JobFailureTracker {
32
+ const failures = new Map<string, { jobName: string; error: string }>();
33
+ let wake: (() => void) | undefined;
34
+ let armed: Promise<void> | undefined;
35
+
36
+ function fire(): void {
37
+ wake?.();
38
+ armed = undefined;
39
+ wake = undefined;
40
+ }
41
+
42
+ return {
43
+ onJobComplete(_jobName, jobId) {
44
+ // A retry that eventually succeeds must not leave a stale failure
45
+ // behind from its earlier attempt(s).
46
+ failures.delete(jobId);
47
+ fire();
48
+ },
49
+ onJobFailed(jobName, jobId, error) {
50
+ failures.set(jobId, { jobName, error });
51
+ fire();
52
+ },
53
+ waitForActivity() {
54
+ armed ??= new Promise<void>((resolve) => {
55
+ wake = resolve;
56
+ });
57
+ return armed;
58
+ },
59
+ failures,
60
+ clear() {
61
+ failures.clear();
62
+ },
63
+ };
64
+ }
65
+
66
+ export type DrainJobsStack = {
67
+ readonly db: DbConnection;
68
+ readonly eventDispatcher?: EventDispatcher;
69
+ readonly jobRunner?: JobRunner;
70
+ };
71
+
72
+ async function sharedConsumersCaughtUp(
73
+ db: DbConnection,
74
+ consumerNames: readonly string[],
75
+ ): Promise<boolean> {
76
+ // skip: no shared consumers wired — nothing to catch up on.
77
+ if (consumerNames.length === 0) return true;
78
+ const target = await getEventsHighWaterMark(db);
79
+ let allCaughtUp = true;
80
+ for (const name of consumerNames) {
81
+ const state = await getConsumerState(db, name);
82
+ if (!state) {
83
+ throw new Error(`drainJobs: consumer "${name}" not registered or per-instance`);
84
+ }
85
+ if (state.status === "dead") {
86
+ throw new Error(
87
+ `drainJobs: consumer "${state.name}" is dead — lastError: ${state.lastError ?? "unknown"}`,
88
+ );
89
+ }
90
+ if (state.lastProcessedEventId < target) allCaughtUp = false;
91
+ }
92
+ return allCaughtUp;
93
+ }
94
+
95
+ export async function drainJobs(stack: DrainJobsStack, tracker: JobFailureTracker): Promise<void> {
96
+ const { db, eventDispatcher, jobRunner } = stack;
97
+ if (!jobRunner) {
98
+ throw new Error(
99
+ "drainJobs: stack.jobRunner is undefined — pass `jobs: {...}` to setupTestStack and " +
100
+ "register at least one r.job(...) so a runner is built.",
101
+ );
102
+ }
103
+
104
+ const sharedConsumerNames = (eventDispatcher?.consumers ?? [])
105
+ .filter((consumer) => (consumer.delivery ?? "shared") === "shared")
106
+ .map((consumer) => consumer.name);
107
+
108
+ try {
109
+ let consecutiveIdlePasses = 0;
110
+ while (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
111
+ await eventDispatcher?.runOnce();
112
+ const pending = await jobRunner.countPendingJobs();
113
+ const caughtUp = await sharedConsumersCaughtUp(db, sharedConsumerNames);
114
+ consecutiveIdlePasses = pending === 0 && caughtUp ? consecutiveIdlePasses + 1 : 0;
115
+ if (consecutiveIdlePasses < REQUIRED_IDLE_PASSES) {
116
+ await Promise.race([tracker.waitForActivity(), sleep(DRAIN_IDLE_POLL_MS)]);
117
+ }
118
+ }
119
+
120
+ if (tracker.failures.size > 0) {
121
+ const details = [...tracker.failures.values()]
122
+ .map((failure) => `${failure.jobName}: ${failure.error}`)
123
+ .join("; ");
124
+ throw new Error(`drainJobs: job(s) failed: ${details}`);
125
+ }
126
+ } finally {
127
+ tracker.clear();
128
+ }
129
+ }
@@ -22,6 +22,7 @@ import { createRateLimitResolver } from "../rate-limit";
22
22
  import { createInMemorySearchAdapter } from "../search";
23
23
  import type { SearchAdapter } from "../search/types";
24
24
  import { createTestDb } from "./db";
25
+ import { createJobFailureTracker, drainJobs } from "./drain-jobs";
25
26
  import { createEventCollector, type EventCollector } from "./event-collector";
26
27
  import { createTestRedis, type TestRedis } from "./redis";
27
28
  import { createRequestHelper, type RequestHelper } from "./request-helper";
@@ -64,6 +65,16 @@ export type TestStack = {
64
65
  // registered in the mounted features. Lets integration tests call
65
66
  // `stack.jobRunner.dispatch(...)` directly, mirroring `ctx.jobRunner`.
66
67
  jobRunner?: JobRunner;
68
+ // Waits for every in-flight job and its follow-up event-consumer /
69
+ // job-trigger cascade to settle — instead of `waitFor` polling on a
70
+ // specific job's side effect. Rejects with the failing job's name and
71
+ // error once a job exhausts its retries, or with a clear error when this
72
+ // stack has no jobRunner at all. Tracked failures accumulate since the
73
+ // PREVIOUS drainJobs() call (or setup, if none yet) — on a shared stack
74
+ // (e.g. beforeAll), a test that deliberately fails a job and never calls
75
+ // drainJobs() itself leaves that failure for the next test's drainJobs()
76
+ // call to reject on.
77
+ drainJobs: () => Promise<void>;
67
78
  cleanup: () => Promise<void>;
68
79
  };
69
80
 
@@ -361,6 +372,11 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
361
372
  // context = appContext + tracer/meter, mirroring the prod entrypoint's
362
373
  // `contextWithObservability(options.context, observability)`.
363
374
  let jobRunner: JobRunner | undefined;
375
+ // Fed to createJobRunner's onJobComplete/onJobFailed below so drainJobs()
376
+ // can tell a job's final outcome apart from an attempt that will retry —
377
+ // built before createJobRunner regardless of whether a runner ends up
378
+ // existing, so the returned drainJobs() closure always has one to read.
379
+ const jobFailureTracker = createJobFailureTracker();
364
380
  if (options.jobs && registry.getAllJobs().size > 0) {
365
381
  jobRunner = createJobRunner({
366
382
  registry,
@@ -374,6 +390,8 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
374
390
  ...(options.jobs.getActiveTenantIds !== undefined && {
375
391
  getActiveTenantIds: options.jobs.getActiveTenantIds,
376
392
  }),
393
+ onJobComplete: (jobName, jobId) => jobFailureTracker.onJobComplete(jobName, jobId),
394
+ onJobFailed: (jobName, jobId, error) => jobFailureTracker.onJobFailed(jobName, jobId, error),
377
395
  });
378
396
  }
379
397
 
@@ -540,6 +558,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
540
558
  ...(eventDispatcher ? { eventDispatcher } : {}),
541
559
  ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
542
560
  ...(jobRunner ? { jobRunner } : {}),
561
+ drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
543
562
  cleanup: async () => {
544
563
  if (jobRunner) await jobRunner.stop();
545
564
  if (eventDispatcher) await eventDispatcher.stop();