@cosmicdrift/kumiko-framework 0.304.0 → 0.306.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 (92) hide show
  1. package/package.json +4 -4
  2. package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
  3. package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
  4. package/src/api/__tests__/server-boot-guards.test.ts +1 -0
  5. package/src/api/__tests__/server-error-logging.test.ts +104 -0
  6. package/src/api/api-constants.ts +13 -0
  7. package/src/api/extra-route.ts +33 -4
  8. package/src/api/index.ts +1 -0
  9. package/src/api/request-context.ts +5 -4
  10. package/src/api/routes.ts +26 -1
  11. package/src/api/server.ts +8 -2
  12. package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
  13. package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
  14. package/src/bun-db/query.ts +42 -18
  15. package/src/changes.json +108 -0
  16. package/src/db/__tests__/pg-error.test.ts +14 -0
  17. package/src/db/__tests__/system-db-view-export.test.ts +107 -0
  18. package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
  19. package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
  20. package/src/db/event-store-executor-write.ts +7 -0
  21. package/src/db/index.ts +1 -1
  22. package/src/db/pg-error.ts +13 -0
  23. package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
  24. package/src/db/tenant-db.ts +140 -16
  25. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
  26. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
  27. package/src/engine/__tests__/boot-validator.test.ts +1 -1
  28. package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
  29. package/src/engine/boot-validator/access-declarations.ts +5 -66
  30. package/src/engine/extension-names.ts +55 -25
  31. package/src/engine/extensions/storage-provider.ts +14 -41
  32. package/src/engine/extensions/tenant-data.ts +4 -0
  33. package/src/engine/extensions/tenant-resource.ts +40 -0
  34. package/src/engine/extensions/user-data.ts +8 -7
  35. package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
  36. package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
  37. package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
  38. package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
  39. package/src/engine/feature-ast/entity-field-types.ts +41 -0
  40. package/src/engine/feature-ast/extractors/handlers.ts +217 -84
  41. package/src/engine/feature-ast/extractors/hooks.ts +72 -15
  42. package/src/engine/feature-ast/extractors/round2.ts +21 -0
  43. package/src/engine/feature-ast/extractors/shared.ts +9 -0
  44. package/src/engine/feature-ast/index.ts +11 -1
  45. package/src/engine/feature-ast/patch.ts +338 -5
  46. package/src/engine/feature-ast/patcher.ts +2 -2
  47. package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
  48. package/src/engine/feature-ast/patterns.ts +22 -15
  49. package/src/engine/feature-ast/render.ts +1 -0
  50. package/src/engine/feature-ui-extensions.ts +8 -7
  51. package/src/engine/index.ts +23 -5
  52. package/src/engine/personal-data-fields.ts +66 -0
  53. package/src/engine/registry-validate.ts +15 -0
  54. package/src/engine/registry.ts +2 -0
  55. package/src/engine/types/extension-options-map.ts +1 -0
  56. package/src/engine/types/index.ts +8 -0
  57. package/src/env/__tests__/dry-run.test.ts +43 -3
  58. package/src/env/dry-run.ts +28 -15
  59. package/src/errors/__tests__/write-failures.test.ts +47 -4
  60. package/src/errors/i18n/de.yaml +12 -0
  61. package/src/errors/i18n/en.yaml +12 -0
  62. package/src/errors/reasons.ts +4 -0
  63. package/src/errors/write-error-info.ts +12 -3
  64. package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -0
  65. package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
  66. package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
  67. package/src/jobs/__tests__/jobs.integration.test.ts +38 -3
  68. package/src/jobs/job-runner.ts +170 -19
  69. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
  70. package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
  71. package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
  72. package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
  73. package/src/pipeline/__tests__/public-intake-runtime-gate.integration.test.ts +425 -0
  74. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
  75. package/src/pipeline/active-membership.ts +5 -1
  76. package/src/pipeline/dispatch-batch.ts +59 -13
  77. package/src/pipeline/dispatch-query.ts +16 -5
  78. package/src/pipeline/dispatch-shared.ts +12 -5
  79. package/src/pipeline/dispatch-stream.ts +7 -2
  80. package/src/pipeline/dispatch-write.ts +22 -5
  81. package/src/pipeline/dispatcher.ts +9 -2
  82. package/src/pipeline/idempotency.ts +16 -0
  83. package/src/pipeline/member-reader.ts +3 -1
  84. package/src/pipeline/system-identity-switch.ts +22 -4
  85. package/src/pipeline/write-origin.ts +107 -0
  86. package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
  87. package/src/rate-limit/middleware.ts +3 -0
  88. package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
  89. package/src/stack/test-stack.ts +5 -0
  90. package/src/testing/closed-connection-error.ts +62 -0
  91. package/src/testing/index.ts +1 -0
  92. package/src/bun-db/__tests__/select-many-retry.test.ts +0 -79
