@bumail/queue 0.2.0 → 0.4.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/README.md +44 -8
- package/dist/chunks/index-b4xvg3xn.js +23 -0
- package/dist/chunks/index-b4xvg3xn.js.map +10 -0
- package/dist/chunks/index-vg971acr.js.map +2 -2
- package/dist/contract/queue-store.d.ts +8 -2
- package/dist/contract/queue-store.d.ts.map +1 -1
- package/dist/dsn/build.d.ts +7 -3
- package/dist/dsn/build.d.ts.map +1 -1
- package/dist/errors.d.ts +5 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +117 -33
- package/dist/index.js.map +8 -7
- package/dist/masked.d.ts +8 -0
- package/dist/masked.d.ts.map +1 -0
- package/dist/postgres/connect.d.ts +6 -0
- package/dist/postgres/connect.d.ts.map +1 -1
- package/dist/postgres/index.js +49 -28
- package/dist/postgres/index.js.map +7 -6
- package/dist/postgres/isolation.d.ts +6 -0
- package/dist/postgres/isolation.d.ts.map +1 -0
- package/dist/postgres/schema.d.ts.map +1 -1
- package/dist/postgres/store.d.ts.map +1 -1
- package/dist/queue/deliver.d.ts +10 -1
- package/dist/queue/deliver.d.ts.map +1 -1
- package/dist/queue/lease.d.ts +41 -0
- package/dist/queue/lease.d.ts.map +1 -0
- package/dist/queue/notify.d.ts +3 -2
- package/dist/queue/notify.d.ts.map +1 -1
- package/dist/queue/worker.d.ts.map +1 -1
- package/dist/redis/connect.d.ts +27 -0
- package/dist/redis/connect.d.ts.map +1 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/index.d.ts.map +1 -0
- package/dist/redis/index.js +438 -0
- package/dist/redis/index.js.map +14 -0
- package/dist/redis/items.d.ts +13 -0
- package/dist/redis/items.d.ts.map +1 -0
- package/dist/redis/options.d.ts +47 -0
- package/dist/redis/options.d.ts.map +1 -0
- package/dist/redis/run.d.ts +12 -0
- package/dist/redis/run.d.ts.map +1 -0
- package/dist/redis/scripts.d.ts +60 -0
- package/dist/redis/scripts.d.ts.map +1 -0
- package/dist/redis/store.d.ts +30 -0
- package/dist/redis/store.d.ts.map +1 -0
- package/docs/README.md +1 -1
- package/docs/guide.md +271 -16
- package/docs/roadmap.md +49 -7
- package/docs/troubleshooting.md +303 -16
- package/package.json +9 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Lua scripts a Redis store runs: each operation that writes is one
|
|
3
|
+
* script, so Redis runs it whole, with no other command between its
|
|
4
|
+
* steps. The fixed keys come as `KEYS`; an item's own keys are its
|
|
5
|
+
* prefix (`ARGV[1]`), `item:` or `message:`, and its id.
|
|
6
|
+
*
|
|
7
|
+
* An item is a hash, `<prefix>item:<id>`; its message a string,
|
|
8
|
+
* `<prefix>message:<id>`, written from the bytes given, as they are. Its
|
|
9
|
+
* member in the sorted sets is its sequence number, sixteen digits, `:`,
|
|
10
|
+
* and its id: Redis orders members of one score by their bytes, so items
|
|
11
|
+
* equally due come oldest first. Times travel as the strings JavaScript
|
|
12
|
+
* writes for them and are only compared here (`tonumber`), never written
|
|
13
|
+
* back from Lua, so none loses a digit.
|
|
14
|
+
*/
|
|
15
|
+
/** A script, and the SHA-1 `EVALSHA` names it by. */
|
|
16
|
+
export interface Script {
|
|
17
|
+
readonly source: string;
|
|
18
|
+
readonly sha: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* KEYS schema; ARGV prefix, version. Writes the version on a new queue;
|
|
22
|
+
* returns the one found.
|
|
23
|
+
*/
|
|
24
|
+
export declare const SCHEMA: Script;
|
|
25
|
+
/**
|
|
26
|
+
* KEYS items, ready, seq; ARGV prefix, id, from, recipients, size,
|
|
27
|
+
* created, message, max (or ''). `{'full', n}` when `max` items are
|
|
28
|
+
* there already, else `{'ok'}`.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ADD: Script;
|
|
31
|
+
/**
|
|
32
|
+
* KEYS ready, leases, items; ARGV prefix, owner, now, expiry. The
|
|
33
|
+
* earliest due, then the oldest, of the items no lease holds and of those
|
|
34
|
+
* whose lease expired, leased to the owner; its fields, or false. The
|
|
35
|
+
* expired leases are read whole: there are as many as the leases a
|
|
36
|
+
* crashed or stalled worker left behind, and the leased items an admin
|
|
37
|
+
* moved later, until they are due.
|
|
38
|
+
*/
|
|
39
|
+
export declare const CLAIM: Script;
|
|
40
|
+
/** KEYS leases; ARGV prefix, id, owner, expiry. 1 while the owner holds the lease, else 0. */
|
|
41
|
+
export declare const RENEW: Script;
|
|
42
|
+
/**
|
|
43
|
+
* KEYS items, ready, leases; ARGV prefix, id, owner, rev, then 'drop',
|
|
44
|
+
* or 'record' and recipients, next, attempts, delay. 0 when the owner
|
|
45
|
+
* does not hold the lease; -1 when another outcome was recorded since
|
|
46
|
+
* the item was read at `rev`, to read it again; 1 once recorded, the
|
|
47
|
+
* lease let go of, or the item and its message dropped.
|
|
48
|
+
*/
|
|
49
|
+
export declare const COMPLETE: Script;
|
|
50
|
+
/**
|
|
51
|
+
* KEYS items, ready, leases; ARGV prefix, id, at, owner (or ''). With an
|
|
52
|
+
* owner, only while it holds the lease, which it lets go of; without,
|
|
53
|
+
* the lease stays. 1 when moved, else 0.
|
|
54
|
+
*/
|
|
55
|
+
export declare const RESCHEDULE: Script;
|
|
56
|
+
/** KEYS items, ready, leases; ARGV prefix, id. The item's fields as it stood, or false; dropped with its message. */
|
|
57
|
+
export declare const CANCEL: Script;
|
|
58
|
+
/** KEYS items; ARGV prefix, first, last. A page of items, each its fields, in one read. */
|
|
59
|
+
export declare const LIST: Script;
|
|
60
|
+
//# sourceMappingURL=scripts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scripts.d.ts","sourceRoot":"","sources":["../../src/redis/scripts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAuBH,qDAAqD;AACrD,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACrB;AAQD;;;GAGG;AACH,eAAO,MAAM,MAAM,QAKjB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,GAAG,QAcd,CAAC;AAEH;;;;;;;GAOG;AACH,eAAO,MAAM,KAAK,QAyBhB,CAAC;AAEH,8FAA8F;AAC9F,eAAO,MAAM,KAAK,QAMhB,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,QAAQ,QAmBnB,CAAC;AAEH;;;;GAIG;AACH,eAAO,MAAM,UAAU,QAerB,CAAC;AAEH,qHAAqH;AACrH,eAAO,MAAM,MAAM,QAUjB,CAAC;AAEH,2FAA2F;AAC3F,eAAO,MAAM,IAAI,QAMf,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { QueueStore } from '../contract/queue-store';
|
|
2
|
+
import type { AddOptions, AttemptResult, ClaimRequest, NewQueueItem, QueueItem, QueueListOptions } from '../contract/types';
|
|
3
|
+
import type { RedisQueueStoreOptions } from './options';
|
|
4
|
+
/**
|
|
5
|
+
* A `QueueStore` on Redis through Bun's own `Bun.RedisClient`: several
|
|
6
|
+
* instances, on one machine or many, share one queue. Every operation
|
|
7
|
+
* that writes is one Lua script, which Redis runs whole, so two
|
|
8
|
+
* instances never take the same item. The due items are in sorted sets,
|
|
9
|
+
* each item a hash, its message a string written byte for byte. One
|
|
10
|
+
* Redis, or a primary with replicas — not Redis Cluster.
|
|
11
|
+
*/
|
|
12
|
+
export declare class RedisQueueStore implements QueueStore {
|
|
13
|
+
#private;
|
|
14
|
+
private constructor();
|
|
15
|
+
/** Checks the options; connects to nothing until the first call. What is wrong is `INVALID`. */
|
|
16
|
+
static open(options: RedisQueueStoreOptions): RedisQueueStore;
|
|
17
|
+
/** Closes the client the store opened for a URL, never one it was given; closing twice is fine. */
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
add(item: NewQueueItem, options?: AddOptions): Promise<QueueItem>;
|
|
20
|
+
get(id: string): Promise<QueueItem | undefined>;
|
|
21
|
+
list(options?: QueueListOptions): Promise<QueueItem[]>;
|
|
22
|
+
count(): Promise<number>;
|
|
23
|
+
readMessage(id: string): Promise<Uint8Array | undefined>;
|
|
24
|
+
claim(request: ClaimRequest): Promise<QueueItem | undefined>;
|
|
25
|
+
renew(id: string, owner: string, expiresAt: number): Promise<boolean>;
|
|
26
|
+
complete(id: string, owner: string, result: AttemptResult): Promise<QueueItem | undefined>;
|
|
27
|
+
reschedule(id: string, at: number, owner?: string): Promise<boolean>;
|
|
28
|
+
cancel(id: string): Promise<QueueItem | undefined>;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/redis/store.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,yBAAyB,CAAC;AAC1D,OAAO,KAAK,EACX,UAAU,EACV,aAAa,EACb,YAAY,EACZ,YAAY,EACZ,SAAS,EACT,gBAAgB,EAChB,MAAM,mBAAmB,CAAC;AAM3B,OAAO,KAAK,EAAoB,sBAAsB,EAAE,MAAM,WAAW,CAAC;AAa1E;;;;;;;GAOG;AACH,qBAAa,eAAgB,YAAW,UAAU;;IAQjD,OAAO;IAOP,gGAAgG;IAChG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,sBAAsB,GAAG,eAAe;IAI7D,mGAAmG;IAC7F,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAmCtB,GAAG,CAAC,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,SAAS,CAAC;IA2BrE,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAS/C,IAAI,CAAC,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAatD,KAAK,IAAI,OAAO,CAAC,MAAM,CAAC;IAKxB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,SAAS,CAAC;IAOxD,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAc5D,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAarE,QAAQ,CACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,aAAa,GACnB,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IA2B3B,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAepE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;CAQxD"}
|
package/docs/README.md
CHANGED
|
@@ -5,6 +5,6 @@ long one.
|
|
|
5
5
|
|
|
6
6
|
| Page | Read it when |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| [Guide](guide.md) | enqueuing, choosing a route (MX, a smarthost, per domain), tuning the retry schedule and the DSNs, running several workers or instances on PostgreSQL, listening to events, testing with a fake clock and sender, or writing a store of your own |
|
|
8
|
+
| [Guide](guide.md) | enqueuing, choosing a route (MX, a smarthost, per domain), tuning the retry schedule and the DSNs, running several workers or instances on PostgreSQL or Redis, listening to events, testing with a fake clock and sender, or writing a store of your own |
|
|
9
9
|
| [Troubleshooting](troubleshooting.md) | the queue or a store threw a `QueueError`, the `error` event fired, or mail stays deferred |
|
|
10
10
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
CHANGED
|
@@ -14,6 +14,7 @@ workers share it.
|
|
|
14
14
|
- [Admin](#admin)
|
|
15
15
|
- [The stores](#the-stores)
|
|
16
16
|
- [PostgreSQL](#postgresql)
|
|
17
|
+
- [Redis](#redis)
|
|
17
18
|
- [Testing](#testing)
|
|
18
19
|
- [Writing a store](#writing-a-store)
|
|
19
20
|
- [Options](#options)
|
|
@@ -97,6 +98,7 @@ recipient's outcome is recorded with the reply that decided it.
|
|
|
97
98
|
| `sendMail` refuses the route's options (`INVALID_OPTION`) | `deferred`, as `4.3.5` | tried again later, and an `error` event: the configuration is yours to fix |
|
|
98
99
|
| a recipient a store holds that `sendMail` would refuse | `failed`, as `5.1.3` | final, alone; a DSN |
|
|
99
100
|
| a sender a store holds that `sendMail` would refuse | `failed`, as `5.1.7` | final, every recipient, with no session; a DSN to that sender, which itself fails as `5.1.3` |
|
|
101
|
+
| a message the store lost ([below](#a-message-the-store-lost)) | `failed`, as `5.3.0` | final, every pending recipient, with no session; a DSN without the original, and a `MESSAGE_UNREADABLE` error |
|
|
100
102
|
|
|
101
103
|
An error is read by its `name` (`SmtpError`) and `code`, not by its
|
|
102
104
|
class: an app with a second copy of `@bumail/smtp` installed, or a
|
|
@@ -159,6 +161,40 @@ seconds (RFC 5321 §4.5.3.2's by default) and the whole session's (1800
|
|
|
159
161
|
seconds, DNS included). `createQueue` checks each one: above 0, and at
|
|
160
162
|
most 2147483 seconds, the longest a timer waits.
|
|
161
163
|
|
|
164
|
+
### A message the store lost
|
|
165
|
+
|
|
166
|
+
An item whose message the store no longer gives — a Redis key evicted
|
|
167
|
+
or deleted, a row removed by hand — cannot be sent. Rather than leave it
|
|
168
|
+
leased, claimed again at every lease and holding its `limits.maxItems`
|
|
169
|
+
place, the attempt fails every recipient still pending, at once:
|
|
170
|
+
|
|
171
|
+
- the failures are recorded, and the attempt counts; then
|
|
172
|
+
- the `error` event gets a `QueueError` of code `MESSAGE_UNREADABLE`,
|
|
173
|
+
`The message of … is unreadable: the store holds the item but not its
|
|
174
|
+
message, so every pending recipient failed`, with the item's `id`;
|
|
175
|
+
- each recipient's `failed` event follows, its `reply` `{ status:
|
|
176
|
+
'5.3.0', text: 'Message unreadable: the queue store holds the item but
|
|
177
|
+
not its message' }`, with no `code`, since no server answered;
|
|
178
|
+
- the failure DSN goes to the sender as usual — never about a message
|
|
179
|
+
from `<>` — with no third part, since there is nothing to return;
|
|
180
|
+
- the item leaves the queue. Recipients already delivered keep their
|
|
181
|
+
state, and are not told again.
|
|
182
|
+
|
|
183
|
+
In that order: the record, the `error`, the `failed` events, the `dsn`
|
|
184
|
+
event. Nothing is told before the record: when the lease was lost
|
|
185
|
+
meanwhile, the attempt reports `LEASE_LOST` only, and the worker that
|
|
186
|
+
holds the item next tells the failures; when `complete` throws, the
|
|
187
|
+
`error` event gets that error alone; when the item was cancelled
|
|
188
|
+
meanwhile, its lease still held, nothing is told at all — no session
|
|
189
|
+
ran, and nothing was recorded. An item that is gone along with its
|
|
190
|
+
message (cancelled, or finished by another worker) is left alone, as
|
|
191
|
+
before.
|
|
192
|
+
|
|
193
|
+
A store keeps an item from being claimed before its message can be read
|
|
194
|
+
— the message written first, or both in one step — so a message missing
|
|
195
|
+
at a claim is always damage, never an add still under way. Every store
|
|
196
|
+
here adds both in one step.
|
|
197
|
+
|
|
162
198
|
## Retries
|
|
163
199
|
|
|
164
200
|
```ts
|
|
@@ -195,7 +231,9 @@ final, and the item's `attempts` counts them.
|
|
|
195
231
|
The queue writes DSNs (RFC 3464) as a `multipart/report`
|
|
196
232
|
(`report-type=delivery-status`, RFC 6522) of three parts: text for a
|
|
197
233
|
person, the `message/delivery-status` fields a program reads, and the
|
|
198
|
-
original's header fields.
|
|
234
|
+
original's header fields. The third is left out, as RFC 6522 §3
|
|
235
|
+
allows, for [a message the store lost](#a-message-the-store-lost): the
|
|
236
|
+
text then says it could not be read.
|
|
199
237
|
|
|
200
238
|
```
|
|
201
239
|
Reporting-MTA: dns; mail.example.net
|
|
@@ -236,7 +274,8 @@ is written as `utf-8; j\x{F6}rg@…`, RFC 6533), and every field is folded.
|
|
|
236
274
|
## Several workers, leases and stopping
|
|
237
275
|
|
|
238
276
|
Any number of queues may share one store, in one process or several —
|
|
239
|
-
or, with [PostgreSQL](#postgresql), on several
|
|
277
|
+
or, with [PostgreSQL](#postgresql) or [Redis](#redis), on several
|
|
278
|
+
machines:
|
|
240
279
|
|
|
241
280
|
```ts
|
|
242
281
|
const store = SqliteQueueStore.open({ directory: '/var/lib/bumail/queue' });
|
|
@@ -254,9 +293,17 @@ queue.start();
|
|
|
254
293
|
stops them.
|
|
255
294
|
- **A crashed worker** loses its items when their leases expire: another
|
|
256
295
|
worker claims them then. A worker whose lease was taken meanwhile
|
|
257
|
-
records nothing, and says so on the `error` event (`LEASE_LOST`)
|
|
258
|
-
|
|
259
|
-
|
|
296
|
+
records nothing, and says so on the `error` event (`LEASE_LOST`) — also
|
|
297
|
+
when that worker has since finished the item and dropped it, the
|
|
298
|
+
message then sent twice. An item cancelled while it was delivered, its
|
|
299
|
+
lease still holding, has its outcomes told by the events, with no DSN
|
|
300
|
+
and no error. Once the lease's expiry has passed, a worker cannot tell
|
|
301
|
+
a cancel from an item another worker finished: it reports `LEASE_LOST`,
|
|
302
|
+
lost or cancelled, and tells no outcome. One gap remains when the
|
|
303
|
+
instances' clocks are out of step: an instance whose clock runs ahead
|
|
304
|
+
can take the item while this worker's clock says the lease holds, and
|
|
305
|
+
if it finishes the item between two renewals, the attempt reads as a
|
|
306
|
+
cancel. Keep the machines on NTP.
|
|
260
307
|
- **`stop()`** claims nothing more, lets every session under way end and
|
|
261
308
|
records its outcome, and gives back what was claimed but not started —
|
|
262
309
|
due at once, no attempt counted — so another worker can take it.
|
|
@@ -299,11 +346,19 @@ off(); // stops listening
|
|
|
299
346
|
| `deferred` | the same, and `nextAttemptAt` |
|
|
300
347
|
| `failed` | the same as `delivered` |
|
|
301
348
|
| `dsn` | `{ kind: 'delayed' \| 'failed', id, of, to, recipients }`: `id` is the DSN's own item, `of` the item it reports on |
|
|
302
|
-
| `error` | `{ error, id? }`: a store that failed (a lease renewal included), a DSN that could not be enqueued, a lease lost, a route `sendMail` refused (`INVALID_OPTION`) |
|
|
303
|
-
|
|
304
|
-
Events come once
|
|
305
|
-
|
|
306
|
-
|
|
349
|
+
| `error` | `{ error, id? }`: a store that failed (a lease renewal included), a DSN that could not be enqueued, a lease lost (`LEASE_LOST`), a message the store lost (`MESSAGE_UNREADABLE`), a route `sendMail` refused (`INVALID_OPTION`) |
|
|
350
|
+
|
|
351
|
+
Events about an outcome come once it is recorded, in this order: a
|
|
352
|
+
`MESSAGE_UNREADABLE` error when the store lost the message, each
|
|
353
|
+
recipient's `delivered`, `deferred` or `failed`, then the `dsn` events.
|
|
354
|
+
An item cancelled while it was delivered, its lease still held, has
|
|
355
|
+
what its sessions did told, with no DSN, though nothing is recorded;
|
|
356
|
+
for a message the store lost, no session ran, and nothing is told.
|
|
357
|
+
Other errors are told as they happen: a route `sendMail` refused during
|
|
358
|
+
its session, a lease lost or a store that failed in place of the
|
|
359
|
+
outcome, and a DSN that could not be enqueued, during the `dsn` step.
|
|
360
|
+
A listener that throws is ignored. `reply.code` is absent
|
|
361
|
+
when no server answered: a connection error, a timeout, the DNS.
|
|
307
362
|
|
|
308
363
|
## Admin
|
|
309
364
|
|
|
@@ -354,7 +409,7 @@ store.close(); // closing twice is fine
|
|
|
354
409
|
each item is claimed once; a writer waits up to `busyTimeout`
|
|
355
410
|
milliseconds for another.
|
|
356
411
|
Not on a network file system: SQLite's locks do not hold there. For
|
|
357
|
-
several machines, use [PostgreSQL](#postgresql).
|
|
412
|
+
several machines, use [PostgreSQL](#postgresql) or [Redis](#redis).
|
|
358
413
|
- **Messages in a table of their own**, `messages`, dropped with their
|
|
359
414
|
item in the same transaction. A queue holds a message for days at most
|
|
360
415
|
and never shares one between items, so content addressing (as
|
|
@@ -368,6 +423,10 @@ store.close(); // closing twice is fine
|
|
|
368
423
|
On PostgreSQL, for instances on several machines: see
|
|
369
424
|
[PostgreSQL](#postgresql).
|
|
370
425
|
|
|
426
|
+
### `RedisQueueStore`
|
|
427
|
+
|
|
428
|
+
On Redis, for instances on several machines: see [Redis](#redis).
|
|
429
|
+
|
|
371
430
|
## PostgreSQL
|
|
372
431
|
|
|
373
432
|
```ts
|
|
@@ -399,8 +458,23 @@ a store on the same database, and they share one queue.
|
|
|
399
458
|
- It is typed by its shape, `PostgresClient` (`unsafe`, `begin` and
|
|
400
459
|
`close`), so the declarations need nothing of `@types/bun`; a `Bun.SQL`
|
|
401
460
|
client fits it.
|
|
461
|
+
- **Writes run at `READ COMMITTED`**, whatever the client's sessions
|
|
462
|
+
default to: each transaction that writes — a claim, a renewal, an
|
|
463
|
+
outcome, an add, a reschedule, a cancel, a migration — starts with
|
|
464
|
+
`SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE`. A client
|
|
465
|
+
shared with code that sets `default_transaction_isolation` to
|
|
466
|
+
`repeatable read` or `serializable` (`new Bun.SQL({ url, connection: {
|
|
467
|
+
default_transaction_isolation: 'serializable' } })`) still serves the
|
|
468
|
+
queue: the claim's `FOR UPDATE SKIP LOCKED` and the `maxItems` lock
|
|
469
|
+
need that level, and under a stricter one concurrent writes would fail
|
|
470
|
+
with SQLSTATE 40001. Reads are one statement each, at the client's
|
|
471
|
+
level.
|
|
402
472
|
- **Nothing connects at `open`**: a wrong option is refused there, and a
|
|
403
|
-
database out of reach on the first call.
|
|
473
|
+
database out of reach on the first call. A URL is never repeated in an
|
|
474
|
+
error, and the password — the URL's, or a given `Bun.SQL`'s — is
|
|
475
|
+
masked should the database's reason name it: always where it stands
|
|
476
|
+
in a URL (`:…@`), and alone, elsewhere, when it is 4 characters or
|
|
477
|
+
more (masking a shorter one everywhere would garble the reason).
|
|
404
478
|
|
|
405
479
|
### The schema
|
|
406
480
|
|
|
@@ -417,7 +491,10 @@ They are the `bun:sqlite` store's tables, column for column. The prefix
|
|
|
417
491
|
is lowercase letters, digits and underscores, starting with a letter or
|
|
418
492
|
an underscore, 40 characters at most: it is written into the statements,
|
|
419
493
|
never bound. Give each queue its own prefix to keep several in one
|
|
420
|
-
database
|
|
494
|
+
database, and never share a prefix with another package's or
|
|
495
|
+
application's tables: the store cannot tell another `<prefix>schema` or
|
|
496
|
+
`<prefix>messages` from its own, and would read that version as the
|
|
497
|
+
queue's. A `bytea` holds 1 GB at most, far above `limits.maxMessageSize`
|
|
421
498
|
(25 MiB by default).
|
|
422
499
|
|
|
423
500
|
### Migrations
|
|
@@ -476,8 +553,9 @@ RETURNING …
|
|
|
476
553
|
|
|
477
554
|
### Pool size
|
|
478
555
|
|
|
479
|
-
|
|
480
|
-
|
|
556
|
+
A read holds a connection for one statement, and each write for one
|
|
557
|
+
short transaction: the isolation level, then its statement or two, then
|
|
558
|
+
the commit. A worker has at most
|
|
481
559
|
`concurrency` items (20) in flight, each mostly waiting on a remote SMTP
|
|
482
560
|
server, not the database: `Bun.SQL`'s default pool of 10 connections is
|
|
483
561
|
enough for one, and a call waits for a free connection rather than fail.
|
|
@@ -485,6 +563,172 @@ Raise `max` only when a busy worker waits on the pool, and keep every
|
|
|
485
563
|
instance's pool, and your application's, within the server's
|
|
486
564
|
`max_connections` (100 by default): four instances of `max: 10` take 40.
|
|
487
565
|
|
|
566
|
+
## Redis
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
import { createQueue } from '@bumail/queue';
|
|
570
|
+
import { RedisQueueStore } from '@bumail/queue/redis';
|
|
571
|
+
|
|
572
|
+
const client = new Bun.RedisClient(Bun.env['REDIS_URL']);
|
|
573
|
+
const store = RedisQueueStore.open({ client, keyPrefix: 'mail:queue:' });
|
|
574
|
+
const queue = createQueue({ store, hostname, resolver, owner: `${host}-${process.pid}` });
|
|
575
|
+
queue.start();
|
|
576
|
+
|
|
577
|
+
process.on('SIGTERM', async () => {
|
|
578
|
+
await queue.stop();
|
|
579
|
+
client.close(); // yours: the store never closes a client it was given
|
|
580
|
+
});
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
`@bumail/queue/redis` runs on Bun's own `Bun.RedisClient` (`Bun.redis`
|
|
584
|
+
is its default instance): there is no driver to install and no peer to
|
|
585
|
+
add. Every instance of your server opens a store on the same Redis, and
|
|
586
|
+
they share one queue. It is held to the same contract specs as the other
|
|
587
|
+
stores, and to the same specs of two instances delivering every item
|
|
588
|
+
exactly once, against `redis:7`.
|
|
589
|
+
|
|
590
|
+
### The client
|
|
591
|
+
|
|
592
|
+
- **`client`** is a `Bun.RedisClient` of yours — you set its options and
|
|
593
|
+
close it — or **`url`** a `redis://` (`rediss://` for TLS, or the other
|
|
594
|
+
schemes `Bun.RedisClient` takes), for which the store opens a client
|
|
595
|
+
with Bun's defaults and closes it in `close()`. One or the other, never
|
|
596
|
+
both.
|
|
597
|
+
- It is typed by its shape, `RedisQueueClient` (`send`, `getBuffer` and
|
|
598
|
+
`close`), so the declarations need nothing of `@types/bun`; a
|
|
599
|
+
`Bun.RedisClient` fits it.
|
|
600
|
+
- **Nothing connects at `open`**: a wrong option is refused there, and a
|
|
601
|
+
server out of reach on the first call. With Bun's defaults a client
|
|
602
|
+
reconnects on its own, up to 20 times, and holds the commands sent
|
|
603
|
+
while it is disconnected until Redis is back (a `PING` sent during a
|
|
604
|
+
5-second stop answered once Redis started again). A command already
|
|
605
|
+
sent when the connection drops, or sent before the client noticed,
|
|
606
|
+
fails instead (`Connection closed`): a renewal tries again on its own,
|
|
607
|
+
and a claim or an outcome is told on `error` (see Troubleshooting).
|
|
608
|
+
- One client is one connection, pipelined: every call of one store goes
|
|
609
|
+
through it, so there is no pool to size.
|
|
610
|
+
- A URL is never repeated in an error, and its password is masked should
|
|
611
|
+
Redis's or Bun's reason name it, as on PostgreSQL. A given client's
|
|
612
|
+
password is not known to the store, and not masked.
|
|
613
|
+
|
|
614
|
+
### The keys
|
|
615
|
+
|
|
616
|
+
Every key starts with `keyPrefix` (`bumail:queue:` by default):
|
|
617
|
+
|
|
618
|
+
| key | type | what it holds |
|
|
619
|
+
| --- | --- | --- |
|
|
620
|
+
| `<prefix>item:<id>` | hash | an item: `id`, `from`, `recipients` (JSON, each recipient's state), `size`, `created`, `next`, `attempts`, `delay`, while leased `owner` and `expires`, its `member` in the sorted sets and `rev`, how many outcomes were recorded |
|
|
621
|
+
| `<prefix>message:<id>` | string | the message as enqueued, whole, byte for byte |
|
|
622
|
+
| `<prefix>items` | sorted set | every item, scored by its next attempt: `list` and `count` |
|
|
623
|
+
| `<prefix>ready` | sorted set | the items no lease holds, scored by their next attempt |
|
|
624
|
+
| `<prefix>leases` | sorted set | the leased items, scored by when their leases expire |
|
|
625
|
+
| `<prefix>seq` | string | the counter that orders items equally due |
|
|
626
|
+
| `<prefix>schema` | string | the layout version |
|
|
627
|
+
|
|
628
|
+
A member of the sorted sets is the item's sequence number, sixteen
|
|
629
|
+
digits, then `:` and its id, so items equally due come oldest first.
|
|
630
|
+
Times are kept as the strings JavaScript writes for them, and read back
|
|
631
|
+
exactly. The prefix is lowercase letters, digits, `_`, `:`, `.` and
|
|
632
|
+
`-`, starting with a letter, 40 characters at most: no `{`, so no
|
|
633
|
+
Cluster hash tag, and no glob character, so `SCAN 0 MATCH <prefix>*`
|
|
634
|
+
finds the queue's keys and nothing else. A `<prefix>schema` that holds
|
|
635
|
+
anything but a layout version is refused at the first call: the prefix
|
|
636
|
+
is another application's. Give each queue its own
|
|
637
|
+
prefix to keep several in one Redis database. An id the store did not
|
|
638
|
+
make (`crypto.randomUUID()`'s form) is one no item has: no id a caller
|
|
639
|
+
gives reaches another key.
|
|
640
|
+
|
|
641
|
+
### Several instances and leases
|
|
642
|
+
|
|
643
|
+
Every operation that writes is one Lua script, run by `EVALSHA` (by
|
|
644
|
+
`EVAL` when Redis no longer has it, after a restart, a failover or a
|
|
645
|
+
`SCRIPT FLUSH`): Redis runs a script whole, with no command of another
|
|
646
|
+
instance between its steps.
|
|
647
|
+
|
|
648
|
+
- **A claim** takes the first item of `ready` due by `now`, and every
|
|
649
|
+
lease of `leases` expired by `now`, keeps the earliest due of them,
|
|
650
|
+
then the oldest, and moves it from `ready` to `leases` with its new
|
|
651
|
+
expiry, in one script. Two instances never take the same item. An
|
|
652
|
+
expired lease is found at once, not by a sweep; there are only as many
|
|
653
|
+
as a crashed or stalled instance left.
|
|
654
|
+
- **`complete`** reads the item, applies the outcome in JavaScript, and
|
|
655
|
+
records it with a script that checks the owner still holds the lease
|
|
656
|
+
and that no other outcome was recorded since (`rev`); otherwise it
|
|
657
|
+
reads again, at most 8 times, then records nothing (`LEASE_LOST`), as
|
|
658
|
+
it does when the lease was lost.
|
|
659
|
+
- **`renew`**, **`reschedule`** and **`cancel`** are a script each, and
|
|
660
|
+
an `add` with `limits.maxItems` counts and adds in one, so two
|
|
661
|
+
instances never both take the last place.
|
|
662
|
+
- **The clocks** of the instances must agree, as on PostgreSQL: the store
|
|
663
|
+
keeps no clock, and Redis's is never read.
|
|
664
|
+
|
|
665
|
+
### Deploying Redis
|
|
666
|
+
|
|
667
|
+
- **One Redis, or a primary with replicas — not Redis Cluster.** A script
|
|
668
|
+
reaches each item's keys, which are not all named up front, so the
|
|
669
|
+
queue's keys must live on one node. Point the store at the primary: a
|
|
670
|
+
replica refuses writes (`READONLY`). After a failover, the URL must
|
|
671
|
+
reach the new primary — a provider's endpoint that follows it, say.
|
|
672
|
+
- **Durability is Redis's, and weaker than PostgreSQL's.** Redis
|
|
673
|
+
acknowledges a write once it is in memory:
|
|
674
|
+
- with `appendonly yes` and `appendfsync always`, a write is on disk
|
|
675
|
+
before it is acknowledged, as on `bun:sqlite` or PostgreSQL;
|
|
676
|
+
- with `appendfsync everysec` (Redis's default once AOF is on), a
|
|
677
|
+
crash of the server loses up to about a second of acknowledged
|
|
678
|
+
writes;
|
|
679
|
+
- with snapshots only (`save`, AOF off), it loses everything since the
|
|
680
|
+
last snapshot, minutes of it;
|
|
681
|
+
- **replication is asynchronous**: a primary that fails before its
|
|
682
|
+
replica has a write loses that write when the replica is promoted,
|
|
683
|
+
whatever the fsync setting.
|
|
684
|
+
|
|
685
|
+
A lost write is an enqueued message gone (the client was told it was
|
|
686
|
+
queued), an outcome forgotten (a recipient delivered and sent the
|
|
687
|
+
message again), or a lease given twice. Use `appendfsync always`
|
|
688
|
+
where a lost message matters, and PostgreSQL where it must survive a
|
|
689
|
+
failover.
|
|
690
|
+
- **`maxmemory-policy noeviction`.** Every message lives in memory, whole,
|
|
691
|
+
until its item is done. Under any other policy a full Redis evicts
|
|
692
|
+
queue keys — an item, or its message — to make room; with
|
|
693
|
+
`noeviction` it refuses the write (`OOM command not allowed`), and
|
|
694
|
+
`enqueue` rejects with that `RedisError`. An item whose hash is gone
|
|
695
|
+
all the same (evicted, or deleted by hand) is dropped by the next
|
|
696
|
+
claim that meets it, its message with it, and never leased. An item
|
|
697
|
+
whose message alone is gone fails its pending recipients at once
|
|
698
|
+
([a message the store lost](#a-message-the-store-lost)). Bound the queue with `limits.maxItems` and
|
|
699
|
+
`limits.maxMessageSize`, and size `maxmemory` for both.
|
|
700
|
+
- **An ACL user** needs the commands the store sends and the ones its
|
|
701
|
+
scripts call, on its prefix's keys (`~<keyPrefix>*`; here the
|
|
702
|
+
example's `mail:queue:`), and nothing else:
|
|
703
|
+
|
|
704
|
+
```
|
|
705
|
+
ACL SETUSER bumail-worker on >secret resetkeys ~mail:queue:* -@all +evalsha +eval +get +set +del +incr +hgetall +hget +hset +hdel +hincrby +zadd +zrem +zrange +zrangebyscore +zcard
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
### What `Bun.redis` does with bytes and text
|
|
709
|
+
|
|
710
|
+
Measured on Bun 1.4.2 against Redis 7:
|
|
711
|
+
|
|
712
|
+
- An argument may be a `Uint8Array`: `send` writes it as it is, every
|
|
713
|
+
byte value, to `EVAL` and `EVALSHA` too. That is how a message is
|
|
714
|
+
written, through the script that adds its item.
|
|
715
|
+
- A bulk string in a reply is decoded as UTF-8 into a JavaScript string,
|
|
716
|
+
so bytes that are not UTF-8 do not come back from `send`. A message is
|
|
717
|
+
read with `getBuffer`, which keeps the bytes.
|
|
718
|
+
- A string argument is encoded as UTF-8, and a lone surrogate becomes
|
|
719
|
+
U+FFFD without a word: the contract's checks refuse a lone surrogate
|
|
720
|
+
(and a NUL, which Redis could keep) before anything is sent, so what is
|
|
721
|
+
read back is always what was given.
|
|
722
|
+
- `HGETALL` sent directly comes back as an object; the same from a script
|
|
723
|
+
as a flat list of names and values. A script's `false` is `null`.
|
|
724
|
+
- An error reply rejects with a `RedisError` whose message is Redis's
|
|
725
|
+
(`NOSCRIPT No matching script…`); none of them repeats the URL.
|
|
726
|
+
- A server out of reach: with Bun's defaults a call waits for 20
|
|
727
|
+
reconnections, about half a minute, then rejects with `Max
|
|
728
|
+
reconnection attempts reached`; a URL that is not one is not refused by
|
|
729
|
+
`new Bun.RedisClient`, only by its first call, so the store checks the
|
|
730
|
+
scheme itself.
|
|
731
|
+
|
|
488
732
|
## Testing
|
|
489
733
|
|
|
490
734
|
Inject the clock and the sender, and call `deliverDue()` by hand: no
|
|
@@ -541,20 +785,31 @@ points at it, and `mxPort` its port.
|
|
|
541
785
|
A store implements `QueueStore`, throws `QueueError` with the contract's
|
|
542
786
|
codes, and keeps its promises:
|
|
543
787
|
|
|
788
|
+
- `add` never makes an item claimable before its message is readable:
|
|
789
|
+
write the message first, or both in one step.
|
|
544
790
|
- `claim` is atomic: two claimers, in any process, never get the same
|
|
545
791
|
item; an item whose lease expired is claimable again.
|
|
792
|
+
- `readMessage` gives `undefined` once the item is gone, and also when
|
|
793
|
+
the store still holds the item but has lost its message: the queue
|
|
794
|
+
then fails the item's pending recipients as `5.3.0`
|
|
795
|
+
([a message the store lost](#a-message-the-store-lost)), so never
|
|
796
|
+
throw for a missing message.
|
|
546
797
|
- `complete` applies the outcome, the schedule and the count, and lets go
|
|
547
798
|
of the lease, in one step and only for the lease's owner; a final
|
|
548
799
|
status is never changed; an item whose every recipient is final is
|
|
549
800
|
dropped with its message.
|
|
550
801
|
- Times are given by the caller; the store keeps no clock.
|
|
551
802
|
- Nothing it returns is shared with what it keeps.
|
|
803
|
+
- `reschedule` with an owner refuses an empty owner as `INVALID`, even
|
|
804
|
+
for an id no item has. An id a store cannot keep (one holding a NUL or
|
|
805
|
+
a lone surrogate) may instead be answered `false`, unknown, before the
|
|
806
|
+
owner is checked.
|
|
552
807
|
- Text it keeps holds no NUL and no lone surrogate: the contract's
|
|
553
808
|
checks refuse them (`INVALID`) in what is added, claimed and recorded,
|
|
554
809
|
since PostgreSQL cannot keep them, and an id holding one is unknown.
|
|
555
810
|
The queue never gives a store such text: it cleans every reply.
|
|
556
811
|
|
|
557
|
-
The contract's specs, `describeQueueStore`, which
|
|
812
|
+
The contract's specs, `describeQueueStore`, which every store here runs,
|
|
558
813
|
are internal for now: a store written outside this package cannot run
|
|
559
814
|
them yet.
|
|
560
815
|
|
package/docs/roadmap.md
CHANGED
|
@@ -6,19 +6,17 @@ only number on it.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
Nothing in progress: the
|
|
10
|
-
taken. The
|
|
9
|
+
Nothing in progress: the MongoDB store, first under Next, is the next
|
|
10
|
+
one taken. The Redis store shipped in 0.3.0 (see Shipped).
|
|
11
11
|
|
|
12
12
|
## Next
|
|
13
13
|
|
|
14
14
|
More answers to the `QueueStore` contract, built for several instances of
|
|
15
15
|
a server sharing one queue — each claim atomic across them, each lease
|
|
16
16
|
taken back from an instance that crashed — and each held to the same
|
|
17
|
-
contract specs as the memory, `bun:sqlite
|
|
18
|
-
adds a dependency.
|
|
17
|
+
contract specs as the memory, `bun:sqlite`, PostgreSQL and Redis
|
|
18
|
+
stores. None adds a dependency.
|
|
19
19
|
|
|
20
|
-
- **A Redis store**, on Bun's own `Bun.redis` — the due items in a sorted
|
|
21
|
-
set, the claim and the lease in one script.
|
|
22
20
|
- **A MongoDB store** — the contract answered structurally, against a
|
|
23
21
|
collection of the shape a MongoDB driver gives, so the package peers on
|
|
24
22
|
no driver: the application passes in its own collection; the claim a
|
|
@@ -55,7 +53,7 @@ Then:
|
|
|
55
53
|
|
|
56
54
|
- **A runtime dependency.** The memory store needs none, the `bun:sqlite`
|
|
57
55
|
store uses Bun's own SQLite, the PostgreSQL store Bun's own `Bun.sql`,
|
|
58
|
-
the Redis store
|
|
56
|
+
the Redis store Bun's own `Bun.redis`, and the MongoDB store will take
|
|
59
57
|
the application's own collection, typed by its shape.
|
|
60
58
|
- **Relaying on its own.** The queue sends what the app enqueues; who may
|
|
61
59
|
send is decided before, by the SMTP server's AUTH or the app. No option
|
|
@@ -67,6 +65,50 @@ Then:
|
|
|
67
65
|
|
|
68
66
|
### Unreleased — merged, not yet published
|
|
69
67
|
|
|
68
|
+
- **The PostgreSQL store's writes run at `READ COMMITTED`** — whatever
|
|
69
|
+
the client's sessions default to, so a client shared with code that
|
|
70
|
+
defaults to `repeatable read` or `serializable` serves the queue:
|
|
71
|
+
concurrent claims, outcomes, adds with `maxItems` and migrations no
|
|
72
|
+
longer fail with SQLSTATE 40001, and `maxItems` holds under them.
|
|
73
|
+
- **A message the store lost no longer loops** — an item whose message
|
|
74
|
+
is gone (a Redis key evicted or deleted, a row removed by hand) fails
|
|
75
|
+
its pending recipients at once as `5.3.0`, `Message unreadable`, says
|
|
76
|
+
so on the `error` event (`MESSAGE_UNREADABLE`), sends its failure DSN
|
|
77
|
+
without the original and leaves the queue, rather than be claimed
|
|
78
|
+
again at every lease, forever, holding its `maxItems` place.
|
|
79
|
+
- **A set-up error never shows the password** — `The PostgreSQL queue
|
|
80
|
+
cannot be set up` and `The Redis queue cannot be set up` mask it
|
|
81
|
+
should the database's reason repeat it; and a password under 4
|
|
82
|
+
characters is masked only where a URL holds it, so the rest of the
|
|
83
|
+
reason stays readable.
|
|
84
|
+
|
|
85
|
+
### 0.3.0
|
|
86
|
+
|
|
87
|
+
- **A Redis store**, `@bumail/queue/redis`, on Bun's own `Bun.redis`,
|
|
88
|
+
for instances of a server on several machines sharing one queue:
|
|
89
|
+
`RedisQueueStore.open({ client })` takes a `Bun.RedisClient`, or
|
|
90
|
+
`{ url }` a `redis://` URL, and needs no driver. Every operation that
|
|
91
|
+
writes is one Lua script, which Redis runs whole: two instances never
|
|
92
|
+
take the same item, a crashed instance's leases expire and its items
|
|
93
|
+
are claimed again, and `maxItems` holds across instances. The due
|
|
94
|
+
items are in sorted sets, each item a hash, its message written byte
|
|
95
|
+
for byte, every key under a `keyPrefix`. Held to the same contract
|
|
96
|
+
specs as the other stores, and to specs of two instances delivering
|
|
97
|
+
every item exactly once. One Redis, or a primary with replicas, not
|
|
98
|
+
Cluster; its durability is Redis's, which the guide spells out.
|
|
99
|
+
- **A lease lost to an instance that finished the item is reported** —
|
|
100
|
+
an instance that stalled past its lease, while another claimed the
|
|
101
|
+
item, delivered it and dropped it, no longer takes the item's absence
|
|
102
|
+
for a cancel: it reports `LEASE_LOST` on the `error` event, tells no
|
|
103
|
+
outcome and sends no DSN, so the operator learns the message may have
|
|
104
|
+
gone out twice. With no record of who dropped an item, a lease that
|
|
105
|
+
expired, or a renewal refused while the item was still there, is
|
|
106
|
+
reported as lost or cancelled; a cancel under a lease that
|
|
107
|
+
still held is told as before, and no longer draws a `LEASE_LOST` from
|
|
108
|
+
a renewal that finds the item gone.
|
|
109
|
+
|
|
110
|
+
### 0.2.0
|
|
111
|
+
|
|
70
112
|
- **A PostgreSQL store**, `@bumail/queue/postgres`, on Bun's own
|
|
71
113
|
`Bun.sql`, for instances of a server on several machines sharing one
|
|
72
114
|
queue: `PostgresQueueStore.open({ sql })` takes a `Bun.SQL` client or a
|