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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user