Skip to content

Commit b10b4e7

Browse files
committed
dnsforward: collect optimistic refreshes through the proxy
Replace the upstream wrapper with the callback added in AdguardTeam/dnsproxy#520. The wrapper could not work. An exchange has to be attributed to the request it belongs to, both so that the statistics ignore lists apply and so that a background refresh can be told from a foreground query, and an upstream.Upstream is given no way to do it: Exchange takes only a *dns.Msg and no context, and ExchangeParallel and ExchangeAll copy the request once per upstream before calling it, so no identity of the original survives. The request-pointer key therefore missed in parallel and fastest-address mode with more than one upstream, and queries from clients with ignored statistics were counted after all. Only the single-upstream path skips the copy, which is why the first regression passed. Foreground exchanges go back to being collected from proxy.DNSContext.QueryStatistics, which is client-attributed and already gated by ShouldCount, so the ignore lists behave exactly as they did before this branch. Background refreshes arrive through proxy.Config.OnOptimisticRefresh, which fires once the optimistic cache has refreshed an expired entry; they belong to no client by construction, so there is nothing to attribute and nothing to double count. A retried exchange is still left out, but the check moved to where the statistics entry is built, and compares against the timeout the upstreams of that request were constructed with: defaultLocalTimeout for private rDNS, the configured one otherwise. Both are covered end to end, and both regressions fail against the unfixed implementation: the retry filter in load-balance, parallel, and fastest-address mode, and the refresh collection against an entry that expires while the test runs.
1 parent 285c2ab commit b10b4e7

10 files changed

