@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.
- package/package.json +4 -4
- package/src/api/__tests__/extra-route-rejection.test.ts +38 -0
- package/src/api/__tests__/extra-routes.integration.test.ts +30 -0
- package/src/api/__tests__/server-boot-guards.test.ts +1 -0
- package/src/api/__tests__/server-error-logging.test.ts +104 -0
- package/src/api/api-constants.ts +13 -0
- package/src/api/extra-route.ts +33 -4
- package/src/api/index.ts +1 -0
- package/src/api/request-context.ts +5 -4
- package/src/api/routes.ts +26 -1
- package/src/api/server.ts +8 -2
- 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 +108 -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/event-store-executor-write.ts +7 -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 +140 -16
- 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/boot-validator/access-declarations.ts +5 -66
- 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 +23 -5
- package/src/engine/personal-data-fields.ts +66 -0
- package/src/engine/registry-validate.ts +15 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/extension-options-map.ts +1 -0
- package/src/engine/types/index.ts +8 -0
- package/src/env/__tests__/dry-run.test.ts +43 -3
- package/src/env/dry-run.ts +28 -15
- package/src/errors/__tests__/write-failures.test.ts +47 -4
- package/src/errors/i18n/de.yaml +12 -0
- package/src/errors/i18n/en.yaml +12 -0
- package/src/errors/reasons.ts +4 -0
- package/src/errors/write-error-info.ts +12 -3
- package/src/jobs/__tests__/job-backoff.integration.test.ts +155 -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 +38 -3
- package/src/jobs/job-runner.ts +170 -19
- 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__/public-intake-runtime-gate.integration.test.ts +425 -0
- package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +59 -0
- package/src/pipeline/active-membership.ts +5 -1
- package/src/pipeline/dispatch-batch.ts +59 -13
- package/src/pipeline/dispatch-query.ts +16 -5
- package/src/pipeline/dispatch-shared.ts +12 -5
- package/src/pipeline/dispatch-stream.ts +7 -2
- package/src/pipeline/dispatch-write.ts +22 -5
- package/src/pipeline/dispatcher.ts +9 -2
- package/src/pipeline/idempotency.ts +16 -0
- package/src/pipeline/member-reader.ts +3 -1
- package/src/pipeline/system-identity-switch.ts +22 -4
- package/src/pipeline/write-origin.ts +107 -0
- package/src/rate-limit/__tests__/middleware.integration.test.ts +40 -0
- package/src/rate-limit/middleware.ts +3 -0
- package/src/stack/__tests__/setup-test-stack-metrics.integration.test.ts +79 -0
- package/src/stack/test-stack.ts +5 -0
- 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();
|
|
@@ -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
|
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|
|
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?: (
|
|
@@ -317,16 +326,59 @@ function timeoutReject(
|
|
|
317
326
|
};
|
|
318
327
|
}
|
|
319
328
|
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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"), {
|
|
431
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|