bunqueue 2.8.34 → 2.8.35

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 (32) hide show
  1. package/dist/application/backgroundTasks.js +9 -2
  2. package/dist/application/queueManager.d.ts +1 -1
  3. package/dist/application/queueManager.js +13 -4
  4. package/dist/client/queue/dlq.d.ts +23 -1
  5. package/dist/client/queue/dlq.js +75 -0
  6. package/dist/client/queue/jobProxy.d.ts +1 -1
  7. package/dist/client/queue/operations/control.d.ts +17 -0
  8. package/dist/client/queue/operations/control.js +41 -0
  9. package/dist/client/queue/queue.d.ts +13 -0
  10. package/dist/client/queue/queue.js +39 -0
  11. package/dist/client/queue/rateLimit.d.ts +18 -3
  12. package/dist/client/queue/rateLimit.js +45 -10
  13. package/dist/client/queue/stall.d.ts +2 -0
  14. package/dist/client/queue/stall.js +12 -0
  15. package/dist/domain/queue/limiterManager.d.ts +14 -2
  16. package/dist/domain/queue/limiterManager.js +32 -5
  17. package/dist/domain/queue/shard.d.ts +2 -1
  18. package/dist/domain/queue/shard.js +5 -2
  19. package/dist/domain/types/command.d.ts +4 -0
  20. package/dist/domain/types/queue.d.ts +4 -0
  21. package/dist/domain/types/queue.js +2 -0
  22. package/dist/infrastructure/persistence/schema.d.ts +2 -2
  23. package/dist/infrastructure/persistence/schema.js +15 -2
  24. package/dist/infrastructure/persistence/sqlite.d.ts +9 -1
  25. package/dist/infrastructure/persistence/sqlite.js +4 -2
  26. package/dist/infrastructure/persistence/statements.d.ts +2 -0
  27. package/dist/infrastructure/persistence/statements.js +2 -2
  28. package/dist/infrastructure/scheduler/cronScheduler.d.ts +5 -0
  29. package/dist/infrastructure/scheduler/cronScheduler.js +39 -13
  30. package/dist/infrastructure/server/handlers/advanced.js +11 -2
  31. package/dist/infrastructure/server/httpRouteQueueConfig.js +2 -0
  32. package/package.json +1 -1
@@ -342,8 +342,15 @@ export function recover(ctx) {
342
342
  const shard = ctx.shards[shardIndex(qs.name)];
343
343
  if (qs.paused)
344
344
  shard.pause(qs.name);
345
- if (qs.rateLimit !== null)
346
- shard.setRateLimit(qs.name, qs.rateLimit);
345
+ if (qs.rateLimit !== null) {
346
+ // TTL'd limits are temporary by definition: an already-expired one must
347
+ // not resurrect as permanent. A still-live one is restored with its
348
+ // remaining TTL so it keeps expiring broker-side.
349
+ const remainingTtl = qs.rateLimitExpiresAt === null ? undefined : qs.rateLimitExpiresAt - Date.now();
350
+ if (remainingTtl === undefined || remainingTtl > 0) {
351
+ shard.setRateLimit(qs.name, qs.rateLimit, qs.rateLimitDuration ?? undefined, remainingTtl);
352
+ }
353
+ }
347
354
  if (qs.concurrencyLimit !== null)
348
355
  shard.setConcurrency(qs.name, qs.concurrencyLimit);
349
356
  ctx.registerQueueName(qs.name);
@@ -196,7 +196,7 @@ export declare class QueueManager {
196
196
  retryDlq(queue: string, jobId?: JobId, limit?: number): number;
197
197
  purgeDlq(queue: string): number;
198
198
  retryCompleted(queue: string, jobId?: JobId): number;
199
- setRateLimit(queue: string, limit: number): void;
199
+ setRateLimit(queue: string, limit: number, durationMs?: number, ttlMs?: number): void;
200
200
  clearRateLimit(queue: string): void;
201
201
  setConcurrency(queue: string, limit: number): void;
202
202
  clearConcurrency(queue: string): void;
@@ -860,8 +860,8 @@ export class QueueManager {
860
860
  return dlqOps.retryCompletedJobs(queue, this.contextFactory.getRetryCompletedContext(), jobId);
861
861
  }
862
862
  // ============ Rate Limiting ============
863
- setRateLimit(queue, limit) {
864
- this.shards[shardIndex(queue)].setRateLimit(queue, limit);
863
+ setRateLimit(queue, limit, durationMs, ttlMs) {
864
+ this.shards[shardIndex(queue)].setRateLimit(queue, limit, durationMs, ttlMs);
865
865
  this.persistQueueState(queue);
866
866
  }
867
867
  clearRateLimit(queue) {
@@ -893,11 +893,20 @@ export class QueueManager {
893
893
  this.storage.deleteQueueState(queue);
894
894
  return;
895
895
  }
896
- this.storage.saveQueueState(queue, state.paused, state.rateLimit, state.concurrencyLimit);
896
+ this.storage.saveQueueState(queue, {
897
+ paused: state.paused,
898
+ rateLimit: state.rateLimit,
899
+ concurrencyLimit: state.concurrencyLimit,
900
+ rateLimitDuration: state.rateLimitDuration,
901
+ rateLimitExpiresAt: state.rateLimitExpiresAt,
902
+ });
897
903
  }
898
904
  /** Get rate limit and concurrency limit for a queue */
899
905
  getQueueLimits(queue) {
900
- const state = this.shards[shardIndex(queue)].getState(queue);
906
+ const shard = this.shards[shardIndex(queue)];
907
+ // Lazy TTL expiry so reads never report a limit that no longer throttles.
908
+ shard.expireRateLimitIfNeeded(queue);
909
+ const state = shard.getState(queue);
901
910
  return { rateLimit: state.rateLimit, concurrencyLimit: state.concurrencyLimit };
902
911
  }
903
912
  /** Get all job results (for cloud telemetry) */
@@ -3,7 +3,8 @@
3
3
  * Wraps dlqOps for Queue class usage
4
4
  */
5
5
  import type { TcpConnectionPool } from '../tcpPool';
6
- import type { DlqConfig, DlqEntry, DlqStats, DlqFilter } from '../types';
6
+ import type { Job, DlqConfig, DlqEntry, DlqStats, DlqFilter } from '../types';
7
+ import { type SimpleJobContext } from './jobProxy';
7
8
  interface DlqContext {
8
9
  name: string;
9
10
  embedded: boolean;
@@ -11,20 +12,41 @@ interface DlqContext {
11
12
  }
12
13
  /** Set DLQ configuration */
13
14
  export declare function setDlqConfig(ctx: DlqContext, config: Partial<DlqConfig>): void;
15
+ /** Set DLQ configuration and resolve once the server has applied it. */
16
+ export declare function setDlqConfigAsync(ctx: DlqContext, config: Partial<DlqConfig>): Promise<void>;
14
17
  /** Get DLQ configuration */
15
18
  export declare function getDlqConfig(ctx: DlqContext): DlqConfig;
16
19
  /** Get DLQ configuration (async, works in TCP mode) */
17
20
  export declare function getDlqConfigAsync(ctx: DlqContext): Promise<DlqConfig>;
18
21
  /** Get DLQ entries */
19
22
  export declare function getDlq<T>(ctx: DlqContext, filter?: DlqFilter): DlqEntry<T>[];
23
+ type DlqQueryContext = DlqContext & Pick<SimpleJobContext, 'getJobState' | 'removeAsync' | 'retryJob' | 'getChildrenValues'>;
24
+ /**
25
+ * Get the dead jobs in the DLQ, working over TCP too (wire command `Dlq`).
26
+ * Returns public Job objects; the wire does not carry DlqEntry metadata
27
+ * (reason, enteredAt, attempts) — that stays embedded-only via getDlq().
28
+ */
29
+ export declare function getDlqJobsAsync<T>(ctx: DlqQueryContext, count?: number): Promise<Job<T>[]>;
20
30
  /** Get DLQ stats */
21
31
  export declare function getDlqStats(ctx: DlqContext): DlqStats;
22
32
  /** Retry DLQ entries */
23
33
  export declare function retryDlq(ctx: DlqContext, id?: string): number;
34
+ /**
35
+ * Retry DLQ entries and resolve with the retried count once the server has
36
+ * processed it. The fire-and-forget retryDlq() always returns 0 over TCP and
37
+ * discards the server's count.
38
+ */
39
+ export declare function retryDlqAsync(ctx: DlqContext, id?: string): Promise<number>;
24
40
  /** Retry DLQ entries by filter */
25
41
  export declare function retryDlqByFilter(ctx: DlqContext, filter: DlqFilter): number;
26
42
  /** Purge DLQ */
27
43
  export declare function purgeDlq(ctx: DlqContext): number;
44
+ /**
45
+ * Purge the DLQ and resolve with the purged count once the server has
46
+ * processed it. The fire-and-forget purgeDlq() always returns 0 over TCP;
47
+ * purge-then-assert-empty patterns need this variant.
48
+ */
49
+ export declare function purgeDlqAsync(ctx: DlqContext): Promise<number>;
28
50
  /** Retry completed job */
29
51
  export declare function retryCompleted(ctx: DlqContext, id?: string): number;
30
52
  /** Retry completed job (async) */
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { getSharedManager } from '../manager';
6
6
  import { jobId } from '../../domain/types/job';
7
+ import { createSimpleJob } from './jobProxy';
7
8
  import * as dlqOps from './dlqOps';
8
9
  /** Client-side cache for TCP mode */
9
10
  const tcpDlqConfigCache = new Map();
@@ -18,6 +19,18 @@ export function setDlqConfig(ctx, config) {
18
19
  void ctx.tcp.send({ cmd: 'SetDlqConfig', queue: ctx.name, config });
19
20
  }
20
21
  }
22
+ /** Set DLQ configuration and resolve once the server has applied it. */
23
+ export async function setDlqConfigAsync(ctx, config) {
24
+ if (ctx.embedded) {
25
+ dlqOps.setDlqConfig(ctx.name, config);
26
+ return;
27
+ }
28
+ if (!ctx.tcp)
29
+ return;
30
+ const current = tcpDlqConfigCache.get(ctx.name) ?? {};
31
+ tcpDlqConfigCache.set(ctx.name, { ...current, ...config });
32
+ await ctx.tcp.send({ cmd: 'SetDlqConfig', queue: ctx.name, config });
33
+ }
21
34
  /** Get DLQ configuration */
22
35
  export function getDlqConfig(ctx) {
23
36
  if (ctx.embedded)
@@ -45,6 +58,38 @@ export function getDlq(ctx, filter) {
45
58
  return [];
46
59
  return dlqOps.getDlqEntries(ctx.name, filter);
47
60
  }
61
+ /**
62
+ * Get the dead jobs in the DLQ, working over TCP too (wire command `Dlq`).
63
+ * Returns public Job objects; the wire does not carry DlqEntry metadata
64
+ * (reason, enteredAt, attempts) — that stays embedded-only via getDlq().
65
+ */
66
+ export async function getDlqJobsAsync(ctx, count) {
67
+ let raw;
68
+ if (ctx.embedded) {
69
+ raw = getSharedManager().getDlq(ctx.name, count);
70
+ }
71
+ else if (ctx.tcp) {
72
+ const response = await ctx.tcp.send({ cmd: 'Dlq', queue: ctx.name, count });
73
+ if (!response.ok)
74
+ return [];
75
+ raw = (response.jobs ?? []);
76
+ }
77
+ else {
78
+ return [];
79
+ }
80
+ return raw.map((job) => {
81
+ const name = job.data?.name ?? 'unknown';
82
+ return createSimpleJob(String(job.id), name, job.data, job.createdAt ?? Date.now(), {
83
+ queueName: ctx.name,
84
+ embedded: ctx.embedded,
85
+ tcp: ctx.tcp,
86
+ getJobState: ctx.getJobState,
87
+ removeAsync: ctx.removeAsync,
88
+ retryJob: ctx.retryJob,
89
+ getChildrenValues: ctx.getChildrenValues,
90
+ });
91
+ });
92
+ }
48
93
  /** Get DLQ stats */
49
94
  export function getDlqStats(ctx) {
50
95
  if (!ctx.embedded) {
@@ -67,6 +112,21 @@ export function retryDlq(ctx, id) {
67
112
  void ctx.tcp.send({ cmd: 'RetryDlq', queue: ctx.name, jobId: id });
68
113
  return 0;
69
114
  }
115
+ /**
116
+ * Retry DLQ entries and resolve with the retried count once the server has
117
+ * processed it. The fire-and-forget retryDlq() always returns 0 over TCP and
118
+ * discards the server's count.
119
+ */
120
+ export async function retryDlqAsync(ctx, id) {
121
+ if (ctx.embedded)
122
+ return dlqOps.retryDlqEmbedded(ctx.name, id);
123
+ if (!ctx.tcp)
124
+ return 0;
125
+ const response = await ctx.tcp.send({ cmd: 'RetryDlq', queue: ctx.name, jobId: id });
126
+ if (!response.ok)
127
+ return 0;
128
+ return (response.count ?? 0);
129
+ }
70
130
  /** Retry DLQ entries by filter */
71
131
  export function retryDlqByFilter(ctx, filter) {
72
132
  if (!ctx.embedded)
@@ -81,6 +141,21 @@ export function purgeDlq(ctx) {
81
141
  void ctx.tcp.send({ cmd: 'PurgeDlq', queue: ctx.name });
82
142
  return 0;
83
143
  }
144
+ /**
145
+ * Purge the DLQ and resolve with the purged count once the server has
146
+ * processed it. The fire-and-forget purgeDlq() always returns 0 over TCP;
147
+ * purge-then-assert-empty patterns need this variant.
148
+ */
149
+ export async function purgeDlqAsync(ctx) {
150
+ if (ctx.embedded)
151
+ return dlqOps.purgeDlqEmbedded(ctx.name);
152
+ if (!ctx.tcp)
153
+ return 0;
154
+ const response = await ctx.tcp.send({ cmd: 'PurgeDlq', queue: ctx.name });
155
+ if (!response.ok)
156
+ return 0;
157
+ return (response.count ?? 0);
158
+ }
84
159
  /** Retry completed job */
85
160
  export function retryCompleted(ctx, id) {
86
161
  if (ctx.embedded) {
@@ -29,7 +29,7 @@ export interface JobReflectionMeta {
29
29
  }
30
30
  /** Create a full Job proxy with TCP methods */
31
31
  export declare function createJobProxy<T>(id: string, name: string, data: T, ctx: JobProxyContext, meta?: JobReflectionMeta): Job<T>;
32
- interface SimpleJobContext {
32
+ export interface SimpleJobContext {
33
33
  queueName: string;
34
34
  /** Execution mode — determines whether to use embedded manager or TCP */
35
35
  embedded?: boolean;
@@ -10,12 +10,29 @@ interface ControlContext {
10
10
  }
11
11
  /** Pause the queue */
12
12
  export declare function pause(ctx: ControlContext): void;
13
+ /** Pause the queue and resolve once the server has processed it. */
14
+ export declare function pauseAsync(ctx: ControlContext): Promise<void>;
13
15
  /** Resume the queue */
14
16
  export declare function resume(ctx: ControlContext): void;
17
+ /** Resume the queue and resolve once the server has processed it. */
18
+ export declare function resumeAsync(ctx: ControlContext): Promise<void>;
15
19
  /** Drain the queue (remove all waiting jobs) */
16
20
  export declare function drain(ctx: ControlContext): void;
21
+ /**
22
+ * Drain the queue and resolve with the number of removed jobs once the
23
+ * server has processed it. Same ordering guarantee as obliterateAsync():
24
+ * jobs added after resolution cannot be caught by a late-arriving drain.
25
+ */
26
+ export declare function drainAsync(ctx: ControlContext): Promise<number>;
17
27
  /** Obliterate the queue (remove all jobs and data) */
18
28
  export declare function obliterate(ctx: ControlContext): void;
29
+ /**
30
+ * Obliterate the queue and resolve once the server has processed it.
31
+ * The fire-and-forget obliterate() gives no ordering guarantee over a
32
+ * multi-connection pool: a PUSH sent right after it can be processed first
33
+ * and then wiped. Await this variant before enqueuing follow-up jobs.
34
+ */
35
+ export declare function obliterateAsync(ctx: ControlContext): Promise<void>;
19
36
  /** Check if queue is paused (sync, embedded only) */
20
37
  export declare function isPaused(ctx: ControlContext): boolean;
21
38
  /** Check if queue is paused (async, works with TCP) */
@@ -10,6 +10,13 @@ export function pause(ctx) {
10
10
  else if (ctx.tcp)
11
11
  void ctx.tcp.send({ cmd: 'Pause', queue: ctx.name });
12
12
  }
13
+ /** Pause the queue and resolve once the server has processed it. */
14
+ export async function pauseAsync(ctx) {
15
+ if (ctx.embedded)
16
+ getSharedManager().pause(ctx.name);
17
+ else if (ctx.tcp)
18
+ await ctx.tcp.send({ cmd: 'Pause', queue: ctx.name });
19
+ }
13
20
  /** Resume the queue */
14
21
  export function resume(ctx) {
15
22
  if (ctx.embedded)
@@ -17,6 +24,13 @@ export function resume(ctx) {
17
24
  else if (ctx.tcp)
18
25
  void ctx.tcp.send({ cmd: 'Resume', queue: ctx.name });
19
26
  }
27
+ /** Resume the queue and resolve once the server has processed it. */
28
+ export async function resumeAsync(ctx) {
29
+ if (ctx.embedded)
30
+ getSharedManager().resume(ctx.name);
31
+ else if (ctx.tcp)
32
+ await ctx.tcp.send({ cmd: 'Resume', queue: ctx.name });
33
+ }
20
34
  /** Drain the queue (remove all waiting jobs) */
21
35
  export function drain(ctx) {
22
36
  if (ctx.embedded)
@@ -24,6 +38,21 @@ export function drain(ctx) {
24
38
  else if (ctx.tcp)
25
39
  void ctx.tcp.send({ cmd: 'Drain', queue: ctx.name });
26
40
  }
41
+ /**
42
+ * Drain the queue and resolve with the number of removed jobs once the
43
+ * server has processed it. Same ordering guarantee as obliterateAsync():
44
+ * jobs added after resolution cannot be caught by a late-arriving drain.
45
+ */
46
+ export async function drainAsync(ctx) {
47
+ if (ctx.embedded)
48
+ return getSharedManager().drain(ctx.name);
49
+ if (!ctx.tcp)
50
+ return 0;
51
+ const response = await ctx.tcp.send({ cmd: 'Drain', queue: ctx.name });
52
+ if (!response.ok)
53
+ return 0;
54
+ return (response.count ?? 0);
55
+ }
27
56
  /** Obliterate the queue (remove all jobs and data) */
28
57
  export function obliterate(ctx) {
29
58
  if (ctx.embedded)
@@ -31,6 +60,18 @@ export function obliterate(ctx) {
31
60
  else if (ctx.tcp)
32
61
  void ctx.tcp.send({ cmd: 'Obliterate', queue: ctx.name });
33
62
  }
63
+ /**
64
+ * Obliterate the queue and resolve once the server has processed it.
65
+ * The fire-and-forget obliterate() gives no ordering guarantee over a
66
+ * multi-connection pool: a PUSH sent right after it can be processed first
67
+ * and then wiped. Await this variant before enqueuing follow-up jobs.
68
+ */
69
+ export async function obliterateAsync(ctx) {
70
+ if (ctx.embedded)
71
+ getSharedManager().obliterate(ctx.name);
72
+ else if (ctx.tcp)
73
+ await ctx.tcp.send({ cmd: 'Obliterate', queue: ctx.name });
74
+ }
34
75
  /** Check if queue is paused (sync, embedded only) */
35
76
  export function isPaused(ctx) {
36
77
  if (!ctx.embedded)
@@ -74,9 +74,13 @@ export declare class Queue<T = unknown> {
74
74
  getCountsPerPriority(): Record<number, number>;
75
75
  getCountsPerPriorityAsync(): Promise<Record<number, number>>;
76
76
  pause(): void;
77
+ pauseAsync(): Promise<void>;
77
78
  resume(): void;
79
+ resumeAsync(): Promise<void>;
78
80
  drain(): void;
81
+ drainAsync(): Promise<number>;
79
82
  obliterate(): void;
83
+ obliterateAsync(): Promise<void>;
80
84
  isPaused(): boolean;
81
85
  isPausedAsync(): Promise<boolean>;
82
86
  waitUntilReady(): Promise<void>;
@@ -106,23 +110,32 @@ export declare class Queue<T = unknown> {
106
110
  changeJobPriority(id: string, opts: ChangePriorityOpts): Promise<void>;
107
111
  extendJobLock(id: string, token: string, duration: number): Promise<number>;
108
112
  setStallConfig(config: Partial<StallConfig>): void;
113
+ setStallConfigAsync(config: Partial<StallConfig>): Promise<void>;
109
114
  getStallConfig(): StallConfig;
110
115
  getStallConfigAsync(): Promise<StallConfig>;
111
116
  setDlqConfig(config: Partial<DlqConfig>): void;
117
+ setDlqConfigAsync(config: Partial<DlqConfig>): Promise<void>;
112
118
  getDlqConfig(): DlqConfig;
113
119
  getDlqConfigAsync(): Promise<DlqConfig>;
114
120
  getDlq(filter?: DlqFilter): DlqEntry<T>[];
121
+ getDlqJobsAsync(count?: number): Promise<Job<T>[]>;
115
122
  getDlqStats(): DlqStats;
116
123
  retryDlq(id?: string): number;
124
+ retryDlqAsync(id?: string): Promise<number>;
117
125
  retryDlqByFilter(filter: DlqFilter): number;
118
126
  purgeDlq(): number;
127
+ purgeDlqAsync(): Promise<number>;
119
128
  retryCompleted(id?: string): number;
120
129
  retryCompletedAsync(id?: string): Promise<number>;
121
130
  setGlobalConcurrency(concurrency: number): void;
131
+ setGlobalConcurrencyAsync(concurrency: number): Promise<void>;
122
132
  removeGlobalConcurrency(): void;
133
+ removeGlobalConcurrencyAsync(): Promise<void>;
123
134
  getGlobalConcurrency(): Promise<number | null>;
124
135
  setGlobalRateLimit(max: number, duration?: number): void;
136
+ setGlobalRateLimitAsync(max: number, duration?: number): Promise<void>;
125
137
  removeGlobalRateLimit(): void;
138
+ removeGlobalRateLimitAsync(): Promise<void>;
126
139
  getGlobalRateLimit(): Promise<{
127
140
  max: number;
128
141
  duration: number;
@@ -262,15 +262,27 @@ export class Queue {
262
262
  pause() {
263
263
  controlOps.pause(this.ctx);
264
264
  }
265
+ pauseAsync() {
266
+ return controlOps.pauseAsync(this.ctx);
267
+ }
265
268
  resume() {
266
269
  controlOps.resume(this.ctx);
267
270
  }
271
+ resumeAsync() {
272
+ return controlOps.resumeAsync(this.ctx);
273
+ }
268
274
  drain() {
269
275
  controlOps.drain(this.ctx);
270
276
  }
277
+ drainAsync() {
278
+ return controlOps.drainAsync(this.ctx);
279
+ }
271
280
  obliterate() {
272
281
  controlOps.obliterate(this.ctx);
273
282
  }
283
+ obliterateAsync() {
284
+ return controlOps.obliterateAsync(this.ctx);
285
+ }
274
286
  isPaused() {
275
287
  return controlOps.isPaused(this.ctx);
276
288
  }
@@ -333,6 +345,9 @@ export class Queue {
333
345
  setStallConfig(config) {
334
346
  stallOps.setStallConfig(this.ctx, config);
335
347
  }
348
+ setStallConfigAsync(config) {
349
+ return stallOps.setStallConfigAsync(this.ctx, config);
350
+ }
336
351
  getStallConfig() {
337
352
  return stallOps.getStallConfig(this.ctx);
338
353
  }
@@ -343,6 +358,9 @@ export class Queue {
343
358
  setDlqConfig(config) {
344
359
  dlqOps.setDlqConfig(this.ctx, config);
345
360
  }
361
+ setDlqConfigAsync(config) {
362
+ return dlqOps.setDlqConfigAsync(this.ctx, config);
363
+ }
346
364
  getDlqConfig() {
347
365
  return dlqOps.getDlqConfig(this.ctx);
348
366
  }
@@ -352,18 +370,27 @@ export class Queue {
352
370
  getDlq(filter) {
353
371
  return dlqOps.getDlq(this.ctx, filter);
354
372
  }
373
+ getDlqJobsAsync(count) {
374
+ return dlqOps.getDlqJobsAsync(this.queryCtx, count);
375
+ }
355
376
  getDlqStats() {
356
377
  return dlqOps.getDlqStats(this.ctx);
357
378
  }
358
379
  retryDlq(id) {
359
380
  return dlqOps.retryDlq(this.ctx, id);
360
381
  }
382
+ retryDlqAsync(id) {
383
+ return dlqOps.retryDlqAsync(this.ctx, id);
384
+ }
361
385
  retryDlqByFilter(filter) {
362
386
  return dlqOps.retryDlqByFilter(this.ctx, filter);
363
387
  }
364
388
  purgeDlq() {
365
389
  return dlqOps.purgeDlq(this.ctx);
366
390
  }
391
+ purgeDlqAsync() {
392
+ return dlqOps.purgeDlqAsync(this.ctx);
393
+ }
367
394
  retryCompleted(id) {
368
395
  return dlqOps.retryCompleted(this.ctx, id);
369
396
  }
@@ -374,18 +401,30 @@ export class Queue {
374
401
  setGlobalConcurrency(concurrency) {
375
402
  rateLimitOps.setGlobalConcurrency(this.ctx, concurrency);
376
403
  }
404
+ setGlobalConcurrencyAsync(concurrency) {
405
+ return rateLimitOps.setGlobalConcurrencyAsync(this.ctx, concurrency);
406
+ }
377
407
  removeGlobalConcurrency() {
378
408
  rateLimitOps.removeGlobalConcurrency(this.ctx);
379
409
  }
410
+ removeGlobalConcurrencyAsync() {
411
+ return rateLimitOps.removeGlobalConcurrencyAsync(this.ctx);
412
+ }
380
413
  getGlobalConcurrency() {
381
414
  return rateLimitOps.getGlobalConcurrency(this.ctx);
382
415
  }
383
416
  setGlobalRateLimit(max, duration) {
384
417
  rateLimitOps.setGlobalRateLimit(this.ctx, max, duration);
385
418
  }
419
+ setGlobalRateLimitAsync(max, duration) {
420
+ return rateLimitOps.setGlobalRateLimitAsync(this.ctx, max, duration);
421
+ }
386
422
  removeGlobalRateLimit() {
387
423
  rateLimitOps.removeGlobalRateLimit(this.ctx);
388
424
  }
425
+ removeGlobalRateLimitAsync() {
426
+ return rateLimitOps.removeGlobalRateLimitAsync(this.ctx);
427
+ }
389
428
  getGlobalRateLimit() {
390
429
  return rateLimitOps.getGlobalRateLimit(this.ctx);
391
430
  }
@@ -9,20 +9,35 @@ interface RateLimitContext {
9
9
  }
10
10
  /** Set global concurrency limit */
11
11
  export declare function setGlobalConcurrency(ctx: RateLimitContext, concurrency: number): void;
12
+ /** Set global concurrency limit and resolve once the server has applied it. */
13
+ export declare function setGlobalConcurrencyAsync(ctx: RateLimitContext, concurrency: number): Promise<void>;
12
14
  /** Remove global concurrency limit */
13
15
  export declare function removeGlobalConcurrency(ctx: RateLimitContext): void;
16
+ /** Remove global concurrency limit and resolve once the server has applied it. */
17
+ export declare function removeGlobalConcurrencyAsync(ctx: RateLimitContext): Promise<void>;
14
18
  /** Get global concurrency limit */
15
19
  export declare function getGlobalConcurrency(_ctx: RateLimitContext): Promise<number | null>;
16
- /** Set global rate limit */
17
- export declare function setGlobalRateLimit(ctx: RateLimitContext, max: number, _duration?: number): void;
20
+ /**
21
+ * Set global rate limit: `max` jobs per `duration` ms (default 1000ms).
22
+ * The window is honored in both embedded and TCP modes.
23
+ */
24
+ export declare function setGlobalRateLimit(ctx: RateLimitContext, max: number, duration?: number): void;
25
+ /** Set global rate limit and resolve once the server has applied it. */
26
+ export declare function setGlobalRateLimitAsync(ctx: RateLimitContext, max: number, duration?: number): Promise<void>;
18
27
  /** Remove global rate limit */
19
28
  export declare function removeGlobalRateLimit(ctx: RateLimitContext): void;
29
+ /** Remove global rate limit and resolve once the server has applied it. */
30
+ export declare function removeGlobalRateLimitAsync(ctx: RateLimitContext): Promise<void>;
20
31
  /** Get global rate limit */
21
32
  export declare function getGlobalRateLimit(_ctx: RateLimitContext): Promise<{
22
33
  max: number;
23
34
  duration: number;
24
35
  } | null>;
25
- /** Set temporary rate limit with expiration */
36
+ /**
37
+ * Set temporary rate limit with expiration. The expiry lives broker-side
38
+ * (RateLimit `ttl`), so it survives client exit and works identically in
39
+ * embedded and TCP modes — no client timer involved.
40
+ */
26
41
  export declare function rateLimit(ctx: RateLimitContext, expireTimeMs: number): Promise<void>;
27
42
  /** Get rate limit TTL */
28
43
  export declare function getRateLimitTtl(_ctx: RateLimitContext, _maxJobs?: number): Promise<number>;
@@ -11,6 +11,13 @@ export function setGlobalConcurrency(ctx, concurrency) {
11
11
  void ctx.tcp.send({ cmd: 'SetConcurrency', queue: ctx.name, limit: concurrency });
12
12
  }
13
13
  }
14
+ /** Set global concurrency limit and resolve once the server has applied it. */
15
+ export async function setGlobalConcurrencyAsync(ctx, concurrency) {
16
+ if (ctx.embedded)
17
+ getSharedManager().setConcurrency(ctx.name, concurrency);
18
+ else if (ctx.tcp)
19
+ await ctx.tcp.send({ cmd: 'SetConcurrency', queue: ctx.name, limit: concurrency });
20
+ }
14
21
  /** Remove global concurrency limit */
15
22
  export function removeGlobalConcurrency(ctx) {
16
23
  if (ctx.embedded) {
@@ -20,19 +27,36 @@ export function removeGlobalConcurrency(ctx) {
20
27
  void ctx.tcp.send({ cmd: 'ClearConcurrency', queue: ctx.name });
21
28
  }
22
29
  }
30
+ /** Remove global concurrency limit and resolve once the server has applied it. */
31
+ export async function removeGlobalConcurrencyAsync(ctx) {
32
+ if (ctx.embedded)
33
+ getSharedManager().clearConcurrency(ctx.name);
34
+ else if (ctx.tcp)
35
+ await ctx.tcp.send({ cmd: 'ClearConcurrency', queue: ctx.name });
36
+ }
23
37
  /** Get global concurrency limit */
24
38
  export function getGlobalConcurrency(_ctx) {
25
39
  return Promise.resolve(null);
26
40
  }
27
- /** Set global rate limit */
28
- export function setGlobalRateLimit(ctx, max, _duration) {
41
+ /**
42
+ * Set global rate limit: `max` jobs per `duration` ms (default 1000ms).
43
+ * The window is honored in both embedded and TCP modes.
44
+ */
45
+ export function setGlobalRateLimit(ctx, max, duration) {
29
46
  if (ctx.embedded) {
30
- getSharedManager().setRateLimit(ctx.name, max);
47
+ getSharedManager().setRateLimit(ctx.name, max, duration);
31
48
  }
32
49
  else if (ctx.tcp) {
33
- void ctx.tcp.send({ cmd: 'RateLimit', queue: ctx.name, limit: max });
50
+ void ctx.tcp.send({ cmd: 'RateLimit', queue: ctx.name, limit: max, duration });
34
51
  }
35
52
  }
53
+ /** Set global rate limit and resolve once the server has applied it. */
54
+ export async function setGlobalRateLimitAsync(ctx, max, duration) {
55
+ if (ctx.embedded)
56
+ getSharedManager().setRateLimit(ctx.name, max, duration);
57
+ else if (ctx.tcp)
58
+ await ctx.tcp.send({ cmd: 'RateLimit', queue: ctx.name, limit: max, duration });
59
+ }
36
60
  /** Remove global rate limit */
37
61
  export function removeGlobalRateLimit(ctx) {
38
62
  if (ctx.embedded) {
@@ -42,20 +66,31 @@ export function removeGlobalRateLimit(ctx) {
42
66
  void ctx.tcp.send({ cmd: 'RateLimitClear', queue: ctx.name });
43
67
  }
44
68
  }
69
+ /** Remove global rate limit and resolve once the server has applied it. */
70
+ export async function removeGlobalRateLimitAsync(ctx) {
71
+ if (ctx.embedded)
72
+ getSharedManager().clearRateLimit(ctx.name);
73
+ else if (ctx.tcp)
74
+ await ctx.tcp.send({ cmd: 'RateLimitClear', queue: ctx.name });
75
+ }
45
76
  /** Get global rate limit */
46
77
  export function getGlobalRateLimit(_ctx) {
47
78
  return Promise.resolve(null);
48
79
  }
49
- /** Set temporary rate limit with expiration */
80
+ /**
81
+ * Set temporary rate limit with expiration. The expiry lives broker-side
82
+ * (RateLimit `ttl`), so it survives client exit and works identically in
83
+ * embedded and TCP modes — no client timer involved.
84
+ */
50
85
  export async function rateLimit(ctx, expireTimeMs) {
86
+ if (!Number.isFinite(expireTimeMs) || expireTimeMs <= 0) {
87
+ throw new Error(`rateLimit: expireTimeMs must be a positive finite number, got ${expireTimeMs}`);
88
+ }
51
89
  if (ctx.embedded) {
52
- getSharedManager().setRateLimit(ctx.name, 1);
53
- setTimeout(() => {
54
- getSharedManager().clearRateLimit(ctx.name);
55
- }, expireTimeMs);
90
+ getSharedManager().setRateLimit(ctx.name, 1, undefined, expireTimeMs);
56
91
  }
57
92
  else if (ctx.tcp) {
58
- await ctx.tcp.send({ cmd: 'RateLimit', queue: ctx.name, limit: 1 });
93
+ await ctx.tcp.send({ cmd: 'RateLimit', queue: ctx.name, limit: 1, ttl: expireTimeMs });
59
94
  }
60
95
  }
61
96
  /** Get rate limit TTL */
@@ -11,6 +11,8 @@ interface StallContext {
11
11
  }
12
12
  /** Set stall detection configuration */
13
13
  export declare function setStallConfig(ctx: StallContext, config: Partial<StallConfig>): void;
14
+ /** Set stall detection configuration and resolve once the server has applied it. */
15
+ export declare function setStallConfigAsync(ctx: StallContext, config: Partial<StallConfig>): Promise<void>;
14
16
  /** Get stall detection configuration */
15
17
  export declare function getStallConfig(ctx: StallContext): StallConfig;
16
18
  /** Get stall detection configuration (async, works in TCP mode) */
@@ -23,6 +23,18 @@ export function setStallConfig(ctx, config) {
23
23
  void ctx.tcp.send({ cmd: 'SetStallConfig', queue: ctx.name, config });
24
24
  }
25
25
  }
26
+ /** Set stall detection configuration and resolve once the server has applied it. */
27
+ export async function setStallConfigAsync(ctx, config) {
28
+ if (ctx.embedded) {
29
+ dlqOps.setStallConfigEmbedded(ctx.name, config);
30
+ return;
31
+ }
32
+ if (!ctx.tcp)
33
+ return;
34
+ const current = tcpConfigCache.get(ctx.name) ?? { ...DEFAULT_STALL_CONFIG };
35
+ tcpConfigCache.set(ctx.name, { ...current, ...config });
36
+ await ctx.tcp.send({ cmd: 'SetStallConfig', queue: ctx.name, config });
37
+ }
26
38
  /** Get stall detection configuration */
27
39
  export function getStallConfig(ctx) {
28
40
  if (ctx.embedded) {
@@ -20,10 +20,22 @@ export declare class LimiterManager {
20
20
  pause(name: string): void;
21
21
  /** Resume queue */
22
22
  resume(name: string): void;
23
- /** Set rate limit for queue */
24
- setRateLimit(queue: string, limit: number): void;
23
+ /**
24
+ * Set rate limit for queue.
25
+ * @param durationMs window the limit applies to (default 1000ms): `limit`
26
+ * tokens refill over `durationMs`. Non-finite or non-positive → default.
27
+ * @param ttlMs auto-expiry: the limit clears itself after this many ms
28
+ * (lazy, checked on acquire/read). Non-finite or non-positive → permanent.
29
+ */
30
+ setRateLimit(queue: string, limit: number, durationMs?: number, ttlMs?: number): void;
25
31
  /** Clear rate limit */
26
32
  clearRateLimit(queue: string): void;
33
+ /**
34
+ * Drop the rate limit if its TTL has elapsed. Lazy expiry: called from the
35
+ * acquire path and from limit reads, so no timer is needed and an expired
36
+ * limit can never throttle a pull.
37
+ */
38
+ expireRateLimitIfNeeded(queue: string): void;
27
39
  /** Try to acquire rate limit token */
28
40
  tryAcquireRateLimit(queue: string): boolean;
29
41
  /** Set concurrency limit for queue */
@@ -34,20 +34,47 @@ export class LimiterManager {
34
34
  this.getState(name).paused = false;
35
35
  }
36
36
  // ============ Rate Limiting ============
37
- /** Set rate limit for queue */
38
- setRateLimit(queue, limit) {
39
- this.rateLimiters.set(queue, new RateLimiter(limit));
40
- this.getState(queue).rateLimit = limit;
37
+ /**
38
+ * Set rate limit for queue.
39
+ * @param durationMs window the limit applies to (default 1000ms): `limit`
40
+ * tokens refill over `durationMs`. Non-finite or non-positive → default.
41
+ * @param ttlMs auto-expiry: the limit clears itself after this many ms
42
+ * (lazy, checked on acquire/read). Non-finite or non-positive → permanent.
43
+ */
44
+ setRateLimit(queue, limit, durationMs, ttlMs) {
45
+ const duration = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : null;
46
+ const ttl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : null;
47
+ const windowSeconds = (duration ?? 1000) / 1000;
48
+ this.rateLimiters.set(queue, new RateLimiter(limit, limit / windowSeconds));
49
+ const state = this.getState(queue);
50
+ state.rateLimit = limit;
51
+ state.rateLimitDuration = duration;
52
+ state.rateLimitExpiresAt = ttl === null ? null : Date.now() + ttl;
41
53
  }
42
54
  /** Clear rate limit */
43
55
  clearRateLimit(queue) {
44
56
  this.rateLimiters.delete(queue);
45
57
  const state = this.queueState.get(queue);
46
- if (state)
58
+ if (state) {
47
59
  state.rateLimit = null;
60
+ state.rateLimitDuration = null;
61
+ state.rateLimitExpiresAt = null;
62
+ }
63
+ }
64
+ /**
65
+ * Drop the rate limit if its TTL has elapsed. Lazy expiry: called from the
66
+ * acquire path and from limit reads, so no timer is needed and an expired
67
+ * limit can never throttle a pull.
68
+ */
69
+ expireRateLimitIfNeeded(queue) {
70
+ const expiresAt = this.queueState.get(queue)?.rateLimitExpiresAt;
71
+ if (expiresAt !== null && expiresAt !== undefined && Date.now() >= expiresAt) {
72
+ this.clearRateLimit(queue);
73
+ }
48
74
  }
49
75
  /** Try to acquire rate limit token */
50
76
  tryAcquireRateLimit(queue) {
77
+ this.expireRateLimitIfNeeded(queue);
51
78
  const limiter = this.rateLimiters.get(queue);
52
79
  return !limiter || limiter.tryAcquire();
53
80
  }
@@ -69,8 +69,9 @@ export declare class Shard {
69
69
  isGroupActive(queue: string, groupId: string): boolean;
70
70
  activateGroup(queue: string, groupId: string): void;
71
71
  releaseGroup(queue: string, groupId: string): void;
72
- setRateLimit(queue: string, limit: number): void;
72
+ setRateLimit(queue: string, limit: number, durationMs?: number, ttlMs?: number): void;
73
73
  clearRateLimit(queue: string): void;
74
+ expireRateLimitIfNeeded(queue: string): void;
74
75
  tryAcquireRateLimit(queue: string): boolean;
75
76
  setConcurrency(queue: string, limit: number): void;
76
77
  clearConcurrency(queue: string): void;
@@ -138,12 +138,15 @@ export class Shard {
138
138
  this.activeGroups.get(queue)?.delete(groupId);
139
139
  }
140
140
  // ============ Rate & Concurrency Limiting (delegated) ============
141
- setRateLimit(queue, limit) {
142
- this.limiterManager.setRateLimit(queue, limit);
141
+ setRateLimit(queue, limit, durationMs, ttlMs) {
142
+ this.limiterManager.setRateLimit(queue, limit, durationMs, ttlMs);
143
143
  }
144
144
  clearRateLimit(queue) {
145
145
  this.limiterManager.clearRateLimit(queue);
146
146
  }
147
+ expireRateLimitIfNeeded(queue) {
148
+ this.limiterManager.expireRateLimitIfNeeded(queue);
149
+ }
147
150
  tryAcquireRateLimit(queue) {
148
151
  return this.limiterManager.tryAcquireRateLimit(queue);
149
152
  }
@@ -253,6 +253,10 @@ export interface RateLimitCommand extends BaseCommand {
253
253
  readonly cmd: 'RateLimit';
254
254
  readonly queue: string;
255
255
  readonly limit: number;
256
+ /** Window in ms the limit applies to (default 1000: `limit` per second). */
257
+ readonly duration?: number;
258
+ /** Auto-expiry in ms: the limit clears itself broker-side after this long. */
259
+ readonly ttl?: number;
256
260
  }
257
261
  export interface SetConcurrencyCommand extends BaseCommand {
258
262
  readonly cmd: 'SetConcurrency';
@@ -7,6 +7,10 @@ export interface QueueState {
7
7
  readonly name: string;
8
8
  paused: boolean;
9
9
  rateLimit: number | null;
10
+ /** Rate-limit window in ms (null = default 1000ms bucket). */
11
+ rateLimitDuration: number | null;
12
+ /** Epoch ms after which the rate limit self-expires (null = permanent). */
13
+ rateLimitExpiresAt: number | null;
10
14
  concurrencyLimit: number | null;
11
15
  activeCount: number;
12
16
  }
@@ -8,6 +8,8 @@ export function createQueueState(name) {
8
8
  name,
9
9
  paused: false,
10
10
  rateLimit: null,
11
+ rateLimitDuration: null,
12
+ rateLimitExpiresAt: null,
11
13
  concurrencyLimit: null,
12
14
  activeCount: 0,
13
15
  };
@@ -4,10 +4,10 @@
4
4
  /** SQLite PRAGMA settings for optimal performance */
5
5
  export declare const PRAGMA_SETTINGS = "\nPRAGMA journal_mode = WAL;\nPRAGMA synchronous = NORMAL;\nPRAGMA cache_size = -64000;\nPRAGMA temp_store = MEMORY;\nPRAGMA mmap_size = 268435456;\nPRAGMA page_size = 4096;\nPRAGMA busy_timeout = 5000;\n";
6
6
  /** Main schema creation */
7
- export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\n-- Stable createdAt/id pagination for unfiltered and logical-state queue views\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_created\n ON jobs(queue, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created\n ON jobs(queue, state, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER\n);\n";
7
+ export declare const SCHEMA = "\n-- Jobs table (using UUIDv7 for job IDs)\n-- Uses BLOB for data fields (MessagePack serialization for ~2-3x faster than JSON)\nCREATE TABLE IF NOT EXISTS jobs (\n id TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n priority INTEGER NOT NULL DEFAULT 0,\n created_at INTEGER NOT NULL,\n run_at INTEGER NOT NULL,\n started_at INTEGER,\n completed_at INTEGER,\n attempts INTEGER NOT NULL DEFAULT 0,\n max_attempts INTEGER NOT NULL DEFAULT 3,\n backoff INTEGER NOT NULL DEFAULT 1000,\n ttl INTEGER,\n timeout INTEGER,\n unique_key TEXT,\n custom_id TEXT,\n depends_on BLOB,\n parent_id TEXT,\n children_ids BLOB,\n tags BLOB,\n state TEXT NOT NULL DEFAULT 'waiting',\n lifo INTEGER NOT NULL DEFAULT 0,\n group_id TEXT,\n progress INTEGER DEFAULT 0,\n progress_msg TEXT,\n remove_on_complete INTEGER DEFAULT 0,\n remove_on_fail INTEGER DEFAULT 0,\n stall_timeout INTEGER,\n last_heartbeat INTEGER,\n timeline BLOB,\n stacktrace BLOB\n);\n\n-- Indexes for common queries\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state\n ON jobs(queue, state);\n-- Stable createdAt/id pagination for unfiltered and logical-state queue views\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_created\n ON jobs(queue, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created\n ON jobs(queue, state, created_at, id);\nCREATE INDEX IF NOT EXISTS idx_jobs_run_at\n ON jobs(run_at) WHERE state IN ('waiting', 'delayed');\nCREATE INDEX IF NOT EXISTS idx_jobs_unique\n ON jobs(queue, unique_key) WHERE unique_key IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_custom_id\n ON jobs(custom_id) WHERE custom_id IS NOT NULL;\nCREATE INDEX IF NOT EXISTS idx_jobs_parent\n ON jobs(parent_id) WHERE parent_id IS NOT NULL;\n\n-- Job results storage (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS job_results (\n job_id TEXT PRIMARY KEY,\n result BLOB,\n completed_at INTEGER NOT NULL\n);\n\n-- Dead letter queue (BLOB for MessagePack - stores full DlqEntry)\nCREATE TABLE IF NOT EXISTS dlq (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n job_id TEXT NOT NULL,\n queue TEXT NOT NULL,\n entry BLOB NOT NULL,\n entered_at INTEGER NOT NULL\n);\n\nCREATE INDEX IF NOT EXISTS idx_dlq_queue ON dlq(queue);\nCREATE INDEX IF NOT EXISTS idx_dlq_job_id ON dlq(job_id);\nCREATE INDEX IF NOT EXISTS idx_dlq_entered_at ON dlq(entered_at);\n\n-- Performance indexes for high-throughput operations\n-- Stall detection: runs every 5s, needs fast lookup of active jobs by started_at\nCREATE INDEX IF NOT EXISTS idx_jobs_state_started\n ON jobs(state, started_at) WHERE state = 'active';\n\n-- Group operations: fast lookup by group_id\nCREATE INDEX IF NOT EXISTS idx_jobs_group_id\n ON jobs(group_id) WHERE group_id IS NOT NULL;\n\n-- Pending jobs: compound index for priority-ordered retrieval\nCREATE INDEX IF NOT EXISTS idx_jobs_pending_priority\n ON jobs(queue, state, priority DESC, run_at ASC) WHERE state IN ('waiting', 'delayed');\n\n-- Completed jobs: index for recovery ordering (issue #84)\nCREATE INDEX IF NOT EXISTS idx_jobs_completed_order\n ON jobs(completed_at DESC) WHERE state = 'completed';\n\n-- Cron jobs (BLOB for MessagePack)\nCREATE TABLE IF NOT EXISTS cron_jobs (\n name TEXT PRIMARY KEY,\n queue TEXT NOT NULL,\n data BLOB NOT NULL,\n schedule TEXT,\n repeat_every INTEGER,\n priority INTEGER NOT NULL DEFAULT 0,\n next_run INTEGER NOT NULL,\n executions INTEGER NOT NULL DEFAULT 0,\n max_limit INTEGER,\n timezone TEXT,\n unique_key TEXT,\n dedup BLOB,\n skip_missed_on_restart INTEGER NOT NULL DEFAULT 0,\n skip_if_no_worker INTEGER NOT NULL DEFAULT 0,\n prevent_overlap INTEGER NOT NULL DEFAULT 1,\n job_options BLOB\n);\n\n-- Queue state persistence (optional)\nCREATE TABLE IF NOT EXISTS queue_state (\n name TEXT PRIMARY KEY,\n paused INTEGER NOT NULL DEFAULT 0,\n rate_limit INTEGER,\n concurrency_limit INTEGER,\n rate_limit_duration INTEGER,\n rate_limit_expires_at INTEGER\n);\n";
8
8
  /** Migration version table */
9
9
  export declare const MIGRATION_TABLE = "\nCREATE TABLE IF NOT EXISTS migrations (\n version INTEGER PRIMARY KEY,\n applied_at INTEGER NOT NULL\n);\n";
10
10
  /** Current schema version */
11
- export declare const SCHEMA_VERSION = 14;
11
+ export declare const SCHEMA_VERSION = 16;
12
12
  /** All migrations in order */
13
13
  export declare const MIGRATIONS: Record<number, string>;
@@ -127,7 +127,9 @@ CREATE TABLE IF NOT EXISTS queue_state (
127
127
  name TEXT PRIMARY KEY,
128
128
  paused INTEGER NOT NULL DEFAULT 0,
129
129
  rate_limit INTEGER,
130
- concurrency_limit INTEGER
130
+ concurrency_limit INTEGER,
131
+ rate_limit_duration INTEGER,
132
+ rate_limit_expires_at INTEGER
131
133
  );
132
134
  `;
133
135
  /** Migration version table */
@@ -138,7 +140,7 @@ CREATE TABLE IF NOT EXISTS migrations (
138
140
  );
139
141
  `;
140
142
  /** Current schema version */
141
- export const SCHEMA_VERSION = 14;
143
+ export const SCHEMA_VERSION = 16;
142
144
  /** All migrations in order */
143
145
  export const MIGRATIONS = {
144
146
  1: SCHEMA,
@@ -199,5 +201,16 @@ CREATE INDEX IF NOT EXISTS idx_jobs_queue_created
199
201
  ON jobs(queue, created_at, id);
200
202
  CREATE INDEX IF NOT EXISTS idx_jobs_queue_state_created
201
203
  ON jobs(queue, state, created_at, id);
204
+ `,
205
+ // Migrations 15-16: Rate-limit window (duration) + TTL auto-expiry on
206
+ // queue_state. One ALTER per migration ON PURPOSE: each runs in its own
207
+ // db.run with its own error-swallow, so a crash between the two leaves a
208
+ // retryable state (a two-ALTER string would abort at the first "duplicate
209
+ // column" on re-run and never execute the second statement).
210
+ 15: `
211
+ ALTER TABLE queue_state ADD COLUMN rate_limit_duration INTEGER;
212
+ `,
213
+ 16: `
214
+ ALTER TABLE queue_state ADD COLUMN rate_limit_expires_at INTEGER;
202
215
  `,
203
216
  };
@@ -204,13 +204,21 @@ export declare class SqliteStorage {
204
204
  * Persist a queue's control-state (paused / rate-limit / concurrency) so it
205
205
  * survives a server restart. Write-through on every pause/resume/limit change.
206
206
  */
207
- saveQueueState(name: string, paused: boolean, rateLimit: number | null, concurrencyLimit: number | null): void;
207
+ saveQueueState(name: string, state: {
208
+ paused: boolean;
209
+ rateLimit: number | null;
210
+ concurrencyLimit: number | null;
211
+ rateLimitDuration?: number | null;
212
+ rateLimitExpiresAt?: number | null;
213
+ }): void;
208
214
  /** Load all persisted queue control-state rows (used by recover() on boot). */
209
215
  loadQueueState(): Array<{
210
216
  name: string;
211
217
  paused: boolean;
212
218
  rateLimit: number | null;
213
219
  concurrencyLimit: number | null;
220
+ rateLimitDuration: number | null;
221
+ rateLimitExpiresAt: number | null;
214
222
  }>;
215
223
  /** Drop a queue's persisted control-state (e.g. on obliterate). */
216
224
  deleteQueueState(name: string): void;
@@ -650,11 +650,11 @@ export class SqliteStorage {
650
650
  * Persist a queue's control-state (paused / rate-limit / concurrency) so it
651
651
  * survives a server restart. Write-through on every pause/resume/limit change.
652
652
  */
653
- saveQueueState(name, paused, rateLimit, concurrencyLimit) {
653
+ saveQueueState(name, state) {
654
654
  this.safeWrite(() => {
655
655
  this.statements
656
656
  .get('upsertQueueState')
657
- .run(name, paused ? 1 : 0, rateLimit, concurrencyLimit);
657
+ .run(name, state.paused ? 1 : 0, state.rateLimit, state.concurrencyLimit, state.rateLimitDuration ?? null, state.rateLimitExpiresAt ?? null);
658
658
  });
659
659
  }
660
660
  /** Load all persisted queue control-state rows (used by recover() on boot). */
@@ -665,6 +665,8 @@ export class SqliteStorage {
665
665
  paused: row.paused === 1,
666
666
  rateLimit: row.rate_limit,
667
667
  concurrencyLimit: row.concurrency_limit,
668
+ rateLimitDuration: row.rate_limit_duration ?? null,
669
+ rateLimitExpiresAt: row.rate_limit_expires_at ?? null,
668
670
  }));
669
671
  }
670
672
  /** Drop a queue's persisted control-state (e.g. on obliterate). */
@@ -67,4 +67,6 @@ export interface DbQueueState {
67
67
  paused: number;
68
68
  rate_limit: number | null;
69
69
  concurrency_limit: number | null;
70
+ rate_limit_duration: number | null;
71
+ rate_limit_expires_at: number | null;
70
72
  }
@@ -57,8 +57,8 @@ export const SQL_STATEMENTS = {
57
57
  `,
58
58
  updateCron: 'UPDATE cron_jobs SET executions = ?, next_run = ? WHERE name = ?',
59
59
  // Queue control-state persistence (#100): paused / rate-limit / concurrency.
60
- upsertQueueState: 'INSERT OR REPLACE INTO queue_state (name, paused, rate_limit, concurrency_limit) VALUES (?, ?, ?, ?)',
61
- loadQueueState: 'SELECT name, paused, rate_limit, concurrency_limit FROM queue_state',
60
+ upsertQueueState: 'INSERT OR REPLACE INTO queue_state (name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at) VALUES (?, ?, ?, ?, ?, ?)',
61
+ loadQueueState: 'SELECT name, paused, rate_limit, concurrency_limit, rate_limit_duration, rate_limit_expires_at FROM queue_state',
62
62
  deleteQueueState: 'DELETE FROM queue_state WHERE name = ?',
63
63
  };
64
64
  /** Prepare all statements */
@@ -100,6 +100,11 @@ export declare class CronScheduler {
100
100
  */
101
101
  private tick;
102
102
  /** Fire a cron job with overlap and worker detection */
103
+ /**
104
+ * Reasons a due cron must not push right now. Evaluated BEFORE the
105
+ * executions increment in tick(), so skips never consume maxLimit budget.
106
+ */
107
+ private getSkipReason;
103
108
  private fireCronJob;
104
109
  /**
105
110
  * Get scheduler stats
@@ -283,6 +283,35 @@ export class CronScheduler {
283
283
  else {
284
284
  newNextRun = executionTime;
285
285
  }
286
+ // Skip decision comes BEFORE the executions increment: a skipped fire
287
+ // pushes no job, so it must not consume the maxLimit budget (a cron
288
+ // with maxLimit=N must deliver N jobs, not N-minus-skips — worst case
289
+ // skipIfNoWorker used to burn the whole budget with zero deliveries).
290
+ // The schedule still advances so the heap doesn't hot-loop on it.
291
+ const skipReason = this.getSkipReason(cron, now);
292
+ if (skipReason) {
293
+ if (this.persistCron) {
294
+ try {
295
+ this.persistCron(cron.name, cron.executions, newNextRun);
296
+ }
297
+ catch (persistErr) {
298
+ // Benign here: nothing was pushed, in-memory advance is enough;
299
+ // the next successful persist catches the row up.
300
+ cronLog.error('Failed to persist cron nextRun on skip', {
301
+ name: cron.name,
302
+ error: String(persistErr),
303
+ });
304
+ }
305
+ }
306
+ cron.nextRun = newNextRun;
307
+ this.dashboardEmit?.('cron:skipped', {
308
+ name: cron.name,
309
+ queue: cron.queue,
310
+ reason: skipReason,
311
+ });
312
+ toReinsert.push(entry);
313
+ continue;
314
+ }
286
315
  // PERSIST STATE FIRST (before pushing job to prevent duplicates)
287
316
  if (this.persistCron) {
288
317
  try {
@@ -339,27 +368,24 @@ export class CronScheduler {
339
368
  this.scheduleNext();
340
369
  }
341
370
  /** Fire a cron job with overlap and worker detection */
342
- async fireCronJob(cron, now) {
371
+ /**
372
+ * Reasons a due cron must not push right now. Evaluated BEFORE the
373
+ * executions increment in tick(), so skips never consume maxLimit budget.
374
+ */
375
+ getSkipReason(cron, now) {
343
376
  // skipIfNoWorker: skip if no workers registered for this queue
344
377
  if (cron.skipIfNoWorker && this.hasWorkers && !this.hasWorkers(cron.queue)) {
345
- this.dashboardEmit?.('cron:skipped', {
346
- name: cron.name,
347
- queue: cron.queue,
348
- reason: 'no-worker',
349
- });
350
- return;
378
+ return 'no-worker';
351
379
  }
352
380
  // Overlap detection: skip if last fire was within repeatEvery window
353
381
  const lastFire = this.lastFiredAt.get(cron.name);
354
382
  const interval = cron.repeatEvery ?? 60000;
355
383
  if (lastFire && now - lastFire < interval * 0.8) {
356
- this.dashboardEmit?.('cron:skipped', {
357
- name: cron.name,
358
- queue: cron.queue,
359
- reason: 'overlap',
360
- });
361
- return;
384
+ return 'overlap';
362
385
  }
386
+ return null;
387
+ }
388
+ async fireCronJob(cron, now) {
363
389
  // preventOverlap: auto-set uniqueKey to block pushes while previous job is active
364
390
  const effectiveUniqueKey = cron.uniqueKey ?? (cron.preventOverlap ? `cron:${cron.name}` : undefined);
365
391
  const opts = cron.jobOptions;
@@ -172,8 +172,17 @@ export function handleRateLimit(cmd, ctx, reqId) {
172
172
  const limit = toFiniteNumber(cmd.limit);
173
173
  if (limit === undefined)
174
174
  return resp.error('limit must be a finite number', reqId);
175
- ctx.queueManager.setRateLimit(cmd.queue, limit);
176
- ctx.queueManager.emitDashboardEvent('ratelimit:set', { queue: cmd.queue, max: limit });
175
+ // Optional window/TTL: invalid values degrade to the defaults (1s window,
176
+ // permanent limit) inside the limiter rather than failing the command.
177
+ const duration = toFiniteNumber(cmd.duration);
178
+ const ttl = toFiniteNumber(cmd.ttl);
179
+ ctx.queueManager.setRateLimit(cmd.queue, limit, duration, ttl);
180
+ ctx.queueManager.emitDashboardEvent('ratelimit:set', {
181
+ queue: cmd.queue,
182
+ max: limit,
183
+ ...(duration !== undefined && { duration }),
184
+ ...(ttl !== undefined && { ttl }),
185
+ });
177
186
  return resp.ok(undefined, reqId);
178
187
  }
179
188
  /** Handle RateLimitClear command */
@@ -76,6 +76,8 @@ export async function routeQueueConfigRoutes(req, path, method, ctx, cors) {
76
76
  cmd: 'RateLimit',
77
77
  queue,
78
78
  limit: body['limit'],
79
+ duration: body['duration'],
80
+ ttl: body['ttl'],
79
81
  }, ctx);
80
82
  return jsonResponse(r, 200, cors);
81
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunqueue",
3
- "version": "2.8.34",
3
+ "version": "2.8.35",
4
4
  "description": "High-performance job queue for Bun & AI agents. SQLite persistence, cron scheduling, priorities, retries, DLQ, webhooks, native MCP server. Zero external infrastructure.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",