@rentbase/common 1.0.25 → 1.0.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/events/consumer.js +111 -3
- package/build/events/event-utils.d.ts +7 -0
- package/build/events/event-utils.js +13 -0
- package/build/events/inbox.d.ts +8 -0
- package/build/events/inbox.js +47 -0
- package/build/events/outbox.d.ts +34 -0
- package/build/events/outbox.js +187 -0
- package/build/events/producer.d.ts +2 -0
- package/build/events/producer.js +99 -16
- package/build/index.d.ts +3 -0
- package/build/index.js +3 -0
- package/package.json +1 -1
package/build/events/consumer.js
CHANGED
|
@@ -14,7 +14,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
14
14
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
15
|
exports.connectConsumer = void 0;
|
|
16
16
|
const amqplib_1 = __importDefault(require("amqplib"));
|
|
17
|
+
const inbox_1 = require("./inbox");
|
|
17
18
|
const DEFAULT_EXCHANGE = process.env.RABBITMQ_EXCHANGE || "rentbase.events";
|
|
19
|
+
const getNumberEnv = (key, fallback) => {
|
|
20
|
+
const raw = process.env[key];
|
|
21
|
+
if (!raw)
|
|
22
|
+
return fallback;
|
|
23
|
+
const n = Number(raw);
|
|
24
|
+
return Number.isFinite(n) ? n : fallback;
|
|
25
|
+
};
|
|
26
|
+
const getBoolEnv = (key, fallback) => {
|
|
27
|
+
const raw = process.env[key];
|
|
28
|
+
if (!raw)
|
|
29
|
+
return fallback;
|
|
30
|
+
return raw === "true" || raw === "1";
|
|
31
|
+
};
|
|
18
32
|
const connectConsumer = (queue, rabbitUrl, onMessage) => __awaiter(void 0, void 0, void 0, function* () {
|
|
19
33
|
if (!rabbitUrl) {
|
|
20
34
|
throw new Error("RabbitMQ URL is required for connectConsumer()");
|
|
@@ -22,28 +36,122 @@ const connectConsumer = (queue, rabbitUrl, onMessage) => __awaiter(void 0, void
|
|
|
22
36
|
const routingKey = String(queue);
|
|
23
37
|
const serviceName = process.env.SERVICE_NAME;
|
|
24
38
|
const queueName = serviceName ? `${serviceName}.${routingKey}` : routingKey;
|
|
39
|
+
// Optional: bounded retries + dead-letter “parking lot”.
|
|
40
|
+
// - If RABBITMQ_MAX_RETRIES >= 0, failures will be republished with an incremented `x-retry-count`.
|
|
41
|
+
// - Once max retries is reached, the message is published to DLX and ACKed (so it won't loop forever).
|
|
42
|
+
// - This does NOT require changing existing queue arguments (safe rollout).
|
|
43
|
+
const maxRetries = getNumberEnv("RABBITMQ_MAX_RETRIES", -1);
|
|
44
|
+
const enableDlq = getBoolEnv("RABBITMQ_ENABLE_DLQ", maxRetries >= 0);
|
|
45
|
+
const dlxExchange = process.env.RABBITMQ_DLX || `${DEFAULT_EXCHANGE}.dlx`;
|
|
46
|
+
const dlqName = `${queueName}.dlq`;
|
|
47
|
+
// Default behavior matches the old implementation: transient errors requeue.
|
|
48
|
+
// If you enable DLQ + bounded retries, requeue is handled by republish.
|
|
49
|
+
const requeueOnError = getBoolEnv("RABBITMQ_REQUEUE_ON_ERROR", true);
|
|
25
50
|
const connection = yield amqplib_1.default.connect(rabbitUrl);
|
|
26
51
|
const channel = yield connection.createChannel();
|
|
27
52
|
// Topic exchange enables pub/sub (each service has its own queue).
|
|
28
53
|
yield channel.assertExchange(DEFAULT_EXCHANGE, "topic", { durable: true });
|
|
54
|
+
if (enableDlq) {
|
|
55
|
+
yield channel.assertExchange(dlxExchange, "topic", { durable: true });
|
|
56
|
+
yield channel.assertQueue(dlqName, { durable: true });
|
|
57
|
+
yield channel.bindQueue(dlqName, dlxExchange, routingKey);
|
|
58
|
+
}
|
|
29
59
|
// Durable queue allows the service to catch up after being down.
|
|
30
60
|
yield channel.assertQueue(queueName, { durable: true });
|
|
31
61
|
yield channel.bindQueue(queueName, DEFAULT_EXCHANGE, routingKey);
|
|
32
62
|
// Process messages one at a time per consumer to avoid overwhelming downstream DBs.
|
|
33
63
|
yield channel.prefetch(1);
|
|
64
|
+
const enableInbox = getBoolEnv("RABBITMQ_ENABLE_INBOX", false);
|
|
65
|
+
const inboxModelName = process.env.RABBITMQ_INBOX_MODEL || "RentbaseInboxEvent";
|
|
66
|
+
let InboxModel;
|
|
67
|
+
let inboxWarned = false;
|
|
68
|
+
const getInbox = () => {
|
|
69
|
+
if (!enableInbox)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (InboxModel)
|
|
72
|
+
return InboxModel;
|
|
73
|
+
try {
|
|
74
|
+
InboxModel = (0, inbox_1.getInboxModel)(inboxModelName);
|
|
75
|
+
return InboxModel;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (!inboxWarned) {
|
|
79
|
+
inboxWarned = true;
|
|
80
|
+
console.warn("[Consumer] Inbox dedup enabled but mongoose is unavailable; continuing without dedup.");
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
34
85
|
channel.consume(queueName, (msg) => __awaiter(void 0, void 0, void 0, function* () {
|
|
35
86
|
if (!msg)
|
|
36
87
|
return;
|
|
37
88
|
try {
|
|
89
|
+
const headers = (msg.properties.headers || {});
|
|
90
|
+
const eventId = msg.properties.messageId ||
|
|
91
|
+
headers["x-event-id"];
|
|
92
|
+
const Inbox = getInbox();
|
|
93
|
+
if (Inbox && eventId) {
|
|
94
|
+
const existing = yield Inbox.findById(eventId).select({ _id: 1 }).lean();
|
|
95
|
+
if (existing) {
|
|
96
|
+
channel.ack(msg);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
38
100
|
const data = JSON.parse(msg.content.toString());
|
|
39
101
|
yield onMessage(data);
|
|
102
|
+
// Mark processed only after handler succeeds.
|
|
103
|
+
if (Inbox && eventId) {
|
|
104
|
+
try {
|
|
105
|
+
yield Inbox.create({
|
|
106
|
+
_id: eventId,
|
|
107
|
+
routingKey,
|
|
108
|
+
exchange: DEFAULT_EXCHANGE,
|
|
109
|
+
processedAt: new Date(),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// Duplicate is fine (another consumer instance processed already).
|
|
114
|
+
if (!(err && (err.code === 11000 || err.code === 11001))) {
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
40
119
|
channel.ack(msg);
|
|
41
120
|
}
|
|
42
121
|
catch (error) {
|
|
43
122
|
console.error(`[Consumer] Error processing ${routingKey}:`, error);
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
123
|
+
// If bounded retries are enabled, republish with incremented retry count.
|
|
124
|
+
if (maxRetries >= 0) {
|
|
125
|
+
const headers = (msg.properties.headers || {});
|
|
126
|
+
const currentRetry = Number(headers["x-retry-count"] || 0) || 0;
|
|
127
|
+
if (currentRetry < maxRetries) {
|
|
128
|
+
const nextHeaders = Object.assign(Object.assign({}, headers), { "x-retry-count": currentRetry + 1, "x-original-routing-key": headers["x-original-routing-key"] || routingKey });
|
|
129
|
+
channel.publish(DEFAULT_EXCHANGE, routingKey, msg.content, {
|
|
130
|
+
persistent: true,
|
|
131
|
+
contentType: msg.properties.contentType || "application/json",
|
|
132
|
+
messageId: msg.properties.messageId,
|
|
133
|
+
correlationId: msg.properties.correlationId,
|
|
134
|
+
headers: nextHeaders,
|
|
135
|
+
});
|
|
136
|
+
channel.ack(msg);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (enableDlq) {
|
|
140
|
+
const nextHeaders = Object.assign(Object.assign({}, (msg.properties.headers || {})), { "x-retry-count": currentRetry, "x-final-failure": true, "x-original-routing-key": headers["x-original-routing-key"] || routingKey });
|
|
141
|
+
channel.publish(dlxExchange, routingKey, msg.content, {
|
|
142
|
+
persistent: true,
|
|
143
|
+
contentType: msg.properties.contentType || "application/json",
|
|
144
|
+
messageId: msg.properties.messageId,
|
|
145
|
+
correlationId: msg.properties.correlationId,
|
|
146
|
+
headers: nextHeaders,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// ACK to stop poison loops. If DLQ is disabled, this drops the message.
|
|
150
|
+
channel.ack(msg);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Default behavior: requeue so transient failures don't drop events.
|
|
154
|
+
channel.nack(msg, false, requeueOnError);
|
|
47
155
|
}
|
|
48
156
|
}));
|
|
49
157
|
console.log(`[Consumer] Listening on ${queueName} (exchange=${DEFAULT_EXCHANGE}, key=${routingKey})`);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.generateEventId = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const generateEventId = () => {
|
|
6
|
+
const b = (0, crypto_1.randomBytes)(16);
|
|
7
|
+
// RFC 4122 v4
|
|
8
|
+
b[6] = (b[6] & 0x0f) | 0x40;
|
|
9
|
+
b[8] = (b[8] & 0x3f) | 0x80;
|
|
10
|
+
const hex = b.toString("hex");
|
|
11
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
12
|
+
};
|
|
13
|
+
exports.generateEventId = generateEventId;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const getInboxModel: (modelName?: string) => any;
|
|
2
|
+
export declare const hasProcessedEvent: (eventId: string, opts?: {
|
|
3
|
+
modelName?: string;
|
|
4
|
+
}) => Promise<boolean>;
|
|
5
|
+
export declare const markEventProcessed: (eventId: string, routingKey: string, opts?: {
|
|
6
|
+
modelName?: string;
|
|
7
|
+
exchange?: string;
|
|
8
|
+
}) => Promise<void>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.markEventProcessed = exports.hasProcessedEvent = exports.getInboxModel = void 0;
|
|
13
|
+
const getMongoose = () => {
|
|
14
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
15
|
+
return require("mongoose");
|
|
16
|
+
};
|
|
17
|
+
const getInboxModel = (modelName = "RentbaseInboxEvent") => {
|
|
18
|
+
var _a;
|
|
19
|
+
const mongoose = getMongoose();
|
|
20
|
+
if ((_a = mongoose.models) === null || _a === void 0 ? void 0 : _a[modelName])
|
|
21
|
+
return mongoose.models[modelName];
|
|
22
|
+
const schema = new mongoose.Schema({
|
|
23
|
+
_id: { type: String, required: true },
|
|
24
|
+
routingKey: { type: String, required: true, index: true },
|
|
25
|
+
exchange: { type: String, required: false, index: true },
|
|
26
|
+
processedAt: { type: Date, required: true, index: true },
|
|
27
|
+
}, { timestamps: true, versionKey: false });
|
|
28
|
+
return mongoose.model(modelName, schema);
|
|
29
|
+
};
|
|
30
|
+
exports.getInboxModel = getInboxModel;
|
|
31
|
+
const hasProcessedEvent = (eventId, opts = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
32
|
+
const Model = (0, exports.getInboxModel)(opts.modelName);
|
|
33
|
+
const existing = yield Model.findById(eventId).select({ _id: 1 }).lean();
|
|
34
|
+
return !!existing;
|
|
35
|
+
});
|
|
36
|
+
exports.hasProcessedEvent = hasProcessedEvent;
|
|
37
|
+
const markEventProcessed = (eventId, routingKey, opts = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
38
|
+
const Model = (0, exports.getInboxModel)(opts.modelName);
|
|
39
|
+
const doc = {
|
|
40
|
+
_id: eventId,
|
|
41
|
+
routingKey,
|
|
42
|
+
exchange: opts.exchange,
|
|
43
|
+
processedAt: new Date(),
|
|
44
|
+
};
|
|
45
|
+
yield Model.create(doc);
|
|
46
|
+
});
|
|
47
|
+
exports.markEventProcessed = markEventProcessed;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { EventMap } from "./types";
|
|
2
|
+
import { EventPublishMeta } from "./event-utils";
|
|
3
|
+
export declare type OutboxRecord = {
|
|
4
|
+
_id: string;
|
|
5
|
+
routingKey: string;
|
|
6
|
+
exchange: string;
|
|
7
|
+
payload: any;
|
|
8
|
+
headers: Record<string, any>;
|
|
9
|
+
occurredAt: Date;
|
|
10
|
+
status: "pending" | "publishing" | "published" | "failed";
|
|
11
|
+
attempts: number;
|
|
12
|
+
lastError?: string;
|
|
13
|
+
lockedUntil?: Date;
|
|
14
|
+
nextAttemptAt?: Date;
|
|
15
|
+
publishedAt?: Date;
|
|
16
|
+
createdAt: Date;
|
|
17
|
+
updatedAt: Date;
|
|
18
|
+
};
|
|
19
|
+
export declare const getOutboxModel: (modelName?: string) => any;
|
|
20
|
+
export declare const enqueueOutboxEvent: <T extends keyof EventMap>(routingKey: T, payload: EventMap[T], meta?: EventPublishMeta & {
|
|
21
|
+
exchange?: string;
|
|
22
|
+
modelName?: string;
|
|
23
|
+
session?: any;
|
|
24
|
+
}) => Promise<string>;
|
|
25
|
+
export declare const startOutboxDispatcher: (rabbitUrl: string, opts?: {
|
|
26
|
+
modelName?: string;
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
batchSize?: number;
|
|
29
|
+
lockMs?: number;
|
|
30
|
+
maxAttempts?: number;
|
|
31
|
+
logger?: Pick<Console, "log" | "error" | "warn">;
|
|
32
|
+
}) => {
|
|
33
|
+
stop: () => void;
|
|
34
|
+
};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.startOutboxDispatcher = exports.enqueueOutboxEvent = exports.getOutboxModel = void 0;
|
|
13
|
+
const producer_1 = require("./producer");
|
|
14
|
+
const event_utils_1 = require("./event-utils");
|
|
15
|
+
const getMongoose = () => {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
17
|
+
return require("mongoose");
|
|
18
|
+
};
|
|
19
|
+
const now = () => new Date();
|
|
20
|
+
const getOutboxModel = (modelName = "RentbaseOutboxEvent") => {
|
|
21
|
+
var _a;
|
|
22
|
+
const mongoose = getMongoose();
|
|
23
|
+
if ((_a = mongoose.models) === null || _a === void 0 ? void 0 : _a[modelName])
|
|
24
|
+
return mongoose.models[modelName];
|
|
25
|
+
const schema = new mongoose.Schema({
|
|
26
|
+
_id: { type: String, required: true },
|
|
27
|
+
routingKey: { type: String, required: true, index: true },
|
|
28
|
+
exchange: { type: String, required: true, index: true },
|
|
29
|
+
payload: { type: mongoose.Schema.Types.Mixed, required: true },
|
|
30
|
+
headers: { type: mongoose.Schema.Types.Mixed, default: {} },
|
|
31
|
+
occurredAt: { type: Date, required: true, index: true },
|
|
32
|
+
status: {
|
|
33
|
+
type: String,
|
|
34
|
+
required: true,
|
|
35
|
+
index: true,
|
|
36
|
+
enum: ["pending", "publishing", "published", "failed"],
|
|
37
|
+
},
|
|
38
|
+
attempts: { type: Number, default: 0 },
|
|
39
|
+
lastError: { type: String },
|
|
40
|
+
lockedUntil: { type: Date, index: true },
|
|
41
|
+
nextAttemptAt: { type: Date, index: true },
|
|
42
|
+
publishedAt: { type: Date, index: true },
|
|
43
|
+
}, { timestamps: true, versionKey: false });
|
|
44
|
+
schema.index({ status: 1, nextAttemptAt: 1, lockedUntil: 1, updatedAt: 1 });
|
|
45
|
+
return mongoose.model(modelName, schema);
|
|
46
|
+
};
|
|
47
|
+
exports.getOutboxModel = getOutboxModel;
|
|
48
|
+
const enqueueOutboxEvent = (routingKey, payload, meta = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
49
|
+
const exchange = meta.exchange || process.env.RABBITMQ_EXCHANGE || "rentbase.events";
|
|
50
|
+
const eventId = meta.eventId || (0, event_utils_1.generateEventId)();
|
|
51
|
+
const occurredAt = meta.occurredAt || now();
|
|
52
|
+
const sourceService = meta.sourceService || process.env.SERVICE_NAME || process.env.npm_package_name;
|
|
53
|
+
const headers = Object.assign(Object.assign(Object.assign({}, (meta.headers || {})), { "x-event-id": eventId, "x-occurred-at": occurredAt.toISOString() }), (sourceService ? { "x-source-service": sourceService } : {}));
|
|
54
|
+
const Model = (0, exports.getOutboxModel)(meta.modelName);
|
|
55
|
+
const doc = {
|
|
56
|
+
_id: eventId,
|
|
57
|
+
routingKey: String(routingKey),
|
|
58
|
+
exchange,
|
|
59
|
+
payload,
|
|
60
|
+
headers,
|
|
61
|
+
occurredAt,
|
|
62
|
+
status: "pending",
|
|
63
|
+
attempts: 0,
|
|
64
|
+
};
|
|
65
|
+
yield Model.create([doc], meta.session ? { session: meta.session } : undefined);
|
|
66
|
+
return eventId;
|
|
67
|
+
});
|
|
68
|
+
exports.enqueueOutboxEvent = enqueueOutboxEvent;
|
|
69
|
+
const isDuplicateKeyError = (err) => err && (err.code === 11000 || err.code === 11001);
|
|
70
|
+
const startOutboxDispatcher = (rabbitUrl, opts = {}) => {
|
|
71
|
+
var _a, _b, _c, _d, _e;
|
|
72
|
+
const pollIntervalMs = (_a = opts.pollIntervalMs) !== null && _a !== void 0 ? _a : 1000;
|
|
73
|
+
const batchSize = (_b = opts.batchSize) !== null && _b !== void 0 ? _b : 50;
|
|
74
|
+
const lockMs = (_c = opts.lockMs) !== null && _c !== void 0 ? _c : 30000;
|
|
75
|
+
const maxAttempts = (_d = opts.maxAttempts) !== null && _d !== void 0 ? _d : 50;
|
|
76
|
+
const logger = (_e = opts.logger) !== null && _e !== void 0 ? _e : console;
|
|
77
|
+
const exchange = process.env.RABBITMQ_EXCHANGE || "rentbase.events";
|
|
78
|
+
const Model = (0, exports.getOutboxModel)(opts.modelName);
|
|
79
|
+
let stopped = false;
|
|
80
|
+
let timer;
|
|
81
|
+
const workerId = (0, event_utils_1.generateEventId)();
|
|
82
|
+
const computeBackoffMs = (attempts) => {
|
|
83
|
+
// 1s, 2s, 4s... capped at 60s
|
|
84
|
+
const ms = 1000 * Math.pow(2, Math.min(10, Math.max(0, attempts)));
|
|
85
|
+
return Math.min(ms, 60000);
|
|
86
|
+
};
|
|
87
|
+
const tick = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
88
|
+
var _f;
|
|
89
|
+
if (stopped)
|
|
90
|
+
return;
|
|
91
|
+
const claimed = [];
|
|
92
|
+
const claimUntil = new Date(Date.now() + lockMs);
|
|
93
|
+
const nowTs = now();
|
|
94
|
+
for (let i = 0; i < batchSize; i++) {
|
|
95
|
+
const doc = yield Model.findOneAndUpdate({
|
|
96
|
+
status: { $in: ["pending", "failed"] },
|
|
97
|
+
$and: [
|
|
98
|
+
{
|
|
99
|
+
$or: [
|
|
100
|
+
{ lockedUntil: { $exists: false } },
|
|
101
|
+
{ lockedUntil: { $lte: nowTs } },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
$or: [
|
|
106
|
+
{ nextAttemptAt: { $exists: false } },
|
|
107
|
+
{ nextAttemptAt: { $lte: nowTs } },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
attempts: { $lt: maxAttempts },
|
|
112
|
+
}, {
|
|
113
|
+
$set: {
|
|
114
|
+
status: "publishing",
|
|
115
|
+
lockedUntil: claimUntil,
|
|
116
|
+
lastError: undefined,
|
|
117
|
+
"headers.x-outbox-worker": workerId,
|
|
118
|
+
},
|
|
119
|
+
$inc: { attempts: 1 },
|
|
120
|
+
}, { sort: { updatedAt: 1 }, new: true });
|
|
121
|
+
if (!doc)
|
|
122
|
+
break;
|
|
123
|
+
claimed.push(doc);
|
|
124
|
+
}
|
|
125
|
+
for (const doc of claimed) {
|
|
126
|
+
try {
|
|
127
|
+
yield (0, producer_1.publishEventWithMeta)(doc.routingKey, doc.payload, {
|
|
128
|
+
eventId: doc._id,
|
|
129
|
+
occurredAt: doc.occurredAt,
|
|
130
|
+
sourceService: (_f = doc.headers) === null || _f === void 0 ? void 0 : _f["x-source-service"],
|
|
131
|
+
headers: doc.headers,
|
|
132
|
+
});
|
|
133
|
+
yield Model.updateOne({ _id: doc._id }, {
|
|
134
|
+
$set: {
|
|
135
|
+
status: "published",
|
|
136
|
+
publishedAt: now(),
|
|
137
|
+
lockedUntil: undefined,
|
|
138
|
+
nextAttemptAt: undefined,
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
const backoff = computeBackoffMs(Number(doc.attempts || 0));
|
|
144
|
+
const nextAttemptAt = new Date(Date.now() + backoff);
|
|
145
|
+
logger.error(`[Outbox] Failed to publish ${doc.routingKey} (${doc._id}). Retrying in ${backoff}ms`, (err === null || err === void 0 ? void 0 : err.message) || err);
|
|
146
|
+
yield Model.updateOne({ _id: doc._id }, {
|
|
147
|
+
$set: {
|
|
148
|
+
status: "failed",
|
|
149
|
+
lastError: String((err === null || err === void 0 ? void 0 : err.message) || err),
|
|
150
|
+
lockedUntil: undefined,
|
|
151
|
+
nextAttemptAt,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const start = () => {
|
|
158
|
+
if (timer)
|
|
159
|
+
return;
|
|
160
|
+
logger.log(`[Outbox] Dispatcher started (interval=${pollIntervalMs}ms, exchange=${exchange})`);
|
|
161
|
+
timer = setInterval(() => {
|
|
162
|
+
void tick();
|
|
163
|
+
}, pollIntervalMs);
|
|
164
|
+
};
|
|
165
|
+
const stop = () => {
|
|
166
|
+
stopped = true;
|
|
167
|
+
if (timer)
|
|
168
|
+
clearInterval(timer);
|
|
169
|
+
timer = undefined;
|
|
170
|
+
};
|
|
171
|
+
// Ensure producer is connected before we start dispatching.
|
|
172
|
+
void (() => __awaiter(void 0, void 0, void 0, function* () {
|
|
173
|
+
try {
|
|
174
|
+
// no-op; kept for signature compatibility (we require rabbitUrl so callers don't forget)
|
|
175
|
+
if (!rabbitUrl)
|
|
176
|
+
throw new Error("rabbitUrl is required");
|
|
177
|
+
start();
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (!isDuplicateKeyError(err)) {
|
|
181
|
+
logger.error("[Outbox] Dispatcher failed to start", (err === null || err === void 0 ? void 0 : err.message) || err);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}))();
|
|
185
|
+
return { stop };
|
|
186
|
+
};
|
|
187
|
+
exports.startOutboxDispatcher = startOutboxDispatcher;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { EventMap } from "./types";
|
|
2
|
+
import { EventPublishMeta } from "./event-utils";
|
|
2
3
|
export declare const connectProducer: (rabbitUrl: string, retries?: number, delay?: number) => Promise<void>;
|
|
4
|
+
export declare const publishEventWithMeta: <T extends keyof EventMap>(queue: string | T, message: any, meta?: EventPublishMeta) => Promise<void>;
|
|
3
5
|
export declare const publishEvent: <T extends keyof EventMap>(queue: T, message: EventMap[T]) => Promise<void>;
|
package/build/events/producer.js
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
|
5
|
+
}) : (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
o[k2] = m[k];
|
|
8
|
+
}));
|
|
9
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
10
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
11
|
+
}) : function(o, v) {
|
|
12
|
+
o["default"] = v;
|
|
13
|
+
});
|
|
14
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
15
|
+
if (mod && mod.__esModule) return mod;
|
|
16
|
+
var result = {};
|
|
17
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
18
|
+
__setModuleDefault(result, mod);
|
|
19
|
+
return result;
|
|
20
|
+
};
|
|
2
21
|
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
22
|
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
23
|
return new (P || (P = Promise))(function (resolve, reject) {
|
|
@@ -8,51 +27,115 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
27
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
28
|
});
|
|
10
29
|
};
|
|
11
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
-
};
|
|
14
30
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
-
exports.publishEvent = exports.connectProducer = void 0;
|
|
16
|
-
const
|
|
31
|
+
exports.publishEvent = exports.publishEventWithMeta = exports.connectProducer = void 0;
|
|
32
|
+
const amqp = __importStar(require("amqplib"));
|
|
33
|
+
const event_utils_1 = require("./event-utils");
|
|
34
|
+
const outbox_1 = require("./outbox");
|
|
35
|
+
let rabbitConnection;
|
|
17
36
|
let channel;
|
|
18
37
|
const DEFAULT_EXCHANGE = process.env.RABBITMQ_EXCHANGE || "rentbase.events";
|
|
38
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
39
|
+
const publishWithConfirm = (exchange, routingKey, payload, options) => {
|
|
40
|
+
if (!channel) {
|
|
41
|
+
return Promise.reject(new Error("RabbitMQ channel not initialized. Call connectProducer() first."));
|
|
42
|
+
}
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
channel.publish(exchange, routingKey, payload, options, (err) => {
|
|
45
|
+
if (err)
|
|
46
|
+
return reject(err);
|
|
47
|
+
resolve();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const sendToQueueWithConfirm = (queueName, payload, options) => {
|
|
52
|
+
if (!channel) {
|
|
53
|
+
return Promise.reject(new Error("RabbitMQ channel not initialized. Call connectProducer() first."));
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
channel.sendToQueue(queueName, payload, options, (err) => {
|
|
57
|
+
if (err)
|
|
58
|
+
return reject(err);
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
};
|
|
19
63
|
const connectProducer = (rabbitUrl, retries = 5, delay = 3000) => __awaiter(void 0, void 0, void 0, function* () {
|
|
20
64
|
try {
|
|
21
|
-
const connection = yield
|
|
22
|
-
|
|
65
|
+
const connection = yield amqp.connect(rabbitUrl);
|
|
66
|
+
rabbitConnection = connection;
|
|
67
|
+
connection.on("error", (err) => {
|
|
68
|
+
console.error("[RabbitMQ Producer ❌] connection error:", err);
|
|
69
|
+
});
|
|
70
|
+
connection.on("close", () => {
|
|
71
|
+
console.error("[RabbitMQ Producer ❌] connection closed");
|
|
72
|
+
rabbitConnection = undefined;
|
|
73
|
+
channel = undefined;
|
|
74
|
+
});
|
|
75
|
+
channel = yield connection.createConfirmChannel();
|
|
23
76
|
yield channel.assertExchange(DEFAULT_EXCHANGE, "topic", { durable: true });
|
|
24
|
-
|
|
77
|
+
if (process.env.RABBITMQ_OUTBOX_AUTO_DISPATCH === "true") {
|
|
78
|
+
try {
|
|
79
|
+
(0, outbox_1.startOutboxDispatcher)(rabbitUrl);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
console.error("[Outbox] Failed to start dispatcher", (err === null || err === void 0 ? void 0 : err.message) || err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.log("[RabbitMQ Producer ✅] Connected successfully (confirm channel)");
|
|
25
86
|
}
|
|
26
87
|
catch (err) {
|
|
27
|
-
console.error(`[RabbitMQ Producer ❌] Connection failed. Retries left: ${retries}`, err.message);
|
|
88
|
+
console.error(`[RabbitMQ Producer ❌] Connection failed. Retries left: ${retries}`, (err === null || err === void 0 ? void 0 : err.message) || err);
|
|
28
89
|
if (retries > 0) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
else {
|
|
32
|
-
console.error("[RabbitMQ Producer 💥] Exhausted all retries. RabbitMQ unavailable.");
|
|
90
|
+
yield sleep(delay);
|
|
91
|
+
return (0, exports.connectProducer)(rabbitUrl, retries - 1, delay);
|
|
33
92
|
}
|
|
93
|
+
console.error("[RabbitMQ Producer 💥] Exhausted all retries. RabbitMQ unavailable.");
|
|
34
94
|
}
|
|
35
95
|
});
|
|
36
96
|
exports.connectProducer = connectProducer;
|
|
37
|
-
const
|
|
97
|
+
const publishEventWithMeta = (queue, message, meta = {}) => __awaiter(void 0, void 0, void 0, function* () {
|
|
38
98
|
if (!channel)
|
|
39
99
|
throw new Error("RabbitMQ channel not initialized. Call connectProducer() first.");
|
|
40
100
|
// Topic exchange enables pub/sub (each service can bind its own durable queue).
|
|
41
101
|
yield channel.assertExchange(DEFAULT_EXCHANGE, "topic", { durable: true });
|
|
42
102
|
const payload = Buffer.from(JSON.stringify(message));
|
|
43
103
|
const routingKey = String(queue);
|
|
44
|
-
|
|
104
|
+
const eventId = meta.eventId || (0, event_utils_1.generateEventId)();
|
|
105
|
+
const occurredAt = meta.occurredAt || new Date();
|
|
106
|
+
const sourceService = meta.sourceService || process.env.SERVICE_NAME || process.env.npm_package_name;
|
|
107
|
+
const headers = Object.assign(Object.assign(Object.assign({}, (meta.headers || {})), { "x-event-id": eventId, "x-occurred-at": occurredAt.toISOString() }), (sourceService ? { "x-source-service": sourceService } : {}));
|
|
108
|
+
yield publishWithConfirm(DEFAULT_EXCHANGE, routingKey, payload, {
|
|
45
109
|
persistent: true,
|
|
46
110
|
contentType: "application/json",
|
|
111
|
+
messageId: eventId,
|
|
112
|
+
headers,
|
|
47
113
|
});
|
|
48
114
|
// Optional legacy mode for old consumers that still read directly from a shared queue.
|
|
49
115
|
if (process.env.RABBITMQ_LEGACY_QUEUE === "true") {
|
|
50
116
|
yield channel.assertQueue(routingKey, { durable: true });
|
|
51
|
-
|
|
117
|
+
yield sendToQueueWithConfirm(routingKey, payload, {
|
|
52
118
|
persistent: true,
|
|
53
119
|
contentType: "application/json",
|
|
120
|
+
messageId: eventId,
|
|
121
|
+
headers,
|
|
54
122
|
});
|
|
55
123
|
}
|
|
56
124
|
console.log(`[Producer ➡️] Published ${routingKey} on ${DEFAULT_EXCHANGE}:`, message);
|
|
57
125
|
});
|
|
126
|
+
exports.publishEventWithMeta = publishEventWithMeta;
|
|
127
|
+
const publishEvent = (queue, message) => __awaiter(void 0, void 0, void 0, function* () {
|
|
128
|
+
// Optional Outbox mode: persist first, then let dispatcher publish.
|
|
129
|
+
if (process.env.RABBITMQ_USE_OUTBOX === "true") {
|
|
130
|
+
try {
|
|
131
|
+
const eventId = yield (0, outbox_1.enqueueOutboxEvent)(queue, message);
|
|
132
|
+
console.log(`[Producer ➕Outbox] Enqueued ${String(queue)} (${eventId})`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.warn("[Producer ➕Outbox] Enqueue failed; falling back to direct publish:", (err === null || err === void 0 ? void 0 : err.message) || err);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
yield (0, exports.publishEventWithMeta)(queue, message);
|
|
140
|
+
});
|
|
58
141
|
exports.publishEvent = publishEvent;
|
package/build/index.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export * from "./middlewares/require-auth";
|
|
|
11
11
|
export * from "./middlewares/validate-request";
|
|
12
12
|
export * from "./events/consumer";
|
|
13
13
|
export * from "./events/producer";
|
|
14
|
+
export * from "./events/event-utils";
|
|
15
|
+
export * from "./events/outbox";
|
|
16
|
+
export * from "./events/inbox";
|
|
14
17
|
export * from "./events/types/request-events";
|
|
15
18
|
export * from "./events/types/user-events";
|
|
16
19
|
export * from "./events/types/property-events";
|
package/build/index.js
CHANGED
|
@@ -24,6 +24,9 @@ __exportStar(require("./middlewares/require-auth"), exports);
|
|
|
24
24
|
__exportStar(require("./middlewares/validate-request"), exports);
|
|
25
25
|
__exportStar(require("./events/consumer"), exports);
|
|
26
26
|
__exportStar(require("./events/producer"), exports);
|
|
27
|
+
__exportStar(require("./events/event-utils"), exports);
|
|
28
|
+
__exportStar(require("./events/outbox"), exports);
|
|
29
|
+
__exportStar(require("./events/inbox"), exports);
|
|
27
30
|
__exportStar(require("./events/types/request-events"), exports);
|
|
28
31
|
__exportStar(require("./events/types/user-events"), exports);
|
|
29
32
|
__exportStar(require("./events/types/property-events"), exports);
|