@@ -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();
@@ -1390,4 +1390,39 @@ describe("boot gates", () => {
1390
1390
  });
1391
1391
  expect(() => createRegistry([sequentialGate])).toThrow(/bootGate with concurrency/);
1392
1392
  });
1393
+
1394
+ test("object-form backoff.delayMs must be a positive integer", () => {
1395
+ for (const badDelayMs of [0, -5, 1.5]) {
1396
+ const feature = defineFeature("badbackoff", (r) => {
1397
+ r.job(
1398
+ "check",
1399
+ { trigger: { manual: true }, backoff: { type: "fixed", delayMs: badDelayMs } },
1400
+ async () => {
1401
+ gateLog.push("never");
1402
+ },
1403
+ );
1404
+ });
1405
+ expect(() => createRegistry([feature])).toThrow(
1406
+ /backoff\.delayMs must be a positive integer/,
1407
+ );
1408
+ }
1409
+
1410
+ const validObjectForm = defineFeature("goodbackoffobject", (r) => {
1411
+ r.job(
1412
+ "check",
1413
+ { trigger: { manual: true }, backoff: { type: "exponential", delayMs: 100 } },
1414
+ async () => {
1415
+ gateLog.push("never");
1416
+ },
1417
+ );
1418
+ });
1419
+ expect(() => createRegistry([validObjectForm])).not.toThrow();
1420
+
1421
+ const validStringForm = defineFeature("goodbackoffstring", (r) => {
1422
+ r.job("check", { trigger: { manual: true }, backoff: "fixed" }, async () => {
1423
+ gateLog.push("never");
1424
+ });
1425
+ });
1426
+ expect(() => createRegistry([validStringForm])).not.toThrow();
1427
+ });
1393
1428
  });
