Skip to content

analytics: say when a stream read came back at the cap - #1704

Merged
ryanbr merged 2 commits into
mainfrom
window-truncation-counter
Aug 28, 2026
Merged

ryanbr merged 2 commits into
mainfrom
window-truncation-counter

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Found while reading a field strap log. Small, diagnostic-only, both platforms.

The gap

The windows line already reports read and served. That log showed why it is not enough — one cold pass:

hr[read=1792781 served=1939063]    ← splicing ~half its rows
rr[read=3727610 served=93980]      ← splicing ~2.5%

Same cap, near-identical per-window demand, wildly different outcomes, and no way to tell which of the three refusal causes fired — truncation, an owner flip, or a gap left by a dayCache hit. I could not determine it from the log, and that is the instrumentation's fault, not the log's.

Why truncation specifically

One of those three causes is also a bug. The reads are ORDER BY ts ASC LIMIT, so a read returning the cap has dropped its newest rows, and the day is then scored on a window missing its tail with nothing anywhere saying so.

The same log has that device at 192,680 rows against the 200,000 cap — 96% — with rawRows still climbing across the day. So this is not hypothetical for that strap; it is a few weeks of data away.

The window already computes the fact (nowTruncated) in order to decide whether the buffer may be spliced. It just never reported it.

The change

A counter beside rowsRead/rowsServed, and two fields on the line:

analyzeRecent windows hr[read=… served=… truncated=0] rr[read=… served=… truncated=17]

A truncated extension that the splice recovers by re-reading the window whole is deliberately not counted. Nothing is lost on that path, and counting it would cry wolf on the one number here that means a score may be wrong rather than merely slow.

Nothing on the scoring path moves — it is a counter, so it cannot change a number.

Scope

This detects; it does not cure. Whether the answer is paginating past the cap or raising it should not be chosen before knowing whether truncation fires at all, and on which stream. Same order the REM funnel and the offload diagnostics were done in.

Verified

Both twins live in Packages/StrandAnalytics, so both sides are covered by default CI — no app-build needed.

  • 4,713 Android tests, 0 failures with --rerun-tasks --no-build-cache, including the analyzeRecentOnCpu bytecode budget guard (the call site grew, so that one matters here).
  • Kotlin and Swift expected strings verified byte-identical, and the Swift logLine compiled standalone and printed exactly them.
  • The Swift package suite cannot run in WSL (GRDB needs sqlite3.h), so that half rides on swift-packages.yml in this PR.

ryanbr added 2 commits August 28, 2026 20:08
The windows log already reports read and served. A field log showed why that is not
enough: hr[read=1792781 served=1939063] against rr[read=3727610 served=93980] on the same
cold pass — one stream splicing half its rows, the other almost none — with no way to tell
which of the three refusal causes fired.

One of those causes is also a bug. The reads are ORDER BY ts ASC LIMIT, so a read that comes
back at the cap has dropped its NEWEST rows, and the day is then scored on a window missing
its tail. The window already computes that (`nowTruncated`) to decide whether the buffer may
be spliced; it simply never said so. The same log has a strap at 192,680 rows against the
200,000 cap — 96% — so this is not a hypothetical for that device.

A counter beside rowsRead and rowsServed, and two fields on the line:

  analyzeRecent windows hr[read=… served=… truncated=0] rr[read=… served=… truncated=17]

A truncated EXTENSION that the splice recovers by re-reading the window whole is deliberately
NOT counted. Nothing is lost on that path, and counting it would cry wolf on the one number
here that means a score may be wrong rather than merely slow.

This detects; it does not cure. Whether the answer is paginating past the cap or raising it
should not be picked before knowing whether truncation fires at all, and for which stream.

Both twins live in Packages/StrandAnalytics, so both sides are covered by default CI without
app-build. Verified: 4,713 Android tests, 0 failures with --rerun-tasks --no-build-cache,
including the analyzeRecentOnCpu bytecode budget guard; the Swift logLine compiled standalone
and printed strings byte-identical to the Kotlin expectations. The Swift package suite itself
cannot run in WSL (GRDB needs sqlite3.h), so it rides on swift-packages.yml.
… tested

Two re-review passes over the counter itself.

The first pass shipped it untested. SlidingStreamWindowTest existed on both platforms with
exactly the right harness — window(limit) forces truncation — and the only new test asserted
the log line's string format, not that the counter ever moves. Covered now on both sides, and
the two suites pin an identical set of seven cases.

The second pass found the comment justifying the design was false. It said a truncated
extension "recovers by re-reading whole, nothing was lost there, so it is deliberately NOT
counted". But the extension's range is a SUBSET of the window it re-reads, so head.size >=
limit forces full.size >= limit: that path always ends truncated, and the case the comment
described cannot occur.

The third pass found the test written to fix the second was vacuous. A truncated read makes
cachedTruncated true, which is the planner's FIRST guard, so the follow-up call is a plain
full read and the Extend branch is never reached. It passed for the wrong reason, and the
corrected comment then cited it as proof of something it did not test.

That branch turns out to be unreachable from a uniform backward walk at all: reaching it needs
a buffer that is valid AND an extension that overruns the cap, and a truncated read never
leaves a valid buffer. Renamed to eachTruncatedWindowCountsSeparately, which is what the test
genuinely pins — the counter is windows-that-lost-rows, not passes — and both source comments
now state the reachability limit rather than imply coverage that does not exist.

Also checked this pass, no changes needed: nothing anywhere parses the windows line, so the
format change breaks no tooling; failedRead leaves all three counters alone, consistent with
rowsRead and rowsServed; and the windows are built per pass, which is what makes the number
readable.

Verified: 4,715 Android tests, 0 failures with --rerun-tasks --no-build-cache, including the
analyzeRecentOnCpu bytecode budget guard.
@ryanbr
ryanbr merged commit 8cecf56 into main Aug 28, 2026
15 checks passed
@ryanbr
ryanbr deleted the window-truncation-counter branch August 28, 2026 08:39
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.

1 participant