Skip to content

perf(ingestion): one repo walk per update, not one per worker - #1926

Open
azhard wants to merge 5 commits into
repowise-dev:mainfrom
azhard:fix/ingestion-redundant-repo-walks
Open

perf(ingestion): one repo walk per update, not one per worker#1926
azhard wants to merge 5 commits into
repowise-dev:mainfrom
azhard:fix/ingestion-redundant-repo-walks

Conversation

@azhard

@azhard azhard commented Aug 25, 2026

Copy link
Copy Markdown

Summary

  • _build_file_info ran one full repo walk per worker. build_repo_graph maps it over every path with ~2x cpu_count workers, and it reaches FileTraverser._console_script_tables(), whose lazy init is an unsynchronised if self._console_scripts is None. Every worker arriving before the first one assigns starts its own _collect_console_scripts — each a full iter_glob walk. Measured on a 17k-file repo with 28 workers: 263.67s vs 8.06s when the value is computed once, for identical FileInfo output. Fixed with double-checked locking, plus the same treatment for the _get_dir_ignore cache on the same hot path (functools.cached_property is not a substitute — 3.12 dropped its internal lock).
  • The package scan answered from files that are not in the index. _primary_language_in and _find_entry_points_in walked each package separately, and neither applied the traverser's own ignore semantics. _BLOCKED_DIRS already contains dist/build/target/vendor, so those directories are never indexed — committed or not — yet they could decide a package's language. A committed dist/bundle.js that is not gitignored and not indexed made a TypeScript package report javascript. Merged into one _scan_package_dir taking the is_pruned predicate scan_package_roots already uses: 30.89s → 1.12s, and the answers get corrected (on the repo I measured, a bench crate stopped reporting dlang from its target/criterion HTML, and package entry points went 1,452 → 11, all 1,441 dropped paths untracked).
  • Eight resolver scans each walked the whole repo during GraphBuilder.build(), costing 339,025 realpath calls and 4.1M lstats. Four now route through the existing glob_via seam and share ResolverContext.walk_snapshot. Net: ingestion pipeline 322.73s → 26.36s, repowise update --since HEAD~1 8m41s → 3m27s, with the graph unchanged (77,493 nodes / 215,904 edges, edge sets equal element by element).

One deliberate behaviour change: discover_cmake_reactor's orphan sweep never took a prune_nested_git argument, so it hardcoded True and ignored the repo's setting. Served from the snapshot it now inherits ctx.prune_nested_git, so a repo indexed with --include-nested-repos surfaces CMakeLists.txt files under nested checkouts that were previously skipped. This brings cmake in line with what ruby.py and swift_spm.py already did, and it has a test either way. Flagging it because it is the one thing here that changes output rather than only cost.

One case gets less informative, deliberately: a package whose sources are all ignored now reports unknown rather than a language read off files nothing indexes.

Related Issues

Refs #327 — the "post-commit hook extremely slow" thread, where the diagnosis was "the incremental update path still re-traverses and reparses the repo so even a small commit can make the background update take a while." This is that, measured and fixed. No open issue tracks it.

Test Plan

  • uv run pytest tests/unit/ingestion tests/unit/pipeline — 2711 passed, 5 skipped. One failure, test_repo_totals_churn.py::test_a_rebased_branch_in_the_history_falls_back, reproduces on a pristine checkout of the same commit (its git rebase -q master fails where init.defaultBranch is main) and is unrelated to these files.
  • uv run pytest tests/unit/ingestion/test_traverser.py tests/unit/ingestion/external_systems/test_cmake.py — 114 passed
  • uv run ruff check — clean on all changed files
  • uv run repowise risk main..HEAD"about as risky as a typical commit in this repo", 47th percentile
  • Web build passes (npm run build) (no frontend changes)

Nine tests added, and I checked which of them actually fail without the fix rather than assuming:

