From 874ea68deb0c471321974b31fd343258c4c89a2b Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:49:08 +0900 Subject: [PATCH] fix(db): index decision_ledger_anchors on (row_hash, status) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `anchorBackendsMissingForRowHash` (#9489) runs `SELECT DISTINCT backend FROM decision_ledger_anchors WHERE row_hash = ? AND status = 'ok' AND backend IN (...)` once per row being re-anchored, but the table had no index on row_hash — so each call was a full table scan that grows with the anchor ledger. Add migration 0203 creating `decision_ledger_anchors_row_hash_status (row_hash, status)` — row_hash first (the equality predicate), status included so the `status = 'ok'` filter is served from the index rather than a row fetch. Mirrors the decision_ledger_record_id index added in 0198. The migration contains nothing else. (Issue named 0202; that number is now taken, so this is the next contiguous 0203.) A new test asserts the index exists after replaying migrations and that the real query SEARCHes via it (not SCAN); the existing anchorBackendsMissingForRowHash behaviour cases confirm results are unchanged. Closes #9652 --- ..._decision_ledger_anchors_row_hash_index.sql | 6 ++++++ test/unit/ledger-anchor-persistence.test.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 migrations/0203_decision_ledger_anchors_row_hash_index.sql diff --git a/migrations/0203_decision_ledger_anchors_row_hash_index.sql b/migrations/0203_decision_ledger_anchors_row_hash_index.sql new file mode 100644 index 000000000..3dc33007c --- /dev/null +++ b/migrations/0203_decision_ledger_anchors_row_hash_index.sql @@ -0,0 +1,6 @@ +-- #9489: anchorBackendsMissingForRowHash queries +-- `SELECT DISTINCT backend FROM decision_ledger_anchors WHERE row_hash = ? AND status = 'ok' AND backend IN (...)` +-- once per row being re-anchored; without an index on (row_hash, status) that is a full table scan per call. +-- Lead with row_hash (the equality predicate) and include status so the `status = 'ok'` filter is served from +-- the index rather than a row fetch. Sibling precedent: decision_ledger_record_id in migrations/0198. +CREATE INDEX IF NOT EXISTS decision_ledger_anchors_row_hash_status ON decision_ledger_anchors (row_hash, status); diff --git a/test/unit/ledger-anchor-persistence.test.ts b/test/unit/ledger-anchor-persistence.test.ts index 5d4cbb828..014f2a7dd 100644 --- a/test/unit/ledger-anchor-persistence.test.ts +++ b/test/unit/ledger-anchor-persistence.test.ts @@ -240,4 +240,22 @@ describe("anchorBackendsMissingForRowHash (#9489)", () => { expect(await anchorBackendsMissingForRowHash(env, "hash-a", [])).toEqual([]); expect(spy).not.toHaveBeenCalled(); }); + + it("is served by the (row_hash, status) index rather than a full table scan (#9652)", async () => { + const env = createTestEnv(); + const idx = await env.DB.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = ?") + .bind("decision_ledger_anchors_row_hash_status") + .first<{ name: string }>(); + expect(idx?.name).toBe("decision_ledger_anchors_row_hash_status"); + + // The exact predicate anchorBackendsMissingForRowHash runs — must SEARCH via the new index, not SCAN. + const plan = await env.DB.prepare( + "EXPLAIN QUERY PLAN SELECT DISTINCT backend FROM decision_ledger_anchors WHERE row_hash = ? AND status = 'ok' AND backend IN ('rekor', 'git')", + ) + .bind("hash-a") + .all<{ detail: string }>(); + const detail = (plan.results ?? []).map((row) => row.detail).join(" "); + expect(detail).toContain("decision_ledger_anchors_row_hash_status"); + expect(detail).not.toContain("SCAN decision_ledger_anchors "); + }); });