@checkstack/queue-memory-backend 0.2.0 ā 0.2.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.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/benchmark.ts +215 -0
- package/src/memory-queue.test.ts +40 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @checkstack/queue-memory-backend
|
|
2
2
|
|
|
3
|
+
## 0.2.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [d94121b]
|
|
8
|
+
- @checkstack/backend-api@0.3.3
|
|
9
|
+
- @checkstack/queue-api@0.1.1
|
|
10
|
+
|
|
11
|
+
## 0.2.1
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- Updated dependencies [180be38]
|
|
16
|
+
- Updated dependencies [7a23261]
|
|
17
|
+
- @checkstack/queue-api@0.1.0
|
|
18
|
+
- @checkstack/common@0.3.0
|
|
19
|
+
- @checkstack/backend-api@0.3.2
|
|
20
|
+
- @checkstack/queue-memory-common@0.1.1
|
|
21
|
+
|
|
3
22
|
## 0.2.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/package.json
CHANGED
package/src/benchmark.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Queue Load Benchmark Script
|
|
4
|
+
*
|
|
5
|
+
* Run manually to measure queue throughput at various concurrency levels:
|
|
6
|
+
*
|
|
7
|
+
* cd plugins/queue-memory-backend
|
|
8
|
+
* bun run src/benchmark.ts
|
|
9
|
+
*
|
|
10
|
+
* This is NOT a test file - it runs benchmarks and prints results.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { InMemoryQueue } from "./memory-queue";
|
|
14
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
15
|
+
|
|
16
|
+
const testLogger: Logger = {
|
|
17
|
+
debug: () => {},
|
|
18
|
+
info: () => {},
|
|
19
|
+
warn: () => {},
|
|
20
|
+
error: () => {},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Simulate an I/O-bound job (like a health check)
|
|
25
|
+
*/
|
|
26
|
+
async function simulateIOWork(durationMs: number): Promise<void> {
|
|
27
|
+
await new Promise((resolve) => setTimeout(resolve, durationMs));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate percentile from sorted array
|
|
32
|
+
*/
|
|
33
|
+
function percentile(sortedValues: number[], p: number): number {
|
|
34
|
+
const index = Math.ceil((p / 100) * sortedValues.length) - 1;
|
|
35
|
+
return sortedValues[Math.max(0, index)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface LoadTestResult {
|
|
39
|
+
concurrency: number;
|
|
40
|
+
totalJobs: number;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
jobsPerSecond: number;
|
|
43
|
+
avgLatencyMs: number;
|
|
44
|
+
p50LatencyMs: number;
|
|
45
|
+
p95LatencyMs: number;
|
|
46
|
+
p99LatencyMs: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run a load test at a specific concurrency level
|
|
51
|
+
*/
|
|
52
|
+
async function runLoadTest(props: {
|
|
53
|
+
concurrency: number;
|
|
54
|
+
totalJobs: number;
|
|
55
|
+
jobDurationMs: number;
|
|
56
|
+
}): Promise<LoadTestResult> {
|
|
57
|
+
const { concurrency, totalJobs, jobDurationMs } = props;
|
|
58
|
+
|
|
59
|
+
const queue = new InMemoryQueue<number>(
|
|
60
|
+
`load-test-${concurrency}`,
|
|
61
|
+
{
|
|
62
|
+
concurrency,
|
|
63
|
+
maxQueueSize: totalJobs + 100,
|
|
64
|
+
delayMultiplier: 1,
|
|
65
|
+
heartbeatIntervalMs: 0,
|
|
66
|
+
},
|
|
67
|
+
testLogger
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const latencies: number[] = [];
|
|
71
|
+
let completed = 0;
|
|
72
|
+
const startTimes = new Map<number, number>();
|
|
73
|
+
|
|
74
|
+
await queue.consume(
|
|
75
|
+
async (job) => {
|
|
76
|
+
const jobStart = startTimes.get(job.data) ?? Date.now();
|
|
77
|
+
await simulateIOWork(jobDurationMs);
|
|
78
|
+
const latency = Date.now() - jobStart;
|
|
79
|
+
latencies.push(latency);
|
|
80
|
+
completed++;
|
|
81
|
+
},
|
|
82
|
+
{ consumerGroup: "load-test-group", maxRetries: 0 }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const testStart = Date.now();
|
|
86
|
+
for (let i = 0; i < totalJobs; i++) {
|
|
87
|
+
startTimes.set(i, Date.now());
|
|
88
|
+
await queue.enqueue(i);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
while (completed < totalJobs) {
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
93
|
+
}
|
|
94
|
+
const testDuration = Date.now() - testStart;
|
|
95
|
+
|
|
96
|
+
await queue.stop();
|
|
97
|
+
|
|
98
|
+
const sortedLatencies = latencies.toSorted((a, b) => a - b);
|
|
99
|
+
const avgLatency =
|
|
100
|
+
latencies.reduce((sum, l) => sum + l, 0) / latencies.length;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
concurrency,
|
|
104
|
+
totalJobs,
|
|
105
|
+
durationMs: testDuration,
|
|
106
|
+
jobsPerSecond: (totalJobs / testDuration) * 1000,
|
|
107
|
+
avgLatencyMs: Math.round(avgLatency),
|
|
108
|
+
p50LatencyMs: percentile(sortedLatencies, 50),
|
|
109
|
+
p95LatencyMs: percentile(sortedLatencies, 95),
|
|
110
|
+
p99LatencyMs: percentile(sortedLatencies, 99),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatResult(result: LoadTestResult): string {
|
|
115
|
+
return [
|
|
116
|
+
`Concurrency: ${result.concurrency}`,
|
|
117
|
+
` Jobs: ${result.totalJobs} in ${result.durationMs}ms`,
|
|
118
|
+
` Throughput: ${result.jobsPerSecond.toFixed(1)} jobs/sec`,
|
|
119
|
+
` Latency: avg=${result.avgLatencyMs}ms, p50=${result.p50LatencyMs}ms, p95=${result.p95LatencyMs}ms, p99=${result.p99LatencyMs}ms`,
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Main benchmark runner
|
|
124
|
+
async function main() {
|
|
125
|
+
console.log("\nš Queue Load Benchmark\n");
|
|
126
|
+
console.log("=".repeat(60));
|
|
127
|
+
|
|
128
|
+
// Test 1: Fast jobs
|
|
129
|
+
console.log("\nš Fast Jobs (10ms simulated I/O)\n");
|
|
130
|
+
for (const concurrency of [5, 10, 25, 50]) {
|
|
131
|
+
const result = await runLoadTest({
|
|
132
|
+
concurrency,
|
|
133
|
+
totalJobs: 100,
|
|
134
|
+
jobDurationMs: 10,
|
|
135
|
+
});
|
|
136
|
+
console.log(formatResult(result));
|
|
137
|
+
console.log("-".repeat(60));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Test 2: Realistic jobs
|
|
141
|
+
console.log("\nš Realistic Jobs (50ms simulated I/O)\n");
|
|
142
|
+
for (const concurrency of [5, 10, 25, 50]) {
|
|
143
|
+
const result = await runLoadTest({
|
|
144
|
+
concurrency,
|
|
145
|
+
totalJobs: 100,
|
|
146
|
+
jobDurationMs: 50,
|
|
147
|
+
});
|
|
148
|
+
console.log(formatResult(result));
|
|
149
|
+
console.log("-".repeat(60));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Test 3: Slow jobs showing concurrency benefit
|
|
153
|
+
console.log("\nš Slow Jobs (200ms) - Demonstrating Concurrency Benefits\n");
|
|
154
|
+
const results: LoadTestResult[] = [];
|
|
155
|
+
for (const concurrency of [1, 5, 10, 25]) {
|
|
156
|
+
const result = await runLoadTest({
|
|
157
|
+
concurrency,
|
|
158
|
+
totalJobs: 50,
|
|
159
|
+
jobDurationMs: 200,
|
|
160
|
+
});
|
|
161
|
+
results.push(result);
|
|
162
|
+
console.log(formatResult(result));
|
|
163
|
+
console.log("-".repeat(60));
|
|
164
|
+
}
|
|
165
|
+
const sequential = results.find((r) => r.concurrency === 1)!;
|
|
166
|
+
const parallel = results.find((r) => r.concurrency === 25)!;
|
|
167
|
+
console.log(
|
|
168
|
+
`\n⨠Speedup: ${(sequential.durationMs / parallel.durationMs).toFixed(
|
|
169
|
+
1
|
|
170
|
+
)}x faster with concurrency=25`
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Test 4: Burst load
|
|
174
|
+
console.log("\nš Burst Load (500 jobs at once)\n");
|
|
175
|
+
const burstResult = await runLoadTest({
|
|
176
|
+
concurrency: 50,
|
|
177
|
+
totalJobs: 500,
|
|
178
|
+
jobDurationMs: 20,
|
|
179
|
+
});
|
|
180
|
+
console.log(formatResult(burstResult));
|
|
181
|
+
|
|
182
|
+
// Test 5: Max throughput
|
|
183
|
+
console.log("\nš Max Throughput (minimal job duration)\n");
|
|
184
|
+
const maxResult = await runLoadTest({
|
|
185
|
+
concurrency: 100,
|
|
186
|
+
totalJobs: 1000,
|
|
187
|
+
jobDurationMs: 1,
|
|
188
|
+
});
|
|
189
|
+
console.log(formatResult(maxResult));
|
|
190
|
+
console.log(
|
|
191
|
+
`\n⨠Max throughput: ${maxResult.jobsPerSecond.toFixed(0)} jobs/sec`
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Capacity planning guidance
|
|
195
|
+
console.log("\n" + "=".repeat(60));
|
|
196
|
+
console.log("š CAPACITY PLANNING GUIDANCE");
|
|
197
|
+
console.log("=".repeat(60));
|
|
198
|
+
console.log(`
|
|
199
|
+
Throughput Formula: jobs/sec ā concurrency / avg_job_duration_seconds
|
|
200
|
+
|
|
201
|
+
Concurrency Settings:
|
|
202
|
+
- Default: 10 (conservative)
|
|
203
|
+
- Moderate: 25-50 (I/O-bound checks)
|
|
204
|
+
- Aggressive: 100 (max, watch resources)
|
|
205
|
+
|
|
206
|
+
Bottlenecks: DB pool, rate limits, memory, sockets
|
|
207
|
+
|
|
208
|
+
Recommendations:
|
|
209
|
+
- Start with concurrency=10
|
|
210
|
+
- Increase if jobs queue but CPU/memory low
|
|
211
|
+
- Use BullMQ for horizontal scaling
|
|
212
|
+
`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await main();
|
package/src/memory-queue.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, jest } from "bun:test";
|
|
2
2
|
import { InMemoryQueue } from "./memory-queue";
|
|
3
3
|
import type { QueueJob } from "@checkstack/queue-api";
|
|
4
4
|
import type { Logger } from "@checkstack/backend-api";
|
|
@@ -345,12 +345,14 @@ describe("InMemoryQueue Consumer Groups", () => {
|
|
|
345
345
|
|
|
346
346
|
describe("Delayed Jobs", () => {
|
|
347
347
|
it("should not process job until delay expires", async () => {
|
|
348
|
-
|
|
349
|
-
|
|
348
|
+
// Use fake timers for deterministic behavior
|
|
349
|
+
jest.useFakeTimers();
|
|
350
|
+
|
|
351
|
+
let processed = false;
|
|
350
352
|
|
|
351
353
|
await queue.consume(
|
|
352
|
-
async (
|
|
353
|
-
|
|
354
|
+
async () => {
|
|
355
|
+
processed = true;
|
|
354
356
|
},
|
|
355
357
|
{ consumerGroup: "delay-group", maxRetries: 0 }
|
|
356
358
|
);
|
|
@@ -358,21 +360,23 @@ describe("InMemoryQueue Consumer Groups", () => {
|
|
|
358
360
|
// Enqueue with 2-second delay (becomes 20ms with delayMultiplier=0.01)
|
|
359
361
|
await queue.enqueue("delayed-job", { startDelay: 2 });
|
|
360
362
|
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
363
|
+
// Advance time but NOT past the delay (20ms)
|
|
364
|
+
jest.advanceTimersByTime(15);
|
|
365
|
+
await Promise.resolve();
|
|
366
|
+
expect(processed).toBe(false);
|
|
364
367
|
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
+
// Advance past the delay
|
|
369
|
+
jest.advanceTimersByTime(10);
|
|
370
|
+
await Promise.resolve();
|
|
371
|
+
expect(processed).toBe(true);
|
|
368
372
|
|
|
369
|
-
|
|
370
|
-
const actualDelay = processedTimes[0] - enqueueTime;
|
|
371
|
-
expect(actualDelay).toBeGreaterThanOrEqual(15); // Allow tolerance
|
|
372
|
-
expect(actualDelay).toBeLessThanOrEqual(200); // Allow more tolerance for CI
|
|
373
|
+
jest.useRealTimers();
|
|
373
374
|
});
|
|
374
375
|
|
|
375
376
|
it("should process non-delayed jobs immediately while delayed jobs wait", async () => {
|
|
377
|
+
// Use fake timers to make this test completely deterministic
|
|
378
|
+
jest.useFakeTimers();
|
|
379
|
+
|
|
376
380
|
const processed: string[] = [];
|
|
377
381
|
|
|
378
382
|
await queue.consume(
|
|
@@ -382,23 +386,33 @@ describe("InMemoryQueue Consumer Groups", () => {
|
|
|
382
386
|
{ consumerGroup: "mixed-delay-group", maxRetries: 0 }
|
|
383
387
|
);
|
|
384
388
|
|
|
385
|
-
// Enqueue delayed job first (10s delay = 100ms with multiplier)
|
|
389
|
+
// Enqueue delayed job first (10s delay = 100ms with 0.01 multiplier)
|
|
386
390
|
await queue.enqueue("delayed", { startDelay: 10 });
|
|
387
391
|
|
|
388
392
|
// Enqueue immediate job
|
|
389
393
|
await queue.enqueue("immediate");
|
|
390
394
|
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
395
|
+
// Advance timers just enough for immediate job to process, but NOT the delayed job
|
|
396
|
+
jest.advanceTimersByTime(10);
|
|
397
|
+
// Flush the promise queue to let the async handler complete
|
|
398
|
+
await Promise.resolve();
|
|
399
|
+
|
|
394
400
|
expect(processed).toEqual(["immediate"]);
|
|
395
401
|
|
|
396
|
-
//
|
|
397
|
-
|
|
402
|
+
// Advance past the delay (100ms total needed)
|
|
403
|
+
jest.advanceTimersByTime(100);
|
|
404
|
+
await Promise.resolve();
|
|
405
|
+
|
|
398
406
|
expect(processed).toEqual(["immediate", "delayed"]);
|
|
407
|
+
|
|
408
|
+
// Restore real timers
|
|
409
|
+
jest.useRealTimers();
|
|
399
410
|
});
|
|
400
411
|
|
|
401
412
|
it("should respect priority with delayed jobs", async () => {
|
|
413
|
+
// Use fake timers for deterministic behavior
|
|
414
|
+
jest.useFakeTimers();
|
|
415
|
+
|
|
402
416
|
const processed: string[] = [];
|
|
403
417
|
|
|
404
418
|
await queue.consume(
|
|
@@ -423,8 +437,9 @@ describe("InMemoryQueue Consumer Groups", () => {
|
|
|
423
437
|
priority: 5,
|
|
424
438
|
});
|
|
425
439
|
|
|
426
|
-
//
|
|
427
|
-
|
|
440
|
+
// Advance past the delay (10ms)
|
|
441
|
+
jest.advanceTimersByTime(15);
|
|
442
|
+
await Promise.resolve();
|
|
428
443
|
|
|
429
444
|
// Should process in priority order (highest first)
|
|
430
445
|
expect(processed).toEqual([
|
|
@@ -432,6 +447,8 @@ describe("InMemoryQueue Consumer Groups", () => {
|
|
|
432
447
|
"medium-priority",
|
|
433
448
|
"low-priority",
|
|
434
449
|
]);
|
|
450
|
+
|
|
451
|
+
jest.useRealTimers();
|
|
435
452
|
});
|
|
436
453
|
});
|
|
437
454
|
|