perf: batch the CODEOWNERS query in validate_files (parked pending #125) - #124
perf: batch the CODEOWNERS query in validate_files (parked pending #125)#124perryqh wants to merge 1 commit into
Conversation
03eff63 to
ee9064b
Compare
ee9064b to
ba60023
Compare
e9dedbf to
c0e5546
Compare
validate_files called team_for_file_from_codeowners once per path. That helper wraps the path in a one-element slice and hands it to the batch query, which reloads the config and re-reads and re-parses the entire CODEOWNERS file every time — parse_codeowners_entries is not memoized, unlike teams_by_github_team_name right beside it. Against an 18k-line CODEOWNERS that cost ~9.5ms per path, linearly: a 2000-file changeset spent 22s, of which ~20s was re-parsing the same file 2000 times. Now the paths are filtered once and handed to the batch query in a single call, which already parallelizes internally. The batch returns a map keyed by project-relative path, so the lookup key has to match what the query computes. Relativization therefore goes through path_utils::relative_to rather than a second hand-rolled copy of strip_prefix; if the two ever diverged, lookups would miss silently and report owned files as unowned. Unowned files are still reported using the caller's original path string, and still in input order, so absolute paths render as before. No IO error behavior change. The batched call cannot attribute a failure to a single path, but that is unobservable here: the only error the query reports is a non-UTF-8 path, and these have already been through to_string_lossy. A missing or unreadable CODEOWNERS is not an error on this path either — the parser logs it and yields no entries, so every path is reported unowned. Both were true before this change. Tests cover the map lookup specifically: several unowned paths in one call, the same path passed twice (one collapsed key), and an absolute plus a relative path to the same file (one key, two original strings). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
c0e5546 to
0455fba
Compare
gv <paths> is 4x faster|
Parking this pending #125. The justification in "Why this targets And on a dual-owned file it is worse than a miss. Regenerating writes the file into CODEOWNERS under one of its two owners, so the per-path check finds an owner and exits 0 with no output. Regenerating conceals the defect. #125 has a failing test: So the honest description of this PR is: it makes a check that misses two of three defect classes 4x faster. Nothing here is incorrect — the batching is sound, the generated CODEOWNERS is byte-identical, and it removes ~10s of genuinely redundant CODEOWNERS re-parsing. But the subject may not survive. Not closing it, because the outcome depends on how #125 is resolved:
Two notes for whoever picks it up: The perf table was measured at The description also still says "all 12 existing |
|
Closing as obsolete, superseded by #125. This optimized reading the generated CODEOWNERS back once per supplied path. #125 removes that step entirely — ownership for supplied paths is now resolved through the mappers, the same way the whole-project run does — so the code path this batched no longer exists. That was the necessary change rather than a preference. Reading CODEOWNERS back could only ever answer "does this path have an owner", which cannot see a file owned two ways: generation picks one winner and writes it, so the file looks owned. The batching itself was correct, and the ~10s of redundant re-parsing it removed was real. It just had the wrong subject. Two things worth salvaging from here rather than losing:
Not reviving this branch. If the fast path later earns an explicit opt-in flag, the batching question can be revisited from scratch against whatever that path actually does. |
Summary
generate-and-validate <paths>on a 1000-file changeset goes from 14.6s to 3.6s (4.1x) on a 130k-file monorepo.What was wrong
validate_fileslooped over paths callingteam_for_file_from_codeowners. That helper wraps the path in a one-element slice and hands it to the batch query — which reloads the config and re-reads and re-parses the entire 17,981-line CODEOWNERS every time.parse_codeowners_entriesis not memoized, unliketeams_by_github_team_namedirectly beside it.Cost: ~9.5ms per path, linear. On a 1000-path changeset that is ~10s spent re-parsing the same file 1000 times.
The batch function already accepts a slice and already parallelizes across it with
par_iter. It was simply being called wrong.Why this targets
gv <paths>and notvalidate <paths>An earlier version of this work also bypassed the project build, which made
validate <paths>~1500x faster. That was closed (#123) becausevalidate <paths>is not equivalent tovalidate— it resolves ownership by reading the CODEOWNERS file, so it validates a derived artifact against itself and cannot detect a stale CODEOWNERS, an annotation naming a nonexistent team, or a file owned two ways. Making that path faster is not a win.Warning
This section was wrong, and it was the load-bearing claim. It read: "
gv <paths>is correct: it regenerates before validating."Regenerating cures only the staleness gap.
gv <paths>still skipsvalidate_invalid_teamandvalidate_file_ownership, becausegenerate()merely writes the generated string and validates nothing. Worse, on a dual-owned file, regenerating writes it into CODEOWNERS under one of its owners — so the per-path check finds an owner and exits 0. Regenerating conceals that defect rather than exposing it.#125 has failing tests for all of it. So this PR optimizes a check that misses two of three defect classes, and the claim that it "does not extend or entrench" the weak path does not survive: a 4x faster weak check is a more attractive weak check.
This change does not make anything less correct — the batching itself is sound and the generated CODEOWNERS is byte-identical. But whether it should land depends on #125's outcome: if the fix routes
<paths>through the mappers or drops the param,validate_filesmay cease to exist and this work is moot. If the fast path survives behind an explicit flag, this still applies.Measured
Corpus: 130,934 tracked files, 91,206 owned, 17,981-line CODEOWNERS. macOS/aarch64, 11 cpus.
Interleaved A/B, 6 rounds, min-of-N.
validate_allis included as a control: this change should not affect it, and it doesn't — which is what makes the other two rows trustworthy.gv <1000 paths>gv <100 paths>validate_all(control)Interleaving matters here: a first attempt measured each branch in its own block and the machine drifted ~35% mid-run, which produced three convincing but entirely fake wins. Running every variant once per round makes drift shared rather than attributed, and the flat control confirms it worked.
The remaining 3.6s is the project build, which
generategenuinely needs — this change removes the per-file term, not the fixed cost.Correctness
unowned_globs, absolute paths, and paths absent from CODEOWNERS.gvstdout, stderr and exit code all byte-identical, and the generated CODEOWNERS (17,981 lines) is byte-identical.validate <paths>output also unchanged, so this is not a behavior change to that path — just a faster one.validate_filestests and the absolute-path case.Unowned files are still reported using the caller's original path string and in input order, so absolute paths render exactly as before. The query is keyed by project-relative path, so the original string is carried alongside rather than recomputed.
Error handling: no behavior change after all
An earlier revision of this description flagged lost per-path IO error attribution as a decision needed before merge. On tracing it, there is nothing to decide — the error arm is unreachable from this call site:
teams_from_files_pathsreports is a non-UTF-8 path (codeowners_file_parser.rs:24-35), and the runner has already put every path throughto_string_lossy, soto_str()always succeeds.parse_codeowners_entriesreturnsVec, notResult. A missing or unreadable CODEOWNERS is not an error on this path at all: the parser logs to stderr and yields no entries (:95-101), so every path is reported unowned instead.So
io_errorswas already always empty here, before and after this change. TheErrarm is kept for completeness and commented as such.Worth a separate issue: that swallowed-read behavior means a missing CODEOWNERS reports the entire changeset as unowned rather than surfacing the real cause. Pre-existing, untouched here.
Follow-up
The parity gap in
validate <paths>is unfixed and is the more valuable piece of work: resolve ownership through the mappers for the supplied paths rather than reading CODEOWNERS. That would makevalidate <paths>trustworthy, and only then is bypassing the project build for it worth doing.🤖 Generated with Claude Code