249 lines
8.5 KiB
TypeScript
249 lines
8.5 KiB
TypeScript
import type { Command } from 'commander';
|
|
|
|
import { createLocalAdapter } from './adapters/local.js';
|
|
import type { QueueConfig } from './types.js';
|
|
|
|
/** Resolve adapter type from env; defaults to 'local'. */
|
|
function resolveAdapterType(): 'bullmq' | 'local' {
|
|
const t = process.env['QUEUE_ADAPTER'] ?? 'local';
|
|
return t === 'bullmq' ? 'bullmq' : 'local';
|
|
}
|
|
|
|
function resolveConfig(): QueueConfig {
|
|
const type = resolveAdapterType();
|
|
if (type === 'bullmq') {
|
|
return { type: 'bullmq', url: process.env['VALKEY_URL'] };
|
|
}
|
|
return { type: 'local', dataDir: process.env['QUEUE_DATA_DIR'] };
|
|
}
|
|
|
|
const BULLMQ_ONLY_MSG =
|
|
'not supported by local adapter — use the bullmq tier for this (set QUEUE_ADAPTER=bullmq)';
|
|
|
|
/**
|
|
* Register queue subcommands on an existing Commander program.
|
|
* Follows the same pattern as registerQualityRails in @mosaicstack/quality-rails.
|
|
*/
|
|
export function registerQueueCommand(parent: Command): void {
|
|
buildQueueCommand(parent.command('queue').description('Manage Mosaic job queues'));
|
|
}
|
|
|
|
function buildQueueCommand(queue: Command): void {
|
|
// ─── list ──────────────────────────────────────────────────────────────
|
|
queue
|
|
.command('list')
|
|
.description('List all queues known to the configured adapter')
|
|
.action(async () => {
|
|
const config = resolveConfig();
|
|
|
|
if (config.type === 'local') {
|
|
const adapter = createLocalAdapter(config);
|
|
// Local adapter tracks queues in its internal Map; we expose them by
|
|
// listing JSON files in the data dir.
|
|
const { readdirSync } = await import('node:fs');
|
|
const { existsSync } = await import('node:fs');
|
|
const dataDir = config.dataDir ?? '.mosaic/queue';
|
|
if (!existsSync(dataDir)) {
|
|
console.log('No queues found (data dir does not exist yet).');
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
const files = readdirSync(dataDir).filter((f: string) => f.endsWith('.json'));
|
|
if (files.length === 0) {
|
|
console.log('No queues found.');
|
|
} else {
|
|
console.log('Queues (local adapter):');
|
|
for (const f of files) {
|
|
console.log(` - ${f.slice(0, -5)}`);
|
|
}
|
|
}
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
|
|
// bullmq — not enough info to enumerate queues without a BullMQ Board
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
|
|
// ─── stats ─────────────────────────────────────────────────────────────
|
|
queue
|
|
.command('stats [name]')
|
|
.description('Show stats for a queue (or all queues)')
|
|
.action(async (name?: string) => {
|
|
const config = resolveConfig();
|
|
|
|
if (config.type === 'local') {
|
|
const adapter = createLocalAdapter(config);
|
|
const { readdirSync } = await import('node:fs');
|
|
const { existsSync } = await import('node:fs');
|
|
const dataDir = config.dataDir ?? '.mosaic/queue';
|
|
|
|
let names: string[] = [];
|
|
if (name) {
|
|
names = [name];
|
|
} else {
|
|
if (existsSync(dataDir)) {
|
|
names = readdirSync(dataDir)
|
|
.filter((f: string) => f.endsWith('.json'))
|
|
.map((f: string) => f.slice(0, -5));
|
|
}
|
|
}
|
|
|
|
if (names.length === 0) {
|
|
console.log('No queues found.');
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
|
|
for (const queueName of names) {
|
|
const len = await adapter.length(queueName);
|
|
console.log(`Queue: ${queueName}`);
|
|
console.log(` waiting: ${len}`);
|
|
console.log(` active: 0 (local adapter — no active tracking)`);
|
|
console.log(` completed: 0 (local adapter — no completed tracking)`);
|
|
console.log(` failed: 0 (local adapter — no failed tracking)`);
|
|
console.log(` delayed: 0 (local adapter — no delayed tracking)`);
|
|
}
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
|
|
// bullmq
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
|
|
// ─── pause ─────────────────────────────────────────────────────────────
|
|
queue
|
|
.command('pause <name>')
|
|
.description('Pause job processing for a queue')
|
|
.action(async (_name: string) => {
|
|
const config = resolveConfig();
|
|
|
|
if (config.type === 'local') {
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
|
|
// ─── resume ────────────────────────────────────────────────────────────
|
|
queue
|
|
.command('resume <name>')
|
|
.description('Resume job processing for a queue')
|
|
.action(async (_name: string) => {
|
|
const config = resolveConfig();
|
|
|
|
if (config.type === 'local') {
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
|
|
// ─── jobs tail ─────────────────────────────────────────────────────────
|
|
const jobs = queue.command('jobs').description('Job-level operations');
|
|
|
|
jobs
|
|
.command('tail [name]')
|
|
.description('Stream new jobs as they arrive (poll-based)')
|
|
.option('--interval <ms>', 'Poll interval in ms', '2000')
|
|
.action(async (name: string | undefined, opts: { interval: string }) => {
|
|
const config = resolveConfig();
|
|
const pollMs = parseInt(opts.interval, 10);
|
|
|
|
if (config.type === 'local') {
|
|
const adapter = createLocalAdapter(config);
|
|
const { existsSync, readdirSync } = await import('node:fs');
|
|
const dataDir = config.dataDir ?? '.mosaic/queue';
|
|
|
|
let names: string[] = [];
|
|
if (name) {
|
|
names = [name];
|
|
} else {
|
|
if (existsSync(dataDir)) {
|
|
names = readdirSync(dataDir)
|
|
.filter((f: string) => f.endsWith('.json'))
|
|
.map((f: string) => f.slice(0, -5));
|
|
}
|
|
}
|
|
|
|
if (names.length === 0) {
|
|
console.log('No queues to tail.');
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
|
|
console.log(`Tailing queues: ${names.join(', ')} (Ctrl-C to stop)`);
|
|
const lastLen = new Map<string, number>();
|
|
for (const qn of names) {
|
|
lastLen.set(qn, await adapter.length(qn));
|
|
}
|
|
|
|
const timer = setInterval(async () => {
|
|
for (const qn of names) {
|
|
const len = await adapter.length(qn);
|
|
const prev = lastLen.get(qn) ?? 0;
|
|
if (len > prev) {
|
|
console.log(
|
|
`[${new Date().toISOString()}] ${qn}: ${len - prev} new job(s) (total: ${len})`,
|
|
);
|
|
}
|
|
lastLen.set(qn, len);
|
|
}
|
|
}, pollMs);
|
|
|
|
process.on('SIGINT', async () => {
|
|
clearInterval(timer);
|
|
await adapter.close();
|
|
process.exit(0);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// bullmq — use subscribe on the channel
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
|
|
// ─── drain ─────────────────────────────────────────────────────────────
|
|
queue
|
|
.command('drain <name>')
|
|
.description('Drain all pending jobs from a queue')
|
|
.option('--yes', 'Skip confirmation prompt')
|
|
.action(async (name: string, opts: { yes?: boolean }) => {
|
|
if (!opts.yes) {
|
|
console.error(
|
|
`WARNING: This will remove all pending jobs from queue "${name}". Re-run with --yes to confirm.`,
|
|
);
|
|
process.exit(1);
|
|
return;
|
|
}
|
|
|
|
const config = resolveConfig();
|
|
|
|
if (config.type === 'local') {
|
|
const adapter = createLocalAdapter(config);
|
|
let removed = 0;
|
|
while ((await adapter.length(name)) > 0) {
|
|
await adapter.dequeue(name);
|
|
removed++;
|
|
}
|
|
console.log(`Drained ${removed} job(s) from queue "${name}".`);
|
|
await adapter.close();
|
|
return;
|
|
}
|
|
|
|
console.log(BULLMQ_ONLY_MSG);
|
|
process.exit(0);
|
|
});
|
|
}
|