Lines changed: 302 additions & 833 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ NOTE: Add new changes BELOW THIS COMMENT.
5050
([#8435]). An optimistic cache hit is answered from the cache right away and the expired entry
5151
is refreshed by a background query, and those background queries used to be left out of the
5252
statistics. The average was therefore based on cache misses alone, which are skewed towards
53-
rare domain names that the upstream itself resolves slower. Every exchange with an upstream
54-
server is now counted, including the background ones.
53+
rare domain names that the upstream itself resolves slower. Those refreshes are now counted
54+
as well, through the new `OnOptimisticRefresh` callback of the DNS proxy.
5555

5656
- Upstream response times on the dashboard being far higher than the actual network latency to
5757
the upstream servers ([#8457]). A plain DNS upstream retries once when an attempt times out,

internal/client/upstreammanager.go

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,10 @@ import (
1616
"github.com/AdguardTeam/golibs/timeutil"
1717
)
1818

19-
// UpstreamConfigWrapper wraps parsed upstream configurations, e.g. to collect
20-
// statistics about the exchanges with them.
21-
type UpstreamConfigWrapper interface {
22-
// WrapUpstreamConfig returns a wrapped copy of uc, or nil if uc is nil. It
23-
// must not modify uc. timeout must be the timeout that the upstreams of uc
24-
// have been constructed with.
25-
WrapUpstreamConfig(
26-
uc *proxy.UpstreamConfig,
27-
timeout time.Duration,
28-
) (wrapped *proxy.UpstreamConfig)
29-
}
30-
3119
// CommonUpstreamConfig contains common settings for custom client upstream
3220
// configurations.
3321
type CommonUpstreamConfig struct {
34-
Bootstrap upstream.Resolver
35-
36-
// UpstreamConfigWrapper wraps the parsed custom client upstream
37-
// configurations. If it's nil, they are used as is.
38-
UpstreamConfigWrapper UpstreamConfigWrapper
39-
22+
Bootstrap upstream.Resolver
4023
UpstreamTimeout time.Duration
4124
BootstrapPreferIPv6 bool
4225
EDNSClientSubnetEnabled bool
@@ -249,10 +232,6 @@ func newCustomUpstreamConfig(
249232
panic(fmt.Errorf("creating custom upstream config: %w", err))
250233
}
251234

252-
if w := conf.UpstreamConfigWrapper; w != nil {
253-
upsConf = w.WrapUpstreamConfig(upsConf, conf.UpstreamTimeout)
254-
}
255-
256235
return proxy.NewCustomUpstreamConfig(
257236
upsConf,
258237
cliConf.upstreamsCacheEnabled,

internal/dnsforward/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ func (s *Server) newProxyConfig(ctx context.Context) (conf *proxy.Config, err er
354354
UpstreamConfig: srvConf.UpstreamConfig,
355355
PrivateRDNSUpstreamConfig: srvConf.PrivateRDNSUpstreamConfig,
356356
RequestHandler: ratelimitMw.Wrap(logMw.Wrap(s.Wrap(s))),
357+
OnOptimisticRefresh: s.handleOptimisticRefresh,
357358
EnableEDNSClientSubnet: srvConf.EDNSClientSubnet.Enabled,
358359
MaxGoroutines: srvConf.MaxGoroutines,
359360
UseDNS64: srvConf.UseDNS64,

internal/dnsforward/dnsforward.go

Lines changed: 10 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -121,24 +121,6 @@ type Server struct {
121121
// stats is the statistics collector for client's DNS usage data.
122122
stats stats.Interface
123123

124-
// ignoredReqs contains the request messages of the queries whose statistics
125-
// must not be counted, keyed by *dns.Msg. An upstream exchange carries no
126-
// client identity, so [statsUpstream] cannot apply the ignore lists itself;
127-
// the request handler records the decision here, while the client is still
128-
// known, see [Server.markIgnoredReq].
129-
//
130-
// Only the ignored queries are stored, so the map is empty unless some
131-
// client or domain is actually ignored.
132-
ignoredReqs sync.Map
133-
134-
// upstreamStats is the same collector as stats, but it's set once and never
135-
// reset, so that it can be used from [statsUpstream] without acquiring
136-
// serverLock. An upstream exchange may happen while serverLock is already
137-
// held for reading, see [Server.Resolve], so acquiring it again there could
138-
// deadlock with a concurrent writer. Updating an already closed collector
139-
// only loses the data, which is acceptable during the shutdown.
140-
upstreamStats stats.Interface
141-
142124
// sysResolvers used to fetch system resolvers to use by default for private
143125
// PTR resolving.
144126
sysResolvers SystemResolvers
@@ -260,14 +242,13 @@ func NewServer(p DNSCreateParams) (s *Server, err error) {
260242
}
261243

262244
s = &Server{
263-
dnsFilter: p.DNSFilter,
264-
dhcpServer: p.DHCPServer,
265-
stats: p.Stats,
266-
upstreamStats: p.Stats,
267-
queryLog: p.QueryLog,
268-
privateNets: p.PrivateNets,
269-
baseLogger: p.Logger,
270-
logger: p.Logger.With(slogutil.KeyPrefix, "dnsforward"),
245+
dnsFilter: p.DNSFilter,
246+
dhcpServer: p.DHCPServer,
247+
stats: p.Stats,
248+
queryLog: p.QueryLog,
249+
privateNets: p.PrivateNets,
250+
baseLogger: p.Logger,
251+
logger: p.Logger.With(slogutil.KeyPrefix, "dnsforward"),
271252
// TODO(e.burkov): Use some case-insensitive string comparison.
272253
localDomainSuffix: strings.ToLower(localDomainSuffix),
273254
etcHosts: etcHosts,
@@ -586,10 +567,9 @@ func (s *Server) prepareUpstreamSettings(ctx context.Context, boot upstream.Reso
586567
return fmt.Errorf("preparing upstream config: %w", err)
587568
}
588569

589-
s.conf.UpstreamConfig = s.WrapUpstreamConfig(uc, s.conf.UpstreamTimeout)
570+
s.conf.UpstreamConfig = uc
590571
s.conf.ClientsContainer.UpdateCommonUpstreamConfig(&client.CommonUpstreamConfig{
591572
Bootstrap: boot,
592-
UpstreamConfigWrapper: s,
593573
UpstreamTimeout: s.conf.UpstreamTimeout,
594574
BootstrapPreferIPv6: s.conf.BootstrapPreferIPv6,
595575
EDNSClientSubnetEnabled: s.conf.EDNSClientSubnet.Enabled,
@@ -685,16 +665,11 @@ func (s *Server) prepareInternalDNS(ctx context.Context) (err error) {
685665
return err
686666
}
687667

688-
privateUC, err := s.prepareLocalResolvers(ctx)
668+
s.conf.PrivateRDNSUpstreamConfig, err = s.prepareLocalResolvers(ctx)
689669
if err != nil {
690670
return err
691671
}
692672

693-
// NOTE: The private rDNS upstreams are constructed with defaultLocalTimeout,
694-
// see prepareLocalResolvers, so that is the timeout a retried exchange with
695-
// them is measured against.
696-
s.conf.PrivateRDNSUpstreamConfig = s.WrapUpstreamConfig(privateUC, defaultLocalTimeout)
697-
698673
err = s.prepareInternalProxy()
699674
if err != nil {
700675
return fmt.Errorf("preparing internal proxy: %w", err)
@@ -723,7 +698,7 @@ func (s *Server) setupFallbackDNS() (uc *proxy.UpstreamConfig, err error) {
723698
return nil, err
724699
}
725700

726-
return s.WrapUpstreamConfig(uc, s.conf.UpstreamTimeout), nil
701+
return uc, nil
727702
}
728703

729704
// setupAddrProc initializes the address processor. It assumes s.serverLock is

internal/dnsforward/process.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -480,11 +480,6 @@ func (s *Server) processUpstream(
480480

481481
s.setCustomUpstream(ctx, l, pctx, dctx.clientID)
482482

483-
// Record whether the statistics ignore this query before resolving it,
484-
// since the upstream wrappers that collect the response times have no way
485-
// of knowing its client, see [Server.markIgnoredReq].
486-
defer s.markIgnoredReq(dctx)()
487-
488483
// Process the request further since it wasn't filtered.
489484
prx := s.proxy()
490485
if prx == nil {

internal/dnsforward/stats.go

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"log/slog"
66
"net"
7+
"net/netip"
78
"time"
89

910
"github.com/AdguardTeam/AdGuardHome/internal/aghnet"
@@ -29,10 +30,19 @@ func (s *Server) processQueryLogsAndStats(
2930
host := aghnet.NormalizeDomain(q.Name)
3031
processingTime := time.Since(dctx.startTime)
3132

32-
ip, ipStr, ids := s.clientIdentity(dctx)
33+
ip := pctx.Addr.Addr().AsSlice()
34+
s.anonymizer.Load()(ip)
35+
ipStr := net.IP(ip).String()
3336

3437
l.DebugContext(ctx, "client ip for stats and querylog", "ip", ipStr)
3538

39+
ids := []string{ipStr}
40+
if dctx.clientID != "" {
41+
// Use the ClientID first because it has a higher priority. Filters
42+
// have the same priority, see applyAdditionalFiltering.
43+
ids = []string{dctx.clientID, ipStr}
44+
}
45+
3646
qt, cl := q.Qtype, q.Qclass
3747

3848
// Synchronize access to s.queryLog and s.stats so they won't be suddenly
@@ -66,25 +76,6 @@ func (s *Server) processQueryLogsAndStats(
6676
return resultCodeSuccess
6777
}
6878

69-
// clientIdentity returns the anonymized address of the client of dctx, its
70-
// string form, and the identifiers by which the query log and the statistics
71-
// know it. dctx must not be nil.
72-
func (s *Server) clientIdentity(dctx *dnsContext) (ip net.IP, ipStr string, ids []string) {
73-
addr := dctx.proxyCtx.Addr.Addr().AsSlice()
74-
s.anonymizer.Load()(addr)
75-
76-
ip = net.IP(addr)
77-
ipStr = ip.String()
78-
79-
if dctx.clientID != "" {
80-
// Use the ClientID first because it has a higher priority. Filters
81-
// have the same priority, see applyAdditionalFiltering.
82-
return ip, ipStr, []string{dctx.clientID, ipStr}
83-
}
84-
85-
return ip, ipStr, []string{ipStr}
86-
}
87-
8879
// shouldLog returns true if the query with the given data should be logged in
8980
// the query log. s.serverLock is expected to be locked.
9081
func (s *Server) shouldLog(host string, qt, cl uint16, ids []string) (ok bool) {
@@ -149,14 +140,104 @@ func (s *Server) logQuery(dctx *dnsContext, ip net.IP, processingTime time.Durat
149140
s.queryLog.Add(p)
150141
}
151142

143+
// retryThreshold returns the duration at or above which a successful exchange
144+
// must have retried after an attempt that timed out, since a single attempt
145+
// cannot outlast the timeout it was made with. pctx must not be nil.
146+
//
147+
// s.serverLock is expected to be locked.
148+
func (s *Server) retryThreshold(pctx *proxy.DNSContext) (d time.Duration) {
149+
if pctx.RequestedPrivateRDNS != (netip.Prefix{}) {
150+
// The private rDNS upstreams are constructed with a timeout of their
151+
// own, see prepareLocalResolvers.
152+
return defaultLocalTimeout
153+
}
154+
155+
return s.conf.UpstreamTimeout
156+
}
157+
158+
// appendCountedUpstreams appends those of us that should be counted in the
159+
// statistics to stats.
160+
//
161+
// A plain DNS upstream retries once when an attempt times out, for example
162+
// when a UDP datagram is lost, and reports the retried exchange as an ordinary
163+
// success. Its duration is then at least the whole timeout, ten seconds by
164+
// default, even though the successful attempt itself took a millisecond.
165+
// Averaging such a sample in makes the reported response time an order of
166+
// magnitude higher than the actual one, so leave it out: it describes the retry
167+
// policy and the configured timeout rather than the speed of the upstream.
168+
//
169+
// See https://github.com/AdguardTeam/AdGuardHome/issues/8457.
170+
func appendCountedUpstreams(
171+
stats []*proxy.UpstreamStatistics,
172+
us []*proxy.UpstreamStatistics,
173+
threshold time.Duration,
174+
) (appended []*proxy.UpstreamStatistics) {
175+
for _, u := range us {
176+
if threshold > 0 && u.Error == nil && u.QueryDuration >= threshold {
177+
continue
178+
}
179+
180+
stats = append(stats, u)
181+
}
182+
183+
return stats
184+
}
185+
186+
// handleOptimisticRefresh records the response times of an optimistic cache
187+
// refresh. It implements [proxy.Config.OnOptimisticRefresh]. dctx must not be
188+
// nil.
189+
//
190+
// Such a refresh is performed in the background once an expired entry has
191+
// already been answered from the cache, so it reaches no request handler and
192+
// belongs to no client. Without it the response times would only ever be
193+
// sampled from cache misses, and with the optimistic cache enabled the popular
194+
// names, which are exactly the ones kept warm, would never be sampled at all.
195+
//
196+
// See https://github.com/AdguardTeam/AdGuardHome/issues/8435.
197+
func (s *Server) handleOptimisticRefresh(dctx *proxy.DNSContext) {
198+
qs := dctx.QueryStatistics()
199+
if qs == nil || dctx.Req == nil || len(dctx.Req.Question) == 0 {
200+
return
201+
}
202+
203+
domain := aghnet.NormalizeDomain(dctx.Req.Question[0].Name)
204+
205+
// Synchronize access to s.stats so it won't be suddenly uninitialized while
206+
// in use, the same way processQueryLogsAndStats does.
207+
s.serverLock.RLock()
208+
defer s.serverLock.RUnlock()
209+
210+
if s.stats == nil {
211+
return
212+
}
213+
214+
threshold := s.retryThreshold(dctx)
215+
for _, u := range appendCountedUpstreams(nil, qs.Main(), threshold) {
216+
if u.IsCached || u.Error != nil {
217+
continue
218+
}
219+
220+
s.stats.UpdateUpstream(&stats.UpstreamEntry{
221+
Address: u.Address,
222+
Domain: domain,
223+
QueryDuration: u.QueryDuration,
224+
})
225+
}
226+
}
227+
152228
// updateStats writes the request data into statistics.
153229
func (s *Server) updateStats(dctx *dnsContext, clientIP string, processingTime time.Duration) {
154230
pctx := dctx.proxyCtx
155231

156-
// NOTE: The upstream response times are not taken from
157-
// [proxy.DNSContext.QueryStatistics] here, since they are collected for
158-
// every exchange, including the background ones, see [statsUpstream].
232+
var upstreamStats []*proxy.UpstreamStatistics
233+
if qs := pctx.QueryStatistics(); qs != nil {
234+
threshold := s.retryThreshold(pctx)
235+
upstreamStats = appendCountedUpstreams(upstreamStats, qs.Main(), threshold)
236+
upstreamStats = appendCountedUpstreams(upstreamStats, qs.Fallback(), threshold)
237+
}
238+
159239
e := &stats.Entry{
240+
UpstreamStats: upstreamStats,
160241
Domain: aghnet.NormalizeDomain(pctx.Req.Question[0].Name),
161242
Result: stats.RNotFiltered,
162243
ProcessingTime: processingTime,

0 commit comments

Comments
 (0)