Skip to content

perf(global-graph): batch repository updates - #3444

Open
ROHIT8759 wants to merge 7 commits into
Graphify-Labs:v8from
ROHIT8759:perf/global-add-many
Open

perf(global-graph): batch repository updates#3444
ROHIT8759 wants to merge 7 commits into
Graphify-Labs:v8from
ROHIT8759:perf/global-add-many

Conversation

@ROHIT8759

Copy link
Copy Markdown
Fixes #3438.

This PR batches global graph repository updates so the global graph is loaded, cross-repo resolved, and saved exactly once per batch, eliminating redundant disk I/O and parsing overhead.

### Key Changes

* Batch API: Added `global_add_many()` as the primary batch API. `global_add()` is preserved for backward compatibility and delegates internally.
* Unified Initialization: Eliminates the repeated whole-graph scans from the normal per-repository merge path; the external label and repository subset indexes are initialized exactly once per batch.
* Atomic Rollback: Reorganized the per-repository merge into distinct PREPARE / MUTATE / APPLY phases. Expected source exceptions are cleanly caught before mutation. If an unexpected exception occurs during the live apply phase, the graph rolls back the partial merge using isolated snapshots.
* CLI Support: Added support for multiple graph paths and properly rejects `--as` when mapping multiple graphs. Integrated `--keep-going` mapping to the new `on_error="skip"` policy.

### Correctness

Added regression coverage ensuring:

* Sequential vs. batched graph equivalence.
* Safe empty-tag validation and skip handling for unchanged hashes.
* Transactional rollback verifying node structure and manifest preservation upon failure.
* Accurate cross-repository member call resolution using a synthetic Java AST.
* The normal batch pipeline avoids per-repository whole-graph scans by initializing indexes once per batch.

Copilot AI lite review requested due to automatic review settings September 9, 2026 19:03

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Replaces global_add with global_add_many, which processes a batch of (path, tag) sources in one manifest load — deduplicating externals across the whole batch, computing a batch-total cross_repo_calls, and rejecting duplicate tags or missing source files up front. Adds an on_error policy ("abort" vs "skip") so a failing source can either halt the batch or be recorded as failed while the rest proceed, with per-repo changes staged and rolled back on error rather than partially applied. Extends graphify global add to accept multiple graph paths plus a --keep-going flag, still inferring each repo tag from the path (or requiring --as for a single graph), and prints per-source added/skipped/failed lines.

Worth a look

  • Load/size-check errors occur after prune, so error rollback path (except) does not restore pruned nodes on skipgraphify/global_graph.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • on_error='skip' failure leaves repo's stale nodes deleted but manifest unchanged, corrupting graph vs manifest consistencygraphify/global_graph.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Rollback removes nodes by data['repo'] but prefixed nodes may not carry repo attrgraphify/global_graph.py:217 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • on_error='skip' persists partial mutations for failed repo without full rollbackgraphify/global_graph.py:240 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • --as can consume --keep-going as the repo taggraphify/cli.py:3122 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 333 functions depend on the 124 functions this change touches.

Health — this change adds coupling hotspots:

  • new: dispatch_command() — 2 callers, 125 callees
  • new: global_add_many() — 18 callers, 8 callees
  • new: _stale_graph_sources() — 7 callers, 6 callees
  • new: _run_hook_guard() — 4 callers, 8 callees
  • new: global_remove() — 5 callers, 5 callees
  • new: test_poisoned_manifest_is_healed() — 0 callers, 6 callees
  • new: test_global_add_many_replacement_failure_preserves_original() — 0 callers, 6 callees

Verification — 333 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 279 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

25 of 263 test file(s) selected (10%) via static blast radius.

  • tests/test_affected_cli.py — impact
  • tests/test_agents_platform.py — impact
  • tests/test_cli_global_add_many.py — changed-test
  • tests/test_codebuddy.py — impact
  • tests/test_devin.py — impact
  • tests/test_explain_cli.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_global_graph.py — impact, changed-test
  • tests/test_god_nodes_cli.py — impact
  • tests/test_hollow_chunks_arm_shrink_guard.py — impact
  • tests/test_hook_guard_token_match.py — impact
  • tests/test_hook_out_of_project_paths.py — impact
  • tests/test_hook_strict.py — impact
  • tests/test_incomplete_build_guard.py — impact
  • tests/test_install.py — impact
  • tests/test_install_references.py — impact
  • tests/test_merge_chunks_validation.py — impact
  • tests/test_multigraph_diagnostics.py — impact
  • tests/test_no_dedup_flag.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_path_cli.py — impact
  • tests/test_query_cli.py — impact
  • tests/test_query_induced_edges.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_unverified_semantic_shrink.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify dispatch\_command.

The verifier did not have enough to check dispatch\_command, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: not verifiable: all 23 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly SystemExit — names the real obstacle, not a sampling gap)

Could not verify: Could not verify global\_add.

The verifier did not have enough to check global\_add, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: parameter `source_path` is annotated `Path` — outside the synthesizable primitive/collection set

· 3 grounded finding(s) anchored inline below; 4 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/global_graph.py

