get/dc: resolve a dc: handle exactly once per run#4273
Merged
Conversation
`diskcache::resolve_dc_path` is not a pure path lookup: past TTL it calls `get_resource` — a network fetch — and on success swaps in a refreshed entry with a different BLAKE3, hence a DIFFERENT materialized CSV. Nothing resolved a `dc:` input once per run and shared the result. Four call sites resolved independently — `Config::new` (config.rs), `process_input`, `get_stats_records` and `get_stats_records_readonly` (util.rs) — and because the first is inside `Config::new`, the count scaled with how many `Config`s a command builds (`viz smart` builds seven). If the TTL boundary was crossed mid-run and the source had changed, the reader's CSV, the row count, the frequency counts and the stats cache could each come from a different blob, with nothing detecting or reporting the divergence. Memoize resolution per run: a `(cache_dir, name)`-keyed map in `diskcache::rich` where the first resolution wins. The outer lock is held only long enough to hand out a per-handle slot; the slot lock is held ACROSS resolution so two concurrent first-callers on the same handle cannot both refresh into different blobs. Successes only — a failed resolution stays unmemoized and is retried. No signature changes, so all four call sites (and viz's own resolve-once threading from #4256) are untouched. Note `resolve_dc_path`'s side effects are NOT all idempotent. CSV and `.idx` materialization are, but the stats-sidecar capture deliberately depends on being called again AFTER `util::get_stats_records` has written the sidecar. Memoizing the whole function would have pushed durable `.stats.jsonl.zst` capture to a later run, so it is split out into `sync_stats_sidecar`, which still runs on every call — purely local, no network. With `RefreshPolicy::Always` this means one re-fetch per run rather than one per resolution; one snapshot per run is the point. Regression test `get_dc_resolves_once_per_run` seeds a `--ttl 0` (permanently stale) entry, then runs `frequency dc:…` and asserts exactly one conditional revalidation against the mock server. Written first and confirmed failing at 4 revalidations before the fix. It uses `frequency` and is deliberately ungated: qsvdp has `get` WITHOUT `feature_capable`, so a `schema`-based (and therefore `feature_capable`-gated) test would leave `GetWebServer::revalidations()` unused there — a dead-code warning and a -D warnings build break. That helper is ungated from `get_cloud` for the same reason. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to standards ✅🟢 Issues
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #4257.
The problem
diskcache::resolve_dc_pathis not a pure path lookup. Past TTL it callsget_resource— a network fetch — and on success swaps in a refreshed entry with a different BLAKE3, hence a different materialized CSV.Nothing resolved a
dc:input once per run and shared the result. Four call sites resolved independently:src/config.rs:203Config::new— so everyConfigbuilt from adc:input resolves againsrc/util.rs:2790process_inputsrc/util.rs:3226get_stats_recordssrc/util.rs:3634get_stats_records_readonlyBecause the first is inside
Config::new, the count scales with how manyConfigs a command builds —viz smartbuilds seven. Measured on this branch's test harness:qsv frequency dc:xresolved 4 times in one run.If the TTL boundary is crossed mid-run and the source actually changed, the reader's CSV, the row count, the frequency counts and the stats cache can each come from a different blob — and nothing detects or reports it. The page renders normally and looks authoritative.
The fix — Option 2 from the issue (per-run memoization)
A
(cache_dir, name)-keyed memo indiskcache::richwhere the first resolution wins for the life of the process.viz's own resolve-once threading from viz: bundle the sidecars a--dict-infodashboard was built from as downloads #4256 is also left as-is: it becomes belt-and-braces rather than load-bearing, and it keeps that PR's provenance guarantee explicit.Process-wide memoization equals per-run memoization because qsv is strictly one-command-per-process (
main.rsdispatches once and exits; the MCP server spawns a fresh binary per tool call). That assumption is stated in the doc comment so a future in-process command loop revisits it.The trap: side effects are not all idempotent
resolve_dc_pathdoes three kinds of disk work. CSV materialization (need_write-guarded) and.idxmaterialization are idempotent. The stats-sidecar capture is not, by design — it captures the.stats.csv.data.jsonlsidecar into a durable content-addressed blob only once it is fresher than the CSV, which is only true afterutil::get_stats_recordshas written it. It relies on being called again later in the run.Memoizing the whole function would have silently pushed durable
.stats.jsonl.zstcapture to a subsequent run. So it is split out intosync_stats_sidecar, which still runs on every call — purely local filesystem work, no network, keyed on the memoizedblake3/ext/csv_path. Verified: a singlefrequency dc:…run still takes the blob count 0 → 1.Behavior note
With
RefreshPolicy::Always, a handle now re-fetches once per run rather than once per resolution. That is the intended fix — one snapshot per run is the point — but it is a real behavior change, and it is called out in the doc comment.Test
get_dc_resolves_once_per_runseeds a--ttl 0(permanently stale) entry, then runsfrequency dc:…and asserts exactly one conditional revalidation against the mock server.revalidations() == 4before the fix, so it is provably non-vacuous.states.csvendpoint, which advertises noCache-Control: max-age— otherwisehttp-cache-reqwestwould short-circuit the conditional GET and the pre-fix count would read low (a false green).revalidationsis the discriminator;body_sends == 1only confirms the 304s never became re-downloads.frequency.qsvdphasgetwithoutfeature_capable, so aschema-based test would have to befeature_capable-gated, leavingGetWebServer::revalidations()unused on qsvdp — a dead-code warning and a-D warningsbuild break. That helper is ungated fromget_cloudfor the same reason. Confirmed the test compiles and passes under-F datapusher_plus.Verification
test_get49 passed,test_frequency179,test_schema12,test_stats684 — all green.get_dc_stats_cache_for_smart_commandsstill passes, confirming thesync_stats_sidecarsplit preserved capture-on-use.cargo clippy --bin qsv -F all_featuresclean (two pre-existing warnings, in untouched files).cargo +nightly fmtapplied.joinandcat rowsover two distinctdc:handles in one run — per-handle keying, no cross-talk.No docopt/USAGE changes, so no help regeneration needed.
Not addressed here
The issue's Not covered here section — whether
resolve_dc_pathdoing network I/O implicitly from insideConfig::newis itself the thing to revisit. The memo means only the first resolution fetches, but it does not move the fetch out of the constructor. That is closer to Option 1 and belongs in its own issue.🤖 Generated with Claude Code