Skip to content

fix: recover sqlite_autoindex_* keys so writes stop corrupting the file (#685) - #689

Open
dpsiderius wants to merge 4 commits into
mainfrom
fix/685-autoindex-maintenance
Open

fix: recover sqlite_autoindex_* keys so writes stop corrupting the file (#685)#689
dpsiderius wants to merge 4 commits into
mainfrom
fix/685-autoindex-maintenance

Conversation

@dpsiderius

@dpsiderius dpsiderius commented Sep 4, 2026

Copy link
Copy Markdown

Summary

Writing to a table that carries a sqlite_autoindex_* — any composite
PRIMARY KEY or UNIQUE constraint — returned success and left the index
stale. Silent data loss, exit code 0 throughout.

ddl_reader::index_schema recovers an index's key columns by parsing its
sqlite_master.sql, and every autoindex SQLite creates has sql = NULL.
The reader returned None, so the index was dropped from
TableSchema::indexes — documented at ddl_reader.rs:106 and locked in by
auto_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:

  • :698schema.indexes.iter().filter(|idx| idx.unique) feeds
    emit_unique_check, so uniqueness was not enforced and a duplicate was
    accepted.
  • :796emit_index_key_ops_from_regs maintains the indexes codegen
    emitted for, so the index b-tree was never updated.

Measured against the pinned oracle (sqlite3 3.53.4 —
tests/corpus/oracle.rs:22), on an oracle-created composite-PK table:

after two inserts + a duplicate main this branch oracle
duplicate insert accepted rejected rejected
error message UNIQUE constraint failed: t.a, t.b identical
SELECT count(*) 1 2 2
PRAGMA integrity_check wrong # of entries in index ok ok

This blocks the SQE integration concretely: its iceberg_tables catalog keys
on (catalog_name, table_namespace, table_name) — exactly this shape — and
today 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_master walk and their keys
recovered 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_info key lists rather than
just index counts. Several parts of it would have been guessed wrong:

  • 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

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 refuse
the 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_sql additionally requires the primary key to name the
table'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 NULL when the table has other columns). 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 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 said
t.sqlite_autoindex_t_1. That divergence pre-existed for every unique
index
, 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_* 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 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 ROWID no-index cases, and
    unique-message parity
  • cargo test --locked1562 passed / 0 failed, unchanged from the
    origin/main baseline measured the same way
  • cargo test --locked --test corpus387 (380 baseline + 7)
  • make lint, cargo fmt --check, make check-mod-files clean
  • make assurance — 86/86 and 276/276, no dead links (spec 013/010-Req-8
    are (planned)-excluded and unmerged, so the dashboard cannot move yet —
    see the traceability note below)
  • Numbering rule, key lists and the message format re-verified directly
    against /opt/homebrew/opt/sqlite/bin/sqlite3 3.53.4, the pinned build
  • make lintboth clippy passes, including the second one for the
    test = false corpus/parity/sqllogictest targets that --tests skips.
    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_arg in the new corpus helpers
    that --all-targets reported clean
  • Verified at afad17e from a detached clean worktree, not the
    working 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

  • 30 of the 32 changed files are a one-line struct-literal addition for the
    new TableSchema::unresolved_autoindex field. The review surface is
    src/schema/ddl_reader.rs (+280), the three write-path guards, and the
    corpus file.
  • No CHANGELOG.md entry or version bump: this repo folds those into a
    separate chore/*-fold-into-0.18.x PR (precedent chore: fold #663 into 0.18.9, revert premature 0.18.10 bump #671).
  • Traceability is incomplete until Feat/embedding api spec #678 lands. This fix discharges spec
    010/Req 8, which exists only in Feat/embedding api spec #678 — not on main. So the Refs: line
    below points at a requirement the tree does not yet carry, and the seven new
    corpus tests cannot get their per-scenario **Tests:** links (repo
    convention) 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 medium estimate. The fix itself matched it; the overrun
is 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

dpsiderius and others added 4 commits September 4, 2026 10:43
…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>
@dpsiderius dpsiderius changed the title Fix/685 autoindex maintenance fix: recover sqlite_autoindex_* keys so writes stop corrupting the file (#685) Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: writes silently corrupt tables carrying a sqlite_autoindex_* (composite PRIMARY KEY / UNIQUE)

1 participant