feat: dynamic ttl which is revalidated while using stale dns cache

This is done as tmdb ttl is very less like 40 seconds so to make sure
any issues wont be caused due to cached dns (previously we were caching
for 5 minutes no matter what ttl)
This commit is contained in:
fallenbagel
2025-01-20 16:56:56 +08:00
committed by gauthier-th
parent 2f80a536c3
commit 6828924493

View File

@@ -1,3 +1,4 @@
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { LRUCache } from 'lru-cache';
import dns from 'node:dns';
@@ -6,6 +7,7 @@ interface DnsCache {
address: string;
family: number;
timestamp: number;
ttl: number;
}
interface CacheStats {
@@ -16,50 +18,187 @@ interface CacheStats {
class DnsCacheManager {
private cache: LRUCache<string, DnsCache>;
private lookupAsync: typeof dns.promises.lookup;
private resolver: dns.promises.Resolver;
private stats: CacheStats = {
hits: 0,
misses: 0,
};
private hardTtlMs: number;
constructor(ttlMs = 300000) {
this.cache = new LRUCache<string, DnsCache>({ max: 500, ttl: ttlMs });
constructor(maxSize = 500, hardTtlMs = 300000) {
this.cache = new LRUCache<string, DnsCache>({
max: maxSize,
ttl: hardTtlMs,
});
this.hardTtlMs = hardTtlMs;
this.lookupAsync = dns.promises.lookup;
this.resolver = new dns.promises.Resolver();
}
async lookup(hostname: string): Promise<DnsCache> {
// Ignore for localhost
if (hostname === 'localhost') {
return {
address: '127.0.0.1',
family: 4,
timestamp: Date.now(),
ttl: 0,
};
}
const cached = this.cache.get(hostname);
if (cached) {
this.stats.hits++;
logger.debug(`DNS cache hit for ${hostname}`, {
label: 'DNSCache',
address: cached.address,
family: cached.family,
age: Date.now() - cached.timestamp,
});
return cached;
const age = Date.now() - cached.timestamp;
const ttlRemaining = Math.max(0, cached.ttl - age);
if (ttlRemaining > 0) {
this.stats.hits++;
logger.debug(`DNS cache hit for ${hostname}`, {
label: 'DNSCache',
address: cached.address,
family: cached.family,
age,
ttlRemaining,
});
return cached;
}
// soft expiration using stale entry while refreshing
if (age < this.hardTtlMs) {
this.stats.hits++;
logger.debug(`Using stale DNS cache for ${hostname}`, {
label: 'DNSCache',
address: cached.address,
family: cached.family,
age,
ttlRemaining,
});
// revalidation
this.resolveWithTtl(hostname)
.then((result) => {
this.cache.set(hostname, {
address: result.address,
family: result.family,
timestamp: Date.now(),
ttl: result.ttl,
});
logger.debug(`DNS cache refreshed for ${hostname}`, {
label: 'DNSCache',
address: result.address,
family: result.family,
ttl: result.ttl,
});
})
.catch((error) => {
logger.error(
`Failed to refresh DNS for ${hostname}: ${error.message}`
);
});
return cached;
}
// hard expiration: remove stale entry
this.cache.delete(hostname);
}
this.stats.misses++;
try {
const result = await this.lookupAsync(hostname);
const result = await this.resolveWithTtl(hostname);
const dnsCache: DnsCache = {
address: result.address,
family: result.family,
timestamp: Date.now(),
ttl: result.ttl,
};
this.cache.set(hostname, dnsCache);
this.cache.set(hostname, dnsCache, { ttl: this.hardTtlMs });
logger.debug(`DNS cache miss for ${hostname}, cached new result`, {
label: 'DNSCache',
address: dnsCache.address,
family: dnsCache.family,
ttl: result.ttl,
});
return dnsCache;
} catch (error) {
throw new Error(`DNS lookup failed for ${hostname}: ${error.message}`);
}
}
private async resolveWithTtl(
hostname: string
): Promise<{ address: string; family: number; ttl: number }> {
if (
!this.resolver ||
typeof this.resolver.resolve4 !== 'function' ||
typeof this.resolver.resolve6 !== 'function'
) {
throw new Error('Resolver is not initialized');
}
try {
const [ipv4Records, ipv6Records] = await Promise.allSettled([
this.resolver.resolve4(hostname, { ttl: true }),
this.resolver.resolve6(hostname, { ttl: true }),
]);
let record: { address: string; ttl: number } | null = null;
let family = 4;
const settings = getSettings();
const preferIpv6 = settings.main.forceIpv4First ? false : true;
if (preferIpv6) {
if (
ipv6Records.status === 'fulfilled' &&
ipv6Records.value.length > 0
) {
record = ipv6Records.value[0];
family = 6;
} else if (
ipv4Records.status === 'fulfilled' &&
ipv4Records.value.length > 0
) {
record = ipv4Records.value[0];
family = 4;
}
} else {
if (
ipv4Records.status === 'fulfilled' &&
ipv4Records.value.length > 0
) {
record = ipv4Records.value[0];
family = 4;
} else if (
ipv6Records.status === 'fulfilled' &&
ipv6Records.value.length > 0
) {
record = ipv6Records.value[0];
family = 6;
}
}
if (!record) {
throw new Error('No DNS records found for ${hostname');
}
const ttl = record.ttl > 0 ? record.ttl * 1000 : 30000;
logger.debug(
`Resolved ${hostname} with TTL: ${record.ttl} (Original), ${ttl} (Applied)`
);
return { address: record.address, family, ttl };
} catch (error) {
logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, {
label: 'DNSCache',
});
throw error;
}
}
getStats() {
const entries = [...this.cache.entries()];
return {
@@ -84,7 +223,7 @@ class DnsCacheManager {
for (const [hostname, data] of this.cache.entries()) {
const age = Date.now() - data.timestamp;
const ttl = (this.cache.ttl ?? 300000) - age;
const ttl = Math.max(0, data.ttl - age);
entries[hostname] = {
address: data.address,