Skip to content

Dedup DNS attribution rows in one pass, not per candidate row - #741

Merged
kasnder merged 1 commit into
masterfrom
claude/dns-dedup-battery-heat-x045rv
Aug 20, 2026
Merged

Dedup DNS attribution rows in one pass, not per candidate row#741
kasnder merged 1 commit into
masterfrom
claude/dns-dedup-battery-heat-x045rv

Conversation

@kasnder

@kasnder kasnder commented Aug 20, 2026

Copy link
Copy Markdown
Member

Summary

Root-causes the battery/heat regression attributed to "DNS deduping". The culprit is not #739 (the DNS response-rewriting dedup — reviewed below), but the qname dedup that #690 added to DatabaseHelper.getQAName().

#690 deduplicates a shared IP's DNS evidence with a correlated scalar subquery: for every candidate row of the IP, SQLite re-searches idx_dns_resource across all of that IP's rows and sorts them in a temp B-tree to pick the freshest row per qname (EXPLAIN QUERY PLAN: CORRELATED SCALAR SUBQUERYSEARCH d2 USING INDEX idx_dns_resource (resource=?)USE TEMP B-TREE FOR ORDER BY, executed once per outer row). That is O(n²) row visits plus one sort per row, where n is the number of dns rows sharing the IP.

Two things make this a battery/heat issue rather than a slow query:

  • It runs for every new connection. ServiceSinkhole.log() calls getQAName(uid, daddr, false) for every logged packet event — with the alive filter off, so n includes the full accumulated history for that IP. blockKnownTracker() runs it again (alive=true) on every ipToHost cache miss, inside the is_address_allowed JNI upcall from the native packet path.
  • It grows over time. insertDns() clamps TTL to the "ttl" preference floor (3 days by default) and expired rows are only removed by cleanup, so hot CDN/tracker IPs steadily accumulate qname rows. The regression creeps in as the table fills — matching a heat report that surfaces weeks after Attribute shared-IP DNS evidence by recency, not alphabetically #690 merged.

Benchmark (real SQLite, 400 qnames × 3 anames on one shared IP = 1,200 rows): ~147 ms per lookup before, ~0.9 ms after (~150×) — per connection, on the log handler thread and the packet-path upcall.

Fix

Replace the correlated subquery with a single MAX(time) aggregate grouped by qname. With exactly one min/max aggregate, SQLite takes the bare columns from the row that supplied the maximum, so #690's semantics are unchanged — freshest row per qname, qnames ordered by recency — while the plan collapses to one index range scan over the IP's rows (same shape as before #690, now with recency ordering).

Validation

  • Query semantics verified against real SQLite: existing test cases (recency ordering, dedup-to-freshest-row) reproduce identical results; EXPLAIN QUERY PLAN confirms the correlated subquery is gone.
  • New Robolectric test pins the alive-filter/dedup interplay: an expired fresher row must not shadow an older row that is still alive (rows inserted directly because insertDns()'s TTL clamp would keep the fresh row alive).
  • No Android SDK in this container, so :app:testGithubDebugUnitTest runs in CI on this PR; I'll watch it.

Review of #739 for the same symptom

"Deduplicate DNS response rewriting" (#739) was also examined for battery/heat mechanisms and none was found: the tc-dns parse is bounded per packet (name-compression depth 8, 255-octet cap), it only runs for port-53 traffic, the Rust library is still built --release, no periodic work, timers, or wakeup sources are added, and the C TCP path's new length-prefix rewrite only shortens isolated complete frames with consistent sequence accounting. Its per-response cost (heap allocation + the same one-per-response JNI upcalls) is bounded by DNS traffic and cannot produce sustained CPU.

🤖 Generated with Claude Code

https://claude.ai/code/session_012Hp2Xpr5CSahi9Qizg9zjp


Generated by Claude Code

getQAName() deduplicated a shared IP's DNS evidence with a correlated
scalar subquery: for every candidate row of the IP, SQLite re-searched
idx_dns_resource over all of that IP's rows and sorted them in a temp
B-tree to find the freshest row per qname. That is O(n^2) row visits
plus one sort per row, it grows with accumulated DNS history (rows live
for at least the TTL floor, three days by default), and the query runs
for every new connection: once from log() with the alive filter off, and
once from blockKnownTracker() on an ipToHost cache miss, inside the
is_address_allowed upcall from the packet path. On IPs shared by
hundreds of qnames this turns every connection into hundreds of
thousands of row visits, which surfaces as sustained CPU, heat, and
battery drain that worsens as the dns table fills.

Replace the correlated subquery with a single MAX(time) aggregate
grouped by qname. With exactly one min/max aggregate, SQLite takes the
bare columns from the row that supplied the maximum, so the dedup
semantics from 6c34ce1 are unchanged - freshest row per qname, qnames
ordered by recency - while the plan collapses to one index range scan
over the IP's rows. In a 1,200-row shared-IP benchmark this drops the
lookup from ~147 ms to ~0.9 ms.

Also cover the alive-filter interplay: an expired fresher row must not
shadow an older row that is still alive.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Hp2Xpr5CSahi9Qizg9zjp
@kasnder
kasnder marked this pull request as ready for review August 20, 2026 20:30
@kasnder
kasnder merged commit 8752649 into master Aug 20, 2026
2 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.

2 participants