test without the fix
test_console_script_tables_is_collected_once fails_collect_console_scripts runs once per worker
test_dir_ignore_cache_publishes_one_spec_per_directory fails — two threads get different spec objects for one directory
test_pre_seeded_root_entry_survives_concurrent_readers passes either way — pins the root entry setdefault keeps
test_gitignored_output_no_longer_decides_the_language fails
test_entry_points_come_only_from_indexed_directories fails
test_a_committed_build_dir_is_excluded_too fails
test_a_package_with_nothing_indexed_reports_unknown pins the shape that gets less informative
test_orphan_glob_reads_the_snapshot_when_given_one covers snapshot/live equivalence
test_orphan_glob_honours_the_snapshot_nested_repo_setting covers the behaviour change above

Checklist

  • My code follows the project's code style
  • I have added tests for new functionality
  • All existing tests still pass
  • I have updated documentation if needed (no user-facing docs affected; rationale is in the docstrings)

Rejected alternatives

  • Pre-computing the console-script tables before the pool. Fixes the measured case only; the next caller off the hot path pays it again, and the invariant that makes it safe is invisible at the call site.
  • Pruning build/dist/out in PRUNED_DIRS. fs_walk.py says explicitly not to use the derived list for manifest discovery, "where a module legitimately rooted at build/ or coverage/ must still be found". The traverser's own ignore test is the right boundary and already exists.
  • A WalkSnapshot.walk() method so ts_workspace._get_repo_scan could share too — that needs an API addition rather than an existing seam, so it is left out.

Not fixed here

Four repo walks remain per build. read_go_modules runs before the ResolverContext exists, so sharing the snapshot means threading it down from GraphBuilder; ts_workspace._get_repo_scan uses walk_repo directly and needs the method above. Happy to follow up on either if you want them in scope.

Azhar Dewji added 3 commits August 25, 2026 18:55
`build_repo_graph` maps `_build_file_info` over every path with ~2x
cpu_count workers, and that method reaches an unsynchronised lazy init
in `_console_script_tables`. Every worker arriving before the first one
assigns starts its own `_collect_console_scripts`, each a full
`iter_glob` walk of the repo. Measured 263.67s against 8.06s when the
value is computed once, for identical FileInfos.

Double-checked locking there, and the same treatment for the
`_get_dir_ignore` cache on the same hot path.

`_primary_language_in` and `_find_entry_points_in` walked each package
separately and neither applied the traverser's ignore semantics, so both
descended into gitignored build output — where language detection opens
every file it cannot classify by extension. They become one
`_scan_package_dir` taking the `is_pruned` predicate `scan_package_roots`
already uses. That also stops `target/criterion` reports outvoting Rust
sources for a bench crate's language.

Four resolver scans route through the existing `glob_via` seam so they
share `ResolverContext.walk_snapshot` instead of walking the repo again.

Graph output is unchanged: same nodes, same edges, edge sets equal.
…shot

The concurrency tests fail without the locks: dropping the double-checked
lock makes `_collect_console_scripts` run once per worker, and the plain
`cache[key] = ...` / `return cache[key]` pair lets two threads hand back
different specs for one directory.

`test_pre_seeded_root_entry_survives_concurrent_readers` passes either
way. It is not a regression test — it pins the root entry `__init__`
seeds, which `setdefault` keeps and a bare assignment would have clobbered.

The cmake pair covers the orphan sweep: one that a snapshot answers with
the same files as a live walk, one that a snapshot built with
`prune_nested_git=False` surfaces a nested checkout the default hides.
That second case is the behaviour change in this branch, so it gets a
test rather than a mention.
The scan's own claim is that a package is described by the sources
traversal indexes, and nothing exercised it. Four cases: a gitignored
output dir no longer outvoting the real source, entry points coming only
from indexed directories, a *committed* `dist/` excluded too because
`_BLOCKED_DIRS` means the traverser never indexed it either, and the one
shape that gets less informative — a package whose sources are all
ignored now reports `unknown`.

Each asserts against `is_pruned=None`, which is what the two old helpers
did, so they fail if the pruning is dropped.
@repowise-bot

repowise-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown

✅ Health of changed files: 4.9 (unchanged)
⚠️ Change risk: moderate, riskier than 55% of this repo's commits.

📋 At a glance
5 hotspots touched · 1 new finding introduced · 4 files with recent fix history.

