mail-time 5.2.1 โ 5.3.1
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 +24 -12
- package/adapters/mongo.d.ts +9 -8
- package/adapters/mongo.js +8 -8
- package/adapters/postgres.d.ts +5 -0
- package/adapters/postgres.js +35 -10
- package/adapters/redis.d.ts +1 -1
- package/adapters/redis.js +37 -18
- package/index.cjs +580 -141
- package/index.d.cts +20 -76
- package/index.d.ts +20 -76
- package/index.js +307 -79
- package/package.json +24 -5
- package/recipient-policy-lease.js +40 -8
- package/recipient-policy.js +155 -20
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Many clients + one or more servers coexist behind the same `prefix` in the same
|
|
|
35
35
|
- ๐ฎ **Email concatenation** โ fold same-`to` emails arriving inside a window into one letter.
|
|
36
36
|
- ๐๏ธ **One-line setup** โ built-in [presets](#settings-presets) for `transactional`, `otp`, `newsletter`, `marketing`, `notifications`, `alerts`.
|
|
37
37
|
- ๐ข๏ธ **Three first-party storages** โ MongoDB, Redis, PostgreSQL. Plus a [custom-adapter contract](https://github.com/veliovgroup/mail-time/blob/master/docs/queue-api.md).
|
|
38
|
-
- ๐ฆ **Bun โฅ 1.1.0 & Node โฅ
|
|
38
|
+
- ๐ฆ **Bun โฅ 1.1.0 & Node โฅ 14.19.3** โ same code, both runtimes. See [supported runtimes](#supported-runtimes).
|
|
39
39
|
- ๐ค **Ships with AI agent skills** โ see [AI agent skills](#ai-agent-skills) below.
|
|
40
40
|
- ๐ **Hand-tuned ESM + CJS + TypeScript declarations**.
|
|
41
41
|
- ๐งช **99%+ Jest line coverage** (85% threshold enforced) + Mocha integration tests for every adapter.
|
|
@@ -176,7 +176,7 @@ Three things every MailTime needs: a connected storage **client**, one or more *
|
|
|
176
176
|
// ESM
|
|
177
177
|
import { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } from 'mail-time';
|
|
178
178
|
|
|
179
|
-
// CommonJS
|
|
179
|
+
// CommonJS (subpaths such as require('mail-time/presets') resolve too, 5.3.1+)
|
|
180
180
|
const { MailTime, MongoQueue, PostgresQueue, RedisQueue, mailTimePreset } = require('mail-time');
|
|
181
181
|
```
|
|
182
182
|
|
|
@@ -374,13 +374,13 @@ Defaults fit moderate traffic in a single region. Reach for a [preset](#settings
|
|
|
374
374
|
| `mode` | `'batch'` | `'one'` to claim a single row per tick (fairness over throughput across cluster nodes) |
|
|
375
375
|
| `concurrency` | `1` | Raise to send N emails in parallel per instance. CAS blocks concurrent claims; SMTP rate limits still apply. |
|
|
376
376
|
| `sendingTimeout` | `300000` (5 min) | Stale-lock recovery window. Must exceed worst-case SMTP roundtrip; MailTime logs a warning below `120000`. |
|
|
377
|
-
| `renewClaim` | `sendingTimeout / 3` | Set `false` to go back to a single stamp at claim time. Lower it if your storage round-trip is slow relative to `sendingTimeout`. |
|
|
377
|
+
| `renewClaim` | `sendingTimeout / 3` (min 1000 ms) | Set `false` to go back to a single stamp at claim time. Lower it if your storage round-trip is slow relative to `sendingTimeout`. |
|
|
378
378
|
| `maxRenewals` | `10` | Renewal-attempt ceiling. Recovery begins `sendingTimeout` after last successful stamp; with responsive storage this is roughly `sendingTimeout + maxRenewals ร renewClaim`. |
|
|
379
379
|
| `shouldFailOver` | โ | Set when your transport can tell "never delivered" from "may have been delivered" โ see [Transport fail-over](#transport-fail-over). |
|
|
380
380
|
| `strictPayload` | `false` | Turn on when anything other than your own app can write to the queue storage. |
|
|
381
381
|
| `revolvingInterval` | `1536` ms | Lower โ faster pickup; higher โ less scheduler I/O |
|
|
382
382
|
| `josk.minRevolvingDelay` / `maxRevolvingDelay` | `512` / `2048` | Lower both โ snappier polls, more storage load |
|
|
383
|
-
| `josk.zombieTime` | `60000` | **Never below 60s.** After an unclean stop during a claimed scan, JoSk 6.4 preserves the claim until `zombieTime`; use graceful destroy on redeploy. |
|
|
383
|
+
| `josk.zombieTime` | `60000` | **Never below 60s.** After an unclean stop during a claimed scan, JoSk 6.4+ preserves the claim until `zombieTime`; use graceful destroy on redeploy. |
|
|
384
384
|
| `josk.concurrency` | `Infinity` | Set `1` if scheduler ticks overlap while `iterate` still runs |
|
|
385
385
|
| `josk.execute` | `'batch'` | Usually leave default; MailTime only registers one JoSk task per instance |
|
|
386
386
|
| `josk.lockOwnerId` | random | Set in production for observability |
|
|
@@ -425,7 +425,7 @@ new MailTime({
|
|
|
425
425
|
});
|
|
426
426
|
```
|
|
427
427
|
|
|
428
|
-
Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See [recipient policy hooks, outcomes, recovery, and rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md).
|
|
428
|
+
Each `to`, `cc`, `bcc`, and `from` entry must hold one mailbox, for example `"Doe, John" <user@example.com>` or `{ name, address }`; use arrays for several recipients. An address MailTime cannot parse fails the task without SMTP or further retries, with `error.code === 'MAIL_TIME_INVALID_ADDRESS'` and `error.field` naming the field. Only the SMTP envelope is filtered. Headers remain unchanged; some transports retain BCC in generated MIME. If suppressed addresses must not appear in message content, remove them from headers before enqueueing. See [recipient policy hooks, outcomes, recovery, and rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md).
|
|
429
429
|
|
|
430
430
|
## Queue payload trust
|
|
431
431
|
|
|
@@ -514,19 +514,20 @@ await mailQueue.sendMail({
|
|
|
514
514
|
| `mode` | `'one' \| 'batch'` | `'batch'` | `'batch'` claims every due row per tick; `'one'` claims a single row per tick. |
|
|
515
515
|
| `concurrency` | `number` | `1` | Parallel SMTPs per instance. CAS blocks concurrent claims for one row. |
|
|
516
516
|
| `sendingTimeout` | `number` (ms) | `300000` | Window after which a stuck `isSending=true` row becomes eligible again. Must exceed worst-case SMTP roundtrip; values below `120000` log a warning. |
|
|
517
|
-
| `renewClaim` | `boolean \| number` (ms) | `sendingTimeout / 3`
|
|
517
|
+
| `renewClaim` | `boolean \| number` (ms) | `sendingTimeout / 3` (min 1000 ms) | Re-stamp `sendingAt` on the claimed row while its SMTP roundtrip runs, so a slow-but-healthy send never loses its lock to a recovery worker. `false` restores the v4 single-stamp behaviour. |
|
|
518
518
|
| `maxRenewals` | `number` | `10` | Renewal-attempt budget per send. Recovery begins `sendingTimeout` after last successful stamp; slow renewal writes can extend elapsed time beyond the nominal interval calculation. |
|
|
519
519
|
| `shouldFailOver` | `(error, info, email) => boolean` | โ | Veto rotating to the next transport for this failure. Default: rotate unless the error carries `mayFailOver === false`. See [Transport fail-over](#transport-fail-over). |
|
|
520
520
|
| `strictPayload` | `boolean` | `false` | Narrow every queued letter to `allowedMailFields` and force nodemailer's `disableFileAccess` / `disableUrlAccess`. See [Queue payload trust](#queue-payload-trust). |
|
|
521
521
|
| `allowedMailFields` | `string[]` | โ | Extra field names permitted under `strictPayload` (added to the built-in allowlist). |
|
|
522
|
-
| `verifyTransports` | `boolean` | `true` | Probe each transport
|
|
522
|
+
| `verifyTransports` | `boolean` | `true` | Probe each transport with `transport.verify()` at `ready()`. A failing transport is quarantined (skipped in rotation and fallback) and reported once through `onError(error, null, { transportIndex, phase: 'verify' })`; `ready()` throws only if every transport fails. Quarantined transports are re-probed in the background (60 s, doubling to 15 min) and return to rotation on success. Verdict rules, timeouts and recovery: [transport verification](https://github.com/veliovgroup/mail-time/blob/master/docs/transport-verification.md). |
|
|
523
|
+
| `verifyTimeout` | `number` | `30000` | Milliseconds `ready()` waits per `transport.verify()`; also the budget of each background re-probe. On timeout `ready()` stops waiting and the transport keeps its current health; a late verdict still applies. Values above `2147483647` (including `Infinity`) are clamped; anything else invalid uses `30000`. |
|
|
523
524
|
| `template` | `string` | `'{{{html}}}'` | Default envelope. |
|
|
524
525
|
| `prefix` | `string` | `''` | Queue namespace. **Same** on every `client` and `server` for one logical queue; **different** per email class. Inherited by the queue adapter; JoSk scheduler uses `mailTimeQueue<prefix>`. |
|
|
525
526
|
| `from` | `string \| (transport, details) => string` | โ | Strongly recommended for spam-passing `From:` formatting. `details` is `{ index, from }` โ see [Resolving the sender](#resolving-the-sender). |
|
|
526
527
|
| `debug` | `boolean` | `false` | Verbose logs. |
|
|
527
528
|
| `onSent(email, info)` | `function` | โ | Without policies, called once the task is fully delivered. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].accepted` lists every address that got through (across all attempts). |
|
|
528
|
-
| `onError(error, email,
|
|
529
|
-
| `recipientPolicies` | `MailTimeRecipientPolicy[]` | โ | Optional nonempty provider list. See [Recipient policies](#recipient-policies). |
|
|
529
|
+
| `onError(error, email, details)` | `function` | โ | `details` is typed `MailTimeErrorDetails`: `{ phase?, transportIndex?, attempt? }` plus the SMTP `info` keys of a failed attempt. Without policies, called once the retry budget is exhausted with at least one un-accepted recipient. Policy mode adds grouped `recipients, summary` arguments. `email.mailOptions[i].rejected` lists each un-delivered address with its last error. Also fires once per transport that fails `verify()` at startup with `email === null` and `details = { transportIndex, phase: 'verify' }`. Also fires once when a storage write that records a send outcome throws, with `details.phase` of `'complete'` (final or retry-release write) or `'checkpoint'` (policy mode, recipient results after SMTP). |
|
|
530
|
+
| `recipientPolicies` | `MailTimeRecipientPolicy[]` | โ | Optional nonempty provider list. When set, `sendMail()` rejects an unparseable address (`MAIL_TIME_INVALID_ADDRESS`, `error.field` such as `to[1]`) before enqueue, and a `server` constructor throws for an unparseable string `from` or transport `from` (`transports[i].from`). See [Recipient policies](#recipient-policies). |
|
|
530
531
|
| `onSuppressed(task, recipients, summary)` | `function` | โ | Terminal suppressed group in policy mode. |
|
|
531
532
|
| `onRejected(task, recipients, summary)` | `function` | โ | Terminal permanently rejected group in policy mode. |
|
|
532
533
|
|
|
@@ -557,9 +558,9 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
|
|
|
557
558
|
- `cancelMail(uuidOrPromise)` โ `Promise<boolean>`. Accepts the `uuid` or the `Promise<string>` from `sendMail`.
|
|
558
559
|
- `cancel(uuid)` โ alias of `cancelMail`.
|
|
559
560
|
- `ping()` โ `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
|
|
560
|
-
- `ready()` โ `Promise<MailTime>`. Awaits all startup work; rejects with `.cause`
|
|
561
|
+
- `ready()` โ `Promise<MailTime>`. Awaits all startup work; rejects on storage failure with the original error in `.cause` (`Error` `cause` is ignored below Node 16.9).
|
|
561
562
|
- `destroy(opts?)` โ `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; sends not yet started are dropped and stay queued; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
|
|
562
|
-
- `drain()` โ `Promise<
|
|
563
|
+
- `drain()` โ `Promise<{ failedWrites }>`. Resolves once every in-flight SMTP attempt finishes. `failedWrites` counts outcome writes that failed since instance creation; each is also reported through `onError` with `details.phase` `'complete'` or `'checkpoint'`. A non-zero value after `destroy({ drain: true })` means a row may stay `sending` and be re-sent after `sendingTimeout`. Details: [completion writes and drain](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#completion-writes-and-drain).
|
|
563
564
|
- `pause()` / `resume()` โ `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
|
|
564
565
|
- `isPaused` โ `boolean`. Read-only; always `false` on `client`.
|
|
565
566
|
|
|
@@ -598,6 +599,8 @@ Upgrade checklists, adapter contract changes, and rollout notes live in the docs
|
|
|
598
599
|
| 4.0 | 4.1 | [v4.0 โ v4.1](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4-v4.1.md) |
|
|
599
600
|
| 4.1 | 5.0 | [v4.1 โ v5](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v4.1-v5.md) |
|
|
600
601
|
| 5.0 | 5.1 | [v5 โ v5.1 Redis Cluster](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5-v5.1.md) |
|
|
602
|
+
| 5.1 | 5.2 | [v5.1 โ v5.2 recipient policies rollout](https://github.com/veliovgroup/mail-time/blob/master/docs/recipient-policies.md#recovery-and-rollout) |
|
|
603
|
+
| 5.2 | 5.3 | [v5.2 โ v5.3](https://github.com/veliovgroup/mail-time/blob/master/docs/migration-v5.2-v5.3.md) |
|
|
601
604
|
|
|
602
605
|
New in v4 (opt-in): `mailTimePreset`, `concurrency`/`mode`, `sendingTimeout`, `drain()`/`pause()`/`resume()`, per-recipient handling, AI skills bundle.
|
|
603
606
|
|
|
@@ -622,9 +625,18 @@ bun test ./test/jest
|
|
|
622
625
|
|
|
623
626
|
`npm test` runs Jest unit tests, then Mocha integration tests, then TypeScript declaration tests. Jest coverage threshold is **85%** across statements, branches, functions, and lines. GitHub Actions runs the matrix against `redis@^4` and `redis@^5`.
|
|
624
627
|
|
|
628
|
+
## Supported runtimes
|
|
629
|
+
|
|
630
|
+
`engines.node` is `>=14.19.3`. The published package (ESM `index.js` and CJS `index.cjs`) is tested from a packed tarball with `test/runtime-matrix/run.sh` on Node 14.19.3, 14.21.3, 16.20.2, 18.19.1, 20.11.1, 22.21.1, 24.16.0 and Bun. Each run sends through a stub transport with `MongoQueue`, and checks load, shutdown, `pause()`/`resume()` and drain behavior (12 checks, all passing).
|
|
631
|
+
|
|
632
|
+
- Node 12 fails to parse the package (optional chaining). Node 14.0 to 14.16 lack `crypto.randomUUID`; 14.17 to 14.18 are untested.
|
|
633
|
+
- The dependency `josk@6.5.0` declares `engines.node >=14.21.3`. Package managers that enforce `engines` (`npm --engine-strict`, Yarn 1) reject the install on Node 14.19.3 to 14.21.2.
|
|
634
|
+
- Store drivers set their own floor. `mongodb` 7 needs Node 20.19+, `mongodb` 6 needs 16.20.1+, `mongodb` 5 needs 14.20.1+; `redis` 5 needs 18.19+; `pg` 8 needs 16+. The Node 14 and 16 runs used `mongodb@3.7.4`. Only `MongoQueue` is tested below Node 18. `RedisQueue` and `PostgresQueue` load on those versions but are untested there.
|
|
635
|
+
- The repository's own test suite (Jest 30, Mocha 11, TypeScript 6) needs Node 20 or later (dev toolchain). CI runs it on Node 20.9, 22 and LTS, and runs the packed-artifact matrix on Node 14.19.3, 16.20.2 and 18.19.1.
|
|
636
|
+
|
|
625
637
|
## Bun
|
|
626
638
|
|
|
627
|
-
MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โฅ 1.1.0, Node โฅ
|
|
639
|
+
MailTime ships pure ESM with a generated CJS bundle. Both runtimes (Bun โฅ 1.1.0, Node โฅ 14.19.3) load it directly:
|
|
628
640
|
|
|
629
641
|
```js
|
|
630
642
|
import { MailTime } from 'mail-time'; // works in both
|
package/adapters/mongo.d.ts
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
export type MongoCollection = {
|
|
2
2
|
collectionName?: string | undefined;
|
|
3
|
-
createIndex: (keys:
|
|
3
|
+
createIndex: (keys: any, opts?: any) => Promise<unknown>;
|
|
4
4
|
indexes: () => Promise<{
|
|
5
|
-
name
|
|
5
|
+
name?: string;
|
|
6
6
|
key: Record<string, unknown>;
|
|
7
7
|
}[]>;
|
|
8
8
|
dropIndex: (name: string) => Promise<unknown>;
|
|
9
|
-
find: (query:
|
|
10
|
-
findOne: (query:
|
|
11
|
-
insertOne: (doc:
|
|
12
|
-
deleteOne: (query:
|
|
9
|
+
find: (query: any, opts?: any) => unknown;
|
|
10
|
+
findOne: (query: any, opts?: any) => Promise<object | null>;
|
|
11
|
+
insertOne: (doc: any) => Promise<unknown>;
|
|
12
|
+
deleteOne: (query: any) => Promise<{
|
|
13
13
|
deletedCount?: number;
|
|
14
14
|
}>;
|
|
15
|
-
updateOne: (query:
|
|
15
|
+
updateOne: (query: any, update: any) => Promise<{
|
|
16
16
|
modifiedCount?: number;
|
|
17
|
+
matchedCount?: number;
|
|
17
18
|
}>;
|
|
18
19
|
};
|
|
19
20
|
export type Db = {
|
|
20
21
|
collection: (name: string) => MongoCollection;
|
|
21
|
-
command: (cmd:
|
|
22
|
+
command: (cmd: any) => Promise<{
|
|
22
23
|
ok?: number;
|
|
23
24
|
}>;
|
|
24
25
|
};
|
package/adapters/mongo.js
CHANGED
|
@@ -14,20 +14,20 @@ const DEFAULT_PREFIX = '';
|
|
|
14
14
|
/**
|
|
15
15
|
* @typedef {object} MongoCollection
|
|
16
16
|
* @property {string} [collectionName]
|
|
17
|
-
* @property {(keys:
|
|
18
|
-
* @property {() => Promise<{ name
|
|
17
|
+
* @property {(keys: any, opts?: any) => Promise<unknown>} createIndex
|
|
18
|
+
* @property {() => Promise<{ name?: string, key: Record<string, unknown> }[]>} indexes
|
|
19
19
|
* @property {(name: string) => Promise<unknown>} dropIndex
|
|
20
|
-
* @property {(query:
|
|
21
|
-
* @property {(query:
|
|
22
|
-
* @property {(doc:
|
|
23
|
-
* @property {(query:
|
|
24
|
-
* @property {(query:
|
|
20
|
+
* @property {(query: any, opts?: any) => unknown} find
|
|
21
|
+
* @property {(query: any, opts?: any) => Promise<object|null>} findOne
|
|
22
|
+
* @property {(doc: any) => Promise<unknown>} insertOne
|
|
23
|
+
* @property {(query: any) => Promise<{ deletedCount?: number }>} deleteOne
|
|
24
|
+
* @property {(query: any, update: any) => Promise<{ modifiedCount?: number, matchedCount?: number }>} updateOne
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* @typedef {object} Db
|
|
29
29
|
* @property {(name: string) => MongoCollection} collection
|
|
30
|
-
* @property {(cmd:
|
|
30
|
+
* @property {(cmd: any) => Promise<{ ok?: number }>} command
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
/**
|
package/adapters/postgres.d.ts
CHANGED
|
@@ -4,6 +4,11 @@ export type PostgresQueryResult = {
|
|
|
4
4
|
};
|
|
5
5
|
export type PostgresClient = {
|
|
6
6
|
query: (queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>;
|
|
7
|
+
/**
|
|
8
|
+
* - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
|
|
9
|
+
*/
|
|
10
|
+
connect?: ((...args: any[]) => any) | undefined;
|
|
11
|
+
totalCount?: number | undefined;
|
|
7
12
|
};
|
|
8
13
|
export type PostgresQueueOption = {
|
|
9
14
|
client: PostgresClient;
|
package/adapters/postgres.js
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
/**
|
|
20
20
|
* @typedef {object} PostgresClient
|
|
21
21
|
* @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
|
|
22
|
+
* @property {(...args: any[]) => any} [connect] - a client whose constructor name ends in `Pool` or that has a numeric `totalCount` (`pg.Pool`) is treated as a pool; setup checks out one connection
|
|
23
|
+
* @property {number} [totalCount]
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
/**
|
|
@@ -119,8 +121,18 @@ class PostgresQueue {
|
|
|
119
121
|
/** @internal */
|
|
120
122
|
__applyPrefix(prefix) {
|
|
121
123
|
this.prefix = prefix;
|
|
122
|
-
this.
|
|
123
|
-
|
|
124
|
+
this.__startSetup();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @internal */
|
|
128
|
+
__startSetup() {
|
|
129
|
+
const setup = this.__setup();
|
|
130
|
+
this.__readyPromise = setup;
|
|
131
|
+
// A failed setup (database still booting) must not poison every later call; the next
|
|
132
|
+
// ready() retries it. The awaiting caller still sees this attempt's rejection.
|
|
133
|
+
setup.catch(() => {
|
|
134
|
+
if (this.__readyPromise === setup) this.__readyPromise = null;
|
|
135
|
+
});
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
/** @internal */
|
|
@@ -145,16 +157,25 @@ class PostgresQueue {
|
|
|
145
157
|
async ready() {
|
|
146
158
|
this.__ensurePrefix();
|
|
147
159
|
this.__debug('[ready]');
|
|
160
|
+
if (!this.__readyPromise) this.__startSetup();
|
|
148
161
|
await this.__readyPromise;
|
|
149
162
|
}
|
|
150
163
|
|
|
151
164
|
/** @internal */
|
|
152
165
|
async __setup() {
|
|
153
166
|
const advisoryLockKey = advisoryLockKeyFor(this.prefix);
|
|
154
|
-
|
|
167
|
+
// `pg_advisory_lock` is session-scoped. Through a `pg.Pool` each `query()` may use a
|
|
168
|
+
// different connection, so lock, DDL and unlock must share one checked-out client;
|
|
169
|
+
// otherwise the lock stays on an idle pooled connection and blocks peer startups.
|
|
170
|
+
const pooled = typeof this.client.connect === 'function'
|
|
171
|
+
&& (typeof this.client.totalCount === 'number' || /Pool$/.test(this.client.constructor?.name || ''));
|
|
172
|
+
const conn = pooled ? await this.client.connect() : this.client;
|
|
173
|
+
let locked = false;
|
|
155
174
|
|
|
156
175
|
try {
|
|
157
|
-
await
|
|
176
|
+
await conn.query('SELECT pg_advisory_lock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
177
|
+
locked = true;
|
|
178
|
+
await conn.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
|
|
158
179
|
id BIGSERIAL PRIMARY KEY,
|
|
159
180
|
prefix TEXT NOT NULL DEFAULT 'default',
|
|
160
181
|
uuid TEXT NOT NULL,
|
|
@@ -175,18 +196,22 @@ class PostgresQueue {
|
|
|
175
196
|
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
|
176
197
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
|
177
198
|
)`);
|
|
178
|
-
await
|
|
179
|
-
await
|
|
180
|
-
await
|
|
199
|
+
await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS is_settled BOOLEAN NOT NULL DEFAULT false');
|
|
200
|
+
await conn.query('ALTER TABLE mail_time_queue ADD COLUMN IF NOT EXISTS recipient_results JSONB');
|
|
201
|
+
await conn.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
|
|
181
202
|
ON mail_time_queue (prefix, uuid)`);
|
|
182
203
|
|
|
183
|
-
await
|
|
204
|
+
await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_due_v1
|
|
184
205
|
ON mail_time_queue (prefix, is_settled, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
|
|
185
206
|
|
|
186
|
-
await
|
|
207
|
+
await conn.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_policy_pending_to_v1
|
|
187
208
|
ON mail_time_queue (prefix, to_address, is_settled, is_sent, is_failed, is_cancelled, send_at)`);
|
|
188
209
|
} finally {
|
|
189
|
-
|
|
210
|
+
try {
|
|
211
|
+
if (locked) await conn.query('SELECT pg_advisory_unlock($1, $2)', [ADVISORY_LOCK_NAMESPACE, advisoryLockKey]);
|
|
212
|
+
} finally {
|
|
213
|
+
if (pooled) conn.release();
|
|
214
|
+
}
|
|
190
215
|
}
|
|
191
216
|
}
|
|
192
217
|
|
package/adapters/redis.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type RedisClient = {
|
|
|
6
6
|
ping?: (() => Promise<string>) | undefined;
|
|
7
7
|
getRandomNode?: (() => unknown) | undefined;
|
|
8
8
|
nodeClient?: ((...args: any[]) => any) | undefined;
|
|
9
|
-
sendCommand?: ((
|
|
9
|
+
sendCommand?: ((...args: any[]) => Promise<unknown>) | undefined;
|
|
10
10
|
scanIterator?: ((options: object) => AsyncIterable<string | string[]>) | undefined;
|
|
11
11
|
hGet?: ((key: string, field: string) => Promise<string | null | undefined>) | undefined;
|
|
12
12
|
eval?: ((script: string, options: {
|
package/adapters/redis.js
CHANGED
|
@@ -19,7 +19,7 @@ import { createHash } from 'crypto';
|
|
|
19
19
|
* @property {() => Promise<string>} [ping]
|
|
20
20
|
* @property {() => unknown} [getRandomNode]
|
|
21
21
|
* @property {(...args: any[]) => any} [nodeClient]
|
|
22
|
-
* @property {(
|
|
22
|
+
* @property {(...args: any[]) => Promise<unknown>} [sendCommand]
|
|
23
23
|
* @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
|
|
24
24
|
* @property {(key: string, field: string) => Promise<string|null|undefined>} [hGet]
|
|
25
25
|
* @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
|
|
@@ -218,6 +218,12 @@ const isNoScriptError = (error) => {
|
|
|
218
218
|
return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
|
|
219
219
|
};
|
|
220
220
|
|
|
221
|
+
// node-redis 4/5 throw `WatchError` from `exec()` when a watched key changed; that is the
|
|
222
|
+
// expected outcome of a lost CAS race, not a storage failure.
|
|
223
|
+
const isWatchError = (error) => {
|
|
224
|
+
return !!error && (error.name === 'WatchError' || error.constructor?.name === 'WatchError');
|
|
225
|
+
};
|
|
226
|
+
|
|
221
227
|
const canReleaseLease = (currentTask, updateObj) => {
|
|
222
228
|
return currentTask
|
|
223
229
|
&& currentTask.tries === updateObj.leaseTries
|
|
@@ -611,15 +617,19 @@ class RedisQueue {
|
|
|
611
617
|
const taskJSON = JSON.stringify(task);
|
|
612
618
|
|
|
613
619
|
if (typeof this.client.multi === 'function') {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
multi.set(
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
620
|
+
// EXEC discards every WATCH on the connection, so an unserialized push would turn a
|
|
621
|
+
// concurrent claim's guarded MULTI into an unconditional write (duplicate send).
|
|
622
|
+
await this.__serialize(async () => {
|
|
623
|
+
const multi = this.client.multi();
|
|
624
|
+
multi.set(letterKey, taskJSON);
|
|
625
|
+
multi.set(sendatKey, `${task.sendAt}`);
|
|
626
|
+
if (task.to) {
|
|
627
|
+
multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
|
|
628
|
+
PXAT: task.sendAt - 128,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
await multi.exec();
|
|
632
|
+
});
|
|
623
633
|
return;
|
|
624
634
|
}
|
|
625
635
|
|
|
@@ -656,7 +666,7 @@ class RedisQueue {
|
|
|
656
666
|
}
|
|
657
667
|
|
|
658
668
|
/** @internal */
|
|
659
|
-
async __cancel(uuid) {
|
|
669
|
+
async __cancel(uuid, attempt = 0) {
|
|
660
670
|
this.__debug('[cancel]', uuid);
|
|
661
671
|
if (typeof uuid !== 'string') {
|
|
662
672
|
return false;
|
|
@@ -677,7 +687,10 @@ class RedisQueue {
|
|
|
677
687
|
const args = keep
|
|
678
688
|
? [uuid, JSON.stringify({ isCancelled: true }), 'cancel', `${Date.now()}`, `${this.mailTimeInstance.sendingTimeout || 300000}`, '0', '0', payload, JSON.stringify({ ...task, isCancelled: true })]
|
|
679
689
|
: [uuid, 'cancel', '0', '0'];
|
|
680
|
-
|
|
690
|
+
const result = Number(await this.__runScript(keep ? 'update' : 'remove', { keys, arguments: args }));
|
|
691
|
+
// -1: the payload changed between hGet and the script (a claim renewal, for example); retry on a fresh snapshot
|
|
692
|
+
if (result === -1 && attempt < 2) return await this.__cancel(uuid, attempt + 1);
|
|
693
|
+
return result >= 1;
|
|
681
694
|
}
|
|
682
695
|
if (!atomic) {
|
|
683
696
|
if (task.recipientResults != null) return false;
|
|
@@ -689,7 +702,7 @@ class RedisQueue {
|
|
|
689
702
|
multi.del(this.__getKey(uuid, 'sendat'));
|
|
690
703
|
return (await multi.exec()) !== null;
|
|
691
704
|
} catch (error) {
|
|
692
|
-
logError('[cancel] storage error', error);
|
|
705
|
+
if (!isWatchError(error)) logError('[cancel] storage error', error);
|
|
693
706
|
return false;
|
|
694
707
|
} finally {
|
|
695
708
|
if (!this.useHashTags && atomic) await this.client.unwatch?.();
|
|
@@ -746,7 +759,9 @@ class RedisQueue {
|
|
|
746
759
|
return false;
|
|
747
760
|
}
|
|
748
761
|
try {
|
|
762
|
+
const concatKey = task.to ? this.__getKey(task.to, 'concatletter') : null;
|
|
749
763
|
await this.client.watch(letterKey);
|
|
764
|
+
if (concatKey) await this.client.watch(concatKey);
|
|
750
765
|
const taskJSON = await this.client.get(letterKey);
|
|
751
766
|
if (!taskJSON) {
|
|
752
767
|
await this.client.unwatch?.();
|
|
@@ -764,8 +779,9 @@ class RedisQueue {
|
|
|
764
779
|
return false;
|
|
765
780
|
}
|
|
766
781
|
const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
|
|
767
|
-
|
|
768
|
-
|
|
782
|
+
// The pointer may already name a newer letter folded for the same recipient.
|
|
783
|
+
if (concatKey && (await this.client.get(concatKey)) === task.uuid) {
|
|
784
|
+
keysToDelete.push(concatKey);
|
|
769
785
|
}
|
|
770
786
|
const multi = this.client.multi();
|
|
771
787
|
for (const key of keysToDelete) {
|
|
@@ -774,7 +790,7 @@ class RedisQueue {
|
|
|
774
790
|
const result = await multi.exec();
|
|
775
791
|
return result !== null;
|
|
776
792
|
} catch (opError) {
|
|
777
|
-
logError('[remove] [lease] [opError]', opError);
|
|
793
|
+
if (!isWatchError(opError)) logError('[remove] [lease] [opError]', opError);
|
|
778
794
|
return false;
|
|
779
795
|
}
|
|
780
796
|
}
|
|
@@ -786,7 +802,10 @@ class RedisQueue {
|
|
|
786
802
|
|
|
787
803
|
const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
|
|
788
804
|
if (task.to) {
|
|
789
|
-
|
|
805
|
+
const concatKey = this.__getKey(task.to, 'concatletter');
|
|
806
|
+
if ((await this.client.get(concatKey)) === task.uuid) {
|
|
807
|
+
keysToDelete.push(concatKey);
|
|
808
|
+
}
|
|
790
809
|
}
|
|
791
810
|
await this.client.del(keysToDelete);
|
|
792
811
|
return true;
|
|
@@ -946,7 +965,7 @@ class RedisQueue {
|
|
|
946
965
|
}
|
|
947
966
|
return true;
|
|
948
967
|
} catch (opError) {
|
|
949
|
-
logError('[update] [try/catch] [opError]', opError);
|
|
968
|
+
if (!isWatchError(opError)) logError('[update] [try/catch] [opError]', opError);
|
|
950
969
|
return false;
|
|
951
970
|
}
|
|
952
971
|
}
|