feat(cache): add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration

This commit is contained in:
2026-02-10 11:22:15 +00:00
parent f3f1f58b67
commit 41fe7a8a47
15 changed files with 282 additions and 39 deletions

View File

@@ -98,24 +98,20 @@ export class CacheCleaner {
const results: { collection: string; deleted: number }[] = [];
try {
// Clean CachedEmail documents
const emailsDeleted = await this.cleanCollection(CachedEmail, now);
// Clean each collection using smartdata's getInstances + delete pattern
const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
// Clean CachedIPReputation documents
const ipReputationDeleted = await this.cleanCollection(CachedIPReputation, now);
const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
// Clean CachedBounce documents
const bouncesDeleted = await this.cleanCollection(CachedBounce, now);
const bouncesDeleted = await this.cleanExpiredDocuments(CachedBounce, now);
results.push({ collection: 'CachedBounce', deleted: bouncesDeleted });
// Clean CachedSuppression documents (but not permanent ones)
const suppressionDeleted = await this.cleanCollection(CachedSuppression, now);
const suppressionDeleted = await this.cleanExpiredDocuments(CachedSuppression, now);
results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted });
// Clean CachedDKIMKey documents
const dkimDeleted = await this.cleanCollection(CachedDKIMKey, now);
const dkimDeleted = await this.cleanExpiredDocuments(CachedDKIMKey, now);
results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted });
// Log results
@@ -137,17 +133,30 @@ export class CacheCleaner {
}
/**
* Clean expired documents from a specific collection
* Clean expired documents from a specific collection using smartdata API
*/
private async cleanCollection<T>(
documentClass: { deleteMany: (filter: any) => Promise<any> },
private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
documentClass: { getInstances: (filter: any) => Promise<T[]> },
now: Date
): Promise<number> {
try {
const result = await documentClass.deleteMany({
// Find all expired documents
const expiredDocs = await documentClass.getInstances({
expiresAt: { $lt: now },
});
return result?.deletedCount || 0;
// Delete each expired document
let deletedCount = 0;
for (const doc of expiredDocs) {
try {
await doc.delete();
deletedCount++;
} catch (deleteError) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
}
}
return deletedCount;
} catch (error) {
logger.log('error', `Error cleaning collection: ${error.message}`);
return 0;

View File

@@ -7,24 +7,27 @@ import * as plugins from '../plugins.js';
* - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt)
* - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/**
* Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
/**
* Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/
@plugins.smartdata.svDb()
public expiresAt: Date;
/**
* Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**

View File

@@ -69,19 +69,21 @@ export class CacheDb {
// Create LocalTsmDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
dbDir: this.options.storagePath,
folderPath: this.options.storagePath,
});
// Start LocalTsmDb and get connection URI
await this.localTsmDb.start();
const mongoDescriptor = this.localTsmDb.mongoDescriptor;
// Start LocalTsmDb and get connection info
const connectionInfo = await this.localTsmDb.start();
if (this.options.debug) {
logger.log('debug', `LocalTsmDb started with descriptor: ${JSON.stringify(mongoDescriptor)}`);
logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
}
// Initialize smartdata with the connection
this.smartdataDb = new plugins.smartdata.SmartdataDb(mongoDescriptor);
// Initialize smartdata with the connection URI
this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionInfo.connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init();
this.isStarted = true;

View File

@@ -33,6 +33,16 @@ export type TBounceCategory =
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedBounce extends CachedDocument<CachedBounce> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* Unique identifier for this bounce record
*/

View File

@@ -15,6 +15,16 @@ const getDb = () => CacheDb.getInstance().getDb();
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_90);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* Composite key: domain:selector
*/

View File

@@ -20,6 +20,16 @@ const getDb = () => CacheDb.getInstance().getDb();
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* Unique identifier for this email
*/

View File

@@ -30,6 +30,16 @@ export interface IIPReputationData {
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* IP address (unique identifier)
*/

View File

@@ -27,6 +27,16 @@ export type TSuppressionReason =
*/
@plugins.smartdata.Collection(() => getDb())
export class CachedSuppression extends CachedDocument<CachedSuppression> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/**
* Email address to suppress (unique identifier)
*/