◐ Off-By-One · answer catalog

python-sqlite-online-index-repair

1 answer(s)pythonpython3

A drop-in Python 3.11+ tool that rebuilds a damaged SQLite secondary index while the database stays live. It never blocks readers for the rebuild, keeps the writer pause to the minimum SQLite allows, scans with bounded memory, detects rows changed during the scan, and atomically publishes a replacement only after it has been proven to match a consistent database snapshot.

📦 Source in repository (JSON)

Answer

The extracted code from the markdown runs clean and passes all tests — the deliverable is genuinely self-contained. The complete solution is saved at ~/work/SOLUTION.md, with the tool at ~/work/online_index_repair.py and tests at ~/work/test_repair.py.

Here is the solution in full:


Online Repair of a Corrupted SQLite Secondary Index

A drop-in Python 3.11+ tool that rebuilds a damaged SQLite secondary index while the database stays live. It never blocks readers for the rebuild, keeps the writer pause to the minimum SQLite allows, scans with bounded memory, detects rows changed during the scan, and atomically publishes a replacement only after it has been proven to match a consistent database snapshot.

file purpose
online_index_repair.py the repair tool (library + CLI)
test_repair.py end-to-end verification (corruption injection, concurrency, interruption, memory)

1. Root-cause analysis

1.1 What "a corrupted secondary index" actually is

SQLite stores every index as its own b-tree in the same database file. Each index leaf entry is a record (<indexed expressions...>, <rowid>). The b-tree is linked to the table only by sqlite_master.rootpage; nothing else keeps the two in sync. Corruption comes in two distinct flavours:

  1. Logical divergence — pages are structurally valid but contents don't match the table. Causes: a crash during a non-durable write, copying the file while a writer was active, manual sqlite_master edits, or PRAGMA writable_schema misuse. Symptoms: PRAGMA integrity_check reports wrong # of entries in index ... or row N missing from index .... DROP INDEX still works.
  2. Physical b-tree damage — a page is unreadable (torn write, bad sector, truncation). Symptoms: PRAGMA integrity_check raises database disk image is malformed, and any statement touching the index fails. Crucially, DROP INDEX fails too, because dropping walks the b-tree to return pages to the freelist.

1.2 Why the obvious repairs are wrong

1.3 The online contract

  1. Readers are never blocked for the rebuild. WAL mode gives readers a stable snapshot while the rebuild writes.
  2. Writers pause only briefly. SQLite builds a native index in one write transaction, so the writer lock is held for the build and the short schema swap — not for verification or cleanup.
  3. Bounded memory. The build uses SQLite's external sorter (PRAGMA temp_store = FILE) with a capped page cache; verification streams one chunk at a time.
  4. Rows changed during the scan are detected. The tool records PRAGMA data_version and verifies the candidate index against the table inside one read transaction, so a moving target cannot produce a false match.
  5. Atomic publish of a verified index. Old index removed and replacement renamed into place in one write transaction, with PRAGMA schema_version bumped so every connection reloads atomically.

1.4 How the design meets the contract

2. The fix

2.1 online_index_repair.py

#!/usr/bin/env python3
"""
online_index_repair.py
======================

Online repair of a corrupted secondary index in a *live* SQLite database.

Why this is hard
----------------
A naive repair is ``REINDEX idx`` or ``DROP INDEX idx; CREATE INDEX idx ...``.
Both fail (or are unsafe) when the index b-tree is physically damaged, and both
take the database's single write lock for the whole rebuild.  A tool that wants
to stay online must therefore:

1. never block readers (use WAL mode, where readers see a stable snapshot);
2. avoid holding the write lock for anything but the essential schema swap;
3. build the replacement with bounded memory (external sort + capped cache);
4. tolerate rows changing while it works, and only publish an index that
   matches a *consistent* database snapshot;
5. survive interruption at any point without leaving the database worse off.

How this tool does it
---------------------
* ``CREATE INDEX`` builds a *shadow* index under a private name.  SQLite does the
  scan with its external sorter (``temp_store=FILE``, capped ``cache_size``), so
  memory is bounded and the resulting b-tree is atomically consistent with the
  snapshot at commit.  SQLite then maintains that shadow for every concurrent
  insert/update/delete, so it cannot go stale afterwards.
* An independent verification pass re-derives the index contents from the base
  table and compares a streaming multiset digest against the shadow.  Both scans
  run inside **one read transaction**, so they observe an identical snapshot even
  while writers commit; ``PRAGMA data_version`` records concurrent commits.
* Publishing is a single write transaction: drop/unlink the old index and rename
  the shadow into place, then bump ``schema_version`` so all connections reload
  the schema atomically.  If the old b-tree is too corrupt to ``DROP``, its
  ``sqlite_master`` entry is unlinked instead (orphan pages are reclaimed later
  by ``VACUUM``).

Public API
----------
``repair_index(db_path, index_name, ...) -> RepairResult``
``diagnose_index(conn, index_name) -> dict``
"""

from __future__ import annotations

import argparse
import hashlib
import json
import re
import sqlite3
import sys
import time
import uuid
from dataclasses import dataclass, field
from typing import Callable, Iterable, Optional

__all__ = ["repair_index", "diagnose_index", "RepairResult", "IndexRepairError"]

DEFAULT_TIMEOUT = 30.0
DEFAULT_CHUNK_ROWS = 5000
DEFAULT_CACHE_KIB = 8192  # 8 MiB page cache -> bounded memory


class IndexRepairError(RuntimeError):
    """Raised when the index cannot be repaired safely."""


# ---------------------------------------------------------------------------
# SQL identifier / DDL helpers
# ---------------------------------------------------------------------------

_IDENT = r'"(?:[^"]|"")*"|\[[^\]]*\]|`[^`]*`|[A-Za-z_][A-Za-z0-9_$]*'


def _quote_ident(name: str) -> str:
    return '"' + str(name).replace('"', '""') + '"'


def _unquote_ident(name: str) -> str:
    name = name.strip()
    if len(name) >= 2 and name[0] == '"' and name[-1] == '"':
        return name[1:-1].replace('""', '"')
    if len(name) >= 2 and name[0] == "`" and name[-1] == "`":
        return name[1:-1]
    if len(name) >= 2 and name[0] == "[" and name[-1] == "]":
        return name[1:-1]
    return name


def _matching_paren(sql: str, open_idx: int) -> int:
    """Index of the ``)`` matching the ``(`` at *open_idx*, ignoring quotes."""
    depth = 0
    i, n = open_idx, len(sql)
    while i < n:
        ch = sql[i]
        if ch in "\"'`":
            quote = ch
            i += 1
            while i < n:
                if sql[i] == quote:
                    if i + 1 < n and sql[i + 1] == quote:
                        i += 2
                        continue
                    break
                i += 1
        elif ch == "[":
            while i < n and sql[i] != "]":
                i += 1
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth == 0:
                return i
        i += 1
    raise IndexRepairError("unbalanced parentheses in index DDL: %r" % sql)


def _split_top_level(columns_sql: str) -> list:
    """Split a comma separated index column list on top-level commas."""
    parts, buf, depth = [], [], 0
    i, n = 0, len(columns_sql)
    while i < n:
        ch = columns_sql[i]
        if ch in "\"'`":
            quote = ch
            buf.append(ch)
            i += 1
            while i < n:
                buf.append(columns_sql[i])
                if columns_sql[i] == quote:
                    if i + 1 < n and columns_sql[i + 1] == quote:
                        buf.append(columns_sql[i + 1])
                        i += 2
                        continue
                    break
                i += 1
        elif ch == "[":
            while i < n:
                buf.append(columns_sql[i])
                if columns_sql[i] == "]":
                    break
                i += 1
        elif ch == "(":
            depth += 1
            buf.append(ch)
        elif ch == ")":
            depth -= 1
            buf.append(ch)
        elif ch == "," and depth == 0:
            parts.append("".join(buf).strip())
            buf = []
        else:
            buf.append(ch)
        i += 1
    if buf:
        parts.append("".join(buf).strip())
    return [p for p in parts if p]


def _strip_sort_direction(expr: str) -> str:
    """Remove a trailing ASC/DESC from a single index key expression."""
    return re.sub(r"(?is)\s+(?:ASC|DESC)\s*$", "", expr).strip()


@dataclass
class IndexDef:
    name: str
    table: str
    columns_sql: str          # text inside the index parentheses
    where_sql: Optional[str]  # text after WHERE, or None
    unique: bool
    raw_sql: str

    def keys(self) -> list:
        return [_strip_sort_direction(p) for p in _split_top_level(self.columns_sql)]

    def ddl(self, new_name: str) -> str:
        unique = "UNIQUE " if self.unique else ""
        where = " WHERE %s" % self.where_sql if self.where_sql else ""
        return "CREATE %sINDEX %s ON %s (%s)%s" % (
            unique, _quote_ident(new_name), _quote_ident(self.table),
            self.columns_sql, where,
        )


def parse_index_def(sql: str) -> IndexDef:
    """Parse a ``CREATE [UNIQUE] INDEX`` statement from ``sqlite_master.sql``."""
    m = re.match(
        r"(?is)^\s*CREATE\s+(UNIQUE\s+)?INDEX\s+"
        r"(?:IF\s+NOT\s+EXISTS\s+)?(" + _IDENT + r")\s+ON\s+(" + _IDENT + r")\s*\(",
        sql,
    )
    if not m:
        raise IndexRepairError("not a CREATE INDEX statement: %r" % sql)
    unique = bool(m.group(1))
    name = _unquote_ident(m.group(2))
    table = _unquote_ident(m.group(3))
    open_idx = m.end() - 1
    close_idx = _matching_paren(sql, open_idx)
    columns_sql = sql[open_idx + 1:close_idx]
    tail = sql[close_idx + 1:].strip()
    where_sql = None
    if tail:
        wm = re.match(r"(?is)^WHERE\s+(.*)$", tail)
        if not wm:
            raise IndexRepairError("unsupported index tail: %r" % tail)
        where_sql = wm.group(1).strip()
    return IndexDef(name, table, columns_sql, where_sql, unique, sql)


# ---------------------------------------------------------------------------
# Connection helpers
# ---------------------------------------------------------------------------

def connect(db_path: str, timeout: float = DEFAULT_TIMEOUT,
            cache_kib: int = DEFAULT_CACHE_KIB) -> sqlite3.Connection:
    conn = sqlite3.connect(db_path, timeout=timeout, isolation_level=None)
    conn.execute("PRAGMA busy_timeout = %d" % int(timeout * 1000))
    conn.execute("PRAGMA journal_mode = WAL")
    conn.execute("PRAGMA synchronous = NORMAL")
    conn.execute("PRAGMA cache_size = %d" % (-cache_kib))
    conn.execute("PRAGMA temp_store = FILE")
    return conn


def _data_version(conn: sqlite3.Connection) -> int:
    try:
        return int(conn.execute("PRAGMA data_version").fetchone()[0])
    except sqlite3.Error:
        return -1


def _rowid_alias(conn: sqlite3.Connection, table: str) -> Optional[str]:
    """Return an unshadowed hidden-rowid alias, or None for WITHOUT ROWID."""
    row = conn.execute(
        "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
    ).fetchone()
    if row and row[0] and re.search(r"(?is)\bWITHOUT\s+ROWID\b", row[0]):
        return None
    cols = {r[1] for r in conn.execute(
        "PRAGMA table_info(%s)" % _quote_ident(table))}
    for alias in ("_rowid_", "rowid", "oid"):
        if alias not in cols:
            return alias
    return None


# ---------------------------------------------------------------------------
# Diagnostics
# ---------------------------------------------------------------------------

@dataclass
class RepairResult:
    index: str
    table: str
    clean_drop: bool
    verification: str
    concurrent_commits: int
    seconds: float
    orphan_pages: bool = False
    notes: list = field(default_factory=list)

    def as_dict(self) -> dict:
        return {
            "index": self.index,
            "table": self.table,
            "clean_drop": self.clean_drop,
            "verification": self.verification,
            "concurrent_commits": self.concurrent_commits,
            "seconds": round(self.seconds, 4),
            "orphan_pages": self.orphan_pages,
            "notes": list(self.notes),
        }


def diagnose_index(conn: sqlite3.Connection, index_name: str) -> dict:
    """Return a diagnostic report for *index_name* without raising on corruption."""
    row = conn.execute(
        "SELECT sql FROM sqlite_master WHERE type='index' AND name=?", (index_name,)
    ).fetchone()
    report = {"index": index_name, "exists": row is not None, "errors": [],
              "rows_table": None, "rows_index": None}
    if row is None:
        report["errors"].append("index does not exist")
        return report
    try:
        idef = parse_index_def(row[0])
    except IndexRepairError as exc:
        report["errors"].append("unparseable DDL: %s" % exc)
        return report
    qt, qi = _quote_ident(idef.table), _quote_ident(index_name)
    alias = _rowid_alias(conn, idef.table)
    prefix = (alias + ", ") if alias else ""
    keys = ", ".join(idef.keys())
    where = " WHERE %s" % idef.where_sql if idef.where_sql else ""
    table_sql = "SELECT %s%s FROM %s NOT INDEXED%s" % (prefix, keys, qt, where)
    index_sql = "SELECT %s%s FROM %s INDEXED BY %s%s" % (prefix, keys, qt, qi, where)
    try:
        t_count, t_hash, i_count, i_hash = _scan_pair(conn, table_sql, index_sql)
    except sqlite3.Error as exc:
        report["errors"].append("content scan failed: %s" % exc)
        return report
    report["rows_table"], report["rows_index"] = t_count, i_count
    if t_count != i_count:
        report["errors"].append(
            "row-count mismatch: table=%d index=%d" % (t_count, i_count))
    elif t_hash != i_hash:
        report["errors"].append("content digest mismatch over %d rows" % t_count)
    return report


# ---------------------------------------------------------------------------
# Bounded-memory verification
# ---------------------------------------------------------------------------

def _row_digest(values: Iterable) -> int:
    h = hashlib.blake2b(digest_size=16)
    for v in values:
        if v is None:
            h.update(b"N;")
        elif isinstance(v, bool):
            h.update(b"I" + str(int(v)).encode() + b";")
        elif isinstance(v, int):
            h.update(b"I" + str(v).encode() + b";")
        elif isinstance(v, float):
            h.update(b"F" + repr(v).encode() + b";")
        elif isinstance(v, bytes):
            h.update(b"B" + v + b";")
        else:
            h.update(b"T" + str(v).encode("utf-8", "surrogatepass") + b";")
    return int.from_bytes(h.digest(), "big")


def _stream_hash(conn: sqlite3.Connection, sql: str, chunk_rows: int,
                 params=()) -> tuple:
    """Stream *sql* and return ``(count, digest_sum)`` with bounded memory."""
    count, acc = 0, 0
    mask = (1 << 256) - 1
    cur = conn.execute(sql, params)
    while True:
        rows = cur.fetchmany(chunk_rows)
        if not rows:
            break
        for row in rows:
            count += 1
            acc = (acc + _row_digest(row)) & mask
    return count, acc


def _scan_pair(conn: sqlite3.Connection, table_sql: str, index_sql: str,
               chunk_rows: int = DEFAULT_CHUNK_ROWS) -> tuple:
    """Scan both SQL statements in one read snapshot; return four aggregates."""
    conn.execute("BEGIN")
    try:
        t_count, t_hash = _stream_hash(conn, table_sql, chunk_rows)
        i_count, i_hash = _stream_hash(conn, index_sql, chunk_rows)
    finally:
        try:
            conn.execute("COMMIT")
        except sqlite3.Error:
            try:
                conn.execute("ROLLBACK")
            except sqlite3.Error:
                pass
    return t_count, t_hash, i_count, i_hash


def verify_shadow(conn: sqlite3.Connection, idef: IndexDef, shadow: str,
                  chunk_rows: int = DEFAULT_CHUNK_ROWS,
                  log: Callable[..., None] = lambda *_: None) -> str:
    """Verify *shadow* against the base table inside one read snapshot.

    The table is force-scanned (``NOT INDEXED``) and the candidate index is
    force-scanned (``INDEXED BY``) within the *same* transaction, so both see an
    identical snapshot even while writers commit.  Only one chunk of rows is in
    memory at a time.  Raises :class:`IndexRepairError` on any mismatch.
    """
    qt, qs = _quote_ident(idef.table), _quote_ident(shadow)
    alias = _rowid_alias(conn, idef.table)
    prefix = (alias + ", ") if alias else ""
    keys = ", ".join(idef.keys())
    where = " WHERE %s" % idef.where_sql if idef.where_sql else ""

    table_sql = "SELECT %s%s FROM %s NOT INDEXED%s" % (prefix, keys, qt, where)
    index_sql = "SELECT %s%s FROM %s INDEXED BY %s%s" % (prefix, keys, qt, qs, where)

    dv_before = _data_version(conn)
    t_count, t_hash, i_count, i_hash = _scan_pair(conn, table_sql, index_sql, chunk_rows)
    dv_after = _data_version(conn)
    if t_count != i_count:
        raise IndexRepairError(
            "verification failed: table has %d rows, index has %d" % (t_count, i_count))
    if t_hash != i_hash:
        raise IndexRepairError(
            "verification failed: content digest mismatch over %d rows" % t_count)
    if dv_before != dv_after:
        log("  concurrent commits observed during verification (snapshot stayed stable)")
    log("  verified %d entries (digest %032x)" % (t_count, t_hash & ((1 << 128) - 1)))
    return "ok"


# ---------------------------------------------------------------------------
# Publish
# ---------------------------------------------------------------------------

def _publish(conn: sqlite3.Connection, idef: IndexDef, shadow: str,
             log: Callable[..., None]) -> bool:
    """Atomically replace the old index with *shadow*.

    Returns ``True`` when the old b-tree could be dropped cleanly, or ``False``
    when it had to be unlinked from ``sqlite_master`` (leaving orphan pages for
    a later ``VACUUM``).  The rename is always performed through
    ``writable_schema`` because SQLite has no ``ALTER INDEX``.
    """
    conn.execute("PRAGMA writable_schema = ON")
    try:
        # --- fast, fully atomic path for a structurally sound b-tree ---------
        try:
            conn.execute("BEGIN IMMEDIATE")
            conn.execute("DROP INDEX %s" % _quote_ident(idef.name))
            conn.execute(
                "UPDATE sqlite_master SET name=?, sql=? "
                "WHERE type='index' AND name=?",
                (idef.name, idef.ddl(idef.name), shadow),
            )
            sv = conn.execute("PRAGMA schema_version").fetchone()[0]
            conn.execute("PRAGMA schema_version = %d" % (sv + 1))
            conn.execute("COMMIT")
            log("  old index dropped cleanly; published atomically")
            return True
        except sqlite3.DatabaseError as exc:
            log("  clean DROP failed (%s); unlinking schema entry" % exc)
            try:
                conn.execute("ROLLBACK")
            except sqlite3.Error:
                pass

        # --- corruption-tolerant path: unlink the broken schema entry --------
        conn.execute("BEGIN IMMEDIATE")
        conn.execute(
            "DELETE FROM sqlite_master WHERE type='index' AND name=?", (idef.name,)
        )
        conn.execute(
            "UPDATE sqlite_master SET name=?, sql=? WHERE type='index' AND name=?",
            (idef.name, idef.ddl(idef.name), shadow),
        )
        sv = conn.execute("PRAGMA schema_version").fetchone()[0]
        conn.execute("PRAGMA schema_version = %d" % (sv + 1))
        conn.execute("COMMIT")
        log("  corrupt index unlinked and replacement published atomically")
        return False
    finally:
        conn.execute("PRAGMA writable_schema = OFF")


def _integrity(conn: sqlite3.Connection) -> tuple:
    try:
        rows = conn.execute("PRAGMA integrity_check").fetchall()
    except sqlite3.DatabaseError as exc:
        return False, str(exc)
    msgs = [r[0] for r in rows]
    if msgs == ["ok"]:
        return True, "ok"
    return False, "; ".join(msgs)


# ---------------------------------------------------------------------------
# Public repair entry point
# ---------------------------------------------------------------------------

def repair_index(db_path: str,
                 index_name: str,
                 *,
                 timeout: float = DEFAULT_TIMEOUT,
                 chunk_rows: int = DEFAULT_CHUNK_ROWS,
                 max_attempts: int = 3,
                 verify: bool = True,
                 vacuum_orphans: bool = False,
                 log: Optional[Callable[..., None]] = print) -> RepairResult:
    """Repair *index_name* online and return a :class:`RepairResult`.

    The database is opened in WAL mode.  The replacement is built as a shadow
    index, verified against a single consistent snapshot, and published in one
    write transaction.  Interruption at any point rolls back to a usable state.

    ``vacuum_orphans`` runs ``VACUUM`` afterwards to reclaim pages left by a
    physically corrupt b-tree; ``VACUUM`` needs exclusive access, so it is off
    by default for a purely online repair.
    """
    if log is None:
        log = lambda *_: None  # noqa: E731

    started = time.time()
    conn = connect(db_path, timeout=timeout)
    shadow = None
    try:
        row = conn.execute(
            "SELECT sql FROM sqlite_master WHERE type='index' AND name=?", (index_name,)
        ).fetchone()
        if row is None:
            raise IndexRepairError("index %r does not exist" % index_name)
        idef = parse_index_def(row[0])
        log("repairing index %r on table %r" % (idef.name, idef.table))

        concurrent = 0
        last_error = None
        for attempt in range(1, max_attempts + 1):
            shadow = "%s__repair_%s" % (index_name, uuid.uuid4().hex[:10])
            log("attempt %d: building shadow index %r" % (attempt, shadow))
            dv0 = _data_version(conn)
            t0 = time.time()
            conn.execute(idef.ddl(shadow))       # atomic, bounded-memory build
            if _data_version(conn) != dv0:
                concurrent += 1
            log("  built in %.3fs" % (time.time() - t0))

            try:
                if verify:
                    verify_shadow(conn, idef, shadow, chunk_rows, log)
                break
            except IndexRepairError as exc:
                last_error = exc
                log("  %s" % exc)
                conn.execute("DROP INDEX IF EXISTS %s" % _quote_ident(shadow))
                shadow = None
                if attempt == max_attempts:
                    raise
        else:  # pragma: no cover
            raise IndexRepairError("no valid index built: %s" % last_error)

        log("publishing %r" % idef.name)
        clean_drop = _publish(conn, idef, shadow, log)
        shadow = None  # ownership transferred to the published name

        ok, detail = _integrity(conn)
        orphan = (not ok) and ("never used" in detail)
        if vacuum_orphans and orphan:
            log("reclaiming orphaned pages with VACUUM")
            conn.execute("VACUUM")
            ok, detail = _integrity(conn)
            orphan = not ok
        if not ok:
            log("warning: integrity_check after publish: %s" % detail)

        return RepairResult(
            index=idef.name, table=idef.table, clean_drop=clean_drop,
            verification=detail, concurrent_commits=concurrent,
            seconds=time.time() - started, orphan_pages=orphan,
            notes=[] if ok else [detail],
        )
    finally:
        if shadow is not None:
            try:
                conn.execute("DROP INDEX IF EXISTS %s" % _quote_ident(shadow))
            except sqlite3.Error:
                pass
        conn.close()


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def _main(argv: list) -> int:
    p = argparse.ArgumentParser(description="Online repair of a SQLite secondary index")
    p.add_argument("db", help="database file")
    p.add_argument("index", help="index name to repair")
    p.add_argument("--vacuum", action="store_true",
                   help="reclaim orphaned pages with VACUUM (needs exclusive access)")
    p.add_argument("--no-verify", action="store_true", help="skip snapshot verification")
    p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
    args = p.parse_args(argv)

    res = repair_index(args.db, args.index, timeout=args.timeout,
                       verify=not args.no_verify, vacuum_orphans=args.vacuum)
    print(json.dumps(res.as_dict(), indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(_main(sys.argv[1:]))

2.2 Usage

python3 online_index_repair.py /path/to/app.db idx_email
python3 online_index_repair.py /path/to/app.db idx_email --vacuum
from online_index_repair import diagnose_index, repair_index
import sqlite3

conn = sqlite3.connect("app.db")
print(diagnose_index(conn, "idx_email"))   # -> {"errors": [...]}
conn.close()

result = repair_index("app.db", "idx_email")
print(result.as_dict())
# {'index': 'idx_email', 'clean_drop': True, 'verification': 'ok', ...}

repair_index is idempotent: running it on a healthy index simply rebuilds and republishes it.

3. Verification

3.1 What the tests prove

test corrupts asserts
test_parse_variants — quoted names, UNIQUE, partial WHERE, expression keys parse correctly
test_logical_repair removes an index leaf cell diagnose_index detects it; repair takes the clean DROP path; integrity_check == ok; index content equals table
test_physical_repair zeroes the index root page DROP/REINDEX would fail; repair unlinks the schema entry; VACUUM reclaims orphan pages and restores integrity_check == ok
test_unique_partial partial UNIQUE index predicate honoured during verification and repair
test_rowid_shadow_and_without_rowid user column named rowid; WITHOUT ROWID table correct hidden-rowid alias chosen (or omitted)
test_concurrent_writers_readers healthy index, live load ~9k writer commits and ~10k reader queries complete during the rebuild with zero errors; final integrity_check == ok
test_interruption_safety simulated Ctrl-C during verification no shadow left behind, old index intact, later repair succeeds
test_bounded_memory 20 000-row index Python peak memory ~1.5 MiB (rows streamed)

3.2 test_repair.py

The full test file is at ~/work/test_repair.py (372 lines). It uses two corruption injectors:

It then runs eight tests covering parsing, both corruption classes, partial/unique/expression indexes, rowid shadowing, WITHOUT ROWID, concurrent writers/readers, interruption, and bounded memory.

3.3 Observed result

[test_parse_variants]
  PASS: quoted identifiers parsed
  PASS: DESC stripped for verification: ['b COLLATE NOCASE']
  PASS: unique + partial parsed
  PASS: expression keys parsed: ['a', 'lower(b)']
[test_logical_repair]
  PASS: diagnose reports corruption: ['row-count mismatch: table=300 index=299']
  PASS: clean DROP path used
  PASS: index matches table after repair
  PASS: integrity_check ok (no orphans): [('ok',)]
  PASS: no orphan pages reported
[test_physical_repair]
  PASS: diagnose reports physical corruption
  PASS: unlink path used for corrupt b-tree
  PASS: index matches table after repair
  PASS: orphan pages detected (expected)
  PASS: index still matches after vacuum
  PASS: integrity_check ok after VACUUM: [('ok',)]
[test_unique_partial]
  PASS: partial unique index integrity ok: [('ok',)]
  PASS: partial unique index row count correct: 500
[test_rowid_shadow_and_without_rowid]
  PASS: rowid-shadowing table repaired
  PASS: WITHOUT ROWID table repaired
[test_concurrent_writers_readers]
  PASS: no writer/reader errors: []
  PASS: writers committed during rebuild (8863)
  PASS: readers served during rebuild (9712)
  PASS: index matches table after concurrent repair
  PASS: integrity ok after concurrent repair: [('ok',)]
  PASS: concurrent commit counter reported
[test_interruption_safety]
  PASS: repair was interrupted
  PASS: no shadow left behind, old index intact: ['idx_b']
  PASS: subsequent repair succeeds
[test_bounded_memory]
  PASS: python peak memory bounded: 1.5 MiB
  PASS: 20k-row index matches table
  PASS: 20k-row integrity ok (clean drop): [('ok',)]

ALL TESTS PASSED

3.4 Reproduce

python3 test_repair.py            # exits 0, prints ALL TESTS PASSED

4. Operational notes and limitations

Evidence & signatures

# Evidence
- Problem class: python-sqlite-online-index-repair
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-04T22:27:21.629Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement an online repair tool for a corrupted secondary index in a live SQLite database, where writers continue committing and the repair must tolerate interruption without blocking readers for the full rebuild. Use bounded memory, detect rows changed during the scan, and atomically publish only an index whose contents match a consistent database snapshot.", "environment": "python3", "language": "python", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "python-sqlite-online-index-repair", "provider": "openrouter", "solved_at": "2026-10-04T22:27:21.629Z", "version": "3.11"}
Generated from the verified corpus · MIT licensedBack to the catalog