@boringnode/queue 0.5.2 → 0.6.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.
Files changed (35) hide show
  1. package/README.md +94 -14
  2. package/build/chunk-6IO4P6RB.js +145 -0
  3. package/build/chunk-6IO4P6RB.js.map +1 -0
  4. package/build/{chunk-VRXHCWNK.js → chunk-AHUVTAI7.js} +220 -15
  5. package/build/chunk-AHUVTAI7.js.map +1 -0
  6. package/build/chunk-S37X3CBO.js +500 -0
  7. package/build/chunk-S37X3CBO.js.map +1 -0
  8. package/build/index.d.ts +10 -2
  9. package/build/index.js +187 -31
  10. package/build/index.js.map +1 -1
  11. package/build/{job-Z5fBSzRX.d.ts → job-C4oyCVxR.d.ts} +131 -10
  12. package/build/src/contracts/adapter.d.ts +1 -1
  13. package/build/src/drivers/fake_adapter.d.ts +7 -6
  14. package/build/src/drivers/fake_adapter.js +1 -1
  15. package/build/src/drivers/knex_adapter.d.ts +6 -5
  16. package/build/src/drivers/knex_adapter.js +112 -0
  17. package/build/src/drivers/knex_adapter.js.map +1 -1
  18. package/build/src/drivers/redis_adapter.d.ts +6 -5
  19. package/build/src/drivers/redis_adapter.js +134 -368
  20. package/build/src/drivers/redis_adapter.js.map +1 -1
  21. package/build/src/drivers/redis_job_storage.d.ts +17 -0
  22. package/build/src/drivers/redis_job_storage.js +14 -0
  23. package/build/src/drivers/redis_job_storage.js.map +1 -0
  24. package/build/src/drivers/redis_scripts.d.ts +87 -0
  25. package/build/src/drivers/redis_scripts.js +29 -0
  26. package/build/src/drivers/redis_scripts.js.map +1 -0
  27. package/build/src/drivers/sync_adapter.d.ts +2 -1
  28. package/build/src/drivers/sync_adapter.js +7 -1
  29. package/build/src/drivers/sync_adapter.js.map +1 -1
  30. package/build/src/otel.d.ts +2 -2
  31. package/build/src/types/index.d.ts +1 -1
  32. package/build/src/types/main.d.ts +1 -1
  33. package/build/src/types/tracing_channels.d.ts +7 -1
  34. package/package.json +17 -17
  35. package/build/chunk-VRXHCWNK.js.map +0 -1
package/README.md CHANGED
@@ -26,6 +26,7 @@ npm install @boringnode/queue
26
26
  - **Priority Queues**: Process high-priority jobs first
27
27
  - **Bulk Dispatch**: Efficiently dispatch thousands of jobs at once
28
28
  - **Job Grouping**: Organize related jobs for monitoring
29
+ - **Job Deduplication**: Prevent duplicate jobs with custom IDs
29
30
  - **Retry with Backoff**: Exponential, linear, or fixed backoff strategies
30
31
  - **Job Timeout**: Fail or retry jobs that exceed a time limit
31
32
  - **Job History**: Retain completed/failed jobs for debugging
@@ -131,6 +132,85 @@ await SendEmailJob.dispatchMany(recipients).group('newsletter-jan-2025')
131
132
 
132
133
  The `groupId` is stored with job data and accessible via `job.data.groupId`.
133
134
 
