symbol_edges has no index on to_id and symbols has none on ref_count, so reverse-edge queries full-scan #143
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
h-dv/code-index#143
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by a production-readiness review using
EXPLAIN QUERY PLANagainst the live index (227,012 refs, 30,760 edges).symbol_edgesisWITHOUT ROWIDwithPRIMARY KEY (from_id, to_id)and no secondary index:WHERE from_id = ?→SEARCH symbol_edges USING PRIMARY KEY✅WHERE to_id = ?→SCAN symbol_edges❌safe_delete'snewly_orphaned(crates/daemon/src/refactor.rs:932) runs a correlatedNOT EXISTSonto_id— one full scan per outgoing edge, O(out_degree × |edges|). At 30k edges this is tolerable; at the >1M edges #41 is meant to exercise, it is the thing that falls over.Separately, there is no index on
symbols.ref_count, and two queriesORDER BY ref_count DESCwithout one.This is the #53 "three missing indexes" shape again — not algorithmic cost, just missing indexes. One
CREATE INDEXeach.Care needed
symbols/symbol_edgescosts time at upgrade. See the migration-time hazard filed alongside this — measure the cost before adding, and record it.tests/corpus/cost-baseline.jsonratchets SQLite work counters. Adding indexes should REDUCEvm_stepon the affected repos. If it does not, the index is not being used and that is the real finding.Related
🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Measured at 1.1M edges — and the recommendation in this issue was wrong
#41's scale work reproduced this and measured it end to end. The defect is real and worse than filed; the fix I proposed is not.
The measurement
Fixture: a genuinely indexed project with 1.1M live edges grafted into the same tables and generation, queried through the real
code-index-daemonbinary over real RPC — not a bareConnection.NOT EXISTS(shipped until now)NOT IN361× / 321×, out-degree 1000, 20 orphan candidates.
EXPLAIN QUERY PLANconfirms the mechanism filed here:SCAN e2underCORRELATED SCALAR SUBQUERY 1, and the outerORDER BY f.pathdenies early exit.Why it hid, which is the part worth keeping
EXISTSshort-circuits on a dense graph — and stops short-circuiting exactly where the answer istrue, i.e. on the rowsnewly_orphanedexists to return. The first fixture built measured 145 ms and looked fine; that was the cheap branch. A benchmark that never produces orphans cannot see this.Alongside it:
GRAPH_EDGE_CAPguardsgraph.rsand never reachesrefactor.rs. On the same index the graph tools declined in microseconds whilesafe_deleteheld a read-pool connection for 147 s with nothing disclosed.Correcting this issue's ask
That was wrong for the
to_idhalf. The shipped fix is a one-line rewrite to an uncorrelatedINlist — no schema change, no migration. Addingsymbol_edges(to_id)would have grown every database in the wild, at upgrade cost on a chain already ~60 long (see #144), for a case the rewrite handles for free. I filed the index because I reasoned from the query plan and stopped there; the lane measured both and the measurement decides.Equivalence is graded row-for-row against the historical form (605 victims, 201 with orphans, 303 rows, 0 differences), and the test reads the shipped text from
refactor::NEWLY_ORPHANED_SQLso it cannot grade a transcription of itself. The mutation restoring the correlated form is RUN and RED at 147.8 s against a 60 s hang ceiling.The
symbols.ref_counthalf of this issue is untouched and still open.A second uncapped reader, now bounded
local_index::repo_maploads the wholesymbol_edgestable with no predicate: 1.9–2.2 s for 20 rows at 1.1M edges. Bounded rather than a defect, and now gated.Follow-up worth doing (not done)
bounding_site_registry'sGRAPH_EDGE_CAPentry says the graph tools "decline to answer … refusing to answer is the disclosure." True ofexplain_dependency/change_impact, and silently untrue ofsafe_deleteandrepo_map, which read the same table under no cap at all. Both are now measured, but the registry sentence still reads wider than the guarantee. Scoping it is cheap.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Triage 2026-09-06 at
f6a878a: CLOSING. Both halves are resolved — one by a query rewrite that beats the index, one by a refusal with numbers recorded in the tree.Verified against master, not against a lane report.
Half 1 —
symbol_edges(to_id): the index was refused, and the underlying defect was fixed bettera21b224says it outright: "FIXED BY A QUERY REWRITE, NOT AN INDEX. I had filed the index (#143) and was wrong" — an index would have grown every database in the wild, at upgrade cost on a migration chain already ~60 long, for a case one uncorrelatedINhandles for free.The reasoning now lives at the code, not in this issue:
crates/daemon/src/refactor.rs:66, the doc onNEWLY_ORPHANED_SQL. It states the exact fact this issue was filed on — "symbol_edgesisWITHOUT ROWIDwithPRIMARY KEY (from_id, to_id)and carries NO index onto_id(m0013), soe2.to_id = ?is a full SCAN" — and then measures both forms at 1,100,001 edges, out-degree 1000:NOT EXISTS361× and 321×.
O(out_degree × |symbol_edges|)→O(|symbol_edges|).It is graded, and graded against the historical form rather than against a transcription of the new one — the test reads the shipped text out of
refactor::NEWLY_ORPHANED_SQL:Not a vacuous pass: the census line proves 201 of the 605 compared victims actually had orphan candidates, which is the branch the correlated form was fast on and wrong about.
Half 2 —
symbols(ref_count): REFUSED ON MEASUREMENT, and the measurement is in the treecrates/daemon/src/local_index.rs:4462, ontop_referenced_symbols, opens with "#143:ref_countHAS NO INDEX, AND THAT IS A MEASURED DECISION, NOT AN OVERSIGHT. It was filed as one, so here is what an index onsymbols(ref_count DESC, id)actually costs and buys" — measured on this repository's own 155 MB index (14,851 symbols / 231,368 refs, isolated runs, no ANALYZE):SCAN + TEMP B-TREE→SEARCH. 106,700 → 111 vm_step, 14,851 → 0 fullscan_step, 1 → 0 sorter runs, 6.27 ms → 0.021 ms.recompute_ref_counts: 2,359,234 → 2,521,503 vm_step (+6.9%), 196 → 227 ms min over 15 interleaved fresh-copy runs (+15%).The read runs once per
project_overviewand is ~2.4% of that tool. The write runs on every resolve pass — the scoped path, i.e. every incremental re-index the watcher fires on a file save — and rebuilds the whole column regardless of scope. Net negative on exactly the axiscost-baseline.json's #73 bless names as its own disqualifier: "what would make this trade wrong is a hot repeated write path."The methodology note is why I believe the direction: the first version of that measurement was wrong and says so. Re-running both variants against the same two files reported the indexed write 36% faster — WAL accumulation across iterations. Fresh copy per run, alternating, inverted it, and the corrected direction agrees with the opcode count while the contaminated one did not.
The refusal is cross-referenced so it cannot be filed a third time:
crates/indexer/src/migrations/m0061_file_refs_rollup.rs:68contrasts its own per-row-inserted cost against this one explicitly, andtests/corpus/cost-baseline.json:52,66pluscrates/indexer/tests/corpus_cost.rs:78,1195carry it into the ratchet.Verdict
A refusal with numbers is a resolution. Neither index will be added; the read cost it would have bought is real but is paid once per session against a write cost paid on every save, and the reverse-edge full-scan this issue was actually about is gone by a cheaper route. Nothing is left to do here.
Residual, stated
None that belongs to this issue. The one adjacent thing worth knowing:
local_index.rs:4462ends by noting the corpus ratchet cannot see this trade either way —corpus_costmeasures a cold index and issues no daemon read — which is filed as #158 and stays open there, not here.🤖 Triage lane, 2026-09-06, master
f6a878a