@kne/fastify-mq 0.1.0

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.
@@ -0,0 +1,216 @@
1
+ const fp = require('fastify-plugin');
2
+ const { createPgAdapter, createKafkaAdapter } = require('../utils/adapter');
3
+ const { metrics, getValuesMap, sampler } = require('../utils/metrics');
4
+
5
+ module.exports = fp(async (fastify, options) => {
6
+ const { models } = fastify[options.name];
7
+ const sequelize = fastify.sequelize.instance;
8
+
9
+ // 根据配置创建适配器
10
+ let adapter;
11
+ if (options.adapter === 'kafka') {
12
+ adapter = createKafkaAdapter({
13
+ kafka: options.kafka,
14
+ topicPrefix: options.topicPrefix,
15
+ metrics
16
+ });
17
+ } else {
18
+ adapter = createPgAdapter({
19
+ models,
20
+ sequelize,
21
+ generateId: () => fastify.sequelize.generateId(),
22
+ metrics,
23
+ defaultMaxRetries: options.defaultMaxRetries,
24
+ retryBaseDelay: options.retryBaseDelay,
25
+ retryMaxDelay: options.retryMaxDelay,
26
+ lockTimeout: options.lockTimeout,
27
+ pollLimit: options.pollLimit
28
+ });
29
+ }
30
+
31
+ // 存储适配器引用供 index.js onClose 使用
32
+ options._adapter = adapter;
33
+
34
+ // === 消费者逻辑 ===
35
+
36
+ const subscribe = (topic, handler) => {
37
+ if (!options._subscribers[topic]) {
38
+ options._subscribers[topic] = [];
39
+ }
40
+ options._subscribers[topic].push(handler);
41
+ };
42
+
43
+ const startConsumer = () => {
44
+ if (options._consumerTimer) return;
45
+ options._consumerRunning = false;
46
+
47
+ // Kafka 适配器使用自身的消费者机制
48
+ if (adapter.adapterType === 'kafka') {
49
+ const topics = Object.keys(options._subscribers);
50
+ for (const topic of topics) {
51
+ const handlers = options._subscribers[topic] || [];
52
+ adapter.subscribe({
53
+ topic,
54
+ handler: async data => {
55
+ for (const handler of handlers) {
56
+ await handler(data);
57
+ }
58
+ },
59
+ groupId: options.kafkaGroupId
60
+ });
61
+ }
62
+ return;
63
+ }
64
+
65
+ // PG 适配器使用轮询机制
66
+ const runOnce = async () => {
67
+ if (options._consumerRunning) return;
68
+ options._consumerRunning = true;
69
+ let resolveRun;
70
+ options._consumerRunningPromise = new Promise(r => {
71
+ resolveRun = r;
72
+ });
73
+
74
+ try {
75
+ const topics = Object.keys(options._subscribers);
76
+ if (topics.length === 0) return;
77
+
78
+ // 各 topic 并行消费
79
+ await Promise.all(
80
+ topics.map(async topic => {
81
+ try {
82
+ const messages = await adapter.poll({ topic });
83
+ for (const msg of messages) {
84
+ const handlers = options._subscribers[topic] || [];
85
+ // 所有 handler 必须全部成功才 complete,否则 fail
86
+ let allSucceeded = true;
87
+ let lastError = null;
88
+ for (const handler of handlers) {
89
+ try {
90
+ await handler(msg);
91
+ } catch (e) {
92
+ allSucceeded = false;
93
+ lastError = e;
94
+ console.error(`[fastify-mq] Consumer handler error for topic ${topic}, message ${msg.id}:`, e.message);
95
+ }
96
+ }
97
+ if (allSucceeded) {
98
+ await adapter.complete({ id: msg.id });
99
+ } else {
100
+ await adapter.fail({ id: msg.id, error: lastError?.message || 'Handler error' });
101
+ }
102
+ }
103
+ } catch (e) {
104
+ console.error(`[fastify-mq] Consumer poll error for topic ${topic}:`, e.message);
105
+ }
106
+ })
107
+ );
108
+ } finally {
109
+ options._consumerRunning = false;
110
+ resolveRun();
111
+ }
112
+ };
113
+
114
+ options._consumerTimer = setInterval(runOnce, options.pollInterval);
115
+ };
116
+
117
+ const stopConsumer = async () => {
118
+ if (options._consumerTimer) {
119
+ clearInterval(options._consumerTimer);
120
+ options._consumerTimer = null;
121
+ }
122
+ // 等待当前消费周期完成
123
+ if (options._consumerRunningPromise) {
124
+ await options._consumerRunningPromise;
125
+ }
126
+ options._consumerRunning = false;
127
+ };
128
+
129
+ // === Dashboard 逻辑 ===
130
+
131
+ const getDashboardData = async ({ windowMs = 300000, stepMs = 60000 } = {}) => {
132
+ const queueDepthValues = getValuesMap(metrics.queueDepth);
133
+ const consumedValues = getValuesMap(metrics.consumedTotal);
134
+ const failedValues = getValuesMap(metrics.failedTotal);
135
+ const dlqValues = getValuesMap(metrics.dlqTotal);
136
+
137
+ const consumeRate = sampler.getRate('mq_consumed_total', windowMs);
138
+ const failureRate = sampler.getRate('mq_failed_total', windowMs);
139
+ const dlqRate = sampler.getRate('mq_dlq_total', windowMs);
140
+
141
+ const totalConsumeRate = Object.values(consumeRate).reduce((a, b) => a + b, 0);
142
+ const totalFailureRate = Object.values(failureRate).reduce((a, b) => a + b, 0);
143
+ const successRatio = totalConsumeRate + totalFailureRate > 0 ? totalConsumeRate / (totalConsumeRate + totalFailureRate) : null;
144
+
145
+ const successRatioByTopic = {};
146
+ const allTopics = new Set([...Object.keys(consumeRate), ...Object.keys(failureRate)]);
147
+ for (const topic of allTopics) {
148
+ const cr = consumeRate[topic] || 0;
149
+ const fr = failureRate[topic] || 0;
150
+ successRatioByTopic[topic] = cr + fr > 0 ? cr / (cr + fr) : null;
151
+ }
152
+
153
+ const totalConsumed = Object.values(consumedValues).reduce((a, b) => a + b, 0);
154
+ const totalFailed = Object.values(failedValues).reduce((a, b) => a + b, 0);
155
+ const totalDlq = Object.values(dlqValues).reduce((a, b) => a + b, 0);
156
+ const totalQueueDepth = Object.values(queueDepthValues).reduce((a, b) => a + b, 0);
157
+
158
+ const queueDepthTimeSeries = sampler.getTimeSeries('mq_queue_depth', { windowMs, stepMs });
159
+ const consumeRateTimeSeries = sampler.getTimeSeries('mq_consumed_total', { windowMs, stepMs, rate: true });
160
+ const failureRateTimeSeries = sampler.getTimeSeries('mq_failed_total', { windowMs, stepMs, rate: true });
161
+ const dlqRateTimeSeries = sampler.getTimeSeries('mq_dlq_total', { windowMs, stepMs, rate: true });
162
+
163
+ return {
164
+ timestamp: Date.now(),
165
+ current: {
166
+ queueDepth: { byTopic: queueDepthValues, total: totalQueueDepth },
167
+ consumedTotal: { byTopic: consumedValues, total: totalConsumed },
168
+ failedTotal: { byTopic: failedValues, total: totalFailed },
169
+ dlqTotal: { byTopic: dlqValues, total: totalDlq },
170
+ consumeRate: { byTopic: consumeRate, total: totalConsumeRate },
171
+ failureRate: { byTopic: failureRate, total: totalFailureRate },
172
+ dlqRate: { byTopic: dlqRate, total: Object.values(dlqRate).reduce((a, b) => a + b, 0) },
173
+ successRatio,
174
+ successRatioByTopic
175
+ },
176
+ timeSeries: {
177
+ queueDepth: queueDepthTimeSeries,
178
+ consumeRate: consumeRateTimeSeries,
179
+ failureRate: failureRateTimeSeries,
180
+ dlqRate: dlqRateTimeSeries
181
+ }
182
+ };
183
+ };
184
+
185
+ // === 注册服务 ===
186
+
187
+ Object.assign(fastify[options.name].services, {
188
+ message: {
189
+ publish: params => adapter.publish(params),
190
+ poll: params => adapter.poll(params),
191
+ complete: params => adapter.complete(params),
192
+ fail: params => adapter.fail(params),
193
+ list: params => adapter.listMessages(params)
194
+ },
195
+ deadLetter: {
196
+ replay: params => adapter.replayDeadLetter(params),
197
+ batchReplay: params => adapter.batchReplayDeadLetters(params),
198
+ list: params => adapter.listDeadLetters(params)
199
+ },
200
+ trace: {
201
+ get: params => adapter.getTrace(params),
202
+ list: params => adapter.listTraces(params)
203
+ },
204
+ queue: {
205
+ getDepth: params => adapter.getQueueDepth(params),
206
+ recoverLocked: () => adapter.recoverLocked(),
207
+ cleanup: params => adapter.cleanupMessages(params),
208
+ subscribe,
209
+ startConsumer,
210
+ stopConsumer
211
+ },
212
+ dashboard: {
213
+ getData: getDashboardData
214
+ }
215
+ });
216
+ });
@@ -0,0 +1,400 @@
1
+ const crypto = require('crypto');
2
+ const { MESSAGE_STATUS, TRACE_EVENTS } = require('../constants');
3
+
4
+ /**
5
+ * PG 适配器 - 基于 PostgreSQL/Sequelize 的消息队列实现
6
+ * 用于需要从 PG 迁移到 Kafka 的场景,提供统一接口
7
+ */
8
+ const createPgAdapter = options => {
9
+ const { models, sequelize, generateId, metrics, defaultMaxRetries = 3, retryBaseDelay = 1000, retryMaxDelay = 60000, lockTimeout = 30000, pollLimit = 10 } = options;
10
+ const { Op } = sequelize.constructor;
11
+ const instance = sequelize;
12
+
13
+ const CONSUMER_ID = `${process.pid || 'unknown'}_${Date.now()}`;
14
+
15
+ const addTrace = async (traceId, topic, event, detail, messageId) => {
16
+ await models.messageTrace.create({
17
+ traceId,
18
+ topic,
19
+ event,
20
+ detail: detail || null,
21
+ messageId: messageId || null
22
+ });
23
+ };
24
+
25
+ return {
26
+ adapterType: 'pg',
27
+
28
+ async publish({ topic, payload, priority, executeAt, maxRetries, traceId, meta }) {
29
+ const id = generateId();
30
+ const resolvedTraceId = traceId || crypto.randomUUID();
31
+ const message = await models.message.create({
32
+ id,
33
+ topic,
34
+ payload,
35
+ status: MESSAGE_STATUS.PENDING,
36
+ priority: priority || 0,
37
+ executeAt: executeAt || null,
38
+ maxRetries: maxRetries !== undefined ? maxRetries : defaultMaxRetries,
39
+ traceId: resolvedTraceId,
40
+ options: meta || {}
41
+ });
42
+ await addTrace(resolvedTraceId, topic, TRACE_EVENTS.PUBLISHED, { priority, executeAt }, id);
43
+ return message;
44
+ },
45
+
46
+ async poll({ topic, limit }) {
47
+ const now = new Date();
48
+ return instance.transaction(
49
+ {
50
+ isolationLevel: sequelize.constructor.Transaction.ISOLATION_LEVELS.READ_COMMITTED
51
+ },
52
+ async t => {
53
+ const where = {
54
+ topic,
55
+ status: MESSAGE_STATUS.PENDING,
56
+ [Op.and]: [{ [Op.or]: [{ executeAt: null }, { executeAt: { [Op.lte]: now } }] }, { [Op.or]: [{ nextRetryAt: null }, { nextRetryAt: { [Op.lte]: now } }] }]
57
+ };
58
+ const query = {
59
+ where,
60
+ order: [
61
+ ['priority', 'DESC'],
62
+ ['createdAt', 'ASC']
63
+ ],
64
+ limit: limit || pollLimit,
65
+ transaction: t
66
+ };
67
+ if (t.LOCK && t.LOCK.UPDATE) {
68
+ query.lock = t.LOCK.UPDATE;
69
+ }
70
+ const rows = await models.message.findAll(query);
71
+
72
+ if (rows.length === 0) return [];
73
+
74
+ const ids = rows.map(msg => msg.id);
75
+ await models.message.update({ status: MESSAGE_STATUS.PROCESSING, consumerId: CONSUMER_ID, lockedAt: new Date() }, { where: { id: { [Op.in]: ids } }, transaction: t });
76
+
77
+ const traces = rows.map(msg => ({
78
+ traceId: msg.traceId,
79
+ topic: msg.topic,
80
+ event: TRACE_EVENTS.PROCESSING,
81
+ detail: { consumerId: CONSUMER_ID },
82
+ messageId: msg.id
83
+ }));
84
+ await models.messageTrace.bulkCreate(traces, { transaction: t });
85
+
86
+ return rows.map(msg => {
87
+ msg.status = MESSAGE_STATUS.PROCESSING;
88
+ msg.consumerId = CONSUMER_ID;
89
+ msg.lockedAt = new Date();
90
+ return msg;
91
+ });
92
+ }
93
+ );
94
+ },
95
+
96
+ async complete({ id }) {
97
+ const message = await models.message.findByPk(id);
98
+ if (!message) {
99
+ const err = new Error('消息不存在');
100
+ err.statusCode = 404;
101
+ throw err;
102
+ }
103
+ if (message.status !== MESSAGE_STATUS.PROCESSING) {
104
+ const err = new Error(`消息状态不正确,当前: ${message.status},期望: ${MESSAGE_STATUS.PROCESSING}`);
105
+ err.statusCode = 409;
106
+ throw err;
107
+ }
108
+ await message.update({ status: MESSAGE_STATUS.COMPLETED, consumerId: null, lockedAt: null });
109
+ await addTrace(message.traceId, message.topic, TRACE_EVENTS.COMPLETED, null, message.id);
110
+ if (metrics) metrics.consumedTotal.inc({ topic: message.topic });
111
+ return message;
112
+ },
113
+
114
+ async fail({ id, error }) {
115
+ const message = await models.message.findByPk(id);
116
+ if (!message) {
117
+ const err = new Error('消息不存在');
118
+ err.statusCode = 404;
119
+ throw err;
120
+ }
121
+ if (message.status !== MESSAGE_STATUS.PROCESSING) {
122
+ const err = new Error(`消息状态不正确,当前: ${message.status},期望: ${MESSAGE_STATUS.PROCESSING}`);
123
+ err.statusCode = 409;
124
+ throw err;
125
+ }
126
+
127
+ const newRetryCount = message.retryCount + 1;
128
+ const shouldRetry = newRetryCount < message.maxRetries;
129
+
130
+ if (shouldRetry) {
131
+ const delay = Math.min(retryBaseDelay * Math.pow(2, newRetryCount - 1), retryMaxDelay);
132
+ const nextRetryAt = new Date(Date.now() + delay);
133
+ await message.update({ status: MESSAGE_STATUS.PENDING, retryCount: newRetryCount, nextRetryAt, consumerId: null, lockedAt: null });
134
+ await addTrace(message.traceId, message.topic, TRACE_EVENTS.FAILED, { retryCount: newRetryCount, error: error || 'Unknown error', nextRetryAt }, message.id);
135
+ if (metrics) metrics.failedTotal.inc({ topic: message.topic });
136
+ } else {
137
+ await instance.transaction(async t => {
138
+ await models.deadLetter.create({ originalId: message.id, topic: message.topic, payload: message.payload, errorMessage: error || 'Max retries exceeded' }, { transaction: t });
139
+ await message.update({ status: MESSAGE_STATUS.FAILED, consumerId: null, lockedAt: null }, { transaction: t });
140
+ });
141
+ await addTrace(message.traceId, message.topic, TRACE_EVENTS.MOVED_TO_DLQ, { error: error || 'Max retries exceeded', retryCount: newRetryCount }, message.id);
142
+ if (metrics) {
143
+ metrics.failedTotal.inc({ topic: message.topic });
144
+ metrics.dlqTotal.inc({ topic: message.topic });
145
+ }
146
+ }
147
+ return message;
148
+ },
149
+
150
+ async replayDeadLetter({ id }) {
151
+ const deadLetter = await models.deadLetter.findByPk(id);
152
+ if (!deadLetter) {
153
+ const err = new Error('死信不存在');
154
+ err.statusCode = 404;
155
+ throw err;
156
+ }
157
+ if (deadLetter.replayed) {
158
+ const err = new Error('该死信已被重放');
159
+ err.statusCode = 400;
160
+ throw err;
161
+ }
162
+
163
+ const message = await instance.transaction(async t => {
164
+ const msg = await models.message.create(
165
+ {
166
+ topic: deadLetter.topic,
167
+ payload: deadLetter.payload,
168
+ status: MESSAGE_STATUS.PENDING,
169
+ traceId: crypto.randomUUID()
170
+ },
171
+ { transaction: t }
172
+ );
173
+ await deadLetter.update({ replayed: true, replayedAt: new Date() }, { transaction: t });
174
+ return msg;
175
+ });
176
+
177
+ await addTrace(message.traceId, deadLetter.topic, TRACE_EVENTS.REPLAYED, { originalId: deadLetter.originalId, deadLetterId: deadLetter.id }, message.id);
178
+ return message;
179
+ },
180
+
181
+ async batchReplayDeadLetters({ ids }) {
182
+ const results = [];
183
+ for (const id of ids) {
184
+ try {
185
+ const message = await this.replayDeadLetter({ id });
186
+ results.push({ id, success: true, messageId: message.id });
187
+ } catch (e) {
188
+ results.push({ id, success: false, error: e.message || 'Unknown error' });
189
+ }
190
+ }
191
+ return results;
192
+ },
193
+
194
+ async listDeadLetters({ filter = {}, perPage = 20, currentPage = 1 }) {
195
+ const where = {};
196
+ if (filter.topic) where.topic = filter.topic;
197
+ if (filter.replayed !== undefined) where.replayed = filter.replayed;
198
+
199
+ const { rows, count } = await models.deadLetter.findAndCountAll({
200
+ where,
201
+ offset: perPage * (currentPage - 1),
202
+ limit: perPage,
203
+ order: [['createdAt', 'DESC']]
204
+ });
205
+
206
+ return { pageData: rows, totalCount: count };
207
+ },
208
+
209
+ async listMessages({ filter = {}, perPage = 20, currentPage = 1 }) {
210
+ const where = {};
211
+ if (filter.topic) where.topic = filter.topic;
212
+ if (filter.status) where.status = filter.status;
213
+ if (filter.traceId) where.traceId = filter.traceId;
214
+
215
+ const { rows, count } = await models.message.findAndCountAll({
216
+ where,
217
+ offset: perPage * (currentPage - 1),
218
+ limit: perPage,
219
+ order: [['createdAt', 'DESC']]
220
+ });
221
+
222
+ return { pageData: rows, totalCount: count };
223
+ },
224
+
225
+ async getTrace({ traceId }) {
226
+ return models.messageTrace.findAll({ where: { traceId }, order: [['createdAt', 'ASC']] });
227
+ },
228
+
229
+ async listTraces({ filter = {}, perPage = 20, currentPage = 1 }) {
230
+ const where = {};
231
+ if (filter.topic) where.topic = filter.topic;
232
+ if (filter.messageId) where.messageId = filter.messageId;
233
+ if (filter.event) where.event = filter.event;
234
+
235
+ const { rows, count } = await models.messageTrace.findAndCountAll({
236
+ where,
237
+ offset: perPage * (currentPage - 1),
238
+ limit: perPage,
239
+ order: [['createdAt', 'DESC']]
240
+ });
241
+
242
+ return { pageData: rows, totalCount: count };
243
+ },
244
+
245
+ async getQueueDepth({ topic }) {
246
+ const where = { status: MESSAGE_STATUS.PENDING };
247
+ if (topic) where.topic = topic;
248
+ const count = await models.message.count({ where });
249
+ if (metrics) metrics.queueDepth.set({ topic: topic || '_all' }, count);
250
+ return { depth: count };
251
+ },
252
+
253
+ async recoverLocked() {
254
+ const cutoff = new Date(Date.now() - lockTimeout);
255
+ const lockedMessages = await models.message.findAll({
256
+ where: {
257
+ status: MESSAGE_STATUS.PROCESSING,
258
+ lockedAt: { [Op.lte]: cutoff }
259
+ }
260
+ });
261
+
262
+ if (lockedMessages.length === 0) return { recovered: 0 };
263
+
264
+ const ids = lockedMessages.map(m => m.id);
265
+ await models.message.update(
266
+ {
267
+ status: MESSAGE_STATUS.PENDING,
268
+ consumerId: null,
269
+ lockedAt: null
270
+ },
271
+ {
272
+ where: { id: { [Op.in]: ids } }
273
+ }
274
+ );
275
+
276
+ await models.messageTrace.bulkCreate(
277
+ lockedMessages.map(m => ({
278
+ traceId: m.traceId,
279
+ topic: m.topic,
280
+ event: TRACE_EVENTS.LOCK_RECOVERED,
281
+ detail: { previousConsumerId: m.consumerId, lockedAt: m.lockedAt },
282
+ messageId: m.id
283
+ }))
284
+ );
285
+
286
+ return { recovered: ids.length };
287
+ },
288
+
289
+ async cleanupMessages({ status = MESSAGE_STATUS.COMPLETED, olderThan = null } = {}) {
290
+ const where = { status };
291
+ if (olderThan) {
292
+ where.updatedAt = { [Op.lt]: new Date(olderThan) };
293
+ }
294
+ const count = await models.message.destroy({ where });
295
+ return { deleted: count };
296
+ }
297
+ };
298
+ };
299
+
300
+ /**
301
+ * Kafka 适配器 - 基于 kafkajs 的消息队列实现
302
+ * 注意:Kafka 模式下不支持 DLQ 重放、轨迹查询、队列深度和锁定恢复
303
+ */
304
+ const createKafkaAdapter = options => {
305
+ const { kafka, topicPrefix, metrics } = options;
306
+ const producer = kafka.producer();
307
+ const consumers = {};
308
+ let connected = false;
309
+
310
+ const ensureConnected = async () => {
311
+ if (!connected) {
312
+ await producer.connect();
313
+ connected = true;
314
+ }
315
+ };
316
+
317
+ const notSupported = method => () => {
318
+ throw new Error(`Kafka adapter does not support ${method}`);
319
+ };
320
+
321
+ return {
322
+ adapterType: 'kafka',
323
+
324
+ async publish({ topic, payload, priority, traceId, meta }) {
325
+ await ensureConnected();
326
+ const resolvedTraceId = traceId || crypto.randomUUID();
327
+ await producer.send({
328
+ topic: `${topicPrefix || ''}${topic}`,
329
+ messages: [
330
+ {
331
+ key: resolvedTraceId,
332
+ value: JSON.stringify({ payload, traceId: resolvedTraceId, priority: priority || 0, meta: meta || {} }),
333
+ headers: priority ? { priority: String(priority) } : {}
334
+ }
335
+ ]
336
+ });
337
+ return { topic, traceId: resolvedTraceId, status: TRACE_EVENTS.PUBLISHED };
338
+ },
339
+
340
+ async poll() {
341
+ return [];
342
+ },
343
+
344
+ async subscribe({ topic, handler, groupId }) {
345
+ await ensureConnected();
346
+ const consumer = kafka.consumer({ groupId: groupId || `${topic}-group` });
347
+ await consumer.connect();
348
+ await consumer.subscribe({ topic: `${topicPrefix || ''}${topic}`, fromBeginning: false });
349
+ await consumer.run({
350
+ eachMessage: async ({ message }) => {
351
+ try {
352
+ const data = JSON.parse(message.value.toString());
353
+ await handler(data);
354
+ if (metrics) metrics.consumedTotal.inc({ topic });
355
+ } catch (e) {
356
+ if (metrics) metrics.failedTotal.inc({ topic });
357
+ console.error(`[fastify-mq] Kafka consumer error for topic ${topic}:`, e.message);
358
+ }
359
+ }
360
+ });
361
+ consumers[topic] = consumer;
362
+ },
363
+
364
+ async complete() {
365
+ return {};
366
+ },
367
+ async fail() {
368
+ return {};
369
+ },
370
+ replayDeadLetter: notSupported('DLQ replay'),
371
+ batchReplayDeadLetters: notSupported('batch DLQ replay'),
372
+ listDeadLetters: notSupported('DLQ listing'),
373
+ listMessages: notSupported('message listing'),
374
+ getTrace: notSupported('trace query'),
375
+ listTraces: notSupported('trace listing'),
376
+ getQueueDepth: notSupported('queue depth'),
377
+ recoverLocked: notSupported('lock recovery'),
378
+ cleanupMessages: notSupported('message cleanup'),
379
+
380
+ async disconnect() {
381
+ for (const [topic, consumer] of Object.entries(consumers)) {
382
+ try {
383
+ await consumer.disconnect();
384
+ } catch (e) {
385
+ console.error(`[fastify-mq] Kafka consumer disconnect error for topic ${topic}:`, e.message);
386
+ }
387
+ }
388
+ if (connected) {
389
+ try {
390
+ await producer.disconnect();
391
+ } catch (e) {
392
+ console.error('[fastify-mq] Kafka producer disconnect error:', e.message);
393
+ }
394
+ connected = false;
395
+ }
396
+ }
397
+ };
398
+ };
399
+
400
+ module.exports = { createPgAdapter, createKafkaAdapter };