@remit/queue-sidecar 0.0.9 → 0.0.11

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": "@remit/queue-sidecar",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -141,6 +141,23 @@ describe("the sidecar /metrics endpoint", () => {
141
141
  assert.equal(post.status, 400);
142
142
  });
143
143
 
144
+ it("exports how long each queue has gone without handing out a message", async () => {
145
+ store.purgeQueue("work");
146
+ store.sendMessage({ queueName: "work", body: "waiting" });
147
+ const idle = await get("/metrics");
148
+ assert.match(
149
+ idle.body,
150
+ /^remit_queue_last_receive_age_seconds\{queue="work",role="work"\} \d+$/m,
151
+ );
152
+
153
+ const quiet = store.lastReceiveAgeSeconds("work", Date.now() + 901_000);
154
+ assert.ok(quiet >= 900);
155
+
156
+ store.receiveMessages({ queueName: "work", maxMessages: 1 });
157
+ assert.equal(store.lastReceiveAgeSeconds("work"), 0);
158
+ store.purgeQueue("work");
159
+ });
160
+
144
161
  it("counts only visible messages as depth", () => {
145
162
  store.sendMessage({ queueName: "work", body: "c" });
146
163
  store.receiveMessages({ queueName: "work", maxMessages: 1 });
@@ -165,6 +182,7 @@ describe("a store that cannot answer for any queue", () => {
165
182
  getQueueAttributes: () => {
166
183
  throw new Error("never reached");
167
184
  },
185
+ lastReceiveAgeSeconds: () => 0,
168
186
  }),
169
187
  /holds no queues/,
170
188
  );
package/src/metrics.ts CHANGED
@@ -17,9 +17,16 @@ const queueMessages = new Gauge({
17
17
  registers: [registry],
18
18
  });
19
19
 
20
+ const queueLastReceiveAge = new Gauge({
21
+ name: "remit_queue_last_receive_age_seconds",
22
+ help: "Seconds since a queue last handed a message to a consumer, or since the sidecar started if it has not.",
23
+ labelNames: ["queue", "role"],
24
+ registers: [registry],
25
+ });
26
+
20
27
  export type QueueDepthSource = Pick<
21
28
  QueueStore,
22
- "listQueues" | "getQueueAttributes"
29
+ "listQueues" | "getQueueAttributes" | "lastReceiveAgeSeconds"
23
30
  >;
24
31
 
25
32
  export const queueRoles = (
@@ -59,11 +66,13 @@ export const collectQueueDepths = (store: QueueDepthSource): void => {
59
66
  }
60
67
  const roles = queueRoles(queues);
61
68
  queueMessages.reset();
69
+ queueLastReceiveAge.reset();
62
70
  for (const queue of queues) {
63
71
  const attributes = store.getQueueAttributes(queue.name);
64
- queueMessages.set(
65
- { queue: queue.name, role: roles.get(queue.name) ?? "work" },
66
- attributes.approximateNumberOfMessages,
67
- );
72
+ const labels = { queue: queue.name, role: roles.get(queue.name) ?? "work" };
73
+ queueMessages.set(labels, attributes.approximateNumberOfMessages);
74
+ if (labels.role === "work") {
75
+ queueLastReceiveAge.set(labels, store.lastReceiveAgeSeconds(queue.name));
76
+ }
68
77
  }
69
78
  };
package/src/store.test.ts CHANGED
@@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { after, beforeEach, describe, it } from "node:test";
5
5
  import { fileURLToPath } from "node:url";
6
+ import Database from "better-sqlite3";
6
7
  import { bootstrapQueues, parseQueuesConfig } from "./queues-config.js";
7
8
  import {
8
9
  InvalidParameterValueError,
@@ -317,4 +318,57 @@ describe("QueueStore", () => {
317
318
  0,
318
319
  );
319
320
  });
321
+
322
+ const seedBacklog = (rows: number, groups: number): void => {
323
+ const path = join(dir, "queue.db");
324
+ store.close();
325
+ const seed = new Database(path);
326
+ seed
327
+ .prepare(
328
+ `WITH RECURSIVE n(i) AS (
329
+ SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < ?
330
+ )
331
+ INSERT INTO messages (
332
+ message_id, queue_name, body, md5_body, group_id, dedup_id,
333
+ sequence_number, receive_count, visible_at, receipt_handle,
334
+ sent_at, first_received_at
335
+ )
336
+ SELECT 'm' || i, 'orders.fifo', 'x', 'x', 'g' || CAST(i % CAST(? AS INTEGER) AS INTEGER), NULL,
337
+ i, 0, 0, NULL, 0, NULL
338
+ FROM n`,
339
+ )
340
+ .run(rows, groups);
341
+ seed.close();
342
+ store = new QueueStore(path);
343
+ };
344
+
345
+ it("receives only the head of each group from a FIFO backlog", () => {
346
+ seedBacklog(30, 3);
347
+ const received = store.receiveMessages({
348
+ queueName: "orders.fifo",
349
+ maxMessages: 10,
350
+ now: 1_000,
351
+ });
352
+ assert.deepEqual(
353
+ received.map((m) => [m.messageId, m.groupId]),
354
+ [
355
+ ["m1", "g1"],
356
+ ["m2", "g2"],
357
+ ["m3", "g0"],
358
+ ],
359
+ );
360
+ });
361
+
362
+ it("receives from a 200k-row FIFO backlog within a fixed time budget", () => {
363
+ seedBacklog(200_000, 5);
364
+ const started = performance.now();
365
+ const received = store.receiveMessages({
366
+ queueName: "orders.fifo",
367
+ maxMessages: 10,
368
+ now: 1_000,
369
+ });
370
+ const elapsed = performance.now() - started;
371
+ assert.equal(received.length, 5);
372
+ assert.ok(elapsed < 500, `receive took ${elapsed.toFixed(1)} ms`);
373
+ });
320
374
  });
