fix(archive): reject a partial scope-age index in M4 shape validation

`pragma_index_info` reports an index's key columns but not whether it
carries a `WHERE` predicate, so a partial index with the exact expected
columns passed `scope_age_index_is_correct` and M4 wrote its marker over
it. That index cannot serve the unrestricted scope-age range scan the
Phase-2 prune query needs — SQLite falls back to the primary-key
autoindex — so the marker would certify a missing access path.

Probe `pragma_index_list`'s `partial` flag inside the same
`BEGIN IMMEDIATE` transaction and treat a partial named age index like
any other wrong shape: drop and rebuild it non-partial (an index carries
no data, so a rebuild is safe). A mutation-sensitive test precreates a
partial index with correct table and ordered columns and asserts M4
replaces it with a non-partial index before committing the marker.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-08-14 10:38:07 -04:00
co-authored by Will Pfleger
parent 85438f647b
commit f4621562e4
2 changed files with 68 additions and 2 deletions
+24 -2
View File
@@ -139,8 +139,9 @@ fn table_shape_matches(
/// Whether the scope-age index exists on `archived_event_scopes` with exactly
/// the expected key columns in order. False when the index is absent, sits on
/// the wrong table, or indexes the wrong columns — all of which M4 repairs by
/// dropping and recreating it (an index carries no data, so a rebuild is safe).
/// the wrong table, indexes the wrong columns, or is partial (carries a `WHERE`
/// predicate) — all of which M4 repairs by dropping and recreating it (an index
/// carries no data, so a rebuild is safe).
pub(super) fn scope_age_index_is_correct(conn: &Connection) -> Result<bool, String> {
let table: Option<String> = conn
.query_row(
@@ -154,6 +155,27 @@ pub(super) fn scope_age_index_is_correct(conn: &Connection) -> Result<bool, Stri
return Ok(false);
}
// A partial index — one carrying a `WHERE` predicate — can hold the exact
// expected key columns yet still fail to serve the unrestricted scope-age
// range scan the prune query needs (SQLite falls back to the PK autoindex).
// `pragma_index_info` reports key columns but NOT partiality, so probe
// `pragma_index_list`'s `partial` flag explicitly and treat a partial index
// like any other wrong shape (drop + rebuild non-partial). Absent from the
// list (should not happen after the table check above) fails closed.
let is_partial = conn
.query_row(
"SELECT partial FROM pragma_index_list('archived_event_scopes') WHERE name = ?1",
params![SCOPE_AGE_INDEX_NAME],
|r| r.get::<_, i64>(0),
)
.optional()
.map_err(|e| format!("shape check: scope-age index partiality: {e}"))?
.map(|p| p != 0)
.unwrap_or(true);
if is_partial {
return Ok(false);
}
let mut stmt = conn
.prepare(&format!(
"SELECT name FROM pragma_index_info('{SCOPE_AGE_INDEX_NAME}') ORDER BY seqno"
@@ -466,6 +466,50 @@ fn test_m4_wrong_index_order_dropped_and_rebuilt() {
);
}
#[test]
fn test_m4_partial_age_index_dropped_and_rebuilt_non_partial() {
let db = NamedTempFile::new().unwrap();
build_pre_m4_db(db.path());
// Precreate the scope-age index with the expected NAME, right table, and
// EXACT key order — but a `WHERE` predicate making it PARTIAL. It holds the
// right columns, so `pragma_index_info` (key columns only) cannot tell it
// apart from the required index; only the partiality probe catches it. A
// partial index cannot serve the unrestricted prune-age range scan, so M4
// must treat it like any other wrong shape and rebuild it non-partial.
{
let conn = Connection::open(db.path()).unwrap();
conn.execute_batch(&format!(
"CREATE INDEX {SCOPE_AGE_INDEX_NAME}
ON archived_event_scopes
(identity_pubkey, relay_url, scope_type, scope_value, archived_at, id)
WHERE archived_at > 1000;"
))
.unwrap();
// The validator must reject the partial index BEFORE M4 runs — this is
// the exact check that fails without the partiality probe.
assert!(
!scope_age_index_is_correct(&conn).unwrap(),
"a partial index with correct columns must not be certified"
);
}
// M4 rebuilds the partial index into a non-partial one, then the marker lands.
let conn = fresh(&db);
assert_eq!(m4_marker_count(&conn), 1, "M4 completes after the rebuild");
assert!(
scope_age_index_is_correct(&conn).unwrap(),
"the index was rebuilt non-partial with the correct key order"
);
// Prove the rebuilt index carries no `WHERE` predicate.
let partial: i64 = conn
.query_row(
"SELECT partial FROM pragma_index_list('archived_event_scopes') WHERE name = ?1",
params![SCOPE_AGE_INDEX_NAME],
|r| r.get(0),
)
.unwrap();
assert_eq!(partial, 0, "the rebuilt index must not be partial");
}
// ── Concurrency ───────────────────────────────────────────────────────────────
#[test]