@bumail/queue 0.1.0 → 0.3.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 +71 -5
- package/dist/chunks/{index-xhsw4xcz.js → index-vg971acr.js} +70 -6
- package/dist/chunks/index-vg971acr.js.map +12 -0
- package/dist/chunks/index-y3n1w2az.js +18 -0
- package/dist/chunks/index-y3n1w2az.js.map +10 -0
- package/dist/contract/checks.d.ts +6 -0
- package/dist/contract/checks.d.ts.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/index.js +86 -55
- package/dist/index.js.map +8 -8
- package/dist/masked.d.ts +7 -0
- package/dist/masked.d.ts.map +1 -0
- package/dist/memory/index.js +3 -5
- package/dist/memory/index.js.map +2 -2
- package/dist/postgres/connect.d.ts +17 -0
- package/dist/postgres/connect.d.ts.map +1 -0
- package/dist/postgres/index.d.ts +2 -0
- package/dist/postgres/index.d.ts.map +1 -0
- package/dist/postgres/index.js +374 -0
- package/dist/postgres/index.js.map +14 -0
- package/dist/postgres/options.d.ts +37 -0
- package/dist/postgres/options.d.ts.map +1 -0
- package/dist/postgres/rows.d.ts +21 -0
- package/dist/postgres/rows.d.ts.map +1 -0
- package/dist/postgres/schema.d.ts +20 -0
- package/dist/postgres/schema.d.ts.map +1 -0
- package/dist/postgres/statements.d.ts +54 -0
- package/dist/postgres/statements.d.ts.map +1 -0
- package/dist/postgres/store.d.ts +38 -0
- package/dist/postgres/store.d.ts.map +1 -0
- package/dist/queue/deliver.d.ts +7 -0
- 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/outcome.d.ts.map +1 -1
- package/dist/queue/settings/index.d.ts.map +1 -1
- package/dist/queue/worker.d.ts.map +1 -1
- package/dist/redis/connect.d.ts +25 -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 +430 -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/dist/sqlite/index.js +3 -5
- package/dist/sqlite/index.js.map +2 -2
- package/dist/text.d.ts +9 -3
- package/dist/text.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +314 -7
- package/docs/roadmap.md +73 -19
- package/docs/troubleshooting.md +317 -7
- package/package.json +15 -3
- package/dist/chunks/index-p4k4rjb7.js +0 -15
- package/dist/chunks/index-p4k4rjb7.js.map +0 -10
- package/dist/chunks/index-xhsw4xcz.js.map +0 -10
package/docs/guide.md
CHANGED
|
@@ -13,6 +13,8 @@ workers share it.
|
|
|
13
13
|
- [Events](#events)
|
|
14
14
|
- [Admin](#admin)
|
|
15
15
|
- [The stores](#the-stores)
|
|
16
|
+
- [PostgreSQL](#postgresql)
|
|
17
|
+
- [Redis](#redis)
|
|
16
18
|
- [Testing](#testing)
|
|
17
19
|
- [Writing a store](#writing-a-store)
|
|
18
20
|
- [Options](#options)
|
|
@@ -234,7 +236,9 @@ is written as `utf-8; j\x{F6}rg@…`, RFC 6533), and every field is folded.
|
|
|
234
236
|
|
|
235
237
|
## Several workers, leases and stopping
|
|
236
238
|
|
|
237
|
-
Any number of queues may share one store, in one process or several
|
|
239
|
+
Any number of queues may share one store, in one process or several —
|
|
240
|
+
or, with [PostgreSQL](#postgresql) or [Redis](#redis), on several
|
|
241
|
+
machines:
|
|
238
242
|
|
|
239
243
|
```ts
|
|
240
244
|
const store = SqliteQueueStore.open({ directory: '/var/lib/bumail/queue' });
|
|
@@ -252,9 +256,17 @@ queue.start();
|
|
|
252
256
|
stops them.
|
|
253
257
|
- **A crashed worker** loses its items when their leases expire: another
|
|
254
258
|
worker claims them then. A worker whose lease was taken meanwhile
|
|
255
|
-
records nothing, and says so on the `error` event (`LEASE_LOST`)
|
|
256
|
-
|
|
257
|
-
|
|
259
|
+
records nothing, and says so on the `error` event (`LEASE_LOST`) — also
|
|
260
|
+
when that worker has since finished the item and dropped it, the
|
|
261
|
+
message then sent twice. An item cancelled while it was delivered, its
|
|
262
|
+
lease still holding, has its outcomes told by the events, with no DSN
|
|
263
|
+
and no error. Once the lease's expiry has passed, a worker cannot tell
|
|
264
|
+
a cancel from an item another worker finished: it reports `LEASE_LOST`,
|
|
265
|
+
lost or cancelled, and tells no outcome. One gap remains when the
|
|
266
|
+
instances' clocks are out of step: an instance whose clock runs ahead
|
|
267
|
+
can take the item while this worker's clock says the lease holds, and
|
|
268
|
+
if it finishes the item between two renewals, the attempt reads as a
|
|
269
|
+
cancel. Keep the machines on NTP.
|
|
258
270
|
- **`stop()`** claims nothing more, lets every session under way end and
|
|
259
271
|
records its outcome, and gives back what was claimed but not started —
|
|
260
272
|
due at once, no attempt counted — so another worker can take it.
|
|
@@ -352,7 +364,7 @@ store.close(); // closing twice is fine
|
|
|
352
364
|
each item is claimed once; a writer waits up to `busyTimeout`
|
|
353
365
|
milliseconds for another.
|
|
354
366
|
Not on a network file system: SQLite's locks do not hold there. For
|
|
355
|
-
several machines,
|
|
367
|
+
several machines, use [PostgreSQL](#postgresql) or [Redis](#redis).
|
|
356
368
|
- **Messages in a table of their own**, `messages`, dropped with their
|
|
357
369
|
item in the same transaction. A queue holds a message for days at most
|
|
358
370
|
and never shares one between items, so content addressing (as
|
|
@@ -361,6 +373,293 @@ store.close(); // closing twice is fine
|
|
|
361
373
|
- **Migrations** run on open, in one transaction; a database written by a
|
|
362
374
|
newer version is refused.
|
|
363
375
|
|
|
376
|
+
### `PostgresQueueStore`
|
|
377
|
+
|
|
378
|
+
On PostgreSQL, for instances on several machines: see
|
|
379
|
+
[PostgreSQL](#postgresql).
|
|
380
|
+
|
|
381
|
+
### `RedisQueueStore`
|
|
382
|
+
|
|
383
|
+
On Redis, for instances on several machines: see [Redis](#redis).
|
|
384
|
+
|
|
385
|
+
## PostgreSQL
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { createQueue } from '@bumail/queue';
|
|
389
|
+
import { PostgresQueueStore } from '@bumail/queue/postgres';
|
|
390
|
+
|
|
391
|
+
const sql = new Bun.SQL({ url: Bun.env['DATABASE_URL'], max: 10 });
|
|
392
|
+
const store = PostgresQueueStore.open({ sql });
|
|
393
|
+
await store.migrate();
|
|
394
|
+
const queue = createQueue({ store, hostname, resolver, owner: `${host}-${process.pid}` });
|
|
395
|
+
queue.start();
|
|
396
|
+
|
|
397
|
+
process.on('SIGTERM', async () => {
|
|
398
|
+
await queue.stop();
|
|
399
|
+
await sql.close(); // yours: the store never closes a client it was given
|
|
400
|
+
});
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
`@bumail/queue/postgres` runs on Bun's own `Bun.sql`: there is no
|
|
404
|
+
driver to install and no peer to add. Every instance of your server opens
|
|
405
|
+
a store on the same database, and they share one queue.
|
|
406
|
+
|
|
407
|
+
### The client
|
|
408
|
+
|
|
409
|
+
- **`sql`** is a `Bun.SQL` client of yours — you size its pool and close
|
|
410
|
+
it — or a `postgres://` (or `postgresql://`) URL, for which the store
|
|
411
|
+
opens a client with Bun's defaults and closes it in `close()`. A
|
|
412
|
+
`Bun.SQL` client for SQLite or MySQL is refused.
|
|
413
|
+
- It is typed by its shape, `PostgresClient` (`unsafe`, `begin` and
|
|
414
|
+
`close`), so the declarations need nothing of `@types/bun`; a `Bun.SQL`
|
|
415
|
+
client fits it.
|
|
416
|
+
- **Nothing connects at `open`**: a wrong option is refused there, and a
|
|
417
|
+
database out of reach on the first call.
|
|
418
|
+
|
|
419
|
+
### The schema
|
|
420
|
+
|
|
421
|
+
Three tables, each named by `tablePrefix` (`bumail_queue_` by default),
|
|
422
|
+
in the connection's default schema (its `search_path`):
|
|
423
|
+
|
|
424
|
+
| table | what it holds |
|
|
425
|
+
| --- | --- |
|
|
426
|
+
| `<prefix>items` | an item a row: `seq` (an identity, the order among items equally due), `id` (`text`, unique), `sender`, `recipients` (`jsonb`, each recipient's state), `size`, `created_at`, `next_attempt_at`, `attempts`, `delay_notified`, `lease_owner` and `lease_expires_at`. Times are milliseconds since the epoch, as `double precision`, as the queue gives them. `<prefix>items_due`, on `(next_attempt_at, seq)`, serves the claim |
|
|
427
|
+
| `<prefix>messages` | the message as enqueued, whole, as `bytea`, keyed by its item's `id` and dropped with it (`ON DELETE CASCADE`) |
|
|
428
|
+
| `<prefix>schema` | the version: how many migrations ran |
|
|
429
|
+
|
|
430
|
+
They are the `bun:sqlite` store's tables, column for column. The prefix
|
|
431
|
+
is lowercase letters, digits and underscores, starting with a letter or
|
|
432
|
+
an underscore, 40 characters at most: it is written into the statements,
|
|
433
|
+
never bound. Give each queue its own prefix to keep several in one
|
|
434
|
+
database. A `bytea` holds 1 GB at most, far above `limits.maxMessageSize`
|
|
435
|
+
(25 MiB by default).
|
|
436
|
+
|
|
437
|
+
### Migrations
|
|
438
|
+
|
|
439
|
+
`migrate()` makes the tables, or brings them to the last migration, in
|
|
440
|
+
one transaction; without it, the first call that needs them does. It
|
|
441
|
+
runs once per store. Instances starting together wait on one advisory
|
|
442
|
+
lock (`pg_advisory_xact_lock`), so each migration runs once. A
|
|
443
|
+
migration that fails is `INVALID`, `The PostgreSQL queue cannot be set
|
|
444
|
+
up: …`, and the next call tries again; tables written by a newer version
|
|
445
|
+
of the package are refused.
|
|
446
|
+
|
|
447
|
+
The role needs `CREATE` on the schema the first time, then only `SELECT`,
|
|
448
|
+
`INSERT`, `UPDATE` and `DELETE` on the three tables: a store whose tables
|
|
449
|
+
are current only reads their version, with no lock and no DDL. So run
|
|
450
|
+
`migrate()` once from a deploy step with an owner's role, and give the
|
|
451
|
+
workers a narrower one:
|
|
452
|
+
|
|
453
|
+
```sql
|
|
454
|
+
GRANT SELECT, INSERT, UPDATE, DELETE
|
|
455
|
+
ON bumail_queue_items, bumail_queue_messages, bumail_queue_schema
|
|
456
|
+
TO bumail_worker;
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
After an upgrade that adds a migration, run that step again before the
|
|
460
|
+
workers start: theirs cannot make the change.
|
|
461
|
+
|
|
462
|
+
### Several instances and leases
|
|
463
|
+
|
|
464
|
+
```sql
|
|
465
|
+
UPDATE <prefix>items SET lease_owner = $1, lease_expires_at = $3
|
|
466
|
+
WHERE seq = (SELECT seq FROM <prefix>items
|
|
467
|
+
WHERE next_attempt_at <= $2 AND (lease_owner IS NULL OR lease_expires_at <= $2)
|
|
468
|
+
ORDER BY next_attempt_at, seq LIMIT 1
|
|
469
|
+
FOR UPDATE SKIP LOCKED)
|
|
470
|
+
RETURNING …
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
- **A claim** is that one statement: the earliest due item that no other
|
|
474
|
+
transaction is taking, locked as it is picked, then leased. Two
|
|
475
|
+
instances never take the same item, and none waits on an item another
|
|
476
|
+
is claiming: it takes the next.
|
|
477
|
+
- **The lease** works as on `bun:sqlite`: renewed every third of
|
|
478
|
+
`leaseMs`, let go of with the outcome. `complete` locks the item's row
|
|
479
|
+
and records the outcome only while its owner holds the lease. An
|
|
480
|
+
instance that crashes loses its items when their leases expire, and
|
|
481
|
+
another takes them then; one that stalled past its lease records
|
|
482
|
+
nothing once another instance has claimed the item (`LEASE_LOST`):
|
|
483
|
+
`complete` checks who owns the lease, not when it expires.
|
|
484
|
+
- **The clocks** of the instances must agree: each gives the time of its
|
|
485
|
+
own claims and leases, as the store keeps no clock. An instance whose
|
|
486
|
+
clock runs ahead sees leases expire early by as much. Keep the machines
|
|
487
|
+
on NTP; the 10-minute `leaseMs` leaves room for seconds of skew.
|
|
488
|
+
- **`limits.maxItems`**: the adds that count wait on one advisory lock,
|
|
489
|
+
so two instances never both take the last place.
|
|
490
|
+
|
|
491
|
+
### Pool size
|
|
492
|
+
|
|
493
|
+
Each call holds a connection for one statement, `complete` and an add
|
|
494
|
+
with `limits.maxItems` for one short transaction. A worker has at most
|
|
495
|
+
`concurrency` items (20) in flight, each mostly waiting on a remote SMTP
|
|
496
|
+
server, not the database: `Bun.SQL`'s default pool of 10 connections is
|
|
497
|
+
enough for one, and a call waits for a free connection rather than fail.
|
|
498
|
+
Raise `max` only when a busy worker waits on the pool, and keep every
|
|
499
|
+
instance's pool, and your application's, within the server's
|
|
500
|
+
`max_connections` (100 by default): four instances of `max: 10` take 40.
|
|
501
|
+
|
|
502
|
+
## Redis
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { createQueue } from '@bumail/queue';
|
|
506
|
+
import { RedisQueueStore } from '@bumail/queue/redis';
|
|
507
|
+
|
|
508
|
+
const client = new Bun.RedisClient(Bun.env['REDIS_URL']);
|
|
509
|
+
const store = RedisQueueStore.open({ client, keyPrefix: 'mail:queue:' });
|
|
510
|
+
const queue = createQueue({ store, hostname, resolver, owner: `${host}-${process.pid}` });
|
|
511
|
+
queue.start();
|
|
512
|
+
|
|
513
|
+
process.on('SIGTERM', async () => {
|
|
514
|
+
await queue.stop();
|
|
515
|
+
client.close(); // yours: the store never closes a client it was given
|
|
516
|
+
});
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
`@bumail/queue/redis` runs on Bun's own `Bun.RedisClient` (`Bun.redis`
|
|
520
|
+
is its default instance): there is no driver to install and no peer to
|
|
521
|
+
add. Every instance of your server opens a store on the same Redis, and
|
|
522
|
+
they share one queue. It is held to the same contract specs as the other
|
|
523
|
+
stores, and to the same specs of two instances delivering every item
|
|
524
|
+
exactly once, against `redis:7`.
|
|
525
|
+
|
|
526
|
+
### The client
|
|
527
|
+
|
|
528
|
+
- **`client`** is a `Bun.RedisClient` of yours — you set its options and
|
|
529
|
+
close it — or **`url`** a `redis://` (`rediss://` for TLS, or the other
|
|
530
|
+
schemes `Bun.RedisClient` takes), for which the store opens a client
|
|
531
|
+
with Bun's defaults and closes it in `close()`. One or the other, never
|
|
532
|
+
both.
|
|
533
|
+
- It is typed by its shape, `RedisQueueClient` (`send`, `getBuffer` and
|
|
534
|
+
`close`), so the declarations need nothing of `@types/bun`; a
|
|
535
|
+
`Bun.RedisClient` fits it.
|
|
536
|
+
- **Nothing connects at `open`**: a wrong option is refused there, and a
|
|
537
|
+
server out of reach on the first call. With Bun's defaults a client
|
|
538
|
+
reconnects on its own, up to 20 times, and holds the commands sent
|
|
539
|
+
while it is disconnected until Redis is back (a `PING` sent during a
|
|
540
|
+
5-second stop answered once Redis started again). A command already
|
|
541
|
+
sent when the connection drops, or sent before the client noticed,
|
|
542
|
+
fails instead (`Connection closed`): a renewal tries again on its own,
|
|
543
|
+
and a claim or an outcome is told on `error` (see Troubleshooting).
|
|
544
|
+
- One client is one connection, pipelined: every call of one store goes
|
|
545
|
+
through it, so there is no pool to size.
|
|
546
|
+
|
|
547
|
+
### The keys
|
|
548
|
+
|
|
549
|
+
Every key starts with `keyPrefix` (`bumail:queue:` by default):
|
|
550
|
+
|
|
551
|
+
| key | type | what it holds |
|
|
552
|
+
| --- | --- | --- |
|
|
553
|
+
| `<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 |
|
|
554
|
+
| `<prefix>message:<id>` | string | the message as enqueued, whole, byte for byte |
|
|
555
|
+
| `<prefix>items` | sorted set | every item, scored by its next attempt: `list` and `count` |
|
|
556
|
+
| `<prefix>ready` | sorted set | the items no lease holds, scored by their next attempt |
|
|
557
|
+
| `<prefix>leases` | sorted set | the leased items, scored by when their leases expire |
|
|
558
|
+
| `<prefix>seq` | string | the counter that orders items equally due |
|
|
559
|
+
| `<prefix>schema` | string | the layout version |
|
|
560
|
+
|
|
561
|
+
A member of the sorted sets is the item's sequence number, sixteen
|
|
562
|
+
digits, then `:` and its id, so items equally due come oldest first.
|
|
563
|
+
Times are kept as the strings JavaScript writes for them, and read back
|
|
564
|
+
exactly. The prefix is lowercase letters, digits, `_`, `:`, `.` and
|
|
565
|
+
`-`, starting with a letter, 40 characters at most: no `{`, so no
|
|
566
|
+
Cluster hash tag, and no glob character, so `SCAN 0 MATCH <prefix>*`
|
|
567
|
+
finds the queue's keys and nothing else. A `<prefix>schema` that holds
|
|
568
|
+
anything but a layout version is refused at the first call: the prefix
|
|
569
|
+
is another application's. Give each queue its own
|
|
570
|
+
prefix to keep several in one Redis database. An id the store did not
|
|
571
|
+
make (`crypto.randomUUID()`'s form) is one no item has: no id a caller
|
|
572
|
+
gives reaches another key.
|
|
573
|
+
|
|
574
|
+
### Several instances and leases
|
|
575
|
+
|
|
576
|
+
Every operation that writes is one Lua script, run by `EVALSHA` (by
|
|
577
|
+
`EVAL` when Redis no longer has it, after a restart, a failover or a
|
|
578
|
+
`SCRIPT FLUSH`): Redis runs a script whole, with no command of another
|
|
579
|
+
instance between its steps.
|
|
580
|
+
|
|
581
|
+
- **A claim** takes the first item of `ready` due by `now`, and every
|
|
582
|
+
lease of `leases` expired by `now`, keeps the earliest due of them,
|
|
583
|
+
then the oldest, and moves it from `ready` to `leases` with its new
|
|
584
|
+
expiry, in one script. Two instances never take the same item. An
|
|
585
|
+
expired lease is found at once, not by a sweep; there are only as many
|
|
586
|
+
as a crashed or stalled instance left.
|
|
587
|
+
- **`complete`** reads the item, applies the outcome in JavaScript, and
|
|
588
|
+
records it with a script that checks the owner still holds the lease
|
|
589
|
+
and that no other outcome was recorded since (`rev`); otherwise it
|
|
590
|
+
reads again, at most 8 times, then records nothing (`LEASE_LOST`), as
|
|
591
|
+
it does when the lease was lost.
|
|
592
|
+
- **`renew`**, **`reschedule`** and **`cancel`** are a script each, and
|
|
593
|
+
an `add` with `limits.maxItems` counts and adds in one, so two
|
|
594
|
+
instances never both take the last place.
|
|
595
|
+
- **The clocks** of the instances must agree, as on PostgreSQL: the store
|
|
596
|
+
keeps no clock, and Redis's is never read.
|
|
597
|
+
|
|
598
|
+
### Deploying Redis
|
|
599
|
+
|
|
600
|
+
- **One Redis, or a primary with replicas — not Redis Cluster.** A script
|
|
601
|
+
reaches each item's keys, which are not all named up front, so the
|
|
602
|
+
queue's keys must live on one node. Point the store at the primary: a
|
|
603
|
+
replica refuses writes (`READONLY`). After a failover, the URL must
|
|
604
|
+
reach the new primary — a provider's endpoint that follows it, say.
|
|
605
|
+
- **Durability is Redis's, and weaker than PostgreSQL's.** Redis
|
|
606
|
+
acknowledges a write once it is in memory:
|
|
607
|
+
- with `appendonly yes` and `appendfsync always`, a write is on disk
|
|
608
|
+
before it is acknowledged, as on `bun:sqlite` or PostgreSQL;
|
|
609
|
+
- with `appendfsync everysec` (Redis's default once AOF is on), a
|
|
610
|
+
crash of the server loses up to about a second of acknowledged
|
|
611
|
+
writes;
|
|
612
|
+
- with snapshots only (`save`, AOF off), it loses everything since the
|
|
613
|
+
last snapshot, minutes of it;
|
|
614
|
+
- **replication is asynchronous**: a primary that fails before its
|
|
615
|
+
replica has a write loses that write when the replica is promoted,
|
|
616
|
+
whatever the fsync setting.
|
|
617
|
+
|
|
618
|
+
A lost write is an enqueued message gone (the client was told it was
|
|
619
|
+
queued), an outcome forgotten (a recipient delivered and sent the
|
|
620
|
+
message again), or a lease given twice. Use `appendfsync always`
|
|
621
|
+
where a lost message matters, and PostgreSQL where it must survive a
|
|
622
|
+
failover.
|
|
623
|
+
- **`maxmemory-policy noeviction`.** Every message lives in memory, whole,
|
|
624
|
+
until its item is done. Under any other policy a full Redis evicts
|
|
625
|
+
queue keys — an item, or its message — to make room; with
|
|
626
|
+
`noeviction` it refuses the write (`OOM command not allowed`), and
|
|
627
|
+
`enqueue` rejects with that `RedisError`. An item whose hash is gone
|
|
628
|
+
all the same (evicted, or deleted by hand) is dropped by the next
|
|
629
|
+
claim that meets it, its message with it, and never leased. Bound the queue with `limits.maxItems` and
|
|
630
|
+
`limits.maxMessageSize`, and size `maxmemory` for both.
|
|
631
|
+
- **An ACL user** needs the commands the store sends and the ones its
|
|
632
|
+
scripts call, on its prefix's keys (`~<keyPrefix>*`; here the
|
|
633
|
+
example's `mail:queue:`), and nothing else:
|
|
634
|
+
|
|
635
|
+
```
|
|
636
|
+
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
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### What `Bun.redis` does with bytes and text
|
|
640
|
+
|
|
641
|
+
Measured on Bun 1.4.2 against Redis 7:
|
|
642
|
+
|
|
643
|
+
- An argument may be a `Uint8Array`: `send` writes it as it is, every
|
|
644
|
+
byte value, to `EVAL` and `EVALSHA` too. That is how a message is
|
|
645
|
+
written, through the script that adds its item.
|
|
646
|
+
- A bulk string in a reply is decoded as UTF-8 into a JavaScript string,
|
|
647
|
+
so bytes that are not UTF-8 do not come back from `send`. A message is
|
|
648
|
+
read with `getBuffer`, which keeps the bytes.
|
|
649
|
+
- A string argument is encoded as UTF-8, and a lone surrogate becomes
|
|
650
|
+
U+FFFD without a word: the contract's checks refuse a lone surrogate
|
|
651
|
+
(and a NUL, which Redis could keep) before anything is sent, so what is
|
|
652
|
+
read back is always what was given.
|
|
653
|
+
- `HGETALL` sent directly comes back as an object; the same from a script
|
|
654
|
+
as a flat list of names and values. A script's `false` is `null`.
|
|
655
|
+
- An error reply rejects with a `RedisError` whose message is Redis's
|
|
656
|
+
(`NOSCRIPT No matching script…`); none of them repeats the URL.
|
|
657
|
+
- A server out of reach: with Bun's defaults a call waits for 20
|
|
658
|
+
reconnections, about half a minute, then rejects with `Max
|
|
659
|
+
reconnection attempts reached`; a URL that is not one is not refused by
|
|
660
|
+
`new Bun.RedisClient`, only by its first call, so the store checks the
|
|
661
|
+
scheme itself.
|
|
662
|
+
|
|
364
663
|
## Testing
|
|
365
664
|
|
|
366
665
|
Inject the clock and the sender, and call `deliverDue()` by hand: no
|
|
@@ -425,8 +724,16 @@ codes, and keeps its promises:
|
|
|
425
724
|
dropped with its message.
|
|
426
725
|
- Times are given by the caller; the store keeps no clock.
|
|
427
726
|
- Nothing it returns is shared with what it keeps.
|
|
428
|
-
|
|
429
|
-
|
|
727
|
+
- `reschedule` with an owner refuses an empty owner as `INVALID`, even
|
|
728
|
+
for an id no item has. An id a store cannot keep (one holding a NUL or
|
|
729
|
+
a lone surrogate) may instead be answered `false`, unknown, before the
|
|
730
|
+
owner is checked.
|
|
731
|
+
- Text it keeps holds no NUL and no lone surrogate: the contract's
|
|
732
|
+
checks refuse them (`INVALID`) in what is added, claimed and recorded,
|
|
733
|
+
since PostgreSQL cannot keep them, and an id holding one is unknown.
|
|
734
|
+
The queue never gives a store such text: it cleans every reply.
|
|
735
|
+
|
|
736
|
+
The contract's specs, `describeQueueStore`, which every store here runs,
|
|
430
737
|
are internal for now: a store written outside this package cannot run
|
|
431
738
|
them yet.
|
|
432
739
|
|
package/docs/roadmap.md
CHANGED
|
@@ -6,26 +6,24 @@ only number on it.
|
|
|
6
6
|
|
|
7
7
|
## Now
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
smarthost, or a route per domain), a global and a per-domain
|
|
12
|
-
concurrency limit, retries with exponential back-off and jitter on RFC
|
|
13
|
-
5321 §4.5.4.1's schedule, a 5xx failed at once, DSNs (RFC 3464) for a
|
|
14
|
-
failure and a delay, never about a message from `<>`; the `QueueStore`
|
|
15
|
-
contract with an atomic claim and leases for several workers, its
|
|
16
|
-
memory store and its `bun:sqlite` store, held to one spec suite;
|
|
17
|
-
events, `list`, `retryNow` and `cancel`; an injectable clock and sender.
|
|
9
|
+
Nothing in progress: the MongoDB store, first under Next, is the next
|
|
10
|
+
one taken. The Redis store is merged (see Shipped).
|
|
18
11
|
|
|
19
12
|
## Next
|
|
20
13
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
14
|
+
More answers to the `QueueStore` contract, built for several instances of
|
|
15
|
+
a server sharing one queue — each claim atomic across them, each lease
|
|
16
|
+
taken back from an instance that crashed — and each held to the same
|
|
17
|
+
contract specs as the memory, `bun:sqlite`, PostgreSQL and Redis
|
|
18
|
+
stores. None adds a dependency.
|
|
19
|
+
|
|
20
|
+
- **A MongoDB store** — the contract answered structurally, against a
|
|
27
21
|
collection of the shape a MongoDB driver gives, so the package peers on
|
|
28
|
-
no driver; the claim a
|
|
22
|
+
no driver: the application passes in its own collection; the claim a
|
|
23
|
+
`findOneAndUpdate`.
|
|
24
|
+
|
|
25
|
+
Then:
|
|
26
|
+
|
|
29
27
|
- **MTA-STS (RFC 8461) and TLS-RPT (RFC 8460)** — a recipient domain's
|
|
30
28
|
policy fetched, cached and enforced, `mxTls: 'required'` with the
|
|
31
29
|
certificate checked against the policy's MX names, and the reports.
|
|
@@ -54,8 +52,9 @@ only number on it.
|
|
|
54
52
|
## Not planned
|
|
55
53
|
|
|
56
54
|
- **A runtime dependency.** The memory store needs none, the `bun:sqlite`
|
|
57
|
-
store uses Bun's own SQLite,
|
|
58
|
-
|
|
55
|
+
store uses Bun's own SQLite, the PostgreSQL store Bun's own `Bun.sql`,
|
|
56
|
+
the Redis store Bun's own `Bun.redis`, and the MongoDB store will take
|
|
57
|
+
the application's own collection, typed by its shape.
|
|
59
58
|
- **Relaying on its own.** The queue sends what the app enqueues; who may
|
|
60
59
|
send is decided before, by the SMTP server's AUTH or the app. No option
|
|
61
60
|
will make the queue accept mail from the network.
|
|
@@ -64,4 +63,59 @@ only number on it.
|
|
|
64
63
|
|
|
65
64
|
## Shipped
|
|
66
65
|
|
|
67
|
-
|
|
66
|
+
### Unreleased — merged, not yet published
|
|
67
|
+
|
|
68
|
+
- **A Redis store**, `@bumail/queue/redis`, on Bun's own `Bun.redis`,
|
|
69
|
+
for instances of a server on several machines sharing one queue:
|
|
70
|
+
`RedisQueueStore.open({ client })` takes a `Bun.RedisClient`, or
|
|
71
|
+
`{ url }` a `redis://` URL, and needs no driver. Every operation that
|
|
72
|
+
writes is one Lua script, which Redis runs whole: two instances never
|
|
73
|
+
take the same item, a crashed instance's leases expire and its items
|
|
74
|
+
are claimed again, and `maxItems` holds across instances. The due
|
|
75
|
+
items are in sorted sets, each item a hash, its message written byte
|
|
76
|
+
for byte, every key under a `keyPrefix`. Held to the same contract
|
|
77
|
+
specs as the other stores, and to specs of two instances delivering
|
|
78
|
+
every item exactly once. One Redis, or a primary with replicas, not
|
|
79
|
+
Cluster; its durability is Redis's, which the guide spells out.
|
|
80
|
+
- **A lease lost to an instance that finished the item is reported** —
|
|
81
|
+
an instance that stalled past its lease, while another claimed the
|
|
82
|
+
item, delivered it and dropped it, no longer takes the item's absence
|
|
83
|
+
for a cancel: it reports `LEASE_LOST` on the `error` event, tells no
|
|
84
|
+
outcome and sends no DSN, so the operator learns the message may have
|
|
85
|
+
gone out twice. With no record of who dropped an item, a lease that
|
|
86
|
+
expired, or a renewal refused while the item was still there, is
|
|
87
|
+
reported as lost or cancelled; a cancel under a lease that
|
|
88
|
+
still held is told as before, and no longer draws a `LEASE_LOST` from
|
|
89
|
+
a renewal that finds the item gone.
|
|
90
|
+
|
|
91
|
+
### 0.2.0
|
|
92
|
+
|
|
93
|
+
- **A PostgreSQL store**, `@bumail/queue/postgres`, on Bun's own
|
|
94
|
+
`Bun.sql`, for instances of a server on several machines sharing one
|
|
95
|
+
queue: `PostgresQueueStore.open({ sql })` takes a `Bun.SQL` client or a
|
|
96
|
+
`postgres://` URL, and needs no driver. A claim is one `UPDATE …
|
|
97
|
+
RETURNING` whose item a `SELECT … FOR UPDATE SKIP LOCKED` picks, so two
|
|
98
|
+
instances never take the same item; a crashed instance's leases expire
|
|
99
|
+
and its items are claimed again. The tables — the `bun:sqlite` store's,
|
|
100
|
+
the message as `bytea` — are made by `migrate()` or the first call,
|
|
101
|
+
once however many instances start together, under a `tablePrefix`.
|
|
102
|
+
Held to the same contract specs as the other stores, and to specs of
|
|
103
|
+
two instances delivering every item exactly once. Once the tables are
|
|
104
|
+
current, a worker's role needs no `CREATE`.
|
|
105
|
+
- **A reply cut at its limit never splits a character** — the text kept
|
|
106
|
+
for a recipient ends before a surrogate pair rather than inside it, and
|
|
107
|
+
every store refuses a NUL or a lone surrogate in what it keeps, so an
|
|
108
|
+
outcome is always recorded.
|
|
109
|
+
|
|
110
|
+
### 0.1.0
|
|
111
|
+
|
|
112
|
+
- **The first slice** — `createQueue`: every recipient's own state,
|
|
113
|
+
delivery by domain through `@bumail/smtp/client` (direct MX, a
|
|
114
|
+
smarthost, or a route per domain), a global and a per-domain
|
|
115
|
+
concurrency limit, retries with exponential back-off and jitter on RFC
|
|
116
|
+
5321 §4.5.4.1's schedule, a 5xx failed at once, DSNs (RFC 3464) for a
|
|
117
|
+
failure and a delay, never about a message from `<>`; the `QueueStore`
|
|
118
|
+
contract with an atomic claim and leases for several workers, its
|
|
119
|
+
memory store and its `bun:sqlite` store, held to one spec suite;
|
|
120
|
+
events, `list`, `retryNow` and `cancel`; an injectable clock and sender.
|
|
121
|
+
The package's first release.
|