The promotion lock exceeds the ceiling plugin enable discloses to operators — 6,297–6,543 ns/row against 6,000 at the 100k shape, reproduced on two machines, and it is a REGRESSION #208
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#208
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 while clearing the nightly
Plugin path cost + pool throughputjob for v0.27.0. Three CI reds in a row, and it reproduces locally. This is not the contention it was first taken for.Measured
bench_promotion_lock_at_the_hundred_thousand_file_shape, at358e1ec:The failure message is the constant's own:
The smaller arms all pass, and the rate rises with scale:
A flat ceiling over a rate that climbs 4,132 → 6,297 across a 200-fold row count. The 100k arm is the only one that crosses it, which is what the 100k arm exists for.
It is a REGRESSION, established rather than assumed
1aa6514(2026-09-04).01a478b(2026-09-05), andgit merge-base --is-ancestor 1aa6514 01a478bsays the arm was present and passing there.git log 01a478b..HEAD -- crates/indexer/src/promotion.rs crates/indexer/tests/bench_promotion_lock.rsis empty. Neither the ceiling nor its test moved.So something else in that window made promotion measurably more expensive per row.
Candidate mechanism — NOT confirmed, and stated as such
Four migrations landed in the window: m0060
refs_rollup, m0061file_refs_rollup, m0062, m0063. The first two install seven triggers between them, and four are onrefs:The 100k shape carries 1.1M refs, and the denominator (
contributions + symbols + refs + imports) counts base rows only — so per-row trigger work would raise ns/row without moving the divisor. That is a plausible mechanism and it fits the timing.I did not confirm it. I did not establish that
generations::promoteperforms INSERTs or DELETEs onrefsrather than an UPDATE of a generation pointer, and if it is an UPDATE these triggers never fire and the cause is elsewhere. Whoever takes this should settle that first — the wrong mechanism would send the fix into the wrong file.Why this matters beyond a red nightly
The number is a product disclosure, not just a test bound.
code-index plugin enabletells an operator how long the promotion lock will hold, derived from this constant, and it is currently 5–9% optimistic at the shape where it matters most.It is also the class this repo has recorded paying for: a correctness suite cannot see a slowdown. ~3,400 workspace tests pass, CI is green on all five
REQUIRED_JOBS, and only this nightly wall-clock ceiling saw it.What must NOT be done
MEASURED_COLLECT_NS_PER_CASCADED_ROWrecords what happens when a ceiling gets raised reflexively until it bounds nothing.Release position
This does not mechanically block v0.27.0 — the job is deliberately not in
release.yml'sREQUIRED_JOBS, by its own comment, because it is a ratio measurement on a shared runner. But the shippedplugin enabledisclosure is wrong by 5–9% at the 100k shape, and the release notes should not claim promotion-lock precision.Related
#41 (scale ceilings — this is one), #161 (the disk pre-flight on the same job), and
MEASURED_COLLECT_NS_PER_CASCADED_ROW, whose idle re-measurement on 2026-09-07 found the same scale-dependent shape and was deliberately left alone for a related reason.Correcting my own filing on two counts, and the mechanism is now read rather than guessed.
1. I checked the wrong file
The issue says "
git log 01a478b..HEADonpromotion.rsand its test is empty." True, and irrelevant:MEASURED_LOCK_NS_PER_GENERATION_ROWlives inpromotion.rs, butpromote()— the thing being timed — lives ingenerations.rs, which did change in the window (c3fc6d6).I re-checked properly, by comparing the function body itself rather than the file:
Byte-identical. The conclusion survives — the timed code did not change — but it was resting on the wrong evidence, and
c3fc6d6's 96 added lines to that file are elsewhere in it.2. My trigger hypothesis was right about the family and wrong about the event
I wrote that m0060/m0061 install
AFTER INSERT/AFTER DELETE ON refstriggers and that the 100k shape carries 1.1M refs. Both true. Butpromotenever inserts or deletes refs, so those triggers cannot fire during promotion. Enumerated, every SQL verb inpromote_in_transaction:I had characterised only 4 of the 7 triggers. The missing three include the ones that matter:
And
promote_in_transactionruns exactly:target_idis the first column in the trigger'sOFlist. So promotion's own ref update fires two rollup triggers per affected row, on a statement whose code is unchanged, under a schema that gained those triggers inside the regression window. That is a mechanism that explains a per-row cost rise with no code change — which is exactly the shape observed.What is still not established
How many rows the
{crossing}predicate actually touches at the 100k shape. If it is a small fraction of the 1.1M refs, two triggers over that subset may not account for 297–543 ns/row across the whole denominator, and something else is also contributing. Whoever takes this should measure the row count that statement updates before assuming it is the whole story — and note there are also twoUPDATE symbolsstatements, one of which recomputesref_countthrough a correlatedSELECT COUNT(*) FROM refs(index-served byidx_refs_target, present since m0001).What does not change
The breach itself: 6,543 ns/row on CI, 6,297 on a second machine, against a 6,000 ceiling, with the smaller arms passing and the rate climbing 4,132 → 6,297. Still a regression, still in a number
plugin enablediscloses to operators, and the constant is still untouched.The lesson I would carry out of my own error: "the file that declares a constant is not the file that does the work it bounds." I let the constant's location stand in for the code's, and only caught it by re-deriving the check.
Fixed in
caa62fe(master). The mechanism this issue proposed is refuted, and the real one was found by removal.The
{crossing}row count — this issue's own open questionZero. At the 100k shape the crossing predicate matches 800,000 refs before the carry and 0 after it. The carry runs first and moves every ref to the incoming generation, so nothing crosses by the time the unbind runs;
Switch::unbound == 0, confirmed at 8k and 100k.So m0060's
AFTER UPDATE OF target_iddoors never fire during a promotion.UPDATE refs SET target_id = NULL … WHERE {crossing}costs 157 ms of a 13.94 s profiled transaction (1.1%) as a pure scan. The correlatedref_countrecount — the other candidate named here — does not appear in the profile at all:temp.promotion_recountis empty for the same reason the unbind is a no-op.The trigger whose
OFlist names your column is not necessarily the trigger your statement fires.The real door: the carry
m0060's D4 and m0061's D3 both watch
generation_id, and the carry is what moves it — via m0052's D2 cascade, once per ref, 1.1M times. Measured by dropping the seven rollup triggers on the same fixture, in the same process, immediately beforepromote:2,213 ns/row = 35% of the lock, 57% of the carry's VDBE work. The counterfactual arm reads 4,038 against the 3,915 recorded before m0060 existed — 3% agreement across a fixture rebuilt from scratch. That closes the causal loop; nothing else in the window contributed measurably.
Per-statement attribution (
sqlite3_trace_v2, 100k, idle): carry 89.9% (12.53 s of 13.94 s),promotion_recount4.9%,promotion_scope1.4%, unbind 1.1%,COMMIT1.0%,DELETE symbol_edges0.9%,UPDATE symbols SET ref_count = 00.7%.Decision: honest re-record, not a fix
The 2,213 ns/row buys the rollups being maintained by the writes — m0060's argument for triggers over a cache is that there is then no invalidation to get wrong, and a promotion is precisely the writer most likely to forget. SQLite cannot disarm a row trigger for one statement, so paying it set-wise means either taking
generation_idout of the rollup keys (which makes the per-generation reads those tables exist for impossible) or moving maintenance back into Rust (the design m0060 argues against by name). Buying a wall-clock ceiling with either is a fix-per-case, in a release that is otherwise ready.MEASURED_LOCK_NS_PER_GENERATION_ROW6,000 → 9,000, by the same method the 6,000 was set by: 1.43× the worst idle reading (6,298) and 1.38× the worst reading under any ordinary load (6,543, shared CI runner) — the old value stood at 1.53× and 1.36× of its own two.The finding worth more than the constant
No test in this tree had ever driven a promotion through either rollup trigger. The cheapest way to reclaim that 35% is to narrow an
OFlist — which would leave both rollups describing the outgoing generation: fast, wrong, and silent. Both suites now carrythe_carry_of_a_promotion_moves_every_bucket, driving a realgenerations::promoteand comparing each rollup against a live scan.Mutations run, RED pasted in the commit: dropping
generation_idfrom m0060's D3/D4OFlists, and from m0061'sWATCHED, both fail with the buckets left on generation 1 where the scan says 2. The ceiling's own mutation is reading 1 below — at 6,000 the bench panicked at 6,298.Standing weakness, named rather than hidden
A flat constant over a rate that climbs 4,213 → 6,298 across a 200-fold row count now over-predicts by 2.3× at the 11.5k-row arm. Over-prediction is the direction this disclosure is allowed to be wrong in (
plugin enableprints it as PREDICTED), but it is an error; the fix if ever wanted is a two-term estimate, andlock_estimateis the single function that would change. Recorded in the module doc so the next lane does not rediscover it.Also recorded: the 100k arm is the stable one across three readings (6,298 / 6,214 / 6,261) while the small arms move up to 18% on a box under load 1.4 — the opposite of what a reader would guess from their run times.
Gates:
fmt 0·clippy 0·doc 0·cargo test --workspace --no-fail-fast0 (317 suites) ·bench_promotion_lock --ignored --test-threads=10.plugin-path-costis dispatch/schedule-only, so it is being dispatched separately to verify the new ceiling on the runner that breached the old one.Verified on the runner that broke it. Closing.
plugin-path-costisschedule/workflow_dispatchonly, so the push CI structurally could not test this — a green push run says nothing about the ceiling. Dispatched run #651 oncaa62feto get the real verdict:The 9,000 ceiling holds where 6,000 breached at 6,543. Both rollup census tests (
the_carry_of_a_promotion_moves_every_bucketand itsfile_refs_rolluptwin) ran green incargo teston the same run, so the door the cost buys is now graded on CI and not only locally.Recap of what this issue actually established, since the headline number is the least interesting part:
{crossing}matches 800,000 refs before the carry and zero after it, so m0060'sAFTER UPDATE OF target_iddoors never fire during a promotion. The unbind is 1.1% of the transaction as a pure scan; the correlatedref_countrecount does not appear at all.OFlist — would have left both rollups describing the outgoing generation: fast, wrong, silent. That gate now exists and is the durable output of this issue.lock_estimateis named as the single function a two-term estimate would change.A watcher note, because it nearly produced a false green here
My first CI watcher used "no rows are running" as its terminal condition. On a DAG that is ambiguous — it is true when the run has finished and when the next job has not been created yet. It polled in exactly that gap, after
test-daemon-legwent green and beforeplugin-path-costexisted, and reported TERMINAL with 11 green rows. The run has 13; jobs are ADDED as they start.Had that been believed, this issue would have been closed as verified by a run in which the only job that could verify it had not executed. The replacement waits on the named job reaching a terminal status and reports "row not created yet" distinctly from "running", so absence can never read as completion. Worth knowing for anyone else watching this job, since it is the last one in the graph by design and therefore the one most likely to be missed this way.