makepad/libs/sqlite_query/tests/live_reader.rs
Admin ed5de46749 sqlite_query: derived tables get their real names, their predicates, and all their arms
Squashed from work; the fine-grained history is under tag archive/work-2026-08-26:
- store: the ceremony dies — batch publish, one transaction, and the engine stops re-reading its own log
- sqlite: derived tables get their real names, their predicates, and all their arms
2026-08-26 08:49:45 +02:00

286 lines
10 KiB
Rust

//! One long-lived reader against a live writer, in one process.
//!
//! This is the asset store's shape: the chat broker keeps a single read-only
//! [`Database`] for the life of the process and calls `refresh()` before every
//! statement, while the store — same process, same file — runs an annotation
//! pass that rewrites each record's `search_labels` rows (delete-all, then
//! re-insert a dozen brand-new distinct labels) in one transaction per record.
//! Hundreds of such transactions in a burst split index pages, free pages and
//! hand them back out again, and roll the write-ahead log over.
//!
//! The invariant under test is the one a query language rests on: a predicate
//! filters. A label that no row carries returns no rows, and a label two rows
//! carry returns those two — whatever the writer is doing at the time.
mod common;
use common::*;
use makepad_sqlite::{Connection, Database, Value};
use std::collections::BTreeSet;
use std::time::Duration;
const ASSETS: usize = 60;
const LABELS_PER_ASSET: usize = 12;
/// The broker's query, verbatim in shape: a join whose selectivity comes from
/// the label predicate alone.
const JOIN_SQL: &str = "SELECT a.canon_alias FROM search_annotations a \
JOIN search_labels l ON l.asset_id = a.asset_id \
WHERE a.live=1 AND a.kind='character' AND l.label IN (?1) LIMIT 20";
fn asset_id(i: usize) -> Value {
let mut b = vec![0u8; 16];
b[0..8].copy_from_slice(&(i as u64).to_be_bytes());
b[8..16].copy_from_slice(&(0xa5a5_0000_0000_0000u64 ^ i as u64).to_be_bytes());
Value::Blob(b)
}
fn alias(i: usize) -> String {
format!("asset-{i:05}")
}
/// The labels asset `i` carries in generation `g`. Every generation is a fresh
/// set of distinct strings, the way a vision pass hands out `vlm-age-*`,
/// `vlm-hair-*` and friends: the old keys are freed and the new ones split
/// pages somewhere else in the index.
fn labels(i: usize, g: usize) -> Vec<String> {
(0..LABELS_PER_ASSET)
.map(|k| format!("vlm-g{g:03}-{k}-{}", (i * 7 + k * 13) % 997))
.collect()
}
fn catalog_schema(w: &mut Connection) {
w.execute("PRAGMA journal_mode=WAL", &[]).unwrap();
w.execute(
"CREATE TABLE search_annotations(\
asset_id BLOB PRIMARY KEY, \
kind TEXT, \
live INTEGER NOT NULL, \
description TEXT NOT NULL, \
canon_alias TEXT NOT NULL DEFAULT '')",
&[],
)
.unwrap();
w.execute(
"CREATE TABLE search_labels(\
asset_id BLOB NOT NULL, \
kind TEXT NOT NULL, \
label TEXT NOT NULL, \
PRIMARY KEY(asset_id, kind, label))",
&[],
)
.unwrap();
w.execute(
"CREATE INDEX search_labels_by_label ON search_labels(kind, label)",
&[],
)
.unwrap();
w.execute(
"CREATE INDEX search_annotations_by_kind ON search_annotations(kind)",
&[],
)
.unwrap();
}
fn seed(w: &mut Connection) {
w.execute("BEGIN", &[]).unwrap();
for i in 0..ASSETS {
w.execute(
"INSERT INTO search_annotations(asset_id, kind, live, description, canon_alias) \
VALUES(?1,'character',1,?2,?3)",
&[
asset_id(i),
Value::text(format!("seed description for {i}")),
Value::text(alias(i)),
],
)
.unwrap();
for l in labels(i, 0) {
w.execute(
"INSERT INTO search_labels(asset_id, kind, label) VALUES(?1,'tag',?2)",
&[asset_id(i), Value::text(l)],
)
.unwrap();
}
}
w.execute("COMMIT", &[]).unwrap();
}
/// One annotation PUT: a whole-record rewrite in a single transaction.
fn put(w: &mut Connection, i: usize, g: usize) {
let r = (|| -> makepad_sqlite::Result<()> {
w.execute("BEGIN", &[])?;
w.execute(
"UPDATE search_annotations SET description = ?2 WHERE asset_id = ?1",
&[
asset_id(i),
Value::text(format!("generation {g} description for {i}")),
],
)?;
w.execute(
"DELETE FROM search_labels WHERE asset_id = ?1",
&[asset_id(i)],
)?;
for l in labels(i, g) {
w.execute(
"INSERT INTO search_labels(asset_id, kind, label) VALUES(?1,'tag',?2)",
&[asset_id(i), Value::text(l)],
)?;
}
w.execute("COMMIT", &[])?;
Ok(())
})();
if let Err(e) = r {
panic!("PUT of asset {i} in generation {g} failed: {e}");
}
}
fn check_integrity(w: &mut Connection, what: &str) {
let schema = w.schema().clone();
let report = makepad_sqlite::integrity::check(w.pager(), &schema, true).unwrap();
assert!(report.ok(), "{what}: {:?}", report.problems);
}
fn aliases_for_label(reader: &mut Database, label: &str) -> BTreeSet<String> {
reader
.query(JOIN_SQL, &[Value::text(label)])
.unwrap_or_else(|e| panic!("query for {label:?} failed: {e}"))
.rows
.iter()
.map(|r| r[0].as_text().unwrap_or_default().to_string())
.collect()
}
/// Rewriting a record's labels must leave the index describing the table.
///
/// Deleting a key that sits on an interior index page pulls its successor up
/// from a leaf. The path to that leaf names a child slot in every page it
/// crosses, and the slot in the page holding the separator is the separator's
/// own — one to the left of the subtree actually walked into. Emptying that
/// leaf then unlinked it using the wrong slot: the parent kept pointing at the
/// freed page and lost the pointer to a live subtree instead. The freed page is
/// zeroed and handed straight back out by the freelist, so an index scan walks
/// into a page belonging to some other b-tree.
#[test]
fn an_annotation_burst_keeps_the_index_describing_the_table() {
let scratch = Scratch::new("live-reader-burst");
let path = scratch.path("catalog.db");
let mut w = Connection::open(&path, Duration::from_secs(5)).unwrap();
catalog_schema(&mut w);
seed(&mut w);
check_integrity(&mut w, "after the seed");
// One label at a time, so the check lands between individual b-tree edits
// rather than after a whole statement.
for g in 1..=2usize {
for i in 0..ASSETS {
for (k, l) in labels(i, g - 1).into_iter().enumerate() {
w.execute(
"DELETE FROM search_labels WHERE asset_id = ?1 AND kind='tag' AND label = ?2",
&[asset_id(i), Value::text(&l)],
)
.unwrap_or_else(|e| panic!("delete {l:?} of asset {i}: {e}"));
check_integrity(&mut w, &format!("after deleting {l:?} (asset {i}, label {k})"));
}
for l in labels(i, g) {
w.execute(
"INSERT INTO search_labels(asset_id, kind, label) VALUES(?1,'tag',?2)",
&[asset_id(i), Value::text(l)],
)
.unwrap_or_else(|e| panic!("insert for asset {i}: {e}"));
}
check_integrity(&mut w, &format!("after rewriting asset {i} to generation {g}"));
}
let n = w.query("SELECT COUNT(*) FROM search_labels", &[]).unwrap();
assert_eq!(
n.rows[0][0].as_integer(),
Some((ASSETS * LABELS_PER_ASSET) as i64),
"generation {g}: the label count drifted"
);
}
// A connection owns the log for its whole life; the CLI cross-check runs
// after it closes and hands the log back.
drop(w);
if have_sqlite3() {
assert_eq!(sqlite3(&path, "PRAGMA integrity_check;\n").trim(), "ok");
}
}
/// The broker's loop: one handle, `refresh()` between statements, an
/// annotation burst underneath it.
#[test]
fn a_long_lived_reader_never_answers_from_a_stale_index() {
let scratch = Scratch::new("live-reader-loop");
let path = scratch.path("catalog.db");
let mut w = Connection::open(&path, Duration::from_secs(5)).unwrap();
catalog_schema(&mut w);
seed(&mut w);
let mut reader = Database::open(&path).unwrap();
let g0 = labels(0, 0);
assert_eq!(
aliases_for_label(&mut reader, &g0[0]),
BTreeSet::from([alias(0)]),
"the warm-up read is already wrong"
);
for generation in 1..=12usize {
for i in 0..ASSETS {
put(&mut w, i, generation);
}
reader.refresh().unwrap();
// A label no row carries any more must return nothing at all. This is
// the live failure: twenty arbitrary characters came back for a label
// that did not exist.
let gone = labels(0, generation - 1);
let got = aliases_for_label(&mut reader, &gone[0]);
assert!(
got.is_empty(),
"generation {generation}: no row carries {:?}, yet the reader returned {got:?}",
gone[0]
);
// And a label that does exist resolves to exactly its own asset.
for i in [0usize, ASSETS / 2, ASSETS - 1] {
let live = labels(i, generation);
assert_eq!(
aliases_for_label(&mut reader, &live[0]),
BTreeSet::from([alias(i)]),
"generation {generation}: {:?} belongs to {} alone",
live[0],
alias(i)
);
}
// The table side stayed fresh throughout the live failure while the
// label predicate did not filter, so it is checked separately.
let desc = reader
.query(
"SELECT description FROM search_annotations WHERE canon_alias = ?1",
&[Value::text(alias(3))],
)
.unwrap();
assert_eq!(
desc.rows[0][0].as_text(),
Some(format!("generation {generation} description for 3").as_str()),
"generation {generation}: the description is stale"
);
let n = reader
.query("SELECT COUNT(*) FROM search_labels", &[])
.unwrap();
assert_eq!(
n.rows[0][0].as_integer(),
Some((ASSETS * LABELS_PER_ASSET) as i64),
"generation {generation}: the label count drifted"
);
}
// A connection owns the log for its whole life; the CLI cross-check runs
// after both handles close and the log is handed back.
drop(reader);
drop(w);
if have_sqlite3() {
assert_eq!(sqlite3(&path, "PRAGMA integrity_check;\n").trim(), "ok");
}
}