@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
@@ -58,6 +58,12 @@ export function failTransition(from: string, to: string, allowed: readonly strin
58
58
  );
59
59
  }
60
60
 
61
+ // The cause travels out-of-band, keyed on the info object's identity: a field
62
+ // on WriteErrorInfo would leak the underlying Error into the /batch failure
63
+ // body and the idempotency cache, both of which serialize the info to JSON.
64
+ // A cache replay yields a fresh object without an entry, so no cause there.
65
+ const causeByWriteErrorInfo = new WeakMap<WriteErrorInfo, Error>();
66
+
61
67
  export function toWriteErrorInfo(err: KumikoError): WriteErrorInfo {
62
68
  // In dev/test surface the cause-snapshot through `details` so the
63
69
  // HTTP response carries something useful. Without this, internal_error
@@ -79,7 +85,7 @@ export function toWriteErrorInfo(err: KumikoError): WriteErrorInfo {
79
85
  : undefined
80
86
  : undefined;
81
87
  const effectiveDetails = err.details ?? causeDetails;
82
- return {
88
+ const info: WriteErrorInfo = {
83
89
  code: err.code,
84
90
  httpStatus: err.httpStatus,
85
91
  i18nKey: err.i18nKey,
@@ -87,6 +93,8 @@ export function toWriteErrorInfo(err: KumikoError): WriteErrorInfo {
87
93
  ...(err.i18nParams && { i18nParams: err.i18nParams }),
88
94
  ...(effectiveDetails !== undefined && { details: effectiveDetails }),
89
95
  };
96
+ if (err.cause instanceof Error) causeByWriteErrorInfo.set(info, err.cause);
97
+ return info;
90
98
  }
91
99
 
92
100
  // Reconstitutes an error from WriteErrorInfo so command() (throw-based) can
@@ -95,19 +103,20 @@ export function toWriteErrorInfo(err: KumikoError): WriteErrorInfo {
95
103
  // httpStatus / details but `instanceof NotFoundError` won't work. That's OK:
96
104
  // the HTTP layer keys off code + httpStatus, not class identity.
97
105
  export function reraiseAsKumikoError(info: WriteErrorInfo): KumikoError {
98
- return new ReraisedError(info);
106
+ return new ReraisedError(info, causeByWriteErrorInfo.get(info));
99
107
  }
100
108
 
101
109
  class ReraisedError extends KumikoError {
102
110
  readonly code: string;
103
111
  readonly httpStatus: number;
104
112
 
105
- constructor(info: WriteErrorInfo) {
113
+ constructor(info: WriteErrorInfo, cause?: Error) {
106
114
  super({
107
115
  message: info.message,
108
116
  i18nKey: info.i18nKey,
109
117
  ...(info.i18nParams && { i18nParams: info.i18nParams }),
110
118
  ...(info.details !== undefined && { details: info.details }),
119
+ ...(cause && { cause }),
111
120
  });
112
121
  this.code = info.code;
113
122
  this.httpStatus = info.httpStatus;
@@ -0,0 +1,155 @@
1
+ // fw#3167: buildRetryBullOpts only passed `{ type }` to BullMQ, never
2
+ // `delay` — BullMQ's fixed/exponential backoff strategies compute
3
+ // NaN/undefined without it (falsy), so a job with `backoff` set retried
4
+ // immediately instead of waiting. These tests assert real inter-attempt
5
+ // gaps via a real HTTP write that triggers the job through `trigger.on`,
6
+ // same delivery path as dispatch-write.ts's afterCommitHooks.
7
+ //
8
+ // Only lower bounds are asserted (never upper) — BullMQ schedules the next
9
+ // attempt at failureTime + delay using the same wall clock this test reads,
10
+ // so a >= assertion is flake-free without inflating waitFor's budget.
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { z } from "zod";
14
+ import { defineFeature } from "../../engine";
15
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
16
+ import { waitFor } from "../../testing";
17
+ import { generateId } from "../../utils";
18
+
19
+ const exponentialDefaultStarts: number[] = [];
20
+ const exponentialCustomStarts: number[] = [];
21
+ const fixedCustomStarts: number[] = [];
22
+
23
+ function gapsBetween(starts: readonly number[]): number[] {
24
+ return starts.slice(1).map((start, index) => start - (starts[index] ?? start));
25
+ }
26
+
27
+ const backoffFixtureFeature = defineFeature("backofffixture", (r) => {
28
+ r.writeHandler(
29
+ "trigger-exponential-default",
30
+ z.object({}),
31
+ async () => ({ isSuccess: true as const, data: {} }),
32
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
33
+ );
34
+ // AC: retries: 3, backoff: "exponential" — fails once, succeeds on retry 2.
35
+ // Default base delay is 1000ms, so the single retry gap must be >= 1000ms.
36
+ r.job(
37
+ "exponential-default",
38
+ {
39
+ trigger: { on: "backofffixture:write:trigger-exponential-default" },
40
+ retries: 3,
41
+ backoff: "exponential",
42
+ },
43
+ async () => {
44
+ exponentialDefaultStarts.push(Date.now());
45
+ if (exponentialDefaultStarts.length === 1) throw new Error("fails on attempt 1");
46
+ },
47
+ );
48
+
49
+ r.writeHandler(
50
+ "trigger-exponential-custom",
51
+ z.object({}),
52
+ async () => ({ isSuccess: true as const, data: {} }),
53
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
54
+ );
55
+ // Per-job configurable + growing: delayMs 50 → gaps 50, 100, 200ms.
56
+ r.job(
57
+ "exponential-custom",
58
+ {
59
+ trigger: { on: "backofffixture:write:trigger-exponential-custom" },
60
+ retries: 3,
61
+ backoff: { type: "exponential", delayMs: 50 },
62
+ },
63
+ async () => {
64
+ exponentialCustomStarts.push(Date.now());
65
+ if (exponentialCustomStarts.length < 4)
66
+ throw new Error(`fails on attempt ${exponentialCustomStarts.length}`);
67
+ },
68
+ );
69
+
70
+ r.writeHandler(
71
+ "trigger-fixed-custom",
72
+ z.object({}),
73
+ async () => ({ isSuccess: true as const, data: {} }),
74
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
75
+ );
76
+ r.job(
77
+ "fixed-custom",
78
+ {
79
+ trigger: { on: "backofffixture:write:trigger-fixed-custom" },
80
+ retries: 1,
81
+ backoff: { type: "fixed", delayMs: 300 },
82
+ },
83
+ async () => {
84
+ fixedCustomStarts.push(Date.now());
85
+ if (fixedCustomStarts.length === 1) throw new Error("fails on attempt 1");
86
+ },
87
+ );
88
+ });
89
+
90
+ let stack: TestStack;
91
+
92
+ beforeAll(async () => {
93
+ stack = await setupTestStack({
94
+ features: [backoffFixtureFeature],
95
+ // Own queue namespace: the default "kumiko-jobs" queue on the shared test
96
+ // Redis is consumed by every parallel test process, and a foreign worker
97
+ // grabbing a promoted delayed retry fails it as "Unknown job".
98
+ jobs: { consumerLane: "worker", queueNamePrefix: `backoff-test-${generateId()}` },
99
+ });
100
+ });
101
+
102
+ afterAll(async () => {
103
+ await stack.cleanup();
104
+ });
105
+
106
+ beforeEach(() => {
107
+ exponentialDefaultStarts.length = 0;
108
+ exponentialCustomStarts.length = 0;
109
+ fixedCustomStarts.length = 0;
110
+ });
111
+
112
+ describe("job backoff waits between retries (fw#3167)", () => {
113
+ test('backoff: "exponential" without delayMs waits the default 1000ms base delay', async () => {
114
+ await stack.http.writeOk(
115
+ "backofffixture:write:trigger-exponential-default",
116
+ {},
117
+ TestUsers.admin,
118
+ );
119
+
120
+ await waitFor(() => {
121
+ expect(exponentialDefaultStarts).toHaveLength(2);
122
+ });
123
+
124
+ const [gap = 0] = gapsBetween(exponentialDefaultStarts);
125
+ expect(gap).toBeGreaterThanOrEqual(1000);
126
+ });
127
+
128
+ test("object-form backoff.delayMs is configurable per job and grows exponentially", async () => {
129
+ await stack.http.writeOk(
130
+ "backofffixture:write:trigger-exponential-custom",
131
+ {},
132
+ TestUsers.admin,
133
+ );
134
+
135
+ await waitFor(() => {
136
+ expect(exponentialCustomStarts).toHaveLength(4);
137
+ });
138
+
139
+ const [gap1 = 0, gap2 = 0, gap3 = 0] = gapsBetween(exponentialCustomStarts);
140
+ expect(gap1).toBeGreaterThanOrEqual(50);
141
+ expect(gap2).toBeGreaterThanOrEqual(100);
142
+ expect(gap3).toBeGreaterThanOrEqual(200);
143
+ });
144
+
145
+ test('backoff: { type: "fixed", delayMs } waits the configured constant delay', async () => {
146
+ await stack.http.writeOk("backofffixture:write:trigger-fixed-custom", {}, TestUsers.admin);
147
+
148
+ await waitFor(() => {
149
+ expect(fixedCustomStarts).toHaveLength(2);
150
+ });
151
+
152
+ const [gap = 0] = gapsBetween(fixedCustomStarts);
153
+ expect(gap).toBeGreaterThanOrEqual(300);
154
+ });
155
+ });
@@ -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
+ });