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