Skip to content

stats: re-enable parallel processing for special-format inputs (#4462) - #4469

Merged
jqnatividad merged 4 commits into
masterfrom
stats-4462-share-resolved-config
Aug 21, 2026
Merged

stats: re-enable parallel processing for special-format inputs (#4462)#4469
jqnatividad merged 4 commits into
masterfrom
stats-4462-share-resolved-config

Conversation

@jqnatividad

Copy link
Copy Markdown
Collaborator

Closes #4462.

stats refused to autoindex a special-format input (.gz/.zip/.parquet/.jsonl/…) and so always ran sequentially. That guard was the narrow fix in #4445 for a real crash: parallel_stats built a fresh Config per worker, each of which resolved its own converted temp, found no index beside it, and panicked — silently, since a panicking pool worker only unwinds its own thread, leaving a headers-only stats file at exit 0.

This adopts the pattern #4459 established across split/search/searchset/pragmastat and #4466 made safe: share one resolved Config. Clones share the Arc<OnceLock> holding the conversion, so every worker sees the same temp and the same sibling index.

Changes

  • sequential_stats, parallel_stats and process_headers_with_weight_exclusion now take &Config; parallel workers get a clone. Exactly one Config is constructed per run, so the invariant is greppable.
  • All three is_special_format() guards removed. The memcheck index fallback mattered most — it fires precisely when the input is too large to process sequentially, so excluding compressed inputs left them with no index escape hatch at all.
  • Autoindex cleanup now resolves the index beside the path that was actually indexed (the temp), not the compressed source — otherwise every such run logged a spurious Could not remove index file.
  • Config::resolve_converted logs one line per conversion, inside the get_or_init closure so it fires exactly once per Config family. This makes Config-rebuilding regressions countable instead of invisible.

On the #4446 repro: nchunks=17 where master logged none, byte-identical to the uncompressed input, and conversions dropped 3 → 1.

Two hazards found along the way

A silent wrong-results trap. stdin delimiter inference sets args.flag_delimiter after rconfig is built, and relied on the compute paths rebuilding their own Config to pick it up. Sharing the pre-built one parsed a TSV as a single comma-delimited column — wrong stats, exit 0, no warning. Re-applied explicitly; stats_infers_stdin_delimiter pins it.

A stale claim in the comments. An earlier note asserted moarstats still rebuilds a Config per worker from the original path. #4464 already fixed that — compute_outliers_and_kga and compute_all_bivariatestats are both handed read_input_path, already resolved by their caller. Left as written, the comment would have sent a maintainer to fix a bug that does not exist. Corrected, and the second valid shape (resolve once, pass the resolved path) is now named alongside the first (share the resolved Config).

Tests

stats_autoindex_is_skipped_for_special_format_inputs is reworked into stats_parallelizes_special_format_inputs. It keeps the valuable half — .zip stats must equal the same data uncompressed — and drops one assertion that was vacuous: no index was ever written beside the compressed source, so it could not fail either way.

Output cannot distinguish sequential from parallel (identical by design, which is the point), so the discriminating evidence is the log: a nchunks= line plus exactly one conversion. Coverage spans both autoindex routes (--cache-threshold and QSV_AUTOINDEX_SIZE) and the sequential path.

All seven code changes were mutation-verified: each reverted individually, with the corresponding assertion confirmed to fail. Reverting the worker change reproduces the original crash. An early mutation pass passed when it should not have, which is what surfaced the uncovered QSV_AUTOINDEX_SIZE route.

Both tests are also pinned against an ambient QSV_AUTOINDEX_SIZE/QSV_DEFAULT_DELIMITERWorkdir::command inherits the environment — and that pinning was itself verified load-bearing.

Green locally: all_features 992 unit + 3748 integration, lite 132 + 2251, clippy clean on both. The .zip fixture is deliberate so the new tests run under -F lite too.

Risk

The concurrency delta is small and one-directional. Workers now carry a non-zero autoindex_size where they previously inherited the env value, so a TOCTOU-deleted index self-heals instead of panicking. The staleness recheck cannot flip — the temp is written once and never touched, so its mtime cannot exceed the index's regardless of machine speed. That is reasoning rather than proof, and a green local suite is not evidence about CI, so macOS and qsvlite runners are the real check.

🤖 Generated with Claude Code

jqnatividad and others added 3 commits August 21, 2026 01:35
…4462)

`stats` refused to autoindex a special-format input (.gz/.zip/.parquet/.jsonl/...)
and so always ran sequentially. That guard was the narrow fix in #4445 for a real
crash: `parallel_stats` built a FRESH `Config` per worker, each of which resolved
its OWN converted temp, found no index beside it, and panicked - silently, since a
panicking pool worker only unwinds its own thread, leaving a headers-only stats
file at exit 0.

Adopt the pattern #4459 established across split/search/searchset/pragmastat and
#4466 made safe: share ONE resolved `Config`. Clones share the `Arc<OnceLock>`
holding the conversion, so every worker sees the same temp and the same sibling
index.

- `sequential_stats` and `parallel_stats` now take `&Config`; workers get a clone
- `process_headers_with_weight_exclusion` takes it too, so exactly one `Config`
  is constructed per run - the invariant is now literally greppable
- drop all three `is_special_format()` guards. The memcheck index fallback
  mattered most: it fires precisely when the input is too large to process
  sequentially, so excluding compressed inputs left them with no index escape
  hatch at all
- autoindex cleanup now resolves the index beside the path that was actually
  indexed (the temp), not the compressed source

Two hazards found while doing this:

- stdin delimiter inference sets `args.flag_delimiter` AFTER `rconfig` is built,
  and relied on the compute paths rebuilding their own `Config` to pick it up.
  Sharing the pre-built one silently parsed a TSV as one comma-delimited column -
  wrong stats, exit 0, no warning. Re-applied explicitly.
- `Config::resolve_converted` now logs one line per conversion, inside the
  `get_or_init` closure so it fires exactly once per Config family. This makes
  Config-rebuilding regressions countable rather than invisible; the compressed
  input went from 3 conversions to 1.

Tests: `stats_autoindex_is_skipped_for_special_format_inputs` reworked into
`stats_parallelizes_special_format_inputs`, keeping the ".zip stats == plain
stats" half and dropping a vacuous assertion (no index was ever written beside
the source, so it could not fail). Output cannot distinguish sequential from
parallel - identical by design - so the discriminating evidence is the log:
a `nchunks=` line and exactly one conversion. Covers both autoindex routes
(--cache-threshold and QSV_AUTOINDEX_SIZE) plus the sequential path. New
`stats_infers_stdin_delimiter` pins the delimiter hazard.