def global_add(source_path: Path, repo_tag: str) -> dict:
"""Add or update a project graph in the global graph.
def global_add_many(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionglobal_add_many()

fans out to 8 callees (efferent coupling); 18 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/global_graph.py
return global_add_many([(source_path, repo_tag)])[0]


def global_remove(repo_tag: str) -> int:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionglobal_remove()

high coupling complexity (Ca·Ce = 25).

Grounded coupling-delta finding (deterministic), not an LLM guess.

# It does not scale with the number of repositories.
assert scan_count == 2

def test_global_add_many_replacement_failure_preserves_original(tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiontest_global_add_many_replacement_failure_preserves_original()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The batch-merge rollback and persistence logic has edge cases that can leave partial mutations or write to disk even when nothing was applied, and the new CLI subprocess tests currently aren’t isolated from the user’s real ~/.graphify state.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces a batched global-graph update pathway so multiple repository graphs can be merged into the global graph with a single load → resolve → save cycle, addressing the repeated O(K²)-style work described in #3438.

Changes:

  • Added global_add_many() as the primary batch API and refactored global_add() to delegate to it.
  • Reworked the merge flow to initialize external-label and repo-node indexes once per batch, and to support rollback on apply-time failures.
  • Updated the CLI to accept multiple graph paths and --keep-going, and added regression tests for batch equivalence and CLI behavior.
File summaries
File Description
graphify/global_graph.py Adds global_add_many() batching + rollback logic and delegates global_add() to it.
graphify/cli.py Extends graphify global add to accept multiple graphs and --keep-going mapped to on_error="skip".
tests/test_global_graph.py Adds extensive regression coverage for batching, skip/abort, replacement, rollback, and cross-repo call resolution.
tests/test_cli_global_add_many.py Adds subprocess-based CLI tests for single/multi add, --as validation, and inferred-tag errors.
Review details

Suppressed comments (5)

tests/test_cli_global_add_many.py:22

  • This subprocess-based CLI test currently writes to the real user HOME (~/.graphify) because graphify.global_graph uses Path.home(); set HOME (and PYTHONPATH) in the subprocess env to isolate test state under tmp_path.
    res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", str(g), "--as", "myrepo"], capture_output=True, text=True)

tests/test_cli_global_add_many.py:37

  • This subprocess-based CLI test currently writes to the real user HOME (~/.graphify) because graphify.global_graph uses Path.home(); set HOME (and PYTHONPATH) in the subprocess env to isolate test state under tmp_path.
    res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", str(g1), str(g2)], capture_output=True, text=True)

tests/test_cli_global_add_many.py:53

  • This subprocess-based CLI test currently writes to the real user HOME (~/.graphify) because graphify.global_graph uses Path.home(); set HOME (and PYTHONPATH) in the subprocess env to isolate test state under tmp_path.
    res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", str(g1), str(g2), "--keep-going"], capture_output=True, text=True)

tests/test_cli_global_add_many.py:64

  • This subprocess-based CLI test should set HOME (and PYTHONPATH) in the subprocess env to avoid depending on or mutating ~/.graphify on the machine running the test suite.
    res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", str(g1), str(g2), "--as", "myrepo"], capture_output=True, text=True)

tests/test_cli_global_add_many.py:77

  • This subprocess invocation sets PYTHONPATH but not HOME; setting HOME to tmp_path isolates graphify's global graph location (Path.home()/.graphify) so the test doesn't depend on or mutate the user's real ~/.graphify.
    env = os.environ.copy()
    env["PYTHONPATH"] = str(Path.cwd())
    res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", "graph.json"], cwd=tmp_path, env=env, capture_output=True, text=True)
  • Files reviewed: 4/4 changed files
  • Comments generated: 5
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread graphify/global_graph.py
Comment on lines +189 to +212
nodes_to_add = [(n, d) for n, d in prefixed.nodes(data=True) if n not in remap]
edges_to_add = []
for u, v, data in prefixed.edges(data=True):
u = remap.get(u, u)
v = remap.get(v, v)
if u != v:
edges_to_add.append((u, v, data))

# --- APPLY ---
for node, data in nodes_to_add:
G.add_node(node, **data)
if not data.get("source_file") and data.get("label"):
external_labels[data["label"]] = node

for u, v, data in edges_to_add:
G.add_edge(u, v, **data)

except Exception as apply_error:
G.remove_nodes_from([n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag])
G.add_nodes_from(to_remove_nodes)
G.add_edges_from(to_remove_edges)
external_labels.clear()
external_labels.update(old_external_labels)
raise apply_error
Comment thread graphify/global_graph.py
Comment on lines 237 to 240
cross_repo_calls = link_cross_repo_member_calls(G)
_save_global_graph(G)

manifest["repos"][repo_tag] = {
"added_at": datetime.now(timezone.utc).isoformat(),
"source_path": str(source_path.resolve()),
"node_count": added,
"edge_count": prefixed.number_of_edges(),
"source_hash": src_hash,
}
_save_global_graph(G)
_save_manifest(manifest)
Comment on lines +12 to +13

res = subprocess.run([sys.executable, "-m", "graphify", "global", "add", str(g)], capture_output=True, text=True)
Comment on lines +658 to +660
# The node for X in repoB should have repo label repoB, because the old one was from repoA and was deleted.
# Wait, external nodes in prefixed graph don't necessarily get repo tag prefix. Actually they do get repo in prefix_graph_for_global.
# The node from repoA should be deleted.
Comment on lines +795 to +797
# Should exactly be 2 scans (1 for our upfront index, 1 for cross_repo_calls pass)!
# It does not scale with the number of repositories.
assert scan_count == 2
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.

2 participants