@crewhaus/queue-consumer 0.1.0 → 0.1.2

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.
Files changed (2) hide show
  1. package/package.json +8 -13
  2. package/src/index.test.ts +201 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/queue-consumer",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Long-running consumer loop for the BATCH target — visibility-timeout-aware, SIGTERM-drains (Section 23 BATCH)",
6
6
  "main": "src/index.ts",
@@ -12,15 +12,15 @@
12
12
  "test": "bun test src"
13
13
  },
14
14
  "dependencies": {
15
- "@crewhaus/errors": "0.0.0",
16
- "@crewhaus/idempotency-keys": "0.0.0",
17
- "@crewhaus/queue-protocol": "0.0.0"
15
+ "@crewhaus/errors": "0.1.2",
16
+ "@crewhaus/idempotency-keys": "0.1.2",
17
+ "@crewhaus/queue-protocol": "0.1.2"
18
18
  },
19
19
  "license": "Apache-2.0",
20
20
  "author": {
21
21
  "name": "Max Meier",
22
- "email": "max@studiomax.io",
23
- "url": "https://studiomax.io"
22
+ "email": "max@crewhaus.ai",
23
+ "url": "https://crewhaus.ai"
24
24
  },
25
25
  "repository": {
26
26
  "type": "git",
@@ -32,12 +32,7 @@
32
32
  "url": "https://github.com/crewhaus/factory/issues"
33
33
  },
34
34
  "publishConfig": {
35
- "access": "restricted"
35
+ "access": "public"
36
36
  },
37
- "files": [
38
- "src",
39
- "README.md",
40
- "LICENSE",
41
- "NOTICE"
42
- ]
37
+ "files": ["src", "README.md", "LICENSE", "NOTICE"]
43
38
  }
