@anishhs/retryq 1.0.0 → 1.2.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 (47) hide show
  1. package/README.md +856 -167
  2. package/dist/cjs/index.d.ts +9 -0
  3. package/dist/cjs/index.d.ts.map +1 -0
  4. package/dist/cjs/index.js +13 -0
  5. package/dist/cjs/index.js.map +1 -0
  6. package/dist/cjs/manager.d.ts +139 -0
  7. package/dist/cjs/manager.d.ts.map +1 -0
  8. package/dist/cjs/manager.js +431 -0
  9. package/dist/cjs/manager.js.map +1 -0
  10. package/dist/cjs/package.json +3 -0
  11. package/dist/cjs/types.d.ts +237 -0
  12. package/dist/cjs/types.d.ts.map +1 -0
  13. package/dist/cjs/types.js +3 -0
  14. package/dist/cjs/types.js.map +1 -0
  15. package/dist/cjs/utils.d.ts +43 -0
  16. package/dist/cjs/utils.d.ts.map +1 -0
  17. package/dist/cjs/utils.js +71 -0
  18. package/dist/cjs/utils.js.map +1 -0
  19. package/dist/cjs/validation.d.ts +37 -0
  20. package/dist/cjs/validation.d.ts.map +1 -0
  21. package/dist/cjs/validation.js +69 -0
  22. package/dist/cjs/validation.js.map +1 -0
  23. package/dist/esm/index.d.ts +9 -0
  24. package/dist/esm/index.d.ts.map +1 -0
  25. package/dist/esm/index.js +8 -0
  26. package/dist/esm/index.js.map +1 -0
  27. package/dist/esm/manager.d.ts +139 -0
  28. package/dist/esm/manager.d.ts.map +1 -0
  29. package/dist/esm/manager.js +427 -0
  30. package/dist/esm/manager.js.map +1 -0
  31. package/dist/esm/package.json +3 -0
  32. package/dist/esm/types.d.ts +237 -0
  33. package/dist/esm/types.d.ts.map +1 -0
  34. package/dist/esm/types.js +2 -0
  35. package/dist/esm/types.js.map +1 -0
  36. package/dist/esm/utils.d.ts +43 -0
  37. package/dist/esm/utils.d.ts.map +1 -0
  38. package/dist/esm/utils.js +64 -0
  39. package/dist/esm/utils.js.map +1 -0
  40. package/dist/esm/validation.d.ts +37 -0
  41. package/dist/esm/validation.d.ts.map +1 -0
  42. package/dist/esm/validation.js +65 -0
  43. package/dist/esm/validation.js.map +1 -0
  44. package/package.json +29 -8
  45. package/dist/index.d.ts +0 -76
  46. package/dist/index.js +0 -165
  47. package/dist/index.js.map +0 -1
package/README.md CHANGED
@@ -1,254 +1,943 @@
1
- ## @anishhs/retryq
1
+ # @anishhs/retryq
2
2
 
3
- A tiny, dependency-free retry queue manager for handling multiple concurrent async jobs with priorities, exponential backoff, jitter, cancellation, and simple introspection.
3
+ A production-ready, zero-dependency retry queue manager for Node.js with support for concurrent job execution, priorities, exponential backoff, jitter, and **force cancellation**.
4
4
 
