@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,219 @@
1
+ /**
2
+ * Buffers log entries and ships them to a Loki server via HTTP push API.
3
+ *
4
+ * Batches by size (batchSize) or time (flushInterval), whichever hits first.
5
+ * Groups entries by stream labels (level + job) to minimize payload size.
6
+ * Retries with exponential backoff, capped at maxRetryAttempts.
7
+ * Drops entries silently if buffer exceeds maxBufferSize — never crashes the worker.
8
+ */
9
+ const MAX_RETRY_ATTEMPTS = 5;
10
+
11
+ export class LokiShipper {
12
+ /**
13
+ * @param {object} options
14
+ * @param {string} options.url - Loki base URL (e.g., http://loki:3100)
15
+ * @param {number} [options.batchSize=100]
16
+ * @param {number} [options.flushInterval=5000]
17
+ * @param {object} [options.labels={}] - static labels added to every stream
18
+ * @param {string} [options.workerId] - Optional per-worker identifier added to every stream label. Overrides any workerId in options.labels.
19
+ * @param {number} [options.maxBufferSize=10000]
20
+ */
21
+ constructor({url, batchSize = 100, flushInterval = 5000, labels = {}, workerId, maxBufferSize = 10000}) {
22
+ if (!url) throw new Error('LokiShipper requires a url');
23
+
24
+ this._url = url.replace(/\/$/, '') + '/loki/api/v1/push';
25
+ this._batchSize = batchSize;
26
+ this._maxBufferSize = maxBufferSize;
27
+ this._staticLabels = workerId ? {...labels, workerId} : {...labels};
28
+ this._buffer = [];
29
+ this._flushing = false;
30
+ this._flushPromise = null;
31
+ this._stopped = false;
32
+ this._droppedCount = 0;
33
+ this._retryDelayMs = 1000;
34
+ this._maxRetryDelayMs = 30000;
35
+ this._stopController = new AbortController();
36
+
37
+ this._timer = setInterval(() => this._flush().catch(() => {}), flushInterval);
38
+ this._timer.unref?.();
39
+ }
40
+
41
+ /**
42
+ * Add a log entry to the buffer.
43
+ * @param {object} entry - {job, id, level, msg, data}
44
+ */
45
+ push(entry) {
46
+ if (this._stopped) return;
47
+
48
+ if (this._buffer.length >= this._maxBufferSize) {
49
+ this._droppedCount++;
50
+ return;
51
+ }
52
+
53
+ this._buffer.push({
54
+ ts: Date.now(),
55
+ entry
56
+ });
57
+
58
+ if (this._buffer.length >= this._batchSize) {
59
+ this._flush().catch(() => {});
60
+ }
61
+ }
62
+
63
+ bufferedCount() {
64
+ return this._buffer.length;
65
+ }
66
+
67
+ droppedCount() {
68
+ return this._droppedCount;
69
+ }
70
+
71
+ /**
72
+ * Flush current buffer to Loki. Caps retries to avoid blocking other flushes.
73
+ * Tracks the in-flight promise so stop() can wait for it before its final flush.
74
+ */
75
+ _flush(maxAttempts = MAX_RETRY_ATTEMPTS, {final = false} = {}) {
76
+ if (this._flushing || this._buffer.length === 0) return Promise.resolve();
77
+ this._flushing = true;
78
+ this._flushPromise = this._doFlush(maxAttempts, final).finally(() => {
79
+ this._flushing = false;
80
+ this._flushPromise = null;
81
+ // Entries pushed while we were flushing may already fill a batch;
82
+ // kick another flush so they don't wait for the next interval tick.
83
+ if (!this._stopped && this._buffer.length >= this._batchSize) {
84
+ this._flush().catch(() => {});
85
+ }
86
+ });
87
+ return this._flushPromise;
88
+ }
89
+
90
+ // `final` lets stop()'s last flush run even though _stopped is already set
91
+ async _doFlush(maxAttempts, final) {
92
+ const batch = this._buffer.splice(0);
93
+ const payload = this._buildPayload(batch);
94
+
95
+ let delayMs = this._retryDelayMs;
96
+ let sent = false;
97
+
98
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
99
+ if (this._stopped && !final) break;
100
+ try {
101
+ const res = await fetch(this._url, {
102
+ method: 'POST',
103
+ headers: {'Content-Type': 'application/json'},
104
+ body: JSON.stringify(payload)
105
+ });
106
+ if (res.ok) {
107
+ sent = true;
108
+ break;
109
+ }
110
+ // 4xx won't succeed on retry — drop with warning
111
+ if (res.status >= 400 && res.status < 500) {
112
+ process.stderr.write(
113
+ `[worker-sdk] LokiShipper: dropping batch of ${batch.length} — Loki returned ${res.status}\n`
114
+ );
115
+ sent = true; // don't re-queue, don't retry
116
+ break;
117
+ }
118
+ // 5xx — retry
119
+ throw new Error(`Loki returned HTTP ${res.status}`);
120
+ } catch {
121
+ if (this._stopped && !final) break;
122
+ if (attempt === maxAttempts - 1) break;
123
+ await this._sleep(delayMs);
124
+ delayMs = Math.min(delayMs * 2, this._maxRetryDelayMs);
125
+ }
126
+ }
127
+
128
+ if (!sent) {
129
+ if (this._stopped && !final) {
130
+ // Stopped mid-retry — re-prepend so stop()'s final flush has one more shot
131
+ this._buffer = batch.concat(this._buffer);
132
+ } else {
133
+ // Retries exhausted — log a warning and drop
134
+ process.stderr.write(
135
+ `[worker-sdk] LokiShipper: dropping batch of ${batch.length} after ${maxAttempts} retries\n`
136
+ );
137
+ this._droppedCount += batch.length;
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Abortable sleep — wakes immediately if stop() is called.
144
+ */
145
+ _sleep(ms) {
146
+ const signal = this._stopController.signal;
147
+ if (signal.aborted) return Promise.resolve();
148
+ return new Promise(resolve => {
149
+ const onAbort = () => {
150
+ clearTimeout(timer);
151
+ resolve();
152
+ };
153
+ const timer = setTimeout(() => {
154
+ // Detach the abort listener so repeated sleeps don't accumulate
155
+ // listeners on the shared signal for the shipper's lifetime.
156
+ signal.removeEventListener('abort', onAbort);
157
+ resolve();
158
+ }, ms);
159
+ signal.addEventListener('abort', onAbort, {once: true});
160
+ });
161
+ }
162
+
163
+ /**
164
+ * Build Loki push payload from buffered entries.
165
+ * Groups entries by stream labels (level + job) to compress payload.
166
+ */
167
+ _buildPayload(batch) {
168
+ const streams = new Map();
169
+
170
+ for (const {ts, entry} of batch) {
171
+ // Static labels never vary — only level + job determine the stream
172
+ const key = `${entry.level}\u0000${entry.job}`;
173
+
174
+ if (!streams.has(key)) {
175
+ streams.set(key, {
176
+ stream: {...this._staticLabels, level: entry.level, job: entry.job},
177
+ values: []
178
+ });
179
+ }
180
+
181
+ let line;
182
+ try {
183
+ line = JSON.stringify({
184
+ id: entry.id,
185
+ msg: entry.msg,
186
+ ...(entry.data ? {data: entry.data} : {})
187
+ });
188
+ } catch {
189
+ // entry.data isn't JSON-serializable (circular, BigInt, ...) — ship without it
190
+ line = JSON.stringify({id: entry.id, msg: entry.msg, data: '[unserializable]'});
191
+ }
192
+ const tsNs = `${ts}000000`; // ms → ns
193
+ streams.get(key).values.push([tsNs, line]);
194
+ }
195
+
196
+ return {streams: Array.from(streams.values())};
197
+ }
198
+
199
+ /**
200
+ * Stop the shipper — flush remaining buffer, cancel timer, abort pending sleeps.
201
+ */
202
+ async stop() {
203
+ this._stopped = true;
204
+ this._stopController.abort();
205
+ if (this._timer) clearInterval(this._timer);
206
+ // Wait for any in-flight flush to settle first — if it was mid-retry it
207
+ // re-prepends its batch to the buffer, so the final flush below can still
208
+ // send it instead of the batch being lost on shutdown.
209
+ const inFlight = this._flushPromise;
210
+ if (inFlight) {
211
+ await inFlight.catch(() => {});
212
+ }
213
+ // Final flush — a single attempt (no retry backoff) so shutdown stays
214
+ // bounded even when Loki is down.
215
+ if (this._buffer.length > 0) {
216
+ await this._flush(1, {final: true});
217
+ }
218
+ }
219
+ }
@@ -0,0 +1,62 @@
1
+ import {Queue} from 'bullmq';
2
+
3
+ /**
4
+ * Single source of truth for how the SDK talks to tier queues:
5
+ * naming, default job options, and Queue construction/caching.
6
+ * Consumed by the client, cron manager, worker, dashboard, and admin probes.
7
+ */
8
+ export const QUEUE_PREFIX = 'worker';
9
+ export const DEFAULT_TIER = 'medium';
10
+
11
+ export function queueName(tier) {
12
+ return `${QUEUE_PREFIX}-${tier}`;
13
+ }
14
+
15
+ /**
16
+ * Build BullMQ job options from a job's YAML config.
17
+ * Shared by createClient.add and CronManager.register so retry mapping and
18
+ * retention policy live in exactly one place.
19
+ * @param {object} jobConfig - {retry?: {maxAttempts?, baseDelay?}, ...}
20
+ */
21
+ export function buildJobOpts(jobConfig) {
22
+ const opts = {
23
+ attempts: jobConfig.retry?.maxAttempts || 1,
24
+ removeOnComplete: {count: 1000},
25
+ removeOnFail: {count: 5000}
26
+ };
27
+
28
+ if (jobConfig.retry?.baseDelay) {
29
+ opts.backoff = {
30
+ type: 'exponential',
31
+ delay: jobConfig.retry.baseDelay
32
+ };
33
+ }
34
+
35
+ return opts;
36
+ }
37
+
38
+ /**
39
+ * Lazy per-tier Queue cache. Queues are created on first use and closed
40
+ * together via closeAll().
41
+ */
42
+ export class QueuePool {
43
+ /**
44
+ * @param {object} connection - ioredis options (or instance) passed to BullMQ
45
+ */
46
+ constructor(connection) {
47
+ this._connection = connection;
48
+ this._queues = new Map();
49
+ }
50
+
51
+ get(tier) {
52
+ const name = queueName(tier);
53
+ if (!this._queues.has(name)) {
54
+ this._queues.set(name, new Queue(name, {connection: this._connection}));
55
+ }
56
+ return this._queues.get(name);
57
+ }
58
+
59
+ async closeAll() {
60
+ await Promise.all(Array.from(this._queues.values()).map(q => q.close()));
61
+ }
62
+ }
@@ -0,0 +1,50 @@
1
+ const FORCE_EXIT_TIMEOUT_MS = 30000;
2
+
3
+ export class ShutdownManager {
4
+ constructor() {
5
+ this._handlers = [];
6
+ this._shutdownPromise = null;
7
+ }
8
+
9
+ register(name, handler) {
10
+ this._handlers.push({name, handler});
11
+ }
12
+
13
+ /**
14
+ * Execute all shutdown handlers in registration order.
15
+ * Returns the same promise if called multiple times (idempotent).
16
+ */
17
+ async shutdown() {
18
+ if (this._shutdownPromise) return this._shutdownPromise;
19
+ this._shutdownPromise = this._runHandlers();
20
+ return this._shutdownPromise;
21
+ }
22
+
23
+ async _runHandlers() {
24
+ for (const {name, handler} of this._handlers) {
25
+ try {
26
+ await handler();
27
+ } catch (err) {
28
+ console.error(`[shutdown] Error in "${name}":`, err.message);
29
+ }
30
+ }
31
+ }
32
+
33
+ installSignalHandlers(onComplete) {
34
+ const handle = async (signal) => {
35
+ console.info(`[shutdown] Received ${signal}, shutting down...`);
36
+
37
+ const forceTimer = setTimeout(() => {
38
+ console.error(`[shutdown] Force exit after ${FORCE_EXIT_TIMEOUT_MS}ms timeout`);
39
+ process.exit(1);
40
+ }, FORCE_EXIT_TIMEOUT_MS);
41
+ forceTimer.unref();
42
+
43
+ await this.shutdown();
44
+ if (onComplete) onComplete();
45
+ };
46
+
47
+ process.on('SIGTERM', () => handle('SIGTERM'));
48
+ process.on('SIGINT', () => handle('SIGINT'));
49
+ }
50
+ }
@@ -0,0 +1,47 @@
1
+ export class HandlerRegistry {
2
+ constructor() {
3
+ this._handlers = new Map();
4
+ }
5
+
6
+ /**
7
+ * Register a handler function for a job name.
8
+ * @param {string} name - Job name
9
+ * @param {Function} handler - async function(payload, context)
10
+ */
11
+ register(name, handler) {
12
+ if (typeof handler !== 'function') {
13
+ throw new Error('Handler must be a function');
14
+ }
15
+ this._handlers.set(name, handler);
16
+ }
17
+
18
+ /**
19
+ * Get handler by job name. Throws if not found.
20
+ * @param {string} name
21
+ * @returns {Function}
22
+ */
23
+ get(name) {
24
+ const handler = this._handlers.get(name);
25
+ if (!handler) {
26
+ throw new Error(`No handler registered for job: ${name}`);
27
+ }
28
+ return handler;
29
+ }
30
+
31
+ /**
32
+ * Check if a handler is registered.
33
+ * @param {string} name
34
+ * @returns {boolean}
35
+ */
36
+ has(name) {
37
+ return this._handlers.has(name);
38
+ }
39
+
40
+ /**
41
+ * List all registered job names.
42
+ * @returns {string[]}
43
+ */
44
+ names() {
45
+ return Array.from(this._handlers.keys());
46
+ }
47
+ }
@@ -0,0 +1,106 @@
1
+ import os from 'os';
2
+ import {HEARTBEAT_KEY_PREFIX} from './heartbeatKey.js';
3
+
4
+ /**
5
+ * Heartbeat — writes a TTL'd key to Redis on a fixed interval.
6
+ *
7
+ * Key format: worker:heartbeat:<workerId>
8
+ * Value: JSON-encoded WorkerInfo ({workerId, hostname, pid, tiers, startedAt, lastBeat})
9
+ * TTL: ttlMs (auto-expires if worker dies between beats)
10
+ *
11
+ * Failure mode: if _beat() throws (Redis blip), log a warning and continue.
12
+ * The next interval tick retries; no state is lost. If Redis is unreachable
13
+ * past ttlMs, the key expires — observers correctly conclude the worker is dead.
14
+ */
15
+ export class Heartbeat {
16
+ /**
17
+ * @param {object} options
18
+ * @param {object} options.redis - ioredis client with set/del
19
+ * @param {string} options.workerId - unique worker identity
20
+ * @param {object} options.tiers - {heavy, medium, light} concurrency
21
+ * @param {number} options.intervalMs - beat cadence
22
+ * @param {number} options.ttlMs - Redis key TTL
23
+ */
24
+ constructor({redis, workerId, tiers, intervalMs, ttlMs}) {
25
+ this._redis = redis;
26
+ this._workerId = workerId;
27
+ this._tiers = tiers;
28
+ this._intervalMs = intervalMs;
29
+ this._ttlMs = ttlMs;
30
+ this._key = `${HEARTBEAT_KEY_PREFIX}${workerId}`;
31
+ this._hostname = os.hostname();
32
+ this._pid = process.pid;
33
+ this._startedAt = Date.now();
34
+ this._timer = null;
35
+ this._started = false;
36
+ this._stopped = false;
37
+ }
38
+
39
+ async start() {
40
+ if (this._stopped) {
41
+ throw new Error('Heartbeat: cannot restart after stop() — create a new instance');
42
+ }
43
+ if (this._started) {
44
+ process.stderr.write(
45
+ '[worker-sdk] Heartbeat: start() called twice — ignoring second call\n'
46
+ );
47
+ return;
48
+ }
49
+ this._started = true;
50
+ await this._beat().catch(err => {
51
+ process.stderr.write(
52
+ `[worker-sdk] Heartbeat: initial beat failed (${err.message}); will retry on interval\n`
53
+ );
54
+ });
55
+ this._timer = setInterval(() => {
56
+ this._beat().catch(err => {
57
+ process.stderr.write(
58
+ `[worker-sdk] Heartbeat: beat failed (${err.message}); retrying next interval\n`
59
+ );
60
+ });
61
+ }, this._intervalMs);
62
+ this._timer.unref?.();
63
+ }
64
+
65
+ async _beat() {
66
+ const payload = JSON.stringify({
67
+ workerId: this._workerId,
68
+ hostname: this._hostname,
69
+ pid: this._pid,
70
+ tiers: this._tiers,
71
+ startedAt: this._startedAt,
72
+ lastBeat: Date.now()
73
+ });
74
+ this._inFlightBeat = this._redis.set(this._key, payload, 'PX', this._ttlMs);
75
+ try {
76
+ await this._inFlightBeat;
77
+ } finally {
78
+ this._inFlightBeat = null;
79
+ }
80
+ }
81
+
82
+ async stop() {
83
+ if (this._stopped) return;
84
+ this._stopped = true;
85
+ if (this._timer) {
86
+ clearInterval(this._timer);
87
+ this._timer = null;
88
+ }
89
+ // Wait for any in-flight beat to settle before deleting the key
90
+ if (this._inFlightBeat) {
91
+ try {
92
+ await this._inFlightBeat;
93
+ } catch {
94
+ // beat errored; carry on
95
+ }
96
+ }
97
+ if (!this._started) return; // never wrote a key; nothing to delete
98
+ if (this._redis && typeof this._redis.del === 'function') {
99
+ try {
100
+ await this._redis.del(this._key);
101
+ } catch {
102
+ // ignore — stopping is best-effort; key will expire via TTL anyway
103
+ }
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Prefix for worker heartbeat keys in Redis.
3
+ * Used by both the Heartbeat module (writer) and listWorkers (reader).
4
+ * Keys take the form `${HEARTBEAT_KEY_PREFIX}<workerId>`.
5
+ */
6
+ export const HEARTBEAT_KEY_PREFIX = 'worker:heartbeat:';
@@ -0,0 +1,169 @@
1
+ import {AsyncLocalStorage} from 'node:async_hooks';
2
+ import {inspect} from 'node:util';
3
+
4
+ const jobStorage = new AsyncLocalStorage();
5
+
6
+ /**
7
+ * Stringify a value for log capture without ever throwing — circular refs,
8
+ * BigInt, and throwing toJSON() must not crash the job that logged them.
9
+ */
10
+ function safeStringify(value) {
11
+ if (typeof value === 'string') return value;
12
+ try {
13
+ const json = JSON.stringify(value);
14
+ return json === undefined ? String(value) : json;
15
+ } catch {
16
+ return inspect(value, {depth: 3, breakLength: Infinity});
17
+ }
18
+ }
19
+ let consolePatched = false;
20
+ let fileLogger = null;
21
+
22
+ /**
23
+ * Set the file logger instance. Called by createWorker after loading config.
24
+ * @param {import('../logging/fileLogger.js').FileLogger|null} logger
25
+ */
26
+ export function setFileLogger(logger) {
27
+ fileLogger = logger;
28
+ }
29
+
30
+ let lokiShipper = null;
31
+
32
+ /**
33
+ * Set the Loki shipper instance. Called by createWorker after loading config.
34
+ * @param {import('../logging/lokiShipper.js').LokiShipper|null} shipper
35
+ */
36
+ export function setLokiShipper(shipper) {
37
+ lokiShipper = shipper;
38
+ }
39
+
40
+ /**
41
+ * Write to the file logger and Loki shipper if configured.
42
+ */
43
+ function writeLogEntry(entry) {
44
+ if (fileLogger) {
45
+ fileLogger.write(entry);
46
+ }
47
+ if (lokiShipper) {
48
+ lokiShipper.push(entry);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Patch console once at module load to route logs to the active job (if any).
54
+ * Uses AsyncLocalStorage so each concurrent job gets its own context — no race conditions.
55
+ */
56
+ function patchConsoleOnce() {
57
+ if (consolePatched) return;
58
+ consolePatched = true;
59
+
60
+ const originalLog = console.log;
61
+ const originalInfo = console.info;
62
+ const originalWarn = console.warn;
63
+ const originalError = console.error;
64
+
65
+ const capture = (originalFn, level) => {
66
+ return (...args) => {
67
+ originalFn.apply(console, args);
68
+
69
+ const store = jobStorage.getStore();
70
+ if (store) {
71
+ const message = args.map(safeStringify).join(' ');
72
+ store.job.log(`[${level}] ${message}`).catch(() => {});
73
+ writeLogEntry({job: store.jobName, id: store.jobId, level, msg: message});
74
+ }
75
+ };
76
+ };
77
+
78
+ console.log = capture(originalLog, 'LOG');
79
+ console.info = capture(originalInfo, 'INFO');
80
+ console.warn = capture(originalWarn, 'WARN');
81
+ console.error = capture(originalError, 'ERROR');
82
+ }
83
+
84
+ export class JobExecutor {
85
+ constructor() {
86
+ patchConsoleOnce();
87
+ }
88
+
89
+ /**
90
+ * Execute a handler with timeout and structured context.
91
+ * Console output is automatically captured to the job's BullMQ logs
92
+ * and file logs via AsyncLocalStorage (concurrency-safe).
93
+ *
94
+ * @param {Function} handler - async function(payload, context)
95
+ * @param {import('bullmq').Job} job - BullMQ job instance
96
+ * @param {number} timeoutMs - Timeout in milliseconds
97
+ * @param {object} [extraContext] - Extra fields merged into the handler context
98
+ * by the caller (e.g. `requeue` for admission control). Keys here override
99
+ * nothing — they are added alongside jobId/attempt/signal/logger.
100
+ * @returns {Promise<*>} Handler result
101
+ */
102
+ async run(handler, job, timeoutMs, extraContext = {}) {
103
+ const ac = new AbortController();
104
+ let timer;
105
+
106
+ if (timeoutMs > 0) {
107
+ timer = setTimeout(() => ac.abort(), timeoutMs);
108
+ }
109
+
110
+ const logger = createJobLogger(job);
111
+
112
+ const context = {
113
+ // Extra fields injected by the processor (e.g. `requeue` for admission control).
114
+ // Spread first so the core fields below can never be shadowed by a caller.
115
+ ...extraContext,
116
+ jobId: job.id,
117
+ attempt: job.attemptsMade + 1,
118
+ signal: ac.signal,
119
+ logger
120
+ };
121
+
122
+ // Store both the BullMQ job (for job.log) and metadata (for file logging)
123
+ const store = {job, jobName: job.name, jobId: job.id};
124
+
125
+ try {
126
+ return await jobStorage.run(store, () => handler(job.data, context));
127
+ } finally {
128
+ if (timer) clearTimeout(timer);
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Create a structured logger that writes to BullMQ job logs + terminal + file.
135
+ */
136
+ function createJobLogger(job) {
137
+ const prefix = `[${job.name}:${job.id}]`;
138
+
139
+ const write = (level, message, data) => {
140
+ const entry = data
141
+ ? `[${level.toUpperCase()}] ${message} ${safeStringify(data)}`
142
+ : `[${level.toUpperCase()}] ${message}`;
143
+
144
+ // Write to Redis (visible in Bull Board)
145
+ job.log(entry).catch(() => {});
146
+
147
+ // Write to terminal
148
+ const output = `${prefix} ${entry}\n`;
149
+ if (level === 'error') {
150
+ process.stderr.write(output);
151
+ } else {
152
+ process.stdout.write(output);
153
+ }
154
+
155
+ writeLogEntry({
156
+ job: job.name,
157
+ id: job.id,
158
+ level: level.toUpperCase(),
159
+ msg: message,
160
+ data
161
+ });
162
+ };
163
+
164
+ return {
165
+ info: (message, data) => write('info', message, data),
166
+ warn: (message, data) => write('warn', message, data),
167
+ error: (message, data) => write('error', message, data)
168
+ };
169
+ }