mail-time 5.2.0 → 5.2.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 +3 -3
- package/adapters/mongo.js +1 -1
- package/adapters/postgres.js +3 -0
- package/adapters/redis.js +6 -0
- package/index.cjs +89 -37
- package/index.d.cts +2 -2
- package/index.d.ts +2 -2
- package/index.js +64 -21
- package/package.json +2 -1
- package/presets.js +15 -15
package/README.md
CHANGED
|
@@ -274,11 +274,11 @@ process.on('SIGTERM', async () => {
|
|
|
274
274
|
});
|
|
275
275
|
```
|
|
276
276
|
|
|
277
|
-
Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
|
|
277
|
+
Graceful destroy waits for JoSk's running queue scan, then in-flight SMTP; claim renewal keeps running while sends drain. Sends still waiting for a `concurrency` slot are dropped at once and their rows stay unclaimed for the next scan, so in-flight SMTP does not count against `schedulerTimeout`. `schedulerTimeout` defaults to 10 seconds for JoSk's running-handler wait; it does not bound JoSk's own storage scan, SMTP, or policy hooks. A timed-out handler or a failed JoSk shutdown (logged) returns `false`; the Promise never rejects. `destroy()` without `{ drain: true }` stops immediately: in-flight completions make no storage writes and their claims recover after `sendingTimeout`. See [JoSk 6.4 recovery and shutdown](https://github.com/veliovgroup/mail-time/blob/master/docs/tuning.md#josk-64-restarts-and-shutdown).
|
|
278
278
|
|
|
279
279
|
#### Pause / resume a server (backpressure)
|
|
280
280
|
|
|
281
|
-
`pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
|
|
281
|
+
`pause()` stops this **server** instance from competing for the queue-drain lease without tearing it down (unlike `destroy()`). In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay queued; other `server` instances keep draining. `resume()` resumes and triggers an immediate scan.
|
|
282
282
|
|
|
283
283
|
```js
|
|
284
284
|
mailQueue.pause(); // stop draining on this pod
|
|
@@ -558,7 +558,7 @@ For deeper JoSk semantics, install the JoSk skill: **`npx skills add veliovgroup
|
|
|
558
558
|
- `cancel(uuid)` — alias of `cancelMail`.
|
|
559
559
|
- `ping()` → `Promise<{status, code, statusCode, paused?, error?}>`. Pings scheduler then queue; `paused` reflects `isPaused`.
|
|
560
560
|
- `ready()` → `Promise<MailTime>`. Awaits all startup work; rejects with `.cause` on storage failure.
|
|
561
|
-
- `destroy(opts?)` → `boolean` or `Promise<boolean>` when `{ drain: true }`. Graceful shutdown waits for JoSk's scan and the SMTP pool; `schedulerTimeout` defaults to 10000 ms; a timed-out scan or failed JoSk shutdown resolves `false` (never rejects). Plain `destroy()` aborts completion writes.
|
|
561
|
+
- `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
562
|
- `drain()` → `Promise<void>`. Resolves once every in-flight SMTP attempt finishes. Useful in tests and graceful-shutdown paths.
|
|
563
563
|
- `pause()` / `resume()` → `boolean`. Server-only reversible backpressure; no-ops on `client` or after `destroy()`. See [Shutdown](#6-shutdown).
|
|
564
564
|
- `isPaused` → `boolean`. Read-only; always `false` on `client`.
|
package/adapters/mongo.js
CHANGED
package/adapters/postgres.js
CHANGED
|
@@ -273,6 +273,9 @@ class PostgresQueue {
|
|
|
273
273
|
LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
|
|
274
274
|
|
|
275
275
|
for (const row of res.rows || []) {
|
|
276
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
276
279
|
await this.mailTimeInstance.___dispatch(normalizeRow(row));
|
|
277
280
|
}
|
|
278
281
|
} catch (iterateError) {
|
package/adapters/redis.js
CHANGED
|
@@ -485,6 +485,9 @@ class RedisQueue {
|
|
|
485
485
|
return;
|
|
486
486
|
}
|
|
487
487
|
for (const value of candidates) {
|
|
488
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
488
491
|
const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
|
|
489
492
|
if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
490
493
|
await this.mailTimeInstance.___dispatch(candidate);
|
|
@@ -505,6 +508,9 @@ class RedisQueue {
|
|
|
505
508
|
for await (const cursorValue of cursor) {
|
|
506
509
|
const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
|
|
507
510
|
for (const sendatKey of sendatKeys) {
|
|
511
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
512
|
+
break outer;
|
|
513
|
+
}
|
|
508
514
|
const raw = await this.client.get(sendatKey);
|
|
509
515
|
if (raw === null || parseInt(raw, 10) > now) {
|
|
510
516
|
continue;
|
package/index.cjs
CHANGED
|
@@ -524,7 +524,7 @@ class MongoQueue {
|
|
|
524
524
|
}
|
|
525
525
|
|
|
526
526
|
try {
|
|
527
|
-
while (await cursor.hasNext()) {
|
|
527
|
+
while (!this.mailTimeInstance.___isStopped && await cursor.hasNext()) {
|
|
528
528
|
await this.mailTimeInstance.___dispatch(await cursor.next());
|
|
529
529
|
}
|
|
530
530
|
} finally {
|
|
@@ -1214,6 +1214,9 @@ class RedisQueue {
|
|
|
1214
1214
|
return;
|
|
1215
1215
|
}
|
|
1216
1216
|
for (const value of candidates) {
|
|
1217
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1217
1220
|
const candidate = normalizePolicyArrays(typeof value === 'string' ? JSON.parse(value) : value);
|
|
1218
1221
|
if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
|
|
1219
1222
|
await this.mailTimeInstance.___dispatch(candidate);
|
|
@@ -1234,6 +1237,9 @@ class RedisQueue {
|
|
|
1234
1237
|
for await (const cursorValue of cursor) {
|
|
1235
1238
|
const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
|
|
1236
1239
|
for (const sendatKey of sendatKeys) {
|
|
1240
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1241
|
+
break outer;
|
|
1242
|
+
}
|
|
1237
1243
|
const raw = await this.client.get(sendatKey);
|
|
1238
1244
|
if (raw === null || parseInt(raw, 10) > now) {
|
|
1239
1245
|
continue;
|
|
@@ -1964,6 +1970,9 @@ class PostgresQueue {
|
|
|
1964
1970
|
LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
|
|
1965
1971
|
|
|
1966
1972
|
for (const row of res.rows || []) {
|
|
1973
|
+
if (this.mailTimeInstance.___isStopped) {
|
|
1974
|
+
break;
|
|
1975
|
+
}
|
|
1967
1976
|
await this.mailTimeInstance.___dispatch(normalizeRow(row));
|
|
1968
1977
|
}
|
|
1969
1978
|
} catch (iterateError) {
|
|
@@ -2305,12 +2314,12 @@ const PRESETS = Object.freeze({
|
|
|
2305
2314
|
transactional: Object.freeze({
|
|
2306
2315
|
concatEmails: false,
|
|
2307
2316
|
retries: 30,
|
|
2308
|
-
retryDelay:
|
|
2317
|
+
retryDelay: 10000,
|
|
2309
2318
|
mode: 'batch',
|
|
2310
2319
|
concurrency: 1,
|
|
2311
2320
|
onError: defaultPresetOnError,
|
|
2312
2321
|
josk: Object.freeze({
|
|
2313
|
-
zombieTime:
|
|
2322
|
+
zombieTime: 120000,
|
|
2314
2323
|
}),
|
|
2315
2324
|
}),
|
|
2316
2325
|
otp: Object.freeze({
|
|
@@ -2318,52 +2327,52 @@ const PRESETS = Object.freeze({
|
|
|
2318
2327
|
retries: 5,
|
|
2319
2328
|
retryDelay: 2000,
|
|
2320
2329
|
revolvingInterval: 1024,
|
|
2321
|
-
sendingTimeout:
|
|
2330
|
+
sendingTimeout: 120000,
|
|
2322
2331
|
mode: 'batch',
|
|
2323
2332
|
concurrency: 4,
|
|
2324
2333
|
onError: defaultPresetOnError,
|
|
2325
2334
|
josk: Object.freeze({
|
|
2326
2335
|
minRevolvingDelay: 256,
|
|
2327
2336
|
maxRevolvingDelay: 1024,
|
|
2328
|
-
zombieTime:
|
|
2337
|
+
zombieTime: 60000,
|
|
2329
2338
|
}),
|
|
2330
2339
|
}),
|
|
2331
2340
|
newsletter: Object.freeze({
|
|
2332
2341
|
concatEmails: true,
|
|
2333
|
-
concatDelay: 5 *
|
|
2342
|
+
concatDelay: 5 * 60000,
|
|
2334
2343
|
concatSubject: 'Your updates',
|
|
2335
2344
|
retries: 5,
|
|
2336
|
-
retryDelay:
|
|
2337
|
-
sendingTimeout:
|
|
2345
|
+
retryDelay: 60000,
|
|
2346
|
+
sendingTimeout: 600000,
|
|
2338
2347
|
mode: 'batch',
|
|
2339
2348
|
concurrency: 2,
|
|
2340
2349
|
onError: defaultPresetOnError,
|
|
2341
2350
|
josk: Object.freeze({
|
|
2342
|
-
zombieTime:
|
|
2351
|
+
zombieTime: 300000,
|
|
2343
2352
|
}),
|
|
2344
2353
|
}),
|
|
2345
2354
|
marketing: Object.freeze({
|
|
2346
2355
|
concatEmails: false,
|
|
2347
2356
|
retries: 10,
|
|
2348
|
-
retryDelay:
|
|
2357
|
+
retryDelay: 30000,
|
|
2349
2358
|
mode: 'batch',
|
|
2350
2359
|
concurrency: 5,
|
|
2351
2360
|
onError: defaultPresetOnError,
|
|
2352
2361
|
josk: Object.freeze({
|
|
2353
|
-
zombieTime:
|
|
2362
|
+
zombieTime: 180000,
|
|
2354
2363
|
}),
|
|
2355
2364
|
}),
|
|
2356
2365
|
notifications: Object.freeze({
|
|
2357
2366
|
concatEmails: true,
|
|
2358
|
-
concatDelay:
|
|
2367
|
+
concatDelay: 60000,
|
|
2359
2368
|
concatSubject: 'New activity',
|
|
2360
2369
|
retries: 8,
|
|
2361
|
-
retryDelay:
|
|
2370
|
+
retryDelay: 30000,
|
|
2362
2371
|
mode: 'batch',
|
|
2363
2372
|
concurrency: 3,
|
|
2364
2373
|
onError: defaultPresetOnError,
|
|
2365
2374
|
josk: Object.freeze({
|
|
2366
|
-
zombieTime:
|
|
2375
|
+
zombieTime: 180000,
|
|
2367
2376
|
}),
|
|
2368
2377
|
}),
|
|
2369
2378
|
alerts: Object.freeze({
|
|
@@ -2371,14 +2380,14 @@ const PRESETS = Object.freeze({
|
|
|
2371
2380
|
retries: 20,
|
|
2372
2381
|
retryDelay: 5000,
|
|
2373
2382
|
revolvingInterval: 1024,
|
|
2374
|
-
sendingTimeout:
|
|
2383
|
+
sendingTimeout: 120000,
|
|
2375
2384
|
mode: 'batch',
|
|
2376
2385
|
concurrency: 2,
|
|
2377
2386
|
onError: defaultPresetOnError,
|
|
2378
2387
|
josk: Object.freeze({
|
|
2379
2388
|
minRevolvingDelay: 256,
|
|
2380
2389
|
maxRevolvingDelay: 1024,
|
|
2381
|
-
zombieTime:
|
|
2390
|
+
zombieTime: 60000,
|
|
2382
2391
|
}),
|
|
2383
2392
|
}),
|
|
2384
2393
|
});
|
|
@@ -2813,11 +2822,19 @@ const createPool = (concurrency) => {
|
|
|
2813
2822
|
let active = 0;
|
|
2814
2823
|
let drainResolvers = [];
|
|
2815
2824
|
|
|
2825
|
+
const settleDrain = () => {
|
|
2826
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
2827
|
+
const resolvers = drainResolvers;
|
|
2828
|
+
drainResolvers = [];
|
|
2829
|
+
for (const r of resolvers) r();
|
|
2830
|
+
}
|
|
2831
|
+
};
|
|
2832
|
+
|
|
2816
2833
|
const tryStart = () => {
|
|
2817
2834
|
while (active < limit && queue.length > 0) {
|
|
2818
2835
|
const job = queue.shift();
|
|
2819
2836
|
active++;
|
|
2820
|
-
job.resolveSlot();
|
|
2837
|
+
job.resolveSlot(true);
|
|
2821
2838
|
Promise.resolve()
|
|
2822
2839
|
.then(job.fn)
|
|
2823
2840
|
.catch((poolError) => {
|
|
@@ -2825,11 +2842,7 @@ const createPool = (concurrency) => {
|
|
|
2825
2842
|
})
|
|
2826
2843
|
.finally(() => {
|
|
2827
2844
|
active--;
|
|
2828
|
-
|
|
2829
|
-
const resolvers = drainResolvers;
|
|
2830
|
-
drainResolvers = [];
|
|
2831
|
-
for (const r of resolvers) r();
|
|
2832
|
-
}
|
|
2845
|
+
settleDrain();
|
|
2833
2846
|
tryStart();
|
|
2834
2847
|
});
|
|
2835
2848
|
}
|
|
@@ -2838,10 +2851,11 @@ const createPool = (concurrency) => {
|
|
|
2838
2851
|
return {
|
|
2839
2852
|
/**
|
|
2840
2853
|
* Queue `fn` for execution under the concurrency limit.
|
|
2841
|
-
* The returned Promise resolves as soon as a slot is acquired and `fn` has started
|
|
2854
|
+
* The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
|
|
2855
|
+
* or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
|
|
2842
2856
|
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
2843
2857
|
* @param {() => Promise<void>} fn
|
|
2844
|
-
* @returns {Promise<
|
|
2858
|
+
* @returns {Promise<boolean>}
|
|
2845
2859
|
*/
|
|
2846
2860
|
dispatch(fn) {
|
|
2847
2861
|
return new Promise((resolveSlot) => {
|
|
@@ -2849,6 +2863,16 @@ const createPool = (concurrency) => {
|
|
|
2849
2863
|
tryStart();
|
|
2850
2864
|
});
|
|
2851
2865
|
},
|
|
2866
|
+
/**
|
|
2867
|
+
* Drop every job still waiting for a slot. Running jobs are untouched.
|
|
2868
|
+
* @returns {number} dropped jobs
|
|
2869
|
+
*/
|
|
2870
|
+
cancelQueued() {
|
|
2871
|
+
const dropped = queue.splice(0);
|
|
2872
|
+
for (const job of dropped) job.resolveSlot(false);
|
|
2873
|
+
settleDrain();
|
|
2874
|
+
return dropped.length;
|
|
2875
|
+
},
|
|
2852
2876
|
drain() {
|
|
2853
2877
|
if (active === 0 && queue.length === 0) {
|
|
2854
2878
|
return Promise.resolve();
|
|
@@ -3100,6 +3124,7 @@ class MailTime {
|
|
|
3100
3124
|
this.__isDestroyed = false;
|
|
3101
3125
|
this.__abortInFlight = false;
|
|
3102
3126
|
this.__isPaused = false;
|
|
3127
|
+
this.__schedulerScans = 0;
|
|
3103
3128
|
this.__readyPromise = null;
|
|
3104
3129
|
this.__schedulerTimer = null;
|
|
3105
3130
|
this.__inFlight = new Set();
|
|
@@ -3277,7 +3302,7 @@ class MailTime {
|
|
|
3277
3302
|
/**
|
|
3278
3303
|
* @memberOf MailTime
|
|
3279
3304
|
* @name destroy
|
|
3280
|
-
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if
|
|
3305
|
+
* @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
|
|
3281
3306
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
3282
3307
|
* @returns {boolean | Promise<boolean>}
|
|
3283
3308
|
*/
|
|
@@ -3293,6 +3318,9 @@ class MailTime {
|
|
|
3293
3318
|
|
|
3294
3319
|
this.__isDestroyed = true;
|
|
3295
3320
|
this.__isPaused = false;
|
|
3321
|
+
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
3322
|
+
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
3323
|
+
this.__pool.cancelQueued();
|
|
3296
3324
|
if (opts?.drain === true) {
|
|
3297
3325
|
return (async () => {
|
|
3298
3326
|
let finished = true;
|
|
@@ -3338,7 +3366,7 @@ class MailTime {
|
|
|
3338
3366
|
/**
|
|
3339
3367
|
* @memberOf MailTime
|
|
3340
3368
|
* @name pause
|
|
3341
|
-
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
3369
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
3342
3370
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
3343
3371
|
*/
|
|
3344
3372
|
pause() {
|
|
@@ -3349,6 +3377,9 @@ class MailTime {
|
|
|
3349
3377
|
const paused = this.scheduler.pause();
|
|
3350
3378
|
if (paused) {
|
|
3351
3379
|
this.__isPaused = true;
|
|
3380
|
+
if (this.__schedulerScans > 0) {
|
|
3381
|
+
this.__pool.cancelQueued();
|
|
3382
|
+
}
|
|
3352
3383
|
}
|
|
3353
3384
|
return paused;
|
|
3354
3385
|
}
|
|
@@ -3674,9 +3705,11 @@ class MailTime {
|
|
|
3674
3705
|
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
3675
3706
|
}
|
|
3676
3707
|
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3708
|
+
for (const field of ['html', 'text', 'subject']) {
|
|
3709
|
+
if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
|
|
3710
|
+
compiledOpts[field] = '';
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3680
3713
|
|
|
3681
3714
|
const mailOptionsList = task.mailOptions || [];
|
|
3682
3715
|
const isMulti = mailOptionsList.length > 1;
|
|
@@ -4193,12 +4226,12 @@ class MailTime {
|
|
|
4193
4226
|
* @internal
|
|
4194
4227
|
* @memberOf MailTime
|
|
4195
4228
|
* @name ___dispatch
|
|
4196
|
-
* @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
4229
|
+
* @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
4197
4230
|
* @param {MailTimeTask} task - email's task object from Storage
|
|
4198
4231
|
* @returns {Promise<void 0>}
|
|
4199
4232
|
*/
|
|
4200
4233
|
async ___dispatch(task) {
|
|
4201
|
-
if (this.
|
|
4234
|
+
if (this.___isStopped) {
|
|
4202
4235
|
return;
|
|
4203
4236
|
}
|
|
4204
4237
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -4209,9 +4242,9 @@ class MailTime {
|
|
|
4209
4242
|
return;
|
|
4210
4243
|
}
|
|
4211
4244
|
this.__inFlight.add(task.uuid);
|
|
4212
|
-
await this.__pool.dispatch(async () => {
|
|
4245
|
+
const started = await this.__pool.dispatch(async () => {
|
|
4213
4246
|
try {
|
|
4214
|
-
if (this.
|
|
4247
|
+
if (this.___isStopped) {
|
|
4215
4248
|
return;
|
|
4216
4249
|
}
|
|
4217
4250
|
await this.___send(task);
|
|
@@ -4219,6 +4252,20 @@ class MailTime {
|
|
|
4219
4252
|
this.__inFlight.delete(task.uuid);
|
|
4220
4253
|
}
|
|
4221
4254
|
});
|
|
4255
|
+
if (!started) {
|
|
4256
|
+
this.__inFlight.delete(task.uuid);
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
/**
|
|
4261
|
+
* @internal
|
|
4262
|
+
* @memberOf MailTime
|
|
4263
|
+
* @name ___isStopped
|
|
4264
|
+
* @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
|
|
4265
|
+
* @returns {boolean}
|
|
4266
|
+
*/
|
|
4267
|
+
get ___isStopped() {
|
|
4268
|
+
return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
|
|
4222
4269
|
}
|
|
4223
4270
|
|
|
4224
4271
|
/**
|
|
@@ -4433,10 +4480,15 @@ class MailTime {
|
|
|
4433
4480
|
return;
|
|
4434
4481
|
}
|
|
4435
4482
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4483
|
+
this.__schedulerScans++;
|
|
4484
|
+
try {
|
|
4485
|
+
return await this.queue.iterate({
|
|
4486
|
+
limit,
|
|
4487
|
+
sendingTimeout: this.sendingTimeout,
|
|
4488
|
+
});
|
|
4489
|
+
} finally {
|
|
4490
|
+
this.__schedulerScans--;
|
|
4491
|
+
}
|
|
4440
4492
|
}
|
|
4441
4493
|
|
|
4442
4494
|
/**
|
package/index.d.cts
CHANGED
|
@@ -404,7 +404,7 @@ export class MailTime {
|
|
|
404
404
|
/**
|
|
405
405
|
* @memberOf MailTime
|
|
406
406
|
* @name destroy
|
|
407
|
-
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if
|
|
407
|
+
* @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
|
|
408
408
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
409
409
|
* @returns {boolean | Promise<boolean>}
|
|
410
410
|
*/
|
|
@@ -423,7 +423,7 @@ export class MailTime {
|
|
|
423
423
|
/**
|
|
424
424
|
* @memberOf MailTime
|
|
425
425
|
* @name pause
|
|
426
|
-
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
426
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
427
427
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
428
428
|
*/
|
|
429
429
|
pause(): boolean;
|
package/index.d.ts
CHANGED
|
@@ -404,7 +404,7 @@ export class MailTime {
|
|
|
404
404
|
/**
|
|
405
405
|
* @memberOf MailTime
|
|
406
406
|
* @name destroy
|
|
407
|
-
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if
|
|
407
|
+
* @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
|
|
408
408
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
409
409
|
* @returns {boolean | Promise<boolean>}
|
|
410
410
|
*/
|
|
@@ -423,7 +423,7 @@ export class MailTime {
|
|
|
423
423
|
/**
|
|
424
424
|
* @memberOf MailTime
|
|
425
425
|
* @name pause
|
|
426
|
-
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
426
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
427
427
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
428
428
|
*/
|
|
429
429
|
pause(): boolean;
|
package/index.js
CHANGED
|
@@ -79,11 +79,19 @@ const createPool = (concurrency) => {
|
|
|
79
79
|
let active = 0;
|
|
80
80
|
let drainResolvers = [];
|
|
81
81
|
|
|
82
|
+
const settleDrain = () => {
|
|
83
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
84
|
+
const resolvers = drainResolvers;
|
|
85
|
+
drainResolvers = [];
|
|
86
|
+
for (const r of resolvers) r();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
82
90
|
const tryStart = () => {
|
|
83
91
|
while (active < limit && queue.length > 0) {
|
|
84
92
|
const job = queue.shift();
|
|
85
93
|
active++;
|
|
86
|
-
job.resolveSlot();
|
|
94
|
+
job.resolveSlot(true);
|
|
87
95
|
Promise.resolve()
|
|
88
96
|
.then(job.fn)
|
|
89
97
|
.catch((poolError) => {
|
|
@@ -91,11 +99,7 @@ const createPool = (concurrency) => {
|
|
|
91
99
|
})
|
|
92
100
|
.finally(() => {
|
|
93
101
|
active--;
|
|
94
|
-
|
|
95
|
-
const resolvers = drainResolvers;
|
|
96
|
-
drainResolvers = [];
|
|
97
|
-
for (const r of resolvers) r();
|
|
98
|
-
}
|
|
102
|
+
settleDrain();
|
|
99
103
|
tryStart();
|
|
100
104
|
});
|
|
101
105
|
}
|
|
@@ -104,10 +108,11 @@ const createPool = (concurrency) => {
|
|
|
104
108
|
return {
|
|
105
109
|
/**
|
|
106
110
|
* Queue `fn` for execution under the concurrency limit.
|
|
107
|
-
* The returned Promise resolves as soon as a slot is acquired and `fn` has started
|
|
111
|
+
* The returned Promise resolves `true` as soon as a slot is acquired and `fn` has started,
|
|
112
|
+
* or `false` if `cancelQueued()` dropped the job before it started (`fn` never runs).
|
|
108
113
|
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
109
114
|
* @param {() => Promise<void>} fn
|
|
110
|
-
* @returns {Promise<
|
|
115
|
+
* @returns {Promise<boolean>}
|
|
111
116
|
*/
|
|
112
117
|
dispatch(fn) {
|
|
113
118
|
return new Promise((resolveSlot) => {
|
|
@@ -115,6 +120,16 @@ const createPool = (concurrency) => {
|
|
|
115
120
|
tryStart();
|
|
116
121
|
});
|
|
117
122
|
},
|
|
123
|
+
/**
|
|
124
|
+
* Drop every job still waiting for a slot. Running jobs are untouched.
|
|
125
|
+
* @returns {number} dropped jobs
|
|
126
|
+
*/
|
|
127
|
+
cancelQueued() {
|
|
128
|
+
const dropped = queue.splice(0);
|
|
129
|
+
for (const job of dropped) job.resolveSlot(false);
|
|
130
|
+
settleDrain();
|
|
131
|
+
return dropped.length;
|
|
132
|
+
},
|
|
118
133
|
drain() {
|
|
119
134
|
if (active === 0 && queue.length === 0) {
|
|
120
135
|
return Promise.resolve();
|
|
@@ -366,6 +381,7 @@ class MailTime {
|
|
|
366
381
|
this.__isDestroyed = false;
|
|
367
382
|
this.__abortInFlight = false;
|
|
368
383
|
this.__isPaused = false;
|
|
384
|
+
this.__schedulerScans = 0;
|
|
369
385
|
this.__readyPromise = null;
|
|
370
386
|
this.__schedulerTimer = null;
|
|
371
387
|
this.__inFlight = new Set();
|
|
@@ -543,7 +559,7 @@ class MailTime {
|
|
|
543
559
|
/**
|
|
544
560
|
* @memberOf MailTime
|
|
545
561
|
* @name destroy
|
|
546
|
-
* @description Stop the scheduler and block future dispatches. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if
|
|
562
|
+
* @description Stop the scheduler and block future dispatches. Sends still waiting for a `concurrency` slot are dropped at once; their rows stay unclaimed for the next scan. Without `{ drain: true }`, in-flight SMTP attempts are neutralized and their claims recover after `sendingTimeout`. With `{ drain: true }`, await JoSk shutdown and in-flight SMTP; resolves false if the queue scan exceeds `schedulerTimeout` (default 10000ms) or JoSk shutdown throws (logged); never rejects. In-flight SMTP does not count against the timeout, and the timeout does not bound SMTP drain time.
|
|
547
563
|
* @param {{ drain?: boolean, schedulerTimeout?: number }} [opts] - schedulerTimeout must be finite and non-negative; used only with drain
|
|
548
564
|
* @returns {boolean | Promise<boolean>}
|
|
549
565
|
*/
|
|
@@ -559,6 +575,9 @@ class MailTime {
|
|
|
559
575
|
|
|
560
576
|
this.__isDestroyed = true;
|
|
561
577
|
this.__isPaused = false;
|
|
578
|
+
// Queued jobs never started SMTP; dropping them lets a scan blocked on a
|
|
579
|
+
// pool slot return, so JoSk shutdown does not wait for in-flight sends.
|
|
580
|
+
this.__pool.cancelQueued();
|
|
562
581
|
if (opts?.drain === true) {
|
|
563
582
|
return (async () => {
|
|
564
583
|
let finished = true;
|
|
@@ -604,7 +623,7 @@ class MailTime {
|
|
|
604
623
|
/**
|
|
605
624
|
* @memberOf MailTime
|
|
606
625
|
* @name pause
|
|
607
|
-
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
626
|
+
* @description Pause this server instance from competing for the queue-drain lease. In-flight SMTP sends finish; sends still waiting for a `concurrency` slot are dropped and their rows stay unclaimed; peer server instances keep draining. Reversible (unlike `destroy()`). No-op on `client` instances or after `destroy()`. To stop scanning *and* wait for in-flight sends: `mailTime.pause(); await mailTime.drain();`.
|
|
608
627
|
* @returns {boolean} `true` if newly paused; `false` if already paused, a client instance, or destroyed
|
|
609
628
|
*/
|
|
610
629
|
pause() {
|
|
@@ -615,6 +634,9 @@ class MailTime {
|
|
|
615
634
|
const paused = this.scheduler.pause();
|
|
616
635
|
if (paused) {
|
|
617
636
|
this.__isPaused = true;
|
|
637
|
+
if (this.__schedulerScans > 0) {
|
|
638
|
+
this.__pool.cancelQueued();
|
|
639
|
+
}
|
|
618
640
|
}
|
|
619
641
|
return paused;
|
|
620
642
|
}
|
|
@@ -940,9 +962,11 @@ class MailTime {
|
|
|
940
962
|
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
941
963
|
}
|
|
942
964
|
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
965
|
+
for (const field of ['html', 'text', 'subject']) {
|
|
966
|
+
if (compiledOpts[field] === void 0 || compiledOpts[field] === null) {
|
|
967
|
+
compiledOpts[field] = '';
|
|
968
|
+
}
|
|
969
|
+
}
|
|
946
970
|
|
|
947
971
|
const mailOptionsList = task.mailOptions || [];
|
|
948
972
|
const isMulti = mailOptionsList.length > 1;
|
|
@@ -1459,12 +1483,12 @@ class MailTime {
|
|
|
1459
1483
|
* @internal
|
|
1460
1484
|
* @memberOf MailTime
|
|
1461
1485
|
* @name ___dispatch
|
|
1462
|
-
* @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
1486
|
+
* @description Queue full-lifecycle send for `task` under the bounded send pool. No-op after `destroy()`, or while paused during a scheduler-driven scan; `destroy()` and `pause()` also drop jobs still waiting for a slot. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
|
|
1463
1487
|
* @param {MailTimeTask} task - email's task object from Storage
|
|
1464
1488
|
* @returns {Promise<void 0>}
|
|
1465
1489
|
*/
|
|
1466
1490
|
async ___dispatch(task) {
|
|
1467
|
-
if (this.
|
|
1491
|
+
if (this.___isStopped) {
|
|
1468
1492
|
return;
|
|
1469
1493
|
}
|
|
1470
1494
|
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
@@ -1475,9 +1499,9 @@ class MailTime {
|
|
|
1475
1499
|
return;
|
|
1476
1500
|
}
|
|
1477
1501
|
this.__inFlight.add(task.uuid);
|
|
1478
|
-
await this.__pool.dispatch(async () => {
|
|
1502
|
+
const started = await this.__pool.dispatch(async () => {
|
|
1479
1503
|
try {
|
|
1480
|
-
if (this.
|
|
1504
|
+
if (this.___isStopped) {
|
|
1481
1505
|
return;
|
|
1482
1506
|
}
|
|
1483
1507
|
await this.___send(task);
|
|
@@ -1485,6 +1509,20 @@ class MailTime {
|
|
|
1485
1509
|
this.__inFlight.delete(task.uuid);
|
|
1486
1510
|
}
|
|
1487
1511
|
});
|
|
1512
|
+
if (!started) {
|
|
1513
|
+
this.__inFlight.delete(task.uuid);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* @internal
|
|
1519
|
+
* @memberOf MailTime
|
|
1520
|
+
* @name ___isStopped
|
|
1521
|
+
* @description `true` after `destroy()`, or while paused during a scheduler-driven scan. Queue adapters check it to end an `iterate` scan early.
|
|
1522
|
+
* @returns {boolean}
|
|
1523
|
+
*/
|
|
1524
|
+
get ___isStopped() {
|
|
1525
|
+
return this.__isDestroyed || (this.__isPaused && this.__schedulerScans > 0);
|
|
1488
1526
|
}
|
|
1489
1527
|
|
|
1490
1528
|
/**
|
|
@@ -1699,10 +1737,15 @@ class MailTime {
|
|
|
1699
1737
|
return;
|
|
1700
1738
|
}
|
|
1701
1739
|
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1740
|
+
this.__schedulerScans++;
|
|
1741
|
+
try {
|
|
1742
|
+
return await this.queue.iterate({
|
|
1743
|
+
limit,
|
|
1744
|
+
sendingTimeout: this.sendingTimeout,
|
|
1745
|
+
});
|
|
1746
|
+
} finally {
|
|
1747
|
+
this.__schedulerScans--;
|
|
1748
|
+
}
|
|
1706
1749
|
}
|
|
1707
1750
|
|
|
1708
1751
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mail-time",
|
|
3
|
-
"version": "5.2.
|
|
3
|
+
"version": "5.2.1",
|
|
4
4
|
"description": "📮 Email queue extending nodemailer with multi-SMTP transports and horizontally scaled apps support",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "./index.d.ts",
|
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
"devDependencies": {
|
|
155
155
|
"@jest/globals": "^30.4.1",
|
|
156
156
|
"@types/node": "^25.9.0",
|
|
157
|
+
"acorn": "^8.18.0",
|
|
157
158
|
"chai": "^6.2.2",
|
|
158
159
|
"jest": "^30.4.2",
|
|
159
160
|
"mocha": "^11.8.0",
|
package/presets.js
CHANGED
|
@@ -60,12 +60,12 @@ const PRESETS = Object.freeze({
|
|
|
60
60
|
transactional: Object.freeze({
|
|
61
61
|
concatEmails: false,
|
|
62
62
|
retries: 30,
|
|
63
|
-
retryDelay:
|
|
63
|
+
retryDelay: 10000,
|
|
64
64
|
mode: 'batch',
|
|
65
65
|
concurrency: 1,
|
|
66
66
|
onError: defaultPresetOnError,
|
|
67
67
|
josk: Object.freeze({
|
|
68
|
-
zombieTime:
|
|
68
|
+
zombieTime: 120000,
|
|
69
69
|
}),
|
|
70
70
|
}),
|
|
71
71
|
otp: Object.freeze({
|
|
@@ -73,52 +73,52 @@ const PRESETS = Object.freeze({
|
|
|
73
73
|
retries: 5,
|
|
74
74
|
retryDelay: 2000,
|
|
75
75
|
revolvingInterval: 1024,
|
|
76
|
-
sendingTimeout:
|
|
76
|
+
sendingTimeout: 120000,
|
|
77
77
|
mode: 'batch',
|
|
78
78
|
concurrency: 4,
|
|
79
79
|
onError: defaultPresetOnError,
|
|
80
80
|
josk: Object.freeze({
|
|
81
81
|
minRevolvingDelay: 256,
|
|
82
82
|
maxRevolvingDelay: 1024,
|
|
83
|
-
zombieTime:
|
|
83
|
+
zombieTime: 60000,
|
|
84
84
|
}),
|
|
85
85
|
}),
|
|
86
86
|
newsletter: Object.freeze({
|
|
87
87
|
concatEmails: true,
|
|
88
|
-
concatDelay: 5 *
|
|
88
|
+
concatDelay: 5 * 60000,
|
|
89
89
|
concatSubject: 'Your updates',
|
|
90
90
|
retries: 5,
|
|
91
|
-
retryDelay:
|
|
92
|
-
sendingTimeout:
|
|
91
|
+
retryDelay: 60000,
|
|
92
|
+
sendingTimeout: 600000,
|
|
93
93
|
mode: 'batch',
|
|
94
94
|
concurrency: 2,
|
|
95
95
|
onError: defaultPresetOnError,
|
|
96
96
|
josk: Object.freeze({
|
|
97
|
-
zombieTime:
|
|
97
|
+
zombieTime: 300000,
|
|
98
98
|
}),
|
|
99
99
|
}),
|
|
100
100
|
marketing: Object.freeze({
|
|
101
101
|
concatEmails: false,
|
|
102
102
|
retries: 10,
|
|
103
|
-
retryDelay:
|
|
103
|
+
retryDelay: 30000,
|
|
104
104
|
mode: 'batch',
|
|
105
105
|
concurrency: 5,
|
|
106
106
|
onError: defaultPresetOnError,
|
|
107
107
|
josk: Object.freeze({
|
|
108
|
-
zombieTime:
|
|
108
|
+
zombieTime: 180000,
|
|
109
109
|
}),
|
|
110
110
|
}),
|
|
111
111
|
notifications: Object.freeze({
|
|
112
112
|
concatEmails: true,
|
|
113
|
-
concatDelay:
|
|
113
|
+
concatDelay: 60000,
|
|
114
114
|
concatSubject: 'New activity',
|
|
115
115
|
retries: 8,
|
|
116
|
-
retryDelay:
|
|
116
|
+
retryDelay: 30000,
|
|
117
117
|
mode: 'batch',
|
|
118
118
|
concurrency: 3,
|
|
119
119
|
onError: defaultPresetOnError,
|
|
120
120
|
josk: Object.freeze({
|
|
121
|
-
zombieTime:
|
|
121
|
+
zombieTime: 180000,
|
|
122
122
|
}),
|
|
123
123
|
}),
|
|
124
124
|
alerts: Object.freeze({
|
|
@@ -126,14 +126,14 @@ const PRESETS = Object.freeze({
|
|
|
126
126
|
retries: 20,
|
|
127
127
|
retryDelay: 5000,
|
|
128
128
|
revolvingInterval: 1024,
|
|
129
|
-
sendingTimeout:
|
|
129
|
+
sendingTimeout: 120000,
|
|
130
130
|
mode: 'batch',
|
|
131
131
|
concurrency: 2,
|
|
132
132
|
onError: defaultPresetOnError,
|
|
133
133
|
josk: Object.freeze({
|
|
134
134
|
minRevolvingDelay: 256,
|
|
135
135
|
maxRevolvingDelay: 1024,
|
|
136
|
-
zombieTime:
|
|
136
|
+
zombieTime: 60000,
|
|
137
137
|
}),
|
|
138
138
|
}),
|
|
139
139
|
});
|