@arkstack/queue 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/QueueManager-DFdwxyXk.js +603 -0
- package/dist/commands/QueueClearCommand.d.ts +13 -0
- package/dist/commands/QueueClearCommand.js +21 -0
- package/dist/commands/QueueWorkCommand.d.ts +13 -0
- package/dist/commands/QueueWorkCommand.js +36 -0
- package/dist/index.d.ts +452 -0
- package/dist/index.js +2 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Toneflix Technologies Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @arkstack/queue
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@arkstack/queue)
|
|
4
|
+
|
|
5
|
+
Queue module for Arkstack, providing a driver based queue transport and worker for background processing.
|
|
6
|
+
|
|
7
|
+
`@arkstack/queue` is the **transport** layer: connections, drivers, and the worker. Author your jobs with [`@arkstack/jobs`](../jobs), which provides the `Job` base class and a `dispatch()` helper on top of this package.
|
|
8
|
+
|
|
9
|
+
## Connections
|
|
10
|
+
|
|
11
|
+
| Driver | Backing store | Notes |
|
|
12
|
+
| ---------- | ----------------------------------- | --------------------------------------- |
|
|
13
|
+
| `sync` | runs inline | default; no worker, great for dev/tests |
|
|
14
|
+
| `database` | a table via `@arkstack/database` | a polling worker drains it |
|
|
15
|
+
| `redis` | Redis lists/sorted sets (`ioredis`) | distributed, supports delays |
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// src/config/queue.ts
|
|
21
|
+
import type { QueueConfig } from '@arkstack/queue';
|
|
22
|
+
|
|
23
|
+
export default (): QueueConfig => ({
|
|
24
|
+
default: env('QUEUE_CONNECTION', 'sync'),
|
|
25
|
+
connections: {
|
|
26
|
+
sync: { driver: 'sync' },
|
|
27
|
+
database: { driver: 'database', table: 'jobs', queue: 'default' },
|
|
28
|
+
redis: {
|
|
29
|
+
driver: 'redis',
|
|
30
|
+
host: env('REDIS_HOST', '127.0.0.1'),
|
|
31
|
+
port: env('REDIS_PORT', 6379),
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { Queue } from '@arkstack/queue';
|
|
41
|
+
|
|
42
|
+
await Queue.push(new SendWelcomeEmail(user)); // onto the default connection
|
|
43
|
+
await Queue.later(60, new ChargeInvoice(invoiceId)); // after 60 seconds
|
|
44
|
+
await Queue.connection('redis').push(job, 'emails'); // a specific connection/queue
|
|
45
|
+
await Queue.size('emails');
|
|
46
|
+
await Queue.clear('emails');
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Workers
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ark queue:work # daemon on the default connection
|
|
53
|
+
ark queue:work redis --queue=emails
|
|
54
|
+
ark queue:work --once # process a single job and exit
|
|
55
|
+
ark queue:clear redis --queue=emails
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Programmatically:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { Queue } from '@arkstack/queue';
|
|
62
|
+
|
|
63
|
+
const worker = Queue.worker('database');
|
|
64
|
+
await worker.daemon({ queue: 'default', sleep: 3 });
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A job is **deleted** on success, **released** for another attempt on failure, and **marked failed** (invoking its `failed` hook) once it exhausts `tries`.
|
|
68
|
+
|
|
69
|
+
## Job (de)serialization
|
|
70
|
+
|
|
71
|
+
Drivers other than `sync` store a serialized payload and need to reconstruct job
|
|
72
|
+
instances in the worker. `@arkstack/jobs` registers these strategies for you; to
|
|
73
|
+
do it manually:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
import { Queue } from '@arkstack/queue';
|
|
77
|
+
|
|
78
|
+
Queue.serializeUsing((job) => ({
|
|
79
|
+
/* JobPayload */
|
|
80
|
+
}));
|
|
81
|
+
Queue.resolveJobsUsing((payload) => rebuildJob(payload));
|
|
82
|
+
```
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { config } from "@arkstack/common";
|
|
3
|
+
//#region src/serialization.ts
|
|
4
|
+
/**
|
|
5
|
+
* Pluggable (de)serialization for queued jobs.
|
|
6
|
+
*
|
|
7
|
+
* The transport drivers don't know how to reconstruct application job classes,
|
|
8
|
+
* so the strategy is injected. `@arkstack/jobs` registers strategies backed by
|
|
9
|
+
* its job registry; standalone usage can register its own, or rely on the
|
|
10
|
+
* defaults (a shallow copy serializer and a resolver that must be overridden).
|
|
11
|
+
*
|
|
12
|
+
* Keeping this in its own module avoids a circular import between the queue
|
|
13
|
+
* manager and the drivers that need to serialize.
|
|
14
|
+
*/
|
|
15
|
+
const defaultSerializer = (job) => {
|
|
16
|
+
const data = typeof job.serialize === "function" ? job.serialize() : { ...job };
|
|
17
|
+
return {
|
|
18
|
+
id: randomUUID(),
|
|
19
|
+
displayName: job.constructor?.name ?? "Closure",
|
|
20
|
+
attempts: 0,
|
|
21
|
+
maxTries: job.tries ?? null,
|
|
22
|
+
backoff: job.backoff ?? 0,
|
|
23
|
+
data
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
const defaultResolver = () => {
|
|
27
|
+
throw new Error("No job resolver registered. Install/boot @arkstack/jobs, or call Queue.resolveJobsUsing().");
|
|
28
|
+
};
|
|
29
|
+
let serializer = defaultSerializer;
|
|
30
|
+
let resolver = defaultResolver;
|
|
31
|
+
const setSerializer = (fn) => {
|
|
32
|
+
serializer = fn;
|
|
33
|
+
};
|
|
34
|
+
const setResolver = (fn) => {
|
|
35
|
+
resolver = fn;
|
|
36
|
+
};
|
|
37
|
+
const serializeJob = (job) => serializer(job);
|
|
38
|
+
const resolveJob = (payload) => resolver(payload);
|
|
39
|
+
/**
|
|
40
|
+
* Reset the strategies to their defaults. Intended for tests.
|
|
41
|
+
*/
|
|
42
|
+
const resetSerialization = () => {
|
|
43
|
+
serializer = defaultSerializer;
|
|
44
|
+
resolver = defaultResolver;
|
|
45
|
+
};
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/Job.ts
|
|
48
|
+
/**
|
|
49
|
+
* A runtime handle to a job that has been popped (reserved) from a queue.
|
|
50
|
+
*
|
|
51
|
+
* The worker uses it to inspect attempt counts and to acknowledge completion
|
|
52
|
+
* (`delete`) or schedule a retry (`release`). Drivers create instances and bind
|
|
53
|
+
* the `delete`/`release` behaviour appropriate to their backing store.
|
|
54
|
+
*/
|
|
55
|
+
var Job = class {
|
|
56
|
+
payload;
|
|
57
|
+
queue;
|
|
58
|
+
handlers;
|
|
59
|
+
deleted = false;
|
|
60
|
+
released = false;
|
|
61
|
+
failed = false;
|
|
62
|
+
constructor(payload, queue, handlers) {
|
|
63
|
+
this.payload = payload;
|
|
64
|
+
this.queue = queue;
|
|
65
|
+
this.handlers = handlers;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The unique job id.
|
|
69
|
+
*
|
|
70
|
+
* @returns
|
|
71
|
+
*/
|
|
72
|
+
id() {
|
|
73
|
+
return this.payload.id;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The job's display name (used to resolve the job class).
|
|
77
|
+
*/
|
|
78
|
+
name() {
|
|
79
|
+
return this.payload.displayName;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* How many times this job has been attempted (including the current run).
|
|
83
|
+
*/
|
|
84
|
+
attempts() {
|
|
85
|
+
return this.payload.attempts;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The maximum number of attempts, or `null` for unlimited.
|
|
89
|
+
*/
|
|
90
|
+
maxTries() {
|
|
91
|
+
return this.payload.maxTries;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Seconds to wait before a released job becomes available again.
|
|
95
|
+
*/
|
|
96
|
+
backoff() {
|
|
97
|
+
return this.payload.backoff;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Acknowledge the job as done and remove it from the queue.
|
|
101
|
+
*/
|
|
102
|
+
async delete() {
|
|
103
|
+
this.deleted = true;
|
|
104
|
+
await this.handlers.delete();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Release the job back onto the queue, optionally after a delay.
|
|
108
|
+
*/
|
|
109
|
+
async release(delay = 0) {
|
|
110
|
+
this.released = true;
|
|
111
|
+
await this.handlers.release(delay);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Mark the job as having failed permanently.
|
|
115
|
+
*/
|
|
116
|
+
markAsFailed() {
|
|
117
|
+
this.failed = true;
|
|
118
|
+
}
|
|
119
|
+
isDeleted() {
|
|
120
|
+
return this.deleted;
|
|
121
|
+
}
|
|
122
|
+
isReleased() {
|
|
123
|
+
return this.released;
|
|
124
|
+
}
|
|
125
|
+
hasFailed() {
|
|
126
|
+
return this.failed;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/Contracts/QueueContract.ts
|
|
131
|
+
/**
|
|
132
|
+
* The contract every queue connection (transport) driver implements.
|
|
133
|
+
*
|
|
134
|
+
* A connection is responsible only for moving job payloads to and from its
|
|
135
|
+
* backing store. Executing jobs is the {@link import('../Worker').Worker}'s job,
|
|
136
|
+
* and reconstructing job instances is handled by the serialization strategy.
|
|
137
|
+
*/
|
|
138
|
+
var QueueContract = class {
|
|
139
|
+
connectionName = "default";
|
|
140
|
+
/** The name this connection was resolved under. */
|
|
141
|
+
getConnectionName() {
|
|
142
|
+
return this.connectionName;
|
|
143
|
+
}
|
|
144
|
+
setConnectionName(name) {
|
|
145
|
+
this.connectionName = name;
|
|
146
|
+
return this;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/drivers/DatabaseQueue.ts
|
|
151
|
+
const now$1 = () => Math.floor(Date.now() / 1e3);
|
|
152
|
+
/**
|
|
153
|
+
* A queue connection backed by a relational table via `@arkstack/database`.
|
|
154
|
+
*
|
|
155
|
+
* Expected columns: `id` (auto increment), `queue` (string), `payload` (text),
|
|
156
|
+
* `attempts` (int), `reserved_at` (nullable int), `available_at` (int),
|
|
157
|
+
* `created_at` (int). Reservation marks `reserved_at` and bumps `attempts`;
|
|
158
|
+
* completion deletes the row; release clears `reserved_at` and reschedules
|
|
159
|
+
* `available_at`. Use `ark queue:table` (or a migration) to create the table.
|
|
160
|
+
*/
|
|
161
|
+
var DatabaseQueue = class extends QueueContract {
|
|
162
|
+
options;
|
|
163
|
+
db;
|
|
164
|
+
constructor(options) {
|
|
165
|
+
super();
|
|
166
|
+
this.options = options;
|
|
167
|
+
}
|
|
168
|
+
get defaultQueue() {
|
|
169
|
+
return this.options.queue ?? "default";
|
|
170
|
+
}
|
|
171
|
+
async database() {
|
|
172
|
+
if (this.db) return this.db;
|
|
173
|
+
try {
|
|
174
|
+
const { DB } = await import("@arkstack/database");
|
|
175
|
+
this.db = DB;
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error("The database queue connection requires the \"@arkstack/database\" package.");
|
|
178
|
+
}
|
|
179
|
+
return this.db;
|
|
180
|
+
}
|
|
181
|
+
async push(job, queue) {
|
|
182
|
+
return this.pushRaw(serializeJob(job), queue ?? job.queue);
|
|
183
|
+
}
|
|
184
|
+
async pushRaw(payload, queue, availableAt = now$1()) {
|
|
185
|
+
await (await this.database()).table(this.options.table).insert({
|
|
186
|
+
queue: queue ?? this.defaultQueue,
|
|
187
|
+
payload: JSON.stringify(payload),
|
|
188
|
+
attempts: payload.attempts,
|
|
189
|
+
reserved_at: null,
|
|
190
|
+
available_at: availableAt,
|
|
191
|
+
created_at: now$1()
|
|
192
|
+
});
|
|
193
|
+
return payload.id;
|
|
194
|
+
}
|
|
195
|
+
async later(delay, job, queue) {
|
|
196
|
+
const availableAt = delay instanceof Date ? Math.floor(delay.getTime() / 1e3) : now$1() + delay;
|
|
197
|
+
return this.pushRaw(serializeJob(job), queue ?? job.queue, availableAt);
|
|
198
|
+
}
|
|
199
|
+
async pop(queue) {
|
|
200
|
+
const db = await this.database();
|
|
201
|
+
const name = queue ?? this.defaultQueue;
|
|
202
|
+
const row = await this.availableQuery(db, name).orderBy({ id: "asc" }).first();
|
|
203
|
+
if (!row) return null;
|
|
204
|
+
const attempts = row.attempts + 1;
|
|
205
|
+
await db.table(this.options.table).where({ id: row.id }).update({
|
|
206
|
+
reserved_at: now$1(),
|
|
207
|
+
attempts
|
|
208
|
+
});
|
|
209
|
+
return new Job({
|
|
210
|
+
...JSON.parse(row.payload),
|
|
211
|
+
attempts
|
|
212
|
+
}, name, {
|
|
213
|
+
delete: async () => {
|
|
214
|
+
await db.table(this.options.table).where({ id: row.id }).delete();
|
|
215
|
+
},
|
|
216
|
+
release: async (delay) => {
|
|
217
|
+
await db.table(this.options.table).where({ id: row.id }).update({
|
|
218
|
+
reserved_at: null,
|
|
219
|
+
available_at: now$1() + delay
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async size(queue) {
|
|
225
|
+
return (await this.database()).table(this.options.table).where({ queue: queue ?? this.defaultQueue }).count();
|
|
226
|
+
}
|
|
227
|
+
async clear(queue) {
|
|
228
|
+
const db = await this.database();
|
|
229
|
+
const name = queue ?? this.defaultQueue;
|
|
230
|
+
const count = await this.size(name);
|
|
231
|
+
await db.table(this.options.table).where({ queue: name }).delete();
|
|
232
|
+
return count;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Due, unreserved jobs on the given queue, oldest first. Released jobs become
|
|
236
|
+
* unreserved with a future `available_at`, so they naturally wait their turn.
|
|
237
|
+
*
|
|
238
|
+
* @param db
|
|
239
|
+
* @param queue
|
|
240
|
+
* @returns
|
|
241
|
+
*/
|
|
242
|
+
availableQuery(db, queue) {
|
|
243
|
+
return db.table(this.options.table).where({ queue }).whereNull("reserved_at").where("available_at", "<=", now$1());
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/drivers/RedisQueue.ts
|
|
248
|
+
const now = () => Math.floor(Date.now() / 1e3);
|
|
249
|
+
/**
|
|
250
|
+
* A Redis backed queue connection using an ioredis compatible client.
|
|
251
|
+
*
|
|
252
|
+
* Per queue it maintains three structures: a `waiting` list (FIFO), a `delayed`
|
|
253
|
+
* sorted set (scored by availability time), and a `reserved` sorted set (scored
|
|
254
|
+
* by retry-visibility time). On `pop` it first migrates due delayed and expired
|
|
255
|
+
* reserved jobs back to the waiting list, then reserves the head of the list.
|
|
256
|
+
*/
|
|
257
|
+
var RedisQueue = class extends QueueContract {
|
|
258
|
+
options;
|
|
259
|
+
client;
|
|
260
|
+
constructor(options) {
|
|
261
|
+
super();
|
|
262
|
+
this.options = options;
|
|
263
|
+
}
|
|
264
|
+
get defaultQueue() {
|
|
265
|
+
return this.options.queue ?? "default";
|
|
266
|
+
}
|
|
267
|
+
get retryAfter() {
|
|
268
|
+
return this.options.retryAfter ?? 90;
|
|
269
|
+
}
|
|
270
|
+
key(queue, suffix = "") {
|
|
271
|
+
return `${this.options.prefix ?? "queues:"}${queue}${suffix}`;
|
|
272
|
+
}
|
|
273
|
+
async connection() {
|
|
274
|
+
if (this.client) return this.client;
|
|
275
|
+
let Redis;
|
|
276
|
+
try {
|
|
277
|
+
Redis = (await import("ioredis")).default;
|
|
278
|
+
} catch {
|
|
279
|
+
throw new Error("The redis queue connection requires the \"ioredis\" package.");
|
|
280
|
+
}
|
|
281
|
+
this.client = this.options.url ? new Redis(this.options.url) : new Redis({
|
|
282
|
+
host: this.options.host ?? "127.0.0.1",
|
|
283
|
+
port: this.options.port ?? 6379,
|
|
284
|
+
password: this.options.password,
|
|
285
|
+
db: this.options.db ?? 0
|
|
286
|
+
});
|
|
287
|
+
return this.client;
|
|
288
|
+
}
|
|
289
|
+
/** Disconnect the underlying client. Useful for tests and shutdown. */
|
|
290
|
+
async disconnect() {
|
|
291
|
+
await this.client?.quit();
|
|
292
|
+
this.client = void 0;
|
|
293
|
+
}
|
|
294
|
+
async push(job, queue) {
|
|
295
|
+
return this.pushRaw(serializeJob(job), queue ?? job.queue);
|
|
296
|
+
}
|
|
297
|
+
async pushRaw(payload, queue, availableAt) {
|
|
298
|
+
const client = await this.connection();
|
|
299
|
+
const name = queue ?? this.defaultQueue;
|
|
300
|
+
if (availableAt && availableAt > now()) await client.zadd(this.key(name, ":delayed"), availableAt, JSON.stringify(payload));
|
|
301
|
+
else await client.rpush(this.key(name), JSON.stringify(payload));
|
|
302
|
+
return payload.id;
|
|
303
|
+
}
|
|
304
|
+
async later(delay, job, queue) {
|
|
305
|
+
const availableAt = delay instanceof Date ? Math.floor(delay.getTime() / 1e3) : now() + delay;
|
|
306
|
+
return this.pushRaw(serializeJob(job), queue ?? job.queue, availableAt);
|
|
307
|
+
}
|
|
308
|
+
async pop(queue) {
|
|
309
|
+
const client = await this.connection();
|
|
310
|
+
const name = queue ?? this.defaultQueue;
|
|
311
|
+
await this.migrate(client, name);
|
|
312
|
+
const raw = await client.lpop(this.key(name));
|
|
313
|
+
if (raw === null) return null;
|
|
314
|
+
const payload = { ...JSON.parse(raw) };
|
|
315
|
+
payload.attempts += 1;
|
|
316
|
+
const reserved = JSON.stringify(payload);
|
|
317
|
+
await client.zadd(this.key(name, ":reserved"), now() + this.retryAfter, reserved);
|
|
318
|
+
return new Job(payload, name, {
|
|
319
|
+
delete: async () => {
|
|
320
|
+
await client.zrem(this.key(name, ":reserved"), reserved);
|
|
321
|
+
},
|
|
322
|
+
release: async (delay) => {
|
|
323
|
+
await client.zrem(this.key(name, ":reserved"), reserved);
|
|
324
|
+
if (delay > 0) await client.zadd(this.key(name, ":delayed"), now() + delay, JSON.stringify(payload));
|
|
325
|
+
else await client.rpush(this.key(name), JSON.stringify(payload));
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
async size(queue) {
|
|
330
|
+
const client = await this.connection();
|
|
331
|
+
const name = queue ?? this.defaultQueue;
|
|
332
|
+
const [waiting, delayed, reserved] = await Promise.all([
|
|
333
|
+
client.llen(this.key(name)),
|
|
334
|
+
client.zcard(this.key(name, ":delayed")),
|
|
335
|
+
client.zcard(this.key(name, ":reserved"))
|
|
336
|
+
]);
|
|
337
|
+
return waiting + delayed + reserved;
|
|
338
|
+
}
|
|
339
|
+
async clear(queue) {
|
|
340
|
+
const client = await this.connection();
|
|
341
|
+
const name = queue ?? this.defaultQueue;
|
|
342
|
+
const count = await this.size(name);
|
|
343
|
+
await client.del(this.key(name), this.key(name, ":delayed"), this.key(name, ":reserved"));
|
|
344
|
+
return count;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Move due delayed jobs and expired reservations back onto the waiting list.
|
|
348
|
+
*/
|
|
349
|
+
async migrate(client, queue) {
|
|
350
|
+
for (const suffix of [":delayed", ":reserved"]) {
|
|
351
|
+
const key = this.key(queue, suffix);
|
|
352
|
+
const due = await client.zrangebyscore(key, "-inf", now());
|
|
353
|
+
for (const member of due) {
|
|
354
|
+
await client.zrem(key, member);
|
|
355
|
+
await client.rpush(this.key(queue), member);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/drivers/SyncQueue.ts
|
|
362
|
+
/**
|
|
363
|
+
* The synchronous connection runs jobs inline, the moment they are pushed.
|
|
364
|
+
*
|
|
365
|
+
* It needs no worker and no infrastructure, which makes it the natural default
|
|
366
|
+
* for development and tests. Delays are ignored — the job runs immediately.
|
|
367
|
+
*/
|
|
368
|
+
var SyncQueue = class extends QueueContract {
|
|
369
|
+
async push(job, _queue) {
|
|
370
|
+
await this.execute(job);
|
|
371
|
+
return "sync";
|
|
372
|
+
}
|
|
373
|
+
async pushRaw(payload, _queue) {
|
|
374
|
+
await this.execute(await resolveJob(payload));
|
|
375
|
+
return payload.id;
|
|
376
|
+
}
|
|
377
|
+
async later(_delay, job, _queue) {
|
|
378
|
+
return this.push(job);
|
|
379
|
+
}
|
|
380
|
+
async pop() {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
async size() {
|
|
384
|
+
return 0;
|
|
385
|
+
}
|
|
386
|
+
async clear() {
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Run a job, invoking its `failed` hook and rethrowing on error so the
|
|
391
|
+
* caller (e.g. a dispatch) observes the failure synchronously.
|
|
392
|
+
*
|
|
393
|
+
* @param job
|
|
394
|
+
*/
|
|
395
|
+
async execute(job) {
|
|
396
|
+
try {
|
|
397
|
+
await job.handle();
|
|
398
|
+
} catch (error) {
|
|
399
|
+
await job.failed?.(error);
|
|
400
|
+
throw error;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/Worker.ts
|
|
406
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
407
|
+
/**
|
|
408
|
+
* Pulls jobs from a connection and executes them.
|
|
409
|
+
*
|
|
410
|
+
* On success a job is deleted; on failure it is released for another attempt
|
|
411
|
+
* until it exhausts `maxTries`, at which point it is marked failed (invoking the
|
|
412
|
+
* job's `failed` hook) and removed.
|
|
413
|
+
*/
|
|
414
|
+
var Worker = class {
|
|
415
|
+
connection;
|
|
416
|
+
shouldStop = false;
|
|
417
|
+
constructor(connection) {
|
|
418
|
+
this.connection = connection;
|
|
419
|
+
}
|
|
420
|
+
/** Signal a running {@link daemon} loop to stop after the current job. */
|
|
421
|
+
stop() {
|
|
422
|
+
this.shouldStop = true;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Pop and process the next job. Returns `true` if a job was processed,
|
|
426
|
+
* `false` when the queue was empty.
|
|
427
|
+
*/
|
|
428
|
+
async runNextJob(queue) {
|
|
429
|
+
const job = await this.connection.pop(queue);
|
|
430
|
+
if (!job) return false;
|
|
431
|
+
await this.process(job);
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Execute a single reserved job, handling success and failure.
|
|
436
|
+
*/
|
|
437
|
+
async process(job) {
|
|
438
|
+
try {
|
|
439
|
+
await (await resolveJob(job.payload)).handle();
|
|
440
|
+
await job.delete();
|
|
441
|
+
} catch (error) {
|
|
442
|
+
await this.handleFailure(job, error);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Continuously process jobs until stopped (or until the queue drains, when
|
|
447
|
+
* `stopWhenEmpty` is set).
|
|
448
|
+
*/
|
|
449
|
+
async daemon(options = {}) {
|
|
450
|
+
const sleep = (options.sleep ?? 3) * 1e3;
|
|
451
|
+
let processed = 0;
|
|
452
|
+
this.shouldStop = false;
|
|
453
|
+
while (!this.shouldStop) {
|
|
454
|
+
if (await this.runNextJob(options.queue)) {
|
|
455
|
+
processed++;
|
|
456
|
+
if (options.maxJobs && processed >= options.maxJobs) break;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (options.stopWhenEmpty) break;
|
|
460
|
+
await wait(sleep);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
async handleFailure(job, error) {
|
|
464
|
+
const max = job.maxTries();
|
|
465
|
+
if (max !== null && job.attempts() >= max) {
|
|
466
|
+
job.markAsFailed();
|
|
467
|
+
try {
|
|
468
|
+
await (await resolveJob(job.payload)).failed?.(error);
|
|
469
|
+
} catch {}
|
|
470
|
+
await job.delete();
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
await job.release(job.backoff());
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
//#endregion
|
|
477
|
+
//#region src/config.ts
|
|
478
|
+
/**
|
|
479
|
+
* Read a value from the `queue` configuration namespace with a fallback.
|
|
480
|
+
*
|
|
481
|
+
* Never throws when the config file is missing; returns the default instead.
|
|
482
|
+
*
|
|
483
|
+
* @param key Dot path within the queue config.
|
|
484
|
+
* @param defaultValue Value returned when the key is not set.
|
|
485
|
+
*/
|
|
486
|
+
const configure = (key, defaultValue) => {
|
|
487
|
+
try {
|
|
488
|
+
return config(`queue.${key}`, defaultValue);
|
|
489
|
+
} catch {
|
|
490
|
+
return defaultValue;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
//#endregion
|
|
494
|
+
//#region src/QueueManager.ts
|
|
495
|
+
/**
|
|
496
|
+
* The queue manager and primary entry point of `@arkstack/queue`.
|
|
497
|
+
*
|
|
498
|
+
* Resolves named connections from configuration, memoizes them, and exposes
|
|
499
|
+
* static convenience methods that proxy the default connection:
|
|
500
|
+
*
|
|
501
|
+
* ```ts
|
|
502
|
+
* await Queue.push(new SendWelcomeEmail(user))
|
|
503
|
+
* await Queue.connection('redis').later(60, new ChargeInvoice(id))
|
|
504
|
+
* ```
|
|
505
|
+
*
|
|
506
|
+
* It also owns the job (de)serialization strategy, which `@arkstack/jobs`
|
|
507
|
+
* configures so workers can reconstruct application job classes.
|
|
508
|
+
*/
|
|
509
|
+
var Queue = class {
|
|
510
|
+
static connections = {};
|
|
511
|
+
static customDrivers = {};
|
|
512
|
+
/**
|
|
513
|
+
* Resolve a queue connection by name (or the default when omitted).
|
|
514
|
+
*
|
|
515
|
+
* @param name
|
|
516
|
+
* @returns
|
|
517
|
+
*/
|
|
518
|
+
static connection(name) {
|
|
519
|
+
const key = name ?? configure("default", "sync");
|
|
520
|
+
if (!this.connections[key]) this.connections[key] = this.resolve(key);
|
|
521
|
+
return this.connections[key];
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Register a custom connection driver factory.
|
|
525
|
+
*
|
|
526
|
+
* @param name
|
|
527
|
+
* @returns
|
|
528
|
+
*/
|
|
529
|
+
static extend(driver, factory) {
|
|
530
|
+
this.customDrivers[driver] = factory;
|
|
531
|
+
return this;
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Register how jobs are reconstructed from a payload (used by workers).
|
|
535
|
+
*
|
|
536
|
+
* @param name
|
|
537
|
+
* @returns
|
|
538
|
+
*/
|
|
539
|
+
static resolveJobsUsing(resolver) {
|
|
540
|
+
setResolver(resolver);
|
|
541
|
+
return this;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Register how job instances are serialized for storage.
|
|
545
|
+
*
|
|
546
|
+
* @param serializer
|
|
547
|
+
* @returns
|
|
548
|
+
*/
|
|
549
|
+
static serializeUsing(serializer) {
|
|
550
|
+
setSerializer(serializer);
|
|
551
|
+
return this;
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Clear memoized connections. Intended for tests or runtime reconfiguration.
|
|
555
|
+
*
|
|
556
|
+
* @param serializer
|
|
557
|
+
* @returns
|
|
558
|
+
*/
|
|
559
|
+
static clearResolved() {
|
|
560
|
+
this.connections = {};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Create a worker bound to the given (or default) connection.
|
|
564
|
+
*
|
|
565
|
+
* @param serializer
|
|
566
|
+
* @returns
|
|
567
|
+
*/
|
|
568
|
+
static worker(name) {
|
|
569
|
+
return new Worker(this.connection(name));
|
|
570
|
+
}
|
|
571
|
+
static resolve(name) {
|
|
572
|
+
const config = configure(`connections.${name}`, void 0);
|
|
573
|
+
if (!config) throw new Error(`Queue connection "${name}" is not configured.`);
|
|
574
|
+
return this.createConnection(config, name).setConnectionName(name);
|
|
575
|
+
}
|
|
576
|
+
static createConnection(config, name) {
|
|
577
|
+
switch (config.driver) {
|
|
578
|
+
case "sync": return new SyncQueue();
|
|
579
|
+
case "database": return new DatabaseQueue(config);
|
|
580
|
+
case "redis": return new RedisQueue(config);
|
|
581
|
+
default:
|
|
582
|
+
if (this.customDrivers[config.driver]) return this.customDrivers[config.driver](config, name);
|
|
583
|
+
throw new Error(`Unsupported queue driver: ${config.driver}`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
static push(job, queue) {
|
|
587
|
+
return this.connection(job.connection).push(job, queue);
|
|
588
|
+
}
|
|
589
|
+
static later(delay, job, queue) {
|
|
590
|
+
return this.connection(job.connection).later(delay, job, queue);
|
|
591
|
+
}
|
|
592
|
+
static pop(queue) {
|
|
593
|
+
return this.connection().pop(queue);
|
|
594
|
+
}
|
|
595
|
+
static size(queue) {
|
|
596
|
+
return this.connection().size(queue);
|
|
597
|
+
}
|
|
598
|
+
static clear(queue) {
|
|
599
|
+
return this.connection().clear(queue);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
//#endregion
|
|
603
|
+
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 };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from "@h3ravel/musket";
|
|
2
|
+
|
|
3
|
+
//#region src/commands/QueueClearCommand.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Delete all of the jobs from a queue.
|
|
6
|
+
*/
|
|
7
|
+
declare class QueueClearCommand extends Command {
|
|
8
|
+
protected signature: string;
|
|
9
|
+
protected description: string;
|
|
10
|
+
handle(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { QueueClearCommand };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { t as Queue } from "../QueueManager-DFdwxyXk.js";
|
|
2
|
+
import { Command } from "@h3ravel/musket";
|
|
3
|
+
//#region src/commands/QueueClearCommand.ts
|
|
4
|
+
/**
|
|
5
|
+
* Delete all of the jobs from a queue.
|
|
6
|
+
*/
|
|
7
|
+
var QueueClearCommand = class extends Command {
|
|
8
|
+
signature = `queue:clear
|
|
9
|
+
{connection? : The queue connection to clear. Defaults to the configured default.}
|
|
10
|
+
{--queue= : The queue to clear.}
|
|
11
|
+
`;
|
|
12
|
+
description = "Delete all of the jobs from the specified queue.";
|
|
13
|
+
async handle() {
|
|
14
|
+
const connection = this.argument("connection");
|
|
15
|
+
const queue = this.option("queue");
|
|
16
|
+
const count = await Queue.connection(connection).clear(queue);
|
|
17
|
+
this.info(`Cleared ${count} job(s) from the [${connection ?? "default"}] connection.`);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
export { QueueClearCommand };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Command } from "@h3ravel/musket";
|
|
2
|
+
|
|
3
|
+
//#region src/commands/QueueWorkCommand.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Process jobs from a queue connection.
|
|
6
|
+
*/
|
|
7
|
+
declare class QueueWorkCommand extends Command {
|
|
8
|
+
protected signature: string;
|
|
9
|
+
protected description: string;
|
|
10
|
+
handle(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
//#endregion
|
|
13
|
+
export { QueueWorkCommand };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { t as Queue } from "../QueueManager-DFdwxyXk.js";
|
|
2
|
+
import { Command } from "@h3ravel/musket";
|
|
3
|
+
//#region src/commands/QueueWorkCommand.ts
|
|
4
|
+
/**
|
|
5
|
+
* Process jobs from a queue connection.
|
|
6
|
+
*/
|
|
7
|
+
var QueueWorkCommand = class extends Command {
|
|
8
|
+
signature = `queue:work
|
|
9
|
+
{connection? : The queue connection to work. Defaults to the configured default.}
|
|
10
|
+
{--queue= : The queue to process.}
|
|
11
|
+
{--sleep=3 : Seconds to sleep when no job is available.}
|
|
12
|
+
{--max-jobs=0 : Stop after processing this many jobs (0 for unlimited).}
|
|
13
|
+
{--once : Process a single job and exit.}
|
|
14
|
+
{--stop-when-empty : Stop when the queue is empty.}
|
|
15
|
+
`;
|
|
16
|
+
description = "Start processing jobs on the queue as a daemon.";
|
|
17
|
+
async handle() {
|
|
18
|
+
const connection = this.argument("connection");
|
|
19
|
+
const worker = Queue.worker(connection);
|
|
20
|
+
const queue = this.option("queue");
|
|
21
|
+
if (this.option("once")) {
|
|
22
|
+
const handled = await worker.runNextJob(queue);
|
|
23
|
+
this.info(handled ? "Processed one job." : "No jobs available.");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.info(`Processing jobs from [${connection ?? "default"}] connection.`);
|
|
27
|
+
await worker.daemon({
|
|
28
|
+
queue,
|
|
29
|
+
sleep: Number(this.option("sleep") ?? 3),
|
|
30
|
+
maxJobs: Number(this.option("max-jobs") ?? 0),
|
|
31
|
+
stopWhenEmpty: Boolean(this.option("stop-when-empty"))
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { QueueWorkCommand };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { DotPath, DotPathValue } from "@arkstack/common";
|
|
2
|
+
|
|
3
|
+
//#region src/Job.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* A runtime handle to a job that has been popped (reserved) from a queue.
|
|
6
|
+
*
|
|
7
|
+
* The worker uses it to inspect attempt counts and to acknowledge completion
|
|
8
|
+
* (`delete`) or schedule a retry (`release`). Drivers create instances and bind
|
|
9
|
+
* the `delete`/`release` behaviour appropriate to their backing store.
|
|
10
|
+
*/
|
|
11
|
+
declare class Job {
|
|
12
|
+
readonly payload: JobPayload;
|
|
13
|
+
readonly queue: string;
|
|
14
|
+
private readonly handlers;
|
|
15
|
+
private deleted;
|
|
16
|
+
private released;
|
|
17
|
+
private failed;
|
|
18
|
+
constructor(payload: JobPayload, queue: string, handlers: JobHandlers);
|
|
19
|
+
/**
|
|
20
|
+
* The unique job id.
|
|
21
|
+
*
|
|
22
|
+
* @returns
|
|
23
|
+
*/
|
|
24
|
+
id(): string;
|
|
25
|
+
/**
|
|
26
|
+
* The job's display name (used to resolve the job class).
|
|
27
|
+
*/
|
|
28
|
+
name(): string;
|
|
29
|
+
/**
|
|
30
|
+
* How many times this job has been attempted (including the current run).
|
|
31
|
+
*/
|
|
32
|
+
attempts(): number;
|
|
33
|
+
/**
|
|
34
|
+
* The maximum number of attempts, or `null` for unlimited.
|
|
35
|
+
*/
|
|
36
|
+
maxTries(): number | null;
|
|
37
|
+
/**
|
|
38
|
+
* Seconds to wait before a released job becomes available again.
|
|
39
|
+
*/
|
|
40
|
+
backoff(): number;
|
|
41
|
+
/**
|
|
42
|
+
* Acknowledge the job as done and remove it from the queue.
|
|
43
|
+
*/
|
|
44
|
+
delete(): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Release the job back onto the queue, optionally after a delay.
|
|
47
|
+
*/
|
|
48
|
+
release(delay?: number): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* Mark the job as having failed permanently.
|
|
51
|
+
*/
|
|
52
|
+
markAsFailed(): void;
|
|
53
|
+
isDeleted(): boolean;
|
|
54
|
+
isReleased(): boolean;
|
|
55
|
+
hasFailed(): boolean;
|
|
56
|
+
}
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/Contracts/QueueContract.d.ts
|
|
59
|
+
/**
|
|
60
|
+
* The contract every queue connection (transport) driver implements.
|
|
61
|
+
*
|
|
62
|
+
* A connection is responsible only for moving job payloads to and from its
|
|
63
|
+
* backing store. Executing jobs is the {@link import('../Worker').Worker}'s job,
|
|
64
|
+
* and reconstructing job instances is handled by the serialization strategy.
|
|
65
|
+
*/
|
|
66
|
+
declare abstract class QueueContract {
|
|
67
|
+
protected connectionName: string;
|
|
68
|
+
/** The name this connection was resolved under. */
|
|
69
|
+
getConnectionName(): string;
|
|
70
|
+
setConnectionName(name: string): this;
|
|
71
|
+
/**
|
|
72
|
+
* Push a job onto the queue. Returns the job id.
|
|
73
|
+
*/
|
|
74
|
+
abstract push(job: Queueable, queue?: string): Promise<string>;
|
|
75
|
+
/**
|
|
76
|
+
* Push an already-serialized payload onto the queue. Returns the job id.
|
|
77
|
+
*/
|
|
78
|
+
abstract pushRaw(payload: JobPayload, queue?: string, availableAt?: number): Promise<string>;
|
|
79
|
+
/**
|
|
80
|
+
* Push a job to be processed after a delay (seconds or an absolute Date).
|
|
81
|
+
*/
|
|
82
|
+
abstract later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
|
|
83
|
+
/**
|
|
84
|
+
* Reserve and return the next available job, or `null` when none is ready.
|
|
85
|
+
*/
|
|
86
|
+
abstract pop(queue?: string): Promise<Job | null>;
|
|
87
|
+
/**
|
|
88
|
+
* The number of jobs waiting on the given queue.
|
|
89
|
+
*/
|
|
90
|
+
abstract size(queue?: string): Promise<number>;
|
|
91
|
+
/**
|
|
92
|
+
* Remove every job from the given queue. Returns the number removed.
|
|
93
|
+
*/
|
|
94
|
+
abstract clear(queue?: string): Promise<number>;
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/types.d.ts
|
|
98
|
+
/**
|
|
99
|
+
* Anything that can be queued and executed by a worker.
|
|
100
|
+
*
|
|
101
|
+
* The only hard requirement is a `handle` method. Optional fields let a job
|
|
102
|
+
* customize its routing and retry behaviour; the `@arkstack/jobs` package builds
|
|
103
|
+
* a richer base class on top of this contract.
|
|
104
|
+
*/
|
|
105
|
+
interface Queueable {
|
|
106
|
+
/** Perform the work for this job. */
|
|
107
|
+
handle(): unknown | Promise<unknown>;
|
|
108
|
+
/** Serialize the job's state for storage. Defaults to a shallow copy. */
|
|
109
|
+
serialize?(): Record<string, unknown>;
|
|
110
|
+
/** Override the queue this job is pushed onto. */
|
|
111
|
+
queue?: string;
|
|
112
|
+
/** Override the connection this job is pushed onto. */
|
|
113
|
+
connection?: string;
|
|
114
|
+
/** Maximum number of attempts before the job is marked failed. */
|
|
115
|
+
tries?: number;
|
|
116
|
+
/** Seconds to wait before a released job becomes available again. */
|
|
117
|
+
backoff?: number;
|
|
118
|
+
/** Seconds to delay before the job first becomes available. */
|
|
119
|
+
delay?: number;
|
|
120
|
+
/** Called when the job exhausts its attempts. */
|
|
121
|
+
failed?(error: unknown): unknown | Promise<unknown>;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The serialized envelope stored on a backing queue and reconstructed by a
|
|
125
|
+
* worker. `data` carries the job's own serialized state; `displayName` is the
|
|
126
|
+
* key used to resolve the concrete job class.
|
|
127
|
+
*/
|
|
128
|
+
interface JobPayload {
|
|
129
|
+
id: string;
|
|
130
|
+
displayName: string;
|
|
131
|
+
attempts: number;
|
|
132
|
+
maxTries: number | null;
|
|
133
|
+
backoff: number;
|
|
134
|
+
data: Record<string, unknown>;
|
|
135
|
+
}
|
|
136
|
+
interface SyncConnectionConfig {
|
|
137
|
+
driver: 'sync';
|
|
138
|
+
}
|
|
139
|
+
interface DatabaseConnectionConfig {
|
|
140
|
+
driver: 'database';
|
|
141
|
+
/** The table jobs are stored in. */
|
|
142
|
+
table: string;
|
|
143
|
+
/** Default queue name. */
|
|
144
|
+
queue?: string;
|
|
145
|
+
/** Seconds after which a reserved-but-unfinished job may be retried. */
|
|
146
|
+
retryAfter?: number;
|
|
147
|
+
}
|
|
148
|
+
interface RedisConnectionConfig {
|
|
149
|
+
driver: 'redis';
|
|
150
|
+
url?: string;
|
|
151
|
+
host?: string;
|
|
152
|
+
port?: number;
|
|
153
|
+
password?: string;
|
|
154
|
+
db?: number;
|
|
155
|
+
/** Default queue name. */
|
|
156
|
+
queue?: string;
|
|
157
|
+
/** Seconds after which a reserved-but-unfinished job may be retried. */
|
|
158
|
+
retryAfter?: number;
|
|
159
|
+
/** Key prefix for queue structures. */
|
|
160
|
+
prefix?: string;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Apps may augment this registry to register custom connection driver configs.
|
|
164
|
+
*/
|
|
165
|
+
interface CustomQueueConnectionRegistry {}
|
|
166
|
+
type QueueConnectionConfig = SyncConnectionConfig | DatabaseConnectionConfig | RedisConnectionConfig | ({
|
|
167
|
+
driver: string;
|
|
168
|
+
} & Record<string, unknown>);
|
|
169
|
+
interface QueueConfig {
|
|
170
|
+
/** The default connection used when none is requested. */
|
|
171
|
+
default: string;
|
|
172
|
+
/** The configured queue connections, keyed by name. */
|
|
173
|
+
connections: Record<string, QueueConnectionConfig> & CustomQueueConnectionRegistry;
|
|
174
|
+
}
|
|
175
|
+
/** Turns a job instance into a storable payload. */
|
|
176
|
+
type JobSerializer = (job: Queueable) => JobPayload;
|
|
177
|
+
/** Reconstructs a runnable job instance from a payload. */
|
|
178
|
+
type JobResolver = (payload: JobPayload) => Queueable | Promise<Queueable>;
|
|
179
|
+
/** Factory for a custom queue connection driver. */
|
|
180
|
+
type QueueConnectionFactory = (config: QueueConnectionConfig, name: string) => QueueContract;
|
|
181
|
+
/**
|
|
182
|
+
* Lifecycle callbacks a driver binds to a reserved job so the worker can
|
|
183
|
+
* acknowledge or retry it without knowing the driver's internals.
|
|
184
|
+
*/
|
|
185
|
+
interface JobHandlers {
|
|
186
|
+
delete(): Promise<void>;
|
|
187
|
+
release(delay: number): Promise<void>;
|
|
188
|
+
}
|
|
189
|
+
interface JobRow {
|
|
190
|
+
id: number | string;
|
|
191
|
+
queue: string;
|
|
192
|
+
payload: string;
|
|
193
|
+
attempts: number;
|
|
194
|
+
reserved_at: number | null;
|
|
195
|
+
available_at: number;
|
|
196
|
+
created_at: number;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Chainable query surface we rely on from `@arkstack/database`'s `DB`. Declared
|
|
200
|
+
* locally so the queue package needn't depend on it at build time (it is an
|
|
201
|
+
* optional peer dependency).
|
|
202
|
+
*/
|
|
203
|
+
interface Query {
|
|
204
|
+
where(where: Record<string, unknown>): Query;
|
|
205
|
+
where(column: string, operator: string, value: unknown): Query;
|
|
206
|
+
whereNull(column: string): Query;
|
|
207
|
+
orderBy(orderBy: Record<string, 'asc' | 'desc'>): Query;
|
|
208
|
+
first(): Promise<JobRow | null>;
|
|
209
|
+
update(values: Record<string, unknown>): Promise<unknown>;
|
|
210
|
+
delete(): Promise<unknown>;
|
|
211
|
+
count(): Promise<number>;
|
|
212
|
+
}
|
|
213
|
+
interface DatabaseFacade {
|
|
214
|
+
table(table: string): Query & {
|
|
215
|
+
insert(values: Record<string, unknown>): Promise<unknown>;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Structural type for the slice of ioredis we use. Declared locally so the
|
|
220
|
+
* package needn't depend on ioredis at build time (optional peer dependency).
|
|
221
|
+
*/
|
|
222
|
+
interface RedisClient {
|
|
223
|
+
rpush(key: string, ...values: string[]): Promise<number>;
|
|
224
|
+
lpop(key: string): Promise<string | null>;
|
|
225
|
+
llen(key: string): Promise<number>;
|
|
226
|
+
del(...keys: string[]): Promise<number>;
|
|
227
|
+
zadd(key: string, score: number, member: string): Promise<unknown>;
|
|
228
|
+
zrem(key: string, member: string): Promise<number>;
|
|
229
|
+
zcard(key: string): Promise<number>;
|
|
230
|
+
zrangebyscore(key: string, min: string | number, max: string | number): Promise<string[]>;
|
|
231
|
+
quit(): Promise<unknown>;
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/Worker.d.ts
|
|
235
|
+
interface WorkerOptions {
|
|
236
|
+
/** The queue name to pull from. */
|
|
237
|
+
queue?: string;
|
|
238
|
+
/** Seconds to wait when the queue is empty before polling again. */
|
|
239
|
+
sleep?: number;
|
|
240
|
+
/** Stop after processing this many jobs (0 = unlimited). */
|
|
241
|
+
maxJobs?: number;
|
|
242
|
+
/** Stop the daemon as soon as the queue drains. */
|
|
243
|
+
stopWhenEmpty?: boolean;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Pulls jobs from a connection and executes them.
|
|
247
|
+
*
|
|
248
|
+
* On success a job is deleted; on failure it is released for another attempt
|
|
249
|
+
* until it exhausts `maxTries`, at which point it is marked failed (invoking the
|
|
250
|
+
* job's `failed` hook) and removed.
|
|
251
|
+
*/
|
|
252
|
+
declare class Worker {
|
|
253
|
+
private readonly connection;
|
|
254
|
+
private shouldStop;
|
|
255
|
+
constructor(connection: QueueContract);
|
|
256
|
+
/** Signal a running {@link daemon} loop to stop after the current job. */
|
|
257
|
+
stop(): void;
|
|
258
|
+
/**
|
|
259
|
+
* Pop and process the next job. Returns `true` if a job was processed,
|
|
260
|
+
* `false` when the queue was empty.
|
|
261
|
+
*/
|
|
262
|
+
runNextJob(queue?: string): Promise<boolean>;
|
|
263
|
+
/**
|
|
264
|
+
* Execute a single reserved job, handling success and failure.
|
|
265
|
+
*/
|
|
266
|
+
process(job: Job): Promise<void>;
|
|
267
|
+
/**
|
|
268
|
+
* Continuously process jobs until stopped (or until the queue drains, when
|
|
269
|
+
* `stopWhenEmpty` is set).
|
|
270
|
+
*/
|
|
271
|
+
daemon(options?: WorkerOptions): Promise<void>;
|
|
272
|
+
private handleFailure;
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/QueueManager.d.ts
|
|
276
|
+
/**
|
|
277
|
+
* The queue manager and primary entry point of `@arkstack/queue`.
|
|
278
|
+
*
|
|
279
|
+
* Resolves named connections from configuration, memoizes them, and exposes
|
|
280
|
+
* static convenience methods that proxy the default connection:
|
|
281
|
+
*
|
|
282
|
+
* ```ts
|
|
283
|
+
* await Queue.push(new SendWelcomeEmail(user))
|
|
284
|
+
* await Queue.connection('redis').later(60, new ChargeInvoice(id))
|
|
285
|
+
* ```
|
|
286
|
+
*
|
|
287
|
+
* It also owns the job (de)serialization strategy, which `@arkstack/jobs`
|
|
288
|
+
* configures so workers can reconstruct application job classes.
|
|
289
|
+
*/
|
|
290
|
+
declare class Queue {
|
|
291
|
+
private static connections;
|
|
292
|
+
private static customDrivers;
|
|
293
|
+
/**
|
|
294
|
+
* Resolve a queue connection by name (or the default when omitted).
|
|
295
|
+
*
|
|
296
|
+
* @param name
|
|
297
|
+
* @returns
|
|
298
|
+
*/
|
|
299
|
+
static connection(name?: string): QueueContract;
|
|
300
|
+
/**
|
|
301
|
+
* Register a custom connection driver factory.
|
|
302
|
+
*
|
|
303
|
+
* @param name
|
|
304
|
+
* @returns
|
|
305
|
+
*/
|
|
306
|
+
static extend(driver: string, factory: QueueConnectionFactory): typeof Queue;
|
|
307
|
+
/**
|
|
308
|
+
* Register how jobs are reconstructed from a payload (used by workers).
|
|
309
|
+
*
|
|
310
|
+
* @param name
|
|
311
|
+
* @returns
|
|
312
|
+
*/
|
|
313
|
+
static resolveJobsUsing(resolver: JobResolver): typeof Queue;
|
|
314
|
+
/**
|
|
315
|
+
* Register how job instances are serialized for storage.
|
|
316
|
+
*
|
|
317
|
+
* @param serializer
|
|
318
|
+
* @returns
|
|
319
|
+
*/
|
|
320
|
+
static serializeUsing(serializer: JobSerializer): typeof Queue;
|
|
321
|
+
/**
|
|
322
|
+
* Clear memoized connections. Intended for tests or runtime reconfiguration.
|
|
323
|
+
*
|
|
324
|
+
* @param serializer
|
|
325
|
+
* @returns
|
|
326
|
+
*/
|
|
327
|
+
static clearResolved(): void;
|
|
328
|
+
/**
|
|
329
|
+
* Create a worker bound to the given (or default) connection.
|
|
330
|
+
*
|
|
331
|
+
* @param serializer
|
|
332
|
+
* @returns
|
|
333
|
+
*/
|
|
334
|
+
static worker(name?: string): Worker;
|
|
335
|
+
private static resolve;
|
|
336
|
+
private static createConnection;
|
|
337
|
+
static push(job: Queueable, queue?: string): Promise<string>;
|
|
338
|
+
static later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
|
|
339
|
+
static pop(queue?: string): ReturnType<QueueContract['pop']>;
|
|
340
|
+
static size(queue?: string): Promise<number>;
|
|
341
|
+
static clear(queue?: string): Promise<number>;
|
|
342
|
+
}
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region src/config.d.ts
|
|
345
|
+
/**
|
|
346
|
+
* Read a value from the `queue` configuration namespace with a fallback.
|
|
347
|
+
*
|
|
348
|
+
* Never throws when the config file is missing; returns the default instead.
|
|
349
|
+
*
|
|
350
|
+
* @param key Dot path within the queue config.
|
|
351
|
+
* @param defaultValue Value returned when the key is not set.
|
|
352
|
+
*/
|
|
353
|
+
declare const configure: <T extends DotPath<QueueConfig>>(key: T, defaultValue: unknown) => DotPathValue<QueueConfig, T>;
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/serialization.d.ts
|
|
356
|
+
declare const setSerializer: (fn: JobSerializer) => void;
|
|
357
|
+
declare const setResolver: (fn: JobResolver) => void;
|
|
358
|
+
declare const serializeJob: (job: Queueable) => JobPayload;
|
|
359
|
+
declare const resolveJob: (payload: JobPayload) => Queueable | Promise<Queueable>;
|
|
360
|
+
/**
|
|
361
|
+
* Reset the strategies to their defaults. Intended for tests.
|
|
362
|
+
*/
|
|
363
|
+
declare const resetSerialization: () => void;
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/drivers/SyncQueue.d.ts
|
|
366
|
+
/**
|
|
367
|
+
* The synchronous connection runs jobs inline, the moment they are pushed.
|
|
368
|
+
*
|
|
369
|
+
* It needs no worker and no infrastructure, which makes it the natural default
|
|
370
|
+
* for development and tests. Delays are ignored — the job runs immediately.
|
|
371
|
+
*/
|
|
372
|
+
declare class SyncQueue extends QueueContract {
|
|
373
|
+
push(job: Queueable, _queue?: string): Promise<string>;
|
|
374
|
+
pushRaw(payload: JobPayload, _queue?: string): Promise<string>;
|
|
375
|
+
later(_delay: number | Date, job: Queueable, _queue?: string): Promise<string>;
|
|
376
|
+
pop(): Promise<Job | null>;
|
|
377
|
+
size(): Promise<number>;
|
|
378
|
+
clear(): Promise<number>;
|
|
379
|
+
/**
|
|
380
|
+
* Run a job, invoking its `failed` hook and rethrowing on error so the
|
|
381
|
+
* caller (e.g. a dispatch) observes the failure synchronously.
|
|
382
|
+
*
|
|
383
|
+
* @param job
|
|
384
|
+
*/
|
|
385
|
+
private execute;
|
|
386
|
+
}
|
|
387
|
+
//#endregion
|
|
388
|
+
//#region src/drivers/DatabaseQueue.d.ts
|
|
389
|
+
/**
|
|
390
|
+
* A queue connection backed by a relational table via `@arkstack/database`.
|
|
391
|
+
*
|
|
392
|
+
* Expected columns: `id` (auto increment), `queue` (string), `payload` (text),
|
|
393
|
+
* `attempts` (int), `reserved_at` (nullable int), `available_at` (int),
|
|
394
|
+
* `created_at` (int). Reservation marks `reserved_at` and bumps `attempts`;
|
|
395
|
+
* completion deletes the row; release clears `reserved_at` and reschedules
|
|
396
|
+
* `available_at`. Use `ark queue:table` (or a migration) to create the table.
|
|
397
|
+
*/
|
|
398
|
+
declare class DatabaseQueue extends QueueContract {
|
|
399
|
+
private readonly options;
|
|
400
|
+
private db?;
|
|
401
|
+
constructor(options: DatabaseConnectionConfig);
|
|
402
|
+
private get defaultQueue();
|
|
403
|
+
private database;
|
|
404
|
+
push(job: Queueable, queue?: string): Promise<string>;
|
|
405
|
+
pushRaw(payload: JobPayload, queue?: string, availableAt?: number): Promise<string>;
|
|
406
|
+
later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
|
|
407
|
+
pop(queue?: string): Promise<Job | null>;
|
|
408
|
+
size(queue?: string): Promise<number>;
|
|
409
|
+
clear(queue?: string): Promise<number>;
|
|
410
|
+
/**
|
|
411
|
+
* Due, unreserved jobs on the given queue, oldest first. Released jobs become
|
|
412
|
+
* unreserved with a future `available_at`, so they naturally wait their turn.
|
|
413
|
+
*
|
|
414
|
+
* @param db
|
|
415
|
+
* @param queue
|
|
416
|
+
* @returns
|
|
417
|
+
*/
|
|
418
|
+
private availableQuery;
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/drivers/RedisQueue.d.ts
|
|
422
|
+
/**
|
|
423
|
+
* A Redis backed queue connection using an ioredis compatible client.
|
|
424
|
+
*
|
|
425
|
+
* Per queue it maintains three structures: a `waiting` list (FIFO), a `delayed`
|
|
426
|
+
* sorted set (scored by availability time), and a `reserved` sorted set (scored
|
|
427
|
+
* by retry-visibility time). On `pop` it first migrates due delayed and expired
|
|
428
|
+
* reserved jobs back to the waiting list, then reserves the head of the list.
|
|
429
|
+
*/
|
|
430
|
+
declare class RedisQueue extends QueueContract {
|
|
431
|
+
private readonly options;
|
|
432
|
+
private client?;
|
|
433
|
+
constructor(options: RedisConnectionConfig);
|
|
434
|
+
private get defaultQueue();
|
|
435
|
+
private get retryAfter();
|
|
436
|
+
private key;
|
|
437
|
+
private connection;
|
|
438
|
+
/** Disconnect the underlying client. Useful for tests and shutdown. */
|
|
439
|
+
disconnect(): Promise<void>;
|
|
440
|
+
push(job: Queueable, queue?: string): Promise<string>;
|
|
441
|
+
pushRaw(payload: JobPayload, queue?: string, availableAt?: number): Promise<string>;
|
|
442
|
+
later(delay: number | Date, job: Queueable, queue?: string): Promise<string>;
|
|
443
|
+
pop(queue?: string): Promise<Job | null>;
|
|
444
|
+
size(queue?: string): Promise<number>;
|
|
445
|
+
clear(queue?: string): Promise<number>;
|
|
446
|
+
/**
|
|
447
|
+
* Move due delayed jobs and expired reservations back onto the waiting list.
|
|
448
|
+
*/
|
|
449
|
+
private migrate;
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
export { CustomQueueConnectionRegistry, DatabaseConnectionConfig, DatabaseFacade, DatabaseQueue, Job, JobHandlers, JobPayload, JobResolver, JobRow, JobSerializer, Query, Queue, QueueConfig, QueueConnectionConfig, QueueConnectionFactory, QueueContract, Queueable, RedisClient, RedisConnectionConfig, RedisQueue, SyncConnectionConfig, SyncQueue, Worker, WorkerOptions, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
|
package/dist/index.js
ADDED
|
@@ -0,0 +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-DFdwxyXk.js";
|
|
2
|
+
export { DatabaseQueue, Job, Queue, QueueContract, RedisQueue, SyncQueue, Worker, configure, resetSerialization, resolveJob, serializeJob, setResolver, setSerializer };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@arkstack/queue",
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Queue module for Arkstack, providing a driver based queue transport and worker for background processing.",
|
|
6
|
+
"homepage": "https://arkstack.toneflix.net/guide/queue",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/arkstack-hq/arkstack.git",
|
|
10
|
+
"directory": "packages/queue"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"queue",
|
|
14
|
+
"worker",
|
|
15
|
+
"jobs",
|
|
16
|
+
"background",
|
|
17
|
+
"sync",
|
|
18
|
+
"redis",
|
|
19
|
+
"database",
|
|
20
|
+
"arkstack"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.js",
|
|
30
|
+
"./commands/QueueClearCommand": "./dist/commands/QueueClearCommand.js",
|
|
31
|
+
"./commands/QueueWorkCommand": "./dist/commands/QueueWorkCommand.js",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@arkstack/common": "^0.13.0"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@h3ravel/musket": "^2.2.0",
|
|
39
|
+
"ioredis": "^5.4.1",
|
|
40
|
+
"@arkstack/contract": "^0.13.0",
|
|
41
|
+
"@arkstack/database": "^0.13.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"@arkstack/database": {
|
|
45
|
+
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"ioredis": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"test": "vitest",
|
|
54
|
+
"version:patch": "pnpm version patch"
|
|
55
|
+
}
|
|
56
|
+
}
|