@cosmicdrift/kumiko-framework 0.228.0 → 0.229.1
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 +3 -3
- package/src/engine/__tests__/screen-definition-exports.test.ts +102 -0
- package/src/engine/index.ts +30 -0
- package/src/engine/types/index.ts +12 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +281 -0
- package/src/jobs/job-runner.ts +81 -4
- package/src/stack/test-stack.ts +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.229.1",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.229.1",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.229.1",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type {
|
|
3
|
+
CustomScreenDefinition,
|
|
4
|
+
DashboardScreenDefinition,
|
|
5
|
+
DashboardStatPanel,
|
|
6
|
+
ProjectionDetailScreenDefinition,
|
|
7
|
+
ProjectionListScreenDefinition,
|
|
8
|
+
RowFieldExtractor,
|
|
9
|
+
ScreenNavSugar,
|
|
10
|
+
UnitKey,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import { requiredKeysFromScreen, screenTitleKey } from "../../i18n/required-surface-keys";
|
|
13
|
+
|
|
14
|
+
// fw#2519: the engine barrel exported ScreenDefinition (the union) but not
|
|
15
|
+
// its individual members — apps splitting screens out of feature.ts into
|
|
16
|
+
// their own module (kumiko-guard-ui's 300-line limit) could only type the
|
|
17
|
+
// union, losing per-kind checking. Every type imported below comes from the
|
|
18
|
+
// public "@cosmicdrift/kumiko-framework/engine" subpath, not an internal
|
|
19
|
+
// ../types path — an internal import would pass before the fix too, since
|
|
20
|
+
// the internal barrel already had these.
|
|
21
|
+
//
|
|
22
|
+
// The import above is `import type`, erased at runtime — this file guards
|
|
23
|
+
// the barrel at compile time only (tsc, via `check-wt.sh`/CI), not via
|
|
24
|
+
// `bun test`. If a type export regresses, `tsc --build` fails; this suite
|
|
25
|
+
// still passes green.
|
|
26
|
+
describe("engine barrel exports the per-screen definition types", () => {
|
|
27
|
+
test("ProjectionListScreenDefinition is assignable and feeds requiredKeysFromScreen", () => {
|
|
28
|
+
const screen: ProjectionListScreenDefinition = {
|
|
29
|
+
id: "recent-jobs",
|
|
30
|
+
type: "projectionList",
|
|
31
|
+
query: "jobs:query:recent",
|
|
32
|
+
columns: [{ field: "status", label: "publicstatus:column.status" }],
|
|
33
|
+
};
|
|
34
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
35
|
+
expect(keys).toContain(screenTitleKey("recent-jobs"));
|
|
36
|
+
expect(keys).toContain("publicstatus:column.status");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ProjectionDetailScreenDefinition is assignable and feeds requiredKeysFromScreen", () => {
|
|
40
|
+
const screen: ProjectionDetailScreenDefinition = {
|
|
41
|
+
id: "job-detail",
|
|
42
|
+
type: "projectionDetail",
|
|
43
|
+
query: "jobs:query:detail",
|
|
44
|
+
layout: {
|
|
45
|
+
sections: [{ title: "publicstatus:section.job", fields: ["runId"] }],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
49
|
+
expect(keys).toContain("publicstatus:section.job");
|
|
50
|
+
expect(keys).toContain("publicstatus:entity:__projection-detail__:field:runId");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("DashboardScreenDefinition + DashboardStatPanel are assignable and feed requiredKeysFromScreen", () => {
|
|
54
|
+
const panel: DashboardStatPanel = {
|
|
55
|
+
kind: "stat",
|
|
56
|
+
id: "open-incidents",
|
|
57
|
+
label: "publicstatus:panel.openIncidents",
|
|
58
|
+
query: "publicstatus:query:incidents:openCount",
|
|
59
|
+
valueField: "count",
|
|
60
|
+
};
|
|
61
|
+
const screen: DashboardScreenDefinition = {
|
|
62
|
+
id: "ops-dashboard",
|
|
63
|
+
type: "dashboard",
|
|
64
|
+
panels: [panel],
|
|
65
|
+
};
|
|
66
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
67
|
+
expect(keys).toContain("publicstatus:panel.openIncidents");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("CustomScreenDefinition standalone carries nav/detailFor directly", () => {
|
|
71
|
+
const nav: ScreenNavSugar = { label: "publicstatus:nav.componentDetail", order: 1 };
|
|
72
|
+
const screen: CustomScreenDefinition = {
|
|
73
|
+
id: "component-detail",
|
|
74
|
+
type: "custom",
|
|
75
|
+
renderer: { react: {} },
|
|
76
|
+
nav,
|
|
77
|
+
detailFor: "component",
|
|
78
|
+
};
|
|
79
|
+
expect(screen.nav?.label).toBe("publicstatus:nav.componentDetail");
|
|
80
|
+
expect(screen.detailFor).toBe("component");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("ProjectionListScreenDefinition also carries nav/detailFor directly, not just via ScreenDefinition", () => {
|
|
84
|
+
const screen: ProjectionListScreenDefinition = {
|
|
85
|
+
id: "job-list",
|
|
86
|
+
type: "projectionList",
|
|
87
|
+
query: "jobs:query:list",
|
|
88
|
+
columns: ["status"],
|
|
89
|
+
nav: { label: "publicstatus:nav.jobList" },
|
|
90
|
+
detailFor: "job",
|
|
91
|
+
};
|
|
92
|
+
expect(screen.nav?.label).toBe("publicstatus:nav.jobList");
|
|
93
|
+
expect(screen.detailFor).toBe("job");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("UnitKey / RowFieldExtractor are exported and usable standalone", () => {
|
|
97
|
+
const unit: UnitKey = "km";
|
|
98
|
+
const extractor: RowFieldExtractor = { pick: ["id", "version"] };
|
|
99
|
+
expect(unit).toBe("km");
|
|
100
|
+
expect(extractor).toEqual({ pick: ["id", "version"] });
|
|
101
|
+
});
|
|
102
|
+
});
|
package/src/engine/index.ts
CHANGED
|
@@ -320,6 +320,16 @@ export type {
|
|
|
320
320
|
CreateUserSeedOptions,
|
|
321
321
|
CustomScreenDefinition,
|
|
322
322
|
CustomScreenRoute,
|
|
323
|
+
DashboardChartPanel,
|
|
324
|
+
DashboardCustomPanel,
|
|
325
|
+
DashboardFeedPanel,
|
|
326
|
+
DashboardFilterDefinition,
|
|
327
|
+
DashboardListPanel,
|
|
328
|
+
DashboardPanelDefinition,
|
|
329
|
+
DashboardProgressListPanel,
|
|
330
|
+
DashboardScreenDefinition,
|
|
331
|
+
DashboardStatGroupPanel,
|
|
332
|
+
DashboardStatPanel,
|
|
323
333
|
DateFieldDef,
|
|
324
334
|
DecimalFieldDef,
|
|
325
335
|
DeleteContext,
|
|
@@ -331,6 +341,7 @@ export type {
|
|
|
331
341
|
EditFieldSpec,
|
|
332
342
|
EditFieldsSection,
|
|
333
343
|
EditLayout,
|
|
344
|
+
EditRelatedListSection,
|
|
334
345
|
EditSectionSpec,
|
|
335
346
|
EntityDefinition,
|
|
336
347
|
EntityEditScreenDefinition,
|
|
@@ -344,10 +355,14 @@ export type {
|
|
|
344
355
|
FieldAccess,
|
|
345
356
|
FieldCondition,
|
|
346
357
|
FieldDefinition,
|
|
358
|
+
FieldFormatRegistry,
|
|
359
|
+
FieldIconKey,
|
|
347
360
|
FieldRenderer,
|
|
348
361
|
FileFieldDef,
|
|
349
362
|
FilesFieldDef,
|
|
350
363
|
Findability,
|
|
364
|
+
FormatSpec,
|
|
365
|
+
FormWidth,
|
|
351
366
|
HandlerContext,
|
|
352
367
|
HasManyRelation,
|
|
353
368
|
HookMap,
|
|
@@ -364,6 +379,10 @@ export type {
|
|
|
364
379
|
KumikoHandlerResultMap,
|
|
365
380
|
LifecycleHookType,
|
|
366
381
|
ListColumnSpec,
|
|
382
|
+
ListFacetSpec,
|
|
383
|
+
ListPaginationMode,
|
|
384
|
+
ListSortDir,
|
|
385
|
+
ListSortSpec,
|
|
367
386
|
LongTextFindability,
|
|
368
387
|
ManyToManyRelation,
|
|
369
388
|
MspErrorMode,
|
|
@@ -395,19 +414,29 @@ export type {
|
|
|
395
414
|
PreQueryHookFn,
|
|
396
415
|
PreSaveHookFn,
|
|
397
416
|
ProjectionDefinition,
|
|
417
|
+
ProjectionDetailScreenDefinition,
|
|
418
|
+
ProjectionListScreenDefinition,
|
|
398
419
|
ProjectionTable,
|
|
399
420
|
QualifiedEventName,
|
|
400
421
|
QueryEvent,
|
|
401
422
|
QueryHandlerDef,
|
|
402
423
|
QueryHandlerFn,
|
|
424
|
+
RecordHeaderSpec,
|
|
403
425
|
ReferenceDataDef,
|
|
404
426
|
Registry,
|
|
405
427
|
RelationDefinition,
|
|
406
428
|
ResolvedPiiFlags,
|
|
407
429
|
RetentionDef,
|
|
408
430
|
RowAction,
|
|
431
|
+
RowActionNavigate,
|
|
432
|
+
RowActionNavigateBase,
|
|
433
|
+
RowActionWriteHandler,
|
|
434
|
+
RowFieldExtractor,
|
|
409
435
|
SaveContext,
|
|
410
436
|
ScreenDefinition,
|
|
437
|
+
ScreenFilter,
|
|
438
|
+
ScreenFilterOp,
|
|
439
|
+
ScreenNavSugar,
|
|
411
440
|
ScreenSlots,
|
|
412
441
|
SecretKeyHandle,
|
|
413
442
|
SelectFieldDef,
|
|
@@ -427,6 +456,7 @@ export type {
|
|
|
427
456
|
TreeChildrenSubscribe,
|
|
428
457
|
TreeNode,
|
|
429
458
|
TreeNodeState,
|
|
459
|
+
UnitKey,
|
|
430
460
|
UnsafeAppendEventFn,
|
|
431
461
|
ValidationError,
|
|
432
462
|
ValidationHookFn,
|
|
@@ -84,6 +84,8 @@ export type {
|
|
|
84
84
|
UiHintOption,
|
|
85
85
|
UiHints,
|
|
86
86
|
} from "@cosmicdrift/kumiko-types/feature";
|
|
87
|
+
// Closed field-icon vocabulary — used by `EditFieldSpec.icon`.
|
|
88
|
+
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
87
89
|
export type {
|
|
88
90
|
AnyFileFieldDef,
|
|
89
91
|
BigIntFieldDef,
|
|
@@ -267,6 +269,7 @@ export type {
|
|
|
267
269
|
EditFieldSpec,
|
|
268
270
|
EditFieldsSection,
|
|
269
271
|
EditLayout,
|
|
272
|
+
EditRelatedListSection,
|
|
270
273
|
EditSectionSpec,
|
|
271
274
|
EntityEditScreenDefinition,
|
|
272
275
|
EntityListScreenDefinition,
|
|
@@ -274,19 +277,28 @@ export type {
|
|
|
274
277
|
FieldFormatRegistry,
|
|
275
278
|
FieldRenderer,
|
|
276
279
|
FormatSpec,
|
|
280
|
+
FormWidth,
|
|
277
281
|
ListColumnSpec,
|
|
278
282
|
ListFacetSpec,
|
|
283
|
+
ListPaginationMode,
|
|
284
|
+
ListSortDir,
|
|
285
|
+
ListSortSpec,
|
|
279
286
|
PlatformComponent,
|
|
280
287
|
ProjectionDetailScreenDefinition,
|
|
281
288
|
ProjectionListScreenDefinition,
|
|
289
|
+
RecordHeaderSpec,
|
|
282
290
|
RowAction,
|
|
283
291
|
RowActionNavigate,
|
|
292
|
+
RowActionNavigateBase,
|
|
284
293
|
RowActionWriteHandler,
|
|
294
|
+
RowFieldExtractor,
|
|
285
295
|
ScreenDefinition,
|
|
286
296
|
ScreenFilter,
|
|
287
297
|
ScreenFilterOp,
|
|
298
|
+
ScreenNavSugar,
|
|
288
299
|
ScreenSlots,
|
|
289
300
|
ToolbarAction,
|
|
301
|
+
UnitKey,
|
|
290
302
|
} from "@cosmicdrift/kumiko-types/screen";
|
|
291
303
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
292
304
|
export type {
|
|
@@ -865,6 +865,287 @@ describe("error handling", () => {
|
|
|
865
865
|
});
|
|
866
866
|
});
|
|
867
867
|
|
|
868
|
+
// --- Multiple runner instances against the same Redis (prod runs N replicas
|
|
869
|
+
// of the worker process) — proves the scheduler dedups across instances and
|
|
870
|
+
// that per-tenant children distribute across whichever worker picks them up,
|
|
871
|
+
// not just the instance that ran the fan-out. Own registries/log per test,
|
|
872
|
+
// not the shared testFeature/jobLog — a per-second cron must not tick
|
|
873
|
+
// during every other test in this file, and the two scenarios below must
|
|
874
|
+
// not observe each other's fan-outs. Job handlers tag their entry with
|
|
875
|
+
// `ctx.db`, which each runner instance sets to a literal string instead of
|
|
876
|
+
// a real DbConnection — a cheap already-typed, already-threaded channel for
|
|
877
|
+
// "which runner instance ran this" (mirrors the `ctx["systemUser"] as
|
|
878
|
+
// SessionUser` cast the multi-tenant.integration.test.ts billing job uses).
|
|
879
|
+
describe("perTenant across multiple runner instances", () => {
|
|
880
|
+
test("a cron tick fires the perTenant wrapper exactly once, not once per runner instance", async () => {
|
|
881
|
+
const log: Array<{ tenantId: string; runnerTag: string }> = [];
|
|
882
|
+
const cronFeature = defineFeature("multicron", (r) => {
|
|
883
|
+
r.job(
|
|
884
|
+
"fanout",
|
|
885
|
+
{ trigger: { cron: "* * * * * *" }, perTenant: true },
|
|
886
|
+
async (_payload, ctx) => {
|
|
887
|
+
log.push({
|
|
888
|
+
tenantId: String(ctx.systemUser.tenantId),
|
|
889
|
+
runnerTag: ctx.db as unknown as string, // @cast-boundary test fixture
|
|
890
|
+
});
|
|
891
|
+
},
|
|
892
|
+
);
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const tenants = ["multi-cron-a", "multi-cron-b"] as TenantId[];
|
|
896
|
+
let wrapperCalls = 0;
|
|
897
|
+
const getActiveTenantIds = async () => {
|
|
898
|
+
wrapperCalls++;
|
|
899
|
+
return tenants;
|
|
900
|
+
};
|
|
901
|
+
const registry = createRegistry([cronFeature]);
|
|
902
|
+
const queueNamePrefix = `kumiko-test-multi-cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
903
|
+
const runnerA = createJobRunner({
|
|
904
|
+
registry,
|
|
905
|
+
context: { db: "runner-a" as unknown as AppContext["db"] },
|
|
906
|
+
redisUrl,
|
|
907
|
+
consumerLane: "worker",
|
|
908
|
+
queueNamePrefix,
|
|
909
|
+
getActiveTenantIds,
|
|
910
|
+
});
|
|
911
|
+
const runnerB = createJobRunner({
|
|
912
|
+
registry,
|
|
913
|
+
context: { db: "runner-b" as unknown as AppContext["db"] },
|
|
914
|
+
redisUrl,
|
|
915
|
+
consumerLane: "worker",
|
|
916
|
+
queueNamePrefix,
|
|
917
|
+
getActiveTenantIds,
|
|
918
|
+
});
|
|
919
|
+
// Query BullMQ directly rather than inferring dedup from log ratios —
|
|
920
|
+
// wrapperCalls is incremented by every wrapper run, so a ratio against
|
|
921
|
+
// it stays constant whether the wrapper fired once or twice per tick.
|
|
922
|
+
const rawQueue = new Queue(`${queueNamePrefix}-worker`, {
|
|
923
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
924
|
+
});
|
|
925
|
+
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
926
|
+
// attributes it to whichever test runs next (fw#1805).
|
|
927
|
+
rawQueue.on("error", () => {});
|
|
928
|
+
try {
|
|
929
|
+
// Both instances call start() → both call upsertJobScheduler() with
|
|
930
|
+
// the SAME schedulerId against the SAME Redis. If that didn't dedup,
|
|
931
|
+
// there would be two scheduler entries and two independent repeat
|
|
932
|
+
// timers driving the tick below.
|
|
933
|
+
await runnerA.start();
|
|
934
|
+
await runnerB.start();
|
|
935
|
+
// The actual once-per-tick proof: exactly one scheduler entry exists
|
|
936
|
+
// for this job in Redis, regardless of how many runner instances
|
|
937
|
+
// called upsertJobScheduler() against it. Two entries here means two
|
|
938
|
+
// independent repeat timers, which would double-fire every tick.
|
|
939
|
+
expect(await rawQueue.getJobSchedulersCount()).toBe(1);
|
|
940
|
+
await waitFor(() => expect(wrapperCalls).toBeGreaterThanOrEqual(1), {
|
|
941
|
+
delays: [2000, 3000, 5000],
|
|
942
|
+
});
|
|
943
|
+
} finally {
|
|
944
|
+
// Stop immediately after catching the first tick so no further ticks
|
|
945
|
+
// can land mid-assertion — the invariant below still holds however
|
|
946
|
+
// many ticks slipped in before stop() took effect.
|
|
947
|
+
await runnerA.stop();
|
|
948
|
+
await runnerB.stop();
|
|
949
|
+
await rawQueue.close();
|
|
950
|
+
}
|
|
951
|
+
await sleep(300); // let any in-flight children land
|
|
952
|
+
|
|
953
|
+
// Secondary sanity check only — this ratio holds whether the wrapper
|
|
954
|
+
// fired once or twice per tick (wrapperCalls scales with it either
|
|
955
|
+
// way), so it does not by itself prove the once-per-tick invariant.
|
|
956
|
+
// It only confirms each wrapper run that did happen fanned out to
|
|
957
|
+
// every tenant exactly once.
|
|
958
|
+
expect(log.length).toBe(wrapperCalls * tenants.length);
|
|
959
|
+
const seenTenants = log.map((e) => e.tenantId).sort();
|
|
960
|
+
const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
|
|
961
|
+
.flat()
|
|
962
|
+
.sort();
|
|
963
|
+
expect(seenTenants).toEqual(expectedTenants);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("manual per-tenant fan-out distributes children across both worker instances", async () => {
|
|
967
|
+
const log: Array<{ tenantId: string; runnerTag: string }> = [];
|
|
968
|
+
const manualFeature = defineFeature("multiworker", (r) => {
|
|
969
|
+
r.job("fanout", { trigger: { manual: true }, perTenant: true }, async (_payload, ctx) => {
|
|
970
|
+
// Widen the window both worker instances are actively racing for
|
|
971
|
+
// children in — without it, a fast single worker could drain a
|
|
972
|
+
// small batch before the other instance's blocking queue-pop even
|
|
973
|
+
// wakes up, which would prove nothing either way.
|
|
974
|
+
await sleep(80);
|
|
975
|
+
log.push({
|
|
976
|
+
tenantId: String(ctx.systemUser.tenantId),
|
|
977
|
+
runnerTag: ctx.db as unknown as string, // @cast-boundary test fixture
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// 12 > a single worker's hardcoded concurrency (5) — with both
|
|
983
|
+
// instances already polling before dispatch, the overflow past one
|
|
984
|
+
// worker's cap can only be served by the other.
|
|
985
|
+
const tenants = Array.from({ length: 12 }, (_, i) => `multi-worker-${i}`) as TenantId[];
|
|
986
|
+
const getActiveTenantIds = async () => tenants;
|
|
987
|
+
const registry = createRegistry([manualFeature]);
|
|
988
|
+
const queueNamePrefix = `kumiko-test-multi-worker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
989
|
+
const runnerA = createJobRunner({
|
|
990
|
+
registry,
|
|
991
|
+
context: { db: "runner-a" as unknown as AppContext["db"] },
|
|
992
|
+
redisUrl,
|
|
993
|
+
consumerLane: "worker",
|
|
994
|
+
queueNamePrefix,
|
|
995
|
+
getActiveTenantIds,
|
|
996
|
+
});
|
|
997
|
+
const runnerB = createJobRunner({
|
|
998
|
+
registry,
|
|
999
|
+
context: { db: "runner-b" as unknown as AppContext["db"] },
|
|
1000
|
+
redisUrl,
|
|
1001
|
+
consumerLane: "worker",
|
|
1002
|
+
queueNamePrefix,
|
|
1003
|
+
getActiveTenantIds,
|
|
1004
|
+
});
|
|
1005
|
+
try {
|
|
1006
|
+
await runnerA.start();
|
|
1007
|
+
await runnerB.start();
|
|
1008
|
+
await runnerA.dispatch("multiworker:job:fanout");
|
|
1009
|
+
await waitFor(
|
|
1010
|
+
() => {
|
|
1011
|
+
expect(log.length).toBe(tenants.length);
|
|
1012
|
+
},
|
|
1013
|
+
{ delays: [200, 300, 500, 1000] },
|
|
1014
|
+
);
|
|
1015
|
+
} finally {
|
|
1016
|
+
await runnerA.stop();
|
|
1017
|
+
await runnerB.stop();
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Every tenant ran exactly once — no duplicate fan-out.
|
|
1021
|
+
expect(log.map((e) => e.tenantId).sort()).toEqual([...tenants].sort());
|
|
1022
|
+
// Both worker instances actually processed children, not just the one
|
|
1023
|
+
// that happened to run the fan-out.
|
|
1024
|
+
const tagsUsed = new Set(log.map((e) => e.runnerTag));
|
|
1025
|
+
expect(tagsUsed.has("runner-a")).toBe(true);
|
|
1026
|
+
expect(tagsUsed.has("runner-b")).toBe(true);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
// A perTenant wrapper's own BullMQ job id is reused to derive each child's
|
|
1031
|
+
// job id (see perTenantChildJobId in job-runner.ts) — a wrapper that runs
|
|
1032
|
+
// twice for the SAME trigger (retry after failure, a Redis drop, an
|
|
1033
|
+
// instance restarting mid-run) must not double the fan-out, while two
|
|
1034
|
+
// DIFFERENT triggers (e.g. two cron ticks) must each still fan out fully.
|
|
1035
|
+
// These are the two failure modes on either side of that invariant.
|
|
1036
|
+
describe("perTenant wrapper re-run for the same trigger", () => {
|
|
1037
|
+
test("a wrapper that fires twice for the same trigger still enqueues exactly one child per tenant", async () => {
|
|
1038
|
+
const log: Array<{ tenantId: string }> = [];
|
|
1039
|
+
const dedupFeature = defineFeature("multidedup", (r) => {
|
|
1040
|
+
r.job("fanout", { trigger: { manual: true }, perTenant: true }, async (_payload, ctx) => {
|
|
1041
|
+
log.push({ tenantId: String(ctx.systemUser.tenantId) });
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
const tenants = ["dedup-a", "dedup-b", "dedup-c"] as TenantId[];
|
|
1046
|
+
const getActiveTenantIds = async () => tenants;
|
|
1047
|
+
const registry = createRegistry([dedupFeature]);
|
|
1048
|
+
const queueNamePrefix = `kumiko-test-dedup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1049
|
+
const runner = createJobRunner({
|
|
1050
|
+
registry,
|
|
1051
|
+
context: {},
|
|
1052
|
+
redisUrl,
|
|
1053
|
+
consumerLane: "worker",
|
|
1054
|
+
queueNamePrefix,
|
|
1055
|
+
getActiveTenantIds,
|
|
1056
|
+
});
|
|
1057
|
+
const rawQueue = new Queue(`${queueNamePrefix}-worker`, {
|
|
1058
|
+
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
1059
|
+
});
|
|
1060
|
+
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
1061
|
+
// attributes it to whichever test runs next (fw#1805).
|
|
1062
|
+
rawQueue.on("error", () => {});
|
|
1063
|
+
|
|
1064
|
+
const wrapperJobId = "fixed-wrapper-run";
|
|
1065
|
+
try {
|
|
1066
|
+
await runner.start();
|
|
1067
|
+
|
|
1068
|
+
// First run of the wrapper for this trigger — the real Worker picks
|
|
1069
|
+
// it up and fans out one child per tenant.
|
|
1070
|
+
await rawQueue.add("_perTenant:multidedup:job:fanout", {}, { jobId: wrapperJobId });
|
|
1071
|
+
await waitFor(() => expect(log.length).toBe(tenants.length), {
|
|
1072
|
+
delays: [200, 300, 500, 1000],
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// Simulate the SAME trigger firing the wrapper a second time. Remove
|
|
1076
|
+
// the now-completed wrapper's bookkeeping and re-add a genuinely
|
|
1077
|
+
// separate Job with the SAME id, so handleJob() runs again with the
|
|
1078
|
+
// identical bullJob.id — the actual "wrapper ran twice" scenario the
|
|
1079
|
+
// deterministic child id is meant to cover.
|
|
1080
|
+
const completedWrapper = await rawQueue.getJob(wrapperJobId);
|
|
1081
|
+
await completedWrapper?.remove();
|
|
1082
|
+
await rawQueue.add("_perTenant:multidedup:job:fanout", {}, { jobId: wrapperJobId });
|
|
1083
|
+
|
|
1084
|
+
// Give the second run's fan-out a chance to land. It re-derives the
|
|
1085
|
+
// SAME child ids as the first run, so BullMQ's existing-jobId add()
|
|
1086
|
+
// should return the already-completed children rather than re-run
|
|
1087
|
+
// the handler.
|
|
1088
|
+
await sleep(500);
|
|
1089
|
+
} finally {
|
|
1090
|
+
await runner.stop();
|
|
1091
|
+
await rawQueue.close();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
expect(log.length).toBe(tenants.length);
|
|
1095
|
+
expect(log.map((e) => e.tenantId).sort()).toEqual([...tenants].sort());
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("two consecutive cron ticks each still enqueue one child per tenant", async () => {
|
|
1099
|
+
const log: Array<{ tenantId: string }> = [];
|
|
1100
|
+
const cronFeature = defineFeature("multidedupcron", (r) => {
|
|
1101
|
+
r.job(
|
|
1102
|
+
"fanout",
|
|
1103
|
+
{ trigger: { cron: "* * * * * *" }, perTenant: true },
|
|
1104
|
+
async (_payload, ctx) => {
|
|
1105
|
+
log.push({ tenantId: String(ctx.systemUser.tenantId) });
|
|
1106
|
+
},
|
|
1107
|
+
);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
const tenants = ["dedup-cron-a", "dedup-cron-b"] as TenantId[];
|
|
1111
|
+
let wrapperCalls = 0;
|
|
1112
|
+
const getActiveTenantIds = async () => {
|
|
1113
|
+
wrapperCalls++;
|
|
1114
|
+
return tenants;
|
|
1115
|
+
};
|
|
1116
|
+
const registry = createRegistry([cronFeature]);
|
|
1117
|
+
const queueNamePrefix = `kumiko-test-dedup-cron-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1118
|
+
const runner = createJobRunner({
|
|
1119
|
+
registry,
|
|
1120
|
+
context: {},
|
|
1121
|
+
redisUrl,
|
|
1122
|
+
consumerLane: "worker",
|
|
1123
|
+
queueNamePrefix,
|
|
1124
|
+
getActiveTenantIds,
|
|
1125
|
+
});
|
|
1126
|
+
try {
|
|
1127
|
+
await runner.start();
|
|
1128
|
+
// Two distinct ticks get two distinct repeat-job ids (different
|
|
1129
|
+
// millis) — each must still produce its own full batch of children;
|
|
1130
|
+
// a wrongly cross-tick dedup would collapse the second tick's log
|
|
1131
|
+
// entries into the first.
|
|
1132
|
+
await waitFor(() => expect(wrapperCalls).toBeGreaterThanOrEqual(2), {
|
|
1133
|
+
delays: [1500, 2000, 3000],
|
|
1134
|
+
});
|
|
1135
|
+
} finally {
|
|
1136
|
+
await runner.stop();
|
|
1137
|
+
}
|
|
1138
|
+
await sleep(300); // let any in-flight children land
|
|
1139
|
+
|
|
1140
|
+
expect(log.length).toBe(wrapperCalls * tenants.length);
|
|
1141
|
+
const seenTenants = log.map((e) => e.tenantId).sort();
|
|
1142
|
+
const expectedTenants = Array.from({ length: wrapperCalls }, () => tenants)
|
|
1143
|
+
.flat()
|
|
1144
|
+
.sort();
|
|
1145
|
+
expect(seenTenants).toEqual(expectedTenants);
|
|
1146
|
+
});
|
|
1147
|
+
});
|
|
1148
|
+
|
|
868
1149
|
describe("handleEvent maxPerTenant", () => {
|
|
869
1150
|
test("skips enqueue when tenant is already at the cap", async () => {
|
|
870
1151
|
clearLog();
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../observability";
|
|
28
28
|
import { createDistributedLock, type DistributedLock } from "../pipeline/distributed-lock";
|
|
29
29
|
import { RedisKeys } from "../pipeline/redis-keys";
|
|
30
|
+
import { bridgeStub } from "../testing/handler-context";
|
|
30
31
|
|
|
31
32
|
// Queue-name convention: <prefix>-<lane>. The prefix is fixed in prod
|
|
32
33
|
// ("kumiko-jobs") — it must match between enqueuers and consumers, and an
|
|
@@ -38,6 +39,15 @@ function queueNameFor(prefix: string, lane: JobRunIn): string {
|
|
|
38
39
|
return `${prefix}-${lane}`;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// perTenant jobs need a source of truth for "which tenants are active" —
|
|
43
|
+
// see createJobRunner's defaultGetActiveTenantIds below. The tenant feature
|
|
44
|
+
// (bundled-features package) registers a query under this exact name when
|
|
45
|
+
// mounted. framework must not depend on bundled-features (wrong direction —
|
|
46
|
+
// bundled-features already depends on framework), so this is a name-only
|
|
47
|
+
// coupling: createJobRunner looks the handler up in the registry by string
|
|
48
|
+
// instead of importing it.
|
|
49
|
+
const ACTIVE_TENANT_IDS_QUERY_NAME = "tenant:query:active-tenant-ids";
|
|
50
|
+
|
|
41
51
|
/**
|
|
42
52
|
* BullMQ job ids are `repeat:<schedulerId>:<millis>`. Colons inside the
|
|
43
53
|
* scheduler id push the segment count to ≥5, which BullMQ's legacy heuristic
|
|
@@ -60,6 +70,24 @@ function legacySchedulerIdForJobName(jobName: string): string {
|
|
|
60
70
|
return `scheduler-${jobName.replace(/\./g, "-")}`;
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
// Deterministic per-tenant child job id, derived from the perTenant
|
|
74
|
+
// wrapper's own BullMQ job id. A cron tick's wrapper id is
|
|
75
|
+
// `repeat:<schedulerId>:<millis>` (stable for that tick, including across
|
|
76
|
+
// BullMQ's own retry/stalled-job redelivery of the *same* job); a manual
|
|
77
|
+
// dispatch's wrapper id is BullMQ's auto-generated id, equally stable
|
|
78
|
+
// across retries of that job. Reusing it means a wrapper that runs twice
|
|
79
|
+
// for the same trigger (retry after failure, a Redis drop, an instance
|
|
80
|
+
// restarting mid-run) re-derives the *same* child ids, and BullMQ's
|
|
81
|
+
// existing-jobId add() no-ops the second batch instead of creating
|
|
82
|
+
// duplicates — the same dedup-on-id mechanism bootJobIdForJobName already
|
|
83
|
+
// relies on above. Same colon-in-BullMQ-id hazard as schedulerIdForJobName
|
|
84
|
+
// (fw#1603/#1604) — the wrapper id already contains ":", so strip
|
|
85
|
+
// separators from both halves before joining instead of interpolating raw.
|
|
86
|
+
function perTenantChildJobId(wrapperJobId: string, tenantId: string): string {
|
|
87
|
+
const sanitize = (value: string) => value.replace(/[.:]/g, "-");
|
|
88
|
+
return `child-${sanitize(wrapperJobId)}-${sanitize(tenantId)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export type JobLogEntry = {
|
|
64
92
|
level: "info" | "warn" | "error";
|
|
65
93
|
message: string;
|
|
@@ -237,6 +265,38 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
237
265
|
// is true keeps those out of error-rate alerts without losing them.
|
|
238
266
|
let stopping = false;
|
|
239
267
|
|
|
268
|
+
// Default for perTenant fan-out when the caller didn't supply
|
|
269
|
+
// options.getActiveTenantIds: if the tenant feature is mounted (its
|
|
270
|
+
// active-tenant-ids query is in the registry), resolve tenants through
|
|
271
|
+
// that instead of forcing every app to wire the option by hand. Same
|
|
272
|
+
// hand-built-context pattern delivery-service.ts uses to invoke a
|
|
273
|
+
// registered handler without a live dispatcher — no dispatcher exists yet
|
|
274
|
+
// at createJobRunner() time, and this call runs with system-level access
|
|
275
|
+
// by construction, so going through the dispatcher's per-request identity
|
|
276
|
+
// checks would add nothing.
|
|
277
|
+
function defaultGetActiveTenantIds(): (() => Promise<TenantId[]>) | undefined {
|
|
278
|
+
const handler = registry.getQueryHandler(ACTIVE_TENANT_IDS_QUERY_NAME);
|
|
279
|
+
const db = context.db as DbConnection | undefined; // @cast-boundary db-operator
|
|
280
|
+
if (!handler || !db) return undefined;
|
|
281
|
+
return async () => {
|
|
282
|
+
const systemUser = createSystemUser(SYSTEM_TENANT_ID);
|
|
283
|
+
const systemModeDb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
|
|
284
|
+
const result = await handler.handler(
|
|
285
|
+
{ type: ACTIVE_TENANT_IDS_QUERY_NAME, payload: {}, user: systemUser },
|
|
286
|
+
{
|
|
287
|
+
db: systemModeDb,
|
|
288
|
+
dbOutsideTransaction: systemModeDb,
|
|
289
|
+
systemDb: createUncheckedSystemDb(systemModeDb),
|
|
290
|
+
registry,
|
|
291
|
+
...bridgeStub(),
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
// @cast-boundary engine-payload — generic query-handler return for typed convention
|
|
295
|
+
return result as TenantId[];
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const getActiveTenantIds = options.getActiveTenantIds ?? defaultGetActiveTenantIds();
|
|
299
|
+
|
|
240
300
|
const allJobs = registry.getAllJobs();
|
|
241
301
|
|
|
242
302
|
// Resolve the lane for a job — "worker" is the default because that's the
|
|
@@ -344,17 +404,34 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
344
404
|
// (it's picked up by this runner's own worker).
|
|
345
405
|
if (rawName.startsWith("_perTenant:")) {
|
|
346
406
|
const actualName = rawName.slice("_perTenant:".length);
|
|
347
|
-
if (!
|
|
348
|
-
throw new Error(
|
|
407
|
+
if (!getActiveTenantIds) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`perTenant job "${actualName}" requires either options.getActiveTenantIds or the ` +
|
|
410
|
+
`tenant feature mounted (it registers "${ACTIVE_TENANT_IDS_QUERY_NAME}", which the ` +
|
|
411
|
+
"framework uses to resolve active tenants on its own)",
|
|
412
|
+
);
|
|
349
413
|
}
|
|
350
414
|
const actualDef = allJobs.get(actualName);
|
|
351
415
|
if (!actualDef) {
|
|
352
416
|
throw new Error(`Unknown job: ${actualName}`);
|
|
353
417
|
}
|
|
354
|
-
const tenantIds = await
|
|
418
|
+
const tenantIds = await getActiveTenantIds();
|
|
355
419
|
const targetQueue = queues[laneForJob(actualDef)];
|
|
420
|
+
// wrapperJobId is only absent for a Job BullMQ somehow never assigned
|
|
421
|
+
// an id to (not observed in practice) — falling back to a constant
|
|
422
|
+
// there would collide every such run's children onto the same ids
|
|
423
|
+
// and silently swallow later runs as "duplicates", which is worse
|
|
424
|
+
// than the duplicate this is meant to prevent. Skip id-based dedup
|
|
425
|
+
// for that edge case instead: enqueue plainly, same as before.
|
|
426
|
+
const wrapperJobId = bullJob.id;
|
|
356
427
|
for (const tenantId of tenantIds) {
|
|
357
|
-
await targetQueue.add(
|
|
428
|
+
await targetQueue.add(
|
|
429
|
+
actualName,
|
|
430
|
+
{ ...bullJob.data, _tenantId: tenantId },
|
|
431
|
+
wrapperJobId !== undefined
|
|
432
|
+
? { jobId: perTenantChildJobId(wrapperJobId, tenantId) }
|
|
433
|
+
: undefined,
|
|
434
|
+
);
|
|
358
435
|
}
|
|
359
436
|
// skip: fan-out dispatcher job, per-tenant children enqueued
|
|
360
437
|
return;
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { extractTableInfo } from "../db/query";
|
|
|
8
8
|
import { createRegistry } from "../engine/registry";
|
|
9
9
|
import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
10
10
|
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
11
|
-
import { createJobRunner, type JobRunner } from "../jobs";
|
|
11
|
+
import { createJobRunner, type JobRunner, type JobRunnerOptions } from "../jobs";
|
|
12
12
|
import type { Lifecycle } from "../lifecycle";
|
|
13
13
|
import { createNoopProvider, type ObservabilityProvider } from "../observability";
|
|
14
14
|
import type { Dispatcher, EventDispatcher } from "../pipeline";
|
|
@@ -154,6 +154,12 @@ export type TestStackOptions = {
|
|
|
154
154
|
jobs?: {
|
|
155
155
|
consumerLane?: JobRunIn;
|
|
156
156
|
queueNamePrefix?: string;
|
|
157
|
+
/** Source of active tenant ids for `perTenant: true` jobs. Omit when the
|
|
158
|
+
* tenant feature is among `options.features` — the framework then
|
|
159
|
+
* resolves tenants on its own via that feature's active-tenant-ids
|
|
160
|
+
* query, same as prod. Only needed for suites that fan a job over
|
|
161
|
+
* tenants without mounting the tenant feature. */
|
|
162
|
+
getActiveTenantIds?: JobRunnerOptions["getActiveTenantIds"];
|
|
157
163
|
};
|
|
158
164
|
/** Override the event dispatcher's polling-timer interval. Default 50ms.
|
|
159
165
|
* Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
|
|
@@ -322,6 +328,9 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
322
328
|
...(options.jobs.queueNamePrefix !== undefined && {
|
|
323
329
|
queueNamePrefix: options.jobs.queueNamePrefix,
|
|
324
330
|
}),
|
|
331
|
+
...(options.jobs.getActiveTenantIds !== undefined && {
|
|
332
|
+
getActiveTenantIds: options.jobs.getActiveTenantIds,
|
|
333
|
+
}),
|
|
325
334
|
});
|
|
326
335
|
}
|
|
327
336
|
|