import type { KV, KvKey, MQMessage, MQStats, MQSubscribeOptions, } from "../../plug-api/types.ts"; import type { DataStore } from "./datastore.ts"; import { parseExpressionString } from "$common/space_lua/parse.ts"; import { LuaEnv } from "$common/space_lua/runtime.ts"; export type ProcessingMessage = MQMessage & { ts: number; }; const queuedPrefix = ["mq", "queued"]; const processingPrefix = ["mq", "processing"]; const dlqPrefix = ["mq", "dlq"]; export class DataStoreMQ { // queue -> set of run() functions localSubscriptions = new Map void>>(); constructor( private ds: DataStore, ) { } // Internal sequencer for messages, only really necessary when batch sending tons of messages within a millisecond seq = 0; async batchSend(queue: string, bodies: any[]): Promise { if (bodies.length === 0) { return; } const messages: KV[] = bodies.map((body) => { const id = `${Date.now()}-${String(++this.seq).padStart(6, "0")}`; const key = [...queuedPrefix, queue, id]; return { key, value: { id, queue, body }, }; }); await this.ds.batchSet(messages); // See if we can immediately process the message with a local subscription const localSubscriptions = this.localSubscriptions.get(queue); if (localSubscriptions) { for (const run of localSubscriptions) { run(); } } } send(queue: string, body: any): Promise { return this.batchSend(queue, [body]); } async poll(queue: string, maxItems: number): Promise { // Note: this is not happening in a transactional way, so we may get duplicate message delivery // Retrieve a batch of messages const messages = await this.ds.luaQuery( [...queuedPrefix, queue], { limit: maxItems, }, ); if (messages.length === 0) { return []; } // Put them in the processing queue await this.ds.batchSet( messages.map((m) => ({ key: [...processingPrefix, queue, m.id], value: { ...m, ts: Date.now(), }, })), ); // Delete them from the queued queue await this.ds.batchDelete( messages.map((m) => [...queuedPrefix, queue, m.id]), ); // Return them return messages; } /** * @param queue * @param batchSize * @param callback * @returns a function to be called to unsubscribe */ subscribe( queue: string, options: MQSubscribeOptions, callback: (messages: MQMessage[]) => Promise | void, ): () => void { let running = true; let timeout: number | undefined; const batchSize = options.batchSize || 1; const run = async () => { try { // We're running, so let's make sure we're not running multiple times if (timeout) { clearTimeout(timeout); } if (!running) { return; } const messages = await this.poll(queue, batchSize); if (messages.length > 0) { await callback(messages); } // If we got exactly the batch size, there might be more messages if (messages.length === batchSize) { await run(); } timeout = setTimeout(run, options.pollInterval || 5000); } catch (e: any) { console.error("Error in MQ subscription handler", e); } }; // Register as a local subscription handler const localSubscriptions = this.localSubscriptions.get(queue); if (!localSubscriptions) { this.localSubscriptions.set(queue, new Set([run])); } else { localSubscriptions.add(run); } // Run the first time (which will schedule subsequent polling intervals) run(); // And return an unsubscribe function return () => { running = false; if (timeout) { clearTimeout(timeout); } // Remove the subscription from localSubscriptions const queueSubscriptions = this.localSubscriptions.get(queue); if (queueSubscriptions) { queueSubscriptions.delete(run); } }; } ack(queue: string, id: string) { return this.batchAck(queue, [id]); } async batchAck(queue: string, ids: string[]) { if (ids.length === 0) { return; } await this.ds.batchDelete( ids.map((id) => [...processingPrefix, queue, id]), ); } async requeueTimeouts( timeout: number, maxRetries?: number, disableDLQ?: boolean, ) { const now = Date.now(); const env = new LuaEnv(); env.setLocal("ts", now - timeout); const messages = await this.ds.luaQuery( processingPrefix, { objectVariable: "m", where: parseExpressionString("m.ts < ts"), }, env, ); if (messages.length === 0) { return; } await this.ds.batchDelete( messages.map((m) => [...processingPrefix, m.queue, m.id]), ); const newMessages: KV[] = []; for (const m of messages) { const retries = (m.retries || 0) + 1; if (maxRetries && retries > maxRetries) { if (disableDLQ) { console.warn( "[mq]", "Message exceeded max retries, flushing message", m, ); } else { console.warn( "[mq]", "Message exceeded max retries, moving to DLQ", m, ); newMessages.push({ key: [...dlqPrefix, m.queue, m.id], value: { queue: m.queue, id: m.id, body: m.body, ts: Date.now(), retries, }, }); } } else { console.info("[mq]", "Message ack timed out, requeueing", m); newMessages.push({ key: [...queuedPrefix, m.queue, m.id], value: { ...m, retries, }, }); } } await this.ds.batchSet(newMessages); } async fetchDLQMessages(): Promise { return (await this.ds.luaQuery(dlqPrefix, {})); } async fetchProcessingMessages(): Promise { return (await this.ds.luaQuery(processingPrefix, {})); } async flushDLQ(): Promise { const ids: KvKey[] = []; for (const item of await this.ds.luaQuery(dlqPrefix, {})) { ids.push([...dlqPrefix, item.queue, item.id]); } await this.ds.batchDelete(ids); } /** * Flushes a queue, including all queued, processing and DLQ messages * @param queue */ async flushQueue(queue: string): Promise { const ids: KvKey[] = []; for ( const item of await this.ds.luaQuery( [...queuedPrefix, queue], {}, ) ) { ids.push([...queuedPrefix, item.id]); } for ( const item of await this.ds.luaQuery([ ...processingPrefix, queue, ], {}) ) { ids.push([...processingPrefix, item.id]); } for ( const item of await this.ds.luaQuery([ ...dlqPrefix, queue, ], {}) ) { ids.push([...dlqPrefix, item.id]); } await this.ds.batchDelete(ids); } async getQueueStats(queue: string): Promise { const queued = (await (this.ds.luaQuery([...queuedPrefix, queue], {}))).length; const processing = (await (this.ds.luaQuery([...processingPrefix, queue], {}))).length; const dlq = (await (this.ds.luaQuery([...dlqPrefix, queue], {}))).length; return { queued, processing, dlq, }; } async getAllQueueStats(): Promise> { const allStatus: Record = {}; for ( const message of await this.ds.luaQuery( queuedPrefix, {}, ) ) { if (!allStatus[message.queue]) { allStatus[message.queue] = { queued: 0, processing: 0, dlq: 0, }; } allStatus[message.queue].queued++; } for ( const message of await this.ds.luaQuery( processingPrefix, {}, ) ) { if (!allStatus[message.queue]) { allStatus[message.queue] = { queued: 0, processing: 0, dlq: 0, }; } allStatus[message.queue].processing++; } for ( const message of await this.ds.luaQuery( dlqPrefix, {}, ) ) { if (!allStatus[message.queue]) { allStatus[message.queue] = { queued: 0, processing: 0, dlq: 0, }; } allStatus[message.queue].dlq++; } return allStatus; } }