218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
import { expect, test, vi } from "vitest";
|
|
import { DataStoreMQ } from "./mq.datastore.ts";
|
|
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
|
|
import { DataStore } from "./datastore.ts";
|
|
|
|
import type { MQMessage } from "../../plug-api/types/datastore.ts";
|
|
import { EventHook } from "../plugos/hooks/event.ts";
|
|
import { System } from "../plugos/system.ts";
|
|
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
|
|
|
|
test("DataStore MQ", async () => {
|
|
vi.useFakeTimers();
|
|
const db = new MemoryKvPrimitives(); // In-memory only, no persistence
|
|
const eventHook = new EventHook();
|
|
const system = new System<EventHookT>();
|
|
system.addHook(eventHook);
|
|
|
|
try {
|
|
const mq = new DataStoreMQ(new DataStore(db), eventHook);
|
|
|
|
let messages: MQMessage[];
|
|
|
|
// Send and ack
|
|
await mq.send("test", "Hello World");
|
|
messages = await mq.poll("test", 10);
|
|
expect(messages.length).toEqual(1);
|
|
await mq.ack("test", messages[0].id);
|
|
expect([]).toEqual(await mq.poll("test", 10));
|
|
|
|
// Timeout
|
|
await mq.send("test", "Hello World");
|
|
messages = await mq.poll("test", 10);
|
|
expect(messages.length).toEqual(1);
|
|
expect([]).toEqual(await mq.poll("test", 10));
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
await mq.requeueTimeouts(10);
|
|
messages = await mq.poll("test", 10);
|
|
const stats = await mq.getQueueStats();
|
|
expect(stats.processing).toEqual(1);
|
|
expect(messages.length).toEqual(1);
|
|
expect(messages[0].retries).toEqual(1);
|
|
|
|
// Max retries
|
|
await vi.advanceTimersByTimeAsync(20);
|
|
await mq.requeueTimeouts(10, 1);
|
|
expect((await mq.fetchDLQMessages()).length).toEqual(1);
|
|
|
|
// Batch send and ack
|
|
await mq.batchSend("test", ["Hello", "World"]);
|
|
const messageBatch1 = await mq.poll("test", 1);
|
|
expect(messageBatch1.length).toEqual(1);
|
|
expect(messageBatch1[0].body).toEqual("Hello");
|
|
const messageBatch2 = await mq.poll("test", 1);
|
|
expect(messageBatch2.length).toEqual(1);
|
|
expect(messageBatch2[0].body).toEqual("World");
|
|
await mq.batchAck("test", [messageBatch1[0].id, messageBatch2[0].id]);
|
|
expect(await mq.fetchProcessingMessages()).toEqual([]);
|
|
|
|
// Subscribe
|
|
let receivedMessage = false;
|
|
const worker = mq.subscribe("test123", {}, async (messages) => {
|
|
expect(messages.length).toEqual(1);
|
|
receivedMessage = true;
|
|
await mq.ack("test123", messages[0].id);
|
|
});
|
|
await mq.send("test123", "Hello World");
|
|
// Wait for message to be processed by checking queue stats
|
|
while ((await mq.getQueueStats("test123")).queued > 0) {
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
}
|
|
expect(receivedMessage).toEqual(true);
|
|
worker.stop();
|
|
expect(mq.queueWaiters.size).toEqual(0);
|
|
} finally {
|
|
await db.close();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("DataStore MQ - Scale test with multiple subscribers", async () => {
|
|
vi.useFakeTimers();
|
|
const db = new MemoryKvPrimitives();
|
|
const eventHook = new EventHook();
|
|
const system = new System<EventHookT>();
|
|
system.addHook(eventHook);
|
|
|
|
try {
|
|
const mq = new DataStoreMQ(new DataStore(db), eventHook);
|
|
|
|
const queueName = "scale-test";
|
|
const totalMessages = 1000;
|
|
const batchSize = 7;
|
|
const numSubscribers = 3;
|
|
|
|
// Track processed messages across all subscribers
|
|
const processedMessages = new Set<string>();
|
|
const subscriberStats = new Map<number, number>();
|
|
const processingLock = new Set<string>(); // Track which messages are being processed
|
|
|
|
// Initialize subscriber stats
|
|
for (let i = 0; i < numSubscribers; i++) {
|
|
subscriberStats.set(i, 0);
|
|
}
|
|
|
|
// Create 3 subscribers, each processing batches of 7 messages
|
|
const workers = [];
|
|
for (let subscriberId = 0; subscriberId < numSubscribers; subscriberId++) {
|
|
const worker = mq.subscribe(
|
|
queueName,
|
|
{ batchSize },
|
|
async (messages) => {
|
|
expect(messages.length <= batchSize).toBe(true);
|
|
|
|
console.log(
|
|
`[Subscriber ${subscriberId}] Processing batch of ${messages.length} messages`,
|
|
);
|
|
|
|
// Process each message in the batch
|
|
const messageIds = [];
|
|
for (const message of messages) {
|
|
// Check for concurrent processing (this shouldn't happen due to MQ design)
|
|
if (processingLock.has(message.body)) {
|
|
console.warn(
|
|
`Message ${message.body} is being processed concurrently by subscriber ${subscriberId}`,
|
|
);
|
|
continue;
|
|
}
|
|
processingLock.add(message.body);
|
|
|
|
// Ensure no duplicate processing
|
|
if (processedMessages.has(message.body)) {
|
|
console.warn(
|
|
`Message ${message.body} already processed by another subscriber`,
|
|
);
|
|
processingLock.delete(message.body);
|
|
continue;
|
|
}
|
|
|
|
processedMessages.add(message.body);
|
|
messageIds.push(message.id);
|
|
|
|
// Update subscriber stats
|
|
const currentCount = subscriberStats.get(subscriberId) || 0;
|
|
subscriberStats.set(subscriberId, currentCount + 1);
|
|
|
|
// Remove from processing lock
|
|
processingLock.delete(message.body);
|
|
}
|
|
|
|
// Ack all messages in the batch that were actually processed
|
|
if (messageIds.length > 0) {
|
|
await mq.batchAck(queueName, messageIds);
|
|
}
|
|
},
|
|
);
|
|
workers.push(worker);
|
|
}
|
|
|
|
// Send messages in smaller batches to allow better distribution
|
|
const messageBodies = [];
|
|
for (let i = 0; i < totalMessages; i++) {
|
|
messageBodies.push(`message-${i}`);
|
|
}
|
|
|
|
// Send in chunks to allow workers to process and become available
|
|
const chunkSize = 50;
|
|
for (let i = 0; i < messageBodies.length; i += chunkSize) {
|
|
const chunk = messageBodies.slice(i, i + chunkSize);
|
|
await mq.batchSend(queueName, chunk);
|
|
// Give a small delay to allow processing
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
}
|
|
|
|
// Wait for all messages to be processed
|
|
const maxWaitTime = 10000; // 10 seconds max wait
|
|
let waitTime = 0;
|
|
|
|
while (processedMessages.size < totalMessages && waitTime < maxWaitTime) {
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
waitTime += 100;
|
|
}
|
|
|
|
// Verify all messages were processed
|
|
expect(processedMessages.size).toEqual(totalMessages);
|
|
|
|
// Wait a bit more to ensure all acks are processed
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
// Verify queue is empty
|
|
const stats = await mq.getQueueStats(queueName);
|
|
expect(stats.queued).toEqual(0);
|
|
expect(stats.processing).toEqual(0);
|
|
|
|
// Verify work distribution among subscribers
|
|
let totalProcessedAcrossSubscribers = 0;
|
|
for (const [subscriberId, count] of subscriberStats.entries()) {
|
|
totalProcessedAcrossSubscribers += count;
|
|
console.log(`Subscriber ${subscriberId} processed ${count} messages`);
|
|
}
|
|
expect(totalProcessedAcrossSubscribers).toEqual(totalMessages);
|
|
|
|
// Verify at least one subscriber processed messages (relaxed requirement since MQ may favor one worker)
|
|
const activeSubscribers = Array.from(subscriberStats.values()).filter(
|
|
(count) => count > 0,
|
|
).length;
|
|
expect(activeSubscribers >= 1).toBe(true);
|
|
|
|
// Stop all workers
|
|
workers.forEach((worker) => worker.stop());
|
|
|
|
// Verify no queue waiters remain
|
|
expect(mq.queueWaiters.size).toEqual(0);
|
|
} finally {
|
|
await db.close();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|