All seven code changes mutation-verified: each was individually reverted and the
corresponding assertion confirmed to fail. Both tests are also pinned against an
ambient QSV_AUTOINDEX_SIZE / QSV_DEFAULT_DELIMITER, verified load-bearing.

Closes #4462

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ments

The handoff note this work came from stated that `moarstats` still rebuilds a
Config per worker from the original path, and pointed at two line numbers. Both
halves are wrong now: the lines are off by four, and #4464 already fixed the
substance — `compute_outliers_and_kga` and `compute_all_bivariatestats` are both
handed `read_input_path`, which is resolved by their caller, so the Configs they
build are ordinary rather than special-format.

That is a second valid shape (resolve once, pass the resolved PATH) alongside the
one #4462 adopted (share the resolved CONFIG), and it is worth naming as such.
Left as-is, the comment would have sent a future maintainer to fix a bug that
does not exist.

Also record why `resolved_path()` in the autoindex cleanup is always a cached
read: it sits inside `if autoindex_set`, which is only set on the compute path
after the OnceLock is populated. Verified empirically — a cache-hit run performs
zero conversions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The comment said `autoindex_set` is set after `rconfig.indexed()` has populated
the OnceLock. It is not — the flag is assigned at stats.rs:2037, immediately
before the `indexed()` call at :2042.

The conclusion is unchanged and still correct: `resolved_path()` in the cleanup
block is a cached read, never a conversion. But what guarantees that is the
cleanup block sitting far BELOW `indexed()` on the compute path, not the order
of the flag assignment. Reworded to say so, and to name the flag's actual
ordering so the next reader does not have to re-derive it.

Comment-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity

Metric Results
Complexity 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

The docs-drift-check CI job failed on this branch: the doc claimed
tests/test_stats.rs was ~8,338 lines against an actual 9,319 (10.5% drift,
above the 10% tolerance).

The staleness predates this branch — master was already at 9,159 lines, i.e.
9.85% drift, sitting just under the threshold. The tests added here tipped it
over rather than caused it.

Refreshed the sibling src/cmd/stats.rs count in the same table too: it claimed
~6,320 against an actual 6,910 (8.5%), which is under tolerance today but on
the same trajectory and would have failed some unrelated PR next.

Both numbers counted, not estimated. docs-drift-check now passes clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jqnatividad
jqnatividad merged commit 5dfd72d into master Aug 21, 2026
20 checks passed
@jqnatividad
jqnatividad deleted the stats-4462-share-resolved-config branch August 21, 2026 12:22
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.

stats: re-enable parallel processing for special-format inputs by sharing the resolved Config

1 participant