@@ -1,4 +1,4 @@
1
- import { Queue, Worker } from "bullmq";
1
+ import { type JobsOptions, Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
@@ -85,8 +85,10 @@ function legacySchedulerIdForJobName(jobName: string): string {
85
85
  // for the same trigger (retry after failure, a Redis drop, an instance
86
86
  // restarting mid-run) re-derives the *same* child ids, and BullMQ's
87
87
  // existing-jobId add() no-ops the second batch instead of creating
88
- // duplicates — the same dedup-on-id mechanism bootJobIdForJobName already
89
- // relies on above. Same colon-in-BullMQ-id hazard as schedulerIdForJobName
88
+ // duplicates — safe only as long as the child job hash outlives the
89
+ // wrapper's retry window, enforced at construction against
90
+ // COMPLETED_JOB_RETENTION_AGE_SEC (see the invariant check in
91
+ // createJobRunner). Same colon-in-BullMQ-id hazard as schedulerIdForJobName
90
92
  // (fw#1603/#1604) — the wrapper id already contains ":", so strip
91
93
  // separators from both halves before joining instead of interpolating raw.
92
94
  function perTenantChildJobId(wrapperJobId: string, tenantId: string): string {
@@ -225,6 +227,13 @@ export type JobRunnerOptions = {
225
227
  // before failing boot. Defaults to BOOT_REDIS_TIMEOUT_MS; tests shrink it
226
228
  // to keep an unreachable-Redis assertion fast.
227
229
  bootRedisTimeoutMs?: number | undefined;
230
+ // Override how long completed/failed jobs stay in Redis before BullMQ's
231
+ // lazy queue-wide sweep evicts them. Defaults to
232
+ // COMPLETED_JOB_RETENTION_AGE_SEC / FAILED_JOB_RETENTION_AGE_SEC; tests
233
+ // shrink both to exercise the sweep without a real 24h/7d wait.
234
+ jobRetention?:
235
+ | { completedAgeSec?: number | undefined; failedAgeSec?: number | undefined }
236
+ | undefined;
228
237
  getActiveTenantIds?: () => Promise<TenantId[]>;
229
238
  onJobStart?: (jobName: string, jobId: string, meta: JobMeta) => void;
230
239
  onJobComplete?: (
@@ -317,16 +326,59 @@ function timeoutReject(
317
326
  };
318
327
  }
319
328
 
320
- // Shared by dispatch() and handleEvent() — an event-triggered job must retry
321
- // on failure the same way a directly-dispatched one does; a duplicated
322
- // inline computation in handleEvent previously dropped both options.
323
- function buildRetryBullOpts(jobDef: JobDefinition): Record<string, unknown> {
324
- const opts: Record<string, unknown> = {};
325
- if (jobDef.retries !== undefined) opts["attempts"] = jobDef.retries + 1;
326
- if (jobDef.backoff) opts["backoff"] = { type: jobDef.backoff };
329
+ // Default base delay when a job opts into backoff without an explicit
330
+ // delayMs. Without a `delay`, BullMQ's fixed/exponential strategies compute
331
+ // NaN/undefined, which is falsy — the job retries immediately instead of
332
+ // waiting.
333
+ const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
334
+
335
+ // Shared by every enqueue path (dispatch(), handleEvent(), cron, runOnBoot,
336
+ // perTenant wrapper/children, sequential re-enqueue) — a job with `retries`
337
+ // set must retry the same way regardless of how it got enqueued, or it
338
+ // fails for good on the very first error on whichever path skips this.
339
+ function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts" | "backoff"> {
340
+ const opts: Pick<JobsOptions, "attempts" | "backoff"> = {};
341
+ if (jobDef.retries !== undefined) opts.attempts = jobDef.retries + 1;
342
+ if (jobDef.backoff) {
343
+ opts.backoff =
344
+ typeof jobDef.backoff === "string"
345
+ ? { type: jobDef.backoff, delay: DEFAULT_JOB_BACKOFF_DELAY_MS }
346
+ : {
347
+ type: jobDef.backoff.type,
348
+ delay: jobDef.backoff.delayMs ?? DEFAULT_JOB_BACKOFF_DELAY_MS,
349
+ };
350
+ }
327
351
  return opts;
328
352
  }
329
353
 
354
+ // BullMQ sweeps queue-wide: any job finishing with keepJobs set can evict
355
+ // OTHER jobs in the same completed/failed zset (moveToFinished lua,
356
+ // removeJobsByMaxAge/removeJobsByMaxCount), not just the finishing job, and
357
+ // the sweep only runs lazily on a later finish into that set. Per-job
358
+ // retention is therefore meaningless on a shared queue — only queue-wide,
359
+ // age-only retention (no count) is safe: a count would evict perTenant
360
+ // children still inside their wrapper's retry window, and boot jobs. The
361
+ // completed age must stay above every perTenant wrapper's own worst-case
362
+ // retry window (see maxRetryWindowMs and the invariant check in
363
+ // createJobRunner below), because child dedup (perTenantChildJobId) relies
364
+ // on the children still existing in Redis when a wrapper retry re-derives
365
+ // their ids. The margin here is generous — queue wait time isn't bounded by
366
+ // backoff — to cover realistic windows. Audit trail lives in read_job_runs,
367
+ // not BullMQ (fw#3199).
368
+ const COMPLETED_JOB_RETENTION_AGE_SEC = 86_400; // 24h
369
+ const FAILED_JOB_RETENTION_AGE_SEC = 604_800; // 7d
370
+
371
+ // Total wall-clock time BullMQ can hold a perTenant wrapper across all its
372
+ // retries, consistent with buildRetryBullOpts's own backoff computation.
373
+ function maxRetryWindowMs(jobDef: JobDefinition): number {
374
+ if (!jobDef.backoff || jobDef.retries === undefined) return 0;
375
+ const delayMs =
376
+ (typeof jobDef.backoff === "string" ? undefined : jobDef.backoff.delayMs) ??
377
+ DEFAULT_JOB_BACKOFF_DELAY_MS;
378
+ const type = typeof jobDef.backoff === "string" ? jobDef.backoff : jobDef.backoff.type;
379
+ return type === "exponential" ? delayMs * (2 ** jobDef.retries - 1) : jobDef.retries * delayMs;
380
+ }
381
+
330
382
  export function createJobRunner(options: JobRunnerOptions): JobRunner {
331
383
  const { registry, context, redisUrl, consumerLane } = options;
332
384
  const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
@@ -383,6 +435,49 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
383
435
 
384
436
  const allJobs = registry.getAllJobs();
385
437
 
438
+ function positiveIntRetentionSec(
439
+ value: number | undefined,
440
+ fallback: number,
441
+ label: string,
442
+ ): number {
443
+ const resolved = value ?? fallback;
444
+ if (!Number.isInteger(resolved) || resolved <= 0) {
445
+ throw new Error(
446
+ `job-runner: jobRetention.${label} must be a positive integer, got ${resolved}`,
447
+ );
448
+ }
449
+ return resolved;
450
+ }
451
+ const completedAgeSec = positiveIntRetentionSec(
452
+ options.jobRetention?.completedAgeSec,
453
+ COMPLETED_JOB_RETENTION_AGE_SEC,
454
+ "completedAgeSec",
455
+ );
456
+ const failedAgeSec = positiveIntRetentionSec(
457
+ options.jobRetention?.failedAgeSec,
458
+ FAILED_JOB_RETENTION_AGE_SEC,
459
+ "failedAgeSec",
460
+ );
461
+
462
+ // perTenant child dedup (perTenantChildJobId) only holds as long as the
463
+ // children are still in Redis when a wrapper retry re-derives their ids,
464
+ // which requires the completed-job retention to outlast the wrapper's own
465
+ // worst-case retry window. Enforced here, before any Redis connection
466
+ // opens below, so a misconfigured job fails boot loudly instead of
467
+ // silently losing dedup in prod.
468
+ for (const [name, jobDef] of allJobs) {
469
+ if (!jobDef.perTenant) continue;
470
+ const windowMs = maxRetryWindowMs(jobDef);
471
+ if (windowMs >= completedAgeSec * 1000) {
472
+ throw new Error(
473
+ `job-runner: perTenant job "${name}" has a retry window of ${windowMs}ms, which is >= ` +
474
+ `the completed-job retention (${completedAgeSec}s). Child dedup relies on children ` +
475
+ "staying in Redis for the whole retry window — lower retries/backoff or raise " +
476
+ "jobRetention.completedAgeSec.",
477
+ );
478
+ }
479
+ }
480
+
386
481
  // Resolve the lane for a job — "worker" is the default because that's the
387
482
  // sensible prod lane (heavy async off the request path). Jobs that opted
388
483
  // into "api" must have been validated at registry boot already.
@@ -426,9 +521,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
426
521
  // queue matching the target job's runIn. Client-creation is cheap (shared
427
522
  // ioredis connection via bullmq), so this doesn't scale with number of
428
523
  // processes.
524
+ // Queue-wide, age-only retention (see COMPLETED_JOB_RETENTION_AGE_SEC
525
+ // above for why) — merged into every add()/addBulk()/upsertJobScheduler()
526
+ // template on both lanes.
527
+ const jobRetentionOpts: Pick<JobsOptions, "removeOnComplete" | "removeOnFail"> = {
528
+ removeOnComplete: { age: completedAgeSec },
529
+ removeOnFail: { age: failedAgeSec },
530
+ };
429
531
  const queues: Readonly<Record<JobRunIn, Queue>> = {
430
- api: new Queue(queueNameFor(queueNamePrefix, "api"), { connection: redisOpts }),
431
- worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
532
+ api: new Queue(queueNameFor(queueNamePrefix, "api"), {
533
+ connection: redisOpts,
534
+ defaultJobOptions: jobRetentionOpts,
535
+ }),
536
+ worker: new Queue(queueNameFor(queueNamePrefix, "worker"), {
537
+ connection: redisOpts,
538
+ defaultJobOptions: jobRetentionOpts,
539
+ }),
432
540
  };
433
541
  // Same unhandled-'error'-crash hazard as lockRedis above, just via
434
542
  // BullMQ's internal ioredis client (fw#1805).
@@ -522,9 +630,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
522
630
  await targetQueue.add(
523
631
  actualName,
524
632
  { ...bullJob.data, _tenantId: tenantId },
525
- wrapperJobId !== undefined
526
- ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
527
- : undefined,
633
+ {
634
+ ...buildRetryBullOpts(actualDef),
635
+ // Dedup over wrapper retries only holds as long as the children
636
+ // stay in Redis for the wrapper's whole retry window — see the
637
+ // COMPLETED_JOB_RETENTION_AGE_SEC invariant check above.
638
+ ...(wrapperJobId !== undefined
639
+ ? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
640
+ : {}),
641
+ },
528
642
  );
529
643
  }
530
644
  // skip: fan-out dispatcher job, per-tenant children enqueued
@@ -553,8 +667,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
553
667
  // same queue the worker just picked from (since only the consuming
554
668
  // lane runs handleJob at all), but route explicitly — no implicit
555
669
  // coupling to "whichever queue the caller happened to be on".
670
+ // The re-enqueued job starts with a full retry budget; a remaining
671
+ // budget is deliberately not carried over, since finalAttempt/
672
+ // tenantVisibleFailure are computed from jobDef.retries against
673
+ // attemptsMade, not against some inherited remainder.
556
674
  await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
557
675
  delay: SEQUENTIAL_RETRY_DELAY_MS,
676
+ ...buildRetryBullOpts(jobDef),
558
677
  });
559
678
  // skip: lock taken, work re-enqueued with delay, current invocation done
560
679
  return;
@@ -900,20 +1019,48 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
900
1019
  {
901
1020
  name: jobDef.perTenant ? `_perTenant:${name}` : name,
902
1021
  data: {},
1022
+ // Queue-level defaultJobOptions (jobRetentionOpts) covers
1023
+ // retention; a count here would sweep queue-wide again and
1024
+ // evict perTenant children and boot jobs.
903
1025
  opts: {
904
- removeOnComplete: { count: 100 },
905
- removeOnFail: { count: 50 },
1026
+ ...buildRetryBullOpts(jobDef),
906
1027
  },
907
1028
  },
908
1029
  );
909
1030
  }
