@avada-falcon/worker-sdk 0.5.0 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avada-falcon/worker-sdk",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Self-hosted background job runner powered by BullMQ + Redis",
5
5
  "type": "module",
6
6
  "main": "src/index.cjs",
@@ -15,7 +15,15 @@ export function createClient(configPath) {
15
15
  const optsCache = new Map(); // job opts are static per job name — build once
16
16
 
17
17
  return {
18
- async add(jobName, payload) {
18
+ /**
19
+ * @param {string} jobName
20
+ * @param {object} payload
21
+ * @param {{jobId?: string}} [opts] - Caller may supply a readable, unique jobId
22
+ * (e.g. `<job>-<shop>-<rand>`). jobId MUST be unique per dispatch — BullMQ silently
23
+ * drops an add whose id already exists (dedup), so the caller owns collision-resistance.
24
+ * Omitted → a random `<jobName>-<uuid>` is used.
25
+ */
26
+ async add(jobName, payload, opts = {}) {
19
27
  const jobConfig = config.jobs[jobName];
20
28
  if (!jobConfig) {
21
29
  throw new Error(`Unknown job: ${jobName}. Define it in your worker config.`);
@@ -28,7 +36,7 @@ export function createClient(configPath) {
28
36
 
29
37
  return queue.add(jobName, payload, {
30
38
  ...optsCache.get(jobName),
31
- jobId: `${jobName}-${randomUUID()}`
39
+ jobId: opts.jobId || `${jobName}-${randomUUID()}`
32
40
  });
33
41
  },
34
42
 
@@ -12,10 +12,17 @@ import {FileLogger} from '../logging/fileLogger.js';
12
12
  import {LokiShipper} from '../logging/lokiShipper.js';
13
13
  import {Heartbeat} from './heartbeat.js';
14
14
 
15
- /** Key under job.data holding how many times the job has been declined by a worker. */
16
- const REQUEUE_COUNT_KEY = '__requeues';
17
- const DEFAULT_MAX_REQUEUES = 50;
15
+ /** Keys stored under job.data to track admission back-off across requeues. */
16
+ const REQUEUE_COUNT_KEY = '__requeues'; // how many times declined — for logging only
17
+ const REQUEUE_FIRST_KEY = '__firstDeclinedAt'; // epoch ms of the FIRST decline — drives the cap
18
18
  const DEFAULT_REQUEUE_DELAY_MS = 200;
19
+ // Runaway BACKSTOP for a stuck admission gate (e.g. a leaked memory budget that never
20
+ // frees), NOT normal backpressure. Wall-clock based, not a requeue COUNT: a count cap of
21
+ // 50 × ~200ms was only ~15s of tolerance — far less than a single job's timeout (up to
22
+ // 540s) — so a job legitimately queued behind long-running jobs failed for merely waiting.
23
+ // 30 min is comfortably longer than any single job, so only a truly stuck fleet trips it.
24
+ // Override per job with jobConfig.maxRequeueWaitMs.
25
+ const DEFAULT_MAX_REQUEUE_WAIT_MS = 30 * 60 * 1000;
19
26
 
20
27
  function requeueCount(job) {
21
28
  const count = job.data?.[REQUEUE_COUNT_KEY];
@@ -83,7 +90,7 @@ export function createWorker(configPath) {
83
90
  const handler = registry.get(job.name);
84
91
  const jobConfig = config.jobs[job.name] || {};
85
92
  const timeout = jobConfig.timeout || 30000;
86
- const maxRequeues = jobConfig.maxRequeues ?? DEFAULT_MAX_REQUEUES;
93
+ const maxRequeueWaitMs = jobConfig.maxRequeueWaitMs ?? DEFAULT_MAX_REQUEUE_WAIT_MS;
87
94
 
88
95
  // Let a handler DECLINE to run on this worker and hand the job back to the queue,
89
96
  // delayed briefly, instead of blocking — a job blocked inside the handler stays
@@ -99,21 +106,31 @@ export function createWorker(configPath) {
99
106
  // then returns normally makes BullMQ complete a job that is also sitting in the
100
107
  // delayed set — it runs twice. Call it as `return ctx.requeue()` or let it throw.
101
108
  const requeue = async (retryMs = DEFAULT_REQUEUE_DELAY_MS) => {
109
+ const now = Date.now();
110
+ const data = job.data || {};
111
+ const firstDeclinedAt = data[REQUEUE_FIRST_KEY] || now;
112
+ const waitedMs = now - firstDeclinedAt;
102
113
  const count = requeueCount(job) + 1;
103
114
 
104
- // No attempt is burned on a requeue, so an admission gate that never opens
105
- // (leaked memory budget, gate bug) would ping-pong this job forever, silently.
106
- // Cap it and fail loudly instead.
107
- if (count > maxRequeues) {
115
+ // A requeue burns no attempt, so a gate that never opens (leaked memory budget,
116
+ // gate bug) would ping-pong this job forever, silently. Fail loudly — but only
117
+ // after a long WALL-CLOCK wait, so a job legitimately queued behind long-running
118
+ // jobs (up to their timeout) is never failed for merely waiting; only a truly
119
+ // stuck gate trips this.
120
+ if (waitedMs > maxRequeueWaitMs) {
108
121
  throw new Error(
109
- `Job ${job.name}:${job.id} was declined ${count - 1} times ` +
110
- `(maxRequeues: ${maxRequeues}) — admission gate never opened.`
122
+ `Job ${job.name}:${job.id} not admitted after ${Math.round(waitedMs / 1000)}s ` +
123
+ `over ${count} tries (maxRequeueWaitMs: ${maxRequeueWaitMs}ms) — admission gate stuck.`
111
124
  );
112
125
  }
113
126
 
114
- // Persist the counter: the in-memory job.data copy is dropped once the job
115
- // goes back to Redis, so it has to be written before re-scheduling.
116
- await job.updateData({...job.data, [REQUEUE_COUNT_KEY]: count});
127
+ // Persist across the re-schedule: the in-memory job.data copy is dropped once the
128
+ // job goes back to Redis. Keep the FIRST-decline stamp stable; bump the info count.
129
+ await job.updateData({
130
+ ...data,
131
+ [REQUEUE_FIRST_KEY]: firstDeclinedAt,
132
+ [REQUEUE_COUNT_KEY]: count
133
+ });
117
134
 
118
135
  // Jitter the delay. Without it, every job this worker declines wakes at the
119
136
  // same instant and gets declined again in lockstep (thundering herd).
@@ -121,10 +138,12 @@ export function createWorker(configPath) {
121
138
 
122
139
  console.warn(
123
140
  `[worker-sdk] Declined ${job.name}:${job.id} — requeued in ${delay}ms ` +
124
- `(${count}/${maxRequeues})`
141
+ `(try ${count}, waited ${Math.round(waitedMs / 1000)}s/${Math.round(
142
+ maxRequeueWaitMs / 1000
143
+ )}s)`
125
144
  );
126
145
 
127
- await job.moveToDelayed(Date.now() + delay, token);
146
+ await job.moveToDelayed(now + delay, token);
128
147
  throw new DelayedError();
129
148
  };
130
149