UNPKG

@proca/queue

Version:

This package provides a **robust RabbitMQ consumer** for processing Proca **action** and **event** messages with strict retry, dead-letter, and crash semantics.

234 lines (201 loc) 6.79 kB
import { decryptPersonalInfo } from '@proca/crypto'; import { AsyncMessage, Connection, ConsumerStatus, Consumer, } from 'rabbitmq-client'; export { ActionMessageV2 as ActionMessage, ActionMessageV2, ProcessStage, } from './actionMessage'; import { ConsumerOpts, SyncCallback, Counters } from './types'; import os from 'os'; let connection: Connection | null = null; let consumer: Consumer | null = null; const listeners: any = []; // functions to be called when a new connection is created export const listenConnection = (fct: any) => listeners.push(fct); export const count: Counters = { queued: undefined, ack: 0, nack: 0 }; async function exitHandler( evtOrExitCodeOrError: number | string | Error ): Promise<void> { try { if (connection) { console.log( 'closing after processing', count.ack, 'and rejecting', count.nack ); await consumer?.close(); await connection?.close(); } console.log('closed, exit now'); process.exit(isNaN(+evtOrExitCodeOrError) ? 0 : +evtOrExitCodeOrError); } catch (e) { console.error('EXIT HANDLER ERROR', e); process.exit(isNaN(+evtOrExitCodeOrError) ? 1 : +evtOrExitCodeOrError); } } export const connect = (queueUrl: string) => { const rabbit = new Connection(queueUrl); connection = rabbit; // global rabbit.on('error', (err) => { console.log('RabbitMQ connection error', err); }); rabbit.on('connection', () => { console.log( `Connection successfully (re)established, ${consumer?.stats?.initialMessageCount} messages in the queue` ); listeners.forEach((d: any) => d(connection)); }); // Connect() might be called multiple times //so guard against duplicate listeners if (!process.listenerCount('SIGINT')) { process.once('SIGINT', exitHandler); } ['uncaughtException', 'unhandledRejection', 'SIGTERM'].forEach((evt) => { // duplicates guard if (!process.listenerCount(evt)) { process.on(evt, exitHandler); } }); return rabbit as any; }; const requeueOnceOrDrop = ( message: AsyncMessage, reason: string = 'unknown reason' ): ConsumerStatus => { console.error(reason); count.nack++; if (message.redelivered) { console.error('already requeued, push to dead-letter'); return ConsumerStatus.DROP; } return ConsumerStatus.REQUEUE; }; export const isPositiveInt = (value: number, name = 'value') => { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer (> 0), got ${value}`); } return value; }; export const syncQueue = async ( queueUrl: string, queueName: string, syncer: SyncCallback, opts?: ConsumerOpts ): Promise<{ close: () => Promise<void> }> => { // might be an overkill but want to be sure that invalid options not cause unexpected behavior const concurrency = opts?.concurrency ? isPositiveInt(opts.concurrency, 'concurrency') : isPositiveInt(1, 'concurrency'); const prefetch = opts?.prefetch ? isPositiveInt(opts.prefetch, 'prefetch') : isPositiveInt(2 * concurrency, 'prefetch'); const maxRetries = opts?.maxRetries ? isPositiveInt(opts.maxRetries, 'maxRetries') : null; const rabbit = await connect(queueUrl); // get host name const tag = os.hostname() + '.' + (opts?.tag ? opts.tag : process.env.npm_package_name); const sub = rabbit.createConsumer( { queue: queueName, requeue: false, noAck: false, queueOptions: { passive: true }, concurrency, consumerTag: tag, qos: { prefetchCount: prefetch }, }, async (message: AsyncMessage) => { // If this function throws an error, then message is NACK'd (rejected) and // possibly requeued or sent to a dead-letter exchange if (maxRetries) { const deaths = message.headers?.['x-death']?.[0]?.count ?? 0; if (deaths > maxRetries) { console.error( `retry limit exceeded (${deaths} > ${maxRetries}) — ACK and drop` ); count.ack++; return ConsumerStatus.ACK; } } let msg: any; try { msg = JSON.parse(message.body.toString()); } catch { return requeueOnceOrDrop( message, `invalid JSON payload, cannot parse ${message.body .toString() .slice(0, 512)}` ); } // if the message is unknown, drop and log but do not crash if (msg?.schema !== 'proca:action:2' && msg?.schema !== 'proca:event:2') { return requeueOnceOrDrop( message, `unknown schema "${msg?.schema || JSON.stringify(msg).slice(0, 512)}"` ); } if (msg.schema === 'proca:action:2') { // make it easier to process by moving the id to their objects if (msg.campaign) msg.campaign.id = msg.campaignId; if (msg.action) msg.action.id = msg.actionId; if (msg.org) msg.org.id = msg.orgId; if (msg.actionPage) msg.actionPage.id = msg.actionPageId; // optional decrypt if (msg.personalInfo && opts?.keyStore) { const plainPII = decryptPersonalInfo(msg.personalInfo, opts.keyStore); msg.contact = { ...msg.contact, ...plainPII }; } } if (msg.schema === 'proca:event:2') { if (msg.campaign) msg.campaign.id = msg.campaignId; if (msg.action) msg.action.id = msg.actionId; if (msg.actionPage) msg.actionPage.id = msg.actionPageId; } try { // we expect the syncer to return boolean. // - return true → ACK // - return false → NACK / requeue // - throw or return non-boolean → process exits immediately const result = await syncer(msg); if (result === true) { count.ack++; return ConsumerStatus.ACK; } if (result === false) { return requeueOnceOrDrop( message, `syncer returned false for message ${msg?.actionId ?? JSON.stringify(msg).slice(0, 512)}` ); } throw new Error( `syncer must return boolean, got: ${JSON.stringify(result)}` ); } catch (e) { // if the syncer throw an error it's a permanent problem, we need to close console.error('fatal error processing:', e); await exitHandler(e instanceof Error ? e : String(e)); throw e; } } ); sub.on('error', (err: any) => { // Maybe the consumer was cancelled, or the connection was reset before a // message could be acknowledged. console.log('rabbit error', err); }); consumer = sub; // global return { close: async () => { await sub.close(); await rabbit.close(); }, }; };