135
+ ## Job Deduplication
136
+
137
+ Prevent the same job from being pushed multiple times. Four modes, all via `.dedup()`:
138
+
139
+ ### Simple (skip while job exists)
140
+
141
+ ```typescript
142
+ // First dispatch - job is created
143
+ await SendInvoiceJob.dispatch({ orderId: 123 }).dedup({ id: 'order-123' }).run()
144
+
145
+ // Second dispatch with same dedup ID - silently skipped
146
+ await SendInvoiceJob.dispatch({ orderId: 123 }).dedup({ id: 'order-123' }).run()
147
+ ```
148
+
149
+ ### Throttle (skip within TTL window)
150
+
151
+ ```typescript
152
+ // Within 5s, duplicates are skipped. After 5s, a new job is created.
153
+ await SendEmailJob.dispatch({ to: 'user@example.com' })
154
+ .dedup({ id: 'welcome-123', ttl: '5s' })
155
+ .run()
156
+ ```
157
+
158
+ ### Extend (reset TTL on duplicate)
159
+
160
+ ```typescript
161
+ // Each duplicate push resets the TTL timer.
162
+ await RateLimitJob.dispatch({ userId: 42 }).dedup({ id: 'rate-42', ttl: '1m', extend: true }).run()
163
+ ```
164
+
165
+ ### Debounce (replace payload + reset TTL)
166
+
167
+ ```typescript
168
+ // Within the 2s window, the latest payload overwrites the previous pending job.
169
+ await SaveDraftJob.dispatch({ content: 'latest draft' })
170
+ .dedup({ id: 'draft-42', ttl: '2s', replace: true, extend: true })
171
+ .run()
172
+ ```
173
+
174
+ ### Inspecting the outcome
175
+
176
+ `DispatchResult` tells you what happened:
177
+
178
+ ```typescript
179
+ const { jobId, deduped } = await SaveDraftJob.dispatch({ content: '...' })
180
+ .dedup({ id: 'draft-42', ttl: '2s', replace: true })
181
+ .run()
182
+
183
+ // deduped: 'added' | 'skipped' | 'replaced' | 'extended'
184
+ // jobId: the UUID of the job (the existing one when deduped)
185
+ ```
186
+
187
+ ### How it works
188
+
189
+ - The dedup ID is automatically prefixed with the job name (`SendInvoiceJob::order-123`), so different job types can reuse the same key.
190
+ - The user-supplied `id` must be ≤ 400 characters, and the combined `<jobName>::<id>` key must be ≤ 510 characters (constrained by the Knex storage column). Both limits are validated at `.dedup()` time.
191
+ - `ttl` accepts a Duration (`'5s'`, `'1m'`) or milliseconds, and must be **positive** when provided. Use `0` or omit `ttl` if you want no expiry — `ttl: 0` is rejected to avoid an ambiguous "expired immediately vs no-expiry" interpretation across engines.
192
+ - `extend` and `replace` **require** `ttl` — calling them without `ttl` throws.
193
+ - `replace` only applies to jobs in `pending` or `delayed` state. Jobs that are active (executing) or retained in history (`completed`/`failed` with retention) are left alone; the dispatch returns `{ deduped: 'skipped' }`.
194
+ - `replace` swaps the **payload only** — priority, queue, delay, groupId, and stored dedup options of the existing job are retained. To change those, use a different dedup id or wait for the TTL to expire.
195
+ - `extend` resets the TTL clock but never changes the window length. The window length is fixed to the `ttl` from the first dispatch that created the dedup slot. Later dispatches that pass a different `ttl` only reset the clock; their `ttl` value is ignored. To resize the window, let the slot expire and start over with a new dispatch.
196
+ - `extend` works in **all states** — even when the existing job is `active` (executing) or retained in history. Unlike `replace` (which is no-op on non-replaceable states), `extend` always refreshes the dedup TTL window. Use this when you want the dedup slot to keep blocking new dispatches for the lifetime of a long-running job.
197
+ - `extend` requires the **first** dispatch to have set a `ttl`. If the slot was created without a `ttl`, later `extend` dispatches have no window to refresh and return `{ deduped: 'skipped' }` instead of `'extended'`.
198
+ - `retryJob` does not touch the dedup entry — a retried job continues to occupy the dedup slot. TTL runs on wall-clock time, so long-running retries may outlive the TTL window. Use a generous TTL or no TTL if retries must stay deduped.
199
+ - Atomic and race-free:
200
+ - **Redis**: a single Lua script per dispatch performs the dedup-key lookup, state check (pending/delayed ZSCORE), payload swap, and TTL refresh atomically.
201
+ - **Knex**: transactional `SELECT ... FOR UPDATE` + insert/update inside a transaction. A nested savepoint catches unique-constraint violations under concurrent inserts and returns `{ deduped: 'skipped' }` pointing at the winner.
202
+ - **SyncAdapter**: executes inline, no dedup support.
203
+
204
+ ### Caveats
205
+
206
+ - Without `.dedup()`, jobs use auto-generated UUIDs and are never deduplicated.
207
+ - The **Sync adapter** ignores `.dedup()` entirely — every dispatch executes inline and `deduped` is always `undefined` on the result. Use Redis or Knex if you need real deduplication.
208
+ - `.dedup()` is only available on single dispatch. `dispatchMany` / `pushManyOn` reject jobs with a `dedup` field.
209
+ - Scheduled jobs (`.schedule()`) do not support dedup — each cron/interval fire is an independent dispatch.
210
+ - With no `ttl`, dedup persists until the job is removed (completed/failed without retention). When retention keeps the record, re-dispatch stays blocked until the record is pruned.
211
+ - With `ttl`, dedup expires after the window — a new job (new UUID) is created. The old job still runs.
212
+ - Knex MySQL concurrent race: MySQL does not support partial unique indexes, so two `pushOn` calls with the same dedup id firing at the exact same instant can both succeed. Serialize at the app layer if strict guarantees are required, or use Postgres / SQLite / Redis (all of which serialize correctly via the partial unique index or Lua atomicity).
213
+
134
214
  ## Job History & Retention
135
215
 
136
216
  Keep completed and failed jobs for debugging:
@@ -536,7 +616,7 @@ import * as boringqueue from '@boringnode/queue'
536
616
 
537
617
  const instrumentation = new QueueInstrumentation({
538
618
  messagingSystem: 'boringqueue', // default
539
- executionSpanLinkMode: 'link', // or 'parent'
619
+ executionSpanLinkMode: 'link', // or 'parent'
540
620
  })
541
621
 
542
622
  instrumentation.enable()
@@ -549,19 +629,19 @@ The instrumentation patches `QueueManager.init()` to automatically inject its wr
549
629
 
550
630
  The instrumentation uses standard [OTel messaging semantic conventions](https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/) where they map cleanly, plus a few queue-specific custom attributes.
551
631
 
