@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.
@@ -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
+ }