package/src/index.test.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { describe, expect, test } from "bun:test";
1
+ import { afterEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { createInMemoryIdempotencyStore } from "@crewhaus/idempotency-keys";
3
- import { type Job, createInMemoryQueue } from "@crewhaus/queue-protocol";
4
- import { type ConsumerObserver, startConsumer } from "./index.js";
3
+ import { type Job, type QueueAdapter, createInMemoryQueue } from "@crewhaus/queue-protocol";
4
+ import { type ConsumerObserver, QueueConsumerError, startConsumer } from "./index.js";
5
5
 
6
6
  describe("startConsumer", () => {
7
7
  test("processes 50 jobs at concurrency 4 (T3 end-to-end)", async () => {
@@ -200,3 +200,201 @@ describe("startConsumer", () => {
200
200
  expect(stats.pending + stats.acked).toBe(5);
201
201
  });
202
202
  });
203
+
204
+ describe("QueueConsumerError", () => {
205
+ test("carries the runtime code, stable name, and cause chain", () => {
206
+ const cause = new Error("adapter exploded");
207
+ const err = new QueueConsumerError("consumer failed", cause);
208
+ expect(err).toBeInstanceOf(Error);
209
+ expect(err.name).toBe("QueueConsumerError");
210
+ expect(err.code).toBe("runtime");
211
+ expect(err.message).toBe("consumer failed");
212
+ expect(err.cause).toBe(cause);
213
+ expect(err.toJSON()).toMatchObject({
214
+ name: "QueueConsumerError",
215
+ code: "runtime",
216
+ message: "consumer failed",
217
+ cause: { name: "Error", message: "adapter exploded" },
218
+ });
219
+ });
220
+
221
+ test("constructs without a cause", () => {
222
+ const err = new QueueConsumerError("no cause");
223
+ expect(err.cause).toBeUndefined();
224
+ });
225
+ });
226
+
227
+ describe("startConsumer — pull loop error surfacing", () => {
228
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
229
+
230
+ afterEach(() => {
231
+ stderrSpy?.mockRestore();
232
+ stderrSpy = undefined;
233
+ });
234
+
235
+ test("an unhandled error in the pull loop is written to stderr and stops the loop", async () => {
236
+ // Capture stderr so the loop-error log doesn't pollute test output AND we
237
+ // can assert it fired. No real stderr write, no real I/O.
238
+ const writes: string[] = [];
239
+ stderrSpy = spyOn(process.stderr, "write").mockImplementation((chunk: unknown): boolean => {
240
+ writes.push(String(chunk));
241
+ return true;
242
+ });
243
+
244
+ // A malformed adapter: `pull` resolves a non-array, so `pulled.length`
245
+ // throws a TypeError OUTSIDE the loop's pull try/catch (that try only
246
+ // guards the awaited pull call). This is the exact "rare loop error"
247
+ // path the catch handler exists for.
248
+ const badQueue: QueueAdapter<number> = {
249
+ kind: "bad",
250
+ pull: async () => null as unknown as ReadonlyArray<Job<number>>,
251
+ ack: async () => {},
252
+ nack: async () => {},
253
+ extendVisibility: async () => {},
254
+ stats: async () => ({ pending: 0, inFlight: 0, acked: 0, nacked: 0, deadLetter: 0 }),
255
+ };
256
+
257
+ const consumer = startConsumer<number, number>({
258
+ queue: badQueue,
259
+ handler: async (n) => n,
260
+ concurrency: 1,
261
+ visibilityTimeoutMs: 5_000,
262
+ });
263
+
264
+ // drain() awaits the (now-rejected-then-caught) loopPromise; it must
265
+ // resolve, not hang, because the catch handler swallows the throw.
266
+ await consumer.drain();
267
+
268
+ expect(writes.length).toBe(1);
269
+ expect(writes[0]).toContain("[queue-consumer] loop error:");
270
+ // The TypeError message about reading a property of null is surfaced.
271
+ expect(writes[0]).toMatch(/null|length/i);
272
+ expect(consumer.inFlight()).toBe(0);
273
+ });
274
+ });
275
+
276
+ describe("startConsumer — visibility renewal sidecar", () => {
277
+ test("fires extendVisibility on each renew tick and swallows renew failures", async () => {
278
+ // Deterministic timer control: capture scheduled callbacks instead of
279
+ // arming the real clock, so we drive renewal ticks by hand — no real
280
+ // timers, no leaked handles.
281
+ const scheduled: Array<{ id: number; fn: () => void; delay: number }> = [];
282
+ let nextId = 1;
283
+ const cleared: number[] = [];
284
+ const fakeSetTimeout = ((fn: () => void, delay?: number) => {
285
+ const id = nextId++;
286
+ scheduled.push({ id, fn, delay: delay ?? 0 });
287
+ return id as unknown as ReturnType<typeof setTimeout>;
288
+ }) as unknown as typeof setTimeout;
289
+ const fakeClearTimeout = ((handle: unknown) => {
290
+ cleared.push(handle as number);
291
+ }) as unknown as typeof clearTimeout;
292
+
293
+ // extendVisibility rejects so we also cover the `.catch(() => {})` swallow.
294
+ const extendCalls: Array<{ jobId: string; additionalMs: number }> = [];
295
+ let releaseHandler!: () => void;
296
+ const handlerGate = new Promise<void>((resolve) => {
297
+ releaseHandler = resolve;
298
+ });
299
+
300
+ const queue = createInMemoryQueue<string>();
301
+ await queue.enqueue("renew-job");
302
+ const baseExtend = queue.extendVisibility.bind(queue);
303
+ const renewQueue: QueueAdapter<string> = {
304
+ ...queue,
305
+ kind: queue.kind,
306
+ pull: queue.pull.bind(queue),
307
+ ack: queue.ack.bind(queue),
308
+ nack: queue.nack.bind(queue),
309
+ stats: queue.stats.bind(queue),
310
+ extendVisibility: async (jobId: string, additionalMs: number) => {
311
+ extendCalls.push({ jobId, additionalMs });
312
+ await baseExtend(jobId, additionalMs).catch(() => {});
313
+ // Force the renew-failure branch to exercise the swallowing catch.
314
+ throw new Error("extendVisibility failed (job already acked)");
315
+ },
316
+ enqueue: queue.enqueue.bind(queue),
317
+ } as unknown as QueueAdapter<string>;
318
+
319
+ const consumer = startConsumer<string, string>({
320
+ queue: renewQueue,
321
+ handler: async () => {
322
+ // Hold the job in-flight until we've driven a renew tick.
323
+ await handlerGate;
324
+ return "done";
325
+ },
326
+ concurrency: 1,
327
+ visibilityTimeoutMs: 5_000,
328
+ visibilityRenewIntervalMs: 1_000,
329
+ _setTimeout: fakeSetTimeout,
330
+ _clearTimeout: fakeClearTimeout,
331
+ });
332
+
333
+ // Wait until the handler is in-flight and the renew sidecar has armed
334
+ // its first timer (via the fake setTimeout).
335
+ const deadline = Date.now() + 2_000;
336
+ while (Date.now() < deadline && scheduled.length === 0) {
337
+ await new Promise((r) => setTimeout(r, 5));
338
+ }
339
+ expect(scheduled.length).toBeGreaterThan(0);
340
+
341
+ // Fire the first renew tick: this invokes extendVisibility(jobId, 2000)
342
+ // and re-arms the next tick.
343
+ const firstTick = scheduled.shift();
344
+ expect(firstTick).toBeDefined();
345
+ firstTick?.fn();
346
+ // Let the rejected extendVisibility promise settle through its catch.
347
+ await new Promise((r) => setTimeout(r, 5));
348
+
349
+ expect(extendCalls.length).toBeGreaterThanOrEqual(1);
350
+ // intervalMs (1000) * 2 is the additional visibility window the tick requests.
351
+ expect(extendCalls[0]?.additionalMs).toBe(2_000);
352
+ // jobId is whatever the in-memory adapter assigned to the single enqueued job.
353
+ expect(typeof extendCalls[0]?.jobId).toBe("string");
354
+ expect(extendCalls[0]?.jobId.length).toBeGreaterThan(0);
355
+ // The tick re-armed a follow-up renew timer.
356
+ expect(scheduled.length).toBeGreaterThanOrEqual(1);
357
+
358
+ // Release the handler so the job completes and stopRenew() clears the
359
+ // outstanding timer (covers the clearTimeout path).
360
+ releaseHandler();
361
+ await consumer.drain();
362
+ expect(cleared.length).toBeGreaterThanOrEqual(1);
363
+ });
364
+
365
+ test("stopRenew before any tick fires clears the armed timer", async () => {
366
+ const scheduled: Array<() => void> = [];
367
+ const cleared: number[] = [];
368
+ const fakeSetTimeout = ((fn: () => void) => {
369
+ scheduled.push(fn);
370
+ return scheduled.length as unknown as ReturnType<typeof setTimeout>;
371
+ }) as unknown as typeof setTimeout;
372
+ const fakeClearTimeout = ((handle: unknown) => {
373
+ cleared.push(handle as number);
374
+ }) as unknown as typeof clearTimeout;
375
+
376
+ const queue = createInMemoryQueue<string>();
377
+ await queue.enqueue("fast-job");
378
+
379
+ const consumer = startConsumer<string, string>({
380
+ queue,
381
+ // Resolves immediately — stopRenew() runs in the finally before any
382
+ // renew tick is driven, so the armed timer is cleared, not fired.
383
+ handler: async () => "ok",
384
+ concurrency: 1,
385
+ visibilityTimeoutMs: 5_000,
386
+ visibilityRenewIntervalMs: 1_000,
387
+ _setTimeout: fakeSetTimeout,
388
+ _clearTimeout: fakeClearTimeout,
389
+ });
390
+
391
+ const deadline = Date.now() + 2_000;
392
+ while (Date.now() < deadline && (await queue.stats()).acked === 0) {
393
+ await new Promise((r) => setTimeout(r, 5));
394
+ }
395
+ await consumer.drain();
396
+ expect((await queue.stats()).acked).toBe(1);
397
+ // The renew timer was armed then cleared on handler completion.
398
+ expect(cleared.length).toBeGreaterThanOrEqual(1);
399
+ });
400
+ });