552
- | Attribute | Kind | Description |
553
- | ------------------------------- | ------- | ------------------------------------------ |
554
- | `messaging.system` | Semconv | `'boringqueue'` (configurable) |
555
- | `messaging.operation.name` | Semconv | `'publish'` or `'process'` |
556
- | `messaging.destination.name` | Semconv | Queue name |
557
- | `messaging.message.id` | Semconv | Job ID for single-message spans |
558
- | `messaging.batch.message_count` | Semconv | Number of jobs in a batch dispatch |
559
- | `messaging.message.retry.count` | Custom | Retry count (0-based) for a job attempt |
560
- | `messaging.job.name` | Custom | Job class name (e.g. `SendEmailJob`) |
561
- | `messaging.job.status` | Custom | `'completed'`, `'failed'`, or `'retrying'` |
562
- | `messaging.job.group_id` | Custom | Queue-specific group identifier |
563
- | `messaging.job.priority` | Custom | Queue-specific job priority |
564
- | `messaging.job.delay_ms` | Custom | Delay before the job becomes available |
632
+ | Attribute | Kind | Description |
633
+ | ------------------------------- | ------- | --------------------------------------------- |
634
+ | `messaging.system` | Semconv | `'boringqueue'` (configurable) |
635
+ | `messaging.operation.name` | Semconv | `'publish'` or `'process'` |
636
+ | `messaging.destination.name` | Semconv | Queue name |
637
+ | `messaging.message.id` | Semconv | Job ID for single-message spans |
638
+ | `messaging.batch.message_count` | Semconv | Number of jobs in a batch dispatch |
639
+ | `messaging.message.retry.count` | Custom | Retry count (0-based) for a job attempt |
640
+ | `messaging.job.name` | Custom | Job class name (e.g. `SendEmailJob`) |
641
+ | `messaging.job.status` | Custom | `'completed'`, `'failed'`, or `'retrying'` |
642
+ | `messaging.job.group_id` | Custom | Queue-specific group identifier |
643
+ | `messaging.job.priority` | Custom | Queue-specific job priority |
644
+ | `messaging.job.delay_ms` | Custom | Delay before the job becomes available |
565
645
  | `messaging.job.queue_time_ms` | Custom | Time spent waiting in queue before processing |
566
646
 
567
647
  ### Trace Context Propagation
