@avada-falcon/worker-sdk 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +442 -0
- package/package.json +50 -0
- package/src/admin/checkDashboard.js +77 -0
- package/src/admin/getClusterHealth.js +114 -0
- package/src/admin/getQueueDepths.js +66 -0
- package/src/admin/listWorkers.js +44 -0
- package/src/admin/pingRedis.js +29 -0
- package/src/admin/withTimeout.js +21 -0
- package/src/client/client.js +39 -0
- package/src/config/loader.js +178 -0
- package/src/cron/cronManager.js +35 -0
- package/src/dashboard/server.js +79 -0
- package/src/dashboard/standalone.js +67 -0
- package/src/index.cjs +18 -0
- package/src/index.js +8 -0
- package/src/logging/fileLogger.js +77 -0
- package/src/logging/lokiShipper.js +219 -0
- package/src/queue/queues.js +62 -0
- package/src/shutdown/shutdownManager.js +50 -0
- package/src/worker/handlerRegistry.js +47 -0
- package/src/worker/heartbeat.js +106 -0
- package/src/worker/heartbeatKey.js +6 -0
- package/src/worker/jobExecutor.js +169 -0
- package/src/worker/tierManager.js +55 -0
- package/src/worker/worker.js +204 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {Worker} from 'bullmq';
|
|
2
|
+
import {queueName} from '../queue/queues.js';
|
|
3
|
+
|
|
4
|
+
export class TierManager {
|
|
5
|
+
constructor(concurrency, redisOpts, processor) {
|
|
6
|
+
this._workers = [];
|
|
7
|
+
|
|
8
|
+
let createdCount = 0;
|
|
9
|
+
for (const [tier, limit] of Object.entries(concurrency)) {
|
|
10
|
+
if (!Number.isInteger(limit)) {
|
|
11
|
+
console.warn(
|
|
12
|
+
`[worker-sdk] TierManager: invalid concurrency for tier "${tier}": ${JSON.stringify(limit)} (expected integer >= 0). Skipping this tier.`
|
|
13
|
+
);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (limit <= 0) {
|
|
17
|
+
console.warn(`[worker-sdk] Skipping tier: ${tier} (concurrency: ${limit})`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const queueName = TierManager.queueName(tier);
|
|
21
|
+
const worker = new Worker(queueName, processor, {
|
|
22
|
+
connection: redisOpts,
|
|
23
|
+
concurrency: limit
|
|
24
|
+
});
|
|
25
|
+
worker.on('error', err => {
|
|
26
|
+
console.error(`[worker-sdk] Worker error on ${queueName}:`, err.message);
|
|
27
|
+
});
|
|
28
|
+
this._workers.push(worker);
|
|
29
|
+
createdCount++;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (createdCount === 0) {
|
|
33
|
+
const tierCount = Object.keys(concurrency).length;
|
|
34
|
+
if (tierCount === 0) {
|
|
35
|
+
console.warn(
|
|
36
|
+
'[worker-sdk] TierManager: concurrency object is empty — no tier workers created. ' +
|
|
37
|
+
'Set at least one tier (heavy/medium/light) to a positive integer.'
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
console.warn(
|
|
41
|
+
'[worker-sdk] TierManager: all tiers have concurrency: 0 — this worker will not process any jobs. ' +
|
|
42
|
+
'This is almost certainly a config mistake.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
static queueName(tier) {
|
|
49
|
+
return queueName(tier);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async closeAll() {
|
|
53
|
+
await Promise.all(this._workers.map(w => w.close()));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import {DelayedError} from 'bullmq';
|
|
3
|
+
|
|
4
|
+
import Redis from 'ioredis';
|
|
5
|
+
import {loadConfig} from '../config/loader.js';
|
|
6
|
+
import {HandlerRegistry} from './handlerRegistry.js';
|
|
7
|
+
import {JobExecutor, setFileLogger, setLokiShipper} from './jobExecutor.js';
|
|
8
|
+
import {TierManager} from './tierManager.js';
|
|
9
|
+
import {CronManager} from '../cron/cronManager.js';
|
|
10
|
+
import {ShutdownManager} from '../shutdown/shutdownManager.js';
|
|
11
|
+
import {FileLogger} from '../logging/fileLogger.js';
|
|
12
|
+
import {LokiShipper} from '../logging/lokiShipper.js';
|
|
13
|
+
import {Heartbeat} from './heartbeat.js';
|
|
14
|
+
|
|
15
|
+
/** Key under job.data holding how many times the job has been declined by a worker. */
|
|
16
|
+
const REQUEUE_COUNT_KEY = '__requeues';
|
|
17
|
+
const DEFAULT_MAX_REQUEUES = 50;
|
|
18
|
+
const DEFAULT_REQUEUE_DELAY_MS = 200;
|
|
19
|
+
|
|
20
|
+
function requeueCount(job) {
|
|
21
|
+
const count = job.data?.[REQUEUE_COUNT_KEY];
|
|
22
|
+
return Number.isInteger(count) && count > 0 ? count : 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a worker instance.
|
|
27
|
+
* @param {string} configPath - Path to YAML config file
|
|
28
|
+
* @returns {{register: Function, start: Function, stop: Function}}
|
|
29
|
+
*/
|
|
30
|
+
export function createWorker(configPath) {
|
|
31
|
+
const config = loadConfig(configPath);
|
|
32
|
+
const workerId = config.worker?.id || `${os.hostname()}-${process.pid}`;
|
|
33
|
+
console.info(`[worker-sdk] Worker ID: ${workerId}`);
|
|
34
|
+
const registry = new HandlerRegistry();
|
|
35
|
+
const executor = new JobExecutor();
|
|
36
|
+
const shutdown = new ShutdownManager();
|
|
37
|
+
|
|
38
|
+
let tierManager;
|
|
39
|
+
let cronManager;
|
|
40
|
+
|
|
41
|
+
function register(name, handler) {
|
|
42
|
+
registry.register(name, handler);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function start() {
|
|
46
|
+
// Validate all registered handlers have config
|
|
47
|
+
const unconfigured = registry.names().filter(n => !config.jobs[n]);
|
|
48
|
+
if (unconfigured.length > 0) {
|
|
49
|
+
throw new Error(`Registered handlers not defined in config: ${unconfigured.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Set up file logging if configured
|
|
53
|
+
if (config.logging?.dir) {
|
|
54
|
+
const retentionDays = config.logging.retentionDays || 30;
|
|
55
|
+
const fl = new FileLogger({dir: config.logging.dir, retentionDays});
|
|
56
|
+
setFileLogger(fl);
|
|
57
|
+
|
|
58
|
+
// Clean up old log files on startup
|
|
59
|
+
fl.cleanup();
|
|
60
|
+
|
|
61
|
+
console.info(`[worker-sdk] File logging enabled: ${config.logging.dir} (retention: ${retentionDays} days)`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Set up Loki log shipping if configured
|
|
65
|
+
let lokiShipper = null;
|
|
66
|
+
if (config.logging?.loki?.url) {
|
|
67
|
+
lokiShipper = new LokiShipper({
|
|
68
|
+
url: config.logging.loki.url,
|
|
69
|
+
batchSize: config.logging.loki.batchSize,
|
|
70
|
+
flushInterval: config.logging.loki.flushInterval,
|
|
71
|
+
labels: config.logging.loki.labels,
|
|
72
|
+
workerId
|
|
73
|
+
});
|
|
74
|
+
setLokiShipper(lokiShipper);
|
|
75
|
+
console.info(`[worker-sdk] Loki shipping enabled: ${config.logging.loki.url}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Shared Redis opts for all BullMQ instances (workers, queues, cron)
|
|
79
|
+
const redisOpts = {...config.redis, maxRetriesPerRequest: null};
|
|
80
|
+
|
|
81
|
+
// Job processor — shared across all tier workers
|
|
82
|
+
const processor = async (job, token) => {
|
|
83
|
+
const handler = registry.get(job.name);
|
|
84
|
+
const jobConfig = config.jobs[job.name] || {};
|
|
85
|
+
const timeout = jobConfig.timeout || 30000;
|
|
86
|
+
const maxRequeues = jobConfig.maxRequeues ?? DEFAULT_MAX_REQUEUES;
|
|
87
|
+
|
|
88
|
+
// Let a handler DECLINE to run on this worker and hand the job back to the queue,
|
|
89
|
+
// delayed briefly, instead of blocking — a job blocked inside the handler stays
|
|
90
|
+
// "active" and pinned to this worker where no idle peer can steal it. App-level
|
|
91
|
+
// admission gates (e.g. a per-worker memory budget) call ctx.requeue().
|
|
92
|
+
//
|
|
93
|
+
// moveToDelayed delays only THIS job (not a queue-wide rate limit, so other workers
|
|
94
|
+
// keep pulling other jobs), then DelayedError tells BullMQ the job was already
|
|
95
|
+
// re-scheduled — handleFailed treats it as non-failure: no attempt burned, no
|
|
96
|
+
// 'failed' event. `token` is BullMQ's job-lock token, the 2nd processor arg.
|
|
97
|
+
//
|
|
98
|
+
// CONTRACT: requeue() always throws. A handler that wraps it in try/catch and
|
|
99
|
+
// then returns normally makes BullMQ complete a job that is also sitting in the
|
|
100
|
+
// delayed set — it runs twice. Call it as `return ctx.requeue()` or let it throw.
|
|
101
|
+
const requeue = async (retryMs = DEFAULT_REQUEUE_DELAY_MS) => {
|
|
102
|
+
const count = requeueCount(job) + 1;
|
|
103
|
+
|
|
104
|
+
// No attempt is burned on a requeue, so an admission gate that never opens
|
|
105
|
+
// (leaked memory budget, gate bug) would ping-pong this job forever, silently.
|
|
106
|
+
// Cap it and fail loudly instead.
|
|
107
|
+
if (count > maxRequeues) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Job ${job.name}:${job.id} was declined ${count - 1} times ` +
|
|
110
|
+
`(maxRequeues: ${maxRequeues}) — admission gate never opened.`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Persist the counter: the in-memory job.data copy is dropped once the job
|
|
115
|
+
// goes back to Redis, so it has to be written before re-scheduling.
|
|
116
|
+
await job.updateData({...job.data, [REQUEUE_COUNT_KEY]: count});
|
|
117
|
+
|
|
118
|
+
// Jitter the delay. Without it, every job this worker declines wakes at the
|
|
119
|
+
// same instant and gets declined again in lockstep (thundering herd).
|
|
120
|
+
const delay = retryMs + Math.floor(Math.random() * retryMs);
|
|
121
|
+
|
|
122
|
+
console.warn(
|
|
123
|
+
`[worker-sdk] Declined ${job.name}:${job.id} — requeued in ${delay}ms ` +
|
|
124
|
+
`(${count}/${maxRequeues})`
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
await job.moveToDelayed(Date.now() + delay, token);
|
|
128
|
+
throw new DelayedError();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return executor.run(handler, job, timeout, {requeue});
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Create tier workers
|
|
135
|
+
tierManager = new TierManager(config.concurrency, redisOpts, processor);
|
|
136
|
+
|
|
137
|
+
// Register cron jobs (same redisOpts)
|
|
138
|
+
cronManager = new CronManager(redisOpts);
|
|
139
|
+
await cronManager.register(config.jobs, {leader: config.cron.leader});
|
|
140
|
+
|
|
141
|
+
// Dashboard is no longer started by createWorker — run createDashboard separately
|
|
142
|
+
if (config.dashboard.port) {
|
|
143
|
+
console.warn(
|
|
144
|
+
'[worker-sdk] dashboard.port is set but createWorker no longer runs the dashboard. ' +
|
|
145
|
+
'Use createDashboard("./worker.config.yml") in a separate process/container instead.'
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Heartbeat — registered first so it shuts down first (signals "dead" within one TTL)
|
|
150
|
+
let heartbeat = null;
|
|
151
|
+
let heartbeatRedis = null;
|
|
152
|
+
if (config.worker.heartbeat.enabled) {
|
|
153
|
+
heartbeatRedis = new Redis({
|
|
154
|
+
...redisOpts,
|
|
155
|
+
lazyConnect: false,
|
|
156
|
+
maxRetriesPerRequest: 3
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
heartbeat = new Heartbeat({
|
|
160
|
+
redis: heartbeatRedis,
|
|
161
|
+
workerId,
|
|
162
|
+
tiers: config.concurrency,
|
|
163
|
+
intervalMs: config.worker.heartbeat.intervalMs,
|
|
164
|
+
ttlMs: config.worker.heartbeat.ttlMs
|
|
165
|
+
});
|
|
166
|
+
await heartbeat.start();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Heartbeat constructor or start() failed — clean up the dedicated connection
|
|
169
|
+
// before rethrowing so we don't leak a TCP socket.
|
|
170
|
+
await heartbeatRedis.quit().catch(() => {});
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
shutdown.register('heartbeat', async () => {
|
|
174
|
+
await heartbeat.stop();
|
|
175
|
+
await heartbeatRedis.quit();
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Register shutdown handlers (executed in FIFO registration order).
|
|
180
|
+
// Loki shipper must be registered LAST so it flushes AFTER workers drain —
|
|
181
|
+
// in-flight jobs can still push logs while BullMQ workers are closing.
|
|
182
|
+
shutdown.register('tierManager', () => tierManager.closeAll());
|
|
183
|
+
shutdown.register('cronManager', () => cronManager.closeAll());
|
|
184
|
+
if (lokiShipper) {
|
|
185
|
+
shutdown.register('lokiShipper', () => lokiShipper.stop());
|
|
186
|
+
}
|
|
187
|
+
shutdown.installSignalHandlers(() => process.exit(0));
|
|
188
|
+
|
|
189
|
+
console.info('[worker-sdk] Worker started');
|
|
190
|
+
console.info(`[worker-sdk] Jobs: ${registry.names().join(', ')}`);
|
|
191
|
+
console.info(`[worker-sdk] Tiers: ${JSON.stringify(config.concurrency)}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function stop() {
|
|
195
|
+
await shutdown.shutdown();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
start,
|
|
200
|
+
stop,
|
|
201
|
+
register,
|
|
202
|
+
workerId
|
|
203
|
+
};
|
|
204
|
+
}
|