@zintrust/queue-monitor 2.0.0 → 2.1.3
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/dist/QueueMonitoringService.d.ts +1 -1
- package/dist/QueueMonitoringService.js +1 -1
- package/dist/api/workerClient.d.ts +20 -0
- package/dist/api/workerClient.js +45 -0
- package/dist/build-manifest.json +101 -25
- package/dist/config/queueMonitor.d.ts +18 -0
- package/dist/config/queueMonitor.js +21 -0
- package/dist/config/workerConfig.d.ts +3 -0
- package/dist/config/workerConfig.js +19 -0
- package/dist/connection.d.ts +2 -2
- package/dist/connection.js +1 -1
- package/dist/dashboard-index.d.ts +5 -0
- package/dist/dashboard-index.js +5 -0
- package/dist/driver-index.d.ts +6 -0
- package/dist/driver-index.js +5 -0
- package/dist/driver.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -1
- package/dist/metrics-index.d.ts +6 -0
- package/dist/metrics-index.js +5 -0
- package/dist/metrics.js +1 -1
- package/dist/routes/workers.d.ts +10 -0
- package/dist/routes/workers.js +20 -0
- package/dist/runtime-index.d.ts +74 -0
- package/dist/runtime-index.js +238 -0
- package/dist/worker.js +1 -1
- package/dist/workers-ui.d.ts +7 -0
- package/dist/workers-ui.js +655 -0
- package/package.json +17 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue Monitor - Runtime-only entrypoint for production Workers
|
|
3
|
+
* Excludes dashboard UI components that are not needed for runtime
|
|
4
|
+
*/
|
|
5
|
+
import { queueConfig } from '@zintrust/core/config';
|
|
6
|
+
import { Logger } from '@zintrust/core/logger';
|
|
7
|
+
import { resolveLockPrefix } from '@zintrust/core/queue';
|
|
8
|
+
import { isNonEmptyString } from '@zintrust/core/utils';
|
|
9
|
+
import { ShutdownTrace } from '@zintrust/core/workers';
|
|
10
|
+
import { createRedisConnection } from './connection';
|
|
11
|
+
import { createBullMQDriver } from './driver';
|
|
12
|
+
import { createMetrics } from './metrics';
|
|
13
|
+
export { createMetrics } from './metrics';
|
|
14
|
+
export { createWorker as createQueueWorker } from './worker';
|
|
15
|
+
const DEFAULTS = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
basePath: '/queue-monitor',
|
|
18
|
+
middleware: [],
|
|
19
|
+
autoRefresh: true,
|
|
20
|
+
refreshIntervalMs: 5000,
|
|
21
|
+
};
|
|
22
|
+
const METRICS_KEYS = {
|
|
23
|
+
attempts: 'metrics:attempts',
|
|
24
|
+
acquired: 'metrics:acquired',
|
|
25
|
+
collisions: 'metrics:collisions',
|
|
26
|
+
};
|
|
27
|
+
const HISTOGRAM_BUCKETS = [
|
|
28
|
+
{ label: '<30s', max: 30_000 },
|
|
29
|
+
{ label: '30s-2m', max: 120_000 },
|
|
30
|
+
{ label: '2-10m', max: 600_000 },
|
|
31
|
+
{ label: '10-60m', max: 3_600_000 },
|
|
32
|
+
{ label: '>60m', min: 3_600_000 },
|
|
33
|
+
];
|
|
34
|
+
const MAX_LOCK_KEYS = 10_000;
|
|
35
|
+
function normalizeQueueNames(queueNames) {
|
|
36
|
+
return Array.from(new Set(queueNames
|
|
37
|
+
.filter((queueName) => typeof queueName === 'string' && isNonEmptyString(queueName))
|
|
38
|
+
.map((name) => name.trim())))
|
|
39
|
+
.filter((name) => name.length > 0)
|
|
40
|
+
.sort((left, right) => left.localeCompare(right));
|
|
41
|
+
}
|
|
42
|
+
async function resolveKnownQueues(knownQueues) {
|
|
43
|
+
if (typeof knownQueues === 'function') {
|
|
44
|
+
return normalizeQueueNames(await knownQueues());
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(knownQueues)) {
|
|
47
|
+
return normalizeQueueNames(knownQueues);
|
|
48
|
+
}
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
// Helper function to scan lock keys with pagination
|
|
52
|
+
const scanLockKeys = async (client, searchPattern, maxKeys) => {
|
|
53
|
+
const keys = [];
|
|
54
|
+
let cursor = '0';
|
|
55
|
+
do {
|
|
56
|
+
// Redis scan must be sequential
|
|
57
|
+
// eslint-disable-next-line no-await-in-loop
|
|
58
|
+
const [nextCursor, batch] = await client.scan(cursor, 'MATCH', searchPattern, 'COUNT', '200');
|
|
59
|
+
cursor = nextCursor;
|
|
60
|
+
keys.push(...batch);
|
|
61
|
+
if (keys.length >= maxKeys) {
|
|
62
|
+
Logger.warn('Lock scan limit reached', {
|
|
63
|
+
pattern: searchPattern,
|
|
64
|
+
keysFound: keys.length,
|
|
65
|
+
});
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
} while (cursor !== '0');
|
|
69
|
+
return keys;
|
|
70
|
+
};
|
|
71
|
+
// Helper function to get TTL statuses for keys
|
|
72
|
+
const getLockStatuses = async (client, keys) => {
|
|
73
|
+
return Promise.all(keys.map((key) => client.pttl(key)));
|
|
74
|
+
};
|
|
75
|
+
// Helper function to build lock objects from keys and statuses
|
|
76
|
+
const buildLockObjects = (keys, statuses, prefixLock) => {
|
|
77
|
+
return keys.map((key, index) => {
|
|
78
|
+
const ttl = statuses[index];
|
|
79
|
+
const exists = typeof ttl === 'number' && ttl > 0;
|
|
80
|
+
return {
|
|
81
|
+
key: key.replace(prefixLock, ''),
|
|
82
|
+
ttl: exists ? ttl : undefined,
|
|
83
|
+
expires: exists ? new Date(Date.now() + ttl).toISOString() : undefined,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
// Helper function to calculate lock metrics
|
|
88
|
+
const calculateLockMetrics = async (client, prefixLock) => {
|
|
89
|
+
const metricsKeys = [
|
|
90
|
+
`${prefixLock}${METRICS_KEYS.attempts}`,
|
|
91
|
+
`${prefixLock}${METRICS_KEYS.acquired}`,
|
|
92
|
+
`${prefixLock}${METRICS_KEYS.collisions}`,
|
|
93
|
+
];
|
|
94
|
+
const [attemptsRaw, acquiredRaw, collisionsRaw] = await client.mget(...metricsKeys);
|
|
95
|
+
const parseMetric = (value) => Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
96
|
+
const attempts = parseMetric(attemptsRaw);
|
|
97
|
+
const acquired = parseMetric(acquiredRaw);
|
|
98
|
+
const collisions = parseMetric(collisionsRaw);
|
|
99
|
+
const collisionRate = attempts > 0 ? collisions / attempts : 0;
|
|
100
|
+
return { attempts, acquired, collisions, collisionRate };
|
|
101
|
+
};
|
|
102
|
+
// Helper function to build histogram from locks
|
|
103
|
+
const buildLockHistogram = (locks) => {
|
|
104
|
+
const histogram = HISTOGRAM_BUCKETS.map((bucket) => ({
|
|
105
|
+
label: bucket.label,
|
|
106
|
+
count: 0,
|
|
107
|
+
}));
|
|
108
|
+
locks.forEach((lock) => {
|
|
109
|
+
if (typeof lock.ttl !== 'number')
|
|
110
|
+
return;
|
|
111
|
+
const ttl = lock.ttl;
|
|
112
|
+
const idx = HISTOGRAM_BUCKETS.findIndex((bucket) => {
|
|
113
|
+
if (typeof bucket.min === 'number')
|
|
114
|
+
return ttl >= bucket.min;
|
|
115
|
+
if (typeof bucket.max === 'number')
|
|
116
|
+
return ttl < bucket.max;
|
|
117
|
+
return false;
|
|
118
|
+
});
|
|
119
|
+
if (idx >= 0)
|
|
120
|
+
histogram[idx].count += 1;
|
|
121
|
+
});
|
|
122
|
+
return histogram;
|
|
123
|
+
};
|
|
124
|
+
function createGetLocks(redisConfig) {
|
|
125
|
+
return async (pattern = '*') => {
|
|
126
|
+
const client = createRedisConnection(redisConfig, 3, { subsystem: 'queue-monitor-locks' });
|
|
127
|
+
const prefix_lock = resolveLockPrefix();
|
|
128
|
+
const searchPattern = `${prefix_lock}${pattern}`;
|
|
129
|
+
try {
|
|
130
|
+
// Scan for lock keys
|
|
131
|
+
const keys = await scanLockKeys(client, searchPattern, MAX_LOCK_KEYS);
|
|
132
|
+
// Get TTL statuses
|
|
133
|
+
const statuses = await getLockStatuses(client, keys);
|
|
134
|
+
// Build lock objects
|
|
135
|
+
const locks = buildLockObjects(keys, statuses, prefix_lock);
|
|
136
|
+
// Calculate metrics
|
|
137
|
+
const metrics = await calculateLockMetrics(client, prefix_lock);
|
|
138
|
+
// Build histogram
|
|
139
|
+
const histogram = buildLockHistogram(locks);
|
|
140
|
+
return {
|
|
141
|
+
locks,
|
|
142
|
+
metrics: {
|
|
143
|
+
active: locks.length,
|
|
144
|
+
...metrics,
|
|
145
|
+
},
|
|
146
|
+
histogram,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
if (typeof client.quit === 'function') {
|
|
151
|
+
await client.quit();
|
|
152
|
+
}
|
|
153
|
+
else if (typeof client.disconnect === 'function') {
|
|
154
|
+
client.disconnect();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function buildSettings(config) {
|
|
160
|
+
return {
|
|
161
|
+
enabled: config.enabled ?? DEFAULTS.enabled,
|
|
162
|
+
basePath: config.basePath ?? DEFAULTS.basePath,
|
|
163
|
+
middleware: config.middleware ?? DEFAULTS.middleware,
|
|
164
|
+
autoRefresh: config.autoRefresh ?? DEFAULTS.autoRefresh,
|
|
165
|
+
refreshIntervalMs: typeof config.refreshIntervalMs === 'number' && Number.isFinite(config.refreshIntervalMs)
|
|
166
|
+
? Math.max(1000, Math.floor(config.refreshIntervalMs))
|
|
167
|
+
: DEFAULTS.refreshIntervalMs,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function createGetSnapshot(driver, startedAt, knownQueues) {
|
|
171
|
+
return async () => {
|
|
172
|
+
const [discoveredQueues, persistedQueues] = await Promise.all([
|
|
173
|
+
driver.getQueues(),
|
|
174
|
+
resolveKnownQueues(knownQueues),
|
|
175
|
+
]);
|
|
176
|
+
const queues = Array.from(new Set([...persistedQueues, ...discoveredQueues])).sort((left, right) => left.localeCompare(right));
|
|
177
|
+
const stats = await Promise.all(queues.map(async (name) => {
|
|
178
|
+
const counts = await driver.getJobCounts(name);
|
|
179
|
+
return { name, counts: counts };
|
|
180
|
+
}));
|
|
181
|
+
return {
|
|
182
|
+
status: 'ok',
|
|
183
|
+
startedAt,
|
|
184
|
+
queues: stats,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export const QueueMonitor = Object.freeze({
|
|
189
|
+
create(config) {
|
|
190
|
+
const settings = buildSettings(config);
|
|
191
|
+
let redisConfig;
|
|
192
|
+
if (config?.redis) {
|
|
193
|
+
redisConfig = config?.redis;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
redisConfig = {
|
|
197
|
+
host: queueConfig.drivers.redis.host,
|
|
198
|
+
port: queueConfig.drivers.redis.port,
|
|
199
|
+
password: queueConfig.drivers.redis.password ?? '',
|
|
200
|
+
db: queueConfig.drivers.redis.database,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const driver = createBullMQDriver(redisConfig);
|
|
204
|
+
const metrics = createMetrics(redisConfig);
|
|
205
|
+
const startedAt = new Date().toISOString();
|
|
206
|
+
ShutdownTrace.logHandles('queue-monitor.create', {
|
|
207
|
+
basePath: settings.basePath,
|
|
208
|
+
autoRefresh: settings.autoRefresh,
|
|
209
|
+
refreshIntervalMs: settings.refreshIntervalMs,
|
|
210
|
+
});
|
|
211
|
+
const getSnapshot = createGetSnapshot(driver, startedAt, config.knownQueues);
|
|
212
|
+
const getLocks = createGetLocks(redisConfig);
|
|
213
|
+
const close = async () => {
|
|
214
|
+
ShutdownTrace.logHandles('queue-monitor.close.start', {
|
|
215
|
+
basePath: settings.basePath,
|
|
216
|
+
});
|
|
217
|
+
await Promise.all([driver.close(), metrics.close()]);
|
|
218
|
+
ShutdownTrace.logHandles('queue-monitor.close.complete', {
|
|
219
|
+
basePath: settings.basePath,
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
getSnapshot,
|
|
224
|
+
getLocks,
|
|
225
|
+
driver,
|
|
226
|
+
metrics,
|
|
227
|
+
close,
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
export default QueueMonitor;
|
|
232
|
+
export { createBullMQDriver } from './driver';
|
|
233
|
+
/**
|
|
234
|
+
* Package version and build metadata
|
|
235
|
+
* Available at runtime for debugging and health checks
|
|
236
|
+
*/
|
|
237
|
+
export const _ZINTRUST_QUEUE_MONITOR_VERSION = '0.1.0';
|
|
238
|
+
export const _ZINTRUST_QUEUE_MONITOR_BUILD_DATE = '__BUILD_DATE__';
|
package/dist/worker.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getBullMQSafeQueueName } from '@zintrust/core';
|
|
1
|
+
import { getBullMQSafeQueueName } from '@zintrust/core/redis';
|
|
2
2
|
import { Worker } from 'bullmq';
|
|
3
3
|
import { createRedisConnection } from './connection';
|
|
4
4
|
export const createWorker = (queueName, processor, redisConfig, metrics) => {
|