feat: dns cache stats in jobs & cache page (and cleanup)

This commit is contained in:
fallenbagel
2025-02-23 03:18:46 +08:00
committed by gauthier-th
parent 73feb07007
commit 2a12cb84c6
5 changed files with 344 additions and 55 deletions

View File

@@ -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:

View File

@@ -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<string, DNSRecord>;
stats: DNSStats;
};
}
export interface StatusResponse {

View File

@@ -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,
},
});
});

View File

@@ -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<string, DnsCache>({
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<dns.LookupAddress[] | { address: string; family: number }> {
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}`, {

View File

@@ -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 = () => {
</Table.TBody>
</Table>
</div>
<div>
<h3 className="heading">{intl.formatMessage(messages.dnsCache)}</h3>
<p className="description">
{intl.formatMessage(messages.dnsCacheDescription)}
</p>
</div>
<div className="section">
<Table>
<thead>
<tr>
<Table.TH>{intl.formatMessage(messages.dnscachename)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.dnscacheactiveaddress)}
</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachehits)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscachemisses)}</Table.TH>
<Table.TH>{intl.formatMessage(messages.dnscacheage)}</Table.TH>
<Table.TH>
{intl.formatMessage(messages.dnscachenetworkerrors)}
</Table.TH>
</tr>
</thead>
<Table.TBody>
{Object.entries(cacheData?.dnsCache.entries || {}).map(
([hostname, data]) => (
<tr key={`cache-list-${hostname}`}>
<Table.TD>{hostname}</Table.TD>
<Table.TD>{data.activeAddress}</Table.TD>
<Table.TD>
{intl.formatNumber(cacheData?.dnsCache.stats.hits ?? 0)}
</Table.TD>
<Table.TD>
{intl.formatNumber(cacheData?.dnsCache.stats.misses ?? 0)}
</Table.TD>
<Table.TD>
{intl.formatNumber(Math.floor(data.age / 1000))}s
</Table.TD>
<Table.TD>{intl.formatNumber(data.networkErrors)}</Table.TD>
{/* <Table.TD alignText="right">
<Button
buttonType="danger"
onClick={() => flushCache(cache)}
>
<TrashIcon />
<span>{intl.formatMessage(messages.flushcache)}</span>
</Button>
</Table.TD> */}
</tr>
)
)}
</Table.TBody>
</Table>
</div>
<div className="break-words">
<h3 className="heading">{intl.formatMessage(messages.imagecache)}</h3>
<p className="description">