@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
JavaScript
// 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
};