@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 ADDED
@@ -0,0 +1,442 @@
1
+ # @avada-falcon/worker-sdk
2
+
3
+ Self-hosted background job runner powered by BullMQ + Redis. Replace Firebase Cloud Functions with a simple, local job queue.
4
+
5
+ ## Features
6
+
7
+ - **Tier-based concurrency** — heavy/medium/light queues with configurable limits
8
+ - **Cron jobs** — first-class scheduled job support via BullMQ repeatable jobs
9
+ - **Bull Board dashboard** — built-in web UI for inspecting jobs, retries, errors
10
+ - **TLS Redis support** — connect to remote Redis over TLS (production-ready)
11
+ - **Env-driven config** — single YAML works across local dev and production
12
+ - **Console capture** — `console.log/warn/error` inside handlers automatically appears in Bull Board
13
+ - **Readable job IDs** — `{jobName}-{uuid}` format
14
+ - **Graceful shutdown** — drains in-flight jobs on SIGTERM/SIGINT
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @avada-falcon/worker-sdk
20
+ ```
21
+
22
+ Requires Node.js >= 20 and a Redis instance you can reach.
23
+
24
+ ## Quick Start
25
+
26
+ ### 1. Create config
27
+
28
+ `worker.config.yml`:
29
+
30
+ ```yaml
31
+ redis:
32
+ host: ${REDIS_HOST:-127.0.0.1}
33
+ port: ${REDIS_PORT:-6379}
34
+ password: ${REDIS_PASSWORD:-}
35
+ tls: ${REDIS_TLS:-}
36
+
37
+ logging:
38
+ dir: ./logs # local file buffer (short retention)
39
+ retentionDays: 7
40
+ loki: # optional — ship to Loki for long-term search
41
+ url: ${LOKI_URL:-}
42
+ batchSize: 100
43
+ flushInterval: 5000
44
+ labels:
45
+ app: my-app
46
+ env: production
47
+
48
+ dashboard:
49
+ port: 3800
50
+ auth:
51
+ username: admin
52
+ password: ${WORKER_DASHBOARD_PASSWORD}
53
+
54
+ concurrency:
55
+ heavy: 2
56
+ medium: 5
57
+ light: 10
58
+
59
+ jobs:
60
+ processOrder:
61
+ tier: heavy
62
+ timeout: 60000
63
+ retry:
64
+ maxAttempts: 3
65
+ baseDelay: 2000
66
+
67
+ sendNotification:
68
+ tier: light
69
+ timeout: 10000
70
+
71
+ dailyReport:
72
+ tier: medium
73
+ timeout: 300000
74
+ cron: "0 0 * * *"
75
+ ```
76
+
77
+ YAML supports `${VAR}` and `${VAR:-default}` env interpolation. The same file can run locally (defaults to `127.0.0.1`) or in production (override via env vars).
78
+
79
+ ### 2. Define handlers
80
+
81
+ ```js
82
+ // jobs/processOrder.js
83
+ export async function execute(payload, context) {
84
+ const {logger, signal, jobId, attempt} = context;
85
+
86
+ logger.info('Processing order', {orderId: payload.orderId});
87
+
88
+ // console.log/warn/error inside this function (and any code it calls)
89
+ // is automatically captured to Bull Board logs
90
+ console.log('Doing some work...');
91
+
92
+ return {success: true};
93
+ }
94
+ ```
95
+
96
+ ### 3. Start worker
97
+
98
+ ```js
99
+ // worker.mjs
100
+ import {createWorker} from '@minhdevtree/worker-sdk';
101
+ import {execute as processOrder} from './jobs/processOrder.js';
102
+ import {execute as sendNotification} from './jobs/sendNotification.js';
103
+ import {execute as dailyReport} from './jobs/dailyReport.js';
104
+
105
+ const worker = createWorker('./worker.config.yml');
106
+
107
+ worker.register('processOrder', processOrder);
108
+ worker.register('sendNotification', sendNotification);
109
+ worker.register('dailyReport', dailyReport);
110
+
111
+ await worker.start();
112
+ ```
113
+
114
+ Run it: `node worker.mjs`
115
+
116
+ ### 4. Push jobs from your app
117
+
118
+ ```js
119
+ import {createClient} from '@minhdevtree/worker-sdk';
120
+
121
+ const client = createClient('./worker.config.yml');
122
+
123
+ await client.add('processOrder', {orderId: 42});
124
+ // → returns {id: "processOrder-e7a3b5c2-3a4f-..."}
125
+ ```
126
+
127
+ The client is lightweight — only creates BullMQ Queue instances on demand. Safe to import anywhere in your app backend.
128
+
129
+ ### 5. Run dashboard as a separate service (recommended)
130
+
131
+ ```js
132
+ // dashboard.mjs
133
+ import {createDashboard} from '@minhdevtree/worker-sdk';
134
+
135
+ const dashboard = createDashboard('./worker.config.yml');
136
+ await dashboard.start();
137
+ ```
138
+
139
+ Run it: `node dashboard.mjs`
140
+
141
+ Running the dashboard as its own process means it stays up even when workers restart, and a single dashboard can serve any number of workers (they all share Redis).
142
+
143
+ ## Tiers
144
+
145
+ Jobs are grouped by resource weight. Each tier maps to a separate BullMQ Worker with its own concurrency limit:
146
+
147
+ | Tier | Default Concurrency | Use case |
148
+ |------|-------------------|----------|
149
+ | heavy | 2 | CPU/memory intensive work — bulk API calls, image processing |
150
+ | medium | 5 | Moderate processing — page scanning, batch operations |
151
+ | light | 10 | Quick tasks — status updates, notifications |
152
+
153
+ Override defaults in `worker.config.yml`:
154
+
155
+ ```yaml
156
+ concurrency:
157
+ heavy: 3
158
+ medium: 10
159
+ light: 20
160
+ ```
161
+
162
+ ## Dashboard
163
+
164
+ Bull Board UI mounted at `http://localhost:3800` (port configurable). Features:
165
+
166
+ - View all queues with job counts
167
+ - Filter jobs by status (completed/failed/waiting/active/delayed)
168
+ - Inspect payload, result, logs, error/stack trace
169
+ - Retry/delete/promote individual jobs
170
+ - View repeatable cron schedules
171
+
172
+ Basic auth is required — set `username` and `password` in config.
173
+
174
+ ## Handler Context
175
+
176
+ ```js
177
+ export async function execute(payload, context) {
178
+ context.jobId // unique job ID — format: jobName-uuid
179
+ context.attempt // current attempt number (1-based)
180
+ context.logger // {info, warn, error} — writes to Bull Board + stdout + log file
181
+ context.signal // AbortSignal — fires when timeout expires
182
+ context.requeue // async (retryMs?) — decline this job, hand it back to the queue
183
+ }
184
+ ```
185
+
186
+ ### Declining a job with `context.requeue()`
187
+
188
+ Use it when this worker is *temporarily* unable to run the job — an app-level admission
189
+ gate such as a per-worker memory budget. Blocking inside the handler instead would keep
190
+ the job `active` and pinned to this worker, where no idle peer can pick it up.
191
+
192
+ ```js
193
+ export async function execute(payload, context) {
194
+ if (!memoryBudget.tryAcquire(payload.size)) {
195
+ return context.requeue(500); // back to the queue, retried in ~500-1000ms
196
+ }
197
+ // ...
198
+ }
199
+ ```
200
+
201
+ - The job is moved to `delayed` and re-dispatched to whichever worker is free. **No attempt
202
+ is burned** and no `failed` event fires — a decline is not a failure.
203
+ - `retryMs` defaults to `200` and is jittered (actual delay is `retryMs`–`2×retryMs`) so
204
+ a batch of declined jobs doesn't wake in lockstep.
205
+ - **`requeue()` always throws.** Do not wrap it in a `try/catch` that swallows the error —
206
+ BullMQ would then complete a job that is also sitting in the delayed set, and it would
207
+ run twice. Call it as `return context.requeue()`, or let the throw propagate.
208
+ - Declines are capped per job at `jobs.<name>.maxRequeues` (default `50`). Past the cap the
209
+ job fails with a real error instead of ping-ponging forever on a gate that never opens.
210
+
211
+ ```yaml
212
+ jobs:
213
+ heavyImport:
214
+ tier: heavy
215
+ timeout: 60000
216
+ maxRequeues: 100 # optional — default 50
217
+ ```
218
+
219
+ ## File Logging
220
+
221
+ When `logging.dir` is configured, all job logs are written to daily JSON line files on disk:
222
+
223
+ ```
224
+ logs/
225
+ 2026-04-14.log
226
+ 2026-04-13.log
227
+ ...
228
+ ```
229
+
230
+ Each line is a JSON object:
231
+
232
+ ```json
233
+ {"ts":"2026-04-14T10:30:00.123Z","job":"processOrder","id":"processOrder-abc123","level":"INFO","msg":"Processing","data":{"orderId":42}}
234
+ ```
235
+
236
+ Both `context.logger.info/warn/error` and captured `console.log/warn/error` are written.
237
+
238
+ Local files act as a short-term buffer — set `retentionDays` to how many days you want to keep on disk (e.g. 7). Old files are auto-deleted on worker startup. For long-term archive, configure `logging.loki` to ship logs to Grafana Loki (see below).
239
+
240
+ Search with grep:
241
+
242
+ ```bash
243
+ # All errors from a specific day
244
+ grep '"ERROR"' logs/2026-04-14.log
245
+
246
+ # Everything for a specific job ID
247
+ grep 'processOrder-abc123' logs/2026-04-14.log
248
+
249
+ # Search across multiple days
250
+ grep 'shopId.*xyz' logs/2026-04-*.log
251
+ ```
252
+
253
+ For Docker deployments, mount the logs directory as a volume so files persist on the host:
254
+
255
+ ```yaml
256
+ volumes:
257
+ - ./logs:/app/functions/logs
258
+ ```
259
+
260
+ ## Cron Jobs
261
+
262
+ Add a `cron` field to any job in the config. The SDK registers it as a BullMQ repeatable job — schedule survives restarts.
263
+
264
+ ```yaml
265
+ jobs:
266
+ dailyReport:
267
+ tier: medium
268
+ timeout: 300000
269
+ cron: "0 0 * * *" # every day at midnight
270
+ ```
271
+
272
+ The handler is registered like any other job — same `execute(payload, context)` signature.
273
+
274
+ ## Env Variables
275
+
276
+ | Variable | Required | Description |
277
+ |---|---|---|
278
+ | `WORKER_DASHBOARD_PASSWORD` | Yes | Bull Board dashboard password |
279
+ | `REDIS_HOST` | No | Defaults to YAML config |
280
+ | `REDIS_PORT` | No | Defaults to YAML config |
281
+ | `REDIS_PASSWORD` | No | Defaults to YAML config |
282
+ | `REDIS_TLS` | No | Set to `true` to enable TLS |
283
+
284
+ Any field in `worker.config.yml` can be made env-driven via `${VAR_NAME:-default}` syntax.
285
+
286
+ ## Long-term log search with Loki
287
+
288
+ This SDK configures BullMQ to retain only the last ~1000 completed jobs in Redis (`removeOnComplete`). For long-term historical search (weeks or months), configure Loki:
289
+
290
+ ```yaml
291
+ logging:
292
+ loki:
293
+ url: http://loki:3100
294
+ batchSize: 100
295
+ flushInterval: 5000
296
+ labels:
297
+ app: my-app
298
+ ```
299
+
300
+ The SDK will push every log entry (both `context.logger.*` and captured `console.*`) to Loki in batches. Use Grafana to search by job name, level, shop ID, date range.
301
+
302
+ If `loki.url` is empty or missing, Loki shipping is disabled — the SDK falls back to file-only logging.
303
+
304
+ Log retention in Loki is controlled by the Loki server's own `retention_period` config, not by the SDK.
305
+
306
+ **Setup your Loki stack** — see [SETUP.md](./SETUP.md) for a Docker Compose example that runs Loki + Grafana alongside the worker.
307
+
308
+ ## Multi-worker deployments
309
+
310
+ A single Redis + Loki + Bull Board can serve any number of workers. Scale horizontally on one machine (`docker compose up --scale`) or across hosts.
311
+
312
+ ### Identity and specialization
313
+
314
+ ```yaml
315
+ # worker.config.yml
316
+ worker:
317
+ id: ${WORKER_ID:-} # empty → auto-generated ${hostname}-${pid}
318
+
319
+ concurrency:
320
+ heavy: 2
321
+ medium: 5
322
+ light: 0 # 0 = opt out — this worker skips the light tier
323
+ ```
324
+
325
+ Each worker gets its own ID. In Docker Compose `--scale N` mode the auto-generated default (`${hostname}-${pid}`) gives meaningful IDs because Docker assigns unique hostnames per replica. For multi-host deployments set `WORKER_ID=mac-mini` / `WORKER_ID=vps-hanoi` per host so Grafana labels stay readable.
326
+
327
+ Tier opt-out via `concurrency: 0` enables heterogeneous pools: a beefy box can be heavy-only, a small box can be light-only. BullMQ distributes jobs atomically — a worker that doesn't subscribe to a tier never pulls from it.
328
+
329
+ ### Liveness: heartbeats
330
+
331
+ ```yaml
332
+ worker:
333
+ heartbeat:
334
+ enabled: true # default
335
+ intervalMs: 10000 # beat every 10s
336
+ ttlMs: 30000 # key expires after 30s of silence
337
+ ```
338
+
339
+ Every worker writes a TTL'd key `worker:heartbeat:<workerId>` to Redis on the interval. If a worker dies or loses Redis connectivity, the key expires automatically. Enumerate live workers:
340
+
341
+ ```js
342
+ import {listWorkers} from '@minhdevtree/worker-sdk';
343
+ import Redis from 'ioredis';
344
+
345
+ const redis = new Redis({host, port, password});
346
+ const workers = await listWorkers(redis);
347
+ // → [{workerId, hostname, pid, tiers, startedAt, lastBeat}, ...]
348
+ ```
349
+
350
+ ### Health checks
351
+
352
+ Four admin helpers for monitoring cluster state:
353
+
354
+ ```js
355
+ import {
356
+ pingRedis,
357
+ getQueueDepths,
358
+ checkDashboard,
359
+ getClusterHealth
360
+ } from '@minhdevtree/worker-sdk';
361
+
362
+ // Redis PING + latency
363
+ await pingRedis(redis);
364
+ // → {ok: true, latencyMs: 3}
365
+
366
+ // Per-tier queue depth (waiting/active/delayed/completed/failed/paused + total)
367
+ // Each tier reports its own ok flag — a per-tier failure doesn't abort the others.
368
+ await getQueueDepths(redis, ['heavy', 'medium', 'light']);
369
+ // → {heavy: {ok: true, waiting: 2, active: 1, ..., total: 3}, ...}
370
+
371
+ // Dashboard /health endpoint probe (no auth)
372
+ await checkDashboard('http://host:3800');
373
+ // → {ok: true, latencyMs: 12, status: 200, uptime: 1234, timestamp: '...'}
374
+
375
+ // One-shot aggregate: runs all probes in parallel
376
+ await getClusterHealth({
377
+ redis,
378
+ tiers: ['heavy', 'medium', 'light'], // optional
379
+ dashboardUrl: 'http://host:3800', // optional
380
+ timeouts: { // optional — defaults shown
381
+ redisMs: 2000,
382
+ workersMs: 5000,
383
+ queuesMs: 5000,
384
+ dashboardMs: 3000
385
+ }
386
+ });
387
+ // → {ok, status: 'healthy'|'degraded'|'unhealthy', checkedAt, redis,
388
+ // workers: {count, items}, queues: {byTier}, dashboard}
389
+ ```
390
+
391
+ `getClusterHealth` doesn't short-circuit — a failure in one probe reports `ok: false` for that section but still returns the others. Top-level `ok` is true only if every probed section is ok. The `status` field classifies the rollup: `healthy` (everything ok), `unhealthy` (Redis itself is down — the critical dependency), or `degraded` (Redis ok but at least one other section failed).
392
+
393
+ `checkDashboard` validates that `baseUrl` parses and uses `http:` or `https:`, sets `redirect: 'manual'`, and rejects responses larger than 64 KiB. These are basic SSRF guards — if you wire `baseUrl` to user input, add your own allow-list on top.
394
+
395
+ ### Scheduled jobs: the cron leader
396
+
397
+ ```yaml
398
+ cron:
399
+ leader: ${CRON_LEADER:-false} # EXACTLY ONE worker should set this to true
400
+ ```
401
+
402
+ In a multi-worker pool exactly one worker should be designated the cron leader. Only that worker registers scheduled jobs — others skip registration. If no worker has `cron.leader: true` and your `jobs` config includes cron entries, the SDK warns on startup and scheduled jobs do not fire.
403
+
404
+ For a pool of three, one compose-file pattern:
405
+
406
+ ```yaml
407
+ services:
408
+ worker-leader:
409
+ environment: {CRON_LEADER: "true"}
410
+ # one replica, always on
411
+ worker:
412
+ environment: {CRON_LEADER: "false"}
413
+ # scale this service: docker compose up -d --scale worker=N
414
+ ```
415
+
416
+ ### Filtering per worker in Grafana
417
+
418
+ Every log pushed to Loki carries the `workerId` label automatically:
419
+
420
+ ```logql
421
+ # just worker-2's logs
422
+ {app="my-app", workerId="mac-mini-2"}
423
+
424
+ # errors from any worker
425
+ {app="my-app", level="ERROR"}
426
+
427
+ # cross-worker comparison by job name
428
+ {app="my-app", job="generateAnchor"} | json
429
+ ```
430
+
431
+ ## Migration from Firebase Pub/Sub
432
+
433
+ | Before (Firebase) | After (Worker SDK) |
434
+ |---|---|
435
+ | `functions.runWith({memory, timeout})` | `worker.config.yml` job entry |
436
+ | `.pubsub.topic('name').onPublish(fn)` | `worker.register('name', execute)` |
437
+ | `JSON.parse(Buffer.from(message.data))` | `payload` (already parsed) |
438
+ | `console.log()` | Works as-is — captured to Bull Board |
439
+ | `publishTopic('next', data)` | `client.add('next', data)` |
440
+ | Runs on Google Cloud | Runs on your machine |
441
+
442
+ See `SETUP.md` for the full integration guide with handler structure, file layout, and migration steps.
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@avada-falcon/worker-sdk",
3
+ "version": "0.5.0",
4
+ "description": "Self-hosted background job runner powered by BullMQ + Redis",
5
+ "type": "module",
6
+ "main": "src/index.cjs",
7
+ "files": [
8
+ "src"
9
+ ],
10
+ "exports": {
11
+ ".": {
12
+ "import": "./src/index.js",
13
+ "require": "./src/index.cjs"
14
+ }
15
+ },
16
+ "scripts": {
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "engines": {
21
+ "node": ">=20"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://gitlab.com/avada/seoon-team/worker-sdk.git"
26
+ },
27
+ "license": "MIT",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "keywords": [
32
+ "worker",
33
+ "job-queue",
34
+ "bullmq",
35
+ "redis",
36
+ "background-jobs",
37
+ "self-hosted"
38
+ ],
39
+ "dependencies": {
40
+ "bullmq": "^5.0.0",
41
+ "@bull-board/api": "^6.0.0",
42
+ "@bull-board/express": "^6.0.0",
43
+ "express": "^5.0.0",
44
+ "ioredis": "^5.0.0",
45
+ "js-yaml": "^4.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "vitest": "^4.0.0"
49
+ }
50
+ }
@@ -0,0 +1,77 @@
1
+ const MAX_BODY_BYTES = 64 * 1024;
2
+
3
+ /**
4
+ * Probe the Bull Board dashboard `/health` endpoint.
5
+ *
6
+ * The `/health` route is unauthenticated — no credentials needed.
7
+ *
8
+ * Hardening:
9
+ * - `baseUrl` must parse as a URL with `http:` or `https:` scheme. Other
10
+ * schemes (file:, gopher:, ...) are rejected to limit SSRF surface.
11
+ * - Redirects are not followed (`redirect: 'manual'`) so a misbehaving
12
+ * dashboard cannot bounce the probe to an internal metadata endpoint.
13
+ * - Response bodies larger than 64 KiB (by Content-Length) are rejected.
14
+ * - The abort timer covers both the request and the JSON body parse.
15
+ *
16
+ * Caller is still responsible for not exposing `baseUrl` to untrusted input
17
+ * without additional allow-listing.
18
+ *
19
+ * @param {string} baseUrl - Dashboard URL (e.g. 'http://192.168.1.162:3800')
20
+ * @param {object} [options]
21
+ * @param {number} [options.timeoutMs=3000]
22
+ * @returns {Promise<{ok: boolean, latencyMs: number, status?: number, uptime?: number, timestamp?: string, error?: string}>}
23
+ */
24
+ export async function checkDashboard(baseUrl, {timeoutMs = 3000} = {}) {
25
+ if (!baseUrl || typeof baseUrl !== 'string') {
26
+ return {ok: false, latencyMs: 0, error: 'baseUrl is required'};
27
+ }
28
+
29
+ let parsed;
30
+ try {
31
+ parsed = new URL(baseUrl);
32
+ } catch {
33
+ return {ok: false, latencyMs: 0, error: 'invalid baseUrl'};
34
+ }
35
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
36
+ return {ok: false, latencyMs: 0, error: `unsupported protocol: ${parsed.protocol}`};
37
+ }
38
+
39
+ const url = baseUrl.replace(/\/+$/, '') + '/health';
40
+ const controller = new AbortController();
41
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
42
+ timer.unref?.();
43
+
44
+ const start = Date.now();
45
+ const fail = (error, status) => ({
46
+ ok: false,
47
+ latencyMs: Date.now() - start,
48
+ ...(status !== undefined && {status}),
49
+ error
50
+ });
51
+
52
+ try {
53
+ const res = await fetch(url, {signal: controller.signal, redirect: 'manual'});
54
+ if (!res.ok) {
55
+ return fail(`HTTP ${res.status}`, res.status);
56
+ }
57
+ const contentLength = Number(res.headers?.get?.('content-length') ?? 0);
58
+ if (contentLength > MAX_BODY_BYTES) {
59
+ return fail(`response too large (${contentLength} bytes)`, res.status);
60
+ }
61
+ const body = await res.json().catch(() => ({}));
62
+ if (body && typeof body.status === 'string' && body.status !== 'ok') {
63
+ return fail(`dashboard status: ${body.status}`, res.status);
64
+ }
65
+ return {
66
+ ok: true,
67
+ latencyMs: Date.now() - start,
68
+ status: res.status,
69
+ uptime: body.uptime,
70
+ timestamp: body.timestamp
71
+ };
72
+ } catch (err) {
73
+ return fail(err.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : err.message);
74
+ } finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
@@ -0,0 +1,114 @@
1
+ import {listWorkers} from './listWorkers.js';
2
+ import {pingRedis} from './pingRedis.js';
3
+ import {getQueueDepths} from './getQueueDepths.js';
4
+ import {checkDashboard} from './checkDashboard.js';
5
+ import {withTimeout} from './withTimeout.js';
6
+
7
+ /**
8
+ * Aggregate cluster health: Redis, workers, queue depths, (optional) dashboard.
9
+ *
10
+ * All probes run in parallel. A failure in one probe does not short-circuit
11
+ * the others — each section reports its own `ok` flag. The top-level `ok` is
12
+ * true only when every probed section is ok.
13
+ *
14
+ * `status` classifies the rollup:
15
+ * - `'healthy'` — every probed section ok
16
+ * - `'unhealthy'` — Redis is down (the critical dependency)
17
+ * - `'degraded'` — Redis ok, but at least one other section failed
18
+ *
19
+ * Throws only on programmer error (missing `redis`); never on transient failures.
20
+ *
21
+ * @param {object} params
22
+ * @param {import('ioredis').Redis} params.redis - ioredis client (used for PING + listWorkers)
23
+ * @param {object|import('ioredis').Redis} [params.connection] - Passed to BullMQ Queue. Defaults to `redis`.
24
+ * @param {string[]} [params.tiers] - Tier names to probe (e.g. ['heavy','medium','light']). Omit to skip queue depths.
25
+ * @param {string} [params.dashboardUrl] - Base URL (e.g. 'http://host:3800'). Omit to skip dashboard probe.
26
+ * @param {object} [params.timeouts] - Per-probe timeouts in ms
27
+ * @param {number} [params.timeouts.redisMs=2000]
28
+ * @param {number} [params.timeouts.dashboardMs=3000]
29
+ * @param {number} [params.timeouts.queuesMs=5000]
30
+ * @param {number} [params.timeouts.workersMs=5000]
31
+ * @returns {Promise<{
32
+ * ok: boolean,
33
+ * status: 'healthy' | 'degraded' | 'unhealthy',
34
+ * checkedAt: string,
35
+ * redis: object,
36
+ * workers: {ok: boolean, count: number, items: Array, error?: string},
37
+ * queues?: {ok: boolean, byTier?: object, error?: string},
38
+ * dashboard?: object
39
+ * }>}
40
+ */
41
+ export async function getClusterHealth({
42
+ redis,
43
+ connection,
44
+ tiers,
45
+ dashboardUrl,
46
+ timeouts = {}
47
+ }) {
48
+ if (!redis) {
49
+ throw new Error('getClusterHealth: `redis` is required');
50
+ }
51
+
52
+ const {
53
+ redisMs = 2000,
54
+ dashboardMs = 3000,
55
+ queuesMs = 5000,
56
+ workersMs = 5000
57
+ } = timeouts;
58
+ const queueConnection = connection || redis;
59
+
60
+ const [redisResult, workersResult, queuesResult, dashboardResult] = await Promise.all([
61
+ pingRedis(redis, {timeoutMs: redisMs}),
62
+ safeRunWithTimeout(() => listWorkers(redis), workersMs),
63
+ tiers && tiers.length > 0
64
+ ? safeRunWithTimeout(() => getQueueDepths(queueConnection, tiers, {timeoutMs: queuesMs}), queuesMs)
65
+ : null,
66
+ dashboardUrl ? checkDashboard(dashboardUrl, {timeoutMs: dashboardMs}) : null
67
+ ]);
68
+
69
+ const workers = workersResult.ok
70
+ ? {ok: true, count: workersResult.value.length, items: workersResult.value}
71
+ : {ok: false, count: 0, items: [], error: workersResult.error};
72
+
73
+ const sections = {
74
+ checkedAt: new Date().toISOString(),
75
+ redis: redisResult,
76
+ workers
77
+ };
78
+
79
+ if (queuesResult) {
80
+ if (!queuesResult.ok) {
81
+ sections.queues = {ok: false, error: queuesResult.error};
82
+ } else {
83
+ const byTier = queuesResult.value;
84
+ const allTiersOk = Object.values(byTier).every(t => t.ok !== false);
85
+ sections.queues = {ok: allTiersOk, byTier};
86
+ }
87
+ }
88
+
89
+ if (dashboardResult) {
90
+ sections.dashboard = dashboardResult;
91
+ }
92
+
93
+ sections.ok =
94
+ sections.redis.ok &&
95
+ sections.workers.ok &&
96
+ (sections.queues?.ok ?? true) &&
97
+ (sections.dashboard?.ok ?? true);
98
+
99
+ sections.status = sections.ok
100
+ ? 'healthy'
101
+ : !sections.redis.ok
102
+ ? 'unhealthy'
103
+ : 'degraded';
104
+
105
+ return sections;
106
+ }
107
+
108
+ async function safeRunWithTimeout(fn, timeoutMs) {
109
+ try {
110
+ return {ok: true, value: await withTimeout(fn(), timeoutMs)};
111
+ } catch (err) {
112
+ return {ok: false, error: err.message};
113
+ }
114
+ }