@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.
package/index.js ADDED
@@ -0,0 +1,126 @@
1
+ const fp = require('fastify-plugin');
2
+ const path = require('node:path');
3
+ const { registry, sampler } = require('./libs/utils/metrics');
4
+
5
+ module.exports = fp(async (fastify, options) => {
6
+ options = Object.assign(
7
+ {},
8
+ {
9
+ dbTableNamePrefix: 't_',
10
+ name: 'mq',
11
+ prefix: '/mq',
12
+ defaultMaxRetries: 3,
13
+ pollLimit: 10,
14
+ pollInterval: 5000,
15
+ lockTimeout: 30000,
16
+ lockRecoveryInterval: 30000,
17
+ retryBaseDelay: 1000,
18
+ retryMaxDelay: 60000,
19
+ metricsSampleInterval: 10000,
20
+ metricsMaxSamples: 360,
21
+ adapter: 'pg',
22
+ kafka: null,
23
+ topicPrefix: '',
24
+ kafkaGroupId: undefined,
25
+ getAuthenticate: type => {
26
+ switch (type) {
27
+ case 'dlq:manage':
28
+ case 'message':
29
+ case 'dlq':
30
+ case 'trace':
31
+ case 'dashboard':
32
+ default:
33
+ return [];
34
+ }
35
+ },
36
+ getMessageModel: () => {
37
+ return fastify[options.name]?.models?.message;
38
+ }
39
+ },
40
+ options
41
+ );
42
+
43
+ options._subscribers = {};
44
+ options._consumerTimer = null;
45
+ options._consumerRunning = false;
46
+ options._consumerRunningPromise = null;
47
+ options._lockRecoveryTimer = null;
48
+ options._adapter = null;
49
+
50
+ fastify.register(require('@kne/fastify-namespace'), {
51
+ options,
52
+ name: options.name,
53
+ modules: [
54
+ ['controllers', path.resolve(__dirname, './libs/controllers')],
55
+ [
56
+ 'models',
57
+ await fastify.sequelize.addModels(path.resolve(__dirname, './libs/models'), {
58
+ prefix: options.dbTableNamePrefix,
59
+ getMessageModel: options.getMessageModel
60
+ })
61
+ ],
62
+ ['services', path.resolve(__dirname, './libs/services')]
63
+ ]
64
+ });
65
+
66
+ // Metrics endpoint
67
+ if (registry) {
68
+ fastify.get(`${options.prefix}/metrics`, async (request, reply) => {
69
+ reply.type('text/plain');
70
+ return registry.metrics();
71
+ });
72
+ }
73
+
74
+ // Start sampler
75
+ if (sampler) {
76
+ sampler.start();
77
+ }
78
+
79
+ // 启动锁定恢复定时器
80
+ fastify.addHook('onReady', () => {
81
+ const { services } = fastify[options.name];
82
+ if (services?.queue?.recoverLocked) {
83
+ options._lockRecoveryTimer = setInterval(async () => {
84
+ try {
85
+ const result = await services.queue.recoverLocked();
86
+ if (result.recovered > 0) {
87
+ console.warn(`[fastify-mq] Recovered ${result.recovered} timed-out locked messages`);
88
+ }
89
+ } catch (e) {
90
+ console.error('[fastify-mq] Lock recovery error:', e.message);
91
+ }
92
+ }, options.lockRecoveryInterval);
93
+ options._lockRecoveryTimer.unref();
94
+ }
95
+ });
96
+
97
+ // 优雅关闭
98
+ fastify.addHook('onClose', async () => {
99
+ const { services } = fastify[options.name];
100
+
101
+ // 停止消费者(优雅等待当前消费周期完成)
102
+ if (services?.queue?.stopConsumer) {
103
+ await services.queue.stopConsumer();
104
+ } else {
105
+ if (options._consumerTimer) {
106
+ clearInterval(options._consumerTimer);
107
+ options._consumerTimer = null;
108
+ }
109
+ options._consumerRunning = false;
110
+ }
111
+
112
+ // 停止锁定恢复定时器
113
+ if (options._lockRecoveryTimer) {
114
+ clearInterval(options._lockRecoveryTimer);
115
+ options._lockRecoveryTimer = null;
116
+ }
117
+
118
+ // 停止采样器
119
+ if (sampler) sampler.stop();
120
+
121
+ // 断开适配器连接
122
+ if (options._adapter?.disconnect) {
123
+ await options._adapter.disconnect();
124
+ }
125
+ });
126
+ });
@@ -0,0 +1,18 @@
1
+ const MESSAGE_STATUS = {
2
+ PENDING: 'PENDING',
3
+ PROCESSING: 'PROCESSING',
4
+ COMPLETED: 'COMPLETED',
5
+ FAILED: 'FAILED'
6
+ };
7
+
8
+ const TRACE_EVENTS = {
9
+ PUBLISHED: 'PUBLISHED',
10
+ PROCESSING: 'PROCESSING',
11
+ COMPLETED: 'COMPLETED',
12
+ FAILED: 'FAILED',
13
+ MOVED_TO_DLQ: 'MOVED_TO_DLQ',
14
+ REPLAYED: 'REPLAYED',
15
+ LOCK_RECOVERED: 'LOCK_RECOVERED'
16
+ };
17
+
18
+ module.exports = { MESSAGE_STATUS, TRACE_EVENTS };
@@ -0,0 +1,28 @@
1
+ const fp = require('fastify-plugin');
2
+
3
+ module.exports = fp(async (fastify, options) => {
4
+ const { services } = fastify[options.name];
5
+
6
+ fastify.get(
7
+ `${options.prefix}/dashboard`,
8
+ {
9
+ onRequest: options.getAuthenticate('dashboard'),
10
+ schema: {
11
+ summary: 'Dashboard 数据接口',
12
+ query: {
13
+ type: 'object',
14
+ properties: {
15
+ window: { type: 'number', default: 300000, description: 'Rate计算窗口(ms), 默认5分钟' },
16
+ step: { type: 'number', default: 60000, description: '时序数据步长(ms), 默认1分钟' }
17
+ }
18
+ }
19
+ }
20
+ },
21
+ async request => {
22
+ return services.dashboard.getData({
23
+ windowMs: request.query.window || 300000,
24
+ stepMs: request.query.step || 60000
25
+ });
26
+ }
27
+ );
28
+ });
@@ -0,0 +1,143 @@
1
+ const fp = require('fastify-plugin');
2
+
3
+ module.exports = fp(async (fastify, options) => {
4
+ const { services } = fastify[options.name];
5
+
6
+ fastify.get(
7
+ `${options.prefix}/dlq/list`,
8
+ {
9
+ onRequest: options.getAuthenticate('dlq'),
10
+ schema: {
11
+ summary: '死信列表',
12
+ query: {
13
+ type: 'object',
14
+ properties: {
15
+ topic: { type: 'string', description: '按主题筛选' },
16
+ replayed: { type: 'boolean', description: '按重放状态筛选' },
17
+ perPage: { type: 'number', default: 20 },
18
+ currentPage: { type: 'number', default: 1 }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ async request => {
24
+ const { topic, replayed, perPage, currentPage } = request.query;
25
+ const filter = {};
26
+ if (topic) filter.topic = topic;
27
+ if (replayed !== undefined) filter.replayed = replayed;
28
+ return services.deadLetter.list({ filter, perPage, currentPage });
29
+ }
30
+ );
31
+
32
+ fastify.post(
33
+ `${options.prefix}/dlq/replay`,
34
+ {
35
+ onRequest: options.getAuthenticate('dlq:manage'),
36
+ schema: {
37
+ summary: '重放死信(批量)',
38
+ body: {
39
+ type: 'object',
40
+ properties: {
41
+ id: { type: 'string', description: '死信ID(单条重放)' },
42
+ ids: { type: 'array', items: { type: 'string' }, description: '死信ID数组(批量重放)' }
43
+ }
44
+ }
45
+ }
46
+ },
47
+ async request => {
48
+ const { id, ids } = request.body;
49
+ if (ids && ids.length > 0) {
50
+ return services.deadLetter.batchReplay({ ids });
51
+ }
52
+ return services.deadLetter.replay({ id });
53
+ }
54
+ );
55
+
56
+ fastify.post(
57
+ `${options.prefix}/dlq/replay/:id`,
58
+ {
59
+ onRequest: options.getAuthenticate('dlq:manage'),
60
+ schema: {
61
+ summary: '重放死信(单条)',
62
+ params: {
63
+ type: 'object',
64
+ required: ['id'],
65
+ properties: {
66
+ id: { type: 'string', description: '死信ID' }
67
+ }
68
+ }
69
+ }
70
+ },
71
+ async request => {
72
+ return services.deadLetter.replay({ id: request.params.id });
73
+ }
74
+ );
75
+
76
+ fastify.get(
77
+ `${options.prefix}/trace/detail`,
78
+ {
79
+ onRequest: options.getAuthenticate('trace'),
80
+ schema: {
81
+ summary: '查询消息轨迹(query参数)',
82
+ query: {
83
+ type: 'object',
84
+ required: ['traceId'],
85
+ properties: {
86
+ traceId: { type: 'string', description: '追踪ID' }
87
+ }
88
+ }
89
+ }
90
+ },
91
+ async request => {
92
+ return services.trace.get(request.query);
93
+ }
94
+ );
95
+
96
+ fastify.get(
97
+ `${options.prefix}/trace/:traceId`,
98
+ {
99
+ onRequest: options.getAuthenticate('trace'),
100
+ schema: {
101
+ summary: '查询消息轨迹(路径参数)',
102
+ params: {
103
+ type: 'object',
104
+ required: ['traceId'],
105
+ properties: {
106
+ traceId: { type: 'string', description: '追踪ID' }
107
+ }
108
+ }
109
+ }
110
+ },
111
+ async request => {
112
+ return services.trace.get({ traceId: request.params.traceId });
113
+ }
114
+ );
115
+
116
+ fastify.get(
117
+ `${options.prefix}/trace/list`,
118
+ {
119
+ onRequest: options.getAuthenticate('trace'),
120
+ schema: {
121
+ summary: '消息轨迹列表',
122
+ query: {
123
+ type: 'object',
124
+ properties: {
125
+ topic: { type: 'string', description: '按主题筛选' },
126
+ messageId: { type: 'string', description: '按消息ID筛选' },
127
+ event: { type: 'string', description: '按事件类型筛选' },
128
+ perPage: { type: 'number', default: 20 },
129
+ currentPage: { type: 'number', default: 1 }
130
+ }
131
+ }
132
+ }
133
+ },
134
+ async request => {
135
+ const { topic, messageId, event, perPage, currentPage } = request.query;
136
+ const filter = {};
137
+ if (topic) filter.topic = topic;
138
+ if (messageId) filter.messageId = messageId;
139
+ if (event) filter.event = event;
140
+ return services.trace.list({ filter, perPage, currentPage });
141
+ }
142
+ );
143
+ });
@@ -0,0 +1,161 @@
1
+ const fp = require('fastify-plugin');
2
+
3
+ module.exports = fp(async (fastify, options) => {
4
+ const { services } = fastify[options.name];
5
+
6
+ fastify.post(
7
+ `${options.prefix}/message/publish`,
8
+ {
9
+ onRequest: options.getAuthenticate('message'),
10
+ schema: {
11
+ summary: '发布消息',
12
+ body: {
13
+ type: 'object',
14
+ required: ['topic', 'payload'],
15
+ properties: {
16
+ topic: { type: 'string', description: '消息主题' },
17
+ payload: { type: 'object', description: '消息内容' },
18
+ priority: { type: 'number', default: 0, description: '优先级' },
19
+ executeAt: { type: 'string', format: 'date-time', description: '定时执行时间' },
20
+ maxRetries: { type: 'number', description: '最大重试次数' },
21
+ traceId: { type: 'string', description: '追踪ID' },
22
+ meta: { type: 'object', description: '扩展元数据' }
23
+ }
24
+ }
25
+ }
26
+ },
27
+ async request => {
28
+ return services.message.publish(request.body);
29
+ }
30
+ );
31
+
32
+ fastify.get(
33
+ `${options.prefix}/message/poll`,
34
+ {
35
+ onRequest: options.getAuthenticate('message'),
36
+ schema: {
37
+ summary: '拉取消息',
38
+ query: {
39
+ type: 'object',
40
+ required: ['topic'],
41
+ properties: {
42
+ topic: { type: 'string', description: '消息主题' },
43
+ limit: { type: 'number', default: 10, description: '拉取数量' },
44
+ lockTimeout: { type: 'number', description: '锁定超时时间(ms)' }
45
+ }
46
+ }
47
+ }
48
+ },
49
+ async request => {
50
+ return services.message.poll(request.query);
51
+ }
52
+ );
53
+
54
+ fastify.post(
55
+ `${options.prefix}/message/complete`,
56
+ {
57
+ onRequest: options.getAuthenticate('message'),
58
+ schema: {
59
+ summary: '确认消息完成',
60
+ body: {
61
+ type: 'object',
62
+ required: ['id'],
63
+ properties: {
64
+ id: { type: 'string', description: '消息ID' }
65
+ }
66
+ }
67
+ }
68
+ },
69
+ async request => {
70
+ return services.message.complete(request.body);
71
+ }
72
+ );
73
+
74
+ fastify.post(
75
+ `${options.prefix}/message/fail`,
76
+ {
77
+ onRequest: options.getAuthenticate('message'),
78
+ schema: {
79
+ summary: '标记消息失败',
80
+ body: {
81
+ type: 'object',
82
+ required: ['id'],
83
+ properties: {
84
+ id: { type: 'string', description: '消息ID' },
85
+ error: { type: 'string', description: '错误信息' }
86
+ }
87
+ }
88
+ }
89
+ },
90
+ async request => {
91
+ return services.message.fail(request.body);
92
+ }
93
+ );
94
+
95
+ fastify.get(
96
+ `${options.prefix}/message/list`,
97
+ {
98
+ onRequest: options.getAuthenticate('message'),
99
+ schema: {
100
+ summary: '消息列表',
101
+ query: {
102
+ type: 'object',
103
+ properties: {
104
+ topic: { type: 'string', description: '按主题筛选' },
105
+ status: { type: 'string', description: '按状态筛选' },
106
+ traceId: { type: 'string', description: '按追踪ID筛选' },
107
+ perPage: { type: 'number', default: 20 },
108
+ currentPage: { type: 'number', default: 1 }
109
+ }
110
+ }
111
+ }
112
+ },
113
+ async request => {
114
+ const { topic, status, traceId, perPage, currentPage } = request.query;
115
+ const filter = {};
116
+ if (topic) filter.topic = topic;
117
+ if (status) filter.status = status;
118
+ if (traceId) filter.traceId = traceId;
119
+ return services.message.list({ filter, perPage, currentPage });
120
+ }
121
+ );
122
+
123
+ fastify.get(
124
+ `${options.prefix}/queue/depth`,
125
+ {
126
+ onRequest: options.getAuthenticate('message'),
127
+ schema: {
128
+ summary: '队列深度',
129
+ query: {
130
+ type: 'object',
131
+ properties: {
132
+ topic: { type: 'string', description: '消息主题(可选,不传则查询全部)' }
133
+ }
134
+ }
135
+ }
136
+ },
137
+ async request => {
138
+ return services.queue.getDepth(request.query);
139
+ }
140
+ );
141
+
142
+ fastify.post(
143
+ `${options.prefix}/queue/cleanup`,
144
+ {
145
+ onRequest: options.getAuthenticate('dlq:manage'),
146
+ schema: {
147
+ summary: '清理已完成的消息',
148
+ body: {
149
+ type: 'object',
150
+ properties: {
151
+ status: { type: 'string', default: 'COMPLETED', description: '要清理的消息状态' },
152
+ olderThan: { type: 'string', format: 'date-time', description: '清理此时间之前更新的消息' }
153
+ }
154
+ }
155
+ }
156
+ },
157
+ async request => {
158
+ return services.queue.cleanup(request.body);
159
+ }
160
+ );
161
+ });
@@ -0,0 +1,44 @@
1
+ module.exports = ({ DataTypes, options }) => {
2
+ return {
3
+ name: 'deadLetter',
4
+ model: {
5
+ originalId: {
6
+ type: DataTypes.STRING,
7
+ allowNull: false,
8
+ comment: '原始消息ID'
9
+ },
10
+ topic: {
11
+ type: DataTypes.STRING,
12
+ allowNull: false,
13
+ comment: '消息主题'
14
+ },
15
+ payload: {
16
+ type: DataTypes.JSONB,
17
+ allowNull: false,
18
+ comment: '消息内容'
19
+ },
20
+ errorMessage: {
21
+ type: DataTypes.TEXT,
22
+ comment: '错误信息'
23
+ },
24
+ replayed: {
25
+ type: DataTypes.BOOLEAN,
26
+ allowNull: false,
27
+ defaultValue: false,
28
+ comment: '是否已重放'
29
+ },
30
+ replayedAt: {
31
+ type: DataTypes.DATE,
32
+ comment: '重放时间'
33
+ },
34
+ options: {
35
+ type: DataTypes.JSONB,
36
+ comment: '扩展字段'
37
+ }
38
+ },
39
+ options: {
40
+ comment: 'MQ死信表',
41
+ indexes: [{ fields: ['original_id'] }, { fields: ['topic'] }, { fields: ['replayed'] }, { fields: ['created_at'] }]
42
+ }
43
+ };
44
+ };
@@ -0,0 +1,44 @@
1
+ module.exports = ({ DataTypes, options }) => {
2
+ return {
3
+ name: 'messageTrace',
4
+ model: {
5
+ traceId: {
6
+ type: DataTypes.STRING,
7
+ allowNull: false,
8
+ comment: '追踪ID'
9
+ },
10
+ topic: {
11
+ type: DataTypes.STRING,
12
+ allowNull: false,
13
+ comment: '消息主题'
14
+ },
15
+ event: {
16
+ type: DataTypes.STRING,
17
+ allowNull: false,
18
+ comment: '事件类型: PUBLISHED/PROCESSING/COMPLETED/FAILED/MOVED_TO_DLQ/REPLAYED'
19
+ },
20
+ messageId: {
21
+ type: DataTypes.STRING,
22
+ comment: '关联消息ID'
23
+ },
24
+ detail: {
25
+ type: DataTypes.JSONB,
26
+ comment: '事件详情'
27
+ },
28
+ options: {
29
+ type: DataTypes.JSONB,
30
+ comment: '扩展字段'
31
+ }
32
+ },
33
+ associate: ({ messageTrace }) => {
34
+ messageTrace.belongsTo(options.getMessageModel(), {
35
+ foreignKey: 'messageId',
36
+ as: 'message'
37
+ });
38
+ },
39
+ options: {
40
+ comment: 'MQ消息轨迹表',
41
+ indexes: [{ fields: ['trace_id', 'created_at'] }, { fields: ['message_id'] }, { fields: ['topic'] }]
42
+ }
43
+ };
44
+ };
@@ -0,0 +1,83 @@
1
+ const { MESSAGE_STATUS } = require('../constants');
2
+
3
+ module.exports = ({ DataTypes, options }) => {
4
+ return {
5
+ model: {
6
+ topic: {
7
+ type: DataTypes.STRING,
8
+ allowNull: false,
9
+ comment: '消息主题'
10
+ },
11
+ payload: {
12
+ type: DataTypes.JSONB,
13
+ allowNull: false,
14
+ comment: '消息内容'
15
+ },
16
+ status: {
17
+ type: DataTypes.STRING,
18
+ allowNull: false,
19
+ defaultValue: MESSAGE_STATUS.PENDING,
20
+ comment: `消息状态: ${Object.values(MESSAGE_STATUS).join('/')}`
21
+ },
22
+ retryCount: {
23
+ type: DataTypes.INTEGER,
24
+ allowNull: false,
25
+ defaultValue: 0,
26
+ comment: '已重试次数'
27
+ },
28
+ maxRetries: {
29
+ type: DataTypes.INTEGER,
30
+ allowNull: false,
31
+ defaultValue: 3,
32
+ comment: '最大重试次数'
33
+ },
34
+ priority: {
35
+ type: DataTypes.INTEGER,
36
+ allowNull: false,
37
+ defaultValue: 0,
38
+ comment: '优先级, 数值越大优先级越高'
39
+ },
40
+ executeAt: {
41
+ type: DataTypes.DATE,
42
+ comment: '定时执行时间'
43
+ },
44
+ nextRetryAt: {
45
+ type: DataTypes.DATE,
46
+ comment: '下次重试时间'
47
+ },
48
+ consumerId: {
49
+ type: DataTypes.STRING,
50
+ comment: '当前消费者标识'
51
+ },
52
+ lockedAt: {
53
+ type: DataTypes.DATE,
54
+ comment: '锁定时间'
55
+ },
56
+ traceId: {
57
+ type: DataTypes.STRING,
58
+ comment: '追踪ID'
59
+ },
60
+ options: {
61
+ type: DataTypes.JSONB,
62
+ comment: '扩展字段'
63
+ }
64
+ },
65
+ associate: ({ message, messageTrace }) => {
66
+ message.hasMany(messageTrace, {
67
+ foreignKey: 'messageId',
68
+ as: 'traces',
69
+ onDelete: 'CASCADE'
70
+ });
71
+ },
72
+ options: {
73
+ comment: 'MQ消息表',
74
+ indexes: [
75
+ { fields: ['topic', 'status', 'priority', 'created_at'] },
76
+ { fields: ['topic', 'status', 'execute_at', 'next_retry_at', 'priority', 'created_at'] },
77
+ { fields: ['trace_id'] },
78
+ { fields: ['consumer_id'] },
79
+ { fields: ['status', 'next_retry_at'] }
80
+ ]
81
+ }
82
+ };
83
+ };