bullmq-dash 0.3.0 → 0.3.2
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/README.md +40 -21
- package/dist/index.js +1548 -1430
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -3,7 +3,64 @@
|
|
|
3
3
|
// src/app.ts
|
|
4
4
|
import { createCliRenderer } from "@opentui/core";
|
|
5
5
|
|
|
6
|
+
// src/data/queue-sort.ts
|
|
7
|
+
var QUEUE_SORT_FIELDS = [
|
|
8
|
+
"name",
|
|
9
|
+
"task-size",
|
|
10
|
+
"waiting",
|
|
11
|
+
"active",
|
|
12
|
+
"completed",
|
|
13
|
+
"failed",
|
|
14
|
+
"delayed"
|
|
15
|
+
];
|
|
16
|
+
function queueSortLabel(sortBy, order) {
|
|
17
|
+
const label = sortBy === "task-size" ? "size" : sortBy;
|
|
18
|
+
return `${label} ${order}`;
|
|
19
|
+
}
|
|
20
|
+
function metric(queue, sortBy) {
|
|
21
|
+
switch (sortBy) {
|
|
22
|
+
case "name":
|
|
23
|
+
return queue.name;
|
|
24
|
+
case "task-size":
|
|
25
|
+
return queue.total;
|
|
26
|
+
case "waiting":
|
|
27
|
+
return queue.counts.wait;
|
|
28
|
+
case "active":
|
|
29
|
+
return queue.counts.active;
|
|
30
|
+
case "completed":
|
|
31
|
+
return queue.counts.completed;
|
|
32
|
+
case "failed":
|
|
33
|
+
return queue.counts.failed;
|
|
34
|
+
case "delayed":
|
|
35
|
+
return queue.counts.delayed;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function defaultSortOrder(sortBy) {
|
|
39
|
+
return sortBy === "name" ? "asc" : "desc";
|
|
40
|
+
}
|
|
41
|
+
function sortQueues(queues, sortBy = "name", order = defaultSortOrder(sortBy)) {
|
|
42
|
+
const direction = order === "asc" ? 1 : -1;
|
|
43
|
+
return queues.toSorted((a, b) => {
|
|
44
|
+
const aMetric = metric(a, sortBy);
|
|
45
|
+
const bMetric = metric(b, sortBy);
|
|
46
|
+
if (typeof aMetric === "number" && typeof bMetric === "number") {
|
|
47
|
+
const diff = aMetric - bMetric;
|
|
48
|
+
if (diff !== 0)
|
|
49
|
+
return diff * direction;
|
|
50
|
+
return a.name.localeCompare(b.name);
|
|
51
|
+
}
|
|
52
|
+
return String(aMetric).localeCompare(String(bMetric)) * direction;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
6
56
|
// src/state.ts
|
|
57
|
+
var QUEUE_SORT_SEQUENCE = [
|
|
58
|
+
{ sortBy: "name", sortOrder: "asc" },
|
|
59
|
+
{ sortBy: "task-size", sortOrder: "desc" },
|
|
60
|
+
{ sortBy: "failed", sortOrder: "desc" },
|
|
61
|
+
{ sortBy: "waiting", sortOrder: "desc" }
|
|
62
|
+
];
|
|
63
|
+
|
|
7
64
|
class StateManager {
|
|
8
65
|
state;
|
|
9
66
|
listeners = new Set;
|
|
@@ -14,6 +71,8 @@ class StateManager {
|
|
|
14
71
|
globalMetrics: null,
|
|
15
72
|
queues: [],
|
|
16
73
|
selectedQueueIndex: 0,
|
|
74
|
+
queueSortBy: "name",
|
|
75
|
+
queueSortOrder: "asc",
|
|
17
76
|
jobs: [],
|
|
18
77
|
jobsTotal: 0,
|
|
19
78
|
jobsPage: 1,
|
|
@@ -84,6 +143,20 @@ class StateManager {
|
|
|
84
143
|
});
|
|
85
144
|
}
|
|
86
145
|
}
|
|
146
|
+
cycleQueueSort() {
|
|
147
|
+
const { queueSortBy, queueSortOrder } = this.state;
|
|
148
|
+
const currentIndex = QUEUE_SORT_SEQUENCE.findIndex((option) => option.sortBy === queueSortBy && option.sortOrder === queueSortOrder);
|
|
149
|
+
const next = QUEUE_SORT_SEQUENCE[(currentIndex + 1) % QUEUE_SORT_SEQUENCE.length];
|
|
150
|
+
const sortedQueues = sortQueues(this.state.queues, next.sortBy, next.sortOrder);
|
|
151
|
+
this.setState({
|
|
152
|
+
queues: sortedQueues,
|
|
153
|
+
queueSortBy: next.sortBy,
|
|
154
|
+
queueSortOrder: next.sortOrder,
|
|
155
|
+
selectedQueueIndex: 0,
|
|
156
|
+
selectedJobIndex: 0,
|
|
157
|
+
jobsPage: 1
|
|
158
|
+
});
|
|
159
|
+
}
|
|
87
160
|
selectNextJob() {
|
|
88
161
|
const { jobs, selectedJobIndex } = this.state;
|
|
89
162
|
if (selectedJobIndex < jobs.length - 1) {
|
|
@@ -230,1049 +303,170 @@ class StateManager {
|
|
|
230
303
|
}
|
|
231
304
|
var stateManager = new StateManager;
|
|
232
305
|
|
|
233
|
-
// src/
|
|
234
|
-
|
|
235
|
-
process.stderr.write(JSON.stringify({ error: message, code, details }) + `
|
|
236
|
-
`);
|
|
237
|
-
}
|
|
306
|
+
// src/data/queues.ts
|
|
307
|
+
import { Queue } from "bullmq";
|
|
238
308
|
|
|
239
|
-
// src/
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const numberValue = Number(value);
|
|
250
|
-
if (!Number.isInteger(numberValue) || numberValue <= 0)
|
|
251
|
-
return null;
|
|
252
|
-
return numberValue;
|
|
309
|
+
// src/redis-options.ts
|
|
310
|
+
function redisConnectionOptions(config) {
|
|
311
|
+
return {
|
|
312
|
+
host: config.redis.host,
|
|
313
|
+
port: config.redis.port,
|
|
314
|
+
username: config.redis.username,
|
|
315
|
+
password: config.redis.password,
|
|
316
|
+
db: config.redis.db,
|
|
317
|
+
...config.redis.tls ? { tls: {} } : {}
|
|
318
|
+
};
|
|
253
319
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
320
|
+
|
|
321
|
+
// src/data/queues.ts
|
|
322
|
+
var QUEUE_NAMES_CACHE_TTL = 5000;
|
|
323
|
+
var SCAN_COUNT = 1000;
|
|
324
|
+
var DEL_BATCH_SIZE = 500;
|
|
325
|
+
function getQueue(ctx, queueName) {
|
|
326
|
+
const cached = ctx.queueCache.get(queueName);
|
|
327
|
+
if (cached)
|
|
328
|
+
return cached;
|
|
329
|
+
const queue = new Queue(queueName, {
|
|
330
|
+
prefix: ctx.config.prefix,
|
|
331
|
+
connection: redisConnectionOptions(ctx.config)
|
|
332
|
+
});
|
|
333
|
+
ctx.queueCache.set(queueName, queue);
|
|
334
|
+
return queue;
|
|
258
335
|
}
|
|
259
|
-
function
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
return null;
|
|
336
|
+
async function discoverQueueNames(ctx) {
|
|
337
|
+
if (ctx.config.queueNames && ctx.config.queueNames.length > 0) {
|
|
338
|
+
return ctx.config.queueNames;
|
|
263
339
|
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}
|
|
340
|
+
const now = Date.now();
|
|
341
|
+
if (ctx.queueNamesCache && now - ctx.queueNamesCache.timestamp < QUEUE_NAMES_CACHE_TTL) {
|
|
342
|
+
return ctx.queueNamesCache.names;
|
|
268
343
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
344
|
+
const queueNames = new Set;
|
|
345
|
+
const prefix = ctx.config.prefix + ":";
|
|
346
|
+
let cursor = "0";
|
|
347
|
+
do {
|
|
348
|
+
const [nextCursor, keys] = await ctx.redis.scan(cursor, "MATCH", `${ctx.config.prefix}:*`, "COUNT", SCAN_COUNT);
|
|
349
|
+
cursor = nextCursor;
|
|
350
|
+
for (const key of keys) {
|
|
351
|
+
if (key.startsWith(prefix)) {
|
|
352
|
+
const rest = key.slice(prefix.length);
|
|
353
|
+
const colonIdx = rest.indexOf(":");
|
|
354
|
+
const queueName = colonIdx === -1 ? rest : rest.slice(0, colonIdx);
|
|
355
|
+
if (queueName) {
|
|
356
|
+
queueNames.add(queueName);
|
|
357
|
+
}
|
|
282
358
|
}
|
|
283
359
|
}
|
|
284
|
-
}
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (typeof value.prefix !== "string")
|
|
292
|
-
errors.push(`${path}.prefix must be a string`);
|
|
293
|
-
else
|
|
294
|
-
profile.prefix = value.prefix;
|
|
295
|
-
}
|
|
296
|
-
if (value.queues !== undefined) {
|
|
297
|
-
if (!Array.isArray(value.queues) || value.queues.some((queue) => typeof queue !== "string")) {
|
|
298
|
-
errors.push(`${path}.queues must be an array of strings`);
|
|
299
|
-
} else {
|
|
300
|
-
profile.queues = value.queues;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
const retentionMs = coerceOptionalPositiveInt(value.retentionMs);
|
|
304
|
-
if (retentionMs === null)
|
|
305
|
-
errors.push(`${path}.retentionMs must be a positive integer`);
|
|
306
|
-
else if (retentionMs !== undefined)
|
|
307
|
-
profile.retentionMs = retentionMs;
|
|
308
|
-
return profile;
|
|
360
|
+
} while (cursor !== "0");
|
|
361
|
+
const sortedNames = Array.from(queueNames).toSorted();
|
|
362
|
+
ctx.queueNamesCache = {
|
|
363
|
+
names: sortedNames,
|
|
364
|
+
timestamp: now
|
|
365
|
+
};
|
|
366
|
+
return sortedNames;
|
|
309
367
|
}
|
|
310
|
-
function
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const profile = validateProfile(profileValue, `profiles.${name}`, errors);
|
|
332
|
-
if (profile)
|
|
333
|
-
file.profiles[name] = profile;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
return errors.length > 0 ? errors : file;
|
|
368
|
+
async function getQueueStats(ctx, queueName) {
|
|
369
|
+
const queue = getQueue(ctx, queueName);
|
|
370
|
+
const [counts, isPaused, schedulersCount] = await Promise.all([
|
|
371
|
+
queue.getJobCounts(),
|
|
372
|
+
queue.isPaused(),
|
|
373
|
+
queue.getJobSchedulersCount()
|
|
374
|
+
]);
|
|
375
|
+
const waitCount = (counts.waiting || 0) + (counts.prioritized || 0);
|
|
376
|
+
return {
|
|
377
|
+
name: queueName,
|
|
378
|
+
counts: {
|
|
379
|
+
wait: waitCount,
|
|
380
|
+
active: counts.active || 0,
|
|
381
|
+
completed: counts.completed || 0,
|
|
382
|
+
failed: counts.failed || 0,
|
|
383
|
+
delayed: counts.delayed || 0,
|
|
384
|
+
schedulers: schedulersCount || 0
|
|
385
|
+
},
|
|
386
|
+
isPaused,
|
|
387
|
+
total: waitCount + (counts.active || 0) + (counts.completed || 0) + (counts.failed || 0) + (counts.delayed || 0)
|
|
388
|
+
};
|
|
338
389
|
}
|
|
339
|
-
function
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
return env.BULLMQ_DASH_CONFIG;
|
|
344
|
-
const xdg = env.XDG_CONFIG_HOME;
|
|
345
|
-
if (xdg)
|
|
346
|
-
return join(xdg, "bullmq-dash", "config.json");
|
|
347
|
-
return join(homedir(), ".config", "bullmq-dash", "config.json");
|
|
390
|
+
async function getAllQueueStats(ctx) {
|
|
391
|
+
const queueNames = await discoverQueueNames(ctx);
|
|
392
|
+
const stats = await Promise.all(queueNames.map((name) => getQueueStats(ctx, name)));
|
|
393
|
+
return stats;
|
|
348
394
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
395
|
+
async function closeAllQueues(ctx) {
|
|
396
|
+
const closePromises = Array.from(ctx.queueCache.values()).map((queue) => queue.close());
|
|
397
|
+
await Promise.all(closePromises);
|
|
398
|
+
ctx.queueCache.clear();
|
|
399
|
+
ctx.queueNamesCache = null;
|
|
400
|
+
}
|
|
401
|
+
async function deleteQueue(ctx, queueName, dryRun = false) {
|
|
402
|
+
const queue = getQueue(ctx, queueName);
|
|
403
|
+
const counts = await queue.getJobCounts();
|
|
404
|
+
const result = {
|
|
405
|
+
name: queueName,
|
|
406
|
+
counts: {
|
|
407
|
+
wait: (counts.waiting || 0) + (counts.prioritized || 0),
|
|
408
|
+
active: counts.active || 0,
|
|
409
|
+
completed: counts.completed || 0,
|
|
410
|
+
failed: counts.failed || 0,
|
|
411
|
+
delayed: counts.delayed || 0
|
|
359
412
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
413
|
+
};
|
|
414
|
+
if (!dryRun) {
|
|
415
|
+
await queue.obliterate({ force: true });
|
|
416
|
+
const escapedQueueName = queueName.replace(/[[*?]/g, "\\$&");
|
|
417
|
+
const repeatKeyPattern = `${ctx.config.prefix}:${escapedQueueName}:*`;
|
|
418
|
+
const allKeys = [];
|
|
419
|
+
let cursor = "0";
|
|
420
|
+
do {
|
|
421
|
+
const [nextCursor, keys] = await ctx.redis.scan(cursor, "MATCH", repeatKeyPattern, "COUNT", SCAN_COUNT);
|
|
422
|
+
cursor = nextCursor;
|
|
423
|
+
allKeys.push(...keys);
|
|
424
|
+
} while (cursor !== "0");
|
|
425
|
+
if (allKeys.length > 0) {
|
|
426
|
+
for (let i = 0;i < allKeys.length; i += DEL_BATCH_SIZE) {
|
|
427
|
+
const batch = allKeys.slice(i, i + DEL_BATCH_SIZE);
|
|
428
|
+
await ctx.redis.del(...batch);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
await queue.close();
|
|
432
|
+
ctx.queueCache.delete(queueName);
|
|
433
|
+
if (ctx.queueNamesCache) {
|
|
434
|
+
ctx.queueNamesCache.names = ctx.queueNamesCache.names.filter((n) => n !== queueName);
|
|
369
435
|
}
|
|
370
|
-
return out;
|
|
371
436
|
}
|
|
372
|
-
return
|
|
437
|
+
return result;
|
|
373
438
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
439
|
+
|
|
440
|
+
// src/data/duration.ts
|
|
441
|
+
var DURATION_PATTERN = /^(\d+)([smhd])$/;
|
|
442
|
+
var MS_PER_UNIT = {
|
|
443
|
+
s: 1000,
|
|
444
|
+
m: 60 * 1000,
|
|
445
|
+
h: 60 * 60 * 1000,
|
|
446
|
+
d: 24 * 60 * 60 * 1000
|
|
447
|
+
};
|
|
448
|
+
var DEFAULT_RETRY_PAGE_SIZE = 1000;
|
|
449
|
+
var MAX_RETRY_PAGE_SIZE = 1e4;
|
|
450
|
+
function parseDuration(raw) {
|
|
451
|
+
const match = DURATION_PATTERN.exec(raw);
|
|
452
|
+
if (!match)
|
|
384
453
|
return null;
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
389
|
-
} catch (error) {
|
|
390
|
-
writeError(`Failed to parse config file: ${path}`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
391
|
-
process.exit(2);
|
|
392
|
-
}
|
|
393
|
-
const parsed = validateProfilesFile(raw);
|
|
394
|
-
if (Array.isArray(parsed)) {
|
|
395
|
-
writeError(`Invalid config file: ${path}`, "CONFIG_ERROR", parsed.join("; "));
|
|
396
|
-
process.exit(2);
|
|
397
|
-
}
|
|
398
|
-
const file = parsed;
|
|
399
|
-
const name = opts.profileName ?? file.defaultProfile;
|
|
400
|
-
if (!name)
|
|
401
|
-
return null;
|
|
402
|
-
const profile = file.profiles[name];
|
|
403
|
-
if (!profile) {
|
|
404
|
-
const available = Object.keys(file.profiles);
|
|
405
|
-
writeError(`Profile '${name}' not found in ${path}`, "CONFIG_ERROR", available.length > 0 ? `Available profiles: ${available.join(", ")}` : "No profiles are defined in the config file.");
|
|
406
|
-
process.exit(2);
|
|
407
|
-
}
|
|
408
|
-
let expanded;
|
|
409
|
-
try {
|
|
410
|
-
expanded = expandEnvRefs(profile, env);
|
|
411
|
-
} catch (error) {
|
|
412
|
-
writeError(`Failed to resolve env vars in profile '${name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
413
|
-
process.exit(2);
|
|
414
|
-
}
|
|
415
|
-
return { sourcePath: path, name, profile: expanded };
|
|
416
|
-
}
|
|
417
|
-
function decodeUrlAuthField(raw, field) {
|
|
418
|
-
try {
|
|
419
|
-
return decodeURIComponent(raw);
|
|
420
|
-
} catch {
|
|
421
|
-
throw new Error(`Invalid percent-encoding in URL ${field}`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
function redactUrl(input) {
|
|
425
|
-
return input.replace(/(:\/\/)[^/]*@/, "$1[REDACTED]@");
|
|
426
|
-
}
|
|
427
|
-
function parseRedisUrl(input) {
|
|
428
|
-
let parsed;
|
|
429
|
-
try {
|
|
430
|
-
parsed = new URL(input);
|
|
431
|
-
} catch {
|
|
432
|
-
throw new Error(`Not a valid URL: ${redactUrl(input)}`);
|
|
433
|
-
}
|
|
434
|
-
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
435
|
-
throw new Error(`Unsupported scheme '${parsed.protocol}' (expected redis:// or rediss://)`);
|
|
436
|
-
}
|
|
437
|
-
if (!parsed.hostname) {
|
|
438
|
-
throw new Error(`Missing host in URL: ${redactUrl(input)}`);
|
|
439
|
-
}
|
|
440
|
-
if (parsed.search || parsed.hash) {
|
|
441
|
-
throw new Error("Query parameters and fragments are not supported in Redis URLs. " + "For TLS, use rediss:// (not ?ssl=true).");
|
|
442
|
-
}
|
|
443
|
-
const username = parsed.username ? decodeUrlAuthField(parsed.username, "username") : undefined;
|
|
444
|
-
const password = parsed.password ? decodeUrlAuthField(parsed.password, "password") : undefined;
|
|
445
|
-
let db;
|
|
446
|
-
if (parsed.pathname && parsed.pathname !== "/") {
|
|
447
|
-
const trimmed = parsed.pathname.replace(/^\//, "");
|
|
448
|
-
if (!/^\d+$/.test(trimmed)) {
|
|
449
|
-
throw new Error(`Database in URL must be a non-negative integer, got '/${trimmed}'`);
|
|
450
|
-
}
|
|
451
|
-
db = Number(trimmed);
|
|
452
|
-
}
|
|
453
|
-
const port = parsed.port ? Number(parsed.port) : 6379;
|
|
454
|
-
return {
|
|
455
|
-
host: parsed.hostname,
|
|
456
|
-
port,
|
|
457
|
-
username,
|
|
458
|
-
password,
|
|
459
|
-
db,
|
|
460
|
-
tls: parsed.protocol === "rediss:"
|
|
461
|
-
};
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// src/config.ts
|
|
465
|
-
var DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
|
466
|
-
function coercePositiveInt(value, fallback) {
|
|
467
|
-
if (value === undefined)
|
|
468
|
-
return fallback;
|
|
469
|
-
const numberValue = Number(value);
|
|
470
|
-
if (!Number.isInteger(numberValue) || numberValue <= 0)
|
|
471
|
-
return null;
|
|
472
|
-
return numberValue;
|
|
473
|
-
}
|
|
474
|
-
function coerceNonNegativeInt(value, fallback) {
|
|
475
|
-
if (value === undefined)
|
|
476
|
-
return fallback;
|
|
477
|
-
const numberValue = Number(value);
|
|
478
|
-
if (!Number.isInteger(numberValue) || numberValue < 0)
|
|
479
|
-
return null;
|
|
480
|
-
return numberValue;
|
|
481
|
-
}
|
|
482
|
-
function coerceString(value, fallback) {
|
|
483
|
-
if (value === undefined)
|
|
484
|
-
return fallback;
|
|
485
|
-
return typeof value === "string" ? value : null;
|
|
486
|
-
}
|
|
487
|
-
function coerceOptionalString(value) {
|
|
488
|
-
if (value === undefined)
|
|
489
|
-
return;
|
|
490
|
-
return typeof value === "string" ? value : null;
|
|
491
|
-
}
|
|
492
|
-
var CONFIG_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queueNames", "retentionMs"];
|
|
493
|
-
var CONFIG_REDIS_ALLOWED_KEYS = ["host", "port", "username", "password", "db", "tls"];
|
|
494
|
-
function validateConfig(raw) {
|
|
495
|
-
const errors = [];
|
|
496
|
-
for (const key of Object.keys(raw)) {
|
|
497
|
-
if (!CONFIG_ALLOWED_KEYS.includes(key)) {
|
|
498
|
-
errors.push(`unknown key '${key}' in config (allowed: ${CONFIG_ALLOWED_KEYS.join(", ")})`);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
for (const key of Object.keys(raw.redis)) {
|
|
502
|
-
if (!CONFIG_REDIS_ALLOWED_KEYS.includes(key)) {
|
|
503
|
-
errors.push(`unknown key '${key}' in config.redis (allowed: ${CONFIG_REDIS_ALLOWED_KEYS.join(", ")})`);
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
const redis = raw.redis;
|
|
507
|
-
const host = coerceString(redis.host, "localhost");
|
|
508
|
-
if (host === null)
|
|
509
|
-
errors.push("redis.host must be a string");
|
|
510
|
-
const port = coercePositiveInt(redis.port, 6379);
|
|
511
|
-
if (port === null)
|
|
512
|
-
errors.push("redis.port must be a positive integer");
|
|
513
|
-
const username = coerceOptionalString(redis.username);
|
|
514
|
-
if (username === null)
|
|
515
|
-
errors.push("redis.username must be a string");
|
|
516
|
-
const password = coerceOptionalString(redis.password);
|
|
517
|
-
if (password === null)
|
|
518
|
-
errors.push("redis.password must be a string");
|
|
519
|
-
const db = coerceNonNegativeInt(redis.db, 0);
|
|
520
|
-
if (db === null)
|
|
521
|
-
errors.push("redis.db must be a non-negative integer");
|
|
522
|
-
const tls = redis.tls;
|
|
523
|
-
if (tls !== undefined && typeof tls !== "boolean") {
|
|
524
|
-
errors.push("redis.tls must be a boolean");
|
|
525
|
-
}
|
|
526
|
-
const pollInterval = coercePositiveInt(raw.pollInterval, 3000);
|
|
527
|
-
if (pollInterval === null)
|
|
528
|
-
errors.push("pollInterval must be a positive integer");
|
|
529
|
-
const prefix = coerceString(raw.prefix, "bull");
|
|
530
|
-
if (prefix === null)
|
|
531
|
-
errors.push("prefix must be a string");
|
|
532
|
-
const queueNames = raw.queueNames;
|
|
533
|
-
if (queueNames !== undefined && (!Array.isArray(queueNames) || queueNames.some((name) => typeof name !== "string"))) {
|
|
534
|
-
errors.push("queueNames must be an array of strings");
|
|
535
|
-
}
|
|
536
|
-
const retentionMs = coercePositiveInt(raw.retentionMs, DEFAULT_RETENTION_MS);
|
|
537
|
-
if (retentionMs === null)
|
|
538
|
-
errors.push("retentionMs must be a positive integer");
|
|
539
|
-
if (errors.length > 0) {
|
|
540
|
-
return { success: false, errors };
|
|
541
|
-
}
|
|
542
|
-
const config = {
|
|
543
|
-
redis: {
|
|
544
|
-
host,
|
|
545
|
-
port,
|
|
546
|
-
db
|
|
547
|
-
},
|
|
548
|
-
pollInterval,
|
|
549
|
-
prefix,
|
|
550
|
-
retentionMs
|
|
551
|
-
};
|
|
552
|
-
if (username !== undefined && username !== null)
|
|
553
|
-
config.redis.username = username;
|
|
554
|
-
if (password !== undefined && password !== null)
|
|
555
|
-
config.redis.password = password;
|
|
556
|
-
if (tls !== undefined)
|
|
557
|
-
config.redis.tls = tls;
|
|
558
|
-
if (queueNames !== undefined)
|
|
559
|
-
config.queueNames = queueNames;
|
|
560
|
-
return { success: true, data: config };
|
|
561
|
-
}
|
|
562
|
-
function loadConfig(cliArgs, profile) {
|
|
563
|
-
const p = profile?.profile;
|
|
564
|
-
const url = cliArgs.redisUrl ?? p?.redis?.url;
|
|
565
|
-
let parts;
|
|
566
|
-
if (url) {
|
|
567
|
-
try {
|
|
568
|
-
parts = parseRedisUrl(url);
|
|
569
|
-
} catch (error) {
|
|
570
|
-
writeError(cliArgs.redisUrl ? "Invalid --redis-url" : `Invalid redis.url in profile '${profile?.name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
571
|
-
process.exit(2);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
const raw = {
|
|
575
|
-
redis: {
|
|
576
|
-
host: parts?.host,
|
|
577
|
-
port: parts?.port,
|
|
578
|
-
username: parts?.username,
|
|
579
|
-
password: parts?.password,
|
|
580
|
-
db: parts?.db,
|
|
581
|
-
tls: parts?.tls
|
|
582
|
-
},
|
|
583
|
-
pollInterval: cliArgs.pollInterval ?? p?.pollInterval,
|
|
584
|
-
prefix: cliArgs.prefix ?? p?.prefix,
|
|
585
|
-
queueNames: cliArgs.queues ?? p?.queues,
|
|
586
|
-
retentionMs: p?.retentionMs
|
|
587
|
-
};
|
|
588
|
-
const result = validateConfig(raw);
|
|
589
|
-
if (!result.success) {
|
|
590
|
-
writeError("Configuration error", "CONFIG_ERROR", result.errors.join("; "));
|
|
591
|
-
process.exit(2);
|
|
592
|
-
}
|
|
593
|
-
return result.data;
|
|
594
|
-
}
|
|
595
|
-
function createConfigFromPrompt(redisUrl, cliArgs) {
|
|
596
|
-
return loadConfig({ ...cliArgs, redisUrl });
|
|
597
|
-
}
|
|
598
|
-
var configInstance = null;
|
|
599
|
-
function setConfig(config) {
|
|
600
|
-
configInstance = config;
|
|
601
|
-
}
|
|
602
|
-
function getConfig() {
|
|
603
|
-
if (!configInstance) {
|
|
604
|
-
throw new Error("Config not initialized. Call setConfig() first.");
|
|
605
|
-
}
|
|
606
|
-
return configInstance;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// src/data/queues.ts
|
|
610
|
-
import { Queue } from "bullmq";
|
|
611
|
-
|
|
612
|
-
// src/data/redis.ts
|
|
613
|
-
import { RedisConnection } from "bullmq";
|
|
614
|
-
var redisConnection = null;
|
|
615
|
-
var redisClientPromise = null;
|
|
616
|
-
async function getRedisClient() {
|
|
617
|
-
if (!redisClientPromise) {
|
|
618
|
-
const config = getConfig();
|
|
619
|
-
redisConnection = new RedisConnection({
|
|
620
|
-
host: config.redis.host,
|
|
621
|
-
port: config.redis.port,
|
|
622
|
-
username: config.redis.username,
|
|
623
|
-
password: config.redis.password,
|
|
624
|
-
db: config.redis.db,
|
|
625
|
-
...config.redis.tls ? { tls: {} } : {},
|
|
626
|
-
lazyConnect: true,
|
|
627
|
-
retryStrategy: (times) => {
|
|
628
|
-
if (times > 3) {
|
|
629
|
-
return null;
|
|
630
|
-
}
|
|
631
|
-
return Math.min(times * 200, 2000);
|
|
632
|
-
}
|
|
633
|
-
}, { blocking: false });
|
|
634
|
-
redisClientPromise = redisConnection.client.catch((err) => {
|
|
635
|
-
redisClientPromise = null;
|
|
636
|
-
redisConnection = null;
|
|
637
|
-
throw err;
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
return redisClientPromise;
|
|
641
|
-
}
|
|
642
|
-
async function connectRedis() {
|
|
643
|
-
await getRedisClient();
|
|
644
|
-
}
|
|
645
|
-
async function disconnectRedis() {
|
|
646
|
-
if (redisConnection) {
|
|
647
|
-
await redisConnection.close();
|
|
648
|
-
redisConnection = null;
|
|
649
|
-
redisClientPromise = null;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
// src/data/queues.ts
|
|
654
|
-
var queueCache = new Map;
|
|
655
|
-
var queueNamesCache = null;
|
|
656
|
-
var QUEUE_NAMES_CACHE_TTL = 5000;
|
|
657
|
-
var SCAN_COUNT = 1000;
|
|
658
|
-
var DEL_BATCH_SIZE = 500;
|
|
659
|
-
function getQueue(queueName) {
|
|
660
|
-
if (!queueCache.has(queueName)) {
|
|
661
|
-
const config = getConfig();
|
|
662
|
-
const queue = new Queue(queueName, {
|
|
663
|
-
prefix: config.prefix,
|
|
664
|
-
connection: {
|
|
665
|
-
host: config.redis.host,
|
|
666
|
-
port: config.redis.port,
|
|
667
|
-
username: config.redis.username,
|
|
668
|
-
password: config.redis.password,
|
|
669
|
-
db: config.redis.db,
|
|
670
|
-
...config.redis.tls ? { tls: {} } : {}
|
|
671
|
-
}
|
|
672
|
-
});
|
|
673
|
-
queueCache.set(queueName, queue);
|
|
674
|
-
}
|
|
675
|
-
return queueCache.get(queueName);
|
|
676
|
-
}
|
|
677
|
-
async function discoverQueueNames() {
|
|
678
|
-
const config = getConfig();
|
|
679
|
-
if (config.queueNames && config.queueNames.length > 0) {
|
|
680
|
-
return config.queueNames;
|
|
681
|
-
}
|
|
682
|
-
const now = Date.now();
|
|
683
|
-
if (queueNamesCache && now - queueNamesCache.timestamp < QUEUE_NAMES_CACHE_TTL) {
|
|
684
|
-
return queueNamesCache.names;
|
|
685
|
-
}
|
|
686
|
-
const redis = await getRedisClient();
|
|
687
|
-
const queueNames = new Set;
|
|
688
|
-
const prefix = config.prefix + ":";
|
|
689
|
-
let cursor = "0";
|
|
690
|
-
do {
|
|
691
|
-
const [nextCursor, keys] = await redis.scan(cursor, "MATCH", `${config.prefix}:*`, "COUNT", SCAN_COUNT);
|
|
692
|
-
cursor = nextCursor;
|
|
693
|
-
for (const key of keys) {
|
|
694
|
-
if (key.startsWith(prefix)) {
|
|
695
|
-
const rest = key.slice(prefix.length);
|
|
696
|
-
const colonIdx = rest.indexOf(":");
|
|
697
|
-
const queueName = colonIdx === -1 ? rest : rest.slice(0, colonIdx);
|
|
698
|
-
if (queueName) {
|
|
699
|
-
queueNames.add(queueName);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
} while (cursor !== "0");
|
|
704
|
-
const sortedNames = Array.from(queueNames).toSorted();
|
|
705
|
-
queueNamesCache = {
|
|
706
|
-
names: sortedNames,
|
|
707
|
-
timestamp: now
|
|
708
|
-
};
|
|
709
|
-
return sortedNames;
|
|
710
|
-
}
|
|
711
|
-
async function getQueueStats(queueName) {
|
|
712
|
-
const queue = getQueue(queueName);
|
|
713
|
-
const [counts, isPaused, schedulersCount] = await Promise.all([
|
|
714
|
-
queue.getJobCounts(),
|
|
715
|
-
queue.isPaused(),
|
|
716
|
-
queue.getJobSchedulersCount()
|
|
717
|
-
]);
|
|
718
|
-
const waitCount = (counts.waiting || 0) + (counts.prioritized || 0);
|
|
719
|
-
return {
|
|
720
|
-
name: queueName,
|
|
721
|
-
counts: {
|
|
722
|
-
wait: waitCount,
|
|
723
|
-
active: counts.active || 0,
|
|
724
|
-
completed: counts.completed || 0,
|
|
725
|
-
failed: counts.failed || 0,
|
|
726
|
-
delayed: counts.delayed || 0,
|
|
727
|
-
schedulers: schedulersCount || 0
|
|
728
|
-
},
|
|
729
|
-
isPaused,
|
|
730
|
-
total: waitCount + (counts.active || 0) + (counts.completed || 0) + (counts.failed || 0) + (counts.delayed || 0)
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
async function getAllQueueStats() {
|
|
734
|
-
const queueNames = await discoverQueueNames();
|
|
735
|
-
const stats = await Promise.all(queueNames.map((name) => getQueueStats(name)));
|
|
736
|
-
return stats;
|
|
737
|
-
}
|
|
738
|
-
async function closeAllQueues() {
|
|
739
|
-
const closePromises = Array.from(queueCache.values()).map((queue) => queue.close());
|
|
740
|
-
await Promise.all(closePromises);
|
|
741
|
-
queueCache.clear();
|
|
742
|
-
queueNamesCache = null;
|
|
743
|
-
}
|
|
744
|
-
async function deleteQueue(queueName, dryRun = false) {
|
|
745
|
-
const queue = getQueue(queueName);
|
|
746
|
-
const counts = await queue.getJobCounts();
|
|
747
|
-
const result = {
|
|
748
|
-
name: queueName,
|
|
749
|
-
counts: {
|
|
750
|
-
wait: (counts.waiting || 0) + (counts.prioritized || 0),
|
|
751
|
-
active: counts.active || 0,
|
|
752
|
-
completed: counts.completed || 0,
|
|
753
|
-
failed: counts.failed || 0,
|
|
754
|
-
delayed: counts.delayed || 0
|
|
755
|
-
}
|
|
756
|
-
};
|
|
757
|
-
if (!dryRun) {
|
|
758
|
-
await queue.obliterate({ force: true });
|
|
759
|
-
const config = getConfig();
|
|
760
|
-
const redis = await getRedisClient();
|
|
761
|
-
const escapedQueueName = queueName.replace(/[[*?]/g, "\\$&");
|
|
762
|
-
const repeatKeyPattern = `${config.prefix}:${escapedQueueName}:*`;
|
|
763
|
-
const allKeys = [];
|
|
764
|
-
let cursor = "0";
|
|
765
|
-
do {
|
|
766
|
-
const [nextCursor, keys] = await redis.scan(cursor, "MATCH", repeatKeyPattern, "COUNT", SCAN_COUNT);
|
|
767
|
-
cursor = nextCursor;
|
|
768
|
-
allKeys.push(...keys);
|
|
769
|
-
} while (cursor !== "0");
|
|
770
|
-
if (allKeys.length > 0) {
|
|
771
|
-
for (let i = 0;i < allKeys.length; i += DEL_BATCH_SIZE) {
|
|
772
|
-
const batch = allKeys.slice(i, i + DEL_BATCH_SIZE);
|
|
773
|
-
await redis.del(...batch);
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
await queue.close();
|
|
777
|
-
queueCache.delete(queueName);
|
|
778
|
-
if (queueNamesCache) {
|
|
779
|
-
queueNamesCache.names = queueNamesCache.names.filter((n) => n !== queueName);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
return result;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// src/data/duration.ts
|
|
786
|
-
var DURATION_PATTERN = /^(\d+)([smhd])$/;
|
|
787
|
-
var MS_PER_UNIT = {
|
|
788
|
-
s: 1000,
|
|
789
|
-
m: 60 * 1000,
|
|
790
|
-
h: 60 * 60 * 1000,
|
|
791
|
-
d: 24 * 60 * 60 * 1000
|
|
792
|
-
};
|
|
793
|
-
var DEFAULT_RETRY_PAGE_SIZE = 1000;
|
|
794
|
-
var MAX_RETRY_PAGE_SIZE = 1e4;
|
|
795
|
-
function parseDuration(raw) {
|
|
796
|
-
const match = DURATION_PATTERN.exec(raw);
|
|
797
|
-
if (!match)
|
|
798
|
-
return null;
|
|
799
|
-
const n = parseInt(match[1], 10);
|
|
800
|
-
const unit = match[2];
|
|
801
|
-
if (!Number.isFinite(n) || n <= 0)
|
|
802
|
-
return null;
|
|
803
|
-
const unitMs = MS_PER_UNIT[unit];
|
|
804
|
-
if (!unitMs)
|
|
805
|
-
return null;
|
|
806
|
-
return n * unitMs;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
// src/data/sqlite.ts
|
|
810
|
-
import { Database } from "bun:sqlite";
|
|
811
|
-
var db = null;
|
|
812
|
-
var SCHEMA = `
|
|
813
|
-
CREATE TABLE IF NOT EXISTS jobs (
|
|
814
|
-
id TEXT NOT NULL,
|
|
815
|
-
queue TEXT NOT NULL,
|
|
816
|
-
name TEXT,
|
|
817
|
-
state TEXT NOT NULL,
|
|
818
|
-
timestamp INTEGER,
|
|
819
|
-
data_preview TEXT,
|
|
820
|
-
removed_at INTEGER,
|
|
821
|
-
PRIMARY KEY (queue, id)
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_queue_state ON jobs(queue, state);
|
|
825
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_name ON jobs(name);
|
|
826
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_timestamp ON jobs(timestamp);
|
|
827
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_active
|
|
828
|
-
ON jobs(queue, state, timestamp) WHERE removed_at IS NULL;
|
|
829
|
-
-- Covers the disconnected-fallback "latest" view: queue + removed_at IS NULL,
|
|
830
|
-
-- ordered by timestamp DESC. Without this, the planner falls back to
|
|
831
|
-
-- idx_jobs_queue_state + a TEMP B-TREE sort, which is O(n log n) over the
|
|
832
|
-
-- queue's live rows and becomes the bottleneck at multi-million row scale.
|
|
833
|
-
CREATE INDEX IF NOT EXISTS idx_jobs_queue_timestamp_live
|
|
834
|
-
ON jobs(queue, timestamp DESC) WHERE removed_at IS NULL;
|
|
835
|
-
`;
|
|
836
|
-
var QUEUES_SCHEMA = `
|
|
837
|
-
CREATE TABLE IF NOT EXISTS queues (
|
|
838
|
-
name TEXT PRIMARY KEY,
|
|
839
|
-
wait_count INTEGER NOT NULL DEFAULT 0,
|
|
840
|
-
active_count INTEGER NOT NULL DEFAULT 0,
|
|
841
|
-
completed_count INTEGER NOT NULL DEFAULT 0,
|
|
842
|
-
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
843
|
-
delayed_count INTEGER NOT NULL DEFAULT 0,
|
|
844
|
-
schedulers_count INTEGER NOT NULL DEFAULT 0,
|
|
845
|
-
is_paused INTEGER NOT NULL DEFAULT 0
|
|
846
|
-
);
|
|
847
|
-
`;
|
|
848
|
-
var SCHEDULERS_SCHEMA = `
|
|
849
|
-
CREATE TABLE IF NOT EXISTS schedulers (
|
|
850
|
-
queue TEXT NOT NULL,
|
|
851
|
-
key TEXT NOT NULL,
|
|
852
|
-
name TEXT NOT NULL,
|
|
853
|
-
pattern TEXT,
|
|
854
|
-
every INTEGER,
|
|
855
|
-
next INTEGER,
|
|
856
|
-
iteration_count INTEGER,
|
|
857
|
-
tz TEXT,
|
|
858
|
-
PRIMARY KEY (queue, key)
|
|
859
|
-
);
|
|
860
|
-
`;
|
|
861
|
-
function migrateRemovedAtColumn(database) {
|
|
862
|
-
const cols = database.prepare("PRAGMA table_info(jobs)").all();
|
|
863
|
-
if (cols.length === 0)
|
|
864
|
-
return;
|
|
865
|
-
if (cols.some((c) => c.name === "removed_at"))
|
|
866
|
-
return;
|
|
867
|
-
database.exec("ALTER TABLE jobs ADD COLUMN removed_at INTEGER");
|
|
868
|
-
}
|
|
869
|
-
var FTS_SCHEMA = `
|
|
870
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS jobs_fts USING fts5(
|
|
871
|
-
id,
|
|
872
|
-
queue,
|
|
873
|
-
name,
|
|
874
|
-
data_preview,
|
|
875
|
-
content='jobs',
|
|
876
|
-
content_rowid='rowid'
|
|
877
|
-
);
|
|
878
|
-
|
|
879
|
-
-- Keep FTS in sync via triggers
|
|
880
|
-
CREATE TRIGGER IF NOT EXISTS jobs_ai AFTER INSERT ON jobs BEGIN
|
|
881
|
-
INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
|
|
882
|
-
VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
|
|
883
|
-
END;
|
|
884
|
-
|
|
885
|
-
CREATE TRIGGER IF NOT EXISTS jobs_ad AFTER DELETE ON jobs BEGIN
|
|
886
|
-
INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
|
|
887
|
-
VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
|
|
888
|
-
END;
|
|
889
|
-
|
|
890
|
-
CREATE TRIGGER IF NOT EXISTS jobs_au AFTER UPDATE ON jobs BEGIN
|
|
891
|
-
INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
|
|
892
|
-
VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
|
|
893
|
-
INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
|
|
894
|
-
VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
|
|
895
|
-
END;
|
|
896
|
-
`;
|
|
897
|
-
var SYNC_STATE_SCHEMA = `
|
|
898
|
-
CREATE TABLE IF NOT EXISTS sync_state (
|
|
899
|
-
queue TEXT PRIMARY KEY,
|
|
900
|
-
job_count INTEGER NOT NULL DEFAULT 0,
|
|
901
|
-
synced_at INTEGER NOT NULL DEFAULT 0
|
|
902
|
-
);
|
|
903
|
-
`;
|
|
904
|
-
function createSqliteDb(dbPath) {
|
|
905
|
-
const config = getConfig();
|
|
906
|
-
const path = dbPath ?? `/tmp/bullmq-dash-${config.redis.host}-${config.redis.port}.db`;
|
|
907
|
-
db = new Database(path);
|
|
908
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
909
|
-
db.exec("PRAGMA synchronous=NORMAL");
|
|
910
|
-
migrateRemovedAtColumn(db);
|
|
911
|
-
db.exec(SCHEMA);
|
|
912
|
-
db.exec(QUEUES_SCHEMA);
|
|
913
|
-
db.exec(SCHEDULERS_SCHEMA);
|
|
914
|
-
db.exec(FTS_SCHEMA);
|
|
915
|
-
db.exec(SYNC_STATE_SCHEMA);
|
|
916
|
-
return db;
|
|
917
|
-
}
|
|
918
|
-
function getSqliteDb() {
|
|
919
|
-
if (!db) {
|
|
920
|
-
return createSqliteDb();
|
|
921
|
-
}
|
|
922
|
-
return db;
|
|
923
|
-
}
|
|
924
|
-
function closeSqliteDb() {
|
|
925
|
-
if (db) {
|
|
926
|
-
db.close();
|
|
927
|
-
db = null;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
function viewClause(view, alias) {
|
|
931
|
-
const col = `${alias}removed_at`;
|
|
932
|
-
switch (view) {
|
|
933
|
-
case "live":
|
|
934
|
-
return `${col} IS NULL`;
|
|
935
|
-
case "history":
|
|
936
|
-
return `${col} IS NOT NULL`;
|
|
937
|
-
case "all":
|
|
938
|
-
return "";
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
function appendStateClause(conditions, values, state, prefix) {
|
|
942
|
-
if (Array.isArray(state)) {
|
|
943
|
-
conditions.push(`${prefix}state IN (${state.map(() => "?").join(",")})`);
|
|
944
|
-
values.push(...state);
|
|
945
|
-
} else if (state && state !== "all") {
|
|
946
|
-
conditions.push(`${prefix}state = ?`);
|
|
947
|
-
values.push(state);
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
function upsertQueueStats(queues) {
|
|
951
|
-
const database = getSqliteDb();
|
|
952
|
-
const stmt = database.prepare(`
|
|
953
|
-
INSERT INTO queues (
|
|
954
|
-
name,
|
|
955
|
-
wait_count,
|
|
956
|
-
active_count,
|
|
957
|
-
completed_count,
|
|
958
|
-
failed_count,
|
|
959
|
-
delayed_count,
|
|
960
|
-
schedulers_count,
|
|
961
|
-
is_paused
|
|
962
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
963
|
-
ON CONFLICT(name) DO UPDATE SET
|
|
964
|
-
wait_count = excluded.wait_count,
|
|
965
|
-
active_count = excluded.active_count,
|
|
966
|
-
completed_count = excluded.completed_count,
|
|
967
|
-
failed_count = excluded.failed_count,
|
|
968
|
-
delayed_count = excluded.delayed_count,
|
|
969
|
-
schedulers_count = excluded.schedulers_count,
|
|
970
|
-
is_paused = excluded.is_paused
|
|
971
|
-
`);
|
|
972
|
-
const replaceObservedQueues = database.transaction((items) => {
|
|
973
|
-
if (items.length === 0) {
|
|
974
|
-
database.prepare("DELETE FROM queues").run();
|
|
975
|
-
database.prepare("DELETE FROM schedulers").run();
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
for (const queue of items) {
|
|
979
|
-
stmt.run(queue.name, queue.counts.wait, queue.counts.active, queue.counts.completed, queue.counts.failed, queue.counts.delayed, queue.counts.schedulers, queue.isPaused ? 1 : 0);
|
|
980
|
-
}
|
|
981
|
-
const placeholders = items.map(() => "?").join(",");
|
|
982
|
-
const names = items.map((queue) => queue.name);
|
|
983
|
-
database.prepare(`DELETE FROM queues WHERE name NOT IN (${placeholders})`).run(...names);
|
|
984
|
-
database.prepare(`DELETE FROM schedulers WHERE queue NOT IN (${placeholders})`).run(...names);
|
|
985
|
-
});
|
|
986
|
-
replaceObservedQueues(queues);
|
|
987
|
-
}
|
|
988
|
-
function upsertSchedulers(queue, schedulers) {
|
|
989
|
-
const database = getSqliteDb();
|
|
990
|
-
const stmt = database.prepare(`
|
|
991
|
-
INSERT INTO schedulers (queue, key, name, pattern, every, next, iteration_count, tz)
|
|
992
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
993
|
-
ON CONFLICT(queue, key) DO UPDATE SET
|
|
994
|
-
name = excluded.name,
|
|
995
|
-
pattern = excluded.pattern,
|
|
996
|
-
every = excluded.every,
|
|
997
|
-
next = excluded.next,
|
|
998
|
-
iteration_count = excluded.iteration_count,
|
|
999
|
-
tz = excluded.tz
|
|
1000
|
-
`);
|
|
1001
|
-
const replaceForQueue = database.transaction((items) => {
|
|
1002
|
-
if (items.length === 0) {
|
|
1003
|
-
database.prepare("DELETE FROM schedulers WHERE queue = ?").run(queue);
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
for (const s of items) {
|
|
1007
|
-
stmt.run(queue, s.key, s.name, s.pattern ?? null, s.every ?? null, s.next ?? null, s.iterationCount ?? null, s.tz ?? null);
|
|
1008
|
-
}
|
|
1009
|
-
const placeholders = items.map(() => "?").join(",");
|
|
1010
|
-
database.prepare(`DELETE FROM schedulers WHERE queue = ? AND key NOT IN (${placeholders})`).run(queue, ...items.map((s) => s.key));
|
|
1011
|
-
});
|
|
1012
|
-
replaceForQueue(schedulers);
|
|
1013
|
-
}
|
|
1014
|
-
function querySchedulers(queue, page = 1, pageSize = 25) {
|
|
1015
|
-
const database = getSqliteDb();
|
|
1016
|
-
const offset = (page - 1) * pageSize;
|
|
1017
|
-
const { total } = database.prepare("SELECT COUNT(*) as total FROM schedulers WHERE queue = ?").get(queue);
|
|
1018
|
-
const rows = database.prepare(`
|
|
1019
|
-
SELECT key, name, pattern, every, next, iteration_count, tz
|
|
1020
|
-
FROM schedulers
|
|
1021
|
-
WHERE queue = ?
|
|
1022
|
-
ORDER BY key ASC
|
|
1023
|
-
LIMIT ? OFFSET ?
|
|
1024
|
-
`).all(queue, pageSize, offset);
|
|
1025
|
-
const schedulers = rows.map((row) => ({
|
|
1026
|
-
key: row.key,
|
|
1027
|
-
name: row.name,
|
|
1028
|
-
pattern: row.pattern ?? undefined,
|
|
1029
|
-
every: row.every ?? undefined,
|
|
1030
|
-
next: row.next ?? undefined,
|
|
1031
|
-
iterationCount: row.iteration_count ?? undefined,
|
|
1032
|
-
tz: row.tz ?? undefined
|
|
1033
|
-
}));
|
|
1034
|
-
return { schedulers, total };
|
|
1035
|
-
}
|
|
1036
|
-
function queryQueueStats() {
|
|
1037
|
-
const database = getSqliteDb();
|
|
1038
|
-
const rows = database.prepare(`
|
|
1039
|
-
SELECT
|
|
1040
|
-
name,
|
|
1041
|
-
wait_count,
|
|
1042
|
-
active_count,
|
|
1043
|
-
completed_count,
|
|
1044
|
-
failed_count,
|
|
1045
|
-
delayed_count,
|
|
1046
|
-
schedulers_count,
|
|
1047
|
-
is_paused
|
|
1048
|
-
FROM queues
|
|
1049
|
-
ORDER BY name ASC
|
|
1050
|
-
`).all();
|
|
1051
|
-
return rows.map((row) => {
|
|
1052
|
-
const counts = {
|
|
1053
|
-
wait: row.wait_count,
|
|
1054
|
-
active: row.active_count,
|
|
1055
|
-
completed: row.completed_count,
|
|
1056
|
-
failed: row.failed_count,
|
|
1057
|
-
delayed: row.delayed_count,
|
|
1058
|
-
schedulers: row.schedulers_count
|
|
1059
|
-
};
|
|
1060
|
-
return {
|
|
1061
|
-
name: row.name,
|
|
1062
|
-
counts,
|
|
1063
|
-
isPaused: row.is_paused === 1,
|
|
1064
|
-
total: counts.wait + counts.active + counts.completed + counts.failed + counts.delayed
|
|
1065
|
-
};
|
|
1066
|
-
});
|
|
1067
|
-
}
|
|
1068
|
-
function queryJobs(params) {
|
|
1069
|
-
const database = getSqliteDb();
|
|
1070
|
-
const {
|
|
1071
|
-
queue,
|
|
1072
|
-
search,
|
|
1073
|
-
state,
|
|
1074
|
-
sort = "timestamp",
|
|
1075
|
-
order = "desc",
|
|
1076
|
-
page = 1,
|
|
1077
|
-
pageSize = 25,
|
|
1078
|
-
view = "live"
|
|
1079
|
-
} = params;
|
|
1080
|
-
const validSorts = ["id", "name", "state", "timestamp"];
|
|
1081
|
-
const sortCol = validSorts.includes(sort) ? sort : "timestamp";
|
|
1082
|
-
const sortOrder = order === "asc" ? "ASC" : "DESC";
|
|
1083
|
-
const offset = (page - 1) * pageSize;
|
|
1084
|
-
if (Array.isArray(state) && state.length === 0) {
|
|
1085
|
-
return { jobs: [], total: 0 };
|
|
1086
|
-
}
|
|
1087
|
-
if (search) {
|
|
1088
|
-
const conditions2 = ["j.queue = ?"];
|
|
1089
|
-
const values2 = [queue];
|
|
1090
|
-
appendStateClause(conditions2, values2, state, "j.");
|
|
1091
|
-
const viewSql2 = viewClause(view, "j.");
|
|
1092
|
-
if (viewSql2)
|
|
1093
|
-
conditions2.push(viewSql2);
|
|
1094
|
-
const where2 = conditions2.join(" AND ");
|
|
1095
|
-
const ftsMatch = `${search}*`;
|
|
1096
|
-
const countSql2 = `SELECT COUNT(*) as total FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ?`;
|
|
1097
|
-
const total2 = database.prepare(countSql2).get(...values2, ftsMatch).total;
|
|
1098
|
-
const qualifiedSort = `j.${sortCol}`;
|
|
1099
|
-
const sql2 = `SELECT j.* FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ? ORDER BY ${qualifiedSort} ${sortOrder} LIMIT ? OFFSET ?`;
|
|
1100
|
-
const jobs2 = database.prepare(sql2).all(...values2, ftsMatch, pageSize, offset);
|
|
1101
|
-
return { jobs: jobs2, total: total2 };
|
|
1102
|
-
}
|
|
1103
|
-
const conditions = ["queue = ?"];
|
|
1104
|
-
const values = [queue];
|
|
1105
|
-
appendStateClause(conditions, values, state, "");
|
|
1106
|
-
const viewSql = viewClause(view, "");
|
|
1107
|
-
if (viewSql)
|
|
1108
|
-
conditions.push(viewSql);
|
|
1109
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1110
|
-
const countSql = `SELECT COUNT(*) as total FROM jobs ${where}`;
|
|
1111
|
-
const total = database.prepare(countSql).get(...values).total;
|
|
1112
|
-
const sql = `SELECT * FROM jobs ${where} ORDER BY ${sortCol} ${sortOrder} LIMIT ? OFFSET ?`;
|
|
1113
|
-
const jobs = database.prepare(sql).all(...values, pageSize, offset);
|
|
1114
|
-
return { jobs, total };
|
|
1115
|
-
}
|
|
1116
|
-
function safeDataPreview(data) {
|
|
1117
|
-
if (data === undefined || data === null)
|
|
454
|
+
const n = parseInt(match[1], 10);
|
|
455
|
+
const unit = match[2];
|
|
456
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
1118
457
|
return null;
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
} catch {
|
|
458
|
+
const unitMs = MS_PER_UNIT[unit];
|
|
459
|
+
if (!unitMs)
|
|
1122
460
|
return null;
|
|
1123
|
-
|
|
1124
|
-
}
|
|
1125
|
-
function upsertJobs(queue, jobs) {
|
|
1126
|
-
const database = getSqliteDb();
|
|
1127
|
-
const stmt = database.prepare(`
|
|
1128
|
-
INSERT INTO jobs (id, queue, name, state, timestamp, data_preview)
|
|
1129
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
1130
|
-
ON CONFLICT(queue, id) DO UPDATE SET
|
|
1131
|
-
name = excluded.name,
|
|
1132
|
-
state = excluded.state,
|
|
1133
|
-
timestamp = excluded.timestamp,
|
|
1134
|
-
data_preview = excluded.data_preview
|
|
1135
|
-
`);
|
|
1136
|
-
const upsert = database.transaction((items) => {
|
|
1137
|
-
for (const job of items) {
|
|
1138
|
-
const dataPreview = safeDataPreview(job.data);
|
|
1139
|
-
stmt.run(job.id, queue, job.name, job.state, job.timestamp, dataPreview);
|
|
1140
|
-
}
|
|
1141
|
-
});
|
|
1142
|
-
upsert(jobs);
|
|
1143
|
-
}
|
|
1144
|
-
function upsertJobStubs(queue, jobs) {
|
|
1145
|
-
const database = getSqliteDb();
|
|
1146
|
-
const stmt = database.prepare(`
|
|
1147
|
-
INSERT INTO jobs (id, queue, name, state, timestamp, data_preview)
|
|
1148
|
-
VALUES (?, ?, NULL, ?, NULL, NULL)
|
|
1149
|
-
ON CONFLICT(queue, id) DO UPDATE SET
|
|
1150
|
-
state = excluded.state
|
|
1151
|
-
`);
|
|
1152
|
-
const upsert = database.transaction((items) => {
|
|
1153
|
-
for (const job of items) {
|
|
1154
|
-
stmt.run(job.id, queue, job.state);
|
|
1155
|
-
}
|
|
1156
|
-
});
|
|
1157
|
-
upsert(jobs);
|
|
1158
|
-
}
|
|
1159
|
-
function upsertSyncState(queue, input) {
|
|
1160
|
-
const database = getSqliteDb();
|
|
1161
|
-
database.prepare(`
|
|
1162
|
-
INSERT INTO sync_state (queue, job_count, synced_at)
|
|
1163
|
-
VALUES (?, ?, ?)
|
|
1164
|
-
ON CONFLICT(queue) DO UPDATE SET
|
|
1165
|
-
job_count = excluded.job_count,
|
|
1166
|
-
synced_at = excluded.synced_at
|
|
1167
|
-
`).run(queue, input.jobCount, input.syncedAt);
|
|
1168
|
-
}
|
|
1169
|
-
function createSyncStaging() {
|
|
1170
|
-
const database = getSqliteDb();
|
|
1171
|
-
database.exec(`
|
|
1172
|
-
DROP TABLE IF EXISTS main.sync_staging;
|
|
1173
|
-
DROP TABLE IF EXISTS temp.sync_staging;
|
|
1174
|
-
CREATE TEMP TABLE sync_staging (
|
|
1175
|
-
id TEXT NOT NULL,
|
|
1176
|
-
queue TEXT NOT NULL,
|
|
1177
|
-
state TEXT NOT NULL,
|
|
1178
|
-
PRIMARY KEY (queue, id)
|
|
1179
|
-
);
|
|
1180
|
-
`);
|
|
1181
|
-
}
|
|
1182
|
-
function insertStagingBatch(queue, jobs) {
|
|
1183
|
-
if (jobs.length === 0)
|
|
1184
|
-
return;
|
|
1185
|
-
const database = getSqliteDb();
|
|
1186
|
-
const stmt = database.prepare("INSERT OR IGNORE INTO sync_staging (id, queue, state) VALUES (?, ?, ?)");
|
|
1187
|
-
const insert = database.transaction((items) => {
|
|
1188
|
-
for (const job of items) {
|
|
1189
|
-
stmt.run(job.id, queue, job.state);
|
|
1190
|
-
}
|
|
1191
|
-
});
|
|
1192
|
-
insert(jobs);
|
|
1193
|
-
}
|
|
1194
|
-
function findResurrectedIdsByStagingDiff(queue) {
|
|
1195
|
-
const database = getSqliteDb();
|
|
1196
|
-
const rows = database.prepare(`
|
|
1197
|
-
SELECT s.id FROM sync_staging s
|
|
1198
|
-
JOIN jobs j ON s.queue = j.queue AND s.id = j.id
|
|
1199
|
-
WHERE s.queue = ? AND j.removed_at IS NOT NULL
|
|
1200
|
-
`).all(queue);
|
|
1201
|
-
return rows.map((r) => r.id);
|
|
1202
|
-
}
|
|
1203
|
-
function findNewIdsByStagingDiff(queue) {
|
|
1204
|
-
const database = getSqliteDb();
|
|
1205
|
-
return database.prepare(`
|
|
1206
|
-
SELECT s.id, s.state FROM sync_staging s
|
|
1207
|
-
LEFT JOIN jobs j ON s.queue = j.queue AND s.id = j.id
|
|
1208
|
-
WHERE s.queue = ? AND j.id IS NULL
|
|
1209
|
-
`).all(queue);
|
|
1210
|
-
}
|
|
1211
|
-
function findChangedIdsByStagingDiff(queue) {
|
|
1212
|
-
const database = getSqliteDb();
|
|
1213
|
-
return database.prepare(`
|
|
1214
|
-
SELECT s.id, s.state FROM sync_staging s
|
|
1215
|
-
JOIN jobs j ON s.queue = j.queue AND s.id = j.id
|
|
1216
|
-
WHERE s.queue = ? AND j.removed_at IS NULL AND s.state != j.state
|
|
1217
|
-
`).all(queue);
|
|
1218
|
-
}
|
|
1219
|
-
function findStaleIdsByStagingDiff(queue) {
|
|
1220
|
-
const database = getSqliteDb();
|
|
1221
|
-
const rows = database.prepare(`
|
|
1222
|
-
SELECT j.id FROM jobs j
|
|
1223
|
-
LEFT JOIN sync_staging s ON j.queue = s.queue AND j.id = s.id
|
|
1224
|
-
WHERE j.queue = ? AND j.removed_at IS NULL AND s.id IS NULL
|
|
1225
|
-
`).all(queue);
|
|
1226
|
-
return rows.map((r) => r.id);
|
|
1227
|
-
}
|
|
1228
|
-
function softDeleteJobsByIds(queue, ids, now) {
|
|
1229
|
-
if (ids.length === 0)
|
|
1230
|
-
return 0;
|
|
1231
|
-
const database = getSqliteDb();
|
|
1232
|
-
const BATCH_SIZE = 900;
|
|
1233
|
-
const run = database.transaction(() => {
|
|
1234
|
-
for (let i = 0;i < ids.length; i += BATCH_SIZE) {
|
|
1235
|
-
const batch = ids.slice(i, i + BATCH_SIZE);
|
|
1236
|
-
const placeholders = batch.map(() => "?").join(",");
|
|
1237
|
-
database.prepare(`UPDATE jobs SET removed_at = ? WHERE queue = ? AND id IN (${placeholders})`).run(now, queue, ...batch);
|
|
1238
|
-
}
|
|
1239
|
-
});
|
|
1240
|
-
run();
|
|
1241
|
-
return ids.length;
|
|
1242
|
-
}
|
|
1243
|
-
function compactRemovedJobs(now, retentionMs) {
|
|
1244
|
-
const database = getSqliteDb();
|
|
1245
|
-
const cutoff = now - retentionMs;
|
|
1246
|
-
const filter = "removed_at IS NOT NULL AND removed_at < ?";
|
|
1247
|
-
const run = database.transaction(() => {
|
|
1248
|
-
const { n } = database.prepare(`SELECT COUNT(*) as n FROM jobs WHERE ${filter}`).get(cutoff);
|
|
1249
|
-
if (n > 0) {
|
|
1250
|
-
database.prepare(`DELETE FROM jobs WHERE ${filter}`).run(cutoff);
|
|
1251
|
-
}
|
|
1252
|
-
return n;
|
|
1253
|
-
});
|
|
1254
|
-
return run();
|
|
1255
|
-
}
|
|
1256
|
-
function dropSyncStaging() {
|
|
1257
|
-
const database = getSqliteDb();
|
|
1258
|
-
database.exec("DROP TABLE IF EXISTS sync_staging");
|
|
461
|
+
return n * unitMs;
|
|
1259
462
|
}
|
|
1260
463
|
|
|
1261
464
|
// src/data/jobs.ts
|
|
1262
465
|
var PAGE_SIZE = 25;
|
|
1263
466
|
var DEFAULT_MAX_RESULTS = 1000;
|
|
1264
|
-
var SYNC_JOB_TYPES = [
|
|
1265
|
-
"active",
|
|
1266
|
-
"waiting",
|
|
1267
|
-
"completed",
|
|
1268
|
-
"failed",
|
|
1269
|
-
"delayed",
|
|
1270
|
-
"prioritized"
|
|
1271
|
-
];
|
|
1272
|
-
var SYNC_PAGE_SIZE = 5000;
|
|
1273
467
|
var VALID_JOB_STATUSES = ["wait", "active", "completed", "failed", "delayed"];
|
|
1274
|
-
async function getAllJobs(queueName, status, maxResults = DEFAULT_MAX_RESULTS, includeData = false) {
|
|
1275
|
-
const queue = getQueue(queueName);
|
|
468
|
+
async function getAllJobs(ctx, queueName, status, maxResults = DEFAULT_MAX_RESULTS, includeData = false) {
|
|
469
|
+
const queue = getQueue(ctx, queueName);
|
|
1276
470
|
const end = maxResults - 1;
|
|
1277
471
|
let tagged;
|
|
1278
472
|
let total;
|
|
@@ -1346,27 +540,8 @@ async function getAllJobs(queueName, status, maxResults = DEFAULT_MAX_RESULTS, i
|
|
|
1346
540
|
});
|
|
1347
541
|
return { jobs: jobSummaries, total };
|
|
1348
542
|
}
|
|
1349
|
-
async function
|
|
1350
|
-
const queue = getQueue(queueName);
|
|
1351
|
-
for (const type of SYNC_JOB_TYPES) {
|
|
1352
|
-
let offset = 0;
|
|
1353
|
-
while (true) {
|
|
1354
|
-
const end = offset + SYNC_PAGE_SIZE - 1;
|
|
1355
|
-
const ids = await queue.getRanges([type], offset, end);
|
|
1356
|
-
if (ids.length === 0)
|
|
1357
|
-
break;
|
|
1358
|
-
const batch = ids.filter((id) => typeof id === "string" && id !== "").map((id) => ({ id, state: type }));
|
|
1359
|
-
if (batch.length > 0) {
|
|
1360
|
-
yield batch;
|
|
1361
|
-
}
|
|
1362
|
-
if (ids.length < SYNC_PAGE_SIZE)
|
|
1363
|
-
break;
|
|
1364
|
-
offset += SYNC_PAGE_SIZE;
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includeData = false) {
|
|
1369
|
-
const queue = getQueue(queueName);
|
|
543
|
+
async function getJobs(ctx, queueName, status, page = 1, pageSize = PAGE_SIZE, includeData = false) {
|
|
544
|
+
const queue = getQueue(ctx, queueName);
|
|
1370
545
|
const start = (page - 1) * pageSize;
|
|
1371
546
|
const end = start + pageSize - 1;
|
|
1372
547
|
let tagged;
|
|
@@ -1431,7 +606,7 @@ async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includ
|
|
|
1431
606
|
total = delayedCounts.delayed || 0;
|
|
1432
607
|
break;
|
|
1433
608
|
case "schedulers":
|
|
1434
|
-
throw new Error("Cannot fetch schedulers via getJobs(). Use
|
|
609
|
+
throw new Error("Cannot fetch schedulers via getJobs(). Use getAllJobSchedulers() instead.");
|
|
1435
610
|
default:
|
|
1436
611
|
tagged = [];
|
|
1437
612
|
total = 0;
|
|
@@ -1457,45 +632,8 @@ async function getJobs(queueName, status, page = 1, pageSize = PAGE_SIZE, includ
|
|
|
1457
632
|
totalPages: Math.ceil(total / pageSize)
|
|
1458
633
|
};
|
|
1459
634
|
}
|
|
1460
|
-
function
|
|
1461
|
-
|
|
1462
|
-
id: row.id,
|
|
1463
|
-
name: row.name ?? "unknown",
|
|
1464
|
-
state: row.state,
|
|
1465
|
-
timestamp: row.timestamp ?? 0
|
|
1466
|
-
};
|
|
1467
|
-
}
|
|
1468
|
-
function storeStateFilter(status) {
|
|
1469
|
-
switch (status) {
|
|
1470
|
-
case "latest":
|
|
1471
|
-
return;
|
|
1472
|
-
case "wait":
|
|
1473
|
-
return ["waiting", "prioritized"];
|
|
1474
|
-
case "schedulers":
|
|
1475
|
-
throw new Error("Cannot fetch schedulers via getJobsFromStore(). Use getJobSchedulers() instead.");
|
|
1476
|
-
default:
|
|
1477
|
-
return status;
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
async function getJobsFromStore(queueName, status, page = 1, pageSize = PAGE_SIZE) {
|
|
1481
|
-
const result = queryJobs({
|
|
1482
|
-
queue: queueName,
|
|
1483
|
-
state: storeStateFilter(status),
|
|
1484
|
-
sort: "timestamp",
|
|
1485
|
-
order: "desc",
|
|
1486
|
-
page,
|
|
1487
|
-
pageSize
|
|
1488
|
-
});
|
|
1489
|
-
return {
|
|
1490
|
-
jobs: result.jobs.map(rowToJobSummary),
|
|
1491
|
-
total: result.total,
|
|
1492
|
-
page,
|
|
1493
|
-
pageSize,
|
|
1494
|
-
totalPages: Math.ceil(result.total / pageSize)
|
|
1495
|
-
};
|
|
1496
|
-
}
|
|
1497
|
-
async function getJobDetail(queueName, jobId) {
|
|
1498
|
-
const queue = getQueue(queueName);
|
|
635
|
+
async function getJobDetail(ctx, queueName, jobId) {
|
|
636
|
+
const queue = getQueue(ctx, queueName);
|
|
1499
637
|
const job = await queue.getJob(jobId);
|
|
1500
638
|
if (!job) {
|
|
1501
639
|
return null;
|
|
@@ -1519,8 +657,8 @@ async function getJobDetail(queueName, jobId) {
|
|
|
1519
657
|
delay: job.delay
|
|
1520
658
|
};
|
|
1521
659
|
}
|
|
1522
|
-
async function deleteJob(queueName, jobId) {
|
|
1523
|
-
const queue = getQueue(queueName);
|
|
660
|
+
async function deleteJob(ctx, queueName, jobId) {
|
|
661
|
+
const queue = getQueue(ctx, queueName);
|
|
1524
662
|
const job = await queue.getJob(jobId);
|
|
1525
663
|
if (!job) {
|
|
1526
664
|
return false;
|
|
@@ -1529,7 +667,7 @@ async function deleteJob(queueName, jobId) {
|
|
|
1529
667
|
return true;
|
|
1530
668
|
}
|
|
1531
669
|
var SAMPLE_ID_COUNT = 5;
|
|
1532
|
-
async function retryFailedJobs(queueName, options) {
|
|
670
|
+
async function retryFailedJobs(ctx, queueName, options) {
|
|
1533
671
|
const pageSize = Math.min(options.pageSize ?? DEFAULT_RETRY_PAGE_SIZE, MAX_RETRY_PAGE_SIZE);
|
|
1534
672
|
const dryRun = options.dryRun ?? false;
|
|
1535
673
|
let cutoffMs;
|
|
@@ -1540,22 +678,51 @@ async function retryFailedJobs(queueName, options) {
|
|
|
1540
678
|
}
|
|
1541
679
|
cutoffMs = Date.now() - durationMs;
|
|
1542
680
|
}
|
|
1543
|
-
const queue = getQueue(queueName);
|
|
1544
|
-
|
|
681
|
+
const queue = getQueue(ctx, queueName);
|
|
682
|
+
let failedJobs;
|
|
683
|
+
let truncated = false;
|
|
684
|
+
const countsPromise = queue.getJobCounts("failed");
|
|
685
|
+
if (options.jobId !== undefined) {
|
|
686
|
+
const [job, counts2] = await Promise.all([queue.getJob(options.jobId), countsPromise]);
|
|
687
|
+
const totalFailed2 = counts2.failed || 0;
|
|
688
|
+
if (!job) {
|
|
689
|
+
return {
|
|
690
|
+
matched: 0,
|
|
691
|
+
retried: 0,
|
|
692
|
+
errors: [],
|
|
693
|
+
sampleJobIds: [],
|
|
694
|
+
totalFailed: totalFailed2,
|
|
695
|
+
truncated: false
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
const state = await job.getState();
|
|
699
|
+
failedJobs = state === "failed" ? [job] : [];
|
|
700
|
+
const matched2 = applyRetryFilters(failedJobs, cutoffMs, options.name);
|
|
701
|
+
return retryMatchedJobs(matched2, totalFailed2, false, dryRun);
|
|
702
|
+
}
|
|
703
|
+
const [fetchedFailedJobs, counts] = await Promise.all([
|
|
1545
704
|
queue.getFailed(0, pageSize - 1),
|
|
1546
|
-
|
|
705
|
+
countsPromise
|
|
1547
706
|
]);
|
|
1548
707
|
const totalFailed = counts.failed || 0;
|
|
1549
|
-
|
|
708
|
+
failedJobs = fetchedFailedJobs;
|
|
709
|
+
truncated = totalFailed > failedJobs.length;
|
|
710
|
+
const matched = applyRetryFilters(failedJobs, cutoffMs, options.name);
|
|
711
|
+
return retryMatchedJobs(matched, totalFailed, truncated, dryRun);
|
|
712
|
+
}
|
|
713
|
+
function applyRetryFilters(jobs, cutoffMs, name) {
|
|
714
|
+
return jobs.filter((job) => {
|
|
1550
715
|
if (cutoffMs !== undefined) {
|
|
1551
716
|
const whenFailed = job.finishedOn ?? job.timestamp ?? 0;
|
|
1552
717
|
if (whenFailed < cutoffMs)
|
|
1553
718
|
return false;
|
|
1554
719
|
}
|
|
1555
|
-
if (
|
|
720
|
+
if (name !== undefined && job.name !== name)
|
|
1556
721
|
return false;
|
|
1557
722
|
return true;
|
|
1558
723
|
});
|
|
724
|
+
}
|
|
725
|
+
async function retryMatchedJobs(matched, totalFailed, truncated, dryRun) {
|
|
1559
726
|
const matchedIds = matched.map((job) => job.id).filter((id) => typeof id === "string" && id.length > 0);
|
|
1560
727
|
const sampleIds = matchedIds.slice(0, SAMPLE_ID_COUNT);
|
|
1561
728
|
if (dryRun) {
|
|
@@ -1565,7 +732,7 @@ async function retryFailedJobs(queueName, options) {
|
|
|
1565
732
|
errors: [],
|
|
1566
733
|
sampleJobIds: sampleIds,
|
|
1567
734
|
totalFailed,
|
|
1568
|
-
truncated
|
|
735
|
+
truncated
|
|
1569
736
|
};
|
|
1570
737
|
}
|
|
1571
738
|
let retried = 0;
|
|
@@ -1587,7 +754,7 @@ async function retryFailedJobs(queueName, options) {
|
|
|
1587
754
|
errors,
|
|
1588
755
|
sampleJobIds: sampleIds,
|
|
1589
756
|
totalFailed,
|
|
1590
|
-
truncated
|
|
757
|
+
truncated
|
|
1591
758
|
};
|
|
1592
759
|
}
|
|
1593
760
|
function formatRelativeTime(timestamp) {
|
|
@@ -1615,8 +782,8 @@ function formatTimestamp(timestamp) {
|
|
|
1615
782
|
|
|
1616
783
|
// src/data/schedulers.ts
|
|
1617
784
|
var DEFAULT_MAX_RESULTS2 = 1000;
|
|
1618
|
-
async function getAllJobSchedulers(queueName, maxResults = DEFAULT_MAX_RESULTS2) {
|
|
1619
|
-
const queue = getQueue(queueName);
|
|
785
|
+
async function getAllJobSchedulers(ctx, queueName, maxResults = DEFAULT_MAX_RESULTS2) {
|
|
786
|
+
const queue = getQueue(ctx, queueName);
|
|
1620
787
|
const end = maxResults - 1;
|
|
1621
788
|
const [schedulers, total] = await Promise.all([
|
|
1622
789
|
queue.getJobSchedulers(0, end, false),
|
|
@@ -1633,8 +800,8 @@ async function getAllJobSchedulers(queueName, maxResults = DEFAULT_MAX_RESULTS2)
|
|
|
1633
800
|
}));
|
|
1634
801
|
return { schedulers: summaries, total };
|
|
1635
802
|
}
|
|
1636
|
-
async function getJobSchedulerDetail(queueName, schedulerKey) {
|
|
1637
|
-
const queue = getQueue(queueName);
|
|
803
|
+
async function getJobSchedulerDetail(ctx, queueName, schedulerKey) {
|
|
804
|
+
const queue = getQueue(ctx, queueName);
|
|
1638
805
|
const schedulers = await queue.getJobSchedulers(0, -1, false);
|
|
1639
806
|
const scheduler = schedulers.find((s) => s.key === schedulerKey);
|
|
1640
807
|
if (!scheduler) {
|
|
@@ -1801,219 +968,522 @@ var metricsTracker = new MetricsTracker;
|
|
|
1801
968
|
function resetMetricsTracker() {
|
|
1802
969
|
metricsTracker.reset();
|
|
1803
970
|
}
|
|
1804
|
-
function aggregateJobCounts(queues) {
|
|
1805
|
-
const jobCounts = {
|
|
1806
|
-
wait: 0,
|
|
1807
|
-
active: 0,
|
|
1808
|
-
completed: 0,
|
|
1809
|
-
failed: 0,
|
|
1810
|
-
delayed: 0,
|
|
1811
|
-
total: 0
|
|
1812
|
-
};
|
|
1813
|
-
for (const { counts } of queues) {
|
|
1814
|
-
jobCounts.wait += counts.wait;
|
|
1815
|
-
jobCounts.active += counts.active;
|
|
1816
|
-
jobCounts.completed += counts.completed;
|
|
1817
|
-
jobCounts.failed += counts.failed;
|
|
1818
|
-
jobCounts.delayed += counts.delayed;
|
|
971
|
+
function aggregateJobCounts(queues) {
|
|
972
|
+
const jobCounts = {
|
|
973
|
+
wait: 0,
|
|
974
|
+
active: 0,
|
|
975
|
+
completed: 0,
|
|
976
|
+
failed: 0,
|
|
977
|
+
delayed: 0,
|
|
978
|
+
total: 0
|
|
979
|
+
};
|
|
980
|
+
for (const { counts } of queues) {
|
|
981
|
+
jobCounts.wait += counts.wait;
|
|
982
|
+
jobCounts.active += counts.active;
|
|
983
|
+
jobCounts.completed += counts.completed;
|
|
984
|
+
jobCounts.failed += counts.failed;
|
|
985
|
+
jobCounts.delayed += counts.delayed;
|
|
986
|
+
}
|
|
987
|
+
jobCounts.total = jobCounts.wait + jobCounts.active + jobCounts.completed + jobCounts.failed + jobCounts.delayed;
|
|
988
|
+
return jobCounts;
|
|
989
|
+
}
|
|
990
|
+
function calculateGlobalMetricsFromQueueStats(queues, rates) {
|
|
991
|
+
return {
|
|
992
|
+
queueCount: queues.length,
|
|
993
|
+
jobCounts: aggregateJobCounts(queues),
|
|
994
|
+
rates
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
function updateMetricsTracker(queues) {
|
|
998
|
+
return metricsTracker.update(aggregateJobCounts(queues));
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/data/sqlite.ts
|
|
1002
|
+
import { Database } from "bun:sqlite";
|
|
1003
|
+
var SCHEMA = `
|
|
1004
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
1005
|
+
id TEXT NOT NULL,
|
|
1006
|
+
queue TEXT NOT NULL,
|
|
1007
|
+
name TEXT,
|
|
1008
|
+
state TEXT NOT NULL,
|
|
1009
|
+
timestamp INTEGER,
|
|
1010
|
+
data_preview TEXT,
|
|
1011
|
+
data_json TEXT,
|
|
1012
|
+
opts_json TEXT,
|
|
1013
|
+
attempts_made INTEGER,
|
|
1014
|
+
failed_reason TEXT,
|
|
1015
|
+
stacktrace_json TEXT,
|
|
1016
|
+
returnvalue_json TEXT,
|
|
1017
|
+
processed_on INTEGER,
|
|
1018
|
+
finished_on INTEGER,
|
|
1019
|
+
progress_json TEXT,
|
|
1020
|
+
repeat_job_key TEXT,
|
|
1021
|
+
delay INTEGER,
|
|
1022
|
+
last_observed_at INTEGER NOT NULL DEFAULT 0,
|
|
1023
|
+
PRIMARY KEY (queue, id)
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_state ON jobs(queue, state);
|
|
1027
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_name ON jobs(name);
|
|
1028
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_timestamp ON jobs(timestamp);
|
|
1029
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_last_observed ON jobs(last_observed_at);
|
|
1030
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_queue_timestamp
|
|
1031
|
+
ON jobs(queue, timestamp DESC);
|
|
1032
|
+
`;
|
|
1033
|
+
var QUEUES_SCHEMA = `
|
|
1034
|
+
CREATE TABLE IF NOT EXISTS queues (
|
|
1035
|
+
name TEXT PRIMARY KEY,
|
|
1036
|
+
wait_count INTEGER NOT NULL DEFAULT 0,
|
|
1037
|
+
active_count INTEGER NOT NULL DEFAULT 0,
|
|
1038
|
+
completed_count INTEGER NOT NULL DEFAULT 0,
|
|
1039
|
+
failed_count INTEGER NOT NULL DEFAULT 0,
|
|
1040
|
+
delayed_count INTEGER NOT NULL DEFAULT 0,
|
|
1041
|
+
schedulers_count INTEGER NOT NULL DEFAULT 0,
|
|
1042
|
+
is_paused INTEGER NOT NULL DEFAULT 0,
|
|
1043
|
+
last_observed_at INTEGER NOT NULL DEFAULT 0
|
|
1044
|
+
);
|
|
1045
|
+
`;
|
|
1046
|
+
var SCHEDULERS_SCHEMA = `
|
|
1047
|
+
CREATE TABLE IF NOT EXISTS schedulers (
|
|
1048
|
+
queue TEXT NOT NULL,
|
|
1049
|
+
key TEXT NOT NULL,
|
|
1050
|
+
name TEXT NOT NULL,
|
|
1051
|
+
pattern TEXT,
|
|
1052
|
+
every INTEGER,
|
|
1053
|
+
next INTEGER,
|
|
1054
|
+
iteration_count INTEGER,
|
|
1055
|
+
tz TEXT,
|
|
1056
|
+
last_observed_at INTEGER NOT NULL DEFAULT 0,
|
|
1057
|
+
PRIMARY KEY (queue, key)
|
|
1058
|
+
);
|
|
1059
|
+
`;
|
|
1060
|
+
function tableColumns(database, table) {
|
|
1061
|
+
const cols = database.prepare(`PRAGMA table_info(${table})`).all();
|
|
1062
|
+
return new Set(cols.map((c) => c.name));
|
|
1063
|
+
}
|
|
1064
|
+
function addColumnIfMissing(database, table, columns, sql) {
|
|
1065
|
+
const match = sql.match(/ADD COLUMN\s+([a-z_]+)/i);
|
|
1066
|
+
const name = match?.[1];
|
|
1067
|
+
if (!name || columns.has(name))
|
|
1068
|
+
return false;
|
|
1069
|
+
database.exec(`ALTER TABLE ${table} ${sql}`);
|
|
1070
|
+
columns.add(name);
|
|
1071
|
+
return true;
|
|
1072
|
+
}
|
|
1073
|
+
function migrateCacheColumns(database, now) {
|
|
1074
|
+
const jobCols = tableColumns(database, "jobs");
|
|
1075
|
+
if (jobCols.size > 0) {
|
|
1076
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN data_json TEXT");
|
|
1077
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN opts_json TEXT");
|
|
1078
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN attempts_made INTEGER");
|
|
1079
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN failed_reason TEXT");
|
|
1080
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN stacktrace_json TEXT");
|
|
1081
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN returnvalue_json TEXT");
|
|
1082
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN processed_on INTEGER");
|
|
1083
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN finished_on INTEGER");
|
|
1084
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN progress_json TEXT");
|
|
1085
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN repeat_job_key TEXT");
|
|
1086
|
+
addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN delay INTEGER");
|
|
1087
|
+
if (addColumnIfMissing(database, "jobs", jobCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
|
|
1088
|
+
database.prepare("UPDATE jobs SET last_observed_at = ?").run(now);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const queueCols = tableColumns(database, "queues");
|
|
1092
|
+
if (queueCols.size > 0 && addColumnIfMissing(database, "queues", queueCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
|
|
1093
|
+
database.prepare("UPDATE queues SET last_observed_at = ?").run(now);
|
|
1094
|
+
}
|
|
1095
|
+
const schedulerCols = tableColumns(database, "schedulers");
|
|
1096
|
+
if (schedulerCols.size > 0 && addColumnIfMissing(database, "schedulers", schedulerCols, "ADD COLUMN last_observed_at INTEGER NOT NULL DEFAULT 0")) {
|
|
1097
|
+
database.prepare("UPDATE schedulers SET last_observed_at = ?").run(now);
|
|
1819
1098
|
}
|
|
1820
|
-
jobCounts.total = jobCounts.wait + jobCounts.active + jobCounts.completed + jobCounts.failed + jobCounts.delayed;
|
|
1821
|
-
return jobCounts;
|
|
1822
|
-
}
|
|
1823
|
-
function calculateGlobalMetricsFromQueueStats(queues, rates) {
|
|
1824
|
-
return {
|
|
1825
|
-
queueCount: queues.length,
|
|
1826
|
-
jobCounts: aggregateJobCounts(queues),
|
|
1827
|
-
rates
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
function updateMetricsTracker(queues) {
|
|
1831
|
-
return metricsTracker.update(aggregateJobCounts(queues));
|
|
1832
1099
|
}
|
|
1100
|
+
var FTS_SCHEMA = `
|
|
1101
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS jobs_fts USING fts5(
|
|
1102
|
+
id,
|
|
1103
|
+
queue,
|
|
1104
|
+
name,
|
|
1105
|
+
data_preview,
|
|
1106
|
+
content='jobs',
|
|
1107
|
+
content_rowid='rowid'
|
|
1108
|
+
);
|
|
1833
1109
|
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1110
|
+
-- Keep FTS in sync via triggers
|
|
1111
|
+
CREATE TRIGGER IF NOT EXISTS jobs_ai AFTER INSERT ON jobs BEGIN
|
|
1112
|
+
INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
|
|
1113
|
+
VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
|
|
1114
|
+
END;
|
|
1838
1115
|
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1116
|
+
CREATE TRIGGER IF NOT EXISTS jobs_ad AFTER DELETE ON jobs BEGIN
|
|
1117
|
+
INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
|
|
1118
|
+
VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
|
|
1119
|
+
END;
|
|
1845
1120
|
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1121
|
+
CREATE TRIGGER IF NOT EXISTS jobs_au AFTER UPDATE ON jobs BEGIN
|
|
1122
|
+
INSERT INTO jobs_fts(jobs_fts, rowid, id, queue, name, data_preview)
|
|
1123
|
+
VALUES ('delete', old.rowid, old.id, old.queue, old.name, old.data_preview);
|
|
1124
|
+
INSERT INTO jobs_fts(rowid, id, queue, name, data_preview)
|
|
1125
|
+
VALUES (new.rowid, new.id, new.queue, new.name, new.data_preview);
|
|
1126
|
+
END;
|
|
1127
|
+
`;
|
|
1128
|
+
function createSqliteDb(config, dbPath) {
|
|
1129
|
+
const path = dbPath ?? `/tmp/bullmq-dash-${config.redis.host}-${config.redis.port}.db`;
|
|
1130
|
+
const handle = new Database(path);
|
|
1131
|
+
handle.exec("PRAGMA journal_mode=WAL");
|
|
1132
|
+
handle.exec("PRAGMA synchronous=NORMAL");
|
|
1133
|
+
migrateCacheColumns(handle, Date.now());
|
|
1134
|
+
handle.exec(SCHEMA);
|
|
1135
|
+
handle.exec(QUEUES_SCHEMA);
|
|
1136
|
+
handle.exec(SCHEDULERS_SCHEMA);
|
|
1137
|
+
handle.exec(FTS_SCHEMA);
|
|
1138
|
+
return handle;
|
|
1139
|
+
}
|
|
1140
|
+
function appendStateClause(conditions, values, state, prefix) {
|
|
1141
|
+
if (Array.isArray(state)) {
|
|
1142
|
+
conditions.push(`${prefix}state IN (${state.map(() => "?").join(",")})`);
|
|
1143
|
+
values.push(...state);
|
|
1144
|
+
} else if (state && state !== "all") {
|
|
1145
|
+
conditions.push(`${prefix}state = ?`);
|
|
1146
|
+
values.push(state);
|
|
1852
1147
|
}
|
|
1853
1148
|
}
|
|
1854
|
-
function
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1149
|
+
function upsertQueueStats(ctx, queues, observedAt = Date.now()) {
|
|
1150
|
+
const database = ctx.db;
|
|
1151
|
+
const stmt = database.prepare(`
|
|
1152
|
+
INSERT INTO queues (
|
|
1153
|
+
name,
|
|
1154
|
+
wait_count,
|
|
1155
|
+
active_count,
|
|
1156
|
+
completed_count,
|
|
1157
|
+
failed_count,
|
|
1158
|
+
delayed_count,
|
|
1159
|
+
schedulers_count,
|
|
1160
|
+
is_paused,
|
|
1161
|
+
last_observed_at
|
|
1162
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1163
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
1164
|
+
wait_count = excluded.wait_count,
|
|
1165
|
+
active_count = excluded.active_count,
|
|
1166
|
+
completed_count = excluded.completed_count,
|
|
1167
|
+
failed_count = excluded.failed_count,
|
|
1168
|
+
delayed_count = excluded.delayed_count,
|
|
1169
|
+
schedulers_count = excluded.schedulers_count,
|
|
1170
|
+
is_paused = excluded.is_paused,
|
|
1171
|
+
last_observed_at = excluded.last_observed_at
|
|
1172
|
+
`);
|
|
1173
|
+
const runUpsert = database.transaction((items) => {
|
|
1174
|
+
for (const queue of items) {
|
|
1175
|
+
stmt.run(queue.name, queue.counts.wait, queue.counts.active, queue.counts.completed, queue.counts.failed, queue.counts.delayed, queue.counts.schedulers, queue.isPaused ? 1 : 0, observedAt);
|
|
1859
1176
|
}
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
syncInProgress = true;
|
|
1863
|
-
syncLockAcquiredAt = Date.now();
|
|
1864
|
-
return { ok: true };
|
|
1177
|
+
});
|
|
1178
|
+
runUpsert(queues);
|
|
1865
1179
|
}
|
|
1866
|
-
function
|
|
1867
|
-
|
|
1868
|
-
|
|
1180
|
+
function upsertSchedulers(ctx, queue, schedulers, observedAt = Date.now()) {
|
|
1181
|
+
const database = ctx.db;
|
|
1182
|
+
const stmt = database.prepare(`
|
|
1183
|
+
INSERT INTO schedulers (
|
|
1184
|
+
queue,
|
|
1185
|
+
key,
|
|
1186
|
+
name,
|
|
1187
|
+
pattern,
|
|
1188
|
+
every,
|
|
1189
|
+
next,
|
|
1190
|
+
iteration_count,
|
|
1191
|
+
tz,
|
|
1192
|
+
last_observed_at
|
|
1193
|
+
)
|
|
1194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1195
|
+
ON CONFLICT(queue, key) DO UPDATE SET
|
|
1196
|
+
name = excluded.name,
|
|
1197
|
+
pattern = excluded.pattern,
|
|
1198
|
+
every = excluded.every,
|
|
1199
|
+
next = excluded.next,
|
|
1200
|
+
iteration_count = excluded.iteration_count,
|
|
1201
|
+
tz = excluded.tz,
|
|
1202
|
+
last_observed_at = excluded.last_observed_at
|
|
1203
|
+
`);
|
|
1204
|
+
const runUpsert = database.transaction((items) => {
|
|
1205
|
+
for (const s of items) {
|
|
1206
|
+
stmt.run(queue, s.key, s.name, s.pattern ?? null, s.every ?? null, s.next ?? null, s.iterationCount ?? null, s.tz ?? null, observedAt);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
runUpsert(schedulers);
|
|
1869
1210
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1211
|
+
function querySchedulers(ctx, queue, page = 1, pageSize = 25) {
|
|
1212
|
+
const database = ctx.db;
|
|
1213
|
+
const offset = (page - 1) * pageSize;
|
|
1214
|
+
const { total } = database.prepare("SELECT COUNT(*) as total FROM schedulers WHERE queue = ?").get(queue);
|
|
1215
|
+
const rows = database.prepare(`
|
|
1216
|
+
SELECT key, name, pattern, every, next, iteration_count, tz, last_observed_at
|
|
1217
|
+
FROM schedulers
|
|
1218
|
+
WHERE queue = ?
|
|
1219
|
+
ORDER BY key ASC
|
|
1220
|
+
LIMIT ? OFFSET ?
|
|
1221
|
+
`).all(queue, pageSize, offset);
|
|
1222
|
+
const schedulers = rows.map((row) => ({
|
|
1223
|
+
key: row.key,
|
|
1224
|
+
name: row.name,
|
|
1225
|
+
pattern: row.pattern ?? undefined,
|
|
1226
|
+
every: row.every ?? undefined,
|
|
1227
|
+
next: row.next ?? undefined,
|
|
1228
|
+
iterationCount: row.iteration_count ?? undefined,
|
|
1229
|
+
tz: row.tz ?? undefined,
|
|
1230
|
+
lastObservedAt: row.last_observed_at
|
|
1231
|
+
}));
|
|
1232
|
+
return { schedulers, total };
|
|
1879
1233
|
}
|
|
1880
|
-
function
|
|
1881
|
-
const
|
|
1882
|
-
|
|
1883
|
-
|
|
1234
|
+
function queryQueueStats(ctx) {
|
|
1235
|
+
const database = ctx.db;
|
|
1236
|
+
const rows = database.prepare(`
|
|
1237
|
+
SELECT
|
|
1238
|
+
name,
|
|
1239
|
+
wait_count,
|
|
1240
|
+
active_count,
|
|
1241
|
+
completed_count,
|
|
1242
|
+
failed_count,
|
|
1243
|
+
delayed_count,
|
|
1244
|
+
schedulers_count,
|
|
1245
|
+
is_paused,
|
|
1246
|
+
last_observed_at
|
|
1247
|
+
FROM queues
|
|
1248
|
+
ORDER BY name ASC
|
|
1249
|
+
`).all();
|
|
1250
|
+
return rows.map((row) => {
|
|
1251
|
+
const counts = {
|
|
1252
|
+
wait: row.wait_count,
|
|
1253
|
+
active: row.active_count,
|
|
1254
|
+
completed: row.completed_count,
|
|
1255
|
+
failed: row.failed_count,
|
|
1256
|
+
delayed: row.delayed_count,
|
|
1257
|
+
schedulers: row.schedulers_count
|
|
1258
|
+
};
|
|
1259
|
+
return {
|
|
1260
|
+
name: row.name,
|
|
1261
|
+
counts,
|
|
1262
|
+
isPaused: row.is_paused === 1,
|
|
1263
|
+
total: counts.wait + counts.active + counts.completed + counts.failed + counts.delayed,
|
|
1264
|
+
lastObservedAt: row.last_observed_at
|
|
1265
|
+
};
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
function queryJobs(ctx, params) {
|
|
1269
|
+
const database = ctx.db;
|
|
1270
|
+
const {
|
|
1271
|
+
queue,
|
|
1272
|
+
search,
|
|
1273
|
+
state,
|
|
1274
|
+
sort = "timestamp",
|
|
1275
|
+
order = "desc",
|
|
1276
|
+
page = 1,
|
|
1277
|
+
pageSize = 25
|
|
1278
|
+
} = params;
|
|
1279
|
+
const validSorts = ["id", "name", "state", "timestamp"];
|
|
1280
|
+
const sortCol = validSorts.includes(sort) ? sort : "timestamp";
|
|
1281
|
+
const sortOrder = order === "asc" ? "ASC" : "DESC";
|
|
1282
|
+
const offset = (page - 1) * pageSize;
|
|
1283
|
+
if (Array.isArray(state) && state.length === 0) {
|
|
1284
|
+
return { jobs: [], total: 0 };
|
|
1884
1285
|
}
|
|
1885
|
-
if (
|
|
1886
|
-
|
|
1286
|
+
if (search) {
|
|
1287
|
+
const conditions2 = ["j.queue = ?"];
|
|
1288
|
+
const values2 = [queue];
|
|
1289
|
+
appendStateClause(conditions2, values2, state, "j.");
|
|
1290
|
+
const where2 = conditions2.join(" AND ");
|
|
1291
|
+
const ftsMatch = `${search}*`;
|
|
1292
|
+
const countSql2 = `SELECT COUNT(*) as total FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ?`;
|
|
1293
|
+
const total2 = database.prepare(countSql2).get(...values2, ftsMatch).total;
|
|
1294
|
+
const qualifiedSort = `j.${sortCol}`;
|
|
1295
|
+
const sql2 = `SELECT j.* FROM jobs j JOIN jobs_fts fts ON j.rowid = fts.rowid WHERE ${where2} AND jobs_fts MATCH ? ORDER BY ${qualifiedSort} ${sortOrder} LIMIT ? OFFSET ?`;
|
|
1296
|
+
const jobs2 = database.prepare(sql2).all(...values2, ftsMatch, pageSize, offset);
|
|
1297
|
+
return { jobs: jobs2, total: total2 };
|
|
1887
1298
|
}
|
|
1299
|
+
const conditions = ["queue = ?"];
|
|
1300
|
+
const values = [queue];
|
|
1301
|
+
appendStateClause(conditions, values, state, "");
|
|
1302
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1303
|
+
const countSql = `SELECT COUNT(*) as total FROM jobs ${where}`;
|
|
1304
|
+
const total = database.prepare(countSql).get(...values).total;
|
|
1305
|
+
const sql = `SELECT * FROM jobs ${where} ORDER BY ${sortCol} ${sortOrder} LIMIT ? OFFSET ?`;
|
|
1306
|
+
const jobs = database.prepare(sql).all(...values, pageSize, offset);
|
|
1307
|
+
return { jobs, total };
|
|
1888
1308
|
}
|
|
1889
|
-
function
|
|
1890
|
-
|
|
1891
|
-
|
|
1309
|
+
function safeDataPreview(data) {
|
|
1310
|
+
if (data === undefined || data === null)
|
|
1311
|
+
return null;
|
|
1312
|
+
try {
|
|
1313
|
+
return JSON.stringify(data).slice(0, 500);
|
|
1314
|
+
} catch {
|
|
1315
|
+
return null;
|
|
1316
|
+
}
|
|
1892
1317
|
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
stateUpdated: 0,
|
|
1901
|
-
softDeleted: 0,
|
|
1902
|
-
total: 0,
|
|
1903
|
-
error: message
|
|
1904
|
-
};
|
|
1318
|
+
function safeJson(data) {
|
|
1319
|
+
if (data === undefined || data === null)
|
|
1320
|
+
return null;
|
|
1321
|
+
try {
|
|
1322
|
+
return JSON.stringify(data);
|
|
1323
|
+
} catch {
|
|
1324
|
+
return null;
|
|
1905
1325
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
const
|
|
1909
|
-
|
|
1910
|
-
|
|
1326
|
+
}
|
|
1327
|
+
function upsertJobs(ctx, queue, jobs, observedAt = Date.now()) {
|
|
1328
|
+
const database = ctx.db;
|
|
1329
|
+
const stmt = database.prepare(`
|
|
1330
|
+
INSERT INTO jobs (
|
|
1331
|
+
id,
|
|
1332
|
+
queue,
|
|
1333
|
+
name,
|
|
1334
|
+
state,
|
|
1335
|
+
timestamp,
|
|
1336
|
+
data_preview,
|
|
1337
|
+
data_json,
|
|
1338
|
+
opts_json,
|
|
1339
|
+
attempts_made,
|
|
1340
|
+
failed_reason,
|
|
1341
|
+
stacktrace_json,
|
|
1342
|
+
returnvalue_json,
|
|
1343
|
+
processed_on,
|
|
1344
|
+
finished_on,
|
|
1345
|
+
progress_json,
|
|
1346
|
+
repeat_job_key,
|
|
1347
|
+
delay,
|
|
1348
|
+
last_observed_at
|
|
1349
|
+
)
|
|
1350
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1351
|
+
ON CONFLICT(queue, id) DO UPDATE SET
|
|
1352
|
+
name = excluded.name,
|
|
1353
|
+
state = excluded.state,
|
|
1354
|
+
timestamp = excluded.timestamp,
|
|
1355
|
+
data_preview = excluded.data_preview,
|
|
1356
|
+
data_json = excluded.data_json,
|
|
1357
|
+
opts_json = excluded.opts_json,
|
|
1358
|
+
attempts_made = excluded.attempts_made,
|
|
1359
|
+
failed_reason = excluded.failed_reason,
|
|
1360
|
+
stacktrace_json = excluded.stacktrace_json,
|
|
1361
|
+
returnvalue_json = excluded.returnvalue_json,
|
|
1362
|
+
processed_on = excluded.processed_on,
|
|
1363
|
+
finished_on = excluded.finished_on,
|
|
1364
|
+
progress_json = excluded.progress_json,
|
|
1365
|
+
repeat_job_key = excluded.repeat_job_key,
|
|
1366
|
+
delay = excluded.delay,
|
|
1367
|
+
last_observed_at = excluded.last_observed_at
|
|
1368
|
+
`);
|
|
1369
|
+
const upsert = database.transaction((items) => {
|
|
1370
|
+
for (const job of items) {
|
|
1371
|
+
const dataPreview = safeDataPreview(job.data);
|
|
1372
|
+
stmt.run(job.id, queue, job.name, job.state, job.timestamp, dataPreview, safeJson(job.data), safeJson(job.opts), job.attemptsMade ?? null, job.failedReason ?? null, safeJson(job.stacktrace), safeJson(job.returnvalue), job.processedOn ?? null, job.finishedOn ?? null, safeJson(job.progress), job.repeatJobKey ?? null, job.delay ?? null, observedAt);
|
|
1911
1373
|
}
|
|
1374
|
+
});
|
|
1375
|
+
upsert(jobs);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// src/data/queue-store.ts
|
|
1379
|
+
var DEFAULT_PAGE_SIZE = 25;
|
|
1380
|
+
function stateFilter(state) {
|
|
1381
|
+
switch (state) {
|
|
1382
|
+
case undefined:
|
|
1383
|
+
case "latest":
|
|
1384
|
+
case "all":
|
|
1385
|
+
return;
|
|
1386
|
+
case "wait":
|
|
1387
|
+
return ["waiting", "prioritized"];
|
|
1388
|
+
case "schedulers":
|
|
1389
|
+
throw new Error("Cannot list schedulers via listJobs(). Use listSchedulers() instead.");
|
|
1390
|
+
default:
|
|
1391
|
+
return state;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function rowToSummary(row) {
|
|
1395
|
+
return {
|
|
1396
|
+
id: row.id,
|
|
1397
|
+
name: row.name ?? "unknown",
|
|
1398
|
+
state: row.state,
|
|
1399
|
+
timestamp: row.timestamp ?? 0,
|
|
1400
|
+
lastObservedAt: row.last_observed_at
|
|
1912
1401
|
};
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1402
|
+
}
|
|
1403
|
+
function listQueues(ctx) {
|
|
1404
|
+
return queryQueueStats(ctx);
|
|
1405
|
+
}
|
|
1406
|
+
function listJobs(ctx, queue, options = {}) {
|
|
1407
|
+
const page = options.page ?? 1;
|
|
1408
|
+
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
1409
|
+
const result = queryJobs(ctx, {
|
|
1410
|
+
queue,
|
|
1411
|
+
state: stateFilter(options.state),
|
|
1412
|
+
sort: "timestamp",
|
|
1413
|
+
order: "desc",
|
|
1414
|
+
page,
|
|
1415
|
+
pageSize
|
|
1416
|
+
});
|
|
1417
|
+
return {
|
|
1418
|
+
jobs: result.jobs.map(rowToSummary),
|
|
1419
|
+
total: result.total,
|
|
1420
|
+
page,
|
|
1421
|
+
pageSize,
|
|
1422
|
+
totalPages: Math.ceil(result.total / pageSize)
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function listSchedulers(ctx, queue, options = {}) {
|
|
1426
|
+
const page = options.page ?? 1;
|
|
1427
|
+
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
|
1428
|
+
const result = querySchedulers(ctx, queue, page, pageSize);
|
|
1429
|
+
return {
|
|
1430
|
+
schedulers: result.schedulers,
|
|
1431
|
+
total: result.total,
|
|
1432
|
+
page,
|
|
1433
|
+
pageSize,
|
|
1434
|
+
totalPages: Math.ceil(result.total / pageSize)
|
|
1435
|
+
};
|
|
1436
|
+
}
|
|
1437
|
+
function recordObservedQueues(ctx, queues, options = {}) {
|
|
1438
|
+
upsertQueueStats(ctx, queues, options.observedAt ?? Date.now());
|
|
1439
|
+
return { observed: queues.length };
|
|
1440
|
+
}
|
|
1441
|
+
function recordObservedJobs(ctx, queue, jobs, options = {}) {
|
|
1442
|
+
upsertJobs(ctx, queue, jobs, options.observedAt ?? Date.now());
|
|
1443
|
+
return { observed: jobs.length };
|
|
1444
|
+
}
|
|
1445
|
+
function recordObservedSchedulers(ctx, queue, schedulers, options = {}) {
|
|
1446
|
+
upsertSchedulers(ctx, queue, schedulers, options.observedAt ?? Date.now());
|
|
1447
|
+
return { observed: schedulers.length };
|
|
1448
|
+
}
|
|
1449
|
+
function staleCutoff(ctx, now) {
|
|
1450
|
+
return now - ctx.config.cacheTtlMs;
|
|
1451
|
+
}
|
|
1452
|
+
function expireStaleRecords(ctx, options = {}) {
|
|
1453
|
+
const database = ctx.db;
|
|
1454
|
+
const cutoff = staleCutoff(ctx, options.now ?? Date.now());
|
|
1455
|
+
return database.transaction(() => {
|
|
1456
|
+
const expiredQueues = database.prepare("SELECT name FROM queues WHERE last_observed_at < ?").all(cutoff);
|
|
1457
|
+
const queueNames = expiredQueues.map((row) => row.name);
|
|
1458
|
+
let cascadedSchedulers = 0;
|
|
1459
|
+
if (queueNames.length > 0) {
|
|
1460
|
+
const placeholders = queueNames.map(() => "?").join(",");
|
|
1461
|
+
cascadedSchedulers = database.prepare(`DELETE FROM schedulers WHERE queue IN (${placeholders})`).run(...queueNames).changes;
|
|
1462
|
+
database.prepare(`DELETE FROM queues WHERE name IN (${placeholders})`).run(...queueNames);
|
|
1463
|
+
}
|
|
1464
|
+
const staleSchedulers = database.prepare("DELETE FROM schedulers WHERE last_observed_at < ?").run(cutoff).changes;
|
|
1465
|
+
const staleJobs = database.prepare("DELETE FROM jobs WHERE last_observed_at < ?").run(cutoff).changes;
|
|
1953
1466
|
return {
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
total: 0,
|
|
1958
|
-
error: message
|
|
1467
|
+
queuesDeleted: queueNames.length,
|
|
1468
|
+
schedulersDeleted: cascadedSchedulers + staleSchedulers,
|
|
1469
|
+
jobsDeleted: staleJobs
|
|
1959
1470
|
};
|
|
1960
|
-
}
|
|
1961
|
-
if (getSqliteDb() === syncDb) {
|
|
1962
|
-
try {
|
|
1963
|
-
dropSyncStaging();
|
|
1964
|
-
} catch (cleanupError) {
|
|
1965
|
-
console.error(`Failed to drop sync_staging for queue "${queueName}":`, cleanupError);
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
releaseSyncLock();
|
|
1969
|
-
}
|
|
1471
|
+
})();
|
|
1970
1472
|
}
|
|
1971
|
-
|
|
1972
|
-
|
|
1473
|
+
|
|
1474
|
+
// src/data/queue-store-lifecycle.ts
|
|
1475
|
+
var CLEANUP_INTERVAL_MS = 60000;
|
|
1476
|
+
function runQueueStoreCleanupIfDue(ctx, now = Date.now()) {
|
|
1477
|
+
const lastCleanupAt = ctx.queueStore.lastCleanupAt;
|
|
1478
|
+
if (lastCleanupAt !== null && now - lastCleanupAt < CLEANUP_INTERVAL_MS) {
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
ctx.queueStore.lastCleanupAt = now;
|
|
1973
1482
|
try {
|
|
1974
|
-
|
|
1483
|
+
expireStaleRecords(ctx, { now });
|
|
1975
1484
|
} catch (error) {
|
|
1976
|
-
|
|
1977
|
-
console.error("SQLite full sync: queue discovery failed:", error);
|
|
1978
|
-
return {
|
|
1979
|
-
queues: 0,
|
|
1980
|
-
totalInserted: 0,
|
|
1981
|
-
totalSoftDeleted: 0,
|
|
1982
|
-
totalCompacted: 0,
|
|
1983
|
-
errors: [{ queue: "", error: message }]
|
|
1984
|
-
};
|
|
1985
|
-
}
|
|
1986
|
-
let totalInserted = 0;
|
|
1987
|
-
let totalSoftDeleted = 0;
|
|
1988
|
-
const errors = [];
|
|
1989
|
-
const invariantErrors = [];
|
|
1990
|
-
for (const q of queues) {
|
|
1991
|
-
try {
|
|
1992
|
-
const result = await syncQueue(q);
|
|
1993
|
-
if (result.error) {
|
|
1994
|
-
errors.push({ queue: q, error: result.error });
|
|
1995
|
-
} else {
|
|
1996
|
-
totalInserted += result.inserted;
|
|
1997
|
-
totalSoftDeleted += result.softDeleted;
|
|
1998
|
-
}
|
|
1999
|
-
} catch (error) {
|
|
2000
|
-
if (!(error instanceof JobResurrectionError)) {
|
|
2001
|
-
throw error;
|
|
2002
|
-
}
|
|
2003
|
-
invariantErrors.push({ queue: q, error: error.message });
|
|
2004
|
-
}
|
|
2005
|
-
}
|
|
2006
|
-
if (invariantErrors.length > 0) {
|
|
2007
|
-
throw new FullSyncInvariantError(invariantErrors);
|
|
1485
|
+
console.warn("Failed to expire stale queue-store records:", error instanceof Error ? error.message : error);
|
|
2008
1486
|
}
|
|
2009
|
-
const totalCompacted = compactRemovedJobs(Date.now(), getConfig().retentionMs);
|
|
2010
|
-
return {
|
|
2011
|
-
queues: queues.length,
|
|
2012
|
-
totalInserted,
|
|
2013
|
-
totalSoftDeleted,
|
|
2014
|
-
totalCompacted,
|
|
2015
|
-
errors
|
|
2016
|
-
};
|
|
2017
1487
|
}
|
|
2018
1488
|
|
|
2019
1489
|
// src/polling.ts
|
|
@@ -2044,6 +1514,10 @@ function schedulersViewState(schedulers, total) {
|
|
|
2044
1514
|
jobsTotalPages: 0
|
|
2045
1515
|
};
|
|
2046
1516
|
}
|
|
1517
|
+
function pageSchedulers(schedulers, page, pageSize = SCHEDULER_PAGE_SIZE) {
|
|
1518
|
+
const start = (page - 1) * pageSize;
|
|
1519
|
+
return schedulers.slice(start, start + pageSize);
|
|
1520
|
+
}
|
|
2047
1521
|
var EMPTY_QUEUE_VIEW = {
|
|
2048
1522
|
jobs: [],
|
|
2049
1523
|
jobsTotal: 0,
|
|
@@ -2055,20 +1529,36 @@ var EMPTY_QUEUE_VIEW = {
|
|
|
2055
1529
|
function clampQueueIndex(queues, currentIndex) {
|
|
2056
1530
|
return queues.length > 0 ? Math.min(currentIndex, queues.length - 1) : 0;
|
|
2057
1531
|
}
|
|
1532
|
+
function selectedQueueIndexAfterSort(queues, previousQueues, previousIndex) {
|
|
1533
|
+
const previousName = previousQueues[previousIndex]?.name;
|
|
1534
|
+
if (previousName) {
|
|
1535
|
+
const nextIndex = queues.findIndex((queue) => queue.name === previousName);
|
|
1536
|
+
if (nextIndex !== -1)
|
|
1537
|
+
return nextIndex;
|
|
1538
|
+
}
|
|
1539
|
+
return clampQueueIndex(queues, previousIndex);
|
|
1540
|
+
}
|
|
2058
1541
|
|
|
2059
1542
|
class PollingManager {
|
|
2060
1543
|
intervalId = null;
|
|
2061
1544
|
isRunning = false;
|
|
2062
1545
|
isPolling = false;
|
|
2063
|
-
|
|
1546
|
+
ctx = null;
|
|
1547
|
+
requireCtx() {
|
|
1548
|
+
if (!this.ctx) {
|
|
1549
|
+
throw new Error("PollingManager.start(ctx) must be called before refresh*/poll");
|
|
1550
|
+
}
|
|
1551
|
+
return this.ctx;
|
|
1552
|
+
}
|
|
1553
|
+
start(ctx) {
|
|
2064
1554
|
if (this.isRunning)
|
|
2065
1555
|
return;
|
|
2066
|
-
|
|
1556
|
+
this.ctx = ctx;
|
|
2067
1557
|
this.isRunning = true;
|
|
2068
1558
|
this.poll();
|
|
2069
1559
|
this.intervalId = setInterval(() => {
|
|
2070
1560
|
this.poll();
|
|
2071
|
-
}, config.pollInterval);
|
|
1561
|
+
}, ctx.config.pollInterval);
|
|
2072
1562
|
}
|
|
2073
1563
|
stop() {
|
|
2074
1564
|
if (this.intervalId) {
|
|
@@ -2076,21 +1566,25 @@ class PollingManager {
|
|
|
2076
1566
|
this.intervalId = null;
|
|
2077
1567
|
}
|
|
2078
1568
|
this.isRunning = false;
|
|
1569
|
+
this.ctx = null;
|
|
2079
1570
|
}
|
|
2080
1571
|
async poll() {
|
|
2081
1572
|
if (this.isPolling)
|
|
2082
1573
|
return;
|
|
1574
|
+
if (!this.ctx)
|
|
1575
|
+
return;
|
|
2083
1576
|
this.isPolling = true;
|
|
1577
|
+
const ctx = this.ctx;
|
|
2084
1578
|
const wasConnected = stateManager.getState().connected;
|
|
2085
1579
|
try {
|
|
2086
1580
|
stateManager.setState({ isLoading: true });
|
|
2087
|
-
const observedQueues = await getAllQueueStats();
|
|
2088
|
-
|
|
2089
|
-
const
|
|
1581
|
+
const observedQueues = await getAllQueueStats(ctx);
|
|
1582
|
+
recordObservedQueues(ctx, observedQueues);
|
|
1583
|
+
const currentState = stateManager.getState();
|
|
1584
|
+
const queues = sortQueues(listQueues(ctx), currentState.queueSortBy, currentState.queueSortOrder);
|
|
2090
1585
|
const rates = updateMetricsTracker(queues);
|
|
2091
1586
|
const globalMetrics = calculateGlobalMetricsFromQueueStats(queues, rates);
|
|
2092
|
-
const
|
|
2093
|
-
const clampedIndex = clampQueueIndex(queues, currentState.selectedQueueIndex);
|
|
1587
|
+
const clampedIndex = selectedQueueIndexAfterSort(queues, currentState.queues, currentState.selectedQueueIndex);
|
|
2094
1588
|
stateManager.setState({
|
|
2095
1589
|
queues,
|
|
2096
1590
|
globalMetrics,
|
|
@@ -2103,8 +1597,7 @@ class PollingManager {
|
|
|
2103
1597
|
if (currentState.jobsStatus === "schedulers") {
|
|
2104
1598
|
const observed = await this.fetchAllSchedulers(selectedQueue.name);
|
|
2105
1599
|
this.persistObservedSchedulers(selectedQueue.name, observed.schedulers);
|
|
2106
|
-
|
|
2107
|
-
stateManager.setState(schedulersViewState(storeResult.schedulers, observed.total));
|
|
1600
|
+
stateManager.setState(schedulersViewState(pageSchedulers(observed.schedulers, currentState.schedulersPage), observed.total));
|
|
2108
1601
|
} else {
|
|
2109
1602
|
const observedJobsResult = await this.fetchVisibleJobs(selectedQueue.name, currentState.jobsStatus, currentState.jobsPage);
|
|
2110
1603
|
this.persistObservedJobs(selectedQueue.name, observedJobsResult.jobs);
|
|
@@ -2119,31 +1612,39 @@ class PollingManager {
|
|
|
2119
1612
|
if (wasConnected) {
|
|
2120
1613
|
resetMetricsTracker();
|
|
2121
1614
|
}
|
|
2122
|
-
await this.applyDisconnectedFallback(errorMessage);
|
|
1615
|
+
await this.applyDisconnectedFallback(ctx, errorMessage);
|
|
2123
1616
|
} finally {
|
|
1617
|
+
runQueueStoreCleanupIfDue(ctx);
|
|
2124
1618
|
this.isPolling = false;
|
|
2125
1619
|
}
|
|
2126
1620
|
}
|
|
2127
|
-
async applyDisconnectedFallback(errorMessage) {
|
|
1621
|
+
async applyDisconnectedFallback(ctx, errorMessage) {
|
|
2128
1622
|
try {
|
|
2129
|
-
const queues =
|
|
1623
|
+
const queues = listQueues(ctx);
|
|
2130
1624
|
const currentState = stateManager.getState();
|
|
2131
|
-
const
|
|
2132
|
-
const
|
|
1625
|
+
const sortedQueues = sortQueues(queues, currentState.queueSortBy, currentState.queueSortOrder);
|
|
1626
|
+
const clampedIndex = selectedQueueIndexAfterSort(sortedQueues, currentState.queues, currentState.selectedQueueIndex);
|
|
1627
|
+
const selectedQueue = sortedQueues[clampedIndex];
|
|
2133
1628
|
let viewState;
|
|
2134
1629
|
if (selectedQueue && currentState.jobsStatus === "schedulers") {
|
|
2135
|
-
const storeResult =
|
|
1630
|
+
const storeResult = listSchedulers(ctx, selectedQueue.name, {
|
|
1631
|
+
page: currentState.schedulersPage,
|
|
1632
|
+
pageSize: SCHEDULER_PAGE_SIZE
|
|
1633
|
+
});
|
|
2136
1634
|
viewState = schedulersViewState(storeResult.schedulers, storeResult.total);
|
|
2137
1635
|
} else if (selectedQueue) {
|
|
2138
|
-
const jobsResult =
|
|
1636
|
+
const jobsResult = listJobs(ctx, selectedQueue.name, {
|
|
1637
|
+
state: currentState.jobsStatus,
|
|
1638
|
+
page: currentState.jobsPage
|
|
1639
|
+
});
|
|
2139
1640
|
viewState = jobsViewState(jobsResult);
|
|
2140
1641
|
} else {
|
|
2141
1642
|
viewState = EMPTY_QUEUE_VIEW;
|
|
2142
1643
|
}
|
|
2143
1644
|
stateManager.setState({
|
|
2144
|
-
queues,
|
|
1645
|
+
queues: sortedQueues,
|
|
2145
1646
|
selectedQueueIndex: clampedIndex,
|
|
2146
|
-
globalMetrics: calculateGlobalMetricsFromQueueStats(
|
|
1647
|
+
globalMetrics: calculateGlobalMetricsFromQueueStats(sortedQueues, ZERO_RATES),
|
|
2147
1648
|
connected: false,
|
|
2148
1649
|
error: errorMessage,
|
|
2149
1650
|
isLoading: false,
|
|
@@ -2159,26 +1660,26 @@ class PollingManager {
|
|
|
2159
1660
|
}
|
|
2160
1661
|
}
|
|
2161
1662
|
async fetchVisibleJobs(queueName, status, page) {
|
|
2162
|
-
return getJobs(queueName, status, page, undefined, true);
|
|
1663
|
+
return getJobs(this.requireCtx(), queueName, status, page, undefined, true);
|
|
2163
1664
|
}
|
|
2164
1665
|
persistObservedJobs(queueName, jobs) {
|
|
1666
|
+
const ctx = this.requireCtx();
|
|
2165
1667
|
try {
|
|
2166
|
-
|
|
2167
|
-
markPolledWrites(queueName, jobs.map((j) => j.id));
|
|
1668
|
+
recordObservedJobs(ctx, queueName, jobs);
|
|
2168
1669
|
} catch (error) {
|
|
2169
1670
|
console.warn(`Failed to persist observed jobs for "${queueName}":`, error instanceof Error ? error.message : error);
|
|
2170
1671
|
}
|
|
2171
1672
|
}
|
|
2172
1673
|
async fetchAllSchedulers(queueName) {
|
|
2173
|
-
const firstBatch = await getAllJobSchedulers(queueName);
|
|
1674
|
+
const firstBatch = await getAllJobSchedulers(this.requireCtx(), queueName);
|
|
2174
1675
|
if (firstBatch.schedulers.length >= firstBatch.total) {
|
|
2175
1676
|
return firstBatch;
|
|
2176
1677
|
}
|
|
2177
|
-
return getAllJobSchedulers(queueName, firstBatch.total);
|
|
1678
|
+
return getAllJobSchedulers(this.requireCtx(), queueName, firstBatch.total);
|
|
2178
1679
|
}
|
|
2179
1680
|
persistObservedSchedulers(queueName, schedulers) {
|
|
2180
1681
|
try {
|
|
2181
|
-
|
|
1682
|
+
recordObservedSchedulers(this.requireCtx(), queueName, schedulers);
|
|
2182
1683
|
} catch (error) {
|
|
2183
1684
|
console.warn(`Failed to persist observed schedulers for "${queueName}":`, error instanceof Error ? error.message : error);
|
|
2184
1685
|
}
|
|
@@ -2205,7 +1706,10 @@ class PollingManager {
|
|
|
2205
1706
|
} catch (error) {
|
|
2206
1707
|
console.error("Failed to observe jobs:", error instanceof Error ? error.message : error);
|
|
2207
1708
|
}
|
|
2208
|
-
const jobsResult = observedJobsResult ??
|
|
1709
|
+
const jobsResult = observedJobsResult ?? listJobs(this.requireCtx(), selectedQueue.name, {
|
|
1710
|
+
state: state.jobsStatus,
|
|
1711
|
+
page: state.jobsPage
|
|
1712
|
+
});
|
|
2209
1713
|
stateManager.setState({
|
|
2210
1714
|
jobs: jobsResult.jobs,
|
|
2211
1715
|
jobsTotal: jobsResult.total,
|
|
@@ -2233,17 +1737,95 @@ class PollingManager {
|
|
|
2233
1737
|
} catch (error) {
|
|
2234
1738
|
console.error("Failed to refresh schedulers:", error instanceof Error ? error.message : error);
|
|
2235
1739
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
1740
|
+
if (observed) {
|
|
1741
|
+
stateManager.setState({
|
|
1742
|
+
schedulers: pageSchedulers(observed.schedulers, state.schedulersPage),
|
|
1743
|
+
schedulersTotal: observed.total,
|
|
1744
|
+
schedulersTotalPages: Math.ceil(observed.total / SCHEDULER_PAGE_SIZE)
|
|
1745
|
+
});
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
const storeResult = listSchedulers(this.requireCtx(), selectedQueue.name, {
|
|
1749
|
+
page: state.schedulersPage,
|
|
1750
|
+
pageSize: SCHEDULER_PAGE_SIZE
|
|
1751
|
+
});
|
|
2238
1752
|
stateManager.setState({
|
|
2239
1753
|
schedulers: storeResult.schedulers,
|
|
2240
|
-
schedulersTotal: total,
|
|
2241
|
-
schedulersTotalPages: Math.ceil(total / SCHEDULER_PAGE_SIZE)
|
|
1754
|
+
schedulersTotal: storeResult.total,
|
|
1755
|
+
schedulersTotalPages: Math.ceil(storeResult.total / SCHEDULER_PAGE_SIZE)
|
|
2242
1756
|
});
|
|
2243
1757
|
}
|
|
2244
1758
|
}
|
|
2245
1759
|
var pollingManager = new PollingManager;
|
|
2246
1760
|
|
|
1761
|
+
// src/context.ts
|
|
1762
|
+
import { RedisConnection } from "bullmq";
|
|
1763
|
+
function createRedisClient(config) {
|
|
1764
|
+
let connection = null;
|
|
1765
|
+
let clientPromise = null;
|
|
1766
|
+
const getClient = () => {
|
|
1767
|
+
if (!clientPromise) {
|
|
1768
|
+
connection = new RedisConnection({
|
|
1769
|
+
...redisConnectionOptions(config),
|
|
1770
|
+
lazyConnect: true,
|
|
1771
|
+
retryStrategy: (times) => {
|
|
1772
|
+
if (times > 3) {
|
|
1773
|
+
return null;
|
|
1774
|
+
}
|
|
1775
|
+
return Math.min(times * 200, 2000);
|
|
1776
|
+
}
|
|
1777
|
+
}, { blocking: false });
|
|
1778
|
+
connection.on("error", () => {});
|
|
1779
|
+
clientPromise = connection.client.catch((err) => {
|
|
1780
|
+
clientPromise = null;
|
|
1781
|
+
connection = null;
|
|
1782
|
+
throw err;
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
return clientPromise;
|
|
1786
|
+
};
|
|
1787
|
+
return {
|
|
1788
|
+
get status() {
|
|
1789
|
+
return connection?.status ?? "wait";
|
|
1790
|
+
},
|
|
1791
|
+
async connect() {
|
|
1792
|
+
await getClient();
|
|
1793
|
+
},
|
|
1794
|
+
async quit() {
|
|
1795
|
+
if (!connection)
|
|
1796
|
+
return;
|
|
1797
|
+
await connection.close();
|
|
1798
|
+
connection = null;
|
|
1799
|
+
clientPromise = null;
|
|
1800
|
+
},
|
|
1801
|
+
async scan(cursor, ...args) {
|
|
1802
|
+
const client = await getClient();
|
|
1803
|
+
return client.scan(cursor, ...args);
|
|
1804
|
+
},
|
|
1805
|
+
async del(...keys) {
|
|
1806
|
+
const client = await getClient();
|
|
1807
|
+
return client.del(...keys);
|
|
1808
|
+
}
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
function createContext(config, opts = {}) {
|
|
1812
|
+
const redis = createRedisClient(config);
|
|
1813
|
+
const db = createSqliteDb(config, opts.dbPath);
|
|
1814
|
+
return {
|
|
1815
|
+
config,
|
|
1816
|
+
redis,
|
|
1817
|
+
db,
|
|
1818
|
+
queueCache: new Map,
|
|
1819
|
+
queueNamesCache: null,
|
|
1820
|
+
queueStore: { lastCleanupAt: null }
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
async function closeContext(ctx) {
|
|
1824
|
+
await closeAllQueues(ctx);
|
|
1825
|
+
await ctx.redis.quit().catch(() => {});
|
|
1826
|
+
ctx.db.close();
|
|
1827
|
+
}
|
|
1828
|
+
|
|
2247
1829
|
// src/ui/layout.ts
|
|
2248
1830
|
import { BoxRenderable, TextRenderable, t, fg, bold } from "@opentui/core";
|
|
2249
1831
|
|
|
@@ -2397,6 +1979,14 @@ import {
|
|
|
2397
1979
|
SelectRenderable,
|
|
2398
1980
|
SelectRenderableEvents
|
|
2399
1981
|
} from "@opentui/core";
|
|
1982
|
+
function formatQueueTaskBar(total, maxTotal, width = 8) {
|
|
1983
|
+
if (width <= 0)
|
|
1984
|
+
return "";
|
|
1985
|
+
if (maxTotal <= 0 || total <= 0)
|
|
1986
|
+
return ".".repeat(width);
|
|
1987
|
+
const filled = Math.max(1, Math.round(total / maxTotal * width));
|
|
1988
|
+
return "#".repeat(filled).padEnd(width, ".");
|
|
1989
|
+
}
|
|
2400
1990
|
function createQueueList(renderer, parent) {
|
|
2401
1991
|
const container = new BoxRenderable2(renderer, {
|
|
2402
1992
|
id: "queue-list-container",
|
|
@@ -2454,9 +2044,10 @@ function createQueueList(renderer, parent) {
|
|
|
2454
2044
|
});
|
|
2455
2045
|
return { container, title, select, emptyText };
|
|
2456
2046
|
}
|
|
2457
|
-
function updateQueueList(elements, queues, selectedIndex, isFocused) {
|
|
2047
|
+
function updateQueueList(elements, queues, selectedIndex, isFocused, sortBy = "name", sortOrder = "asc") {
|
|
2458
2048
|
const { select, emptyText, title } = elements;
|
|
2459
|
-
|
|
2049
|
+
const sortText = queueSortLabel(sortBy, sortOrder);
|
|
2050
|
+
title.content = isFocused ? ` QUEUES [*] ${sortText}` : ` QUEUES ${sortText}`;
|
|
2460
2051
|
title.bg = isFocused ? colors.surface1 : colors.surface0;
|
|
2461
2052
|
if (queues.length === 0) {
|
|
2462
2053
|
select.visible = false;
|
|
@@ -2465,9 +2056,10 @@ function updateQueueList(elements, queues, selectedIndex, isFocused) {
|
|
|
2465
2056
|
}
|
|
2466
2057
|
select.visible = true;
|
|
2467
2058
|
emptyText.visible = false;
|
|
2059
|
+
const maxTotal = Math.max(...queues.map((queue) => queue.total), 0);
|
|
2468
2060
|
const options = queues.map((queue) => ({
|
|
2469
2061
|
name: queue.name,
|
|
2470
|
-
description: `${queue.total} jobs | ${queue.isPaused ? "PAUSED" : "active"}`,
|
|
2062
|
+
description: `${formatQueueTaskBar(queue.total, maxTotal)} ${queue.total} jobs | fail ${queue.counts.failed} | ${queue.isPaused ? "PAUSED" : "active"}`,
|
|
2471
2063
|
value: queue
|
|
2472
2064
|
}));
|
|
2473
2065
|
select.options = options;
|
|
@@ -3449,10 +3041,16 @@ function updatePageJump(elements, visible, inputValue, currentPage, totalPages)
|
|
|
3449
3041
|
|
|
3450
3042
|
// src/app.ts
|
|
3451
3043
|
class App {
|
|
3044
|
+
ctx;
|
|
3452
3045
|
renderer = null;
|
|
3453
3046
|
elements = null;
|
|
3454
3047
|
unsubscribeState = null;
|
|
3455
|
-
|
|
3048
|
+
constructor(ctx) {
|
|
3049
|
+
this.ctx = ctx;
|
|
3050
|
+
}
|
|
3051
|
+
requireCtx() {
|
|
3052
|
+
return this.ctx;
|
|
3053
|
+
}
|
|
3456
3054
|
async start() {
|
|
3457
3055
|
this.renderer = await createCliRenderer({
|
|
3458
3056
|
exitOnCtrlC: false,
|
|
@@ -3464,15 +3062,13 @@ class App {
|
|
|
3464
3062
|
this.unsubscribeState = stateManager.subscribe(() => {
|
|
3465
3063
|
this.render();
|
|
3466
3064
|
});
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
runFullSync();
|
|
3475
|
-
this.syncIntervalId = setInterval(runFullSync, 60000);
|
|
3065
|
+
const ctx = this.ctx;
|
|
3066
|
+
try {
|
|
3067
|
+
await ctx.redis.connect();
|
|
3068
|
+
} catch (error) {
|
|
3069
|
+
console.error("Redis connection failed at startup:", error instanceof Error ? error.message : error);
|
|
3070
|
+
}
|
|
3071
|
+
pollingManager.start(ctx);
|
|
3476
3072
|
this.render();
|
|
3477
3073
|
}
|
|
3478
3074
|
createElements() {
|
|
@@ -3602,6 +3198,12 @@ class App {
|
|
|
3602
3198
|
case "r":
|
|
3603
3199
|
await pollingManager.refresh();
|
|
3604
3200
|
break;
|
|
3201
|
+
case "s":
|
|
3202
|
+
if (state.focusedPane === "queues") {
|
|
3203
|
+
stateManager.cycleQueueSort();
|
|
3204
|
+
await pollingManager.refresh();
|
|
3205
|
+
}
|
|
3206
|
+
break;
|
|
3605
3207
|
case "g":
|
|
3606
3208
|
if (state.focusedPane === "jobs") {
|
|
3607
3209
|
if (state.jobsStatus === "schedulers" && state.schedulersTotalPages > 1) {
|
|
@@ -3638,7 +3240,7 @@ class App {
|
|
|
3638
3240
|
if (!selectedJob || !selectedQueue)
|
|
3639
3241
|
return;
|
|
3640
3242
|
try {
|
|
3641
|
-
const detail = await getJobDetail(selectedQueue.name, selectedJob.id);
|
|
3243
|
+
const detail = await getJobDetail(this.requireCtx(), selectedQueue.name, selectedJob.id);
|
|
3642
3244
|
if (detail) {
|
|
3643
3245
|
stateManager.openJobDetail(detail);
|
|
3644
3246
|
}
|
|
@@ -3650,7 +3252,7 @@ class App {
|
|
|
3650
3252
|
if (!selectedScheduler || !selectedQueue)
|
|
3651
3253
|
return;
|
|
3652
3254
|
try {
|
|
3653
|
-
const detail = await getJobSchedulerDetail(selectedQueue.name, selectedScheduler.key);
|
|
3255
|
+
const detail = await getJobSchedulerDetail(this.requireCtx(), selectedQueue.name, selectedScheduler.key);
|
|
3654
3256
|
if (detail) {
|
|
3655
3257
|
stateManager.openSchedulerDetail(detail);
|
|
3656
3258
|
}
|
|
@@ -3663,7 +3265,7 @@ class App {
|
|
|
3663
3265
|
if (!schedulerDetail?.nextJob || !selectedQueue)
|
|
3664
3266
|
return;
|
|
3665
3267
|
try {
|
|
3666
|
-
const jobDetail = await getJobDetail(selectedQueue.name, schedulerDetail.nextJob.id);
|
|
3268
|
+
const jobDetail = await getJobDetail(this.requireCtx(), selectedQueue.name, schedulerDetail.nextJob.id);
|
|
3667
3269
|
if (jobDetail) {
|
|
3668
3270
|
stateManager.closeSchedulerDetail();
|
|
3669
3271
|
stateManager.openJobDetail(jobDetail);
|
|
@@ -3685,7 +3287,7 @@ class App {
|
|
|
3685
3287
|
return;
|
|
3686
3288
|
}
|
|
3687
3289
|
try {
|
|
3688
|
-
await deleteJob(selectedQueue.name, jobId);
|
|
3290
|
+
await deleteJob(this.requireCtx(), selectedQueue.name, jobId);
|
|
3689
3291
|
stateManager.hideDeleteConfirm();
|
|
3690
3292
|
stateManager.closeJobDetail();
|
|
3691
3293
|
await pollingManager.refresh();
|
|
@@ -3727,10 +3329,10 @@ class App {
|
|
|
3727
3329
|
confirmDialog,
|
|
3728
3330
|
pageJump
|
|
3729
3331
|
} = this.elements;
|
|
3730
|
-
const
|
|
3731
|
-
updateHeaderStatus(layout.headerStatus, state.connected, state.error,
|
|
3332
|
+
const headerConfig = this.ctx.config;
|
|
3333
|
+
updateHeaderStatus(layout.headerStatus, state.connected, state.error, headerConfig.redis.host, headerConfig.redis.port);
|
|
3732
3334
|
updateGlobalMetrics(globalMetrics, state.globalMetrics);
|
|
3733
|
-
updateQueueList(queueList, state.queues, state.selectedQueueIndex, state.focusedPane === "queues");
|
|
3335
|
+
updateQueueList(queueList, state.queues, state.selectedQueueIndex, state.focusedPane === "queues", state.queueSortBy, state.queueSortOrder);
|
|
3734
3336
|
const selectedQueue = stateManager.getSelectedQueue();
|
|
3735
3337
|
updateQueueStats(queueStats, selectedQueue);
|
|
3736
3338
|
updateStatusFilter(statusFilter, state.jobsStatus);
|
|
@@ -3754,28 +3356,254 @@ class App {
|
|
|
3754
3356
|
}
|
|
3755
3357
|
updatePageJump(pageJump, state.showPageJump, state.pageJumpInput, isSchedulersView ? state.schedulersPage : state.jobsPage, isSchedulersView ? state.schedulersTotalPages : state.jobsTotalPages);
|
|
3756
3358
|
}
|
|
3757
|
-
async cleanup() {
|
|
3758
|
-
pollingManager.stop();
|
|
3759
|
-
if (this.
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
if (this.
|
|
3764
|
-
this.
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3359
|
+
async cleanup() {
|
|
3360
|
+
pollingManager.stop();
|
|
3361
|
+
if (this.unsubscribeState) {
|
|
3362
|
+
this.unsubscribeState();
|
|
3363
|
+
}
|
|
3364
|
+
await closeContext(this.ctx);
|
|
3365
|
+
if (this.renderer) {
|
|
3366
|
+
this.renderer.destroy();
|
|
3367
|
+
}
|
|
3368
|
+
process.exit(0);
|
|
3369
|
+
}
|
|
3370
|
+
}
|
|
3371
|
+
|
|
3372
|
+
// src/cli.ts
|
|
3373
|
+
import { parseArgs } from "util";
|
|
3374
|
+
|
|
3375
|
+
// src/errors.ts
|
|
3376
|
+
function writeError(message, code, details) {
|
|
3377
|
+
process.stderr.write(JSON.stringify({ error: message, code, details }) + `
|
|
3378
|
+
`);
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
// src/profiles.ts
|
|
3382
|
+
import { existsSync, readFileSync } from "fs";
|
|
3383
|
+
import { homedir } from "os";
|
|
3384
|
+
import { join } from "path";
|
|
3385
|
+
function isRecord(value) {
|
|
3386
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
3387
|
+
}
|
|
3388
|
+
function coerceOptionalPositiveInt(value) {
|
|
3389
|
+
if (value === undefined)
|
|
3390
|
+
return;
|
|
3391
|
+
const numberValue = Number(value);
|
|
3392
|
+
if (!Number.isInteger(numberValue) || numberValue <= 0)
|
|
3393
|
+
return null;
|
|
3394
|
+
return numberValue;
|
|
3395
|
+
}
|
|
3396
|
+
var PROFILE_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queues", "cacheTtlMs"];
|
|
3397
|
+
var PROFILES_FILE_ALLOWED_KEYS = ["defaultProfile", "profiles"];
|
|
3398
|
+
function unknownKeyMessage(path, key, allowed) {
|
|
3399
|
+
return `unknown key '${key}' in ${path} (allowed: ${allowed.join(", ")})`;
|
|
3400
|
+
}
|
|
3401
|
+
function validateProfile(value, path, errors) {
|
|
3402
|
+
if (!isRecord(value)) {
|
|
3403
|
+
errors.push(`${path} must be an object`);
|
|
3404
|
+
return null;
|
|
3405
|
+
}
|
|
3406
|
+
for (const key of Object.keys(value)) {
|
|
3407
|
+
if (!PROFILE_ALLOWED_KEYS.includes(key)) {
|
|
3408
|
+
errors.push(unknownKeyMessage(path, key, PROFILE_ALLOWED_KEYS));
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
const profile = {};
|
|
3412
|
+
if (value.redis !== undefined) {
|
|
3413
|
+
if (!isRecord(value.redis)) {
|
|
3414
|
+
errors.push(`${path}.redis must be an object`);
|
|
3415
|
+
} else {
|
|
3416
|
+
for (const key of Object.keys(value.redis)) {
|
|
3417
|
+
if (key !== "url")
|
|
3418
|
+
errors.push(unknownKeyMessage(`${path}.redis`, key, ["url"]));
|
|
3419
|
+
}
|
|
3420
|
+
if (typeof value.redis.url !== "string") {
|
|
3421
|
+
errors.push(`${path}.redis.url must be a string`);
|
|
3422
|
+
} else {
|
|
3423
|
+
profile.redis = { url: value.redis.url };
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
const pollInterval = coerceOptionalPositiveInt(value.pollInterval);
|
|
3428
|
+
if (pollInterval === null)
|
|
3429
|
+
errors.push(`${path}.pollInterval must be a positive integer`);
|
|
3430
|
+
else if (pollInterval !== undefined)
|
|
3431
|
+
profile.pollInterval = pollInterval;
|
|
3432
|
+
if (value.prefix !== undefined) {
|
|
3433
|
+
if (typeof value.prefix !== "string")
|
|
3434
|
+
errors.push(`${path}.prefix must be a string`);
|
|
3435
|
+
else
|
|
3436
|
+
profile.prefix = value.prefix;
|
|
3437
|
+
}
|
|
3438
|
+
if (value.queues !== undefined) {
|
|
3439
|
+
if (!Array.isArray(value.queues) || value.queues.some((queue) => typeof queue !== "string")) {
|
|
3440
|
+
errors.push(`${path}.queues must be an array of strings`);
|
|
3441
|
+
} else {
|
|
3442
|
+
profile.queues = value.queues;
|
|
3443
|
+
}
|
|
3444
|
+
}
|
|
3445
|
+
const cacheTtlMs = coerceOptionalPositiveInt(value.cacheTtlMs);
|
|
3446
|
+
if (cacheTtlMs === null)
|
|
3447
|
+
errors.push(`${path}.cacheTtlMs must be a positive integer`);
|
|
3448
|
+
else if (cacheTtlMs !== undefined)
|
|
3449
|
+
profile.cacheTtlMs = cacheTtlMs;
|
|
3450
|
+
return profile;
|
|
3451
|
+
}
|
|
3452
|
+
function validateProfilesFile(value) {
|
|
3453
|
+
const errors = [];
|
|
3454
|
+
if (!isRecord(value))
|
|
3455
|
+
return ["config file must be an object"];
|
|
3456
|
+
for (const key of Object.keys(value)) {
|
|
3457
|
+
if (!PROFILES_FILE_ALLOWED_KEYS.includes(key)) {
|
|
3458
|
+
errors.push(unknownKeyMessage("config file", key, PROFILES_FILE_ALLOWED_KEYS));
|
|
3459
|
+
}
|
|
3460
|
+
}
|
|
3461
|
+
const file = { profiles: {} };
|
|
3462
|
+
if (value.defaultProfile !== undefined) {
|
|
3463
|
+
if (typeof value.defaultProfile !== "string")
|
|
3464
|
+
errors.push("defaultProfile must be a string");
|
|
3465
|
+
else
|
|
3466
|
+
file.defaultProfile = value.defaultProfile;
|
|
3467
|
+
}
|
|
3468
|
+
if (value.profiles !== undefined) {
|
|
3469
|
+
if (!isRecord(value.profiles)) {
|
|
3470
|
+
errors.push("profiles must be an object");
|
|
3471
|
+
} else {
|
|
3472
|
+
for (const [name, profileValue] of Object.entries(value.profiles)) {
|
|
3473
|
+
const profile = validateProfile(profileValue, `profiles.${name}`, errors);
|
|
3474
|
+
if (profile)
|
|
3475
|
+
file.profiles[name] = profile;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
return errors.length > 0 ? errors : file;
|
|
3480
|
+
}
|
|
3481
|
+
function resolveConfigPath(explicitPath, env = process.env) {
|
|
3482
|
+
if (explicitPath)
|
|
3483
|
+
return explicitPath;
|
|
3484
|
+
if (env.BULLMQ_DASH_CONFIG)
|
|
3485
|
+
return env.BULLMQ_DASH_CONFIG;
|
|
3486
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
3487
|
+
if (xdg)
|
|
3488
|
+
return join(xdg, "bullmq-dash", "config.json");
|
|
3489
|
+
return join(homedir(), ".config", "bullmq-dash", "config.json");
|
|
3490
|
+
}
|
|
3491
|
+
var ENV_REF = /^\$\{([A-Z_][A-Z0-9_]*)\}$/;
|
|
3492
|
+
function expandEnvRefs(value, env = process.env) {
|
|
3493
|
+
if (typeof value === "string") {
|
|
3494
|
+
const match = value.match(ENV_REF);
|
|
3495
|
+
if (!match)
|
|
3496
|
+
return value;
|
|
3497
|
+
const key = match[1];
|
|
3498
|
+
const resolved = env[key];
|
|
3499
|
+
if (resolved === undefined) {
|
|
3500
|
+
throw new Error(`Environment variable '${key}' referenced in profile is not set`);
|
|
3501
|
+
}
|
|
3502
|
+
return resolved;
|
|
3503
|
+
}
|
|
3504
|
+
if (Array.isArray(value)) {
|
|
3505
|
+
return value.map((v) => expandEnvRefs(v, env));
|
|
3506
|
+
}
|
|
3507
|
+
if (value && typeof value === "object") {
|
|
3508
|
+
const out = {};
|
|
3509
|
+
for (const [k, v] of Object.entries(value)) {
|
|
3510
|
+
out[k] = expandEnvRefs(v, env);
|
|
3511
|
+
}
|
|
3512
|
+
return out;
|
|
3513
|
+
}
|
|
3514
|
+
return value;
|
|
3515
|
+
}
|
|
3516
|
+
function loadProfile(opts = {}) {
|
|
3517
|
+
const env = opts.env ?? process.env;
|
|
3518
|
+
const explicitConfig = !!opts.configPath || !!env.BULLMQ_DASH_CONFIG;
|
|
3519
|
+
const explicitProfile = !!opts.profileName;
|
|
3520
|
+
const path = resolveConfigPath(opts.configPath, env);
|
|
3521
|
+
if (!existsSync(path)) {
|
|
3522
|
+
if (explicitConfig || explicitProfile) {
|
|
3523
|
+
writeError(`Config file not found: ${path}`, "CONFIG_ERROR", explicitConfig ? "Check the path passed to --config or $BULLMQ_DASH_CONFIG." : `--profile requires a config file. Create one at ${path} or pass --config <path>.`);
|
|
3524
|
+
process.exit(2);
|
|
3525
|
+
}
|
|
3526
|
+
return null;
|
|
3527
|
+
}
|
|
3528
|
+
let raw;
|
|
3529
|
+
try {
|
|
3530
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
3531
|
+
} catch (error) {
|
|
3532
|
+
writeError(`Failed to parse config file: ${path}`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
3533
|
+
process.exit(2);
|
|
3534
|
+
}
|
|
3535
|
+
const parsed = validateProfilesFile(raw);
|
|
3536
|
+
if (Array.isArray(parsed)) {
|
|
3537
|
+
writeError(`Invalid config file: ${path}`, "CONFIG_ERROR", parsed.join("; "));
|
|
3538
|
+
process.exit(2);
|
|
3539
|
+
}
|
|
3540
|
+
const file = parsed;
|
|
3541
|
+
const name = opts.profileName ?? file.defaultProfile;
|
|
3542
|
+
if (!name)
|
|
3543
|
+
return null;
|
|
3544
|
+
const profile = file.profiles[name];
|
|
3545
|
+
if (!profile) {
|
|
3546
|
+
const available = Object.keys(file.profiles);
|
|
3547
|
+
writeError(`Profile '${name}' not found in ${path}`, "CONFIG_ERROR", available.length > 0 ? `Available profiles: ${available.join(", ")}` : "No profiles are defined in the config file.");
|
|
3548
|
+
process.exit(2);
|
|
3549
|
+
}
|
|
3550
|
+
let expanded;
|
|
3551
|
+
try {
|
|
3552
|
+
expanded = expandEnvRefs(profile, env);
|
|
3553
|
+
} catch (error) {
|
|
3554
|
+
writeError(`Failed to resolve env vars in profile '${name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
3555
|
+
process.exit(2);
|
|
3556
|
+
}
|
|
3557
|
+
return { sourcePath: path, name, profile: expanded };
|
|
3558
|
+
}
|
|
3559
|
+
function decodeUrlAuthField(raw, field) {
|
|
3560
|
+
try {
|
|
3561
|
+
return decodeURIComponent(raw);
|
|
3562
|
+
} catch {
|
|
3563
|
+
throw new Error(`Invalid percent-encoding in URL ${field}`);
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
function redactUrl(input) {
|
|
3567
|
+
return input.replace(/(:\/\/)[^/]*@/, "$1[REDACTED]@");
|
|
3568
|
+
}
|
|
3569
|
+
function parseRedisUrl(input) {
|
|
3570
|
+
let parsed;
|
|
3571
|
+
try {
|
|
3572
|
+
parsed = new URL(input);
|
|
3573
|
+
} catch {
|
|
3574
|
+
throw new Error(`Not a valid URL: ${redactUrl(input)}`);
|
|
3575
|
+
}
|
|
3576
|
+
if (parsed.protocol !== "redis:" && parsed.protocol !== "rediss:") {
|
|
3577
|
+
throw new Error(`Unsupported scheme '${parsed.protocol}' (expected redis:// or rediss://)`);
|
|
3578
|
+
}
|
|
3579
|
+
if (!parsed.hostname) {
|
|
3580
|
+
throw new Error(`Missing host in URL: ${redactUrl(input)}`);
|
|
3581
|
+
}
|
|
3582
|
+
if (parsed.search || parsed.hash) {
|
|
3583
|
+
throw new Error("Query parameters and fragments are not supported in Redis URLs. " + "For TLS, use rediss:// (not ?ssl=true).");
|
|
3584
|
+
}
|
|
3585
|
+
const username = parsed.username ? decodeUrlAuthField(parsed.username, "username") : undefined;
|
|
3586
|
+
const password = parsed.password ? decodeUrlAuthField(parsed.password, "password") : undefined;
|
|
3587
|
+
let db;
|
|
3588
|
+
if (parsed.pathname && parsed.pathname !== "/") {
|
|
3589
|
+
const trimmed = parsed.pathname.replace(/^\//, "");
|
|
3590
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
3591
|
+
throw new Error(`Database in URL must be a non-negative integer, got '/${trimmed}'`);
|
|
3771
3592
|
}
|
|
3772
|
-
|
|
3593
|
+
db = Number(trimmed);
|
|
3773
3594
|
}
|
|
3595
|
+
const port = parsed.port ? Number(parsed.port) : 6379;
|
|
3596
|
+
return {
|
|
3597
|
+
host: parsed.hostname,
|
|
3598
|
+
port,
|
|
3599
|
+
username,
|
|
3600
|
+
password,
|
|
3601
|
+
db,
|
|
3602
|
+
tls: parsed.protocol === "rediss:"
|
|
3603
|
+
};
|
|
3774
3604
|
}
|
|
3775
|
-
|
|
3776
3605
|
// src/cli.ts
|
|
3777
|
-
|
|
3778
|
-
var PACKAGE_VERSION = "0.3.0";
|
|
3606
|
+
var PACKAGE_VERSION = "0.3.2";
|
|
3779
3607
|
var HELP_TEXT = `
|
|
3780
3608
|
bullmq-dash - Terminal UI dashboard for BullMQ queue monitoring
|
|
3781
3609
|
|
|
@@ -3787,8 +3615,9 @@ Commands:
|
|
|
3787
3615
|
queues list List all queues with job counts
|
|
3788
3616
|
queues delete <queue> Delete a queue and all its jobs
|
|
3789
3617
|
jobs list <queue> List jobs in a queue
|
|
3618
|
+
jobs failed <queue> List failed jobs in a queue
|
|
3790
3619
|
jobs get <queue> <job-id> Get full detail for a single job
|
|
3791
|
-
jobs retry <queue>
|
|
3620
|
+
jobs retry <queue> Retry failed jobs (supports --dry-run/--yes)
|
|
3792
3621
|
schedulers list <queue> List schedulers in a queue
|
|
3793
3622
|
schedulers get <queue> <scheduler-id> Get detail for a single scheduler
|
|
3794
3623
|
|
|
@@ -3821,8 +3650,10 @@ Examples:
|
|
|
3821
3650
|
bullmq-dash queues list --redis-url redis://localhost:6379
|
|
3822
3651
|
bullmq-dash queues list --profile prod
|
|
3823
3652
|
bullmq-dash queues list --redis-url redis://localhost --human-friendly
|
|
3653
|
+
bullmq-dash jobs failed email --redis-url redis://localhost
|
|
3824
3654
|
bullmq-dash jobs list email --redis-url redis://localhost --job-state failed
|
|
3825
3655
|
bullmq-dash jobs get email 123 --redis-url redis://localhost
|
|
3656
|
+
bullmq-dash jobs retry email --redis-url redis://localhost --job-id 123 --dry-run
|
|
3826
3657
|
bullmq-dash jobs retry email --redis-url redis://localhost --job-state failed --since 1h --dry-run
|
|
3827
3658
|
`;
|
|
3828
3659
|
var CONNECTION_OPTIONS_HELP = `
|
|
@@ -3850,11 +3681,19 @@ var QUEUES_LIST_HELP = `
|
|
|
3850
3681
|
Usage: bullmq-dash queues list [options]
|
|
3851
3682
|
|
|
3852
3683
|
List all discovered queues with their job counts per state.
|
|
3684
|
+
|
|
3685
|
+
Options:
|
|
3686
|
+
--sort-by <field> Sort queues by: name | task-size | waiting | active | completed | failed | delayed
|
|
3687
|
+
Aliases: size, total, wait
|
|
3688
|
+
--sort-order <order> Sort order: asc | desc
|
|
3689
|
+
Defaults to asc for name, desc for metrics.
|
|
3853
3690
|
${CONNECTION_OPTIONS_HELP}
|
|
3854
3691
|
|
|
3855
3692
|
Examples:
|
|
3856
3693
|
bullmq-dash queues list --redis-url redis://localhost
|
|
3857
3694
|
bullmq-dash queues list --redis-url redis://localhost:6380
|
|
3695
|
+
bullmq-dash queues list --redis-url redis://localhost --sort-by task-size
|
|
3696
|
+
bullmq-dash queues list --redis-url redis://localhost --sort-by failed
|
|
3858
3697
|
bullmq-dash queues list --redis-url redis://localhost | jq '.queues[] | select(.counts.failed > 0)'
|
|
3859
3698
|
`;
|
|
3860
3699
|
var QUEUES_DELETE_HELP = `
|
|
@@ -3877,11 +3716,27 @@ Usage: bullmq-dash jobs <action> <queue> [options]
|
|
|
3877
3716
|
|
|
3878
3717
|
Actions:
|
|
3879
3718
|
list <queue> List jobs in a queue
|
|
3719
|
+
failed <queue> List failed jobs in a queue
|
|
3880
3720
|
get <queue> <job-id> Get full detail for a single job
|
|
3881
|
-
retry <queue>
|
|
3721
|
+
retry <queue> Retry failed jobs (supports --dry-run/--yes)
|
|
3882
3722
|
|
|
3883
3723
|
Run 'bullmq-dash jobs <action> --help' for action-specific help.
|
|
3884
3724
|
`;
|
|
3725
|
+
var JOBS_FAILED_HELP = `
|
|
3726
|
+
Usage: bullmq-dash jobs failed <queue> [options]
|
|
3727
|
+
|
|
3728
|
+
List failed jobs in a queue. This is equivalent to:
|
|
3729
|
+
bullmq-dash jobs list <queue> --job-state failed
|
|
3730
|
+
|
|
3731
|
+
Options:
|
|
3732
|
+
--page-size <n> Max results to return (default: 1000)
|
|
3733
|
+
${CONNECTION_OPTIONS_HELP}
|
|
3734
|
+
|
|
3735
|
+
Examples:
|
|
3736
|
+
bullmq-dash jobs failed email --redis-url redis://localhost
|
|
3737
|
+
bullmq-dash jobs failed email --redis-url redis://localhost | jq '.jobs[] | {id, name, timestamp}'
|
|
3738
|
+
bullmq-dash jobs retry email --redis-url redis://localhost --job-id 42 --dry-run
|
|
3739
|
+
`;
|
|
3885
3740
|
var JOBS_LIST_HELP = `
|
|
3886
3741
|
Usage: bullmq-dash jobs list <queue> [options]
|
|
3887
3742
|
|
|
@@ -3910,13 +3765,14 @@ Examples:
|
|
|
3910
3765
|
bullmq-dash jobs get email 123 --redis-url redis://localhost | jq '.job.data'
|
|
3911
3766
|
`;
|
|
3912
3767
|
var JOBS_RETRY_HELP = `
|
|
3913
|
-
Usage: bullmq-dash jobs retry <queue> --job-state failed [options]
|
|
3768
|
+
Usage: bullmq-dash jobs retry <queue> (--job-id <id> | --job-state failed) [options]
|
|
3914
3769
|
|
|
3915
|
-
|
|
3770
|
+
Retry one failed job by ID, or bulk-retry failed jobs in a queue.
|
|
3916
3771
|
Always use --dry-run first to see what would be retried.
|
|
3917
3772
|
|
|
3918
|
-
Required:
|
|
3919
|
-
--job-
|
|
3773
|
+
Required, choose one:
|
|
3774
|
+
--job-id <id> Retry one failed job by ID
|
|
3775
|
+
--job-state failed Bulk-retry failed jobs (other states not supported)
|
|
3920
3776
|
|
|
3921
3777
|
Filters:
|
|
3922
3778
|
--since <duration> Only jobs that failed within this window.
|
|
@@ -3927,21 +3783,24 @@ Filters:
|
|
|
3927
3783
|
Safety:
|
|
3928
3784
|
--dry-run Show what WOULD be retried without enqueueing anything.
|
|
3929
3785
|
Prints matched count and sample job IDs.
|
|
3786
|
+
--yes Skip confirmation prompt (required in non-interactive scripts).
|
|
3930
3787
|
|
|
3931
3788
|
Exit codes:
|
|
3932
3789
|
0 Success (dry-run complete, or all matched jobs retried). Includes empty-match.
|
|
3933
3790
|
1 Runtime / fetch error (e.g. Redis connection failed)
|
|
3934
|
-
2 Config error (invalid flags, missing --job-state, --page-size > 10000)
|
|
3791
|
+
2 Config error (invalid flags, missing --job-id/--job-state, --page-size > 10000)
|
|
3935
3792
|
3 Partial failure \u2014 some jobs retried, some errored (see errors[])
|
|
3936
3793
|
|
|
3937
3794
|
${CONNECTION_OPTIONS_HELP}
|
|
3938
3795
|
|
|
3939
3796
|
Examples:
|
|
3940
3797
|
# Always start with a dry-run
|
|
3798
|
+
bullmq-dash jobs retry payments --redis-url redis://localhost --job-id 42 --dry-run
|
|
3941
3799
|
bullmq-dash jobs retry payments --redis-url redis://localhost --job-state failed --since 1h --dry-run
|
|
3942
3800
|
|
|
3943
3801
|
# Then retry for real
|
|
3944
|
-
bullmq-dash jobs retry payments --redis-url redis://localhost --job-
|
|
3802
|
+
bullmq-dash jobs retry payments --redis-url redis://localhost --job-id 42 --yes
|
|
3803
|
+
bullmq-dash jobs retry payments --redis-url redis://localhost --job-state failed --since 1h --yes
|
|
3945
3804
|
|
|
3946
3805
|
# Filter by job name
|
|
3947
3806
|
bullmq-dash jobs retry email --redis-url redis://localhost --job-state failed --name welcome-email --dry-run
|
|
@@ -3983,7 +3842,7 @@ Examples:
|
|
|
3983
3842
|
bullmq-dash schedulers get email my-cron --redis-url redis://localhost | jq '.scheduler.recentJobs'
|
|
3984
3843
|
`;
|
|
3985
3844
|
var RESOURCE_COMMANDS = new Set(["queues", "jobs", "schedulers"]);
|
|
3986
|
-
var ACTIONS = new Set(["list", "get", "retry", "delete"]);
|
|
3845
|
+
var ACTIONS = new Set(["list", "failed", "get", "retry", "delete"]);
|
|
3987
3846
|
function extractSubcommand(argv) {
|
|
3988
3847
|
const firstFlagIndex = argv.findIndex((arg) => arg.startsWith("-"));
|
|
3989
3848
|
if (firstFlagIndex === -1) {
|
|
@@ -4012,7 +3871,37 @@ function getRequiredArg(positionals, index, name, usage) {
|
|
|
4012
3871
|
}
|
|
4013
3872
|
return arg;
|
|
4014
3873
|
}
|
|
4015
|
-
function
|
|
3874
|
+
function parseQueueSortBy(rawValue) {
|
|
3875
|
+
if (rawValue === undefined)
|
|
3876
|
+
return;
|
|
3877
|
+
switch (rawValue) {
|
|
3878
|
+
case "size":
|
|
3879
|
+
case "total":
|
|
3880
|
+
case "task-size":
|
|
3881
|
+
return "task-size";
|
|
3882
|
+
case "wait":
|
|
3883
|
+
case "waiting":
|
|
3884
|
+
return "waiting";
|
|
3885
|
+
case "name":
|
|
3886
|
+
case "active":
|
|
3887
|
+
case "completed":
|
|
3888
|
+
case "failed":
|
|
3889
|
+
case "delayed":
|
|
3890
|
+
return rawValue;
|
|
3891
|
+
default:
|
|
3892
|
+
writeError(`Invalid --sort-by value: '${rawValue}'`, "CONFIG_ERROR", `Valid queue sort fields: ${QUEUE_SORT_FIELDS.join(", ")}. Aliases: size, total, wait.`);
|
|
3893
|
+
process.exit(2);
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
function parseSortOrder(rawValue) {
|
|
3897
|
+
if (rawValue === undefined)
|
|
3898
|
+
return;
|
|
3899
|
+
if (rawValue === "asc" || rawValue === "desc")
|
|
3900
|
+
return rawValue;
|
|
3901
|
+
writeError(`Invalid --sort-order value: '${rawValue}'`, "CONFIG_ERROR", "Valid sort orders: asc, desc.");
|
|
3902
|
+
process.exit(2);
|
|
3903
|
+
}
|
|
3904
|
+
function parseSubcommand(positionals, help, jobState, pageSize, since, name, retryJobId, dryRun, yes = false, sortBy, sortOrder) {
|
|
4016
3905
|
if (positionals.length === 0)
|
|
4017
3906
|
return;
|
|
4018
3907
|
const resource = positionals[0];
|
|
@@ -4044,7 +3933,13 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
|
|
|
4044
3933
|
if (help)
|
|
4045
3934
|
showSubcommandHelp(QUEUES_LIST_HELP);
|
|
4046
3935
|
assertArgCount(positionals, 2, "queues list [options]");
|
|
4047
|
-
|
|
3936
|
+
const parsedSortBy = parseQueueSortBy(sortBy);
|
|
3937
|
+
const parsedSortOrder = parseSortOrder(sortOrder);
|
|
3938
|
+
return {
|
|
3939
|
+
kind: "queues-list",
|
|
3940
|
+
sortBy: parsedSortBy,
|
|
3941
|
+
sortOrder: parsedSortOrder ?? (parsedSortBy ? defaultSortOrder(parsedSortBy) : undefined)
|
|
3942
|
+
};
|
|
4048
3943
|
}
|
|
4049
3944
|
if (action === "delete") {
|
|
4050
3945
|
if (help)
|
|
@@ -4066,6 +3961,18 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
|
|
|
4066
3961
|
assertArgCount(positionals, 3, usage);
|
|
4067
3962
|
return { kind: "jobs-list", queue, jobState, pageSize };
|
|
4068
3963
|
}
|
|
3964
|
+
if (action === "failed") {
|
|
3965
|
+
if (help)
|
|
3966
|
+
showSubcommandHelp(JOBS_FAILED_HELP);
|
|
3967
|
+
const usage = "jobs failed <queue> [--page-size <n>]";
|
|
3968
|
+
const queue = getRequiredArg(positionals, 2, "queue", usage);
|
|
3969
|
+
assertArgCount(positionals, 3, usage);
|
|
3970
|
+
if (jobState && jobState !== "failed") {
|
|
3971
|
+
writeError("--job-state cannot override 'jobs failed'", "CONFIG_ERROR", "Use 'jobs list <queue> --job-state <state>' for other states.");
|
|
3972
|
+
process.exit(2);
|
|
3973
|
+
}
|
|
3974
|
+
return { kind: "jobs-list", queue, jobState: "failed", pageSize };
|
|
3975
|
+
}
|
|
4069
3976
|
if (action === "get") {
|
|
4070
3977
|
if (help)
|
|
4071
3978
|
showSubcommandHelp(JOBS_GET_HELP);
|
|
@@ -4078,20 +3985,29 @@ function parseSubcommand(positionals, help, jobState, pageSize, since, name, dry
|
|
|
4078
3985
|
if (action === "retry") {
|
|
4079
3986
|
if (help)
|
|
4080
3987
|
showSubcommandHelp(JOBS_RETRY_HELP);
|
|
4081
|
-
const usage = "jobs retry <queue> --job-state failed [--since <duration>] [--name <pattern>] [--dry-run]";
|
|
3988
|
+
const usage = "jobs retry <queue> (--job-id <id> | --job-state failed) [--since <duration>] [--name <pattern>] [--dry-run|--yes]";
|
|
4082
3989
|
const queue = getRequiredArg(positionals, 2, "queue", usage);
|
|
4083
3990
|
assertArgCount(positionals, 3, usage);
|
|
4084
|
-
if (!jobState) {
|
|
4085
|
-
writeError("--job-state is required for 'jobs retry'", "CONFIG_ERROR", "Use --job-
|
|
3991
|
+
if (!jobState && !retryJobId) {
|
|
3992
|
+
writeError("--job-id or --job-state is required for 'jobs retry'", "CONFIG_ERROR", "Use --job-id <id> for one failed job or --job-state failed for a filtered batch.");
|
|
4086
3993
|
process.exit(2);
|
|
4087
3994
|
}
|
|
4088
|
-
if (jobState !== "failed") {
|
|
3995
|
+
if (jobState && jobState !== "failed") {
|
|
4089
3996
|
writeError(`Unsupported --job-state '${jobState}' for 'jobs retry'`, "CONFIG_ERROR", "Only --job-state failed is currently supported.");
|
|
4090
3997
|
process.exit(2);
|
|
4091
3998
|
}
|
|
4092
|
-
return {
|
|
3999
|
+
return {
|
|
4000
|
+
kind: "jobs-retry",
|
|
4001
|
+
queue,
|
|
4002
|
+
jobState: "failed",
|
|
4003
|
+
jobId: retryJobId,
|
|
4004
|
+
since,
|
|
4005
|
+
name,
|
|
4006
|
+
pageSize,
|
|
4007
|
+
dryRun
|
|
4008
|
+
};
|
|
4093
4009
|
}
|
|
4094
|
-
writeError(`Invalid action '${action}' for jobs`, "CONFIG_ERROR", "Available actions: list, get, retry. Use --help for usage.");
|
|
4010
|
+
writeError(`Invalid action '${action}' for jobs`, "CONFIG_ERROR", "Available actions: list, failed, get, retry. Use --help for usage.");
|
|
4095
4011
|
process.exit(2);
|
|
4096
4012
|
}
|
|
4097
4013
|
case "schedulers": {
|
|
@@ -4149,8 +4065,11 @@ function parseCliArgs() {
|
|
|
4149
4065
|
"job-state": { type: "string" },
|
|
4150
4066
|
"page-size": { type: "string" },
|
|
4151
4067
|
"human-friendly": { type: "boolean" },
|
|
4068
|
+
"sort-by": { type: "string" },
|
|
4069
|
+
"sort-order": { type: "string" },
|
|
4152
4070
|
since: { type: "string" },
|
|
4153
4071
|
name: { type: "string" },
|
|
4072
|
+
"job-id": { type: "string" },
|
|
4154
4073
|
"dry-run": { type: "boolean" },
|
|
4155
4074
|
yes: { type: "boolean" },
|
|
4156
4075
|
profile: { type: "string" },
|
|
@@ -4177,7 +4096,15 @@ function parseCliArgs() {
|
|
|
4177
4096
|
writeError(`Invalid --since value '${since}'`, "CONFIG_ERROR", "Expected format: 30s, 5m, 1h, 24h, 7d. Must be a positive integer followed by s/m/h/d.");
|
|
4178
4097
|
process.exit(2);
|
|
4179
4098
|
}
|
|
4180
|
-
const subcommand = parseSubcommand(positionals, !!values.help, values["job-state"], pageSize, since, nameFilter, dryRun, yes ?? false);
|
|
4099
|
+
const subcommand = parseSubcommand(positionals, !!values.help, values["job-state"], pageSize, since, nameFilter, values["job-id"], dryRun, yes ?? false, values["sort-by"], values["sort-order"]);
|
|
4100
|
+
if (values["sort-by"] && (!subcommand || subcommand.kind !== "queues-list")) {
|
|
4101
|
+
writeError("--sort-by can only be used with 'queues list'", "CONFIG_ERROR", "Usage: queues list --sort-by task-size");
|
|
4102
|
+
process.exit(2);
|
|
4103
|
+
}
|
|
4104
|
+
if (values["sort-order"] && (!subcommand || subcommand.kind !== "queues-list")) {
|
|
4105
|
+
writeError("--sort-order can only be used with 'queues list'", "CONFIG_ERROR", "Usage: queues list --sort-by task-size --sort-order desc");
|
|
4106
|
+
process.exit(2);
|
|
4107
|
+
}
|
|
4181
4108
|
if (subcommand?.kind === "jobs-retry" && pageSize !== undefined && pageSize > MAX_RETRY_PAGE_SIZE) {
|
|
4182
4109
|
writeError(`--page-size exceeds ${MAX_RETRY_PAGE_SIZE}`, "CONFIG_ERROR", `--page-size is capped at ${MAX_RETRY_PAGE_SIZE}. For larger batches, run multiple passes with narrower filters (--since, --name).`);
|
|
4183
4110
|
process.exit(2);
|
|
@@ -4198,6 +4125,10 @@ function parseCliArgs() {
|
|
|
4198
4125
|
writeError("--name can only be used with 'jobs retry'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-state failed --name <pattern>");
|
|
4199
4126
|
process.exit(2);
|
|
4200
4127
|
}
|
|
4128
|
+
if (values["job-id"] && (!subcommand || subcommand.kind !== "jobs-retry")) {
|
|
4129
|
+
writeError("--job-id can only be used with 'jobs retry'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-id <id> --dry-run");
|
|
4130
|
+
process.exit(2);
|
|
4131
|
+
}
|
|
4201
4132
|
if (humanFriendly && !subcommand) {
|
|
4202
4133
|
writeError("--human-friendly can only be used with subcommands", "CONFIG_ERROR", "Usage: bullmq-dash <command> --human-friendly. Use --help for usage.");
|
|
4203
4134
|
process.exit(2);
|
|
@@ -4210,12 +4141,12 @@ function parseCliArgs() {
|
|
|
4210
4141
|
writeError("--dry-run can only be used with 'jobs retry' or 'queues delete'", "CONFIG_ERROR", "Usage: jobs retry <queue> --job-state failed --dry-run or queues delete <queue> --dry-run");
|
|
4211
4142
|
process.exit(2);
|
|
4212
4143
|
}
|
|
4213
|
-
if (yes && (!subcommand || subcommand.kind !== "queues-delete")) {
|
|
4214
|
-
writeError("--yes can only be used with 'queues delete'", "CONFIG_ERROR", "Usage: queues delete <queue> --yes");
|
|
4144
|
+
if (yes && (!subcommand || subcommand.kind !== "queues-delete" && subcommand.kind !== "jobs-retry")) {
|
|
4145
|
+
writeError("--yes can only be used with 'queues delete' or 'jobs retry'", "CONFIG_ERROR", "Usage: queues delete <queue> --yes or jobs retry <queue> --job-state failed --yes");
|
|
4215
4146
|
process.exit(2);
|
|
4216
4147
|
}
|
|
4217
4148
|
if (dryRun && yes) {
|
|
4218
|
-
writeError("--dry-run and --yes cannot be used together", "CONFIG_ERROR", "Usage: queues delete <queue> [--dry-run] or
|
|
4149
|
+
writeError("--dry-run and --yes cannot be used together", "CONFIG_ERROR", "Usage: queues delete <queue> [--dry-run|--yes] or jobs retry <queue> [--dry-run|--yes]");
|
|
4219
4150
|
process.exit(2);
|
|
4220
4151
|
}
|
|
4221
4152
|
if (humanFriendly && values.tui) {
|
|
@@ -4271,6 +4202,141 @@ function shouldLoadProfile(cliArgs) {
|
|
|
4271
4202
|
return !cliArgs.redisUrl || !!cliArgs.profile || !!cliArgs.configPath;
|
|
4272
4203
|
}
|
|
4273
4204
|
|
|
4205
|
+
// src/config.ts
|
|
4206
|
+
var DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
4207
|
+
function coercePositiveInt(value, fallback) {
|
|
4208
|
+
if (value === undefined)
|
|
4209
|
+
return fallback;
|
|
4210
|
+
const numberValue = Number(value);
|
|
4211
|
+
if (!Number.isInteger(numberValue) || numberValue <= 0)
|
|
4212
|
+
return null;
|
|
4213
|
+
return numberValue;
|
|
4214
|
+
}
|
|
4215
|
+
function coerceNonNegativeInt(value, fallback) {
|
|
4216
|
+
if (value === undefined)
|
|
4217
|
+
return fallback;
|
|
4218
|
+
const numberValue = Number(value);
|
|
4219
|
+
if (!Number.isInteger(numberValue) || numberValue < 0)
|
|
4220
|
+
return null;
|
|
4221
|
+
return numberValue;
|
|
4222
|
+
}
|
|
4223
|
+
function coerceString(value, fallback) {
|
|
4224
|
+
if (value === undefined)
|
|
4225
|
+
return fallback;
|
|
4226
|
+
return typeof value === "string" ? value : null;
|
|
4227
|
+
}
|
|
4228
|
+
function coerceOptionalString(value) {
|
|
4229
|
+
if (value === undefined)
|
|
4230
|
+
return;
|
|
4231
|
+
return typeof value === "string" ? value : null;
|
|
4232
|
+
}
|
|
4233
|
+
var CONFIG_ALLOWED_KEYS = ["redis", "pollInterval", "prefix", "queueNames", "cacheTtlMs"];
|
|
4234
|
+
var CONFIG_REDIS_ALLOWED_KEYS = ["host", "port", "username", "password", "db", "tls"];
|
|
4235
|
+
function validateConfig(raw) {
|
|
4236
|
+
const errors = [];
|
|
4237
|
+
for (const key of Object.keys(raw)) {
|
|
4238
|
+
if (!CONFIG_ALLOWED_KEYS.includes(key)) {
|
|
4239
|
+
errors.push(`unknown key '${key}' in config (allowed: ${CONFIG_ALLOWED_KEYS.join(", ")})`);
|
|
4240
|
+
}
|
|
4241
|
+
}
|
|
4242
|
+
for (const key of Object.keys(raw.redis)) {
|
|
4243
|
+
if (!CONFIG_REDIS_ALLOWED_KEYS.includes(key)) {
|
|
4244
|
+
errors.push(`unknown key '${key}' in config.redis (allowed: ${CONFIG_REDIS_ALLOWED_KEYS.join(", ")})`);
|
|
4245
|
+
}
|
|
4246
|
+
}
|
|
4247
|
+
const redis = raw.redis;
|
|
4248
|
+
const host = coerceString(redis.host, "localhost");
|
|
4249
|
+
if (host === null)
|
|
4250
|
+
errors.push("redis.host must be a string");
|
|
4251
|
+
const port = coercePositiveInt(redis.port, 6379);
|
|
4252
|
+
if (port === null)
|
|
4253
|
+
errors.push("redis.port must be a positive integer");
|
|
4254
|
+
const username = coerceOptionalString(redis.username);
|
|
4255
|
+
if (username === null)
|
|
4256
|
+
errors.push("redis.username must be a string");
|
|
4257
|
+
const password = coerceOptionalString(redis.password);
|
|
4258
|
+
if (password === null)
|
|
4259
|
+
errors.push("redis.password must be a string");
|
|
4260
|
+
const db = coerceNonNegativeInt(redis.db, 0);
|
|
4261
|
+
if (db === null)
|
|
4262
|
+
errors.push("redis.db must be a non-negative integer");
|
|
4263
|
+
const tls = redis.tls;
|
|
4264
|
+
if (tls !== undefined && typeof tls !== "boolean") {
|
|
4265
|
+
errors.push("redis.tls must be a boolean");
|
|
4266
|
+
}
|
|
4267
|
+
const pollInterval = coercePositiveInt(raw.pollInterval, 3000);
|
|
4268
|
+
if (pollInterval === null)
|
|
4269
|
+
errors.push("pollInterval must be a positive integer");
|
|
4270
|
+
const prefix = coerceString(raw.prefix, "bull");
|
|
4271
|
+
if (prefix === null)
|
|
4272
|
+
errors.push("prefix must be a string");
|
|
4273
|
+
const queueNames = raw.queueNames;
|
|
4274
|
+
if (queueNames !== undefined && (!Array.isArray(queueNames) || queueNames.some((name) => typeof name !== "string"))) {
|
|
4275
|
+
errors.push("queueNames must be an array of strings");
|
|
4276
|
+
}
|
|
4277
|
+
const cacheTtlMs = coercePositiveInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS);
|
|
4278
|
+
if (cacheTtlMs === null)
|
|
4279
|
+
errors.push("cacheTtlMs must be a positive integer");
|
|
4280
|
+
if (errors.length > 0) {
|
|
4281
|
+
return { success: false, errors };
|
|
4282
|
+
}
|
|
4283
|
+
const config = {
|
|
4284
|
+
redis: {
|
|
4285
|
+
host,
|
|
4286
|
+
port,
|
|
4287
|
+
db
|
|
4288
|
+
},
|
|
4289
|
+
pollInterval,
|
|
4290
|
+
prefix,
|
|
4291
|
+
cacheTtlMs
|
|
4292
|
+
};
|
|
4293
|
+
if (username !== undefined && username !== null)
|
|
4294
|
+
config.redis.username = username;
|
|
4295
|
+
if (password !== undefined && password !== null)
|
|
4296
|
+
config.redis.password = password;
|
|
4297
|
+
if (tls !== undefined)
|
|
4298
|
+
config.redis.tls = tls;
|
|
4299
|
+
if (queueNames !== undefined)
|
|
4300
|
+
config.queueNames = queueNames;
|
|
4301
|
+
return { success: true, data: config };
|
|
4302
|
+
}
|
|
4303
|
+
function loadConfig(cliArgs, profile) {
|
|
4304
|
+
const p = profile?.profile;
|
|
4305
|
+
const url = cliArgs.redisUrl ?? p?.redis?.url;
|
|
4306
|
+
let parts;
|
|
4307
|
+
if (url) {
|
|
4308
|
+
try {
|
|
4309
|
+
parts = parseRedisUrl(url);
|
|
4310
|
+
} catch (error) {
|
|
4311
|
+
writeError(cliArgs.redisUrl ? "Invalid --redis-url" : `Invalid redis.url in profile '${profile?.name}'`, "CONFIG_ERROR", error instanceof Error ? error.message : String(error));
|
|
4312
|
+
process.exit(2);
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
const raw = {
|
|
4316
|
+
redis: {
|
|
4317
|
+
host: parts?.host,
|
|
4318
|
+
port: parts?.port,
|
|
4319
|
+
username: parts?.username,
|
|
4320
|
+
password: parts?.password,
|
|
4321
|
+
db: parts?.db,
|
|
4322
|
+
tls: parts?.tls
|
|
4323
|
+
},
|
|
4324
|
+
pollInterval: cliArgs.pollInterval ?? p?.pollInterval,
|
|
4325
|
+
prefix: cliArgs.prefix ?? p?.prefix,
|
|
4326
|
+
queueNames: cliArgs.queues ?? p?.queues,
|
|
4327
|
+
cacheTtlMs: p?.cacheTtlMs
|
|
4328
|
+
};
|
|
4329
|
+
const result = validateConfig(raw);
|
|
4330
|
+
if (!result.success) {
|
|
4331
|
+
writeError("Configuration error", "CONFIG_ERROR", result.errors.join("; "));
|
|
4332
|
+
process.exit(2);
|
|
4333
|
+
}
|
|
4334
|
+
return result.data;
|
|
4335
|
+
}
|
|
4336
|
+
function createConfigFromPrompt(redisUrl, cliArgs) {
|
|
4337
|
+
return loadConfig({ ...cliArgs, redisUrl });
|
|
4338
|
+
}
|
|
4339
|
+
|
|
4274
4340
|
// src/ui/config-prompt.ts
|
|
4275
4341
|
import * as readline from "readline";
|
|
4276
4342
|
var cyan = (text) => `\x1B[36m${text}\x1B[0m`;
|
|
@@ -4548,6 +4614,8 @@ var MAX_DISPLAYED_ERRORS = 10;
|
|
|
4548
4614
|
function formatJobsRetry(r) {
|
|
4549
4615
|
const lines = [];
|
|
4550
4616
|
const filterParts = [`state=${r.filter.jobState}`];
|
|
4617
|
+
if (r.filter.jobId)
|
|
4618
|
+
filterParts.push(`jobId=${r.filter.jobId}`);
|
|
4551
4619
|
if (r.filter.since)
|
|
4552
4620
|
filterParts.push(`since=${r.filter.since}`);
|
|
4553
4621
|
if (r.filter.name)
|
|
@@ -4586,7 +4654,7 @@ function formatJobsRetry(r) {
|
|
|
4586
4654
|
}
|
|
4587
4655
|
if (r.dryRun) {
|
|
4588
4656
|
lines.push("");
|
|
4589
|
-
lines.push("Run without --dry-run to retry these jobs.");
|
|
4657
|
+
lines.push("Run with --yes and without --dry-run to retry these jobs from scripts.");
|
|
4590
4658
|
}
|
|
4591
4659
|
return lines.join(`
|
|
4592
4660
|
`);
|
|
@@ -4600,10 +4668,28 @@ function createResponse(data) {
|
|
|
4600
4668
|
...data
|
|
4601
4669
|
};
|
|
4602
4670
|
}
|
|
4603
|
-
|
|
4604
|
-
const
|
|
4605
|
-
|
|
4606
|
-
|
|
4671
|
+
function omitObservationMetadata(item) {
|
|
4672
|
+
const copy = { ...item };
|
|
4673
|
+
delete copy.lastObservedAt;
|
|
4674
|
+
return copy;
|
|
4675
|
+
}
|
|
4676
|
+
function publicJobSummary(job) {
|
|
4677
|
+
return {
|
|
4678
|
+
id: job.id,
|
|
4679
|
+
name: job.name,
|
|
4680
|
+
state: job.state,
|
|
4681
|
+
timestamp: job.timestamp
|
|
4682
|
+
};
|
|
4683
|
+
}
|
|
4684
|
+
async function fetchQueuesOverview(ctx, sortBy = "name", sortOrder = defaultSortOrder(sortBy)) {
|
|
4685
|
+
const queueNames = await discoverQueueNames(ctx);
|
|
4686
|
+
const queues = await Promise.all(queueNames.map((name) => getQueueStats(ctx, name)));
|
|
4687
|
+
const sortedQueues = sortQueues(queues, sortBy, sortOrder);
|
|
4688
|
+
const observedAt = Date.now();
|
|
4689
|
+
try {
|
|
4690
|
+
recordObservedQueues(ctx, sortedQueues, { observedAt });
|
|
4691
|
+
} catch {}
|
|
4692
|
+
const jobCounts = sortedQueues.reduce((acc, q) => ({
|
|
4607
4693
|
wait: acc.wait + q.counts.wait,
|
|
4608
4694
|
active: acc.active + q.counts.active,
|
|
4609
4695
|
completed: acc.completed + q.counts.completed,
|
|
@@ -4612,15 +4698,15 @@ async function fetchQueuesOverview() {
|
|
|
4612
4698
|
total: acc.total + q.total
|
|
4613
4699
|
}), { wait: 0, active: 0, completed: 0, failed: 0, delayed: 0, total: 0 });
|
|
4614
4700
|
return createResponse({
|
|
4615
|
-
queues,
|
|
4701
|
+
queues: sortedQueues.map(omitObservationMetadata),
|
|
4616
4702
|
metrics: {
|
|
4617
|
-
queueCount:
|
|
4703
|
+
queueCount: sortedQueues.length,
|
|
4618
4704
|
jobCounts
|
|
4619
4705
|
}
|
|
4620
4706
|
});
|
|
4621
4707
|
}
|
|
4622
|
-
async function fetchQueuesDelete(queueName, dryRun) {
|
|
4623
|
-
const result = await deleteQueue(queueName, dryRun);
|
|
4708
|
+
async function fetchQueuesDelete(ctx, queueName, dryRun) {
|
|
4709
|
+
const result = await deleteQueue(ctx, queueName, dryRun);
|
|
4624
4710
|
return createResponse({
|
|
4625
4711
|
queue: queueName,
|
|
4626
4712
|
deleted: !dryRun,
|
|
@@ -4629,22 +4715,16 @@ async function fetchQueuesDelete(queueName, dryRun) {
|
|
|
4629
4715
|
totalJobs: result.counts.wait + result.counts.active + result.counts.completed + result.counts.failed + result.counts.delayed
|
|
4630
4716
|
});
|
|
4631
4717
|
}
|
|
4632
|
-
async function fetchJobsList(queueName, jobState, maxResults) {
|
|
4633
|
-
const { jobs, total } = await getAllJobs(queueName, jobState, maxResults, true);
|
|
4718
|
+
async function fetchJobsList(ctx, queueName, jobState, maxResults) {
|
|
4719
|
+
const { jobs, total } = await getAllJobs(ctx, queueName, jobState, maxResults, true);
|
|
4720
|
+
const observedAt = Date.now();
|
|
4634
4721
|
try {
|
|
4635
|
-
|
|
4636
|
-
id: j.id,
|
|
4637
|
-
name: j.name,
|
|
4638
|
-
state: j.state,
|
|
4639
|
-
timestamp: j.timestamp,
|
|
4640
|
-
data: j.data
|
|
4641
|
-
})));
|
|
4642
|
-
markPolledWrites(queueName, jobs.map((j) => j.id));
|
|
4722
|
+
recordObservedJobs(ctx, queueName, jobs, { observedAt });
|
|
4643
4723
|
} catch {}
|
|
4644
4724
|
return createResponse({
|
|
4645
4725
|
queue: queueName,
|
|
4646
4726
|
jobState: jobState ?? "all",
|
|
4647
|
-
jobs,
|
|
4727
|
+
jobs: jobs.map(publicJobSummary),
|
|
4648
4728
|
total
|
|
4649
4729
|
});
|
|
4650
4730
|
}
|
|
@@ -4653,9 +4733,11 @@ function computeRetryExitCode(result) {
|
|
|
4653
4733
|
return 3;
|
|
4654
4734
|
return 0;
|
|
4655
4735
|
}
|
|
4656
|
-
async function fetchJobsRetry(queueName, jobState, since, name, pageSize, dryRun) {
|
|
4657
|
-
const result = await retryFailedJobs(queueName, { since, name, pageSize, dryRun });
|
|
4736
|
+
async function fetchJobsRetry(ctx, queueName, jobState, jobId, since, name, pageSize, dryRun) {
|
|
4737
|
+
const result = await retryFailedJobs(ctx, queueName, { jobId, since, name, pageSize, dryRun });
|
|
4658
4738
|
const filter = { jobState };
|
|
4739
|
+
if (jobId !== undefined)
|
|
4740
|
+
filter.jobId = jobId;
|
|
4659
4741
|
if (since !== undefined)
|
|
4660
4742
|
filter.since = since;
|
|
4661
4743
|
if (name !== undefined)
|
|
@@ -4673,40 +4755,52 @@ async function fetchJobsRetry(queueName, jobState, since, name, pageSize, dryRun
|
|
|
4673
4755
|
truncated: result.truncated
|
|
4674
4756
|
});
|
|
4675
4757
|
}
|
|
4676
|
-
async function fetchJobDetail(queueName, jobId) {
|
|
4677
|
-
const job = await getJobDetail(queueName, jobId);
|
|
4758
|
+
async function fetchJobDetail(ctx, queueName, jobId) {
|
|
4759
|
+
const job = await getJobDetail(ctx, queueName, jobId);
|
|
4678
4760
|
if (!job) {
|
|
4679
4761
|
writeError(`Job '${jobId}' not found in queue '${queueName}'`, "RUNTIME_ERROR");
|
|
4680
4762
|
try {
|
|
4681
|
-
await
|
|
4763
|
+
await closeContext(ctx);
|
|
4682
4764
|
} catch {}
|
|
4683
4765
|
process.exit(1);
|
|
4684
4766
|
}
|
|
4767
|
+
const observedAt = Date.now();
|
|
4768
|
+
try {
|
|
4769
|
+
recordObservedJobs(ctx, queueName, [job], { observedAt });
|
|
4770
|
+
} catch {}
|
|
4685
4771
|
return createResponse({
|
|
4686
4772
|
queue: queueName,
|
|
4687
|
-
job
|
|
4773
|
+
job: omitObservationMetadata(job)
|
|
4688
4774
|
});
|
|
4689
4775
|
}
|
|
4690
|
-
async function fetchSchedulersList(queueName, maxResults) {
|
|
4691
|
-
const { schedulers, total } = await getAllJobSchedulers(queueName, maxResults);
|
|
4776
|
+
async function fetchSchedulersList(ctx, queueName, maxResults) {
|
|
4777
|
+
const { schedulers, total } = await getAllJobSchedulers(ctx, queueName, maxResults);
|
|
4778
|
+
const observedAt = Date.now();
|
|
4779
|
+
try {
|
|
4780
|
+
recordObservedSchedulers(ctx, queueName, schedulers, { observedAt });
|
|
4781
|
+
} catch {}
|
|
4692
4782
|
return createResponse({
|
|
4693
4783
|
queue: queueName,
|
|
4694
|
-
schedulers,
|
|
4784
|
+
schedulers: schedulers.map(omitObservationMetadata),
|
|
4695
4785
|
total
|
|
4696
4786
|
});
|
|
4697
4787
|
}
|
|
4698
|
-
async function fetchSchedulerDetail(queueName, schedulerKey) {
|
|
4699
|
-
const scheduler = await getJobSchedulerDetail(queueName, schedulerKey);
|
|
4788
|
+
async function fetchSchedulerDetail(ctx, queueName, schedulerKey) {
|
|
4789
|
+
const scheduler = await getJobSchedulerDetail(ctx, queueName, schedulerKey);
|
|
4700
4790
|
if (!scheduler) {
|
|
4701
4791
|
writeError(`Scheduler '${schedulerKey}' not found in queue '${queueName}'`, "RUNTIME_ERROR");
|
|
4702
4792
|
try {
|
|
4703
|
-
await
|
|
4793
|
+
await closeContext(ctx);
|
|
4704
4794
|
} catch {}
|
|
4705
4795
|
process.exit(1);
|
|
4706
4796
|
}
|
|
4797
|
+
const observedAt = Date.now();
|
|
4798
|
+
try {
|
|
4799
|
+
recordObservedSchedulers(ctx, queueName, [scheduler], { observedAt });
|
|
4800
|
+
} catch {}
|
|
4707
4801
|
return createResponse({
|
|
4708
4802
|
queue: queueName,
|
|
4709
|
-
scheduler
|
|
4803
|
+
scheduler: omitObservationMetadata(scheduler)
|
|
4710
4804
|
});
|
|
4711
4805
|
}
|
|
4712
4806
|
function validateJobState(jobState) {
|
|
@@ -4730,33 +4824,40 @@ function promptConfirmation(message) {
|
|
|
4730
4824
|
});
|
|
4731
4825
|
});
|
|
4732
4826
|
}
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4827
|
+
function jobsRetryConfirmationMessage(subcommand) {
|
|
4828
|
+
if (subcommand.jobId) {
|
|
4829
|
+
return `Retry failed job '${subcommand.jobId}' in queue '${subcommand.queue}'?`;
|
|
4830
|
+
}
|
|
4831
|
+
const filters = [];
|
|
4832
|
+
if (subcommand.since)
|
|
4833
|
+
filters.push(`since=${subcommand.since}`);
|
|
4834
|
+
if (subcommand.name)
|
|
4835
|
+
filters.push(`name=${subcommand.name}`);
|
|
4836
|
+
const suffix = filters.length > 0 ? ` matching ${filters.join(", ")}` : "";
|
|
4837
|
+
return `Retry failed jobs in queue '${subcommand.queue}'${suffix}?`;
|
|
4737
4838
|
}
|
|
4738
|
-
async function routeAndFetch(subcommand) {
|
|
4839
|
+
async function routeAndFetch(ctx, subcommand) {
|
|
4739
4840
|
switch (subcommand.kind) {
|
|
4740
4841
|
case "queues-list":
|
|
4741
|
-
return fetchQueuesOverview();
|
|
4842
|
+
return fetchQueuesOverview(ctx, subcommand.sortBy, subcommand.sortOrder);
|
|
4742
4843
|
case "queues-delete":
|
|
4743
|
-
return fetchQueuesDelete(subcommand.queue, subcommand.dryRun ?? false);
|
|
4844
|
+
return fetchQueuesDelete(ctx, subcommand.queue, subcommand.dryRun ?? false);
|
|
4744
4845
|
case "jobs-list": {
|
|
4745
4846
|
const validState = validateJobState(subcommand.jobState);
|
|
4746
|
-
return fetchJobsList(subcommand.queue, validState, subcommand.pageSize);
|
|
4847
|
+
return fetchJobsList(ctx, subcommand.queue, validState, subcommand.pageSize);
|
|
4747
4848
|
}
|
|
4748
4849
|
case "jobs-get":
|
|
4749
|
-
return fetchJobDetail(subcommand.queue, subcommand.jobId);
|
|
4850
|
+
return fetchJobDetail(ctx, subcommand.queue, subcommand.jobId);
|
|
4750
4851
|
case "jobs-retry": {
|
|
4751
4852
|
const validState = validateJobState(subcommand.jobState);
|
|
4752
4853
|
if (!validState)
|
|
4753
4854
|
throw new Error("jobs retry requires --job-state");
|
|
4754
|
-
return fetchJobsRetry(subcommand.queue, validState, subcommand.since, subcommand.name, subcommand.pageSize, subcommand.dryRun);
|
|
4855
|
+
return fetchJobsRetry(ctx, subcommand.queue, validState, subcommand.jobId, subcommand.since, subcommand.name, subcommand.pageSize, subcommand.dryRun);
|
|
4755
4856
|
}
|
|
4756
4857
|
case "schedulers-list":
|
|
4757
|
-
return fetchSchedulersList(subcommand.queue, subcommand.pageSize);
|
|
4858
|
+
return fetchSchedulersList(ctx, subcommand.queue, subcommand.pageSize);
|
|
4758
4859
|
case "schedulers-get":
|
|
4759
|
-
return fetchSchedulerDetail(subcommand.queue, subcommand.schedulerId);
|
|
4860
|
+
return fetchSchedulerDetail(ctx, subcommand.queue, subcommand.schedulerId);
|
|
4760
4861
|
default: {
|
|
4761
4862
|
const _exhaustive = subcommand;
|
|
4762
4863
|
throw new Error(`Unhandled subcommand: ${_exhaustive.kind}`);
|
|
@@ -4788,15 +4889,14 @@ function formatOutput(result, subcommand, humanFriendly) {
|
|
|
4788
4889
|
}
|
|
4789
4890
|
}
|
|
4790
4891
|
}
|
|
4791
|
-
async function runJsonMode(
|
|
4792
|
-
|
|
4793
|
-
if (subcommand.kind === "queues-delete" && !yes && !dryRun) {
|
|
4892
|
+
async function runJsonMode(ctx, subcommand, humanFriendly = false, yes = false) {
|
|
4893
|
+
if (subcommand.kind === "queues-delete" && !yes && !(subcommand.dryRun ?? false)) {
|
|
4794
4894
|
if (process.stdin.isTTY) {
|
|
4795
4895
|
const confirmed = await promptConfirmation(`Delete queue '${subcommand.queue}' and all its jobs? This cannot be undone.`);
|
|
4796
4896
|
if (!confirmed) {
|
|
4797
4897
|
process.stderr.write(`Cancelled.
|
|
4798
4898
|
`);
|
|
4799
|
-
await
|
|
4899
|
+
await closeContext(ctx);
|
|
4800
4900
|
process.exit(1);
|
|
4801
4901
|
}
|
|
4802
4902
|
} else {
|
|
@@ -4804,16 +4904,33 @@ async function runJsonMode(config, subcommand, humanFriendly = false, dryRun = f
|
|
|
4804
4904
|
process.exit(2);
|
|
4805
4905
|
}
|
|
4806
4906
|
}
|
|
4907
|
+
if (subcommand.kind === "jobs-retry" && !yes && !subcommand.dryRun) {
|
|
4908
|
+
if (process.stdin.isTTY) {
|
|
4909
|
+
const confirmed = await promptConfirmation(jobsRetryConfirmationMessage(subcommand));
|
|
4910
|
+
if (!confirmed) {
|
|
4911
|
+
process.stderr.write(`Cancelled.
|
|
4912
|
+
`);
|
|
4913
|
+
await closeContext(ctx);
|
|
4914
|
+
process.exit(1);
|
|
4915
|
+
}
|
|
4916
|
+
} else {
|
|
4917
|
+
writeError("Confirmation required: run with --yes flag in non-interactive mode", "CONFIG_ERROR", "Use --yes to retry failed jobs in scripts, or run in interactive terminal.");
|
|
4918
|
+
process.exit(2);
|
|
4919
|
+
}
|
|
4920
|
+
}
|
|
4807
4921
|
try {
|
|
4808
|
-
await
|
|
4922
|
+
await ctx.redis.connect();
|
|
4809
4923
|
} catch (error) {
|
|
4810
4924
|
writeError("Redis connection failed", "REDIS_ERROR", error instanceof Error ? error.message : String(error));
|
|
4925
|
+
try {
|
|
4926
|
+
await closeContext(ctx);
|
|
4927
|
+
} catch {}
|
|
4811
4928
|
process.exit(1);
|
|
4812
4929
|
}
|
|
4813
|
-
createSqliteDb();
|
|
4814
4930
|
let exitCode = 0;
|
|
4815
4931
|
try {
|
|
4816
|
-
const result = await routeAndFetch(subcommand);
|
|
4932
|
+
const result = await routeAndFetch(ctx, subcommand);
|
|
4933
|
+
runQueueStoreCleanupIfDue(ctx);
|
|
4817
4934
|
const output = formatOutput(result, subcommand, humanFriendly);
|
|
4818
4935
|
process.stdout.write(output + `
|
|
4819
4936
|
`);
|
|
@@ -4823,11 +4940,11 @@ async function runJsonMode(config, subcommand, humanFriendly = false, dryRun = f
|
|
|
4823
4940
|
} catch (error) {
|
|
4824
4941
|
writeError("Failed to fetch data", "RUNTIME_ERROR", error instanceof Error ? error.message : String(error));
|
|
4825
4942
|
try {
|
|
4826
|
-
await
|
|
4943
|
+
await closeContext(ctx);
|
|
4827
4944
|
} catch {}
|
|
4828
4945
|
process.exit(1);
|
|
4829
4946
|
}
|
|
4830
|
-
await
|
|
4947
|
+
await closeContext(ctx);
|
|
4831
4948
|
process.exit(exitCode);
|
|
4832
4949
|
}
|
|
4833
4950
|
|
|
@@ -4852,7 +4969,8 @@ async function main() {
|
|
|
4852
4969
|
process.exit(2);
|
|
4853
4970
|
}
|
|
4854
4971
|
const config = loadConfig(cliArgs, profile);
|
|
4855
|
-
|
|
4972
|
+
const ctx = createContext(config);
|
|
4973
|
+
await runJsonMode(ctx, cliArgs.subcommand, cliArgs.humanFriendly, cliArgs.yes);
|
|
4856
4974
|
return;
|
|
4857
4975
|
}
|
|
4858
4976
|
if (cliArgs.tui) {
|
|
@@ -4865,9 +4983,9 @@ async function main() {
|
|
|
4865
4983
|
const url = await runConfigPrompt();
|
|
4866
4984
|
config = createConfigFromPrompt(url, cliArgs);
|
|
4867
4985
|
}
|
|
4868
|
-
setConfig(config);
|
|
4869
4986
|
try {
|
|
4870
|
-
const
|
|
4987
|
+
const ctx = createContext(config);
|
|
4988
|
+
const app = new App(ctx);
|
|
4871
4989
|
await app.start();
|
|
4872
4990
|
} catch (error) {
|
|
4873
4991
|
writeError("Failed to start application", "RUNTIME_ERROR", error instanceof Error ? error.message : String(error));
|