@arkstack/queue 0.19.3 → 0.19.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,12 +49,29 @@ await Queue.clear('emails')
49
49
  ## Workers
50
50
 
51
51
  ```bash
52
- ark queue:work # daemon on the default connection
52
+ ark queue:work # daemon on the default connection's default queue
53
53
  ark queue:work redis --queue=emails
54
+ ark queue:work --queue=high,default # priority list: high first, then default
55
+ ark queue:work --queue=* # every queue on the connection
54
56
  ark queue:work --once # process a single job and exit
55
57
  ark queue:clear redis --queue=emails
56
58
  ```
57
59
 
60
+ `--queue` takes one queue, a comma-separated list, or `*`:
61
+
62
+ - **No `--queue`**: only the connection's default queue (`queue` in its config). Jobs
63
+ sent elsewhere with `onQueue(...)` or a job's `queue` property are **not** processed.
64
+ - **A list** (`--queue=webhook,ocr,default`) is a strict priority order: on every
65
+ loop the worker takes the first job it finds walking the list, so later queues
66
+ run only while earlier ones are empty.
67
+ - **`*`** works every queue on the connection. The `database` driver takes the
68
+ oldest due job across all queues; the `redis` driver discovers queues with
69
+ `SCAN` and works them round-robin. Combine it with names to prioritize some:
70
+ `--queue=webhook,*`.
71
+
72
+ Every queue that receives jobs needs a worker that lists it (or `*`), or its jobs
73
+ wait forever.
74
+
58
75
  Programmatically:
59
76
 
60
77
  ```ts
@@ -62,8 +79,12 @@ import { Queue } from '@arkstack/queue'
62
79
 
63
80
  const worker = Queue.worker('database')
64
81
  await worker.daemon({ queue: 'default', sleep: 3 })
82
+ await worker.daemon({ queue: ['webhook', '*'], sleep: 3 }) // priority list / wildcard
65
83
  ```
66
84
 
85
+ A custom driver supports `*` by overriding `popAny()` on `QueueContract`; the base
86
+ implementation works only the default queue.
87
+
67
88
  A job is **deleted** on success, **released** for another attempt on failure, and **marked failed** (invoking its `failed` hook) once it exhausts `tries`.
68
89
 
69
90
  ## Job (de)serialization
@@ -152,6 +152,15 @@ var QueueContract = class {
152
152
  getDefaultQueue() {
153
153
  return "default";
154
154
  }
155
+ /**
156
+ * Reserve the next job from any queue on this connection, for the worker's `*`
157
+ * wildcard. Drivers that can list their queues override this; the default
158
+ * works only the default queue, so a driver without that ability behaves
159
+ * exactly as a worker given no queue.
160
+ */
161
+ async popAny() {
162
+ return this.pop();
163
+ }
155
164
  };
156
165
  //#endregion
157
166
  //#region src/drivers/DatabaseQueue.ts
@@ -210,9 +219,25 @@ var DatabaseQueue = class extends QueueContract {
210
219
  }
