@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
|
@@ -71,7 +71,7 @@ import type {
|
|
|
71
71
|
AgentHandlerHints,
|
|
72
72
|
ClaimKeyType,
|
|
73
73
|
EscapeHatchDeclaration,
|
|
74
|
-
|
|
74
|
+
RateLimitDeclaration,
|
|
75
75
|
} from "../types/handlers";
|
|
76
76
|
import type { HookPhase } from "../types/hooks";
|
|
77
77
|
import type { HttpRouteMethod } from "../types/http-route";
|
|
@@ -81,6 +81,7 @@ import type { RelationDefinition } from "../types/relations";
|
|
|
81
81
|
import type { ScreenDefinition } from "../types/screen";
|
|
82
82
|
import type { TreeActionDef } from "../types/tree-node";
|
|
83
83
|
import type { WorkspaceDefinition } from "../types/workspace";
|
|
84
|
+
import type { RawRefSentinel } from "./extractors/shared";
|
|
84
85
|
import type { SourceLocation } from "./source-location";
|
|
85
86
|
|
|
86
87
|
// =============================================================================
|
|
@@ -367,6 +368,10 @@ export type ScreenPattern = {
|
|
|
367
368
|
// overrides that derivation explicitly (force-show or force-hide);
|
|
368
369
|
// `agent.risk` ("low" | "mid" | "high") classifies the action's blast
|
|
369
370
|
// radius, defaulting to "mid" for a write handler.
|
|
371
|
+
//
|
|
372
|
+
// access/rateLimit/escapeHatch/agent additionally accept a RawRefSentinel
|
|
373
|
+
// for a non-literal value (imported/same-file const, or a sub-value like
|
|
374
|
+
// `personalData: PD`) that would otherwise lose the reference on render.
|
|
370
375
|
export type WriteHandlerPattern = {
|
|
371
376
|
readonly kind: "writeHandler";
|
|
372
377
|
readonly source: SourceLocation;
|
|
@@ -384,12 +389,12 @@ export type WriteHandlerPattern = {
|
|
|
384
389
|
// handlerBody: the closure body as source text. Always opaque — AI
|
|
385
390
|
// generates raw TypeScript, no DSL interpretation.
|
|
386
391
|
readonly handlerBody?: SourceLocation;
|
|
387
|
-
readonly access?: AccessRule;
|
|
392
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
388
393
|
readonly description?: string;
|
|
389
|
-
readonly agent?: AgentHandlerHints;
|
|
390
|
-
readonly rateLimit?:
|
|
394
|
+
readonly agent?: AgentHandlerHints | RawRefSentinel;
|
|
395
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
391
396
|
readonly unsafeSkipTransitionGuard?: boolean;
|
|
392
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
397
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
393
398
|
};
|
|
394
399
|
|
|
395
400
|
// `r.queryHandler(...)` — registers a read handler: name, Zod input schema,
|
|
@@ -408,25 +413,27 @@ export type QueryHandlerPattern = {
|
|
|
408
413
|
readonly handlerName?: string;
|
|
409
414
|
readonly schemaSource?: SourceLocation;
|
|
410
415
|
readonly handlerBody?: SourceLocation;
|
|
411
|
-
readonly access?: AccessRule;
|
|
416
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
412
417
|
readonly description?: string;
|
|
413
|
-
readonly agent?: AgentHandlerHints;
|
|
414
|
-
readonly rateLimit?:
|
|
415
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
418
|
+
readonly agent?: AgentHandlerHints | RawRefSentinel;
|
|
419
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
420
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
416
421
|
};
|
|
417
422
|
|
|
418
423
|
// `r.streamHandler(...)` — registers a streaming read handler: name, Zod
|
|
419
|
-
// input schema, async-generator handler closure, plus optional `access
|
|
420
|
-
// `rateLimit` rules
|
|
421
|
-
// case as
|
|
424
|
+
// input schema, async-generator handler closure, plus optional `access`,
|
|
425
|
+
// `rateLimit`, and `escapeHatch` rules (StreamHandlerDef carries all three
|
|
426
|
+
// at runtime). Same header/body split and opaque single-reference case as
|
|
427
|
+
// QueryHandlerPattern.
|
|
422
428
|
export type StreamHandlerPattern = {
|
|
423
429
|
readonly kind: "streamHandler";
|
|
424
430
|
readonly source: SourceLocation;
|
|
425
431
|
readonly handlerName?: string;
|
|
426
432
|
readonly schemaSource?: SourceLocation;
|
|
427
433
|
readonly handlerBody?: SourceLocation;
|
|
428
|
-
readonly access?: AccessRule;
|
|
429
|
-
readonly rateLimit?:
|
|
434
|
+
readonly access?: AccessRule | RawRefSentinel;
|
|
435
|
+
readonly rateLimit?: RateLimitDeclaration | RawRefSentinel;
|
|
436
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
430
437
|
};
|
|
431
438
|
|
|
432
439
|
// `r.hook(type, target, fn, options?)` — attaches a lifecycle hook
|
|
@@ -447,7 +454,7 @@ export type HookPattern = {
|
|
|
447
454
|
readonly target: string | readonly string[] | { readonly allOf: string };
|
|
448
455
|
readonly fnBody: SourceLocation;
|
|
449
456
|
readonly phase?: HookPhase;
|
|
450
|
-
readonly escapeHatch?: EscapeHatchDeclaration;
|
|
457
|
+
readonly escapeHatch?: EscapeHatchDeclaration | RawRefSentinel;
|
|
451
458
|
};
|
|
452
459
|
|
|
453
460
|
// `r.job(name, options, handler)` — registers a background job, qualified
|
|
@@ -436,6 +436,7 @@ function renderStreamHandler(p: StreamHandlerPattern): string {
|
|
|
436
436
|
lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
|
|
437
437
|
if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
|
|
438
438
|
if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
|
|
439
|
+
if (p.escapeHatch !== undefined) lines.push(` escapeHatch: ${renderValue(p.escapeHatch)},`);
|
|
439
440
|
lines.push("});");
|
|
440
441
|
return lines.join("\n");
|
|
441
442
|
}
|
|
@@ -229,9 +229,9 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
229
229
|
useExtension(
|
|
230
230
|
extensionNameOrDefinition:
|
|
231
231
|
| string
|
|
232
|
-
| ({ readonly name: string; readonly entity: NameOrRef } &
|
|
232
|
+
| ({ readonly name: string; readonly entity: NameOrRef } & object),
|
|
233
233
|
entityRef?: NameOrRef,
|
|
234
|
-
options?:
|
|
234
|
+
options?: object,
|
|
235
235
|
): void {
|
|
236
236
|
const [extensionName, resolvedEntityRef, resolvedOptions] =
|
|
237
237
|
typeof extensionNameOrDefinition === "string"
|
|
@@ -241,11 +241,12 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
241
241
|
return [name, entity, rest] as const;
|
|
242
242
|
})();
|
|
243
243
|
const resolvedEntityName = resolveName(resolvedEntityRef);
|
|
244
|
+
// @cast-boundary engine-bridge — typed per-extension options → erased registration bag
|
|
245
|
+
const optionsBag = resolvedOptions as Record<string, unknown> | undefined;
|
|
244
246
|
// fw#2914 — cross-cutting escapeHatch convention for hook-context db
|
|
245
|
-
// access (mirrors r.hook's validation above).
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
const escapeHatch = resolvedOptions?.["escapeHatch"];
|
|
247
|
+
// access (mirrors r.hook's validation above). Validated the same way
|
|
248
|
+
// for every extension regardless of its typed hook shape.
|
|
249
|
+
const escapeHatch = optionsBag?.["escapeHatch"];
|
|
249
250
|
if (escapeHatch !== undefined) {
|
|
250
251
|
const reason =
|
|
251
252
|
typeof escapeHatch === "object" && escapeHatch !== null
|
|
@@ -261,7 +262,7 @@ export function buildUiExtensionsMethods<TName extends string>(
|
|
|
261
262
|
state.extensionUsages.push({
|
|
262
263
|
extensionName,
|
|
263
264
|
entityName: resolvedEntityName,
|
|
264
|
-
options:
|
|
265
|
+
options: optionsBag,
|
|
265
266
|
});
|
|
266
267
|
},
|
|
267
268
|
extensionSelector(extensionName: string, key: { readonly name: string } | string): void {
|
package/src/engine/index.ts
CHANGED
|
@@ -98,7 +98,7 @@ export {
|
|
|
98
98
|
export { declareEscapeHatch } from "./escape-hatch-declaration";
|
|
99
99
|
export type { EmitCtx } from "./event-helpers";
|
|
100
100
|
export { emitEvent, typedPayload } from "./event-helpers";
|
|
101
|
-
export type { KumikoExtensionName } from "./extension-names";
|
|
101
|
+
export type { KumikoExtensionName, TenantResourceExtensionName } from "./extension-names";
|
|
102
102
|
export {
|
|
103
103
|
EXT_DERIVATIVE_OVERLAY_RESOLVER,
|
|
104
104
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
@@ -124,11 +124,18 @@ export type {
|
|
|
124
124
|
StorageProviderExtensionHooks,
|
|
125
125
|
StorageProviderHookCtx,
|
|
126
126
|
} from "./extensions/storage-provider";
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
export {
|
|
128
|
+
isTenantDataExtensionHooks,
|
|
129
|
+
type TenantDataDestroyHook,
|
|
130
|
+
type TenantDataExtensionHooks,
|
|
131
|
+
type TenantDataHookCtx,
|
|
131
132
|
} from "./extensions/tenant-data";
|
|
133
|
+
export {
|
|
134
|
+
isTenantResourceExtensionHooks,
|
|
135
|
+
type TenantResourceDestroyHook,
|
|
136
|
+
type TenantResourceExtensionHooks,
|
|
137
|
+
type TenantResourceHookCtx,
|
|
138
|
+
} from "./extensions/tenant-resource";
|
|
132
139
|
export type {
|
|
133
140
|
TenantUserModel,
|
|
134
141
|
UserDataDeleteHook,
|
|
@@ -136,6 +143,7 @@ export type {
|
|
|
136
143
|
UserDataExportHook,
|
|
137
144
|
UserDataExportSnippet,
|
|
138
145
|
UserDataExtensionHooks,
|
|
146
|
+
UserDataExtensionOptions,
|
|
139
147
|
UserDataHookCtx,
|
|
140
148
|
UserDataStorageProvider,
|
|
141
149
|
} from "./extensions/user-data";
|
|
@@ -178,10 +186,13 @@ export type {
|
|
|
178
186
|
FormFieldLabel,
|
|
179
187
|
FormFieldSpec,
|
|
180
188
|
FormInputType,
|
|
189
|
+
HandlerHeaderUpdate,
|
|
181
190
|
ParseError,
|
|
182
191
|
ParseResult,
|
|
183
192
|
PatternCategory,
|
|
184
193
|
PatternChange,
|
|
194
|
+
PatternChangeIssue,
|
|
195
|
+
PatternChangesParseResult,
|
|
185
196
|
PatternFormSchema,
|
|
186
197
|
PatternId,
|
|
187
198
|
RenderFeatureFileInput,
|
|
@@ -195,11 +206,13 @@ export {
|
|
|
195
206
|
groupByCategory,
|
|
196
207
|
PATTERN_LIBRARY,
|
|
197
208
|
parseFeatureFile,
|
|
209
|
+
parsePatternChanges,
|
|
198
210
|
parseSourceFile,
|
|
199
211
|
removePattern,
|
|
200
212
|
renderFeatureFile,
|
|
201
213
|
renderPattern,
|
|
202
214
|
replacePattern,
|
|
215
|
+
updatePattern,
|
|
203
216
|
VERSION_HEADER,
|
|
204
217
|
} from "./feature-ast";
|
|
205
218
|
export {
|
|
@@ -392,6 +405,8 @@ export type {
|
|
|
392
405
|
EscapeHatchTarget,
|
|
393
406
|
EscapeHatchUseEvent,
|
|
394
407
|
EventDef,
|
|
408
|
+
ExtensionOptionsArgs,
|
|
409
|
+
ExtensionOptionsFor,
|
|
395
410
|
FeatureDefinition,
|
|
396
411
|
FeatureRegistrar,
|
|
397
412
|
FieldAccess,
|
|
@@ -419,6 +434,7 @@ export type {
|
|
|
419
434
|
JsonbFieldDef,
|
|
420
435
|
KumikoEntityTypeMap,
|
|
421
436
|
KumikoEventTypeMap,
|
|
437
|
+
KumikoExtensionOptionsMap,
|
|
422
438
|
KumikoHandlerPayloadMap,
|
|
423
439
|
KumikoHandlerResultMap,
|
|
424
440
|
LifecycleHookType,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type * from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
@@ -72,6 +72,12 @@ export type {
|
|
|
72
72
|
KumikoHandlerPayloadMap,
|
|
73
73
|
KumikoHandlerResultMap,
|
|
74
74
|
} from "@cosmicdrift/kumiko-types/event-type-map";
|
|
75
|
+
// Cross-Feature Compile-Time-Type-Map for r.useExtension options — mirrors KumikoEventTypeMap above.
|
|
76
|
+
export type {
|
|
77
|
+
ExtensionOptionsArgs,
|
|
78
|
+
ExtensionOptionsFor,
|
|
79
|
+
KumikoExtensionOptionsMap,
|
|
80
|
+
} from "@cosmicdrift/kumiko-types/extension-options-map";
|
|
75
81
|
export type {
|
|
76
82
|
BootCheckContext,
|
|
77
83
|
BootCheckFn,
|
|
@@ -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
|
+
});
|