@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 +1 -1
- package/src/metrics.test.ts +18 -0
- package/src/metrics.ts +14 -5
- package/src/store.test.ts +54 -0
- package/src/store.ts +71 -36
package/package.json
CHANGED
package/src/metrics.test.ts
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
436
|
-
queue_name
|
|
437
|
-
|
|
438
|
-
|
|
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(
|
|
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 {
|