211
220
  async pop(queue) {
212
221
  const db = await this.database();
213
- const name = queue ?? this.defaultQueue;
214
- const row = await this.availableQuery(db, name).orderBy({ id: "asc" }).first();
215
- if (!row) return null;
222
+ const row = await this.availableQuery(db, queue ?? this.defaultQueue).orderBy({ id: "asc" }).first();
223
+ return row ? this.reserve(db, row) : null;
224
+ }
225
+ /**
226
+ * The oldest due job on any queue. Ordering by id across queues is FIFO by
227
+ * arrival, so a busy queue can't starve a quiet one.
228
+ */
229
+ async popAny() {
230
+ const db = await this.database();
231
+ const row = await this.availableQuery(db).orderBy({ id: "asc" }).first();
232
+ return row ? this.reserve(db, row) : null;
233
+ }
234
+ /**
235
+ * Reserve a fetched row and wrap it as a {@link Job}.
236
+ *
237
+ * @param db
238
+ * @param row
239
+ */
240
+ async reserve(db, row) {
216
241
  const attempts = row.attempts + 1;
217
242
  await db.table(this.options.table).where({ id: row.id }).update({
218
243
  reserved_at: now$1(),
@@ -221,7 +246,7 @@ var DatabaseQueue = class extends QueueContract {
221
246
  return new Job({
222
247
  ...JSON.parse(row.payload),
223
248
  attempts
224
- }, name, {
249
+ }, row.queue, {
225
250
  delete: async () => {
226
251
  await db.table(this.options.table).where({ id: row.id }).delete();
227
252
  },
@@ -244,15 +269,17 @@ var DatabaseQueue = class extends QueueContract {
244
269
  return count;
245
270
  }
246
271
  /**
247
- * Due, unreserved jobs on the given queue, oldest first. Released jobs become
248
- * unreserved with a future `available_at`, so they naturally wait their turn.
272
+ * Due, unreserved jobs, oldest first: on one queue, or on every queue when
273
+ * `queue` is omitted. Released jobs become unreserved with a future
274
+ * `available_at`, so they naturally wait their turn.
249
275
  *
250
276
  * @param db
251
277
  * @param queue
252
278
  * @returns
253
279
  */
254
280
  availableQuery(db, queue) {
255
- return db.table(this.options.table).where({ queue }).whereNull("reserved_at").whereRaw("available_at <= " + now$1());
281
+ const table = db.table(this.options.table);
282
+ return (queue === void 0 ? table : table.where({ queue })).whereNull("reserved_at").whereRaw("available_at <= " + now$1());
256
283
  }
257
284
  };
258
285
  //#endregion
@@ -269,6 +296,8 @@ const now = () => Math.floor(Date.now() / 1e3);
269
296
  var RedisQueue = class extends QueueContract {
270
297
  options;
271
298
  client;
299
+ /** Rotates which queue {@link popAny} tries first, so none is starved. */
300
+ anyCursor = 0;
272
301
  constructor(options) {
273
302
  super();
274
303
  this.options = options;
@@ -342,6 +371,22 @@ var RedisQueue = class extends QueueContract {
342
371
  }
343
372
  });
344
373
  }
374
+ /**
375
+ * Reserve the next job from any queue. Redis has no ordering across lists, so
376
+ * the queues are discovered with `SCAN` (never the blocking `KEYS`) and tried
377
+ * round-robin, starting one further along on each call.
378
+ */
379
+ async popAny() {
380
+ const queues = await this.queueNames(await this.connection());
381
+ for (let offset = 0; offset < queues.length; offset++) {
382
+ const job = await this.pop(queues[(this.anyCursor + offset) % queues.length]);
383
+ if (job) {
384
+ this.anyCursor = (this.anyCursor + offset + 1) % queues.length;
385
+ return job;
386
+ }
387
+ }
388
+ return null;
389
+ }
345
390
  async size(queue) {
346
391
  const client = await this.connection();
347
392
  const name = queue ?? this.defaultQueue;
@@ -360,6 +405,21 @@ var RedisQueue = class extends QueueContract {
360
405
  return count;
361
406
  }
362
407
  /**
408
+ * Every queue with jobs in any state (waiting, delayed or reserved), sorted so
409
+ * the round-robin order is stable.
410
+ */
411
+ async queueNames(client) {
412
+ const prefix = this.options.prefix ?? "queues:";
413
+ const names = /* @__PURE__ */ new Set();
414
+ let cursor = "0";
415
+ do {
416
+ const [next, keys] = await client.scan(cursor, "MATCH", `${prefix}*`, "COUNT", 100);
417
+ for (const key of keys) names.add(key.slice(prefix.length).replace(/:(delayed|reserved)$/, ""));
418
+ cursor = next;
419
+ } while (cursor !== "0");
420
+ return [...names].sort();
421
+ }
422
+ /**
363
423
  * Move due delayed jobs and expired reservations back onto the waiting list.
364
424
  */
365
425
  async migrate(client, queue) {
@@ -419,6 +479,8 @@ var SyncQueue = class extends QueueContract {
419
479
  };
420
480
  //#endregion
421
481
  //#region src/Worker.ts
482
+ /** Matches every queue on the connection in a worker's queue list. */
483
+ const ANY_QUEUE = "*";
422
484
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
423
485
  /**
424
486
  * Pulls jobs from a connection and executes them.
@@ -450,15 +512,31 @@ var Worker = class {
450
512
  }
451
513
  /**
452
514
  * Pop and process the next job. Returns `true` if a job was processed,
453
- * `false` when the queue was empty.
515
+ * `false` when every queue asked for was empty.
516
+ *
517
+ * @param queue A queue, or a priority-ordered list of queues (see
518
+ * {@link WorkerOptions.queue}). Omitted: the connection's default.
454
519
  */
455
520
  async runNextJob(queue) {
456
- const job = await this.connection.pop(queue);
521
+ const job = await this.nextJob(queue);
457
522
  if (!job) return false;
458
523
  await this.process(job);
459
524
  return true;
460
525
  }
461
526
  /**
527
+ * Reserve the next job, walking the queues in priority order.
528
+ *
529
+ * @param queue
530
+ */
531
+ async nextJob(queue) {
532
+ const queues = Array.isArray(queue) ? queue : [queue];
533
+ for (const name of queues) {
534
+ const job = name === "*" ? await this.connection.popAny() : await this.connection.pop(name);
535
+ if (job) return job;
536
+ }
537
+ return null;
538
+ }
539
+ /**
462
540
  * Execute a single reserved job, handling success and failure.
463
541
  */
464
542
  async process(job) {
@@ -631,4 +709,4 @@ var Queue = class {
631
709
  }
632
710
  };
633
711
  //#endregion
634
- export { RedisQueue as a, Job as c, serializeJob as d, setResolver as f, SyncQueue as i, resetSerialization as l, configure as n, DatabaseQueue as o, setSerializer as p, Worker as r, QueueContract as s, Queue as t, resolveJob as u };
712
+ export { SyncQueue as a, QueueContract as c, resolveJob as d, serializeJob as f, Worker as i, Job as l, setSerializer as m, configure as n, RedisQueue as o, setResolver as p, ANY_QUEUE as r, DatabaseQueue as s, Queue as t, resetSerialization as u };
@@ -1,4 +1,4 @@
1
- import { t as Queue } from "../QueueManager--4wZ8gJz.js";
1
+ import { t as Queue } from "../QueueManager-HqKDfrUq.js";
2
2
  import { Command } from "@h3ravel/musket";
3
3
  import { bootArkorm } from "@arkstack/database";
4
4
  //#region src/commands/QueueClearCommand.ts
@@ -7,6 +7,19 @@ declare class QueueWorkCommand extends Command {
7
7
  protected signature: string;
8
8
  protected description: string;
9
9
  handle(): Promise<void>;
10
+ /**
11
+ * The queues to work, from `--queue`: `ocr` → `['ocr']`, `webhook,ocr` → a
12
+ * priority list, `*` → every queue. Falls back to the connection's default.
13
+ *
14
+ * @param fallback The connection's default queue.
15
+ */
16
+ private queues;
17
+ /**
18
+ * How the worker's queues read in its startup line.
19
+ *
20
+ * @param queues
21
+ */
22
+ private describe;
10
23
  /**
11
24
  * Read a multi-word flag. Musket hands over the parsed options camelCased,
12
25
  * so `--stop-when-empty` arrives as `stopWhenEmpty`; the kebab-case name is
@@ -1,4 +1,4 @@
1
- import { r as Worker, t as Queue } from "../QueueManager--4wZ8gJz.js";
1
+ import { i as Worker, t as Queue } from "../QueueManager-HqKDfrUq.js";
2
2
  import { Command } from "@h3ravel/musket";
3
3
  import { bootArkorm } from "@arkstack/database";
4
4
  //#region src/commands/QueueWorkCommand.ts
@@ -10,7 +10,7 @@ const reason = (error) => error instanceof Error ? error.message : String(error)
10
10
  var QueueWorkCommand = class extends Command {
11
11
  signature = `queue:work
12
12
  {connection? : The queue connection to work. Defaults to the configured default.}
13
- {--queue= : The queue to process.}
13
+ {--queue= : The queue(s) to process: a name, a comma-separated priority list, or * for every queue.}
14
14
  {--sleep=3 : Seconds to sleep when no job is available.}
15
15
  {--max-jobs=0 : Stop after processing this many jobs (0 for unlimited).}
16
16
  {--once : Process a single job and exit.}
@@ -27,7 +27,7 @@ var QueueWorkCommand = class extends Command {
27
27
  this.error(released ? `Failed (attempt ${job.attempts()}, retrying): ${job.name()} — ${reason(error)}` : `Failed permanently: ${job.name()} — ${reason(error)}`);
28
28
  }
29
29
  });
30
- const queue = this.option("queue") ?? connection.getDefaultQueue();
30
+ const queue = this.queues(connection.getDefaultQueue());
31
31
  try {
32
32
  bootArkorm();
33
33
  } catch {}
@@ -36,7 +36,7 @@ var QueueWorkCommand = class extends Command {
36
36
  if (!await worker.runNextJob(queue)) this.info("No jobs available.");
37
37
  return;
38
38
  }
39
- this.info(`Processing jobs from the [${connection.getConnectionName()}] connection on the [${queue}] queue.`);
39
+ this.info(`Processing jobs from the [${connection.getConnectionName()}] connection on ${this.describe(queue)}.`);
40
40
  await worker.daemon({
41
41
  queue,
42
42
  sleep: Number(this.option("sleep") ?? 3),
@@ -45,6 +45,25 @@ var QueueWorkCommand = class extends Command {
45
45
  });
46
46
  }
47
47
  /**
48
+ * The queues to work, from `--queue`: `ocr` → `['ocr']`, `webhook,ocr` → a
49
+ * priority list, `*` → every queue. Falls back to the connection's default.
50
+ *
51
+ * @param fallback The connection's default queue.
52
+ */
53
+ queues(fallback) {
54
+ const names = String(this.option("queue") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
55
+ return names.length ? [...new Set(names)] : [fallback];
56
+ }
57
+ /**
58
+ * How the worker's queues read in its startup line.
59
+ *
60
+ * @param queues
61
+ */
62
+ describe(queues) {
63
+ if (queues.length === 1) return queues[0] === "*" ? "every queue" : `the [${queues[0]}] queue`;
64
+ return `the [${queues.join(", ")}] queues, in that order`;
65
+ }
66
+ /**
48
67
  * Read a multi-word flag. Musket hands over the parsed options camelCased,
49
68
  * so `--stop-when-empty` arrives as `stopWhenEmpty`; the kebab-case name is
50
69
  * accepted too so the flag can't go quietly missing again.
package/dist/index.d.ts CHANGED
@@ -88,6 +88,13 @@ declare abstract class QueueContract {
88
88
  * Reserve and return the next available job, or `null` when none is ready.
89
89
  */
90
90
  abstract pop(queue?: string): Promise<Job | null>;
91
+ /**
92
+ * Reserve the next job from any queue on this connection, for the worker's `*`
93
+ * wildcard. Drivers that can list their queues override this; the default
94
+ * works only the default queue, so a driver without that ability behaves
95
+ * exactly as a worker given no queue.
96
+ */
97
+ popAny(): Promise<Job | null>;
91
98
  /**
92
99
  * The number of jobs waiting on the given queue.
93
100
  */
@@ -232,13 +239,20 @@ interface RedisClient {
232
239
  zrem(key: string, member: string): Promise<number>;
233
240
  zcard(key: string): Promise<number>;
234
241
  zrangebyscore(key: string, min: string | number, max: string | number): Promise<string[]>;
242
+ scan(cursor: string | number, ...args: (string | number)[]): Promise<[cursor: string, keys: string[]]>;
235
243
  quit(): Promise<unknown>;
236
244
  }
237
245
  //#endregion
238
246
  //#region src/Worker.d.ts
247
+ /** Matches every queue on the connection in a worker's queue list. */
248
+ declare const ANY_QUEUE = "*";
239
249
  interface WorkerOptions {
240
- /** The queue name to pull from. */
241
- queue?: string;
250
+ /**
251
+ * The queue(s) to pull from. A list is a strict priority order: each loop
252
+ * takes the first job found walking the list. {@link ANY_QUEUE} (`*`) matches
253
+ * any queue, so `['webhook', '*']` means "webhook first, then anything".
254
+ */
255
+ queue?: string | string[];
242
256
  /** Seconds to wait when the queue is empty before polling again. */
243
257
  sleep?: number;
244
258
  /** Stop after processing this many jobs (0 = unlimited). */
@@ -285,9 +299,18 @@ declare class Worker {
285
299
  on(handlers: WorkerHandlers): this;
286
300
  /**
287
301
  * Pop and process the next job. Returns `true` if a job was processed,
288
- * `false` when the queue was empty.
302
+ * `false` when every queue asked for was empty.
303
+ *
304
+ * @param queue A queue, or a priority-ordered list of queues (see
305
+ * {@link WorkerOptions.queue}). Omitted: the connection's default.
306
+ */
307
+ runNextJob(queue?: string | string[]): Promise<boolean>;
308
+ /**
309
+ * Reserve the next job, walking the queues in priority order.
310
+ *
311
+ * @param queue
289
312
  */
290
- runNextJob(queue?: string): Promise<boolean>;
313
+ private nextJob;
291
314
  /**
292
315
  * Execute a single reserved job, handling success and failure.
293
316
  */
@@ -436,11 +459,24 @@ declare class DatabaseQueue extends QueueContract {
436
459
  pushRaw(payload: JobPayload, queue?: string, availableAt?: number): Promise<string>;
437
460
  later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
438
461
  pop(queue?: string): Promise<Job | null>;
462
+ /**
463
+ * The oldest due job on any queue. Ordering by id across queues is FIFO by
464
+ * arrival, so a busy queue can't starve a quiet one.
465
+ */
466
+ popAny(): Promise<Job | null>;
467
+ /**
468
+ * Reserve a fetched row and wrap it as a {@link Job}.
469
+ *
470
+ * @param db
471
+ * @param row
472
+ */
473
+ private reserve;
439
474
  size(queue?: string): Promise<number>;
440
475
  clear(queue?: string): Promise<number>;
441
476
  /**
442
- * Due, unreserved jobs on the given queue, oldest first. Released jobs become
443
- * unreserved with a future `available_at`, so they naturally wait their turn.
477
+ * Due, unreserved jobs, oldest first: on one queue, or on every queue when
478
+ * `queue` is omitted. Released jobs become unreserved with a future
479
+ * `available_at`, so they naturally wait their turn.
444
480
  *
445
481
  * @param db
446
482
  * @param queue
@@ -461,6 +497,8 @@ declare class DatabaseQueue extends QueueContract {
461
497
  declare class RedisQueue extends QueueContract {
462
498
  private readonly options;
463
499
  private client?;
500
+ /** Rotates which queue {@link popAny} tries first, so none is starved. */
501
+ private anyCursor;
464
502
  constructor(options: RedisConnectionConfig);
465
503
  private get defaultQueue();
466
504
  /** The configured queue this connection works when none is named. */
@@ -474,12 +512,23 @@ declare class RedisQueue extends QueueContract {
474
512
  pushRaw(payload: JobPayload, queue?: string, availableAt?: number): Promise<string>;
475
513
  later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
476
514
  pop(queue?: string): Promise<Job | null>;
515
+ /**
516
+ * Reserve the next job from any queue. Redis has no ordering across lists, so
517
+ * the queues are discovered with `SCAN` (never the blocking `KEYS`) and tried
518
+ * round-robin, starting one further along on each call.
519
+ */
520
+ popAny(): Promise<Job | null>;
477
521
  size(queue?: string): Promise<number>;
478
522
  clear(queue?: string): Promise<number>;
523
+ /**
524
+ * Every queue with jobs in any state (waiting, delayed or reserved), sorted so
525
+ * the round-robin order is stable.
526
+ */
527
+ private queueNames;
479
528
  /**
480
529
  * Move due delayed jobs and expired reservations back onto the waiting list.
481
530
  */
482
531
  private migrate;
483
532
  }
484
533
  //#endregion
485
- export { CustomQueueConnectionRegistry, DatabaseConnectionConfig, DatabaseFacade, DatabaseQueue, Job, JobHandlers, JobPayload, JobResolver, JobRow, JobSerializer, Query, Queue, QueueConfig, QueueConnectionConfig, QueueConnectionFactory, QueueContract, Queueable, RedisClient, RedisConnectionConfig, RedisQueue, SyncConnectionConfig, SyncQueue, Worker, WorkerHandlers, WorkerOptions, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
534
+ export { ANY_QUEUE, CustomQueueConnectionRegistry, DatabaseConnectionConfig, DatabaseFacade, DatabaseQueue, Job, JobHandlers, JobPayload, JobResolver, JobRow, JobSerializer, Query, Queue, QueueConfig, QueueConnectionConfig, QueueConnectionFactory, QueueContract, Queueable, RedisClient, RedisConnectionConfig, RedisQueue, SyncConnectionConfig, SyncQueue, Worker, WorkerHandlers, WorkerOptions, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as RedisQueue, c as Job, d as serializeJob, f as setResolver, i as SyncQueue, l as resetSerialization, n as configure, o as DatabaseQueue, p as setSerializer, r as Worker, s as QueueContract, t as Queue, u as resolveJob } from "./QueueManager--4wZ8gJz.js";
2
- export { DatabaseQueue, Job, Queue, QueueContract, RedisQueue, SyncQueue, Worker, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
1
+ import { a as SyncQueue, c as QueueContract, d as resolveJob, f as serializeJob, i as Worker, l as Job, m as setSerializer, n as configure, o as RedisQueue, p as setResolver, r as ANY_QUEUE, s as DatabaseQueue, t as Queue, u as resetSerialization } from "./QueueManager-HqKDfrUq.js";
2
+ export { ANY_QUEUE, DatabaseQueue, Job, Queue, QueueContract, RedisQueue, SyncQueue, Worker, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkstack/queue",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
4
4
  "type": "module",
5
5
  "description": "Queue module for Arkstack, providing a driver based queue transport and worker for background processing.",
6
6
  "homepage": "https://arkstack.toneflix.net/guide/queue",
@@ -34,13 +34,13 @@
34
34
  "./package.json": "./package.json"
35
35
  },
36
36
  "dependencies": {
37
- "@arkstack/common": "^0.19.3"
37
+ "@arkstack/common": "^0.19.5"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@h3ravel/musket": "^2.2.13",
41
41
  "ioredis": "^5.4.1",
42
- "@arkstack/contract": "^0.19.3",
43
- "@arkstack/database": "^0.19.3"
42
+ "@arkstack/database": "^0.19.5",
43
+ "@arkstack/contract": "^0.19.5"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "@arkstack/database": {