@cosmicdrift/kumiko-framework 0.227.0 → 0.229.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.227.0",
3
+ "version": "0.229.0",
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.227.0",
201
+ "@cosmicdrift/kumiko-types": "0.229.0",
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.227.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.229.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -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();
@@ -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 (!options.getActiveTenantIds) {
348
- throw new Error(`perTenant job "${actualName}" requires getActiveTenantIds option`);
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 options.getActiveTenantIds();
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(actualName, { ...bullJob.data, _tenantId: tenantId });
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;
@@ -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