Files & modules (2)
  • packages (4 files)
    • .../resolvers/ruby.py
    • .../ingestion/traverser.py
    • .../resolvers/swift_spm.py
    • .../external_systems/cmake.py
  • tests (1 file)
    • .../ingestion/test_traverser.py

✅ Health gate: passed

📌 Before you merge

  • Run .../ingestion/test_cpp_include_fragments.py, .../ingestion/test_cpp_workspace.py, .../ingestion/test_ruby_resolver.py, .../ingestion/test_swift_resolver.py (+3 more): they import the changed files

🎯 Blast radius (symbols whose signature this PR changed, and who calls them)

  • build_swift_targets in .../resolvers/swift_spm.py signature changed. Called by 1 symbol outside this PR: .../ingestion/test_swift_resolver.py::TestBuildSwiftTargets::test_merges_multiple_packages
🔎 More signals (2)

🗺️ Change map

flowchart LR
  subgraph PR ["Changed in this PR (6 with dependents)"]
    f_packages_core_src_repowise_core_ingestion_external_systems_cmake_py[".../external_systems/cmake.py 🔥"]:::changed
    f_packages_core_src_repowise_core_ingestion_resolvers_ruby_py[".../resolvers/ruby.py 🔥"]:::changed
    f_packages_core_src_repowise_core_ingestion_resolvers_swift_spm_py[".../resolvers/swift_spm.py 🔥"]:::changed
    f_packages_core_src_repowise_core_ingestion_traverser_py[".../ingestion/traverser.py 🔥"]:::changed
    f_packages_core_src_repowise_core_ingestion_resolvers_cpp_workspace_py[".../resolvers/cpp_workspace.py"]:::changed
    f_tests_unit_ingestion_test_traverser_py[".../ingestion/test_traverser.py"]:::changed
  end
  f_packages_core_src_repowise_core_ingestion_external_systems___init___py[".../external_systems/__init__.py"]
  f_packages_core_src_repowise_core_ingestion_external_systems_cmake_py --> f_packages_core_src_repowise_core_ingestion_external_systems___init___py
  f_packages_core_src_repowise_core_ingestion_resolvers___init___py[".../resolvers/__init__.py"]
  f_packages_core_src_repowise_core_ingestion_resolvers_ruby_py --> f_packages_core_src_repowise_core_ingestion_resolvers___init___py
  f_packages_core_src_repowise_core_ingestion_graph__resolvers_py[".../graph/_resolvers.py"]
  f_packages_core_src_repowise_core_ingestion_resolvers_swift_spm_py --> f_packages_core_src_repowise_core_ingestion_graph__resolvers_py
  f_packages_core_src_repowise_core_ingestion_resolvers_swift_py[".../resolvers/swift.py"]
  f_packages_core_src_repowise_core_ingestion_resolvers_swift_spm_py --> f_packages_core_src_repowise_core_ingestion_resolvers_swift_py
  f_packages_cli_src_repowise_cli_commands_dead_code_cmd_py[".../commands/dead_code_cmd.py"]
  f_packages_core_src_repowise_core_ingestion_traverser_py --> f_packages_cli_src_repowise_cli_commands_dead_code_cmd_py
  f_packages_cli_src_repowise_cli_commands_update_cmd_persistence_py[".../update_cmd/persistence.py"]
  f_packages_core_src_repowise_core_ingestion_traverser_py --> f_packages_cli_src_repowise_cli_commands_update_cmd_persistence_py
  f_packages_core_src_repowise_core_analysis_dead_code_analyzer_py[".../dead_code/analyzer.py"]
  f_packages_core_src_repowise_core_ingestion_traverser_py --> f_packages_core_src_repowise_core_analysis_dead_code_analyzer_py
  f_packages_core_src_repowise_core_analysis_decisions_extractor_py[".../decisions/extractor.py"]
  f_packages_core_src_repowise_core_ingestion_traverser_py --> f_packages_core_src_repowise_core_analysis_decisions_extractor_py
  f_packages_core_src_repowise_core_ingestion_call_resolver_py[".../ingestion/call_resolver.py"]
  f_packages_core_src_repowise_core_ingestion_resolvers_cpp_workspace_py --> f_packages_core_src_repowise_core_ingestion_call_resolver_py
  f_packages_core_src_repowise_core_ingestion_graph_warmups_py[".../ingestion/graph_warmups.py"]
  f_packages_core_src_repowise_core_ingestion_resolvers_cpp_workspace_py --> f_packages_core_src_repowise_core_ingestion_graph_warmups_py
  f_tests_unit_ingestion_test_traverser_py --> f_packages_cli_src_repowise_cli_commands_dead_code_cmd_py
  more(["+11 more dependents"])
  PR --> more
  t_tests_unit_ingestion_test_cpp_include_fragments_py(["✅ .../ingestion/test_cpp_include_fragments.py"]):::guard
  t_tests_unit_ingestion_test_cpp_include_fragments_py -.-> f_packages_core_src_repowise_core_ingestion_external_systems_cmake_py
  t_tests_unit_ingestion_test_ruby_resolver_py(["✅ .../ingestion/test_ruby_resolver.py"]):::guard
  t_tests_unit_ingestion_test_ruby_resolver_py -.-> f_packages_core_src_repowise_core_ingestion_resolvers_ruby_py
  t_tests_unit_ingestion_test_swift_resolver_py(["✅ .../ingestion/test_swift_resolver.py"]):::guard
  t_tests_unit_ingestion_test_swift_resolver_py -.-> f_packages_core_src_repowise_core_ingestion_resolvers_swift_spm_py
  classDef changed fill:#dbeafe,stroke:#1d4ed8,color:#1e3a5f
  classDef warn fill:#fef3c7,stroke:#b45309,color:#78350f
  classDef guard fill:#dcfce7,stroke:#15803d,color:#14532d
