@nicnocquee/dataqueue 1.24.0 → 1.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/index.cjs +2754 -972
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +440 -12
- package/dist/index.d.ts +440 -12
- package/dist/index.js +2752 -973
- package/dist/index.js.map +1 -1
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
- package/migrations/1751186053000_add_job_events_table.sql +12 -8
- package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
- package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
- package/migrations/1781200000000_add_wait_support.sql +12 -0
- package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
- package/migrations/1781200000002_add_performance_indexes.sql +34 -0
- package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
- package/package.json +20 -6
- package/src/backend.ts +163 -0
- package/src/backends/postgres.ts +1111 -0
- package/src/backends/redis-scripts.ts +533 -0
- package/src/backends/redis.test.ts +543 -0
- package/src/backends/redis.ts +834 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +6 -1
- package/src/index.ts +99 -36
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +512 -44
- package/src/queue.test.ts +217 -6
- package/src/queue.ts +311 -902
- package/src/test-util.ts +32 -0
- package/src/types.ts +349 -16
- package/src/wait.test.ts +698 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
-- Up Migration: Add timeout_ms and failure_reason to job_queue
|
|
2
|
-
ALTER TABLE job_queue ADD COLUMN timeout_ms INT;
|
|
3
|
-
ALTER TABLE job_queue ADD COLUMN failure_reason VARCHAR;
|
|
2
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS timeout_ms INT;
|
|
3
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS failure_reason VARCHAR;
|
|
4
4
|
|
|
5
5
|
-- Down Migration: Remove timeout_ms and failure_reason from job_queue
|
|
6
6
|
ALTER TABLE job_queue DROP COLUMN IF EXISTS timeout_ms;
|
|
@@ -7,15 +7,19 @@ CREATE TABLE IF NOT EXISTS job_events (
|
|
|
7
7
|
metadata JSONB
|
|
8
8
|
);
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
DO $$ BEGIN
|
|
11
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_job_events_job_queue') THEN
|
|
12
|
+
ALTER TABLE job_events ADD CONSTRAINT fk_job_events_job_queue FOREIGN KEY (job_id) REFERENCES job_queue(id) ON DELETE CASCADE;
|
|
13
|
+
END IF;
|
|
14
|
+
END $$;
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_job_events_job_id ON job_events(job_id);
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_job_events_event_type ON job_events(event_type);
|
|
13
17
|
|
|
14
|
-
ALTER TABLE job_queue ADD COLUMN completed_at TIMESTAMPTZ;
|
|
15
|
-
ALTER TABLE job_queue ADD COLUMN started_at TIMESTAMPTZ;
|
|
16
|
-
ALTER TABLE job_queue ADD COLUMN last_retried_at TIMESTAMPTZ;
|
|
17
|
-
ALTER TABLE job_queue ADD COLUMN last_failed_at TIMESTAMPTZ;
|
|
18
|
-
ALTER TABLE job_queue ADD COLUMN last_cancelled_at TIMESTAMPTZ;
|
|
18
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
|
|
19
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ;
|
|
20
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS last_retried_at TIMESTAMPTZ;
|
|
21
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS last_failed_at TIMESTAMPTZ;
|
|
22
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS last_cancelled_at TIMESTAMPTZ;
|
|
19
23
|
|
|
20
24
|
-- Down Migration
|
|
21
25
|
DROP INDEX IF EXISTS idx_job_events_event_type;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
-- Up Migration: Add force_kill_on_timeout to job_queue
|
|
2
|
-
ALTER TABLE job_queue ADD COLUMN force_kill_on_timeout BOOLEAN DEFAULT FALSE;
|
|
2
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS force_kill_on_timeout BOOLEAN DEFAULT FALSE;
|
|
3
3
|
|
|
4
4
|
-- Down Migration: Remove force_kill_on_timeout from job_queue
|
|
5
5
|
ALTER TABLE job_queue DROP COLUMN IF EXISTS force_kill_on_timeout;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Up Migration: Add idempotency_key to job_queue
|
|
2
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(255);
|
|
3
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_job_queue_idempotency_key ON job_queue (idempotency_key) WHERE idempotency_key IS NOT NULL;
|
|
4
|
+
|
|
5
|
+
-- Down Migration: Remove idempotency_key from job_queue
|
|
6
|
+
DROP INDEX IF EXISTS idx_job_queue_idempotency_key;
|
|
7
|
+
ALTER TABLE job_queue DROP COLUMN IF EXISTS idempotency_key;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Up Migration: Add wait support columns to job_queue
|
|
2
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS wait_until TIMESTAMPTZ DEFAULT NULL;
|
|
3
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS wait_token_id VARCHAR(255) DEFAULT NULL;
|
|
4
|
+
ALTER TABLE job_queue ADD COLUMN IF NOT EXISTS step_data JSONB DEFAULT '{}';
|
|
5
|
+
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_job_queue_wait_until ON job_queue (wait_until) WHERE status = 'waiting' AND wait_until IS NOT NULL;
|
|
7
|
+
|
|
8
|
+
-- Down Migration: Remove wait support columns from job_queue
|
|
9
|
+
DROP INDEX IF EXISTS idx_job_queue_wait_until;
|
|
10
|
+
ALTER TABLE job_queue DROP COLUMN IF EXISTS wait_until;
|
|
11
|
+
ALTER TABLE job_queue DROP COLUMN IF EXISTS wait_token_id;
|
|
12
|
+
ALTER TABLE job_queue DROP COLUMN IF EXISTS step_data;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
-- Up Migration: Create waitpoints table for token-based waits
|
|
2
|
+
CREATE TABLE IF NOT EXISTS waitpoints (
|
|
3
|
+
id VARCHAR(255) PRIMARY KEY,
|
|
4
|
+
job_id INT REFERENCES job_queue(id) ON DELETE CASCADE,
|
|
5
|
+
status VARCHAR(20) NOT NULL DEFAULT 'waiting',
|
|
6
|
+
output JSONB DEFAULT NULL,
|
|
7
|
+
timeout_at TIMESTAMPTZ DEFAULT NULL,
|
|
8
|
+
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
9
|
+
completed_at TIMESTAMPTZ DEFAULT NULL,
|
|
10
|
+
tags TEXT[] DEFAULT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_waitpoints_job_id ON waitpoints(job_id);
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_waitpoints_status ON waitpoints(status);
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_waitpoints_timeout ON waitpoints(timeout_at) WHERE status = 'waiting';
|
|
16
|
+
|
|
17
|
+
-- Down Migration: Drop waitpoints table
|
|
18
|
+
DROP TABLE IF EXISTS waitpoints;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Up Migration: Add composite and partial indexes for performance at scale
|
|
2
|
+
|
|
3
|
+
-- Composite partial index for the getNextBatch claim query.
|
|
4
|
+
-- Covers pending jobs ordered by priority then age.
|
|
5
|
+
-- The run_at <= NOW() filter is applied at query time (NOW() is not IMMUTABLE).
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_job_queue_claimable
|
|
7
|
+
ON job_queue (priority DESC, created_at ASC)
|
|
8
|
+
WHERE status = 'pending';
|
|
9
|
+
|
|
10
|
+
-- Partial index for failed jobs eligible for retry (used in getNextBatch).
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_job_queue_failed_retry
|
|
12
|
+
ON job_queue (next_attempt_at ASC)
|
|
13
|
+
WHERE status = 'failed' AND next_attempt_at IS NOT NULL;
|
|
14
|
+
|
|
15
|
+
-- Index for reclaimStuckJobs: processing jobs ordered by lock age.
|
|
16
|
+
CREATE INDEX IF NOT EXISTS idx_job_queue_stuck
|
|
17
|
+
ON job_queue (locked_at ASC)
|
|
18
|
+
WHERE status = 'processing';
|
|
19
|
+
|
|
20
|
+
-- Index for cleanupOldJobs: completed jobs by update time.
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_job_queue_cleanup
|
|
22
|
+
ON job_queue (updated_at ASC)
|
|
23
|
+
WHERE status = 'completed';
|
|
24
|
+
|
|
25
|
+
-- Index for job_events cleanup and time-based queries.
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_job_events_created_at
|
|
27
|
+
ON job_events (created_at ASC);
|
|
28
|
+
|
|
29
|
+
-- Down Migration
|
|
30
|
+
DROP INDEX IF EXISTS idx_job_queue_claimable;
|
|
31
|
+
DROP INDEX IF EXISTS idx_job_queue_failed_retry;
|
|
32
|
+
DROP INDEX IF EXISTS idx_job_queue_stuck;
|
|
33
|
+
DROP INDEX IF EXISTS idx_job_queue_cleanup;
|
|
34
|
+
DROP INDEX IF EXISTS idx_job_events_created_at;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nicnocquee/dataqueue",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "PostgreSQL-
|
|
3
|
+
"version": "1.25.0",
|
|
4
|
+
"description": "PostgreSQL or Redis-backed job queue for Node.js applications with support for serverless environments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -19,26 +19,31 @@
|
|
|
19
19
|
"keywords": [
|
|
20
20
|
"nextjs",
|
|
21
21
|
"postgresql",
|
|
22
|
+
"redis",
|
|
22
23
|
"job-queue",
|
|
23
24
|
"background-jobs",
|
|
24
25
|
"vercel"
|
|
25
26
|
],
|
|
26
27
|
"author": "Nico Prananta",
|
|
27
28
|
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/nicnocquee/dataqueue.git",
|
|
32
|
+
"directory": "packages/dataqueue"
|
|
33
|
+
},
|
|
28
34
|
"dependencies": {
|
|
29
35
|
"pg": "^8.0.0",
|
|
30
|
-
"pg-connection-string": "^2.9.1"
|
|
31
|
-
"ts-node": "^10.9.2"
|
|
36
|
+
"pg-connection-string": "^2.9.1"
|
|
32
37
|
},
|
|
33
38
|
"devDependencies": {
|
|
34
39
|
"@arethetypeswrong/cli": "^0.18.2",
|
|
35
40
|
"@changesets/cli": "^2.29.5",
|
|
36
41
|
"@types/node": "^24.0.4",
|
|
37
42
|
"@types/pg": "^8.15.4",
|
|
38
|
-
"
|
|
39
|
-
"jsdom": "^26.1.0",
|
|
43
|
+
"ioredis": "^5.9.3",
|
|
40
44
|
"node-pg-migrate": "^8.0.3",
|
|
41
45
|
"pnpm": "^9.0.0",
|
|
46
|
+
"ts-node": "^10.9.2",
|
|
42
47
|
"prettier": "^3.6.2",
|
|
43
48
|
"tsup": "^8.5.0",
|
|
44
49
|
"turbo": "^1.13.0",
|
|
@@ -47,9 +52,18 @@
|
|
|
47
52
|
"vitest": "^3.2.4"
|
|
48
53
|
},
|
|
49
54
|
"peerDependencies": {
|
|
55
|
+
"ioredis": "^5.0.0",
|
|
50
56
|
"node-pg-migrate": "^8.0.3",
|
|
51
57
|
"pg": "^8.0.0"
|
|
52
58
|
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"ioredis": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"node-pg-migrate": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
53
67
|
"bin": {
|
|
54
68
|
"dataqueue-cli": "./cli.cjs"
|
|
55
69
|
},
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JobOptions,
|
|
3
|
+
JobRecord,
|
|
4
|
+
JobEvent,
|
|
5
|
+
JobEventType,
|
|
6
|
+
FailureReason,
|
|
7
|
+
TagQueryMode,
|
|
8
|
+
JobType,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Filter options used by getJobs, cancelAllUpcomingJobs, editAllPendingJobs
|
|
13
|
+
*/
|
|
14
|
+
export interface JobFilters {
|
|
15
|
+
jobType?: string;
|
|
16
|
+
priority?: number;
|
|
17
|
+
runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
18
|
+
tags?: { values: string[]; mode?: TagQueryMode };
|
|
19
|
+
/**
|
|
20
|
+
* Cursor for keyset pagination. When provided, only return jobs with id < cursor.
|
|
21
|
+
* This is more efficient than OFFSET for large datasets.
|
|
22
|
+
* Cannot be used together with offset.
|
|
23
|
+
*/
|
|
24
|
+
cursor?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fields that can be updated on a job
|
|
29
|
+
*/
|
|
30
|
+
export interface JobUpdates {
|
|
31
|
+
payload?: any;
|
|
32
|
+
maxAttempts?: number;
|
|
33
|
+
priority?: number;
|
|
34
|
+
runAt?: Date | null;
|
|
35
|
+
timeoutMs?: number | null;
|
|
36
|
+
tags?: string[] | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Abstract backend interface that both PostgreSQL and Redis implement.
|
|
41
|
+
* All storage operations go through this interface so the processor
|
|
42
|
+
* and public API are backend-agnostic.
|
|
43
|
+
*/
|
|
44
|
+
export interface QueueBackend {
|
|
45
|
+
// ── Job CRUD ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** Add a job and return its numeric ID. */
|
|
48
|
+
addJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
49
|
+
job: JobOptions<PayloadMap, T>,
|
|
50
|
+
): Promise<number>;
|
|
51
|
+
|
|
52
|
+
/** Get a single job by ID, or null if not found. */
|
|
53
|
+
getJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
54
|
+
id: number,
|
|
55
|
+
): Promise<JobRecord<PayloadMap, T> | null>;
|
|
56
|
+
|
|
57
|
+
/** Get jobs filtered by status, ordered by createdAt DESC. */
|
|
58
|
+
getJobsByStatus<PayloadMap, T extends JobType<PayloadMap>>(
|
|
59
|
+
status: string,
|
|
60
|
+
limit?: number,
|
|
61
|
+
offset?: number,
|
|
62
|
+
): Promise<JobRecord<PayloadMap, T>[]>;
|
|
63
|
+
|
|
64
|
+
/** Get all jobs, ordered by createdAt DESC. */
|
|
65
|
+
getAllJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
66
|
+
limit?: number,
|
|
67
|
+
offset?: number,
|
|
68
|
+
): Promise<JobRecord<PayloadMap, T>[]>;
|
|
69
|
+
|
|
70
|
+
/** Get jobs matching arbitrary filters, ordered by createdAt DESC. */
|
|
71
|
+
getJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
72
|
+
filters?: JobFilters,
|
|
73
|
+
limit?: number,
|
|
74
|
+
offset?: number,
|
|
75
|
+
): Promise<JobRecord<PayloadMap, T>[]>;
|
|
76
|
+
|
|
77
|
+
/** Get jobs by tag(s) with query mode. */
|
|
78
|
+
getJobsByTags<PayloadMap, T extends JobType<PayloadMap>>(
|
|
79
|
+
tags: string[],
|
|
80
|
+
mode?: TagQueryMode,
|
|
81
|
+
limit?: number,
|
|
82
|
+
offset?: number,
|
|
83
|
+
): Promise<JobRecord<PayloadMap, T>[]>;
|
|
84
|
+
|
|
85
|
+
// ── Processing lifecycle ──────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Atomically claim a batch of ready jobs for the given worker.
|
|
89
|
+
* Equivalent to SELECT … FOR UPDATE SKIP LOCKED in Postgres.
|
|
90
|
+
*/
|
|
91
|
+
getNextBatch<PayloadMap, T extends JobType<PayloadMap>>(
|
|
92
|
+
workerId: string,
|
|
93
|
+
batchSize?: number,
|
|
94
|
+
jobType?: string | string[],
|
|
95
|
+
): Promise<JobRecord<PayloadMap, T>[]>;
|
|
96
|
+
|
|
97
|
+
/** Mark a job as completed. */
|
|
98
|
+
completeJob(jobId: number): Promise<void>;
|
|
99
|
+
|
|
100
|
+
/** Mark a job as failed with error info and schedule retry. */
|
|
101
|
+
failJob(
|
|
102
|
+
jobId: number,
|
|
103
|
+
error: Error,
|
|
104
|
+
failureReason?: FailureReason,
|
|
105
|
+
): Promise<void>;
|
|
106
|
+
|
|
107
|
+
/** Update locked_at to keep the job alive (heartbeat). */
|
|
108
|
+
prolongJob(jobId: number): Promise<void>;
|
|
109
|
+
|
|
110
|
+
// ── Job management ────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Retry a failed/cancelled job immediately. */
|
|
113
|
+
retryJob(jobId: number): Promise<void>;
|
|
114
|
+
|
|
115
|
+
/** Cancel a pending job. */
|
|
116
|
+
cancelJob(jobId: number): Promise<void>;
|
|
117
|
+
|
|
118
|
+
/** Cancel all pending jobs matching optional filters. Returns count. */
|
|
119
|
+
cancelAllUpcomingJobs(filters?: JobFilters): Promise<number>;
|
|
120
|
+
|
|
121
|
+
/** Edit a single pending job. */
|
|
122
|
+
editJob(jobId: number, updates: JobUpdates): Promise<void>;
|
|
123
|
+
|
|
124
|
+
/** Edit all pending jobs matching filters. Returns count. */
|
|
125
|
+
editAllPendingJobs(
|
|
126
|
+
filters: JobFilters | undefined,
|
|
127
|
+
updates: JobUpdates,
|
|
128
|
+
): Promise<number>;
|
|
129
|
+
|
|
130
|
+
/** Delete completed jobs older than N days. Returns count deleted. */
|
|
131
|
+
cleanupOldJobs(daysToKeep?: number): Promise<number>;
|
|
132
|
+
|
|
133
|
+
/** Delete job events older than N days. Returns count deleted. */
|
|
134
|
+
cleanupOldJobEvents(daysToKeep?: number): Promise<number>;
|
|
135
|
+
|
|
136
|
+
/** Reclaim jobs stuck in 'processing' for too long. Returns count. */
|
|
137
|
+
reclaimStuckJobs(maxProcessingTimeMinutes?: number): Promise<number>;
|
|
138
|
+
|
|
139
|
+
// ── Progress ──────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Update the progress percentage (0-100) for a job. */
|
|
142
|
+
updateProgress(jobId: number, progress: number): Promise<void>;
|
|
143
|
+
|
|
144
|
+
// ── Events ────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/** Record a job event. Should not throw. */
|
|
147
|
+
recordJobEvent(
|
|
148
|
+
jobId: number,
|
|
149
|
+
eventType: JobEventType,
|
|
150
|
+
metadata?: any,
|
|
151
|
+
): Promise<void>;
|
|
152
|
+
|
|
153
|
+
/** Get all events for a job, ordered by createdAt ASC. */
|
|
154
|
+
getJobEvents(jobId: number): Promise<JobEvent[]>;
|
|
155
|
+
|
|
156
|
+
// ── Internal helpers ──────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Set a pending reason for unpicked jobs of a given type. */
|
|
159
|
+
setPendingReasonForUnpickedJobs(
|
|
160
|
+
reason: string,
|
|
161
|
+
jobType?: string | string[],
|
|
162
|
+
): Promise<void>;
|
|
163
|
+
}
|