From 2a12cb84c6e891e9b9e5a6b5e3416b16f5aa5aec Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Sun, 23 Feb 2025 03:18:46 +0800 Subject: [PATCH] feat: dns cache stats in jobs & cache page (and cleanup) --- jellyseerr-api.yml | 56 +++++ server/interfaces/api/settingsInterfaces.ts | 28 +++ server/routes/settings/index.ts | 22 +- server/utils/dnsCacheManager.ts | 230 +++++++++++++++--- .../Settings/SettingsJobsCache/index.tsx | 63 +++++ 5 files changed, 344 insertions(+), 55 deletions(-) diff --git a/jellyseerr-api.yml b/jellyseerr-api.yml index 60f16e51..ccaa5dcc 100644 --- a/jellyseerr-api.yml +++ b/jellyseerr-api.yml @@ -2967,6 +2967,62 @@ paths: imageCount: type: number example: 123 + dnsCache: + type: object + properties: + stats: + type: object + properties: + size: + type: number + example: 1 + maxSize: + type: number + example: 500 + hits: + type: number + example: 19 + misses: + type: number + example: 1 + failures: + type: number + example: 0 + ipv4Fallbacks: + type: number + example: 0 + hitRate: + type: number + example: 0.95 + entries: + type: array + additionalProperties: + type: object + properties: + addresses: + type: object + properties: + ipv4: + type: number + example: 1 + ipv6: + type: number + example: 1 + activeAddress: + type: string + example: 127.0.0.1 + family: + type: number + example: 4 + age: + type: number + example: 10 + ttl: + type: number + example: 10 + networkErrors: + type: number + example: 0 apiCaches: type: array items: diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 9393370d..be05f7ab 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -61,9 +61,37 @@ export interface CacheItem { }; } +export interface DNSAddresses { + ipv4: number; + ipv6: number; +} + +export interface DNSRecord { + addresses: DNSAddresses; + activeAddress: string; + family: number; + age: number; + ttl: number; + networkErrors: number; +} + +export interface DNSStats { + size: number; + maxSize: number; + hits: number; + misses: number; + failures: number; + ipv4Fallbacks: number; + hitRate: number; +} + export interface CacheResponse { apiCaches: CacheItem[]; imageCache: Record<'tmdb' | 'avatar', { size: number; imageCount: number }>; + dnsCache: { + entries: Record; + stats: DNSStats; + }; } export interface StatusResponse { diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 5cfd4850..1d6421b9 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -60,23 +60,8 @@ const filteredMainSettings = ( return main; }; -settingsRoutes.get('/dnsCache', async (req, res) => { - const stats = dnsCache.getStats(); - const entries = dnsCache.getCacheEntries(); - - res.json({ - stats, - entries, - }); -}); - settingsRoutes.get('/main', (req, res, next) => { const settings = getSettings(); - const stats = dnsCache.getStats(); - const entries = dnsCache.getCacheEntries(); - - console.log(entries); - console.log(stats); if (!req.user) { return next({ status: 400, message: 'User missing from request.' }); @@ -771,12 +756,19 @@ settingsRoutes.get('/cache', async (_req, res) => { const tmdbImageCache = await ImageProxy.getImageStats('tmdb'); const avatarImageCache = await ImageProxy.getImageStats('avatar'); + const stats = dnsCache.getStats(); + const entries = dnsCache.getCacheEntries(); + return res.status(200).json({ apiCaches, imageCache: { tmdb: tmdbImageCache, avatar: avatarImageCache, }, + dnsCache: { + stats, + entries, + }, }); }); diff --git a/server/utils/dnsCacheManager.ts b/server/utils/dnsCacheManager.ts index 60b71012..9dfbb0fd 100644 --- a/server/utils/dnsCacheManager.ts +++ b/server/utils/dnsCacheManager.ts @@ -3,6 +3,37 @@ import logger from '@server/logger'; import { LRUCache } from 'lru-cache'; import dns from 'node:dns'; +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[] | undefined, + family?: number +) => void; + +type LookupFunction = { + (hostname: string, callback: LookupCallback): void; + ( + hostname: string, + options: dns.LookupOptions, + callback: LookupCallback + ): void; + ( + hostname: string, + options: dns.LookupOneOptions, + callback: LookupCallback + ): void; + ( + hostname: string, + options: dns.LookupAllOptions, + callback: LookupCallback + ): void; + ( + hostname: string, + options: dns.LookupOptions | dns.LookupOneOptions | dns.LookupAllOptions, + callback: LookupCallback + ): void; + __promisify__: typeof dns.lookup.__promisify__; +} & typeof dns.lookup; + interface DnsCache { addresses: { ipv4: string[]; ipv6: string[] }; activeAddress: string; @@ -33,6 +64,8 @@ class DnsCacheManager { private maxRetries: number; private testMode: boolean; private forceIpv4InTest: boolean; + private originalDnsLookup: typeof dns.lookup; + private originalPromisify: any; constructor( maxSize = 500, @@ -41,6 +74,9 @@ class DnsCacheManager { testMode = false, forceIpv4InTest = true ) { + this.originalDnsLookup = dns.lookup; + this.originalPromisify = this.originalDnsLookup.__promisify__; + this.cache = new LRUCache({ max: maxSize, ttl: hardTtlMs, @@ -58,6 +94,160 @@ class DnsCacheManager { } } + public initialize(): void { + const wrappedLookup = (( + hostname: string, + options: + | number + | dns.LookupOneOptions + | dns.LookupOptions + | dns.LookupAllOptions, + callback: LookupCallback + ): void => { + if (typeof options === 'function') { + callback = options; + options = {}; + } + + this.lookup(hostname) + .then((result) => { + if ((options as dns.LookupOptions).all) { + const allAddresses: dns.LookupAddress[] = []; + + result.addresses.ipv4.forEach((addr) => { + allAddresses.push({ address: addr, family: 4 }); + }); + + result.addresses.ipv6.forEach((addr) => { + allAddresses.push({ address: addr, family: 6 }); + }); + + callback( + null, + allAddresses.length > 0 + ? allAddresses + : [{ address: result.activeAddress, family: result.family }] + ); + } else { + callback(null, result.activeAddress, result.family); + } + }) + .catch((error) => { + logger.warn( + `Cached DNS lookup failed for ${hostname}, falling back to native DNS: ${error.message}`, + { + label: 'DNSCache', + } + ); + + try { + this.originalDnsLookup( + hostname, + options as any, + (err, addr, fam) => { + if (!err && addr) { + const cacheEntry = { + addresses: { + ipv4: Array.isArray(addr) + ? addr + .filter((a) => !a.address.includes(':')) + .map((a) => a.address) + : typeof addr === 'string' && !addr.includes(':') + ? [addr] + : [], + ipv6: Array.isArray(addr) + ? addr + .filter((a) => a.address.includes(':')) + .map((a) => a.address) + : typeof addr === 'string' && addr.includes(':') + ? [addr] + : [], + }, + activeAddress: Array.isArray(addr) + ? addr[0]?.address || '' + : addr || '', + family: Array.isArray(addr) + ? addr[0]?.family || 4 + : fam || 4, + timestamp: Date.now(), + ttl: 60000, + networkErrors: 0, + }; + + this.updateCache(hostname, cacheEntry).catch(() => { + logger.debug( + `Failed to update DNS cache for ${hostname}: ${error.message}`, + { + label: 'DNSCache', + } + ); + }); + } + callback(err, addr, fam); + } + ); + return; + } catch (fallbackError) { + logger.error( + `Native DNS fallback also failed for ${hostname}: ${fallbackError.message}`, + { + label: 'DNSCache', + } + ); + } + + callback(error, undefined, undefined); + }); + }) as LookupFunction; + + (wrappedLookup as any).__promisify__ = async function ( + hostname: string, + options?: + | dns.LookupAllOptions + | dns.LookupOneOptions + | number + | dns.LookupOptions + ): Promise { + try { + const result = await this.lookup(hostname); + + if ( + options && + typeof options === 'object' && + 'all' in options && + options.all === true + ) { + const allAddresses: dns.LookupAddress[] = []; + + result.addresses.ipv4.forEach((addr: string) => { + allAddresses.push({ address: addr, family: 4 }); + }); + + result.addresses.ipv6.forEach((addr: string) => { + allAddresses.push({ address: addr, family: 6 }); + }); + + return allAddresses.length > 0 + ? allAddresses + : [{ address: result.activeAddress, family: result.family }]; + } + + return { address: result.activeAddress, family: result.family }; + } catch (error) { + if (this.originalPromisify) { + const nativeResult = await this.originalPromisify( + hostname, + options as any + ); + return nativeResult; + } + throw error; + } + }; + + dns.lookup = wrappedLookup; + } + async lookup( hostname: string, retryCount = 0, @@ -111,26 +301,12 @@ class DnsCacheManager { } this.stats.hits++; - logger.debug(`DNS cache hit for ${hostname}`, { - label: 'DNSCache', - activeAddress: cached.activeAddress, - family: cached.family, - age, - ttlRemaining, - }); return cached; } // Soft expiration. Will use stale entry while refreshing if (age < this.hardTtlMs) { this.stats.hits++; - logger.debug(`Using stale DNS cache for ${hostname}`, { - label: 'DNSCache', - address: cached.activeAddress, - family: cached.family, - age, - ttlRemaining, - }); // Background refresh this.resolveWithTtl(hostname) @@ -155,17 +331,6 @@ class DnsCacheManager { ttl: result.ttl, networkErrors: 0, }); - - logger.debug(`DNS cache refreshed for ${hostname}`, { - label: 'DNSCache', - addresses: { - ipv4: result.addresses.ipv4.length, - ipv6: result.addresses.ipv6.length, - }, - activeAddress, - family, - ttl: result.ttl, - }); }) .catch((error) => { logger.error( @@ -207,17 +372,6 @@ class DnsCacheManager { this.cache.set(hostname, dnsCache); - logger.debug(`DNS cache miss for ${hostname}, cached new result`, { - label: 'DNSCache', - addresses: { - ipv4: result.addresses.ipv4.length, - ipv6: result.addresses.ipv6.length, - }, - activeAddress, - family, - ttl: result.ttl, - }); - return dnsCache; } catch (error) { this.stats.failures++; @@ -386,10 +540,6 @@ class DnsCacheManager { const ttlMs = minTtl * 1000; - logger.debug( - `Resolved ${hostname} with TTL: ${minTtl}s, found IPv4: ${addresses.ipv4.length}, IPv6: ${addresses.ipv6.length}` - ); - return { addresses, ttl: ttlMs }; } catch (error) { logger.error(`Failed to resolve ${hostname} with TTL: ${error.message}`, { diff --git a/src/components/Settings/SettingsJobsCache/index.tsx b/src/components/Settings/SettingsJobsCache/index.tsx index 0f951293..612d01f3 100644 --- a/src/components/Settings/SettingsJobsCache/index.tsx +++ b/src/components/Settings/SettingsJobsCache/index.tsx @@ -54,6 +54,15 @@ const messages: { [messageName: string]: MessageDescriptor } = defineMessages( cachekeys: 'Total Keys', cacheksize: 'Key Size', cachevsize: 'Value Size', + dnsCache: 'DNS Cache', + dnsCacheDescription: + 'Jellyseerr caches DNS lookups to optimize performance and avoid making unnecessary API calls.', + dnscachename: 'Hostname', + dnscacheactiveaddress: 'Active Address', + dnscachehits: 'Hits', + dnscachemisses: 'Misses', + dnscacheage: 'Age', + dnscachenetworkerrors: 'Network Errors', flushcache: 'Flush Cache', unknownJob: 'Unknown Job', 'plex-recently-added-scan': 'Plex Recently Added Scan', @@ -173,6 +182,7 @@ const SettingsJobs = () => { refreshInterval: 10000, } ); + console.log(cacheData); const [jobModalState, dispatch] = useReducer(jobModalReducer, { isOpen: false, @@ -567,6 +577,59 @@ const SettingsJobs = () => { +
+

{intl.formatMessage(messages.dnsCache)}

+

+ {intl.formatMessage(messages.dnsCacheDescription)} +

+
+
+ + + + {intl.formatMessage(messages.dnscachename)} + + {intl.formatMessage(messages.dnscacheactiveaddress)} + + {intl.formatMessage(messages.dnscachehits)} + {intl.formatMessage(messages.dnscachemisses)} + {intl.formatMessage(messages.dnscacheage)} + + {intl.formatMessage(messages.dnscachenetworkerrors)} + + + + + {Object.entries(cacheData?.dnsCache.entries || {}).map( + ([hostname, data]) => ( + + {hostname} + {data.activeAddress} + + {intl.formatNumber(cacheData?.dnsCache.stats.hits ?? 0)} + + + {intl.formatNumber(cacheData?.dnsCache.stats.misses ?? 0)} + + + {intl.formatNumber(Math.floor(data.age / 1000))}s + + {intl.formatNumber(data.networkErrors)} + {/* + + */} + + ) + )} + +
+

{intl.formatMessage(messages.imagecache)}