@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,291 @@
1
+ const formatLabel = labels => {
2
+ const keys = Object.keys(labels).sort();
3
+ if (keys.length === 0) return '';
4
+ return '{' + keys.map(k => `${k}="${labels[k]}"`).join(',') + '}';
5
+ };
6
+
7
+ const getValuesMap = collector => {
8
+ const result = {};
9
+ for (const [key, value] of collector._values) {
10
+ const labelVals = key.split('\0');
11
+ const labels = {};
12
+ collector.labelNames.forEach((k, i) => {
13
+ if (labelVals[i] !== '') labels[k] = labelVals[i];
14
+ });
15
+ const labelKey = labels.topic || '_all';
16
+ result[labelKey] = value;
17
+ }
18
+ return result;
19
+ };
20
+
21
+ class Counter {
22
+ constructor({ name, help, labelNames, maxCardinality = 1000 }) {
23
+ this.name = name;
24
+ this.help = help;
25
+ this.labelNames = labelNames || [];
26
+ this.maxCardinality = maxCardinality;
27
+ this._values = new Map();
28
+ }
29
+
30
+ _key(labels) {
31
+ return this.labelNames.map(k => labels[k] || '').join('\0');
32
+ }
33
+
34
+ inc(labels = {}) {
35
+ const key = this._key(labels);
36
+ if (this._values.has(key)) {
37
+ this._values.set(key, this._values.get(key) + 1);
38
+ } else if (this._values.size < this.maxCardinality) {
39
+ this._values.set(key, 1);
40
+ }
41
+ }
42
+
43
+ format() {
44
+ const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} counter`];
45
+ for (const [key, value] of this._values) {
46
+ const labelVals = key.split('\0');
47
+ const labels = {};
48
+ this.labelNames.forEach((k, i) => {
49
+ if (labelVals[i] !== '') labels[k] = labelVals[i];
50
+ });
51
+ lines.push(`${this.name}${formatLabel(labels)} ${value}`);
52
+ }
53
+ return lines.join('\n');
54
+ }
55
+ }
56
+
57
+ class Gauge {
58
+ constructor({ name, help, labelNames, maxCardinality = 1000 }) {
59
+ this.name = name;
60
+ this.help = help;
61
+ this.labelNames = labelNames || [];
62
+ this.maxCardinality = maxCardinality;
63
+ this._values = new Map();
64
+ }
65
+
66
+ _key(labels) {
67
+ return this.labelNames.map(k => labels[k] || '').join('\0');
68
+ }
69
+
70
+ set(labels, value) {
71
+ if (typeof labels === 'number') {
72
+ value = labels;
73
+ labels = {};
74
+ }
75
+ const key = this._key(labels);
76
+ if (this._values.has(key) || this._values.size < this.maxCardinality) {
77
+ this._values.set(key, value);
78
+ }
79
+ }
80
+
81
+ format() {
82
+ const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} gauge`];
83
+ for (const [key, value] of this._values) {
84
+ const labelVals = key.split('\0');
85
+ const labels = {};
86
+ this.labelNames.forEach((k, i) => {
87
+ if (labelVals[i] !== '') labels[k] = labelVals[i];
88
+ });
89
+ lines.push(`${this.name}${formatLabel(labels)} ${value}`);
90
+ }
91
+ return lines.join('\n');
92
+ }
93
+ }
94
+
95
+ class Sampler {
96
+ constructor(registry, { interval = 10000, maxSamples = 360 } = {}) {
97
+ this._registry = registry;
98
+ this._interval = interval;
99
+ this._maxSamples = maxSamples;
100
+ this._samples = [];
101
+ this._timer = null;
102
+ }
103
+
104
+ start() {
105
+ if (this._timer) return;
106
+ this._timer = setInterval(() => this._takeSample(), this._interval);
107
+ this._timer.unref();
108
+ this._takeSample();
109
+ }
110
+
111
+ stop() {
112
+ if (this._timer) {
113
+ clearInterval(this._timer);
114
+ this._timer = null;
115
+ }
116
+ }
117
+
118
+ _takeSample() {
119
+ const snapshot = { timestamp: Date.now() };
120
+ for (const collector of this._registry._collectors) {
121
+ snapshot[collector.name] = getValuesMap(collector);
122
+ }
123
+ this._samples.push(snapshot);
124
+ if (this._samples.length > this._maxSamples) {
125
+ this._samples.shift();
126
+ }
127
+ }
128
+
129
+ getSamples() {
130
+ return this._samples;
131
+ }
132
+
133
+ getRate(metricName, windowMs = 300000) {
134
+ const now = Date.now();
135
+ const cutoff = now - windowMs;
136
+ const samples = this._samples.filter(s => s.timestamp >= cutoff);
137
+ if (samples.length < 2) {
138
+ const current = this._samples.length > 0 ? this._samples[this._samples.length - 1] : null;
139
+ if (!current || !current[metricName]) return {};
140
+ const result = {};
141
+ for (const [topic, val] of Object.entries(current[metricName])) {
142
+ result[topic] = 0;
143
+ }
144
+ return result;
145
+ }
146
+
147
+ const oldest = samples[0];
148
+ const latest = samples[samples.length - 1];
149
+ const elapsedSec = (latest.timestamp - oldest.timestamp) / 1000;
150
+ if (elapsedSec <= 0) return {};
151
+
152
+ const result = {};
153
+ const latestValues = latest[metricName] || {};
154
+ const oldestValues = oldest[metricName] || {};
155
+
156
+ const allTopics = new Set([...Object.keys(latestValues), ...Object.keys(oldestValues)]);
157
+ for (const topic of allTopics) {
158
+ const diff = (latestValues[topic] || 0) - (oldestValues[topic] || 0);
159
+ result[topic] = diff / elapsedSec;
160
+ }
161
+ return result;
162
+ }
163
+
164
+ getTimeSeries(metricName, { windowMs, stepMs = 60000, rate = false } = {}) {
165
+ const now = Date.now();
166
+ const start = windowMs ? now - windowMs : this._samples.length > 0 ? this._samples[0].timestamp : now;
167
+ const samples = this._samples.filter(s => s.timestamp >= start);
168
+ if (samples.length === 0) return [];
169
+
170
+ if (rate) {
171
+ return this._buildRateTimeSeries(metricName, samples, stepMs, windowMs || 300000);
172
+ }
173
+
174
+ const buckets = this._bucketSamples(samples, stepMs);
175
+ return buckets.map(bucket => {
176
+ const point = { timestamp: bucket.timestamp };
177
+ const values = bucket.sample[metricName] || {};
178
+ for (const [topic, val] of Object.entries(values)) {
179
+ point[topic] = val;
180
+ }
181
+ return point;
182
+ });
183
+ }
184
+
185
+ _buildRateTimeSeries(metricName, samples, stepMs, windowMs) {
186
+ const buckets = this._bucketSamples(samples, stepMs);
187
+ const windowSamples = this._samples.filter(s => s.timestamp >= Date.now() - windowMs);
188
+
189
+ return buckets.map(bucket => {
190
+ const point = { timestamp: bucket.timestamp };
191
+ const currentValues = bucket.sample[metricName] || {};
192
+
193
+ const olderSample = windowSamples.find(s => s.timestamp <= bucket.timestamp - windowMs + stepMs);
194
+ if (!olderSample) {
195
+ for (const topic of Object.keys(currentValues)) {
196
+ point[topic] = 0;
197
+ }
198
+ return point;
199
+ }
200
+
201
+ const elapsedSec = (bucket.timestamp - olderSample.timestamp) / 1000;
202
+ if (elapsedSec <= 0) {
203
+ for (const topic of Object.keys(currentValues)) {
204
+ point[topic] = 0;
205
+ }
206
+ return point;
207
+ }
208
+
209
+ const olderValues = olderSample[metricName] || {};
210
+ const allTopics = new Set([...Object.keys(currentValues), ...Object.keys(olderValues)]);
211
+ for (const topic of allTopics) {
212
+ point[topic] = ((currentValues[topic] || 0) - (olderValues[topic] || 0)) / elapsedSec;
213
+ }
214
+ return point;
215
+ });
216
+ }
217
+
218
+ _bucketSamples(samples, stepMs) {
219
+ if (samples.length === 0) return [];
220
+ const buckets = [];
221
+ const startTime = samples[0].timestamp;
222
+ const lastTime = samples[samples.length - 1].timestamp;
223
+ for (let t = startTime; t <= lastTime; t += stepMs) {
224
+ const idx = this._binarySearchClosest(samples, t);
225
+ buckets.push({ timestamp: t, sample: samples[idx] });
226
+ }
227
+ return buckets;
228
+ }
229
+
230
+ _binarySearchClosest(samples, target) {
231
+ let lo = 0;
232
+ let hi = samples.length - 1;
233
+ while (lo < hi) {
234
+ const mid = (lo + hi) >>> 1;
235
+ if (samples[mid].timestamp < target) {
236
+ lo = mid + 1;
237
+ } else {
238
+ hi = mid;
239
+ }
240
+ }
241
+ if (lo === 0) return 0;
242
+ const dLo = Math.abs(samples[lo].timestamp - target);
243
+ const dPrev = Math.abs(samples[lo - 1].timestamp - target);
244
+ return dPrev <= dLo ? lo - 1 : lo;
245
+ }
246
+ }
247
+
248
+ class Registry {
249
+ constructor() {
250
+ this._collectors = [];
251
+ }
252
+
253
+ register(collector) {
254
+ this._collectors.push(collector);
255
+ }
256
+
257
+ metrics() {
258
+ return this._collectors.map(c => c.format()).join('\n');
259
+ }
260
+ }
261
+
262
+ const registry = new Registry();
263
+
264
+ const metrics = {
265
+ queueDepth: new Gauge({
266
+ name: 'mq_queue_depth',
267
+ help: 'Current queue depth',
268
+ labelNames: ['topic']
269
+ }),
270
+ consumedTotal: new Counter({
271
+ name: 'mq_consumed_total',
272
+ help: 'Total consumed messages',
273
+ labelNames: ['topic']
274
+ }),
275
+ failedTotal: new Counter({
276
+ name: 'mq_failed_total',
277
+ help: 'Total failed messages',
278
+ labelNames: ['topic']
279
+ }),
280
+ dlqTotal: new Counter({
281
+ name: 'mq_dlq_total',
282
+ help: 'Total DLQ messages',
283
+ labelNames: ['topic']
284
+ })
285
+ };
286
+
287
+ Object.values(metrics).forEach(m => registry.register(m));
288
+
289
+ const sampler = new Sampler(registry);
290
+
291
+ module.exports = { registry, metrics, sampler, getValuesMap };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "@kne/fastify-mq",
3
+ "version": "0.1.0",
4
+ "description": "Reliable message queue plugin for Fastify based on PostgreSQL, supporting at-least-once delivery, DLQ, delayed messages, priority, tracing and Kafka migration",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "init": "husky",
8
+ "build:md": "npx @kne/md-doc",
9
+ "start:md": "npx @kne/md-doc --watch",
10
+ "prettier": "prettier --config .prettierrc --write '{libs/**/*,index}.{js,jsx,ts,tsx,json,css,scss}'",
11
+ "lint-staged": "npx lint-staged",
12
+ "test": "mocha --exit test/**/*.test.js",
13
+ "test:coverage": "nyc --reporter=html --reporter=text mocha --exit test/**/*.test.js"
14
+ },
15
+ "lint-staged": {
16
+ "{libs/**/*,index}.{js,jsx,ts,tsx,json,css,scss}": [
17
+ "prettier --config .prettierrc --write",
18
+ "git add"
19
+ ]
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "index.d.ts",
24
+ "libs",
25
+ "grafana"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/kne-union/fastify-mq.git"
30
+ },
31
+ "keywords": [
32
+ "fastify",
33
+ "message-queue",
34
+ "mq",
35
+ "postgresql",
36
+ "dlq",
37
+ "dead-letter",
38
+ "reliable-delivery",
39
+ "at-least-once",
40
+ "delayed-message",
41
+ "priority-queue",
42
+ "message-tracing",
43
+ "kafka-migration"
44
+ ],
45
+ "author": "linzp",
46
+ "license": "ISC",
47
+ "bugs": {
48
+ "url": "https://github.com/kne-union/fastify-mq/issues"
49
+ },
50
+ "homepage": "https://github.com/kne-union/fastify-mq#readme",
51
+ "peerDependencies": {
52
+ "@kne/fastify-namespace": "*",
53
+ "@kne/fastify-sequelize": "*",
54
+ "fastify-plugin": ">=5"
55
+ },
56
+ "devDependencies": {
57
+ "@fastify/env": "^5.0.2",
58
+ "@kne/fastify-namespace": "^1.2.1",
59
+ "@kne/fastify-sequelize": "^3.0.6",
60
+ "chai": "^6.2.2",
61
+ "fastify": "^5.4.0",
62
+ "fastify-plugin": "^5.0.1",
63
+ "husky": "^9.0.11",
64
+ "mocha": "^11.7.5",
65
+ "nodemon": "^3.1.3",
66
+ "nyc": "^17.1.0",
67
+ "prettier": "^3.2.5",
68
+ "sqlite3": "^5.1.7",
69
+ "typescript": "^6.0.3"
70
+ }
71
+ }