910
1031
  }
911
1032
 
1033
+ // Persistent marker outside the swept completed/failed sets: a plain
1034
+ // Redis hash at one key per consumer queue, fields = boot job ids
1035
+ // already enqueued (BullMQ's IRedisClient has no set commands, only
1036
+ // hash/hexists — a hash with dummy values does the same job). The job
1037
+ // hash itself (removeOnComplete/-Fail: age-bound) is not a safe dedup
1038
+ // target any more — retention now evicts it eventually, which would
1039
+ // otherwise re-run a boot job "once per dataset" every time it ages
1040
+ // out. Order matters: HEXISTS check, then add(), then HSET.
1041
+ // Concurrent starts still dedupe on the existing job hash from add()'s
1042
+ // own jobId no-op; a crash between add() and HSET dedupes again on the
1043
+ // *next* boot (the job hash is still there) instead of losing the
1044
+ // boot job forever, which HSET-first would risk if the process died
1045
+ // before add() ran.
1046
+ const bootEnqueuedKey = consumerQueue.toKey("kumiko-boot-enqueued");
912
1047
  for (const [name, jobDef] of allJobs) {
913
1048
  if (laneForJob(jobDef) !== consumerLane) continue;
914
1049
  if (jobDef.runOnBoot) {
915
1050
  const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
916
- await consumerQueue.add(bootName, {}, { jobId: bootJobIdForJobName(name) });
1051
+ const bootJobId = bootJobIdForJobName(name);
1052
+ const client = await consumerQueue.client;
1053
+ const alreadyEnqueued = await client.hexists(bootEnqueuedKey, bootJobId);
1054
+ if (alreadyEnqueued) continue;
1055
+ await consumerQueue.add(
1056
+ bootName,
1057
+ {},
1058
+ {
1059
+ jobId: bootJobId,
1060
+ ...buildRetryBullOpts(jobDef),
1061
+ },
1062
+ );
1063
+ await client.hset(bootEnqueuedKey, { [bootJobId]: 1 });
917
1064
  }
918
1065
  }
919
1066
 
@@ -960,7 +1107,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
960
1107
 
961
1108
  // perTenant: dispatch the fan-out wrapper instead
962
1109
  if (jobDef.perTenant) {
963
- const job = await targetQueue.add(`_perTenant:${jobName}`, payload ?? {});
1110
+ const job = await targetQueue.add(
1111
+ `_perTenant:${jobName}`,
1112
+ payload ?? {},
1113
+ buildRetryBullOpts(jobDef),
1114
+ );
964
1115
  return job.id ?? "unknown";
965
1116
  }
966
1117