@@ -0,0 +1,145 @@
1
+ // src/drivers/redis_job_storage.ts
2
+ function hydrateRedisJob(data, overlay) {
3
+ const jobData = JSON.parse(data);
4
+ const parsedOverlay = overlay ? JSON.parse(overlay) : void 0;
5
+ if (parsedOverlay?.payloadUndefined) {
6
+ jobData.payload = void 0;
7
+ } else if (parsedOverlay?.payload !== void 0) {
8
+ jobData.payload = JSON.parse(parsedOverlay.payload);
9
+ }
10
+ return {
11
+ ...jobData,
12
+ ...parsedOverlay?.attempts !== void 0 ? { attempts: parsedOverlay.attempts } : {},
13
+ ...parsedOverlay?.stalledCount !== void 0 ? { stalledCount: parsedOverlay.stalledCount } : {}
14
+ };
15
+ }
16
+ function encodeRedisJobPayloadOverlay(payload) {
17
+ const encoded = JSON.stringify(payload);
18
+ return encoded === void 0 ? ["", "1"] : [encoded, "0"];
19
+ }
20
+ var REDIS_JOB_STORAGE_LUA = `
21
+ local function read_job_overlay(overlay_key, job_id)
22
+ local overlay_json = redis.call('HGET', overlay_key, job_id)
23
+ if overlay_json then
24
+ return cjson.decode(overlay_json)
25
+ end
26
+ return {}
27
+ end
28
+
29
+ local function write_job_overlay(overlay_key, job_id, overlay)
30
+ local has_overlay = false
31
+ for _ in pairs(overlay) do
32
+ has_overlay = true
33
+ break
34
+ end
35
+
36
+ if has_overlay then
37
+ redis.call('HSET', overlay_key, job_id, cjson.encode(overlay))
38
+ else
39
+ redis.call('HDEL', overlay_key, job_id)
40
+ end
41
+ end
42
+
43
+ local function store_job_data(data_key, overlay_key, job_id, job_data)
44
+ redis.call('HDEL', overlay_key, job_id)
45
+ redis.call('HSET', data_key, job_id, job_data)
46
+ end
47
+
48
+ local function delete_job_data(data_key, overlay_key, job_id)
49
+ redis.call('HDEL', data_key, job_id)
50
+ redis.call('HDEL', overlay_key, job_id)
51
+ end
52
+
53
+ local function delete_jobs_data(data_key, overlay_key, job_ids)
54
+ if #job_ids > 0 then
55
+ redis.call('HDEL', data_key, unpack(job_ids))
56
+ redis.call('HDEL', overlay_key, unpack(job_ids))
57
+ end
58
+ end
59
+
60
+ local function encode_job_result(job_data, overlay_key, job_id, result)
61
+ local encoded = result or {}
62
+ encoded.data = job_data
63
+
64
+ local overlay_json = redis.call('HGET', overlay_key, job_id)
65
+ if overlay_json then
66
+ encoded.overlay = overlay_json
67
+ end
68
+
69
+ return cjson.encode(encoded)
70
+ end
71
+
72
+ local function apply_payload_overlay(overlay, payload_data, payload_is_undefined)
73
+ if payload_is_undefined == 1 then
74
+ overlay.payload = nil
75
+ overlay.payloadUndefined = true
76
+ else
77
+ overlay.payload = payload_data
78
+ overlay.payloadUndefined = nil
79
+ end
80
+ end
81
+ `;
82
+ var REDIS_DEDUP_LUA = `
83
+ local function resolve_dedup_existing_job(
84
+ data_key,
85
+ state_key_a,
86
+ state_key_b,
87
+ overlay_key,
88
+ dedup_key,
89
+ extend,
90
+ replace,
91
+ payload_data,
92
+ payload_is_undefined
93
+ )
94
+ local existing = redis.call('GET', dedup_key)
95
+ if not existing then
96
+ return nil
97
+ end
98
+
99
+ local existing_data = redis.call('HGET', data_key, existing)
100
+ if not existing_data then
101
+ return nil
102
+ end
103
+
104
+ local in_state_a = redis.call('ZSCORE', state_key_a, existing)
105
+ local in_state_b = redis.call('ZSCORE', state_key_b, existing)
106
+ local replaceable = in_state_a or in_state_b
107
+ local ok, existing_job = pcall(cjson.decode, existing_data)
108
+ local original_ttl = nil
109
+
110
+ if ok and type(existing_job) == 'table' and existing_job.dedup then
111
+ original_ttl = tonumber(existing_job.dedup.ttl)
112
+ end
113
+
114
+ if replace == 1 and replaceable then
115
+ if ok and existing_job then
116
+ local overlay = read_job_overlay(overlay_key, existing)
117
+ apply_payload_overlay(overlay, payload_data, payload_is_undefined)
118
+ write_job_overlay(overlay_key, existing, overlay)
119
+
120
+ if extend == 1 and original_ttl and original_ttl > 0 then
121
+ redis.call('PEXPIRE', dedup_key, original_ttl)
122
+ end
123
+
124
+ return {'replaced', existing}
125
+ end
126
+
127
+ return {'skipped', existing}
128
+ end
129
+
130
+ if extend == 1 and original_ttl and original_ttl > 0 then
131
+ redis.call('PEXPIRE', dedup_key, original_ttl)
132
+ return {'extended', existing}
133
+ end
134
+
135
+ return {'skipped', existing}
136
+ end
137
+ `;
138
+
139
+ export {
140
+ hydrateRedisJob,
141
+ encodeRedisJobPayloadOverlay,
142
+ REDIS_JOB_STORAGE_LUA,
143
+ REDIS_DEDUP_LUA
144
+ };
145
+ //# sourceMappingURL=chunk-6IO4P6RB.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/drivers/redis_job_storage.ts"],"sourcesContent":["import type { JobData } from '../types/main.js'\n\n/**\n * Redis stores the original job JSON as an opaque payload in the data hash.\n * Lua scripts write only mutable Redis-side overrides in this overlay so\n * legacy jobs without overlays keep decoding from their original data.\n */\nexport type RedisJobOverlay = Partial<Pick<JobData, 'attempts' | 'stalledCount'>> & {\n payload?: string\n payloadUndefined?: boolean\n}\n\nexport function hydrateRedisJob(data: string, overlay?: string | null | false): JobData {\n const jobData = JSON.parse(data) as JobData\n const parsedOverlay = overlay ? (JSON.parse(overlay) as RedisJobOverlay) : undefined\n\n if (parsedOverlay?.payloadUndefined) {\n jobData.payload = undefined\n } else if (parsedOverlay?.payload !== undefined) {\n jobData.payload = JSON.parse(parsedOverlay.payload)\n }\n\n return {\n ...jobData,\n ...(parsedOverlay?.attempts !== undefined ? { attempts: parsedOverlay.attempts } : {}),\n ...(parsedOverlay?.stalledCount !== undefined\n ? { stalledCount: parsedOverlay.stalledCount }\n : {}),\n }\n}\n\nexport function encodeRedisJobPayloadOverlay(payload: JobData['payload']): [string, string] {\n const encoded = JSON.stringify(payload)\n\n return encoded === undefined ? ['', '1'] : [encoded, '0']\n}\n\nexport const REDIS_JOB_STORAGE_LUA = `\n local function read_job_overlay(overlay_key, job_id)\n local overlay_json = redis.call('HGET', overlay_key, job_id)\n if overlay_json then\n return cjson.decode(overlay_json)\n end\n return {}\n end\n\n local function write_job_overlay(overlay_key, job_id, overlay)\n local has_overlay = false\n for _ in pairs(overlay) do\n has_overlay = true\n break\n end\n\n if has_overlay then\n redis.call('HSET', overlay_key, job_id, cjson.encode(overlay))\n else\n redis.call('HDEL', overlay_key, job_id)\n end\n end\n\n local function store_job_data(data_key, overlay_key, job_id, job_data)\n redis.call('HDEL', overlay_key, job_id)\n redis.call('HSET', data_key, job_id, job_data)\n end\n\n local function delete_job_data(data_key, overlay_key, job_id)\n redis.call('HDEL', data_key, job_id)\n redis.call('HDEL', overlay_key, job_id)\n end\n\n local function delete_jobs_data(data_key, overlay_key, job_ids)\n if #job_ids > 0 then\n redis.call('HDEL', data_key, unpack(job_ids))\n redis.call('HDEL', overlay_key, unpack(job_ids))\n end\n end\n\n local function encode_job_result(job_data, overlay_key, job_id, result)\n local encoded = result or {}\n encoded.data = job_data\n\n local overlay_json = redis.call('HGET', overlay_key, job_id)\n if overlay_json then\n encoded.overlay = overlay_json\n end\n\n return cjson.encode(encoded)\n end\n\n local function apply_payload_overlay(overlay, payload_data, payload_is_undefined)\n if payload_is_undefined == 1 then\n overlay.payload = nil\n overlay.payloadUndefined = true\n else\n overlay.payload = payload_data\n overlay.payloadUndefined = nil\n end\n end\n`\n\nexport const REDIS_DEDUP_LUA = `\n local function resolve_dedup_existing_job(\n data_key,\n state_key_a,\n state_key_b,\n overlay_key,\n dedup_key,\n extend,\n replace,\n payload_data,\n payload_is_undefined\n )\n local existing = redis.call('GET', dedup_key)\n if not existing then\n return nil\n end\n\n local existing_data = redis.call('HGET', data_key, existing)\n if not existing_data then\n return nil\n end\n\n local in_state_a = redis.call('ZSCORE', state_key_a, existing)\n local in_state_b = redis.call('ZSCORE', state_key_b, existing)\n local replaceable = in_state_a or in_state_b\n local ok, existing_job = pcall(cjson.decode, existing_data)\n local original_ttl = nil\n\n if ok and type(existing_job) == 'table' and existing_job.dedup then\n original_ttl = tonumber(existing_job.dedup.ttl)\n end\n\n if replace == 1 and replaceable then\n if ok and existing_job then\n local overlay = read_job_overlay(overlay_key, existing)\n apply_payload_overlay(overlay, payload_data, payload_is_undefined)\n write_job_overlay(overlay_key, existing, overlay)\n\n if extend == 1 and original_ttl and original_ttl > 0 then\n redis.call('PEXPIRE', dedup_key, original_ttl)\n end\n\n return {'replaced', existing}\n end\n\n return {'skipped', existing}\n end\n\n if extend == 1 and original_ttl and original_ttl > 0 then\n redis.call('PEXPIRE', dedup_key, original_ttl)\n return {'extended', existing}\n end\n\n return {'skipped', existing}\n end\n`\n"],"mappings":";AAYO,SAAS,gBAAgB,MAAc,SAA0C;AACtF,QAAM,UAAU,KAAK,MAAM,IAAI;AAC/B,QAAM,gBAAgB,UAAW,KAAK,MAAM,OAAO,IAAwB;AAE3E,MAAI,eAAe,kBAAkB;AACnC,YAAQ,UAAU;AAAA,EACpB,WAAW,eAAe,YAAY,QAAW;AAC/C,YAAQ,UAAU,KAAK,MAAM,cAAc,OAAO;AAAA,EACpD;AAEA,SAAO;AAAA,IACL,GAAG;AAAA,IACH,GAAI,eAAe,aAAa,SAAY,EAAE,UAAU,cAAc,SAAS,IAAI,CAAC;AAAA,IACpF,GAAI,eAAe,iBAAiB,SAChC,EAAE,cAAc,cAAc,aAAa,IAC3C,CAAC;AAAA,EACP;AACF;AAEO,SAAS,6BAA6B,SAA+C;AAC1F,QAAM,UAAU,KAAK,UAAU,OAAO;AAEtC,SAAO,YAAY,SAAY,CAAC,IAAI,GAAG,IAAI,CAAC,SAAS,GAAG;AAC1D;AAEO,IAAM,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+D9B,IAAM,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;","names":[]}
@@ -603,6 +603,7 @@ var JobDispatcher = class {
603
603
  #delay;
604
604
  #priority;
605
605
  #groupId;
606
+ #dedup;
606
607
  /**
607
608
  * Create a new job dispatcher.
608
609
  *
@@ -694,6 +695,76 @@ var JobDispatcher = class {
694
695
  this.#groupId = groupId;
695
696
  return this;
696
697
  }
698
+ /**
699
+ * Configure deduplication for this job.
700
+ *
701
+ * Modes:
702
+ * - **Simple** (`{ id }`): skip duplicates while the job exists.
703
+ * - **Throttle** (`{ id, ttl }`): skip duplicates within a TTL window.
704
+ * - **Extend** (`{ id, ttl, extend: true }`): reset the TTL clock on each duplicate.
705
+ * The window length stays at the original ttl from the first dispatch.
706
+ * - **Replace** (`{ id, ttl, replace: true }`): swap the payload of the existing
707
+ * pending/delayed job on duplicate within TTL. Active jobs and retained
708
+ * completed/failed jobs return `'skipped'`. Only `payload` changes —
709
+ * priority/queue/delay/groupId are preserved.
710
+ * - **Debounce** (`{ id, ttl, replace: true, extend: true }`): replace + reset TTL.
711
+ *
712
+ * The id is automatically prefixed with the job name to prevent collisions
713
+ * between different job types.
714
+ *
715
+ * @param options.id - Unique deduplication key
716
+ * @param options.ttl - TTL as Duration ('5s', 5000). Required for extend/replace.
717
+ * @param options.extend - Reset the TTL clock on duplicate within window. Window
718
+ * length stays at the original ttl; this option's `ttl` arg is ignored on extend.
719
+ * @param options.replace - Swap payload of existing pending/delayed job within
720
+ * window. Active and retained jobs are not modified.
721
+ *
722
+ * @example
723
+ * ```typescript
724
+ * // Simple dedup
725
+ * await SendInvoiceJob.dispatch({ orderId: 123 })
726
+ * .dedup({ id: 'order-123' })
727
+ *
728
+ * // Throttle: 5 second window
729
+ * await SendEmailJob.dispatch({ to: 'x' })
730
+ * .dedup({ id: 'welcome', ttl: '5s' })
731
+ *
732
+ * // Debounce: replace payload within window
733
+ * await SaveDraftJob.dispatch({ content: 'latest' })
734
+ * .dedup({ id: 'draft-42', ttl: '2s', replace: true, extend: true })
735
+ * ```
736
+ */
737
+ dedup(options) {
738
+ if (!options.id) {
739
+ throw new Error("Dedup ID must be a non-empty string");
740
+ }
741
+ if (options.id.length > 400) {
742
+ throw new Error("Dedup ID must be 400 characters or less");
743
+ }
744
+ const prefixedLength = this.#name.length + 2 + options.id.length;
745
+ if (prefixedLength > 510) {
746
+ throw new Error(
747
+ `Dedup ID combined with job name exceeds 510 characters (got ${prefixedLength}). Shorten either the job name or the dedup id.`
748
+ );
749
+ }
750
+ if ((options.extend || options.replace) && options.ttl === void 0) {
751
+ throw new Error("dedup.ttl is required when extend or replace is set");
752
+ }
753
+ let parsedTtl;
754
+ if (options.ttl !== void 0) {
755
+ parsedTtl = parse(options.ttl);
756
+ if (!Number.isFinite(parsedTtl) || parsedTtl <= 0) {
757
+ throw new Error("dedup.ttl must be a positive duration");
758
+ }
759
+ }
760
+ this.#dedup = {
761
+ id: options.id,
762
+ ttl: parsedTtl,
763
+ extend: options.extend,
764
+ replace: options.replace
765
+ };
766
+ return this;
767
+ }
697
768
  /**
698
769
  * Use a specific adapter for this job.
699
770
  *
@@ -726,6 +797,7 @@ var JobDispatcher = class {
726
797
  */
727
798
  async run() {
728
799
  const id = randomUUID();
800
+ const dedupId = this.#dedup ? `${this.#name}::${this.#dedup.id}` : void 0;
729
801
  debug_default("dispatching job %s with id %s using payload %s", this.#name, id, this.#payload);
730
802
  const adapter = this.#getAdapterInstance();
731
803
  const wrapInternal = QueueManager.getInternalOperationWrapper();
@@ -737,16 +809,31 @@ var JobDispatcher = class {
737
809
  attempts: 0,
738
810
  priority: this.#priority,
739
811
  groupId: this.#groupId,
740
- createdAt: Date.now()
812
+ createdAt: Date.now(),
813
+ ...dedupId ? {
814
+ dedup: {
815
+ id: dedupId,
816
+ ttl: this.#dedup.ttl,
817
+ extend: this.#dedup.extend,
818
+ replace: this.#dedup.replace
819
+ }
820
+ } : {}
741
821
  };
742
822
  const message = { jobs: [jobData], queue: this.#queue, delay: parsedDelay };
823
+ let pushResult;
743
824
  await dispatchChannel.tracePromise(async () => {
744
- if (parsedDelay !== void 0) {
745
- await wrapInternal(() => adapter.pushLaterOn(this.#queue, jobData, parsedDelay));
746
- } else {
747
- await wrapInternal(() => adapter.pushOn(this.#queue, jobData));
825
+ const result = parsedDelay !== void 0 ? await wrapInternal(() => adapter.pushLaterOn(this.#queue, jobData, parsedDelay)) : await wrapInternal(() => adapter.pushOn(this.#queue, jobData));
826
+ if (result && typeof result === "object" && "outcome" in result) {
827
+ pushResult = { outcome: result.outcome, jobId: result.jobId };
828
+ message.dedupOutcome = result.outcome;
748
829
  }
749
830
  }, message);
831
+ if (pushResult && this.#dedup) {
832
+ return {
833
+ jobId: pushResult.jobId,
834
+ deduped: pushResult.outcome
835
+ };
836
+ }
750
837
  return { jobId: id };
751
838
  }
752
839
  /**
@@ -1297,7 +1384,9 @@ var FakeAdapter = class {
1297
1384
  #pendingTimeouts = /* @__PURE__ */ new Set();
1298
1385
  #schedules = /* @__PURE__ */ new Map();
1299
1386
  #pushedJobs = [];
1387
+ #dedupIndex = /* @__PURE__ */ new Map();
1300
1388
  #onDispose;
1389
+ #workerId = "";
1301
1390
  /**
1302
1391
  * Set the function to call when the fake is disposed
1303
1392
  */
@@ -1308,7 +1397,8 @@ var FakeAdapter = class {
1308
1397
  [Symbol.dispose]() {
1309
1398
  this.#onDispose?.();
1310
1399
  }
1311
- setWorkerId(_workerId) {
1400
+ setWorkerId(workerId) {
1401
+ this.#workerId = workerId;
1312
1402
  }
1313
1403
  getPushedJobs() {
1314
1404
  return [...this.#pushedJobs];
@@ -1334,6 +1424,7 @@ var FakeAdapter = class {
1334
1424
  this.#failedJobs.clear();
1335
1425
  this.#schedules.clear();
1336
1426
  this.#pushedJobs = [];
1427
+ this.#dedupIndex.clear();
1337
1428
  }
1338
1429
  assertPushed(matcher, query) {
1339
1430
  const record = this.findPushed(matcher, query);
@@ -1366,21 +1457,33 @@ var FakeAdapter = class {
1366
1457
  return this.pushOn("default", jobData);
1367
1458
  }
1368
1459
  async pushOn(queue, jobData) {
1460
+ const deduped = await this.#applyDedup(queue, jobData);
1461
+ if (deduped) return deduped;
1369
1462
  this.#recordPush(queue, jobData);
1370
1463
  this.#enqueue(queue, jobData);
1464
+ if (jobData.dedup) {
1465
+ return { outcome: "added", jobId: jobData.id };
1466
+ }
1371
1467
  }
1372
1468
  async pushLater(jobData, delay) {
1373
1469
  return this.pushLaterOn("default", jobData, delay);
1374
1470
  }
1375
- pushLaterOn(queue, jobData, delay) {
1471
+ async pushLaterOn(queue, jobData, delay) {
1472
+ const deduped = await this.#applyDedup(queue, jobData);
1473
+ if (deduped) return deduped;
1376
1474
  this.#recordPush(queue, jobData, delay);
1377
1475
  this.#schedulePush(queue, jobData, delay);
1378
- return Promise.resolve();
1476
+ if (jobData.dedup) {
1477
+ return { outcome: "added", jobId: jobData.id };
1478
+ }
1379
1479
  }
1380
1480
  async pushMany(jobs) {
1381
1481
  return this.pushManyOn("default", jobs);
1382
1482
  }
1383
1483
  async pushManyOn(queue, jobs) {
1484
+ if (jobs.some((j) => j.dedup)) {
1485
+ throw new Error("dedup is not supported in batch dispatch; use single dispatch");
1486
+ }
1384
1487
  for (const job of jobs) {
1385
1488
  await this.pushOn(queue, job);
1386
1489
  }
@@ -1407,7 +1510,7 @@ var FakeAdapter = class {
1407
1510
  return null;
1408
1511
  }
1409
1512
  const acquiredAt = Date.now();
1410
- this.#activeJobs.set(job.id, { job, acquiredAt, queue });
1513
+ this.#activeJobs.set(job.id, { job, acquiredAt, queue, workerId: this.#workerId });
1411
1514
  return { ...job, acquiredAt };
1412
1515
  }
1413
1516
  async completeJob(jobId, queue, removeOnComplete) {
@@ -1415,6 +1518,7 @@ var FakeAdapter = class {
1415
1518
  if (!active) return;
1416
1519
  this.#activeJobs.delete(jobId);
1417
1520
  if (removeOnComplete === void 0 || removeOnComplete === true) {
1521
+ this.#cleanupDedupForJob(queue, active.job);
1418
1522
  return;
1419
1523
  }
1420
1524
  this.#storeHistory(queue, "completed", active.job, removeOnComplete);
@@ -1424,6 +1528,7 @@ var FakeAdapter = class {
1424
1528
  if (!active) return;
1425
1529
  this.#activeJobs.delete(jobId);
1426
1530
  if (removeOnFail === void 0 || removeOnFail === true) {
1531
+ this.#cleanupDedupForJob(queue, active.job);
1427
1532
  return;
1428
1533
  }
1429
1534
  this.#storeHistory(queue, "failed", active.job, removeOnFail, error);
@@ -1459,6 +1564,7 @@ var FakeAdapter = class {
1459
1564
  const currentStalledCount = active.job.stalledCount ?? 0;
1460
1565
  if (currentStalledCount >= maxStalledCount) {
1461
1566
  this.#activeJobs.delete(jobId);
1567
+ this.#cleanupDedupForJob(active.queue, active.job);
1462
1568
  continue;
1463
1569
  }
1464
1570
  this.#activeJobs.delete(jobId);
@@ -1471,6 +1577,18 @@ var FakeAdapter = class {
1471
1577
  }
1472
1578
  return recovered;
1473
1579
  }
1580
+ async renewJobs(queue, jobIds) {
1581
+ const now = Date.now();
1582
+ let renewed = 0;
1583
+ for (const jobId of jobIds) {
1584
+ const active = this.#activeJobs.get(jobId);
1585
+ if (active && active.queue === queue && active.workerId === this.#workerId) {
1586
+ active.acquiredAt = now;
1587
+ renewed++;
1588
+ }
1589
+ }
1590
+ return renewed;
1591
+ }
1474
1592
  async getJob(jobId, queue) {
1475
1593
  const active = this.#activeJobs.get(jobId);
1476
1594
  if (active && active.queue === queue) {
@@ -1627,23 +1745,110 @@ var FakeAdapter = class {
1627
1745
  const records = store.get(queue);
1628
1746
  records.push(record);
1629
1747
  if (retention && retention !== true) {
1630
- this.#applyRetention(records, retention);
1748
+ this.#applyRetention(records, retention, queue);
1631
1749
  }
1632
1750
  }
1633
- #applyRetention(records, retention) {
1751
+ #applyRetention(records, retention, queue) {
1634
1752
  if (retention === false || retention === true) {
1635
1753
  return;
1636
1754
  }
1755
+ const pruned = [];
1637
1756
  if (retention.age !== void 0) {
1638
1757
  const maxAgeMs = parse(retention.age);
1639
1758
  if (maxAgeMs > 0) {
1640
1759
  const cutoff = Date.now() - maxAgeMs;
1641
- const filtered = records.filter((record) => (record.finishedAt ?? 0) >= cutoff);
1642
- records.splice(0, records.length, ...filtered);
1760
+ const kept = [];
1761
+ for (const record of records) {
1762
+ if ((record.finishedAt ?? 0) >= cutoff) {
1763
+ kept.push(record);
1764
+ } else {
1765
+ pruned.push(record);
1766
+ }
1767
+ }
1768
+ records.splice(0, records.length, ...kept);
1643
1769
  }
1644
1770
  }
1645
1771
  if (retention.count !== void 0 && retention.count > 0 && records.length > retention.count) {
1646
- records.splice(0, records.length - retention.count);
1772
+ const excess = records.length - retention.count;
1773
+ pruned.push(...records.slice(0, excess));
1774
+ records.splice(0, excess);
1775
+ }
1776
+ for (const record of pruned) {
1777
+ this.#cleanupDedupForJob(queue, record.data);
1778
+ }
1779
+ }
1780
+ #applyDedup(queue, jobData) {
1781
+ if (!jobData.dedup) return null;
1782
+ const dedupId = jobData.dedup.id;
1783
+ const now = Date.now();
1784
+ const entry = this.#getDedupEntry(queue, dedupId);
1785
+ if (entry) {
1786
+ const withinTtl = !entry.ttl || now - entry.createdAt < entry.ttl;
1787
+ if (withinTtl) {
1788
+ const existing = this.#findJobById(queue, entry.jobId);
1789
+ if (existing) {
1790
+ const replaceable = existing.location === "pending" || existing.location === "delayed";
1791
+ if (jobData.dedup.replace && replaceable) {
1792
+ existing.job.payload = structuredClone(jobData.payload);
1793
+ if (jobData.dedup.extend && entry.ttl) {
1794
+ entry.createdAt = now;
1795
+ }
1796
+ return { outcome: "replaced", jobId: entry.jobId };
1797
+ }
1798
+ if (jobData.dedup.extend && entry.ttl) {
1799
+ entry.createdAt = now;
1800
+ return { outcome: "extended", jobId: entry.jobId };
1801
+ }
1802
+ return { outcome: "skipped", jobId: entry.jobId };
1803
+ }
1804
+ }
1805
+ }
1806
+ this.#setDedupEntry(queue, dedupId, {
1807
+ jobId: jobData.id,
1808
+ createdAt: now,
1809
+ ttl: jobData.dedup.ttl,
1810
+ replace: jobData.dedup.replace,
1811
+ extend: jobData.dedup.extend
1812
+ });
1813
+ return null;
1814
+ }
1815
+ #findJobById(queue, jobId) {
1816
+ const active = this.#activeJobs.get(jobId);
1817
+ if (active && active.queue === queue) {
1818
+ return { job: active.job, location: "active" };
1819
+ }
1820
+ const pending = this.#queues.get(queue)?.find((j) => j.id === jobId);
1821
+ if (pending) {
1822
+ return { job: pending, location: "pending" };
1823
+ }
1824
+ const delayed = this.#delayedJobs.get(queue)?.get(jobId);
1825
+ if (delayed) {
1826
+ return { job: delayed.job, location: "delayed" };
1827
+ }
1828
+ const completed = this.#findHistory(this.#completedJobs, queue, jobId);
1829
+ if (completed) {
1830
+ return { job: completed.data, location: "completed" };
1831
+ }
1832
+ const failed = this.#findHistory(this.#failedJobs, queue, jobId);
1833
+ if (failed) {
1834
+ return { job: failed.data, location: "failed" };
1835
+ }
1836
+ return null;
1837
+ }
1838
+ #getDedupEntry(queue, dedupId) {
1839
+ return this.#dedupIndex.get(queue)?.get(dedupId);
1840
+ }
1841
+ #setDedupEntry(queue, dedupId, entry) {
1842
+ if (!this.#dedupIndex.has(queue)) {
1843
+ this.#dedupIndex.set(queue, /* @__PURE__ */ new Map());
1844
+ }
1845
+ this.#dedupIndex.get(queue).set(dedupId, entry);
1846
+ }
1847
+ #cleanupDedupForJob(queue, job) {
1848
+ if (!job.dedup) return;
1849
+ const entry = this.#getDedupEntry(queue, job.dedup.id);
1850
+ if (entry && entry.jobId === job.id) {
1851
+ this.#dedupIndex.get(queue)?.delete(job.dedup.id);
1647
1852
  }
1648
1853
  }
1649
1854
  #findHistory(store, queue, jobId) {
@@ -1721,4 +1926,4 @@ export {
1721
1926
  ScheduleBuilder,
1722
1927
  Job
1723
1928
  };
1724
- //# sourceMappingURL=chunk-VRXHCWNK.js.map
1929
+ //# sourceMappingURL=chunk-AHUVTAI7.js.map