@push.rocks/smartmta 8.1.0 → 8.1.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/changelog.md +16 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.d.ts +28 -1
- package/dist_ts/mail/delivery/classes.delivery.queue.js +220 -3
- package/dist_ts/mail/interfaces.storage.d.ts +34 -0
- package/dist_ts/mail/interfaces.storage.js +89 -3
- package/dist_ts/mail/routing/classes.email.action.executor.d.ts +15 -0
- package/dist_ts/mail/routing/classes.email.action.executor.js +36 -13
- package/dist_ts/mail/routing/classes.unified.email.server.d.ts +71 -1
- package/dist_ts/mail/routing/classes.unified.email.server.js +488 -5
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +1 -0
- package/ts/mail/delivery/classes.delivery.queue.ts +303 -1
- package/ts/mail/interfaces.storage.ts +148 -2
- package/ts/mail/routing/classes.email.action.executor.ts +93 -13
- package/ts/mail/routing/classes.unified.email.server.ts +667 -5
package/package.json
CHANGED
package/ts/00_commitinfo_data.ts
CHANGED
package/ts/index.ts
CHANGED
|
@@ -58,6 +58,10 @@ export interface IBounceOperation {
|
|
|
58
58
|
*/
|
|
59
59
|
export interface IQueueItem {
|
|
60
60
|
id: string;
|
|
61
|
+
/** Stable caller-supplied key used to make enqueue replay-safe. */
|
|
62
|
+
idempotencyKey?: string;
|
|
63
|
+
/** Fingerprint of the exact work associated with the idempotency key. */
|
|
64
|
+
idempotencyFingerprint?: string;
|
|
61
65
|
processingMode: EmailProcessingMode;
|
|
62
66
|
processingResult: Email;
|
|
63
67
|
/** Materialized attachment count for queue/detail consumers. */
|
|
@@ -86,6 +90,8 @@ interface IStoredEmail {
|
|
|
86
90
|
interface IStoredQueueItemV1 {
|
|
87
91
|
schemaVersion: 1;
|
|
88
92
|
id: string;
|
|
93
|
+
idempotencyKey?: string;
|
|
94
|
+
idempotencyFingerprint?: string;
|
|
89
95
|
processingMode: EmailProcessingMode;
|
|
90
96
|
processingResult: IStoredEmail;
|
|
91
97
|
route?: IEmailRoute;
|
|
@@ -121,6 +127,17 @@ type IStoredQueueItem = IStoredQueueItemV1 | IStoredQueueItemV2;
|
|
|
121
127
|
|
|
122
128
|
type TQueueLifecycleState = 'new' | 'initialized' | 'shutting-down' | 'closed';
|
|
123
129
|
|
|
130
|
+
interface IStoredQueueIdempotencyTombstone {
|
|
131
|
+
schemaVersion: 1;
|
|
132
|
+
id: string;
|
|
133
|
+
idempotencyKey: string;
|
|
134
|
+
idempotencyFingerprint: string;
|
|
135
|
+
expiresAt: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS = 31 * 24 * 60 * 60 * 1000;
|
|
139
|
+
const QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT = 256;
|
|
140
|
+
|
|
124
141
|
export interface IQueueFailureOptions {
|
|
125
142
|
/** Unknown failures default to retryable so temporary internal faults do not lose mail. */
|
|
126
143
|
retryable?: boolean;
|
|
@@ -159,6 +176,15 @@ export interface IQueueOptions {
|
|
|
159
176
|
maxRetryDelay?: number;
|
|
160
177
|
}
|
|
161
178
|
|
|
179
|
+
export interface IQueueEnqueueOptions {
|
|
180
|
+
/** Stable key that resolves to one deterministic queue item id. */
|
|
181
|
+
idempotencyKey?: string;
|
|
182
|
+
/** Required with idempotencyKey to reject reuse for different work. */
|
|
183
|
+
idempotencyFingerprint?: string;
|
|
184
|
+
/** Fail closed unless the queue is backed by managed blob storage. */
|
|
185
|
+
requireDurable?: boolean;
|
|
186
|
+
}
|
|
187
|
+
|
|
162
188
|
/**
|
|
163
189
|
* Queue statistics interface
|
|
164
190
|
*/
|
|
@@ -225,6 +251,11 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
225
251
|
private totalProcessed: number = 0;
|
|
226
252
|
private ownsManagedPrefix = false;
|
|
227
253
|
private lifecycleState: TQueueLifecycleState = 'new';
|
|
254
|
+
private readonly idempotentEnqueueFlights = new Map<string, {
|
|
255
|
+
fingerprint: string;
|
|
256
|
+
promise: Promise<string>;
|
|
257
|
+
}>();
|
|
258
|
+
private idempotencyTombstoneCleanupCursor?: string;
|
|
228
259
|
|
|
229
260
|
/**
|
|
230
261
|
* Create a new unified delivery queue
|
|
@@ -279,6 +310,143 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
279
310
|
}
|
|
280
311
|
}
|
|
281
312
|
|
|
313
|
+
/** Whether queue entries are committed through caller-managed blob storage. */
|
|
314
|
+
public hasManagedStorage(): boolean {
|
|
315
|
+
return !!this.options.storageManager;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Whether managed storage can list a backend-bounded page of keys. */
|
|
319
|
+
public hasPaginatedManagedStorage(): boolean {
|
|
320
|
+
return typeof this.options.storageManager?.listPage === 'function';
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private idempotentQueueId(idempotencyKey: string): string {
|
|
324
|
+
return `idempotent-${createHash('sha256')
|
|
325
|
+
.update('smartmta-queue-v1\0')
|
|
326
|
+
.update(idempotencyKey)
|
|
327
|
+
.digest('hex')}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private idempotencyTombstonePrefix(): string {
|
|
331
|
+
return `${this.options.storagePrefix.slice(0, -1)}.idempotency/`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private idempotencyTombstoneKey(id: string): string {
|
|
335
|
+
return `${this.idempotencyTombstonePrefix()}${id}.json`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private async readIdempotencyTombstone(
|
|
339
|
+
id: string,
|
|
340
|
+
): Promise<IStoredQueueIdempotencyTombstone | null> {
|
|
341
|
+
const storageManager = this.options.storageManager;
|
|
342
|
+
if (!storageManager) return null;
|
|
343
|
+
const key = this.idempotencyTombstoneKey(id);
|
|
344
|
+
const value = await storageManager.get(key);
|
|
345
|
+
if (!value) return null;
|
|
346
|
+
const tombstone = this.parseIdempotencyTombstone(value, key, id);
|
|
347
|
+
if (tombstone.expiresAt <= Date.now()) {
|
|
348
|
+
await storageManager.delete(key);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return tombstone;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private parseIdempotencyTombstone(
|
|
355
|
+
value: Buffer,
|
|
356
|
+
key: string,
|
|
357
|
+
expectedId: string,
|
|
358
|
+
): IStoredQueueIdempotencyTombstone {
|
|
359
|
+
let tombstone: IStoredQueueIdempotencyTombstone;
|
|
360
|
+
try {
|
|
361
|
+
tombstone = JSON.parse(value.toString('utf8')) as IStoredQueueIdempotencyTombstone;
|
|
362
|
+
} catch {
|
|
363
|
+
throw new Error(`Invalid queue idempotency tombstone at ${key}`);
|
|
364
|
+
}
|
|
365
|
+
if (
|
|
366
|
+
tombstone?.schemaVersion !== 1
|
|
367
|
+
|| tombstone.id !== expectedId
|
|
368
|
+
|| typeof tombstone.idempotencyKey !== 'string'
|
|
369
|
+
|| !tombstone.idempotencyKey
|
|
370
|
+
|| tombstone.idempotencyKey.length > 512
|
|
371
|
+
|| this.idempotentQueueId(tombstone.idempotencyKey) !== expectedId
|
|
372
|
+
|| typeof tombstone.idempotencyFingerprint !== 'string'
|
|
373
|
+
|| !/^[a-f0-9]{64}$/.test(tombstone.idempotencyFingerprint)
|
|
374
|
+
|| !Number.isFinite(tombstone.expiresAt)
|
|
375
|
+
) {
|
|
376
|
+
throw new Error(`Invalid queue idempotency tombstone at ${key}`);
|
|
377
|
+
}
|
|
378
|
+
return tombstone;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private async persistIdempotencyTombstone(
|
|
382
|
+
id: string,
|
|
383
|
+
idempotencyKey: string,
|
|
384
|
+
idempotencyFingerprint: string,
|
|
385
|
+
): Promise<void> {
|
|
386
|
+
const storageManager = this.options.storageManager;
|
|
387
|
+
if (!storageManager) return;
|
|
388
|
+
const tombstone: IStoredQueueIdempotencyTombstone = {
|
|
389
|
+
schemaVersion: 1,
|
|
390
|
+
id,
|
|
391
|
+
idempotencyKey,
|
|
392
|
+
idempotencyFingerprint,
|
|
393
|
+
expiresAt: Date.now() + ACCEPTED_ENVELOPE_IDEMPOTENCY_RETENTION_MS,
|
|
394
|
+
};
|
|
395
|
+
await storageManager.set(
|
|
396
|
+
this.idempotencyTombstoneKey(id),
|
|
397
|
+
Buffer.from(JSON.stringify(tombstone)),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private async cleanupExpiredIdempotencyTombstones(
|
|
402
|
+
limit = QUEUE_IDEMPOTENCY_TOMBSTONE_CLEANUP_LIMIT,
|
|
403
|
+
): Promise<void> {
|
|
404
|
+
const storageManager = this.options.storageManager;
|
|
405
|
+
if (!storageManager?.listPage || limit < 1) return;
|
|
406
|
+
const prefix = this.idempotencyTombstonePrefix();
|
|
407
|
+
const page = await storageManager.listPage(prefix, {
|
|
408
|
+
limit,
|
|
409
|
+
...(this.idempotencyTombstoneCleanupCursor
|
|
410
|
+
? { continuationToken: this.idempotencyTombstoneCleanupCursor }
|
|
411
|
+
: {}),
|
|
412
|
+
});
|
|
413
|
+
if (
|
|
414
|
+
!page
|
|
415
|
+
|| !Array.isArray(page.keys)
|
|
416
|
+
|| page.keys.length > limit
|
|
417
|
+
|| page.keys.some((key) => typeof key !== 'string' || !key.startsWith(prefix))
|
|
418
|
+
|| new Set(page.keys).size !== page.keys.length
|
|
419
|
+
|| (
|
|
420
|
+
page.continuationToken !== undefined
|
|
421
|
+
&& (
|
|
422
|
+
typeof page.continuationToken !== 'string'
|
|
423
|
+
|| page.continuationToken.length === 0
|
|
424
|
+
|| page.continuationToken === this.idempotencyTombstoneCleanupCursor
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
) {
|
|
428
|
+
throw new Error('Invalid paginated queue idempotency tombstone listing');
|
|
429
|
+
}
|
|
430
|
+
this.idempotencyTombstoneCleanupCursor = page.continuationToken;
|
|
431
|
+
const now = Date.now();
|
|
432
|
+
for (const key of page.keys) {
|
|
433
|
+
const value = await storageManager.get(key);
|
|
434
|
+
if (!value) continue;
|
|
435
|
+
try {
|
|
436
|
+
if (!key.endsWith('.json')) {
|
|
437
|
+
throw new Error(`Invalid queue idempotency tombstone key: ${key}`);
|
|
438
|
+
}
|
|
439
|
+
const id = key.slice(prefix.length, -'.json'.length);
|
|
440
|
+
const tombstone = this.parseIdempotencyTombstone(value, key, id);
|
|
441
|
+
if (tombstone.expiresAt <= now) {
|
|
442
|
+
await storageManager.delete(key);
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
await storageManager.delete(key);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
282
450
|
private createInitialRecipientStates(email: Email): IRecipientDeliveryState[] {
|
|
283
451
|
const byRecipient = new Map<string, IRecipientDeliveryState>();
|
|
284
452
|
const now = new Date();
|
|
@@ -461,23 +629,113 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
461
629
|
processingResult: Email,
|
|
462
630
|
mode: EmailProcessingMode,
|
|
463
631
|
route?: IEmailRoute,
|
|
632
|
+
enqueueOptions: IQueueEnqueueOptions = {},
|
|
464
633
|
): Promise<string> {
|
|
465
634
|
this.assertMutable();
|
|
466
635
|
if (!(processingResult instanceof Email)) {
|
|
467
636
|
throw new Error('Queue processingResult must be an Email instance');
|
|
468
637
|
}
|
|
469
638
|
|
|
639
|
+
if (enqueueOptions.requireDurable && !this.options.storageManager) {
|
|
640
|
+
throw new Error('Durable idempotent enqueue requires managed queue storage');
|
|
641
|
+
}
|
|
642
|
+
if (
|
|
643
|
+
(enqueueOptions.idempotencyKey === undefined)
|
|
644
|
+
!== (enqueueOptions.idempotencyFingerprint === undefined)
|
|
645
|
+
) {
|
|
646
|
+
throw new Error('Queue idempotencyKey and idempotencyFingerprint must be provided together');
|
|
647
|
+
}
|
|
648
|
+
if (enqueueOptions.idempotencyKey !== undefined) {
|
|
649
|
+
if (this.options.storageManager && !this.options.storageManager.listPage) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
'Managed idempotent enqueue requires paginated managed queue storage',
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (!enqueueOptions.idempotencyKey || enqueueOptions.idempotencyKey.length > 512) {
|
|
655
|
+
throw new Error('Queue idempotencyKey must contain 1 to 512 characters');
|
|
656
|
+
}
|
|
657
|
+
if (!/^[a-f0-9]{64}$/.test(enqueueOptions.idempotencyFingerprint!)) {
|
|
658
|
+
throw new Error('Queue idempotencyFingerprint must be a lowercase SHA-256 digest');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const id = this.idempotentQueueId(enqueueOptions.idempotencyKey);
|
|
662
|
+
const existing = this.queue.get(id);
|
|
663
|
+
if (existing) {
|
|
664
|
+
if (
|
|
665
|
+
existing.idempotencyKey !== enqueueOptions.idempotencyKey
|
|
666
|
+
|| existing.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
|
|
667
|
+
) {
|
|
668
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
669
|
+
}
|
|
670
|
+
return id;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const activeFlight = this.idempotentEnqueueFlights.get(id);
|
|
674
|
+
if (activeFlight) {
|
|
675
|
+
if (activeFlight.fingerprint !== enqueueOptions.idempotencyFingerprint) {
|
|
676
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
677
|
+
}
|
|
678
|
+
return activeFlight.promise;
|
|
679
|
+
}
|
|
680
|
+
const flight = (async () => {
|
|
681
|
+
const tombstone = await this.readIdempotencyTombstone(id);
|
|
682
|
+
if (tombstone) {
|
|
683
|
+
if (
|
|
684
|
+
tombstone.idempotencyKey !== enqueueOptions.idempotencyKey
|
|
685
|
+
|| tombstone.idempotencyFingerprint !== enqueueOptions.idempotencyFingerprint
|
|
686
|
+
) {
|
|
687
|
+
throw new Error(`Queue idempotency key collision for ${id}`);
|
|
688
|
+
}
|
|
689
|
+
return id;
|
|
690
|
+
}
|
|
691
|
+
return this.enqueueNew(processingResult, mode, route, {
|
|
692
|
+
id,
|
|
693
|
+
idempotencyKey: enqueueOptions.idempotencyKey!,
|
|
694
|
+
idempotencyFingerprint: enqueueOptions.idempotencyFingerprint!,
|
|
695
|
+
});
|
|
696
|
+
})();
|
|
697
|
+
this.idempotentEnqueueFlights.set(id, {
|
|
698
|
+
fingerprint: enqueueOptions.idempotencyFingerprint!,
|
|
699
|
+
promise: flight,
|
|
700
|
+
});
|
|
701
|
+
try {
|
|
702
|
+
return await flight;
|
|
703
|
+
} finally {
|
|
704
|
+
if (this.idempotentEnqueueFlights.get(id)?.promise === flight) {
|
|
705
|
+
this.idempotentEnqueueFlights.delete(id);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return this.enqueueNew(processingResult, mode, route);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private async enqueueNew(
|
|
714
|
+
processingResult: Email,
|
|
715
|
+
mode: EmailProcessingMode,
|
|
716
|
+
route?: IEmailRoute,
|
|
717
|
+
idempotency?: {
|
|
718
|
+
id: string;
|
|
719
|
+
idempotencyKey: string;
|
|
720
|
+
idempotencyFingerprint: string;
|
|
721
|
+
},
|
|
722
|
+
): Promise<string> {
|
|
470
723
|
// Check if queue is full
|
|
471
724
|
if (this.queue.size >= this.options.maxQueueSize) {
|
|
472
725
|
throw new Error('Queue is full');
|
|
473
726
|
}
|
|
474
727
|
|
|
475
728
|
// Generate a unique ID
|
|
476
|
-
const id =
|
|
729
|
+
const id = idempotency?.id
|
|
730
|
+
|| `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
477
731
|
|
|
478
732
|
// Create queue item
|
|
479
733
|
const item: IQueueItem = {
|
|
480
734
|
id,
|
|
735
|
+
...(idempotency ? {
|
|
736
|
+
idempotencyKey: idempotency.idempotencyKey,
|
|
737
|
+
idempotencyFingerprint: idempotency.idempotencyFingerprint,
|
|
738
|
+
} : {}),
|
|
481
739
|
processingMode: mode,
|
|
482
740
|
processingResult,
|
|
483
741
|
attachmentCount: processingResult.attachments.length,
|
|
@@ -496,6 +754,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
496
754
|
|
|
497
755
|
// Commit durable state before exposing the item in memory or emitting acceptance.
|
|
498
756
|
await this.persistItem(item);
|
|
757
|
+
if (idempotency) {
|
|
758
|
+
await this.persistIdempotencyTombstone(
|
|
759
|
+
id,
|
|
760
|
+
idempotency.idempotencyKey,
|
|
761
|
+
idempotency.idempotencyFingerprint,
|
|
762
|
+
);
|
|
763
|
+
}
|
|
499
764
|
this.queue.set(id, item);
|
|
500
765
|
|
|
501
766
|
// Update statistics
|
|
@@ -978,6 +1243,13 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
978
1243
|
return false;
|
|
979
1244
|
}
|
|
980
1245
|
|
|
1246
|
+
if (item.idempotencyKey && item.idempotencyFingerprint) {
|
|
1247
|
+
await this.persistIdempotencyTombstone(
|
|
1248
|
+
item.id,
|
|
1249
|
+
item.idempotencyKey,
|
|
1250
|
+
item.idempotencyFingerprint,
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
981
1253
|
// Delete durable state before making the removal visible in memory.
|
|
982
1254
|
await this.removeItemFromStorage(id);
|
|
983
1255
|
this.queue.delete(id);
|
|
@@ -1008,6 +1280,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1008
1280
|
return {
|
|
1009
1281
|
schemaVersion: 2,
|
|
1010
1282
|
id: item.id,
|
|
1283
|
+
...(item.idempotencyKey !== undefined ? {
|
|
1284
|
+
idempotencyKey: item.idempotencyKey,
|
|
1285
|
+
idempotencyFingerprint: item.idempotencyFingerprint,
|
|
1286
|
+
} : {}),
|
|
1011
1287
|
processingMode: item.processingMode,
|
|
1012
1288
|
processingResult: {
|
|
1013
1289
|
options: item.processingResult.toEmailOptions(),
|
|
@@ -1081,6 +1357,27 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1081
1357
|
if (!parsed || typeof parsed !== 'object' || typeof parsed.id !== 'string' || !parsed.id) {
|
|
1082
1358
|
throw new Error(`Invalid managed queue item at ${key}: missing id`);
|
|
1083
1359
|
}
|
|
1360
|
+
if (
|
|
1361
|
+
parsed.idempotencyKey !== undefined
|
|
1362
|
+
&& (
|
|
1363
|
+
typeof parsed.idempotencyKey !== 'string'
|
|
1364
|
+
|| !parsed.idempotencyKey
|
|
1365
|
+
|| parsed.idempotencyKey.length > 512
|
|
1366
|
+
|| typeof parsed.idempotencyFingerprint !== 'string'
|
|
1367
|
+
|| !/^[a-f0-9]{64}$/.test(parsed.idempotencyFingerprint)
|
|
1368
|
+
)
|
|
1369
|
+
) {
|
|
1370
|
+
throw new Error(`Invalid managed queue item at ${key}: invalid idempotency metadata`);
|
|
1371
|
+
}
|
|
1372
|
+
if (parsed.idempotencyKey === undefined && parsed.idempotencyFingerprint !== undefined) {
|
|
1373
|
+
throw new Error(`Invalid managed queue item at ${key}: orphaned idempotency fingerprint`);
|
|
1374
|
+
}
|
|
1375
|
+
if (parsed.idempotencyKey !== undefined) {
|
|
1376
|
+
const expectedId = this.idempotentQueueId(parsed.idempotencyKey);
|
|
1377
|
+
if (parsed.id !== expectedId) {
|
|
1378
|
+
throw new Error(`Invalid managed queue item at ${key}: idempotency id mismatch`);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1084
1381
|
if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) {
|
|
1085
1382
|
throw new Error(`Invalid managed queue item at ${key}: unsupported schema version`);
|
|
1086
1383
|
}
|
|
@@ -1362,6 +1659,10 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1362
1659
|
|
|
1363
1660
|
return {
|
|
1364
1661
|
id: parsed.id,
|
|
1662
|
+
...(parsed.idempotencyKey !== undefined ? {
|
|
1663
|
+
idempotencyKey: parsed.idempotencyKey,
|
|
1664
|
+
idempotencyFingerprint: parsed.idempotencyFingerprint,
|
|
1665
|
+
} : {}),
|
|
1365
1666
|
processingMode: parsed.processingMode,
|
|
1366
1667
|
processingResult,
|
|
1367
1668
|
attachmentCount: parsed.attachmentCount ?? processingResult.attachments.length,
|
|
@@ -1551,6 +1852,7 @@ export class UnifiedDeliveryQueue extends EventEmitter {
|
|
|
1551
1852
|
for (const id of idsToRemove) {
|
|
1552
1853
|
await this.removeItem(id);
|
|
1553
1854
|
}
|
|
1855
|
+
await this.cleanupExpiredIdempotencyTombstones();
|
|
1554
1856
|
|
|
1555
1857
|
if (idsToRemove.length > 0) {
|
|
1556
1858
|
logger.log('info', `Cleaned up ${idsToRemove.length} old items from delivery queue`);
|
|
@@ -9,6 +9,23 @@ export interface IStorageManager {
|
|
|
9
9
|
get(key: string): Promise<string | null>;
|
|
10
10
|
set(key: string, value: string): Promise<void>;
|
|
11
11
|
list(prefix: string): Promise<string[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Return at most `limit` keys from one backend-bounded page.
|
|
14
|
+
*
|
|
15
|
+
* Continuation tokens are opaque, scoped to the supplied prefix, and must
|
|
16
|
+
* only be passed back to the same storage implementation. A returned token
|
|
17
|
+
* must advance a non-overlapping traversal, remain valid after any keys in
|
|
18
|
+
* its page are deleted, and eventually exhaust an otherwise stable prefix.
|
|
19
|
+
* Offset-based tokens do not satisfy this deletion-stability requirement.
|
|
20
|
+
*
|
|
21
|
+
* This optional method preserves compatibility for existing callers;
|
|
22
|
+
* features which need bounded cleanup must require it explicitly and must
|
|
23
|
+
* never fall back to list().
|
|
24
|
+
*/
|
|
25
|
+
listPage?(
|
|
26
|
+
prefix: string,
|
|
27
|
+
options: IStorageListPageOptions,
|
|
28
|
+
): Promise<IStorageListPageResult>;
|
|
12
29
|
delete(key: string): Promise<void>;
|
|
13
30
|
}
|
|
14
31
|
|
|
@@ -17,18 +34,131 @@ export interface IBlobStorageManager {
|
|
|
17
34
|
get(key: string): Promise<Buffer | null>;
|
|
18
35
|
set(key: string, value: Buffer): Promise<void>;
|
|
19
36
|
list(prefix: string): Promise<string[]>;
|
|
37
|
+
/** Backend-bounded counterpart to list(); see IStorageManager.listPage. */
|
|
38
|
+
listPage?(
|
|
39
|
+
prefix: string,
|
|
40
|
+
options: IStorageListPageOptions,
|
|
41
|
+
): Promise<IStorageListPageResult>;
|
|
20
42
|
delete(key: string): Promise<void>;
|
|
21
43
|
}
|
|
22
44
|
|
|
45
|
+
export interface IStorageListPageOptions {
|
|
46
|
+
/** Positive maximum number of keys the backend may return. */
|
|
47
|
+
readonly limit: number;
|
|
48
|
+
/** Opaque token returned by the immediately preceding page. */
|
|
49
|
+
readonly continuationToken?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IStorageListPageResult {
|
|
53
|
+
/** Unique keys under the requested prefix; length must not exceed limit. */
|
|
54
|
+
readonly keys: string[];
|
|
55
|
+
/** Omitted when the prefix has been exhausted. */
|
|
56
|
+
readonly continuationToken?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Hard upper bound for one SmartMTA storage listing page. */
|
|
60
|
+
export const SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT = 1_000;
|
|
61
|
+
|
|
62
|
+
function assertValidListPageOptions(options: IStorageListPageOptions): void {
|
|
63
|
+
if (
|
|
64
|
+
!Number.isInteger(options.limit)
|
|
65
|
+
|| options.limit < 1
|
|
66
|
+
|| options.limit > SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT
|
|
67
|
+
) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Storage list page limit must be an integer from 1 to ${SMARTMTA_STORAGE_LIST_PAGE_MAX_LIMIT}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
options.continuationToken !== undefined
|
|
74
|
+
&& (
|
|
75
|
+
typeof options.continuationToken !== 'string'
|
|
76
|
+
|| options.continuationToken.length === 0
|
|
77
|
+
)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error('Storage list page continuation token must be a non-empty string');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function lowerBound(sortedKeys: readonly string[], target: string): number {
|
|
84
|
+
let low = 0;
|
|
85
|
+
let high = sortedKeys.length;
|
|
86
|
+
while (low < high) {
|
|
87
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
88
|
+
if (sortedKeys[middle] < target) low = middle + 1;
|
|
89
|
+
else high = middle;
|
|
90
|
+
}
|
|
91
|
+
return low;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function upperBound(sortedKeys: readonly string[], target: string): number {
|
|
95
|
+
let low = 0;
|
|
96
|
+
let high = sortedKeys.length;
|
|
97
|
+
while (low < high) {
|
|
98
|
+
const middle = low + Math.floor((high - low) / 2);
|
|
99
|
+
if (sortedKeys[middle] <= target) low = middle + 1;
|
|
100
|
+
else high = middle;
|
|
101
|
+
}
|
|
102
|
+
return low;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function insertSortedKey(sortedKeys: string[], key: string): void {
|
|
106
|
+
sortedKeys.splice(lowerBound(sortedKeys, key), 0, key);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeSortedKey(sortedKeys: string[], key: string): void {
|
|
110
|
+
const index = lowerBound(sortedKeys, key);
|
|
111
|
+
if (sortedKeys[index] === key) sortedKeys.splice(index, 1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function listSortedPrefixPage(
|
|
115
|
+
sortedKeys: readonly string[],
|
|
116
|
+
prefix: string,
|
|
117
|
+
options: IStorageListPageOptions,
|
|
118
|
+
): IStorageListPageResult {
|
|
119
|
+
assertValidListPageOptions(options);
|
|
120
|
+
if (
|
|
121
|
+
options.continuationToken !== undefined
|
|
122
|
+
&& !options.continuationToken.startsWith(prefix)
|
|
123
|
+
) {
|
|
124
|
+
throw new Error('Storage list page continuation token does not belong to the prefix');
|
|
125
|
+
}
|
|
126
|
+
let index = options.continuationToken === undefined
|
|
127
|
+
? lowerBound(sortedKeys, prefix)
|
|
128
|
+
: Math.max(
|
|
129
|
+
lowerBound(sortedKeys, prefix),
|
|
130
|
+
upperBound(sortedKeys, options.continuationToken),
|
|
131
|
+
);
|
|
132
|
+
const keys: string[] = [];
|
|
133
|
+
while (index < sortedKeys.length && keys.length < options.limit) {
|
|
134
|
+
const key = sortedKeys[index];
|
|
135
|
+
if (!key.startsWith(prefix)) break;
|
|
136
|
+
keys.push(key);
|
|
137
|
+
index++;
|
|
138
|
+
}
|
|
139
|
+
const hasMore = (
|
|
140
|
+
index < sortedKeys.length
|
|
141
|
+
&& sortedKeys[index].startsWith(prefix)
|
|
142
|
+
);
|
|
143
|
+
return {
|
|
144
|
+
keys,
|
|
145
|
+
...(hasMore && keys.length > 0
|
|
146
|
+
? { continuationToken: keys[keys.length - 1] }
|
|
147
|
+
: {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
23
151
|
/** In-memory small-state store for tests and explicitly non-durable runtimes. */
|
|
24
152
|
export class MemoryStorageManager implements IStorageManager {
|
|
25
153
|
private readonly entries = new Map<string, string>();
|
|
154
|
+
private readonly sortedKeys: string[] = [];
|
|
26
155
|
|
|
27
156
|
public async get(key: string): Promise<string | null> {
|
|
28
157
|
return this.entries.get(key) ?? null;
|
|
29
158
|
}
|
|
30
159
|
|
|
31
160
|
public async set(key: string, value: string): Promise<void> {
|
|
161
|
+
if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
|
|
32
162
|
this.entries.set(key, value);
|
|
33
163
|
}
|
|
34
164
|
|
|
@@ -36,14 +166,22 @@ export class MemoryStorageManager implements IStorageManager {
|
|
|
36
166
|
return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
|
|
37
167
|
}
|
|
38
168
|
|
|
169
|
+
public async listPage(
|
|
170
|
+
prefix: string,
|
|
171
|
+
options: IStorageListPageOptions,
|
|
172
|
+
): Promise<IStorageListPageResult> {
|
|
173
|
+
return listSortedPrefixPage(this.sortedKeys, prefix, options);
|
|
174
|
+
}
|
|
175
|
+
|
|
39
176
|
public async delete(key: string): Promise<void> {
|
|
40
|
-
this.entries.delete(key);
|
|
177
|
+
if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
|
|
41
178
|
}
|
|
42
179
|
}
|
|
43
180
|
|
|
44
181
|
/** In-memory blob store for tests and explicitly non-durable runtimes. */
|
|
45
182
|
export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
46
183
|
private readonly entries = new Map<string, Buffer>();
|
|
184
|
+
private readonly sortedKeys: string[] = [];
|
|
47
185
|
|
|
48
186
|
public async get(key: string): Promise<Buffer | null> {
|
|
49
187
|
const value = this.entries.get(key);
|
|
@@ -51,6 +189,7 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
|
51
189
|
}
|
|
52
190
|
|
|
53
191
|
public async set(key: string, value: Buffer): Promise<void> {
|
|
192
|
+
if (!this.entries.has(key)) insertSortedKey(this.sortedKeys, key);
|
|
54
193
|
this.entries.set(key, Buffer.from(value));
|
|
55
194
|
}
|
|
56
195
|
|
|
@@ -58,8 +197,15 @@ export class MemoryBlobStorageManager implements IBlobStorageManager {
|
|
|
58
197
|
return [...this.entries.keys()].filter((key) => key.startsWith(prefix)).sort();
|
|
59
198
|
}
|
|
60
199
|
|
|
200
|
+
public async listPage(
|
|
201
|
+
prefix: string,
|
|
202
|
+
options: IStorageListPageOptions,
|
|
203
|
+
): Promise<IStorageListPageResult> {
|
|
204
|
+
return listSortedPrefixPage(this.sortedKeys, prefix, options);
|
|
205
|
+
}
|
|
206
|
+
|
|
61
207
|
public async delete(key: string): Promise<void> {
|
|
62
|
-
this.entries.delete(key);
|
|
208
|
+
if (this.entries.delete(key)) removeSortedKey(this.sortedKeys, key);
|
|
63
209
|
}
|
|
64
210
|
}
|
|
65
211
|
|