Skip to content

Conversation

@SuperOleg39
Copy link
Contributor

@SuperOleg39 SuperOleg39 commented Sep 26, 2025

Rationale

Alternative implementation for #4565

Custom cache for DNS interceptor allows:

  • add custom metrics for DNS cache hit rate monitoring
  • use any cache library (lru-cache, etc.)

Reference - https://github.com/szmarczak/cacheable-lookup?tab=readme-ov-file#cache

Changes

Added cache parameter for DNSInstance

Status

this.#records.delete(hostname)
}

// Delegate to storage decide can we do more lookups or not
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For example, lru-cache adapter can always return false here

// DNS interceptor
export type DNSInterceptorRecord = { address: string, ttl: number, family: 4 | 6 }
export type DNSInterceptorOriginRecords = { 4: { ips: DNSInterceptorRecord[] } | null, 6: { ips: DNSInterceptorRecord[] } | null }
export type DNSInterceptorOriginRecords = { records: { 4: { ips: DNSInterceptorRecord[] } | null, 6: { ips: DNSInterceptorRecord[] } | null } }
Copy link
Contributor Author

@SuperOleg39 SuperOleg39 Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here was incorrect typings - cached records shape is { records: ... }

maxTTL?: number
maxItems?: number
lookup?: (hostname: string, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void
lookup?: (origin: URL, options: LookupOptions, callback: (err: NodeJS.ErrnoException | null, addresses: DNSInterceptorRecord[]) => void) => void
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here also was incorrect typings - before pick method calls, string origin is converted to URL

const { InvalidArgumentError, InformationalError } = require('../core/errors')
const maxInt = Math.pow(2, 31) - 1

class DNSStorage {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lru-cache adapter example:

const cache = new LRU({ max: maxItems })

const adapter = {
  set: (hostname: string, records: any, opts: { ttl: number }): void => {
    cache.set(hostname, records, opts);
  },
  get: (hostname: string): any => {
    return cache.get(hostname);
  },
  delete: (hostname: string) => {
    cache.delete(hostname);
  },
  full: (): boolean => {
    return false;
  },
};

const interceptor = interceptors.dns({ maxItems: Infinity, maxTTL: 60000, cache: adapter })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grafana visualization:

Image

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cache metrics was a veeeery useful feature for us. But mostly for HTTP requests ofc)

if (typeof record.ttl === 'number') {
// The record TTL is expected to be in ms
record.ttl = Math.min(record.ttl, this.#maxTTL)
minTTL = Math.min(minTTL, record.ttl)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For LRU or any other caches, which will not be able to respect ttl per record, just pass the minimum value from records.

Can't find better solution because records is a mutable object and we need to store the same for every origin,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be fine to keep it as is, this just duplicates it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to find minimum TTL for all resolved records for origin, and pass it to external storage, I don't quite understand about duplication

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok see it now, missed that bit

@Uzlopak Uzlopak requested a review from Copilot September 26, 2025 19:41
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds custom storage support for the DNS interceptor, allowing users to provide their own cache implementation instead of relying on the built-in Map-based storage. This enables integration with external cache libraries like lru-cache and provides better control over caching behavior including custom metrics collection.

  • Introduces a configurable storage parameter for DNS interceptor that accepts custom cache implementations
  • Refactors internal DNS storage logic into a separate DNSStorage class while maintaining backward compatibility
  • Adds comprehensive validation for custom storage interface requirements

Reviewed Changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 3 comments.

File Description
lib/interceptor/dns.js Implements the core storage abstraction with DNSStorage class and storage parameter validation
test/interceptors/dns.js Adds comprehensive test coverage for external storage functionality
docs/docs/api/Dispatcher.md Documents the new storage parameter with usage examples including LRU cache integration

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.


this.#records.set(origin.hostname, records)
// We provide a default TTL if external storage will be used without TTL per record-level support
this.storage.set(origin.hostname, records, { ttl: minTTL })
Copy link

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The storage interface documentation shows that the ttl parameter is optional, but this code always passes it. Consider checking if the storage implementation supports TTL before passing the options parameter to avoid potential issues with storage implementations that don't expect additional parameters.

Suggested change
this.storage.set(origin.hostname, records, { ttl: minTTL })
if (typeof this.storage.set === 'function' && this.storage.set.length >= 3) {
this.storage.set(origin.hostname, records, { ttl: minTTL })
} else {
this.storage.set(origin.hostname, records)
}

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The third argument is not used isn't it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be used in external storages, if lru-cache or quick-lru will be used under the hood

setRecords (origin, addresses) {
const timestamp = Date.now()
const records = { records: { 4: null, 6: null } }
let minTTL = this.#maxTTL
Copy link

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The minTTL initialization should use Infinity instead of this.#maxTTL to correctly calculate the minimum TTL across all records. With the current implementation, if all records have TTL values greater than maxTTL, minTTL will incorrectly remain at maxTTL instead of the actual minimum.

Suggested change
let minTTL = this.#maxTTL
let minTTL = Infinity

Copilot uses AI. Check for mistakes.
get(origin) {
return lru.get(origin);
},
set(origin, records, { ttl }) {
Copy link

Copilot AI Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The destructuring assignment assumes the options parameter will always be passed and contain a ttl property. This should be updated to handle cases where options might be undefined: set(origin, records, options = {}) { const { ttl } = options; ... }

Suggested change
set(origin, records, { ttl }) {
set(origin, records, options = {}) {
const { ttl } = options;

Copilot uses AI. Check for mistakes.
@codecov-commenter
Copy link

codecov-commenter commented Sep 26, 2025

Codecov Report

❌ Patch coverage is 98.18182% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.94%. Comparing base (d23bf9c) to head (47d5739).
⚠️ Report is 18 commits behind head on main.

Files with missing lines Patch % Lines
lib/interceptor/dns.js 98.18% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##           main    #4589       +/-   ##
=========================================
+ Coverage      0   92.94%   +92.94%     
=========================================
  Files         0      106      +106     
  Lines         0    33015    +33015     
=========================================
+ Hits          0    30685    +30685     
- Misses        0     2330     +2330     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

- The function should return a single record from the records array.
- By default a simplified version of Round Robin is used.
- The `records` property can be mutated to store the state of the balancing algorithm.
- `storage: { size: number, get(origin: string): DNSInterceptorOriginRecords | null, set(origin: string, records: DNSInterceptorOriginRecords | null, options?: { ttl: number }): void, delete(origin: string): void, full(): boolean }` - Custom storage for resolved DNS records
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's decompose it into its own section within the DNS interceptor itself

if (typeof record.ttl === 'number') {
// The record TTL is expected to be in ms
record.ttl = Math.min(record.ttl, this.#maxTTL)
minTTL = Math.min(minTTL, record.ttl)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be fine to keep it as is, this just duplicates it.


this.#records.set(origin.hostname, records)
// We provide a default TTL if external storage will be used without TTL per record-level support
this.storage.set(origin.hostname, records, { ttl: minTTL })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The third argument is not used isn't it?

Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

Copy link
Member

@mcollina mcollina left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add test for the types?

@SuperOleg39
Copy link
Contributor Author

Can you add test for the types?

Yep, ready

@metcoder95 metcoder95 merged commit 83db0fc into nodejs:main Oct 8, 2025
28 of 29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants