index: a SQLite failure during a generation build is recorded as invalid_fact, blaming the package for the disk #128
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#128
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 #78. Verified first-hand 2026-09-04.
The defect
crates/indexer/src/build.rs:745-754, theErrarm of the dispatch loop:It is not a
map_err— it is a bareErr(e) =>overdispatch_round(..) -> Result<Round, IndexerError>.IndexerErrorisSqlite(#[from] rusqlite::Error)(crates/indexer/src/db.rs), anddispatch_round's body is?on rusqlite calls throughout:conn.prepare(…)?,rows.collect::<rusqlite::Result<_>>()?,conn.transaction()?,write_pending_contribution(…)?, four.execute(params![…])?,tx.commit()?,owed_count(conn, generation)?.So a
SQLITE_FULL,SQLITE_BUSYorSQLITE_IOERRon any of them arrives at that line indistinguishable from a plugin contract violation, and is recorded under a stable reason code that tells the operator the package emitted bad facts when the truth is that the disk filled up.#78 enumerates these as two separate classes under "Failure and rollback": "invalid fact response" and "SQLite busy/space/resource exhaustion".
storage_exhaustedexists, is registered as having no producer, and its registered justification is contradicted by that codecrates/mcp-server/tests/reason_code_registry.rs:A
SQLITE_FULLpath does turn into a stable reason today — the wrong one.No test drives the arm
Every occurrence of the literal in the tree:
build.rs:749,build.rs:752,generations.rs:266(doc),generations.rs:308(declaration),generations.rs:1476, plus prose inreason_code_registry.rsand two.mdfiles.generations.rs:1476is insideretained_diagnostics_are_bounded, which uses"invalid_fact"merely as a valid reason string to testclamp_detailagainst theDIAGNOSTIC_BYTESCHECK — it never reachesbuild.rs.By contrast
gate_failedandsource_churn_deadlineeach have a real driving test (generation_promotion.rs:2644,generation_build.rs:812). #78's own test list names "disk-full/busy cancellation"; nothing implements it.A second, smaller finding at the same site
generations::fail(...)?at line 749 is itself a database write. Under genuine disk exhaustion it fails too and the?propagates — defeating the promise stated four lines above it (build.rs:733: "?here would leave a generation inbuildingwith no failure row, so every error is turned into a recorded failure"). Recovery is not permanent (next_actionresumes frombuilding), but the stated invariant is conditional on the disk.Shape of the fix
Classify rather than collapse: match
IndexerError::Sqlite(rusqlite::Error::SqliteFailure(e, _))onDiskFull/IoErr→storage_exhausted,Busy/Locked→ its own class, everything else →invalid_fact. Remove theno_producerrow fromreason_code_registry.rs. Add a fault-injection test that actually drives each arm — the reason code is what an operator repairs from, so a test that only asserts the string is in a table is not evidence.DAEMON_READERSis a hand-list with a summed floor, so an unregistered daemon reader is invisible to the generation-gate scan #129Triage 2026-09-06: LEFT OPEN by a narrow margin — the headline defect is fixed and graded, but the tree still says a decision is "left to #128", and closing would leave that pointer dangling.
What is fixed, and it is the substance of the issue
generations::failure_class—crates/indexer/src/generations.rs:378, with the structural partition doc from:327and the helperstore_refused_the_writeat:400coveringDiskFull/SystemIoFailure/CantOpen/ReadOnly/NoMem/Busy/Locked, and excludingConstraint/Mismatch/Misuse.Errarm now calls it —crates/indexer/src/build.rs:828,let reason = generations::failure_class(&e);.no_producerrow is removed —crates/mcp-server/tests/reason_code_registry.rs:1195-1203(floor moved 19 → 18, with the reason) and:1796-1804(the oldstorage_exhaustedmutation recipe retired, recording that adding the producer turned the gate red). That last detail is the good kind: the registry noticed its own change.build.rs:733-745now states that the promise is conditional on the store, and that the resumablebuildingstate is the honest outcome. Correct: under genuine disk exhaustiongenerations::failcannot write either, and pretending otherwise would be the overclaim.And there is a real fault-injection test, which is what this issue asked for:
crates/indexer/tests/generation_build.rs:882— it usesPRAGMA max_page_countso the error is SQLite's own, asserts the durablegeneration_failuresrow, and carries both a kill mutation and a positive control recorded as RUN.Two deviations from the stated fix shape, both reasoned
Busy/Lockeddid not get "its own class" as the issue proposed; they fold intostorage_exhausted. The reason is written atgenerations.rs:363-372: splitting contention into its own code is representable only with a migration, and SQLite's own message rides in the failure'sdetail(bounded byDIAGNOSTIC_BYTES), which is where "database or disk is full" and "database is locked" are told apart. Defensible.IndexerError::Iohas no arm because no path intodispatch_roundproduces one — an arm would be a claim with no site and no test behind it.Why it stays open
generations.rs:355-363explicitly leaves a decision to this issue:That is the same defect this issue is named for — blaming the package for something that is not the package — narrowed from "the disk" to "our own trigger". It is much rarer and much less damaging, and it may well be the right call to accept it. But the tree currently points at this issue for that decision.
So: close it and re-point that sentence, or leave it open until the decision is taken. I have left it open because a close would silently orphan the pointer, and this issue's whole subject is a wrong attribution surviving because nobody looked. If the preference is to close and file a successor, say so — it is a one-line change to
generations.rs:362.🤖 Triage lane, 2026-09-06, master
45cf6e4code-index://docs/reason-codesis at 3,979 of its 4,000-token cap, so the next reason code this project mints cannot be documented #184FIXED, merged as
a80eb61. Verified at1d81180:generations.rsno longer ends_ => "invalid_fact"— the line survives only in a comment recording what it used to be, and in the mutation that restores it.failure_classhad a residual arm that contradicted the structural partition written two paragraphs above it in the same file.PluginContractis the only variant a package can cause; everything else is ours. Three arms now, withUNCLASSIFIEDtaking the residual — it was already inFAILURE_REASONSand already accepted by m0050's trigger, so no migration was needed.Its
UNWRITTENrow is gone too. That row's justification said the vocabulary covered "every failure path this build CLASSIFIES", which was false in its first clause and therefore backwards in its second.Mutation run: restore
_ => "invalid_fact"→left: "invalid_fact" / right: "unclassified". Second mutation: restore theUNWRITTENrow verbatim → "registered UNWRITTEN (reserved) and 1 production source(s) name it".Closing.