fix: recover sqlite_autoindex_* keys so writes stop corrupting the file (#685) - #689
Open
dpsiderius wants to merge 4 commits into
Open
fix: recover sqlite_autoindex_* keys so writes stop corrupting the file (#685)#689dpsiderius wants to merge 4 commits into
dpsiderius wants to merge 4 commits into
Conversation
…le (#685) Writing to a table carrying a `sqlite_autoindex_*` returned success and left the index stale. `ddl_reader::index_schema` recovers an index's key columns by parsing its `sqlite_master.sql`, and every autoindex has `sql = NULL`, so it was dropped from `TableSchema::indexes` — the same list that drives both `emit_unique_check` (insert.rs:698) and index maintenance (insert.rs:796). Duplicates were accepted and the index was never updated; the oracle then reported `wrong # of entries in index` and `count(*)` undercounted from the stale index. Autoindex rows are now deferred during the `sqlite_master` walk and their keys recovered from the owning table's own DDL. The numbering rule is oracle-derived (3.51.0), not inferred, and several parts of it are counter-intuitive: - declaration order decides, not primary-key-first — `UNIQUE (c), PRIMARY KEY (a, b)` numbers the UNIQUE `_1`; - column-level `PRIMARY KEY`/`UNIQUE` count as much as table-level; - a rowid-alias primary key gets no index AND consumes no number, so `(a INTEGER PRIMARY KEY, b TEXT UNIQUE)` puts UNIQUE(b) at `_1`; - a `WITHOUT ROWID` primary key gets none — it *is* the table; - redundant constraints collapse: `PRIMARY KEY (a), UNIQUE (a)` is one index, as is `a TEXT PRIMARY KEY UNIQUE`. Safety valve, per spec 010/Req 8 and spec 007/Req 1's hot-journal precedent: an autoindex whose key cannot be recovered sets `TableSchema::unresolved_autoindex`, and INSERT/UPDATE/DELETE codegen refuse rather than write. Failing the statement beats silently producing a corrupt database. Two things found while doing this, both handled deliberately: - The rowid-alias rule needed here is not the one `rowid_alias_from_sql` implements — that function additionally requires the primary key to name the table's *only* column, which is not SQLite's rule and is a live read-correctness bug (#686). This ticket implements the correct rule in a local helper rather than smuggling a crate-wide rowid change into a corruption fix; the helper collapses into a call to the shared one when #686 lands. - Unique-violation messages named the index, not the columns. Stock SQLite says `UNIQUE constraint failed: t.a, t.b, t.c`; we said `t.sqlite_autoindex_t_1`. That divergence pre-existed for *every* unique index, but this fix made it newly reachable for autoindexes with a generated name no caller could act on, so the format now matches the oracle byte-for-byte. No existing test asserted the old format, which is a coverage gap this ticket closes. Verified: 1562 unit tests and 387 corpus tests pass (both unchanged from baseline), clippy/fmt/mod-files clean, assurance still 86/86 and 276/276 with no dead links. New `tests/corpus/autoindex_maintenance_test.rs` covers all three spec 010/Req 8 scenarios plus the numbering rule, the rowid-alias and WITHOUT ROWID cases, and message parity. Not included: emitting `sqlite_autoindex_*` on CREATE TABLE, so a table this crate *creates* with a declared composite key still lacks its index. That is the other half of the corruption story and is filed separately — this half fixes adopting a stock-created file, which is the SQE case. Refs: 010/Req-8, #685, #686, #678 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit added `TableSchema::unresolved_autoindex` but left seven test files' struct literals uncommitted in my working tree, so the branch as pushed did not compile — `cargo build --tests` failed with 8 `missing field` errors in `tests/tiers/tier1.rs` and `tests/unit/codegen*.rs`. The 1562-passing run reported on the previous commit was real, but it measured the working tree rather than the committed branch. Recording that here rather than amending: the distinction is the actual lesson, and force-pushing over a branch already sent for review would hide it. Verified from the committed state this time: `cargo build --tests` clean, 1562 unit tests and 387 corpus tests pass. Refs: #685 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`make lint`'s second clippy pass — the one that lints the `test = false` corpus/parity/sqllogictest targets `--tests` skips — failed on `clippy::ptr_arg` for the two new helpers that forward `db` to `page_size_of(&Path)`. Only these two of the file's four `&PathBuf` params were flagged, and that asymmetry is the lint working as designed: `seed` and `oracle_select` pass `db` straight into `Command::arg`, a generic `AsRef<OsStr>` bound clippy won't assume the deref target satisfies, so it stays quiet there. `our_insert` and `autoindex_map` hand `db` to functions already typed `&Path`, proving the slice suffices. Left the other two alone rather than churn lines the gate is happy with. Signature-only; call sites deref-coerce unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Writing to a table that carries a
sqlite_autoindex_*— any compositePRIMARY KEYorUNIQUEconstraint — returned success and left the indexstale. Silent data loss, exit code 0 throughout.
ddl_reader::index_schemarecovers an index's key columns by parsing itssqlite_master.sql, and every autoindex SQLite creates hassql = NULL.The reader returned
None, so the index was dropped fromTableSchema::indexes— documented atddl_reader.rs:106and locked in byauto_index_with_null_sql_is_omitted.Dropping it is defensible on a read: you lose an access path, answers stay
correct. On a write it is corruption, because the same list drives two
separate jobs in
src/codegen/stmt/insert.rs::698—schema.indexes.iter().filter(|idx| idx.unique)feedsemit_unique_check, so uniqueness was not enforced and a duplicate wasaccepted.
:796—emit_index_key_ops_from_regsmaintains the indexes codegenemitted for, so the index b-tree was never updated.
Measured against the pinned oracle (
sqlite33.53.4 —tests/corpus/oracle.rs:22), on an oracle-created composite-PK table:mainUNIQUE constraint failed: t.a, t.bSELECT count(*)PRAGMA integrity_checkwrong # of entries in indexokokThis blocks the SQE integration concretely: its
iceberg_tablescatalog keyson
(catalog_name, table_namespace, table_name)— exactly this shape — andtoday works around it by dropping the declared primary key for a named unique
index and refusing writes to any catalog carrying a
sqlite_autoindex_*.How the keys are recovered
Autoindex rows are deferred during the
sqlite_masterwalk and their keysrecovered afterwards from the owning table's own DDL. The numbering rule is
oracle-derived, not inferred — re-derived against the pinned 3.53.4 build
across eleven DDL shapes, checking
pragma_index_infokey lists rather thanjust index counts. Several parts of it would have been guessed wrong:
UNIQUE (c), PRIMARY KEY (a, b)numbers the UNIQUE_1.PRIMARY KEY/UNIQUEcount as much as table-level.(a INTEGER PRIMARY KEY, b TEXT UNIQUE)putsUNIQUE(b)at_1.WITHOUT ROWIDprimary key gets none — it is the table.PRIMARY KEY (a), UNIQUE (a)is oneindex, as is
a TEXT PRIMARY KEY UNIQUE.Safety valve
Recovery cannot be total, so it is not the only line of defence. An autoindex
whose constraint this reader fails to parse sets
TableSchema::unresolved_autoindex, and INSERT/UPDATE/DELETE codegen refusethe statement rather than write. Spec 010/Req 8 accepts either recovering
the index or refusing the write; spec 007/Req 1's hot-journal handling is the
same argument on the read side. Failing a statement beats silently producing a
corrupt database.
Two things found on the way, both handled deliberately
1. The rowid-alias rule this fix needs is not the one the crate implements.
rowid_alias_from_sqladditionally requires the primary key to name thetable's only column, which is not SQLite's rule — it is a live
read-correctness bug, filed as #686 (a table-level
PRIMARY KEY(col)rowid alias reads back
NULLwhen the table has other columns). This ticketimplements the correct rule in a local helper rather than smuggling a
crate-wide rowid change into a corruption fix. The helper collapses into a
call to the shared function when #686 lands.
2. Unique-violation messages named the index, not the columns. Stock
SQLite says
UNIQUE constraint failed: t.a, t.b, t.c; we saidt.sqlite_autoindex_t_1. That divergence pre-existed for every uniqueindex, so fixing it changes named-index messages too — flagged here because
it is wider than the ticket. It is in scope regardless: this fix made the path
newly reachable for autoindexes, whose generated name tells a caller nothing
they can act on. The format now matches the oracle byte-for-byte. No existing
test asserted the old format, which is the coverage gap the new corpus file
closes.
Not included
Emitting
sqlite_autoindex_*onCREATE TABLE, so a table this cratecreates with a declared composite key still lacks its index. That is the
other half of the corruption story and is filed separately as #687. This
half fixes adopting a stock-created file, which is the SQE case.
Test plan
tests/corpus/autoindex_maintenance_test.rs— 7 new oracle-diff tests:all three spec 010/Req 8 scenarios, the declaration-order numbering
rule, the rowid-alias and
WITHOUT ROWIDno-index cases, andunique-message parity
cargo test --locked— 1562 passed / 0 failed, unchanged from theorigin/mainbaseline measured the same waycargo test --locked --test corpus— 387 (380 baseline + 7)make lint,cargo fmt --check,make check-mod-filescleanmake assurance— 86/86 and 276/276, no dead links (spec 013/010-Req-8are
(planned)-excluded and unmerged, so the dashboard cannot move yet —see the traceability note below)
against
/opt/homebrew/opt/sqlite/bin/sqlite33.53.4, the pinned buildmake lint— both clippy passes, including the second one for thetest = falsecorpus/parity/sqllogictest targets that--testsskips.Worth calling out: my first check used
cargo clippy --all-targets,which does not cover them, and the third commit exists because the
second pass caught a real
clippy::ptr_argin the new corpus helpersthat
--all-targetsreported cleanafad17efrom a detached clean worktree, not theworking tree — see the second commit, which exists because the first
push did not compile and I would rather record that than force-push
over it
Notes for a reviewer
new
TableSchema::unresolved_autoindexfield. The review surface issrc/schema/ddl_reader.rs(+280), the three write-path guards, and thecorpus file.
CHANGELOG.mdentry or version bump: this repo folds those into aseparate
chore/*-fold-into-0.18.xPR (precedent chore: fold #663 into 0.18.9, revert premature 0.18.10 bump #671).010/Req 8, which exists only in Feat/embedding api spec #678 — not on
main. So theRefs:linebelow points at a requirement the tree does not yet carry, and the seven new
corpus tests cannot get their per-scenario
**Tests:**links (repoconvention) until it does. Sequencing this after Feat/embedding api spec #678 is fine; merging it
before is also fine, as long as the spec PR adds those links.
Spend: over the
mediumestimate. The fix itself matched it; the overrunis the two follow-up bugs it surfaced (#686, #687), the message-parity
divergence that had to be fixed in-branch to avoid shipping a
newly-reachable wrong message, and the recovery commit.
Refs: 010/Req-8, #686, #687, #678
Closes #685
🤖 Generated with Claude Code