This page covers the DNS response caching layer in firestack: the ctransport decorator, how cache buckets are organized, how TTL bumping works, how concurrent requests are coalesced via the Barrier pattern, and how network failure is detected via the hangover mechanism.
For information about the underlying transports that ctransport wraps, see DNS Transport Implementations. For the ALG system's internal IP-level cache (separate from this response cache), see DNS Gateway and ALG System. For async primitives such as Barrier and Hangover, see Core Utilities and Concurrency Primitives.
Every concrete DNS transport registered with the resolver (DOH, DOT, DNS53, DNSCrypt, ODoH) is automatically wrapped in a ctransport caching decorator. The caching transport is stored in the resolver's transport map alongside the uncached original, identified by the prefix CT (the string "Cache").
Caching transport registration logic:
The CT constant is defined as x.CT (imported from intra/backend) [intra/backend/dnsx.go:17-17]. The caching transport is typically initialized with a default minimum cache lifetime via NewCachingTransport [intra/dnsx/cacher.go:114-145]. In NewResolver, if the transport is the Default one, it is wrapped with a 10-minute TTL cache [intra/dnsx/transport.go:235-242].
Sources: [intra/backend/dnsx.go:17-17], [intra/dnsx/transport.go:235-242], [intra/dnsx/cacher.go:114-145]
Caching decorator, bucket, and entry — intra/dnsx/cacher.go:
ctransport (wraps Transport)
├── store []*cache — 128 sharded buckets
├── reqbarrier — coalesces concurrent duplicate queries
├── hangover — tracks send failure threshold
├── ttl, halflife, bumps — cache lifetime parameters
└── Transport — embedded underlying transport
| Struct | Role |
|---|---|
ctransport | Decorator implementing Cacher (and thus Transport) [intra/dnsx/cacher.go:92-106] |
cache | A single shard: map[string]*cres plus metadata [intra/dnsx/cacher.go:70-82] |
cres | A cached entry: *dns.Msg + *x.DNSSummary + expiry + bump count [intra/dnsx/cacher.go:84-89] |
Constants:
| Constant | Value | Meaning |
|---|---|---|
defbuckets | 128 | Number of shards in store [intra/dnsx/cacher.go:37-37] |
defsize | 256 | Max entries per shard [intra/dnsx/cacher.go:35-35] |
defbumps | 10 | Max TTL bumps per entry [intra/dnsx/cacher.go:33-33] |
defttl | 2 h | Default entry lifetime [intra/dnsx/cacher.go:31-31] |
battl | 30 s | Request barrier timeout [intra/dnsx/cacher.go:44-44] |
httl | 10 s | Hangover detection threshold [intra/dnsx/cacher.go:46-46] |
scrubgap | 5 min | Minimum interval between scrub passes [intra/dnsx/cacher.go:39-39] |
stalettl | 15 s | TTL applied to stale responses served from cache [intra/dnsx/cacher.go:41-41] |
Sources: [intra/dnsx/cacher.go:29-51], [intra/dnsx/cacher.go:70-106]
The store field is a 128-element slice of *cache [intra/dnsx/cacher.go:98-98]. Each shard is initialized during NewCachingTransport. The shard index is determined by an FNV-32a hash of the normalized query name, modulo 128 [intra/dnsx/cacher.go:180-184].
Sharded store layout:
Cache key format (from mkcachekey):
<normalized-qname> + ":" + <qtype-int> + ":" + <do-flag>
[intra/dnsx/cacher.go:201-201]
Where <do-flag> is "1" if DNSSEC (DO bit) is requested, otherwise "0" [intra/dnsx/cacher.go:196-199].
Sources: [intra/dnsx/cacher.go:180-202], [intra/dnsx/cacher.go:98-98]
The query path logic resides in ctransport.Query and its private helper ctransport.fetch.
Sources: [intra/dnsx/cacher.go:371-483], [intra/dnsx/cacher.go:186-202]
Each time freshCopy is called for a key, the entry's expiry is extended — up to defbumps (10) times [intra/dnsx/cacher.go:246-258]. The extension amount grows with successive reads:
extension = bumps_count × halflife
[intra/dnsx/cacher.go:247-247]
Where halflife = ttl / 2 [intra/dnsx/cacher.go:136-136]. For a defttl (2 hour) cache, halflife is 1 hour.
TTL bump schedule (starting from initial expiry E):
Read # (bumps) | Extension | Effective expiry (cumulative) |
|---|---|---|
| 0 (initial store) | 0 | E |
| 1st read | 0 × halflife = 0 | E |
| 2nd read | 1 × halflife | E + 1h |
| 3rd read | 2 × halflife | E + 3h |
| 10th read (max) | stopped | no further extension |
There is also a 50% probabilistic cache refresh for entries with more than 2 bumps (recent = bumps <= 2). This triggers upstream refresh without fully starving the cache [intra/dnsx/cacher.go:413-421].
Sources: [intra/dnsx/cacher.go:236-258], [intra/dnsx/cacher.go:413-421]
When cache.put stores a response, the expiry time is computed based on the answer's TTL compared to the bucket's minimum TTL [intra/dnsx/cacher.go:296-302].
.onion addresses and non-success rcodes (including SERVFAIL) are never cached [intra/dnsx/cacher.go:266-276]. Truncated responses are also excluded [intra/dnsx/cacher.go:262-264].
Sources: [intra/dnsx/cacher.go:262-316]
The reqbarrier field is a core.Barrier[*cres, string] [intra/dnsx/cacher.go:104-104]. When multiple goroutines issue queries for the same cache key concurrently, only one actual upstream request is sent. All other callers wait and receive the same result via the Barrier.DoIt pattern.
The barrier has a timeout of battl (30 seconds) [intra/dnsx/cacher.go:44-44]. If the upstream call does not complete within that window, waiting goroutines receive a nil result and fall through to error handling.
Sources: [intra/dnsx/cacher.go:104-104], [intra/dnsx/cacher.go:139-140], [intra/core/barrier.go:224-274]
The hangover field is a core.Hangover that tracks persistent send failures [intra/dnsx/cacher.go:105-105]. Its purpose is to prevent apps from receiving cached DNS responses when there is no actual network connectivity.
Hangover state machine:
| Method | Called When |
|---|---|
hangover.Note() | t.Transport.Status() == SendFailed [intra/dnsx/cacher.go:364-365] |
hangover.Break() | Any non-SendFailed status [intra/dnsx/cacher.go:367-368] |
hangover.Within(httl) | Before cache lookup [intra/dnsx/cacher.go:403-403] |
hangover.Exceeds(httl) | After barrier [intra/dnsx/cacher.go:474-474] |
Sources: [intra/dnsx/cacher.go:363-369], [intra/dnsx/cacher.go:403-427], [intra/dnsx/cacher.go:440-483], [intra/core/hangover.go:16-53]
Each shard performs lazy eviction. scrubCache is called periodically during cache operations. It only runs if at least scrubgap (5 minutes) has elapsed since the last scrub [intra/dnsx/cacher.go:212-214]. On high load (≥75% of defsize used), it evicts expired entries up to maxscrubs (64) per pass [intra/dnsx/cacher.go:218-232].
Sources: [intra/dnsx/cacher.go:204-234], [intra/dnsx/cacher.go:48-48]
Lifecycle management:
Stop(): Cancels the context, which triggers context.AfterFunc(ctx, ct.Clear) [intra/dnsx/cacher.go:142-142].Clear(): Locks the top-level ctransport mutex, then iterates all shards and clears each shard's map [intra/dnsx/cacher.go:532-551].Sources: [intra/dnsx/cacher.go:65-68], [intra/dnsx/cacher.go:354-357], [intra/dnsx/cacher.go:532-551]