5
- - **Concurrency control**: limit how many jobs run at once
6
- - **Exponential backoff** with configurable base delay, multiplier, jitter
7
- - **Global time cap** per job via `maxTime`
8
- - **Priority queueing**: higher priority jobs run first
9
- - **Cancellation**: cancel pending retries and sleep waits
10
- - **Introspection**: list jobs, find by id/label
11
- - **TypeScript** ready with bundled types
5
+ [![npm version](https://img.shields.io/npm/v/@anishhs/retryq.svg)](https://www.npmjs.com/package/@anishhs/retryq)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
7
+ [![License: ISC](https://img.shields.io/badge/License-ISC-green.svg)](https://opensource.org/licenses/ISC)
12
8
 
9
+ ## Features
13
10
 
14
- ### Installation
11
+ - ✅ **Concurrency control** - Limit concurrent job execution
12
+ - ✅ **Priority queue** - Higher priority jobs execute first
13
+ - ✅ **Exponential backoff** with configurable delay, multiplier, `maxDelay`, and jitter
14
+ - ✅ **Lifecycle events & hooks** - `retry`/`success`/`failure`/`cancel`/`idle` events + per-job callbacks
15
+ - ✅ **Conditional retries** - `shouldRetry(error, attempt)` predicate to skip non-retryable errors
16
+ - ✅ **Force cancellation** - Abort in-progress jobs with AbortController
17
+ - ✅ **Cooperative cancellation** - Graceful job termination
18
+ - ✅ **Real time limits** - `maxTime` (and `attemptTimeout`) actively abort in-flight attempts
19
+ - ✅ **Queue draining** - `onIdle()` / `drain()` to await all work
20
+ - ✅ **Memory safe** - Bounded job history with LRU eviction
21
+ - ✅ **Job introspection** - List, find, and track jobs by ID or label
22
+ - ✅ **TypeScript** - Generic, fully-typed API with bundled declarations
23
+ - ✅ **Dual ESM + CJS** - Ships both module formats with an `exports` map
24
+ - ✅ **Zero dependencies** - Minimal footprint, no external packages
25
+
26
+ ## Installation
15
27
 
16
28
  ```bash
17
29
  npm install @anishhs/retryq
18
30
  ```
19
31
 
20
- Node.js 16+ recommended.
32
+ **Requirements**: Node.js 16+
33
+
34
+ ## Quick Start
35
+
36
+ ```typescript
37
+ import { RetryQManager } from '@anishhs/retryq';
38
+
39
+ // Create manager with 3 concurrent jobs max
40
+ const retryQ = new RetryQManager({ maxConcurrent: 3 });
41
+
42
+ // Create a job with retry logic
43
+ const job = retryQ.createJob(async (signal) => {
44
+ // Your async operation here
45
+ const response = await fetch('https://api.example.com/data', { signal });
46
+ return response.json();
47
+ }, {
48
+ retries: 5, // Retry up to 5 times
49
+ delay: 1000, // Initial delay 1s
50
+ backoff: 2, // Double delay each retry
51
+ jitter: 0.1, // ±10% randomization
52
+ maxTime: 30000, // Total timeout 30s
53
+ priority: 10, // Higher priority = runs sooner
54
+ label: 'fetch-data' // Human-readable identifier
55
+ });
56
+
57
+ // Wait for result
58
+ job.promise
59
+ .then(data => console.log('Success:', data))
60
+ .catch(err => console.error('Failed:', err));
61
+
62
+ // Cancel if needed
63
+ job.cancel(true); // Force abort in-progress execution
64
+ ```
21
65
 
66
+ ## Table of Contents
22
67
 
23
- ### Quick start
68
+ - [Core Concepts](#core-concepts)
69
+ - [API Reference](#api-reference)
70
+ - [Cancellation Modes](#cancellation-modes)
71
+ - [Usage Examples](#usage-examples)
72
+ - [Configuration Options](#configuration-options)
73
+ - [Best Practices](#best-practices)
74
+ - [Migration Guide](#migration-guide)
75
+ - [Changelog](#changelog)
24
76
 
25
- ```ts
26
- import { RetryQManager } from "@anishhs/retryq";
77
+ ---
27
78
 
28
- // Allow up to 3 jobs to run concurrently
29
- const retryQ = new RetryQManager(3);
79
+ ## Core Concepts
30
80
 
31
- // Any function returning a Promise can be a job
32
- async function flakyTask() {
33
- // ... do something that may fail
34
- }
81
+ ### Job Lifecycle
35
82
 
36
- const job = retryQ.createJob(flakyTask, {
37
- label: "sync-user",
38
- priority: 10,
39
- retries: 5,
40
- delay: 500, // ms
41
- backoff: 2, // exponential factor
42
- jitter: 0.1, // ±10%
43
- maxTime: 5000, // total time window per job (ms)
83
+ ```
84
+ pending → running → completed
85
+ failed
86
+ cancelled
87
+ ```
88
+
89
+ 1. **Pending**: Job queued, waiting for available slot
90
+ 2. **Running**: Job executing with retries
91
+ 3. **Completed**: Job succeeded
92
+ 4. **Failed**: Job exhausted all retries
93
+ 5. **Cancelled**: Job cancelled by user
94
+
95
+ ### Retry Logic
96
+
97
+ ```
98
+ Attempt 1: Execute immediately
99
+ ↓ (fails)
100
+ Attempt 2: Wait delay * backoff^0 = 1000ms
101
+ ↓ (fails)
102
+ Attempt 3: Wait delay * backoff^1 = 2000ms
103
+ ↓ (fails)
104
+ Attempt 4: Wait delay * backoff^2 = 4000ms
105
+ ...
106
+ ```
107
+
108
+ Each delay includes jitter: `delay ± (delay * jitter)`
109
+
110
+ ### Priority Queue
111
+
112
+ Jobs with higher `priority` values execute first:
113
+
114
+ ```typescript
115
+ retryQ.createJob(taskA, { priority: 1 }); // Runs last
116
+ retryQ.createJob(taskB, { priority: 5 }); // Runs second
117
+ retryQ.createJob(taskC, { priority: 10 }); // Runs first
118
+ ```
119
+
120
+ ---
121
+
122
+ ## API Reference
123
+
124
+ ### RetryQManager
125
+
126
+ #### Constructor
127
+
128
+ ```typescript
129
+ new RetryQManager(config?: RetryQManagerConfig | number)
130
+ ```
131
+
132
+ **Parameters**:
133
+ - `config.maxConcurrent` - Maximum concurrent jobs (default: `Infinity`)
134
+ - `config.maxHistorySize` - Maximum jobs in history (default: `1000`)
135
+
136
+ **Legacy**: Accepts number for `maxConcurrent` (backwards compatible)
137
+
138
+ ```typescript
139
+ // New style (recommended)
140
+ const retryQ = new RetryQManager({
141
+ maxConcurrent: 5,
142
+ maxHistorySize: 1000
44
143
  });
45
144
 
46
- // Get the actual result or throw last error
47
- job.promise
48
- .then((value) => console.log("completed", value))
49
- .catch((err) => console.error("failed", err));
145
+ // Old style (still works)
146
+ const retryQ = new RetryQManager(5);
50
147
  ```
51
148
 
149
+ ---
52
150
 
53
- ### How it works (in short)
54
- - Jobs are queued and sorted by `priority` (higher first).
55
- - Up to `maxConcurrent` jobs can run simultaneously.
56
- - Each job retries up to `retries` times with exponential backoff starting from `delay` and multiplying by `backoff`.
57
- - A random jitter (±`jitter` fraction) is applied to each wait to avoid thundering-herd patterns.
58
- - The total elapsed time per job is capped by `maxTime`.
59
- - `job.promise` resolves with the function’s resolved value, or rejects with the last error.
151
+ #### createJob()
60
152
 
153
+ ```typescript
154
+ createJob(
155
+ fn: (signal?: AbortSignal) => Promise<any>,
156
+ options?: RetryQJobOptions
157
+ ): RetryQJob
158
+ ```
61
159
 
62
- ## API
160
+ **Parameters**:
161
+ - `fn` - Async function to execute
162
+ - `signal` - Optional AbortSignal for force cancellation
163
+ - `options` - Job configuration (see [Configuration](#configuration-options))
63
164
 
64
- ### `class RetryQManager`
165
+ **Returns**: `RetryQJob` object
65
166
 
66
- #### `constructor(maxConcurrent?: number)`
67
- - **maxConcurrent**: maximum number of jobs allowed to run at once. Default: `Infinity`.
167
+ ```typescript
168
+ const job = retryQ.createJob(async (signal) => {
169
+ // Check signal to support force cancellation
170
+ if (signal?.aborted) throw new Error('Aborted');
68
171
 
69
- #### `createJob(fn: () => Promise<any>, options?: RetryQJobOptions): RetryQJob`
70
- Queues and starts a job. Returns a `RetryQJob` with a `promise` that resolves with the function’s return value or rejects after exhausting retries.
172
+ return await doWork();
173
+ }, {
174
+ retries: 3,
175
+ delay: 1000,
176
+ label: 'my-job'
177
+ });
178
+ ```
179
+
180
+ ---
181
+
182
+ #### cancelJob()
71
183
 
72
- - `fn`: async function to execute (must return a Promise)
73
- - `options`: optional behavior overrides (see below)
184
+ ```typescript
185
+ cancelJob(id: string, force?: boolean): void
186
+ ```
187
+
188
+ **Parameters**:
189
+ - `id` - Job ID to cancel
190
+ - `force` - Enable force cancellation (default: `false`)
74
191
 
75
- #### `cancelJob(id: string): void`
76
- Cancels a job by id. If the job is sleeping between retries, the sleep is interrupted. If the job’s function is currently executing, it will not be forcibly aborted (user code should be cooperative if needed), but further retries are stopped and the job is marked `cancelled`.
192
+ ```typescript
193
+ // Cooperative cancellation (default)
194
+ retryQ.cancelJob(job.id);
195
+
196
+ // Force cancellation (aborts via AbortSignal)
197
+ retryQ.cancelJob(job.id, true);
198
+ ```
77
199
 
78
- #### `listJobs()`
79
- Returns a snapshot of jobs grouped by state:
80
- ```ts
81
- {
82
- pending: Array<{ id, label, state, retriesLeft, priority }>,
83
- running: Array<{ id, label, state, retriesLeft, priority }>,
84
- failed: Array<{ id, label, state, retriesLeft, priority }>,
85
- completed: Array<{ id, label, state, retriesLeft, priority }>,
200
+ ---
201
+
202
+ #### listJobs()
203
+
204
+ ```typescript
205
+ listJobs(): {
206
+ pending: JobSummary[];
207
+ running: JobSummary[];
208
+ failed: JobSummary[];
209
+ completed: JobSummary[];
86
210
  }
87
211
  ```
88
212
 
89
- #### `findJobById(id: string)`
90
- Returns the `RetryQJob` if found in any state, otherwise `null`.
213
+ **Returns**: Snapshot of all jobs grouped by state
91
214
 
92
- #### `findJobsByLabel(label: string)`
93
- Returns an array of `RetryQJob` with the given label across any state.
215
+ ```typescript
216
+ const { pending, running, failed, completed } = retryQ.listJobs();
217
+ console.log(`${running.length} jobs currently executing`);
218
+ ```
94
219
 
220
+ ---
95
221
 
96
- ### Types
222
+ #### findJobById()
97
223
 
98
- ```ts
99
- export type RetryQJobOptions = {
100
- retries?: number; // default 3
101
- delay?: number; // initial delay in ms, default 1000
102
- backoff?: number; // multiplier, default 2
103
- maxTime?: number; // total allowed time in ms, default 5000
104
- jitter?: number; // fraction, e.g., 0.1 = ±10%, default 0.1
105
- label?: string; // human-readable tag
106
- priority?: number; // higher runs sooner, default 1
107
- };
224
+ ```typescript
225
+ findJobById(id: string): RetryQJob | null
226
+ ```
227
+
228
+ **Returns**: Job if found, otherwise `null`
108
229
 
109
- export type JobState =
110
- | "pending"
111
- | "running"
112
- | "completed"
113
- | "failed"
114
- | "cancelled";
115
-
116
- export interface RetryQJob {
117
- id: string;
118
- label: string;
119
- state: JobState;
120
- priority: number;
121
- retriesLeft: number;
122
- promise: Promise<any>; // resolves to your function’s actual value
123
- cancel: () => void; // convenience wrapper for cancelJob
124
- fn: () => Promise<any>;
125
- options: RetryQJobOptions;
126
- createdAt: number;
127
- startedAt?: number;
128
- finishedAt?: number;
129
- error?: any;
230
+ ```typescript
231
+ const job = retryQ.findJobById('job-123');
232
+ if (job) {
233
+ console.log('Job state:', job.state);
130
234
  }
131
235
  ```
132
236
 
237
+ ---
133
238
 
134
- ## Usage patterns
239
+ #### findJobsByLabel()
135
240
 
136
- ### Priorities and concurrency
137
- ```ts
138
- const q = new RetryQManager(2); // two at a time
241
+ ```typescript
242
+ findJobsByLabel(label: string): RetryQJob[]
243
+ ```
139
244
 
140
- q.createJob(taskA, { label: "A", priority: 5 });
141
- q.createJob(taskB, { label: "B", priority: 1 });
142
- q.createJob(taskC, { label: "C", priority: 10 });
245
+ **Returns**: Array of jobs with matching label
143
246
 
144
- // C and A start first (highest priorities), then B.
247
+ ```typescript
248
+ const emailJobs = retryQ.findJobsByLabel('send-email');
249
+ console.log(`${emailJobs.length} email jobs found`);
145
250
  ```
146
251
 
147
- ### Custom backoff and jitter
148
- ```ts
149
- q.createJob(fetchWithRetry, {
150
- retries: 4,
151
- delay: 250,
152
- backoff: 1.5,
153
- jitter: 0.2, // ±20%
154
- maxTime: 8000,
155
- });
252
+ ---
253
+
254
+ #### clearHistory()
255
+
256
+ ```typescript
257
+ clearHistory(state?: JobState): void
156
258
  ```
157
259
 
158
- ### Cancellation
159
- ```ts
160
- const job = q.createJob(sendEmail, { label: "email#42" });
260
+ **Parameters**:
261
+ - `state` - Optional state to clear (`'failed'` or `'completed'`)
262
+ - Omit to clear both
263
+
264
+ ```typescript
265
+ // Clear completed jobs only
266
+ retryQ.clearHistory('completed');
161
267
 
162
- // later
163
- q.cancelJob(job.id);
164
- // or
165
- job.cancel();
268
+ // Clear all history
269
+ retryQ.clearHistory();
166
270
  ```
167
271
 
168
- If the job is sleeping between retries, the sleep is aborted immediately. If it’s executing your function, it will not be forcibly interrupted—cooperative cancellation is advised for long-running tasks.
272
+ ---
169
273
 
170
- ### Introspection
171
- ```ts
172
- const { pending, running, failed, completed } = q.listJobs();
274
+ #### onIdle() / drain()
173
275
 
174
- const maybe = q.findJobById("job-123");
175
- const allEmailJobs = q.findJobsByLabel("email#42");
276
+ ```typescript
277
+ onIdle(): Promise<void>
278
+ drain(): Promise<void> // alias
176
279
  ```
177
280
 
281
+ Resolves when the queue is fully idle (no pending **and** no running jobs).
282
+ Resolves immediately if already idle.
178
283
 
179
- ## Error handling
180
- - When a job ultimately fails (retries exhausted or `maxTime` exceeded), its `promise` rejects with the last captured error.
181
- - Failed and cancelled jobs are tracked in `listJobs()` for post-mortem or metrics.
284
+ ```typescript
285
+ for (const item of items) {
286
+ retryQ.createJob(() => process(item), { retries: 3 });
287
+ }
288
+ await retryQ.onIdle(); // wait for the whole batch to settle
289
+ ```
182
290
 
291
+ ---
183
292
 
184
- ## Defaults
185
- - `retries`: 3
186
- - `delay`: 1000 ms
187
- - `backoff`: 2
188
- - `maxTime`: 5000 ms
189
- - `jitter`: 0.1 (±10%)
190
- - `priority`: 1
191
- - `maxConcurrent`: `Infinity` (constructor)
293
+ ### Events
192
294
 
295
+ `RetryQManager` extends Node's `EventEmitter` and emits typed events:
296
+
297
+ ```typescript
298
+ retryQ.on('retry', ({ job, info }) => console.log(`retry #${info.attempt} in ${info.nextDelay}ms`));
299
+ retryQ.on('success', ({ job, result }) => console.log('done', job.label));
300
+ retryQ.on('failure', ({ job, error }) => console.error('failed', job.label, error));
301
+ retryQ.on('cancel', ({ job }) => console.log('cancelled', job.label));
302
+ retryQ.on('idle', () => console.log('queue drained'));
303
+ ```
193
304
 
194
- ## Common recipes
305
+ | Event | Payload | Fired when |
306
+ |-------|---------|-----------|
307
+ | `retry` | `{ job, info: RetryInfo }` | A failed attempt schedules another try |
308
+ | `success` | `{ job, result }` | A job completes successfully |
309
+ | `failure` | `{ job, error }` | A job fails terminally |
310
+ | `cancel` | `{ job }` | A job is cancelled |
311
+ | `idle` | _(none)_ | The queue transitions to fully idle |
195
312
 
196
- ### Retrying HTTP with fetch/axios
197
- ```ts
198
- async function getJson(url: string) {
199
- const res = await fetch(url);
200
- if (!res.ok) throw new Error("Network error " + res.status);
313
+ Prefer per-job feedback? Use the `onRetry` / `onSuccess` / `onFailure` /
314
+ `onCancel` callbacks in `RetryQJobOptions`.
315
+
316
+ ---
317
+
318
+ ### Conditional Retries
319
+
320
+ Skip retries for errors that will never succeed:
321
+
322
+ ```typescript
323
+ retryQ.createJob(async (signal) => {
324
+ const res = await fetch(url, { signal });
325
+ if (!res.ok) throw Object.assign(new Error('HTTP'), { status: res.status });
201
326
  return res.json();
327
+ }, {
328
+ retries: 5,
329
+ // Retry 5xx and network errors; give up on 4xx immediately.
330
+ shouldRetry: (err) => !(err?.status >= 400 && err?.status < 500),
331
+ });
332
+ ```
333
+
334
+ ---
335
+
336
+ ### RetryQJob Interface
337
+
338
+ ```typescript
339
+ interface RetryQJob {
340
+ id: string; // Unique identifier
341
+ label: string; // Human-readable name
342
+ state: JobState; // Current state
343
+ priority: number; // Execution priority
344
+ retriesLeft: number; // Remaining attempts
345
+ promise: Promise<any>; // Result promise
346
+ cancel: (force?: boolean) => void; // Cancel method
347
+ fn: (signal?: AbortSignal) => Promise<any>;
348
+ options: RetryQJobOptions; // Configuration
349
+ createdAt: number; // Timestamp (ms)
350
+ startedAt?: number; // Execution start (ms)
351
+ finishedAt?: number; // Completion time (ms)
352
+ error?: any; // Last error
353
+ abortController?: AbortController; // Internal controller
202
354
  }
355
+ ```
356
+
357
+ ---
358
+
359
+ ## Cancellation Modes
360
+
361
+ ### 1. Cooperative Cancellation (Default)
362
+
363
+ **Usage**: `job.cancel()` or `job.cancel(false)`
364
+
365
+ **Behavior**:
366
+ - ✅ Prevents future retries
367
+ - ✅ Interrupts sleep between retries
368
+ - ❌ Does NOT abort in-progress execution
369
+
370
+ **When to use**:
371
+ - Operations should complete cleanly
372
+ - Legacy code without signal support
373
+ - Database transactions
203
374
 
204
- const q = new RetryQManager(4);
205
- const job = q.createJob(() => getJson("https://api.example.com/data"), {
206
- retries: 6,
207
- delay: 300,
208
- backoff: 2,
209
- jitter: 0.15,
210
- maxTime: 5000,
375
+ ```typescript
376
+ const job = retryQ.createJob(async () => {
377
+ await database.transaction();
378
+ return 'done';
211
379
  });
212
380
 
213
- const data = await job.promise;
381
+ job.cancel(); // Waits for transaction to complete
214
382
  ```
215
383
 
216
- ### Queueing many tasks with labels
217
- ```ts
218
- const q = new RetryQManager(5);
384
+ ---
385
+
386
+ ### 2. Force Cancellation ⭐ NEW!
387
+
388
+ **Usage**: `job.cancel(true)`
389
+
390
+ **Behavior**:
391
+ - ✅ Prevents future retries
392
+ - ✅ Interrupts sleep between retries
393
+ - ✅ **Aborts in-progress execution via AbortSignal**
394
+
395
+ **When to use**:
396
+ - HTTP requests (fetch, axios)
397
+ - Long-running computations
398
+ - File uploads/downloads
399
+ - Polling operations
400
+
401
+ ```typescript
402
+ const job = retryQ.createJob(async (signal) => {
403
+ // Check signal to enable force abort
404
+ for (let i = 0; i < 1000; i++) {
405
+ if (signal?.aborted) throw new Error('Aborted');
406
+ await processItem(i);
407
+ }
408
+ });
409
+
410
+ job.cancel(true); // Immediately aborts execution
411
+ ```
412
+
413
+ ---
414
+
415
+ ### External AbortController
416
+
417
+ Link your own `AbortController` to the job:
418
+
419
+ ```typescript
420
+ const controller = new AbortController();
421
+
422
+ const job = retryQ.createJob(async (signal) => {
423
+ return await longOperation(signal);
424
+ }, {
425
+ signal: controller.signal // Link external signal
426
+ });
427
+
428
+ // Cancel via external controller
429
+ controller.abort();
430
+
431
+ // Or via job method
432
+ job.cancel(true);
433
+ ```
434
+
435
+ ---
436
+
437
+ ## Usage Examples
438
+
439
+ ### Example 1: HTTP Requests with Retries
440
+
441
+ ```typescript
442
+ async function fetchWithRetry(url: string) {
443
+ const retryQ = new RetryQManager({ maxConcurrent: 5 });
444
+
445
+ const job = retryQ.createJob(async (signal) => {
446
+ const response = await fetch(url, { signal });
447
+
448
+ if (!response.ok) {
449
+ throw new Error(`HTTP ${response.status}`);
450
+ }
451
+
452
+ return response.json();
453
+ }, {
454
+ retries: 5,
455
+ delay: 1000,
456
+ backoff: 2,
457
+ jitter: 0.15,
458
+ maxTime: 30000,
459
+ label: 'fetch-api'
460
+ });
461
+
462
+ return job.promise;
463
+ }
464
+
465
+ // Use it
466
+ const data = await fetchWithRetry('https://api.example.com/data');
467
+ ```
468
+
469
+ ---
470
+
471
+ ### Example 2: Batch Processing with Priority
472
+
473
+ ```typescript
474
+ const retryQ = new RetryQManager({ maxConcurrent: 3 });
475
+
476
+ const users = ['user1', 'user2', 'user3'];
219
477
 
220
478
  for (const userId of users) {
221
- q.createJob(() => syncUser(userId), {
222
- label: `sync-user:${userId}`,
223
- priority: 5,
479
+ retryQ.createJob(async (signal) => {
480
+ if (signal?.aborted) throw new Error('Aborted');
481
+ return await syncUser(userId);
482
+ }, {
483
+ label: `sync-${userId}`,
484
+ priority: userId === 'admin' ? 10 : 5, // Admin first
485
+ retries: 3
224
486
  });
225
487
  }
226
488
  ```
227
489
 
490
+ ---
491
+
492
+ ### Example 3: File Upload with Progress Tracking
493
+
494
+ ```typescript
495
+ const uploadJob = retryQ.createJob(async (signal) => {
496
+ const formData = new FormData();
497
+ formData.append('file', fileBlob);
498
+
499
+ const response = await fetch('/upload', {
500
+ method: 'POST',
501
+ body: formData,
502
+ signal // Abort upload on cancel
503
+ });
504
+
505
+ return response.json();
506
+ }, {
507
+ retries: 3,
508
+ delay: 2000,
509
+ label: 'file-upload'
510
+ });
511
+
512
+ // User clicks cancel button
513
+ cancelButton.onclick = () => uploadJob.cancel(true);
514
+
515
+ // Track progress
516
+ uploadJob.promise
517
+ .then(result => console.log('Upload complete:', result))
518
+ .catch(err => console.log('Upload failed:', err.message));
519
+ ```
520
+
521
+ ---
522
+
523
+ ### Example 4: Polling with Auto-Stop
524
+
525
+ ```typescript
526
+ const pollJob = retryQ.createJob(async (signal) => {
527
+ while (true) {
528
+ if (signal?.aborted) throw new Error('Polling stopped');
529
+
530
+ const status = await checkJobStatus(signal);
531
+
532
+ if (status === 'completed') {
533
+ return status;
534
+ }
535
+
536
+ await new Promise(resolve => setTimeout(resolve, 5000));
537
+ }
538
+ }, {
539
+ retries: 100,
540
+ delay: 5000,
541
+ maxTime: 300000, // 5 minutes total
542
+ label: 'poll-job-status'
543
+ });
544
+
545
+ // Stop polling
546
+ setTimeout(() => pollJob.cancel(true), 60000);
547
+ ```
548
+
549
+ ---
550
+
551
+ ### Example 5: Graceful Shutdown
228
552
 
229
- ## Notes and caveats
230
- - Cancellation does not forcibly abort your `fn` while it’s running; design long-running tasks to be cancellable if required.
231
- - `maxTime` is a soft cap applied across the job’s lifetime. If elapsed time exceeds `maxTime`, the manager stops retrying and marks the job failed.
232
- - The manager generates ids like `job-<timestamp>-<random>`; you can set a human-readable `label` for easier lookups.
553
+ ```typescript
554
+ const jobs: RetryQJob[] = [];
233
555
 
556
+ // Queue multiple jobs
557
+ for (let i = 0; i < 100; i++) {
558
+ const job = retryQ.createJob(async (signal) => {
559
+ return await processItem(i, signal);
560
+ }, { retries: 3 });
561
+
562
+ jobs.push(job);
563
+ }
564
+
565
+ // Handle shutdown signal
566
+ process.on('SIGTERM', async () => {
567
+ console.log('Shutting down gracefully...');
568
+
569
+ // Cancel all running jobs cooperatively
570
+ jobs.forEach(job => {
571
+ if (job.state === 'running' || job.state === 'pending') {
572
+ job.cancel(); // Cooperative
573
+ }
574
+ });
575
+
576
+ // Wait for jobs to finish (with timeout)
577
+ await Promise.race([
578
+ Promise.allSettled(jobs.map(j => j.promise)),
579
+ new Promise(resolve => setTimeout(resolve, 10000))
580
+ ]);
581
+
582
+ process.exit(0);
583
+ });
584
+ ```
585
+
586
+ ---
587
+
588
+ ## Configuration Options
589
+
590
+ ### RetryQJobOptions
591
+
592
+ ```typescript
593
+ type RetryQJobOptions<T = unknown> = {
594
+ retries?: number; // Number of retry attempts (default: 3)
595
+ delay?: number; // Initial delay in ms (default: 1000)
596
+ backoff?: number; // Delay multiplier (default: 2)
597
+ maxTime?: number; // Total time limit in ms (default: 30000)
598
+ maxDelay?: number; // Cap for a single backoff delay (default: Infinity)
599
+ attemptTimeout?: number; // Per-attempt timeout in ms (default: Infinity)
600
+ jitter?: number; // Jitter fraction 0-1 (default: 0.1)
601
+ label?: string; // Human-readable identifier (default: job ID)
602
+ priority?: number; // Execution priority (default: 1)
603
+ signal?: AbortSignal; // External abort signal (optional)
604
+
605
+ // Conditional retry: return false to stop retrying immediately
606
+ shouldRetry?: (error: unknown, attempt: number) => boolean;
607
+
608
+ // Per-job lifecycle callbacks
609
+ onRetry?: (info: RetryInfo) => void;
610
+ onSuccess?: (result: T) => void;
611
+ onFailure?: (error: unknown) => void;
612
+ onCancel?: () => void;
613
+ };
614
+ ```
615
+
616
+ ### Default Values
617
+
618
+ | Option | Default | Description |
619
+ |--------|---------|-------------|
620
+ | `retries` | `3` | Number of retry attempts after initial try |
621
+ | `delay` | `1000` | Initial delay between retries (ms) |
622
+ | `backoff` | `2` | Multiplier for exponential backoff |
623
+ | `maxTime` | `30000` | Total execution time limit (30s), enforced during attempts |
624
+ | `maxDelay` | `Infinity` | Cap for a single backoff delay (ms) |
625
+ | `attemptTimeout` | `Infinity` | Per-attempt timeout (ms) |
626
+ | `jitter` | `0.1` | Random delay variation (±10%) |
627
+ | `priority` | `1` | Queue priority (higher = sooner) |
628
+ | `maxConcurrent` | `Infinity` | Concurrent job limit |
629
+ | `maxHistorySize` | `1000` | Jobs kept in history per state |
630
+
631
+ ### Validation Rules
632
+
633
+ ```typescript
634
+ // retries: 0 to 100
635
+ if (retries < 0) throw new Error('retries must be >= 0');
636
+ if (retries > 100) throw new Error('retries cannot exceed 100 (DoS protection)');
637
+
638
+ // delay: >= 0
639
+ if (delay < 0) throw new Error('delay must be >= 0');
640
+
641
+ // backoff: >= 1
642
+ if (backoff < 1) throw new Error('backoff must be >= 1');
643
+
644
+ // maxTime: > 0
645
+ if (maxTime <= 0) throw new Error('maxTime must be > 0');
646
+
647
+ // jitter: 0 to 1
648
+ if (jitter < 0 || jitter > 1) throw new Error('jitter must be between 0 and 1');
649
+ ```
650
+
651
+ ---
652
+
653
+ ## Best Practices
654
+
655
+ ### ✅ DO
656
+
657
+ **1. Use AbortSignal for force cancellation**
658
+ ```typescript
659
+ async (signal) => {
660
+ if (signal?.aborted) throw new Error('Aborted');
661
+ await work();
662
+ }
663
+ ```
664
+
665
+ **2. Set appropriate maxTime**
666
+ ```typescript
667
+ // Long operations need higher limits
668
+ retryQ.createJob(fn, { maxTime: 60000 }); // 1 minute
669
+ ```
670
+
671
+ **3. Use labels for tracking**
672
+ ```typescript
673
+ retryQ.createJob(fn, { label: 'user-sync:123' });
674
+ ```
675
+
676
+ **4. Clean up history periodically**
677
+ ```typescript
678
+ setInterval(() => retryQ.clearHistory('completed'), 3600000); // Hourly
679
+ ```
680
+
681
+ **5. Monitor queue depth**
682
+ ```typescript
683
+ const { pending, running } = retryQ.listJobs();
684
+ console.log(`Queue: ${pending.length} pending, ${running.length} running`);
685
+ ```
686
+
687
+ ---
688
+
689
+ ### ❌ DON'T
690
+
691
+ **1. Don't ignore signal parameter**
692
+ ```typescript
693
+ // BAD - force cancel won't work
694
+ async () => await work();
695
+
696
+ // GOOD - supports force cancel
697
+ async (signal) => {
698
+ if (signal?.aborted) throw new Error('Aborted');
699
+ await work();
700
+ }
701
+ ```
702
+
703
+ **2. Don't use infinite retries**
704
+ ```typescript
705
+ // BAD - will retry forever
706
+ { retries: Infinity }
707
+
708
+ // GOOD - capped at 100
709
+ { retries: 10 }
710
+ ```
711
+
712
+ **3. Don't leak secrets in errors**
713
+ ```typescript
714
+ // BAD - error might contain API key
715
+ throw new Error(`Failed with key: ${apiKey}`);
716
+
717
+ // GOOD - sanitized error
718
+ throw new Error('API request failed');
719
+ ```
720
+
721
+ ---
722
+
723
+ ## Migration Guide
724
+
725
+ ### From v1.1.x to v1.2.x
726
+
727
+ **No breaking API changes.** All existing code keeps working; new options,
728
+ callbacks, events, and methods are additive. Two behavior fixes to be aware of:
729
+
730
+ - `listJobs().cancelled` now holds cancelled jobs — they no longer appear under
731
+ `failed`.
732
+ - `maxTime` now actively aborts an in-flight attempt once the budget is
733
+ exhausted (previously it only blocked starting a new attempt).
734
+
735
+ The package now ships **both ESM and CJS** with an `exports` map; `import` and
736
+ `require` both resolve automatically.
737
+
738
+ ### From v1.0.x to v1.1.x
739
+
740
+ **No breaking changes!** All existing code works.
741
+
742
+ **To add force cancellation**:
743
+
744
+ ```typescript
745
+ // Before (v1.0.x)
746
+ const job = retryQ.createJob(async () => {
747
+ await work();
748
+ });
749
+
750
+ // After (v1.1.x with force cancel)
751
+ const job = retryQ.createJob(async (signal) => {
752
+ if (signal?.aborted) throw new Error('Aborted');
753
+ await work();
754
+ });
755
+
756
+ job.cancel(true); // Now supports force abort!
757
+ ```
758
+
759
+ ---
760
+
761
+ ## Performance
762
+
763
+ ### Benchmarks
764
+
765
+ **Tested on**: MacBook Pro M1, 16GB RAM, Node.js 20
766
+
767
+ | Operation | Performance |
768
+ |-----------|------------|
769
+ | Create 1000 jobs | ~5ms |
770
+ | ID collision (1000 concurrent) | 0 collisions |
771
+ | Signal check (1M iterations) | ~2-3ms |
772
+ | Queue processing (100 jobs) | <1ms |
773
+ | Memory usage (10K jobs) | ~50MB |
774
+
775
+ ### Memory Management
776
+
777
+ - **Bounded history**: LRU eviction at `maxHistorySize`
778
+ - **Registry cleanup**: Automatic cleanup after job completion
779
+ - **No leaks**: All references cleaned up properly
780
+
781
+ ---
782
+
783
+ ## TypeScript Support
784
+
785
+ Full type safety with bundled declarations:
786
+
787
+ ```typescript
788
+ import {
789
+ RetryQManager,
790
+ RetryQJob,
791
+ RetryQJobOptions,
792
+ RetryQManagerConfig,
793
+ JobState,
794
+ CancelableFunction
795
+ } from '@anishhs/retryq';
796
+
797
+ const manager: RetryQManager = new RetryQManager({
798
+ maxConcurrent: 5,
799
+ maxHistorySize: 1000
800
+ });
801
+
802
+ const job: RetryQJob = manager.createJob(
803
+ async (signal?: AbortSignal) => {
804
+ return 'result';
805
+ },
806
+ {
807
+ retries: 3,
808
+ delay: 1000
809
+ }
810
+ );
811
+ ```
812
+
813
+ ---
814
+
815
+ ## Troubleshooting
816
+
817
+ ### Issue: Jobs not executing
818
+
819
+ **Cause**: Exceeded `maxConcurrent` limit
820
+
821
+ **Solution**: Increase limit or wait for jobs to complete
822
+ ```typescript
823
+ new RetryQManager({ maxConcurrent: 10 }); // Increase from default
824
+ ```
825
+
826
+ ---
827
+
828
+ ### Issue: Memory growing unbounded
829
+
830
+ **Cause**: Too many jobs in history
831
+
832
+ **Solution**: Lower `maxHistorySize` or clear history
833
+ ```typescript
834
+ new RetryQManager({ maxHistorySize: 500 }); // Lower limit
835
+ retryQ.clearHistory(); // Manual cleanup
836
+ ```
837
+
838
+ ---
839
+
840
+ ### Issue: Force cancel not working
841
+
842
+ **Cause**: Job function doesn't check signal
843
+
844
+ **Solution**: Add signal checks
845
+ ```typescript
846
+ async (signal) => {
847
+ if (signal?.aborted) throw new Error('Aborted');
848
+ // ... your code
849
+ }
850
+ ```
851
+
852
+ ---
853
+
854
+ ## FAQ
855
+
856
+ **Q: Is this production-ready?**
857
+ A: Yes — covered by a `node:test` suite spanning concurrency, cancellation, events, timeouts, and retry semantics.
858
+
859
+ **Q: Does it work with TypeScript?**
860
+ A: Yes, full TypeScript support with bundled type definitions.
861
+
862
+ **Q: Can I use this in serverless (Lambda)?**
863
+ A: Yes, but jobs are in-memory only. They won't persist across cold starts.
864
+
865
+ **Q: Does it support distributed systems?**
866
+ A: No, it's single-process only. For distributed queues, use Redis/RabbitMQ.
867
+
868
+ **Q: What's the difference between cooperative and force cancellation?**
869
+ A: Cooperative prevents retries but allows current execution to complete. Force uses AbortSignal to interrupt in-progress execution.
870
+
871
+ **Q: Can I use this with fetch/axios?**
872
+ A: Yes! Pass the signal parameter directly to fetch() or axios.
873
+
874
+ ---
875
+
876
+ ## Examples Repository
877
+
878
+ More examples available at: [github.com/anishhs-gh/retryq-examples](https://github.com/anishhs-gh/retryq-examples) *(coming soon)*
879
+
880
+ ---
234
881
 
235
882
  ## Development
236
883
 
237
- Scripts:
238
884
  ```bash
239
- # build TypeScript to dist/
240
- npm run build
885
+ npm install # install dev dependencies
886
+ npm run typecheck # tsc --noEmit
887
+ npm run build # emit dual ESM + CJS into dist/ (with .d.ts)
888
+ npm test # build (pretest) then run node:test suite
889
+ ```
241
890
 
242
- # run compiled output
243
- npm start
891
+ The package builds to both module formats:
244
892
 
245
- # dev-run directly from src/
246
- npm run dev
247
- ```
893
+ - CommonJS `dist/cjs` (`require`)
894
+ - ES modules → `dist/esm` (`import`)
895
+
896
+ resolved automatically via the `exports` map in `package.json`.
897
+
898
+ ## CI / Release
248
899
 
249
- `tsconfig.json` emits `dist/index.js` and type declarations in `dist/index.d.ts`.
900
+ | Workflow | Trigger | Purpose |
901
+ |----------|---------|---------|
902
+ | **Test** | push/PR to `develop`/`master` | Typecheck, build, and test on Node 16/18/20 |
903
+ | **Audit** | push/PR + weekly | `npm audit` of production dependencies |
904
+ | **Dry-run publish** | push to `develop`, PRs | Gated on Test + Audit; verifies the npm token authenticates and has publish permission, and that `npm publish` would succeed — without publishing |
905
+ | **Publish (manual)** | `workflow_dispatch` | Gated on Test + Audit; publishes to npm and creates a GitHub Release + version tag |
250
906
 
907
+ Releases are **manual**: bump the version in `package.json`, merge to `master`,
908
+ then run the **Publish** workflow from the Actions tab. Requires an `NPM_TOKEN`
909
+ repository secret with publish access to the `@anishhs` scope.
910
+
911
+ ## Contributing
912
+
913
+ Contributions welcome! Please:
914
+ 1. Fork the repository
915
+ 2. Create a feature branch (off `develop`)
916
+ 3. Add tests for new features (`tests/*.test.js`, `node:test`)
917
+ 4. Ensure `npm test` passes
918
+ 5. Submit a pull request into `develop`
919
+
920
+ ---
251
921
 
252
922
  ## License
253
923
 
254
924
  ISC © Anish Shekh
925
+
926
+ ---
927
+
928
+ ## Changelog
929
+
930
+ See [CHANGELOG.md](./CHANGELOG.md) for version history.
931
+
932
+ ---
933
+
934
+ ## Support
935
+
936
+ - **Issues**: [GitHub Issues](https://github.com/anishhs-gh/retryq/issues)
937
+ - **GitHub**: [anishhs-gh](https://github.com/anishhs-gh)
938
+ - **Website**: [anishhs.com](https://anishhs.com)
939
+ - **LinkedIn**: [linkedin.com/in/anishsh](https://linkedin.com/in/anishsh)
940
+
941
+ ---
942
+
943
+ **Made with ❤️ by Anish Shekh**