@cosmicdrift/kumiko-framework 0.305.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.
- package/package.json +4 -4
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +71 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/bun-db/__tests__/closed-connection-retry.integration.test.ts +159 -0
- package/src/bun-db/__tests__/select-many-retry.integration.test.ts +138 -0
- package/src/bun-db/query.ts +42 -18
- package/src/changes.json +66 -0
- package/src/db/__tests__/pg-error.test.ts +14 -0
- package/src/db/__tests__/system-db-view-export.test.ts +107 -0
- package/src/db/__tests__/tenant-db-no-raw.test.ts +10 -0
- package/src/db/__tests__/with-systemdb-unsafe-raw-grant.test.ts +71 -0
- package/src/db/index.ts +1 -1
- package/src/db/pg-error.ts +13 -0
- package/src/db/queries/__tests__/{unsafe-read-retrying.test.ts → unsafe-read-retrying.integration.test.ts} +28 -28
- package/src/db/tenant-db.ts +90 -13
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +2 -2
- package/src/engine/__tests__/boot-validator.test.ts +1 -1
- package/src/engine/__tests__/tier-resolver-extension.test.ts +1 -1
- package/src/engine/extension-names.ts +55 -25
- package/src/engine/extensions/storage-provider.ts +14 -41
- package/src/engine/extensions/tenant-data.ts +4 -0
- package/src/engine/extensions/tenant-resource.ts +40 -0
- package/src/engine/extensions/user-data.ts +8 -7
- package/src/engine/feature-ast/__tests__/handler-header-roundtrip.test.ts +669 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +5 -3
- package/src/engine/feature-ast/__tests__/patch-update.test.ts +718 -0
- package/src/engine/feature-ast/__tests__/pattern-change-schema.test.ts +819 -0
- package/src/engine/feature-ast/entity-field-types.ts +41 -0
- package/src/engine/feature-ast/extractors/handlers.ts +217 -84
- package/src/engine/feature-ast/extractors/hooks.ts +72 -15
- package/src/engine/feature-ast/extractors/round2.ts +21 -0
- package/src/engine/feature-ast/extractors/shared.ts +9 -0
- package/src/engine/feature-ast/index.ts +11 -1
- package/src/engine/feature-ast/patch.ts +338 -5
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/pattern-change-schema.ts +1411 -0
- package/src/engine/feature-ast/patterns.ts +22 -15
- package/src/engine/feature-ast/render.ts +1 -0
- package/src/engine/feature-ui-extensions.ts +8 -7
- package/src/engine/index.ts +21 -5
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +6 -0
- package/src/jobs/__tests__/job-retention.integration.test.ts +316 -0
- package/src/jobs/__tests__/job-retry-enqueue-paths.integration.test.ts +309 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -3
- package/src/jobs/job-runner.ts +151 -14
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +113 -26
- package/src/pipeline/__tests__/dispatcher.test.ts +5 -0
- package/src/pipeline/__tests__/hook-systemdb-escape-hatch.integration.test.ts +246 -0
- package/src/pipeline/__tests__/idempotency-transient-failure.integration.test.ts +198 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/dispatch-batch.ts +56 -13
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/testing/closed-connection-error.ts +62 -0
- package/src/testing/index.ts +1 -0
- 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
|
-
//
|
|
1365
|
-
//
|
|
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();
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|
|
89
|
-
//
|
|
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?: (
|
|
@@ -323,9 +332,10 @@ function timeoutReject(
|
|
|
323
332
|
// waiting.
|
|
324
333
|
const DEFAULT_JOB_BACKOFF_DELAY_MS = 1_000;
|
|
325
334
|
|
|
326
|
-
// Shared by dispatch()
|
|
327
|
-
//
|
|
328
|
-
//
|
|
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.
|
|
329
339
|
function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts" | "backoff"> {
|
|
330
340
|
const opts: Pick<JobsOptions, "attempts" | "backoff"> = {};
|
|
331
341
|
if (jobDef.retries !== undefined) opts.attempts = jobDef.retries + 1;
|
|
@@ -341,6 +351,34 @@ function buildRetryBullOpts(jobDef: JobDefinition): Pick<JobsOptions, "attempts"
|
|
|
341
351
|
return opts;
|
|
342
352
|
}
|
|
343
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
|
+
|
|
344
382
|
export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
345
383
|
const { registry, context, redisUrl, consumerLane } = options;
|
|
346
384
|
const queueNamePrefix = options.queueNamePrefix ?? DEFAULT_QUEUE_NAME_PREFIX;
|
|
@@ -397,6 +435,49 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
397
435
|
|
|
398
436
|
const allJobs = registry.getAllJobs();
|
|
399
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
|
+
|
|
400
481
|
// Resolve the lane for a job — "worker" is the default because that's the
|
|
401
482
|
// sensible prod lane (heavy async off the request path). Jobs that opted
|
|
402
483
|
// into "api" must have been validated at registry boot already.
|
|
@@ -440,9 +521,22 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
440
521
|
// queue matching the target job's runIn. Client-creation is cheap (shared
|
|
441
522
|
// ioredis connection via bullmq), so this doesn't scale with number of
|
|
442
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
|
+
};
|
|
443
531
|
const queues: Readonly<Record<JobRunIn, Queue>> = {
|
|
444
|
-
api: new Queue(queueNameFor(queueNamePrefix, "api"), {
|
|
445
|
-
|
|
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
|
+
}),
|
|
446
540
|
};
|
|
447
541
|
// Same unhandled-'error'-crash hazard as lockRedis above, just via
|
|
448
542
|
// BullMQ's internal ioredis client (fw#1805).
|
|
@@ -536,9 +630,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
536
630
|
await targetQueue.add(
|
|
537
631
|
actualName,
|
|
538
632
|
{ ...bullJob.data, _tenantId: tenantId },
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
+
},
|
|
542
642
|
);
|
|
543
643
|
}
|
|
544
644
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
@@ -567,8 +667,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
567
667
|
// same queue the worker just picked from (since only the consuming
|
|
568
668
|
// lane runs handleJob at all), but route explicitly — no implicit
|
|
569
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.
|
|
570
674
|
await queues[laneForJob(jobDef)].add(jobName, bullJob.data, {
|
|
571
675
|
delay: SEQUENTIAL_RETRY_DELAY_MS,
|
|
676
|
+
...buildRetryBullOpts(jobDef),
|
|
572
677
|
});
|
|
573
678
|
// skip: lock taken, work re-enqueued with delay, current invocation done
|
|
574
679
|
return;
|
|
@@ -914,20 +1019,48 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
914
1019
|
{
|
|
915
1020
|
name: jobDef.perTenant ? `_perTenant:${name}` : name,
|
|
916
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.
|
|
917
1025
|
opts: {
|
|
918
|
-
|
|
919
|
-
removeOnFail: { count: 50 },
|
|
1026
|
+
...buildRetryBullOpts(jobDef),
|
|
920
1027
|
},
|
|
921
1028
|
},
|
|
922
1029
|
);
|
|
923
1030
|
}
|
|
924
1031
|
}
|
|
925
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");
|
|
926
1047
|
for (const [name, jobDef] of allJobs) {
|
|
927
1048
|
if (laneForJob(jobDef) !== consumerLane) continue;
|
|
928
1049
|
if (jobDef.runOnBoot) {
|
|
929
1050
|
const bootName = jobDef.perTenant ? `_perTenant:${name}` : name;
|
|
930
|
-
|
|
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 });
|
|
931
1064
|
}
|
|
932
1065
|
}
|
|
933
1066
|
|
|
@@ -974,7 +1107,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
974
1107
|
|
|
975
1108
|
// perTenant: dispatch the fan-out wrapper instead
|
|
976
1109
|
if (jobDef.perTenant) {
|
|
977
|
-
const job = await targetQueue.add(
|
|
1110
|
+
const job = await targetQueue.add(
|
|
1111
|
+
`_perTenant:${jobName}`,
|
|
1112
|
+
payload ?? {},
|
|
1113
|
+
buildRetryBullOpts(jobDef),
|
|
1114
|
+
);
|
|
978
1115
|
return job.id ?? "unknown";
|
|
979
1116
|
}
|
|
980
1117
|
|