Loading

Solid arrows: code that imports the changed files (21 direct dependents, from the last indexed snapshot). Dashed: history/tests.

🔥 Hotspots touched (5)

  • .../resolvers/ruby.py: 1 commits/90d, 2 dependents · primary owner: Swati Ahuja (72%)
  • .../ingestion/test_traverser.py: 14 commits/90d, 3 dependents · primary owner: Raghav Chamadiya (78%)
  • .../ingestion/traverser.py: 22 commits/90d, 28 dependents · primary owner: Raghav Chamadiya (88%)
2 more
  • .../resolvers/swift_spm.py: 3 commits/90d, 3 dependents · primary owner: Raghav Chamadiya (99%)
  • .../external_systems/cmake.py: 3 commits/90d, 4 dependents · primary owner: Raghav Chamadiya (100%)

👀 Suggested reviewers @RaghavChamadiya


📊 See the full report for this PR
Your repo map with this PR's blast radius lit up, every caller of the contracts it changes, and health before and after. No sign-in. · ⭐ Star Repowise · 📥 Install bot · Silence on a single PR with [skip repowise] in the title · Per-repo toggle on repowise.dev/settings?tab=bot · Updated 2026-08-26 22:51 UTC

Azhar Dewji added 2 commits August 25, 2026 19:47
…ng it

The bot flagged two new findings on `_scan_package_dir`, and a reviewer had
separately called the `is_pruned=None` default dead flexibility — the one
caller always passes `dir_chain_skipped`. Both are the same thing: an
optional predicate buys a branch nothing takes, and a scan that skips it
answers from files nothing indexes, which is the bug this function exists
to fix.

Required now, so the branch goes with it. `complex_method` clears;
`nested_complexity` stays, and extracting the inner loop to chase it would
trade one shape of complexity for a mutation-heavy helper.

The tests that used `None` to mean "what the two old helpers did" say it
outright with a `_never_pruned` predicate.
`test_a_committed_build_dir_is_excluded_too` gave the package one `.ts`
and one `.js`, so `counts` was a 1-1 tie and `max(counts, key=...)`
returned whichever language came first. `walk_repo` does not sort
dirnames, so that is filesystem order: `dist` first here, `src` first on
CI, and the assertion flipped between them.

Five bundles instead of one, so the unindexed directory outnumbers the
real source and the pre-fix answer is `javascript` either way. Checked by
forcing both orders: the old shape yields javascript/typescript, the new
one yields javascript twice.

The sibling gitignored-output test already used five, which is why it
passed on both.
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