package/src/store.ts CHANGED
@@ -97,6 +97,8 @@ const toQueueRecord = (row: QueueRow): QueueRecord => ({
97
97
  export class QueueStore {
98
98
  private readonly db: Database.Database;
99
99
  private sequenceCounter: bigint;
100
+ private readonly startedAt = Date.now();
101
+ private readonly lastReceiveAt = new Map<string, number>();
100
102
 
101
103
  constructor(filename: string) {
102
104
  this.db = new Database(filename);
@@ -139,6 +141,8 @@ export class QueueStore {
139
141
  );
140
142
  CREATE INDEX IF NOT EXISTS messages_queue_order
141
143
  ON messages (queue_name, id);
144
+ CREATE INDEX IF NOT EXISTS messages_queue_group
145
+ ON messages (queue_name, group_id, id);
142
146
  CREATE TABLE IF NOT EXISTS dedup (
143
147
  queue_name TEXT NOT NULL,
144
148
  dedup_id TEXT NOT NULL,
@@ -305,32 +309,13 @@ export class QueueStore {
305
309
  input.visibilityTimeoutSeconds ?? queue.visibilityTimeoutSeconds;
306
310
 
307
311
  const receive = this.db.transaction((): ReceivedMessage[] => {
308
- const inFlightGroups = new Set(
309
- (
310
- this.db
311
- .prepare(
312
- `SELECT DISTINCT group_id FROM messages
313
- WHERE queue_name = ? AND group_id IS NOT NULL
314
- AND visible_at > ?`,
315
- )
316
- .all(input.queueName, now) as { group_id: string }[]
317
- ).map((r) => r.group_id),
318
- );
319
-
320
- const candidates = this.db
321
- .prepare(
322
- `SELECT * FROM messages
323
- WHERE queue_name = ? AND visible_at <= ?
324
- ORDER BY id ASC`,
325
- )
326
- .all(input.queueName, now) as MessageRow[];
327
-
328
312
  const picked: ReceivedMessage[] = [];
329
- const claimedGroups = new Set<string>();
330
-
331
- for (const candidate of candidates) {
332
- if (picked.length >= input.maxMessages) break;
333
313
 
314
+ for (const candidate of this.nextCandidates(
315
+ queue,
316
+ now,
317
+ input.maxMessages,
318
+ )) {
334
319
  if (
335
320
  queue.maxReceiveCount !== null &&
336
321
  queue.deadLetterTargetName !== null &&
@@ -340,12 +325,6 @@ export class QueueStore {
340
325
  continue;
341
326
  }
342
327
 
343
- if (queue.fifo && candidate.group_id !== null) {
344
- if (inFlightGroups.has(candidate.group_id)) continue;
345
- if (claimedGroups.has(candidate.group_id)) continue;
346
- claimedGroups.add(candidate.group_id);
347
- }
348
-
349
328
  const receiptHandle = randomUUID();
350
329
  const receiveCount = candidate.receive_count + 1;
351
330
  const firstReceived = candidate.first_received_at ?? now;
@@ -380,7 +359,15 @@ export class QueueStore {
380
359
  return picked;
381
360
  });
382
361
 
383
- return receive();
362
+ const received = receive();
363
+ if (received.length > 0) this.lastReceiveAt.set(input.queueName, now);
364
+ return received;
365
+ }
366
+
367
+ lastReceiveAgeSeconds(queueName: string, now = Date.now()): number {
368
+ this.requireQueue(queueName);
369
+ const since = this.lastReceiveAt.get(queueName) ?? this.startedAt;
370
+ return Math.max(0, Math.floor((now - since) / 1000));
384
371
  }
385
372
 
386
373
  deleteMessage(queueName: string, receiptHandle: string): void {
@@ -425,19 +412,67 @@ export class QueueStore {
425
412
  };
426
413
  }
427
414
 
415
+ private nextCandidates(
416
+ queue: QueueRecord,
417
+ now: number,
418
+ limit: number,
419
+ ): MessageRow[] {
420
+ if (!queue.fifo) {
421
+ return this.db
422
+ .prepare(
423
+ `SELECT * FROM messages
424
+ WHERE queue_name = ? AND visible_at <= ?
425
+ ORDER BY id ASC LIMIT ?`,
426
+ )
427
+ .all(queue.name, now, limit) as MessageRow[];
428
+ }
429
+
430
+ return this.db
431
+ .prepare(
432
+ `WITH RECURSIVE g(gid) AS (
433
+ SELECT MIN(group_id) FROM messages WHERE queue_name = @queue
434
+ UNION ALL
435
+ SELECT (
436
+ SELECT MIN(group_id) FROM messages
437
+ WHERE queue_name = @queue AND group_id > g.gid
438
+ )
439
+ FROM g WHERE g.gid IS NOT NULL
440
+ )
441
+ SELECT m.* FROM g
442
+ JOIN messages m ON m.id = (
443
+ SELECT MIN(id) FROM messages
444
+ WHERE queue_name = @queue AND group_id = g.gid
445
+ )
446
+ WHERE m.visible_at <= @now
447
+ ORDER BY m.id ASC LIMIT @limit`,
448
+ )
449
+ .all({ queue: queue.name, now, limit }) as MessageRow[];
450
+ }
451
+
428
452
  private moveToDeadLetter(
429
453
  message: MessageRow,
430
454
  deadLetterName: string,
431
455
  now: number,
432
456
  ): void {
457
+ this.db.prepare("DELETE FROM messages WHERE id = ?").run(message.id);
433
458
  this.db
434
459
  .prepare(
435
- `UPDATE messages SET
436
- queue_name = ?, receive_count = 0, visible_at = 0,
437
- receipt_handle = NULL, first_received_at = NULL, sent_at = ?
438
- WHERE id = ?`,
460
+ `INSERT INTO messages (
461
+ message_id, queue_name, body, md5_body, group_id, dedup_id,
462
+ sequence_number, receive_count, visible_at, receipt_handle,
463
+ sent_at, first_received_at
464
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, NULL, ?, NULL)`,
439
465
  )
440
- .run(deadLetterName, now, message.id);
466
+ .run(
467
+ message.message_id,
468
+ deadLetterName,
469
+ message.body,
470
+ message.md5_body,
471
+ message.group_id,
472
+ message.dedup_id,
473
+ message.sequence_number,
474
+ now,
475
+ );
441
476
  }
442
477
 
443
478
  private pruneDedup(queueName: string, now: number): void {