perf(graph): batch edge/node writes and bare-endpoint resolution - #858
skyhancloud wants to merge 2 commits into
Conversation
Fixes the build/postprocess hang on large graphs (issue tirth8205#721). Root cause: GraphStore opens the SQLite connection in autocommit mode (isolation_level=None), but several write paths issued one UPDATE (or one SELECT+INSERT round trip) per row without opening an explicit transaction. On graphs with 10^5+ nodes and 10^6+ edges that means one WAL commit + fsync per row and ~3 SQL round trips per edge, which can take tens of minutes or appear permanently hung (142k nodes / 1.14M edges in the report). Changes: - store_file_nodes_edges / store_file_batch now delete the file's rows and re-insert everything with executemany in the existing single transaction. Duplicate call sites (same kind/source/target/file/line) are collapsed in Python with the previous last-write-wins semantics, so one plain INSERT batch is safe. Nodes keep their ON CONFLICT(qualified_name) upsert. - _resolve_bare_endpoints collects every mutation and applies it with a chunked executemany inside one BEGIN IMMEDIATE transaction instead of one autocommitted UPDATE per edge; the candidate read now streams instead of materialising the full edge set, and an EXISTS guard short-circuits the no-op case without a full fetch. - resolve_cpp_scoped_call_targets gets the same single-transaction batching for its call-target and TESTED_BY-mirror updates. - Signature computation (postprocessing and MCP build paths) batches via a new GraphStore.update_node_signatures() instead of per-row updates. - New regression test for duplicate call-site collapse in the batched store. No schema change; result counts and post-state are identical to the previous per-row paths (verified old-vs-new on identical synthetic DBs).
|
The batching itself looks right. I checked atomicity under mid-batch failure, duplicate call-site collapse, the ON CONFLICT path against stale qualified-name rows, chunk boundaries with _UPDATE_BATCH forced small, unicode paths, and idempotent re-runs of the resolvers; everything matches the old per-row semantics and rolls back cleanly. One thing blocks merge: the suite is red. tests/test_transactions.py line 84, test_rollback_on_failure_in_batch_ops, patches store.upsert_node to simulate a failure inside store_file_nodes_edges, but _replace_file_data no longer calls upsert_node, so the patch never fires and pytest fails with DID NOT RAISE. Please update that test to inject the failure into the new path, for example patch _replace_file_data or pass a node whose extra contains a non-JSON-serializable value, and keep the rollback assertions. Minor: sync_tested_by in resolve_cpp_scoped_call_targets still returns changed_mirror but nothing reads it now; drop the dead bookkeeping. Ruff and mypy are clean, and #835 and #861 both merge cleanly on top of this. |
Thanks. Fixed both points:
Local run: |
|
Integrated on |
|
Merged into |
…are-endpoint resolution Port the reviewed token-efficiency implementation with targeted regression coverage. Source-PR: tirth8205#858 Maintainer corrections and scope extraction applied where needed.
Fixes #721
Root cause
GraphStoreopens its SQLite connection in autocommit mode(
isolation_level=None), but three write paths issued one SQL statement perrow without opening an explicit transaction:
store_file_nodes_edges/store_file_batchcalledupsert_node/upsert_edgeonce per symbol — a SELECT + INSERT (or SELECT + UPDATE +last_insert_rowid) round trip per row. At 142k nodes / 1.14M edges thatis ~3.4M statements, each maintaining 9 indexes on a multi-GB database.
_resolve_bare_endpointsranconn.execute(update_sql, ...)per bare edgeand only called
commit()afterwards. Because the connection is inautocommit mode, every UPDATE committed on its own — one WAL commit +
fsync per row. With hundreds of thousands of bare CALLS / TESTED_BY
endpoints this is effectively a hang: the exact "freezes at
conn.execute(update_sql, ...)" report in [Bug]: Hang at "INFO: Spring event resolver: indexed 0 events and emitted 0 CALLS edges" #721.Signature computation looped
update_node_signatureper node — oneautocommitted UPDATE per node.
resolve_cpp_scoped_call_targetshad the same per-row autocommit pattern forits call-target and TESTED_BY-mirror updates.
Changes
store_file_nodes_edges/store_file_batchnow delete the file's rows andre-insert everything with
executemanyinside the existing singletransaction. Duplicate call sites (same kind/source/target/file/line) are
collapsed in Python with the previous last-write-wins semantics, so a plain
INSERT batch is safe; nodes keep their
ON CONFLICT(qualified_name)upsert._resolve_bare_endpointscollects every mutation and applies it with achunked
executemanyinside oneBEGIN IMMEDIATEtransaction instead ofone autocommitted UPDATE per edge. The candidate scan now streams instead of
materialising the full edge set, and an
EXISTSguard short-circuits theno-op case without a full fetch.
resolve_cpp_scoped_call_targetsgets the same single-transaction batchingfor its call-target and TESTED_BY-mirror updates.
new
GraphStore.update_node_signatures().test_store_file_nodes_edges_collapses_duplicate_call_sites.No schema change. Resolution counts and final DB state are identical to the
previous per-row paths (verified old-vs-new on identical synthetic databases).
Verification
idempotent re-runs, transaction guards for sqlite3.OperationalError: cannot start a transaction within a transaction on update when git diff includes deleted files #135/BEGIN IMMEDIATE in store_file_nodes_edges raises OperationalError when conn already in an implicit transaction #489).
identical synthetic graphs.
stage now completes:
Notes
matching edge), but the write side is now one transaction per stage, not one
commit per row.
_UPDATE_BATCH = 50_000chunksexecutemanyto bound peak memory whilekeeping a single commit/checkpoint.