@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224110011

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/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,547 @@ 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
+ });
2438
+ });
2439
+
2440
+ describe('group-based concurrency limits (Postgres)', () => {
2441
+ let pool: Pool;
2442
+ let dbName: string;
2443
+
2444
+ beforeEach(async () => {
2445
+ const setup = await createTestDbAndPool();
2446
+ pool = setup.pool;
2447
+ dbName = setup.dbName;
2448
+ });
2449
+
2450
+ afterEach(async () => {
2451
+ await pool.end();
2452
+ await destroyTestDb(dbName);
2453
+ });
2454
+
2455
+ it('stores and returns group metadata on jobs', async () => {
2456
+ const id = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2457
+ jobType: 'email',
2458
+ payload: { to: 'grouped@example.com' },
2459
+ group: { id: 'tenant-a', tier: 'gold' },
2460
+ });
2461
+
2462
+ const job = await queue.getJob(pool, id);
2463
+ expect(job?.groupId).toBe('tenant-a');
2464
+ expect(job?.groupTier).toBe('gold');
2465
+ });
2466
+
2467
+ it('enforces global per-group limits across workers', async () => {
2468
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2469
+ jobType: 'email',
2470
+ payload: { to: 'a-1@example.com' },
2471
+ group: { id: 'tenant-a' },
2472
+ });
2473
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2474
+ jobType: 'email',
2475
+ payload: { to: 'a-2@example.com' },
2476
+ group: { id: 'tenant-a' },
2477
+ });
2478
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2479
+ jobType: 'email',
2480
+ payload: { to: 'b-1@example.com' },
2481
+ group: { id: 'tenant-b' },
2482
+ });
2483
+
2484
+ const firstBatch = await queue.getNextBatch(
2485
+ pool,
2486
+ 'worker-1',
2487
+ 10,
2488
+ undefined,
2489
+ 1,
2490
+ );
2491
+ expect(firstBatch).toHaveLength(2);
2492
+ expect(new Set(firstBatch.map((job) => job.groupId))).toEqual(
2493
+ new Set(['tenant-a', 'tenant-b']),
2494
+ );
2495
+
2496
+ const blockedBatch = await queue.getNextBatch(
2497
+ pool,
2498
+ 'worker-2',
2499
+ 10,
2500
+ undefined,
2501
+ 1,
2502
+ );
2503
+ expect(blockedBatch).toHaveLength(0);
2504
+
2505
+ const groupAJob = firstBatch.find((job) => job.groupId === 'tenant-a');
2506
+ expect(groupAJob).toBeDefined();
2507
+ await queue.completeJob(pool, groupAJob!.id);
2508
+
2509
+ const resumedBatch = await queue.getNextBatch(
2510
+ pool,
2511
+ 'worker-3',
2512
+ 10,
2513
+ undefined,
2514
+ 1,
2515
+ );
2516
+ expect(resumedBatch).toHaveLength(1);
2517
+ expect(resumedBatch[0]?.groupId).toBe('tenant-a');
2518
+ });
2519
+
2520
+ it('keeps ungrouped jobs unaffected by groupConcurrency', async () => {
2521
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2522
+ jobType: 'email',
2523
+ payload: { to: 'ungrouped-1@example.com' },
2524
+ });
2525
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2526
+ jobType: 'email',
2527
+ payload: { to: 'ungrouped-2@example.com' },
2528
+ });
2529
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2530
+ jobType: 'email',
2531
+ payload: { to: 'grouped-1@example.com' },
2532
+ group: { id: 'tenant-c' },
2533
+ });
2534
+ await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
2535
+ jobType: 'email',
2536
+ payload: { to: 'grouped-2@example.com' },
2537
+ group: { id: 'tenant-c' },
2538
+ });
2539
+
2540
+ const batch = await queue.getNextBatch(pool, 'worker-u', 10, undefined, 1);
2541
+ expect(batch).toHaveLength(3);
2542
+ expect(batch.filter((job) => job.groupId === 'tenant-c')).toHaveLength(1);
2543
+ expect(batch.filter((job) => !job.groupId)).toHaveLength(2);
2544
+ });
1961
2545
  });
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
- ): Promise<number> => new PostgresBackend(pool).addJob(job);
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,
@@ -65,15 +73,20 @@ export const getNextBatch = async <
65
73
  workerId: string,
66
74
  batchSize = 10,
67
75
  jobType?: string | string[],
76
+ groupConcurrency?: number,
68
77
  ): Promise<JobRecord<PayloadMap, T>[]> =>
69
78
  new PostgresBackend(pool).getNextBatch<PayloadMap, T>(
70
79
  workerId,
71
80
  batchSize,
72
81
  jobType,
82
+ groupConcurrency,
73
83
  );
74
84
 
75
- export const completeJob = async (pool: Pool, jobId: number): Promise<void> =>
76
- new PostgresBackend(pool).completeJob(jobId);
85
+ export const completeJob = async (
86
+ pool: Pool,
87
+ jobId: number,
88
+ output?: unknown,
89
+ ): Promise<void> => new PostgresBackend(pool).completeJob(jobId, output);
77
90
 
78
91
  export const prolongJob = async (pool: Pool, jobId: number): Promise<void> =>
79
92
  new PostgresBackend(pool).prolongJob(jobId);
@@ -109,6 +122,9 @@ export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
109
122
  runAt?: Date | null;
110
123
  timeoutMs?: number | null;
111
124
  tags?: string[] | null;
125
+ retryDelay?: number | null;
126
+ retryBackoff?: boolean | null;
127
+ retryDelayMax?: number | null;
112
128
  },
113
129
  ): Promise<void> => new PostgresBackend(pool).editJob(jobId, updates);
114
130
 
@@ -134,6 +150,9 @@ export const editAllPendingJobs = async <
134
150
  runAt?: Date | null;
135
151
  timeoutMs?: number;
136
152
  tags?: string[];
153
+ retryDelay?: number | null;
154
+ retryBackoff?: boolean | null;
155
+ retryDelayMax?: number | null;
137
156
  },
138
157
  ): Promise<number> =>
139
158
  new PostgresBackend(pool).editAllPendingJobs(filters, updates);