Skip to content

Commit 15d22f1

Browse files
authored
Fix-e2e-tests (#35)
* Add dead-letter job handling and related tests - Introduced the `deadLetterJobType` option in the `JobOptions` interface to specify a destination for jobs that exhaust their retry attempts. - Updated the `JobRecord` interface to include fields for tracking dead-letter job metadata, such as `deadLetteredAt` and `deadLetterJobId`. - Enhanced documentation to explain the dead-letter routing feature, including examples of configuration and payload structure. - Implemented logic in both PostgreSQL and Redis backends to create dead-letter jobs when retries are exhausted. - Added tests to verify the correct behavior of dead-letter routing and job reprocessing. These changes improve error handling and job management in DataQueue, providing users with a robust mechanism for dealing with failed jobs. * Enhance testing and coverage reporting in CI workflow - Updated the CI workflow to include coverage reporting for unit and E2E tests, utilizing Codecov for coverage uploads. - Added new scripts for running tests with coverage in both the dataqueue and E2E applications. - Configured Vite to support coverage reporting with the V8 provider, generating reports in multiple formats. - Updated `.gitignore` to exclude coverage directories from version control. These changes improve test visibility and ensure comprehensive coverage reporting across the project. * Update Codecov action to v5 and add token for coverage uploads in CI workflow - Upgraded the Codecov GitHub Action from v4 to v5 for improved functionality. - Added a token for secure coverage uploads for both unit and E2E tests, enhancing security and reliability in the CI process. These changes ensure better integration with Codecov and improve coverage reporting in the CI workflow. * update readme --------- Co-authored-by: Nico Prananta <311343+nicnocquee@users.noreply.github.com>
1 parent e0cb8b5 commit 15d22f1

13 files changed

Lines changed: 494 additions & 39 deletions

File tree

.github/workflows/ci.yml

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,20 @@ jobs:
6565
working-directory: packages/dataqueue
6666
run: pnpm run lint
6767

68-
- name: Run test
68+
- name: Run test coverage
6969
working-directory: packages/dataqueue
70-
run: pnpm run test
70+
run: pnpm run test:coverage
7171
timeout-minutes: 3
7272

73+
- name: Upload unit coverage to Codecov
74+
uses: codecov/codecov-action@v5
75+
with:
76+
files: packages/dataqueue/coverage/lcov.info
77+
token: ${{ secrets.CODECOV_TOKEN }}
78+
flags: unit
79+
name: unit-coverage
80+
fail_ci_if_error: false
81+
7382
e2e:
7483
runs-on: ubuntu-latest
7584
services:
@@ -122,13 +131,22 @@ jobs:
122131
working-directory: apps/e2e
123132
run: pnpm run build
124133

125-
- name: Run E2E tests
134+
- name: Run E2E coverage
126135
working-directory: apps/e2e
127-
run: pnpm run test:e2e
136+
run: pnpm run test:e2e:coverage
128137
timeout-minutes: 5
129138
env:
130139
PG_DATAQUEUE_DATABASE: postgres://postgres:postgres@localhost:5432/e2e_test
131140

141+
- name: Upload e2e coverage to Codecov
142+
uses: codecov/codecov-action@v5
143+
with:
144+
files: apps/e2e/coverage/lcov.info
145+
token: ${{ secrets.CODECOV_TOKEN }}
146+
flags: e2e
147+
name: e2e-coverage
148+
fail_ci_if_error: false
149+
132150
- name: Upload Playwright report
133151
uses: actions/upload-artifact@v4
134152
if: ${{ !cancelled() }}

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ dist
33
data
44
.turbo
55
redis-data
6+
coverage
7+
**/coverage
68

79
# Generated from MDX docs by prebuild
810
packages/dataqueue/ai/docs-content.json

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
![DataQueue](./apps/website/app/opengraph-image.png)
44

5+
[![codecov](https://codecov.io/gh/nicnocquee/dataqueue/graph/badge.svg?token=RXECBHHO1Y)](https://codecov.io/gh/nicnocquee/dataqueue)
6+
57
A lightweight, Redis or PostgreSQL job queue for Node.js/TypeScript projects. Schedule, process, and manage background jobs with ease. Perfect for web apps (Next.js, etc.) deployed to serverless platforms like Vercel, AWS Lambda, etc.
68

79
## Installation

apps/docs/public/llms-full.txt

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ The `JobOptions` interface defines the options for creating a new job in the que
199199

200200
- `tags?`: _string[]_ — Tags for this job. Used for grouping, searching, or batch operations.
201201
- `idempotencyKey?`: _string_ — Optional idempotency key. When provided, ensures that only one job exists for a given key. If a job with the same key already exists, `addJob` returns the existing job's ID instead of creating a duplicate. See [Idempotency](/usage/add-job#idempotency) for details.
202+
- `deadLetterJobType?`: _string_ — Optional dead-letter destination job type. When the job exhausts retries, DataQueue creates a new pending job in this job type with an envelope payload containing source metadata, original payload, and failure context.
202203

203204
## Example
204205

@@ -213,6 +214,7 @@ const job = {
213214
forceKillOnTimeout: false, // Use graceful shutdown (default)
214215
tags: ['welcome', 'user'], // tags for grouping/searching
215216
idempotencyKey: 'welcome-email-user-123', // prevent duplicate jobs
217+
deadLetterJobType: 'email_dead_letter', // route exhausted failures
216218
};
217219
```
218220

@@ -335,13 +337,15 @@ interface JobOptions {
335337
retryDelay?: number; // Base delay between retries in seconds (default: 60)
336338
retryBackoff?: boolean; // Use exponential backoff (default: true)
337339
retryDelayMax?: number; // Max delay cap in seconds (default: none)
340+
deadLetterJobType?: string; // Route exhausted failures to this job type
338341
group?: { id: string; tier?: string }; // Optional group for global concurrency limits
339342
}
340343
```
341344

342345
- `retryDelay` - Base delay between retries in seconds. When `retryBackoff` is true, this is the base for exponential backoff (`retryDelay * 2^attempts`). When false, retries use this fixed delay. Default: `60`.
343346
- `retryBackoff` - Whether to use exponential backoff. When true, delay doubles with each attempt and includes jitter. Default: `true`.
344347
- `retryDelayMax` - Maximum delay cap in seconds. Only meaningful when `retryBackoff` is true. No limit when omitted.
348+
- `deadLetterJobType` - Optional dead-letter destination. When retries are exhausted, a new pending job is created in this job type with an envelope payload (`originalJob`, `originalPayload`, `failure`).
345349
- `group` - Optional grouping metadata. Use `group.id` to enforce global per-group limits with `ProcessorOptions.groupConcurrency`. `group.tier` is reserved for future policies.
346350

347351
#### AddJobOptions
@@ -497,10 +501,11 @@ interface EditJobOptions {
497501
retryDelay?: number | null;
498502
retryBackoff?: boolean | null;
499503
retryDelayMax?: number | null;
504+
deadLetterJobType?: string | null;
500505
}
501506
```
502507

503-
All fields are optional - only provided fields will be updated. Note that `jobType` cannot be changed. Set retry fields to `null` to revert to legacy default behavior.
508+
All fields are optional - only provided fields will be updated. Note that `jobType` cannot be changed. Set retry fields to `null` to revert to legacy default behavior. Set `deadLetterJobType` to `null` to clear dead-letter routing for pending jobs.
504509

505510
#### Example
506511

@@ -858,7 +863,7 @@ The `JobRecord` interface represents a job stored in the queue, including its st
858863
- `jobType`: _string_ — The type of the job.
859864
- `payload`: _any_ — The job payload.
860865
- `status`:
861-
_'pending' | 'processing' | 'completed' | 'failed' | 'cancelled'_ —
866+
_'pending' | 'processing' | 'completed' | 'failed' | 'cancelled' | 'waiting'_ —
862867
Current job status.
863868
- `createdAt`: _Date_ — When the job was created.
864869
- `updated_at`: _Date_ — When the job was last updated.
@@ -890,6 +895,9 @@ The `JobRecord` interface represents a job stored in the queue, including its st
890895
- `idempotencyKey?`: _string | null_ — The idempotency key for this job, if one was provided when the job was created.
891896
- `progress?`: _number | null_ — Progress percentage (0–100) reported by the handler via `ctx.setProgress()`. `null` if no progress has been reported. See [Progress Tracking](/usage/progress-tracking).
892897
- `output?`: _unknown_ — Handler output stored via `ctx.setOutput(data)` or by returning a value from the handler. `null` if no output has been stored. See [Job Output](/usage/job-output).
898+
- `deadLetterJobType?`: _string | null_ — Configured dead-letter destination job type for this job.
899+
- `deadLetteredAt?`: _Date | null_ — Timestamp when this job was routed to a dead-letter job.
900+
- `deadLetterJobId?`: _number | null_ — Linked dead-letter job ID created when retries were exhausted.
893901

894902
## Example
895903

@@ -1994,8 +2002,9 @@ Type handlers as `JobHandlers<PayloadMap>` — TypeScript enforces a handler for
19942002
1. Creating initJobQueue per request (creates a DB pool each time)
19952003
2. Missing handler for a job type (fails with NoHandler)
19962004
3. Not checking signal.aborted in long handlers
1997-
4. Forgetting reclaimStuckJobs() — crashed workers leave jobs stuck
1998-
5. Skipping migrations (PostgreSQL requires `dataqueue-cli migrate`)
2005+
4. Forgetting dead-letter routing for critical jobs — set `deadLetterJobType` so exhausted failures are inspectable/replayable
2006+
5. Forgetting reclaimStuckJobs() — crashed workers leave jobs stuck
2007+
6. Skipping migrations (PostgreSQL requires `dataqueue-cli migrate`)
19992008
```
20002009

20012010
---
@@ -3059,6 +3068,37 @@ A job handler can fail for many reasons, such as a bug in the code or running ou
30593068

30603069
When a job fails, it is marked as `failed` and retried up to `maxAttempts` times (default: 3). You can view the error history for a job in its `errorHistory` field.
30613070

3071+
## Dead-letter queues
3072+
3073+
You can route permanently failed jobs to a dead-letter job type using `deadLetterJobType`.
3074+
3075+
When a job exhausts retries (`attempts >= maxAttempts`), DataQueue:
3076+
3077+
1. Keeps the source job as `failed`.
3078+
2. Creates a new pending dead-letter job in `deadLetterJobType`.
3079+
3. Stores linkage metadata on the source job (`deadLetteredAt`, `deadLetterJobId`).
3080+
3081+
```ts
3082+
await jobQueue.addJob({
3083+
jobType: 'email',
3084+
payload: { to: 'user@example.com' },
3085+
maxAttempts: 3,
3086+
deadLetterJobType: 'email_dead_letter',
3087+
});
3088+
```
3089+
3090+
The dead-letter job payload is an envelope:
3091+
3092+
```ts
3093+
{
3094+
originalJob: { id, jobType, attempts, maxAttempts },
3095+
originalPayload: { ... }, // original job payload
3096+
failure: { message, reason, failedAt },
3097+
}
3098+
```
3099+
3100+
If `deadLetterJobType` is not set, behavior is unchanged: exhausted jobs remain failed without creating a dead-letter job.
3101+
30623102
## Retry configuration
30633103

30643104
You can control the retry behavior per-job using three options:

apps/e2e/e2e/maintenance.spec.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { test, expect } from '@playwright/test';
22
import {
33
addJob,
4-
processJobs,
54
cleanupOldJobs,
5+
processJobs,
66
reclaimStuckJobs,
7+
waitForJobStatus,
78
} from './helpers';
89

910
test.describe('Maintenance', () => {
@@ -21,9 +22,42 @@ test.describe('Maintenance', () => {
2122
expect(deleted).toBeGreaterThanOrEqual(0);
2223
});
2324

24-
test('reclaim stuck jobs (does not error)', async ({ request }) => {
25-
// Just verify the endpoint works without errors
25+
test('reclaim stuck jobs transitions a processing job back to pending', async ({
26+
request,
27+
}) => {
28+
const { id } = await addJob(request, {
29+
jobType: 'slow-job',
30+
payload: { value: 'reclaim-test', delayMs: 7000 },
31+
});
32+
33+
const processingRun = processJobs(request, {
34+
batchSize: 1,
35+
concurrency: 1,
36+
jobType: 'slow-job',
37+
});
38+
39+
await waitForJobStatus(request, id, 'processing', 5000, 100);
40+
2641
const { reclaimed } = await reclaimStuckJobs(request, 0);
27-
expect(reclaimed).toBeGreaterThanOrEqual(0);
42+
expect(reclaimed).toBeGreaterThanOrEqual(1);
43+
44+
const reclaimedJob = await waitForJobStatus(
45+
request,
46+
id,
47+
'pending',
48+
3000,
49+
100,
50+
);
51+
expect(reclaimedJob.lockedAt).toBeNull();
52+
expect(reclaimedJob.lockedBy).toBeNull();
53+
54+
await processingRun;
55+
await processJobs(request, {
56+
batchSize: 1,
57+
concurrency: 1,
58+
jobType: 'slow-job',
59+
});
60+
61+
await waitForJobStatus(request, id, 'completed', 10000, 100);
2862
});
2963
});

apps/e2e/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"build": "next build",
88
"start": "next start --port 3099",
99
"test:e2e": "PG_DATAQUEUE_DATABASE=${PG_DATAQUEUE_DATABASE:-postgres://postgres:postgres@localhost:5432/e2e_test} dataqueue-cli migrate && playwright test",
10+
"test:e2e:coverage": "rm -rf coverage && mkdir -p coverage/e2e-v8 && NODE_V8_COVERAGE=coverage/e2e-v8 PG_DATAQUEUE_DATABASE=${PG_DATAQUEUE_DATABASE:-postgres://postgres:postgres@localhost:5432/e2e_test} dataqueue-cli migrate && NODE_V8_COVERAGE=coverage/e2e-v8 playwright test && c8 report --temp-directory coverage/e2e-v8 --reporter=text --reporter=html --reporter=lcov --report-dir coverage",
1011
"test:e2e:ui": "playwright test --ui",
1112
"migrate-dataqueue": "dataqueue-cli migrate"
1213
},
@@ -24,6 +25,7 @@
2425
"@types/node": "^20",
2526
"@types/react": "^19",
2627
"@types/react-dom": "^19",
28+
"c8": "^10.1.3",
2729
"typescript": "^5"
2830
}
2931
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"build": "turbo run build",
1616
"lint": "turbo run lint",
1717
"test": "turbo run test",
18+
"test:coverage": "turbo run test:coverage --filter \"@nicnocquee/dataqueue\"",
1819
"test:e2e": "turbo run test:e2e --filter e2e",
20+
"test:e2e:coverage": "turbo run test:e2e:coverage --filter e2e",
1921
"format": "prettier --write .",
2022
"check-format": "prettier --check ."
2123
},

packages/dataqueue/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"ci": "npm run build && npm run check-format && npm run check-exports && npm run lint && npm run test",
2424
"lint": "tsc",
2525
"test": "vitest run --reporter=verbose",
26+
"test:coverage": "vitest run --reporter=verbose --coverage",
2627
"format": "prettier --write .",
2728
"check-format": "prettier --check .",
2829
"check-exports": "attw --pack .",
@@ -56,6 +57,7 @@
5657
"@arethetypeswrong/cli": "^0.18.2",
5758
"@types/node": "^24.0.4",
5859
"@types/pg": "^8.15.4",
60+
"@vitest/coverage-v8": "^3.2.4",
5961
"ioredis": "^5.9.3",
6062
"node-pg-migrate": "^8.0.3",
6163
"pnpm": "^9.0.0",

packages/dataqueue/src/backends/redis.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,71 @@ describe('Redis backend integration', () => {
544544
expect(job?.status).toBe('pending');
545545
});
546546

547+
it('reclaims an in-flight job via supervisor and allows reprocessing', async () => {
548+
let firstAttempt = true;
549+
const handler = vi.fn(async () => {
550+
if (firstAttempt) {
551+
firstAttempt = false;
552+
await new Promise<void>(() => {});
553+
}
554+
});
555+
556+
const jobId = await jobQueue.addJob({
557+
jobType: 'test',
558+
payload: { foo: 'redis-reclaim-live-loop' },
559+
});
560+
561+
const firstProcessor = jobQueue.createProcessor(
562+
{
563+
email: vi.fn(async () => {}),
564+
sms: vi.fn(async () => {}),
565+
test: handler,
566+
},
567+
{ pollInterval: 25, batchSize: 1, concurrency: 1 },
568+
);
569+
firstProcessor.startInBackground();
570+
571+
let processingJob = await jobQueue.getJob(jobId);
572+
for (let i = 0; i < 50; i++) {
573+
if (processingJob?.status === 'processing') {
574+
break;
575+
}
576+
await new Promise((resolve) => setTimeout(resolve, 20));
577+
processingJob = await jobQueue.getJob(jobId);
578+
}
579+
expect(processingJob?.status).toBe('processing');
580+
581+
await firstProcessor.stopAndDrain(25);
582+
583+
const supervisor = jobQueue.createSupervisor({
584+
stuckJobsTimeoutMinutes: 0,
585+
cleanupJobsDaysToKeep: 0,
586+
cleanupEventsDaysToKeep: 0,
587+
expireTimedOutTokens: false,
588+
});
589+
const maintenance = await supervisor.start();
590+
expect(maintenance.reclaimedJobs).toBe(1);
591+
592+
const reclaimedJob = await jobQueue.getJob(jobId);
593+
expect(reclaimedJob?.status).toBe('pending');
594+
expect(reclaimedJob?.lockedAt).toBeNull();
595+
expect(reclaimedJob?.lockedBy).toBeNull();
596+
597+
const secondProcessor = jobQueue.createProcessor(
598+
{
599+
email: vi.fn(async () => {}),
600+
sms: vi.fn(async () => {}),
601+
test: handler,
602+
},
603+
{ batchSize: 1, concurrency: 1 },
604+
);
605+
await secondProcessor.start();
606+
607+
const completedJob = await jobQueue.getJob(jobId);
608+
expect(completedJob?.status).toBe('completed');
609+
expect(handler).toHaveBeenCalledTimes(2);
610+
});
611+
547612
it('getPool should throw for Redis backend', () => {
548613
expect(() => jobQueue.getPool()).toThrow(
549614
'getPool() is only available with the PostgreSQL backend',

0 commit comments

Comments
 (0)