@cosmicdrift/kumiko-server-runtime 0.305.0 → 0.307.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-server-runtime",
3
- "version": "0.305.0",
3
+ "version": "0.307.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -80,9 +80,9 @@
80
80
  }
81
81
  },
82
82
  "dependencies": {
83
- "@cosmicdrift/kumiko-bundled-features": "0.305.0",
84
- "@cosmicdrift/kumiko-framework": "0.305.0",
85
- "@cosmicdrift/kumiko-headless": "0.305.0",
83
+ "@cosmicdrift/kumiko-bundled-features": "0.307.0",
84
+ "@cosmicdrift/kumiko-framework": "0.307.0",
85
+ "@cosmicdrift/kumiko-headless": "0.307.0",
86
86
  "temporal-polyfill": "^0.3.2"
87
87
  },
88
88
  "publishConfig": {
@@ -71,6 +71,7 @@ describe("startDevJobRunners", () => {
71
71
  db,
72
72
  context: {},
73
73
  redisUrl: testRedis.redisUrl,
74
+ queueNamePrefix: `test-lane-default-${crypto.randomUUID()}`,
74
75
  // Construction-only — this job never calls ctx.write/ctx.queryAs, so a
75
76
  // dispatcher built against a bare `{}` context is enough to satisfy
76
77
  // the (now required) opts.dispatcher without wiring a full stack.
@@ -152,11 +153,12 @@ describe("startDevJobRunners attaches the dispatcher (kumiko-framework#2553)", (
152
153
  });
153
154
 
154
155
  test("a dev-run job's ctx.write and ctx.queryAs both succeed", async () => {
156
+ const attachQueueNamePrefix = `test-attach-dispatcher-${crypto.randomUUID()}`;
155
157
  const stack = await setupTestStack({
156
158
  features: [attachDispatcherFeature, createJobsFeature()],
157
159
  // Enqueuer-only — startDevJobRunners below is the sole consumer,
158
160
  // mirroring create-kumiko-server.ts.
159
- jobs: {},
161
+ jobs: { queueNamePrefix: attachQueueNamePrefix },
160
162
  });
161
163
  await unsafeCreateEntityTable(stack.db, attachNoteEntity);
162
164
 
@@ -167,6 +169,7 @@ describe("startDevJobRunners attaches the dispatcher (kumiko-framework#2553)", (
167
169
  context: stack.context,
168
170
  redisUrl: stack.redis.redisUrl,
169
171
  dispatcher: stack.dispatcher,
172
+ queueNamePrefix: attachQueueNamePrefix,
170
173
  });
171
174
 
172
175
  try {
@@ -190,3 +193,70 @@ describe("startDevJobRunners attaches the dispatcher (kumiko-framework#2553)", (
190
193
  }
191
194
  }, 15000);
192
195
  });
196
+
197
+ // Regression guard for the shared-Redis job-queue-collision bug: two boots
198
+ // (e.g. two parallel dev/e2e servers) that don't set distinct
199
+ // queueNamePrefix values consume from the same BullMQ queue names and steal
200
+ // each other's jobs ("Unknown job: ..."). Both registries below register the
201
+ // SAME feature/job name on purpose — if the queues were shared, runner B
202
+ // would recognize and execute jobs meant for runner A (proving the
203
+ // collision), not just fail to run them. Distinct prefixes must keep every
204
+ // dispatched job on runner A's side.
205
+ describe("startDevJobRunners isolates queues by queueNamePrefix", () => {
206
+ test("two runners with distinct prefixes on the same Redis don't cross-consume", async () => {
207
+ const runsA: string[] = [];
208
+ const runsB: string[] = [];
209
+
210
+ const isoFeatureA = defineFeature("iso", (r) => {
211
+ r.job("mark", { trigger: { manual: true } }, async (payload) => {
212
+ runsA.push((payload as { note: string }).note);
213
+ });
214
+ });
215
+ const isoFeatureB = defineFeature("iso", (r) => {
216
+ r.job("mark", { trigger: { manual: true } }, async (payload) => {
217
+ runsB.push((payload as { note: string }).note);
218
+ });
219
+ });
220
+
221
+ const registryA = createRegistry([isoFeatureA, createJobsFeature()]);
222
+ const registryB = createRegistry([isoFeatureB, createJobsFeature()]);
223
+
224
+ const runnerA = await startDevJobRunners({
225
+ registry: registryA,
226
+ db,
227
+ context: {},
228
+ redisUrl: testRedis.redisUrl,
229
+ queueNamePrefix: `test-iso-a-${crypto.randomUUID()}`,
230
+ dispatcher: createDispatcher(registryA, {}),
231
+ });
232
+ const runnerB = await startDevJobRunners({
233
+ registry: registryB,
234
+ db,
235
+ context: {},
236
+ redisUrl: testRedis.redisUrl,
237
+ queueNamePrefix: `test-iso-b-${crypto.randomUUID()}`,
238
+ dispatcher: createDispatcher(registryB, {}),
239
+ });
240
+
241
+ try {
242
+ const dispatchA = runnerA.runners[0];
243
+ if (dispatchA === undefined) throw new Error("expected a runner for registry A");
244
+
245
+ const notes = Array.from({ length: 10 }, (_, i) => `from-a-${i}`);
246
+ for (const note of notes) {
247
+ await dispatchA.dispatch("iso:job:mark", { note });
248
+ }
249
+
250
+ // If the queues were shared, some of these 10 jobs would land on
251
+ // runner B instead — runsA would never reach length 10 and this
252
+ // would time out rather than pass.
253
+ await waitFor(() => {
254
+ expect(runsA).toHaveLength(10);
255
+ });
256
+ expect(runsB).toEqual([]);
257
+ } finally {
258
+ await runnerA.stop();
259
+ await runnerB.stop();
260
+ }
261
+ });
262
+ });
@@ -20,6 +20,10 @@ export async function startDevJobRunners(opts: {
20
20
  readonly context: AppContext;
21
21
  readonly redisUrl: string;
22
22
  readonly dispatcher: Dispatcher;
23
+ // Redis is shared across boots and apps (unlike the ephemeral per-boot
24
+ // test DB); no silent default here so every caller is forced to pick a
25
+ // prefix that isolates its own queues.
26
+ readonly queueNamePrefix: string;
23
27
  }): Promise<{ readonly runners: readonly JobRunner[]; readonly stop: () => Promise<void> }> {
24
28
  const jobs = [...opts.registry.getAllJobs().values()];
25
29
  if (opts.registry.getFeature("jobs") === undefined || jobs.length === 0) {
@@ -38,6 +42,7 @@ export async function startDevJobRunners(opts: {
38
42
  registry: opts.registry,
39
43
  context: { ...opts.context, db: opts.db },
40
44
  redisUrl: opts.redisUrl,
45
+ queueNamePrefix: opts.queueNamePrefix,
41
46
  consumerLane: lane,
42
47
  ...logger,
43
48
  });