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