perf: project_overview needs generation-scoped O(1) aggregates, not repeated refs scans #73
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.
Blocks
Depends on
Reference
h-dv/code-index#73
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?
Split out of #67. That issue fixed the livelock by moving the health path off
stats(); this is the remaining cost, which is real but not fatal.stats()scansrefsthree times:SELECT COUNT(*) FROM refs WHERE reclassified = 0lang_resolution(JOINrefs × files, GROUP BY lang)kind_resolution(GROUP BY kind, qualifier IS NOT NULL)EXPLAIN QUERY PLANshowsSCAN refsfor all three. Only the first has a fix as simple as an index; the other two are inherently aggregate scans over the whole table.project_overviewis documented as the FIRST call an agent makes on an unfamiliar repo. Three seconds before the first useful answer, on every call, is a bad first impression on exactly the repos where the tool matters most.Options
stats(). Cost: writer coupling, and a staleness question (reportas_ofalongside).project_overviewreturns the cheap facts immediately and the resolution breakdown behind a flag or a second call. Cheapest to build; moves the cost rather than removing it, and the breakdown is genuinely useful — it is how an agent learns wherefind_callersis trustworthy.resolution_by_kindis what tells an agent a number is soft), and a sampled denominator is exactly the kind of quietly-wrong figure the project has spent several missions removing.Constraint
Whatever is chosen must not reintroduce #67. The invariant now has a guard (
crates/daemon/tests/health_probe_e2e.rsasserts noSCANof a large table on the health path, reading the SQL fromLocalIndex::health's own source) — but that guard covers the HEALTH path only. Ifstats()is ever wired back into a liveness check, the guard will not notice.Suggest extending the shape test to cover any query reachable from a bounded probe, not just
health().Runtime-plugin architecture requirement
#77/#78 add active-generation, package, capability and dynamic-influence dimensions. They must not be implemented as more full refs scans in project_overview.
Choose an O(1)-read aggregate design before adding those fields:
Incremental maintenance is optional; correctness and atomic generation consistency are mandatory. Cache invalidation must be driven by resolver/generation commit, not TTL.
Extend the acceptance test to a 2M-ref index with two plugin generations. project_overview must remain bounded without scanning refs, and promotion must switch all breakdowns together. The health path remains independently protected from any stats call.
perf: project_overview full-scans refs three times — ~2.9s on a 2M-ref indexto perf: project_overview needs generation-scoped O(1) aggregates, not repeated refs scansHalf done — m0060 fixed the census,
file_healthwas left in placem0060's
WITHOUT ROWIDrefs rollup took the census half ofproject_overviewfrom 237.4 ms → 29.8 ms. That part is done.But a production review measured the tool again on this repo's live index (227,012 refs, 30,760 edges) and the other half is unchanged:
That is the
file_healthcore (crates/daemon/src/graph.rs:1686), simplified. The real query addspools_cte— a fullsymbolsGROUP BY with a correlatedEXISTSper row — and measures 223 ms, ~90% of the tool.LIMIT 50bounds the output, not the work. This is the identical O(refs) shape m0060 removed from the census, still present in the sibling block of the same tool.Related measurement from the same pass:
resolution_gapsis 0.83 s — five to six fullrefsscans plus twopools_cteevaluations, andpath_glob/langare applied after the join, so narrowing the query does not reduce the scan.Two things filed alongside that bear on this:
elapsed_mslogged for tool calls. That is what turns "slow" into "hung with no diagnosis".internal_resolution_pctis computed over exactly thisfile_health50-file window and ships with no denominator.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Landed as m0061
file_refs_rollup— per-criterion verdictCommitted on
merge/platform-and-73as07e24b1, gating now.generation_idis m0061's first PK column; recorded ingeneration_policy_registryasGenerationScopedgeneration_promotion::the_census_switches_with_the_factsasserts the per-file census against a live scan before promotion, after promotion and after rollback, with the positive control that superseded buckets remainas_ofgeneration + indexed timestamp returnedfile_healthrowproject_overviewbounded without scanning refshealth_probe_e2euntouched and greenThe criterion was true of the bug
The old statement drove from
filesand seeked refs per file: it read the entire ref table while never printingSCAN. A guard copied fromrefs_rollup_e2ewould have been green on the defect. The real property is graded by cost instead — adding 4,000 refs the reply never mentions moves the old shape ×2.65 and the new one ×1.09.Both sides, in the same unit
file_healthfalls 16,235,136 → 7,106,933 vm_step on a 237,036-ref index and 28,152,473 → 10,037,957 on rust-analyzer's 406,372, against +3.84% paid once at cold index (2.34–5.70% per repo, a constant 40.3–53.8 opcodes per inserted graph-edge ref across all seven languages). Repaid by the secondproject_overviewanyone runs.The resolver bind pass pays zero —
target_idis on no doorUPDATE OFlist, whichno_door_wakes_on_a_bindgrades. That is what distinguishes this from the #143 refusal, whose index taxed a fullrecompute_ref_countssweep on every re-index.corpus_ratchetis green on both legs, so index content is byte-identical;baseline.jsonmd5 unmoved.Correction to this issue's text
Half wrong. Verified on a v61 rust-analyzer index: a prefix
path_globdoes narrow —SEARCH f USING COVERING INDEX sqlite_autoindex_files_1 (path>? AND path<?)thenSEARCH r USING idx_refs_file_line. Onlylangalone fails to (SCAN r).resolution_gaps(0.83 s) is its own issue, not this one: it is not on theproject_overviewpath, and its cost is inherent to reason-coding the whole unresolved population, which is its contract — it cannot be bounded to 50 files.Residual, named rather than closed
The acceptance test asks for 2M refs; this is measured on real 237k and 406k indexes plus a scaling test. Recorded in the commit message, not silently dropped.
Also recorded:
cost-baseline.json's claim thatfullscan_stepwas unmoved is now the measurement — −1, −6 and −1 out of 230k–1.01M, at most 0.002% and down. That file's whole job is attribution, and a scan that had grown would announce itself in exactly that field.The 2M-ref, two-generation acceptance test is BUILT and GREEN. refs x6.62 costs x1.000.
The last comment marked this issue's final line — "Extend the acceptance test to a 2M-ref index with two plugin generations.
project_overviewmust remain bounded without scanning refs" — PARTIAL, with the substitution named rather than hidden: measured on real 237k and 406k indexes plus a scaling ratio. The brief for this lane allowed a reasoned refusal. It is cheaper to build than to argue, so it is built:crates/daemon/tests/overview_scale_2m_e2e.rs, registered on the nightlycorpus-scalejob.index_healthmoved x1.000 — down 85 opcodes out of 10.5M, which istopranking noise, with a byte-identical reply.refs_rolluppromises.Why the existing scaling test does not subsume this, precisely
file_health_bounded_e2e::the_work_follows_the_reported_files_not_the_indexis right about what it grades and cannot reach this line, for two structural reasons it states itself:pools_cteis O(symbols) and runs on BOTH paths, so padding that grew symbols in step with refs would grow the fast path too and the ceiling below would be measuring the fixture instead of the change." Correct for a guard on the CHANGE; it meansindex_health's other growth term was graded by nothing.ReadEpoch::probeshort-circuits (gated = active.is_some() && generations > 1) and every generation predicate this acceptance is about is an empty string there.So the new file moves one dimension per stage on ONE database — refs, then symbols, then a second generation — and each ratio is a property of the query shape because
vm_stepis deterministic for a given database and statement. Stage 2 adds its refs to files that already exist (graft_more_refs), sofiles,file_contributions,symbolsand the row count offile_refs_rollupare all unchanged and the only term that can move is the one this issue names.THE FINDING:
pools_cteis the term that remains, and it is now measuredgraph::index_healthhas three terms and this issue's line names one:toprankingfile_refs_rolluppools_cte—GROUP BY s.name, s.langoversymbolswith a correlatedEXISTSmCTELIMITboundsfile_health_bounded_e2eStage 3 isolates the middle one: symbols x6.62 (32,002 -> 212,002) with refs FLAT costs x2.141. That is sub-linear, so it is not a defect against this issue — which is about refs — and it is not nothing either: it is 12.1M of the 22.6M opcodes a
project_overviewpays on a 2M-ref index. It is asserted here only against a super-linear shape, because a tight ratchet on a cost nobody has decided to pay down would forbid work rather than grade it. It is now a number instead of an inference.The mutation, RUN, in its sharpest form
graph::index_healthreturnsindex_health_from_scanunconditionally — the shape of a database that lost its rollup:Wall clock under the mutation: 380 ms -> 3.79 s. Restored,
graph.rsmd5710fb58922c7c8b63416f9c13626faecboth sides,git statusclean.The same run is also the contrast that makes the attribution readable: under the scan path the SYMBOL stage costs x1.065 and the epoch stage x1.151, because refs dominate everything. Under the shipped path those are x2.141 and x0.999. Two different cost structures, same database.
Anti-vacuity, four ways
file_refs_rollupmust tally withrefsper generation — the reply's counts come fromrefsand only its RANKING comes from the rollup, so a short rollup would put the answer on the wrong files while every number in it still added up. That is the one drift the ceilings cannot see.And the
#[ignore]is paired withthe_scale_leg_is_registered_in_ci, which reads.forgejo/workflowsand fails if nothing dispatches the suite — #109's two crons that had never fired are why that pairing is not optional.One honest note on placement
40.63 s total, 31.7 s of it the graft. That is comparable to
graph_cap_scale_e2e's 34 s serial, which is on the PR path. So#[ignore]+ nightly is conservative rather than forced — the decision was taken before the measurement existed. What argues against the per-push path is not the time but the peak disk of a ~4.12M-row database on a runner that has a free-space pre-flight. That is one number away from being decidable, and the module doc says so.What remains on this issue
Nothing that I can find. Re-reading the acceptance list against the tree:
generation_promotion::the_census_switches_with_the_factsas_ofgeneration + timestamphealth_probe_e2euntouchedThe residual
4c08caarecorded is paid. This issue looks closable, and the only thing I would keep out of the close is thepools_ctemeasurement above — it is not a #73 defect, but it is the next thing anyone asking "what does the first call cost on a large repo?" will want, and it now has a number.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K
Closing. The restore run is bit-identical, which is the determinism claim proved rather than asserted.
After restoring
graph.rsfrom the mutation (cpsnapshot, md5710fb58922c7c8b63416f9c13626faecboth sides,touched,git statusclean), the suite was re-run:Every
vm_stepfigure is identical to the first run, digit for digit — 10,566,827 / 10,566,742 / 22,626,742 / 22,613,316 / 228,525,973 / 238 — across two runs at different machine loads (~6 and ~11 one-minute average) and 40 s apart. Wall clock moved 380 ms -> 490 ms between them, which is exactly why the gate is onvm_stepand the wall clock is a 60 s hang detector.That is the instrument's own claim — "deterministic for a given database and statement, so a RATIO between two fixtures is a property of the query shape and of nothing else" — demonstrated on this fixture rather than inherited from
corpus_cost's.Closing on this evidence
The last acceptance line is paid: the test exists, runs at 2,120,001 active refs beside a 2,000,000-ref pending generation, is registered on the nightly
corpus-scalejob with an in-tree guard that reddens if the job stops naming it, and its central ceiling has a run mutation with real RED (exit 101,refs x6.62 -> work x6.224).Two things deliberately left out of this close, both stated above rather than buried:
pools_cte's O(symbols) term — x2.141 for x6.62 symbols, sub-linear, 12.1M of the 22.6M opcodes aproject_overviewpays on a 2M-ref index. Not a defect against this issue, which is about refs. It is now a measured number where before it was an inference, and it is the next thing anyone asking "what does the first call cost on a large repo?" will want.generation_promotion::the_census_switches_with_the_facts, not at 2M. What the new file adds is the scale half of the other clause: that the gated read is a seek and not a filter over two million rows.🤖 Generated with Claude Code
https://claude.ai/code/session_01K1zj5VcFJvJt3pQxe9259K