plugin/cache: add prefer_positive stale policy (#8378)

* plugin/cache: add prefer_positive stale policy

Add an opt-in serve_stale_policy that prefers an eligible success-cache
answer over denial-cache entries while serve_stale is enabled. Preserve the
existing ncache-first behavior when the policy is absent.

Also classify SOA-backed CNAME NODATA responses in the cache so incomplete
answers cannot be selected as positive stale responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

* plugin/cache: retain last-known-good positive answers

Keep an answering success-cache item reachable when a later NOERROR or
referral response overwrites the visible cache key without answering the
question. This lets prefer_positive survive empty responses, referrals, and
additional-only data while leaving policy-off lookup behavior unchanged.

Return the exact accepted verify refresh item instead of re-reading an
ambiguous cache key, avoiding expired TTL wraparound for uncacheable replies.
Add regression coverage for non-answer refreshes, NODATA, SERVFAIL, NOTIMP,
stale-window expiry, and bounded verify reply shaping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

* plugin/cache: validate preferred stale answers

Reject truncated, DNSSEC-expired, mismatched-class, unrelated ANY, and ambiguous CNAME refreshes before replacing a stale last-known-good answer. Precompute answer eligibility when cache items are created so prefer_positive hits avoid repeated CNAME walks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

---------

Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>
Co-authored-by: Nitin Nizhawan <nnizhawan@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
This commit is contained in:
Nitin Nizhawan
2026-08-14 13:48:39 +05:30
committed by GitHub
parent 2eb7d16071
commit 87ccb6f90e
7 changed files with 658 additions and 52 deletions

View File

@@ -163,10 +163,10 @@ func (c *Cache) doRefresh(ctx context.Context, state request.Request, cw dns.Res
// verifyWithTimeout runs the upstream verify in a background goroutine and races it
// against verifyStaleTimeout. If the verify completes within the timeout and the
// response is cacheable (NoError or NXDomain), the freshly cached entry is served
// to the client and served is true. Otherwise served is false and the caller falls
// through to serve stale; the goroutine continues to run and any successful response
// will update the cache without writing to the (now-detached) client connection.
// response is accepted by verifyStaleResponseWriter, the freshly cached entry is
// served to the client and served is true. Otherwise served is false and the caller
// falls through to serve stale; the goroutine continues to run and any cacheable
// response updates the cache without writing to the detached client connection.
func (c *Cache) verifyWithTimeout(ctx context.Context, state request.Request, w dns.ResponseWriter, cw *verifyStaleResponseWriter, r *dns.Msg, do, ad bool, i *item, failureRecheck time.Duration, now func() time.Time) (served bool, code int, err error) {
type result struct {
code int
@@ -191,17 +191,18 @@ func (c *Cache) verifyWithTimeout(ctx context.Context, state request.Request, w
if !cw.refreshed {
return false, 0, nil
}
fresh := c.exists(state.Name(), state.QType(), state.QClass(), state.Do(), state.Req.CheckingDisabled)
if fresh == nil {
// Should not happen: refreshed=true means the upstream response was cacheable.
if cw.response == nil {
return true, res.code, res.err
}
now := c.now()
if c.keepttl {
now = fresh.stored
response := cw.response
if cw.item != nil {
now := c.now()
if c.keepttl {
now = cw.item.stored
}
response = cw.item.toMsg(r, now, do, ad)
}
resp := fresh.toMsg(r, now, do, ad)
if err := w.WriteMsg(resp); err != nil {
if err := w.WriteMsg(response); err != nil {
return true, dns.RcodeServerFailure, err
}
return true, dns.RcodeSuccess, nil
@@ -227,6 +228,19 @@ func (c *Cache) getIfNotStale(now time.Time, state request.Request, server strin
k := hash(state.Name(), state.QType(), state.QClass(), state.Do(), state.Req.CheckingDisabled)
cacheRequests.WithLabelValues(server, c.zonesMetricLabel, c.viewMetricLabel).Inc()
if c.preferPositive && c.staleUpTo > 0 {
if i, ok := c.pcache.Get(k); ok {
i = i.answeringItem(state)
if i != nil {
ttl := i.ttl(now)
if ttl > 0 || -ttl < int(c.staleUpTo.Seconds()) {
cacheHits.WithLabelValues(server, Success, c.zonesMetricLabel, c.viewMetricLabel).Inc()
return i
}
}
}
}
if i, ok := c.ncache.Get(k); ok {
ttl := i.ttl(now)
if i.matches(state) && (ttl > 0 || (c.staleUpTo > 0 && -ttl < int(c.staleUpTo.Seconds()))) {