@cosmicdrift/kumiko-framework 0.305.0 → 0.307.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.
Files changed (87) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/redis-sse-broker.integration.test.ts +66 -0
  3. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  4. package/src/api/__tests__/server-error-logging.test.ts +71 -0
  5. package/src/api/__tests__/sse-broker.test.ts +49 -0
  6. package/src/api/redis-sse-broker.ts +17 -3
  7. package/src/api/request-context.ts +29 -4
  8. package/src/api/routes.ts +26 -1
  9. package/src/api/sse-broker.ts +29 -11
  10. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  11. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  12. package/src/bun-db/query.ts +42 -18
  13. package/src/changes.json +92 -0
  14. package/src/db/__tests__/pg-error.test.ts +14 -0
  15. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  16. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  17. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  18. package/src/db/index.ts +1 -1
  19. package/src/db/pg-error.ts +13 -0
  20. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  21. package/src/db/queries/event-consumer.ts +57 -3
  22. package/src/db/queries/event-store.ts +69 -0
  23. package/src/db/tenant-db.ts +133 -18
  24. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  25. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  26. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  27. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  28. package/src/engine/extension-names.ts +55 -25
  29. package/src/engine/extensions/storage-provider.ts +14 -41
  30. package/src/engine/extensions/tenant-data.ts +4 -0
  31. package/src/engine/extensions/tenant-resource.ts +40 -0
  32. package/src/engine/extensions/user-data.ts +8 -7
  33. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  34. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  35. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  36. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  37. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  38. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  39. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  40. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  41. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  42. package/src/engine/feature-ast/index.ts +11 -1
  43. package/src/engine/feature-ast/patch.ts +338 -5
  44. package/src/engine/feature-ast/patcher.ts +2 -2
  45. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  46. package/src/engine/feature-ast/patterns.ts +22 -15
  47. package/src/engine/feature-ast/render.ts +1 -0
  48. package/src/engine/feature-ui-extensions.ts +8 -7
  49. package/src/engine/index.ts +21 -5
  50. package/src/engine/types/extension-options-map.ts +1 -0
  51. package/src/engine/types/index.ts +6 -0
  52. package/src/event-store/__tests__/event-attribution.integration.test.ts +53 -3
  53. package/src/event-store/admin-api.ts +5 -0
  54. package/src/event-store/event-store.ts +16 -7
  55. package/src/jobs/__tests__/job-public-intake-origin.integration.test.ts +536 -0
  56. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  57. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  58. package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
  59. package/src/jobs/job-runner.ts +211 -19
  60. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  61. package/src/pipeline/__tests__/dispatcher-utils.test.ts +8 -0
  62. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  63. package/src/pipeline/__tests__/event-dispatcher-commit-order.integration.test.ts +278 -0
  64. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +1 -0
  65. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.test.ts +6 -6
  66. package/src/pipeline/__tests__/event-dispatcher-per-consumer-turns.integration.test.ts +126 -0
  67. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  68. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  69. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +261 -2
  70. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  71. package/src/pipeline/dispatch-batch.ts +102 -29
  72. package/src/pipeline/dispatch-stream.ts +7 -3
  73. package/src/pipeline/dispatcher-utils.ts +21 -2
  74. package/src/pipeline/dispatcher.ts +71 -6
  75. package/src/pipeline/event-consumer-state.ts +26 -0
  76. package/src/pipeline/event-dispatcher-admin.ts +32 -5
  77. package/src/pipeline/event-dispatcher-delivery.ts +109 -57
  78. package/src/pipeline/event-dispatcher.ts +167 -50
  79. package/src/pipeline/idempotency.ts +16 -0
  80. package/src/pipeline/pending-gap-ranges.ts +72 -0
  81. package/src/pipeline/system-hooks.ts +8 -1
  82. package/src/pipeline/system-identity-switch.ts +22 -4
  83. package/src/pipeline/write-origin.ts +31 -10
  84. package/src/stack/test-stack.ts +1 -1
  85. package/src/testing/closed-connection-error.ts +62 -0
  86. package/src/testing/index.ts +1 -0
  87. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -0,0 +1,316 @@
1
+ // BullMQ's moveToFinished lua sweeps a queue's WHOLE
2
+ // completed/failed zset whenever any job finishes with keepJobs set, not
3
+ // just the finishing job, and only lazily (on a later finish into the same
4
+ // set). These tests exercise the real sweep against a real Redis, on every
5
+ // enqueue path, plus the runOnBoot marker and the perTenant invariant that
6
+ // keeps the sweep from breaking wrapper-retry child dedup.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9
+ import { Queue } from "bullmq";
10
+ import { z } from "zod";
11
+ import { createRegistry, defineFeature } from "../../engine";
12
+ import type { AppContext, TenantId } from "../../engine/types";
13
+ import { createTestRedis, type TestRedis } from "../../stack";
14
+ import { sleep, waitFor } from "../../testing";
15
+ import { bootJobIdForJobName, createJobRunner } from "../job-runner";
16
+
17
+ let testRedis: TestRedis;
18
+ let redisUrl: string;
19
+
20
+ beforeAll(async () => {
21
+ testRedis = await createTestRedis();
22
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
23
+ });
24
+
25
+ afterAll(async () => {
26
+ await testRedis.cleanup();
27
+ });
28
+
29
+ function uniquePrefix(tag: string): string {
30
+ return `kumiko-test-retention-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
31
+ }
32
+
33
+ async function purgeQueueKeys(queueNamePrefix: string): Promise<void> {
34
+ const workerKeys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
35
+ if (workerKeys.length > 0) await testRedis.redis.del(...workerKeys);
36
+ const apiKeys = await testRedis.redis.keys(`bull:${queueNamePrefix}-api:*`);
37
+ if (apiKeys.length > 0) await testRedis.redis.del(...apiKeys);
38
+ }
39
+
40
+ function rawWorkerQueue(queueNamePrefix: string): Queue {
41
+ const queue = new Queue(`${queueNamePrefix}-worker`, {
42
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
43
+ });
44
+ // A post-close 'error' here is otherwise unhandled and bun:test attributes
45
+ // it to whichever test runs next (fw#1805).
46
+ queue.on("error", () => {});
47
+ return queue;
48
+ }
49
+
50
+ describe("bounded job retention (fw#3199)", () => {
51
+ test("completed jobs from every enqueue path are swept after the age once a later job completes", async () => {
52
+ const queueNamePrefix = uniquePrefix("completed");
53
+ const tenants = ["ret-a", "ret-b"] as TenantId[];
54
+ const completed: Array<{ name: string; jobId: string }> = [];
55
+
56
+ const feature = defineFeature("retention", (r) => {
57
+ const someEvent = r.defineEvent("some-event", z.object({}), { piiFields: "none" });
58
+ r.job("tick", { trigger: { manual: true } }, async () => {});
59
+ r.job("onEvent", { trigger: { on: someEvent.name } }, async () => {});
60
+ r.job("fanout", { trigger: { manual: true }, perTenant: true }, async () => {});
61
+ r.job("cronJob", { trigger: { cron: "* * * * * *" } }, async () => {});
62
+ r.job("sweeper", { trigger: { manual: true } }, async () => {});
63
+ });
64
+
65
+ const registry = createRegistry([feature]);
66
+ const context: AppContext = {};
67
+ const runner = createJobRunner({
68
+ registry,
69
+ context,
70
+ redisUrl,
71
+ consumerLane: "worker",
72
+ queueNamePrefix,
73
+ getActiveTenantIds: async () => tenants,
74
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
75
+ onJobComplete: (name, jobId) => {
76
+ completed.push({ name, jobId });
77
+ },
78
+ });
79
+
80
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
81
+ try {
82
+ await runner.start();
83
+ const tickId = await runner.dispatch("retention:job:tick");
84
+ await runner.handleEvent("retention:event:some-event", {});
85
+ const wrapperId = await runner.dispatch("retention:job:fanout");
86
+
87
+ await waitFor(() => {
88
+ expect(completed.some((c) => c.jobId === tickId)).toBe(true);
89
+ expect(completed.some((c) => c.name === "retention:job:on-event")).toBe(true);
90
+ expect(completed.filter((c) => c.name === "retention:job:fanout").length).toBe(2);
91
+ expect(completed.some((c) => c.name === "retention:job:cron-job")).toBe(true);
92
+ });
93
+
94
+ const cronId = completed.find((c) => c.name === "retention:job:cron-job")?.jobId;
95
+ const eventId = completed.find((c) => c.name === "retention:job:on-event")?.jobId;
96
+ const childIds = completed
97
+ .filter((c) => c.name === "retention:job:fanout")
98
+ .map((c) => c.jobId);
99
+
100
+ await sleep(1200);
101
+
102
+ await runner.dispatch("retention:job:sweeper");
103
+ await waitFor(() => {
104
+ expect(completed.some((c) => c.name === "retention:job:sweeper")).toBe(true);
105
+ });
106
+
107
+ const idsToCheck = [tickId, wrapperId, eventId, cronId, ...childIds].filter(
108
+ (id): id is string => id !== undefined,
109
+ );
110
+ for (const id of idsToCheck) {
111
+ await waitFor(async () => {
112
+ expect(await rawQueue.getJob(id)).toBeUndefined();
113
+ });
114
+ }
115
+ } finally {
116
+ await rawQueue.close();
117
+ await runner.stop();
118
+ await purgeQueueKeys(queueNamePrefix);
119
+ }
120
+ });
121
+
122
+ test("failed jobs are swept after the age once a later failure completes", async () => {
123
+ const queueNamePrefix = uniquePrefix("failed");
124
+ const failed: Array<{ name: string; jobId: string }> = [];
125
+
126
+ const feature = defineFeature("retentionfail", (r) => {
127
+ r.job("boom", { trigger: { manual: true } }, async () => {
128
+ throw new Error("always fails");
129
+ });
130
+ });
131
+
132
+ const registry = createRegistry([feature]);
133
+ const context: AppContext = {};
134
+ const runner = createJobRunner({
135
+ registry,
136
+ context,
137
+ redisUrl,
138
+ consumerLane: "worker",
139
+ queueNamePrefix,
140
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
141
+ onJobFailed: (name, jobId) => {
142
+ failed.push({ name, jobId });
143
+ },
144
+ });
145
+
146
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
147
+ try {
148
+ await runner.start();
149
+ const firstId = await runner.dispatch("retentionfail:job:boom");
150
+ await waitFor(() => {
151
+ expect(failed.some((f) => f.jobId === firstId)).toBe(true);
152
+ });
153
+
154
+ await sleep(1200);
155
+
156
+ const secondId = await runner.dispatch("retentionfail:job:boom");
157
+ await waitFor(() => {
158
+ expect(failed.some((f) => f.jobId === secondId)).toBe(true);
159
+ });
160
+
161
+ await waitFor(async () => {
162
+ expect(await rawQueue.getJob(firstId)).toBeUndefined();
163
+ });
164
+ } finally {
165
+ await rawQueue.close();
166
+ await runner.stop();
167
+ await purgeQueueKeys(queueNamePrefix);
168
+ }
169
+ });
170
+
171
+ test("runOnBoot marker survives the retention sweep and still dedupes across restarts", async () => {
172
+ const queueNamePrefix = uniquePrefix("boot");
173
+ let bootRuns = 0;
174
+ let tickRuns = 0;
175
+
176
+ const feature = defineFeature("retentionboot", (r) => {
177
+ r.job("boot", { trigger: { manual: true }, runOnBoot: true }, async () => {
178
+ bootRuns += 1;
179
+ });
180
+ r.job("tick", { trigger: { manual: true } }, async () => {
181
+ tickRuns += 1;
182
+ });
183
+ });
184
+
185
+ const registry1 = createRegistry([feature]);
186
+ const context: AppContext = {};
187
+ const runner1 = createJobRunner({
188
+ registry: registry1,
189
+ context,
190
+ redisUrl,
191
+ consumerLane: "worker",
192
+ queueNamePrefix,
193
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
194
+ });
195
+
196
+ const rawQueue = rawWorkerQueue(queueNamePrefix);
197
+ const bootJobId = bootJobIdForJobName("retentionboot:job:boot");
198
+ try {
199
+ await runner1.start();
200
+ await waitFor(() => {
201
+ expect(bootRuns).toBe(1);
202
+ });
203
+
204
+ await sleep(1200);
205
+ await runner1.dispatch("retentionboot:job:tick");
206
+ await waitFor(() => {
207
+ expect(tickRuns).toBe(1);
208
+ });
209
+
210
+ // Proves the sweep actually ran — without it this assertion (and the
211
+ // regression it guards) would pass on the old fixed-job-id dedup too.
212
+ await waitFor(async () => {
213
+ expect(await rawQueue.getJob(bootJobId)).toBeUndefined();
214
+ });
215
+
216
+ await runner1.stop();
217
+
218
+ const registry2 = createRegistry([feature]);
219
+ const runner2 = createJobRunner({
220
+ registry: registry2,
221
+ context,
222
+ redisUrl,
223
+ consumerLane: "worker",
224
+ queueNamePrefix,
225
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
226
+ });
227
+ try {
228
+ await runner2.start();
229
+ await sleep(300);
230
+ expect(bootRuns).toBe(1);
231
+ } finally {
232
+ await runner2.stop();
233
+ }
234
+ } finally {
235
+ await rawQueue.close();
236
+ await purgeQueueKeys(queueNamePrefix);
237
+ }
238
+ });
239
+
240
+ test("createJobRunner throws for a perTenant job whose retry window reaches the completed retention", () => {
241
+ const feature = defineFeature("retentioninvariant", (r) => {
242
+ r.job(
243
+ "risky",
244
+ {
245
+ trigger: { manual: true },
246
+ perTenant: true,
247
+ retries: 2,
248
+ backoff: { type: "fixed", delayMs: 1000 },
249
+ },
250
+ async () => {},
251
+ );
252
+ });
253
+ const registry = createRegistry([feature]);
254
+ expect(() =>
255
+ createJobRunner({
256
+ registry,
257
+ context: {},
258
+ redisUrl,
259
+ consumerLane: "worker",
260
+ queueNamePrefix: uniquePrefix("invariant"),
261
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
262
+ }),
263
+ ).toThrow(/retry window/);
264
+ });
265
+
266
+ test("createJobRunner does not throw for a non-perTenant job with the same retry config", async () => {
267
+ const feature = defineFeature("retentionnonpertenant", (r) => {
268
+ r.job(
269
+ "risky",
270
+ { trigger: { manual: true }, retries: 2, backoff: { type: "fixed", delayMs: 1000 } },
271
+ async () => {},
272
+ );
273
+ });
274
+ const registry = createRegistry([feature]);
275
+ const queueNamePrefix = uniquePrefix("nonpertenant");
276
+ let ran = false;
277
+ const runner = createJobRunner({
278
+ registry,
279
+ context: {},
280
+ redisUrl,
281
+ consumerLane: "worker",
282
+ queueNamePrefix,
283
+ jobRetention: { completedAgeSec: 1, failedAgeSec: 1 },
284
+ onJobComplete: () => {
285
+ ran = true;
286
+ },
287
+ });
288
+ try {
289
+ await runner.start();
290
+ await runner.dispatch("retentionnonpertenant:job:risky");
291
+ await waitFor(() => {
292
+ expect(ran).toBe(true);
293
+ });
294
+ } finally {
295
+ await runner.stop();
296
+ await purgeQueueKeys(queueNamePrefix);
297
+ }
298
+ });
299
+
300
+ test.each([0, 1.5])("createJobRunner throws for jobRetention.completedAgeSec = %p", (value) => {
301
+ const feature = defineFeature("retentionbadvalue", (r) => {
302
+ r.job("noop", { trigger: { manual: true } }, async () => {});
303
+ });
304
+ const registry = createRegistry([feature]);
305
+ expect(() =>
306
+ createJobRunner({
307
+ registry,
308
+ context: {},
309
+ redisUrl,
310
+ consumerLane: "worker",
311
+ queueNamePrefix: uniquePrefix("badvalue"),
312
+ jobRetention: { completedAgeSec: value },
313
+ }),
314
+ ).toThrow();
315
+ });
316
+ });
@@ -0,0 +1,309 @@
1
+ // fw#3184: buildRetryBullOpts (attempts/backoff derived from a job's
2
+ // `retries`/`backoff`) was only wired into dispatch() and handleEvent().
3
+ // Cron, runOnBoot, the perTenant wrapper, its fanned-out children, and the
4
+ // sequential re-enqueue path all called queue.add() without it, so a job
5
+ // with `retries` set still failed for good on its very first error when
6
+ // reached through any of those paths. Where a fresh job could also rerun the
7
+ // handler (a new cron tick, a sequential re-enqueue), the test asserts a
8
+ // SECOND attempt on the SAME BullMQ job id — "the handler ran again
9
+ // eventually" would pass without the fix there.
10
+
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { Queue } from "bullmq";
13
+ import { createRegistry, defineFeature } from "../../engine";
14
+ import type { AppContext, TenantId } from "../../engine/types";
15
+ import { createTestRedis, type TestRedis } from "../../stack";
16
+ import { sleep, waitFor } from "../../testing";
17
+ import { bootJobIdForJobName, createJobRunner, type JobMeta } from "../job-runner";
18
+
19
+ let testRedis: TestRedis;
20
+ let redisUrl: string;
21
+
22
+ beforeAll(async () => {
23
+ testRedis = await createTestRedis();
24
+ redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
25
+ });
26
+
27
+ afterAll(async () => {
28
+ await testRedis.cleanup();
29
+ });
30
+
31
+ function uniquePrefix(tag: string): string {
32
+ return `kumiko-test-retry-${tag}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
33
+ }
34
+
35
+ async function purgeWorkerKeys(queueNamePrefix: string): Promise<void> {
36
+ // Cron leaves its repeatable scheduler firing into a stopped worker if not
37
+ // purged — the same hazard the jobs.integration.test.ts helper guards
38
+ // against.
39
+ const keys = await testRedis.redis.keys(`bull:${queueNamePrefix}-worker:*`);
40
+ if (keys.length > 0) await testRedis.redis.del(...keys);
41
+ }
42
+
43
+ describe("job retries on every enqueue path (fw#3184)", () => {
44
+ test("cron: a job with retries recovers via a second attempt on the same BullMQ job id", async () => {
45
+ let callCount = 0;
46
+ const starts: Array<{ jobId: string; attempt: number | undefined }> = [];
47
+ const cronFeature = defineFeature("retrycron", (r) => {
48
+ r.job("flaky", { trigger: { cron: "* * * * * *" }, retries: 1 }, async () => {
49
+ callCount += 1;
50
+ if (callCount === 1) throw new Error("fails on first cron tick");
51
+ });
52
+ });
53
+
54
+ const registry = createRegistry([cronFeature]);
55
+ const context: AppContext = {};
56
+ const queueNamePrefix = uniquePrefix("cron");
57
+ const runner = createJobRunner({
58
+ registry,
59
+ context,
60
+ redisUrl,
61
+ consumerLane: "worker",
62
+ queueNamePrefix,
63
+ onJobStart: (_name, jobId, meta: JobMeta) => {
64
+ starts.push({ jobId, attempt: meta.attempt });
65
+ },
66
+ });
67
+
68
+ try {
69
+ await runner.start();
70
+ await waitFor(
71
+ () => {
72
+ const byJob = new Map<string, number>();
73
+ for (const s of starts)
74
+ byJob.set(s.jobId, Math.max(byJob.get(s.jobId) ?? 0, s.attempt ?? 0));
75
+ const reachedRetry = [...byJob.values()].some((attempt) => attempt >= 2);
76
+ expect(reachedRetry).toBe(true);
77
+ },
78
+ { delays: [500, 1000, 2000, 3000] },
79
+ );
80
+ } finally {
81
+ await runner.stop();
82
+ await purgeWorkerKeys(queueNamePrefix);
83
+ }
84
+ });
85
+
86
+ test("runOnBoot: a job with retries recovers via a second attempt on the boot job id", async () => {
87
+ const starts: Array<{ jobId: string; attempt: number | undefined }> = [];
88
+ let callCount = 0;
89
+ const bootFeature = defineFeature("retryboot", (r) => {
90
+ r.job("flaky", { trigger: { manual: true }, runOnBoot: true, retries: 1 }, async () => {
91
+ callCount += 1;
92
+ if (callCount === 1) throw new Error("fails on first boot run");
93
+ });
94
+ });
95
+
96
+ const registry = createRegistry([bootFeature]);
97
+ const context: AppContext = {};
98
+ const queueNamePrefix = uniquePrefix("boot");
99
+ const runner = createJobRunner({
100
+ registry,
101
+ context,
102
+ redisUrl,
103
+ consumerLane: "worker",
104
+ queueNamePrefix,
105
+ onJobStart: (_name, jobId, meta: JobMeta) => {
106
+ starts.push({ jobId, attempt: meta.attempt });
107
+ },
108
+ });
109
+
110
+ const expectedJobId = bootJobIdForJobName("retryboot:job:flaky");
111
+ try {
112
+ await runner.start();
113
+ await waitFor(() => {
114
+ const attempts = starts.filter((s) => s.jobId === expectedJobId).map((s) => s.attempt);
115
+ expect(attempts).toContain(2);
116
+ });
117
+ } finally {
118
+ await runner.stop();
119
+ await purgeWorkerKeys(queueNamePrefix);
120
+ }
121
+ });
122
+
123
+ test("perTenant child: a job with retries recovers per tenant via a second attempt", async () => {
124
+ const starts: Array<{ jobId: string; attempt: number | undefined }> = [];
125
+ const callCountByTenant = new Map<string, number>();
126
+ const tenants = ["retry-child-a", "retry-child-b"] as TenantId[];
127
+ const perTenantFeature = defineFeature("retrychild", (r) => {
128
+ r.job(
129
+ "flaky",
130
+ { trigger: { manual: true }, perTenant: true, retries: 1 },
131
+ async (_payload, ctx) => {
132
+ const tenantId = String(ctx.systemUser.tenantId);
133
+ const count = (callCountByTenant.get(tenantId) ?? 0) + 1;
134
+ callCountByTenant.set(tenantId, count);
135
+ if (count === 1) throw new Error(`fails on first attempt for ${tenantId}`);
136
+ },
137
+ );
138
+ });
139
+
140
+ const registry = createRegistry([perTenantFeature]);
141
+ const context: AppContext = {};
142
+ const queueNamePrefix = uniquePrefix("child");
143
+ const getActiveTenantIds = async () => tenants;
144
+ const runner = createJobRunner({
145
+ registry,
146
+ context,
147
+ redisUrl,
148
+ consumerLane: "worker",
149
+ queueNamePrefix,
150
+ getActiveTenantIds,
151
+ onJobStart: (name, jobId, meta: JobMeta) => {
152
+ if (name === "retrychild:job:flaky") starts.push({ jobId, attempt: meta.attempt });
153
+ },
154
+ });
155
+
156
+ try {
157
+ await runner.start();
158
+ await runner.dispatch("retrychild:job:flaky");
159
+ await waitFor(() => {
160
+ for (const tenantId of tenants) {
161
+ expect(callCountByTenant.get(tenantId)).toBe(2);
162
+ }
163
+ });
164
+ const byJob = new Map<string, number>();
165
+ for (const s of starts) byJob.set(s.jobId, Math.max(byJob.get(s.jobId) ?? 0, s.attempt ?? 0));
166
+ expect([...byJob.values()].some((attempt) => attempt >= 2)).toBe(true);
167
+ } finally {
168
+ await runner.stop();
169
+ await purgeWorkerKeys(queueNamePrefix);
170
+ }
171
+ });
172
+
173
+ test("perTenant wrapper: retries on getActiveTenantIds failure without double-fanning-out", async () => {
174
+ const tenants = ["retry-wrapper-a", "retry-wrapper-b"] as TenantId[];
175
+ const handlerCalls = new Map<string, number>();
176
+ let getActiveTenantIdsCalls = 0;
177
+ const wrapperFeature = defineFeature("retrywrapper", (r) => {
178
+ r.job(
179
+ "fanout",
180
+ { trigger: { manual: true }, perTenant: true, retries: 1 },
181
+ async (_payload, ctx) => {
182
+ const tenantId = String(ctx.systemUser.tenantId);
183
+ handlerCalls.set(tenantId, (handlerCalls.get(tenantId) ?? 0) + 1);
184
+ },
185
+ );
186
+ });
187
+
188
+ const registry = createRegistry([wrapperFeature]);
189
+ const context: AppContext = {};
190
+ const queueNamePrefix = uniquePrefix("wrapper");
191
+ const getActiveTenantIds = async () => {
192
+ getActiveTenantIdsCalls += 1;
193
+ if (getActiveTenantIdsCalls === 1)
194
+ throw new Error("fails resolving tenants on first attempt");
195
+ return tenants;
196
+ };
197
+ const runner = createJobRunner({
198
+ registry,
199
+ context,
200
+ redisUrl,
201
+ consumerLane: "worker",
202
+ queueNamePrefix,
203
+ getActiveTenantIds,
204
+ });
205
+
206
+ try {
207
+ await runner.start();
208
+ await runner.dispatch("retrywrapper:job:fanout");
209
+ await waitFor(() => {
210
+ for (const tenantId of tenants) {
211
+ expect(handlerCalls.get(tenantId)).toBe(1);
212
+ }
213
+ });
214
+ // A wrapper retry re-derives the same child ids from its own stable
215
+ // BullMQ job id, so a late second fan-out would be a no-op on
216
+ // existing-jobId add() — but nothing should even attempt one here.
217
+ await sleep(400);
218
+ for (const tenantId of tenants) {
219
+ expect(handlerCalls.get(tenantId)).toBe(1);
220
+ }
221
+ expect(getActiveTenantIdsCalls).toBe(2);
222
+ } finally {
223
+ await runner.stop();
224
+ await purgeWorkerKeys(queueNamePrefix);
225
+ }
226
+ });
227
+
228
+ test("sequential re-enqueue: a job with retries recovers via a second attempt on the re-enqueued job id", async () => {
229
+ const starts: Array<{ jobId: string; attempt: number | undefined }> = [];
230
+ let callCount = 0;
231
+ let releaseGate: (() => void) | undefined;
232
+ const gate = new Promise<void>((resolve) => {
233
+ releaseGate = resolve;
234
+ });
235
+ const sequentialFeature = defineFeature("retryseq", (r) => {
236
+ r.job(
237
+ "flaky",
238
+ { trigger: { manual: true }, concurrency: "sequential", retries: 1 },
239
+ async () => {
240
+ callCount += 1;
241
+ const attemptNumber = callCount;
242
+ if (attemptNumber === 1) {
243
+ await gate;
244
+ return;
245
+ }
246
+ if (attemptNumber === 2) throw new Error("fails on second call (first retry slot)");
247
+ },
248
+ );
249
+ });
250
+
251
+ const registry = createRegistry([sequentialFeature]);
252
+ const context: AppContext = {};
253
+ const queueNamePrefix = uniquePrefix("seq");
254
+ const runner = createJobRunner({
255
+ registry,
256
+ context,
257
+ redisUrl,
258
+ consumerLane: "worker",
259
+ queueNamePrefix,
260
+ onJobStart: (_name, jobId, meta: JobMeta) => {
261
+ starts.push({ jobId, attempt: meta.attempt });
262
+ },
263
+ });
264
+
265
+ const rawQueue = new Queue(`${queueNamePrefix}-worker`, {
266
+ connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
267
+ });
268
+ // A post-close 'error' here is otherwise unhandled and bun:test
269
+ // attributes it to whichever test runs next (fw#1805).
270
+ rawQueue.on("error", () => {});
271
+
272
+ try {
273
+ await runner.start();
274
+ const firstJobId = await runner.dispatch("retryseq:job:flaky");
275
+ const secondJobId = await runner.dispatch("retryseq:job:flaky");
276
+
277
+ await waitFor(() => {
278
+ expect(callCount).toBeGreaterThanOrEqual(1);
279
+ });
280
+
281
+ // Wait until the second dispatch's held-lock re-enqueue has actually
282
+ // landed as a delayed job before releasing the gate — otherwise the
283
+ // gate could resolve before there is anything left to retry.
284
+ await waitFor(
285
+ async () => {
286
+ const delayed = await rawQueue.getDelayed();
287
+ expect(delayed.some((j) => j.name === "retryseq:job:flaky")).toBe(true);
288
+ },
289
+ { delays: [100, 200, 400, 800] },
290
+ );
291
+
292
+ releaseGate?.();
293
+
294
+ await waitFor(() => {
295
+ const byJob = new Map<string, number>();
296
+ for (const s of starts)
297
+ byJob.set(s.jobId, Math.max(byJob.get(s.jobId) ?? 0, s.attempt ?? 0));
298
+ const retriedJobId = [...byJob.entries()].find(
299
+ ([jobId, attempt]) => attempt >= 2 && jobId !== firstJobId && jobId !== secondJobId,
300
+ );
301
+ expect(retriedJobId).toBeDefined();
302
+ });
303
+ } finally {
304
+ await rawQueue.close();
305
+ await runner.stop();
306
+ await purgeWorkerKeys(queueNamePrefix);
307
+ }
308
+ });
309
+ });
@@ -1361,9 +1361,9 @@ describe("boot gates", () => {
1361
1361
  await second.start();
1362
1362
  await sleep(300);
1363
1363
  expect(gateLog.filter((e) => e === "gate").length).toBe(2);
1364
- // Same fixed boot job id, still present in Redis from the first start,
1365
- // so BullMQ drops the second enqueue — that dedup is why runOnBoot
1366
- // cannot gate a deploy.
1364
+ // The persistent boot-enqueued marker set from the first start is
1365
+ // still present in Redis, so the second start skips re-enqueueing —
1366
+ // that dedup is why runOnBoot cannot gate a deploy.
1367
1367
  expect(gateLog.filter((e) => e === "boot").length).toBe(1);
1368
1368
  } finally {
1369
1369
  await second.stop();