@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224075710
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/ai/docs-content.json +23 -11
- package/ai/rules/advanced.md +77 -1
- package/ai/rules/basic.md +72 -3
- package/ai/rules/react-dashboard.md +5 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +159 -0
- package/ai/skills/dataqueue-core/SKILL.md +107 -3
- package/ai/skills/dataqueue-react/SKILL.md +19 -7
- package/dist/index.cjs +937 -108
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +358 -11
- package/dist/index.d.ts +358 -11
- package/dist/index.js +937 -108
- package/dist/index.js.map +1 -1
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/package.json +1 -1
- package/src/backend.ts +36 -3
- package/src/backends/postgres.ts +344 -42
- package/src/backends/redis-scripts.ts +173 -8
- package/src/backends/redis.test.ts +668 -0
- package/src/backends/redis.ts +244 -15
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/processor.ts +133 -49
- package/src/queue.test.ts +477 -0
- package/src/queue.ts +20 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +318 -3
package/src/queue.test.ts
CHANGED
|
@@ -79,6 +79,47 @@ describe('queue integration', () => {
|
|
|
79
79
|
expect(job?.status).toBe('completed');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it('should store output when completing a job', async () => {
|
|
83
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
84
|
+
jobType: 'email',
|
|
85
|
+
payload: { to: 'output@example.com' },
|
|
86
|
+
});
|
|
87
|
+
await queue.getNextBatch(pool, 'worker-output', 1);
|
|
88
|
+
await queue.completeJob(pool, jobId, {
|
|
89
|
+
url: 'https://example.com/report.pdf',
|
|
90
|
+
});
|
|
91
|
+
const job = await queue.getJob(pool, jobId);
|
|
92
|
+
expect(job?.status).toBe('completed');
|
|
93
|
+
expect(job?.output).toEqual({ url: 'https://example.com/report.pdf' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should have null output when completing without output', async () => {
|
|
97
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
98
|
+
jobType: 'email',
|
|
99
|
+
payload: { to: 'no-output@example.com' },
|
|
100
|
+
});
|
|
101
|
+
await queue.getNextBatch(pool, 'worker-no-output', 1);
|
|
102
|
+
await queue.completeJob(pool, jobId);
|
|
103
|
+
const job = await queue.getJob(pool, jobId);
|
|
104
|
+
expect(job?.status).toBe('completed');
|
|
105
|
+
expect(job?.output).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should preserve output set via updateOutput when completing without output arg', async () => {
|
|
109
|
+
const { PostgresBackend } = await import('./backends/postgres.js');
|
|
110
|
+
const backend = new PostgresBackend(pool);
|
|
111
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
112
|
+
jobType: 'email',
|
|
113
|
+
payload: { to: 'pre-output@example.com' },
|
|
114
|
+
});
|
|
115
|
+
await queue.getNextBatch(pool, 'worker-pre-output', 1);
|
|
116
|
+
await backend.updateOutput(jobId, { interim: true });
|
|
117
|
+
await queue.completeJob(pool, jobId);
|
|
118
|
+
const job = await queue.getJob(pool, jobId);
|
|
119
|
+
expect(job?.status).toBe('completed');
|
|
120
|
+
expect(job?.output).toEqual({ interim: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
82
123
|
it('should get the next batch of jobs to process', async () => {
|
|
83
124
|
// Add jobs (do not set runAt, use DB default)
|
|
84
125
|
const jobId1 = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
@@ -1958,4 +1999,440 @@ describe('getJobs', () => {
|
|
|
1958
1999
|
const batch3 = await queue.getNextBatch(pool, 'worker-1', 1);
|
|
1959
2000
|
expect(batch3.length).toBe(0);
|
|
1960
2001
|
});
|
|
2002
|
+
|
|
2003
|
+
// ── Configurable retry strategy tests ────────────────────────────────
|
|
2004
|
+
|
|
2005
|
+
it('uses legacy backoff when no retry config is set', async () => {
|
|
2006
|
+
// Setup
|
|
2007
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2008
|
+
jobType: 'email',
|
|
2009
|
+
payload: { to: 'legacy@example.com' },
|
|
2010
|
+
maxAttempts: 3,
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
// Act
|
|
2014
|
+
await queue.getNextBatch(pool, 'worker-1', 1);
|
|
2015
|
+
await queue.failJob(pool, jobId, new Error('fail'));
|
|
2016
|
+
|
|
2017
|
+
// Assert — legacy formula: 2^1 * 60s = 120s from now
|
|
2018
|
+
const job = await queue.getJob(pool, jobId);
|
|
2019
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
2020
|
+
const delaySec =
|
|
2021
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
2022
|
+
expect(delaySec).toBeGreaterThanOrEqual(115);
|
|
2023
|
+
expect(delaySec).toBeLessThanOrEqual(125);
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
it('uses fixed delay when retryBackoff is false', async () => {
|
|
2027
|
+
// Setup
|
|
2028
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2029
|
+
jobType: 'email',
|
|
2030
|
+
payload: { to: 'fixed@example.com' },
|
|
2031
|
+
maxAttempts: 3,
|
|
2032
|
+
retryDelay: 10,
|
|
2033
|
+
retryBackoff: false,
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// Act
|
|
2037
|
+
await queue.getNextBatch(pool, 'worker-1', 1);
|
|
2038
|
+
await queue.failJob(pool, jobId, new Error('fail'));
|
|
2039
|
+
|
|
2040
|
+
// Assert — fixed 10s delay
|
|
2041
|
+
const job = await queue.getJob(pool, jobId);
|
|
2042
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
2043
|
+
expect(job?.retryDelay).toBe(10);
|
|
2044
|
+
expect(job?.retryBackoff).toBe(false);
|
|
2045
|
+
const delaySec =
|
|
2046
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
2047
|
+
expect(delaySec).toBeGreaterThanOrEqual(9);
|
|
2048
|
+
expect(delaySec).toBeLessThanOrEqual(11);
|
|
2049
|
+
});
|
|
2050
|
+
|
|
2051
|
+
it('uses exponential backoff with custom retryDelay', async () => {
|
|
2052
|
+
// Setup
|
|
2053
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2054
|
+
jobType: 'email',
|
|
2055
|
+
payload: { to: 'expo@example.com' },
|
|
2056
|
+
maxAttempts: 3,
|
|
2057
|
+
retryDelay: 5,
|
|
2058
|
+
retryBackoff: true,
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
// Act — attempt 1
|
|
2062
|
+
await queue.getNextBatch(pool, 'worker-1', 1);
|
|
2063
|
+
await queue.failJob(pool, jobId, new Error('fail'));
|
|
2064
|
+
|
|
2065
|
+
// Assert — exponential: 5 * 2^1 = 10s, with jitter [5, 10]
|
|
2066
|
+
const job = await queue.getJob(pool, jobId);
|
|
2067
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
2068
|
+
const delaySec =
|
|
2069
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
2070
|
+
expect(delaySec).toBeGreaterThanOrEqual(4);
|
|
2071
|
+
expect(delaySec).toBeLessThanOrEqual(11);
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it('caps exponential backoff with retryDelayMax', async () => {
|
|
2075
|
+
// Setup
|
|
2076
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2077
|
+
jobType: 'email',
|
|
2078
|
+
payload: { to: 'capped@example.com' },
|
|
2079
|
+
maxAttempts: 5,
|
|
2080
|
+
retryDelay: 100,
|
|
2081
|
+
retryBackoff: true,
|
|
2082
|
+
retryDelayMax: 30,
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
// Act — attempt 1
|
|
2086
|
+
await queue.getNextBatch(pool, 'worker-1', 1);
|
|
2087
|
+
await queue.failJob(pool, jobId, new Error('fail'));
|
|
2088
|
+
|
|
2089
|
+
// Assert — 100 * 2^1 = 200s but capped at 30s, with jitter [15, 30]
|
|
2090
|
+
const job = await queue.getJob(pool, jobId);
|
|
2091
|
+
expect(job?.nextAttemptAt).not.toBeNull();
|
|
2092
|
+
expect(job?.retryDelayMax).toBe(30);
|
|
2093
|
+
const delaySec =
|
|
2094
|
+
(job!.nextAttemptAt!.getTime() - job!.lastFailedAt!.getTime()) / 1000;
|
|
2095
|
+
expect(delaySec).toBeGreaterThanOrEqual(14);
|
|
2096
|
+
expect(delaySec).toBeLessThanOrEqual(31);
|
|
2097
|
+
});
|
|
2098
|
+
|
|
2099
|
+
it('stores retry config on job record', async () => {
|
|
2100
|
+
// Setup
|
|
2101
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2102
|
+
jobType: 'email',
|
|
2103
|
+
payload: { to: 'config@example.com' },
|
|
2104
|
+
retryDelay: 30,
|
|
2105
|
+
retryBackoff: false,
|
|
2106
|
+
retryDelayMax: 120,
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
// Act
|
|
2110
|
+
const job = await queue.getJob(pool, jobId);
|
|
2111
|
+
|
|
2112
|
+
// Assert
|
|
2113
|
+
expect(job?.retryDelay).toBe(30);
|
|
2114
|
+
expect(job?.retryBackoff).toBe(false);
|
|
2115
|
+
expect(job?.retryDelayMax).toBe(120);
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
it('returns null retry config for jobs without it', async () => {
|
|
2119
|
+
// Setup
|
|
2120
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2121
|
+
jobType: 'email',
|
|
2122
|
+
payload: { to: 'noconfig@example.com' },
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
// Act
|
|
2126
|
+
const job = await queue.getJob(pool, jobId);
|
|
2127
|
+
|
|
2128
|
+
// Assert
|
|
2129
|
+
expect(job?.retryDelay).toBeNull();
|
|
2130
|
+
expect(job?.retryBackoff).toBeNull();
|
|
2131
|
+
expect(job?.retryDelayMax).toBeNull();
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it('allows editing retry config via editJob', async () => {
|
|
2135
|
+
// Setup
|
|
2136
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
|
|
2137
|
+
jobType: 'email',
|
|
2138
|
+
payload: { to: 'edit@example.com' },
|
|
2139
|
+
});
|
|
2140
|
+
|
|
2141
|
+
// Act
|
|
2142
|
+
await queue.editJob(pool, jobId, {
|
|
2143
|
+
retryDelay: 15,
|
|
2144
|
+
retryBackoff: false,
|
|
2145
|
+
retryDelayMax: 60,
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
// Assert
|
|
2149
|
+
const job = await queue.getJob(pool, jobId);
|
|
2150
|
+
expect(job?.retryDelay).toBe(15);
|
|
2151
|
+
expect(job?.retryBackoff).toBe(false);
|
|
2152
|
+
expect(job?.retryDelayMax).toBe(60);
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
describe('queue.addJob with db option (BYOC)', () => {
|
|
2157
|
+
let pool: Pool;
|
|
2158
|
+
let dbName: string;
|
|
2159
|
+
|
|
2160
|
+
beforeEach(async () => {
|
|
2161
|
+
const setup = await createTestDbAndPool();
|
|
2162
|
+
pool = setup.pool;
|
|
2163
|
+
dbName = setup.dbName;
|
|
2164
|
+
});
|
|
2165
|
+
|
|
2166
|
+
afterEach(async () => {
|
|
2167
|
+
await pool.end();
|
|
2168
|
+
await destroyTestDb(dbName);
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it('rolls back the job when the transaction is rolled back', async () => {
|
|
2172
|
+
// Setup
|
|
2173
|
+
const client = await pool.connect();
|
|
2174
|
+
await client.query('BEGIN');
|
|
2175
|
+
|
|
2176
|
+
// Act
|
|
2177
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
2178
|
+
pool,
|
|
2179
|
+
{ jobType: 'email', payload: { to: 'rollback@example.com' } },
|
|
2180
|
+
{ db: client },
|
|
2181
|
+
);
|
|
2182
|
+
await client.query('ROLLBACK');
|
|
2183
|
+
client.release();
|
|
2184
|
+
|
|
2185
|
+
// Assert
|
|
2186
|
+
const job = await queue.getJob(pool, jobId);
|
|
2187
|
+
expect(job).toBeNull();
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
it('persists the job when the transaction is committed', async () => {
|
|
2191
|
+
// Setup
|
|
2192
|
+
const client = await pool.connect();
|
|
2193
|
+
await client.query('BEGIN');
|
|
2194
|
+
|
|
2195
|
+
// Act
|
|
2196
|
+
const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(
|
|
2197
|
+
pool,
|
|
2198
|
+
{ jobType: 'email', payload: { to: 'commit@example.com' } },
|
|
2199
|
+
{ db: client },
|
|
2200
|
+
);
|
|
2201
|
+
await client.query('COMMIT');
|
|
2202
|
+
client.release();
|
|
2203
|
+
|
|
2204
|
+
// Assert
|
|
2205
|
+
const job = await queue.getJob(pool, jobId);
|
|
2206
|
+
expect(job).not.toBeNull();
|
|
2207
|
+
expect(job?.payload).toEqual({ to: 'commit@example.com' });
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
describe('addJobs batch insert', () => {
|
|
2212
|
+
let pool: Pool;
|
|
2213
|
+
let dbName: string;
|
|
2214
|
+
|
|
2215
|
+
beforeEach(async () => {
|
|
2216
|
+
const setup = await createTestDbAndPool();
|
|
2217
|
+
pool = setup.pool;
|
|
2218
|
+
dbName = setup.dbName;
|
|
2219
|
+
});
|
|
2220
|
+
|
|
2221
|
+
afterEach(async () => {
|
|
2222
|
+
await pool.end();
|
|
2223
|
+
await destroyTestDb(dbName);
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
it('inserts multiple jobs and returns IDs in order', async () => {
|
|
2227
|
+
// Act
|
|
2228
|
+
const ids = await queue.addJobs<
|
|
2229
|
+
{ email: { to: string }; report: { id: string } },
|
|
2230
|
+
'email' | 'report'
|
|
2231
|
+
>(pool, [
|
|
2232
|
+
{ jobType: 'email', payload: { to: 'a@test.com' } },
|
|
2233
|
+
{ jobType: 'report', payload: { id: 'r1' } },
|
|
2234
|
+
{ jobType: 'email', payload: { to: 'b@test.com' } },
|
|
2235
|
+
]);
|
|
2236
|
+
|
|
2237
|
+
// Assert
|
|
2238
|
+
expect(ids).toHaveLength(3);
|
|
2239
|
+
expect(ids[0]).toBeLessThan(ids[1]);
|
|
2240
|
+
expect(ids[1]).toBeLessThan(ids[2]);
|
|
2241
|
+
|
|
2242
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2243
|
+
expect(job0?.jobType).toBe('email');
|
|
2244
|
+
expect(job0?.payload).toEqual({ to: 'a@test.com' });
|
|
2245
|
+
|
|
2246
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2247
|
+
expect(job1?.jobType).toBe('report');
|
|
2248
|
+
expect(job1?.payload).toEqual({ id: 'r1' });
|
|
2249
|
+
|
|
2250
|
+
const job2 = await queue.getJob(pool, ids[2]);
|
|
2251
|
+
expect(job2?.jobType).toBe('email');
|
|
2252
|
+
expect(job2?.payload).toEqual({ to: 'b@test.com' });
|
|
2253
|
+
});
|
|
2254
|
+
|
|
2255
|
+
it('returns empty array for empty input', async () => {
|
|
2256
|
+
// Act
|
|
2257
|
+
const ids = await queue.addJobs(pool, []);
|
|
2258
|
+
|
|
2259
|
+
// Assert
|
|
2260
|
+
expect(ids).toEqual([]);
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
it('respects priority and runAt per job', async () => {
|
|
2264
|
+
// Setup
|
|
2265
|
+
const futureDate = new Date(Date.now() + 60_000);
|
|
2266
|
+
|
|
2267
|
+
// Act
|
|
2268
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2269
|
+
{ jobType: 'task', payload: { n: 1 }, priority: 5 },
|
|
2270
|
+
{ jobType: 'task', payload: { n: 2 }, priority: 10, runAt: futureDate },
|
|
2271
|
+
]);
|
|
2272
|
+
|
|
2273
|
+
// Assert
|
|
2274
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2275
|
+
expect(job0?.priority).toBe(5);
|
|
2276
|
+
|
|
2277
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2278
|
+
expect(job1?.priority).toBe(10);
|
|
2279
|
+
expect(job1?.runAt.getTime()).toBeCloseTo(futureDate.getTime(), -3);
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
it('handles idempotency keys for new jobs', async () => {
|
|
2283
|
+
// Act
|
|
2284
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2285
|
+
{ jobType: 'task', payload: { n: 1 }, idempotencyKey: 'key-a' },
|
|
2286
|
+
{ jobType: 'task', payload: { n: 2 }, idempotencyKey: 'key-b' },
|
|
2287
|
+
]);
|
|
2288
|
+
|
|
2289
|
+
// Assert
|
|
2290
|
+
expect(ids).toHaveLength(2);
|
|
2291
|
+
expect(ids[0]).not.toBe(ids[1]);
|
|
2292
|
+
|
|
2293
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2294
|
+
expect(job0?.idempotencyKey).toBe('key-a');
|
|
2295
|
+
|
|
2296
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2297
|
+
expect(job1?.idempotencyKey).toBe('key-b');
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
it('returns existing IDs for conflicting idempotency keys', async () => {
|
|
2301
|
+
// Setup — insert a job first
|
|
2302
|
+
const existingId = await queue.addJob<{ task: { n: number } }, 'task'>(
|
|
2303
|
+
pool,
|
|
2304
|
+
{ jobType: 'task', payload: { n: 0 }, idempotencyKey: 'dup-key' },
|
|
2305
|
+
);
|
|
2306
|
+
|
|
2307
|
+
// Act — batch includes a duplicate key
|
|
2308
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2309
|
+
{ jobType: 'task', payload: { n: 1 } },
|
|
2310
|
+
{ jobType: 'task', payload: { n: 2 }, idempotencyKey: 'dup-key' },
|
|
2311
|
+
{ jobType: 'task', payload: { n: 3 } },
|
|
2312
|
+
]);
|
|
2313
|
+
|
|
2314
|
+
// Assert
|
|
2315
|
+
expect(ids).toHaveLength(3);
|
|
2316
|
+
expect(ids[1]).toBe(existingId);
|
|
2317
|
+
expect(ids[0]).not.toBe(existingId);
|
|
2318
|
+
expect(ids[2]).not.toBe(existingId);
|
|
2319
|
+
});
|
|
2320
|
+
|
|
2321
|
+
it('handles mix of keyed and non-keyed jobs', async () => {
|
|
2322
|
+
// Act
|
|
2323
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2324
|
+
{ jobType: 'task', payload: { n: 1 } },
|
|
2325
|
+
{ jobType: 'task', payload: { n: 2 }, idempotencyKey: 'mix-1' },
|
|
2326
|
+
{ jobType: 'task', payload: { n: 3 } },
|
|
2327
|
+
{ jobType: 'task', payload: { n: 4 }, idempotencyKey: 'mix-2' },
|
|
2328
|
+
{ jobType: 'task', payload: { n: 5 } },
|
|
2329
|
+
]);
|
|
2330
|
+
|
|
2331
|
+
// Assert
|
|
2332
|
+
expect(ids).toHaveLength(5);
|
|
2333
|
+
const uniqueIds = new Set(ids);
|
|
2334
|
+
expect(uniqueIds.size).toBe(5);
|
|
2335
|
+
|
|
2336
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2337
|
+
expect(job1?.idempotencyKey).toBe('mix-1');
|
|
2338
|
+
|
|
2339
|
+
const job3 = await queue.getJob(pool, ids[3]);
|
|
2340
|
+
expect(job3?.idempotencyKey).toBe('mix-2');
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
it('records added events only for newly inserted jobs', async () => {
|
|
2344
|
+
// Setup — pre-insert a job with a known key
|
|
2345
|
+
const existingId = await queue.addJob<{ task: { n: number } }, 'task'>(
|
|
2346
|
+
pool,
|
|
2347
|
+
{ jobType: 'task', payload: { n: 0 }, idempotencyKey: 'evt-key' },
|
|
2348
|
+
);
|
|
2349
|
+
|
|
2350
|
+
// Act
|
|
2351
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2352
|
+
{ jobType: 'task', payload: { n: 1 } },
|
|
2353
|
+
{ jobType: 'task', payload: { n: 2 }, idempotencyKey: 'evt-key' },
|
|
2354
|
+
]);
|
|
2355
|
+
|
|
2356
|
+
// Assert — the new job should have an event from addJobs
|
|
2357
|
+
const events0 = await queue.getJobEvents(pool, ids[0]);
|
|
2358
|
+
const addedEvents0 = events0.filter(
|
|
2359
|
+
(e: JobEvent) => e.eventType === JobEventType.Added,
|
|
2360
|
+
);
|
|
2361
|
+
expect(addedEvents0).toHaveLength(1);
|
|
2362
|
+
|
|
2363
|
+
// The duplicate should only have the original event from addJob, not a second from addJobs
|
|
2364
|
+
const eventsExisting = await queue.getJobEvents(pool, existingId);
|
|
2365
|
+
const addedEventsExisting = eventsExisting.filter(
|
|
2366
|
+
(e: JobEvent) => e.eventType === JobEventType.Added,
|
|
2367
|
+
);
|
|
2368
|
+
expect(addedEventsExisting).toHaveLength(1);
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
it('stores tags correctly per job', async () => {
|
|
2372
|
+
// Act
|
|
2373
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(pool, [
|
|
2374
|
+
{ jobType: 'task', payload: { n: 1 }, tags: ['urgent', 'billing'] },
|
|
2375
|
+
{ jobType: 'task', payload: { n: 2 }, tags: ['low-priority'] },
|
|
2376
|
+
{ jobType: 'task', payload: { n: 3 } },
|
|
2377
|
+
]);
|
|
2378
|
+
|
|
2379
|
+
// Assert
|
|
2380
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2381
|
+
expect(job0?.tags).toEqual(['urgent', 'billing']);
|
|
2382
|
+
|
|
2383
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2384
|
+
expect(job1?.tags).toEqual(['low-priority']);
|
|
2385
|
+
|
|
2386
|
+
const job2 = await queue.getJob(pool, ids[2]);
|
|
2387
|
+
expect(job2?.tags).toBeNull();
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
it('works with transactional db option — commit', async () => {
|
|
2391
|
+
// Setup
|
|
2392
|
+
const client = await pool.connect();
|
|
2393
|
+
await client.query('BEGIN');
|
|
2394
|
+
|
|
2395
|
+
// Act
|
|
2396
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(
|
|
2397
|
+
pool,
|
|
2398
|
+
[
|
|
2399
|
+
{ jobType: 'task', payload: { n: 1 } },
|
|
2400
|
+
{ jobType: 'task', payload: { n: 2 } },
|
|
2401
|
+
],
|
|
2402
|
+
{ db: client },
|
|
2403
|
+
);
|
|
2404
|
+
await client.query('COMMIT');
|
|
2405
|
+
client.release();
|
|
2406
|
+
|
|
2407
|
+
// Assert
|
|
2408
|
+
expect(ids).toHaveLength(2);
|
|
2409
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2410
|
+
expect(job0).not.toBeNull();
|
|
2411
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2412
|
+
expect(job1).not.toBeNull();
|
|
2413
|
+
});
|
|
2414
|
+
|
|
2415
|
+
it('works with transactional db option — rollback', async () => {
|
|
2416
|
+
// Setup
|
|
2417
|
+
const client = await pool.connect();
|
|
2418
|
+
await client.query('BEGIN');
|
|
2419
|
+
|
|
2420
|
+
// Act
|
|
2421
|
+
const ids = await queue.addJobs<{ task: { n: number } }, 'task'>(
|
|
2422
|
+
pool,
|
|
2423
|
+
[
|
|
2424
|
+
{ jobType: 'task', payload: { n: 1 } },
|
|
2425
|
+
{ jobType: 'task', payload: { n: 2 } },
|
|
2426
|
+
],
|
|
2427
|
+
{ db: client },
|
|
2428
|
+
);
|
|
2429
|
+
await client.query('ROLLBACK');
|
|
2430
|
+
client.release();
|
|
2431
|
+
|
|
2432
|
+
// Assert — jobs should not exist after rollback
|
|
2433
|
+
const job0 = await queue.getJob(pool, ids[0]);
|
|
2434
|
+
expect(job0).toBeNull();
|
|
2435
|
+
const job1 = await queue.getJob(pool, ids[1]);
|
|
2436
|
+
expect(job1).toBeNull();
|
|
2437
|
+
});
|
|
1961
2438
|
});
|
package/src/queue.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
JobEventType,
|
|
17
17
|
TagQueryMode,
|
|
18
18
|
WaitpointRecord,
|
|
19
|
+
AddJobOptions,
|
|
19
20
|
} from './types.js';
|
|
20
21
|
import { PostgresBackend } from './backends/postgres.js';
|
|
21
22
|
|
|
@@ -34,7 +35,14 @@ export const recordJobEvent = async (
|
|
|
34
35
|
export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
35
36
|
pool: Pool,
|
|
36
37
|
job: JobOptions<PayloadMap, T>,
|
|
37
|
-
|
|
38
|
+
options?: AddJobOptions,
|
|
39
|
+
): Promise<number> => new PostgresBackend(pool).addJob(job, options);
|
|
40
|
+
|
|
41
|
+
export const addJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
42
|
+
pool: Pool,
|
|
43
|
+
jobs: JobOptions<PayloadMap, T>[],
|
|
44
|
+
options?: AddJobOptions,
|
|
45
|
+
): Promise<number[]> => new PostgresBackend(pool).addJobs(jobs, options);
|
|
38
46
|
|
|
39
47
|
export const getJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
40
48
|
pool: Pool,
|
|
@@ -72,8 +80,11 @@ export const getNextBatch = async <
|
|
|
72
80
|
jobType,
|
|
73
81
|
);
|
|
74
82
|
|
|
75
|
-
export const completeJob = async (
|
|
76
|
-
|
|
83
|
+
export const completeJob = async (
|
|
84
|
+
pool: Pool,
|
|
85
|
+
jobId: number,
|
|
86
|
+
output?: unknown,
|
|
87
|
+
): Promise<void> => new PostgresBackend(pool).completeJob(jobId, output);
|
|
77
88
|
|
|
78
89
|
export const prolongJob = async (pool: Pool, jobId: number): Promise<void> =>
|
|
79
90
|
new PostgresBackend(pool).prolongJob(jobId);
|
|
@@ -109,6 +120,9 @@ export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
|
109
120
|
runAt?: Date | null;
|
|
110
121
|
timeoutMs?: number | null;
|
|
111
122
|
tags?: string[] | null;
|
|
123
|
+
retryDelay?: number | null;
|
|
124
|
+
retryBackoff?: boolean | null;
|
|
125
|
+
retryDelayMax?: number | null;
|
|
112
126
|
},
|
|
113
127
|
): Promise<void> => new PostgresBackend(pool).editJob(jobId, updates);
|
|
114
128
|
|
|
@@ -134,6 +148,9 @@ export const editAllPendingJobs = async <
|
|
|
134
148
|
runAt?: Date | null;
|
|
135
149
|
timeoutMs?: number;
|
|
136
150
|
tags?: string[];
|
|
151
|
+
retryDelay?: number | null;
|
|
152
|
+
retryBackoff?: boolean | null;
|
|
153
|
+
retryDelayMax?: number | null;
|
|
137
154
|
},
|
|
138
155
|
): Promise<number> =>
|
|
139
156
|
new PostgresBackend(pool).editAllPendingJobs(filters, updates);
|