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.

245 lines (241 loc) 7.45 kB
// src/queue.ts import { decryptPersonalInfo } from "@proca/crypto"; import { Connection, ConsumerStatus } from "rabbitmq-client"; // src/actionMessage.ts var actionMessageV1to2 = (a1) => { let pii = {}; let personalInfo = null; if (a1.contact.nonce && a1.contact.publicKey && a1.contact.signKey) { personalInfo = { payload: a1.contact.payload, nonce: a1.contact.nonce, encryptKey: { id: 0, // XXX no id info here! public: a1.contact.publicKey }, signKey: { id: 0, // XXX no id info here! public: a1.contact.signKey } }; } else { pii = JSON.parse(a1.contact.payload); } const a2 = { schema: "proca:action:2", stage: a1.stage, actionId: a1.actionId, actionPage: a1.actionPage, actionPageId: a1.actionPageId, campaign: a1.campaign, campaignId: a1.campaignId, orgId: 1, org: { name: "", title: "" }, action: { actionType: a1.action.actionType, createdAt: a1.action.createdAt, customFields: a1.action.fields, testing: false }, contact: { contactRef: a1.contact.ref, firstName: "", email: "", area: a1.contact.area, ...pii }, personalInfo, tracking: a1.tracking, privacy: a1.privacy && { withConsent: true, optIn: a1.privacy.communication, givenAt: a1.privacy.givenAt, emailStatus: null, emailStatusChanged: null } }; return a2; }; // src/queue.ts import os from "os"; var connection = null; var consumer = null; var listeners = []; var listenConnection = (fct) => listeners.push(fct); var count = { queued: void 0, ack: 0, nack: 0 }; async function exitHandler(evtOrExitCodeOrError) { try { if (connection) { console.log( "closing after processing", count.ack, "and rejecting", count.nack ); await (consumer == null ? void 0 : consumer.close()); await (connection == null ? void 0 : 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); } } var connect = (queueUrl) => { const rabbit = new Connection(queueUrl); connection = rabbit; rabbit.on("error", (err) => { console.log("RabbitMQ connection error", err); }); rabbit.on("connection", () => { var _a; console.log( `Connection successfully (re)established, ${(_a = consumer == null ? void 0 : consumer.stats) == null ? void 0 : _a.initialMessageCount} messages in the queue` ); listeners.forEach((d) => d(connection)); }); if (!process.listenerCount("SIGINT")) { process.once("SIGINT", exitHandler); } ["uncaughtException", "unhandledRejection", "SIGTERM"].forEach((evt) => { if (!process.listenerCount(evt)) { process.on(evt, exitHandler); } }); return rabbit; }; var requeueOnceOrDrop = (message, reason = "unknown reason") => { console.error(reason); count.nack++; if (message.redelivered) { console.error("already requeued, push to dead-letter"); return ConsumerStatus.DROP; } return ConsumerStatus.REQUEUE; }; var isPositiveInt = (value, name = "value") => { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer (> 0), got ${value}`); } return value; }; var syncQueue = async (queueUrl, queueName, syncer, opts) => { const concurrency = (opts == null ? void 0 : opts.concurrency) ? isPositiveInt(opts.concurrency, "concurrency") : isPositiveInt(1, "concurrency"); const prefetch = (opts == null ? void 0 : opts.prefetch) ? isPositiveInt(opts.prefetch, "prefetch") : isPositiveInt(2 * concurrency, "prefetch"); const maxRetries = (opts == null ? void 0 : opts.maxRetries) ? isPositiveInt(opts.maxRetries, "maxRetries") : null; const rabbit = await connect(queueUrl); const tag = os.hostname() + "." + ((opts == null ? void 0 : 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) => { var _a, _b, _c; if (maxRetries) { const deaths = ((_c = (_b = (_a = message.headers) == null ? void 0 : _a["x-death"]) == null ? void 0 : _b[0]) == null ? void 0 : _c.count) ?? 0; if (deaths > maxRetries) { console.error( `retry limit exceeded (${deaths} > ${maxRetries}) \u2014 ACK and drop` ); count.ack++; return ConsumerStatus.ACK; } } let msg; try { msg = JSON.parse(message.body.toString()); } catch { return requeueOnceOrDrop( message, `invalid JSON payload, cannot parse ${message.body.toString().slice(0, 512)}` ); } if ((msg == null ? void 0 : msg.schema) !== "proca:action:2" && (msg == null ? void 0 : msg.schema) !== "proca:event:2") { return requeueOnceOrDrop( message, `unknown schema "${(msg == null ? void 0 : msg.schema) || JSON.stringify(msg).slice(0, 512)}"` ); } if (msg.schema === "proca:action:2") { 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; if (msg.personalInfo && (opts == null ? void 0 : 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 { 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 == null ? void 0 : msg.actionId) ?? JSON.stringify(msg).slice(0, 512)}` ); } throw new Error( `syncer must return boolean, got: ${JSON.stringify(result)}` ); } catch (e) { console.error("fatal error processing:", e); await exitHandler(e instanceof Error ? e : String(e)); throw e; } } ); sub.on("error", (err) => { console.log("rabbit error", err); }); consumer = sub; return { close: async () => { await sub.close(); await rabbit.close(); } }; }; // src/utils.ts var pause = (time) => { const min = time && time > 2 ? time / 2 : 1; const max = time ? time * 2 : 2; time = Math.floor(Math.random() * (max - min + 1) + min) * 1e3; console.log("wait", time); return new Promise((resolve) => setTimeout(() => resolve(time), time)); }; export { actionMessageV1to2, connect, count, listenConnection, pause, syncQueue };