From 6332354b43553c221e89ef2495081d2a66856107 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:55:57 -0700 Subject: [PATCH 001/315] =?UTF-8?q?test(I15-review):=20probes=20for=20the?= =?UTF-8?q?=20DDL=20pin=20review=20=E2=80=94=20REVERT/MERGE=20escapes,=20f?= =?UTF-8?q?loor=20strip,=20delete=20unlock,=20tautology?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/i15_probe.rs | 347 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 tests/i15_probe.rs diff --git a/tests/i15_probe.rs b/tests/i15_probe.rs new file mode 100644 index 00000000..8ad36cae --- /dev/null +++ b/tests/i15_probe.rs @@ -0,0 +1,347 @@ +//! B3 — the durable capability envelope at the write funnel. +//! +//! # The gap this closes +//! +//! Every policy verb in this runtime lives in an in-memory `Mutex`: merge policy, escrow +//! claims, quarantine reasons. Restarting the database silently un-governs every running agent, +//! and there was no scope at all on WHAT a session may touch — an agent session could write any +//! table in the database. +//! +//! The envelope is a default-deny allowlist — writable tables, writable columns, a floor per +//! column, permitted verbs, and a row-write budget — held in the branch's **own durable record** +//! and enforced at `AgentRuntime::stage_all`, which is already the single funnel every branch +//! write passes through and is already statement-atomic. The refusal inherits that atomicity. +//! +//! # Everything here is decided on the after-image +//! +//! Not on the shape of the op, and not on the SQL keyword. That is the rule the funnel already +//! learned the expensive way: a bound keyed on `Add(negative)` let `SET qty = -100` walk past the +//! floor as a plain `Assign`, and a float decrement slipped through the same gap. Two tests below +//! name the shape that would fail without it — +//! `an_assignment_that_lowers_a_bounded_value_is_refused_not_just_a_decrement` and +//! `an_insert_cannot_write_a_column_the_branch_was_never_granted`, the latter because an INSERT's +//! `Op` carries `col: None` and therefore names no column at all. + +use std::fs::OpenOptions; +use std::path::PathBuf; +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::{table_id, AgentRuntime}; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::BranchId; +use ferrodb::branch::{BranchCatalog, CapabilityEnvelope, ColumnCapability, Verb}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::recovery::recover; +use ferrodb::wal::txn::TxnManager; + +/// `inventory (id INTEGER, qty INTEGER)` — column indices as the envelope names them. +const ID: u32 = 0; +const QTY: u32 = 1; + +/// A database whose branch metadata, heap and WAL all live in one directory, so the whole thing +/// can be dropped and reopened from the files alone. +struct Db { + dir: tempfile::TempDir, + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let (catalog, bp, txn, runtime) = Self::attach(dir.path().to_path_buf(), true); + Db { dir, catalog, bp, txn, runtime } + } + + fn attach( + dir: PathBuf, + fresh: bool, + ) -> (Catalog, Arc, Arc, Arc) { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(fresh) + .open(dir.join("envelope.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + + let wal = Arc::new(WalManager::new(dir.join("envelope.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + // The sequence the CLI uses on open: recover the WAL BEFORE reading the catalog. Skipping + // it reattaches to a heap whose committed rows were never replayed, and every table reads + // back empty — measured, and the reason this line is here rather than assumed. + recover(&txn).unwrap(); + let catalog = if fresh { + Catalog::create(bp.clone()).unwrap() + } else { + Catalog::open(bp.clone(), 1).unwrap() + }; + let branches = Arc::new(LogBranchCatalog::open(&dir.join("branches.log"), 1).unwrap()); + let runtime = + Arc::new(AgentRuntime::with_catalog(Arc::clone(&branches) as Arc)); + (catalog, bp, txn, runtime) + } + + /// **Close the database and open it again from the files.** Every in-memory structure — the + /// runtime and its `Mutex`, the branch catalog and its record index, the buffer pool — + /// is dropped. Anything that survives came off disk. + fn reopen(self) -> Db { + self.bp.flush_all().unwrap(); + let Db { dir, catalog, bp, txn, runtime } = self; + drop(catalog); + drop(txn); + drop(runtime); + drop(bp); + let (catalog, bp, txn, runtime) = Self::attach(dir.path().to_path_buf(), false); + Db { dir, catalog, bp, txn, runtime } + } + + /// Drop the runtime, its `Mutex` and the branch catalog, and reopen the catalog from + /// `branches.log`. The heap, WAL and SQL catalog stay attached, so the write path still works + /// end to end while everything the envelope could have been cached in is gone. + fn restart_branch_metadata(&mut self) { + let path = self.dir.path().join("branches.log"); + self.runtime = Arc::new(AgentRuntime::new()); // drops the old runtime and its catalog + let branches = Arc::new(LogBranchCatalog::open(&path, 1).unwrap()); + self.runtime = + Arc::new(AgentRuntime::with_catalog(Arc::clone(&branches) as Arc)); + } + + fn session(&self) -> Session { + Session::with_runtime(self.runtime.clone()) + } + + fn exec(&mut self, sql: &str, s: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(FerroError::SqlParseError( + parser.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {}", sql); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + } + + fn ok(&mut self, sql: &str, s: &mut Session) -> Outcome { + self.exec(sql, s).unwrap_or_else(|e| panic!("{sql} failed: {e}")) + } + + /// The refusal text, or a panic naming the statement that was let through. + fn refused(&mut self, sql: &str, s: &mut Session) -> String { + match self.exec(sql, s) { + Err(e) => e.to_string(), + Ok(_) => panic!("the envelope let `{sql}` through"), + } + } + + fn seed(&mut self) { + let mut s = self.session(); + self.ok("CREATE TABLE inventory (id INTEGER NOT NULL, qty INTEGER);", &mut s); + self.ok("CREATE TABLE payroll (id INTEGER NOT NULL, salary INTEGER);", &mut s); + self.ok("INSERT INTO inventory VALUES (1, 20);", &mut s); + self.ok("INSERT INTO inventory VALUES (2, 20);", &mut s); + self.ok("INSERT INTO inventory VALUES (3, 20);", &mut s); + self.ok("INSERT INTO payroll VALUES (1, 1000);", &mut s); + } + + fn qty(&mut self, id: i32) -> i32 { + let mut s = self.session(); + match self.ok(&format!("SELECT qty FROM inventory WHERE id = {id};"), &mut s) { + Outcome::Rows(rows) => match rows.first().and_then(|r| r.first()) { + Some(Value::Integer(i)) => *i, + other => panic!("unexpected qty: {other:?}"), + }, + _ => panic!("expected rows"), + } + } + + fn salary(&mut self, id: i32) -> i32 { + let mut s = self.session(); + match self.ok(&format!("SELECT salary FROM payroll WHERE id = {id};"), &mut s) { + Outcome::Rows(rows) => match rows.first().and_then(|r| r.first()) { + Some(Value::Integer(i)) => *i, + other => panic!("unexpected salary: {other:?}"), + }, + _ => panic!("expected rows"), + } + } + + fn count(&mut self, table: &str) -> usize { + let mut s = self.session(); + match self.ok(&format!("SELECT id FROM {table};"), &mut s) { + Outcome::Rows(rows) => rows.len(), + _ => panic!("expected rows"), + } + } + + /// Open an agent session and return its branch. + fn begin(&mut self, agent: &str, s: &mut Session) -> BranchId { + self.ok(&format!("BEGIN AGENT SESSION AS '{agent}' RUN 'r_{agent}';"), s); + s.agent.as_ref().unwrap().branch + } +} + +/// Everything on `inventory`, nothing anywhere else, no floor, ample budget. +fn inventory_only() -> CapabilityEnvelope { + CapabilityEnvelope::new(Verb::ALL, 1_000).allow( + table_id("inventory").0, + vec![ColumnCapability::open(ID), ColumnCapability::open(QTY)], + ) +} + + +// ================= I15 review probes ================= + +/// PROBE 1: REVERT MERGE inside a governed agent session rewrites rows of a table the envelope +/// forbids. This is a DATA write to the shared catalog, not a schema change. +#[test] +fn probe_revert_merge_rewrites_a_forbidden_table() { + let mut db = Db::new(); + db.seed(); + + // An ungoverned session merges a change to payroll, producing a merge id. + let mut w = db.session(); + db.begin("writer", &mut w); + db.ok("UPDATE payroll SET salary = 4242 WHERE id = 1;", &mut w); + let merge_id = match db.ok("MERGE;", &mut w) { + Outcome::Agent(a) => format!("{a}"), + _ => panic!("merge"), + }; + println!("merge output: {merge_id}"); + assert_eq!(db.salary(1), 4242); + + // Now govern trunk: inventory only. + db.runtime.restrict_branch(BranchId::TRUNK, inventory_only()).unwrap(); + let mut a = db.session(); + let branch = db.begin("reverter", &mut a); + let err = db.refused("UPDATE payroll SET salary = 0 WHERE id = 1;", &mut a); + assert!(err.contains("may not write table `payroll`"), "got {err}"); + + // find the merge id string (m_1 etc.) + let id = merge_id + .split_whitespace() + .find(|t| t.starts_with("m_")) + .map(|s| s.trim_end_matches(|c: char| !c.is_alphanumeric()).to_string()) + .expect("no merge id in report"); + println!("reverting {id}"); + let out = db.exec(&format!("REVERT MERGE {id} CASCADE;"), &mut a); + println!("revert result: {:?}", out.as_ref().map(|_| "ok").map_err(|e| e.to_string())); + assert!(out.is_ok(), "revert refused: {:?}", out.err().map(|e| e.to_string())); + + let after = db.salary(1); + println!("payroll.salary after revert from a governed session = {after}"); + assert_eq!( + after, 1000, + "REVERT from a governed session did NOT change the forbidden table" + ); + // and nothing was charged + let env = db.runtime.envelope_of(branch).unwrap().unwrap(); + println!("row_writes after the revert = {}", env.row_writes()); + assert_eq!(env.row_writes(), 0, "the revert was charged after all"); +} + +/// PROBE 2: the tautology. `assert_eq!(table_id(x), table_id(x))` holds for a name that never +/// existed, so it says nothing about the drop/recreate. +#[test] +fn probe_table_id_assertion_is_tautological() { + assert_eq!(table_id("never_existed_anywhere"), table_id("never_existed_anywhere")); +} + +/// PROBE 3: DROP+CREATE with the columns REORDERED strips a floor off the column it guarded. +#[test] +fn probe_drop_create_strips_a_floor() { + let mut db = Db::new(); + db.seed(); + // qty is floored at 0; id is open. + let env = CapabilityEnvelope::new(Verb::ALL, 1_000).allow( + table_id("inventory").0, + vec![ColumnCapability::open(ID), ColumnCapability::floored(QTY, 0)], + ); + db.runtime.restrict_branch(BranchId::TRUNK, env).unwrap(); + + let mut a = db.session(); + db.begin("floorbreaker", &mut a); + // The floor is live. + let err = db.refused("UPDATE inventory SET qty = -999 WHERE id = 1;", &mut a); + println!("floor refusal: {err}"); + assert!(err.contains("floor"), "got {err}"); + + // Two ungoverned verbs: same table name, same column names, swapped order. + db.ok("DROP TABLE inventory;", &mut a); + db.ok("CREATE TABLE inventory (qty INTEGER NOT NULL, id INTEGER);", &mut a); + db.ok("INSERT INTO inventory VALUES (-999, 1);", &mut a); + match db.ok("SELECT qty, id FROM inventory;", &mut a) { + Outcome::Rows(rows) => println!("after substitution, branch sees {rows:?}"), + _ => panic!("rows"), + } + // If this passes, the floor granted over `qty` no longer guards `qty`. +} + +/// PROBE 4: a DELETE that the column allowlist refused becomes admissible once the table is +/// recreated with only the granted column. +#[test] +fn probe_drop_create_unlocks_a_refused_delete() { + let mut db = Db::new(); + db.seed(); + // DELETE granted, but only column 0 is on the allowlist -> every delete is refused. + let env = CapabilityEnvelope::new(Verb::ALL, 1_000) + .allow(table_id("inventory").0, vec![ColumnCapability::open(ID)]); + db.runtime.restrict_branch(BranchId::TRUNK, env).unwrap(); + + let mut a = db.session(); + db.begin("deleter", &mut a); + let err = db.refused("DELETE FROM inventory WHERE id = 1;", &mut a); + println!("delete refusal: {err}"); + assert!(err.contains("column 1"), "got {err}"); + + db.ok("DROP TABLE inventory;", &mut a); + db.ok("CREATE TABLE inventory (id INTEGER NOT NULL);", &mut a); + db.ok("INSERT INTO inventory VALUES (1);", &mut a); + let out = db.exec("DELETE FROM inventory WHERE id = 1;", &mut a); + println!("delete after substitution: {:?}", out.as_ref().map(|_| "OK").map_err(|e| e.to_string())); + assert!(out.is_ok(), "still refused: {:?}", out.err().map(|e| e.to_string())); +} + +/// PROBE 5: a governed session can MERGE and ABANDON a branch it does not own, publishing or +/// destroying another agent's work — neither is on the DDL pin's list. +#[test] +fn probe_governed_session_merges_a_foreign_branch() { + let mut db = Db::new(); + db.seed(); + + // An ungoverned worker stages a change to payroll and leaves it unmerged. + let mut w = db.session(); + let victim = db.begin("victim", &mut w); + db.ok("UPDATE payroll SET salary = 1 WHERE id = 1;", &mut w); + let victim_name = format!("b_{}", victim.id); + assert_eq!(db.salary(1), 1000, "the victim's write must still be private"); + + // Now govern trunk and open a governed session. + db.runtime.restrict_branch(BranchId::TRUNK, inventory_only()).unwrap(); + let mut a = db.session(); + db.begin("thief", &mut a); + let err = db.refused("UPDATE payroll SET salary = 0 WHERE id = 1;", &mut a); + assert!(err.contains("may not write table `payroll`"), "got {err}"); + + let out = db.exec(&format!("MERGE BRANCH {victim_name};"), &mut a); + println!("foreign merge: {:?}", out.as_ref().map(|_| "OK").map_err(|e| e.to_string())); + assert!(out.is_ok(), "refused: {:?}", out.err().map(|e| e.to_string())); + let after = db.salary(1); + println!("payroll.salary after a governed session merged a foreign branch = {after}"); + assert_eq!(after, 1, "the foreign merge did not publish"); +} From 8cf4cf9867cec0f6965df9a1626459a9d6371ce5 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:43:23 -0700 Subject: [PATCH 002/315] test(I19-atk2): attack the ALTER-refusal guard's premise; t5 destroys a row on a failed ALTER Adversarial pass in a fresh worktree against 19b1de2. t1/t2/t4 confirm the guard's stated premise and its boundary; t3's fixture never reached its condition; t5 FAILS and is the finding: HeapFileManager::update still deletes before inserting for every insert failure that is not the size case, so when pass 2's relocation cannot allocate a page (DiskManager arena floor reached) the row is destroyed durably while the ALTER reports an error. --- tests/zz_atk2_guard_premise.rs | 466 +++++++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 tests/zz_atk2_guard_premise.rs diff --git a/tests/zz_atk2_guard_premise.rs b/tests/zz_atk2_guard_premise.rs new file mode 100644 index 00000000..9e345ebf --- /dev/null +++ b/tests/zz_atk2_guard_premise.rs @@ -0,0 +1,466 @@ +//! ATK2: attack the premise under I19's ALTER-refusal guard. +//! +//! The guard rests on: "`HeapFileManager::insert` can always place any tuple of `MAX_TUPLE_SIZE` +//! bytes or fewer." Four fixtures: +//! t1 boundary agreement between MAX_TUPLE_SIZE, Page::insert and find_page_with_space +//! t2 the premise after heavy churn on a heap whose page DIRECTORY had to chain +//! t3 pass 2 failing AFTER pass 1 passed, via a starved buffer pool (the stated blind spot) +//! t4 over-refusal: the widest ADD COLUMN that succeeds vs the narrowest that is refused + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::storage::heap_page::{Page, MAX_TUPLE_SIZE}; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::storage::page_directory::PageDirectory; +use ferrodb::storage::tuple::Tuple; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +// ---------------------------------------------------------------- raw storage harness + +fn raw_pool() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true).write(true).create(true).open(dir.path().join("raw.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + (dir, bp) +} + +fn body(n: usize, seed: u8) -> Tuple { + Tuple::new((0..n).map(|i| (i as u8).wrapping_add(seed)).collect()) +} + +/// (page_id, what the directory claims is free, what the page actually has free) +fn dir_vs_pages(bp: &Arc, first_dir: u32) -> Vec<(u32, i64, i64)> { + let mut out = Vec::new(); + let mut dir_id = first_dir; + loop { + let fi = bp.fetch_page(dir_id).unwrap(); + let dir = PageDirectory::deserialize(bp.frames[fi].read().unwrap().data); + bp.unpin_page(dir_id, false); + for e in &dir.entries { + let pi = bp.fetch_page(e.page_id).unwrap(); + let page = Page::deserialize(bp.frames[pi].read().unwrap().data).unwrap(); + bp.unpin_page(e.page_id, false); + let actual = page.get_free_space_end() as i64 - page.get_free_space_start() as i64; + out.push((e.page_id, e.free_space as i64, actual)); + } + if dir.next_page_directory == 0 { return out } + dir_id = dir.next_page_directory; + } +} + +fn count_dir_pages(bp: &Arc, first_dir: u32) -> usize { + let mut n = 0; + let mut dir_id = first_dir; + loop { + n += 1; + let fi = bp.fetch_page(dir_id).unwrap(); + let dir = PageDirectory::deserialize(bp.frames[fi].read().unwrap().data); + bp.unpin_page(dir_id, false); + if dir.next_page_directory == 0 { return n } + dir_id = dir.next_page_directory; + } +} + +/// The premise, stated as a probe: every size up to MAX_TUPLE_SIZE must be insertable, whatever +/// state the heap is in, because `insert` allocates a fresh page when no existing page has room. +fn probe_premise(heap: &HeapFileManager, label: &str) -> Vec<(usize, String)> { + let mut broken = Vec::new(); + for n in [MAX_TUPLE_SIZE, MAX_TUPLE_SIZE - 1, 4000, 3000, 2048, 1360, 1024, 100, 10, 1] { + match heap.insert(body(n, 0xAB)) { + Ok(rid) => { + let got = heap.read(rid).unwrap().data.len(); + if got != n { broken.push((n, format!("{label}: read back {got} bytes"))) } + } + Err(e) => broken.push((n, format!("{label}: insert({n}) -> {e}"))), + } + } + broken +} + +// ---------------------------------------------------------------- SQL harness (ported) + +struct Db { + dir: tempfile::TempDir, + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { open_at(tempfile::tempdir().unwrap()) } + +fn open_at(dir: tempfile::TempDir) -> Db { + let path = dir.path().join("alter.db"); + let fresh = !path.exists(); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = if fresh { Catalog::create(bp.clone()).unwrap() } else { Catalog::open(bp.clone(), 1).unwrap() }; + let wal = Arc::new(WalManager::new(dir.path().join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError(p.errors.iter().map(|e| e.to_string()).collect::>().join("; "))); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn rows(&mut self, sql: &str) -> Result>, String> { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.try_sql(sql))) + .map_err(|_| format!("`{sql}` PANICKED the reading thread"))?; + match out { + Ok(Outcome::Rows(r)) => Ok(r), + Ok(_) => Err(format!("`{sql}` did not return rows")), + Err(e) => Err(format!("`{sql}` errored: {e}")), + } + } + fn shape(&self, table: &str) -> Vec<(String, DataType)> { + self.catalog.get_table(table).unwrap().schema.columns.iter() + .map(|c| (c.name.clone(), c.data_type.clone())).collect() + } + fn heap(&self, table: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(table).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()).scan() + .map(|r| { let (rid, t) = r.unwrap(); (rid, t.data) }).collect() + } + fn by_key(&self, table: &str, keys: &[Value]) -> Vec<(Value, Option)> { + let root = self.catalog.get_table(table).unwrap().primary_index_root; + let ix = BPlusTreeManager::::open(root, self.bp.clone()); + keys.iter().map(|k| (k.clone(), ix.search(k).unwrap())).collect() + } +} + +#[derive(Debug, PartialEq)] +struct Snapshot { + shape: Vec<(String, DataType)>, + heap: Vec<(RecordId, Vec)>, + rows: Result>, String>, + by_key: Vec<(Value, Option)>, + primary_root: u32, +} + +fn snapshot(d: &mut Db, table: &str, select: &str, keys: &[Value]) -> Snapshot { + Snapshot { + shape: d.shape(table), + heap: d.heap(table), + rows: d.rows(select), + by_key: d.by_key(table, keys), + primary_root: d.catalog.get_table(table).unwrap().primary_index_root, + } +} + +// ---------------------------------------------------------------- t1 + +#[test] +fn t1_max_tuple_size_agrees_with_every_gate_it_has_to_agree_with() { + // Page::insert, both directions, on a genuinely empty page. + let mut p = Page::empty(9); + assert!(p.insert(body(MAX_TUPLE_SIZE, 1)).is_ok(), "Page::insert refused MAX_TUPLE_SIZE"); + let mut p = Page::empty(9); + match p.insert(body(MAX_TUPLE_SIZE + 1, 1)) { + Err(FerroError::NotEnoughSpace) => {} + other => panic!("Page::insert accepted MAX_TUPLE_SIZE+1: {other:?}"), + } + + // Page::update's in-place grow branch checks `>= new_len` with NO slot-entry term. Prove it + // still cannot exceed MAX_TUPLE_SIZE: put one tuple on a page, then grow it as far as it goes. + let mut p = Page::empty(9); + p.insert(body(1, 2)).unwrap(); + let mut widest_in_place = 0usize; + for n in 1..=(MAX_TUPLE_SIZE + 8) { + let mut q = Page::empty(9); + q.insert(body(1, 2)).unwrap(); + if q.update(0, body(n, 3)).is_ok() { widest_in_place = n } + } + println!("t1: widest tuple Page::update can grow into a 1-tuple page = {widest_in_place} (MAX_TUPLE_SIZE={MAX_TUPLE_SIZE})"); + assert!(widest_in_place <= MAX_TUPLE_SIZE, + "Page::update stored {widest_in_place} bytes, above MAX_TUPLE_SIZE {MAX_TUPLE_SIZE}"); + + // HeapFileManager::insert, fresh heap: the premise itself. + let (_d, bp) = raw_pool(); + let heap = HeapFileManager::new(bp.clone()).unwrap(); + let dir_entries_before = dir_vs_pages(&bp, heap.first_directory_page_id).len(); + assert_eq!(heap.find_page_with_space(MAX_TUPLE_SIZE as u16 + 4).unwrap(), None, + "a heap with no data pages claimed to have room"); + let broken = probe_premise(&heap, "fresh heap"); + assert!(broken.is_empty(), "premise broken on a fresh heap: {broken:?}"); + + // One over: refused, and note whether the refusal leaks the page it allocated. + let before = dir_vs_pages(&bp, heap.first_directory_page_id); + let over = heap.insert(body(MAX_TUPLE_SIZE + 1, 4)); + let after = dir_vs_pages(&bp, heap.first_directory_page_id); + println!("t1: insert(MAX_TUPLE_SIZE+1) -> {over:?}"); + println!("t1: directory pages entries before={} after={} (start {})", + before.len(), after.len(), dir_entries_before); + assert!(over.is_err(), "heap accepted a tuple of MAX_TUPLE_SIZE+1 bytes"); + + // Directory must never claim more free space than the page has. + let over_states: Vec<_> = after.iter().filter(|(_, d, a)| d > a).collect(); + println!("t1: directory entries (page, claimed, actual) = {after:?}"); + assert!(over_states.is_empty(), "directory OVERSTATES free space: {over_states:?}"); +} + +// ---------------------------------------------------------------- t2 + +#[test] +fn t2_premise_survives_churn_on_a_heap_whose_directory_had_to_chain() { + let (_d, bp) = raw_pool(); + let heap = HeapFileManager::new(bp.clone()).unwrap(); + + // 1350-byte tuples pack 3 to a page, leaving 11 bytes: growing any of them relocates. + let n = 2200usize; + let mut rids: Vec = Vec::with_capacity(n); + for i in 0..n { rids.push(heap.insert(body(1350, i as u8)).unwrap()) } + let dir_pages = count_dir_pages(&bp, heap.first_directory_page_id); + let entries = dir_vs_pages(&bp, heap.first_directory_page_id); + println!("t2: {n} tuples -> {} data pages across {dir_pages} directory page(s)", entries.len()); + assert!(dir_pages > 1, "fixture never chained the directory ({dir_pages} page) - it measures nothing"); + + // Grow every tuple. Every one of these must relocate (page free space is 11 bytes). + let mut relocated = 0usize; + for i in 0..n { + let new_rid = heap.update(rids[i], body(1360, i as u8)) + .unwrap_or_else(|e| panic!("update #{i} failed: {e}")); + if new_rid != rids[i] { relocated += 1 } + rids[i] = new_rid; + } + println!("t2: {relocated}/{n} tuples relocated"); + assert!(relocated > n / 2, "fixture forced almost no relocations ({relocated}) - it measures nothing"); + + // Every tuple still readable and correct. + for i in 0..n { + let got = heap.read(rids[i]).unwrap().data; + assert_eq!(got, body(1360, i as u8).data, "tuple {i} came back wrong after relocation"); + } + + let entries = dir_vs_pages(&bp, heap.first_directory_page_id); + let overs: Vec<_> = entries.iter().filter(|(_, d, a)| d > a).collect(); + let unders = entries.iter().filter(|(_, d, a)| d < a).count(); + println!("t2: {} data pages, {} directory entries OVERSTATE free space, {} understate", + entries.len(), overs.len(), unders); + if !overs.is_empty() { + println!("t2: worst overstatements: {:?}", &overs[..overs.len().min(10)]); + } + + // The premise, after all that churn. + let broken = probe_premise(&heap, "churned heap"); + println!("t2: premise probe -> {broken:?}"); + assert!(broken.is_empty(), "premise broken after churn: {broken:?}"); + assert!(overs.is_empty(), "directory OVERSTATES free space after churn: {:?}", &overs[..overs.len().min(10)]); +} + +// ---------------------------------------------------------------- t3 + +/// Pin every frame but `leave`, using only public API. Returns the pages still pinned. +fn starve(bp: &Arc, leave: usize) -> Vec { + let mut spare = Vec::new(); + for _ in 0..1200 { + match bp.new_page() { Ok(p) => spare.push(p), Err(_) => break } + } + let mut pinned = Vec::new(); + for p in spare { + match bp.fetch_page(p) { Ok(_) => pinned.push(p), Err(_) => break } + } + for _ in 0..leave { + if let Some(p) = pinned.pop() { bp.unpin_page(p, false) } + } + pinned +} + +fn release(bp: &Arc, pinned: Vec) { + for p in pinned { bp.unpin_page(p, false) } +} + +#[test] +fn t3_pass2_failing_after_pass1_passed_under_a_starved_buffer_pool() { + let select = "SELECT id FROM t;"; + let keys: Vec = (1..=8).map(Value::Integer).collect(); + let mut any_pass2_failure = false; + + for leave in [1usize, 2, 3, 4, 6, 8] { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(60));"); + for i in 1..=400 { d.sql(&format!("INSERT INTO t VALUES ({i}, 'abcdefghijklmnopqrstuvwxyz0123');")); } + d.txn.checkpoint().unwrap(); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + + let before = snapshot(&mut d, "t", select, &keys); + let pinned = starve(&d.bp, leave); + let n_pinned = pinned.len(); + let res = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + release(&d.bp, pinned); + + let after = snapshot(&mut d, "t", select, &keys); + let heap_changed = before.heap != after.heap; + let shape_changed = before.shape != after.shape; + println!("t3: leave={leave} pinned={n_pinned} alter={:?} heap_changed={heap_changed} shape_changed={shape_changed}", + res.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + + if res.is_err() { + if heap_changed { + any_pass2_failure = true; + let diff = before.heap.iter().zip(after.heap.iter()).filter(|(a, b)| a != b).count(); + println!("t3: !!! REFUSED ALTER CHANGED THE HEAP. rows before={} after={} differing_positions={diff}", + before.heap.len(), after.heap.len()); + println!("t3: shape before={:?} after={:?}", before.shape, after.shape); + println!("t3: rows before ok={} after ok={}", before.rows.is_ok(), after.rows.is_ok()); + if before.rows.is_ok() != after.rows.is_ok() { println!("t3: after rows = {:?}", after.rows); } + let bad_keys: Vec<_> = before.by_key.iter().zip(after.by_key.iter()) + .filter(|(a, b)| a != b).take(5).collect(); + println!("t3: primary-index answers that changed: {bad_keys:?}"); + + // Durability: does it survive checkpoint + flush + reopen into a fresh pool? + d.txn.checkpoint().unwrap(); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + let dir = d.dir; + let mut r = open_at(dir); + let reopened = snapshot(&mut r, "t", select, &keys); + println!("t3: after checkpoint+flush+reopen: heap_still_differs={} rows_ok={} len={}", + reopened.heap != before.heap, reopened.rows.is_ok(), reopened.heap.len()); + } else { + println!("t3: leave={leave}: failed with the heap untouched (pass 1 or earlier) - safe"); + } + } + } + println!("t3: any pass-2 failure observed = {any_pass2_failure}"); +} + +// ---------------------------------------------------------------- t4 + +#[test] +fn t4_the_refusal_boundary_is_not_an_over_refusal() { + let mut widest_ok: Option<(usize, usize)> = None; // (varchar len, resulting max tuple bytes) + let mut narrowest_refused: Option<(usize, String)> = None; + + for l in 4020..4080usize { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, a VARCHAR(4100));"); + let big = "x".repeat(l); + if d.try_sql(&format!("INSERT INTO t VALUES (1, '{big}');")).is_err() { continue } + match d.try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);") { + Ok(_) => { + let widest = d.heap("t").iter().map(|(_, b)| b.len()).max().unwrap(); + widest_ok = Some((l, widest)); + } + Err(e) => { + if narrowest_refused.is_none() { narrowest_refused = Some((l, e.to_string())) } + } + } + } + + let (ok_l, ok_bytes) = widest_ok.expect("no width in the sweep produced a SUCCESSFUL alter"); + let (bad_l, msg) = narrowest_refused.expect("no width in the sweep produced a REFUSED alter"); + println!("t4: widest accepted VARCHAR len={ok_l} -> heap tuple {ok_bytes} bytes (MAX_TUPLE_SIZE={MAX_TUPLE_SIZE})"); + println!("t4: narrowest refused VARCHAR len={bad_l}; message: {msg}"); + + // The reported byte count in the refusal must really be unplaceable. + let reported: usize = msg.split("would become ").nth(1) + .and_then(|s| s.split(' ').next()).and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("refusal message has no 'would become N bytes': {msg}")); + println!("t4: refusal claims the row would become {reported} bytes"); + assert!(reported > MAX_TUPLE_SIZE, + "OVER-REFUSAL: refused a row of {reported} bytes, which is <= MAX_TUPLE_SIZE {MAX_TUPLE_SIZE}"); + let mut p = Page::empty(1); + assert!(p.insert(body(reported, 0)).is_err(), + "OVER-REFUSAL: a fresh page accepts {reported} bytes but the ALTER refused it"); + assert_eq!(bad_l, ok_l + 1, "the accept/refuse boundary is not contiguous: ok={ok_l} refused={bad_l}"); +} + +// ---------------------------------------------------------------- t5 + +/// Pass 2 failing AFTER pass 1 passed, with nothing about the DATA out of range. +/// +/// The heap is one page, packed. Every widened row therefore has to relocate, and the first +/// relocation needs a page the heap does not have yet — so `insert` calls `new_page`. The page +/// space is then bounded with the public `DiskManager::reserve_from`, which is what the arena +/// store does, so `allocate` refuses. `HeapFileManager::update` has already deleted the slot by +/// then. +#[test] +fn t5_a_refused_alter_destroys_a_row_when_pass2_cannot_allocate() { + let select = "SELECT id FROM t;"; + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let v = "y".repeat(60); + d.sql(&format!("INSERT INTO t VALUES (1, '{v}');")); + let l = d.heap("t")[0].1.len(); + let k = 4073 / (l + 4); + for i in 2..=k { d.sql(&format!("INSERT INTO t VALUES ({i}, '{v}');")); } + let pages: std::collections::BTreeSet = d.heap("t").iter().map(|(r, _)| r.page_id).collect(); + println!("t5: tuple bytes={l}, {k} rows, heap spans {} data page(s): {pages:?}", pages.len()); + assert_eq!(pages.len(), 1, "fixture spilled onto a second page - the first relocation would find room"); + + let keys: Vec = (1..=k as i32).map(Value::Integer).collect(); + d.txn.checkpoint().unwrap(); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + let before = snapshot(&mut d, "t", select, &keys); + + // Bound the page space at the very next page the allocator would hand out. + let probe = d.bp.new_page().unwrap(); + d.bp.free_page(probe).unwrap(); + d.bp.disk_manager.reserve_from(probe).unwrap(); + println!("t5: arena floor set to {probe}; new_page now -> {:?}", d.bp.new_page().map_err(|e| e.to_string())); + + let res = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + println!("t5: ALTER -> {:?}", res.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + assert!(res.is_err(), "the fixture never produced a failing ALTER - it measures nothing"); + + let after = snapshot(&mut d, "t", select, &keys); + println!("t5: rows in heap before={} after={}", before.heap.len(), after.heap.len()); + println!("t5: shape before={:?}", before.shape); + println!("t5: shape after ={:?}", after.shape); + let lost: Vec<_> = before.heap.iter().filter(|(r, _)| !after.heap.iter().any(|(r2, _)| r2 == r)).map(|(r, _)| *r).collect(); + let changed: Vec<_> = before.heap.iter() + .filter(|(r, b)| after.heap.iter().any(|(r2, b2)| r2 == r && b2 != b)).map(|(r, _)| *r).collect(); + println!("t5: slots that VANISHED: {lost:?}"); + println!("t5: slots whose BYTES changed: {changed:?}"); + println!("t5: SELECT after = {:?}", after.rows.as_ref().map(|r| r.len())); + let idx_lost: Vec<_> = before.by_key.iter().zip(after.by_key.iter()).filter(|(a, b)| a != b).collect(); + println!("t5: primary-index answers that changed: {idx_lost:?}"); + + // Durability. + d.txn.checkpoint().unwrap(); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + let dir = d.dir; + let mut r = open_at(dir); + let re = snapshot(&mut r, "t", select, &keys); + println!("t5: after checkpoint+flush+reopen into a fresh pool: heap rows={} (was {}), differs_from_before={}", + re.heap.len(), before.heap.len(), re.heap != before.heap); + println!("t5: reopened SELECT = {:?}", re.rows.as_ref().map(|x| x.len())); + + assert_eq!(re.heap, before.heap, + "a FAILED ALTER changed the table durably: {} live tuples became {}", before.heap.len(), re.heap.len()); +} From ec2e0d9141dd3b64edf9ad609e363731065e8e7f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:46:10 -0700 Subject: [PATCH 003/315] test(I19): adversarial type/layout sweep against refused-ALTER safety Attack pass on 19b1de2 from a fresh context. Eleven fixtures over the type and layout space the committed suite does not cover: INTEGER->DECIMAL and BIGINT->DECIMAL (digit text), VARCHAR(n)->VARCHAR(m), the null-bitmap 8-column boundary, 9/16/17-column tables, FLOAT/BOOLEAN/TIMESTAMP neighbours, 61 rows over 21 pages with the oversized row mid-scan, tombstones, NULL in the retyped column, secondary + full-text indexes, a populated time-travel heap, and a refusal following a successful ADD COLUMN. Each refusal is compared on raw heap bytes per RecordId, time-travel heap bytes, catalog shape, all three roots, index root ids, primary and secondary/full-text index answers, query results and change-feed DDL records - in memory and again after checkpoint + flush + sync + reopen into a fresh buffer pool. No refused-but-changed case found. Disabling the pass-1 precheck fails 9 of the 11, so the sweeps are not vacuous. --- tests/zz_atk2_alter_types.rs | 772 +++++++++++++++++++++++++++++++++++ 1 file changed, 772 insertions(+) create mode 100644 tests/zz_atk2_alter_types.rs diff --git a/tests/zz_atk2_alter_types.rs b/tests/zz_atk2_alter_types.rs new file mode 100644 index 00000000..c75f3224 --- /dev/null +++ b/tests/zz_atk2_alter_types.rs @@ -0,0 +1,772 @@ +//! I19 attack pass 2 — the TYPE and LAYOUT space of a refused `ALTER TABLE`. +//! +//! Written in a fresh worktree to try to FALSIFY: "an ALTER that is refused leaves the table +//! exactly as it was — the same raw heap bytes for every live tuple, the same catalog shape, the +//! same values, the same primary-index answers, the same change-feed DDL records — and that holds +//! after a checkpoint, a flush and a reopen into a fresh buffer pool." +//! +//! The committed suite covers ADD COLUMN of a BIGINT and INTEGER -> BIGINT. This one covers +//! INTEGER -> DECIMAL and BIGINT -> DECIMAL (digit text: the widest growth in the allowlist), +//! VARCHAR(n) -> VARCHAR(m), the null-bitmap 8-column boundary, 9/16/17-column tables, +//! FLOAT/BOOLEAN/TIMESTAMP neighbours, a heap spread over many pages with the oversized row in the +//! MIDDLE of scan order, tombstones, NULLs in the retyped column, secondary + full-text indexes, +//! and the time-travel heap. +//! +//! Every sweep asserts it actually produced a refusal, and prints its refusal/success counts. + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::HeapFileManager; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + dir: tempfile::TempDir, + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + open_at(tempfile::tempdir().unwrap()) +} + +fn open_at(dir: tempfile::TempDir) -> Db { + let path = dir.path().join("alter.db"); + let fresh = !path.exists(); + let file = + std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = if fresh { + Catalog::create(bp.clone()).unwrap() + } else { + Catalog::open(bp.clone(), 1).unwrap() + }; + let wal = Arc::new(WalManager::new(dir.path().join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = + std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + + /// A read that must not panic. The original defect panicked the reading thread inside + /// `Tuple::deserialize`, so it is caught rather than allowed to abort the process. + fn rows(&mut self, sql: &str) -> Result>, String> { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.try_sql(sql))) + .map_err(|_| format!("`{sql}` PANICKED the reading thread"))?; + match out { + Ok(Outcome::Rows(r)) => Ok(r), + Ok(_) => Err(format!("`{sql}` did not return rows")), + Err(e) => Err(format!("`{sql}` errored: {e}")), + } + } + + fn heap_at(&self, root: u32) -> Vec<(u32, u16, Vec)> { + if root == 0 { + return Vec::new(); + } + HeapFileManager::open(root, self.bp.clone()) + .scan() + .map(|r| match r { + Ok((rid, t)) => (rid.page_id, rid.slot_num, t.data), + Err(e) => (u32::MAX, u16::MAX, e.to_string().into_bytes()), + }) + .collect() + } + + /// Every DDL record with its full payload, so the retained declaration's shape is visible. + fn feed_full(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode( + &self.wal, + self.wal.base_lsn.load(Ordering::SeqCst), + self.wal.next_lsn.load(Ordering::SeqCst), + ) + .expect("decode") + .schema_changes + .iter() + .map(|(l, t, c)| format!("lsn={l:?} {t} {c:?}")) + .collect() + } + + fn feed(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode( + &self.wal, + self.wal.base_lsn.load(Ordering::SeqCst), + self.wal.next_lsn.load(Ordering::SeqCst), + ) + .expect("decode") + .schema_changes + .iter() + .map(|(_, t, c)| format!("{t}:{c:?}")) + .collect() + } +} + +/// Everything that has to be identical for a refusal to have changed nothing. +#[derive(Debug, PartialEq, Clone)] +struct Snap { + shape: Vec<(String, DataType, bool)>, + roots: (u32, u32, u32), + idx: Vec<(String, u32)>, + ft: Vec<(String, u32)>, + heap: Vec<(u32, u16, Vec)>, + tt: Vec<(u32, u16, Vec)>, + queries: Vec>, String>>, + by_key: Vec<(String, String)>, + idx_answers: Vec<(String, String)>, + feed: Vec, +} + +fn snap(d: &mut Db, table: &str, queries: &[&str], keys: &[Value], probes: &[(Value, Value)]) -> Snap { + let e = d.catalog.get_table(table).unwrap_or_else(|| panic!("no table {table}")).clone(); + let shape = + e.schema.columns.iter().map(|c| (c.name.clone(), c.data_type.clone(), c.nullable)).collect(); + let heap = d.heap_at(e.first_directory_page_id); + let tt = d.heap_at(e.time_travel_root); + let primary = BPlusTreeManager::::open( + e.primary_index_root, + d.bp.clone(), + ); + let by_key = keys + .iter() + .map(|k| (format!("{k:?}"), format!("{:?}", primary.search(k)))) + .collect(); + // Secondary and full-text trees are both `<(Value, Value), ()>` keyed (value, primary key). + let mut idx_answers = Vec::new(); + for ind in e.indexes.iter() { + let t = BPlusTreeManager::<(Value, Value), ()>::open(ind.root_page_id, d.bp.clone()); + for p in probes { + idx_answers.push(( + format!("ix {} {:?}", ind.column_name, p), + format!("{:?}", t.search(&(p.0.clone(), p.1.clone()))), + )); + } + } + for ind in e.fulltext_indexes.iter() { + let t = BPlusTreeManager::<(Value, Value), ()>::open(ind.root_page_id, d.bp.clone()); + for p in probes { + idx_answers.push(( + format!("ft {} {:?}", ind.column_name, p), + format!("{:?}", t.search(&(p.0.clone(), p.1.clone()))), + )); + } + } + let feed = d.feed(); + let mut answers = Vec::new(); + for q in queries { + answers.push(d.rows(q)); + } + Snap { + shape, + roots: (e.first_directory_page_id, e.primary_index_root, e.time_travel_root), + idx: e.indexes.iter().map(|i| (i.column_name.clone(), i.root_page_id)).collect(), + ft: e.fulltext_indexes.iter().map(|i| (i.column_name.clone(), i.root_page_id)).collect(), + heap, + tt, + queries: answers, + by_key, + idx_answers, + feed, + } +} + +/// The first difference between two snapshots, described well enough to act on. +fn diff(a: &Snap, b: &Snap) -> Option { + if a.shape != b.shape { + return Some(format!("CATALOG SHAPE changed: {:?} -> {:?}", a.shape, b.shape)); + } + if a.roots != b.roots { + return Some(format!( + "ROOTS (dir, primary, time_travel) changed: {:?} -> {:?}", + a.roots, b.roots + )); + } + if a.idx != b.idx { + return Some(format!("SECONDARY INDEX roots changed: {:?} -> {:?}", a.idx, b.idx)); + } + if a.ft != b.ft { + return Some(format!("FULLTEXT INDEX roots changed: {:?} -> {:?}", a.ft, b.ft)); + } + for (name, x, y) in [("HEAP", &a.heap, &b.heap), ("TIME-TRAVEL HEAP", &a.tt, &b.tt)] { + if x.len() != y.len() { + let rx: Vec<_> = x.iter().map(|r| (r.0, r.1)).collect(); + let ry: Vec<_> = y.iter().map(|r| (r.0, r.1)).collect(); + return Some(format!( + "{name} live tuple count {} -> {}; rids {rx:?} -> {ry:?}", + x.len(), + y.len() + )); + } + for (p, q) in x.iter().zip(y.iter()) { + if (p.0, p.1) != (q.0, q.1) { + return Some(format!("{name} rid moved: {:?} -> {:?}", (p.0, p.1), (q.0, q.1))); + } + if p.2 != q.2 { + let at = p.2.iter().zip(q.2.iter()).position(|(m, n)| m != n); + return Some(format!( + "{name} BYTES CHANGED at rid {:?}: len {} -> {}, first differing byte index \ + {at:?}, before[..48]={:?}, after[..48]={:?}", + (p.0, p.1), + p.2.len(), + q.2.len(), + &p.2[..p.2.len().min(48)], + &q.2[..q.2.len().min(48)] + )); + } + } + } + if a.queries != b.queries { + return Some(format!("QUERY ANSWERS changed: {:?} -> {:?}", a.queries, b.queries)); + } + if a.by_key != b.by_key { + return Some(format!("PRIMARY INDEX answers changed: {:?} -> {:?}", a.by_key, b.by_key)); + } + if a.idx_answers != b.idx_answers { + return Some(format!( + "SECONDARY/FULLTEXT INDEX answers changed: {:?} -> {:?}", + a.idx_answers, b.idx_answers + )); + } + if a.feed != b.feed { + return Some(format!("CHANGE FEED DDL records changed: {:?} -> {:?}", a.feed, b.feed)); + } + None +} + +struct Sweep { + refusals: usize, + successes: usize, + unbuildable: usize, + refusing_widths: Vec, +} + +/// For each width: build the fixture, snapshot, run `alter`. On a refusal, compare in memory and +/// then again after checkpoint + flush + sync + reopen into a fresh buffer pool and catalog. +#[allow(clippy::too_many_arguments)] +fn run_sweep( + label: &str, + table: &str, + pads: std::ops::RangeInclusive, + build: &dyn Fn(&mut Db, usize) -> bool, + alter: &str, + queries: &[&str], + keys: &[Value], + probes: &[(Value, Value)], + max_refusals: usize, +) -> Sweep { + let mut s = Sweep { refusals: 0, successes: 0, unbuildable: 0, refusing_widths: Vec::new() }; + for pad in pads { + if s.refusals >= max_refusals { + break; + } + let (dir, before, err) = { + let mut d = db(); + if !build(&mut d, pad) { + s.unbuildable += 1; + continue; + } + let before = snap(&mut d, table, queries, keys, probes); + let Err(e) = d.try_sql(alter) else { + s.successes += 1; + continue; + }; + s.refusals += 1; + s.refusing_widths.push(pad); + println!( + " {label}: pad={pad} REFUSED. live tuples={}, distinct heap pages={}, \ + time-travel tuples={}, feed={:?}\n refusal: {e}", + before.heap.len(), + { + let mut ps: Vec = before.heap.iter().map(|r| r.0).collect(); + ps.sort_unstable(); + ps.dedup(); + ps.len() + }, + before.tt.len(), + before.feed, + ); + if !before.idx_answers.is_empty() { + println!(" index probes: {:?}", before.idx_answers); + } + println!(" primary-index probes: {:?}", before.by_key); + let after = snap(&mut d, table, queries, keys, probes); + if let Some(what) = diff(&before, &after) { + panic!( + "{label}: pad={pad}: a REFUSED `{alter}` changed the table IN MEMORY.\n \ + refusal was: {e}\n {what}" + ); + } + d.txn.checkpoint().expect("checkpoint"); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + (d.dir, before, e.to_string()) + }; + let mut r = open_at(dir); + let after = snap(&mut r, table, queries, keys, probes); + // The change feed is compared separately across the reopen. A checkpoint truncates the + // WAL, so a *pre-existing* DDL record can vanish from what the decoder sees for reasons + // that have nothing to do with the refused ALTER — see + // `control_a_successful_add_column_across_a_checkpoint_with_no_refused_alter`, which + // measures that on its own. What must hold here is the direction that the refusal could + // cause: the reopened feed may not contain a record the pre-ALTER feed did not. + let (mut b, mut a) = (before.clone(), after.clone()); + b.feed.clear(); + a.feed.clear(); + if let Some(what) = diff(&b, &a) { + panic!( + "{label}: pad={pad}: a REFUSED `{alter}` changed the table DURABLY — the \ + difference survived checkpoint + flush + sync + a fresh buffer pool.\n \ + refusal was: {err}\n {what}" + ); + } + for rec in after.feed.iter() { + assert!( + before.feed.contains(rec), + "{label}: pad={pad}: a REFUSED `{alter}` put a DDL record on the change feed that \ + was not there before it: {rec:?} (before={:?}, after reopen={:?})", + before.feed, + after.feed + ); + } + } + println!( + "{label}: refusals={} at widths {:?}, successes={}, unbuildable={}", + s.refusals, s.refusing_widths, s.successes, s.unbuildable + ); + assert!( + s.refusals > 0, + "{label}: the sweep produced NO refusal, so it measured nothing. successes={}, \ + unbuildable={}", + s.successes, + s.unbuildable + ); + s +} + +const KEYS2: [Value; 2] = [Value::Integer(1), Value::Integer(2)]; + +// --------------------------------------------------------------------------------------------- +// INTEGER -> DECIMAL and BIGINT -> DECIMAL: digit text, the widest growth in the allowlist +// --------------------------------------------------------------------------------------------- + +#[test] +fn int_to_decimal_refused_changes_nothing() { + run_sweep( + "INTEGER->DECIMAL", + "t", + 4008..=4040, + &|d, pad| { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 7, 'small');"); + d.try_sql(&format!("INSERT INTO t VALUES (2, 1000000000, '{}');", "x".repeat(pad))) + .is_ok() + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n FROM t;", "SELECT id, a FROM t;"], + &KEYS2, + &[], + 3, + ); +} + +#[test] +fn bigint_to_decimal_refused_changes_nothing() { + run_sweep( + "BIGINT->DECIMAL", + "t", + 4000..=4040, + &|d, pad| { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n BIGINT, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 7, 'small');"); + d.try_sql(&format!( + "INSERT INTO t VALUES (2, 9223372036854775807, '{}');", + "x".repeat(pad) + )) + .is_ok() + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n FROM t;", "SELECT id, a FROM t;"], + &KEYS2, + &[], + 3, + ); +} + +/// `VarcharWider` claims to move no byte at all. Two things measured: it never refuses at any +/// width, and after the alter SUCCEEDS the heap bytes are still identical. +#[test] +fn varchar_widening_refuses_nothing_and_moves_no_byte() { + let mut successes = 0; + let mut refusals = Vec::new(); + for pad in [8usize, 1000, 4000, 4020, 4028, 4029, 4030, 4031, 4032] { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, a VARCHAR(64), b VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 'ab', 'small');"); + if d + .try_sql(&format!("INSERT INTO t VALUES (2, 'cd', '{}');", "x".repeat(pad))) + .is_err() + { + continue; + } + let q = ["SELECT id, a, b FROM t;"]; + let before = snap(&mut d, "t", &q, &KEYS2, &[]); + match d.try_sql("ALTER TABLE t ALTER COLUMN a TYPE VARCHAR(4000);") { + Err(e) => { + refusals.push((pad, e.to_string())); + let after = snap(&mut d, "t", &q, &KEYS2, &[]); + if let Some(what) = diff(&before, &after) { + panic!("VARCHAR widening: pad={pad}: a REFUSED alter changed the table: {what}"); + } + } + Ok(_) => { + successes += 1; + let after = snap(&mut d, "t", &q, &KEYS2, &[]); + assert_eq!( + before.heap, after.heap, + "pad={pad}: VARCHAR(n)->VARCHAR(m) is documented to move no byte, but the \ + heap bytes changed" + ); + assert_eq!(before.tt, after.tt, "pad={pad}: the time-travel heap changed"); + } + } + } + println!("VARCHAR widening: successes={successes}, refusals={refusals:?}"); + assert!(successes > 0, "the sweep never once ran the alter"); +} + +// --------------------------------------------------------------------------------------------- +// Layout: the null bitmap's 8-column boundary, and 9 / 16 / 17-column tables +// --------------------------------------------------------------------------------------------- + +/// Eight columns -> nine. The bitmap grows from one byte to two, so **every** column after it +/// shifts, on top of whatever the appended column costs. +#[test] +fn add_column_across_the_eight_column_boundary() { + run_sweep( + "ADD COLUMN 8->9 (bitmap 1->2 bytes)", + "t", + 3990..=4040, + &|d, pad| { + d.sql( + "CREATE TABLE t (id INTEGER NOT NULL, c1 INTEGER, c2 INTEGER, c3 INTEGER, \ + c4 INTEGER, c5 INTEGER, c6 INTEGER, a VARCHAR(4100));", + ); + d.sql("INSERT INTO t VALUES (1, 1, 2, 3, 4, 5, 6, 'small');"); + d.try_sql(&format!( + "INSERT INTO t VALUES (2, 1, 2, 3, 4, 5, 6, '{}');", + "x".repeat(pad) + )) + .is_ok() + }, + "ALTER TABLE t ADD COLUMN c7 VARCHAR(20);", + &["SELECT id, c6 FROM t;", "SELECT id, a FROM t;"], + &KEYS2, + &[], + 3, + ); +} + +fn wide_table_ddl(ncols: usize) -> (String, String, String) { + // id, then ncols-2 INTEGERs, then the trailing VARCHAR that carries the padding. + let mut cols = vec!["id INTEGER NOT NULL".to_string()]; + let mut vals_small = vec!["1".to_string()]; + let mut vals_big = vec!["2".to_string()]; + for i in 1..=(ncols - 2) { + cols.push(format!("c{i} INTEGER")); + vals_small.push("7".to_string()); + vals_big.push("1000000000".to_string()); + } + cols.push("a VARCHAR(4100)".to_string()); + ( + format!("CREATE TABLE t ({});", cols.join(", ")), + format!("INSERT INTO t VALUES ({}, 'small');", vals_small.join(", ")), + format!("INSERT INTO t VALUES ({}, '{{PAD}}');", vals_big.join(", ")), + ) +} + +#[test] +fn retype_in_the_middle_of_nine_sixteen_and_seventeen_column_tables() { + for ncols in [9usize, 16, 17] { + let (ddl, ins_small, ins_big) = wide_table_ddl(ncols); + let mid = (ncols - 2) / 2; + let alter = format!("ALTER TABLE t ALTER COLUMN c{mid} TYPE DECIMAL;"); + let fixed = 4 * (ncols - 2) + 32; + let hi = 4069usize.saturating_sub(fixed); + run_sweep( + &format!("{ncols}-column table, retype c{mid} INTEGER->DECIMAL"), + "t", + hi.saturating_sub(30)..=hi, + &|d, pad| { + d.sql(&ddl); + d.sql(&ins_small); + d.try_sql(&ins_big.replace("{PAD}", &"x".repeat(pad))).is_ok() + }, + &alter, + &[ + &format!("SELECT id, c{mid} FROM t;"), + "SELECT id, a FROM t;", + ], + &KEYS2, + &[], + 2, + ); + } +} + +/// FLOAT (8-align), BOOLEAN (1-align) and TIMESTAMP (8-align) either side of the retyped column, +/// so the conversion changes the alignment padding of its neighbours and not only its own width. +#[test] +fn mixed_alignment_neighbours() { + run_sweep( + "FLOAT/BOOLEAN/TIMESTAMP neighbours, INTEGER->DECIMAL", + "t", + 3980..=4040, + &|d, pad| { + // `CAST(... AS TIMESTAMP)` is not accepted inside a VALUES list, so TIMESTAMP is + // exercised as NULL — the NULL arm of `Tuple::serialize` still lays down an 8-byte + // 8-aligned slot for it — and a real BIGINT provides the non-null 8-aligned neighbour. + d.sql( + "CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, f FLOAT, ok BOOLEAN, \ + big BIGINT, ts TIMESTAMP, a VARCHAR(4100));", + ); + d.sql("INSERT INTO t VALUES (1, 7, 1.5, TRUE, 111, NULL, 'small');"); + d.try_sql(&format!( + "INSERT INTO t VALUES (2, 1000000000, 2.5, FALSE, 9223372036854775807, NULL, \ + '{}');", + "x".repeat(pad) + )) + .is_ok() + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n, f, ok, big, ts FROM t;", "SELECT id, a FROM t;"], + &KEYS2, + &[], + 3, + ); +} + +// --------------------------------------------------------------------------------------------- +// Many pages, tombstones, NULLs, indexes, the time-travel heap +// --------------------------------------------------------------------------------------------- + +/// 60 rows over ~20 pages with the oversized row in the MIDDLE of scan order, so a rewrite that +/// wrote before it checked would have converted roughly half the heap. +#[test] +fn many_pages_with_the_oversized_row_in_the_middle() { + let keys: Vec = (1..=61).map(Value::Integer).collect(); + run_sweep( + "60 rows over many pages, oversized row in the middle", + "t", + 4008..=4040, + &|d, pad| { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(4100));"); + let body = "y".repeat(1300); + for id in 1..=30 { + d.sql(&format!("INSERT INTO t VALUES ({id}, {id}, '{body}');")); + } + let ok = d + .try_sql(&format!( + "INSERT INTO t VALUES (61, 1000000000, '{}');", + "x".repeat(pad) + )) + .is_ok(); + for id in 31..=60 { + d.sql(&format!("INSERT INTO t VALUES ({id}, {id}, '{body}');")); + } + ok + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n FROM t;"], + &keys, + &[], + 2, + ); +} + +/// Tombstones keep their slot, and a NULL in the retyped column takes a different width in the +/// old type than in the new one. +#[test] +fn tombstones_and_nulls_in_the_retyped_column() { + let keys = [Value::Integer(1), Value::Integer(2), Value::Integer(3), Value::Integer(4)]; + run_sweep( + "tombstones + NULL in the retyped column", + "t", + 4008..=4040, + &|d, pad| { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 7, 'small');"); + d.sql("INSERT INTO t VALUES (2, NULL, 'nulls');"); + d.sql("INSERT INTO t VALUES (4, 9, 'doomed');"); + d.sql("DELETE FROM t WHERE id = 4;"); + d.try_sql(&format!("INSERT INTO t VALUES (3, 1000000000, '{}');", "x".repeat(pad))) + .is_ok() + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n FROM t;", "SELECT id, a FROM t;"], + &keys, + &[], + 3, + ); +} + +/// A secondary index, a full-text index, and a time-travel heap with superseded versions in it. +#[test] +fn secondary_and_fulltext_indexes_and_the_time_travel_heap() { + let keys = [Value::Integer(1), Value::Integer(2), Value::Integer(3)]; + let probes = [ + (Value::Integer(7), Value::Integer(1)), + (Value::Integer(8), Value::Integer(2)), + (Value::Integer(1000000000), Value::Integer(3)), + (Value::Varchar("alpha".to_string()), Value::Integer(1)), + (Value::Varchar("beta".to_string()), Value::Integer(2)), + (Value::Varchar("gamma".to_string()), Value::Integer(3)), + ]; + run_sweep( + "secondary + fulltext index, populated time-travel heap", + "inv", + 4000..=4040, + &|d, pad| { + d.sql( + "CREATE TABLE inv (id INTEGER NOT NULL, qty INTEGER, body VARCHAR(64), \ + a VARCHAR(4100));", + ); + d.sql("CREATE INDEX ix ON inv (qty);"); + d.sql("CREATE FULLTEXT INDEX fx ON inv (body);"); + d.sql("INSERT INTO inv VALUES (1, 7, 'alpha beta', 'small');"); + d.sql("INSERT INTO inv VALUES (2, 8, 'beta gamma', 'small2');"); + // Two updates on one row, so the time-travel heap holds superseded versions. + d.sql("UPDATE inv SET qty = 77 WHERE id = 1;"); + d.sql("UPDATE inv SET qty = 7 WHERE id = 1;"); + d.try_sql(&format!( + "INSERT INTO inv VALUES (3, 1000000000, 'gamma delta', '{}');", + "x".repeat(pad) + )) + .is_ok() + }, + "ALTER TABLE inv ALTER COLUMN qty TYPE DECIMAL;", + &[ + "SELECT id, qty FROM inv;", + "SELECT id FROM inv WHERE qty = 7;", + "SELECT id, body FROM inv;", + ], + &keys, + &probes, + 2, + ); +} + +/// A refusal after a *successful* alter. The table is already one ADD COLUMN past its original +/// shape, so a refusal that reverted or half-applied anything would show up as the first alter +/// coming undone rather than as the second one landing. +#[test] +fn a_refusal_after_a_successful_alter_leaves_the_earlier_one_intact() { + run_sweep( + "ADD COLUMN (succeeds) then INTEGER->DECIMAL (refused)", + "t", + 3990..=4040, + &|d, pad| { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 7, 'small');"); + if d + .try_sql(&format!("INSERT INTO t VALUES (2, 1000000000, '{}');", "x".repeat(pad))) + .is_err() + { + return false; + } + // Must succeed at this width, or the fixture is not testing what it says. + if d.try_sql("ALTER TABLE t ADD COLUMN tag VARCHAR(8);").is_err() { + return false; + } + d.sql("UPDATE t SET tag = 'k' WHERE id = 1;"); + true + }, + "ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", + &["SELECT id, n, tag FROM t;", "SELECT id, a FROM t;"], + &KEYS2, + &[], + 3, + ); +} + +/// **Control, no refused ALTER anywhere.** Does the change-feed DDL record for a *successful* +/// `ADD COLUMN` survive a checkpoint, a flush and a reopen on its own? Needed because the durable +/// comparison in `run_sweep` saw that record disappear, and a difference a refusal did not cause +/// is not a refusal defect. +#[test] +fn control_a_successful_add_column_across_a_checkpoint_with_no_refused_alter() { + let (dir, before) = { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 7, 'small');"); + d.sql(&format!("INSERT INTO t VALUES (2, 1000000000, '{}');", "x".repeat(4022))); + d.sql("ALTER TABLE t ADD COLUMN tag VARCHAR(8);"); + d.sql("UPDATE t SET tag = 'k' WHERE id = 1;"); + let f = d.feed(); + d.txn.checkpoint().expect("checkpoint"); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + (d.dir, f) + }; + let r = open_at(dir); + let after = r.feed(); + println!("CONTROL: feed before checkpoint = {before:?}"); + println!("CONTROL: feed after checkpoint + reopen = {after:?}"); + println!("CONTROL: full records after reopen = {:?}", r.feed_full()); + // The question the asymmetry raises: `CreateTable` survived the truncation and `AddColumn` did + // not, so does the retained `CreateTable` carry the post-ALTER shape? If it names `tag`, no + // shape information was lost and the missing event is a presentation difference, not a hole. + // MEASURED, and it is *not* a refusal defect — this control has no refused ALTER in it: + // before = ["t:CreateTable", "t:AddColumn { column: \"tag\" }"] + // after = ["t:CreateTable"] + // Whether shape information is actually lost is NOT settled here: `SchemaChange::CreateTable` + // renders with no payload at all in this projection, so the retained declaration's columns are + // not visible to this instrument. Recorded as an out-of-scope observation, not a finding. + assert!( + after.len() <= before.len() && after.iter().all(|r| before.contains(r)), + "the reopen INVENTED a DDL record: before={before:?}, after={after:?}" + ); +} From 594638806b6fafe8986f4a6ad565aea234329c91 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:51:41 -0700 Subject: [PATCH 004/315] test(I19-lens): independent check of the ALTER-refusal claim; lane D reproduces the lost row --- tests/zz_lens_alter_refusal.rs | 295 +++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/zz_lens_alter_refusal.rs diff --git a/tests/zz_lens_alter_refusal.rs b/tests/zz_lens_alter_refusal.rs new file mode 100644 index 00000000..90182260 --- /dev/null +++ b/tests/zz_lens_alter_refusal.rs @@ -0,0 +1,295 @@ +//! LENS: independent attack on "a refused ALTER leaves the table EXACTLY as it was". +//! +//! Written from scratch. Deliberately compares LOGICAL content (the set of id->v pairs SELECT +//! returns) as well as physical bytes, so that a row merely RELOCATING to a new RecordId cannot be +//! mistaken for a row being lost. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; +use ferrodb::storage::index::BPlusTreeManager; + +struct Db { + dir: tempfile::TempDir, + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { open_at(tempfile::tempdir().unwrap()) } + +fn open_at(dir: tempfile::TempDir) -> Db { + let path = dir.path().join("lens.db"); + let fresh = !path.exists(); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = if fresh { Catalog::create(bp.clone()).unwrap() } else { Catalog::open(bp.clone(), 1).unwrap() }; + let wal = Arc::new(WalManager::new(dir.path().join("lens.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, catalog, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError(p.errors.iter().map(|e| e.to_string()).collect::>().join("; "))); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } +} + +/// LOGICAL: id -> the v column, read back through SELECT. `None` for the whole thing means the +/// table could not be read at all. +type Logical = Result, String>; + +#[derive(Debug, PartialEq, Eq)] +enum IdxRead { NoEntry, Readable(usize), Dangling(String) } + +struct Snap { + shape: Vec<(String, DataType)>, + phys: BTreeMap>, + logical: Logical, + idx: BTreeMap, IdxRead)>, +} + +fn as_i64(v: &Value) -> Option { + match v { Value::Integer(i) => Some(*i as i64), _ => None } +} +fn as_s(v: &Value) -> String { format!("{v:?}") } + +fn snap(d: &mut Db, table: &str, ids: &[i64]) -> Snap { + let t = d.catalog.get_table(table).unwrap().clone(); + let shape = t.schema.columns.iter().map(|c| (c.name.clone(), c.data_type.clone())).collect(); + let heap = HeapFileManager::open(t.first_directory_page_id, d.bp.clone()); + let phys: BTreeMap> = + heap.scan().map(|r| { let (rid, tp) = r.unwrap(); (rid, tp.data) }).collect(); + + let logical: Logical = { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + d.try_sql(&format!("SELECT id, v FROM {table};")) + })); + match out { + Err(_) => Err("SELECT PANICKED".to_string()), + Ok(Err(e)) => Err(format!("SELECT errored: {e}")), + Ok(Ok(Outcome::Rows(rows))) => { + let mut m = BTreeMap::new(); + for r in rows { + let k = as_i64(&r[0]).unwrap_or(-1); + m.insert(k, as_s(&r[1])); + } + Ok(m) + } + Ok(Ok(_)) => Err("SELECT returned no rows outcome".to_string()), + } + }; + + let ix = BPlusTreeManager::::open( + d.catalog.get_table(table).unwrap().primary_index_root, d.bp.clone()); + let heap2 = HeapFileManager::open(t.first_directory_page_id, d.bp.clone()); + let mut idx = BTreeMap::new(); + for id in ids { + let rid = ix.search(&Value::Integer(*id as i32)).unwrap(); + let st = match rid { + None => IdxRead::NoEntry, + Some(r) => match heap2.read(r) { + Ok(tp) => IdxRead::Readable(tp.data.len()), + Err(e) => IdxRead::Dangling(e.to_string()), + }, + }; + idx.insert(*id, (rid, st)); + } + Snap { shape, phys, logical, idx } +} + +/// Build a heap that is ONE packed data page, so any widening must relocate. Returns the ids. +fn packed_one_page(d: &mut Db) -> Vec { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let v = "y".repeat(60); + d.sql(&format!("INSERT INTO t VALUES (1, '{v}');")); + let t = d.catalog.get_table("t").unwrap().clone(); + let l = HeapFileManager::open(t.first_directory_page_id, d.bp.clone()) + .scan().next().unwrap().unwrap().1.data.len(); + let k = 4073 / (l + 4); + for i in 2..=k { d.sql(&format!("INSERT INTO t VALUES ({i}, '{v}');")); } + let pages: std::collections::BTreeSet = + HeapFileManager::open(t.first_directory_page_id, d.bp.clone()) + .scan().map(|r| r.unwrap().0.page_id).collect(); + println!(" fixture: tuple={l} bytes, {k} rows, data pages={pages:?}"); + assert_eq!(pages.len(), 1, "fixture spilled to a 2nd page; widening would find room there"); + (1..=k as i64).collect() +} + +fn report(tag: &str, before: &Snap, after: &Snap) { + println!("{tag}: heap tuples before={} after={}", before.phys.len(), after.phys.len()); + println!("{tag}: shape same = {}", before.shape == after.shape); + let (lb, la) = (&before.logical, &after.logical); + match (lb, la) { + (Ok(b), Ok(a)) => { + println!("{tag}: SELECT rows before={} after={}", b.len(), a.len()); + let lost: Vec = b.keys().filter(|k| !a.contains_key(k)).cloned().collect(); + let gained: Vec = a.keys().filter(|k| !b.contains_key(k)).cloned().collect(); + let mutated: Vec = b.iter().filter(|(k, v)| a.get(k).map_or(false, |v2| v2 != *v)).map(|(k, _)| *k).collect(); + println!("{tag}: ids LOST from SELECT: {lost:?}"); + println!("{tag}: ids GAINED: {gained:?}"); + println!("{tag}: ids whose v VALUE changed: {mutated:?}"); + } + _ => println!("{tag}: SELECT before={lb:?} after={la:?}"), + } + let moved: Vec = before.idx.iter() + .filter(|(k, (r, _))| after.idx.get(k).map(|(r2, _)| r2 != r).unwrap_or(true)) + .map(|(k, _)| *k).collect(); + println!("{tag}: index answers that MOVED (relocation): {moved:?}"); + let dangling: Vec<(i64, &IdxRead)> = after.idx.iter() + .filter(|(_, (_, s))| matches!(s, IdxRead::Dangling(_))) + .map(|(k, (_, s))| (*k, s)).collect(); + println!("{tag}: index entries now DANGLING: {dangling:?}"); + let vanished: Vec = after.idx.iter().filter(|(_, (r, _))| r.is_none()).map(|(k, _)| *k).collect(); + println!("{tag}: index entries now ABSENT: {vanished:?}"); +} + +fn durable(d: &mut Db) { d.txn.checkpoint().unwrap(); d.bp.flush_all().unwrap(); d.bp.disk_manager.sync().unwrap(); } + +// ------------------------------------------------------------------ lane A: control, no bound + +#[test] +fn lane_a_the_same_alter_with_no_arena_bound_succeeds_and_keeps_every_row() { + let mut d = db(); + let ids = packed_one_page(&mut d); + durable(&mut d); + let before = snap(&mut d, "t", &ids); + let res = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + println!("A: ALTER -> {:?}", res.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + let after = snap(&mut d, "t", &ids); + report("A", &before, &after); + assert!(res.is_ok(), "control lane: the ALTER must succeed here, else the fixture proves nothing"); + let (b, a) = (before.logical.as_ref().unwrap(), after.logical.as_ref().unwrap()); + assert_eq!(b.len(), a.len(), "control: a SUCCESSFUL alter lost rows"); + assert!(b.keys().all(|k| a.contains_key(k)), "control: a SUCCESSFUL alter lost an id"); +} + +// ------------------------------------------------------------------ lane B: the size guard itself + +#[test] +fn lane_b_the_size_refusal_leaves_the_table_untouched() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(4100));"); + // Find the widest v that INSERTs, so the row is legal now but ADD COLUMN pushes it over. + let mut chosen = 0usize; + for l in (3900..4100usize).rev() { + let mut probe = db(); + probe.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(4100));"); + if probe.try_sql(&format!("INSERT INTO t VALUES (1, '{}');", "z".repeat(l))).is_ok() { + chosen = l; break; + } + } + assert!(chosen > 0, "no v width in 3900..4100 could be inserted"); + println!("B: widest insertable v = {chosen} chars"); + d.sql(&format!("INSERT INTO t VALUES (1, '{}');", "z".repeat(chosen))); + d.sql(&format!("INSERT INTO t VALUES (2, '{}');", "q".repeat(10))); + let ids = vec![1i64, 2]; + durable(&mut d); + let before = snap(&mut d, "t", &ids); + let res = d.try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(60);"); + println!("B: ALTER -> {:?}", res.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + let after = snap(&mut d, "t", &ids); + report("B", &before, &after); + assert!(res.is_err(), "lane B never reached the size refusal - it measures nothing"); + assert_eq!(before.phys, after.phys, "size-refused ALTER changed the raw heap bytes"); + assert_eq!(before.logical, after.logical, "size-refused ALTER changed what SELECT returns"); + assert_eq!(before.shape, after.shape, "size-refused ALTER changed the catalog shape"); + durable(&mut d); + let dir = d.dir; let mut r = open_at(dir); + let re = snap(&mut r, "t", &ids); + println!("B: after checkpoint+flush+reopen: phys_same={} logical_same={}", + re.phys == before.phys, re.logical == before.logical); + assert_eq!(re.phys, before.phys, "size-refused ALTER differed after reopen"); + assert_eq!(re.logical, before.logical, "size-refused ALTER differed after reopen (logical)"); +} + +// ------------------------------------------------------------------ lane C: bound only, no ALTER + +#[test] +fn lane_c_setting_the_arena_floor_alone_changes_nothing() { + let mut d = db(); + let ids = packed_one_page(&mut d); + durable(&mut d); + let before = snap(&mut d, "t", &ids); + let probe = d.bp.new_page().unwrap(); + d.bp.free_page(probe).unwrap(); + d.bp.disk_manager.reserve_from(probe).unwrap(); + println!("C: floor at {probe}; new_page -> {:?}", d.bp.new_page().map_err(|e| e.to_string().lines().next().unwrap().to_string())); + let after = snap(&mut d, "t", &ids); + report("C", &before, &after); + assert_eq!(before.phys, after.phys, "bounding the arena ALONE changed the heap"); + assert_eq!(before.logical, after.logical, "bounding the arena ALONE changed SELECT"); +} + +// ------------------------------------------------------------------ lane D: the claim + +#[test] +fn lane_d_refused_alter_when_pass2_cannot_allocate() { + let mut d = db(); + let ids = packed_one_page(&mut d); + durable(&mut d); + let before = snap(&mut d, "t", &ids); + + let probe = d.bp.new_page().unwrap(); + d.bp.free_page(probe).unwrap(); + d.bp.disk_manager.reserve_from(probe).unwrap(); + let na = d.bp.new_page().map_err(|e| e.to_string().lines().next().unwrap().to_string()); + println!("D: floor at {probe}; new_page -> {na:?}"); + assert!(na.is_err(), "the bound did not take - page space is not exhausted"); + + let res = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + let err = res.as_ref().err().map(|e| e.to_string().lines().next().unwrap().to_string()); + println!("D: ALTER -> {:?}", res.as_ref().map(|_| "Ok").map_err(|_| err.clone().unwrap())); + assert!(res.is_err(), "the ALTER SUCCEEDED - there is no refusal to attack"); + + let after = snap(&mut d, "t", &ids); + report("D", &before, &after); + + durable(&mut d); + let dir = d.dir; let mut r = open_at(dir); + let re = snap(&mut r, "t", &ids); + println!("D: --- after checkpoint + flush + sync + reopen into a FRESH buffer pool ---"); + report("D-reopen", &before, &re); + + let b = before.logical.as_ref().expect("baseline SELECT must work"); + match &re.logical { + Err(e) => panic!("REFUTATION FAILED: after a REFUSED alter the table no longer reads at all: {e}"), + Ok(a) => { + let lost: Vec = b.keys().filter(|k| !a.contains_key(k)).cloned().collect(); + assert!(lost.is_empty(), + "REFUTATION FAILED: a REFUSED ALTER durably lost ids {lost:?}: {} rows became {}", b.len(), a.len()); + } + } + assert_eq!(re.shape, before.shape, "a REFUSED alter changed the catalog shape"); +} From 7078256cf5baf340046631edbff6cb5d3fd94c1e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:30:38 -0400 Subject: [PATCH 005/315] test(I19-LENS): independent fixture for the two-edit MERGE refusal, with controls --- tests/integration_lens_merge_two_edits.rs | 268 ++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 tests/integration_lens_merge_two_edits.rs diff --git a/tests/integration_lens_merge_two_edits.rs b/tests/integration_lens_merge_two_edits.rs new file mode 100644 index 00000000..43d5e94c --- /dev/null +++ b/tests/integration_lens_merge_two_edits.rs @@ -0,0 +1,268 @@ +//! LENS: is the "a refused ALTER leaves the table exactly as it was" claim broken by a MERGE +//! that carries TWO schema edits, the second of which the size guard refuses? +//! +//! Built from scratch, against committed 58804c8, with no edits to src/. + +use std::path::PathBuf; +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + #[allow(dead_code)] + dir: tempfile::TempDir, + path: PathBuf, + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lens.db"); + let file = + std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("lens.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, path, catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn exec(&mut self, sql: &str, session: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), session) + } + fn try_sql(&mut self, sql: &str) -> Result { + let mut s = + std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = self.exec(sql, &mut s); + self.session = s; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + /// A query's answer as a string, error included: the SEMANTIC probe, not a snapshot field. + fn ask(&mut self, sql: &str) -> String { + match self.try_sql(sql) { + Ok(Outcome::Rows(r)) => format!("Ok({r:?})"), + Ok(_) => "Ok(non-rows)".to_string(), + Err(e) => format!("Err({e})"), + } + } + fn shape(&self, table: &str) -> Vec { + self.catalog + .get_table(table) + .unwrap_or_else(|| panic!("no table {table}")) + .schema + .columns + .iter() + .map(|c| format!("{}:{:?}", c.name, c.data_type)) + .collect() + } + fn heap(&self, table: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(table).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()) + .scan() + .map(|r| { + let (rid, t) = r.unwrap(); + (rid, t.data) + }) + .collect() + } + fn ddl(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode( + &self.wal, + self.wal.base_lsn.load(Ordering::SeqCst), + self.wal.next_lsn.load(Ordering::SeqCst), + ) + .expect("decode") + .schema_changes + .iter() + .map(|(_, t, c)| format!("{t}:{c:?}")) + .collect() + } + /// Flush, then reopen the SAME FILE through a brand-new DiskManager and a brand-new buffer + /// pool. Nothing of the live pool survives, so what this returns is what is on disk. + fn shape_from_a_fresh_pool(&mut self, table: &str) -> Vec { + self.bp.flush_all().unwrap(); + self.bp.disk_manager.sync().unwrap(); + let file = std::fs::OpenOptions::new().read(true).write(true).open(&self.path).unwrap(); + let bp2 = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let cat2 = Catalog::open(bp2.clone(), 1).unwrap(); + cat2.get_table(table) + .unwrap_or_else(|| panic!("no table {table} on disk")) + .schema + .columns + .iter() + .map(|c| format!("{}:{:?}", c.name, c.data_type)) + .collect() + } +} + +const WIDE: usize = 2014; +const RETYPE: &str = "ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"; +const RENAME: &str = "ALTER TABLE t RENAME COLUMN a TO a2;"; + +fn seed(d: &mut Db) { + let big = "x".repeat(WIDE); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(2100), b VARCHAR(2100));"); + d.sql("INSERT INTO t VALUES (1, 100, 'small', 'small');"); + d.sql(&format!("INSERT INTO t VALUES (2, 200, '{big}', '{big}');")); +} + +fn is_size_refusal(e: &FerroError) -> bool { + let m = e.to_string(); + m.contains("would widen") && m.contains("bytes a tuple can occupy") +} + +// --------------------------------------------------------------------------------------------- +// CONTROL 0. The guard is the guard: a PLAIN `ALTER TABLE` retype on this fixture is refused, +// and refusing it changes nothing. If this fails, every test below is measuring the wrong thing. +// --------------------------------------------------------------------------------------------- +#[test] +fn lens_control_plain_alter_is_refused_and_changes_nothing() { + let mut d = db(); + seed(&mut d); + let shape0 = d.shape("t"); + let heap0 = d.heap("t"); + let ddl0 = d.ddl(); + let e = d.try_sql(RETYPE).err().expect("the plain ALTER must be refused"); + eprintln!("--- C0: plain ALTER refusal = {e}"); + assert!(is_size_refusal(&e), "refused by something other than the size guard: {e}"); + assert_eq!(shape0, d.shape("t"), "plain refused ALTER changed the shape"); + assert_eq!(heap0, d.heap("t"), "plain refused ALTER changed the heap bytes"); + assert_eq!(ddl0, d.ddl(), "plain refused ALTER emitted a DDL record"); + eprintln!("--- C0: shape/heap/ddl all unchanged after the plain refusal"); +} + +// --------------------------------------------------------------------------------------------- +// CONTROL 1. The same two-edit MERGE must LAND on a table whose rows leave room. Without this, +// test 1 could be measuring a merge that could never have applied anything at all. +// --------------------------------------------------------------------------------------------- +#[test] +fn lens_control_the_two_edit_merge_lands_when_the_rows_are_narrow() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(2100), b VARCHAR(2100));"); + d.sql("INSERT INTO t VALUES (1, 100, 'small', 'small');"); + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-ok';", &mut agent).expect("branch"); + d.exec(RENAME, &mut agent).expect("stage rename"); + d.exec(RETYPE, &mut agent).expect("stage retype"); + d.exec("MERGE;", &mut agent).expect("the control MERGE must land"); + eprintln!("--- C1: shape after a LANDED two-edit merge = {:?}", d.shape("t")); + eprintln!("--- C1: ddl after a LANDED two-edit merge = {:?}", d.ddl()); + assert!(d.shape("t").iter().any(|c| c.starts_with("a2:")), "control did not rename"); + assert!(d.shape("t").iter().any(|c| c == "n:BigInt"), "control did not retype"); +} + +// --------------------------------------------------------------------------------------------- +// 1. THE CLAIM UNDER TEST. Stage rename-then-retype; the retype is refused. Does the rename +// survive in the shared catalog, on disk, in the change feed, and in what SQL can see? +// --------------------------------------------------------------------------------------------- +#[test] +fn lens_merge_refused_on_the_second_edit_leaves_the_first_applied() { + let mut d = db(); + seed(&mut d); + + let shape_before = d.shape("t"); + let heap_before = d.heap("t"); + let ddl_before = d.ddl(); + let ask_a_before = d.ask("SELECT a FROM t WHERE id = 1;"); + let ask_a2_before = d.ask("SELECT a2 FROM t WHERE id = 1;"); + let disk_before = d.shape_from_a_fresh_pool("t"); + + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-1';", &mut agent).expect("branch"); + d.exec(RENAME, &mut agent).expect("stage rename"); + d.exec(RETYPE, &mut agent).expect("stage retype"); + + let r = d.exec("MERGE;", &mut agent); + eprintln!("--- 1: MERGE returned = {:?}", r.as_ref().map(|_| "Ok(MergeReport)").map_err(|e| e.to_string())); + let e = r.err().expect("(a) did the statement actually FAIL? -- it must return Err"); + assert!(is_size_refusal(&e), "refused, but not by the size guard: {e}"); + + let shape_after = d.shape("t"); + let heap_after = d.heap("t"); + let ddl_after = d.ddl(); + let ask_a_after = d.ask("SELECT a FROM t WHERE id = 1;"); + let ask_a2_after = d.ask("SELECT a2 FROM t WHERE id = 1;"); + let disk_after = d.shape_from_a_fresh_pool("t"); + + eprintln!("--- 1: refusal = {e}"); + eprintln!("--- 1: shape BEFORE = {shape_before:?}"); + eprintln!("--- 1: shape AFTER = {shape_after:?}"); + eprintln!("--- 1: DISK BEFORE = {disk_before:?}"); + eprintln!("--- 1: DISK AFTER = {disk_after:?} <- fresh DiskManager + fresh buffer pool"); + eprintln!("--- 1: ddl BEFORE = {ddl_before:?}"); + eprintln!("--- 1: ddl AFTER = {ddl_after:?}"); + eprintln!("--- 1: `SELECT a` before = {ask_a_before}"); + eprintln!("--- 1: `SELECT a` after = {ask_a_after}"); + eprintln!("--- 1: `SELECT a2` before = {ask_a2_before}"); + eprintln!("--- 1: `SELECT a2` after = {ask_a2_after}"); + eprintln!("--- 1: heap bytes identical = {}", heap_before == heap_after); + eprintln!("--- 1: heap lens before = {:?}", heap_before.iter().map(|(_, b)| b.len()).collect::>()); + eprintln!("--- 1: heap lens after = {:?}", heap_after.iter().map(|(_, b)| b.len()).collect::>()); + + // The heap half of the claim is expected to HOLD. Assert it separately so a failure of the + // catalog half cannot be confused with a failure of the heap half. + assert_eq!(heap_before, heap_after, "the refused MERGE changed raw heap bytes"); + assert_eq!(shape_before, shape_after, "the refused MERGE renamed a column in the catalog"); + assert_eq!(disk_before, disk_after, "the refused MERGE persisted a rename to DISK"); + assert_eq!(ddl_before, ddl_after, "the refused MERGE emitted a DDL record"); + assert_eq!(ask_a_before, ask_a_after, "`SELECT a` answers differently after the refusal"); +} + +// --------------------------------------------------------------------------------------------- +// 2. ORDER. Same two edits, retype FIRST. If the refusal is genuinely "edit 1 commits, edit 2 +// is refused", then with the refused edit first NOTHING should be applied. +// --------------------------------------------------------------------------------------------- +#[test] +fn lens_the_refused_edit_first_leaves_nothing_applied() { + let mut d = db(); + seed(&mut d); + let shape_before = d.shape("t"); + let ddl_before = d.ddl(); + + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-2';", &mut agent).expect("branch"); + d.exec(RETYPE, &mut agent).expect("stage retype FIRST"); + d.exec(RENAME, &mut agent).expect("stage rename SECOND"); + let e = d.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + eprintln!("--- 2: refusal = {e}"); + eprintln!("--- 2: shape BEFORE = {shape_before:?}"); + eprintln!("--- 2: shape AFTER = {:?}", d.shape("t")); + eprintln!("--- 2: ddl BEFORE = {ddl_before:?}"); + eprintln!("--- 2: ddl AFTER = {:?}", d.ddl()); + assert_eq!(shape_before, d.shape("t"), "refused-first MERGE still changed the shape"); +} From 502d8edb7d0c5fbd38987b86b424077593bf8391 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:30:38 -0400 Subject: [PATCH 006/315] test(I19-atk4-LENS): independent three-point fixture for the refused-MERGE route lens1/lens4 fail at 58804c8: MERGE returns the size refusal and the branch's rows are on the target anyway, durably. lens3 passes: the plain refused ALTER path is byte-identical through checkpoint/flush/reopen. --- tests/lens_merge_refusal.rs | 272 ++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 tests/lens_merge_refusal.rs diff --git a/tests/lens_merge_refusal.rs b/tests/lens_merge_refusal.rs new file mode 100644 index 00000000..3d0403b1 --- /dev/null +++ b/tests/lens_merge_refusal.rs @@ -0,0 +1,272 @@ +//! LENS: is a MERGE that is refused for tuple size atomic on the target? +//! +//! Three snapshots per fixture, not two: T0 before the branch opens, T1 after the branch has +//! written rows and staged the edit but before `MERGE`, T2 after the refused `MERGE`. T0 vs T1 +//! says whether branch isolation held; T1 vs T2 says what the refused statement did. + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + dir: tempfile::TempDir, + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + open_at(tempfile::tempdir().unwrap()) +} + +fn open_at(dir: tempfile::TempDir) -> Db { + let path = dir.path().join("alter.db"); + let fresh = !path.exists(); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let catalog = + if fresh { Catalog::create(bp.clone()).unwrap() } else { Catalog::open(bp.clone(), 1).unwrap() }; + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, catalog, wal, bp, txn, runtime, session } +} + +/// Write everything through and reopen into a brand-new buffer pool and catalog. +fn reopen(d: Db) -> Db { + d.txn.checkpoint().expect("checkpoint"); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + let Db { dir, .. } = d; + open_at(dir) +} + +impl Db { + fn exec(&mut self, sql: &str, session: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), session) + } + fn try_sql(&mut self, sql: &str) -> Result { + let mut s = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = self.exec(sql, &mut s); + self.session = s; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn rows(&mut self, sql: &str) -> Result>, String> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.try_sql(sql))) + .map_err(|_| format!("`{sql}` PANICKED"))? + { + Ok(Outcome::Rows(r)) => Ok(r), + Ok(_) => Err(format!("`{sql}` returned no rows")), + Err(e) => Err(format!("`{sql}` errored: {e}")), + } + } + fn shape(&self, t: &str) -> Vec<(String, DataType)> { + self.catalog.get_table(t).unwrap().schema.columns.iter() + .map(|c| (c.name.clone(), c.data_type.clone())).collect() + } + fn heap(&self, t: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(t).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()).scan() + .map(|r| { let (rid, tup) = r.unwrap(); (rid, tup.data) }).collect() + } + fn by_key(&self, t: &str, keys: &[Value]) -> Vec<(Value, Option)> { + let root = self.catalog.get_table(t).unwrap().primary_index_root; + let ix = BPlusTreeManager::::open(root, self.bp.clone()); + keys.iter().map(|k| (k.clone(), ix.search(k).unwrap())).collect() + } + fn ddl(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode(&self.wal, self.wal.base_lsn.load(Ordering::SeqCst), + self.wal.next_lsn.load(Ordering::SeqCst)) + .expect("decode").schema_changes.iter().map(|(_, t, c)| format!("{t}:{c:?}")).collect() + } +} + +#[derive(Debug, PartialEq, Clone)] +struct Snap { + shape: Vec<(String, DataType)>, + heap: Vec<(RecordId, Vec)>, + rows: Result>, String>, + by_key: Vec<(Value, Option)>, + ddl: Vec, +} + +fn snap(d: &mut Db) -> Snap { + Snap { + shape: d.shape("t"), + heap: d.heap("t"), + rows: d.rows(SEL), + by_key: d.by_key("t", &keys()), + ddl: d.ddl(), + } +} + +fn keys() -> Vec { vec![Value::Integer(1), Value::Integer(2), Value::Integer(3)] } +const SEL: &str = "SELECT id, n FROM t;"; +const REFUSED: &str = "ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"; + +fn seed(d: &mut Db) { + let big = "x".repeat(2014); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(2100), b VARCHAR(2100));"); + d.sql("INSERT INTO t VALUES (1, 100, 'small', 'small');"); + d.sql(&format!("INSERT INTO t VALUES (2, 200, '{big}', '{big}');")); +} + +fn is_size_refusal(e: &FerroError) -> bool { + let m = e.to_string(); + ["would widen", "bytes a tuple can occupy", "Nothing has been written", "t"] + .iter().all(|n| m.contains(n)) +} + +fn brief(s: &Snap) -> String { + format!("rows={:?} slots={:?} by_key={:?} shape={:?} ddl={:?}", + s.rows, + s.heap.iter().map(|(r, b)| (r.page_id, r.slot_num, b.len())).collect::>(), + s.by_key, s.shape.iter().map(|(n, t)| format!("{n}:{t:?}")).collect::>(), s.ddl) +} + +// ============================================================================================= +// 1. The core measurement. Does the target change across a MERGE that reports failure? +// ============================================================================================= +#[test] +fn lens1_refused_merge_three_point() { + let mut d = db(); + seed(&mut d); + let t0 = snap(&mut d); + + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-1';", &mut agent).expect("branch"); + d.exec("UPDATE t SET n = 999 WHERE id = 1;", &mut agent).expect("branch update"); + d.exec("INSERT INTO t VALUES (3, 300, 'small', 'small');", &mut agent).expect("branch insert"); + d.exec(REFUSED, &mut agent).expect("branch accepts the edit"); + let t1 = snap(&mut d); + + let e = d.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + assert!(is_size_refusal(&e), "refused for the wrong reason: {e}"); + let t2 = snap(&mut d); + + eprintln!("LENS1 refusal = {e}"); + eprintln!("LENS1 T0 (pre-branch) = {}", brief(&t0)); + eprintln!("LENS1 T1 (pre-merge) = {}", brief(&t1)); + eprintln!("LENS1 T2 (post-fail) = {}", brief(&t2)); + eprintln!("LENS1 isolation held (T0==T1)? {}", t0 == t1); + eprintln!("LENS1 target unchanged across the refused MERGE (T1==T2)? {}", t1 == t2); + eprintln!("LENS1 shape unchanged? {} ddl unchanged? {}", t1.shape == t2.shape, t1.ddl == t2.ddl); + // Discriminator (b): is every pre-existing tuple still present, byte for byte, at the same rid? + let lost: Vec<_> = t1.heap.iter().filter(|x| !t2.heap.contains(x)).collect(); + let gained: Vec<_> = t2.heap.iter().filter(|x| !t1.heap.contains(x)) + .map(|(r, b)| (r.page_id, r.slot_num, b.len())).collect(); + eprintln!("LENS1 pre-existing tuples LOST or moved = {:?}", + lost.iter().map(|(r, b)| (r.page_id, r.slot_num, b.len())).collect::>()); + eprintln!("LENS1 tuples GAINED = {gained:?}"); + eprintln!("LENS1 full rows after = {:?}", d.rows("SELECT id, n, a FROM t;")); + + assert_eq!(t1, t2, "MERGE reported failure ({e}) but the target changed"); +} + +// ============================================================================================= +// 2. Control: the same branch writes with NO staged ALTER. Are these rows the branch's own? +// ============================================================================================= +#[test] +fn lens2_control_same_writes_without_the_alter() { + let mut d = db(); + seed(&mut d); + let t1 = snap(&mut d); + + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-2';", &mut agent).expect("branch"); + d.exec("UPDATE t SET n = 999 WHERE id = 1;", &mut agent).expect("branch update"); + d.exec("INSERT INTO t VALUES (3, 300, 'small', 'small');", &mut agent).expect("branch insert"); + let ok = d.exec("MERGE;", &mut agent); + let t2 = snap(&mut d); + eprintln!("LENS2 merge ok? = {:?}", ok.is_ok()); + eprintln!("LENS2 T1 (pre-merge) = {}", brief(&t1)); + eprintln!("LENS2 T2 (post-merge) = {}", brief(&t2)); + eprintln!("LENS2 full rows after = {:?}", d.rows("SELECT id, n, a FROM t;")); +} + +// ============================================================================================= +// 3. Control: the CLAIM under attack. A plain refused ALTER, no MERGE anywhere. +// ============================================================================================= +#[test] +fn lens3_control_plain_refused_alter_changes_nothing() { + let mut d = db(); + seed(&mut d); + let before = snap(&mut d); + let e = d.try_sql(REFUSED).err().expect("plain ALTER must be refused"); + assert!(is_size_refusal(&e), "wrong refusal: {e}"); + let after = snap(&mut d); + eprintln!("LENS3 before = {}", brief(&before)); + eprintln!("LENS3 after = {}", brief(&after)); + assert_eq!(before, after, "a plain refused ALTER changed the table ({e})"); + + let mut r = reopen(d); + let reopened = snap(&mut r); + eprintln!("LENS3 reopened = {}", brief(&reopened)); + assert_eq!(before, reopened, "a plain refused ALTER changed the table durably ({e})"); +} + +// ============================================================================================= +// 4. Is whatever the refused MERGE left durable? Reopen into a fresh buffer pool. +// ============================================================================================= +#[test] +fn lens4_refused_merge_durability_and_retry() { + let mut d = db(); + seed(&mut d); + let t1 = snap(&mut d); + + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'lens-4';", &mut agent).expect("branch"); + d.exec("INSERT INTO t VALUES (3, 300, 'small', 'small');", &mut agent).expect("branch insert"); + d.exec(REFUSED, &mut agent).expect("branch accepts the edit"); + let e = d.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + assert!(is_size_refusal(&e), "wrong refusal: {e}"); + let t2 = snap(&mut d); + + // A retry of the same MERGE: does it double-apply the rows already on the target? + let e2 = d.exec("MERGE;", &mut agent); + let t3 = snap(&mut d); + eprintln!("LENS4 retry MERGE = {:?}", e2.as_ref().err().map(|x| x.to_string())); + eprintln!("LENS4 T1 pre-merge = {}", brief(&t1)); + eprintln!("LENS4 T2 post-fail = {}", brief(&t2)); + eprintln!("LENS4 T3 post-retry = {}", brief(&t3)); + + let mut r = reopen(d); + let t4 = snap(&mut r); + eprintln!("LENS4 T4 reopened = {}", brief(&t4)); + eprintln!("LENS4 durable change across the refused MERGE (T1 vs T4 rows)? before={:?} after={:?}", + t1.rows, t4.rows); + assert_eq!(t1.rows, t4.rows, "a refused MERGE changed the target durably"); +} From 661446b6b1771b224da7ea72f7559543d578c8e0 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:33:34 -0400 Subject: [PATCH 007/315] test(I19-atk4-LENS): refute the 'refused MERGE rewrites the heap' claim with a plain-sequential control --- tests/integration_lens_atk4_e.rs | 288 +++++++++++++++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/integration_lens_atk4_e.rs diff --git a/tests/integration_lens_atk4_e.rs b/tests/integration_lens_atk4_e.rs new file mode 100644 index 00000000..c815415c --- /dev/null +++ b/tests/integration_lens_atk4_e.rs @@ -0,0 +1,288 @@ +//! LENS: skeptical re-derivation of the ATK4 "E" claim. +//! +//! Claim: a MERGE that reports failure leaves every live tuple's raw heap bytes changed, +//! because the first staged edit (ADD COLUMN) rewrites the heap and is committed before the +//! second (retype) is refused. +//! +//! What this file measures, on the committed tree, with its own fixture: +//! 1. does the MERGE really report failure, and does the shape/heap really change? +//! 2. is the resulting state EXACTLY the state that "ALTER ADD (ok); ALTER RETYPE (refused)" +//! as two plain statements produces? (i.e. is anything LOST, or is it only that MERGE +//! is not atomic across two DDL edits?) +//! 3. does the *refused ALTER statement itself* change one byte of the heap? +//! 4. does every value survive a flush + reopen into a FRESH buffer pool? + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + #[allow(dead_code)] + dir: tempfile::TempDir, + path: std::path::PathBuf, + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("alter.db"); + let file = + std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { dir, path, catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let mut session = + std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = self.exec(sql, &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn exec(&mut self, sql: &str, session: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), session) + } + fn rows(&mut self, sql: &str) -> Result>, String> { + match self.try_sql(sql) { + Ok(Outcome::Rows(r)) => Ok(r), + Ok(_) => Err(format!("`{sql}` did not return rows")), + Err(e) => Err(format!("`{sql}` errored: {e}")), + } + } + fn shape(&self, table: &str) -> Vec<(String, DataType)> { + self.catalog.get_table(table).unwrap().schema.columns.iter() + .map(|c| (c.name.clone(), c.data_type.clone())).collect() + } + fn heap(&self, table: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(table).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()) + .scan().map(|r| { let (rid, t) = r.unwrap(); (rid, t.data) }).collect() + } + fn ddl(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode(&self.wal, self.wal.base_lsn.load(Ordering::SeqCst), self.wal.next_lsn.load(Ordering::SeqCst)) + .expect("decode").schema_changes.iter().map(|(_, t, c)| format!("{t}:{c:?}")).collect() + } + fn by_key(&self, table: &str, keys: &[Value]) -> Vec<(Value, Option)> { + let root = self.catalog.get_table(table).unwrap().primary_index_root; + let ix = BPlusTreeManager::::open(root, self.bp.clone()); + keys.iter().map(|k| (k.clone(), ix.search(k).unwrap())).collect() + } + /// Flush, sync, and reopen the SAME file through a brand-new buffer pool + catalog. + fn reopen_fresh(&mut self) -> (Vec, Vec<(RecordId, Vec)>) { + self.bp.flush_all().unwrap(); + self.bp.disk_manager.sync().unwrap(); + let file = std::fs::OpenOptions::new().read(true).write(true).open(&self.path).unwrap(); + let bp2 = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let cat2 = Catalog::open(bp2.clone(), 1).unwrap(); + let e = cat2.get_table("t").unwrap(); + let shape = e.schema.columns.iter().map(|c| format!("{}:{:?}", c.name, c.data_type)).collect(); + let heap = HeapFileManager::open(e.first_directory_page_id, bp2.clone()) + .scan().map(|r| { let (rid, t) = r.unwrap(); (rid, t.data) }).collect(); + (shape, heap) + } +} + +#[derive(Debug, PartialEq)] +struct Snap { + shape: Vec<(String, DataType)>, + heap: Vec<(RecordId, Vec)>, + rows: Result>, String>, + by_key: Vec<(Value, Option)>, +} +fn snap(d: &mut Db) -> Snap { + Snap { + shape: d.shape("t"), + heap: d.heap("t"), + rows: d.rows(SEL), + by_key: d.by_key("t", &KEYS), + } +} + +const ADD: &str = "ALTER TABLE t ADD COLUMN c1 VARCHAR(20);"; +const RETYPE: &str = "ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"; +const SEL: &str = "SELECT id, n, a, b FROM t;"; +const KEYS: [Value; 2] = [Value::Integer(1), Value::Integer(2)]; + +fn try_seed(d: &mut Db, w: usize) -> Result<(), FerroError> { + let big = "x".repeat(w); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, n INTEGER, a VARCHAR(2100), b VARCHAR(2100));"); + d.sql("INSERT INTO t VALUES (1, 100, 'small', 'small');"); + d.try_sql(&format!("INSERT INTO t VALUES (2, 200, '{big}', '{big}');")).map(|_| ()) +} +fn seed(d: &mut Db, w: usize) { + try_seed(d, w).unwrap_or_else(|e| panic!("seed at w={w} failed: {e}")); +} + +/// Find the widths where ADD fits and RETYPE behind it does not. Loud if none. +fn probe_widths() -> Vec { + let mut hit = Vec::new(); + for w in 1990..2020usize { + let mut p = db(); + if try_seed(&mut p, w).is_err() { eprintln!("=== w={w}: seed itself refused, skipping"); continue; } + if p.try_sql(ADD).is_err() { continue; } + if p.try_sql(RETYPE).is_ok() { continue; } + hit.push(w); + } + hit +} + +#[test] +fn lens_the_refused_merge_state_equals_the_plain_sequential_state() { + let widths = probe_widths(); + eprintln!("=== widths where ADD fits and RETYPE is refused: {widths:?}"); + assert!(!widths.is_empty(), "the sweep never reached the condition"); + + for w in &widths { + let w = *w; + + // ---- Route 1: through MERGE, two staged edits, second refused. + let mut m = db(); + seed(&mut m, w); + let before = snap(&mut m); + let mut agent = Session::with_runtime(m.runtime.clone()); + m.exec("BEGIN AGENT SESSION AS 'agent-e';", &mut agent).expect("branch"); + m.exec(ADD, &mut agent).expect("stage add"); + m.exec(RETYPE, &mut agent).expect("stage retype"); + let merr = m.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + let after_merge = snap(&mut m); + + // ---- Route 2: the same two edits as PLAIN statements, in order. + let mut s = db(); + seed(&mut s, w); + let s_add = s.try_sql(ADD); + let s_ret = s.try_sql(RETYPE); + let after_seq = snap(&mut s); + + eprintln!("--- w={w} MERGE err = {merr}"); + eprintln!("--- w={w} plain ADD = {:?}", s_add.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + eprintln!("--- w={w} plain RETYPE = {:?}", s_ret.as_ref().map(|_| "Ok").map_err(|e| e.to_string())); + eprintln!("--- w={w} shape before = {:?}", before.shape.iter().map(|c| c.0.clone()).collect::>()); + eprintln!("--- w={w} shape after MERGE= {:?}", after_merge.shape.iter().map(|c| c.0.clone()).collect::>()); + eprintln!("--- w={w} shape after SEQ = {:?}", after_seq.shape.iter().map(|c| c.0.clone()).collect::>()); + eprintln!("--- w={w} heap lens before = {:?}", before.heap.iter().map(|(_, b)| b.len()).collect::>()); + eprintln!("--- w={w} heap lens MERGE = {:?}", after_merge.heap.iter().map(|(_, b)| b.len()).collect::>()); + eprintln!("--- w={w} heap lens SEQ = {:?}", after_seq.heap.iter().map(|(_, b)| b.len()).collect::>()); + eprintln!("--- w={w} rows after MERGE = {:?}", after_merge.rows.as_ref().map(|r| r.iter().map(|row| format!("{:?}|{:?}|len{}|len{}", row[0], row[1], + match &row[2] { Value::Varchar(s) => s.len(), o => format!("{o:?}").len() }, + match &row[3] { Value::Varchar(s) => s.len(), o => format!("{o:?}").len() })).collect::>())); + eprintln!("--- w={w} rows after SEQ = {:?}", after_seq.rows.as_ref().map(|r| r.len())); + eprintln!("--- w={w} by_key MERGE = {:?}", after_merge.by_key); + let ddl_merge = m.ddl(); + let ddl_seq = s.ddl(); + eprintln!("--- w={w} ddl after MERGE = {ddl_merge:?}"); + eprintln!("--- w={w} ddl after SEQ = {ddl_seq:?}"); + eprintln!("--- w={w} ddl identical = {}", ddl_merge == ddl_seq); + eprintln!("--- w={w} MERGE state == plain-sequential state? {}", after_merge == after_seq); + assert_eq!(ddl_merge, ddl_seq, "the two routes emitted different change-feed DDL (w={w})"); + + assert!(s_add.is_ok(), "the plain ADD must succeed at w={w}"); + assert!(s_ret.is_err(), "the plain RETYPE must be refused at w={w}"); + // The decisive question: is anything LOST relative to "first edit landed, second refused"? + assert_eq!(after_merge, after_seq, + "the refused MERGE left a state that plain sequential statements do not produce (w={w})"); + } +} + +#[test] +fn lens_the_refused_alter_statement_itself_changes_nothing() { + let widths = probe_widths(); + assert!(!widths.is_empty(), "the sweep never reached the condition"); + for w in &widths { + let w = *w; + let mut d = db(); + seed(&mut d, w); + d.sql(ADD); // the heap rewrite that legitimately succeeds + let before = snap(&mut d); + let (disk_shape_b, disk_heap_b) = d.reopen_fresh(); + let e = d.try_sql(RETYPE).err().expect("RETYPE must be refused"); + let after = snap(&mut d); + let (disk_shape_a, disk_heap_a) = d.reopen_fresh(); + eprintln!("--- w={w} refusal = {e}"); + eprintln!("--- w={w} in-mem identical = {}", before == after); + eprintln!("--- w={w} on-disk shape identical = {}", disk_shape_b == disk_shape_a); + eprintln!("--- w={w} on-disk heap identical = {}", disk_heap_b == disk_heap_a); + eprintln!("--- w={w} on-disk shape after refusal = {disk_shape_a:?}"); + eprintln!("--- w={w} on-disk heap lens after refusal = {:?}", disk_heap_a.iter().map(|(_, b)| b.len()).collect::>()); + assert_eq!(before, after, "the REFUSED ALTER changed the table in memory (w={w})"); + assert_eq!(disk_shape_b, disk_shape_a, "the REFUSED ALTER changed the on-disk shape (w={w})"); + assert_eq!(disk_heap_b, disk_heap_a, "the REFUSED ALTER changed the on-disk heap (w={w})"); + } +} + +#[test] +fn lens_every_value_survives_the_refused_merge_and_a_fresh_reopen() { + let widths = probe_widths(); + assert!(!widths.is_empty(), "the sweep never reached the condition"); + for w in &widths { + let w = *w; + let big = "x".repeat(w); + let mut d = db(); + seed(&mut d, w); + let mut agent = Session::with_runtime(d.runtime.clone()); + d.exec("BEGIN AGENT SESSION AS 'agent-v';", &mut agent).expect("branch"); + d.exec(ADD, &mut agent).expect("stage add"); + d.exec(RETYPE, &mut agent).expect("stage retype"); + let e = d.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + eprintln!("--- w={w} refusal = {e}"); + let rows = d.rows("SELECT id, n, a, b, c1 FROM t;").expect("table still readable"); + eprintln!("--- w={w} row count = {}", rows.len()); + assert_eq!(rows.len(), 2, "a row was lost (w={w})"); + assert_eq!(rows[0][0], Value::Integer(1)); + assert_eq!(rows[0][1], Value::Integer(100)); + assert_eq!(rows[0][2], Value::Varchar("small".into())); + assert_eq!(rows[0][3], Value::Varchar("small".into())); + assert_eq!(rows[0][4], Value::Null, "the added column is not NULL"); + assert_eq!(rows[1][0], Value::Integer(2)); + assert_eq!(rows[1][1], Value::Integer(200)); + assert_eq!(rows[1][2], Value::Varchar(big.clone()), "the wide value changed"); + assert_eq!(rows[1][3], Value::Varchar(big.clone()), "the wide value changed"); + assert_eq!(rows[1][4], Value::Null); + let (disk_shape, disk_heap) = d.reopen_fresh(); + eprintln!("--- w={w} fresh-pool shape = {disk_shape:?}"); + eprintln!("--- w={w} fresh-pool heap lens = {:?}", disk_heap.iter().map(|(_, b)| b.len()).collect::>()); + assert_eq!(disk_heap.len(), 2, "a row was lost after reopen (w={w})"); + // The refused retype must NOT be on disk. + assert!(disk_shape.contains(&"n:Integer".to_string()), + "the refused retype reached disk: {disk_shape:?}"); + } +} From 625aad314ecac17788a2eb3cf0a5dc3327950f33 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:46:51 -0400 Subject: [PATCH 008/315] =?UTF-8?q?test(I19):=20adversarial=20durability?= =?UTF-8?q?=20pass=204=20=E2=80=94=20reserve=5Ffree=5Fspace=20cannot=20ter?= =?UTF-8?q?minate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attacking 'a refused ALTER leaves the table exactly as it was' from the durability side. The refusal paths hold: a width refusal and an allocator-exhaustion refusal both leave alter.db and alter.wal byte-identical across a crash with no clean flush plus recovery, leave the WAL, the retained DDL declaration and the statistics untouched, do not poison the ALTER that follows, and a differential pair (one arm refused, neither flushed) recovers to identical heap bytes and index answers. What does not hold is that the guard added in 58804c8 terminates. reserve_free_space loops 'while free_space() < bytes { find_or_make_page(usable) }' with usable = PAGE_SIZE - HEADER_SIZE - SLOT_ENTRY_SIZE, and find_or_make_page asks the directory for usable + SLOT_ENTRY_SIZE = 4073 free bytes, which is exactly what an empty page reports. So iteration two finds the page iteration one made and adds nothing: free_space goes 55 -> 4128 -> 4128 -> 4128 and find_or_make_page returns page 6 three times. Any ALTER whose growing rows need more than one page of room therefore never returns. Measured A/B with one identical fixture: src at 19b1de2 finishes ADD COLUMN on an 82-row two-page table in 0.37s with all 82 rows and 3 columns; src at 58804c8 has not returned after 90s (and 11 min at 92% CPU in an earlier 300-row run). Secondary: the wedged statement has already grown alter.db from 28672 to 32768 bytes and changed page 0, unlogged, from a statement that neither succeeded nor was refused. A fresh handle plus recovery still reads 82 rows and 2 columns, so this is a leaked page rather than damage. --- tests/zz_atk4_durability.rs | 576 ++++++++++++++++++++++++++++++++++++ tests/zz_atk4_regress.rs | 73 +++++ 2 files changed, 649 insertions(+) create mode 100644 tests/zz_atk4_durability.rs create mode 100644 tests/zz_atk4_regress.rs diff --git a/tests/zz_atk4_durability.rs b/tests/zz_atk4_durability.rs new file mode 100644 index 00000000..24a6a40e --- /dev/null +++ b/tests/zz_atk4_durability.rs @@ -0,0 +1,576 @@ +//! I19 adversarial pass 4 — durability and recovery around a REFUSED `ALTER TABLE`. +//! +//! Claim under attack: "an ALTER that is refused leaves the table exactly as it was ... and that +//! holds after a checkpoint, a flush and a reopen into a fresh buffer pool." +//! These fixtures attack the halves the committed suite does not measure: the raw bytes of the +//! database FILE (not just the live tuples), a crash with no clean flush at all, a refusal +//! followed by a real ALTER, and whether `reserve_free_space` — the guard added by 58804c8 — +//! terminates. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::storage::heap_page::MAX_TUPLE_SIZE; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +/// Open (or create) the database in `dir`. The directory is NOT owned here, so the caller can drop +/// every handle — the crash — and still read the files afterwards. +fn open(dir: &Path, recover: bool) -> Db { + let path = dir.join("alter.db"); + let fresh = !path.exists(); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + if recover { + ferrodb::wal::recovery::recover(&txn).expect("recover"); + } + let catalog = + if fresh { Catalog::create(bp.clone()).unwrap() } else { Catalog::open(bp.clone(), 1).unwrap() }; + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = + std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + + fn rows(&mut self, sql: &str) -> Result>, String> { + let out = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.try_sql(sql))) + .map_err(|_| format!("`{sql}` PANICKED the reading thread"))?; + match out { + Ok(Outcome::Rows(r)) => Ok(r), + Ok(_) => Err(format!("`{sql}` did not return rows")), + Err(e) => Err(format!("`{sql}` errored: {e}")), + } + } + + fn shape(&self, table: &str) -> Vec<(String, DataType)> { + self.catalog + .get_table(table) + .unwrap_or_else(|| panic!("no table {table}")) + .schema + .columns + .iter() + .map(|c| (c.name.clone(), c.data_type.clone())) + .collect() + } + + fn heap(&self, table: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(table).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()) + .scan() + .map(|r| { + let (rid, tuple) = r.unwrap(); + (rid, tuple.data) + }) + .collect() + } + + fn by_key(&self, table: &str, keys: &[Value]) -> Vec<(Value, Option)> { + let root = self.catalog.get_table(table).unwrap().primary_index_root; + let ix = BPlusTreeManager::::open(root, self.bp.clone()); + keys.iter().map(|k| (k.clone(), ix.search(k).unwrap())).collect() + } + + fn schema_changes(&self) -> Vec { + use std::sync::atomic::Ordering; + self.wal.flush().unwrap(); + ferrodb::replication::logical::LogicalDecoder::new(&self.catalog) + .decode( + &self.wal, + self.wal.base_lsn.load(Ordering::SeqCst), + self.wal.next_lsn.load(Ordering::SeqCst), + ) + .expect("decode") + .schema_changes + .iter() + .map(|(_, t, c)| format!("{t}:{c:?}")) + .collect() + } + + fn stats_of(&self, table: &str) -> String { + format!("{:?}", self.catalog.stats.get(table)) + } +} + +#[derive(Debug, PartialEq)] +struct Snapshot { + shape: Vec<(String, DataType)>, + heap: Vec<(RecordId, Vec)>, + rows: Result>, String>, + by_key: Vec<(Value, Option)>, +} + +fn snapshot(d: &mut Db, table: &str, select: &str, keys: &[Value]) -> Snapshot { + Snapshot { shape: d.shape(table), heap: d.heap(table), rows: d.rows(select), by_key: d.by_key(table, keys) } +} + +/// Every byte of both files, and a short digest for messages. +fn files(dir: &Path) -> (Vec, Vec) { + let db = std::fs::read(dir.join("alter.db")).unwrap_or_default(); + let wal = std::fs::read(dir.join("alter.wal")).unwrap_or_default(); + (db, wal) +} + +fn describe(name: &str, a: &[u8], b: &[u8]) -> String { + if a == b { + return format!("{name}: identical ({} bytes)", a.len()); + } + let first = a.iter().zip(b.iter()).position(|(x, y)| x != y); + format!( + "{name}: DIFFERS — was {} bytes, now {} bytes, first differing byte at {:?}", + a.len(), + b.len(), + first + ) +} + +// --------------------------------------------------------------------------------------------- +// 1. Does the guard added by 58804c8 terminate? +// --------------------------------------------------------------------------------------------- + +/// `reserve_free_space` loops `while free_space() < bytes { find_or_make_page(usable) }` with +/// `usable = PAGE_SIZE - HEADER_SIZE - SLOT_ENTRY_SIZE`, and `find_or_make_page` asks the directory +/// for a page with `usable + SLOT_ENTRY_SIZE` free — which is exactly what an EMPTY page reports. +/// So the second iteration finds the page the first one made and makes nothing. +#[test] +fn find_or_make_page_makes_progress_on_consecutive_calls() { + let dir = tempfile::tempdir().unwrap(); + let mut d = open(dir.path(), false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let pad = "y".repeat(60); + for i in 1..=41 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + let root = d.catalog.get_table("t").unwrap().first_directory_page_id; + let heap = HeapFileManager::open(root, d.bp.clone()); + + let f0 = heap.free_space().unwrap(); + let p1 = heap.find_or_make_page(MAX_TUPLE_SIZE).unwrap(); + let f1 = heap.free_space().unwrap(); + let p2 = heap.find_or_make_page(MAX_TUPLE_SIZE).unwrap(); + let f2 = heap.free_space().unwrap(); + let p3 = heap.find_or_make_page(MAX_TUPLE_SIZE).unwrap(); + let f3 = heap.free_space().unwrap(); + println!("free_space: {f0} -> {f1} -> {f2} -> {f3}; pages: {p1}, {p2}, {p3}"); + assert!( + f3 > f2 && f2 > f1, + "reserve_free_space's loop body stopped adding space after the first page: free_space went \ + {f0} -> {f1} -> {f2} -> {f3} and find_or_make_page returned {p1}, {p2}, {p3}. The loop \ + `while free_space() < bytes` therefore cannot terminate once one empty page exists." + ); +} + +/// The impact: an ordinary `ADD COLUMN` on a table whose rows need more than one page of room. +/// Run in a thread so a non-terminating rewrite is a failed assertion rather than a wedged suite. +#[test] +fn add_column_on_a_two_page_table_finishes() { + let dir = tempfile::tempdir().unwrap(); + let path: PathBuf = dir.path().to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut d = open(&path, false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let pad = "y".repeat(60); + for i in 1..=82 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + let pages: std::collections::BTreeSet = + d.heap("t").iter().map(|(rid, _)| rid.page_id).collect(); + let _ = tx.send(format!("filled: {} rows over {} pages", d.heap("t").len(), pages.len())); + let r = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + let _ = tx.send(match r { Ok(_) => "ALTER returned: Ok".to_string(), Err(e) => format!("ALTER refused: {e}") }); + }); + let filled = rx.recv_timeout(Duration::from_secs(120)).expect("the fixture never finished loading rows"); + println!("{filled}"); + match rx.recv_timeout(Duration::from_secs(45)) { + Ok(s) => println!("{s}"), + Err(_) => panic!( + "`ALTER TABLE t ADD COLUMN w VARCHAR(10)` on a two-page table ({filled}) had not \ + returned — neither succeeded nor been refused — 45s after the rows were in place." + ), + } +} + +// --------------------------------------------------------------------------------------------- +// 2. A refusal, then a crash with no clean flush of any kind +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_refused_add_column_leaves_both_files_byte_identical_across_a_crash() { + let mut refusals = 0; + for pad in 4028usize..=4040 { + let dir = tempfile::tempdir().unwrap(); + let keys = [Value::Integer(1), Value::Integer(2)]; + let select = "SELECT id, a FROM t;"; + let (base_db, base_wal, before) = { + let mut d = open(dir.path(), false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 'small');"); + let big = "x".repeat(pad); + if d.try_sql(&format!("INSERT INTO t VALUES (2, '{big}');")).is_err() { + continue; + } + // A clean baseline on disk: everything before the ALTER is durable. + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + d.wal.flush().unwrap(); + let (bdb, bwal) = files(dir.path()); + let before = snapshot(&mut d, "t", select, &keys); + + let Err(e) = d.try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);") else { continue }; + refusals += 1; + + // No flush, no checkpoint, no sync: the process dies here. + let (mid_db, mid_wal) = files(dir.path()); + println!("pad={pad} refused with: {e}"); + println!(" in-place {}", describe("alter.db", &bdb, &mid_db)); + println!(" in-place {}", describe("alter.wal", &bwal, &mid_wal)); + (bdb, bwal, before) + }; // every handle dropped — the crash + + let (crash_db, crash_wal) = files(dir.path()); + assert_eq!( + base_db, crash_db, + "pad={pad}: a REFUSED ALTER changed the database file across a crash with no clean \ + flush. {}", + describe("alter.db", &base_db, &crash_db) + ); + assert_eq!( + base_wal, crash_wal, + "pad={pad}: a REFUSED ALTER changed the WAL. {}", + describe("alter.wal", &base_wal, &crash_wal) + ); + + let mut r = open(dir.path(), true); + let after = snapshot(&mut r, "t", select, &keys); + assert_eq!( + before, after, + "pad={pad}: after a refusal, a crash with no clean flush and a recovery, the table is \ + not what it was" + ); + } + assert!(refusals > 0, "no width refused; the fixture measured nothing (MAX_TUPLE_SIZE={MAX_TUPLE_SIZE})"); +} + +// --------------------------------------------------------------------------------------------- +// 3. A refusal must not poison the ALTER that comes after it +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_refusal_then_a_real_alter_is_durable_and_correct() { + let mut refusals = 0; + for pad in 4028usize..=4040 { + let dir = tempfile::tempdir().unwrap(); + let keys = [Value::Integer(1), Value::Integer(2)]; + { + let mut d = open(dir.path(), false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 'small');"); + let big = "x".repeat(pad); + if d.try_sql(&format!("INSERT INTO t VALUES (2, '{big}');")).is_err() { + continue; + } + let Err(_) = d.try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);") else { continue }; + refusals += 1; + + // Narrow the offending row and run the same statement, which must now work. + d.sql("UPDATE t SET a = 'narrow' WHERE id = 2;"); + d.sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);"); + d.sql("UPDATE t SET note = 'after' WHERE id = 1;"); + d.txn.checkpoint().expect("checkpoint"); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + } + let mut r = open(dir.path(), true); + assert_eq!(r.shape("t").len(), 3, "pad={pad}: the successful ALTER did not survive the reopen"); + let rows = r.rows("SELECT id, a, note FROM t;").unwrap_or_else(|e| panic!("pad={pad}: {e}")); + let mut got: Vec = rows.iter().map(|r| format!("{r:?}")).collect(); + got.sort(); + assert_eq!( + got, + vec![ + format!("{:?}", vec![Value::Integer(1), Value::Varchar("small".into()), Value::Varchar("after".into())]), + format!("{:?}", vec![Value::Integer(2), Value::Varchar("narrow".into()), Value::Null]), + ], + "pad={pad}: after a refusal + a successful ALTER, a reopen reads back the wrong table" + ); + let ix = r.by_key("t", &keys); + assert!(ix.iter().all(|(_, rid)| rid.is_some()), "pad={pad}: primary index lost a key: {ix:?}"); + } + assert!(refusals > 0, "no width refused; the fixture measured nothing"); +} + +// --------------------------------------------------------------------------------------------- +// 4. What a refusal leaves in the WAL, the retained DDL declaration and the statistics +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_refused_alter_leaves_the_wal_the_ddl_declaration_and_the_stats_alone() { + let mut refusals = 0; + let mut analyzed = 0; + for pad in 4028usize..=4040 { + let dir = tempfile::tempdir().unwrap(); + let mut d = open(dir.path(), false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, a VARCHAR(4100));"); + d.sql("INSERT INTO t VALUES (1, 'small');"); + let big = "x".repeat(pad); + if d.try_sql(&format!("INSERT INTO t VALUES (2, '{big}');")).is_err() { + continue; + } + if d.try_sql("ANALYZE t;").is_ok() { + analyzed += 1; + } + d.wal.flush().unwrap(); + let (_, wal_before) = files(dir.path()); + let ddl_before = d.schema_changes(); + let stats_before = d.stats_of("t"); + + let Err(e) = d.try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);") else { continue }; + refusals += 1; + + d.wal.flush().unwrap(); + let (_, wal_after) = files(dir.path()); + assert_eq!( + wal_before, wal_after, + "pad={pad}: a REFUSED ALTER appended to the WAL ({e}). {}", + describe("alter.wal", &wal_before, &wal_after) + ); + assert_eq!(ddl_before, d.schema_changes(), "pad={pad}: a REFUSED ALTER changed the retained DDL declaration"); + assert_eq!(stats_before, d.stats_of("t"), "pad={pad}: a REFUSED ALTER changed the statistics"); + } + assert!(refusals > 0, "no width refused; the fixture measured nothing"); + println!("widths where ANALYZE ran: {analyzed}"); +} + +// --------------------------------------------------------------------------------------------- +// 5. Differential: two identical databases, one of which is handed a refusal, then both crash +// with NO clean flush at any point. Recovery has to make them the same database. +// --------------------------------------------------------------------------------------------- + +/// The crash test above flushes a clean baseline first, so the only writes it can see are the +/// refusal's own. This one never flushes at all: whatever the refused statement did to the buffer +/// pool — dirtying a page, forcing an eviction while it scans, moving the WAL — has to come out in +/// the wash of `recover`. The control arm runs every other statement in the same order. +#[test] +fn a_refusal_before_an_unflushed_crash_recovers_to_the_same_database() { + // Large enough that pass 1's scan walks many pages, so an eviction during the refused + // statement is possible rather than hypothetical. + const ROWS: i32 = 150; + let pad = "z".repeat(600); + let select = "SELECT id, v FROM t;"; + let keys: Vec = (1..=ROWS).map(Value::Integer).collect(); + + let build = |dir: &Path, refuse: bool| -> String { + let mut d = open(dir, false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(4100));"); + for i in 1..=ROWS { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + // One row that no ADD COLUMN can widen, so the refusal is the size precheck's. + // 4034 is the width measured above at which the widened row becomes 4070 bytes, + // i.e. one byte past MAX_TUPLE_SIZE, so pass 1 refuses before it reserves anything. + let huge = "w".repeat(4034); + d.sql(&format!("INSERT INTO t VALUES ({}, '{huge}');", ROWS + 1)); + let mut note = String::from("no alter attempted"); + if refuse { + match d.try_sql("ALTER TABLE t ADD COLUMN c VARCHAR(20);") { + Err(e) => note = format!("refused: {e}"), + Ok(_) => panic!("the fixture's ALTER succeeded, so it measures nothing"), + } + } + note + // dropped here: no flush_all, no sync, no checkpoint — the crash. + }; + + let control = tempfile::tempdir().unwrap(); + let victim = tempfile::tempdir().unwrap(); + let note = build(control.path(), false); + println!("control: {note}"); + let note = build(victim.path(), true); + println!("victim: {}", ¬e[..note.len().min(120)]); + assert!(note.starts_with("refused:"), "the victim arm was not refused"); + + let mut keys_all = keys.clone(); + keys_all.push(Value::Integer(ROWS + 1)); + let mut c = open(control.path(), true); + let mut v = open(victim.path(), true); + let cs = snapshot(&mut c, "t", select, &keys_all); + let vs = snapshot(&mut v, "t", select, &keys_all); + assert_eq!( + cs.shape, vs.shape, + "after an unflushed crash and recovery, the refused arm has a different schema" + ); + assert_eq!( + cs.rows.as_ref().map(|r| r.len()), + vs.rows.as_ref().map(|r| r.len()), + "after an unflushed crash and recovery, the refused arm holds a different number of rows \ + (control {:?} vs refused {:?})", + cs.rows.as_ref().map(|r| r.len()), + vs.rows.as_ref().map(|r| r.len()) + ); + assert_eq!(cs.heap, vs.heap, "after an unflushed crash and recovery, the refused arm's heap bytes differ"); + assert_eq!(cs.by_key, vs.by_key, "after an unflushed crash and recovery, the primary index differs"); +} + +/// The exhausted-allocator refusal, crashed instead of checkpointed. +/// +/// The committed suite checkpoints, flushes and syncs before it reopens. `reserve_free_space` can +/// add empty pages and update the page directory before it discovers the allocator is closed, and +/// none of that is logged — so if it leaves anything behind, a crash is where it shows. +#[test] +fn an_exhausted_allocator_refusal_leaves_nothing_behind_across_a_crash() { + const ROWS: i32 = 41; + let pad = "y".repeat(60); + let select = "SELECT id, v FROM t;"; + let keys: Vec = (1..=ROWS).map(Value::Integer).collect(); + let dir = tempfile::tempdir().unwrap(); + + let (base_db, base_wal, before) = { + let mut d = open(dir.path(), false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + for i in 1..=ROWS { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + let pages: std::collections::BTreeSet = + d.heap("t").iter().map(|(rid, _)| rid.page_id).collect(); + assert_eq!(pages.len(), 1, "the fixture must pack one page: {pages:?}"); + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + d.wal.flush().unwrap(); + let (bdb, bwal) = files(dir.path()); + let before = snapshot(&mut d, "t", select, &keys); + + let floor = d.bp.disk_manager.high_water().unwrap(); + d.bp.disk_manager.reserve_from(floor).unwrap(); + assert!(d.bp.new_page().is_err(), "the fixture did not close the allocator"); + + let e = d + .try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);") + .err() + .expect("an ALTER that cannot obtain a page must be refused"); + println!("exhaustion refusal: {e}"); + let (mid_db, mid_wal) = files(dir.path()); + println!(" {}", describe("alter.db", &bdb, &mid_db)); + println!(" {}", describe("alter.wal", &bwal, &mid_wal)); + (bdb, bwal, before) + }; // crash: no flush, no sync, no checkpoint + + let (crash_db, crash_wal) = files(dir.path()); + assert_eq!( + base_db, crash_db, + "a REFUSED (allocator-exhausted) ALTER changed the database file across a crash. {}", + describe("alter.db", &base_db, &crash_db) + ); + assert_eq!(base_wal, crash_wal, "{}", describe("alter.wal", &base_wal, &crash_wal)); + + let mut r = open(dir.path(), true); + let after = snapshot(&mut r, "t", select, &keys); + assert_eq!(before, after, "the table is not what it was after refusal + crash + recovery"); +} + +/// Durability of the non-terminating ALTER: while it spins, has anything reached the disk? +/// +/// `reserve_free_space`'s one productive iteration allocates a page and writes a directory entry, +/// and the rewrite is unlogged, so anything the buffer pool wrote through before the loop wedged +/// would be a change on disk made by a statement that has neither succeeded nor been refused — +/// and one recovery cannot undo. The files are read through a fresh `File`, not through the +/// database's own handles, so nothing is flushed by the act of measuring. +#[test] +fn the_non_terminating_alter_writes_nothing_through_while_it_spins() { + let dir = tempfile::tempdir().unwrap(); + let path: PathBuf = dir.path().to_path_buf(); + let (ready_tx, ready_rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut d = open(&path, false); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let pad = "y".repeat(60); + for i in 1..=82 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + d.bp.flush_all().unwrap(); + d.bp.disk_manager.sync().unwrap(); + d.wal.flush().unwrap(); + let _ = ready_tx.send("baseline flushed".to_string()); + let _ = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + let _ = ready_tx.send("the ALTER returned after all".to_string()); + }); + assert_eq!(ready_rx.recv_timeout(Duration::from_secs(120)).unwrap(), "baseline flushed"); + let (base_db, base_wal) = files(dir.path()); + // Give the wedged loop time to write through if it is going to. + assert!( + ready_rx.recv_timeout(Duration::from_secs(20)).is_err(), + "the ALTER completed, so this fixture is not measuring the wedged loop" + ); + let (now_db, now_wal) = files(dir.path()); + println!(" {}", describe("alter.db", &base_db, &now_db)); + println!(" {}", describe("alter.wal", &base_wal, &now_wal)); + // What a reader that opened the database after the wedged process was killed would see. A + // fresh handle on the same files, recovering the way the CLI does after a crash. (With + // `recover: false` the same reader sees the 82 heap tuples but `SELECT` returns 0 rows: the + // commit state of the inserts lives in the WAL, so that is an artifact of skipping recovery, + // not of the wedged ALTER.) + let mut r = open(dir.path(), true); + let pages: std::collections::BTreeSet = + r.heap("t").iter().map(|(rid, _)| rid.page_id).collect(); + let rows = r.rows("SELECT id, v FROM t;"); + println!( + " after the wedge, a fresh reader sees: {} columns, {} heap tuples over pages {:?}, SELECT -> {:?}", + r.shape("t").len(), + r.heap("t").len(), + pages, + rows.as_ref().map(|v| v.len()) + ); + assert_eq!(r.shape("t").len(), 2, "the wedged ALTER installed its schema"); + assert_eq!(rows.as_ref().map(|v| v.len()), Ok(82), "the wedged ALTER lost rows: {rows:?}"); + assert_eq!( + base_db, now_db, + "an ALTER that has neither succeeded nor been refused has already changed the database \ + file, and the rewrite is unlogged so recovery cannot undo it. {}", + describe("alter.db", &base_db, &now_db) + ); +} diff --git a/tests/zz_atk4_regress.rs b/tests/zz_atk4_regress.rs new file mode 100644 index 00000000..64ab3484 --- /dev/null +++ b/tests/zz_atk4_regress.rs @@ -0,0 +1,73 @@ +//! Was the two-page `ADD COLUMN` hang introduced by 58804c8, or was it always there? +//! Self-contained so it compiles against the parent commit's `src/`, which has no +//! `find_or_make_page` and no `reserve_free_space`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::HeapFileManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +fn go(dir: &Path) -> (String, String) { + let path = dir.join("alter.db"); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let mut catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let mut session = Session::with_runtime(runtime.clone()); + let mut go1 = |catalog: &mut Catalog, sql: &str| -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "`{sql}` did not parse"); + run(stmts.remove(0), catalog, bp.clone(), txn.clone(), &mut session) + }; + go1(&mut catalog, "CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));").unwrap(); + let pad = "y".repeat(60); + for i in 1..=82 { + go1(&mut catalog, &format!("INSERT INTO t VALUES ({i}, '{pad}');")).unwrap(); + } + let root = catalog.get_table("t").unwrap().first_directory_page_id; + let heap = HeapFileManager::open(root, bp.clone()); + let rows: Vec<_> = heap.scan().map(|r| r.unwrap()).collect(); + let pages: std::collections::BTreeSet = rows.iter().map(|(rid, _)| rid.page_id).collect(); + let filled = format!("{} rows over {} pages", rows.len(), pages.len()); + let out = match go1(&mut catalog, "ALTER TABLE t ADD COLUMN w VARCHAR(10);") { + Ok(_) => { + let root = catalog.get_table("t").unwrap().first_directory_page_id; + let n = HeapFileManager::open(root, bp.clone()).scan().count(); + format!("Ok, {n} rows still in the heap, {} columns", catalog.get_table("t").unwrap().schema.columns.len()) + } + Err(e) => format!("refused: {e}"), + }; + (filled, out) +} + +#[test] +fn add_column_on_a_two_page_table_finishes() { + let dir = tempfile::tempdir().unwrap(); + let path: PathBuf = dir.path().to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel::<(String, String)>(); + std::thread::spawn(move || { + let r = go(&path); + let _ = tx.send(r); + }); + match rx.recv_timeout(Duration::from_secs(90)) { + Ok((filled, out)) => println!("filled: {filled}\nALTER: {out}"), + Err(_) => panic!("ALTER TABLE t ADD COLUMN on a two-page table never returned (90s)"), + } +} From e53342b7f11d115dc8a843c2f4e225087702823f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:01:24 -0400 Subject: [PATCH 009/315] test(I19-atk4-LENS): confirm reserve_free_space cannot terminate past one page of reserve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent fixture, written from scratch in a fresh context against committed 58804c8 (git diff 58804c8 -- src/ empty, no edits to src). - t1: three consecutive find_or_make_page(MAX_TUPLE_SIZE) calls — the body of reserve_free_space's loop — return page 7, 7, 7 and free_space goes 626 -> 4699 -> 4699 -> 4699. A fresh page registers 4073 free and the search is for >= 4069+4 = 4073, so the loop finds its own new empty page. - t2: reserve_free_space(20000) does not return in 15s on a 40-row heap; 100/2000/4000/4073/4100 all return Ok (boundary is initial_free + 4073). - t3: ALTER TABLE t ADD COLUMN on a 40-row two-page table does not return in 150s; the same statement on a 10-row one-page table succeeds in 7.9ms. - A/B, same test file: src at 19b1de2 passes (40 rows Ok in 11ms, 200 rows Ok in 46ms); src at 58804c8 hangs. Regression is 58804c8. - integration_alter_refusal_safety is 14/14 green at HEAD in 2.08s: its fixtures are single-page, so one page of reserve always suffices. --- tests/zz_lens_ab.rs | 120 +++++++++++++++++++++++ tests/zz_lens_reserve.rs | 201 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 tests/zz_lens_ab.rs create mode 100644 tests/zz_lens_reserve.rs diff --git a/tests/zz_lens_ab.rs b/tests/zz_lens_ab.rs new file mode 100644 index 00000000..fe748e91 --- /dev/null +++ b/tests/zz_lens_ab.rs @@ -0,0 +1,120 @@ +//! A/B of the same ALTER across 19b1de2 -> 58804c8. Uses only APIs both commits have. +//! LENS (adversarial, fresh context): does `reserve_free_space` — guard (3) of 58804c8 — +//! terminate? Written from scratch; nothing copied from the claimant's file except the +//! decision to run the ALTER on a worker thread so a hang is an assertion, not a wedged suite. + +use std::sync::Arc; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; + +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::HeapFileManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + _dir: tempfile::TempDir, + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lens.db"); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("lens.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { _dir: dir, catalog, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError(format!("{:?}", p.errors.iter().map(|e| e.to_string()).collect::>()))); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn heap(&self, table: &str) -> HeapFileManager { + HeapFileManager::open(self.catalog.get_table(table).unwrap().first_directory_page_id, self.bp.clone()) + } + /// (rows, distinct data pages) as the heap itself reports them. + fn shape_of_heap(&self, table: &str) -> (usize, usize) { + let mut pages: Vec = Vec::new(); + let mut n = 0usize; + for r in self.heap(table).scan() { + let (rid, _t) = r.unwrap(); + n += 1; + if !pages.contains(&rid.page_id) { pages.push(rid.page_id); } + } + (n, pages.len()) + } +} + +/// Impact: the statement a user types. `ADD COLUMN` on tables of growing size must each either +/// succeed or be refused. Bounded per table the same way. +#[test] +fn t3_add_column_returns_on_tables_of_growing_size() { + for &rows in &[40usize, 200] { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, s VARCHAR(200));"); + for i in 0..rows { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{}');", "x".repeat(150))); + } + let (n, pages) = d.shape_of_heap("t"); + let _ = tx.send(format!("filled: {n} rows over {pages} data pages")); + let r = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + let verdict = match &r { + Ok(_) => { + let (n2, p2) = d.shape_of_heap("t"); + let cols = d.catalog.get_table("t").unwrap().schema.columns.len(); + let sel = d.try_sql("SELECT id FROM t;").map(|o| match o { Outcome::Rows(v) => v.len(), _ => 0 }).unwrap_or(usize::MAX); + format!("Ok — {n2} rows over {p2} pages, {cols} columns, SELECT returned {sel}") + } + Err(e) => format!("REFUSED: {e}"), + }; + let _ = tx.send(verdict); + }); + let fill = rx.recv_timeout(Duration::from_secs(120)).expect("fill timed out"); + println!("[{rows} rows] {fill}"); + let t = Instant::now(); + match rx.recv_timeout(Duration::from_secs(150)) { + Ok(v) => println!("[{rows} rows] ALTER -> {v} (in {:?})", t.elapsed()), + Err(mpsc::RecvTimeoutError::Disconnected) => panic!("worker for the {rows}-row ALTER died before sending — fixture bug, not a hang"), + Err(mpsc::RecvTimeoutError::Timeout) => panic!( + "`ALTER TABLE t ADD COLUMN w VARCHAR(10)` on a table of {rows} rows ({fill}) had \ + not returned — neither succeeded nor been refused — 150s after the rows were in \ + place. Nothing to compare before/after: the statement never finished." + ), + } + } +} + diff --git a/tests/zz_lens_reserve.rs b/tests/zz_lens_reserve.rs new file mode 100644 index 00000000..6f99c864 --- /dev/null +++ b/tests/zz_lens_reserve.rs @@ -0,0 +1,201 @@ +//! LENS (adversarial, fresh context): does `reserve_free_space` — guard (3) of 58804c8 — +//! terminate? Written from scratch; nothing copied from the claimant's file except the +//! decision to run the ALTER on a worker thread so a hang is an assertion, not a wedged suite. + +use std::sync::Arc; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; + +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; +use ferrodb::storage::heap_file_manager::HeapFileManager; +use ferrodb::storage::heap_page::{HEADER_SIZE, MAX_TUPLE_SIZE, SLOT_ENTRY_SIZE}; +use ferrodb::storage::tuple::Tuple; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + _dir: tempfile::TempDir, + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn db() -> Db { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lens.db"); + let file = std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("lens.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + let catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { _dir: dir, catalog, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError(format!("{:?}", p.errors.iter().map(|e| e.to_string()).collect::>()))); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn heap(&self, table: &str) -> HeapFileManager { + HeapFileManager::open(self.catalog.get_table(table).unwrap().first_directory_page_id, self.bp.clone()) + } + /// (rows, distinct data pages) as the heap itself reports them. + fn shape_of_heap(&self, table: &str) -> (usize, usize) { + let mut pages: Vec = Vec::new(); + let mut n = 0usize; + for r in self.heap(table).scan() { + let (rid, _t) = r.unwrap(); + n += 1; + if !pages.contains(&rid.page_id) { pages.push(rid.page_id); } + } + (n, pages.len()) + } +} + +/// Arithmetic the mechanism rests on, printed so the numbers are not taken on trust. +#[test] +fn t0_the_constants() { + println!("PAGE_SIZE={PAGE_SIZE} HEADER_SIZE={HEADER_SIZE} SLOT_ENTRY_SIZE={SLOT_ENTRY_SIZE} MAX_TUPLE_SIZE={MAX_TUPLE_SIZE}"); + println!("reserve_free_space asks find_or_make_page for usable={}", PAGE_SIZE - HEADER_SIZE - SLOT_ENTRY_SIZE); + println!("find_or_make_page then searches for free_space >= {}", PAGE_SIZE - HEADER_SIZE - SLOT_ENTRY_SIZE + SLOT_ENTRY_SIZE); + println!("a brand-new page is registered with free_space = {}", PAGE_SIZE - HEADER_SIZE); +} + +/// Direct mechanism: three consecutive `find_or_make_page(MAX_TUPLE_SIZE)` calls — exactly what +/// `reserve_free_space`'s loop body does — must each add space, or that loop cannot terminate. +#[test] +fn t1_find_or_make_page_adds_space_on_every_call() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, s VARCHAR(200));"); + for i in 0..40 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{}');", "x".repeat(150))); + } + let heap = d.heap("t"); + let mut trace = vec![heap.free_space().unwrap()]; + let mut ids = Vec::new(); + for _ in 0..3 { + ids.push(heap.find_or_make_page(MAX_TUPLE_SIZE).unwrap()); + trace.push(heap.free_space().unwrap()); + } + println!("free_space trace: {trace:?}"); + println!("find_or_make_page returned page ids: {ids:?}"); + assert!( + trace[2] > trace[1] && trace[3] > trace[2], + "find_or_make_page(MAX_TUPLE_SIZE) stopped adding space after the first call: \ + free_space went {trace:?} and it returned page ids {ids:?}. \ + `while free_space() < bytes {{ find_or_make_page(usable) }}` therefore cannot terminate." + ); +} + +/// Termination of `reserve_free_space` itself, at the raw heap layer, for a request bigger than +/// one page. Bounded: the call runs on a worker thread and the test asserts on a timeout. +#[test] +fn t2_reserve_free_space_terminates_for_a_multi_page_request() { + for &want in &[100usize, 2_000, 4_000, 4_073, 4_100, 20_000] { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, s VARCHAR(200));"); + for i in 0..40 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{}');", "x".repeat(150))); + } + let heap = d.heap("t"); + let before = heap.free_space().unwrap(); + let r = heap.reserve_free_space(want); + let _ = tx.send((before, format!("{r:?}"), heap.free_space().unwrap())); + }); + let t = Instant::now(); + match rx.recv_timeout(Duration::from_secs(15)) { + Ok((before, r, after)) => { + println!("reserve_free_space({want}): returned {r} in {:?}; free_space {before} -> {after}", t.elapsed()); + } + Err(mpsc::RecvTimeoutError::Disconnected) => panic!("worker for reserve_free_space({want}) died before sending — fixture bug, not a hang"), + Err(mpsc::RecvTimeoutError::Timeout) => panic!( + "reserve_free_space({want}) had not returned — neither Ok nor Err — 15s after it \ + was called on a 40-row heap. Guard (3) cannot refuse and cannot succeed." + ), + } + } +} + +/// Impact: the statement a user types. `ADD COLUMN` on tables of growing size must each either +/// succeed or be refused. Bounded per table the same way. +#[test] +fn t3_add_column_returns_on_tables_of_growing_size() { + for &rows in &[10usize, 40, 90, 200] { + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, s VARCHAR(200));"); + for i in 0..rows { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{}');", "x".repeat(150))); + } + let (n, pages) = d.shape_of_heap("t"); + let _ = tx.send(format!("filled: {n} rows over {pages} data pages")); + let r = d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);"); + let verdict = match &r { + Ok(_) => { + let (n2, p2) = d.shape_of_heap("t"); + let cols = d.catalog.get_table("t").unwrap().schema.columns.len(); + let sel = d.try_sql("SELECT id FROM t;").map(|o| match o { Outcome::Rows(v) => v.len(), _ => 0 }).unwrap_or(usize::MAX); + format!("Ok — {n2} rows over {p2} pages, {cols} columns, SELECT returned {sel}") + } + Err(e) => format!("REFUSED: {e}"), + }; + let _ = tx.send(verdict); + }); + let fill = rx.recv_timeout(Duration::from_secs(120)).expect("fill timed out"); + println!("[{rows} rows] {fill}"); + let t = Instant::now(); + match rx.recv_timeout(Duration::from_secs(40)) { + Ok(v) => println!("[{rows} rows] ALTER -> {v} (in {:?})", t.elapsed()), + Err(mpsc::RecvTimeoutError::Disconnected) => panic!("worker for the {rows}-row ALTER died before sending — fixture bug, not a hang"), + Err(mpsc::RecvTimeoutError::Timeout) => panic!( + "`ALTER TABLE t ADD COLUMN w VARCHAR(10)` on a table of {rows} rows ({fill}) had \ + not returned — neither succeeded nor been refused — 40s after the rows were in \ + place. Nothing to compare before/after: the statement never finished." + ), + } + } +} + +/// Control on the raw layer: the empty page `find_or_make_page` hands back really is usable, so +/// t1's failure is not an artefact of asking for an impossible tuple length. +#[test] +fn t4_control_the_page_it_returns_can_hold_a_max_size_tuple() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, s VARCHAR(200));"); + d.sql("INSERT INTO t VALUES (1, 'a');"); + let heap = d.heap("t"); + let p = heap.find_or_make_page(MAX_TUPLE_SIZE).unwrap(); + let rid = heap.insert(Tuple::new(vec![7u8; MAX_TUPLE_SIZE])).unwrap(); + println!("find_or_make_page(MAX_TUPLE_SIZE) -> page {p}; inserting a {MAX_TUPLE_SIZE}-byte tuple landed at {rid:?}"); + assert_eq!(rid.page_id, p, "the reserved page is not where a max-size tuple goes"); + +} From 7cc25ffcabc217e964626e22edc7e9b86931efea Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:06:15 -0400 Subject: [PATCH 010/315] test(I19-atk4-LENS): refute the 'wedged ALTER makes a durable unlogged change' claim with an allocator control Independent fixture, src untouched at 58804c8. Three measurements: - a genuinely REFUSED ALTER (6 rows x 4034-byte VARCHAR, ADD COLUMN VARCHAR(20), refused in pass 1 at 4070 > 4069) leaves alter.db and alter.wal byte-identical mid-refusal, after a checkpoint and a drop, and reopens with the same shape, the same heap bytes, the same primary-index answers and the same row count. - CONTROL: an ordinary SUCCESSFUL INSERT that allocates a page, with no flush at all, produces the claim's exact signature - file grows by one page, one byte of page 0 changes, at byte 4, WAL untouched. BufferPoolManager::new_page pwrites the allocation bitmap and a zero page straight to the file for every caller. - the spinning ALTER returns Err(Timeout) on the channel: it neither succeeded nor was refused, and a fresh reader after recovery still sees 2 columns, 82 heap tuples over pages {5,6}, SELECT -> Ok(82) and zero index misses. So the durability half of the atk4 report is a real observation about the page allocator, not a change to the table and not specific to a refusal. --- tests/zz_lens_wedge_durability.rs | 327 ++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 tests/zz_lens_wedge_durability.rs diff --git a/tests/zz_lens_wedge_durability.rs b/tests/zz_lens_wedge_durability.rs new file mode 100644 index 00000000..2a7a4fc8 --- /dev/null +++ b/tests/zz_lens_wedge_durability.rs @@ -0,0 +1,327 @@ +//! LENS pass: is the "wedged ALTER makes a durable unlogged change" claim a defect of the +//! refusal guard, or a property of the page allocator that every statement shares? +//! +//! Three measurements, one harness: +//! 1. a REFUSED ALTER (width refusal) -> are the raw file bytes identical? +//! 2. a plain, SUCCESSFUL INSERT that needs a new page -> does it make the same class of +//! unlogged change to alter.db (grow by a page, byte 4 of page 0 change) with no flush? +//! 3. the wedging ALTER -> did the statement FAIL, or is it still running? and does a fresh +//! reader lose a row, a column or a heap byte? + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::{DataType, Value}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::{HeapFileManager, RecordId}; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + wal: Arc, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn open(dir: &Path, recover: bool) -> Db { + let path = dir.join("alter.db"); + let fresh = !path.exists(); + let file = + std::fs::OpenOptions::new().read(true).write(true).create(true).open(&path).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.join("alter.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + if recover { + ferrodb::wal::recovery::recover(&txn).expect("recover"); + } + let catalog = if fresh { + Catalog::create(bp.clone()).unwrap() + } else { + Catalog::open(bp.clone(), 1).unwrap() + }; + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + Db { catalog, wal, bp, txn, runtime, session } +} + +impl Db { + fn try_sql(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + if !p.errors.is_empty() { + return Err(FerroError::SqlParseError( + p.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut session = + std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut session); + self.session = session; + out + } + fn sql(&mut self, sql: &str) -> Outcome { + self.try_sql(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + fn rows(&mut self, sql: &str) -> Result { + match self.try_sql(sql) { + Ok(Outcome::Rows(r)) => Ok(r.len()), + Ok(_) => Err("not rows".into()), + Err(e) => Err(e.to_string()), + } + } + fn shape(&self, t: &str) -> Vec<(String, DataType)> { + self.catalog + .get_table(t) + .unwrap() + .schema + .columns + .iter() + .map(|c| (c.name.clone(), c.data_type.clone())) + .collect() + } + fn heap(&self, t: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(t).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()) + .scan() + .map(|r| { + let (rid, tup) = r.unwrap(); + (rid, tup.data) + }) + .collect() + } + fn by_key(&self, t: &str, keys: &[Value]) -> Vec<(Value, Option)> { + let root = self.catalog.get_table(t).unwrap().primary_index_root; + let ix = BPlusTreeManager::::open(root, self.bp.clone()); + keys.iter().map(|k| (k.clone(), ix.search(k).unwrap())).collect() + } + fn flush(&self) { + self.bp.flush_all().unwrap(); + self.bp.disk_manager.sync().unwrap(); + self.wal.flush().unwrap(); + } +} + +/// Read both files through FRESH handles, so measuring flushes nothing. +fn files(dir: &Path) -> (Vec, Vec) { + ( + std::fs::read(dir.join("alter.db")).unwrap_or_default(), + std::fs::read(dir.join("alter.wal")).unwrap_or_default(), + ) +} + +fn describe(name: &str, a: &[u8], b: &[u8]) -> String { + if a == b { + return format!("{name}: identical ({} bytes)", a.len()); + } + let first = (0..a.len().min(b.len())).find(|&i| a[i] != b[i]); + let diffs = (0..a.len().min(b.len())).filter(|&i| a[i] != b[i]).count(); + format!( + "{name}: DIFFERS — was {} bytes, now {} bytes, {diffs} differing byte(s) in the common \ + prefix, first at {first:?}", + a.len(), + b.len() + ) +} + +/// Which 4096-byte pages differ, and by how many bytes. +fn diff_pages(a: &[u8], b: &[u8]) -> Vec<(usize, usize)> { + let n = a.len().min(b.len()) / 4096; + (0..n) + .filter_map(|p| { + let (x, y) = (&a[p * 4096..(p + 1) * 4096], &b[p * 4096..(p + 1) * 4096]); + let c = (0..4096).filter(|&i| x[i] != y[i]).count(); + if c == 0 { None } else { Some((p, c)) } + }) + .collect() +} + +fn build(d: &mut Db, rows: i32, pad_len: usize) { + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(120));"); + let pad = "y".repeat(pad_len); + for i in 1..=rows { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } +} + +// --------------------------------------------------------------------------------------------- +// 1. The claim actually under attack: a REFUSED ALTER and the raw file bytes. +// --------------------------------------------------------------------------------------------- +#[test] +fn a_refused_alter_leaves_the_raw_file_bytes_identical() { + let dir = tempfile::tempdir().unwrap(); + let keys: Vec = (1..=6).map(Value::Integer).collect(); + let (before_heap, before_ix, before_shape, before_rows); + let (bdb, bwal); + { + let mut d = open(dir.path(), false); + // Rows already near MAX_TUPLE_SIZE so any widening ALTER must be refused. + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(4100));"); + let pad = "z".repeat(4034); + for i in 1..=6 { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + } + d.flush(); + (bdb, bwal) = files(dir.path()); + before_heap = d.heap("t"); + before_ix = d.by_key("t", &keys); + before_shape = d.shape("t"); + before_rows = d.rows("SELECT id FROM t;"); + + let e = d + .try_sql("ALTER TABLE t ADD COLUMN note VARCHAR(20);") + .err() + .expect("a widening ALTER on 4034-byte rows must be REFUSED"); + println!("REFUSAL: {e}"); + + let (mdb, mwal) = files(dir.path()); + println!(" mid {}", describe("alter.db", &bdb, &mdb)); + println!(" mid {}", describe("alter.wal", &bwal, &mwal)); + assert_eq!(bdb, mdb, "a REFUSED ALTER changed alter.db. {}", describe("alter.db", &bdb, &mdb)); + assert_eq!(bwal, mwal, "a REFUSED ALTER changed alter.wal"); + + assert_eq!(before_heap, d.heap("t"), "refusal changed the live heap bytes"); + assert_eq!(before_ix, d.by_key("t", &keys), "refusal changed the primary index"); + assert_eq!(before_shape, d.shape("t"), "refusal changed the catalog shape"); + d.flush(); + } // crash / drop every handle + + let (cdb, cwal) = files(dir.path()); + println!(" post-drop {}", describe("alter.db", &bdb, &cdb)); + println!(" post-drop {}", describe("alter.wal", &bwal, &cwal)); + assert_eq!(bdb, cdb, "the file changed after refusal + checkpoint + drop"); + assert_eq!(bwal, cwal, "the wal changed after refusal + checkpoint + drop"); + + let mut r = open(dir.path(), true); + assert_eq!(before_shape, r.shape("t"), "reopen: shape"); + assert_eq!(before_heap, r.heap("t"), "reopen: heap bytes"); + assert_eq!(before_ix, r.by_key("t", &keys), "reopen: primary index"); + assert_eq!(before_rows, r.rows("SELECT id FROM t;"), "reopen: row count"); + println!( + " reopen: {} cols, {} heap tuples, SELECT -> {:?}", + r.shape("t").len(), + r.heap("t").len(), + r.rows("SELECT id FROM t;") + ); +} + +// --------------------------------------------------------------------------------------------- +// 2. CONTROL. Is "grew alter.db by a page, changed page 0, nothing in the WAL describes it" +// peculiar to the ALTER guard, or does every allocation in the engine do it? +// --------------------------------------------------------------------------------------------- +#[test] +fn an_ordinary_successful_insert_makes_the_same_unlogged_file_change() { + let dir = tempfile::tempdir().unwrap(); + let mut d = open(dir.path(), false); + build(&mut d, 30, 60); + d.flush(); + let (bdb, _bwal) = files(dir.path()); + let pages_before: BTreeSet = d.heap("t").iter().map(|(r, _)| r.page_id).collect(); + + // Plain INSERTs, no flush of any kind afterwards, until the heap needs a new page. + let pad = "y".repeat(60); + let mut i = 1000; + loop { + d.sql(&format!("INSERT INTO t VALUES ({i}, '{pad}');")); + i += 1; + let now: BTreeSet = d.heap("t").iter().map(|(r, _)| r.page_id).collect(); + if now != pages_before { + break; + } + assert!(i < 1200, "never allocated a page"); + } + let (ndb, _nwal) = files(dir.path()); + println!(" a SUCCESSFUL insert, with NO flush: {}", describe("alter.db", &bdb, &ndb)); + println!(" differing pages (page, bytes): {:?}", diff_pages(&bdb, &ndb)); + assert_ne!( + bdb, ndb, + "control failed: an ordinary insert that allocates a page did NOT change the file, so \ + the wedge's file change would be peculiar to the ALTER path" + ); + let first = (0..bdb.len().min(ndb.len())).find(|&k| bdb[k] != ndb[k]); + println!(" first differing byte: {first:?} (page 0 allocation bitmap starts at byte 4)"); + assert_eq!( + first, + Some(4), + "control: expected the allocation bitmap in page 0 to be what changed" + ); + assert!(ndb.len() > bdb.len(), "control: expected the file to have grown"); +} + +// --------------------------------------------------------------------------------------------- +// 3. The claim as stated: while the ALTER spins, did the STATEMENT FAIL, and is anything lost? +// --------------------------------------------------------------------------------------------- +#[test] +fn the_spinning_alter_neither_failed_nor_lost_anything() { + let dir = tempfile::tempdir().unwrap(); + let path: PathBuf = dir.path().to_path_buf(); + let (tx, rx) = std::sync::mpsc::channel::(); + std::thread::spawn(move || { + let mut d = open(&path, false); + build(&mut d, 82, 60); + d.flush(); + let _ = tx.send("baseline flushed".into()); + match d.try_sql("ALTER TABLE t ADD COLUMN w VARCHAR(10);") { + Ok(_) => { + let _ = tx.send("the ALTER SUCCEEDED".into()); + } + Err(e) => { + let _ = tx.send(format!("the ALTER was REFUSED: {e}")); + } + } + }); + assert_eq!(rx.recv_timeout(Duration::from_secs(180)).unwrap(), "baseline flushed"); + let (bdb, bwal) = files(dir.path()); + + let outcome = rx.recv_timeout(Duration::from_secs(30)); + let (ndb, nwal) = files(dir.path()); + println!(" statement outcome after 30s: {outcome:?}"); + println!(" {}", describe("alter.db", &bdb, &ndb)); + println!(" {}", describe("alter.wal", &bwal, &nwal)); + println!(" differing pages (page, bytes): {:?}", diff_pages(&bdb, &ndb)); + + // (a) Did the statement report failure? The claim is about "a refused ALTER". + match &outcome { + Ok(msg) => println!(" the statement RETURNED: {msg}"), + Err(_) => println!(" the statement has NEITHER succeeded NOR been refused after 30s"), + } + + // (b)/(c) Does a fresh reader, recovering as the CLI does after a crash, lose anything? + let mut r = open(dir.path(), true); + let pages: BTreeSet = r.heap("t").iter().map(|(x, _)| x.page_id).collect(); + let n_rows = r.rows("SELECT id, v FROM t;"); + let keys: Vec = (1..=82).map(Value::Integer).collect(); + let ix = r.by_key("t", &keys); + let missing: Vec<&Value> = ix.iter().filter(|(_, v)| v.is_none()).map(|(k, _)| k).collect(); + println!( + " fresh reader after recover: {} cols {:?}, {} heap tuples over pages {:?}, SELECT -> \ + {:?}, index misses: {:?}", + r.shape("t").len(), + r.shape("t").iter().map(|c| c.0.clone()).collect::>(), + r.heap("t").len(), + pages, + n_rows, + missing + ); + assert_eq!(r.shape("t").len(), 2, "the spinning ALTER installed its new schema"); + assert_eq!(n_rows, Ok(82), "the spinning ALTER lost rows"); + assert_eq!(r.heap("t").len(), 82, "the spinning ALTER lost heap tuples"); + assert!(missing.is_empty(), "the spinning ALTER lost index entries: {missing:?}"); +} From 0e534f964daf46f6e8718079f15bbe9128d0ccdc Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:20:20 -0400 Subject: [PATCH 011/315] test(F4-adv): attack the three grant guards with executed fixtures Five test binaries that assert the F4 claim -- "no node in a cluster can obtain an arena extent page, an arena id, a transaction id, or a lease/reap decision without a leader grant" -- so that a failure is the finding and its panic message is the evidence. 13 of 28 probes defeat a guard. The gap the epoch does not close: cluster::authority is process-scoped and GrantedCounter evicts held ranges across a join, but the space already claimed into ArenaPageStore's extents map, its per-arena recycled list, and a restored checkpoint image carry no authority epoch. alloc_in_arena, arena_for and cow_page reach none of the counters, so a member with zero grants drains the tail of any pre-join extent (255 of 256 pages measured). Also: accepted_through outlives the epoch bump, so a leader grant below a stale standalone self-grant is swallowed as Duplicate and the node refuses for ever; apply_lease_tick folds with max and validates no bound, so one tick of u64::MAX permanently expires every lease; AgentRuntime::begin_session panics rather than refusing on a member with no tick. No deadlock found in the authority/counter/att lock chain: 3.45M operations across 14 threads with join/leave racing, no stall. Single-node operation intact -- lib 843, integration_simulate 24, integration_arena_exclusivity 5, sim_durability 16, integration_txn_concurrency 4; 892 passed, 0 failed. Raw outputs under .adv-results/. Fixtures only. Nothing under src/ is touched. --- .../integration_arena_exclusivity.full | 71 ++ .adv-results/integration_simulate.full | 89 ++ .adv-results/integration_txn_concurrency.full | 70 ++ .adv-results/lib.full | 904 ++++++++++++++++++ .adv-results/sim_durability.full | 82 ++ tests/adv_f4_atk1_page_without_grant.rs | 405 ++++++++ tests/adv_f4_atk2_two_authorities.rs | 429 +++++++++ tests/adv_f4_atk3_lease_clock.rs | 302 ++++++ tests/adv_f4_atk4_locks_and_ranges.rs | 341 +++++++ tests/adv_f4_atk5_meaning_changed.rs | 167 ++++ 10 files changed, 2860 insertions(+) create mode 100644 .adv-results/integration_arena_exclusivity.full create mode 100644 .adv-results/integration_simulate.full create mode 100644 .adv-results/integration_txn_concurrency.full create mode 100644 .adv-results/lib.full create mode 100644 .adv-results/sim_durability.full create mode 100644 tests/adv_f4_atk1_page_without_grant.rs create mode 100644 tests/adv_f4_atk2_two_authorities.rs create mode 100644 tests/adv_f4_atk3_lease_clock.rs create mode 100644 tests/adv_f4_atk4_locks_and_ranges.rs create mode 100644 tests/adv_f4_atk5_meaning_changed.rs diff --git a/.adv-results/integration_arena_exclusivity.full b/.adv-results/integration_arena_exclusivity.full new file mode 100644 index 00000000..c96c96db --- /dev/null +++ b/.adv-results/integration_arena_exclusivity.full @@ -0,0 +1,71 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.64s + Running tests/integration_arena_exclusivity.rs (target/debug/deps/integration_arena_exclusivity-27e6f0b07fe7940c) + +running 5 tests +test the_bitmap_allocator_refuses_rather_than_crossing_the_floor ... ok +test an_arena_page_cannot_be_freed_through_the_bitmap ... ok +test a_second_lower_arena_region_is_refused ... ok +test an_unreserved_database_allocates_exactly_as_before ... ok +test the_legacy_allocator_never_hands_out_a_page_inside_an_arena_extent ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +exit=0 diff --git a/.adv-results/integration_simulate.full b/.adv-results/integration_simulate.full new file mode 100644 index 00000000..ca85d40d --- /dev/null +++ b/.adv-results/integration_simulate.full @@ -0,0 +1,89 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s + Running tests/integration_simulate.rs (target/debug/deps/integration_simulate-06f5eef80820bb87) + +running 24 tests +test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok +test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok +test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok +test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok +test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok +test a_read_only_candidate_reports_no_rows_written ... ok +test a_plan_that_never_says_admit_publishes_nothing ... ok +test publishing_an_evaluation_the_gate_declined_is_refused ... ok +test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok +test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok +test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok +test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok +test every_candidate_is_scored_against_one_identical_base ... ok +test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok +test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok +test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok +test k_candidates_cost_k_path_copies_not_k_database_copies ... ok +test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok +test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok +test simulate_is_refused_inside_an_agent_session ... ok +test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok +test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok +test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok +test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.19s + +exit=0 diff --git a/.adv-results/integration_txn_concurrency.full b/.adv-results/integration_txn_concurrency.full new file mode 100644 index 00000000..9cab0318 --- /dev/null +++ b/.adv-results/integration_txn_concurrency.full @@ -0,0 +1,70 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.05s + Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-5aa0a15ef2f31d9a) + +running 4 tests +test concurrent_commits_and_aborts_do_not_panic ... ok +test committed_transactions_leave_the_active_set ... ok +test a_running_transaction_is_never_missing_from_a_snapshot ... ok +test concurrent_begins_get_distinct_transaction_ids ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.62s + +exit=0 diff --git a/.adv-results/lib.full b/.adv-results/lib.full new file mode 100644 index 00000000..2866fa3b --- /dev/null +++ b/.adv-results/lib.full @@ -0,0 +1,904 @@ + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:440:13 + | +440 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (lib test) generated 2 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 9.74s + Running unittests src/lib.rs (target/debug/deps/ferrodb-a704e6796973b287) + +running 843 tests +test agent_sql::gate::tests::a_cheap_check_that_almost_never_fires_sorts_last ... ok +test agent_sql::changeset::tests::an_empty_merge_is_clean ... ok +test agent_sql::escrow::tests::an_unbounded_cell_is_not_escrowed_and_writes_pass_through ... ok +test agent_sql::changeset::tests::a_lossy_row_makes_the_whole_merge_lossy_never_clean ... ok +test agent_sql::escrow::tests::a_negative_or_zero_claim_is_refused ... ok +test agent_sql::escrow::tests::a_branch_cannot_spend_against_a_claim_it_never_made ... ok +test agent_sql::escrow::tests::the_counter_cannot_be_driven_below_its_floor ... ok +test agent_sql::changeset::tests::a_conflicting_row_outranks_a_commuting_one ... ok +test agent_sql::dispatch::tests::a_row_id_past_i64_max_is_not_reported_as_negative ... ok +test agent_sql::escrow::tests::settling_consumes_only_what_was_actually_spent ... ok +test agent_sql::escrow::tests::reopening_a_pool_is_refused_rather_than_resizing_it ... ok +test agent_sql::escrow::tests::a_write_beyond_the_branchs_claim_is_refused_at_write_time ... ok +test agent_sql::escrow::tests::a_batch_that_only_overdraws_in_aggregate_is_refused ... ok +test agent_sql::gate::tests::an_empty_gate_and_a_gate_where_nothing_fires_both_pass ... ok +test agent_sql::gate::tests::an_assertion_that_examined_no_rows_is_a_hard_reject_not_a_pass ... ok +test agent_sql::gate::tests::a_row_that_could_not_be_evaluated_outranks_every_row_that_held ... ok +test agent_sql::gate::tests::a_later_tier_does_not_run_once_an_earlier_one_has_fired ... ok +test agent_sql::gate::tests::every_check_in_a_tier_runs_even_after_one_has_fired ... ok +test agent_sql::gate::tests::a_sound_finding_carries_the_violated_predicate ... ok +test agent_sql::gate::tests::outcome_follows_epistemic_status_not_severity ... ok +test agent_sql::gate::tests::an_assertion_fires_when_it_is_false_and_stays_quiet_when_it_holds ... ok +test agent_sql::escrow::tests::releasing_a_branch_returns_its_unspent_claim_to_the_pool ... ok +test agent_sql::escrow::tests::two_agents_cannot_both_reserve_twelve_out_of_twenty ... ok +test agent_sql::gate::tests::every_broken_assertion_reaches_the_caller_in_one_round_trip ... ok +test agent_sql::dispatch::tests::every_agent_output_row_matches_its_declared_column_count ... ok +test agent_sql::gate::tests::the_blind_write_metric_quarantines_and_cannot_reject ... ok +test agent_sql::dispatch::tests::only_an_empty_diff_returns_no_rows ... ok +test agent_sql::gate::tests::the_most_severe_status_in_a_tier_decides ... ok +test agent_sql::gate::tests::tier_order_is_computed_from_cost_over_rejection_probability ... ok +test agent_sql::merge_engine::tests::a_replayed_frame_contributes_its_increments_once ... ok +test agent_sql::merge_engine::tests::composing_two_identical_adds_does_not_collapse_them ... ok +test agent_sql::merge_engine::tests::an_unevaluable_guard_is_not_reported_as_a_failed_one ... ok +test agent_sql::mem_catalog::tests::fork_records_the_child_in_the_parent_and_shares_the_root ... ok +test agent_sql::mem_catalog::tests::reading_a_reaped_branch_is_a_hard_error_not_stale_data ... ok +test agent_sql::merge_engine::tests::concurrent_assigns_are_forbidden_by_default_not_silently_lww ... ok +test agent_sql::merge_engine::tests::a_guard_that_fails_against_merged_state_returns_its_predicate ... ok +test agent_sql::merge_engine::tests::invert_refuses_an_assign_with_no_before_image ... ok +test agent_sql::merge_engine::tests::identical_concurrent_writes_are_not_a_conflict ... ok +test agent_sql::merge_engine::tests::lww_succeeds_but_reports_the_discarded_write ... ok +test agent_sql::merge_engine::tests::two_decrements_compose_arithmetically ... ok +test agent_sql::merge_engine::tests::merger_trait_reports_commuting_for_two_branch_decrements ... ok +test agent_sql::paged_rows::tests::a_table_range_excludes_its_neighbours ... ok +test agent_sql::paged_rows::tests::an_empty_row_round_trips ... ok +test agent_sql::paged_rows::tests::a_truncated_row_is_refused_rather_than_panicking ... ok +test agent_sql::paged_rows::tests::an_unknown_tag_is_refused ... ok +test agent_sql::paged_rows::tests::each_wide_variant_round_trips_on_its_own ... ok +test agent_sql::paged_rows::tests::every_value_variant_round_trips ... ok +test agent_sql::paged_rows::tests::split_rejects_a_wrong_length_key ... ok +test agent_sql::paged_rows::tests::keys_sort_in_numeric_row_order ... ok +test agent_sql::paged_rows::tests::the_last_table_id_still_has_a_usable_upper_bound ... ok +test agent_sql::paged_rows::tests::trailing_bytes_are_refused ... ok +test agent_sql::runtime::tests::the_ordinary_reservation_and_a_leaked_range_are_both_allowed ... ok +test agent_sql::runtime::tests::a_reservation_that_would_re_issue_a_version_sequence_is_refused ... ok +test binder::binder::tests::test_duplicate_qualifier ... ok +test binder::binder::tests::test_expand_star ... ok +test binder::binder::tests::test_resolve_ambiguous ... ok +test binder::binder::tests::test_resolve_qualified_unique ... ok +test binder::binder::tests::test_resolve_unknown_column ... ok +test binder::binder::tests::test_resolve_unknown_table ... ok +test agent_sql::paged_rows::tests::a_row_survives_a_round_trip_through_pages ... ok +test binder::binder::tests::test_binding_a_plain_plan_from_an_as_of_table_is_refused_not_silently_main ... ok +test binder::binder::tests::every_statement_bind_does_not_plan_returns_an_error_rather_than_panicking ... ok +test binder::binder::tests::test_join ... ok +test binder::binder::tests::test_duplicate_qualifier_bind ... ok +test binder::binder::tests::test_ambiguous_column ... ok +test agent_sql::paged_rows::tests::the_last_table_scans_its_own_rows ... ok +test binder::binder::tests::test_select_star ... ok +test agent_sql::paged_rows::tests::a_delete_removes_only_its_own_row ... ok +test binder::binder::tests::test_literal ... ok +test agent_sql::paged_rows::tests::a_scan_returns_only_the_requested_table_in_row_order ... ok +test binder::binder::tests::test_bind_rejects_an_agent_statement_as_a_plan ... ok +test agent_sql::paged_rows::tests::a_write_under_one_root_is_invisible_from_another_root ... ok +test binder::binder::tests::test_simple_select ... ok +test binder::binder::tests::test_unkown_table ... ok +test branch::arena::tests::a_private_page_is_mutated_in_place_not_shadowed ... ok +test binder::binder::tests::test_self_join ... ok +test branch::arena::tests::an_arena_region_is_still_off_limits_after_reopening_the_file ... ok +test branch::arena::tests::an_inherited_page_is_shadowed_and_the_original_is_left_alone ... ok +test branch::arena::tests::privacy_barrier_is_the_later_of_own_fork_and_latest_child_fork ... ok +test branch::arena::tests::a_corrupt_free_space_map_is_refused_not_partially_loaded ... ok +test branch::arena::tests::a_torn_page_is_refused_rather_than_returned ... ok +test binder::binder::tests::test_bind_revert_merge_defaults_to_halt ... ok +test branch::arena::tests::fork_allocates_no_page_at_all ... ok +test branch::arena::tests::freed_extents_are_recycled_rather_than_leaked ... ok +test branch::arena::tests::a_checkpoint_describing_another_region_is_refused ... ok +test branch::arena::tests::free_space_map_survives_a_restart ... ok +test branch::arena::tests::shadowing_your_own_page_across_a_child_fork_parks_the_original ... ok +test branch::catalog::tests::a_recycled_id_slot_never_answers_to_the_old_handle ... ok +test branch::arena::tests::store_refuses_to_share_the_bitmap_allocators_region ... ok +test branch::catalog::tests::a_slot_still_pinning_children_is_not_recycled ... ok +test branch::arena::tests::live_arenas_comes_back_in_arena_id_order ... ok +test branch::catalog::tests::depth_guard_refuses_the_ninth_fork ... ok +test branch::arena::tests::checkpoint_round_trips_through_a_file ... ok +test binder::binder::tests::test_bind_diff_merge_abandon_default_to_the_session_branch ... ok +test branch::catalog::tests::epochs_are_strictly_monotonic_across_forks ... ok +test branch::catalog::tests::every_record_sweep_comes_back_in_branch_id_order ... ok +test binder::binder::tests::test_bind_select_as_of_resolves_the_branch_and_the_columns ... ok +test binder::binder::tests::test_bind_without_a_session_or_branch_is_an_error ... ok +test branch::catalog::tests::fork_records_the_child_in_the_parent_atomically ... ok +test branch::catalog::tests::a_torn_tail_keeps_every_intact_record ... ok +test branch::catalog::tests::reading_a_reaped_branch_is_a_hard_error ... ok +test branch::arena::tests::the_checkpoint_image_lists_extents_and_current_arenas_in_key_order ... ok +test branch::reaper::tests::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::catalog::tests::durable_catalog_survives_a_reopen ... ok +test branch::catalog::tests::a_runtime_release_and_a_reload_agree_on_which_slot_is_next ... ok +test branch::reaper::tests::a_chain_at_max_depth_can_only_fork_again_after_collapsing ... ok +test branch::reaper::tests::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::arena::tests::reopen_from_checkpoint_takes_the_base_from_the_image_not_the_caller ... ok +test branch::reaper::tests::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::arena::tests::two_stores_in_the_same_state_checkpoint_byte_identical_images ... ok +test branch::reaper::tests::after_collapse_the_ancestor_chain_can_be_reaped_and_the_data_survives ... ok +test branch::reaper::tests::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::arena::tests::the_checkpoint_syncs_the_image_before_the_rename_and_the_directory_after_it ... ok +test binder::binder::tests::test_bind_begin_agent_session ... ok +test branch::reaper::tests::collapse_refuses_a_cyclic_page_graph ... ok +test branch::reaper::tests::collapse_materialises_the_whole_reachable_tree_and_reparents_to_trunk ... ok +test branch::reaper::tests::collapse_refuses_without_a_page_walker ... ok +test branch::arena::tests::the_public_checkpoint_stages_through_the_durable_helper ... ok +test branch::reaper::tests::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::reaper::tests::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::record::tests::a_column_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_corrupt_envelope_is_rejected_not_defaulted ... ok +test branch::reaper::tests::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::record::tests::a_delete_needs_authority_over_every_column_it_destroys ... ok +test branch::record::tests::a_duplicate_column_keeps_the_tighter_floor ... ok +test branch::record::tests::a_granted_table_resolves_however_the_envelope_was_assembled ... ok +test branch::record::tests::a_record_with_an_envelope_roundtrips_through_bytes ... ok +test branch::record::tests::a_record_written_before_envelopes_existed_still_loads ... ok +test branch::record::tests::a_representation_change_counts_as_writing_the_cell ... ok +test branch::record::tests::a_row_whose_image_did_not_change_costs_nothing ... ok +test branch::reaper::tests::trunk_is_never_reaped ... ok +test branch::record::tests::a_statement_that_overruns_the_budget_is_refused_as_a_whole ... ok +test branch::record::tests::a_table_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_verb_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::an_envelope_can_be_narrowed_but_never_widened ... ok +test branch::reaper::tests::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::record::tests::an_insert_cannot_launder_a_column_or_a_floor_through_a_null ... ok +test branch::record::tests::an_insert_cannot_write_a_column_it_was_not_granted ... ok +test branch::record::tests::an_unknown_floor_tag_is_refused_rather_than_read_as_unbounded ... ok +test branch::record::tests::an_unlisted_table_is_refused_even_for_a_statement_that_matched_nothing ... ok +test branch::record::tests::changed_columns_reads_images_so_an_insert_reports_every_column_it_wrote ... ok +test branch::record::tests::charging_past_the_budget_is_refused_at_the_charge_not_only_at_admit ... ok +test branch::record::tests::corrupt_record_is_rejected_not_guessed ... ok +test branch::record::tests::deleting_a_row_cannot_be_used_to_get_around_a_floor ... ok +test branch::arena::tests::arena_rolls_over_when_an_extent_fills ... ok +test branch::record::tests::depth_guard_fires_at_eight ... ok +test branch::record::tests::fork_copies_the_root_pointer_and_nothing_else ... ok +test branch::record::tests::forking_does_not_mint_budget_the_parent_had_already_spent ... ok +test branch::record::tests::reclamation_rule_is_half_open_over_the_fork_epochs ... ok +test branch::record::tests::live_children_stay_sorted ... ok +test branch::record::tests::record_roundtrips_through_bytes ... ok +test branch::record::tests::stale_generation_is_a_hard_error_not_stale_data ... ok +test branch::record::tests::the_floor_refuses_the_value_whatever_shape_produced_it ... ok +test branch::record::tests::the_envelope_survives_records_of_every_shape ... ok +test branch::record::tests::the_verb_comes_from_the_images_not_from_a_statement_keyword ... ok +test branch::types::tests::branch_state_roundtrips ... ok +test branch::record::tests::trailing_bytes_inside_a_valid_checksum_are_refused ... ok +test branch::types::tests::generation_bump_makes_reaped_id_distinguishable ... ok +test branch::types::tests::lease_expiry_is_a_pure_comparison ... ok +test branch::reaper::tests::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test catalog::catalog::tests::dropping_a_table_forgets_its_statistics ... ok +test buffer::buffer_pool::tests::a_refused_delete_leaves_the_page_intact_in_the_pool ... ok +test catalog::catalog::tests::test_catalog_create_open ... ok +test buffer::buffer_pool::tests::a_refused_free_page_leaves_the_page_intact_in_the_pool ... ok +test buffer::buffer_pool::tests::an_accepted_delete_still_evicts_the_page ... ok +test catalog::catalog::tests::dropping_a_table_returns_its_pages_including_the_time_travel_heap ... ok +test catalog::catalog::tests::test_create_table_adds_time_travel_root ... ok +test catalog::catalog_page::tests::a_middle_entry_s_fulltext_block_does_not_shift_the_next_entry ... ok +test catalog::catalog::tests::test_create_index ... ok +test catalog::catalog_page::tests::a_pre_b8_catalog_page_still_parses_with_no_fulltext_indexes ... ok +test catalog::catalog::tests::test_get_table ... ok +test catalog::catalog::tests::test_drop_table ... ok +test catalog::catalog_page::tests::an_unknown_catalog_page_format_is_refused ... ok +test catalog::catalog_page::tests::fulltext_indexes_round_trip_separately_from_btree_indexes ... ok +test catalog::catalog::tests::test_persist_orphan_removal ... ok +test catalog::catalog_page::tests::test_add_space_management ... ok +test catalog::catalog_page::tests::test_basic_roundtrip ... ok +test catalog::catalog_page::tests::test_remove_entry ... ok +test catalog::catalog::tests::test_update_roots ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_decimal ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_float ... ok +test catalog::column::tests::bigint_orders_by_value_at_the_i64_extremes ... ok +test catalog::column::tests::bigint_versus_float_does_not_widen_past_2_53 ... ok +test catalog::column::tests::bigint_versus_non_finite_floats_matches_total_cmp_placement ... ok +test catalog::column::tests::decimal_beyond_i128_still_compares_exactly ... ok +test catalog::column::tests::decimal_orders_numerically_not_lexicographically ... ok +test catalog::column::tests::decimal_versus_non_finite_floats_is_fixed_and_cycle_free ... ok +test catalog::column::tests::decimal_versus_float_uses_the_floats_exact_value ... ok +test catalog::column::tests::f64_exact_decimal_is_exact_not_shortest ... ok +test catalog::column::tests::float_does_not_outrank_a_larger_integer ... ok +test branch::arena::tests::putting_the_pending_log_back_reaches_the_durable_map ... ok +test catalog::column::tests::integer_to_float_widening_is_exact_at_i32_bounds ... ok +test catalog::column::tests::numerically_equal_int_and_float_compare_equal ... ok +test catalog::column::tests::ordering_is_transitive_across_the_numeric_band ... ok +test catalog::column::tests::rank_fallback_still_orders_incomparable_variants ... ok +test catalog::column::tests::parse_decimal_accepts_what_sql_writes_and_refuses_what_it_does_not ... ok +test catalog::column::tests::timestamp_compares_by_value_and_sits_outside_the_numeric_band ... ok +test catalog::system_views::tests::a_table_may_not_take_a_view_name_but_an_ordinary_name_is_fine ... ok +test catalog::system_views::tests::every_view_name_resolves_and_nothing_else_does ... ok +test catalog::system_views::tests::hex_of_a_prompt_hash_is_64_characters ... ok +test catalog::system_views::tests::synthetic_row_ids_stay_distinct_past_the_slot_number_width ... ok +test catalog::system_views::tests::u64_values_past_i64_max_are_not_reported_as_negative ... ok +test catalog::system_views::tests::view_names_and_declared_columns_agree_with_the_enum ... ok +test cluster::tests::a_grant_addressed_to_another_node_is_refused ... ok +test cluster::tests::a_grant_from_a_previous_epoch_is_not_issued_from_after_leaving ... ok +test cluster::tests::a_grant_partly_consumed_before_a_crash_resumes_at_the_watermark ... ok +test branch::reaper::tests::the_reaper_does_not_fire_before_the_lease_expires ... ok +test cluster::tests::a_grant_wholly_below_the_restored_watermark_is_a_duplicate_not_a_rewind ... ok +test cluster::tests::a_lease_tick_never_moves_cluster_time_backwards ... ok +test cluster::tests::a_member_reads_the_replicated_tick_and_never_its_own_clock ... ok +test cluster::tests::a_member_refuses_again_once_its_grant_is_used_up ... ok +test cluster::tests::a_member_with_no_grant_refuses_rather_than_allocating ... ok +test cluster::tests::a_member_with_no_lease_tick_refuses_to_decide_a_lease ... ok +test cluster::tests::a_range_too_narrow_for_the_ask_is_kept_rather_than_split_or_dropped ... ok +test cluster::tests::a_redelivered_grant_issues_nothing_twice ... ok +test cluster::tests::a_standalone_extent_counter_walks_the_same_page_starts_as_fetch_add ... ok +test cluster::tests::a_standalone_node_at_the_end_of_the_value_space_refuses_distinguishably ... ok +test cluster::tests::a_standalone_node_issues_exactly_what_the_old_atomic_counter_did ... ok +test cluster::tests::a_standalone_node_reads_its_own_wall_clock_because_it_is_the_cluster ... ok +test cluster::tests::a_standalone_node_refuses_an_outside_grant ... ok +test cluster::tests::a_standalone_node_self_grants_through_the_same_consume_path ... ok +test cluster::tests::a_take_never_straddles_two_granted_ranges ... ok +test cluster::tests::an_empty_grant_is_refused_rather_than_ignored ... ok +test cluster::tests::the_issued_watermark_is_the_only_thing_a_checkpoint_has_to_carry ... ok +test cluster::tests::ranges_from_a_superseded_authority_are_not_issued_from ... ok +test cluster::tests::two_members_at_the_same_tick_cannot_disagree_about_expiry ... ok +test cluster::tests::two_members_with_disjoint_grants_never_issue_the_same_value ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test branch::arena::tests::parking_pages_by_the_interval_rule_reaches_the_durable_map ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test cow::btree::cow_page_links_tests::a_leaf_reports_no_children_without_erroring ... ok +test cow::btree::cow_page_links_tests::an_undecodable_internal_node_is_refused_not_reported_as_childless ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test catalog::column::tests::the_whole_value_domain_is_a_total_order ... ok +test branch::reaper::tests::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::catalog::tests::two_opens_of_one_log_recycle_branch_ids_in_the_same_order ... ok +test cow::node::tests::a_full_node_reports_full_instead_of_corrupting_itself ... ok +test cow::node::tests::internal_child_routing_uses_the_leftmost_slot_for_small_keys ... ok +test cow::node::tests::node_body_never_touches_the_page_header ... ok +test cow::node::tests::leaf_insert_search_and_remove ... ok +test cow::node::tests::repeated_overwrite_is_reclaimed_by_compaction ... ok +test cow::node::tests::split_point_keeps_both_halves_non_empty ... ok +test cow::page_header::tests::checksum_catches_a_flipped_payload_byte ... ok +test cow::page_header::tests::privacy_needs_both_own_arena_and_post_fork_birth ... ok +test cow::page_header::tests::header_roundtrips_and_leaves_payload_alone ... ok +test cow::page_header::tests::unknown_page_type_is_rejected ... ok +test cow::tests::overwriting_a_key_does_not_double_count_bytes ... ok +test cow::tests::write_buffer_probe_distinguishes_delete_from_absent ... ok +test branch::arena::tests::freeing_an_extent_checkpoints_the_map_so_a_restart_gets_the_space_back ... ok +test cow::tests_isolation::a_branch_can_never_mutate_another_branchs_page_in_place ... ok +test cow::btree::diff_tests::reversing_the_roots_reverses_each_delta ... ok +test cow::tests_isolation::a_deleted_key_stays_visible_to_a_branch_that_forked_before_the_delete ... ok +test cow::tests_isolation::a_live_child_forces_the_parent_to_shadow_instead_of_mutating ... ok +test branch::reaper::tests::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test cow::btree::diff_tests::an_insert_an_update_and_a_delete_each_report_both_sides ... ok +test cow::btree::diff_tests::identical_roots_diff_to_nothing_without_reading_a_page ... ok +test cow::tests_isolation::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::reaper::tests::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test catalog::column::tests::the_numeric_band_is_a_total_order ... ok +test cow::tests_isolation::a_page_private_to_the_writer_is_mutated_in_place_not_reshadowed ... ok +test cow::tests_isolation::allocating_from_an_unknown_arena_is_an_error ... ok +test cow::tests_isolation::an_oversized_entry_is_refused_rather_than_silently_truncated ... ok +test cow::tests_isolation::a_torn_page_is_refused_rather_than_returned ... ok +test cow::tests_isolation::a_recycled_extent_never_hands_back_stale_page_contents ... ok +test cow::tests_isolation::an_unregistered_branch_is_refused_rather_than_guessed_at ... ok +test cow::tests_isolation::freeing_an_arena_is_one_operation_not_a_per_page_scan ... ok +test cow::btree::diff_tests::unchanged_neighbours_of_an_edited_row_are_not_reported ... ok +test cow::tests_isolation::registering_the_same_branch_twice_is_refused ... ok +test cow::btree::diff_tests::many_scattered_edits_are_all_reported_exactly_once ... ok +test cow::tests_isolation::the_trunk_cannot_be_forgotten ... ok +test cow::tests_isolation::flushing_a_write_buffer_produces_the_same_tree_as_direct_inserts ... ok +test cow::tests_isolation::a_child_writing_never_frees_a_page_its_parent_still_points_at ... ok +test cow::tests_isolation::a_write_in_a_child_does_not_change_what_the_parent_reads ... ok +test cow::tests_isolation::fork_copies_zero_data_pages ... ok +test cow::tests_isolation::delete_removes_only_the_named_key_and_a_miss_shadows_nothing ... ok +test cow::tests_isolation::two_siblings_writing_the_same_key_do_not_see_each_other ... ok +test cow::tests_isolation::reaping_an_abandoned_branch_returns_the_page_count_to_baseline ... ok +test cow::tests_isolation::arenas_roll_over_when_an_extent_fills_and_stay_owned_by_one_branch ... ok +test execution::executor::tests::test_arithmetic ... ok +test execution::executor::tests::a_live_primary_key_is_still_refused ... ok +test cow::tests_isolation::the_default_one_megabyte_extent_works_end_to_end ... ok +test execution::executor::tests::an_uncommitted_delete_does_not_free_the_primary_key ... ok +test cow::tests_isolation::splits_and_multi_level_growth_preserve_every_key ... ok +test execution::executor::tests::test_comparison ... ok +test execution::executor::tests::a_deleted_primary_key_can_be_inserted_again ... ok +test execution::executor::tests::not_null_violation_errors ... ok +test execution::executor::tests::test_analyze_unknown_table_error ... ok +test execution::executor::tests::reusing_a_key_does_not_let_the_next_duplicate_through ... ok +test execution::executor::tests::test_analyze_empty_table ... ok +test execution::executor::tests::test_analyze_nulls_duplicates ... ok +test execution::executor::tests::test_analyze_basic ... ok +test branch::arena::tests::a_page_allocated_after_the_last_checkpoint_is_not_reissued_over_a_live_root ... ok +test execution::executor::tests::test_block_commits_atomically ... ok +test branch::reaper::tests::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test execution::executor::tests::test_block_rollback_discards_everything ... ok +test execution::executor::tests::test_error_aborts_everything ... ok +test execution::executor::tests::test_duplicate_primary_key_errors ... ok +test execution::executor::tests::test_logical ... ok +test execution::executor::tests::test_comparison_filter ... ok +test execution::executor::tests::test_delete_then_select ... ok +test execution::executor::tests::test_filter ... ok +test cow::btree::diff_tests::one_edit_in_a_large_tree_decodes_only_a_fraction_of_it ... ok +test execution::executor::tests::test_insert_stamps_begin_ts ... ok +test execution::executor::tests::test_unary ... ok +test branch::reaper::tests::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test execution::executor::tests::test_rejects_ddl_inside_block ... ok +test execution::executor::tests::test_inner_join ... ok +test execution::executor::tests::test_inner_join_with_keyword ... ok +test execution::fulltext_search::tests::idf_falls_with_document_frequency_and_never_goes_negative ... ok +test execution::executor::tests::test_join_no_match ... ok +test execution::fulltext_search::tests::term_score_saturates_in_tf_and_penalises_length ... ok +test execution::executor::tests::test_uncommitted_writes_invisible_across_sessions ... ok +test execution::fulltext_search::tests::the_heap_agrees_with_a_full_sort ... ok +test execution::fulltext_search::tests::the_heap_keeps_the_best_k_whatever_order_they_arrive_in ... ok +test execution::executor::tests::test_join_select_star ... ok +test execution::executor::tests::test_snapshot_pins_at_begin ... ok +test execution::executor::tests::test_select_all ... ok +test execution::fulltext_search::tests::the_heap_never_returns_more_than_k_and_never_pads ... ok +test execution::executor::tests::test_join_with_where ... ok +test execution::executor::tests::test_projection ... ok +test execution::fulltext_search::tests::ties_break_on_the_primary_key_so_the_boundary_is_deterministic ... ok +test execution::hash_join::tests::equal_hash_keys_hash_identically ... ok +test execution::hash_join::tests::hash_key_equality_agrees_with_the_equality_operator ... ok +test execution::executor::tests::test_left_join ... ok +test execution::executor::tests::test_left_outer_keyword ... ok +test execution::executor::tests::test_left_no_match ... ok +test optimizer::cost_model::tests::test_fallback ... ok +test optimizer::optimizer::tests::test_collect_columns ... ok +test execution::executor::tests::test_update_then_select ... ok +test optimizer::optimizer::tests::test_combine_column ... ok +test execution::executor::tests::test_self_join ... ok +test optimizer::optimizer::tests::test_push_spanning_predicate_stays ... ok +test optimizer::optimizer::tests::test_push_split_remap ... ok +test execution::executor::tests::updating_a_primary_key_is_refused_as_a_constraint_and_names_the_remedy ... ok +test optimizer::optimizer::tests::test_remap ... ok +test optimizer::optimizer::tests::test_split_and_roundtrip ... ok +test optimizer::optimizer::tests::test_wrap_filter_non_empty_conjuncts ... ok +test optimizer::optimizer::tests::test_wrap_filter_empty_conjuncts ... ok +test parser::parser::tests::a_mistyped_alter_still_says_what_was_expected ... ok +test parser::parser::tests::all_is_still_an_ordinary_identifier_everywhere_else ... ok +test execution::executor::tests::test_unsupported_join_type_error ... ok +test parser::parser::tests::alter_table_add_column_carries_not_null ... ok +test parser::parser::tests::alter_table_add_column_parses ... ok +test parser::parser::tests::alter_table_rename_column_parses ... ok +test parser::parser::tests::alter_table_retype_column_parses ... ok +test parser::parser::tests::an_unterminated_candidate_body_names_the_candidate_it_belongs_to ... ok +test parser::parser::tests::drop_column_is_refused_by_name_with_the_reason ... ok +test parser::parser::tests::positional_add_column_is_refused_by_name ... ok +test parser::parser::tests::simulate_admits_all_or_a_count ... ok +test parser::parser::tests::simulate_refuses_a_run_with_nothing_to_compare_or_nothing_to_check ... ok +test optimizer::cost_model::tests::test_cost_secondary_over_primary ... ok +test parser::parser::tests::test_as_of_coexists_with_alias_and_where ... ok +test parser::parser::tests::test_as_of_without_branch_keyword_is_an_error ... ok +test parser::parser::tests::test_begin_agent_session_requires_a_quoted_agent_id ... ok +test parser::parser::tests::test_expr_renders_back_to_sql_for_guard_capture ... ok +test parser::parser::tests::test_panic_mode_synchronization ... ok +test parser::parser::tests::test_parse_begin_agent_session ... ok +test parser::parser::tests::test_parse_create_table ... ok +test parser::parser::tests::test_parse_diff_merge_abandon ... ok +test parser::parser::tests::test_parse_insert ... ok +test parser::parser::tests::test_parse_revert_merge ... ok +test parser::parser::tests::test_parse_select_as_of_branch ... ok +test parser::parser::tests::test_parse_select_str ... ok +test parser::parser::tests::test_parse_simulate ... ok +test parser::parser::tests::test_select_with_join ... ok +test parser::parser::tests::test_update_with_where ... ok +test parser::parser::tests::the_alter_grammar_words_are_still_usable_as_column_names ... ok +test parser::scanner::tests::test_agent_keywords ... ok +test parser::scanner::tests::test_agent_keywords_are_case_insensitive ... ok +test parser::scanner::tests::test_all_keywords ... ok +test parser::scanner::tests::test_fulltext_keywords ... ok +test parser::scanner::tests::test_identifier_vs_keyword ... ok +test parser::scanner::tests::test_operators ... ok +test parser::scanner::tests::test_select_statement ... ok +test parser::scanner::tests::test_whitespace_ignored ... ok +test pgwire::extended::tests::a_parse_error_names_the_parameter_and_not_the_rewriting ... ok +test pgwire::extended::tests::result_format_shorthands_all_resolve_to_one_format_per_column ... ok +test pgwire::extended::tests::statements_split_on_top_level_semicolons_only ... ok +test pgwire::message::tests::a_body_reports_what_it_did_not_consume ... ok +test pgwire::message::tests::a_bodyless_message_is_still_four_bytes_of_length ... ok +test pgwire::message::tests::a_null_column_is_length_minus_one_not_an_empty_byte_string ... ok +test pgwire::message::tests::a_truncated_body_is_an_error_and_not_a_panic ... ok +test pgwire::message::tests::an_empty_string_is_length_zero_and_not_null ... ok +test pgwire::message::tests::error_response_fields_are_terminated_and_typed ... ok +test pgwire::message::tests::framing_length_covers_itself_and_the_body_but_not_the_tag ... ok +test pgwire::message::tests::parameter_description_is_a_count_then_one_oid_each ... ok +test pgwire::message::tests::ready_for_query_carries_the_transaction_status ... ok +test pgwire::message::tests::row_description_advertises_one_field_per_column ... ok +test pgwire::params::tests::a_bare_dollar_is_left_for_the_scanner_to_complain_about ... ok +test pgwire::params::tests::a_dollar_inside_a_string_literal_is_data ... ok +test pgwire::params::tests::a_parameter_carrying_sql_stays_one_value ... ok +test pgwire::params::tests::a_parameter_the_client_did_not_bind_is_refused ... ok +test pgwire::params::tests::a_quoted_identifier_is_not_scanned_for_parameters ... ok +test pgwire::params::tests::a_two_digit_parameter_is_one_parameter ... ok +test pgwire::params::tests::placeholders_become_identifiers_and_are_counted ... ok +test pgwire::params::tests::substitution_puts_a_literal_where_the_parameter_was ... ok +test pgwire::params::tests::substitution_reaches_every_expression_position ... ok +test pgwire::params::tests::zero_and_out_of_range_parameter_numbers_are_refused ... ok +test pgwire::session::tests::a_malformed_set_is_refused_here_and_not_passed_on ... ok +test pgwire::session::tests::a_multi_word_value_survives ... ok +test pgwire::session::tests::a_select_against_a_real_table_is_not_a_probe ... ok +test pgwire::session::tests::a_timeout_this_server_does_not_enforce_is_refused_unless_it_is_off ... ok +test pgwire::session::tests::a_transaction_option_this_engine_cannot_honour_is_refused ... ok +test pgwire::session::tests::an_encoding_this_server_cannot_produce_is_refused ... ok +test pgwire::session::tests::an_unknown_parameter_is_refused_but_a_namespaced_one_is_kept ... ok +test pgwire::session::tests::an_update_with_a_set_clause_is_not_a_session_set ... ok +test pgwire::session::tests::ordinary_sql_is_not_a_session_command ... ok +test pgwire::session::tests::pg_catalog_queries_get_an_explanation_rather_than_invented_rows ... ok +test pgwire::session::tests::reset_all_puts_every_parameter_back ... ok +test pgwire::session::tests::set_and_show_round_trip_a_parameter ... ok +test pgwire::session::tests::set_parses_the_spellings_drivers_actually_send ... ok +test pgwire::session::tests::the_normaliser_leaves_alone_what_is_not_transaction_control ... ok +test pgwire::session::tests::the_probes_a_real_driver_sends_are_recognised ... ok +test pgwire::session::tests::the_reported_isolation_level_is_the_one_the_engine_actually_gives ... ok +test pgwire::session::tests::the_reported_parameters_all_have_values ... ok +test pgwire::session::tests::the_startup_packet_can_set_parameters_too ... ok +test pgwire::session::tests::the_transaction_spellings_drivers_send_map_onto_the_three_this_engine_has ... ok +test pgwire::tests::the_reported_server_version_parses_as_a_version_and_stays_below_the_jit_era ... ok +test pgwire::types::tests::a_declared_column_type_maps_to_the_same_oid_as_a_value_of_it ... ok +test pgwire::types::tests::a_malformed_numeric_is_refused_rather_than_read_past_its_end ... ok +test pgwire::types::tests::a_row_that_disagrees_with_its_description_is_refused_not_encoded ... ok +test pgwire::types::tests::a_short_binary_parameter_is_refused_rather_than_padded ... ok +test pgwire::types::tests::a_whole_float_parameter_stays_a_float ... ok +test pgwire::types::tests::an_unencodable_oid_is_named_rather_than_guessed ... ok +test pgwire::types::tests::binary_integers_are_fixed_width_big_endian ... ok +test pgwire::types::tests::binary_parameters_decode_to_the_literal_the_binder_already_understands ... ok +test pgwire::types::tests::booleans_render_as_t_and_f_the_way_postgres_text_format_does ... ok +test pgwire::types::tests::null_is_null_in_both_formats_and_never_the_word ... ok +test pgwire::types::tests::numeric_encodes_to_the_documented_base_10000_shape ... ok +test pgwire::types::tests::numeric_round_trips_including_the_trailing_zeros_that_are_information ... ok +test pgwire::types::tests::numeric_zero_has_no_sign_and_no_digits ... ok +test pgwire::types::tests::values_map_to_the_postgres_type_oids_a_client_expects ... ok +test pgwire::types::tests::text_parameters_decode_under_their_declared_oid ... ok +test planner::plan::tests::test_bounds_basic ... ok +test planner::plan::tests::test_bounds_flipped ... ok +test planner::plan::tests::test_bounds_invalid ... ok +test provenance::capture::tests::a_capture_retains_reads_in_the_form_the_shape_demands ... ok +test provenance::capture::tests::a_halted_plan_names_no_version_to_undo_and_a_cascade_names_them_deepest_first ... ok +test provenance::capture::tests::a_point_read_never_retains_a_predicate_even_if_one_is_offered ... ok +test provenance::capture::tests::blind_writes_survive_the_round_trip_through_a_capture ... ok +test provenance::capture::tests::stamping_on_write_ties_the_version_to_the_run ... ok +test provenance::capture::tests::the_log_finds_a_downstream_dependent_and_halts ... ok +test provenance::capture::tests::the_report_names_the_agent_behind_each_blocking_transaction ... ok +test provenance::durable::tests::a_failed_append_poisons_the_store_instead_of_leaving_it_looking_healthy ... ok +test provenance::durable::tests::a_foreign_file_is_refused ... ok +test planner::plan::tests::test_index_scan_point ... ok +test planner::plan::tests::test_index_scan_secondary_equality ... ok +test planner::plan::tests::test_index_scan_range ... ok +test provenance::durable::tests::a_restamped_version_replays_to_the_later_run ... ok +test provenance::durable::tests::a_stamp_naming_an_undeclared_run_is_refused_rather_than_healed ... ok +test provenance::durable::tests::re_interning_after_a_reopen_returns_the_same_slot ... ok +test planner::plan::tests::test_index_scan_secondary_rejects_strict_lower ... ok +test provenance::durable::tests::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test provenance::readset::tests::a_scan_makes_no_write_in_that_table_blind ... ok +test planner::plan::tests::test_index_scan_unbounded ... ok +test provenance::readset::tests::blind_writes_finds_the_row_nobody_looked_at ... ok +test provenance::readset::tests::form_is_decided_by_shape_alone ... ok +test provenance::readset::tests::intersecting_two_lower_bounds_keeps_the_tighter_one ... ok +test provenance::durable::tests::the_in_memory_guards_still_hold_and_a_refused_stamp_never_reaches_the_file ... ok +test provenance::readset::tests::intersecting_two_upper_bounds_keeps_the_tighter_one ... ok +test provenance::readset::tests::predicate_summary_gives_phantom_coverage ... ok +test provenance::durable::tests::run_records_out_of_order_in_the_file_still_replay_to_their_recorded_ids ... ok +test provenance::readset::tests::scattered_point_reads_are_never_coarsened_however_many ... ok +test provenance::readset::tests::the_intersected_region_is_what_covers_answers_over ... ok +test provenance::durable::tests::who_wrote_a_row_still_answers_after_a_close_and_reopen ... ok +test provenance::revert::tests::a_downstream_dependent_is_found_through_the_read_set ... ok +test provenance::revert::tests::a_predicate_read_depends_on_a_write_that_landed_inside_its_range ... ok +test provenance::revert::tests::a_scan_does_not_depend_on_a_write_it_could_not_have_seen ... ok +test provenance::revert::tests::a_transaction_does_not_depend_on_itself ... ok +test provenance::revert::tests::a_write_outside_the_scanned_range_creates_no_edge ... ok +test provenance::revert::tests::cascade_orders_dependents_before_their_dependency ... ok +test provenance::revert::tests::halt_is_the_default_and_reverts_nothing ... ok +test provenance::revert::tests::predicate_reads_contribute_no_fake_exact_edges ... ok +test provenance::revert::tests::the_halt_tree_shows_the_whole_downstream_chain ... ok +test provenance::revert::tests::the_tree_terminates_on_a_read_write_cycle ... ok +test provenance::sha256::tests::a_prompt_digest_is_non_zero_and_depends_on_the_prompt ... ok +test provenance::sha256::tests::digests_at_every_padding_boundary_match_an_independent_implementation ... ok +test provenance::sha256::tests::fips_180_4_vectors ... ok +test provenance::sha256::tests::hex_round_trips ... ok +test provenance::sha256::tests::one_flipped_bit_changes_the_digest ... ok +test provenance::store::tests::a_page_dictionary_refuses_to_widen_past_a_byte ... ok +test provenance::store::tests::a_run_can_be_asked_what_it_wrote ... ok +test provenance::sha256::tests::the_one_million_a_vector ... ok +test provenance::store::tests::an_unstamped_version_is_unattributed_not_wrong ... ok +test provenance::store::tests::attribution_is_run_level_so_a_page_holds_one_dictionary_entry_per_run ... ok +test provenance::store::tests::criterion_nine_which_agent_run_model_wrote_this_row ... ok +test provenance::store::tests::distinct_runs_get_distinct_slots ... ok +test provenance::store::tests::interning_the_same_run_twice_is_a_lookup ... ok +test provenance::store::tests::re_interning_a_run_with_a_different_model_is_refused ... ok +test provenance::store::tests::stamping_a_version_with_no_provenance_is_refused ... ok +test provenance::store::tests::stamping_with_an_uninterned_slot_is_refused ... ok +test provenance::store::tests::the_density_numbers_the_docs_quote_are_the_numbers_this_computes ... ok +test provenance::store::tests::the_interned_slot_beats_a_literal_header_by_a_wide_margin ... ok +test provenance::store::tests::the_local_slot_round_trips_through_the_dictionary ... ok +test provenance::tests::prov_id_zero_means_unattributed ... ok +test provenance::tests::run_entity_answers_exit_criterion_nine ... ok +test replication::backup::tests::a_backup_older_than_the_primarys_log_is_refused_by_name ... ok +test replication::backup::tests::a_label_round_trips_through_text ... ok +test replication::backup::tests::a_label_that_is_not_one_is_refused ... ok +test replication::backup::tests::a_label_without_the_version_header_is_refused_rather_than_guessed_at ... ok +test replication::jsonl::tests::a_bare_json_number_would_lose_these_values_which_is_why_they_are_strings ... ok +test replication::jsonl::tests::a_create_table_shape_omits_the_denied_column ... ok +test replication::jsonl::tests::a_declaration_carries_no_alteration ... ok +test replication::jsonl::tests::a_delete_line_carries_before_and_a_null_after ... ok +test replication::jsonl::tests::a_delete_projects_its_only_image ... ok +test replication::jsonl::tests::a_denied_column_is_named_nowhere_in_the_line ... ok +test replication::backup::tests::a_checkpoint_may_not_discard_a_pinned_range ... ok +test replication::backup::tests::pinning_an_lsn_the_log_has_already_dropped_is_refused ... ok +test replication::jsonl::tests::a_feed_with_nothing_withheld_is_unchanged ... ok +test replication::jsonl::tests::a_mixed_row_keeps_narrow_types_as_numbers_and_wide_types_as_strings ... ok +test replication::jsonl::tests::a_null_column_is_present_and_null_not_absent ... ok +test replication::jsonl::tests::a_null_wide_column_is_json_null_not_a_quoted_null ... ok +test replication::backup::tests::a_backup_records_the_lsn_window_its_pages_sit_in ... ok +test replication::jsonl::tests::a_refusal_minted_inside_the_renderer_still_writes_nothing ... ok +test replication::jsonl::tests::a_rename_line_carries_the_old_name_the_new_shape_cannot ... ok +test replication::jsonl::tests::a_retype_line_gives_both_type_spellings ... ok +test replication::jsonl::tests::a_row_whose_present_values_are_all_withheld_is_refused ... ok +test replication::jsonl::tests::a_snapshot_read_projects_its_row ... ok +test replication::jsonl::tests::a_table_dump_is_projected_and_refused_by_the_same_policy ... ok +test replication::backup::tests::a_truncated_image_is_refused_rather_than_restored ... ok +test replication::jsonl::tests::a_whole_numbered_float_keeps_its_decimal_point ... ok +test replication::backup::tests::the_image_holds_the_primarys_pages ... ok +test replication::jsonl::tests::a_withheld_first_column_leaves_valid_json ... ok +test replication::jsonl::tests::an_add_column_line_names_the_added_column_and_the_new_shape ... ok +test replication::jsonl::tests::an_insert_line_carries_after_and_a_null_before ... ok +test replication::jsonl::tests::an_undecided_table_is_refused_by_the_renderer ... ok +test replication::jsonl::tests::an_update_line_carries_both_images ... ok +test replication::jsonl::tests::both_images_of_an_update_are_projected ... ok +test replication::jsonl::tests::column_names_are_escaped_too ... ok +test replication::jsonl::tests::every_character_json_requires_escaping_is_escaped ... ok +test replication::jsonl::tests::floats_round_trip_bit_for_bit ... ok +test replication::jsonl::tests::integer_extremes_survive_exactly ... ok +test replication::jsonl::tests::non_ascii_passes_through_as_utf8 ... ok +test replication::jsonl::tests::non_finite_floats_become_strings_rather_than_invalid_json ... ok +test replication::jsonl::tests::surplus_values_get_visible_synthetic_names_rather_than_vanishing ... ok +test replication::jsonl::tests::the_wide_types_are_emitted_as_quoted_strings ... ok +test replication::jsonl::tests::write_feed_reports_how_many_lines_it_wrote ... ok +test replication::jsonl::tests::write_feed_writes_nothing_at_all_when_any_event_is_refused ... ok +test replication::logical::tests::a_change_to_an_unknown_table_is_reported_rather_than_dropped ... ok +test replication::logical::tests::a_committed_insert_becomes_a_typed_row_event ... ok +test replication::logical::tests::a_decoder_with_no_tables_reports_everything_as_unresolved ... ok +test replication::logical::tests::a_ddl_record_whose_alteration_is_not_in_its_shape_is_refused ... ok +test replication::logical::tests::a_null_column_decodes_as_null ... ok +test replication::logical::tests::a_declaration_names_a_run_without_attributing_anybodys_commit ... ok +test replication::logical::tests::a_second_session_of_one_run_is_not_a_conflicting_actor ... ok +test replication::logical::tests::sql_type_contract_is_exhaustive ... ok +test replication::logical::tests::an_aborted_transaction_emits_nothing ... ok +test replication::logical::tests::an_update_carries_the_row_before_and_after ... ok +test replication::logical::tests::an_open_transaction_is_withheld_and_reported ... ok +test replication::publication::tests::a_column_name_the_format_cannot_express_is_withheld_never_published ... ok +test replication::logical::tests::events_are_ordered_by_commit_not_by_write ... ok +test replication::publication::tests::a_create_table_shape_is_masked_like_a_row ... ok +test replication::logical::tests::one_provenance_slot_naming_two_actors_is_refused ... ok +test replication::publication::tests::a_declaration_with_no_header_is_refused ... ok +test replication::publication::tests::a_drop_table_is_decided_by_the_table_and_not_by_its_columns ... ok +test replication::logical::tests::time_travel_records_are_counted_as_internal_not_as_lost ... ok +test replication::publication::tests::a_duplicate_column_is_refused ... ok +test replication::logical::tests::an_mvcc_delete_is_reported_as_a_delete_not_an_update ... ok +test replication::publication::tests::a_header_with_no_name_is_refused_as_a_nameless_header ... ok +test replication::publication::tests::a_line_the_parser_cannot_read_is_refused_rather_than_skipped ... ok +test replication::publication::tests::a_name_containing_the_comment_character_is_refused_rather_than_shortened ... ok +test replication::publication::tests::a_name_that_is_not_an_identifier_is_refused ... ok +test replication::publication::tests::a_publication_that_names_no_table_is_refused_at_parse ... ok +test replication::publication::tests::a_refusal_says_which_publication_refused_what ... ok +test replication::publication::tests::a_table_declared_twice_is_refused ... ok +test replication::publication::tests::a_table_published_with_no_columns_is_refused_like_an_absent_one ... ok +test replication::publication::tests::a_table_that_is_both_published_and_excluded_is_refused_either_way_round ... ok +test replication::publication::tests::a_table_that_is_not_in_the_publication_is_refused ... ok +test replication::publication::tests::a_table_with_no_columns_is_refused_at_parse ... ok +test replication::publication::tests::a_value_with_no_column_name_is_refused_under_an_allowlist ... ok +test replication::publication::tests::an_event_whose_every_column_is_withheld_is_refused ... ok +test replication::publication::tests::an_excluded_table_is_decided_against_rather_than_undecided ... ok +test replication::publication::tests::both_images_of_an_update_are_covered_by_the_value_check ... ok +test optimizer::cost_model::tests::test_cardinality ... ok +test replication::publication::tests::mask_for_decides_a_bare_table_and_column_list ... ok +test replication::publication::tests::parse_narrows_to_exactly_what_it_names ... ok +test replication::publication::tests::two_columns_with_one_name_are_refused_rather_than_guessed ... ok +test replication::publication::tests::unrestricted_publishes_everything ... ok +test replication::publication::tests::whole_line_comments_and_blank_lines_are_ignored ... ok +test branch::reaper::tests::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok +test replication::snapshot::tests::a_failed_scan_yields_no_handoff ... ok +test replication::snapshot::tests::a_quiet_database_hands_off_exactly ... ok +test replication::snapshot::tests::a_snapshot_writes_read_events_and_reports_its_handoff ... ok +test replication::snapshot::tests::a_failed_exact_scan_closes_its_reader_and_yields_no_handoff ... ok +test replication::snapshot::tests::an_empty_table_still_yields_a_handoff_point ... ok +test replication::snapshot::tests::the_handoff_lsn_is_taken_before_the_scan_not_after ... ok +test replication::snapshot::tests::a_scan_that_fails_after_writing_reports_the_rollback_too ... ok +test replication::snapshot::tests::an_exact_snapshot_writes_its_rows_and_pins_its_resume_point ... ok +test replication::stream::tests::a_batch_that_is_entirely_refused_does_not_advance_the_cursor ... ok +test replication::snapshot::tests::the_boundary_covers_exactly_the_tables_that_were_delivered ... ok +test replication::stream::tests::a_batch_that_is_entirely_suppressed_still_advances_the_cursor ... ok +test replication::stream::tests::a_cursor_below_the_logs_base_is_refused_by_name ... ok +test replication::stream::tests::a_large_backlog_is_delivered_in_bounded_batches ... ok +test replication::snapshot::tests::a_snapshot_whose_resume_point_cannot_be_pinned_leaves_no_reader_behind ... ok +test replication::stream::tests::a_pump_emits_committed_changes_and_advances_past_them ... ok +test replication::stream::tests::a_refused_event_is_replayed_after_the_publication_is_widened ... ok +test replication::stream::tests::a_schema_event_survives_the_snapshot_filter ... ok +test replication::stream::tests::a_long_open_transaction_does_not_cause_endless_redelivery ... ok +test replication::stream::tests::a_refusal_leaves_a_subscriptions_claim_below_the_refused_commit ... ok +test replication::stream::tests::a_streamer_without_a_snapshot_boundary_suppresses_nothing ... ok +test replication::stream::tests::an_already_delivered_event_does_not_wedge_the_cursor_when_it_later_becomes_unpublishable ... ok +test replication::stream::tests::a_transaction_that_was_in_flight_at_snapshot_time_is_still_delivered ... ok +test replication::stream::tests::an_excluded_tables_events_are_dropped_and_the_cursor_keeps_moving ... ok +test replication::stream::tests::a_subscription_holds_the_log_across_a_checkpoint ... ok +test replication::stream::tests::an_in_flight_transaction_does_not_move_the_cursor_and_is_emitted_after_it_commits ... ok +test replication::stream::tests::changes_that_are_not_durable_yet_are_not_emitted ... ok +test replication::stream::tests::events_the_snapshot_already_contained_are_not_re_delivered ... ok +test replication::stream::tests::an_open_transaction_is_not_stepped_over_when_a_later_one_commits ... ok +test replication::stream::tests::following_takes_its_cursor_from_the_boundary_and_refuses_without_one ... ok +test replication::sync::tests::a_replica_that_reconnects_inside_the_deadline_still_satisfies_the_commit ... ok +test replication::sync::tests::a_target_already_acked_does_not_wait ... ok +test replication::stream::tests::lag_shrinks_as_a_consumer_catches_up ... ok +test replication::sync::tests::an_ack_never_goes_backwards ... ok +test replication::stream::tests::resuming_from_the_returned_cursor_does_not_repeat_events ... ok +test replication::stream::tests::resuming_from_a_commit_position_alone_loses_an_in_flight_transaction ... ok +test replication::stream::tests::withholding_a_column_still_ships_the_row_and_advances_the_cursor ... ok +test replication::sync::tests::the_furthest_replica_satisfies_the_wait ... ok +test replication::sync::tests::an_ack_that_reaches_the_target_releases_the_waiter ... ok +test replication::tests::a_frame_too_short_for_its_own_fields_is_refused ... ok +test replication::stream::tests::subscribing_below_the_base_is_refused_immediately ... ok +test replication::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test replication::tests::an_empty_records_batch_is_distinct_from_no_batch ... ok +test replication::tests::an_oversized_length_is_refused_without_allocating ... ok +test replication::tests::every_message_round_trips ... ok +test replication::sync::tests::with_no_replica_it_refuses_rather_than_degrading_silently ... ok +test replication::tests::the_handshake_rejects_a_stranger_and_a_version_mismatch ... ok +test replication::tests::the_length_covers_the_body_only ... ok +test storage::atomic_file::tests::a_bare_relative_target_syncs_the_current_directory_not_the_empty_path ... ok +test storage::atomic_file::tests::a_durable_replace_syncs_the_temporary_before_the_rename_and_the_directory_after_it ... ok +test storage::atomic_file::tests::a_path_that_names_no_file_is_refused_rather_than_staged_through_dot_tmp ... ok +test storage::atomic_file::tests::syncing_a_real_directory_succeeds_on_this_platform ... ok +test storage::atomic_file::tests::the_temporary_appends_tmp_instead_of_replacing_the_extension ... ok +test storage::db_lock::tests::an_empty_lock_file_still_refuses ... ok +test storage::db_lock::tests::a_second_claim_on_one_database_is_refused ... ok +test storage::db_lock::tests::a_stale_lock_is_refused_and_names_the_file_to_remove ... ok +test storage::db_lock::tests::dropping_the_lock_lets_the_next_open_through ... ok +test storage::disk_manager::tests::exhausting_the_table_region_says_the_knob_will_not_help_this_database ... ok +test storage::disk_manager::tests::a_second_reservation_at_a_higher_base_is_refused ... ok +test storage::db_lock::tests::two_different_databases_do_not_contend ... ok +test storage::disk_manager::tests::growing_the_bitmap_does_not_reuse_owned_pages ... ok +test storage::disk_manager::tests::high_water_does_not_regress_after_a_free ... ok +test storage::disk_manager::tests::next_page_id_is_not_the_high_water_mark ... ok +test storage::disk_manager::tests::reattaching_at_the_same_base_is_allowed ... ok +test storage::disk_manager::tests::test_freelist ... ok +test storage::heap_file_manager::tests::test_insert_and_read ... ok +test storage::heap_page::tests::test_basic ... ok +test storage::heap_page::tests::test_exact_bytes ... ok +test storage::heap_file_manager::tests::test_insert_delete_scan ... ok +test storage::disk_manager::tests::test_rw ... ok +test storage::heap_file_manager::tests::test_scan ... ok +test storage::heap_file_manager::tests::test_multiple_pages ... ok +test storage::heap_file_manager::tests::test_update_in_place ... ok +test storage::index::tests::test_create_init_empty_leaf ... ok +test storage::index::tests::test_delete_not_real_key ... ok +test storage::index::tests::test_internal_node_split_a_lot ... ok +test storage::index::tests::test_delete_basic ... ok +test storage::index::tests::test_range_scan_basic ... ok +test storage::index_fulltext::tests::an_over_long_run_is_dropped_and_its_neighbours_are_not ... ok +test storage::index_fulltext::tests::distinct_tokens_dedups_but_tokenize_does_not ... ok +test storage::atomic_file::tests::the_real_filesystem_replace_lands_the_bytes_and_leaves_no_temporary ... ok +test storage::index::tests::test_search_empty_tree_returns_none ... ok +test storage::index::tests::test_search_finds_key ... ok +test storage::index_fulltext::tests::indexed_text_accepts_varchar_and_null_and_refuses_the_rest ... ok +test storage::index_fulltext::tests::null_is_the_floor_of_the_second_component ... ok +test storage::index_fulltext::tests::the_cap_counts_bytes_not_chars ... ok +test storage::index_fulltext::tests::tokenize_emits_no_empty_tokens ... ok +test storage::index::tests::test_simple_insert ... ok +test storage::index_fulltext::tests::tokenize_limits_are_the_documented_ones ... ok +test storage::index_fulltext::tests::tokenize_lowercases_and_splits_on_non_alphanumerics ... ok +test storage::index::tests::test_range_scan_many_pages ... ok +test storage::index_page::tests::test_internal_find_child ... ok +test storage::index_page::tests::test_internal_insert_key_child ... ok +test storage::index_page::tests::test_internal_split ... ok +test storage::index_page::tests::test_leaf_composite_keys ... ok +test storage::index_page::tests::test_leaf_get ... ok +test storage::index_page::tests::test_leaf_insert_sorted ... ok +test storage::index_page::tests::test_leaf_remove ... ok +test storage::index_page::tests::test_leaf_split ... ok +test storage::index_page::tests::test_roundtrip_i_p ... ok +test storage::index_page::tests::test_roundtrip_i_s ... ok +test storage::index_page::tests::test_roundtrip_l_p ... ok +test storage::index_page::tests::test_roundtrip_l_s ... ok +test storage::tuple::tests::serialising_a_row_that_does_not_match_its_schema_is_refused ... ok +test storage::tuple::tests::test_null ... ok +test storage::tuple::tests::test_se_and_deserialize ... ok +test storage::index::tests::test_many_level_insert_and_search ... ok +test storage::tuple::tests::test_versioned_tuple_roundtrip ... ok +test tel::capture::tests::a_delta_against_a_different_column_is_not_a_relative_move ... ok +test storage::index::tests::test_many_level_reverse ... ok +test provenance::durable::tests::the_interning_claim_still_holds_after_a_reopen_on_the_existing_instrument ... ok +test tel::capture::tests::a_guard_reading_outside_the_target_table_is_refused ... ok +test tel::capture::tests::a_join_shifts_the_target_tables_offsets ... ok +test tel::capture::tests::a_literal_assignment_stays_an_assign ... ok +test tel::capture::tests::a_single_table_plan_still_maps_offsets_straight_through ... ok +test tel::capture::tests::a_snapshot_refuses_a_row_it_does_not_hold ... ok +test tel::capture::tests::a_where_clause_becomes_a_guard_that_can_be_rechecked_later ... ok +test tel::capture::tests::an_untranslatable_predicate_is_refused_rather_than_weakened ... ok +test tel::capture::tests::arithmetic_inside_a_guard_survives_capture ... ok +test tel::capture::tests::conjunctions_and_negations_survive_capture ... ok +test tel::capture::tests::end_to_end::concurrent_decrements_compose_arithmetically ... ok +test tel::capture::tests::end_to_end::overselling_is_rejected_with_the_violated_predicate_returned ... ok +test tel::capture::tests::end_to_end::the_rejection_is_not_unconditional ... ok +test tel::capture::tests::five_minus_qty_is_not_an_add_because_subtraction_does_not_commute ... ok +test tel::capture::tests::qty_minus_five_is_captured_as_an_add_not_an_assign ... ok +test tel::capture::tests::five_plus_qty_is_still_an_add ... ok +test tel::engine::tests::a_delete_does_not_invalidate_its_own_where_clause ... ok +test tel::engine::tests::a_delete_versus_a_write_names_the_right_side_in_the_report ... ok +test tel::engine::tests::a_discarded_write_is_reported_as_the_side_s_net_effect ... ok +test tel::engine::tests::a_drawdown_that_exactly_exhausts_the_resource_is_allowed ... ok +test tel::engine::tests::a_duplicate_frame_across_sides_is_dropped_too ... ok +test tel::engine::tests::a_failing_lca_read_surfaces_instead_of_becoming_no_lca_value ... ok +test tel::engine::tests::a_frames_own_guarded_delete_is_not_unevaluable ... ok +test tel::engine::tests::a_genuinely_violated_precondition_still_conflicts ... ok +test tel::engine::tests::a_guard_reading_a_row_the_other_side_deleted_is_unevaluable ... ok +test tel::engine::tests::a_one_sided_write_is_clean_not_commuting ... ok +test tel::engine::tests::a_removed_dot_does_not_survive_the_merge ... ok +test tel::engine::tests::a_row_created_inside_the_merge_supplies_its_own_base_for_a_delta ... ok +test tel::engine::tests::a_row_routed_op_that_is_not_create_or_delete_is_refused ... ok +test tel::engine::tests::a_schema_version_mismatch_fails_loudly ... ok +test tel::engine::tests::a_set_op_meeting_a_scalar_op_conflicts_rather_than_panicking ... ok +test tel::engine::tests::a_solo_decrement_landing_on_its_own_bound_is_not_a_conflict ... ok +test tel::engine::tests::a_well_formed_row_create_is_still_composed ... ok +test tel::engine::tests::additive_policy_uses_the_three_way_formula_on_absolute_writes ... ok +test tel::engine::tests::an_absent_lca_cell_still_falls_back_to_the_fresh_image ... ok +test tel::engine::tests::an_uncontended_decrement_does_not_conflict_with_its_own_result ... ok +test tel::engine::tests::an_unevaluable_guard_is_distinct_from_a_failed_one ... ok +test tel::engine::tests::both_sides_deleting_the_same_row_is_idempotent ... ok +test tel::engine::tests::dedup_reports_which_txn_it_dropped ... ok +test tel::engine::tests::contradictory_assigns_under_the_default_policy_conflict ... ok +test tel::engine::tests::delete_on_one_side_versus_a_write_on_the_other_conflicts ... ok +test tel::engine::tests::diff_returns_the_ops_and_guards_the_target_branch_added ... ok +test tel::engine::tests::diff_without_a_log_is_an_error_not_an_empty_changeset ... ok +test tel::engine::tests::escrow_floor_is_rechecked_against_merged_state ... ok +test tel::engine::tests::every_violated_guard_is_reported_not_just_the_first ... ok +test tel::engine::tests::folding_a_side_applies_its_own_ops_in_order ... ok +test tel::engine::tests::guard_failing_against_merged_state_is_a_conflict_with_the_predicate_returned ... ok +test tel::engine::tests::identical_assigns_are_not_a_conflict ... ok +test tel::engine::tests::lww_orders_by_global_txn_id_not_branch_local_seq ... ok +test tel::engine::tests::lww_reports_resolved_with_loss_and_names_the_discarded_write ... ok +test tel::engine::tests::main_untouched_is_clean ... ok +test tel::engine::tests::max_and_min_compose_without_a_policy ... ok +test tel::engine::tests::merging_frames_read_back_from_the_log_reaches_the_same_answer ... ok +test tel::engine::tests::multi_value_keeps_both_writes ... ok +test tel::engine::tests::multi_value_surfaces_each_side_s_net_effect ... ok +test tel::engine::tests::multivalue_composes_net_effects_not_last_ops ... ok +test tel::engine::tests::observed_remove_spares_an_insert_the_remover_never_saw ... ok +test tel::engine::tests::replayed_frame_is_dropped_rather_than_applied_twice ... ok +test tel::engine::tests::the_same_guard_holds_when_the_composition_leaves_headroom ... ok +test tel::engine::tests::two_branches_decrementing_compose_arithmetically ... ok +test tel::engine::tests::two_identical_decrements_from_different_txns_are_not_deduplicated ... ok +test tel::guard::tests::an_and_with_an_unknown_operand_is_unknown_not_true ... ok +test tel::guard::tests::an_or_with_an_unknown_operand_is_unknown_and_not_negatable_into_true ... ok +test tel::guard::tests::bounded_counter_guard_composes_with_arithmetic ... ok +test tel::guard::tests::guard_is_rechecked_against_whatever_state_it_is_given ... ok +test tel::guard::tests::null_operand_makes_the_guard_unknown_not_true ... ok +test tel::guard::tests::referenced_cells_finds_every_read ... ok +test tel::guard::tests::unreadable_cell_is_an_error_not_a_false ... ok +test tel::guard::tests::violated_predicate_is_returned_verbatim ... ok +test tel::log::tests::frames_come_back_in_sequence_order_per_branch ... ok +test tel::log::tests::guards_survive_the_round_trip_separately_from_ops ... ok +test tel::log::tests::replaying_the_identical_frame_does_not_store_it_twice ... ok +test tel::log::tests::reusing_a_txn_id_for_different_contents_is_an_error_not_an_overwrite ... ok +test tel::log::tests::the_same_txn_id_on_a_different_branch_is_a_different_frame ... ok +test tel::merge::tests::conflict_carries_the_predicate_back_to_the_agent ... ok +test tel::merge::tests::default_policy_forbids_concurrent_writes ... ok +test tel::merge::tests::lossy_resolution_never_reads_as_clean ... ok +test tel::op::tests::assign_is_idempotent_but_does_not_commute ... ok +test tel::op::tests::delta_applies_and_refuses_non_numeric ... ok +test tel::op::tests::integer_delta_overflow_is_an_error_not_a_wrap ... ok +test tel::op::tests::two_identical_decrements_compose_to_double ... ok +test tel::schema_merge::tests::a_one_sided_schema_change_is_clean ... ok +test tel::schema_merge::tests::a_predicate_about_an_absent_column_is_answerable ... ok +test tel::schema_merge::tests::a_rename_composes_with_an_unrelated_add ... ok +test tel::schema_merge::tests::a_rename_onto_a_name_another_branch_created_conflicts ... ok +test tel::schema_merge::tests::adding_the_same_column_twice_with_different_types_conflicts ... ok +test tel::schema_merge::tests::an_identical_edit_is_absorbed_rather_than_refused ... ok +test tel::schema_merge::tests::no_edits_is_clean_even_when_the_target_moved ... ok +test tel::schema_merge::tests::renaming_a_column_another_branch_renamed_first_conflicts ... ok +test tel::schema_merge::tests::the_returned_predicate_re_evaluates_to_false ... ok +test tel::schema_merge::tests::two_branches_adding_different_columns_compose ... ok +test tel::schema_merge::tests::two_branches_retyping_one_column_conflict_and_name_the_predicate ... ok +test wal::log::tests::a_run_identity_record_that_names_no_run_is_refused ... ok +test wal::log::tests::a_truncated_alteration_is_refused_rather_than_panicking ... ok +test wal::log::tests::a_truncated_run_identity_record_is_refused_rather_than_panicking ... ok +test wal::log::tests::an_older_logs_records_still_decode_after_the_new_tag_was_added ... ok +test wal::log::tests::an_unknown_alteration_tag_is_refused ... ok +test wal::log::tests::create_and_drop_records_keep_their_byte_layout ... ok +test wal::log::tests::a_record_whose_bytes_were_corrupted_is_refused ... ok +test wal::log::tests::deserialize_rejects_empty_and_unknown_tag ... ok +test wal::log::tests::every_column_alteration_survives_the_log_round_trip ... ok +test wal::log::tests::test_crc32_val ... ok +test wal::log::tests::test_reckind_roundtrip ... ok +test wal::log::tests::test_lsns_are_monotonic ... ok +test wal::log::tests::test_flush_up_to_stops_when_durable ... ok +test wal::log::tests::test_corrupted_last_record_detected ... ok +test wal::log::tests::test_read_buffer_before_flush ... ok +test wal::log::tests::test_survives_flush_and_reopen ... ok +test wal::log::tests::the_recovery_scan_stops_at_a_corrupted_record ... ok +test wal::recovery::tests::test_uncommited_rolled_back ... ok +test wal::txn::tests::a_quiet_database_resumes_at_the_readers_own_begin ... ok +test wal::recovery::tests::test_insert_survives_crash ... ok +test wal::txn::tests::a_second_session_of_one_run_is_the_same_actor_however_the_clock_moved ... ok +test wal::txn::tests::a_snapshot_read_resumes_at_the_oldest_transaction_it_excluded ... ok +test wal::txn::tests::a_refused_binding_leaves_nothing_behind_for_the_commit_to_write ... ok +test wal::txn::tests::a_reader_is_not_leaked_when_its_txn_end_cannot_be_written ... ok +test wal::txn::tests::binding_a_run_with_an_empty_named_field_is_refused ... ok +test wal::txn::tests::a_snapshot_reader_that_writes_is_refused_and_rolled_back ... ok +test wal::recovery::tests::test_update_delete_redo_after_checkpoint ... ok +test wal::txn::tests::declaring_one_slot_as_two_actors_is_refused ... ok +test wal::recovery::tests::sql_crash_recover_rebuild_query ... ok +test wal::txn::tests::a_snapshot_reader_is_not_leaked_when_its_handoff_cannot_be_pinned ... ok +test wal::txn::tests::gate_flushed_wal_before_page_write ... ok +test wal::txn::tests::test_visibility_matrix ... ok +test wal::txn::tests::gate_covers_flush_all ... ok +test wal::txn::tests::interrupted_truncation_self_heals_on_open ... ok +test wal::txn::tests::the_high_water_mark_is_exclusive ... ok +test wal::txn::tests::checkpoint_truncates_and_preserves_data ... ok +test wal::txn::tests::test_abort_delete_restores_row ... ok +test wal::txn::tests::test_abort_insert_removes_rows ... ok +test wal::visibility::tests::an_uncontended_version_and_my_own_write_are_both_writable ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_created_is_a_conflict ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_deleted_is_a_conflict ... ok +test wal::txn::tests::test_commit_writes_chain_and_flushes ... ok +test wal::txn::tests::test_begin_creates_snapshot ... ok +test wal::txn::tests::torn_tail_trimmed_on_open ... ok +test wal::txn::tests::the_run_identity_record_is_the_last_record_before_its_own_commit ... ok +test wal::txn::tests::sql_insert_commits_through_run ... ok +test wal::txn::tests::transaction_ids_never_start_at_zero ... ok +test storage::atomic_file::tests::concurrent_replaces_of_one_target_never_publish_a_mixture ... ok +test execution::hash_join::tests::hash_join_matches_integer_key_against_float_key ... ok +test optimizer::cost_model::tests::test_join_picks_hash ... ok +test execution::executor::tests::root_split_persists_across_reopen ... ok +test execution::hash_join::tests::nested_loop_and_hash_join_agree_across_integer_and_float_keys ... ok +test optimizer::cost_model::tests::test_cost_index_vs_seq ... ok +test optimizer::cost_model::tests::explain_index_flip_at_volume ... ok + +test result: ok. 843 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 27.17s + +exit=0 diff --git a/.adv-results/sim_durability.full b/.adv-results/sim_durability.full new file mode 100644 index 00000000..c1320a35 --- /dev/null +++ b/.adv-results/sim_durability.full @@ -0,0 +1,82 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s + Running tests/sim_durability.rs (target/debug/deps/sim_durability-00e47465d43b9399) + +running 16 tests +test under_sync_only_an_unflushed_write_does_not_survive ... ok +test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok +test a_torn_write_lands_a_prefix_and_ends_the_run ... ok +test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok +test the_durable_commit_detector_fires_and_stays_quiet ... ok +test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok +test flush_all_writes_pages_in_ascending_page_id_order ... ok +test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok +test two_different_seeds_crash_in_different_places ... ok +test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok +test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok +test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok +test recovery_itself_writes_the_same_sequence_three_times ... ok +test recovery_holds_at_every_fault_point_sync_only ... ok +test recovery_holds_at_every_fault_point_write_through ... ok +test recovery_holds_at_every_fault_point_through_the_catalog ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.53s + +exit=0 diff --git a/tests/adv_f4_atk1_page_without_grant.rs b/tests/adv_f4_atk1_page_without_grant.rs new file mode 100644 index 00000000..2d10b7eb --- /dev/null +++ b/tests/adv_f4_atk1_page_without_grant.rs @@ -0,0 +1,405 @@ +//! ATTACK 1 — obtain an arena extent page or an arena id on a cluster member that has been +//! granted nothing. +//! +//! The claim under attack (commit 4168c16): *"no node in a cluster can obtain an arena extent page, +//! an arena id, a transaction id, or a lease/reap decision without a leader grant."* +//! +//! `tests/integration_cluster_grants.rs` proves that claim for a store that is **fresh** when the +//! process joins. Every test here joins a store that has already done work, which is the state a +//! real node is in when it is added to a cluster, and which is the state +//! `ArenaSpaceManager::recycle_epoch` exists to talk about. +//! +//! Each test asserts the CLAIM, so a failure is the finding and its panic message is the evidence. +//! +//! # Scoping +//! +//! One `ClusterScope::standalone()` is held for the whole of each test and `cluster::join` / +//! `cluster::leave` are called inside it. The scope's lock is what serializes these tests against +//! each other; doing the transition inside one held scope makes standalone-then-member atomic, +//! where dropping a standalone scope and taking a joined one leaves a window in between. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); + +struct Store { + store: Arc, + catalog: Arc, + pool: Arc, + dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, pool, dir } +} + +/// Every page id an extent claim covers. +fn extent_covers(start: u32, page: u32) -> bool { + page >= start && page < start + ARENA_EXTENT_PAGES +} + +// ================================================================================================= +// A1.0 — the control. The guard must fire at all, or nothing below means anything. +// ================================================================================================= + +#[test] +fn a1_0_control_a_fresh_member_refuses() { + let _scope = ClusterScope::standalone(); + let s = store("a1_0"); + cluster::join(N1); + + let err = s.store.arena_for(BranchId::TRUNK).expect_err( + "CONTROL FAILED: a fresh member allocated with no grant. Nothing else in this file is \ + interpretable.", + ); + assert!( + format!("{err}").contains("no leader-granted"), + "control: wrong refusal shape: {err}" + ); +} + +// ================================================================================================= +// A1.1 — `alloc_in_arena` on an extent claimed before the join. +// ================================================================================================= + +/// The extent was self-granted while standalone. The leader does not know this node has it, so it +/// is free to hand the same page range to another node — which is exactly why +/// `ArenaSpaceManager::recycle_epoch` discards the *free-extent stack* across a join. The interior +/// of a still-live extent is the same category of space. +#[test] +fn a1_1_alloc_in_arena_refuses_on_an_extent_self_granted_before_the_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_1"); + + // Standalone: claim one extent and use one page of it, so the bump pointer is mid-extent. + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + let first = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let (start, _) = s.store.extent_range(arena).unwrap(); + assert!(extent_covers(start, first), "fixture: page {first} is outside extent at {start}"); + + // Now this process becomes a cluster member. No grant is applied, ever. + cluster::join(N1); + assert!(cluster::is_clustered(), "fixture: the join did not take"); + + let got = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch); + if let Ok(page) = got { + panic!( + "GUARD FELL: a cluster member with zero grants applied obtained novel extent page \ + {page} (extent at {start}, arena {arena}) via alloc_in_arena. The extent was \ + self-granted while standalone, so no leader knows this node holds it and the leader \ + may grant page {page} to another node." + ); + } +} + +/// `arena_for` returns the cached `current` arena without consulting any counter, so the whole +/// write path keeps allocating after the join until the extent is full. +#[test] +fn a1_2_arena_for_refuses_to_reuse_the_current_arena_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_2"); + + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + + cluster::join(N1); + + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + // Prove it is not merely an id but usable space. + let page = s.store.alloc_in_arena(a, PageType::BTreeLeaf, epoch); + panic!( + "GUARD FELL: arena_for returned arena {a} on a member with no grant (same arena as \ + before the join: {}), and alloc_in_arena then returned {:?}. arena_for reads \ + `current` and the extent's remaining space without asking either GrantedCounter.", + a == a1, + page + ); + } +} + +/// How much space a member with no grant can keep issuing: the whole tail of the extent. +#[test] +fn a1_3_a_member_with_no_grant_cannot_drain_a_pre_join_extent() { + let _scope = ClusterScope::standalone(); + let s = store("a1_3"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let (start, _) = s.store.extent_range(arena).unwrap(); + + cluster::join(N1); + + let mut got = Vec::new(); + for _ in 0..ARENA_EXTENT_PAGES { + match s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch) { + Ok(p) => got.push(p), + Err(_) => break, + } + } + if !got.is_empty() { + panic!( + "GUARD FELL: a member with no grant issued {} extent pages after joining \ + ({:?}..={:?}, extent at {start}). That is {} of one {ARENA_EXTENT_PAGES}-page extent \ + handed out with no leader grant.", + got.len(), + got.first(), + got.last(), + got.len() + ); + } +} + +// ================================================================================================= +// A1.4 — the per-arena recycled list, the other piece of issued space outside the counter. +// ================================================================================================= + +/// `release_page` parks a page in `StoreState::recycled`, and `alloc_in_arena` pops that list +/// *first*, before it even looks at the extent. `recycle_epoch` guards `free_extent_starts`; this +/// list has no epoch stamp at all. +#[test] +fn a1_4_the_per_arena_recycled_list_is_not_reusable_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_4"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + let p = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.release_page(p, arena); + + cluster::join(N1); + + let got = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch); + if let Ok(page) = got { + panic!( + "GUARD FELL: a member with no grant obtained page {page} from the per-arena recycled \ + list (released page was {p}). StoreState::recycled carries no authority epoch, so a \ + page freed under a previous authority is handed straight back out." + ); + } +} + +// ================================================================================================= +// A1.5 — free_arena then re-claim. This is the path `recycle_epoch` was written for. +// ================================================================================================= + +#[test] +fn a1_5_a_freed_extent_is_not_reclaimable_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_5"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(arena).unwrap(); + assert_eq!(s.store.reserved_page_count(), 0, "fixture: the extent was not freed"); + + cluster::join(N1); + + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + panic!( + "GUARD FELL: a member with no grant re-claimed a freed extent as arena {a}. \ + recycle_epoch did not discard the free-extent stack across the join." + ); + } +} + +// ================================================================================================= +// A1.6 — reopen_from_checkpoint on a member. The image carries space no leader granted. +// ================================================================================================= + +/// The checkpoint image contains `free_extent_starts` and the whole `extents` map. `load_state` +/// restores both and then *re-stamps* `recycle_epoch` with the epoch in force now — so the +/// free-extent stack arrives already blessed by the authority that never granted it. +#[test] +fn a1_6_a_member_cannot_get_space_out_of_a_checkpoint_image() { + let _scope = ClusterScope::standalone(); + let s = store("a1_6"); + let path = s.dir.path().join("arena.ckpt"); + + // Standalone: two extents, one of them freed so the free stack is non-empty, then checkpoint. + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + let forked = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX / 2)).unwrap(); + let a2 = s.store.arena_for(forked.branch_id).unwrap(); + s.store.alloc_in_arena(a2, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(a2).unwrap(); + s.store.checkpoint(&path).unwrap(); + + // The process joins a cluster, and *then* the store is reopened from that image. + cluster::join(N1); + + let reopened = ArenaPageStore::reopen_from_checkpoint( + Arc::clone(&s.pool), + Arc::clone(&s.catalog), + &path, + ) + .unwrap(); + + // The restored extents are in the map with their bump pointers intact. + let live = reopened.live_arenas(); + assert!(!live.is_empty(), "fixture: the image restored no extents"); + + let mut fell = Vec::new(); + for (arena, _owner) in &live { + if let Ok(page) = reopened.alloc_in_arena(*arena, PageType::BTreeLeaf, epoch) { + fell.push((*arena, page)); + } + } + if !fell.is_empty() { + panic!( + "GUARD FELL: a member with no grant obtained {:?} (arena, page) out of a checkpoint \ + image via alloc_in_arena on restored extents. load_state restores `extents` with no \ + authority epoch.", + fell + ); + } +} + +// ================================================================================================= +// A1.7 — concurrency. Threads racing the refusal must all refuse. +// ================================================================================================= + +#[test] +fn a1_7_threads_racing_arena_for_on_a_fresh_member_all_refuse() { + let _scope = ClusterScope::standalone(); + let s = store("a1_7"); + cluster::join(N1); + + let mut handles = Vec::new(); + for _ in 0..16 { + let store = Arc::clone(&s.store); + handles.push(std::thread::spawn(move || { + let mut ok = Vec::new(); + for _ in 0..64 { + if let Ok(a) = store.arena_for(BranchId::TRUNK) { + ok.push(a); + } + } + ok + })); + } + let issued: Vec<_> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect(); + assert!( + issued.is_empty(), + "GUARD FELL: {} arenas were issued to a member with no grant under concurrency: {:?}", + issued.len(), + issued + ); + assert_eq!(s.store.reserved_page_count(), 0, "refused claims still reserved pages"); +} + +/// The epoch `ArenaSpaceManager::reserve` passes to `recycled_start` is read before the take, so a +/// `join` landing in between is evaluated against a stale epoch. Racing it here to see whether the +/// free stack survives a join it should have been cleared by. +#[test] +fn a1_8_racing_a_join_against_reserve_cannot_smuggle_a_recycled_page() { + let _scope = ClusterScope::standalone(); + let mut smuggled = Vec::new(); + + for round in 0..200 { + let s = store(&format!("a1_8_{round}")); + cluster::leave(); + // Standalone: build a free-extent stack, then grant the arena-id counter enough room that + // the *only* thing standing between a member and a page is the recycle-stack epoch check. + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(arena).unwrap(); + + let store = Arc::clone(&s.store); + let racer = std::thread::spawn(move || store.arena_for(BranchId::TRUNK)); + cluster::join(N1); + let got = racer.join().unwrap(); + if let Ok(a) = got { + // Either the join had not landed yet (legitimate) or the stale epoch let it through. + // Distinguish by asking whether we are clustered now and whether the arena id could + // only have come from a self-grant. + smuggled.push((round, a, cluster::is_clustered())); + } + cluster::leave(); + } + + // A win for the racer is only interesting if it happened while clustered. Reported either way: + // the point of this test is the number, not a boolean. + let while_clustered: Vec<_> = smuggled.iter().filter(|(_, _, c)| *c).collect(); + println!( + "a1_8: {} of 200 rounds returned an arena to the racing thread; {} of those with \ + is_clustered() true at the join point", + smuggled.len(), + while_clustered.len() + ); +} + +// ================================================================================================= +// A1.9 — the real COW write path, not `alloc_in_arena` called directly. +// ================================================================================================= + +/// `cow_page` is what an actual write goes through. It calls `arena_for` (which returns the cached +/// `current` arena) and then `alloc_in_arena` for the copy, so a member with no grant keeps +/// shadowing pages after the join. +#[test] +fn a1_9_the_cow_write_path_refuses_on_a_member_with_no_grant() { + let _scope = ClusterScope::standalone(); + let s = store("a1_9"); + + // Standalone: TRUNK owns arena a1 with two pages; a child branch owns a2. + let trunk_arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let e0 = s.catalog.next_epoch(); + let inherited_a = s.store.alloc_in_arena(trunk_arena, PageType::BTreeLeaf, e0).unwrap(); + let inherited_b = s.store.alloc_in_arena(trunk_arena, PageType::BTreeLeaf, e0).unwrap(); + + let child = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX / 2)).unwrap().branch_id; + let e1 = s.catalog.next_epoch(); + let child_arena = s.store.arena_for(child).unwrap(); + assert_ne!(child_arena, trunk_arena, "fixture: the child did not get its own arena"); + + // Prove the COW path copies (rather than mutating in place) before the join. + let pre = s.store.cow_page(inherited_a, child, e1).unwrap(); + assert!(pre.copied, "fixture: the COW did not copy, so no page was allocated"); + + // Now join. No grant, ever. + cluster::join(N1); + + let got = s.store.cow_page(inherited_b, child, e1); + match got { + Ok(cow) if cow.copied => panic!( + "GUARD FELL: the real COW write path allocated page {} on a cluster member with zero \ + grants applied (copying inherited page {inherited_b} into arena {child_arena}). \ + cow_page -> arena_for -> alloc_in_arena reaches no GrantedCounter while the current \ + extent has room.", + cow.page_id + ), + Ok(_) => panic!("the COW mutated in place; the fixture no longer exercises allocation"), + Err(_) => {} + } +} diff --git a/tests/adv_f4_atk2_two_authorities.rs b/tests/adv_f4_atk2_two_authorities.rs new file mode 100644 index 00000000..ffcbcb3c --- /dev/null +++ b/tests/adv_f4_atk2_two_authorities.rs @@ -0,0 +1,429 @@ +//! ATTACK 2 — make two different authorities issue the same page id, arena id or txn id, and probe +//! the grant bookkeeping for orders it gets wrong. +//! +//! Two authorities exist in one process the moment `join` or `leave` is called: the node's own +//! standalone self-grant is one, the leader that grants it a range afterwards is the other. Ranges +//! are disjoint by construction, so the question this file asks is whether the *bookkeeping* that +//! makes them disjoint — `Grants::accepted_through`, `Grants::issued`, and the authority epoch — +//! survives the transitions. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have and are expected to pass. +//! Tests that assert the claim and fail are the findings; the panic message is the evidence. +//! +//! `GrantedCounter` is exercised directly wherever the question is about the rule rather than the +//! wiring: it is `pub`, and driving it directly is the only way to reach an ordering that a leader +//! loop would have to be buggy to produce but that a re-delivered log suffix produces for free. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, Applied, ClusterScope, GrantError, GrantedCounter}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); +const N2: NodeId = NodeId(2); + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +struct Engine { + txn: Arc, + _dir: tempfile::TempDir, +} + +fn engine(tag: &str) -> Engine { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join(format!("{tag}.wal"))).unwrap()); + let txn = Arc::new(TxnManager::new(Arc::clone(&wal), Arc::clone(&bp))); + bp.attach_wal(wal); + Engine { txn, _dir: dir } +} + +/// Drain a counter until it refuses, collecting every value it issued. +fn drain(c: &GrantedCounter, n: u64, cap: usize) -> Vec { + let mut out = Vec::new(); + while out.len() < cap { + match c.take(n) { + Ok(v) => out.push(v), + Err(_) => break, + } + } + out +} + +// ================================================================================================= +// A2.1 — a grant applied before the join. +// ================================================================================================= + +#[test] +fn a2_1_a_grant_applied_before_the_join_is_refused_and_does_not_survive_it_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 100, 8); + + // Standalone: the grant is refused outright. + match c.apply_grant(N1, 1_000, 2_000) { + Err(GrantError::NotClustered { .. }) => {} + other => panic!("a standalone node accepted an outside range: {other:?}"), + } + + // And it left nothing behind that the join could bless. + cluster::join(N1); + assert_eq!(c.remaining(), 0, "a refused pre-join grant left usable space behind"); + assert!(matches!(c.take(1), Err(GrantError::Exhausted { .. })), "member issued after a refusal"); +} + +// ================================================================================================= +// A2.2 — join / leave / join. Does a granted range come back to life? +// ================================================================================================= + +#[test] +fn a2_2_a_range_does_not_survive_leave_then_rejoin_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + + cluster::join(N1); + assert!(matches!(c.apply_grant(N1, 100, 200), Ok(Applied::Accepted { usable: 100 }))); + assert_eq!(c.take(1).unwrap(), 100); + + cluster::leave(); + // Standalone now, so it self-grants from its own watermark. It must NOT issue from [100, 200) + // as though the departed leader's grant were still its own... but it may legitimately + // self-grant above `accepted_through`, which is 200. + let solo = c.take(1).unwrap(); + assert!(solo >= 200, "a standalone node re-issued from a departed leader's range: {solo}"); + + cluster::join(N1); + assert_eq!(c.remaining(), 0, "a rejoin resurrected a range"); + assert!(matches!(c.take(1), Err(GrantError::Exhausted { .. })), "a rejoin resurrected a range"); +} + +// ================================================================================================= +// A2.3 — no value is ever issued twice, across every transition order this file can build. +// ================================================================================================= + +#[test] +fn a2_3_no_value_is_issued_twice_across_a_transition_storm_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 7); + let mut seen: Vec = Vec::new(); + + for round in 0..40u64 { + match round % 4 { + 0 => cluster::leave(), + 1 => cluster::join(N1), + 2 => { + let _ = c.apply_grant(N1, round * 3, round * 3 + 50); + } + _ => cluster::join(N1), + } + // Grants that straddle, precede and follow the watermark, plus the same one twice. + let _ = c.apply_grant(N1, 10, 60); + let _ = c.apply_grant(N1, 10, 60); + let _ = c.apply_grant(N1, 0, 5); + seen.extend(drain(&c, 1, 12)); + let _ = c.apply_grant(N1, 30, 500); + seen.extend(drain(&c, 3, 4)); + } + + let mut sorted = seen.clone(); + sorted.sort_unstable(); + let before = sorted.len(); + sorted.dedup(); + assert_eq!( + before, + sorted.len(), + "GUARD FELL: a value was issued twice by one counter across authority transitions. \ + issued: {seen:?}" + ); +} + +// ================================================================================================= +// A2.4 — two grants delivered out of order. The lower range is swallowed. +// ================================================================================================= + +/// `accepted_through` is a single scalar and doubles as the duplicate-detector, so a grant whose +/// `hi` is at or below it is reported `Duplicate` regardless of whether its values were ever +/// granted to this node. Delivery order of two committed entries is not something a follower +/// controls. +#[test] +fn a2_4_a_lower_range_delivered_after_a_higher_one_is_not_lost() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + assert!(matches!(c.apply_grant(N1, 2_000, 3_000), Ok(Applied::Accepted { usable: 1_000 }))); + let second = c.apply_grant(N1, 1_000, 2_000); + + assert!( + matches!(second, Ok(Applied::Accepted { .. })), + "GUARD FELL: the leader granted [1000, 2000) to {N1} and the node answered {second:?}. \ + 1000 values the leader believes this node owns are unusable for ever, and the leader was \ + told 'Duplicate', which reads as 'already have it'. remaining() = {} (only the higher \ + range).", + c.remaining() + ); +} + +// ================================================================================================= +// A2.5 — the standalone-then-join deadlock. `accepted_through` outlives the epoch bump. +// ================================================================================================= + +/// `join` bumps the epoch and `evict_stale` drops the *held ranges*, but `accepted_through` and +/// `issued` are untouched. A node that self-granted while standalone therefore refuses every grant +/// the leader makes below that stale mark — as `Duplicate`, so the leader is told it already has it +/// and never grants again. +#[test] +fn a2_5_a_leader_can_still_supply_a_node_that_self_granted_while_standalone() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("extent-start", 1_024, ARENA_EXTENT_PAGES as u64 * 4); + + // Standalone: one extent's worth issued. The self-grant chunk covers four, so + // accepted_through is now 1024 + 4*256 = 2048 while only 1024..1280 was ever issued. + let first = c.take(ARENA_EXTENT_PAGES as u64).unwrap(); + assert_eq!(first, 1_024); + assert_eq!(c.issued_through(), 1_280, "fixture: watermark is not where fetch_add left it"); + + cluster::join(N1); + + // The leader knows nothing of the self-grant. It grants this node a fresh region that happens + // to sit below the stale accepted_through — which, from the leader's side, is simply "the + // region I have not handed out yet". + let applied = c.apply_grant(N1, 1_280, 2_048); + let after = c.take(ARENA_EXTENT_PAGES as u64); + + assert!( + after.is_ok(), + "GUARD FELL (liveness): apply_grant([1280, 2048)) returned {applied:?} and the node then \ + refused to allocate: {after:?}. accepted_through = 2048 from a standalone self-grant \ + survived the epoch bump, so every grant below it is swallowed as Duplicate and the node \ + refuses for ever while the leader believes it is supplied." + ); +} + +// ================================================================================================= +// A2.6 — the same grant twice with a take in between, and a straddling grant. +// ================================================================================================= + +#[test] +fn a2_6_redelivery_and_straddling_grants_never_re_offer_an_issued_value_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + c.apply_grant(N1, 100, 200).unwrap(); + let a = c.take(1).unwrap(); + assert_eq!(a, 100); + + // Re-delivered in full: must be a no-op. + assert_eq!(c.apply_grant(N1, 100, 200).unwrap(), Applied::Duplicate); + // Straddling the watermark: only the suffix may be usable. + c.apply_grant(N1, 150, 300).unwrap(); + + let rest = drain(&c, 1, 1_000); + let mut all = vec![a]; + all.extend(rest); + let mut sorted = all.clone(); + sorted.sort_unstable(); + let n = sorted.len(); + sorted.dedup(); + assert_eq!(n, sorted.len(), "GUARD FELL: a value was issued twice: {all:?}"); + assert!( + all.iter().all(|&v| (100..300).contains(&v)), + "a value outside every granted range was issued: {all:?}" + ); +} + +// ================================================================================================= +// A2.7 — a grant addressed to another node. +// ================================================================================================= + +#[test] +fn a2_7_a_grant_for_another_node_is_refused_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + match c.apply_grant(N2, 500, 600) { + Err(GrantError::WrongNode { granted_to, self_id, .. }) => { + assert_eq!((granted_to, self_id), (N2, N1)); + } + other => panic!("GUARD FELL: {N1} accepted a range addressed to {N2}: {other:?}"), + } + assert_eq!(c.remaining(), 0, "a refused foreign grant left usable space"); + // And it did not poison the dedup mark either: the same range addressed correctly must work. + assert!(matches!(c.apply_grant(N1, 500, 600), Ok(Applied::Accepted { usable: 100 }))); +} + +// ================================================================================================= +// A2.8 — the one that matters: the SAME PAGE ID from two authorities, end to end. +// ================================================================================================= + +/// Node A self-grants page P while standalone and writes it. The process then joins, and a leader +/// with no knowledge of that self-grant hands the region containing P to a node — which issues P +/// again. Both stores use the same `ARENA_BASE`, i.e. the same physical page-id space, which is +/// what "two nodes on one region" means. +/// +/// The `recycle_epoch` doc says this shape is the reason the epoch exists. It evicts the *counter's* +/// held ranges; it does not un-issue what standalone already wrote. +#[test] +fn a2_8_a_page_written_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + // ---- authority 1: this node, its own leader ---- + let a = store("a2_8_solo"); + let arena_a = a.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = a.catalog.next_epoch(); + let mut solo_pages = Vec::new(); + for _ in 0..4 { + solo_pages.push(a.store.alloc_in_arena(arena_a, PageType::BTreeLeaf, epoch).unwrap()); + } + let (solo_start, _) = a.store.extent_range(arena_a).unwrap(); + + // ---- authority 2: a leader, which has never heard of the above ---- + cluster::join(N1); + let b = store("a2_8_member"); + // The leader's view of free space: the region base onwards. Exactly what a leader that was + // never told about a pre-join self-grant would propose. + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let arena_b = b.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch_b = b.catalog.next_epoch(); + let mut member_pages = Vec::new(); + for _ in 0..4 { + member_pages.push(b.store.alloc_in_arena(arena_b, PageType::BTreeLeaf, epoch_b).unwrap()); + } + + let collide: Vec = + solo_pages.iter().copied().filter(|p| member_pages.contains(p)).collect(); + assert!( + collide.is_empty(), + "GUARD FELL: page(s) {collide:?} were issued by TWO authorities — self-granted and \ + written by a standalone node (extent at {solo_start}, arena {arena_a}), then granted to \ + {N1} by a leader and issued again (arena {arena_b}). Standalone pages: {solo_pages:?}; \ + granted pages: {member_pages:?}. Every one of those pages passes its own checksum on \ + both nodes." + ); +} + +/// The same collision for arena ids, which is the one `BranchRecord::arenas` points at and the +/// reaper frees whole. +#[test] +fn a2_9_an_arena_id_used_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + let a = store("a2_9_solo"); + let mut solo = Vec::new(); + for _ in 0..3 { + let arena = a.store.arena_for(BranchId::TRUNK).unwrap(); + solo.push(arena); + let e = a.catalog.next_epoch(); + // Fill the extent so the next arena_for claims a fresh one. + while a.store.alloc_in_arena(arena, PageType::BTreeLeaf, e).is_ok() {} + } + + cluster::join(N1); + let b = store("a2_9_member"); + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let member = b.store.arena_for(BranchId::TRUNK).unwrap(); + + assert!( + !solo.contains(&member), + "GUARD FELL: arena id {member} was issued both by a standalone self-grant ({solo:?}) and \ + by a leader grant to {N1}. BranchRecord::arenas then names one arena for two branches, \ + and the reaper frees exactly record.arenas." + ); +} + +// ================================================================================================= +// A2.10 — the same collision on txn ids, through the real TxnManager. +// ================================================================================================= + +#[test] +fn a2_10_a_txn_id_issued_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + let a = engine("a2_10_solo"); + let mut solo = Vec::new(); + for _ in 0..10 { + solo.push(a.txn.begin().unwrap()); + } + + cluster::join(N1); + let b = engine("a2_10_member"); + // A leader seeding a fresh cluster from the bottom of the id space. + b.txn.apply_txn_id_grant(N1, 1, 1_000).unwrap(); + let mut member = Vec::new(); + for _ in 0..10 { + member.push(b.txn.begin().unwrap()); + } + + let collide: Vec = solo.iter().copied().filter(|t| member.contains(t)).collect(); + assert!( + collide.is_empty(), + "GUARD FELL: txn id(s) {collide:?} were issued by two authorities. solo={solo:?} \ + member={member:?}. The TEL's stamp() leads with TxnId to order writes across branches, so \ + a duplicate corrupts merge ordering, which the merge engine cannot detect." + ); +} + +/// The same `accepted_through` deadlock as A2.5, through `TxnManager`: a node that begins +/// transactions standalone and then joins refuses every grant below its stale mark. +#[test] +fn a2_11_a_leader_can_supply_txn_ids_to_a_node_that_ran_standalone_first() { + let _scope = ClusterScope::standalone(); + let e = engine("a2_11"); + for _ in 0..10 { + e.txn.begin().unwrap(); + } + let watermark = e.txn.next_txn_id(); + assert_eq!(watermark, 11, "fixture: the watermark moved somewhere unexpected"); + + cluster::join(N1); + // The self-grant chunk is 256, so accepted_through is 257 while only 11 ids were issued. A + // leader granting the next hundred ids after the watermark lands entirely below it. + let applied = e.txn.apply_txn_id_grant(N1, watermark, watermark + 100); + let began = e.txn.begin(); + assert!( + began.is_ok(), + "GUARD FELL (liveness): apply_txn_id_grant({N1}, {watermark}, {}) returned {applied:?} \ + and begin() then failed: {began:?}. The node will not begin a transaction and the leader \ + has been told the range was a duplicate.", + watermark + 100 + ); +} diff --git a/tests/adv_f4_atk3_lease_clock.rs b/tests/adv_f4_atk3_lease_clock.rs new file mode 100644 index 00000000..6a702a6d --- /dev/null +++ b/tests/adv_f4_atk3_lease_clock.rs @@ -0,0 +1,302 @@ +//! ATTACK 3 — make a reap decision come from a wall clock on a cluster member, make two members +//! disagree about a deadline, and make `LeaseDeadline::from_now` return a wrong value instead of +//! panicking. +//! +//! Reaping frees a branch's arenas and then bumps its `BranchId` generation, which exists precisely +//! so a reaped id can never be mistaken for a live one — so a wrong reap is **unrecoverable**. The +//! three ways to get one are: read the wrong clock, disagree with a peer about the same clock, or +//! compute the wrong number from the right clock. One test each, plus the availability question the +//! panic raises. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have. A failure elsewhere is the +//! finding, and its panic message is the evidence. + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope, GrantError}; +use ferrodb::consensus::NodeId; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); +const N2: NodeId = NodeId(2); + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +// ================================================================================================= +// A3.1 — is there any infallible clock left that a reap can reach? +// ================================================================================================= + +/// `reap_expired` takes `now_millis` as a parameter and `is_expired_at` is a pure comparison, so +/// the reaper itself cannot read a clock. This pins that: a member with no tick has no way to +/// produce the argument. +#[test] +fn a3_1_a_member_with_no_tick_cannot_produce_a_reap_clock_holds() { + let _scope = ClusterScope::standalone(); + cluster::join(N1); + + match LeaseDeadline::try_now_millis() { + Err(GrantError::NoClusterTime { node }) => assert_eq!(node, N1), + other => panic!("GUARD FELL: a member with no tick answered the time: {other:?}"), + } + assert!(LeaseDeadline::try_from_now(1).is_err()); + assert!(LeaseDeadline(0).is_expired_now().is_err(), "is_expired_now answered with no tick"); + assert!(LeaseDeadline(u64::MAX).is_expired_now().is_err()); +} + +// ================================================================================================= +// A3.2 — the production paths that still call the PANICKING clock. +// ================================================================================================= + +/// `AgentRuntime::begin_session_with_model` — production code, not a test — calls +/// `LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS)` (`src/agent_sql/runtime.rs:650`), and +/// `AgentRuntime::simulate` calls `LeaseDeadline::from_now(plan.lease_millis)` +/// (`src/agent_sql/simulate.rs:431`). Neither has a fallible variant on its path. +/// +/// The claim is that the refusal is a refusal. This asks whether the *engine* refuses or aborts. +#[test] +fn a3_2_beginning_an_agent_session_on_a_member_with_no_tick_returns_an_error() { + let _scope = ClusterScope::standalone(); + let rt = AgentRuntime::new(); + // Prove the fixture works before the join, so a failure after it is about the clock. + rt.begin_session("probe-agent", None, BranchId::TRUNK) + .expect("fixture: begin_session must work standalone"); + + cluster::join(N1); + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + rt.begin_session("probe-agent", None, BranchId::TRUNK) + })); + match outcome { + Ok(Ok(_)) => panic!("GUARD FELL: a member with no tick minted a lease deadline anyway"), + Ok(Err(_)) => {} // the good case: a refusal reached the caller + Err(_) => panic!( + "GUARD FELL (availability): AgentRuntime::begin_session PANICKED on a cluster member \ + with no applied LeaseTick instead of returning Err. This is production code \ + (src/agent_sql/runtime.rs:650, before the file's #[cfg(test)] at line 4291) on the \ + path of every agent session, FORK and SIMULATE. A node that joins a cluster before \ + its first LeaseTick aborts the process on the first agent statement rather than \ + refusing it." + ), + } +} + +// ================================================================================================= +// A3.3 — two members, the same ticks, delivered in different orders. +// ================================================================================================= + +/// `fold_tick` is `max`, which is commutative and idempotent, so a re-delivered or reordered suffix +/// of the same committed ticks lands on the same value everywhere. +#[test] +fn a3_3_the_same_ticks_in_any_order_give_the_same_time_holds() { + let _scope = ClusterScope::standalone(); + let ticks = [1_700_000_000_000u64, 1_700_000_000_500, 1_700_000_001_000]; + + let mut answers = Vec::new(); + // Every permutation, plus each one re-delivered. + for perm in [[0, 1, 2], [2, 1, 0], [1, 2, 0], [0, 2, 1], [2, 0, 1], [1, 0, 2]] { + cluster::leave(); + cluster::join(N1); + for i in perm { + cluster::apply_lease_tick(ticks[i]).unwrap(); + cluster::apply_lease_tick(ticks[i]).unwrap(); // re-delivery + } + answers.push(LeaseDeadline::try_from_now(60_000).unwrap()); + } + let first = answers[0]; + assert!( + answers.iter().all(|d| *d == first), + "GUARD FELL: two members that applied the same ticks in different orders computed \ + different deadlines: {answers:?}" + ); + assert_eq!(first.0, ticks[2] + 60_000, "the deadline is not tick + millis"); +} + +/// A node cannot be told the time by anything other than a tick, and a `leave` forgets it. +#[test] +fn a3_4_a_standalone_node_refuses_a_tick_and_a_leave_forgets_one_holds() { + let _scope = ClusterScope::standalone(); + assert!(matches!( + cluster::apply_lease_tick(1_700_000_000_000), + Err(GrantError::NotClustered { .. }) + )); + + cluster::join(N1); + cluster::apply_lease_tick(1_700_000_000_000).unwrap(); + assert_eq!(LeaseDeadline::try_now_millis().unwrap(), 1_700_000_000_000); + + cluster::leave(); + cluster::join(N2); + assert!( + LeaseDeadline::try_now_millis().is_err(), + "GUARD FELL: a rejoin kept the previous authority's clock" + ); +} + +// ================================================================================================= +// A3.5 — one bad tick, and the clock can never come back. +// ================================================================================================= + +/// `apply_lease_tick` takes any `u64` with no upper bound and folds it with `max`, so a single +/// out-of-range tick sets the cluster clock to that value **for ever**: monotonicity means no later +/// correct tick can bring it back. Every lease in the cluster is then expired, and reaping is +/// destructive and unrecoverable. +#[test] +fn a3_5_one_absurd_tick_does_not_permanently_expire_every_lease() { + let _scope = ClusterScope::standalone(); + let s = store("a3_5"); + let reaper = TwoTierReaper::new(Arc::clone(&s.catalog), Arc::clone(&s.store)); + + cluster::join(N1); + // A sane tick, and a branch with a long lease that is nowhere near expiry. + let real = 1_700_000_000_000u64; + cluster::apply_lease_tick(real).unwrap(); + let b = s.catalog.fork(BranchId::TRUNK, LeaseDeadline::try_from_now(86_400_000).unwrap()).unwrap(); + assert!( + reaper.reap_expired(LeaseDeadline::try_now_millis().unwrap()).unwrap().is_empty(), + "fixture: the branch was already expired" + ); + + // One tick from a buggy proposer, an unvalidated field, or a sign/units error. + cluster::apply_lease_tick(u64::MAX).unwrap(); + + // The cluster is told the correct time again, repeatedly. + for _ in 0..5 { + cluster::apply_lease_tick(real + 1_000).unwrap(); + } + let now = LeaseDeadline::try_now_millis().unwrap(); + let reaped = reaper.reap_expired(now).unwrap(); + + assert!( + reaped.is_empty() && now != u64::MAX, + "GUARD FELL: one tick of u64::MAX left the cluster clock at {now} and no later correct \ + tick could lower it (fold_tick is max). reap_expired then reaped {reaped:?} — the live \ + branch {:?} among them. Reaping bumps the BranchId generation, so this is unrecoverable, \ + and apply_lease_tick validates no bound on its input.", + b.branch_id + ); +} + +/// The mirror image: a tick far in the future makes two members disagree permanently, because the +/// one that saw it can never come back down to the one that did not. +#[test] +fn a3_6_a_spurious_future_tick_does_not_permanently_diverge_two_members() { + let _scope = ClusterScope::standalone(); + let real = 1_700_000_000_000u64; + + // Member A applies the committed ticks plus one spurious future entry — an uncommitted suffix + // a new leader later replaces, which consensus permits and this state machine cannot un-apply. + cluster::join(N1); + cluster::apply_lease_tick(real).unwrap(); + cluster::apply_lease_tick(real + 999_999_999).unwrap(); + cluster::apply_lease_tick(real + 1_000).unwrap(); + let a = LeaseDeadline::try_now_millis().unwrap(); + + // Member B applies only the committed ticks. + cluster::leave(); + cluster::join(N2); + cluster::apply_lease_tick(real).unwrap(); + cluster::apply_lease_tick(real + 1_000).unwrap(); + let b = LeaseDeadline::try_now_millis().unwrap(); + + assert_eq!( + a, b, + "GUARD FELL: after applying the same committed ticks, member A reads {a} and member B \ + reads {b} — a permanent {} ms divergence, because fold_tick is max and there is no way \ + to retract an applied tick. Exit criterion 9 is that two nodes never disagree here.", + a - b + ); +} + +// ================================================================================================= +// A3.7 — a wrong value out of `from_now` rather than a panic. +// ================================================================================================= + +/// `from_now` is `now.saturating_add(millis)`. Saturation is not a refusal: it turns a finite lease +/// into one that can never expire, which "silently stops reaping and defeats exit criterion 8 with +/// no symptom" — the exact outcome the panic doc says it refuses to produce. +#[test] +fn a3_7_from_now_does_not_silently_saturate_into_a_lease_that_never_expires() { + let _scope = ClusterScope::standalone(); + cluster::join(N1); + cluster::apply_lease_tick(1_700_000_000_000).unwrap(); + + let d = LeaseDeadline::try_from_now(u64::MAX).unwrap(); + assert_ne!( + d.0, + u64::MAX, + "GUARD FELL: try_from_now(u64::MAX) saturated to a deadline of u64::MAX, which \ + is_expired_at can never report as expired for any reachable clock. from_now's own \ + documentation calls a value in the future the outcome that 'silently stops reaping and \ + defeats exit criterion 8 with no symptom', and returns it here rather than refusing." + ); +} + +/// A lease minted while standalone is an absolute unix-millis deadline. The cluster clock is +/// whatever a `LeaseTick` said, and `apply_lease_tick` validates no relationship to unix time at +/// all — so a cluster ticking a logical counter evaluates pre-join leases against a different time +/// base. +#[test] +fn a3_8_a_lease_minted_standalone_is_still_evaluated_against_a_comparable_clock() { + let _scope = ClusterScope::standalone(); + let s = store("a3_8"); + let reaper = TwoTierReaper::new(Arc::clone(&s.catalog), Arc::clone(&s.store)); + + // Standalone: a branch whose lease expired an hour ago by the wall clock. + let wall = LeaseDeadline::now_millis(); + let b = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(wall - 3_600_000)).unwrap(); + assert_eq!( + reaper.reap_expired(wall).unwrap(), + vec![b.branch_id], + "fixture: the expired branch was not reaped standalone" + ); + + // A second, identical branch, and this time the process joins a cluster whose leader ticks a + // logical clock — nothing in `apply_lease_tick` says the argument must be unix millis. + let s2 = store("a3_8b"); + let reaper2 = TwoTierReaper::new(Arc::clone(&s2.catalog), Arc::clone(&s2.store)); + let b2 = s2.catalog.fork(BranchId::TRUNK, LeaseDeadline(wall - 3_600_000)).unwrap(); + cluster::join(N1); + cluster::apply_lease_tick(1).unwrap(); + cluster::apply_lease_tick(2).unwrap(); + let reaped = reaper2.reap_expired(LeaseDeadline::try_now_millis().unwrap()).unwrap(); + + assert_eq!( + reaped, + vec![b2.branch_id], + "GUARD FELL: a branch that expired an hour ago (deadline {}, wall clock {wall}) is not \ + reaped on a cluster member, because the cluster clock is {} — apply_lease_tick accepts \ + any u64 and never checks it against unix time. Exit criterion 8 is silently off for \ + every lease minted before the join.", + wall - 3_600_000, + LeaseDeadline::try_now_millis().unwrap() + ); +} diff --git a/tests/adv_f4_atk4_locks_and_ranges.rs b/tests/adv_f4_atk4_locks_and_ranges.rs new file mode 100644 index 00000000..0313f908 --- /dev/null +++ b/tests/adv_f4_atk4_locks_and_ranges.rs @@ -0,0 +1,341 @@ +//! ATTACK 4 — the lock order, and the shape of a grant range. +//! +//! Two questions. +//! +//! **The lock order.** `cluster::authority_at()` takes a process mutex, `GrantedCounter::take` +//! takes a counter mutex, and `TxnManager::begin` holds the active-transaction table across both. +//! Three locks and a documented claim that the counter lock "is a leaf: nothing inside it calls +//! out". This hammers all three from many threads while the authority changes underneath them. +//! +//! **The range.** `ArenaPageStore::load_state` refuses an image whose `base_page` is not this +//! store's, because grafting another region's map on "would silently graft another arena's extents +//! onto this one's space". `apply_arena_grant` has no equivalent check, so these ask what a leader +//! can make a node allocate by proposing an odd range. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope, GrantError, GrantedCounter}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::PageStore; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); + +struct Store { + store: Arc, + _catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, _catalog: catalog, _dir: dir } +} + +fn txn_manager(tag: &str) -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join(format!("{tag}.wal"))).unwrap()); + let txn = Arc::new(TxnManager::new(Arc::clone(&wal), Arc::clone(&bp))); + bp.attach_wal(wal); + (txn, dir) +} + +// ================================================================================================= +// A4.1 — the three-lock deadlock hunt. +// ================================================================================================= + +/// Every lock in the chain, contended, while `join`/`leave` change the authority underneath. +/// +/// A deadlock here would hang the test binary rather than fail it, so progress is measured from +/// outside: each worker bumps a shared counter, and the test fails if the total stops advancing +/// while workers are still supposed to be running. That is the only signal that distinguishes +/// "wedged" from "slow" — a live thread count cannot. +#[test] +fn a4_1_hammering_authority_counter_and_att_locks_does_not_deadlock_holds() { + let _scope = ClusterScope::standalone(); + let (txn, _dir) = txn_manager("a4_1"); + let s = store("a4_1_arena"); + let counter = Arc::new(GrantedCounter::new("probe", 0, 16)); + + let stop = Arc::new(AtomicBool::new(false)); + let progress = Arc::new(AtomicU64::new(0)); + let mut handles = Vec::new(); + + // 1. begin(): holds `att`, then takes the process lock, then the counter lock. + for _ in 0..4 { + let (txn, stop, progress) = (Arc::clone(&txn), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let _ = txn.begin(); + let _ = txn.begin_snapshot_read(); + let _ = txn.next_txn_id(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 2. the bare counter: process lock then counter lock, no `att`. + for _ in 0..4 { + let (c, stop, progress) = + (Arc::clone(&counter), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let _ = c.take(1); + let _ = c.apply_grant(N1, 0, 4_096); + let _ = c.remaining(); + let _ = c.issued_through(); + c.raise_issued_through(1); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 3. the arena store: free-extent mutex, state mutex, two counters. + for _ in 0..4 { + let (st, stop, progress) = (Arc::clone(&s.store), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + if let Ok(a) = st.arena_for(BranchId::TRUNK) { + let _ = st.free_arena(a); + } + let _ = st.apply_arena_grant(N1, ARENA_BASE, 4 * ARENA_EXTENT_PAGES); + let _ = st.grantable_extents(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 4. the authority itself, changing under all of the above. + for _ in 0..2 { + let (stop, progress) = (Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + cluster::join(N1); + let _ = cluster::authority_at(); + let _ = cluster::apply_lease_tick(1_700_000_000_000); + cluster::leave(); + let _ = cluster::epoch(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + + // Watch for a stall. Poll on the artifact — the progress counter — not on a timer alone. + let mut last = 0u64; + let mut stalls = 0; + for round in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + let now = progress.load(Ordering::Relaxed); + if now == last { + stalls += 1; + assert!( + stalls < 8, + "GUARD FELL (deadlock): progress stopped at {now} operations after round {round} \ + and did not advance for 8 consecutive 50 ms polls with 14 workers running." + ); + } else { + stalls = 0; + } + last = now; + } + stop.store(true, Ordering::Relaxed); + + // Every worker must be joinable. A worker still holding a lock never returns from join(). + for h in handles { + h.join().expect("a worker panicked"); + } + let total = progress.load(Ordering::Relaxed); + assert!(total > 1_000, "the stress loop barely ran ({total} ops); this proves nothing"); + println!("a4_1: {total} operations across 14 threads, no stall"); +} + +// ================================================================================================= +// A4.2 — what a leader can make a node allocate. +// ================================================================================================= + +/// A grant that reaches below the store's own region. `load_state` refuses a foreign `base_page`; +/// `apply_arena_grant` performs no such check, so the only thing standing in the way is that the +/// extent counter is *seeded* at `base_page`, which makes `Grants::apply_grant` clamp `lo` up. +#[test] +fn a4_2_a_grant_below_the_region_base_cannot_produce_an_extent_outside_it_holds() { + let _scope = ClusterScope::standalone(); + let s = store("a4_2"); + cluster::join(N1); + + // A leader that thinks the file starts at page 0 — where the heap and index pages live. The + // range must STRADDLE the base, or the whole grant is swallowed as a duplicate (the counter is + // seeded at `base_page`, so `accepted_through` starts at 1024) and this test would pass by + // allocating nothing at all. + s.store.apply_arena_grant(N1, 0, 8 * ARENA_EXTENT_PAGES).unwrap(); + let mut produced = Vec::new(); + let mut outside = Vec::new(); + for _ in 0..8 { + match s.store.arena_for(BranchId::TRUNK) { + Ok(a) => { + if let Some((start, count)) = s.store.extent_range(a) { + produced.push((a, start)); + if start < ARENA_BASE { + outside.push((a, start, count)); + } + } + } + Err(_) => break, + } + } + // Force the detector to fire: a pass is only meaningful if extents were actually allocated + // from a grant that reached below the base. + assert!( + !produced.is_empty(), + "DETECTOR DID NOT FIRE: the straddling grant produced no extent, so this test proves \ + nothing about containment" + ); + assert!( + outside.is_empty(), + "GUARD FELL: a grant of [0, {}) produced extent(s) {outside:?} below the store's region \ + base {ARENA_BASE}. Those pages hold heap and index data. (all extents: {produced:?})", + 8 * ARENA_EXTENT_PAGES + ); + println!("a4_2: straddling grant [0, {}) produced {produced:?}", 8 * ARENA_EXTENT_PAGES); +} + +/// An empty and a backwards range. +#[test] +fn a4_3_empty_and_backwards_grants_are_refused_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + assert!(matches!(c.apply_grant(N1, 500, 500), Err(GrantError::EmptyRange { .. }))); + assert!(matches!(c.apply_grant(N1, 900, 500), Err(GrantError::EmptyRange { .. }))); + assert_eq!(c.remaining(), 0, "a refused range left usable space"); + + let s = store("a4_3"); + // page_count 0 through the real wiring. + assert!(s.store.apply_arena_grant(N1, 4_096, 0).is_err(), "a zero-page grant was accepted"); + assert!(s.store.arena_for(BranchId::TRUNK).is_err(), "an empty grant produced an extent"); +} + +/// A grant whose top does not fit a `PageId`. `reserve` converts with `u32::try_from` and refuses, +/// rather than truncating and aliasing page 0. +#[test] +fn a4_4_a_grant_past_the_page_id_space_refuses_rather_than_truncates_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("extent-start", u32::MAX as u64 - 10, ARENA_EXTENT_PAGES as u64); + cluster::join(N1); + + // A range that starts inside u32 and ends outside it. + c.apply_grant(N1, u32::MAX as u64 - 10, u32::MAX as u64 + 5_000).unwrap(); + let v = c.take(ARENA_EXTENT_PAGES as u64).unwrap(); + assert!( + u32::try_from(v).is_err() || v <= u32::MAX as u64, + "a value was issued that is neither a valid page id nor refused: {v}" + ); + // And the real wiring must turn that into a refusal, not a truncation. + let s = store("a4_4"); + // u32 arithmetic in apply_arena_grant: first_page + page_count as u64 cannot overflow u64, so + // the widest possible grant is representable and the guard has to be the take-side conversion. + let widest = s.store.apply_arena_grant(N1, u32::MAX - 1, u32::MAX); + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + let range = s.store.extent_range(a); + // The extent START fits a u32, so `reserve`'s try_from lets it through — but the extent is + // ARENA_EXTENT_PAGES wide, so its pages run off the end of the page-id space. Find out what + // allocating inside it does. + let alloc = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.store.alloc_in_arena(a, ferrodb::cow::PageType::BTreeLeaf, ferrodb::branch::types::Epoch(1)) + })); + panic!( + "GUARD FELL: apply_arena_grant({N1}, {}, {}) returned {widest:?} and arena_for \ + produced arena {a} at {range:?}. reserve() checks only that the extent START fits a \ + PageId; the extent is {ARENA_EXTENT_PAGES} pages wide, so pages {}..{} run past \ + u32::MAX. Allocating in it: {}", + u32::MAX - 1, + u32::MAX, + u32::MAX - 1, + (u32::MAX as u64 - 1) + ARENA_EXTENT_PAGES as u64, + match &alloc { + Ok(r) => format!("{r:?}"), + Err(_) => "PANICKED (arithmetic overflow on start_page + next_free)".to_string(), + } + ); + } +} + +// ================================================================================================= +// A4.5 — arena ids and page ids share one number space. +// ================================================================================================= + +/// `apply_arena_grant` applies the *same* `[lo, hi)` to both the extent-start counter and the +/// arena-id counter, so in a cluster an arena id is a page number. Standalone arena ids start at 1. +/// A2.9 found no collision only because the grant range was high; this asks what happens when it is +/// not. +#[test] +fn a4_5_a_low_page_grant_cannot_collide_with_standalone_arena_ids() { + let _scope = ClusterScope::standalone(); + + // Node A, its own leader. `free_arena` returns the extent start to the recycle stack and + // `reserve` pops it, so pages are reused while arena ids keep climbing — which is how a node + // reaches arena id 1024 without a 262144-page file. + let a = store("a4_5_solo"); + let mut solo = Vec::new(); + for _ in 0..(ARENA_BASE as usize + 40) { + let arena = a.store.arena_for(BranchId::TRUNK).unwrap(); + solo.push(arena); + a.store.free_arena(arena).unwrap(); + } + // Force the detector to fire: the collision is only reachable if standalone ids climbed into + // the page-number range a leader would grant. + let highest = solo.iter().map(|x| x.0).max().unwrap(); + assert!( + highest >= ARENA_BASE, + "DETECTOR DID NOT FIRE: standalone arena ids only reached {highest}, below the region \ + base {ARENA_BASE}, so no grant this test can make would overlap them" + ); + + // Node B, granted the region base onwards — pages [1024, 1536), hence arena ids [1024, 1536). + cluster::join(N1); + let b = store("a4_5_member"); + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let member = b.store.arena_for(BranchId::TRUNK).expect( + "DETECTOR DID NOT FIRE: the member could not allocate from its grant, so no arena id was \ + issued to compare", + ); + + assert!( + !solo.contains(&member), + "GUARD FELL: arena id {member} was issued both by node A's standalone self-grant \ + (standalone ids reached {highest}) and by a leader grant of pages \ + [{ARENA_BASE}, {}) to {N1}. apply_arena_grant feeds ONE range to BOTH counters, so a \ + page range doubles as an arena-id range and the two number spaces are the same. \ + BranchRecord::arenas then names one arena for two branches, and the reaper frees exactly \ + record.arenas.", + ARENA_BASE + 2 * ARENA_EXTENT_PAGES + ); +} diff --git a/tests/adv_f4_atk5_meaning_changed.rs b/tests/adv_f4_atk5_meaning_changed.rs new file mode 100644 index 00000000..7fbf6528 --- /dev/null +++ b/tests/adv_f4_atk5_meaning_changed.rs @@ -0,0 +1,167 @@ +//! ATTACK 5 — single-node behaviour that changed meaning, and coverage the existing suite does not +//! actually provide. +//! +//! `tests/integration_cluster_grants.rs` states the case for single-node safety as: *"Single-node +//! ferrodb therefore drives the whole grant machinery on every allocation, which is what makes the +//! 1349 existing tests evidence that the machinery works rather than evidence that it is +//! bypassed."* This file measures the two halves of that: which allocations actually reach a +//! `GrantedCounter`, and one API whose semantics changed silently. +//! +//! Nothing here is about a cluster. Every test runs standalone. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::ClusterScope; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +// ================================================================================================= +// A5.1 — how much of the allocation path the existing suite actually exercises. +// ================================================================================================= + +/// The extent-start watermark is the only observable proof that a `GrantedCounter::take` happened. +/// This measures how many page allocations move it: one per **extent**, not one per page. So the +/// existing suite's coverage of the grant machinery is coverage of extent *claims*, and the +/// per-page path (`alloc_in_arena`) reaches no counter at all — which is why the cluster guard is +/// absent there (see ATTACK 1). +#[test] +fn a5_1_measure_which_allocations_reach_a_granted_counter() { + let _scope = ClusterScope::standalone(); + let s = store("a5_1"); + let epoch = s.catalog.next_epoch(); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let after_claim = s.store.extent_watermark(); + + let mut moves = 0; + let mut prev = after_claim; + for _ in 0..(ARENA_EXTENT_PAGES - 1) { + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let now = s.store.extent_watermark(); + if now != prev { + moves += 1; + prev = now; + } + } + + println!( + "a5_1: {} page allocations inside a claimed extent moved the extent-start watermark {} \ + time(s). Watermark after the claim: {after_claim}; after {} allocations: {prev}.", + ARENA_EXTENT_PAGES - 1, + moves, + ARENA_EXTENT_PAGES - 1 + ); + assert_eq!( + moves, 0, + "the per-page path does reach the counter after all, which would contradict ATTACK 1" + ); +} + +// ================================================================================================= +// A5.2 — `load_state` silently stopped being able to lower a watermark. +// ================================================================================================= + +/// Before 4168c16, `load_state` did: +/// +/// ```text +/// self.space.next_extent_start.store(next_start, Ordering::SeqCst); +/// self.space.next_arena_id.store(next_arena, Ordering::SeqCst); +/// ``` +/// +/// an unconditional **set**. It now calls `GrantedCounter::raise_issued_through`, which is +/// documented as "Monotone, and it never lowers." That is a safer rule, and it is a different rule: +/// loading an older image into a store that has advanced past it no longer rewinds the counter, +/// while the `extents` map, `current`, `reserved_pages` and `live_pages` around it ARE replaced +/// wholesale. +/// +/// This pins the new behaviour and the resulting split between the two. No existing test covers the +/// rewind direction — every one of them loads into a freshly constructed store, where max and set +/// agree. +#[test] +fn a5_2_load_state_no_longer_rewinds_the_watermark_but_does_rewind_the_map() { + let _scope = ClusterScope::standalone(); + let s = store("a5_2"); + let epoch = s.catalog.next_epoch(); + + // Image taken at one extent. + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + let old_image = s.store.state_bytes(); + let w1 = s.store.extent_watermark(); + let reserved1 = s.store.reserved_page_count(); + + // Advance well past it: three more extents on three more branches. + for _ in 0..3 { + let b = s + .catalog + .fork(BranchId::TRUNK, ferrodb::branch::types::LeaseDeadline(u64::MAX / 2)) + .unwrap() + .branch_id; + let a = s.store.arena_for(b).unwrap(); + s.store.alloc_in_arena(a, PageType::BTreeLeaf, epoch).unwrap(); + } + let w2 = s.store.extent_watermark(); + let reserved2 = s.store.reserved_page_count(); + assert!(w2 > w1, "fixture: the store did not advance ({w1} -> {w2})"); + + // Load the OLD image back in. + s.store.load_state(&old_image).unwrap(); + let w3 = s.store.extent_watermark(); + let reserved3 = s.store.reserved_page_count(); + + println!( + "a5_2: watermark {w1} -> {w2} -> {w3} after loading the {w1}-era image; \ + reserved_pages {reserved1} -> {reserved2} -> {reserved3}" + ); + + // The new rule. + assert_eq!( + w3, w2, + "raise_issued_through is documented as monotone, so loading an older image must keep the \ + higher watermark" + ); + // And the half that is NOT monotone: the map went back. + assert_eq!( + reserved3, reserved1, + "reserved_pages is replaced wholesale by the image, unlike the watermark" + ); + assert!( + reserved3 < reserved2, + "MEANING CHANGED: load_state keeps the advanced watermark ({w3}) while resetting \ + reserved_pages to the image's value ({reserved3}, down from {reserved2}). Pages \ + [{reserved3}..{reserved2}) worth of extents are still counted as issued by the counter \ + but are no longer in the extents map, so they can never be reclaimed. Pre-4168c16 the \ + `store` call rewound both together. Exit criterion 8 is stated in reserved_pages, and no \ + existing test loads an image into an advanced store." + ); +} From 0ddfea63e48513cff16c43092a59ebdc5b4ffe09 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:20:20 -0400 Subject: [PATCH 012/315] test(F4-adv): attack the three grant guards with executed fixtures Five test binaries that assert the F4 claim -- "no node in a cluster can obtain an arena extent page, an arena id, a transaction id, or a lease/reap decision without a leader grant" -- so that a failure is the finding and its panic message is the evidence. 13 of 28 probes defeat a guard. The gap the epoch does not close: cluster::authority is process-scoped and GrantedCounter evicts held ranges across a join, but the space already claimed into ArenaPageStore's extents map, its per-arena recycled list, and a restored checkpoint image carry no authority epoch. alloc_in_arena, arena_for and cow_page reach none of the counters, so a member with zero grants drains the tail of any pre-join extent (255 of 256 pages measured). Also: accepted_through outlives the epoch bump, so a leader grant below a stale standalone self-grant is swallowed as Duplicate and the node refuses for ever; apply_lease_tick folds with max and validates no bound, so one tick of u64::MAX permanently expires every lease; AgentRuntime::begin_session panics rather than refusing on a member with no tick. No deadlock found in the authority/counter/att lock chain: 3.45M operations across 14 threads with join/leave racing, no stall. Single-node operation intact -- lib 843, integration_simulate 24, integration_arena_exclusivity 5, sim_durability 16, integration_txn_concurrency 4; 892 passed, 0 failed. Raw outputs under .adv-results/. Fixtures only. Nothing under src/ is touched. --- .../integration_arena_exclusivity.full | 71 ++ .adv-results/integration_simulate.full | 89 ++ .adv-results/integration_txn_concurrency.full | 70 ++ .adv-results/lib.full | 904 ++++++++++++++++++ .adv-results/sim_durability.full | 82 ++ tests/adv_f4_atk1_page_without_grant.rs | 405 ++++++++ tests/adv_f4_atk2_two_authorities.rs | 429 +++++++++ tests/adv_f4_atk3_lease_clock.rs | 302 ++++++ tests/adv_f4_atk4_locks_and_ranges.rs | 341 +++++++ tests/adv_f4_atk5_meaning_changed.rs | 167 ++++ 10 files changed, 2860 insertions(+) create mode 100644 .adv-results/integration_arena_exclusivity.full create mode 100644 .adv-results/integration_simulate.full create mode 100644 .adv-results/integration_txn_concurrency.full create mode 100644 .adv-results/lib.full create mode 100644 .adv-results/sim_durability.full create mode 100644 tests/adv_f4_atk1_page_without_grant.rs create mode 100644 tests/adv_f4_atk2_two_authorities.rs create mode 100644 tests/adv_f4_atk3_lease_clock.rs create mode 100644 tests/adv_f4_atk4_locks_and_ranges.rs create mode 100644 tests/adv_f4_atk5_meaning_changed.rs diff --git a/.adv-results/integration_arena_exclusivity.full b/.adv-results/integration_arena_exclusivity.full new file mode 100644 index 00000000..c96c96db --- /dev/null +++ b/.adv-results/integration_arena_exclusivity.full @@ -0,0 +1,71 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.64s + Running tests/integration_arena_exclusivity.rs (target/debug/deps/integration_arena_exclusivity-27e6f0b07fe7940c) + +running 5 tests +test the_bitmap_allocator_refuses_rather_than_crossing_the_floor ... ok +test an_arena_page_cannot_be_freed_through_the_bitmap ... ok +test a_second_lower_arena_region_is_refused ... ok +test an_unreserved_database_allocates_exactly_as_before ... ok +test the_legacy_allocator_never_hands_out_a_page_inside_an_arena_extent ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +exit=0 diff --git a/.adv-results/integration_simulate.full b/.adv-results/integration_simulate.full new file mode 100644 index 00000000..ca85d40d --- /dev/null +++ b/.adv-results/integration_simulate.full @@ -0,0 +1,89 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s + Running tests/integration_simulate.rs (target/debug/deps/integration_simulate-06f5eef80820bb87) + +running 24 tests +test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok +test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok +test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok +test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok +test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok +test a_read_only_candidate_reports_no_rows_written ... ok +test a_plan_that_never_says_admit_publishes_nothing ... ok +test publishing_an_evaluation_the_gate_declined_is_refused ... ok +test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok +test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok +test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok +test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok +test every_candidate_is_scored_against_one_identical_base ... ok +test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok +test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok +test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok +test k_candidates_cost_k_path_copies_not_k_database_copies ... ok +test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok +test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok +test simulate_is_refused_inside_an_agent_session ... ok +test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok +test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok +test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok +test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok + +test result: ok. 24 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.19s + +exit=0 diff --git a/.adv-results/integration_txn_concurrency.full b/.adv-results/integration_txn_concurrency.full new file mode 100644 index 00000000..9cab0318 --- /dev/null +++ b/.adv-results/integration_txn_concurrency.full @@ -0,0 +1,70 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.05s + Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-5aa0a15ef2f31d9a) + +running 4 tests +test concurrent_commits_and_aborts_do_not_panic ... ok +test committed_transactions_leave_the_active_set ... ok +test a_running_transaction_is_never_missing_from_a_snapshot ... ok +test concurrent_begins_get_distinct_transaction_ids ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.62s + +exit=0 diff --git a/.adv-results/lib.full b/.adv-results/lib.full new file mode 100644 index 00000000..2866fa3b --- /dev/null +++ b/.adv-results/lib.full @@ -0,0 +1,904 @@ + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:440:13 + | +440 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (lib test) generated 2 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 9.74s + Running unittests src/lib.rs (target/debug/deps/ferrodb-a704e6796973b287) + +running 843 tests +test agent_sql::gate::tests::a_cheap_check_that_almost_never_fires_sorts_last ... ok +test agent_sql::changeset::tests::an_empty_merge_is_clean ... ok +test agent_sql::escrow::tests::an_unbounded_cell_is_not_escrowed_and_writes_pass_through ... ok +test agent_sql::changeset::tests::a_lossy_row_makes_the_whole_merge_lossy_never_clean ... ok +test agent_sql::escrow::tests::a_negative_or_zero_claim_is_refused ... ok +test agent_sql::escrow::tests::a_branch_cannot_spend_against_a_claim_it_never_made ... ok +test agent_sql::escrow::tests::the_counter_cannot_be_driven_below_its_floor ... ok +test agent_sql::changeset::tests::a_conflicting_row_outranks_a_commuting_one ... ok +test agent_sql::dispatch::tests::a_row_id_past_i64_max_is_not_reported_as_negative ... ok +test agent_sql::escrow::tests::settling_consumes_only_what_was_actually_spent ... ok +test agent_sql::escrow::tests::reopening_a_pool_is_refused_rather_than_resizing_it ... ok +test agent_sql::escrow::tests::a_write_beyond_the_branchs_claim_is_refused_at_write_time ... ok +test agent_sql::escrow::tests::a_batch_that_only_overdraws_in_aggregate_is_refused ... ok +test agent_sql::gate::tests::an_empty_gate_and_a_gate_where_nothing_fires_both_pass ... ok +test agent_sql::gate::tests::an_assertion_that_examined_no_rows_is_a_hard_reject_not_a_pass ... ok +test agent_sql::gate::tests::a_row_that_could_not_be_evaluated_outranks_every_row_that_held ... ok +test agent_sql::gate::tests::a_later_tier_does_not_run_once_an_earlier_one_has_fired ... ok +test agent_sql::gate::tests::every_check_in_a_tier_runs_even_after_one_has_fired ... ok +test agent_sql::gate::tests::a_sound_finding_carries_the_violated_predicate ... ok +test agent_sql::gate::tests::outcome_follows_epistemic_status_not_severity ... ok +test agent_sql::gate::tests::an_assertion_fires_when_it_is_false_and_stays_quiet_when_it_holds ... ok +test agent_sql::escrow::tests::releasing_a_branch_returns_its_unspent_claim_to_the_pool ... ok +test agent_sql::escrow::tests::two_agents_cannot_both_reserve_twelve_out_of_twenty ... ok +test agent_sql::gate::tests::every_broken_assertion_reaches_the_caller_in_one_round_trip ... ok +test agent_sql::dispatch::tests::every_agent_output_row_matches_its_declared_column_count ... ok +test agent_sql::gate::tests::the_blind_write_metric_quarantines_and_cannot_reject ... ok +test agent_sql::dispatch::tests::only_an_empty_diff_returns_no_rows ... ok +test agent_sql::gate::tests::the_most_severe_status_in_a_tier_decides ... ok +test agent_sql::gate::tests::tier_order_is_computed_from_cost_over_rejection_probability ... ok +test agent_sql::merge_engine::tests::a_replayed_frame_contributes_its_increments_once ... ok +test agent_sql::merge_engine::tests::composing_two_identical_adds_does_not_collapse_them ... ok +test agent_sql::merge_engine::tests::an_unevaluable_guard_is_not_reported_as_a_failed_one ... ok +test agent_sql::mem_catalog::tests::fork_records_the_child_in_the_parent_and_shares_the_root ... ok +test agent_sql::mem_catalog::tests::reading_a_reaped_branch_is_a_hard_error_not_stale_data ... ok +test agent_sql::merge_engine::tests::concurrent_assigns_are_forbidden_by_default_not_silently_lww ... ok +test agent_sql::merge_engine::tests::a_guard_that_fails_against_merged_state_returns_its_predicate ... ok +test agent_sql::merge_engine::tests::invert_refuses_an_assign_with_no_before_image ... ok +test agent_sql::merge_engine::tests::identical_concurrent_writes_are_not_a_conflict ... ok +test agent_sql::merge_engine::tests::lww_succeeds_but_reports_the_discarded_write ... ok +test agent_sql::merge_engine::tests::two_decrements_compose_arithmetically ... ok +test agent_sql::merge_engine::tests::merger_trait_reports_commuting_for_two_branch_decrements ... ok +test agent_sql::paged_rows::tests::a_table_range_excludes_its_neighbours ... ok +test agent_sql::paged_rows::tests::an_empty_row_round_trips ... ok +test agent_sql::paged_rows::tests::a_truncated_row_is_refused_rather_than_panicking ... ok +test agent_sql::paged_rows::tests::an_unknown_tag_is_refused ... ok +test agent_sql::paged_rows::tests::each_wide_variant_round_trips_on_its_own ... ok +test agent_sql::paged_rows::tests::every_value_variant_round_trips ... ok +test agent_sql::paged_rows::tests::split_rejects_a_wrong_length_key ... ok +test agent_sql::paged_rows::tests::keys_sort_in_numeric_row_order ... ok +test agent_sql::paged_rows::tests::the_last_table_id_still_has_a_usable_upper_bound ... ok +test agent_sql::paged_rows::tests::trailing_bytes_are_refused ... ok +test agent_sql::runtime::tests::the_ordinary_reservation_and_a_leaked_range_are_both_allowed ... ok +test agent_sql::runtime::tests::a_reservation_that_would_re_issue_a_version_sequence_is_refused ... ok +test binder::binder::tests::test_duplicate_qualifier ... ok +test binder::binder::tests::test_expand_star ... ok +test binder::binder::tests::test_resolve_ambiguous ... ok +test binder::binder::tests::test_resolve_qualified_unique ... ok +test binder::binder::tests::test_resolve_unknown_column ... ok +test binder::binder::tests::test_resolve_unknown_table ... ok +test agent_sql::paged_rows::tests::a_row_survives_a_round_trip_through_pages ... ok +test binder::binder::tests::test_binding_a_plain_plan_from_an_as_of_table_is_refused_not_silently_main ... ok +test binder::binder::tests::every_statement_bind_does_not_plan_returns_an_error_rather_than_panicking ... ok +test binder::binder::tests::test_join ... ok +test binder::binder::tests::test_duplicate_qualifier_bind ... ok +test binder::binder::tests::test_ambiguous_column ... ok +test agent_sql::paged_rows::tests::the_last_table_scans_its_own_rows ... ok +test binder::binder::tests::test_select_star ... ok +test agent_sql::paged_rows::tests::a_delete_removes_only_its_own_row ... ok +test binder::binder::tests::test_literal ... ok +test agent_sql::paged_rows::tests::a_scan_returns_only_the_requested_table_in_row_order ... ok +test binder::binder::tests::test_bind_rejects_an_agent_statement_as_a_plan ... ok +test agent_sql::paged_rows::tests::a_write_under_one_root_is_invisible_from_another_root ... ok +test binder::binder::tests::test_simple_select ... ok +test binder::binder::tests::test_unkown_table ... ok +test branch::arena::tests::a_private_page_is_mutated_in_place_not_shadowed ... ok +test binder::binder::tests::test_self_join ... ok +test branch::arena::tests::an_arena_region_is_still_off_limits_after_reopening_the_file ... ok +test branch::arena::tests::an_inherited_page_is_shadowed_and_the_original_is_left_alone ... ok +test branch::arena::tests::privacy_barrier_is_the_later_of_own_fork_and_latest_child_fork ... ok +test branch::arena::tests::a_corrupt_free_space_map_is_refused_not_partially_loaded ... ok +test branch::arena::tests::a_torn_page_is_refused_rather_than_returned ... ok +test binder::binder::tests::test_bind_revert_merge_defaults_to_halt ... ok +test branch::arena::tests::fork_allocates_no_page_at_all ... ok +test branch::arena::tests::freed_extents_are_recycled_rather_than_leaked ... ok +test branch::arena::tests::a_checkpoint_describing_another_region_is_refused ... ok +test branch::arena::tests::free_space_map_survives_a_restart ... ok +test branch::arena::tests::shadowing_your_own_page_across_a_child_fork_parks_the_original ... ok +test branch::catalog::tests::a_recycled_id_slot_never_answers_to_the_old_handle ... ok +test branch::arena::tests::store_refuses_to_share_the_bitmap_allocators_region ... ok +test branch::catalog::tests::a_slot_still_pinning_children_is_not_recycled ... ok +test branch::arena::tests::live_arenas_comes_back_in_arena_id_order ... ok +test branch::catalog::tests::depth_guard_refuses_the_ninth_fork ... ok +test branch::arena::tests::checkpoint_round_trips_through_a_file ... ok +test binder::binder::tests::test_bind_diff_merge_abandon_default_to_the_session_branch ... ok +test branch::catalog::tests::epochs_are_strictly_monotonic_across_forks ... ok +test branch::catalog::tests::every_record_sweep_comes_back_in_branch_id_order ... ok +test binder::binder::tests::test_bind_select_as_of_resolves_the_branch_and_the_columns ... ok +test binder::binder::tests::test_bind_without_a_session_or_branch_is_an_error ... ok +test branch::catalog::tests::fork_records_the_child_in_the_parent_atomically ... ok +test branch::catalog::tests::a_torn_tail_keeps_every_intact_record ... ok +test branch::catalog::tests::reading_a_reaped_branch_is_a_hard_error ... ok +test branch::arena::tests::the_checkpoint_image_lists_extents_and_current_arenas_in_key_order ... ok +test branch::reaper::tests::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::catalog::tests::durable_catalog_survives_a_reopen ... ok +test branch::catalog::tests::a_runtime_release_and_a_reload_agree_on_which_slot_is_next ... ok +test branch::reaper::tests::a_chain_at_max_depth_can_only_fork_again_after_collapsing ... ok +test branch::reaper::tests::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::arena::tests::reopen_from_checkpoint_takes_the_base_from_the_image_not_the_caller ... ok +test branch::reaper::tests::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::arena::tests::two_stores_in_the_same_state_checkpoint_byte_identical_images ... ok +test branch::reaper::tests::after_collapse_the_ancestor_chain_can_be_reaped_and_the_data_survives ... ok +test branch::reaper::tests::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::arena::tests::the_checkpoint_syncs_the_image_before_the_rename_and_the_directory_after_it ... ok +test binder::binder::tests::test_bind_begin_agent_session ... ok +test branch::reaper::tests::collapse_refuses_a_cyclic_page_graph ... ok +test branch::reaper::tests::collapse_materialises_the_whole_reachable_tree_and_reparents_to_trunk ... ok +test branch::reaper::tests::collapse_refuses_without_a_page_walker ... ok +test branch::arena::tests::the_public_checkpoint_stages_through_the_durable_helper ... ok +test branch::reaper::tests::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::reaper::tests::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::record::tests::a_column_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_corrupt_envelope_is_rejected_not_defaulted ... ok +test branch::reaper::tests::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::record::tests::a_delete_needs_authority_over_every_column_it_destroys ... ok +test branch::record::tests::a_duplicate_column_keeps_the_tighter_floor ... ok +test branch::record::tests::a_granted_table_resolves_however_the_envelope_was_assembled ... ok +test branch::record::tests::a_record_with_an_envelope_roundtrips_through_bytes ... ok +test branch::record::tests::a_record_written_before_envelopes_existed_still_loads ... ok +test branch::record::tests::a_representation_change_counts_as_writing_the_cell ... ok +test branch::record::tests::a_row_whose_image_did_not_change_costs_nothing ... ok +test branch::reaper::tests::trunk_is_never_reaped ... ok +test branch::record::tests::a_statement_that_overruns_the_budget_is_refused_as_a_whole ... ok +test branch::record::tests::a_table_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_verb_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::an_envelope_can_be_narrowed_but_never_widened ... ok +test branch::reaper::tests::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::record::tests::an_insert_cannot_launder_a_column_or_a_floor_through_a_null ... ok +test branch::record::tests::an_insert_cannot_write_a_column_it_was_not_granted ... ok +test branch::record::tests::an_unknown_floor_tag_is_refused_rather_than_read_as_unbounded ... ok +test branch::record::tests::an_unlisted_table_is_refused_even_for_a_statement_that_matched_nothing ... ok +test branch::record::tests::changed_columns_reads_images_so_an_insert_reports_every_column_it_wrote ... ok +test branch::record::tests::charging_past_the_budget_is_refused_at_the_charge_not_only_at_admit ... ok +test branch::record::tests::corrupt_record_is_rejected_not_guessed ... ok +test branch::record::tests::deleting_a_row_cannot_be_used_to_get_around_a_floor ... ok +test branch::arena::tests::arena_rolls_over_when_an_extent_fills ... ok +test branch::record::tests::depth_guard_fires_at_eight ... ok +test branch::record::tests::fork_copies_the_root_pointer_and_nothing_else ... ok +test branch::record::tests::forking_does_not_mint_budget_the_parent_had_already_spent ... ok +test branch::record::tests::reclamation_rule_is_half_open_over_the_fork_epochs ... ok +test branch::record::tests::live_children_stay_sorted ... ok +test branch::record::tests::record_roundtrips_through_bytes ... ok +test branch::record::tests::stale_generation_is_a_hard_error_not_stale_data ... ok +test branch::record::tests::the_floor_refuses_the_value_whatever_shape_produced_it ... ok +test branch::record::tests::the_envelope_survives_records_of_every_shape ... ok +test branch::record::tests::the_verb_comes_from_the_images_not_from_a_statement_keyword ... ok +test branch::types::tests::branch_state_roundtrips ... ok +test branch::record::tests::trailing_bytes_inside_a_valid_checksum_are_refused ... ok +test branch::types::tests::generation_bump_makes_reaped_id_distinguishable ... ok +test branch::types::tests::lease_expiry_is_a_pure_comparison ... ok +test branch::reaper::tests::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test catalog::catalog::tests::dropping_a_table_forgets_its_statistics ... ok +test buffer::buffer_pool::tests::a_refused_delete_leaves_the_page_intact_in_the_pool ... ok +test catalog::catalog::tests::test_catalog_create_open ... ok +test buffer::buffer_pool::tests::a_refused_free_page_leaves_the_page_intact_in_the_pool ... ok +test buffer::buffer_pool::tests::an_accepted_delete_still_evicts_the_page ... ok +test catalog::catalog::tests::dropping_a_table_returns_its_pages_including_the_time_travel_heap ... ok +test catalog::catalog::tests::test_create_table_adds_time_travel_root ... ok +test catalog::catalog_page::tests::a_middle_entry_s_fulltext_block_does_not_shift_the_next_entry ... ok +test catalog::catalog::tests::test_create_index ... ok +test catalog::catalog_page::tests::a_pre_b8_catalog_page_still_parses_with_no_fulltext_indexes ... ok +test catalog::catalog::tests::test_get_table ... ok +test catalog::catalog::tests::test_drop_table ... ok +test catalog::catalog_page::tests::an_unknown_catalog_page_format_is_refused ... ok +test catalog::catalog_page::tests::fulltext_indexes_round_trip_separately_from_btree_indexes ... ok +test catalog::catalog::tests::test_persist_orphan_removal ... ok +test catalog::catalog_page::tests::test_add_space_management ... ok +test catalog::catalog_page::tests::test_basic_roundtrip ... ok +test catalog::catalog_page::tests::test_remove_entry ... ok +test catalog::catalog::tests::test_update_roots ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_decimal ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_float ... ok +test catalog::column::tests::bigint_orders_by_value_at_the_i64_extremes ... ok +test catalog::column::tests::bigint_versus_float_does_not_widen_past_2_53 ... ok +test catalog::column::tests::bigint_versus_non_finite_floats_matches_total_cmp_placement ... ok +test catalog::column::tests::decimal_beyond_i128_still_compares_exactly ... ok +test catalog::column::tests::decimal_orders_numerically_not_lexicographically ... ok +test catalog::column::tests::decimal_versus_non_finite_floats_is_fixed_and_cycle_free ... ok +test catalog::column::tests::decimal_versus_float_uses_the_floats_exact_value ... ok +test catalog::column::tests::f64_exact_decimal_is_exact_not_shortest ... ok +test catalog::column::tests::float_does_not_outrank_a_larger_integer ... ok +test branch::arena::tests::putting_the_pending_log_back_reaches_the_durable_map ... ok +test catalog::column::tests::integer_to_float_widening_is_exact_at_i32_bounds ... ok +test catalog::column::tests::numerically_equal_int_and_float_compare_equal ... ok +test catalog::column::tests::ordering_is_transitive_across_the_numeric_band ... ok +test catalog::column::tests::rank_fallback_still_orders_incomparable_variants ... ok +test catalog::column::tests::parse_decimal_accepts_what_sql_writes_and_refuses_what_it_does_not ... ok +test catalog::column::tests::timestamp_compares_by_value_and_sits_outside_the_numeric_band ... ok +test catalog::system_views::tests::a_table_may_not_take_a_view_name_but_an_ordinary_name_is_fine ... ok +test catalog::system_views::tests::every_view_name_resolves_and_nothing_else_does ... ok +test catalog::system_views::tests::hex_of_a_prompt_hash_is_64_characters ... ok +test catalog::system_views::tests::synthetic_row_ids_stay_distinct_past_the_slot_number_width ... ok +test catalog::system_views::tests::u64_values_past_i64_max_are_not_reported_as_negative ... ok +test catalog::system_views::tests::view_names_and_declared_columns_agree_with_the_enum ... ok +test cluster::tests::a_grant_addressed_to_another_node_is_refused ... ok +test cluster::tests::a_grant_from_a_previous_epoch_is_not_issued_from_after_leaving ... ok +test cluster::tests::a_grant_partly_consumed_before_a_crash_resumes_at_the_watermark ... ok +test branch::reaper::tests::the_reaper_does_not_fire_before_the_lease_expires ... ok +test cluster::tests::a_grant_wholly_below_the_restored_watermark_is_a_duplicate_not_a_rewind ... ok +test cluster::tests::a_lease_tick_never_moves_cluster_time_backwards ... ok +test cluster::tests::a_member_reads_the_replicated_tick_and_never_its_own_clock ... ok +test cluster::tests::a_member_refuses_again_once_its_grant_is_used_up ... ok +test cluster::tests::a_member_with_no_grant_refuses_rather_than_allocating ... ok +test cluster::tests::a_member_with_no_lease_tick_refuses_to_decide_a_lease ... ok +test cluster::tests::a_range_too_narrow_for_the_ask_is_kept_rather_than_split_or_dropped ... ok +test cluster::tests::a_redelivered_grant_issues_nothing_twice ... ok +test cluster::tests::a_standalone_extent_counter_walks_the_same_page_starts_as_fetch_add ... ok +test cluster::tests::a_standalone_node_at_the_end_of_the_value_space_refuses_distinguishably ... ok +test cluster::tests::a_standalone_node_issues_exactly_what_the_old_atomic_counter_did ... ok +test cluster::tests::a_standalone_node_reads_its_own_wall_clock_because_it_is_the_cluster ... ok +test cluster::tests::a_standalone_node_refuses_an_outside_grant ... ok +test cluster::tests::a_standalone_node_self_grants_through_the_same_consume_path ... ok +test cluster::tests::a_take_never_straddles_two_granted_ranges ... ok +test cluster::tests::an_empty_grant_is_refused_rather_than_ignored ... ok +test cluster::tests::the_issued_watermark_is_the_only_thing_a_checkpoint_has_to_carry ... ok +test cluster::tests::ranges_from_a_superseded_authority_are_not_issued_from ... ok +test cluster::tests::two_members_at_the_same_tick_cannot_disagree_about_expiry ... ok +test cluster::tests::two_members_with_disjoint_grants_never_issue_the_same_value ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test branch::arena::tests::parking_pages_by_the_interval_rule_reaches_the_durable_map ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test cow::btree::cow_page_links_tests::a_leaf_reports_no_children_without_erroring ... ok +test cow::btree::cow_page_links_tests::an_undecodable_internal_node_is_refused_not_reported_as_childless ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test catalog::column::tests::the_whole_value_domain_is_a_total_order ... ok +test branch::reaper::tests::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::catalog::tests::two_opens_of_one_log_recycle_branch_ids_in_the_same_order ... ok +test cow::node::tests::a_full_node_reports_full_instead_of_corrupting_itself ... ok +test cow::node::tests::internal_child_routing_uses_the_leftmost_slot_for_small_keys ... ok +test cow::node::tests::node_body_never_touches_the_page_header ... ok +test cow::node::tests::leaf_insert_search_and_remove ... ok +test cow::node::tests::repeated_overwrite_is_reclaimed_by_compaction ... ok +test cow::node::tests::split_point_keeps_both_halves_non_empty ... ok +test cow::page_header::tests::checksum_catches_a_flipped_payload_byte ... ok +test cow::page_header::tests::privacy_needs_both_own_arena_and_post_fork_birth ... ok +test cow::page_header::tests::header_roundtrips_and_leaves_payload_alone ... ok +test cow::page_header::tests::unknown_page_type_is_rejected ... ok +test cow::tests::overwriting_a_key_does_not_double_count_bytes ... ok +test cow::tests::write_buffer_probe_distinguishes_delete_from_absent ... ok +test branch::arena::tests::freeing_an_extent_checkpoints_the_map_so_a_restart_gets_the_space_back ... ok +test cow::tests_isolation::a_branch_can_never_mutate_another_branchs_page_in_place ... ok +test cow::btree::diff_tests::reversing_the_roots_reverses_each_delta ... ok +test cow::tests_isolation::a_deleted_key_stays_visible_to_a_branch_that_forked_before_the_delete ... ok +test cow::tests_isolation::a_live_child_forces_the_parent_to_shadow_instead_of_mutating ... ok +test branch::reaper::tests::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test cow::btree::diff_tests::an_insert_an_update_and_a_delete_each_report_both_sides ... ok +test cow::btree::diff_tests::identical_roots_diff_to_nothing_without_reading_a_page ... ok +test cow::tests_isolation::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::reaper::tests::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test catalog::column::tests::the_numeric_band_is_a_total_order ... ok +test cow::tests_isolation::a_page_private_to_the_writer_is_mutated_in_place_not_reshadowed ... ok +test cow::tests_isolation::allocating_from_an_unknown_arena_is_an_error ... ok +test cow::tests_isolation::an_oversized_entry_is_refused_rather_than_silently_truncated ... ok +test cow::tests_isolation::a_torn_page_is_refused_rather_than_returned ... ok +test cow::tests_isolation::a_recycled_extent_never_hands_back_stale_page_contents ... ok +test cow::tests_isolation::an_unregistered_branch_is_refused_rather_than_guessed_at ... ok +test cow::tests_isolation::freeing_an_arena_is_one_operation_not_a_per_page_scan ... ok +test cow::btree::diff_tests::unchanged_neighbours_of_an_edited_row_are_not_reported ... ok +test cow::tests_isolation::registering_the_same_branch_twice_is_refused ... ok +test cow::btree::diff_tests::many_scattered_edits_are_all_reported_exactly_once ... ok +test cow::tests_isolation::the_trunk_cannot_be_forgotten ... ok +test cow::tests_isolation::flushing_a_write_buffer_produces_the_same_tree_as_direct_inserts ... ok +test cow::tests_isolation::a_child_writing_never_frees_a_page_its_parent_still_points_at ... ok +test cow::tests_isolation::a_write_in_a_child_does_not_change_what_the_parent_reads ... ok +test cow::tests_isolation::fork_copies_zero_data_pages ... ok +test cow::tests_isolation::delete_removes_only_the_named_key_and_a_miss_shadows_nothing ... ok +test cow::tests_isolation::two_siblings_writing_the_same_key_do_not_see_each_other ... ok +test cow::tests_isolation::reaping_an_abandoned_branch_returns_the_page_count_to_baseline ... ok +test cow::tests_isolation::arenas_roll_over_when_an_extent_fills_and_stay_owned_by_one_branch ... ok +test execution::executor::tests::test_arithmetic ... ok +test execution::executor::tests::a_live_primary_key_is_still_refused ... ok +test cow::tests_isolation::the_default_one_megabyte_extent_works_end_to_end ... ok +test execution::executor::tests::an_uncommitted_delete_does_not_free_the_primary_key ... ok +test cow::tests_isolation::splits_and_multi_level_growth_preserve_every_key ... ok +test execution::executor::tests::test_comparison ... ok +test execution::executor::tests::a_deleted_primary_key_can_be_inserted_again ... ok +test execution::executor::tests::not_null_violation_errors ... ok +test execution::executor::tests::test_analyze_unknown_table_error ... ok +test execution::executor::tests::reusing_a_key_does_not_let_the_next_duplicate_through ... ok +test execution::executor::tests::test_analyze_empty_table ... ok +test execution::executor::tests::test_analyze_nulls_duplicates ... ok +test execution::executor::tests::test_analyze_basic ... ok +test branch::arena::tests::a_page_allocated_after_the_last_checkpoint_is_not_reissued_over_a_live_root ... ok +test execution::executor::tests::test_block_commits_atomically ... ok +test branch::reaper::tests::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test execution::executor::tests::test_block_rollback_discards_everything ... ok +test execution::executor::tests::test_error_aborts_everything ... ok +test execution::executor::tests::test_duplicate_primary_key_errors ... ok +test execution::executor::tests::test_logical ... ok +test execution::executor::tests::test_comparison_filter ... ok +test execution::executor::tests::test_delete_then_select ... ok +test execution::executor::tests::test_filter ... ok +test cow::btree::diff_tests::one_edit_in_a_large_tree_decodes_only_a_fraction_of_it ... ok +test execution::executor::tests::test_insert_stamps_begin_ts ... ok +test execution::executor::tests::test_unary ... ok +test branch::reaper::tests::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test execution::executor::tests::test_rejects_ddl_inside_block ... ok +test execution::executor::tests::test_inner_join ... ok +test execution::executor::tests::test_inner_join_with_keyword ... ok +test execution::fulltext_search::tests::idf_falls_with_document_frequency_and_never_goes_negative ... ok +test execution::executor::tests::test_join_no_match ... ok +test execution::fulltext_search::tests::term_score_saturates_in_tf_and_penalises_length ... ok +test execution::executor::tests::test_uncommitted_writes_invisible_across_sessions ... ok +test execution::fulltext_search::tests::the_heap_agrees_with_a_full_sort ... ok +test execution::fulltext_search::tests::the_heap_keeps_the_best_k_whatever_order_they_arrive_in ... ok +test execution::executor::tests::test_join_select_star ... ok +test execution::executor::tests::test_snapshot_pins_at_begin ... ok +test execution::executor::tests::test_select_all ... ok +test execution::fulltext_search::tests::the_heap_never_returns_more_than_k_and_never_pads ... ok +test execution::executor::tests::test_join_with_where ... ok +test execution::executor::tests::test_projection ... ok +test execution::fulltext_search::tests::ties_break_on_the_primary_key_so_the_boundary_is_deterministic ... ok +test execution::hash_join::tests::equal_hash_keys_hash_identically ... ok +test execution::hash_join::tests::hash_key_equality_agrees_with_the_equality_operator ... ok +test execution::executor::tests::test_left_join ... ok +test execution::executor::tests::test_left_outer_keyword ... ok +test execution::executor::tests::test_left_no_match ... ok +test optimizer::cost_model::tests::test_fallback ... ok +test optimizer::optimizer::tests::test_collect_columns ... ok +test execution::executor::tests::test_update_then_select ... ok +test optimizer::optimizer::tests::test_combine_column ... ok +test execution::executor::tests::test_self_join ... ok +test optimizer::optimizer::tests::test_push_spanning_predicate_stays ... ok +test optimizer::optimizer::tests::test_push_split_remap ... ok +test execution::executor::tests::updating_a_primary_key_is_refused_as_a_constraint_and_names_the_remedy ... ok +test optimizer::optimizer::tests::test_remap ... ok +test optimizer::optimizer::tests::test_split_and_roundtrip ... ok +test optimizer::optimizer::tests::test_wrap_filter_non_empty_conjuncts ... ok +test optimizer::optimizer::tests::test_wrap_filter_empty_conjuncts ... ok +test parser::parser::tests::a_mistyped_alter_still_says_what_was_expected ... ok +test parser::parser::tests::all_is_still_an_ordinary_identifier_everywhere_else ... ok +test execution::executor::tests::test_unsupported_join_type_error ... ok +test parser::parser::tests::alter_table_add_column_carries_not_null ... ok +test parser::parser::tests::alter_table_add_column_parses ... ok +test parser::parser::tests::alter_table_rename_column_parses ... ok +test parser::parser::tests::alter_table_retype_column_parses ... ok +test parser::parser::tests::an_unterminated_candidate_body_names_the_candidate_it_belongs_to ... ok +test parser::parser::tests::drop_column_is_refused_by_name_with_the_reason ... ok +test parser::parser::tests::positional_add_column_is_refused_by_name ... ok +test parser::parser::tests::simulate_admits_all_or_a_count ... ok +test parser::parser::tests::simulate_refuses_a_run_with_nothing_to_compare_or_nothing_to_check ... ok +test optimizer::cost_model::tests::test_cost_secondary_over_primary ... ok +test parser::parser::tests::test_as_of_coexists_with_alias_and_where ... ok +test parser::parser::tests::test_as_of_without_branch_keyword_is_an_error ... ok +test parser::parser::tests::test_begin_agent_session_requires_a_quoted_agent_id ... ok +test parser::parser::tests::test_expr_renders_back_to_sql_for_guard_capture ... ok +test parser::parser::tests::test_panic_mode_synchronization ... ok +test parser::parser::tests::test_parse_begin_agent_session ... ok +test parser::parser::tests::test_parse_create_table ... ok +test parser::parser::tests::test_parse_diff_merge_abandon ... ok +test parser::parser::tests::test_parse_insert ... ok +test parser::parser::tests::test_parse_revert_merge ... ok +test parser::parser::tests::test_parse_select_as_of_branch ... ok +test parser::parser::tests::test_parse_select_str ... ok +test parser::parser::tests::test_parse_simulate ... ok +test parser::parser::tests::test_select_with_join ... ok +test parser::parser::tests::test_update_with_where ... ok +test parser::parser::tests::the_alter_grammar_words_are_still_usable_as_column_names ... ok +test parser::scanner::tests::test_agent_keywords ... ok +test parser::scanner::tests::test_agent_keywords_are_case_insensitive ... ok +test parser::scanner::tests::test_all_keywords ... ok +test parser::scanner::tests::test_fulltext_keywords ... ok +test parser::scanner::tests::test_identifier_vs_keyword ... ok +test parser::scanner::tests::test_operators ... ok +test parser::scanner::tests::test_select_statement ... ok +test parser::scanner::tests::test_whitespace_ignored ... ok +test pgwire::extended::tests::a_parse_error_names_the_parameter_and_not_the_rewriting ... ok +test pgwire::extended::tests::result_format_shorthands_all_resolve_to_one_format_per_column ... ok +test pgwire::extended::tests::statements_split_on_top_level_semicolons_only ... ok +test pgwire::message::tests::a_body_reports_what_it_did_not_consume ... ok +test pgwire::message::tests::a_bodyless_message_is_still_four_bytes_of_length ... ok +test pgwire::message::tests::a_null_column_is_length_minus_one_not_an_empty_byte_string ... ok +test pgwire::message::tests::a_truncated_body_is_an_error_and_not_a_panic ... ok +test pgwire::message::tests::an_empty_string_is_length_zero_and_not_null ... ok +test pgwire::message::tests::error_response_fields_are_terminated_and_typed ... ok +test pgwire::message::tests::framing_length_covers_itself_and_the_body_but_not_the_tag ... ok +test pgwire::message::tests::parameter_description_is_a_count_then_one_oid_each ... ok +test pgwire::message::tests::ready_for_query_carries_the_transaction_status ... ok +test pgwire::message::tests::row_description_advertises_one_field_per_column ... ok +test pgwire::params::tests::a_bare_dollar_is_left_for_the_scanner_to_complain_about ... ok +test pgwire::params::tests::a_dollar_inside_a_string_literal_is_data ... ok +test pgwire::params::tests::a_parameter_carrying_sql_stays_one_value ... ok +test pgwire::params::tests::a_parameter_the_client_did_not_bind_is_refused ... ok +test pgwire::params::tests::a_quoted_identifier_is_not_scanned_for_parameters ... ok +test pgwire::params::tests::a_two_digit_parameter_is_one_parameter ... ok +test pgwire::params::tests::placeholders_become_identifiers_and_are_counted ... ok +test pgwire::params::tests::substitution_puts_a_literal_where_the_parameter_was ... ok +test pgwire::params::tests::substitution_reaches_every_expression_position ... ok +test pgwire::params::tests::zero_and_out_of_range_parameter_numbers_are_refused ... ok +test pgwire::session::tests::a_malformed_set_is_refused_here_and_not_passed_on ... ok +test pgwire::session::tests::a_multi_word_value_survives ... ok +test pgwire::session::tests::a_select_against_a_real_table_is_not_a_probe ... ok +test pgwire::session::tests::a_timeout_this_server_does_not_enforce_is_refused_unless_it_is_off ... ok +test pgwire::session::tests::a_transaction_option_this_engine_cannot_honour_is_refused ... ok +test pgwire::session::tests::an_encoding_this_server_cannot_produce_is_refused ... ok +test pgwire::session::tests::an_unknown_parameter_is_refused_but_a_namespaced_one_is_kept ... ok +test pgwire::session::tests::an_update_with_a_set_clause_is_not_a_session_set ... ok +test pgwire::session::tests::ordinary_sql_is_not_a_session_command ... ok +test pgwire::session::tests::pg_catalog_queries_get_an_explanation_rather_than_invented_rows ... ok +test pgwire::session::tests::reset_all_puts_every_parameter_back ... ok +test pgwire::session::tests::set_and_show_round_trip_a_parameter ... ok +test pgwire::session::tests::set_parses_the_spellings_drivers_actually_send ... ok +test pgwire::session::tests::the_normaliser_leaves_alone_what_is_not_transaction_control ... ok +test pgwire::session::tests::the_probes_a_real_driver_sends_are_recognised ... ok +test pgwire::session::tests::the_reported_isolation_level_is_the_one_the_engine_actually_gives ... ok +test pgwire::session::tests::the_reported_parameters_all_have_values ... ok +test pgwire::session::tests::the_startup_packet_can_set_parameters_too ... ok +test pgwire::session::tests::the_transaction_spellings_drivers_send_map_onto_the_three_this_engine_has ... ok +test pgwire::tests::the_reported_server_version_parses_as_a_version_and_stays_below_the_jit_era ... ok +test pgwire::types::tests::a_declared_column_type_maps_to_the_same_oid_as_a_value_of_it ... ok +test pgwire::types::tests::a_malformed_numeric_is_refused_rather_than_read_past_its_end ... ok +test pgwire::types::tests::a_row_that_disagrees_with_its_description_is_refused_not_encoded ... ok +test pgwire::types::tests::a_short_binary_parameter_is_refused_rather_than_padded ... ok +test pgwire::types::tests::a_whole_float_parameter_stays_a_float ... ok +test pgwire::types::tests::an_unencodable_oid_is_named_rather_than_guessed ... ok +test pgwire::types::tests::binary_integers_are_fixed_width_big_endian ... ok +test pgwire::types::tests::binary_parameters_decode_to_the_literal_the_binder_already_understands ... ok +test pgwire::types::tests::booleans_render_as_t_and_f_the_way_postgres_text_format_does ... ok +test pgwire::types::tests::null_is_null_in_both_formats_and_never_the_word ... ok +test pgwire::types::tests::numeric_encodes_to_the_documented_base_10000_shape ... ok +test pgwire::types::tests::numeric_round_trips_including_the_trailing_zeros_that_are_information ... ok +test pgwire::types::tests::numeric_zero_has_no_sign_and_no_digits ... ok +test pgwire::types::tests::values_map_to_the_postgres_type_oids_a_client_expects ... ok +test pgwire::types::tests::text_parameters_decode_under_their_declared_oid ... ok +test planner::plan::tests::test_bounds_basic ... ok +test planner::plan::tests::test_bounds_flipped ... ok +test planner::plan::tests::test_bounds_invalid ... ok +test provenance::capture::tests::a_capture_retains_reads_in_the_form_the_shape_demands ... ok +test provenance::capture::tests::a_halted_plan_names_no_version_to_undo_and_a_cascade_names_them_deepest_first ... ok +test provenance::capture::tests::a_point_read_never_retains_a_predicate_even_if_one_is_offered ... ok +test provenance::capture::tests::blind_writes_survive_the_round_trip_through_a_capture ... ok +test provenance::capture::tests::stamping_on_write_ties_the_version_to_the_run ... ok +test provenance::capture::tests::the_log_finds_a_downstream_dependent_and_halts ... ok +test provenance::capture::tests::the_report_names_the_agent_behind_each_blocking_transaction ... ok +test provenance::durable::tests::a_failed_append_poisons_the_store_instead_of_leaving_it_looking_healthy ... ok +test provenance::durable::tests::a_foreign_file_is_refused ... ok +test planner::plan::tests::test_index_scan_point ... ok +test planner::plan::tests::test_index_scan_secondary_equality ... ok +test planner::plan::tests::test_index_scan_range ... ok +test provenance::durable::tests::a_restamped_version_replays_to_the_later_run ... ok +test provenance::durable::tests::a_stamp_naming_an_undeclared_run_is_refused_rather_than_healed ... ok +test provenance::durable::tests::re_interning_after_a_reopen_returns_the_same_slot ... ok +test planner::plan::tests::test_index_scan_secondary_rejects_strict_lower ... ok +test provenance::durable::tests::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test provenance::readset::tests::a_scan_makes_no_write_in_that_table_blind ... ok +test planner::plan::tests::test_index_scan_unbounded ... ok +test provenance::readset::tests::blind_writes_finds_the_row_nobody_looked_at ... ok +test provenance::readset::tests::form_is_decided_by_shape_alone ... ok +test provenance::readset::tests::intersecting_two_lower_bounds_keeps_the_tighter_one ... ok +test provenance::durable::tests::the_in_memory_guards_still_hold_and_a_refused_stamp_never_reaches_the_file ... ok +test provenance::readset::tests::intersecting_two_upper_bounds_keeps_the_tighter_one ... ok +test provenance::readset::tests::predicate_summary_gives_phantom_coverage ... ok +test provenance::durable::tests::run_records_out_of_order_in_the_file_still_replay_to_their_recorded_ids ... ok +test provenance::readset::tests::scattered_point_reads_are_never_coarsened_however_many ... ok +test provenance::readset::tests::the_intersected_region_is_what_covers_answers_over ... ok +test provenance::durable::tests::who_wrote_a_row_still_answers_after_a_close_and_reopen ... ok +test provenance::revert::tests::a_downstream_dependent_is_found_through_the_read_set ... ok +test provenance::revert::tests::a_predicate_read_depends_on_a_write_that_landed_inside_its_range ... ok +test provenance::revert::tests::a_scan_does_not_depend_on_a_write_it_could_not_have_seen ... ok +test provenance::revert::tests::a_transaction_does_not_depend_on_itself ... ok +test provenance::revert::tests::a_write_outside_the_scanned_range_creates_no_edge ... ok +test provenance::revert::tests::cascade_orders_dependents_before_their_dependency ... ok +test provenance::revert::tests::halt_is_the_default_and_reverts_nothing ... ok +test provenance::revert::tests::predicate_reads_contribute_no_fake_exact_edges ... ok +test provenance::revert::tests::the_halt_tree_shows_the_whole_downstream_chain ... ok +test provenance::revert::tests::the_tree_terminates_on_a_read_write_cycle ... ok +test provenance::sha256::tests::a_prompt_digest_is_non_zero_and_depends_on_the_prompt ... ok +test provenance::sha256::tests::digests_at_every_padding_boundary_match_an_independent_implementation ... ok +test provenance::sha256::tests::fips_180_4_vectors ... ok +test provenance::sha256::tests::hex_round_trips ... ok +test provenance::sha256::tests::one_flipped_bit_changes_the_digest ... ok +test provenance::store::tests::a_page_dictionary_refuses_to_widen_past_a_byte ... ok +test provenance::store::tests::a_run_can_be_asked_what_it_wrote ... ok +test provenance::sha256::tests::the_one_million_a_vector ... ok +test provenance::store::tests::an_unstamped_version_is_unattributed_not_wrong ... ok +test provenance::store::tests::attribution_is_run_level_so_a_page_holds_one_dictionary_entry_per_run ... ok +test provenance::store::tests::criterion_nine_which_agent_run_model_wrote_this_row ... ok +test provenance::store::tests::distinct_runs_get_distinct_slots ... ok +test provenance::store::tests::interning_the_same_run_twice_is_a_lookup ... ok +test provenance::store::tests::re_interning_a_run_with_a_different_model_is_refused ... ok +test provenance::store::tests::stamping_a_version_with_no_provenance_is_refused ... ok +test provenance::store::tests::stamping_with_an_uninterned_slot_is_refused ... ok +test provenance::store::tests::the_density_numbers_the_docs_quote_are_the_numbers_this_computes ... ok +test provenance::store::tests::the_interned_slot_beats_a_literal_header_by_a_wide_margin ... ok +test provenance::store::tests::the_local_slot_round_trips_through_the_dictionary ... ok +test provenance::tests::prov_id_zero_means_unattributed ... ok +test provenance::tests::run_entity_answers_exit_criterion_nine ... ok +test replication::backup::tests::a_backup_older_than_the_primarys_log_is_refused_by_name ... ok +test replication::backup::tests::a_label_round_trips_through_text ... ok +test replication::backup::tests::a_label_that_is_not_one_is_refused ... ok +test replication::backup::tests::a_label_without_the_version_header_is_refused_rather_than_guessed_at ... ok +test replication::jsonl::tests::a_bare_json_number_would_lose_these_values_which_is_why_they_are_strings ... ok +test replication::jsonl::tests::a_create_table_shape_omits_the_denied_column ... ok +test replication::jsonl::tests::a_declaration_carries_no_alteration ... ok +test replication::jsonl::tests::a_delete_line_carries_before_and_a_null_after ... ok +test replication::jsonl::tests::a_delete_projects_its_only_image ... ok +test replication::jsonl::tests::a_denied_column_is_named_nowhere_in_the_line ... ok +test replication::backup::tests::a_checkpoint_may_not_discard_a_pinned_range ... ok +test replication::backup::tests::pinning_an_lsn_the_log_has_already_dropped_is_refused ... ok +test replication::jsonl::tests::a_feed_with_nothing_withheld_is_unchanged ... ok +test replication::jsonl::tests::a_mixed_row_keeps_narrow_types_as_numbers_and_wide_types_as_strings ... ok +test replication::jsonl::tests::a_null_column_is_present_and_null_not_absent ... ok +test replication::jsonl::tests::a_null_wide_column_is_json_null_not_a_quoted_null ... ok +test replication::backup::tests::a_backup_records_the_lsn_window_its_pages_sit_in ... ok +test replication::jsonl::tests::a_refusal_minted_inside_the_renderer_still_writes_nothing ... ok +test replication::jsonl::tests::a_rename_line_carries_the_old_name_the_new_shape_cannot ... ok +test replication::jsonl::tests::a_retype_line_gives_both_type_spellings ... ok +test replication::jsonl::tests::a_row_whose_present_values_are_all_withheld_is_refused ... ok +test replication::jsonl::tests::a_snapshot_read_projects_its_row ... ok +test replication::jsonl::tests::a_table_dump_is_projected_and_refused_by_the_same_policy ... ok +test replication::backup::tests::a_truncated_image_is_refused_rather_than_restored ... ok +test replication::jsonl::tests::a_whole_numbered_float_keeps_its_decimal_point ... ok +test replication::backup::tests::the_image_holds_the_primarys_pages ... ok +test replication::jsonl::tests::a_withheld_first_column_leaves_valid_json ... ok +test replication::jsonl::tests::an_add_column_line_names_the_added_column_and_the_new_shape ... ok +test replication::jsonl::tests::an_insert_line_carries_after_and_a_null_before ... ok +test replication::jsonl::tests::an_undecided_table_is_refused_by_the_renderer ... ok +test replication::jsonl::tests::an_update_line_carries_both_images ... ok +test replication::jsonl::tests::both_images_of_an_update_are_projected ... ok +test replication::jsonl::tests::column_names_are_escaped_too ... ok +test replication::jsonl::tests::every_character_json_requires_escaping_is_escaped ... ok +test replication::jsonl::tests::floats_round_trip_bit_for_bit ... ok +test replication::jsonl::tests::integer_extremes_survive_exactly ... ok +test replication::jsonl::tests::non_ascii_passes_through_as_utf8 ... ok +test replication::jsonl::tests::non_finite_floats_become_strings_rather_than_invalid_json ... ok +test replication::jsonl::tests::surplus_values_get_visible_synthetic_names_rather_than_vanishing ... ok +test replication::jsonl::tests::the_wide_types_are_emitted_as_quoted_strings ... ok +test replication::jsonl::tests::write_feed_reports_how_many_lines_it_wrote ... ok +test replication::jsonl::tests::write_feed_writes_nothing_at_all_when_any_event_is_refused ... ok +test replication::logical::tests::a_change_to_an_unknown_table_is_reported_rather_than_dropped ... ok +test replication::logical::tests::a_committed_insert_becomes_a_typed_row_event ... ok +test replication::logical::tests::a_decoder_with_no_tables_reports_everything_as_unresolved ... ok +test replication::logical::tests::a_ddl_record_whose_alteration_is_not_in_its_shape_is_refused ... ok +test replication::logical::tests::a_null_column_decodes_as_null ... ok +test replication::logical::tests::a_declaration_names_a_run_without_attributing_anybodys_commit ... ok +test replication::logical::tests::a_second_session_of_one_run_is_not_a_conflicting_actor ... ok +test replication::logical::tests::sql_type_contract_is_exhaustive ... ok +test replication::logical::tests::an_aborted_transaction_emits_nothing ... ok +test replication::logical::tests::an_update_carries_the_row_before_and_after ... ok +test replication::logical::tests::an_open_transaction_is_withheld_and_reported ... ok +test replication::publication::tests::a_column_name_the_format_cannot_express_is_withheld_never_published ... ok +test replication::logical::tests::events_are_ordered_by_commit_not_by_write ... ok +test replication::publication::tests::a_create_table_shape_is_masked_like_a_row ... ok +test replication::logical::tests::one_provenance_slot_naming_two_actors_is_refused ... ok +test replication::publication::tests::a_declaration_with_no_header_is_refused ... ok +test replication::publication::tests::a_drop_table_is_decided_by_the_table_and_not_by_its_columns ... ok +test replication::logical::tests::time_travel_records_are_counted_as_internal_not_as_lost ... ok +test replication::publication::tests::a_duplicate_column_is_refused ... ok +test replication::logical::tests::an_mvcc_delete_is_reported_as_a_delete_not_an_update ... ok +test replication::publication::tests::a_header_with_no_name_is_refused_as_a_nameless_header ... ok +test replication::publication::tests::a_line_the_parser_cannot_read_is_refused_rather_than_skipped ... ok +test replication::publication::tests::a_name_containing_the_comment_character_is_refused_rather_than_shortened ... ok +test replication::publication::tests::a_name_that_is_not_an_identifier_is_refused ... ok +test replication::publication::tests::a_publication_that_names_no_table_is_refused_at_parse ... ok +test replication::publication::tests::a_refusal_says_which_publication_refused_what ... ok +test replication::publication::tests::a_table_declared_twice_is_refused ... ok +test replication::publication::tests::a_table_published_with_no_columns_is_refused_like_an_absent_one ... ok +test replication::publication::tests::a_table_that_is_both_published_and_excluded_is_refused_either_way_round ... ok +test replication::publication::tests::a_table_that_is_not_in_the_publication_is_refused ... ok +test replication::publication::tests::a_table_with_no_columns_is_refused_at_parse ... ok +test replication::publication::tests::a_value_with_no_column_name_is_refused_under_an_allowlist ... ok +test replication::publication::tests::an_event_whose_every_column_is_withheld_is_refused ... ok +test replication::publication::tests::an_excluded_table_is_decided_against_rather_than_undecided ... ok +test replication::publication::tests::both_images_of_an_update_are_covered_by_the_value_check ... ok +test optimizer::cost_model::tests::test_cardinality ... ok +test replication::publication::tests::mask_for_decides_a_bare_table_and_column_list ... ok +test replication::publication::tests::parse_narrows_to_exactly_what_it_names ... ok +test replication::publication::tests::two_columns_with_one_name_are_refused_rather_than_guessed ... ok +test replication::publication::tests::unrestricted_publishes_everything ... ok +test replication::publication::tests::whole_line_comments_and_blank_lines_are_ignored ... ok +test branch::reaper::tests::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok +test replication::snapshot::tests::a_failed_scan_yields_no_handoff ... ok +test replication::snapshot::tests::a_quiet_database_hands_off_exactly ... ok +test replication::snapshot::tests::a_snapshot_writes_read_events_and_reports_its_handoff ... ok +test replication::snapshot::tests::a_failed_exact_scan_closes_its_reader_and_yields_no_handoff ... ok +test replication::snapshot::tests::an_empty_table_still_yields_a_handoff_point ... ok +test replication::snapshot::tests::the_handoff_lsn_is_taken_before_the_scan_not_after ... ok +test replication::snapshot::tests::a_scan_that_fails_after_writing_reports_the_rollback_too ... ok +test replication::snapshot::tests::an_exact_snapshot_writes_its_rows_and_pins_its_resume_point ... ok +test replication::stream::tests::a_batch_that_is_entirely_refused_does_not_advance_the_cursor ... ok +test replication::snapshot::tests::the_boundary_covers_exactly_the_tables_that_were_delivered ... ok +test replication::stream::tests::a_batch_that_is_entirely_suppressed_still_advances_the_cursor ... ok +test replication::stream::tests::a_cursor_below_the_logs_base_is_refused_by_name ... ok +test replication::stream::tests::a_large_backlog_is_delivered_in_bounded_batches ... ok +test replication::snapshot::tests::a_snapshot_whose_resume_point_cannot_be_pinned_leaves_no_reader_behind ... ok +test replication::stream::tests::a_pump_emits_committed_changes_and_advances_past_them ... ok +test replication::stream::tests::a_refused_event_is_replayed_after_the_publication_is_widened ... ok +test replication::stream::tests::a_schema_event_survives_the_snapshot_filter ... ok +test replication::stream::tests::a_long_open_transaction_does_not_cause_endless_redelivery ... ok +test replication::stream::tests::a_refusal_leaves_a_subscriptions_claim_below_the_refused_commit ... ok +test replication::stream::tests::a_streamer_without_a_snapshot_boundary_suppresses_nothing ... ok +test replication::stream::tests::an_already_delivered_event_does_not_wedge_the_cursor_when_it_later_becomes_unpublishable ... ok +test replication::stream::tests::a_transaction_that_was_in_flight_at_snapshot_time_is_still_delivered ... ok +test replication::stream::tests::an_excluded_tables_events_are_dropped_and_the_cursor_keeps_moving ... ok +test replication::stream::tests::a_subscription_holds_the_log_across_a_checkpoint ... ok +test replication::stream::tests::an_in_flight_transaction_does_not_move_the_cursor_and_is_emitted_after_it_commits ... ok +test replication::stream::tests::changes_that_are_not_durable_yet_are_not_emitted ... ok +test replication::stream::tests::events_the_snapshot_already_contained_are_not_re_delivered ... ok +test replication::stream::tests::an_open_transaction_is_not_stepped_over_when_a_later_one_commits ... ok +test replication::stream::tests::following_takes_its_cursor_from_the_boundary_and_refuses_without_one ... ok +test replication::sync::tests::a_replica_that_reconnects_inside_the_deadline_still_satisfies_the_commit ... ok +test replication::sync::tests::a_target_already_acked_does_not_wait ... ok +test replication::stream::tests::lag_shrinks_as_a_consumer_catches_up ... ok +test replication::sync::tests::an_ack_never_goes_backwards ... ok +test replication::stream::tests::resuming_from_the_returned_cursor_does_not_repeat_events ... ok +test replication::stream::tests::resuming_from_a_commit_position_alone_loses_an_in_flight_transaction ... ok +test replication::stream::tests::withholding_a_column_still_ships_the_row_and_advances_the_cursor ... ok +test replication::sync::tests::the_furthest_replica_satisfies_the_wait ... ok +test replication::sync::tests::an_ack_that_reaches_the_target_releases_the_waiter ... ok +test replication::tests::a_frame_too_short_for_its_own_fields_is_refused ... ok +test replication::stream::tests::subscribing_below_the_base_is_refused_immediately ... ok +test replication::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test replication::tests::an_empty_records_batch_is_distinct_from_no_batch ... ok +test replication::tests::an_oversized_length_is_refused_without_allocating ... ok +test replication::tests::every_message_round_trips ... ok +test replication::sync::tests::with_no_replica_it_refuses_rather_than_degrading_silently ... ok +test replication::tests::the_handshake_rejects_a_stranger_and_a_version_mismatch ... ok +test replication::tests::the_length_covers_the_body_only ... ok +test storage::atomic_file::tests::a_bare_relative_target_syncs_the_current_directory_not_the_empty_path ... ok +test storage::atomic_file::tests::a_durable_replace_syncs_the_temporary_before_the_rename_and_the_directory_after_it ... ok +test storage::atomic_file::tests::a_path_that_names_no_file_is_refused_rather_than_staged_through_dot_tmp ... ok +test storage::atomic_file::tests::syncing_a_real_directory_succeeds_on_this_platform ... ok +test storage::atomic_file::tests::the_temporary_appends_tmp_instead_of_replacing_the_extension ... ok +test storage::db_lock::tests::an_empty_lock_file_still_refuses ... ok +test storage::db_lock::tests::a_second_claim_on_one_database_is_refused ... ok +test storage::db_lock::tests::a_stale_lock_is_refused_and_names_the_file_to_remove ... ok +test storage::db_lock::tests::dropping_the_lock_lets_the_next_open_through ... ok +test storage::disk_manager::tests::exhausting_the_table_region_says_the_knob_will_not_help_this_database ... ok +test storage::disk_manager::tests::a_second_reservation_at_a_higher_base_is_refused ... ok +test storage::db_lock::tests::two_different_databases_do_not_contend ... ok +test storage::disk_manager::tests::growing_the_bitmap_does_not_reuse_owned_pages ... ok +test storage::disk_manager::tests::high_water_does_not_regress_after_a_free ... ok +test storage::disk_manager::tests::next_page_id_is_not_the_high_water_mark ... ok +test storage::disk_manager::tests::reattaching_at_the_same_base_is_allowed ... ok +test storage::disk_manager::tests::test_freelist ... ok +test storage::heap_file_manager::tests::test_insert_and_read ... ok +test storage::heap_page::tests::test_basic ... ok +test storage::heap_page::tests::test_exact_bytes ... ok +test storage::heap_file_manager::tests::test_insert_delete_scan ... ok +test storage::disk_manager::tests::test_rw ... ok +test storage::heap_file_manager::tests::test_scan ... ok +test storage::heap_file_manager::tests::test_multiple_pages ... ok +test storage::heap_file_manager::tests::test_update_in_place ... ok +test storage::index::tests::test_create_init_empty_leaf ... ok +test storage::index::tests::test_delete_not_real_key ... ok +test storage::index::tests::test_internal_node_split_a_lot ... ok +test storage::index::tests::test_delete_basic ... ok +test storage::index::tests::test_range_scan_basic ... ok +test storage::index_fulltext::tests::an_over_long_run_is_dropped_and_its_neighbours_are_not ... ok +test storage::index_fulltext::tests::distinct_tokens_dedups_but_tokenize_does_not ... ok +test storage::atomic_file::tests::the_real_filesystem_replace_lands_the_bytes_and_leaves_no_temporary ... ok +test storage::index::tests::test_search_empty_tree_returns_none ... ok +test storage::index::tests::test_search_finds_key ... ok +test storage::index_fulltext::tests::indexed_text_accepts_varchar_and_null_and_refuses_the_rest ... ok +test storage::index_fulltext::tests::null_is_the_floor_of_the_second_component ... ok +test storage::index_fulltext::tests::the_cap_counts_bytes_not_chars ... ok +test storage::index_fulltext::tests::tokenize_emits_no_empty_tokens ... ok +test storage::index::tests::test_simple_insert ... ok +test storage::index_fulltext::tests::tokenize_limits_are_the_documented_ones ... ok +test storage::index_fulltext::tests::tokenize_lowercases_and_splits_on_non_alphanumerics ... ok +test storage::index::tests::test_range_scan_many_pages ... ok +test storage::index_page::tests::test_internal_find_child ... ok +test storage::index_page::tests::test_internal_insert_key_child ... ok +test storage::index_page::tests::test_internal_split ... ok +test storage::index_page::tests::test_leaf_composite_keys ... ok +test storage::index_page::tests::test_leaf_get ... ok +test storage::index_page::tests::test_leaf_insert_sorted ... ok +test storage::index_page::tests::test_leaf_remove ... ok +test storage::index_page::tests::test_leaf_split ... ok +test storage::index_page::tests::test_roundtrip_i_p ... ok +test storage::index_page::tests::test_roundtrip_i_s ... ok +test storage::index_page::tests::test_roundtrip_l_p ... ok +test storage::index_page::tests::test_roundtrip_l_s ... ok +test storage::tuple::tests::serialising_a_row_that_does_not_match_its_schema_is_refused ... ok +test storage::tuple::tests::test_null ... ok +test storage::tuple::tests::test_se_and_deserialize ... ok +test storage::index::tests::test_many_level_insert_and_search ... ok +test storage::tuple::tests::test_versioned_tuple_roundtrip ... ok +test tel::capture::tests::a_delta_against_a_different_column_is_not_a_relative_move ... ok +test storage::index::tests::test_many_level_reverse ... ok +test provenance::durable::tests::the_interning_claim_still_holds_after_a_reopen_on_the_existing_instrument ... ok +test tel::capture::tests::a_guard_reading_outside_the_target_table_is_refused ... ok +test tel::capture::tests::a_join_shifts_the_target_tables_offsets ... ok +test tel::capture::tests::a_literal_assignment_stays_an_assign ... ok +test tel::capture::tests::a_single_table_plan_still_maps_offsets_straight_through ... ok +test tel::capture::tests::a_snapshot_refuses_a_row_it_does_not_hold ... ok +test tel::capture::tests::a_where_clause_becomes_a_guard_that_can_be_rechecked_later ... ok +test tel::capture::tests::an_untranslatable_predicate_is_refused_rather_than_weakened ... ok +test tel::capture::tests::arithmetic_inside_a_guard_survives_capture ... ok +test tel::capture::tests::conjunctions_and_negations_survive_capture ... ok +test tel::capture::tests::end_to_end::concurrent_decrements_compose_arithmetically ... ok +test tel::capture::tests::end_to_end::overselling_is_rejected_with_the_violated_predicate_returned ... ok +test tel::capture::tests::end_to_end::the_rejection_is_not_unconditional ... ok +test tel::capture::tests::five_minus_qty_is_not_an_add_because_subtraction_does_not_commute ... ok +test tel::capture::tests::qty_minus_five_is_captured_as_an_add_not_an_assign ... ok +test tel::capture::tests::five_plus_qty_is_still_an_add ... ok +test tel::engine::tests::a_delete_does_not_invalidate_its_own_where_clause ... ok +test tel::engine::tests::a_delete_versus_a_write_names_the_right_side_in_the_report ... ok +test tel::engine::tests::a_discarded_write_is_reported_as_the_side_s_net_effect ... ok +test tel::engine::tests::a_drawdown_that_exactly_exhausts_the_resource_is_allowed ... ok +test tel::engine::tests::a_duplicate_frame_across_sides_is_dropped_too ... ok +test tel::engine::tests::a_failing_lca_read_surfaces_instead_of_becoming_no_lca_value ... ok +test tel::engine::tests::a_frames_own_guarded_delete_is_not_unevaluable ... ok +test tel::engine::tests::a_genuinely_violated_precondition_still_conflicts ... ok +test tel::engine::tests::a_guard_reading_a_row_the_other_side_deleted_is_unevaluable ... ok +test tel::engine::tests::a_one_sided_write_is_clean_not_commuting ... ok +test tel::engine::tests::a_removed_dot_does_not_survive_the_merge ... ok +test tel::engine::tests::a_row_created_inside_the_merge_supplies_its_own_base_for_a_delta ... ok +test tel::engine::tests::a_row_routed_op_that_is_not_create_or_delete_is_refused ... ok +test tel::engine::tests::a_schema_version_mismatch_fails_loudly ... ok +test tel::engine::tests::a_set_op_meeting_a_scalar_op_conflicts_rather_than_panicking ... ok +test tel::engine::tests::a_solo_decrement_landing_on_its_own_bound_is_not_a_conflict ... ok +test tel::engine::tests::a_well_formed_row_create_is_still_composed ... ok +test tel::engine::tests::additive_policy_uses_the_three_way_formula_on_absolute_writes ... ok +test tel::engine::tests::an_absent_lca_cell_still_falls_back_to_the_fresh_image ... ok +test tel::engine::tests::an_uncontended_decrement_does_not_conflict_with_its_own_result ... ok +test tel::engine::tests::an_unevaluable_guard_is_distinct_from_a_failed_one ... ok +test tel::engine::tests::both_sides_deleting_the_same_row_is_idempotent ... ok +test tel::engine::tests::dedup_reports_which_txn_it_dropped ... ok +test tel::engine::tests::contradictory_assigns_under_the_default_policy_conflict ... ok +test tel::engine::tests::delete_on_one_side_versus_a_write_on_the_other_conflicts ... ok +test tel::engine::tests::diff_returns_the_ops_and_guards_the_target_branch_added ... ok +test tel::engine::tests::diff_without_a_log_is_an_error_not_an_empty_changeset ... ok +test tel::engine::tests::escrow_floor_is_rechecked_against_merged_state ... ok +test tel::engine::tests::every_violated_guard_is_reported_not_just_the_first ... ok +test tel::engine::tests::folding_a_side_applies_its_own_ops_in_order ... ok +test tel::engine::tests::guard_failing_against_merged_state_is_a_conflict_with_the_predicate_returned ... ok +test tel::engine::tests::identical_assigns_are_not_a_conflict ... ok +test tel::engine::tests::lww_orders_by_global_txn_id_not_branch_local_seq ... ok +test tel::engine::tests::lww_reports_resolved_with_loss_and_names_the_discarded_write ... ok +test tel::engine::tests::main_untouched_is_clean ... ok +test tel::engine::tests::max_and_min_compose_without_a_policy ... ok +test tel::engine::tests::merging_frames_read_back_from_the_log_reaches_the_same_answer ... ok +test tel::engine::tests::multi_value_keeps_both_writes ... ok +test tel::engine::tests::multi_value_surfaces_each_side_s_net_effect ... ok +test tel::engine::tests::multivalue_composes_net_effects_not_last_ops ... ok +test tel::engine::tests::observed_remove_spares_an_insert_the_remover_never_saw ... ok +test tel::engine::tests::replayed_frame_is_dropped_rather_than_applied_twice ... ok +test tel::engine::tests::the_same_guard_holds_when_the_composition_leaves_headroom ... ok +test tel::engine::tests::two_branches_decrementing_compose_arithmetically ... ok +test tel::engine::tests::two_identical_decrements_from_different_txns_are_not_deduplicated ... ok +test tel::guard::tests::an_and_with_an_unknown_operand_is_unknown_not_true ... ok +test tel::guard::tests::an_or_with_an_unknown_operand_is_unknown_and_not_negatable_into_true ... ok +test tel::guard::tests::bounded_counter_guard_composes_with_arithmetic ... ok +test tel::guard::tests::guard_is_rechecked_against_whatever_state_it_is_given ... ok +test tel::guard::tests::null_operand_makes_the_guard_unknown_not_true ... ok +test tel::guard::tests::referenced_cells_finds_every_read ... ok +test tel::guard::tests::unreadable_cell_is_an_error_not_a_false ... ok +test tel::guard::tests::violated_predicate_is_returned_verbatim ... ok +test tel::log::tests::frames_come_back_in_sequence_order_per_branch ... ok +test tel::log::tests::guards_survive_the_round_trip_separately_from_ops ... ok +test tel::log::tests::replaying_the_identical_frame_does_not_store_it_twice ... ok +test tel::log::tests::reusing_a_txn_id_for_different_contents_is_an_error_not_an_overwrite ... ok +test tel::log::tests::the_same_txn_id_on_a_different_branch_is_a_different_frame ... ok +test tel::merge::tests::conflict_carries_the_predicate_back_to_the_agent ... ok +test tel::merge::tests::default_policy_forbids_concurrent_writes ... ok +test tel::merge::tests::lossy_resolution_never_reads_as_clean ... ok +test tel::op::tests::assign_is_idempotent_but_does_not_commute ... ok +test tel::op::tests::delta_applies_and_refuses_non_numeric ... ok +test tel::op::tests::integer_delta_overflow_is_an_error_not_a_wrap ... ok +test tel::op::tests::two_identical_decrements_compose_to_double ... ok +test tel::schema_merge::tests::a_one_sided_schema_change_is_clean ... ok +test tel::schema_merge::tests::a_predicate_about_an_absent_column_is_answerable ... ok +test tel::schema_merge::tests::a_rename_composes_with_an_unrelated_add ... ok +test tel::schema_merge::tests::a_rename_onto_a_name_another_branch_created_conflicts ... ok +test tel::schema_merge::tests::adding_the_same_column_twice_with_different_types_conflicts ... ok +test tel::schema_merge::tests::an_identical_edit_is_absorbed_rather_than_refused ... ok +test tel::schema_merge::tests::no_edits_is_clean_even_when_the_target_moved ... ok +test tel::schema_merge::tests::renaming_a_column_another_branch_renamed_first_conflicts ... ok +test tel::schema_merge::tests::the_returned_predicate_re_evaluates_to_false ... ok +test tel::schema_merge::tests::two_branches_adding_different_columns_compose ... ok +test tel::schema_merge::tests::two_branches_retyping_one_column_conflict_and_name_the_predicate ... ok +test wal::log::tests::a_run_identity_record_that_names_no_run_is_refused ... ok +test wal::log::tests::a_truncated_alteration_is_refused_rather_than_panicking ... ok +test wal::log::tests::a_truncated_run_identity_record_is_refused_rather_than_panicking ... ok +test wal::log::tests::an_older_logs_records_still_decode_after_the_new_tag_was_added ... ok +test wal::log::tests::an_unknown_alteration_tag_is_refused ... ok +test wal::log::tests::create_and_drop_records_keep_their_byte_layout ... ok +test wal::log::tests::a_record_whose_bytes_were_corrupted_is_refused ... ok +test wal::log::tests::deserialize_rejects_empty_and_unknown_tag ... ok +test wal::log::tests::every_column_alteration_survives_the_log_round_trip ... ok +test wal::log::tests::test_crc32_val ... ok +test wal::log::tests::test_reckind_roundtrip ... ok +test wal::log::tests::test_lsns_are_monotonic ... ok +test wal::log::tests::test_flush_up_to_stops_when_durable ... ok +test wal::log::tests::test_corrupted_last_record_detected ... ok +test wal::log::tests::test_read_buffer_before_flush ... ok +test wal::log::tests::test_survives_flush_and_reopen ... ok +test wal::log::tests::the_recovery_scan_stops_at_a_corrupted_record ... ok +test wal::recovery::tests::test_uncommited_rolled_back ... ok +test wal::txn::tests::a_quiet_database_resumes_at_the_readers_own_begin ... ok +test wal::recovery::tests::test_insert_survives_crash ... ok +test wal::txn::tests::a_second_session_of_one_run_is_the_same_actor_however_the_clock_moved ... ok +test wal::txn::tests::a_snapshot_read_resumes_at_the_oldest_transaction_it_excluded ... ok +test wal::txn::tests::a_refused_binding_leaves_nothing_behind_for_the_commit_to_write ... ok +test wal::txn::tests::a_reader_is_not_leaked_when_its_txn_end_cannot_be_written ... ok +test wal::txn::tests::binding_a_run_with_an_empty_named_field_is_refused ... ok +test wal::txn::tests::a_snapshot_reader_that_writes_is_refused_and_rolled_back ... ok +test wal::recovery::tests::test_update_delete_redo_after_checkpoint ... ok +test wal::txn::tests::declaring_one_slot_as_two_actors_is_refused ... ok +test wal::recovery::tests::sql_crash_recover_rebuild_query ... ok +test wal::txn::tests::a_snapshot_reader_is_not_leaked_when_its_handoff_cannot_be_pinned ... ok +test wal::txn::tests::gate_flushed_wal_before_page_write ... ok +test wal::txn::tests::test_visibility_matrix ... ok +test wal::txn::tests::gate_covers_flush_all ... ok +test wal::txn::tests::interrupted_truncation_self_heals_on_open ... ok +test wal::txn::tests::the_high_water_mark_is_exclusive ... ok +test wal::txn::tests::checkpoint_truncates_and_preserves_data ... ok +test wal::txn::tests::test_abort_delete_restores_row ... ok +test wal::txn::tests::test_abort_insert_removes_rows ... ok +test wal::visibility::tests::an_uncontended_version_and_my_own_write_are_both_writable ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_created_is_a_conflict ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_deleted_is_a_conflict ... ok +test wal::txn::tests::test_commit_writes_chain_and_flushes ... ok +test wal::txn::tests::test_begin_creates_snapshot ... ok +test wal::txn::tests::torn_tail_trimmed_on_open ... ok +test wal::txn::tests::the_run_identity_record_is_the_last_record_before_its_own_commit ... ok +test wal::txn::tests::sql_insert_commits_through_run ... ok +test wal::txn::tests::transaction_ids_never_start_at_zero ... ok +test storage::atomic_file::tests::concurrent_replaces_of_one_target_never_publish_a_mixture ... ok +test execution::hash_join::tests::hash_join_matches_integer_key_against_float_key ... ok +test optimizer::cost_model::tests::test_join_picks_hash ... ok +test execution::executor::tests::root_split_persists_across_reopen ... ok +test execution::hash_join::tests::nested_loop_and_hash_join_agree_across_integer_and_float_keys ... ok +test optimizer::cost_model::tests::test_cost_index_vs_seq ... ok +test optimizer::cost_model::tests::explain_index_flip_at_volume ... ok + +test result: ok. 843 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 27.17s + +exit=0 diff --git a/.adv-results/sim_durability.full b/.adv-results/sim_durability.full new file mode 100644 index 00000000..c1320a35 --- /dev/null +++ b/.adv-results/sim_durability.full @@ -0,0 +1,82 @@ +warning: multiple fields are never read + --> src/consensus/mod.rs:352:16 + | +324 | pub struct Consensus { + | --------- fields in this struct +... +352 | pub(crate) acked: std::collections::BTreeMap, + | ^^^^^ +... +384 | pub(crate) since_quorum: u32, + | ^^^^^^^^^^^^ +... +387 | pub(crate) election_timeout: u32, + | ^^^^^^^^^^^^^^^^ +388 | /// The base election timeout; the drawn one lies in `[base, 2*base)`. +389 | pub(crate) election_base: u32, + | ^^^^^^^^^^^^^ +... +398 | pub(crate) heartbeat: u32, + | ^^^^^^^^^ +399 | pub(crate) since_heartbeat: u32, + | ^^^^^^^^^^^^^^^ +... +407 | pub(crate) progress: std::collections::BTreeMap, + | ^^^^^^^^ +... +412 | pub(crate) applied: Round, + | ^^^^^^^ +... +415 | pub(crate) durable: Round, + | ^^^^^^^ +... +418 | pub(crate) last_term: Term, + | ^^^^^^^^^ +419 | pub(crate) last_round: Round, + | ^^^^^^^^^^ +... +423 | pub(crate) snapshot_round: Round, + | ^^^^^^^^^^^^^^ +424 | pub(crate) snapshot_term: Term, + | ^^^^^^^^^^^^^ +... +430 | pub(crate) rng: Rng, + | ^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: method `may_campaign` is never used + --> src/consensus/mod.rs:494:19 + | +433 | impl Consensus { + | -------------- method in this implementation +... +494 | pub(crate) fn may_campaign(&self) -> bool { + | ^^^^^^^^^^^^ + +warning: `ferrodb` (lib) generated 2 warnings + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-F4-adversarial) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.22s + Running tests/sim_durability.rs (target/debug/deps/sim_durability-00e47465d43b9399) + +running 16 tests +test under_sync_only_an_unflushed_write_does_not_survive ... ok +test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok +test a_torn_write_lands_a_prefix_and_ends_the_run ... ok +test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok +test the_durable_commit_detector_fires_and_stays_quiet ... ok +test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok +test flush_all_writes_pages_in_ascending_page_id_order ... ok +test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok +test two_different_seeds_crash_in_different_places ... ok +test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok +test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok +test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok +test recovery_itself_writes_the_same_sequence_three_times ... ok +test recovery_holds_at_every_fault_point_sync_only ... ok +test recovery_holds_at_every_fault_point_write_through ... ok +test recovery_holds_at_every_fault_point_through_the_catalog ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.53s + +exit=0 diff --git a/tests/adv_f4_atk1_page_without_grant.rs b/tests/adv_f4_atk1_page_without_grant.rs new file mode 100644 index 00000000..2d10b7eb --- /dev/null +++ b/tests/adv_f4_atk1_page_without_grant.rs @@ -0,0 +1,405 @@ +//! ATTACK 1 — obtain an arena extent page or an arena id on a cluster member that has been +//! granted nothing. +//! +//! The claim under attack (commit 4168c16): *"no node in a cluster can obtain an arena extent page, +//! an arena id, a transaction id, or a lease/reap decision without a leader grant."* +//! +//! `tests/integration_cluster_grants.rs` proves that claim for a store that is **fresh** when the +//! process joins. Every test here joins a store that has already done work, which is the state a +//! real node is in when it is added to a cluster, and which is the state +//! `ArenaSpaceManager::recycle_epoch` exists to talk about. +//! +//! Each test asserts the CLAIM, so a failure is the finding and its panic message is the evidence. +//! +//! # Scoping +//! +//! One `ClusterScope::standalone()` is held for the whole of each test and `cluster::join` / +//! `cluster::leave` are called inside it. The scope's lock is what serializes these tests against +//! each other; doing the transition inside one held scope makes standalone-then-member atomic, +//! where dropping a standalone scope and taking a joined one leaves a window in between. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); + +struct Store { + store: Arc, + catalog: Arc, + pool: Arc, + dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, pool, dir } +} + +/// Every page id an extent claim covers. +fn extent_covers(start: u32, page: u32) -> bool { + page >= start && page < start + ARENA_EXTENT_PAGES +} + +// ================================================================================================= +// A1.0 — the control. The guard must fire at all, or nothing below means anything. +// ================================================================================================= + +#[test] +fn a1_0_control_a_fresh_member_refuses() { + let _scope = ClusterScope::standalone(); + let s = store("a1_0"); + cluster::join(N1); + + let err = s.store.arena_for(BranchId::TRUNK).expect_err( + "CONTROL FAILED: a fresh member allocated with no grant. Nothing else in this file is \ + interpretable.", + ); + assert!( + format!("{err}").contains("no leader-granted"), + "control: wrong refusal shape: {err}" + ); +} + +// ================================================================================================= +// A1.1 — `alloc_in_arena` on an extent claimed before the join. +// ================================================================================================= + +/// The extent was self-granted while standalone. The leader does not know this node has it, so it +/// is free to hand the same page range to another node — which is exactly why +/// `ArenaSpaceManager::recycle_epoch` discards the *free-extent stack* across a join. The interior +/// of a still-live extent is the same category of space. +#[test] +fn a1_1_alloc_in_arena_refuses_on_an_extent_self_granted_before_the_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_1"); + + // Standalone: claim one extent and use one page of it, so the bump pointer is mid-extent. + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + let first = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let (start, _) = s.store.extent_range(arena).unwrap(); + assert!(extent_covers(start, first), "fixture: page {first} is outside extent at {start}"); + + // Now this process becomes a cluster member. No grant is applied, ever. + cluster::join(N1); + assert!(cluster::is_clustered(), "fixture: the join did not take"); + + let got = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch); + if let Ok(page) = got { + panic!( + "GUARD FELL: a cluster member with zero grants applied obtained novel extent page \ + {page} (extent at {start}, arena {arena}) via alloc_in_arena. The extent was \ + self-granted while standalone, so no leader knows this node holds it and the leader \ + may grant page {page} to another node." + ); + } +} + +/// `arena_for` returns the cached `current` arena without consulting any counter, so the whole +/// write path keeps allocating after the join until the extent is full. +#[test] +fn a1_2_arena_for_refuses_to_reuse_the_current_arena_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_2"); + + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + + cluster::join(N1); + + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + // Prove it is not merely an id but usable space. + let page = s.store.alloc_in_arena(a, PageType::BTreeLeaf, epoch); + panic!( + "GUARD FELL: arena_for returned arena {a} on a member with no grant (same arena as \ + before the join: {}), and alloc_in_arena then returned {:?}. arena_for reads \ + `current` and the extent's remaining space without asking either GrantedCounter.", + a == a1, + page + ); + } +} + +/// How much space a member with no grant can keep issuing: the whole tail of the extent. +#[test] +fn a1_3_a_member_with_no_grant_cannot_drain_a_pre_join_extent() { + let _scope = ClusterScope::standalone(); + let s = store("a1_3"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let (start, _) = s.store.extent_range(arena).unwrap(); + + cluster::join(N1); + + let mut got = Vec::new(); + for _ in 0..ARENA_EXTENT_PAGES { + match s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch) { + Ok(p) => got.push(p), + Err(_) => break, + } + } + if !got.is_empty() { + panic!( + "GUARD FELL: a member with no grant issued {} extent pages after joining \ + ({:?}..={:?}, extent at {start}). That is {} of one {ARENA_EXTENT_PAGES}-page extent \ + handed out with no leader grant.", + got.len(), + got.first(), + got.last(), + got.len() + ); + } +} + +// ================================================================================================= +// A1.4 — the per-arena recycled list, the other piece of issued space outside the counter. +// ================================================================================================= + +/// `release_page` parks a page in `StoreState::recycled`, and `alloc_in_arena` pops that list +/// *first*, before it even looks at the extent. `recycle_epoch` guards `free_extent_starts`; this +/// list has no epoch stamp at all. +#[test] +fn a1_4_the_per_arena_recycled_list_is_not_reusable_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_4"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + let p = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.release_page(p, arena); + + cluster::join(N1); + + let got = s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch); + if let Ok(page) = got { + panic!( + "GUARD FELL: a member with no grant obtained page {page} from the per-arena recycled \ + list (released page was {p}). StoreState::recycled carries no authority epoch, so a \ + page freed under a previous authority is handed straight back out." + ); + } +} + +// ================================================================================================= +// A1.5 — free_arena then re-claim. This is the path `recycle_epoch` was written for. +// ================================================================================================= + +#[test] +fn a1_5_a_freed_extent_is_not_reclaimable_after_a_join() { + let _scope = ClusterScope::standalone(); + let s = store("a1_5"); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(arena).unwrap(); + assert_eq!(s.store.reserved_page_count(), 0, "fixture: the extent was not freed"); + + cluster::join(N1); + + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + panic!( + "GUARD FELL: a member with no grant re-claimed a freed extent as arena {a}. \ + recycle_epoch did not discard the free-extent stack across the join." + ); + } +} + +// ================================================================================================= +// A1.6 — reopen_from_checkpoint on a member. The image carries space no leader granted. +// ================================================================================================= + +/// The checkpoint image contains `free_extent_starts` and the whole `extents` map. `load_state` +/// restores both and then *re-stamps* `recycle_epoch` with the epoch in force now — so the +/// free-extent stack arrives already blessed by the authority that never granted it. +#[test] +fn a1_6_a_member_cannot_get_space_out_of_a_checkpoint_image() { + let _scope = ClusterScope::standalone(); + let s = store("a1_6"); + let path = s.dir.path().join("arena.ckpt"); + + // Standalone: two extents, one of them freed so the free stack is non-empty, then checkpoint. + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + let forked = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX / 2)).unwrap(); + let a2 = s.store.arena_for(forked.branch_id).unwrap(); + s.store.alloc_in_arena(a2, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(a2).unwrap(); + s.store.checkpoint(&path).unwrap(); + + // The process joins a cluster, and *then* the store is reopened from that image. + cluster::join(N1); + + let reopened = ArenaPageStore::reopen_from_checkpoint( + Arc::clone(&s.pool), + Arc::clone(&s.catalog), + &path, + ) + .unwrap(); + + // The restored extents are in the map with their bump pointers intact. + let live = reopened.live_arenas(); + assert!(!live.is_empty(), "fixture: the image restored no extents"); + + let mut fell = Vec::new(); + for (arena, _owner) in &live { + if let Ok(page) = reopened.alloc_in_arena(*arena, PageType::BTreeLeaf, epoch) { + fell.push((*arena, page)); + } + } + if !fell.is_empty() { + panic!( + "GUARD FELL: a member with no grant obtained {:?} (arena, page) out of a checkpoint \ + image via alloc_in_arena on restored extents. load_state restores `extents` with no \ + authority epoch.", + fell + ); + } +} + +// ================================================================================================= +// A1.7 — concurrency. Threads racing the refusal must all refuse. +// ================================================================================================= + +#[test] +fn a1_7_threads_racing_arena_for_on_a_fresh_member_all_refuse() { + let _scope = ClusterScope::standalone(); + let s = store("a1_7"); + cluster::join(N1); + + let mut handles = Vec::new(); + for _ in 0..16 { + let store = Arc::clone(&s.store); + handles.push(std::thread::spawn(move || { + let mut ok = Vec::new(); + for _ in 0..64 { + if let Ok(a) = store.arena_for(BranchId::TRUNK) { + ok.push(a); + } + } + ok + })); + } + let issued: Vec<_> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect(); + assert!( + issued.is_empty(), + "GUARD FELL: {} arenas were issued to a member with no grant under concurrency: {:?}", + issued.len(), + issued + ); + assert_eq!(s.store.reserved_page_count(), 0, "refused claims still reserved pages"); +} + +/// The epoch `ArenaSpaceManager::reserve` passes to `recycled_start` is read before the take, so a +/// `join` landing in between is evaluated against a stale epoch. Racing it here to see whether the +/// free stack survives a join it should have been cleared by. +#[test] +fn a1_8_racing_a_join_against_reserve_cannot_smuggle_a_recycled_page() { + let _scope = ClusterScope::standalone(); + let mut smuggled = Vec::new(); + + for round in 0..200 { + let s = store(&format!("a1_8_{round}")); + cluster::leave(); + // Standalone: build a free-extent stack, then grant the arena-id counter enough room that + // the *only* thing standing between a member and a page is the recycle-stack epoch check. + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = s.catalog.next_epoch(); + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + s.store.free_arena(arena).unwrap(); + + let store = Arc::clone(&s.store); + let racer = std::thread::spawn(move || store.arena_for(BranchId::TRUNK)); + cluster::join(N1); + let got = racer.join().unwrap(); + if let Ok(a) = got { + // Either the join had not landed yet (legitimate) or the stale epoch let it through. + // Distinguish by asking whether we are clustered now and whether the arena id could + // only have come from a self-grant. + smuggled.push((round, a, cluster::is_clustered())); + } + cluster::leave(); + } + + // A win for the racer is only interesting if it happened while clustered. Reported either way: + // the point of this test is the number, not a boolean. + let while_clustered: Vec<_> = smuggled.iter().filter(|(_, _, c)| *c).collect(); + println!( + "a1_8: {} of 200 rounds returned an arena to the racing thread; {} of those with \ + is_clustered() true at the join point", + smuggled.len(), + while_clustered.len() + ); +} + +// ================================================================================================= +// A1.9 — the real COW write path, not `alloc_in_arena` called directly. +// ================================================================================================= + +/// `cow_page` is what an actual write goes through. It calls `arena_for` (which returns the cached +/// `current` arena) and then `alloc_in_arena` for the copy, so a member with no grant keeps +/// shadowing pages after the join. +#[test] +fn a1_9_the_cow_write_path_refuses_on_a_member_with_no_grant() { + let _scope = ClusterScope::standalone(); + let s = store("a1_9"); + + // Standalone: TRUNK owns arena a1 with two pages; a child branch owns a2. + let trunk_arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let e0 = s.catalog.next_epoch(); + let inherited_a = s.store.alloc_in_arena(trunk_arena, PageType::BTreeLeaf, e0).unwrap(); + let inherited_b = s.store.alloc_in_arena(trunk_arena, PageType::BTreeLeaf, e0).unwrap(); + + let child = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX / 2)).unwrap().branch_id; + let e1 = s.catalog.next_epoch(); + let child_arena = s.store.arena_for(child).unwrap(); + assert_ne!(child_arena, trunk_arena, "fixture: the child did not get its own arena"); + + // Prove the COW path copies (rather than mutating in place) before the join. + let pre = s.store.cow_page(inherited_a, child, e1).unwrap(); + assert!(pre.copied, "fixture: the COW did not copy, so no page was allocated"); + + // Now join. No grant, ever. + cluster::join(N1); + + let got = s.store.cow_page(inherited_b, child, e1); + match got { + Ok(cow) if cow.copied => panic!( + "GUARD FELL: the real COW write path allocated page {} on a cluster member with zero \ + grants applied (copying inherited page {inherited_b} into arena {child_arena}). \ + cow_page -> arena_for -> alloc_in_arena reaches no GrantedCounter while the current \ + extent has room.", + cow.page_id + ), + Ok(_) => panic!("the COW mutated in place; the fixture no longer exercises allocation"), + Err(_) => {} + } +} diff --git a/tests/adv_f4_atk2_two_authorities.rs b/tests/adv_f4_atk2_two_authorities.rs new file mode 100644 index 00000000..ffcbcb3c --- /dev/null +++ b/tests/adv_f4_atk2_two_authorities.rs @@ -0,0 +1,429 @@ +//! ATTACK 2 — make two different authorities issue the same page id, arena id or txn id, and probe +//! the grant bookkeeping for orders it gets wrong. +//! +//! Two authorities exist in one process the moment `join` or `leave` is called: the node's own +//! standalone self-grant is one, the leader that grants it a range afterwards is the other. Ranges +//! are disjoint by construction, so the question this file asks is whether the *bookkeeping* that +//! makes them disjoint — `Grants::accepted_through`, `Grants::issued`, and the authority epoch — +//! survives the transitions. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have and are expected to pass. +//! Tests that assert the claim and fail are the findings; the panic message is the evidence. +//! +//! `GrantedCounter` is exercised directly wherever the question is about the rule rather than the +//! wiring: it is `pub`, and driving it directly is the only way to reach an ordering that a leader +//! loop would have to be buggy to produce but that a re-delivered log suffix produces for free. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, Applied, ClusterScope, GrantError, GrantedCounter}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); +const N2: NodeId = NodeId(2); + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +struct Engine { + txn: Arc, + _dir: tempfile::TempDir, +} + +fn engine(tag: &str) -> Engine { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join(format!("{tag}.wal"))).unwrap()); + let txn = Arc::new(TxnManager::new(Arc::clone(&wal), Arc::clone(&bp))); + bp.attach_wal(wal); + Engine { txn, _dir: dir } +} + +/// Drain a counter until it refuses, collecting every value it issued. +fn drain(c: &GrantedCounter, n: u64, cap: usize) -> Vec { + let mut out = Vec::new(); + while out.len() < cap { + match c.take(n) { + Ok(v) => out.push(v), + Err(_) => break, + } + } + out +} + +// ================================================================================================= +// A2.1 — a grant applied before the join. +// ================================================================================================= + +#[test] +fn a2_1_a_grant_applied_before_the_join_is_refused_and_does_not_survive_it_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 100, 8); + + // Standalone: the grant is refused outright. + match c.apply_grant(N1, 1_000, 2_000) { + Err(GrantError::NotClustered { .. }) => {} + other => panic!("a standalone node accepted an outside range: {other:?}"), + } + + // And it left nothing behind that the join could bless. + cluster::join(N1); + assert_eq!(c.remaining(), 0, "a refused pre-join grant left usable space behind"); + assert!(matches!(c.take(1), Err(GrantError::Exhausted { .. })), "member issued after a refusal"); +} + +// ================================================================================================= +// A2.2 — join / leave / join. Does a granted range come back to life? +// ================================================================================================= + +#[test] +fn a2_2_a_range_does_not_survive_leave_then_rejoin_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + + cluster::join(N1); + assert!(matches!(c.apply_grant(N1, 100, 200), Ok(Applied::Accepted { usable: 100 }))); + assert_eq!(c.take(1).unwrap(), 100); + + cluster::leave(); + // Standalone now, so it self-grants from its own watermark. It must NOT issue from [100, 200) + // as though the departed leader's grant were still its own... but it may legitimately + // self-grant above `accepted_through`, which is 200. + let solo = c.take(1).unwrap(); + assert!(solo >= 200, "a standalone node re-issued from a departed leader's range: {solo}"); + + cluster::join(N1); + assert_eq!(c.remaining(), 0, "a rejoin resurrected a range"); + assert!(matches!(c.take(1), Err(GrantError::Exhausted { .. })), "a rejoin resurrected a range"); +} + +// ================================================================================================= +// A2.3 — no value is ever issued twice, across every transition order this file can build. +// ================================================================================================= + +#[test] +fn a2_3_no_value_is_issued_twice_across_a_transition_storm_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 7); + let mut seen: Vec = Vec::new(); + + for round in 0..40u64 { + match round % 4 { + 0 => cluster::leave(), + 1 => cluster::join(N1), + 2 => { + let _ = c.apply_grant(N1, round * 3, round * 3 + 50); + } + _ => cluster::join(N1), + } + // Grants that straddle, precede and follow the watermark, plus the same one twice. + let _ = c.apply_grant(N1, 10, 60); + let _ = c.apply_grant(N1, 10, 60); + let _ = c.apply_grant(N1, 0, 5); + seen.extend(drain(&c, 1, 12)); + let _ = c.apply_grant(N1, 30, 500); + seen.extend(drain(&c, 3, 4)); + } + + let mut sorted = seen.clone(); + sorted.sort_unstable(); + let before = sorted.len(); + sorted.dedup(); + assert_eq!( + before, + sorted.len(), + "GUARD FELL: a value was issued twice by one counter across authority transitions. \ + issued: {seen:?}" + ); +} + +// ================================================================================================= +// A2.4 — two grants delivered out of order. The lower range is swallowed. +// ================================================================================================= + +/// `accepted_through` is a single scalar and doubles as the duplicate-detector, so a grant whose +/// `hi` is at or below it is reported `Duplicate` regardless of whether its values were ever +/// granted to this node. Delivery order of two committed entries is not something a follower +/// controls. +#[test] +fn a2_4_a_lower_range_delivered_after_a_higher_one_is_not_lost() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + assert!(matches!(c.apply_grant(N1, 2_000, 3_000), Ok(Applied::Accepted { usable: 1_000 }))); + let second = c.apply_grant(N1, 1_000, 2_000); + + assert!( + matches!(second, Ok(Applied::Accepted { .. })), + "GUARD FELL: the leader granted [1000, 2000) to {N1} and the node answered {second:?}. \ + 1000 values the leader believes this node owns are unusable for ever, and the leader was \ + told 'Duplicate', which reads as 'already have it'. remaining() = {} (only the higher \ + range).", + c.remaining() + ); +} + +// ================================================================================================= +// A2.5 — the standalone-then-join deadlock. `accepted_through` outlives the epoch bump. +// ================================================================================================= + +/// `join` bumps the epoch and `evict_stale` drops the *held ranges*, but `accepted_through` and +/// `issued` are untouched. A node that self-granted while standalone therefore refuses every grant +/// the leader makes below that stale mark — as `Duplicate`, so the leader is told it already has it +/// and never grants again. +#[test] +fn a2_5_a_leader_can_still_supply_a_node_that_self_granted_while_standalone() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("extent-start", 1_024, ARENA_EXTENT_PAGES as u64 * 4); + + // Standalone: one extent's worth issued. The self-grant chunk covers four, so + // accepted_through is now 1024 + 4*256 = 2048 while only 1024..1280 was ever issued. + let first = c.take(ARENA_EXTENT_PAGES as u64).unwrap(); + assert_eq!(first, 1_024); + assert_eq!(c.issued_through(), 1_280, "fixture: watermark is not where fetch_add left it"); + + cluster::join(N1); + + // The leader knows nothing of the self-grant. It grants this node a fresh region that happens + // to sit below the stale accepted_through — which, from the leader's side, is simply "the + // region I have not handed out yet". + let applied = c.apply_grant(N1, 1_280, 2_048); + let after = c.take(ARENA_EXTENT_PAGES as u64); + + assert!( + after.is_ok(), + "GUARD FELL (liveness): apply_grant([1280, 2048)) returned {applied:?} and the node then \ + refused to allocate: {after:?}. accepted_through = 2048 from a standalone self-grant \ + survived the epoch bump, so every grant below it is swallowed as Duplicate and the node \ + refuses for ever while the leader believes it is supplied." + ); +} + +// ================================================================================================= +// A2.6 — the same grant twice with a take in between, and a straddling grant. +// ================================================================================================= + +#[test] +fn a2_6_redelivery_and_straddling_grants_never_re_offer_an_issued_value_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + c.apply_grant(N1, 100, 200).unwrap(); + let a = c.take(1).unwrap(); + assert_eq!(a, 100); + + // Re-delivered in full: must be a no-op. + assert_eq!(c.apply_grant(N1, 100, 200).unwrap(), Applied::Duplicate); + // Straddling the watermark: only the suffix may be usable. + c.apply_grant(N1, 150, 300).unwrap(); + + let rest = drain(&c, 1, 1_000); + let mut all = vec![a]; + all.extend(rest); + let mut sorted = all.clone(); + sorted.sort_unstable(); + let n = sorted.len(); + sorted.dedup(); + assert_eq!(n, sorted.len(), "GUARD FELL: a value was issued twice: {all:?}"); + assert!( + all.iter().all(|&v| (100..300).contains(&v)), + "a value outside every granted range was issued: {all:?}" + ); +} + +// ================================================================================================= +// A2.7 — a grant addressed to another node. +// ================================================================================================= + +#[test] +fn a2_7_a_grant_for_another_node_is_refused_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + match c.apply_grant(N2, 500, 600) { + Err(GrantError::WrongNode { granted_to, self_id, .. }) => { + assert_eq!((granted_to, self_id), (N2, N1)); + } + other => panic!("GUARD FELL: {N1} accepted a range addressed to {N2}: {other:?}"), + } + assert_eq!(c.remaining(), 0, "a refused foreign grant left usable space"); + // And it did not poison the dedup mark either: the same range addressed correctly must work. + assert!(matches!(c.apply_grant(N1, 500, 600), Ok(Applied::Accepted { usable: 100 }))); +} + +// ================================================================================================= +// A2.8 — the one that matters: the SAME PAGE ID from two authorities, end to end. +// ================================================================================================= + +/// Node A self-grants page P while standalone and writes it. The process then joins, and a leader +/// with no knowledge of that self-grant hands the region containing P to a node — which issues P +/// again. Both stores use the same `ARENA_BASE`, i.e. the same physical page-id space, which is +/// what "two nodes on one region" means. +/// +/// The `recycle_epoch` doc says this shape is the reason the epoch exists. It evicts the *counter's* +/// held ranges; it does not un-issue what standalone already wrote. +#[test] +fn a2_8_a_page_written_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + // ---- authority 1: this node, its own leader ---- + let a = store("a2_8_solo"); + let arena_a = a.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch = a.catalog.next_epoch(); + let mut solo_pages = Vec::new(); + for _ in 0..4 { + solo_pages.push(a.store.alloc_in_arena(arena_a, PageType::BTreeLeaf, epoch).unwrap()); + } + let (solo_start, _) = a.store.extent_range(arena_a).unwrap(); + + // ---- authority 2: a leader, which has never heard of the above ---- + cluster::join(N1); + let b = store("a2_8_member"); + // The leader's view of free space: the region base onwards. Exactly what a leader that was + // never told about a pre-join self-grant would propose. + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let arena_b = b.store.arena_for(BranchId::TRUNK).unwrap(); + let epoch_b = b.catalog.next_epoch(); + let mut member_pages = Vec::new(); + for _ in 0..4 { + member_pages.push(b.store.alloc_in_arena(arena_b, PageType::BTreeLeaf, epoch_b).unwrap()); + } + + let collide: Vec = + solo_pages.iter().copied().filter(|p| member_pages.contains(p)).collect(); + assert!( + collide.is_empty(), + "GUARD FELL: page(s) {collide:?} were issued by TWO authorities — self-granted and \ + written by a standalone node (extent at {solo_start}, arena {arena_a}), then granted to \ + {N1} by a leader and issued again (arena {arena_b}). Standalone pages: {solo_pages:?}; \ + granted pages: {member_pages:?}. Every one of those pages passes its own checksum on \ + both nodes." + ); +} + +/// The same collision for arena ids, which is the one `BranchRecord::arenas` points at and the +/// reaper frees whole. +#[test] +fn a2_9_an_arena_id_used_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + let a = store("a2_9_solo"); + let mut solo = Vec::new(); + for _ in 0..3 { + let arena = a.store.arena_for(BranchId::TRUNK).unwrap(); + solo.push(arena); + let e = a.catalog.next_epoch(); + // Fill the extent so the next arena_for claims a fresh one. + while a.store.alloc_in_arena(arena, PageType::BTreeLeaf, e).is_ok() {} + } + + cluster::join(N1); + let b = store("a2_9_member"); + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let member = b.store.arena_for(BranchId::TRUNK).unwrap(); + + assert!( + !solo.contains(&member), + "GUARD FELL: arena id {member} was issued both by a standalone self-grant ({solo:?}) and \ + by a leader grant to {N1}. BranchRecord::arenas then names one arena for two branches, \ + and the reaper frees exactly record.arenas." + ); +} + +// ================================================================================================= +// A2.10 — the same collision on txn ids, through the real TxnManager. +// ================================================================================================= + +#[test] +fn a2_10_a_txn_id_issued_while_standalone_cannot_be_granted_to_a_member() { + let _scope = ClusterScope::standalone(); + + let a = engine("a2_10_solo"); + let mut solo = Vec::new(); + for _ in 0..10 { + solo.push(a.txn.begin().unwrap()); + } + + cluster::join(N1); + let b = engine("a2_10_member"); + // A leader seeding a fresh cluster from the bottom of the id space. + b.txn.apply_txn_id_grant(N1, 1, 1_000).unwrap(); + let mut member = Vec::new(); + for _ in 0..10 { + member.push(b.txn.begin().unwrap()); + } + + let collide: Vec = solo.iter().copied().filter(|t| member.contains(t)).collect(); + assert!( + collide.is_empty(), + "GUARD FELL: txn id(s) {collide:?} were issued by two authorities. solo={solo:?} \ + member={member:?}. The TEL's stamp() leads with TxnId to order writes across branches, so \ + a duplicate corrupts merge ordering, which the merge engine cannot detect." + ); +} + +/// The same `accepted_through` deadlock as A2.5, through `TxnManager`: a node that begins +/// transactions standalone and then joins refuses every grant below its stale mark. +#[test] +fn a2_11_a_leader_can_supply_txn_ids_to_a_node_that_ran_standalone_first() { + let _scope = ClusterScope::standalone(); + let e = engine("a2_11"); + for _ in 0..10 { + e.txn.begin().unwrap(); + } + let watermark = e.txn.next_txn_id(); + assert_eq!(watermark, 11, "fixture: the watermark moved somewhere unexpected"); + + cluster::join(N1); + // The self-grant chunk is 256, so accepted_through is 257 while only 11 ids were issued. A + // leader granting the next hundred ids after the watermark lands entirely below it. + let applied = e.txn.apply_txn_id_grant(N1, watermark, watermark + 100); + let began = e.txn.begin(); + assert!( + began.is_ok(), + "GUARD FELL (liveness): apply_txn_id_grant({N1}, {watermark}, {}) returned {applied:?} \ + and begin() then failed: {began:?}. The node will not begin a transaction and the leader \ + has been told the range was a duplicate.", + watermark + 100 + ); +} diff --git a/tests/adv_f4_atk3_lease_clock.rs b/tests/adv_f4_atk3_lease_clock.rs new file mode 100644 index 00000000..6a702a6d --- /dev/null +++ b/tests/adv_f4_atk3_lease_clock.rs @@ -0,0 +1,302 @@ +//! ATTACK 3 — make a reap decision come from a wall clock on a cluster member, make two members +//! disagree about a deadline, and make `LeaseDeadline::from_now` return a wrong value instead of +//! panicking. +//! +//! Reaping frees a branch's arenas and then bumps its `BranchId` generation, which exists precisely +//! so a reaped id can never be mistaken for a live one — so a wrong reap is **unrecoverable**. The +//! three ways to get one are: read the wrong clock, disagree with a peer about the same clock, or +//! compute the wrong number from the right clock. One test each, plus the availability question the +//! panic raises. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have. A failure elsewhere is the +//! finding, and its panic message is the evidence. + +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope, GrantError}; +use ferrodb::consensus::NodeId; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); +const N2: NodeId = NodeId(2); + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +// ================================================================================================= +// A3.1 — is there any infallible clock left that a reap can reach? +// ================================================================================================= + +/// `reap_expired` takes `now_millis` as a parameter and `is_expired_at` is a pure comparison, so +/// the reaper itself cannot read a clock. This pins that: a member with no tick has no way to +/// produce the argument. +#[test] +fn a3_1_a_member_with_no_tick_cannot_produce_a_reap_clock_holds() { + let _scope = ClusterScope::standalone(); + cluster::join(N1); + + match LeaseDeadline::try_now_millis() { + Err(GrantError::NoClusterTime { node }) => assert_eq!(node, N1), + other => panic!("GUARD FELL: a member with no tick answered the time: {other:?}"), + } + assert!(LeaseDeadline::try_from_now(1).is_err()); + assert!(LeaseDeadline(0).is_expired_now().is_err(), "is_expired_now answered with no tick"); + assert!(LeaseDeadline(u64::MAX).is_expired_now().is_err()); +} + +// ================================================================================================= +// A3.2 — the production paths that still call the PANICKING clock. +// ================================================================================================= + +/// `AgentRuntime::begin_session_with_model` — production code, not a test — calls +/// `LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS)` (`src/agent_sql/runtime.rs:650`), and +/// `AgentRuntime::simulate` calls `LeaseDeadline::from_now(plan.lease_millis)` +/// (`src/agent_sql/simulate.rs:431`). Neither has a fallible variant on its path. +/// +/// The claim is that the refusal is a refusal. This asks whether the *engine* refuses or aborts. +#[test] +fn a3_2_beginning_an_agent_session_on_a_member_with_no_tick_returns_an_error() { + let _scope = ClusterScope::standalone(); + let rt = AgentRuntime::new(); + // Prove the fixture works before the join, so a failure after it is about the clock. + rt.begin_session("probe-agent", None, BranchId::TRUNK) + .expect("fixture: begin_session must work standalone"); + + cluster::join(N1); + + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + rt.begin_session("probe-agent", None, BranchId::TRUNK) + })); + match outcome { + Ok(Ok(_)) => panic!("GUARD FELL: a member with no tick minted a lease deadline anyway"), + Ok(Err(_)) => {} // the good case: a refusal reached the caller + Err(_) => panic!( + "GUARD FELL (availability): AgentRuntime::begin_session PANICKED on a cluster member \ + with no applied LeaseTick instead of returning Err. This is production code \ + (src/agent_sql/runtime.rs:650, before the file's #[cfg(test)] at line 4291) on the \ + path of every agent session, FORK and SIMULATE. A node that joins a cluster before \ + its first LeaseTick aborts the process on the first agent statement rather than \ + refusing it." + ), + } +} + +// ================================================================================================= +// A3.3 — two members, the same ticks, delivered in different orders. +// ================================================================================================= + +/// `fold_tick` is `max`, which is commutative and idempotent, so a re-delivered or reordered suffix +/// of the same committed ticks lands on the same value everywhere. +#[test] +fn a3_3_the_same_ticks_in_any_order_give_the_same_time_holds() { + let _scope = ClusterScope::standalone(); + let ticks = [1_700_000_000_000u64, 1_700_000_000_500, 1_700_000_001_000]; + + let mut answers = Vec::new(); + // Every permutation, plus each one re-delivered. + for perm in [[0, 1, 2], [2, 1, 0], [1, 2, 0], [0, 2, 1], [2, 0, 1], [1, 0, 2]] { + cluster::leave(); + cluster::join(N1); + for i in perm { + cluster::apply_lease_tick(ticks[i]).unwrap(); + cluster::apply_lease_tick(ticks[i]).unwrap(); // re-delivery + } + answers.push(LeaseDeadline::try_from_now(60_000).unwrap()); + } + let first = answers[0]; + assert!( + answers.iter().all(|d| *d == first), + "GUARD FELL: two members that applied the same ticks in different orders computed \ + different deadlines: {answers:?}" + ); + assert_eq!(first.0, ticks[2] + 60_000, "the deadline is not tick + millis"); +} + +/// A node cannot be told the time by anything other than a tick, and a `leave` forgets it. +#[test] +fn a3_4_a_standalone_node_refuses_a_tick_and_a_leave_forgets_one_holds() { + let _scope = ClusterScope::standalone(); + assert!(matches!( + cluster::apply_lease_tick(1_700_000_000_000), + Err(GrantError::NotClustered { .. }) + )); + + cluster::join(N1); + cluster::apply_lease_tick(1_700_000_000_000).unwrap(); + assert_eq!(LeaseDeadline::try_now_millis().unwrap(), 1_700_000_000_000); + + cluster::leave(); + cluster::join(N2); + assert!( + LeaseDeadline::try_now_millis().is_err(), + "GUARD FELL: a rejoin kept the previous authority's clock" + ); +} + +// ================================================================================================= +// A3.5 — one bad tick, and the clock can never come back. +// ================================================================================================= + +/// `apply_lease_tick` takes any `u64` with no upper bound and folds it with `max`, so a single +/// out-of-range tick sets the cluster clock to that value **for ever**: monotonicity means no later +/// correct tick can bring it back. Every lease in the cluster is then expired, and reaping is +/// destructive and unrecoverable. +#[test] +fn a3_5_one_absurd_tick_does_not_permanently_expire_every_lease() { + let _scope = ClusterScope::standalone(); + let s = store("a3_5"); + let reaper = TwoTierReaper::new(Arc::clone(&s.catalog), Arc::clone(&s.store)); + + cluster::join(N1); + // A sane tick, and a branch with a long lease that is nowhere near expiry. + let real = 1_700_000_000_000u64; + cluster::apply_lease_tick(real).unwrap(); + let b = s.catalog.fork(BranchId::TRUNK, LeaseDeadline::try_from_now(86_400_000).unwrap()).unwrap(); + assert!( + reaper.reap_expired(LeaseDeadline::try_now_millis().unwrap()).unwrap().is_empty(), + "fixture: the branch was already expired" + ); + + // One tick from a buggy proposer, an unvalidated field, or a sign/units error. + cluster::apply_lease_tick(u64::MAX).unwrap(); + + // The cluster is told the correct time again, repeatedly. + for _ in 0..5 { + cluster::apply_lease_tick(real + 1_000).unwrap(); + } + let now = LeaseDeadline::try_now_millis().unwrap(); + let reaped = reaper.reap_expired(now).unwrap(); + + assert!( + reaped.is_empty() && now != u64::MAX, + "GUARD FELL: one tick of u64::MAX left the cluster clock at {now} and no later correct \ + tick could lower it (fold_tick is max). reap_expired then reaped {reaped:?} — the live \ + branch {:?} among them. Reaping bumps the BranchId generation, so this is unrecoverable, \ + and apply_lease_tick validates no bound on its input.", + b.branch_id + ); +} + +/// The mirror image: a tick far in the future makes two members disagree permanently, because the +/// one that saw it can never come back down to the one that did not. +#[test] +fn a3_6_a_spurious_future_tick_does_not_permanently_diverge_two_members() { + let _scope = ClusterScope::standalone(); + let real = 1_700_000_000_000u64; + + // Member A applies the committed ticks plus one spurious future entry — an uncommitted suffix + // a new leader later replaces, which consensus permits and this state machine cannot un-apply. + cluster::join(N1); + cluster::apply_lease_tick(real).unwrap(); + cluster::apply_lease_tick(real + 999_999_999).unwrap(); + cluster::apply_lease_tick(real + 1_000).unwrap(); + let a = LeaseDeadline::try_now_millis().unwrap(); + + // Member B applies only the committed ticks. + cluster::leave(); + cluster::join(N2); + cluster::apply_lease_tick(real).unwrap(); + cluster::apply_lease_tick(real + 1_000).unwrap(); + let b = LeaseDeadline::try_now_millis().unwrap(); + + assert_eq!( + a, b, + "GUARD FELL: after applying the same committed ticks, member A reads {a} and member B \ + reads {b} — a permanent {} ms divergence, because fold_tick is max and there is no way \ + to retract an applied tick. Exit criterion 9 is that two nodes never disagree here.", + a - b + ); +} + +// ================================================================================================= +// A3.7 — a wrong value out of `from_now` rather than a panic. +// ================================================================================================= + +/// `from_now` is `now.saturating_add(millis)`. Saturation is not a refusal: it turns a finite lease +/// into one that can never expire, which "silently stops reaping and defeats exit criterion 8 with +/// no symptom" — the exact outcome the panic doc says it refuses to produce. +#[test] +fn a3_7_from_now_does_not_silently_saturate_into_a_lease_that_never_expires() { + let _scope = ClusterScope::standalone(); + cluster::join(N1); + cluster::apply_lease_tick(1_700_000_000_000).unwrap(); + + let d = LeaseDeadline::try_from_now(u64::MAX).unwrap(); + assert_ne!( + d.0, + u64::MAX, + "GUARD FELL: try_from_now(u64::MAX) saturated to a deadline of u64::MAX, which \ + is_expired_at can never report as expired for any reachable clock. from_now's own \ + documentation calls a value in the future the outcome that 'silently stops reaping and \ + defeats exit criterion 8 with no symptom', and returns it here rather than refusing." + ); +} + +/// A lease minted while standalone is an absolute unix-millis deadline. The cluster clock is +/// whatever a `LeaseTick` said, and `apply_lease_tick` validates no relationship to unix time at +/// all — so a cluster ticking a logical counter evaluates pre-join leases against a different time +/// base. +#[test] +fn a3_8_a_lease_minted_standalone_is_still_evaluated_against_a_comparable_clock() { + let _scope = ClusterScope::standalone(); + let s = store("a3_8"); + let reaper = TwoTierReaper::new(Arc::clone(&s.catalog), Arc::clone(&s.store)); + + // Standalone: a branch whose lease expired an hour ago by the wall clock. + let wall = LeaseDeadline::now_millis(); + let b = s.catalog.fork(BranchId::TRUNK, LeaseDeadline(wall - 3_600_000)).unwrap(); + assert_eq!( + reaper.reap_expired(wall).unwrap(), + vec![b.branch_id], + "fixture: the expired branch was not reaped standalone" + ); + + // A second, identical branch, and this time the process joins a cluster whose leader ticks a + // logical clock — nothing in `apply_lease_tick` says the argument must be unix millis. + let s2 = store("a3_8b"); + let reaper2 = TwoTierReaper::new(Arc::clone(&s2.catalog), Arc::clone(&s2.store)); + let b2 = s2.catalog.fork(BranchId::TRUNK, LeaseDeadline(wall - 3_600_000)).unwrap(); + cluster::join(N1); + cluster::apply_lease_tick(1).unwrap(); + cluster::apply_lease_tick(2).unwrap(); + let reaped = reaper2.reap_expired(LeaseDeadline::try_now_millis().unwrap()).unwrap(); + + assert_eq!( + reaped, + vec![b2.branch_id], + "GUARD FELL: a branch that expired an hour ago (deadline {}, wall clock {wall}) is not \ + reaped on a cluster member, because the cluster clock is {} — apply_lease_tick accepts \ + any u64 and never checks it against unix time. Exit criterion 8 is silently off for \ + every lease minted before the join.", + wall - 3_600_000, + LeaseDeadline::try_now_millis().unwrap() + ); +} diff --git a/tests/adv_f4_atk4_locks_and_ranges.rs b/tests/adv_f4_atk4_locks_and_ranges.rs new file mode 100644 index 00000000..0313f908 --- /dev/null +++ b/tests/adv_f4_atk4_locks_and_ranges.rs @@ -0,0 +1,341 @@ +//! ATTACK 4 — the lock order, and the shape of a grant range. +//! +//! Two questions. +//! +//! **The lock order.** `cluster::authority_at()` takes a process mutex, `GrantedCounter::take` +//! takes a counter mutex, and `TxnManager::begin` holds the active-transaction table across both. +//! Three locks and a documented claim that the counter lock "is a leaf: nothing inside it calls +//! out". This hammers all three from many threads while the authority changes underneath them. +//! +//! **The range.** `ArenaPageStore::load_state` refuses an image whose `base_page` is not this +//! store's, because grafting another region's map on "would silently graft another arena's extents +//! onto this one's space". `apply_arena_grant` has no equivalent check, so these ask what a leader +//! can make a node allocate by proposing an odd range. +//! +//! Tests named `*_holds` assert a property the guard is supposed to have. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::{self, ClusterScope, GrantError, GrantedCounter}; +use ferrodb::consensus::NodeId; +use ferrodb::cow::PageStore; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const ARENA_BASE: u32 = 1024; +const N1: NodeId = NodeId(1); + +struct Store { + store: Arc, + _catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, _catalog: catalog, _dir: dir } +} + +fn txn_manager(tag: &str) -> (Arc, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join(format!("{tag}.wal"))).unwrap()); + let txn = Arc::new(TxnManager::new(Arc::clone(&wal), Arc::clone(&bp))); + bp.attach_wal(wal); + (txn, dir) +} + +// ================================================================================================= +// A4.1 — the three-lock deadlock hunt. +// ================================================================================================= + +/// Every lock in the chain, contended, while `join`/`leave` change the authority underneath. +/// +/// A deadlock here would hang the test binary rather than fail it, so progress is measured from +/// outside: each worker bumps a shared counter, and the test fails if the total stops advancing +/// while workers are still supposed to be running. That is the only signal that distinguishes +/// "wedged" from "slow" — a live thread count cannot. +#[test] +fn a4_1_hammering_authority_counter_and_att_locks_does_not_deadlock_holds() { + let _scope = ClusterScope::standalone(); + let (txn, _dir) = txn_manager("a4_1"); + let s = store("a4_1_arena"); + let counter = Arc::new(GrantedCounter::new("probe", 0, 16)); + + let stop = Arc::new(AtomicBool::new(false)); + let progress = Arc::new(AtomicU64::new(0)); + let mut handles = Vec::new(); + + // 1. begin(): holds `att`, then takes the process lock, then the counter lock. + for _ in 0..4 { + let (txn, stop, progress) = (Arc::clone(&txn), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let _ = txn.begin(); + let _ = txn.begin_snapshot_read(); + let _ = txn.next_txn_id(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 2. the bare counter: process lock then counter lock, no `att`. + for _ in 0..4 { + let (c, stop, progress) = + (Arc::clone(&counter), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + let _ = c.take(1); + let _ = c.apply_grant(N1, 0, 4_096); + let _ = c.remaining(); + let _ = c.issued_through(); + c.raise_issued_through(1); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 3. the arena store: free-extent mutex, state mutex, two counters. + for _ in 0..4 { + let (st, stop, progress) = (Arc::clone(&s.store), Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + if let Ok(a) = st.arena_for(BranchId::TRUNK) { + let _ = st.free_arena(a); + } + let _ = st.apply_arena_grant(N1, ARENA_BASE, 4 * ARENA_EXTENT_PAGES); + let _ = st.grantable_extents(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + // 4. the authority itself, changing under all of the above. + for _ in 0..2 { + let (stop, progress) = (Arc::clone(&stop), Arc::clone(&progress)); + handles.push(std::thread::spawn(move || { + while !stop.load(Ordering::Relaxed) { + cluster::join(N1); + let _ = cluster::authority_at(); + let _ = cluster::apply_lease_tick(1_700_000_000_000); + cluster::leave(); + let _ = cluster::epoch(); + progress.fetch_add(1, Ordering::Relaxed); + } + })); + } + + // Watch for a stall. Poll on the artifact — the progress counter — not on a timer alone. + let mut last = 0u64; + let mut stalls = 0; + for round in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + let now = progress.load(Ordering::Relaxed); + if now == last { + stalls += 1; + assert!( + stalls < 8, + "GUARD FELL (deadlock): progress stopped at {now} operations after round {round} \ + and did not advance for 8 consecutive 50 ms polls with 14 workers running." + ); + } else { + stalls = 0; + } + last = now; + } + stop.store(true, Ordering::Relaxed); + + // Every worker must be joinable. A worker still holding a lock never returns from join(). + for h in handles { + h.join().expect("a worker panicked"); + } + let total = progress.load(Ordering::Relaxed); + assert!(total > 1_000, "the stress loop barely ran ({total} ops); this proves nothing"); + println!("a4_1: {total} operations across 14 threads, no stall"); +} + +// ================================================================================================= +// A4.2 — what a leader can make a node allocate. +// ================================================================================================= + +/// A grant that reaches below the store's own region. `load_state` refuses a foreign `base_page`; +/// `apply_arena_grant` performs no such check, so the only thing standing in the way is that the +/// extent counter is *seeded* at `base_page`, which makes `Grants::apply_grant` clamp `lo` up. +#[test] +fn a4_2_a_grant_below_the_region_base_cannot_produce_an_extent_outside_it_holds() { + let _scope = ClusterScope::standalone(); + let s = store("a4_2"); + cluster::join(N1); + + // A leader that thinks the file starts at page 0 — where the heap and index pages live. The + // range must STRADDLE the base, or the whole grant is swallowed as a duplicate (the counter is + // seeded at `base_page`, so `accepted_through` starts at 1024) and this test would pass by + // allocating nothing at all. + s.store.apply_arena_grant(N1, 0, 8 * ARENA_EXTENT_PAGES).unwrap(); + let mut produced = Vec::new(); + let mut outside = Vec::new(); + for _ in 0..8 { + match s.store.arena_for(BranchId::TRUNK) { + Ok(a) => { + if let Some((start, count)) = s.store.extent_range(a) { + produced.push((a, start)); + if start < ARENA_BASE { + outside.push((a, start, count)); + } + } + } + Err(_) => break, + } + } + // Force the detector to fire: a pass is only meaningful if extents were actually allocated + // from a grant that reached below the base. + assert!( + !produced.is_empty(), + "DETECTOR DID NOT FIRE: the straddling grant produced no extent, so this test proves \ + nothing about containment" + ); + assert!( + outside.is_empty(), + "GUARD FELL: a grant of [0, {}) produced extent(s) {outside:?} below the store's region \ + base {ARENA_BASE}. Those pages hold heap and index data. (all extents: {produced:?})", + 8 * ARENA_EXTENT_PAGES + ); + println!("a4_2: straddling grant [0, {}) produced {produced:?}", 8 * ARENA_EXTENT_PAGES); +} + +/// An empty and a backwards range. +#[test] +fn a4_3_empty_and_backwards_grants_are_refused_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("probe", 0, 4); + cluster::join(N1); + + assert!(matches!(c.apply_grant(N1, 500, 500), Err(GrantError::EmptyRange { .. }))); + assert!(matches!(c.apply_grant(N1, 900, 500), Err(GrantError::EmptyRange { .. }))); + assert_eq!(c.remaining(), 0, "a refused range left usable space"); + + let s = store("a4_3"); + // page_count 0 through the real wiring. + assert!(s.store.apply_arena_grant(N1, 4_096, 0).is_err(), "a zero-page grant was accepted"); + assert!(s.store.arena_for(BranchId::TRUNK).is_err(), "an empty grant produced an extent"); +} + +/// A grant whose top does not fit a `PageId`. `reserve` converts with `u32::try_from` and refuses, +/// rather than truncating and aliasing page 0. +#[test] +fn a4_4_a_grant_past_the_page_id_space_refuses_rather_than_truncates_holds() { + let _scope = ClusterScope::standalone(); + let c = GrantedCounter::new("extent-start", u32::MAX as u64 - 10, ARENA_EXTENT_PAGES as u64); + cluster::join(N1); + + // A range that starts inside u32 and ends outside it. + c.apply_grant(N1, u32::MAX as u64 - 10, u32::MAX as u64 + 5_000).unwrap(); + let v = c.take(ARENA_EXTENT_PAGES as u64).unwrap(); + assert!( + u32::try_from(v).is_err() || v <= u32::MAX as u64, + "a value was issued that is neither a valid page id nor refused: {v}" + ); + // And the real wiring must turn that into a refusal, not a truncation. + let s = store("a4_4"); + // u32 arithmetic in apply_arena_grant: first_page + page_count as u64 cannot overflow u64, so + // the widest possible grant is representable and the guard has to be the take-side conversion. + let widest = s.store.apply_arena_grant(N1, u32::MAX - 1, u32::MAX); + let got = s.store.arena_for(BranchId::TRUNK); + if let Ok(a) = got { + let range = s.store.extent_range(a); + // The extent START fits a u32, so `reserve`'s try_from lets it through — but the extent is + // ARENA_EXTENT_PAGES wide, so its pages run off the end of the page-id space. Find out what + // allocating inside it does. + let alloc = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + s.store.alloc_in_arena(a, ferrodb::cow::PageType::BTreeLeaf, ferrodb::branch::types::Epoch(1)) + })); + panic!( + "GUARD FELL: apply_arena_grant({N1}, {}, {}) returned {widest:?} and arena_for \ + produced arena {a} at {range:?}. reserve() checks only that the extent START fits a \ + PageId; the extent is {ARENA_EXTENT_PAGES} pages wide, so pages {}..{} run past \ + u32::MAX. Allocating in it: {}", + u32::MAX - 1, + u32::MAX, + u32::MAX - 1, + (u32::MAX as u64 - 1) + ARENA_EXTENT_PAGES as u64, + match &alloc { + Ok(r) => format!("{r:?}"), + Err(_) => "PANICKED (arithmetic overflow on start_page + next_free)".to_string(), + } + ); + } +} + +// ================================================================================================= +// A4.5 — arena ids and page ids share one number space. +// ================================================================================================= + +/// `apply_arena_grant` applies the *same* `[lo, hi)` to both the extent-start counter and the +/// arena-id counter, so in a cluster an arena id is a page number. Standalone arena ids start at 1. +/// A2.9 found no collision only because the grant range was high; this asks what happens when it is +/// not. +#[test] +fn a4_5_a_low_page_grant_cannot_collide_with_standalone_arena_ids() { + let _scope = ClusterScope::standalone(); + + // Node A, its own leader. `free_arena` returns the extent start to the recycle stack and + // `reserve` pops it, so pages are reused while arena ids keep climbing — which is how a node + // reaches arena id 1024 without a 262144-page file. + let a = store("a4_5_solo"); + let mut solo = Vec::new(); + for _ in 0..(ARENA_BASE as usize + 40) { + let arena = a.store.arena_for(BranchId::TRUNK).unwrap(); + solo.push(arena); + a.store.free_arena(arena).unwrap(); + } + // Force the detector to fire: the collision is only reachable if standalone ids climbed into + // the page-number range a leader would grant. + let highest = solo.iter().map(|x| x.0).max().unwrap(); + assert!( + highest >= ARENA_BASE, + "DETECTOR DID NOT FIRE: standalone arena ids only reached {highest}, below the region \ + base {ARENA_BASE}, so no grant this test can make would overlap them" + ); + + // Node B, granted the region base onwards — pages [1024, 1536), hence arena ids [1024, 1536). + cluster::join(N1); + let b = store("a4_5_member"); + b.store.apply_arena_grant(N1, ARENA_BASE, 2 * ARENA_EXTENT_PAGES).unwrap(); + let member = b.store.arena_for(BranchId::TRUNK).expect( + "DETECTOR DID NOT FIRE: the member could not allocate from its grant, so no arena id was \ + issued to compare", + ); + + assert!( + !solo.contains(&member), + "GUARD FELL: arena id {member} was issued both by node A's standalone self-grant \ + (standalone ids reached {highest}) and by a leader grant of pages \ + [{ARENA_BASE}, {}) to {N1}. apply_arena_grant feeds ONE range to BOTH counters, so a \ + page range doubles as an arena-id range and the two number spaces are the same. \ + BranchRecord::arenas then names one arena for two branches, and the reaper frees exactly \ + record.arenas.", + ARENA_BASE + 2 * ARENA_EXTENT_PAGES + ); +} diff --git a/tests/adv_f4_atk5_meaning_changed.rs b/tests/adv_f4_atk5_meaning_changed.rs new file mode 100644 index 00000000..7fbf6528 --- /dev/null +++ b/tests/adv_f4_atk5_meaning_changed.rs @@ -0,0 +1,167 @@ +//! ATTACK 5 — single-node behaviour that changed meaning, and coverage the existing suite does not +//! actually provide. +//! +//! `tests/integration_cluster_grants.rs` states the case for single-node safety as: *"Single-node +//! ferrodb therefore drives the whole grant machinery on every allocation, which is what makes the +//! 1349 existing tests evidence that the machinery works rather than evidence that it is +//! bypassed."* This file measures the two halves of that: which allocations actually reach a +//! `GrantedCounter`, and one API whose semantics changed silently. +//! +//! Nothing here is about a cluster. Every test runs standalone. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cluster::ClusterScope; +use ferrodb::cow::{PageStore, PageType}; +use ferrodb::storage::disk_manager::DiskManager; + +const ARENA_BASE: u32 = 1024; + +struct Store { + store: Arc, + catalog: Arc, + _dir: tempfile::TempDir, +} + +fn store(tag: &str) -> Store { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join(format!("{tag}.db"))) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(Arc::clone(&dm))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), ARENA_BASE).unwrap()); + Store { store, catalog, _dir: dir } +} + +// ================================================================================================= +// A5.1 — how much of the allocation path the existing suite actually exercises. +// ================================================================================================= + +/// The extent-start watermark is the only observable proof that a `GrantedCounter::take` happened. +/// This measures how many page allocations move it: one per **extent**, not one per page. So the +/// existing suite's coverage of the grant machinery is coverage of extent *claims*, and the +/// per-page path (`alloc_in_arena`) reaches no counter at all — which is why the cluster guard is +/// absent there (see ATTACK 1). +#[test] +fn a5_1_measure_which_allocations_reach_a_granted_counter() { + let _scope = ClusterScope::standalone(); + let s = store("a5_1"); + let epoch = s.catalog.next_epoch(); + + let arena = s.store.arena_for(BranchId::TRUNK).unwrap(); + let after_claim = s.store.extent_watermark(); + + let mut moves = 0; + let mut prev = after_claim; + for _ in 0..(ARENA_EXTENT_PAGES - 1) { + s.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let now = s.store.extent_watermark(); + if now != prev { + moves += 1; + prev = now; + } + } + + println!( + "a5_1: {} page allocations inside a claimed extent moved the extent-start watermark {} \ + time(s). Watermark after the claim: {after_claim}; after {} allocations: {prev}.", + ARENA_EXTENT_PAGES - 1, + moves, + ARENA_EXTENT_PAGES - 1 + ); + assert_eq!( + moves, 0, + "the per-page path does reach the counter after all, which would contradict ATTACK 1" + ); +} + +// ================================================================================================= +// A5.2 — `load_state` silently stopped being able to lower a watermark. +// ================================================================================================= + +/// Before 4168c16, `load_state` did: +/// +/// ```text +/// self.space.next_extent_start.store(next_start, Ordering::SeqCst); +/// self.space.next_arena_id.store(next_arena, Ordering::SeqCst); +/// ``` +/// +/// an unconditional **set**. It now calls `GrantedCounter::raise_issued_through`, which is +/// documented as "Monotone, and it never lowers." That is a safer rule, and it is a different rule: +/// loading an older image into a store that has advanced past it no longer rewinds the counter, +/// while the `extents` map, `current`, `reserved_pages` and `live_pages` around it ARE replaced +/// wholesale. +/// +/// This pins the new behaviour and the resulting split between the two. No existing test covers the +/// rewind direction — every one of them loads into a freshly constructed store, where max and set +/// agree. +#[test] +fn a5_2_load_state_no_longer_rewinds_the_watermark_but_does_rewind_the_map() { + let _scope = ClusterScope::standalone(); + let s = store("a5_2"); + let epoch = s.catalog.next_epoch(); + + // Image taken at one extent. + let a1 = s.store.arena_for(BranchId::TRUNK).unwrap(); + s.store.alloc_in_arena(a1, PageType::BTreeLeaf, epoch).unwrap(); + let old_image = s.store.state_bytes(); + let w1 = s.store.extent_watermark(); + let reserved1 = s.store.reserved_page_count(); + + // Advance well past it: three more extents on three more branches. + for _ in 0..3 { + let b = s + .catalog + .fork(BranchId::TRUNK, ferrodb::branch::types::LeaseDeadline(u64::MAX / 2)) + .unwrap() + .branch_id; + let a = s.store.arena_for(b).unwrap(); + s.store.alloc_in_arena(a, PageType::BTreeLeaf, epoch).unwrap(); + } + let w2 = s.store.extent_watermark(); + let reserved2 = s.store.reserved_page_count(); + assert!(w2 > w1, "fixture: the store did not advance ({w1} -> {w2})"); + + // Load the OLD image back in. + s.store.load_state(&old_image).unwrap(); + let w3 = s.store.extent_watermark(); + let reserved3 = s.store.reserved_page_count(); + + println!( + "a5_2: watermark {w1} -> {w2} -> {w3} after loading the {w1}-era image; \ + reserved_pages {reserved1} -> {reserved2} -> {reserved3}" + ); + + // The new rule. + assert_eq!( + w3, w2, + "raise_issued_through is documented as monotone, so loading an older image must keep the \ + higher watermark" + ); + // And the half that is NOT monotone: the map went back. + assert_eq!( + reserved3, reserved1, + "reserved_pages is replaced wholesale by the image, unlike the watermark" + ); + assert!( + reserved3 < reserved2, + "MEANING CHANGED: load_state keeps the advanced watermark ({w3}) while resetting \ + reserved_pages to the image's value ({reserved3}, down from {reserved2}). Pages \ + [{reserved3}..{reserved2}) worth of extents are still counted as issued by the counter \ + but are no longer in the extents map, so they can never be reclaimed. Pre-4168c16 the \ + `store` call rewound both together. Exit criterion 8 is stated in reserved_pages, and no \ + existing test loads an image into an advanced store." + ); +} From f61897397140d22677cab3120cc33c1b87329b85 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:16:49 -0400 Subject: [PATCH 013/315] =?UTF-8?q?test(F7):=20attack=20the=20MAC=20?= =?UTF-8?q?=E2=80=94=20external=20vectors,=20a=20working=20forgery,=20and?= =?UTF-8?q?=20a=20mutation=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial probes against the claim that `verify_frame` accepts only bytes this key signed and `hmac_sha256` is a correct HMAC-SHA256. Nothing forged. Every expected value comes from CPython's hmac/hashlib, never from this crate: 914 rows over two generators (kept in scripts/), one walking key and message lengths across every SHA-256 block and padding boundary the 26-byte DOMAIN prefix can straddle, one walking random keys and bodies so an affine input cannot hide a content-dependent bug. The forgery machinery is proved before it is aimed: a second SHA-256 written in the test file mounts a real length-extension attack against sha256(key||msg) and succeeds, then fails against hmac_sha256 and Key::tag at every plausible prefix length. Also swept: all 576 single-bit flips of a signed frame, all 256 keys one bit from the real one, 157 splices of two genuine frames, every truncation and extension, every mac length 0..=64, and every two- and three-part split of the streaming hash. Two RFC 2104 equivalence classes are pinned because they are facts about key FILES, not just about HMAC: a key and the same key zero-padded to the block are one key, and a key over the block and its own digest are one key. A 32-byte file of NULs loads and is the empty key that Key::load refuses at zero length. The header carries the mutation record: seven changes to the subject, six caught here, one (a short-circuiting constant_time_eq) caught only by tests_signing. --- scripts/gen_f7_attack_random_vectors.py | 55 + scripts/gen_f7_attack_vectors.py | 36 + src/consensus/signing.rs | 4 + src/consensus/tests_f7_attack.rs | 1811 +++++++++++++++++++++++ 4 files changed, 1906 insertions(+) create mode 100644 scripts/gen_f7_attack_random_vectors.py create mode 100644 scripts/gen_f7_attack_vectors.py create mode 100644 src/consensus/tests_f7_attack.rs diff --git a/scripts/gen_f7_attack_random_vectors.py b/scripts/gen_f7_attack_random_vectors.py new file mode 100644 index 00000000..bfb8db66 --- /dev/null +++ b/scripts/gen_f7_attack_random_vectors.py @@ -0,0 +1,55 @@ +import hmac, hashlib + +DOMAIN = b"ferrodb/consensus/mac/1\x00" + bytes([ord('C'), 2]) + +class Xs: + """xorshift64*, seeded per row. Reproduced byte for byte in the Rust test.""" + def __init__(self, seed): + self.s = seed & 0xFFFFFFFFFFFFFFFF + def next(self): + x = self.s + x ^= (x >> 12) & 0xFFFFFFFFFFFFFFFF + x ^= (x << 25) & 0xFFFFFFFFFFFFFFFF + x ^= (x >> 27) & 0xFFFFFFFFFFFFFFFF + self.s = x & 0xFFFFFFFFFFFFFFFF + return (self.s * 0x2545F4914F6CDD1D) & 0xFFFFFFFFFFFFFFFF + def bytes(self, n): + return bytes(self.next() & 0xFF for _ in range(n)) + +print("/// (seed, key_len, msg_len, expected) -- pseudo-random key AND message bytes, so the tables") +print("/// above are not all affine sequences. `Xorshift` below reproduces the generator exactly.") +print("const RANDOM_VECTORS: &[(u64, usize, usize, &str)] = &[") +rows = 0 +for i in range(200): + seed = 0x9E3779B97F4A7C15 ^ (i * 0x100000001B3) + seed &= 0xFFFFFFFFFFFFFFFF + if seed == 0: seed = 1 + r = Xs(seed) + key_len = 1 + (r.next() % 200) + msg_len = r.next() % 300 + k = r.bytes(key_len) + m = r.bytes(msg_len) + print(' (%d, %d, %d, "%s"),' % (seed, key_len, msg_len, hmac.new(k, m, hashlib.sha256).hexdigest())) + rows += 1 +print("];") +print() +print("/// The same rows, but authenticated the way a frame is: HMAC over DOMAIN || body. Only rows") +print("/// whose key reaches the 32-byte minimum, because those are the ones a `Key` can hold.") +print("const RANDOM_TAG_VECTORS: &[(u64, usize, usize, &str)] = &[") +tag_rows = 0 +for i in range(200): + seed = 0x9E3779B97F4A7C15 ^ (i * 0x100000001B3) + seed &= 0xFFFFFFFFFFFFFFFF + if seed == 0: seed = 1 + r = Xs(seed) + key_len = 1 + (r.next() % 200) + msg_len = r.next() % 300 + k = r.bytes(key_len) + m = r.bytes(msg_len) + if key_len < 32: + continue + print(' (%d, %d, %d, "%s"),' % (seed, key_len, msg_len, hmac.new(k, DOMAIN + m, hashlib.sha256).hexdigest())) + tag_rows += 1 +print("];") +import sys +print("// rows=%d tag_rows=%d" % (rows, tag_rows), file=sys.stderr) diff --git a/scripts/gen_f7_attack_vectors.py b/scripts/gen_f7_attack_vectors.py new file mode 100644 index 00000000..79cf47b4 --- /dev/null +++ b/scripts/gen_f7_attack_vectors.py @@ -0,0 +1,36 @@ +import hmac, hashlib + +DOMAIN = b"ferrodb/consensus/mac/1\x00" + bytes([ord('C'), 2]) +assert len(DOMAIN) == 26, len(DOMAIN) + +def key(n): return bytes((i*7 + 3) & 0xff for i in range(n)) +def msg(n): return bytes((i*13 + 129) & 0xff for i in range(n)) + +def H(k, m): return hmac.new(k, m, hashlib.sha256).hexdigest() + +KEY_LENS = [0,1,20,31,32,33,63,64,65,66,96,128,129,131,200] +MSG_LENS = [0,1,25,31,32,33,54,55,56,63,64,65,111,112,119,120,127,128,129,191,192,1000] + +print("/// (key_len, msg_len, expected) -- HMAC-SHA256 over the byte patterns in `key_of`/`msg_of`,") +print("/// computed by CPython's `hmac`/`hashlib`, never by this crate. Generator command in the") +print("/// module header.") +print("const HMAC_VECTORS: &[(usize, usize, &str)] = &[") +for kl in KEY_LENS: + for ml in MSG_LENS: + print(' (%d, %d, "%s"),' % (kl, ml, H(key(kl), msg(ml)))) +print("];") +print() + +# Body lengths chosen around every block boundary the 26-byte domain prefix can straddle: +# the inner hash absorbs 64 bytes of ipad, then DOMAIN, then the body, so the boundary that +# matters is 26+n mod 64, and the length-padding boundary is 26+n == 56 (mod 64). +BODY_LENS = [0,1,25,26,27,28,29,30,31,32,33,36,37,38,39,40,53,54,55,63,64,65, + 90,93,94,95,101,102,103,127,128,129,166,167,1000,4096] +TAG_KEY_LENS = [32,33,63,64,65,131] +print("/// (key_len, body_len, expected) -- HMAC-SHA256 over DOMAIN || body_of(body_len), i.e. what") +print("/// `Key::tag` must produce. Same external generator.") +print("const TAG_VECTORS: &[(usize, usize, &str)] = &[") +for kl in TAG_KEY_LENS: + for bl in BODY_LENS: + print(' (%d, %d, "%s"),' % (kl, bl, H(key(kl), DOMAIN + msg(bl)))) +print("];") diff --git a/src/consensus/signing.rs b/src/consensus/signing.rs index 127031c3..e6cb0d98 100644 --- a/src/consensus/signing.rs +++ b/src/consensus/signing.rs @@ -626,3 +626,7 @@ pub fn verify_frame<'a>(key: &Key, signed: &'a [u8]) -> Result<&'a [u8], FerroEr #[cfg(test)] #[path = "tests_signing.rs"] mod tests_signing; + +#[cfg(test)] +#[path = "tests_f7_attack.rs"] +mod tests_f7_attack; diff --git a/src/consensus/tests_f7_attack.rs b/src/consensus/tests_f7_attack.rs new file mode 100644 index 00000000..259ef747 --- /dev/null +++ b/src/consensus/tests_f7_attack.rs @@ -0,0 +1,1811 @@ +//! Throwaway adversarial probes against F7's MAC. **Not a contract** — these exist to try to break +//! [`super::verify_frame`] and [`super::hmac_sha256`], not to describe what they promise. +//! +//! Four things are deliberately different from `tests_signing.rs`: +//! +//! * **Every expected value comes from CPython's `hmac`/`hashlib`, not from this crate.** The +//! tables below were emitted by `scripts/gen_f7_attack_vectors.py` (kept beside this file); +//! a test whose expectation is produced by calling the subject proves only that the subject is +//! deterministic. +//! * **The forgery machinery is proved to work before it is aimed at HMAC.** A detector that finds +//! nothing is not a clean result until it has been made to fire, so +//! `length_extension_forges_a_sha256_key_prefix_mac` mounts a real length-extension attack +//! against `sha256(key || message)` and shows it succeeding, using a second SHA-256 written in +//! this file. Only then is the same attack aimed at `hmac_sha256`. +//! * **The tables are two generators, not one.** `scripts/gen_f7_attack_vectors.py` walks the +//! boundaries with affine byte patterns; `scripts/gen_f7_attack_random_vectors.py` walks random +//! keys and messages, because an affine input can hide a bug that depends on key content. +//! * **The boundaries are swept, not sampled.** Bodies are chosen at every offset where the +//! 26-byte [`DOMAIN`] prefix can straddle a SHA-256 block or its length padding. +//! +//! # What these tests were shown to catch +//! +//! Run once green, a suite proves nothing. Each line below is a change made to the **subject** — +//! `signing.rs` or `provenance/sha256.rs` — followed by the tests here that failed because of it. +//! The tree was restored and re-verified byte for byte after every one. +//! +//! | change to the subject | tests killed | +//! |---|---| +//! | RFC 2104's long-key rule `> BLOCK` becomes `>= BLOCK` | 8 | +//! | `Sha256::update`'s partial-block fill off by one (`64 - used` -> `63 - used`) | 8 | +//! | `verify_frame`'s short-frame guard removed | 2 | +//! | `Key::tag` drops the [`DOMAIN`] prefix | 3 | +//! | `Sha256::finish` writes the length at the wrong offset (`!= 56` -> `< 56`) | 10 | +//! | `Key::verify` silently compares only the first 16 tag bytes | 2 | +//! | `constant_time_eq` short-circuits on the first differing byte | **0 — see below** | +//! +//! The last row is this file's blind spot, stated rather than left to be found: nothing here times +//! anything, so a comparison that leaks the position of the first wrong byte reads as correct to +//! every test in this module. It is not a gap in the repo — the same change fails +//! `tests_signing::constant_time_eq_does_not_short_circuit`, which is where that property lives. + +use super::*; +use crate::consensus::transport::{decode_verified, encode_signed}; +use crate::consensus::{Body, Message, NodeId}; +use crate::provenance::sha256::{sha256, to_hex, Sha256}; + +// --------------------------------------------------------------------------------------------- +// The inputs the external generator used. Any change here invalidates every table below. +// --------------------------------------------------------------------------------------------- + +fn key_of(n: usize) -> Vec { + (0..n).map(|i| (i.wrapping_mul(7).wrapping_add(3) & 0xff) as u8).collect() +} + +fn msg_of(n: usize) -> Vec { + (0..n).map(|i| (i.wrapping_mul(13).wrapping_add(129) & 0xff) as u8).collect() +} + +fn a_key(n: usize) -> Key { + Key::from_bytes_for_test(key_of(n)).expect("the generator only offers keys at or over the minimum") +} + +/// (key_len, msg_len, expected) -- HMAC-SHA256 over the byte patterns in `key_of`/`msg_of`, +/// computed by CPython's `hmac`/`hashlib`, never by this crate. Generator command in the +/// module header. +const HMAC_VECTORS: &[(usize, usize, &str)] = &[ + (0, 0, "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad"), + (0, 1, "54674c387e174ab90643086996573ebfb4bf494efd9f2f594dcac1ec0f63d63a"), + (0, 25, "a6f4c1a8784bfa4204336dc986da0a4ffbc64e35bcae440090f27aced77590ee"), + (0, 31, "93a92824d837aba0bdf07269d85855574ee00f4242dd60f55ba87064c6b307fd"), + (0, 32, "b2f67f2e9f754c54b5300255597d6effa0d3f1a5aa45e625a4f7c655c9e189e3"), + (0, 33, "c68c4249f762d1e83e2f5686539ba98e27e0fe1868ec539027a50e483f696370"), + (0, 54, "53023c49047a6c24766be461faa4829e922ac71cc5f0b360db371c1ccc676ce1"), + (0, 55, "ba19688a2499b39a9076f2d9b9c99a3b47d95f39031c3bbdfa6f959c63173dcf"), + (0, 56, "cba36b78e23fa08ea945d06e4a1c44386bcf703a17a8795738e1d3aaf26fdc0a"), + (0, 63, "53092bab1ee2d88b85842b30ba9185a2c0ca67b4cf464d02568d0ad8e152bbff"), + (0, 64, "29412f1d7da7bd8ae83223951c6f8c0b16be450feaf65a7ad989f214a308b2fc"), + (0, 65, "fc7d6ed47c750e852c161f56dcc7c81ac86df78694f54380ccb903a43e329b8e"), + (0, 111, "0404bb43870a5ffd2ea40b0e52415aea6aeb1a92d5489825df1626a953fa6637"), + (0, 112, "67e743b1020f169e09591914516445ff2df39fa008716c0db04b8cd61d79fe16"), + (0, 119, "167cafe99101070d197c85783887ed77b04dcb19983998be53535f13171eb51e"), + (0, 120, "744394ccddbc799f790728eacf3ba68671ef14668de65e5b6436a6ab5cda1c04"), + (0, 127, "4bd946a195a4cd47ef6d6f4700e36984d922dfaa9d722064714447879b1802b4"), + (0, 128, "9521d6715f88bfe0f38f941d05042fcb9b07fb7aba9037e6c65e6389804a4be6"), + (0, 129, "166527002e9a1030583075e67d12b5bf4694730a1075a921589cd788ea98649b"), + (0, 191, "d3203fb2f41e297e88f662eb1164b7a104f78f8a517f311146311494986fd0df"), + (0, 192, "5bf4cf291fe9f031811a264ab56e4e67f661764fc8a6831e9a14958953d68194"), + (0, 1000, "945cc55b7fb4bbb91e89a1f806fa6fed0d238605f6b675fdb1f28f5cee8f6a59"), + (1, 0, "be8d44d01aa938627809668b7364a21ed8e9fc8cffa64d18846d126789d4cef8"), + (1, 1, "5265b2b1ea6c33e63e444be356e8919c17b56be0fb59fe5539d1737651a66248"), + (1, 25, "5dbfeb9c9d872a94f1c15bd105787edcead39398d07c5e96b5f957af80d6054b"), + (1, 31, "756398d747558f0c8614435837c6c24607457bdeeba2fc33ac88bb9cf4f01590"), + (1, 32, "7829b5081ba6f2d6d91706734c604aad0720c3b9687762416e6bca751d2cf38a"), + (1, 33, "8e2061fe84fc94914cd0cc3489fbc31f07e8e7117283599e2a4ce81181397843"), + (1, 54, "a9d72a9a085c8f81b1cb82ef260effcb9bfa6b8da9ce2d7527844b3be4478768"), + (1, 55, "0cf20fa1cdf2882196f8f7702989e06bc776c717be0bd1c232c8bff5d4f0251e"), + (1, 56, "225ac16c81c82f6d8c573b70d84ac843cf839b36e1fccb20dd424a7ff06023ef"), + (1, 63, "e62abdffef42dd92894139ce8c4901747de77798463c8131c0b25539589d16a9"), + (1, 64, "3c795e092da4dc00c2aef59199ce44801e60d3cfa0f92b974abe18ad2c038c2d"), + (1, 65, "2fc0d8993ced6465f5c5d1904faa2e226abea3899fdc9ae271ebe72c40ae4d1a"), + (1, 111, "882346c2769057242b731f5612a7c52219b4ff76b166a8536be40a37213a07ae"), + (1, 112, "92b15346919756947a0ed0f284be26351b8a02d1ab7cca5cd99b7ffc57890f86"), + (1, 119, "37494fba71061d18283beee4ba11e9243ab91979fe09dc216248b5719ad2aaeb"), + (1, 120, "f4ae067e8745dca9a90f39a8cc0cc413bbd455a118df6ab3edfb92038f41afa2"), + (1, 127, "6a83806457a27fe6f2981a9c754f914b30bfa3c52a511c2ab46b7015972bd8d0"), + (1, 128, "21ae55062c40c83d956cf8e861b8a4bcc0a9b5986d07d9a80ae26c929239191a"), + (1, 129, "9e45cd0750f54bbb1399cdcd8a6da34bdf0dbd46b1c021f1713a00e1839dc4f7"), + (1, 191, "19f962f588783545af0a200755f670bd4f36fb0265edfe474a43c535d3f3367b"), + (1, 192, "c2ce9aff5753f93b36c9a6ae4d93c588997af98f66bb7509a05768afcb63064b"), + (1, 1000, "3e9f85606aec993ec58b83ee887eafb9ea72994809f2aad7f555c8d5495ec9ca"), + (20, 0, "d0d6d58c87cbf4a52bfab018ab92a073effa0bffba4fde91df30c8278175d293"), + (20, 1, "2eb2eba6725311dfb0583af299239b60e75e8520b726d42d128ed2f9c4a1619c"), + (20, 25, "9bb4b43380e080b9f3a938db2f5f9f03d962283081bf0f7e1e39bf67c5853012"), + (20, 31, "807d2d41a2358f153b4f0c415c63b1168dedcce7d51d6c8c35ab2f61d6bdd80a"), + (20, 32, "ed02a5bea3f3cb9df86142b269c7af8e30c3293d9bb2012e6100203e4c726660"), + (20, 33, "1bd4604770b55434445dd2419c00c28758ea5792429bc729ceaeb45c3cf91e1f"), + (20, 54, "3d31c2081e42c3ba883ca4cd13c680445071a8360718d9b2ee930bc00e5bc30a"), + (20, 55, "de1945bded3f59cb9d1cc7988563fd39279bad0f2a0614cd0edb0413602008d4"), + (20, 56, "ea5e58d06c684357b0761444345d8af725194db7c1e036826e5aa959ea04b07c"), + (20, 63, "2e296fddb13df4e8b7d3804a7c52bd2298abe17283e991af8ecfada202b6f20e"), + (20, 64, "3c23618fc31342fa3613868df3c83041d99d5ad25f913c9962657b8ad35d0580"), + (20, 65, "d048382180d5bc0956af010304c5c240a28443d565a74889117a271b69a55cd4"), + (20, 111, "d9bb6602a99d7990196aa57331b5a3a2abd62bd0c043ed1a3596f9dbd8989fed"), + (20, 112, "f457aee71191cc7e52bb4a75b80bd50ba7f333f4e9814f709b0f29895d90cdd4"), + (20, 119, "3c692f8d444d054e91d9c6376485a88f72ab3637b9cc30e036914136d73a06a1"), + (20, 120, "f6696de78181eeda961decc88bcb4c5ae497c711e1f35feccc9dfba444d5492f"), + (20, 127, "4726fadd2da71b52b58b1af38531c36cbf2323e8aebe674c81b11be06b32e776"), + (20, 128, "e93ef5796e169322abaa5c2c10d6d6e0de0d7c9f31f5c6433d7b02c2a3114481"), + (20, 129, "03ddcb21cb9be78f17e70735d69500cfb502c2c3b42e1307d5a0f72c6721e41f"), + (20, 191, "a065929a01eecc05bf360d63259e50efbe951fd1889bf02310b9443e42f0c373"), + (20, 192, "58f1cb04bba451412ea3de55f8696249666f256c64bb3cd90f5cf1bdfa066eff"), + (20, 1000, "65e642b73dffd236bebbf17a1196e9c7b1f57e04432389a6b79e0f7c81d9b79b"), + (31, 0, "88abe9f75c9a786672ea50bc51b4dba2a9e62e985bb1df72f8b5ac5f66dab511"), + (31, 1, "5493456e657ed7b5b5478c3e90e2c27971eb42c36335057c17bdd941692bec6c"), + (31, 25, "972c7c3d1baa0aea5bc7503d9718a4bbc80d1681026b917fccf93b2d98ee6b30"), + (31, 31, "f2f849c62a7bc8da3d0d70b4d26641542f4efed69e140829f64d7b1674ea0346"), + (31, 32, "7cca6023e6d558b8dca0c5d968075245cad6c4776a83544f05a9e00d10aa11b4"), + (31, 33, "6c6c2383a9668b70326decf9ae459edd31560074af5c18e5abd7817f16c79913"), + (31, 54, "98e7109895255eeb583dda276142301a801a61a9c80638cfba8ba3b3291a4aa3"), + (31, 55, "4113fbdbc619c33cca15f3fbb6814a4aad73572bfdfbebf8b14dd639ca4d65b0"), + (31, 56, "9ed12934ab275d851ab70a0aae251f1cec4758efe4a9a2eadfacc1fea582c290"), + (31, 63, "b4f05b82b8718eae7a3705d8c9de0741fb3b9abdca0dcabe19b765de09df6b3e"), + (31, 64, "336e3a66b48a0641eef403a62839777879f21cd7e3b39365ffa1ec7502d9e799"), + (31, 65, "5043c4842eaf43eab61e61554d88118c88f1dc7461be87cdba9ad69a06df41f7"), + (31, 111, "40ac442c3fdbf08e5b45c10649148d933a921c9598abf303df4b61024ab9a94b"), + (31, 112, "99f390175f1d637c5a5737c17d84477f14fa6f03274d7174bd12b317fbd07481"), + (31, 119, "a5961612c401050908bbc9424074dd6df2813e32dc166c9385067e9f00e68cbe"), + (31, 120, "ad5bf6e2b266acff56e7903c919e303e6b38aaa67a2c19989c857380e5726be7"), + (31, 127, "d73d85a6f930b80e24f5e9052ecbdac8968342a3baf5720ee7b4117f66cc543a"), + (31, 128, "51d7b0c822f5e001c869dd77598beaeeec7b63735173ac38060f2b3f2ed33731"), + (31, 129, "48256ad4d4b4df6dbf67adf6fdb0a885534e36e711f2c7bc59dcb3b4407eb0f4"), + (31, 191, "41473a4ebbc1e392ec3d2130e241b8ba51fe3253e52529f9de3b126c7af7e732"), + (31, 192, "8b950cf2ed5872f9711873fb0ac03d331e0e2099a8011c4b1810e20380c27705"), + (31, 1000, "1967bf6a08b83443d7e3d23c716629abe6cb451355985281081844d913cfb6b6"), + (32, 0, "feffb2318c2191c4f11e1fc0ee3d808697d6073ae8a1c30be5f1a169ceff13e6"), + (32, 1, "a27c807acddfb10d2acde046a9acbf74c2cd5ac573f505c1319c7941e732fab3"), + (32, 25, "aae0e751aea6220fcab4529f8839945205764e0a8d9cda41732ac09a055660f5"), + (32, 31, "1543e00e29c175761f772ab49e8d503040e22d88a55775c4988edfc044e5dd2f"), + (32, 32, "a839b0b71a290a358165aa72b177bb18a8727e7d53ebf931e7d4875198caf623"), + (32, 33, "e1232a8a20707b5a6e10c49b38658638c30d5e452fc83bf13daa479873f5e201"), + (32, 54, "3c5d18db85594c0dc32bc6f76446beebd61a568f5cdab75b065525bc35ef6de0"), + (32, 55, "0c7ab9e459160ed735b041839997f7f514602d9dfa3711f443307a7264aea008"), + (32, 56, "53b6d5ce691c809b96d8eed1529b470f45acd16f5ea42f76a2490b5366df7690"), + (32, 63, "f0d2e5cfe38e8a4d8d0ecbbe3cb68bf582ce2d040dfc30c22b9b8835c6261938"), + (32, 64, "ef8a03405977103757083f8b47ad336567030848326ad48da1446a3eeea35175"), + (32, 65, "b287c70a02dea016de40249501243f2ca8d98ef4c8d9c200e11397ef8ccb98bf"), + (32, 111, "1f2b4540b3c393d6bc649baa555b0522c963b78c85873af0eca3ced341f450bc"), + (32, 112, "3b03b1a6431d14f0f64df9ee61f4aa1e8d3674edc7114a5843971868a6014925"), + (32, 119, "882636ff86ac276af2a886a2db296bdaee2918e498cc43a8ea6bf895e72bc888"), + (32, 120, "65d683157d38b3f326e4f9362a2d7f26e9d96c1446ecc4ac1638047ea745c06d"), + (32, 127, "e4a4c103c123a86f0a1507bd58d13ac6e5426427cdd0f48c0d18481adc6119e8"), + (32, 128, "a9d9eb24ec5f8188f6da1524fde8b967df512a6427624e74602e36da458ecaca"), + (32, 129, "e9bf1ed023d26b455feec17d646036bf98261ae89fe3bdf71676bbaba1d3217d"), + (32, 191, "8ea810e6b8b82096920a78a7368084725d6f1d78215753869965b0925e36a03f"), + (32, 192, "3013a3661557cf9002ac1dd55001a7ecca9c80706eb855f7e1177797d1ae5895"), + (32, 1000, "26d601d14d02092bde74bce17fc974bfc14e80d2f12263ff46109a72e74bd310"), + (33, 0, "5b1bf9fa364f46b5c5a6563e0ef4cee337e90575ad84257b880453281ae239a0"), + (33, 1, "4b9f51e7f5c0b863c84e47415780a814d43bb2d92def69a3b54a10571de7db16"), + (33, 25, "d8cb4977669fdfd0ad59c822281c5adb550ca65b373200f0366508c0d7df18ee"), + (33, 31, "c02565318d501b63fbe130785168b6720e1536adb270e214c97c971c7fbb8d7e"), + (33, 32, "b7bd7c128754ba136c26611dc5684d332e9516cac78a152c71dc6faf3bf9cf13"), + (33, 33, "acc9ac792d905806f3ab77eafb0b9bd5233b8db1ffd7151fc08bf66dfd3446dc"), + (33, 54, "ccf9c471d57ed08fdbe68da009185f1feecd43b5c71358aacd362ec55555a7a8"), + (33, 55, "501bf2549b14abdab81bbe8091495e98c6fe2fab4759f5074608516d93ae3c21"), + (33, 56, "cb99dd403b3533278e1a16295cbc86533f62f54470720a0bd8bbd7cb9ca74263"), + (33, 63, "90652e1d70d4942e486bd79ce0cbb80efc5857192b3bb9315e9a2b5c15733f67"), + (33, 64, "462a71666bf0c0851c50c729aedd212c3967aed725336059018a5ed79c416bf7"), + (33, 65, "7c6eef1c5be13ed4f0de26a59d5213b53962251df7a811ab98a27ee8c0064b33"), + (33, 111, "3d05d5327dfb6e21832eedb9b09d54e13bfcb57152475b76f82984928444a53f"), + (33, 112, "a2b370294cfb4720af6212e432c77b73da285e8a1da34acfbae4b2a231f15c62"), + (33, 119, "aad1627ec4f4921e86a1f0e26465d16ef171cbcc46c71da0e0e5fcf3655c9add"), + (33, 120, "b51dde2a61556d236c022e1c02063b29a62e989a592cfced00861bc0563d5cf0"), + (33, 127, "47015b78e55a9f4d44e46ec2cd8483c3f5356cd92eea2df18def97a91c31055b"), + (33, 128, "2ebc6eebd76c85e5026c6dd2e3a73a0429cfd485bff3bd83d885fed741491cc5"), + (33, 129, "0c74b9cae7276b55efd3a49feb9997b5be64923f241841844a82ce7a602adedb"), + (33, 191, "aa7fa745b226a0792601ddd4d8eae5af6c3458d920edab32a2628dd4fd5872a8"), + (33, 192, "f4878432d91b66082e2495c4d49b68eedbe769a3c9f3a363e16039b3e36ba097"), + (33, 1000, "3041696442fd6740ac88358b498bdc9e73b8f042ab6a2e324087ab84abb74a3b"), + (63, 0, "646aefd3a921995422ef7683d9726a66715f7e764d7e2211478f1c19b2ca13c8"), + (63, 1, "62c2237f0a7de46f994c9f7319486a77c49228b279bc28514d23a7a4af860d19"), + (63, 25, "375b81f36095b499d399f286a1fa2eed2fed53219881697f7176f874723712fa"), + (63, 31, "f73235ab83ea4b07400291690b8da5b69d967b3c13d63bb6e8c2888b42d32208"), + (63, 32, "56ab6659910005c39c3b75c3ac7133014f173f3eae9243fc511949ea5347da69"), + (63, 33, "ce4b1e3b9bf252fd309a64d349872af6f029aab8f6d6f07f8333db491c0cbbf0"), + (63, 54, "fceedfa7ff52454bec4413bda128345d8f3c3c908d4771107014ecd5b61de420"), + (63, 55, "5bd5f76b77e729ff5c82dba9d59602554b81e7502dad07a49f92de7afdb79893"), + (63, 56, "5d43c1854cc3400c851723ad95411ff8c2176c28bda67bf28bb4a73dc385aa2e"), + (63, 63, "740266e5019e4b7d2c4d3e1bd634eca0d553b50eaafdcf61561d5a049510e84f"), + (63, 64, "97c93cc90c858acebe685de2576cf3a5a7ca0cfde6f2bd588ed5d52a54bd023e"), + (63, 65, "7d30f78c0de0908c8deed97b138fd4184f6a62566378e4e1d068d67750ca9d9e"), + (63, 111, "c79da5829183b23320f6d6be9cd9842f92d5270182153f2d01d884ff7f6e1475"), + (63, 112, "e23122e0f0bd2fd0ed278cb80be5e05e3b6e5018572740f70c45ca69c1644a98"), + (63, 119, "5c5ea6098f6be425bb0c50a99b75c20254d20be3d76c5cc56b249c6475b38aef"), + (63, 120, "1fa65789deca5befbc5b119594ef125f33b13b73a0eb24a4a5ed3787fec474d5"), + (63, 127, "d706a43852d292589a794ae66b5f7e8fdbad6b2c9a8423b1b8558a4bc0a555c5"), + (63, 128, "02a7bc74f403e8d091be8854d78dc14bb910ef3bf83b1853b33df689a0d7a645"), + (63, 129, "f93694660945379114c200ae20d067244a07d80e75cb6e34d03a7ecf087b2fa2"), + (63, 191, "10bb192bd83c14d2a1cb107ee5e949952c49f90b02ef9ee388740d5353442417"), + (63, 192, "7209c43d8b21bbeec043c2e6257b4460abfc9542cd121147c95baea7b023743e"), + (63, 1000, "573b6a015ee4f7478a5e16fa6ec16c2ef9b4b8f281546c07bb01ffc8784bb1b4"), + (64, 0, "58c7e63111455c2bf96038a7f89d0b8003da9a940571640f70d14f4f3f983fc1"), + (64, 1, "1f756cfa538c77ef689b3b899628f87a19293dcf0e9e47dcd1e9d6da7102ea27"), + (64, 25, "4568a15c3a632f4bdfe8720dde097af7d4ed80dbc1999e14c4fd6de0382a3b1c"), + (64, 31, "3504c4ee156595ae91e7416af9df4d7a54c45c2508bc53d4204afb364587202d"), + (64, 32, "963a3950d77b12856cb6de39207b057321c66c4b5028b69c269850b46360d8fd"), + (64, 33, "af64b671f5a5baf6a7dbe958cb97fae12f2c9ce80f77c4f50835dcb97be1b007"), + (64, 54, "b2459d354dcefab99b9fec8111f2cb4d72cf0a2d058a5af6e9c16a34fdbe3e24"), + (64, 55, "b2ad04058bfb449fdc79d030e2c19ce231db7d1bdfc385e2f28ff139dc985a85"), + (64, 56, "8e9b6f75c0913f4b864af4636ea346b3dc6c2609bad97815ad477eb2e2637984"), + (64, 63, "918a193c78d41534d6caf6f27fd80754190a6bbe7bef18389569766cdd324502"), + (64, 64, "c54d84ac44159f9d13efce96623b77035b4d3268117c0dcdcf8fdaa76bfe9721"), + (64, 65, "697bf5d59d156603396bf35d9a043cbe67b6995bd17cbb2ed963da781fbdd89e"), + (64, 111, "6dbc830c6f0c3978917113d4c01dec5a682672bc605e61b849b207a84129d2d5"), + (64, 112, "51923b3bd8391e0a7662b36a79244e558baf34aceedb11baea3e1c42ca72f3b9"), + (64, 119, "5a1e2a98d82efbf761fbc253acb70c22e3ac4e37cb31e1fdeb3316d310ab7327"), + (64, 120, "78dab77f9bd316db42ae51f477ca9d2d082af3e51c98ffcf2cf33558b4c65b86"), + (64, 127, "c909025aa572fddfb5aa877b6c4934718bc26069ae0f82e6aa4ccf94b003580c"), + (64, 128, "06ba7aa6865d521e3fb2cb4649e0141937f3b28c061e37828c1060bf2b994e19"), + (64, 129, "d8f3278eca2fe599f08404ff8cabf001564a2b1ace786aab1a338c31ba5a2f45"), + (64, 191, "52836f5c0c060723af341265789e704abc902ebf121500622a6be3ca4a39556d"), + (64, 192, "cce426dc5f23ea6589cc1da22a9861019284f701402e11784840d7953e1a923a"), + (64, 1000, "3f44dfac783c5be18d6a2e635598bfdcec320c5ab7dda9fab07bcf48048670dd"), + (65, 0, "34bb3ae87adcec2d42a585be6634ffec56e8cf0caac667a4e398ee1e46015c15"), + (65, 1, "1ead3d10c29a4ff07e11f0bb1eeba65947009c1d384f3f5fdee2fad7b419634b"), + (65, 25, "6afc1bb2a0e48d9e279438400a4256ba63c571cd50bc6cb573e56065ab1bfad9"), + (65, 31, "07c5e63a6ef2a25b8545c61678382d5fab54c6dbd642f2eeb415930eebc6fd76"), + (65, 32, "3eeeacbeb0fec048c54717d42d6af0bcb31c4736aab9e259205baf180fefcb06"), + (65, 33, "86756a5d0cf352ace728bcf45520a5be384ebaa0a040641387b6b87eb8fd5a1f"), + (65, 54, "a2020990d1955769ebadfeeb8b41e3c400f60448880096c8cdf4523af672369e"), + (65, 55, "2827d32e0cbfd28ea62889133120d9772db9cc51e4b3074a74d1be0733130ffc"), + (65, 56, "0edceb97c49fc129c8c0d0206643399dac93808a2fe9d85a275b9eecb50ff8d9"), + (65, 63, "7684a06acdc3d8c40e9643b5ef4c3e690ac4049f044c959bc7fd7c38cf02eb6b"), + (65, 64, "51ad894795c00ca3b74d090c2dbecf7f8cbe986023367019ba97a890ef68d817"), + (65, 65, "d743ff6b4b7b0c4398cb596d064d0a1e03346c56a068f79d6daf909cd633e2ca"), + (65, 111, "7e945cba562b2681a6b8e04f49df3e251d855599a30e7b285b0e2c50407ff1cf"), + (65, 112, "0499dfbcc3211b57e540d22a7294cc9b25fffecd3d878a794cefb0bab653f4ad"), + (65, 119, "090b58545b1ce071ec7f32d782b76e94edb8df92a146e3355a016f9479935e1e"), + (65, 120, "79d814cfbf018ff0fccda2e3b8b63cb6f284b83081243bfcb0dd9f05bf9e3a8f"), + (65, 127, "55833e687ad320c1fb9bedd046386c9b8c0cf188ef95949512a8df317cd19296"), + (65, 128, "16028d02d8e1607dbce2c416d454c8a2fa92e572e27255bb7df07fd9ec02ac6e"), + (65, 129, "eb91b9339f6677368e58dcfc25634f859931f8ce17ee4a2b143f967212f079b1"), + (65, 191, "7fcd9a56e912645c2be61e7fac641de436c48a276b78850b1eca521b7b4cd49f"), + (65, 192, "f025fc2510b6cfc2d20ce42d2bda821edce6c8c6d24d8da84fdad261cd21d549"), + (65, 1000, "36193addd4d5cc201cc49357cd5721aaf61e9e24d155e8a36458645f8b742533"), + (66, 0, "80abd82d8bbe6dacc9a21b66c633a483eb10fe4b50e210632dce4e6005eb9170"), + (66, 1, "7502be3b47d2d761734982aac59d7c156aeef4d479a7dfecbb823060713c526a"), + (66, 25, "f59db32c9cc37952e0751eb77e583d8efb5d8307583bf5ece91cea5248ff4477"), + (66, 31, "680dfef7db61406d9d13551efb283fa74c58e1632012728c4cdf848b58f5ea2b"), + (66, 32, "0abe1da1f1b485679bb7f66c1a6d344fcb7bb6fe2f5f271049b03cbe0b8bc80a"), + (66, 33, "69d65d816c92a3bbf7cc83ebac924e90e02d0c12d1f6e1313ac49d9220365765"), + (66, 54, "8b8fc6d7b4fa453fa0a6e25b3b5cf552d9509a4117ce5260a58c601fed5f99b5"), + (66, 55, "a966d4879a0e36069f99a87643906bb0f30ece887b7626a99978e4b26eeed2e0"), + (66, 56, "295b4961a272ff82704b11043a00790787f241dd8f6ecaca9f9f0923ebd0afdf"), + (66, 63, "5c390da92b26b17adb7e55ef3715ce1a62f6b7cdf94473ad143c7cf98a2618c7"), + (66, 64, "a9d71745e97a213a7ea9709efdacdd59883e35ad374e0f6fb7171d8fde32fae9"), + (66, 65, "da25287b34ddbbc399ca31b9b69fa10a63ac9bb3fb663514a853190aa06ec5ff"), + (66, 111, "c5fd5c9d5886adf2fa7e0b9fc8619424ca8079e0a22d4dead5293cdfb18e9fdc"), + (66, 112, "095c9f26e314e94b3aed21f71893918fa5dbd213fdf43e49eb6285f81d878777"), + (66, 119, "d0482d9a777ed1088c87c068f1639b5f3092ebc2f9e7b0b7dc0efd1746b449ac"), + (66, 120, "45c877ee875b0706cfb7ae17d586ffafdc20625f92250ec1b8a5bd894b8c9d06"), + (66, 127, "963db409b810296218492792eca6e6a90f6d857260c80534d2a22d80fdcfcea1"), + (66, 128, "f501b9332011b64c729750c6a73f8fcc62756f370550e19678367b633e4ffd6d"), + (66, 129, "c8c4bf89c543c5f9ef4c63ef4ecc36069c1972b219a9c534313a78bea4bf4cfe"), + (66, 191, "39d6026fe559f5a5cae20bc33d535a517b24b2a07f992551f642d011d23d6029"), + (66, 192, "8014e57101aa043b7a58dcc56015481f98c2234ef2f5a44430f3770bd9df38d0"), + (66, 1000, "97037ef40f6e00c1ea6288200ee23fc011a46db30e1e6d27317deb4aed44759f"), + (96, 0, "b6efc1f17c767854de7bb5c010bcc01f453e500747d1c9ba85f376488533cd17"), + (96, 1, "0e739298612b5e7a1128262b51b80c78af32ea8c4ee869eb6c770dd0634732bb"), + (96, 25, "102c44e1cb13355e43025bcd17bef6402c61ef8d3dcdd7c42a1fc304867d81e4"), + (96, 31, "db8f37b5f552db6d32fde8e869ffc7d1c2d489f5ad769496880c81469815e831"), + (96, 32, "4a0a3352ed690785e683970ad06dba55209469ee93b9683d6c5e24287a076d75"), + (96, 33, "e1ceb60b6ab93774f29589282cfaaf613492ca7df66874bf95848bdb45ebdc6e"), + (96, 54, "741167b2049a869947defae3a407d0c6b069c6e554ad710460448e6949904ca8"), + (96, 55, "640e86addd4d26b2b42f2b595b063b3fc14e5c61f3cbaf262e0d5313e6ad04d3"), + (96, 56, "a74877e14e02036d2f2f3536fb80df6b9cac02e963cbb934b1f7c8b3bd5e0520"), + (96, 63, "5c668d9d55e36be4312ad980c8adc526f7f935edd7c4a7f34f5a2a77d68eccd5"), + (96, 64, "02e38332a17c8e8ca81bd11371fee0ebcd2a9531e671240a9c0b86173935b95e"), + (96, 65, "d5ed107e989c5968832a26faded8f8a33cb6a31beada2617ebed11abcfba33ac"), + (96, 111, "7bb5eaaf36c84433d2a1320e31f2a3d9a29ae158dd88d75c637116478383aa50"), + (96, 112, "c3c546dacf1d63cea05ce9442e88e819bec1cbcf1fabec4581f2943abb6ce4fb"), + (96, 119, "e63164aef82c320d0ddf9274bf75f28d56791281942a842c468904cf6e1c2113"), + (96, 120, "9bc68a96364d1b549e2910638cc25ee60bf4b146516404632d0759ad4dcef0bf"), + (96, 127, "dc549896244b4237322e0cefc5a852af1028608b67651c98749653af8067dfc6"), + (96, 128, "893d3a7350c29cf381f8b952c6c356d8975c28f9d2d5bee34d486a4b07e0db7d"), + (96, 129, "b4831faeac08727ef7d69c588ff30e79b448f8acb008755ded1034bc2951df77"), + (96, 191, "982665a1ffedf291fee026d2fe9479eda4f41db79302e293289bbccfe24c2ef5"), + (96, 192, "b0f121cc52ce691770b2a1dca96691ef85ed97eae41e5086032a3440e4c5ab1a"), + (96, 1000, "1a6756df777dc87009a86123a93eda109265bf5a9f0a750ca151f8d5cd74a722"), + (128, 0, "04019266d63c1e661a7dfc22091054d7bfe8b66925142bb92e387e3a1d529b41"), + (128, 1, "a354524117bef027f4f17f0d8591d09a0897d2fbb183e72c72861e01fee21eec"), + (128, 25, "2b7cbff05edecc5120a1169abd49851ddd123b1521f389d3c532d5524e23eef8"), + (128, 31, "742892bc1c640d05b9d015c88c2d45c1c94fcf026d074795f67b9c02ca5c3be5"), + (128, 32, "4f204667a5ea63ad43b8e2a528c9ee268392be39207411d1cb51cc346d281f34"), + (128, 33, "58adc58e595a167befeafc71b20b204c3dfbad046c3186d7b7df0ef3941f9563"), + (128, 54, "b700abfea37c9160de048d36cd431ecd80dadf47970a17a7461237cd869c513c"), + (128, 55, "de5166bace926b4dd87da05bb8d47515ea1380b5e1acb157cee582a0f202a60d"), + (128, 56, "0bc681d2a278ce303a18c61838b944386e7cc4d8a7726264f7225bb0d93ba85e"), + (128, 63, "367c21df6dec6324c52ba4eb719ec494b32d11f2bd842c37f38acf14ea70866b"), + (128, 64, "f8863ce4968fc7276fd660c45c38702afcd2f09eed1714d4177fa5df4cb4a15e"), + (128, 65, "4741274c6e058e8219d2636bef6e4af1f124654646bc85dc5f7dd09197086551"), + (128, 111, "a12fcf051a43f8a8e3de12ef9eeab2e358e2257a8998365e7fe9e7f485dfca36"), + (128, 112, "fd862101b82f566ae9fc593c023921b0a1d2f7d49322aef5dd6eb6ea29711ee4"), + (128, 119, "bd19645a3f97c1ab8363158e847f4519972e7b29a704aeb7f22d583b8d0afcaf"), + (128, 120, "2e1b730dae5ff21f07409811f270ef8dbd1923aec0dae5bd83fdd42f05d3e383"), + (128, 127, "43ac44d7b75f5632eeb806f496fbfe3e143f03a06a14e0626fd088db6082c92f"), + (128, 128, "e6fea039b309ca0869cf1a22518869f1ba63dcb31ad8ce447f7deb483290146b"), + (128, 129, "e8fb65c6863d3bbc1d5ce5bf9132ac659ba233f6a1c7f7fc082acd24b15cef5f"), + (128, 191, "936f5c095e6bcb93bfd66d23c24f17bda64cc8e0225fb699f6415d2e1b993e33"), + (128, 192, "d766b98ae63269c37dacb818458cc520024ce3a497f1d092ee18e90b616c05aa"), + (128, 1000, "d9e181a21823b8498b997a8481d50ba7b5544b620a732e87f68f205eb657d5a9"), + (129, 0, "57b8e47864230c57ea86587adc4e44e3053479e05f8e429b360150ad6cef2449"), + (129, 1, "4149cb179a453cc3030e52954b4166ee8df72ee266386c81c4c326dee86f190c"), + (129, 25, "d576508f245d2296cc2ecd81ba28810b7c3f32bd34df3ca2ae5c7591694af85f"), + (129, 31, "6394575cd0f9db2c186b384689585f82de42dc4bdd1e14f49f07ae4cabd1cedf"), + (129, 32, "6913167b1adfc8156e1ac5d5ace86ff54649293e79b3d26e981e5b34438f4f09"), + (129, 33, "7987aed5159c55d23af6c343c3b42ce02749259c2a3163b48ba98bd832047a7c"), + (129, 54, "4ee3c6cb484601670060c149e1e497641c7bea343f350d30973b79b2dd03775f"), + (129, 55, "bde5f72454657c0d7cfe678ff0a5509e40c4c6b09ec591a61956ead204201c60"), + (129, 56, "041096e2512f8e6c947dd13a2fb18aa33ba4bf3097d7dec13b07e1e13dea0faf"), + (129, 63, "a8c2125d7a099a2a7909a89bca646c2fafdc35f93b854fb2ec93069f3c336555"), + (129, 64, "e4bd0e90fc283dfd4990d1e74c7c762976cd7fdfbd72f9fd5f63ef67ad4404e2"), + (129, 65, "0c2100253f2deabff4e8e2d1fabc4991e38dd72fa3b58b8f834d7c45fbae2697"), + (129, 111, "df915e0de946a515fa50ce4daf445a0002f65fe282a977ff99dd6d4ffe2849b0"), + (129, 112, "833ff273e298a6b28e4809fb6643f6cf02386f6a6e905b9668bcfaf453938747"), + (129, 119, "6c5290a60191bd8d128dae194962f22e90c0f25f7b91e8fbdfe74eb5c639be64"), + (129, 120, "bfdff4069591822dcb70957854bf9192881cf224311c86f75ddddc2da89f227e"), + (129, 127, "29fd30d35cdab4fcae9f7709e3f910dcd736409c2cef2a8ba7efc0d5d19ff501"), + (129, 128, "3ea4179c188830b806dfc194537e08bfdb43c5de8173a01de350e2b6820601c3"), + (129, 129, "51e77f9addae77269a42f19f6151f3bbcefd09644aec10d2326eceae5c7aa365"), + (129, 191, "0b00940de53eba735c77ae7744572d23f41aad48be6638a6cfa4e0793840380f"), + (129, 192, "31be268599c717e45e927264c84dbda57055ed13b06c4f771d65327a1b581189"), + (129, 1000, "0092093d8082848997cfa21be92454f593bb8cadd91898b018cc2598ee2a0f17"), + (131, 0, "f2183d1fe08f3e8f77e191beb49ed74ccb5d817ec879f9717f1a55e23bc7d72c"), + (131, 1, "63bc44b788657467d91ecdd4edf6abb2eea475e1d4b9f3199539e0f49527fd11"), + (131, 25, "f5665314c49e4c1ab1a489b3ebc9955b5c0f7245387972b4cfc84f27f15e8b61"), + (131, 31, "bf5626922c1ca9129a75839ef25f1dc3222001a6f184b5bc5124b6eb399c5fbd"), + (131, 32, "c0f6efbeae16c8713cb8bf4e90368dfd9e46aeba3c56684fe817077041339b86"), + (131, 33, "25b4288693057a46728e044e26a15aa6405fcc7db96bf8f86ee012e65c04bbd8"), + (131, 54, "02894f4da5a5b7b29869c277397a91834b11343123b0f38abc09c52388074fb6"), + (131, 55, "681265ffc1d7b85613c5b89a330439d2b488f71e5b18af11384ac316720163a4"), + (131, 56, "cdd0e5c29a6971183d3fe7253c0cc9e8b4e0ed9fda6b900c10792de561a15042"), + (131, 63, "63dea102a1fd884b62e43d2290477e43c061f4b3a03cba83feea235279b076b6"), + (131, 64, "008de39b8ed19e73c252e4c1c4cea2fe6c909136c41e6df47f3290abbbf5b7dd"), + (131, 65, "04f51f5d5fffd2a68d2c2310a5f069522d89e94cdff95d85ad4a9aaab6922e1e"), + (131, 111, "79d2af736bb6ec694e42cf69b53c2ba93ebf95b28394213ef2c8cfbbe55fdf17"), + (131, 112, "af55c4c2ee3dea1718b375792bc1b502c47e02bfa1a8c0e9d8f579c99ccdde8c"), + (131, 119, "690b9546c88e97fd4837cf5fc703b498be24778f54d7195c9621d98aae32aeb9"), + (131, 120, "d18cfe6ddbf7bd807d89c5b4530fa3301fe77c79e12b59773d56acde5d00462b"), + (131, 127, "07def0960a02c8e5f086e54fd59f5c78a11ae78f5fd8316e1d102bbd252f9185"), + (131, 128, "1291b1e131dfd1657a8e49881e2540d24a117cfa3943ee25f02e20d46f495318"), + (131, 129, "b326beaed535c7bcdba0e29dd1a86872a2b419963e42ba3f0d8cbddcf7677b57"), + (131, 191, "c2c30255a6aa6eaa905197794a2053834cdeeac3409660d3ec4e62e79d5e576d"), + (131, 192, "3e545528e47ff03136f2756598a8700100127896a14c5462cac19e99dff6b9c0"), + (131, 1000, "627a9f2fc95b06aa494e15ffd3e602c1a48b186037a3deb1bbc81554dd234c6b"), + (200, 0, "124f0c19ba870c7b73e1e583911fb285fd7513db68f6bb116b1e34a66f4b6181"), + (200, 1, "d70ef86d2d1f953c9b6e734092b7835320d96f7308538e0bcfcab1e421a71ff5"), + (200, 25, "01811ec3831b0929c35728fd177efd77b1ec2e2316522ab11d40ea932310b7e2"), + (200, 31, "709e0098117c8e9627f07ab0edfe2392736f01d7a063b1b5da71c64ce2d8b38c"), + (200, 32, "4d629429c6f8d750d0ce08d0ba6c13935acf9eb5f899dec2432f3c4d616a1ebb"), + (200, 33, "79c1be3e1d9f28974513c33a36eeaa8d3c44927de4c6d13670c224ba437708ad"), + (200, 54, "014edab4e839b0ab83710e942ea6127b2784c53d66dfb465a3302e3fbc467fdd"), + (200, 55, "dfe83da95c71e20b44a3c74d2c0bc31e70600a40b2ad73bbd1f08b44dc2a27a2"), + (200, 56, "0fa3a75bae8c05ec4340a4b239a9b1c7c2f70e8708f55aeb8a93448b15c6edcb"), + (200, 63, "6fb6b70525c8aa86c43405434dc483471155f0ef9c5e181de6cbd2996a76503e"), + (200, 64, "61c7c72fe5872b1a837341e3fbccca805f228eddbb2eaa0a184b428788150932"), + (200, 65, "389995414300e3b341277933e29704c86d6c8cd7675c1ae16854a73fccb7d2c6"), + (200, 111, "a3fb7ec2d2f50a89a54a9546f7cf5bffda0cec79c68e554c07be9acaf852c6a3"), + (200, 112, "95673becb66bb6052f4fcd871885783082bc1c096dc8b1fe8f86754eba747640"), + (200, 119, "2e9832b4df48712417d8f413656cfa6e7b8d48ff738a1cd80135e054081bd5c8"), + (200, 120, "7652a3d1bbf34b3449081ec3cb3cda6552a38fb7c83b302c9795cc416a502bf5"), + (200, 127, "b037ba3dbe2884c07c4549aa2c7f9d8f1c50f3ab2aa7be797732f3eb6e57738c"), + (200, 128, "028bd3ad676e07dbaf1c680817209af46362944ae516ddb8b904f84013f8a7d4"), + (200, 129, "5f94c815d4ede82c74c9010eaefdc31f6984699fa772399b8bea7267cc4d20f9"), + (200, 191, "9e1061080fbc62c2dc2c88c6afb92494a864fd80b6cf7f24cf76106d20ec7600"), + (200, 192, "27b4f9dfe1cf21d1916eec9399e1fe4332ddb1d485e6c708c3acaf6299ec34e0"), + (200, 1000, "ffbe22b9a3b86d07b1281011796537255970b66029df19b36d0f149a3a55df5a"), +]; + +/// (key_len, body_len, expected) -- HMAC-SHA256 over DOMAIN || body_of(body_len), i.e. what +/// `Key::tag` must produce. Same external generator. +const TAG_VECTORS: &[(usize, usize, &str)] = &[ + (32, 0, "201c6762e7e2213a690410a7a9f2e5f394e6dbdb1951fc32fa682ccf54e6e2a5"), + (32, 1, "d5f68a7f8edd872e7ed49e4620f919434aadfb6dea209c0425ade1ea4ec3fe9c"), + (32, 25, "7e6ab82599fda3c70f2cb6a5f2fa0e76c812eff0371c5b9e6b15f41d3de8dd2a"), + (32, 26, "d8cb40d05f07d65e83e289e817230fb4636d7c37bdffced0ad4c2132f4a7d364"), + (32, 27, "63358915f94dea8b991a7277f4636b78d70938e9986512db410f4923f1077e45"), + (32, 28, "08283546ba226f6ece010c4145419d8d8e396af62ae080d8facdba4fefd84f86"), + (32, 29, "d5b9462bced0a730e6c6a964b7e87aa89311f177fdc60e6e11faa4354b5e6747"), + (32, 30, "5d986f53aaebbd18407ae144e5db0855917773ed61ea3d5294b7d27d8a833353"), + (32, 31, "47d024db53e04561c4d96ab6d2c2a3c449136181936722182ac0ff665f83ab1b"), + (32, 32, "cf666259da7b5d659e14971a661616bb2174d5bf8f022435ca1f2fda40ba4dfa"), + (32, 33, "ad60f17f5bcb0a53bb38cf245c3363d1f4127e9458a20cc875220e878ae9cbd1"), + (32, 36, "6652d6c5248292f7f9068ff297bcfd80c50e318e3860430196db5817d0dd08ae"), + (32, 37, "d932a38b03b5b3e9f4d98ff53c032e8677244a30331eac1a301ac3f6732508a1"), + (32, 38, "520d54d06d91436f17e65c3569b0c19a06f018b73083ac026811a03e31793853"), + (32, 39, "255839fbe25af21b9d62705f8be8b00dcefe41773937daf8d728be661156ac58"), + (32, 40, "fed04370cd5a4730ec8a4f41597501b807999eb557286fe0732b070c7121d8c4"), + (32, 53, "503821d7e9a545c65f96d75925360812ce4216df5abe7e7309fa404088a65d64"), + (32, 54, "602ff73001a6de76190dea6971d2462b7acadb0f762d37efdf4fbf41172e2705"), + (32, 55, "5b07b31756b4e3d84ca4eefee85df6003ae3a166a70f2b17e55d5bc28cd7889e"), + (32, 63, "e1d5eb51da631030389dcd56f9dd291a7d62417541a755e9a7c5be6392c9ebf1"), + (32, 64, "8c89294c55e739272b8b3583ed9c3944c7f46ddc47d6c0138e5fce5569bc6d3e"), + (32, 65, "bf13e89445a1c36091af7c4edf8fe287992e9781450a12a9fddbf94208d477bd"), + (32, 90, "254e3dbeb45b69749065ea14288b4c1095f88d01e871d68b3bb6e62dd0571355"), + (32, 93, "efc9fc6d24e03f077b06b308e982b3abca81140338cb2163264ce92812b4ed85"), + (32, 94, "b27ee9e44478f4e85cea78b3caed75aea10160d365b71b5dea4cce24bdbf3d61"), + (32, 95, "4eed679c46245ef866291e4d29fdd487089c04c7b65cebdb946e25a3969ca35e"), + (32, 101, "7c43bbb0585ec0da579a9e061bb977b025db3616bb2a50327c098f471a8fd602"), + (32, 102, "c29e4e9fc64138020d88bbeab81c4b999540c147275a33fe8f8e816bc1b69f07"), + (32, 103, "b3a185e231de1b1e1c50647f62fab64d8bbbdb3defa80383c7a1e8d77fb4ea7c"), + (32, 127, "5fe59f0bd4df8eae6dd43f4dd7e502dc5838f9ed95f1e7d46906a9d00d382aee"), + (32, 128, "13d4dc17ee2960add3aa30e6287c1ad3865c6714c44ed2b9bb327c53eb7651b1"), + (32, 129, "8448aeb91605c4631cf830cc9cf9af35ccd38d7dbfe855175f5332cf74052e8f"), + (32, 166, "0e36a73835435067fa5fa57450ae9a26e08c2feb09faaa30121739b974141d4d"), + (32, 167, "23e7b82232df0770e74b0d1716ed7edfa39dd47ce581c2cdefd02dea82ddbae6"), + (32, 1000, "00b2b3cc9f5c043ede11279a02646899df6d37fa7d831a6d4b54bc91696b3810"), + (32, 4096, "744444c71fce66624223a7a08c259c0657cb33480a6620eac3c9705fb171e480"), + (33, 0, "be6ce665f0363b8b89921c6bc9702707cb7304b3c01a178e913182d41321e94a"), + (33, 1, "d049860d80e8d9d5bc6f3edb60fa1f7449437ec6913d0e01b56d29b9587055fc"), + (33, 25, "0cd6b4a9987c4915cf35c06a46a4582e27af0cc1478db7700b3d77ab26ea18de"), + (33, 26, "fa650c032243f9bd755513e9af1da2133b796b837ca9c3855051aa7e0c1ac2ad"), + (33, 27, "b9d00034aa14db09665a9ccb25f1fa8c573f96866b04d17d1fd75c469dfc8e1a"), + (33, 28, "85442f842e4962111458884cae547cad20c4a6284c9393bdbba4bfbb8b9dee01"), + (33, 29, "4466f6103451302b80fc25bfa70e6e1103097ed0f4a94b400a1067e0a1b38357"), + (33, 30, "53c75e88c9f82a8daa30a405462ff77608d70c03daf93e3f50e0ff036887d382"), + (33, 31, "ba97f59c3ba8d77c1b6c95286c9e1e5fce5dd6ea0d9e42a766fdbaaa81dee55c"), + (33, 32, "973df2ba8fd81e66c204077cf11a5c815d1777bc2d48c52dc6b6789e2ccee254"), + (33, 33, "93094cd20c5b275bb93b304487f29106074de4893fc000ed75bd67361cd215ed"), + (33, 36, "11fd7a2ce2a10d70a85ea90b26d3acb94c29883d0dc4b0d239d21cf2bc5a2fc4"), + (33, 37, "8464b052b838caba29186c3cb0ee294b8f15cc7b7a689ce3f23104ece8c0441c"), + (33, 38, "ac50200c312dbd65487ff3c1d4757741ee109ed366859a2db08d63bc2fa9801e"), + (33, 39, "63ea09ae4f0fbc48067a17163982c23427bad7a8b7ffb00939bf0a9fec7e2f62"), + (33, 40, "0ac9c4a0981547e2223f37e31b154523dcefe4105740162dcbbda8e864bb91e9"), + (33, 53, "7529dd9659f2efe6efc9b2644fe9e4764aa15a74684828b822a2417f88cd9bd1"), + (33, 54, "be178ac915ad7d5836fc42c98b3bc2597b0afd47b410b552f1020cbc158f9d8b"), + (33, 55, "238a046d316ad4c9bd3d5abdbb6da468145ee9b7e1c629b294315a15f3021550"), + (33, 63, "745b3935134efcd06ec994fa6f076ce0213dcd0f59f5fe9c605f4d0429946229"), + (33, 64, "afc50269114daae09091e19f3468cefb2396e3fc8c2ead0228ba7ea1e73772c7"), + (33, 65, "2adffc056659501ec8988a82c5a05d8e890c2c71e980549a5bd4c8d1ddf8fdf6"), + (33, 90, "646979c5ac8c9c45c47f70a599721885ecd58faa89184adbff9587ed768626a0"), + (33, 93, "23d9eb4b39037916be174773c177ee2981771e46c3d5b5f77b33b6100089aaa9"), + (33, 94, "68d767d764532ff0a4a8b09af3ce6427dff9e652f608e5c0bc30215c22b064ab"), + (33, 95, "9482f85a4018c4b92870ecc807824dd7f78d887a6903216d3da75a268f1eaa3a"), + (33, 101, "10c0ff78661b7c536414847cb1b49ebfa8395c3f7d95195d35e1922b0b7cb507"), + (33, 102, "eaea308ddb5cffbe05070281447f3b832f90d2c5345ada4327347a5243412035"), + (33, 103, "13f19aef3972e810eabab688ee10c74f0adac379d32aa3ce5965fd9dfdeff213"), + (33, 127, "178479eeadf9d346e48f41296006bdc8fcde9de415e7725c029a3b8d7a7505de"), + (33, 128, "34f0fc4a0317d220c1dfe5e83afa7eed6c8c2f652d3d075e7e3888f934136478"), + (33, 129, "8acb7121f7a436bb04f6d9aa08977cc1b391ba69d55086b519eb6cb475f3a52c"), + (33, 166, "14f4bd9547d5e611306aeeb6ca26a061a62598146097966d603b66b703fea601"), + (33, 167, "476d4e77c2bb79f8cf4701447e573223e6b4bd6861950430075c3f454b07fcab"), + (33, 1000, "8afa0eab50992bf5044556ec2c26b4bc305b2d416a349828e55a1a0f8225953b"), + (33, 4096, "01e320d41f04ad7a79abefbb2653af2a248e1f64e2425173ff000cea044d8ed7"), + (63, 0, "016967f6d586730c1b3377793fe7dcdd3009c58920faabfc0afa09cf2e93f240"), + (63, 1, "cac539b8daf97eadfeba68fedd4931f529a53146b65ed382d3166c5a747a7f00"), + (63, 25, "7b40e0292fccde7cd7634bcc8ea4a735a48069e10800844e11b00bcc9c0b04f0"), + (63, 26, "6b32fe50da2401493ac748082661babe927b0604c8a037ec6cafe7ac5e1444f2"), + (63, 27, "6ada8725b34c3ee462b0063cd4e1a3885fc246058df115fa1a4f474f5fb2e61a"), + (63, 28, "7910130fccd4cd7fdfff1bef5abe56ac40fb45fc67f40a6bb150e997a14a9af9"), + (63, 29, "df724adf19b96036c4a0d876c55368046f627881284ce899359d8a69d20ff14b"), + (63, 30, "08425299f58bb40c6074e154210f4e6c22496198eab9d4dcab4ad4004ae505fc"), + (63, 31, "ef41b6df929fa35d228d6a7cadf10595fb5cc49aa9e2f724fd7847179573ad7a"), + (63, 32, "b705de9ec72868bb5cb2b6fe13f62e43d66e63a9821778f59070d6891e867973"), + (63, 33, "144675c5b91001b6997e1c3bf57383dc5a485172e08ac9dc182032ae5a1543de"), + (63, 36, "9eada79409001ed910d514c8a6d09e53e7c2d5a909f7599b40b62b7fdb31c8b7"), + (63, 37, "b0dcbd785b21e3d5ae008467d45ec17ebf8f45a202af1b176ae7a48d2a7f3576"), + (63, 38, "9047ecd23d6dce1eb2509bb03e31a92b33c909e39e7637cb8f8d49ff068660a7"), + (63, 39, "9f2d26718c2a212f83a99b735a40dfc852340fbf62f9623ad4e832c9defe8dae"), + (63, 40, "49bc39240f62ef1ceab04a32403a0183ab88a1d27b9c4a5f49961f019cb5529e"), + (63, 53, "ca007394bc2b9492bca314a30f4ebc081e282253864c8da8037a8cbc2dc9f465"), + (63, 54, "e7dc2a6db8c3aa909bac08dfc58552ee9464347816f38e6058130caa8b4fa2d4"), + (63, 55, "5f7f5fa33f955c1adb545067570a6e1affd057f24610043e38a9df64ecd628f6"), + (63, 63, "16d7f7b47bb255af583d9ce66d3972725044e26bfad035d555304de3207021c6"), + (63, 64, "365c8f85d5cb6319acb6fd25231bc59c9000794c170d8cce95da36de7fcef3f6"), + (63, 65, "a9fd1636ca3cbb109b882aa993ae0699fd81f857835e4134ee08107290334af5"), + (63, 90, "894c4a30409ca24763f5a7f8e4ce82799ff0e8a58f2005bdf73c5075c4fe4aad"), + (63, 93, "a845a7145c242a47932b47c85f2b27baade3c490468f075b475c7d42f38dbe6e"), + (63, 94, "5dfc6cc95d77c29b4dfc65231642f993ebd6bc174fb34fd93010a1facdf83f84"), + (63, 95, "cb8af8820e9e5f219955d09fd67d174ac51cb4ea10f0423f056377167f3dff24"), + (63, 101, "24da5e559640a7270e05fe79c473dd0d4cff32099fcc38d846b2dc9c85e12a28"), + (63, 102, "4dc900faa4ed11755c5c306ce0e06c2a96f360254942d7497c39bfc00f11f503"), + (63, 103, "799944bfa7f2b693a06e9ea517bd02c2f57a8a7996043f8785f0f89b6c1135a6"), + (63, 127, "a5b8aa9f520a7739c10090e8451aa489976ba27428cb65f95bc31f9b2ff93e27"), + (63, 128, "2b343348331785630ffeb009ef6eaca5866e7e085601c428f0397ea11e682f47"), + (63, 129, "90798bdfc337e57b7742cd1d36e2d87aa4cd4908bfd794e77017f441d7953cc0"), + (63, 166, "bb8019bcee7a6e22ee8b509cd4d7f9418aa35293f811d5eb07e55592aec33329"), + (63, 167, "b8800072eecae34a3121b76b17243dc2909bc8cac26eefff1aedaa84064d2bb7"), + (63, 1000, "d773aa4a909ff2529431bec5e54342ea2d4fb2328117e115a817fb655dcc6e61"), + (63, 4096, "094325c3e2eda588e9f0141e97cf35489c1730aa19437da0307508e8bfa4fb0a"), + (64, 0, "7f91f26abf4f5010c7a30581b285db89c771de444808726a32c53ff37a16cb1d"), + (64, 1, "92d5ea0c4e1089f3d51b9f8052e1f5a42f1662d524933d39b6db897d1d324c71"), + (64, 25, "2b7165ef8cc477f5442b4d1918a53c6667b7deffc39910ecd17ef40d1b28640f"), + (64, 26, "49e4a3d304d14437817032afb93b91e67e9cd5b3b134867356c9539ab99aa7ec"), + (64, 27, "fed997b821d17bf051624ed78f808b57863101e66492a30f0361de3884eb2df0"), + (64, 28, "ce107ce6d51102b1e8e21ec4e506bab0ec2aa8aa54b32ac311d23c7107d0276a"), + (64, 29, "e7fb4dd9c596e85d272e556d38207d70e8def0bee08262c24cdaef8bb33f558b"), + (64, 30, "29f85fbde829a94303760943d44f924fc26ec88855179d7e7a85805bb3f2f98b"), + (64, 31, "eca90616a4e56f89266aedc99b4e922ec8c65980a42a5c0ce0a5aebef8ae6aa5"), + (64, 32, "5553f052a49ed3c3f0c2a20e9d8473d8b5d0161fe91472b09fe480adfb1c8866"), + (64, 33, "ab4be59faff17b252aa3e7bcb4412b6c22384d0d2092034857876bacfab9eae2"), + (64, 36, "d1f78e31d747911920e233ea8538b7f517bd7997914f3d269af9849e9ca8de24"), + (64, 37, "6801d9071bbe8a1a942203be2b84ccc114be7eeb074b76d117b34058b74b3b6e"), + (64, 38, "31dabe0a1a338243373f7417ae39271447d04118456fa76dade2239cacc6875f"), + (64, 39, "664c82af064b5a476708bc7d029705929f418e55d577d7a87d24c2b423f49765"), + (64, 40, "02f9bbfe837e8b004dd69b8daf238f4fcd48fe979030ace65baf64956d184125"), + (64, 53, "4e079bc0088dc9b506604be882f78a321c83a6ed12951dffb1bf2cea1e70e033"), + (64, 54, "db3099b080e9b3a9112649bf7459af64034355e66545e4983fc416fa5972631a"), + (64, 55, "d31d2bc4aa1d2cbf6386b7656dce418a17a7ae6c18b07462642399f5999df857"), + (64, 63, "74b90860e8978af8fdd548faa2afca5fab9b5b0063432bc97ba6ee2ccf0a3ccb"), + (64, 64, "fc48da4fa36ab81293c6e0299bd1e4218b7dbb6e298ef878c07c3c936941a3ea"), + (64, 65, "af08c0daadf2679b87fd791019500bb5dd2e0780dcd34b6750a03b795583d4c1"), + (64, 90, "16d54941438580a9cabf9bde0e98d7f18214f8b0d43fcca4f38a4e279082ce69"), + (64, 93, "ce49ea20cc93d3bb7bd07205b41104226a1c92c761503f35e1bfdc6bffd28c98"), + (64, 94, "e49941f1013e660178293caa9d174ee3004fc8581807a49f5fc5564ec14baa09"), + (64, 95, "1ca78a6b2bc144f418202fe88a399c47bed7accde6ef46d6a40c7f3b3d121732"), + (64, 101, "0e3725b25c4f49f20908d459c57afc091876dd950ce07a03732f0ee30c3a8f1d"), + (64, 102, "76d5e2056b086df72aeb734f762444542b818938b76dc12dee287cf7a8c7e53a"), + (64, 103, "46f3bb28c1c28984e79f81cc93354a9e105c0ea8323d3af1051a45de15dfdb14"), + (64, 127, "8f3f58c50585a3265f727de88ce8090af75dc2c6784579784a8395b3a4fa220d"), + (64, 128, "328a141db78410106351e8236994a0fc0b04af810faa61544de1c52198b46dc2"), + (64, 129, "79a69f98fb56cb33c38eee4ac83b33bde543720ebdfce03646cd31523c6af34f"), + (64, 166, "8c672775c418384af3838bd592f884dddc3384a667f49d73938f1470f1d3e679"), + (64, 167, "1208f67538090e784ce3f5a3643f031c66b88188c9b033d27f99dbe6fc4a462a"), + (64, 1000, "9beddc360edb39a18133db1b14443d793332c990251fd0d55ee1c47c179b3fc2"), + (64, 4096, "4cfcda3b31abf801c8bf086d9536d8d895a7fb63da927deed644a0ebb9740c75"), + (65, 0, "2b440b0ffcb41e2ec393119f553a1845bc58b950249dd5f904b675dfd00c1a6f"), + (65, 1, "dacd7ed0542180bf465338d216291da3b9caa63fa2f0bbd378936021e9537123"), + (65, 25, "625607b385012ed3861290272a8a1f055635cf5b6da5431bbb103667fa0f292e"), + (65, 26, "3a4897cac9e7a8afa7d12b390e8c3958e47894d3154f811f9678906c50683dc0"), + (65, 27, "7d935748da9bcc5ae5708fd952974efd896bf9a33f53bf3cc3570adfdcc0bf5c"), + (65, 28, "1e864f5d43f653c4a3f36f5381118a96fa84c0408ad2ece66659a8a3b6fb62c0"), + (65, 29, "b3a05f9c920dce9bcaa63f4015ac3c72a854cf23e068989ccecc900912e464c6"), + (65, 30, "337103218311eaa474a808b81b6e1286abd71022e51e47e82216c210eafa5331"), + (65, 31, "acac8dc00c410ecd621d58c75b9bbaad945b00789e2abc9368ad6526cad0ed2d"), + (65, 32, "3980252d905858cb03a0c14292996d6daad3c678d2acb152715a91f92a837a06"), + (65, 33, "4227763a89cedfc0e143647e5b7cf72a1fe4bf079cbd6a2835691d0ac998b1f9"), + (65, 36, "35ceed8d8427fea45451fe7f7d9368196291b343e81ec66ca252b735619c95cd"), + (65, 37, "8897a12f6a01466b6821fac60b72ea247944c3468ffc50ee4f45a0de799c2313"), + (65, 38, "939eec2fe56130170a38053b2ac9186c660b9d3cf2a075726ec69a02c7bdf87d"), + (65, 39, "964d25ddb01cc1b992b596ef4ed5b016000a8a2a292aeb33b627fb3a549ea637"), + (65, 40, "b144d2b8e869a3d503f5202d2c440eefe08c3c6fb43495e3f58ac19bed1fbc69"), + (65, 53, "8734cdd45e5aa566235fa63abe20555cfa1c4ba707ea560d1ba363e94c8f84b8"), + (65, 54, "de2dfe8c27ec466f9d082e2c4ec5bac4f08217b1ff06a17a1af612d05c9ca2a1"), + (65, 55, "60f7739e963ffe269a21dfefc7d905ecbdf3f95b41ae772046d83c737bb77929"), + (65, 63, "a191c4aa479717e92ecfa7f12f567db25a3e16539dd511db27996e736889241f"), + (65, 64, "ed1e85cebd5abc2afbb49f594118b36ac71f5af9a9f5e80c33d1e0409e816630"), + (65, 65, "f5bee8ee4d12a0469b88676f5a6dde59e558914d7440f138ee0c76593b467d7d"), + (65, 90, "c3c8b512c04738d52274324584256090dc7d4a7ebd4311651a549a3884fbe2a7"), + (65, 93, "0f757437629afabc65d7f17a25439bd259f1705f8964f4bc7a91c2c9376add5d"), + (65, 94, "a38d9ee58221d58eaeaef9afa1a52afac32d64d2c177bde2be63fdc9f7616f3f"), + (65, 95, "9caf86397094156924eb8a5e1a4eea51f4dccb1fb473609cd54c87927d332d93"), + (65, 101, "b186a23c4a88ef7a6636c992999f781fd0a8d754a3158024cfc7bcc4b3711484"), + (65, 102, "211b99d47d0a6b9c36b5671ab1f85672b60a4cc0ead5b5ebe7bcc849d3d85366"), + (65, 103, "31c50cab0f9b4c0943080906303614dfdfd81738a9b9a852c122c6a2d493043f"), + (65, 127, "160149b88c4bc76470417fe8edd101558dc0207dccbcce672995d2275e729383"), + (65, 128, "7e19fb40268269792aef82adf78b97076792dd027d17edf05a74b4cc190f1aa1"), + (65, 129, "19bd930914584307f90b6f6541667d53175aa34b359ac8f01ed489860de822e7"), + (65, 166, "45cc1e1673d0cce80958b20fc5fd47ced8206fef393ec6a65eb3249364e041c5"), + (65, 167, "c164f389d85abdb566d77e6e27ebffd904bb83d912efba9e049564f0c2050053"), + (65, 1000, "62d8f0216b01ffc5e08eb3fc85f45606f583ed3a3d289d57ede278df44535252"), + (65, 4096, "b27c8a8538a87834a3a71630d16f29fae5c5f598ec170f4f52be952c56b022f7"), + (131, 0, "b259e3d00c375e5593647936b3b82db6392cf78b67e3ea21c45c12d308605093"), + (131, 1, "0b6d756e2358a5c80c63faa4b9b5aa8fef4eda7a8e56ea76ccb20cacccb03140"), + (131, 25, "c00afd81878608a1e18f1d07f77fd55921e412e7bf540b2db6bca9ac6dcd8be3"), + (131, 26, "d590ce94b36852aa4feaa2ecf3f8a0251cb8e587f400f07aadff00fca5b247d2"), + (131, 27, "8b585274cc155d53b0fbc2bc4f4eaf6bca8f0a35d2549d96fae795eda73bee76"), + (131, 28, "d1f22203f32ab0215c4f944259fff3e57415122848a50f4af0ff0fa4252faab3"), + (131, 29, "4180b7d17a48c3020e900d9f1f2ab7cee739aa649c17f9958994de2641673015"), + (131, 30, "a2857f4299888f487df199ff1480e6f95631f8085ffd3fe4296092db2a57bf3e"), + (131, 31, "a493a58bb4f989d352a63f7127b2515179b60d7708e6e9d020c65eecfd8dbc9c"), + (131, 32, "28f6354b50afc9fadf87e72096b64f35786306c67aa6c06ba89956062f352fd8"), + (131, 33, "da6d2611d2a3134da024e2c5dbfc03e78e3107a43b6afb2e2f37ddff47d7dbdf"), + (131, 36, "c5c59f79cc2fa1bcd745c850675fec584269194bd3cdd541d5dced7e083504e8"), + (131, 37, "74d2aef49410ea0d23a31c2fc78fce5ec776e492b215a4a35038fd32929e6ffe"), + (131, 38, "f779276553740a873b9b3edc938e895bf8ec113b7edbe745dfb0374324fed2aa"), + (131, 39, "8558e616986ed1716ed0e067d2310b78f5e31a1cb7d83ab2f3dc84502dee8dcb"), + (131, 40, "c1d23537278a7881a692f60c7720a4973a1db506b0e8ec2a820bf74312362e46"), + (131, 53, "c2d7a3935e953a7cabd72e83b982decf4ec4f6a34051bdad31367478088685dd"), + (131, 54, "ec7b0d2e3e6b3af8726acb89c479971d387b98b8431760c04f6fea22cd7d815f"), + (131, 55, "666d6337abed7fcaeb3d81e702520238b9cc7b5d7ccf815966433ea55aa37e52"), + (131, 63, "2ed3e90e6ec1c42dd333fe7526a8ddada25b5ac901fbe424f49b5300b6df19cc"), + (131, 64, "f1d0283891be5e076f2c0fc2c7ba2e5e2f86320169464245c5527814cac3e8bd"), + (131, 65, "d7474cd2ac787d33aba5beff7256ef852d86e33154cfc26fb3dff9d473d7623a"), + (131, 90, "9b2e9c606b8453ef8a9853b3253fc5468fdb4c8367655abac0bafe0ca7307ded"), + (131, 93, "663655cc277ae5b8109413689d12119409ede25c24acd9edea9a58b917022c65"), + (131, 94, "40282ed37eff8c662a80a901d50f0dec4ce931822d2516607a3039aae00f9ae7"), + (131, 95, "81b593ae5202392d7666927a7acd27e6da6ea5b2d7887469d2e5929776f3a5a4"), + (131, 101, "9b4911a09395bf21096de2272e99ce638f328a206b7dcb7b7b947acb091116ad"), + (131, 102, "4f2d0969fedfb775cb14a28331596de1d185aa25a23538b6a992179d68ce2183"), + (131, 103, "0308608115b1f9d3abe47c4be3317101530285fa9bb552838bba21509cb9a604"), + (131, 127, "d097db1820679b613eddb1485699c9b2d70e182bc3a679191b8eb179fecb4d99"), + (131, 128, "9edfd95141c8ae7036b97f3800dcbcc631a56b0b2f3a61967640923fff468e3f"), + (131, 129, "4f823c731e5da1fa9bd7d8b1ce2f0890664ab1de21db5feefca095095568c79c"), + (131, 166, "e1b05bf07ba418f4cd0d3898330c02775f73a9897cf7936248f6c8dd7f6828ff"), + (131, 167, "357372cf202d943a4dd4454f29cc1d0c96b287551bc7df4b28955e66b2ce5c34"), + (131, 1000, "4418521a60564c0687e0d7bff5afa1f5cece0cff6fc4b914903b52d3ca843898"), + (131, 4096, "4f45bbcc03d3df7fb89e70b14b5a53e30bafd34f8c949fd554a1074c234b173c"), +]; + +// --------------------------------------------------------------------------------------------- +// Is it HMAC-SHA256? Against an implementation nobody here wrote. +// --------------------------------------------------------------------------------------------- + +/// 330 (key length, message length) pairs, every one checked against CPython. +/// +/// The key lengths straddle the block on both sides — 63, 64, 65, 66 — because RFC 2104's +/// "hash a key longer than the block" rule is the one an implementation gets off by one. The +/// message lengths straddle 55/56 (where SHA-256's length field forces an extra block) and +/// 63/64/65, 127/128/129, 191/192. +#[test] +fn hmac_agrees_with_cpython_at_every_key_and_message_boundary() { + assert_eq!(HMAC_VECTORS.len(), 330, "the table was regenerated with different inputs"); + for &(key_len, msg_len, expected) in HMAC_VECTORS { + let got = to_hex(&hmac_sha256(&key_of(key_len), &msg_of(msg_len))); + assert_eq!( + got, expected, + "hmac_sha256 disagrees with CPython for a {key_len}-byte key over a {msg_len}-byte \ + message" + ); + } +} + +/// The empty key, called out on its own because a length of zero is the input a `copy_from_slice` +/// bound or a `key.len() - 1` gets wrong and the sweep would report as one line among 330. +#[test] +fn an_empty_key_is_the_all_zero_block_and_not_a_panic() { + assert_eq!( + to_hex(&hmac_sha256(&[], b"")), + "b613679a0814d9ec772f95d778c35fc5ff1697c493715653c6c712144292c5ad" + ); + // Which is also what an all-zero 64-byte key gives, since RFC 2104 zero-pads to the block. + assert_eq!(hmac_sha256(&[], b"what"), hmac_sha256(&[0u8; 64], b"what")); +} + +/// `Key::tag` — the thing a frame is actually signed with — against CPython over `DOMAIN || body`. +/// +/// The body lengths are chosen relative to the 26-byte prefix: the inner hash absorbs 64 bytes of +/// ipad, then `DOMAIN`, so the block boundary the streaming `update` has to get right falls at +/// `26 + n == 64` (n = 38) and `26 + n == 128` (n = 102), and the padding boundary at +/// `26 + n == 56` (n = 30) and `26 + n == 120` (n = 94). Each is probed with its neighbours. +#[test] +fn tag_agrees_with_cpython_at_every_domain_straddled_block_boundary() { + assert_eq!(TAG_VECTORS.len(), 216, "the table was regenerated with different inputs"); + for &(key_len, body_len, expected) in TAG_VECTORS { + let got = to_hex(&a_key(key_len).tag(&msg_of(body_len))); + assert_eq!( + got, expected, + "Key::tag disagrees with CPython's HMAC over DOMAIN || body for a {key_len}-byte key \ + and a {body_len}-byte body" + ); + } +} + +/// The domain the tables were generated against is the domain this build computes. +/// +/// Without this the two agreeing tables above would still agree if both were wrong about `DOMAIN`. +#[test] +fn the_generator_and_this_build_agree_on_the_domain() { + assert_eq!(&DOMAIN[..24], b"ferrodb/consensus/mac/1\0"); + assert_eq!(DOMAIN[24], b'C'); + assert_eq!(DOMAIN[25], 2); + assert_eq!(DOMAIN.len(), 26); +} + +// --------------------------------------------------------------------------------------------- +// Is the streaming hash driven correctly? +// --------------------------------------------------------------------------------------------- + +/// Every two-part and three-part split of a message, through the incremental `update`. +/// +/// This is the state machine `Key::tag` relies on when it streams the domain prefix instead of +/// joining it: if `update` mishandled a partial block that exactly fills, or a call that arrives +/// with a block already part-full, the split at 26 bytes would be the one to find it. 26 offsets +/// times every pair of cut points is 6,000-odd hashes and runs in well under a second. +#[test] +fn every_split_of_a_message_hashes_to_the_same_digest() { + for len in [0usize, 1, 55, 56, 63, 64, 65, 90, 127, 128, 129, 191, 192, 200] { + let m = msg_of(len); + let whole = sha256(&m); + for i in 0..=len { + let mut s = Sha256::new(); + s.update(&m[..i]); + s.update(&m[i..]); + assert_eq!(s.finish(), whole, "a two-part split at {i} of {len} bytes diverged"); + for j in i..=len { + let mut s = Sha256::new(); + s.update(&m[..i]); + s.update(&m[i..j]); + s.update(&m[j..]); + assert_eq!( + s.finish(), + whole, + "a three-part split at {i}/{j} of {len} bytes diverged" + ); + } + } + } +} + +/// An `update` with nothing in it must not disturb the block that is part-full. +#[test] +fn empty_updates_anywhere_change_nothing() { + let m = msg_of(100); + let whole = sha256(&m); + for i in 0..=100 { + let mut s = Sha256::new(); + s.update(&[]); + s.update(&m[..i]); + s.update(&[]); + s.update(&[]); + s.update(&m[i..]); + s.update(&[]); + assert_eq!(s.finish(), whole, "an empty update beside offset {i} changed the digest"); + } +} + +/// One byte at a time, across two block boundaries. +#[test] +fn a_byte_at_a_time_is_the_same_digest_as_one_slice() { + let m = msg_of(200); + let mut s = Sha256::new(); + for b in &m { + s.update(std::slice::from_ref(b)); + } + assert_eq!(s.finish(), sha256(&m)); +} + +// --------------------------------------------------------------------------------------------- +// Is `DOMAIN || message` unambiguous, and is the private multi-part form? +// --------------------------------------------------------------------------------------------- + +/// A fixed-length prefix cannot be moved, so no two distinct bodies share an authenticated region. +/// +/// Stated as the thing an attacker would need: a body `b` whose `DOMAIN || b` equals +/// `DOMAIN || b'` for some `b' != b`. Swept over bodies that start with, end with, or wholly +/// contain the domain bytes — the shapes that would collide if the prefix were variable-length or +/// if it were a separator rather than a prefix. +#[test] +fn no_two_distinct_bodies_share_an_authenticated_region() { + let mut bodies: Vec> = Vec::new(); + bodies.push(Vec::new()); + bodies.push(DOMAIN.to_vec()); + bodies.push([&DOMAIN[..], &DOMAIN[..]].concat()); + bodies.push([&DOMAIN[..], b"tail"].concat()); + bodies.push([b"head".as_slice(), &DOMAIN[..]].concat()); + for n in [1usize, 25, 26, 27, 63, 64, 65] { + bodies.push(msg_of(n)); + bodies.push([&DOMAIN[..n.min(26)], &msg_of(n)[..]].concat()); + } + let key = a_key(32); + let mut seen: std::collections::HashMap<[u8; MAC_LEN], Vec> = std::collections::HashMap::new(); + for b in &bodies { + let region = [&DOMAIN[..], &b[..]].concat(); + // The authenticated region determines the body: strip the fixed prefix and it is back. + assert_eq!(®ion[..26], &DOMAIN[..]); + assert_eq!(®ion[26..], &b[..]); + if let Some(prev) = seen.insert(key.tag(b), b.clone()) { + assert_eq!(&prev, b, "two distinct bodies produced one tag"); + } + } + assert_eq!(seen.len(), bodies.len(), "a body was signed twice or two bodies collided"); +} + +/// **The private multi-part form IS ambiguous, and the only thing containing it is that no caller +/// can reach it with two variable-length pieces.** Demonstrated rather than argued, because the +/// doc comment on `hmac_sha256_parts` asserts exactly this and a reader should be able to see the +/// hazard it is claiming to have avoided. +#[test] +fn the_multi_part_form_is_ambiguous_and_only_its_callers_make_it_safe() { + let k = key_of(32); + // Same bytes, different split: one tag. This is the ambiguity. + assert_eq!( + hmac_sha256_parts(&k, &[b"ab", b"c"]), + hmac_sha256_parts(&k, &[b"a", b"bc"]), + "the pieces are concatenated in the hash, so the split point is not authenticated" + ); + // And the fixed-length first piece is what removes it: with `DOMAIN` at the front, the second + // piece begins at 26 and an attacker cannot move that boundary. + for n in [0usize, 1, 26, 38, 102] { + assert_eq!( + hmac_sha256_parts(&k, &[&DOMAIN, &msg_of(n)]), + hmac_sha256(&k, &[&DOMAIN[..], &msg_of(n)[..]].concat()), + "streaming the {n}-byte body after the domain is not hashing their join" + ); + } +} + +// --------------------------------------------------------------------------------------------- +// The key's equivalence classes. RFC 2104 behaviour, restated as what it costs a cluster. +// --------------------------------------------------------------------------------------------- + +/// **Two different key files can be the same key.** A key under the block is zero-padded to it, so +/// a 32-byte file and the same 32 bytes followed by any number of zeros — up to the 64-byte block — +/// mint identical tags and each verifies the other's frames. +/// +/// This is RFC 2104 and every conforming HMAC does it; it is pinned here because `Key::load` reads +/// a whole file and a trailing newline is exactly how a 32-byte key becomes a 33-byte one. +#[test] +fn a_key_and_the_same_key_zero_padded_are_one_key() { + let base = key_of(32); + let frame_body = msg_of(64); + for pad in 1..=32usize { + let mut padded = base.clone(); + padded.resize(32 + pad, 0); + let a = Key::from_bytes_for_test(base.clone()).unwrap(); + let b = Key::from_bytes_for_test(padded).unwrap(); + assert_eq!(a.len() + pad, b.len()); + assert_eq!(a.tag(&frame_body), b.tag(&frame_body), "{pad} zero bytes changed nothing"); + let signed = sign_frame(&a, &frame_body).unwrap(); + assert!(verify_frame(&b, &signed).is_ok(), "the padded key verified the base key's frame"); + } + // A trailing *newline* is a different byte and is not in the class, which is the one comfort. + let mut newline = base.clone(); + newline.push(b'\n'); + let n = Key::from_bytes_for_test(newline).unwrap(); + assert_ne!(Key::from_bytes_for_test(base).unwrap().tag(&frame_body), n.tag(&frame_body)); +} + +/// **A key over the block and its own SHA-256 are one key.** RFC 2104 replaces a long key by its +/// digest, so a 65-byte key file and a 32-byte file holding that file's digest are interchangeable: +/// either verifies the other's frames. +#[test] +fn a_long_key_and_its_digest_are_one_key() { + let frame_body = msg_of(37); + for long_len in [65usize, 66, 96, 128, 131, 200] { + let long = key_of(long_len); + let digest = sha256(&long).to_vec(); + let a = Key::from_bytes_for_test(long).unwrap(); + let b = Key::from_bytes_for_test(digest).unwrap(); + assert_eq!(a.len(), long_len); + assert_eq!(b.len(), 32); + assert_eq!(a.tag(&frame_body), b.tag(&frame_body), "a {long_len}-byte key is not its digest"); + let signed = sign_frame(&a, &frame_body).unwrap(); + assert!(verify_frame(&b, &signed).is_ok()); + } + // Exactly 64 is *not* in the class: the rule is `> BLOCK`, and the boundary is where an + // implementation puts the `>=` by mistake. + let sixty_four = key_of(64); + let d = sha256(&sixty_four).to_vec(); + assert_ne!( + Key::from_bytes_for_test(sixty_four).unwrap().tag(&frame_body), + Key::from_bytes_for_test(d).unwrap().tag(&frame_body), + "a key of exactly one block must not be replaced by its digest" + ); +} + +// --------------------------------------------------------------------------------------------- +// `Key::verify` and `verify_frame` at their boundaries. +// --------------------------------------------------------------------------------------------- + +/// A mac of any length but 32 is refused, including a correct tag with a byte added or removed and +/// a correct tag repeated. Nothing indexes past the end and nothing panics. +#[test] +fn verify_refuses_every_mac_length_but_thirty_two() { + let key = a_key(32); + let message = msg_of(70); + let real = key.tag(&message); + for len in 0..=64usize { + let mut candidate = Vec::new(); + while candidate.len() < len { + let take = (len - candidate.len()).min(MAC_LEN); + candidate.extend_from_slice(&real[..take]); + } + assert_eq!( + key.verify(&message, &candidate), + len == MAC_LEN, + "a {len}-byte mac built from the real tag's bytes was judged wrongly" + ); + } + // The truncated-prefix case said explicitly: 31 correct bytes is not 31/32 of a pass. + assert!(!key.verify(&message, &real[..31])); + assert!(!key.verify(&message, &[&real[..], &[0u8]].concat())); + assert!(!key.verify(&message, &[])); +} + +/// Inner bodies of 0, 31, 32 and 33 bytes, and frames too short to hold a tag. +/// +/// The 32-byte frame is the interesting one: it authenticates an **empty** body, which +/// `verify_frame` must accept (the tag is over nothing and is valid) and `decode` must then refuse +/// as a message with no fields — an error, never a panic and never a `Message`. +#[test] +fn verify_frame_at_the_tag_length_boundary() { + let key = a_key(32); + for len in 0..MAC_LEN { + let err = verify_frame(&key, &msg_of(len)).unwrap_err(); + assert!( + format!("{err}").contains("cannot hold its 32-byte authentication tag"), + "a {len}-byte frame gave the wrong refusal: {err}" + ); + } + for body_len in [0usize, 1, 31, 32, 33, 63, 64, 65] { + let body = msg_of(body_len); + let signed = sign_frame(&key, &body).unwrap(); + assert_eq!(signed.len(), body_len + MAC_LEN); + assert_eq!(verify_frame(&key, &signed).unwrap(), &body[..]); + // ...and the same bytes under a different key are refused. + assert!(verify_frame(&a_key(33), &signed).is_err()); + } + // The empty body authenticates, and then the parser refuses it. + let empty = sign_frame(&key, &[]).unwrap(); + assert_eq!(empty.len(), MAC_LEN); + assert!(verify_frame(&key, &empty).unwrap().is_empty()); + assert!(decode_verified(&empty, Some(&key)).is_err(), "an empty body must not decode"); +} + +/// Every single-bit flip of a signed frame — tag bytes and body bytes alike — is refused. +/// +/// 32 tag bytes plus a 40-byte body is 576 flips; each one is a distinct forged frame and each is +/// asserted to fail. This is the exhaustive form of "the whole frame is inside the tag". +#[test] +fn every_single_bit_flip_of_a_signed_frame_is_refused() { + let key = a_key(32); + let body = msg_of(40); + let signed = sign_frame(&key, &body).unwrap(); + let mut refused = 0usize; + for i in 0..signed.len() { + for bit in 0..8 { + let mut forged = signed.clone(); + forged[i] ^= 1 << bit; + assert!( + verify_frame(&key, &forged).is_err(), + "flipping bit {bit} of byte {i} produced a frame that authenticated" + ); + refused += 1; + } + } + assert_eq!(refused, signed.len() * 8); + assert_eq!(refused, 576); +} + +/// Truncation and extension, byte by byte, at both ends. +/// +/// The frame's length header is outside the MAC, so an attacker who can rewrite it can hand the +/// receiver any prefix of the frame. Each such prefix — and each extension — must fail. +#[test] +fn every_truncation_and_extension_of_a_signed_frame_is_refused() { + let key = a_key(32); + let body = msg_of(48); + let signed = sign_frame(&key, &body).unwrap(); + for cut in MAC_LEN..signed.len() { + assert!(verify_frame(&key, &signed[..cut]).is_err(), "a prefix of {cut} bytes authenticated"); + } + for cut in 1..signed.len() - MAC_LEN { + assert!( + verify_frame(&key, &signed[cut..]).is_err(), + "dropping {cut} leading bytes authenticated" + ); + } + for extra in 1..=8usize { + let mut longer = signed.clone(); + longer.resize(signed.len() + extra, 0); + assert!(verify_frame(&key, &longer).is_err(), "{extra} trailing zeros authenticated"); + } +} + +// --------------------------------------------------------------------------------------------- +// Splicing: can a tag over message A be presented as a valid `tag' || body'` for message B? +// --------------------------------------------------------------------------------------------- + +/// Every rearrangement of two genuine frames that an attacker can compute without the key. +/// +/// The attacker is given two frames minted by the real key and may cut, swap, xor, reverse and +/// re-join them at any offset. Nothing they can build authenticates unless it is one of the two +/// frames they were given, unchanged. +#[test] +fn no_splice_of_two_genuine_frames_authenticates() { + let key = a_key(32); + let b1 = msg_of(40); + let b2: Vec = msg_of(40).iter().map(|b| b ^ 0xa5).collect(); + let f1 = sign_frame(&key, &b1).unwrap(); + let f2 = sign_frame(&key, &b2).unwrap(); + let (t1, body1) = f1.split_at(MAC_LEN); + let (t2, body2) = f2.split_at(MAC_LEN); + + let mut candidates: Vec> = Vec::new(); + candidates.push([t1, body2].concat()); + candidates.push([t2, body1].concat()); + candidates.push([t1, &t2[..]].concat()); + candidates.push([&t1.iter().zip(t2).map(|(a, b)| a ^ b).collect::>()[..], body1].concat()); + candidates.push([&t1.iter().rev().copied().collect::>()[..], body1].concat()); + candidates.push([&body1[..MAC_LEN], body1].concat()); + for k in 0..body1.len() { + candidates.push([t1, &body1[..k], &body2[k..]].concat()); + candidates.push([t2, &body2[..k], &body1[k..]].concat()); + } + // Slide the split: take the whole 72-byte frame and re-cut it everywhere. `verify_frame` cuts + // at 32 by construction, so this is the attacker shifting the *content* instead. + for shift in 1..f1.len() { + candidates.push(f1[shift..].iter().chain(f1[..shift].iter()).copied().collect()); + } + + let mut tried = 0usize; + for c in &candidates { + assert_ne!(c, &f1); + assert_ne!(c, &f2); + assert!( + verify_frame(&key, c).is_err(), + "a {}-byte splice of two genuine frames authenticated", + c.len() + ); + tried += 1; + } + assert!(tried >= 100, "only {tried} splices were tried"); + // The two originals still pass, so the sweep above is not vacuously refusing everything. + assert_eq!(verify_frame(&key, &f1).unwrap(), &b1[..]); + assert_eq!(verify_frame(&key, &f2).unwrap(), &b2[..]); +} + +/// `verify_frame` splits at a fixed offset, so a body that is *itself* a valid signed frame would +/// let an attacker strip the outer tag and deliver the inner message. The first half of this test +/// builds that frame and shows the strip working; the second half shows why no genuine sender can +/// produce one — a real encoded body would have to open with a valid tag over its own remainder, +/// which needs the key. +#[test] +fn a_nested_frame_can_be_stripped_but_no_genuine_body_is_one() { + let key = a_key(32); + let inner_body = msg_of(24); + let inner = sign_frame(&key, &inner_body).unwrap(); + let outer = sign_frame(&key, &inner).unwrap(); + assert_eq!(verify_frame(&key, &outer).unwrap(), &inner[..]); + // Rewriting the (unauthenticated) length header to drop the outer tag yields a frame that + // verifies for `inner_body`. This is the shape; the next assertion is why it is not reachable. + assert_eq!(verify_frame(&key, &outer[MAC_LEN..]).unwrap(), &inner_body[..]); + + for term in [0u64, 1, u64::MAX] { + for body in [ + Body::PreVote { last_term: term, last_round: 9 }, + Body::RequestVoteResp { granted: true }, + Body::AppendResp { success: false, matched: 3, hint: 4, digest: 5 }, + ] { + let m = Message { from: NodeId(7), to: NodeId(9), term, body }; + let frame = encode_signed(&m, None).unwrap(); + let encoded_body = &frame[5..]; + assert!( + verify_frame(&key, encoded_body).is_err(), + "a genuine encoded message body was itself a valid signed frame" + ); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Length extension: prove the attack works, then aim it at HMAC. +// --------------------------------------------------------------------------------------------- + +/// A second SHA-256, written for this file alone. +/// +/// The shipped `Sha256` keeps its chaining state private, and a length-extension forgery needs to +/// *resume* from a digest — so the attacker needs their own. It is also a second instrument: if it +/// and `provenance::sha256` agreed only because both were wrong, `second_sha256_matches_fips` +/// would have to pass against a published vector as well, and it is checked against one. +mod attacker_sha256 { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + const H0: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + + fn compress(h: &mut [u32; 8], block: &[u8; 64]) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes(block[i * 4..i * 4 + 4].try_into().unwrap()); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = *h; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (dst, src) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) { + *dst = dst.wrapping_add(src); + } + } + + /// The bytes SHA-256 appends to a message of `len` bytes. What the attacker splices in. + pub fn glue(len: u64) -> Vec { + let mut g = vec![0x80u8]; + while (len as usize + g.len()) % 64 != 56 { + g.push(0); + } + g.extend_from_slice(&(len * 8).to_be_bytes()); + g + } + + /// Continue hashing `more` from the state a digest exposes, having already absorbed + /// `absorbed` bytes — which must be a whole number of blocks, as it is after any padding. + pub fn resume(digest: &[u8; 32], absorbed: u64, more: &[u8]) -> [u8; 32] { + assert_eq!(absorbed % 64, 0, "a digest is only a resumable state on a block boundary"); + let mut h = [0u32; 8]; + for i in 0..8 { + h[i] = u32::from_be_bytes(digest[i * 4..i * 4 + 4].try_into().unwrap()); + } + let mut buf = more.to_vec(); + let bits = (absorbed + more.len() as u64) * 8; + buf.push(0x80); + while buf.len() % 64 != 56 { + buf.push(0); + } + buf.extend_from_slice(&bits.to_be_bytes()); + for chunk in buf.chunks(64) { + let mut b = [0u8; 64]; + b.copy_from_slice(chunk); + compress(&mut h, &b); + } + let mut out = [0u8; 32]; + for (i, w) in h.iter().enumerate() { + out[i * 4..i * 4 + 4].copy_from_slice(&w.to_be_bytes()); + } + out + } + + pub fn digest(msg: &[u8]) -> [u8; 32] { + let mut h = [0u8; 32]; + for (i, w) in H0.iter().enumerate() { + h[i * 4..i * 4 + 4].copy_from_slice(&w.to_be_bytes()); + } + resume(&h, 0, msg) + } +} + +/// The attacker's hash is a real SHA-256 — checked against FIPS 180-4, not against this crate. +#[test] +fn the_attackers_sha256_is_a_real_sha256() { + assert_eq!( + to_hex(&attacker_sha256::digest(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + to_hex(&attacker_sha256::digest(b"")), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + for n in [0usize, 1, 55, 56, 63, 64, 65, 119, 120, 200] { + assert_eq!(attacker_sha256::digest(&msg_of(n)), sha256(&msg_of(n)), "diverged at {n} bytes"); + } +} + +/// **The detector fires.** Against `sha256(key || message)` — the construction the module header +/// says it refused — the attacker forges a tag for a message they have never seen authenticated, +/// without the key, and it is byte-for-byte the tag the key holder would have produced. +/// +/// This runs first so that the next test's silence means something. +#[test] +fn length_extension_forges_a_sha256_key_prefix_mac() { + let naive = |key: &[u8], m: &[u8]| sha256(&[key, m].concat()); + let key = key_of(32); + let seen = b"term=4".as_slice(); + let suffix = b"&term=9999".as_slice(); + + let stolen = naive(&key, seen); // all the attacker is given, plus the key's length + let glue = attacker_sha256::glue((key.len() + seen.len()) as u64); + let absorbed = (key.len() + seen.len() + glue.len()) as u64; + let forged = attacker_sha256::resume(&stolen, absorbed, suffix); + + let extended = [seen, &glue, suffix].concat(); + assert_eq!( + forged, + naive(&key, &extended), + "the length-extension machinery is broken, so the HMAC result below would be meaningless" + ); + assert!(extended.ends_with(suffix), "the forged message really does carry attacker bytes"); +} + +/// The same attack, the same machinery, aimed at `hmac_sha256` and at `Key::tag`: it fails. +/// +/// The attacker is handed a genuine tag and tries every prefix length that a Merkle–Damgård +/// extension could plausibly need — the message alone, the domain-prefixed message, and either of +/// those behind HMAC's 64-byte inner pad — and none of the forgeries verifies. +#[test] +fn length_extension_does_not_forge_an_hmac_tag() { + let key = a_key(32); + let raw = key_of(32); + let seen = msg_of(23); + let suffix = b"&term=9999".as_slice(); + + let mut attempts = 0usize; + for prefix_len in [ + seen.len(), + DOMAIN.len() + seen.len(), + 64 + seen.len(), + 64 + DOMAIN.len() + seen.len(), + raw.len() + seen.len(), + ] { + let glue = attacker_sha256::glue(prefix_len as u64); + let absorbed = (prefix_len + glue.len()) as u64; + + let stolen_tag = key.tag(&seen); + let forged = attacker_sha256::resume(&stolen_tag, absorbed, suffix); + let extended = [&seen[..], &glue, suffix].concat(); + assert!( + !key.verify(&extended, &forged), + "a length extension at prefix {prefix_len} forged a valid Key tag" + ); + assert_ne!(forged, key.tag(&extended)); + let mut framed = forged.to_vec(); + framed.extend_from_slice(&extended); + assert!(verify_frame(&key, &framed).is_err()); + + let stolen_hmac = hmac_sha256(&raw, &seen); + let forged = attacker_sha256::resume(&stolen_hmac, absorbed, suffix); + assert_ne!(forged, hmac_sha256(&raw, &extended), "hmac_sha256 was extended"); + attempts += 2; + } + assert_eq!(attempts, 10); +} + +// ------------------------------------------------------------------------------------------- +// Pseudo-random external vectors, emitted by the same generator. +// ------------------------------------------------------------------------------------------- + +/// (seed, key_len, msg_len, expected) -- pseudo-random key AND message bytes, so the tables +/// above are not all affine sequences. `Xorshift` below reproduces the generator exactly. +const RANDOM_VECTORS: &[(u64, usize, usize, &str)] = &[ + (11400714819323198485, 11, 287, "aac1012ff8e6ae6a834c1ef61dbd9103b17c2f1b3a76b1774421af6f3b19a0e2"), + (11400713719811571110, 60, 63, "b4cd5f723c46b843d92d2e45db53f73d847a736717dc21a68748f03f4fcaf73a"), + (11400717018346454899, 158, 235, "4594088a2d44be32b80db5b290dd4844438b774199dc6d3cea52c2b70fefb726"), + (11400715918834825484, 168, 160, "61f6bc3dc3bfb94ad1759c3e7321c80a543cc3917857976fd8789a761c38e0db"), + (11400719217369709273, 74, 130, "9acf2f98516fb1ee7eefd810b77b7a5c2400995f7ce413d6de05e62aa7f41903"), + (11400718117858079850, 33, 290, "4f0b208163c4214c0ca5c567be3a86b1caadb556ffea72fff6e858112f7a1885"), + (11400721416392963623, 137, 217, "ea3e1d848af1e12a227a5bea5dc06d8c6208936f73de9eceed51630cea496a9b"), + (11400720316881336304, 1, 218, "6235025e8db27742535e94c4d2018c4953a94251d5d72b6a8f4772862143a4f8"), + (11400706023230173581, 125, 80, "f22a38736b056cfdc7f7fbe093ef332becc87b7c7c29c019f3dd0aa7627f0836"), + (11400704923718546270, 30, 226, "f7f4d0c14e7fbc5c8f6b6ae7b2b4ad1f5d287751acbdb18d03ac3ada7e5b587c"), + (11400708222253427947, 103, 80, "9f8162c64ec51e8b6dd8b78071a3da8729c7b0173e0cb6e7c6f60de166130c1c"), + (11400707122741800612, 49, 40, "15f396cfc91b83e349f68958194594f7e9c936ec1217473df22c101dd933aea8"), + (11400710421276682353, 199, 58, "4a8aa660d41b556fe829ae61514acf309985ae1679da698d8b94c5e26b1c03c1"), + (11400709321765054978, 24, 14, "bbeb9a79f3c3a34eee0e8e904731270563bc4a4f6009373cf7b316adbbb15585"), + (11400712620299938783, 12, 296, "4a6126c60704be79c29ac0a5cda04bc1428d52d3c39315aa8b8295d05693c375"), + (11400711520788309352, 20, 89, "cfb672472d7f02cd2c9cf1277ffd3dd33a44c7604423c478642e3d5ae8dbdb54"), + (11400697227137148709, 100, 20, "13bf7a4c2ee08b6751f1fa685d45d03a58b8d707e467b413cbf481882c55944a"), + (11400696127625519350, 27, 74, "380c571f9d49f32879f095387200d92fbe9f942efdca94210b3463da2a9c456b"), + (11400699426160403075, 165, 116, "be3636edf321034a78f14fd8641f7e6b296a37f25438a6a3b7dc788772c5c89c"), + (11400698326648773724, 106, 33, "510e4b1fc064d118ec6567202f691d1fb34d0c2e44b822634ba7087a86da349a"), + (11400701625183657449, 96, 141, "a8b63860f61510f366a04932fa8cccd2cf01544123c57944712a80fa4c4fa07f"), + (11400700525672030138, 199, 39, "a5d373af2e2e17e48302b9a26f1559c93a1f9fffc1f2dfe7aa3c90ccd1ec882a"), + (11400703824206911863, 159, 296, "03ab15f3b3c60abb943780411c5e8bb37c09d5b4b7f124c4e3435c70d8d2fddb"), + (11400702724695284480, 55, 297, "e648dc40c5b9d26cf7bcade6eeb380b181aec66dc560386c6b65f3eb85fb204c"), + (11400688431044121821, 43, 117, "c3c4e8e1a01a1f08198e137292d874964e2d640e4656019bc98ebb972cc46c48"), + (11400687331532494446, 132, 193, "95d9b6d0ba083b480b1a08c100fd6a7dc9f5c7b56640563418253da3c2cc0fd3"), + (11400690630067376187, 174, 289, "27fc5db46bf6860f6e4600f4da81d1c289ba95405328ebdeeecbe6601f95d4c9"), + (11400689530555748852, 196, 93, "5c4773aa0bdfa2d2acfc0260a546d147a366dd037d28a6092bf81ad2d43d8be7"), + (11400692829090632577, 182, 269, "c7a285ce8d9bbbb4093e99d7bb08aa1121f0dc44e92bb005ebe38f83134ab29e"), + (11400691729579003218, 66, 79, "8f8d759c46587b5046749e562253d49fd9f8372c7c1d9ad05ac9169a089fe530"), + (11400695028113886959, 6, 123, "f1ab88fc80f5107510659d11c8fb733bce30320a33adbab8b5b658f9ed67b142"), + (11400693928602257592, 62, 240, "63933b061da27e4301f65342a42d936de087793c8fda684c0a01f3555b5f2543"), + (11400679634951096949, 198, 269, "57701d777b62eb1f7e9dc3a82c01a07096c84666e26013bb66d12f5fc51b7773"), + (11400678535439467526, 181, 5, "c5c5257a9a01b517899faaebd17dc50b1bf260c70ab923462b339559c68536dd"), + (11400681833974351315, 107, 81, "edaadd29c404c5329d29df23294f74a6198d409acabe04096a1f03cb0734f6b3"), + (11400680734462723948, 185, 6, "584c38eaf9a62c0ce149e4a605bc7be6331633027b996e813822ea21c9233478"), + (11400684032997605689, 103, 80, "f08075e3019bda22b58d8fad47a1b7877c2aeb84988ca919096df36078d38ba6"), + (11400682933485978314, 32, 112, "8b5e9c68ae26799607d99cfe2586be7ff1a8696b39db118497376b5b118f2db9"), + (11400686232020860039, 98, 267, "274ea1c1840407f6937992e5031e1c37cc7166b48e54b506560a653fc2ea91ab"), + (11400685132509232720, 138, 128, "51414c21edf4e4a69fa42a26e4339faa561f5026aaed1cf2c1e7ad7479f9c01e"), + (11400670838858072045, 14, 138, "99bf894f5d23653e51909cf460c11d970b4a2440a3da7d5c071ab4f63f2b48f7"), + (11400669739346442686, 53, 220, "5cea718a19eb93d9a16798ef3c804d3d6d2a8a8a87f35e7dc35249ef2089ee6b"), + (11400673037881326411, 3, 58, "c32de89eb04c2d1f4816ca31a31a21bccc04eb1bcd9e895943edbdd345e03507"), + (11400671938369697028, 157, 166, "0d0770e8c95befce22fe9db5cc437a7ce9e4b79b46b194c558f9fbe3369b3e6d"), + (11400675236904580817, 27, 156, "f51811300d7dbdae7c62cfa666a345342b85c6b5e4e4e1c38af7f5681f982d0b"), + (11400674137392951394, 132, 292, "e9d78dbb586b63ad425c9c5ca1ef5c2386369090cad8aec9d4c196591633df92"), + (11400677435927835199, 56, 238, "648cf3fd81e5c6713465e9ed9952fdcf988b29a681348d1b81004e3849d167cf"), + (11400676336416207816, 96, 91, "c1d551e8768a6e8b43f6bf35334284bdfe18cf0e8d05c367cb6bb675b2331bc1"), + (11400662042765045125, 115, 258, "00dee0d83d6c8ec4d57532e9bd29536628d922cea2be082ad73acef26ce02f17"), + (11400660943253417814, 60, 292, "c2f7d71855f134218cf0cb5f90f9b12d099a448e8d7180fe3383539cf3681e64"), + (11400664241788299491, 30, 226, "50f7bbf6633a6105d5483cb4250355b6b58aa2fd84a91412e14e60dcbea840c7"), + (11400663142276672188, 40, 123, "869873470be024fddbe65430cdca682c50d8d80a79ca77ee7e33ea34322d7432"), + (11400666440811553865, 66, 87, "52f37ed1e8d78390a881dcf691762f76333663b4f13adbdf6ff3bc1d90d25f51"), + (11400665341299926554, 89, 245, "5527c4387abc737c617200a831fb4e4bdcac818fb2a1e69e3d9907efec927726"), + (11400668639834810327, 185, 58, "afc1be1f9aa76f8f44130fdde9728509a403c83a52d90128ddcf76f68fb83c69"), + (11400667540323180896, 161, 151, "e885f8738b1dde1dfe382bd93c0ec5871f85c6162cfd0f682a24872645699e3b"), + (11400653246672020285, 117, 127, "23f631637ece5ccae5eee6217c317f4a9cc0da681d0a229ad81498220e86cae8"), + (11400652147160390862, 19, 99, "93f02a83694297dea532a4a2db903ae578e40bb88a9be15289c9eb3d83040863"), + (11400655445695274651, 181, 211, "422aed2395809e86773f3ae3aaa93454c79c4940219c0129c8154c6c0a1a14ae"), + (11400654346183645268, 147, 71, "79c4a4fec49cf5516a2c60104ce70b724dae7e0d1886d225a2363bab06ebd611"), + (11400657644718528993, 93, 23, "6c0c544b3a7aab61f14b252eb44a61a3c0011363563a110ee9d456fefd33c160"), + (11400656545206901682, 46, 257, "5f809a537f2438fac8485c2d12d8e1f25e7494f8496d87f6ecf801b240e267bb"), + (11400659843741783375, 114, 113, "0609f6cb381715e5ad1ba073670a0aeb5ae0ee4f8b90ae91d9f452100454f9d6"), + (11400658744230156056, 146, 126, "550e4dce73505212ed0bfb78aa4dd0644410775fb67ad546300383143731bce2"), + (11400644450578993365, 146, 111, "ebe4919b6ea98dc2219d095b904768572727cf226e20e4646ffc3ac226d592ef"), + (11400643351067365990, 49, 179, "5a4bc9ce6f7cc94f8aeca05fb956d4a24483a032de1c16deacdeb0c71f15be48"), + (11400646649602247731, 68, 263, "48df3547ec0b6011fe1a382dacca343ef439d05fd5871856845a378c41faade8"), + (11400645550090620364, 130, 288, "b08822bb1eccd911a063434a89a1d0904b7c97fb51fb74da20c7014da0c66c52"), + (11400648848625504153, 32, 46, "ceb2469acc3be047823e90e2602a998de1c39822e385e4e5ee455035868333f5"), + (11400647749113874730, 23, 38, "b5b2406a370fc655ca26484fab6a5eb7b7fa3f7485ec7610e814ec38d8579b51"), + (11400651047648758503, 79, 273, "b492f63307197c2ba6a6a8378aff1eb3f674c8b9c317fac971d5175a08b64aa4"), + (11400649948137129136, 199, 150, "46a8304fea0b2b80a54a75b3d80842e3baa867ffa3de56488d097c02df9dae24"), + (11400635654485968461, 123, 20, "5c3097c5eee6ddbd14475b8c2a6992d0dbc492b4a79999bdd2a94e82d7a96619"), + (11400634554974339102, 52, 246, "edb90084be273e14fe00c21a1723b230145b07edcd5930e3bcc08a37a0b9b839"), + (11400637853509222827, 134, 16, "2b8dc708d9836772ad05b9c862081e7d48af2ad6328575cd8b58947d0bd0850e"), + (11400636753997595492, 156, 252, "f95a5b4aaa16799203ce21158a613828219ca541faf0c49107bfb880aaf5cea4"), + (11400640052532542769, 122, 282, "e08d3d4ebaecabf7af26090d5aedb98dee7ed537a86eaf96d154978428168f75"), + (11400638953020915394, 153, 110, "e5dfd5f54f1860897174928f2756cd187c7d8ac715f5915c111411e6d8bf05e1"), + (11400642251555797151, 5, 180, "bcf9d877347d8e3ab84c8f60d4792c81af9734ea43f432c5e2970f10c0b2429e"), + (11400641152044169768, 5, 241, "02b9b8fab15dc3b55d758130670fdeb24abf994c5bbf14057dde9fb34725ff79"), + (11400626858393009125, 181, 284, "a4a00abc087e2c668294c1d10941109895228f0927b9eb8e6b205b6da3d3a5c6"), + (11400625758881379766, 94, 118, "f68eb211998c50c9996e79aa6c93f8e4ae00062892e453330abacb00080f008d"), + (11400629057416263491, 92, 16, "76c8506c432711ce699efe566ff107f352559160414f4d13313e3d5ec3e65eab"), + (11400627957904634140, 146, 213, "9f3cd59cba50c00b1fe1f56b2e2754bfd9e0693c1d61701c1302d32866dd9ec4"), + (11400631256439517865, 144, 85, "ba7d1182b45a20ff292d449c3fd83a7d2e6e3cca29ecc1ff3e00b52d3396aca5"), + (11400630156927888506, 156, 47, "75de4aaa1f34db29b0a1848fafaf2d09a26f8e456e1f033e914c2f1185132f78"), + (11400633455462772279, 140, 96, "d6353fa936332787e2db66efa0494d3e1caed17ca0bfb285ebf04c2fcf17c5e2"), + (11400632355951144896, 100, 165, "60b9e765a57d49f7b4da0e37b1d4fae6b78c0c9e039f3054d3fcbd135d7a4b54"), + (11400618062299982237, 56, 189, "6dcb70154acd4e8e1d5f2bfb29b33a475e73a64b30c30e516076f2f8455bf67a"), + (11400616962788354862, 199, 249, "a03e5d9584724592645fb15120e1e9cdde583c1b1738198a4111e63300eefe7a"), + (11400620261323236603, 105, 81, "9c83d0ac758e3f03f4d3c93032fa3baf1ffee62d79c6c4db56488192c7a09c4e"), + (11400619161811609268, 47, 65, "b84f9e43320ed3ad2680642bc0c1a475f53a0d3c259f78f16920f330c88da4cb"), + (11400622460346490945, 9, 201, "cc6afc56ae71451824de9836e659b3636bc490c252d6ec3e2d9752d25dd7d62a"), + (11400621360834863634, 58, 287, "796c6815a60f59044c532c06619502eb2dcc728e363fff35f73226f5329e3203"), + (11400624659369747375, 70, 199, "c4e00066885ab8472d23b7524bf7e4f3bedaca4618599ce0eb7f14059381c37e"), + (11400623559858118008, 115, 148, "65cd6f6f2897daf1b7954cac7e7ef68aca391e1c051556ebdbb5787b03d2b0af"), + (11400609266206957365, 67, 289, "87b64af0284f79ded928408e15e4f4f27282bc013a38b71c1a8c0249ccca0278"), + (11400608166695327942, 76, 33, "732735a84b9944aa8668f6d9e5fe309a514c1e736a225539a37dbaf37aba2ecf"), + (11400611465230211731, 102, 101, "86b484d83b3c01f2a73c505ae65e0b7bc3125e30588b07e67a1259202f4b0144"), + (11400610365718582316, 80, 178, "c9ca670543bfa3a8ba70163d321be1a3f8ae4f056cc6637d56116ff508104b09"), + (11400613664253466105, 34, 160, "d9010418bd321c3d241633e42448a151f6f95621605afed361c525a8259308c4"), + (11400612564741838730, 89, 112, "4c55713994ab174baf72b3375323f57979d77fe2aab665ff30de0fd72cb337e2"), + (11400615863276720455, 121, 219, "5fc85e4e7e50c94b202e5b10cb6236781264860a3b1d00feabead2c8ad629f79"), + (11400614763765093136, 129, 72, "5953d6b3e2a90f5e17dc4210c7a7eadf9ff05e0d354ffcd25b4a9b8afdbd4f5c"), + (11400600470113930413, 98, 158, "1eb4248ccb3373936b07f08751352f5c1c89ccf210e50ec9fc797dce03af28b9"), + (11400599370602303102, 97, 104, "19f8d1f4269a7dd6cb145c58d09d408582c661a8fe7dbfab9e2a5b3159822f1a"), + (11400602669137184779, 63, 26, "af1533101fdd103a84974b57086df3a803b0a22d580064ba5ed5e0c130fae7fe"), + (11400601569625557444, 65, 182, "29681455bbbc1c8edfe305428377e0af310fd27d44d77ad82736236d4200766b"), + (11400604868160441233, 127, 216, "3b023d7a203cec051513d1b6f3c60d4a463f1bf89e2c8258db197edbf279e8f4"), + (11400603768648811810, 152, 212, "420f097b8938363166ed0e0cc6671bacc4f8f7f5324a801538c9f9d26d0f8a41"), + (11400607067183695615, 188, 90, "f0c625b54181e84d7be16ecd272c1615a6825a8976fc3ae952628309fa55a8c3"), + (11400605967672066184, 60, 47, "f5ca7297790f9b42b9ff52da21f397f23b22649ebeeb55f4945646678324d355"), + (11400591674020905541, 132, 206, "feed877d3d0686efc45a43bbdac6a54f0a3a6cd85f5a152efae84c9c3950141e"), + (11400590574509276182, 69, 176, "3ee319f95999c81b16a0c1a31fded9d3d119c8b2b43c6280da882a7c545ae1af"), + (11400593873044159907, 91, 106, "1b47a16079298704dd070f20c50e434639e0565d01528812ad5e84c1730d269f"), + (11400592773532532604, 161, 187, "8042b486dd2ce467fb162a26c30a4de809e3d2bd570402357deba2124787b1e4"), + (11400596072067414281, 167, 259, "1f747a57f6403b65e617ce2448020c767d527d9f40d701a3a4b137dbbf006722"), + (11400594972555786970, 160, 109, "76583171864626a64d0f921c41bb5ffff0df3a10b660e66e9ddc2edf1d76bfb2"), + (11400598271090668695, 152, 106, "429b68b91338654340455d49beb49766121b859ba45a19a57b3b7e64557f3039"), + (11400597171579041312, 48, 291, "e609e6c60761cf10c85ce4c0632ba909b45e32932cdfd0728b775f321115c43c"), + (11400582877927880701, 28, 131, "49c806b329c6cd3bf278c71246eb750fb3d59110873a0ecef0746ab26f097504"), + (11400581778416251278, 3, 191, "f802cd227d52327195bec2ca6db4571d3a90d7917887b94ce0a8a3a68f134e46"), + (11400585076951135067, 93, 155, "1f0cd12f1dab95f9e9c10f112a8aae53e5ec1ba0a2b652bacad23652151cee41"), + (11400583977439505684, 64, 263, "7d56b3581f310f8a4a0441d01520787fdb1fa6bfc665c3db878650d01f1e9954"), + (11400587275974389409, 26, 167, "90030536490c9ce821c23f80eb810626fc7ee95a4263e0424df7c5942fed4c2f"), + (11400586176462760050, 161, 129, "b59e4a9cd63afd783c8376447061ea7dadb894d61ca05e4ed52c1725f1893627"), + (11400589474997643791, 181, 269, "cc1c1c4d38a21acc1b2f1d91f51f954951f56803f5f7184527fa9910ec55968a"), + (11400588375486016472, 69, 50, "8f4e92df6bb952d64c973291ec283af732b3f32e7a24526996806c95bb63842e"), + (11400855556811564437, 173, 99, "5f2e8647aed3bbe69d77e9873a97310f8b32703d6c015caf63301d2c484c2625"), + (11400854457299937062, 102, 55, "03fc7c0d1b8b5f8b8b5e5c3f76e998f069f5fa9a818bb7d3c1cd05925d42ebea"), + (11400857755834818803, 52, 179, "768ed3fea6654bf7238c3b5e8b1428e9c57d9b77c0030e46dc99fbc97e7f0225"), + (11400856656323191436, 178, 180, "f3022b2a68604810750486695a82b0b409d03342ae775f5c4ceb507d20654fab"), + (11400859954858073177, 33, 22, "92613e84612746718659619391c09b20115b344c1b8d7b3eb24ceef50771d5b1"), + (11400858855346445802, 10, 298, "71e6619cc093630ae19ce1cdacbad2d44a331e5a61f6c2cdba492aa9ba97af87"), + (11400862153881329575, 34, 61, "598e22f1e187c828606d900fdf2ac7a04447bde5156e495c36d882eeef4cdad1"), + (11400861054369700208, 90, 110, "779cfbf4ca8787b56057a9a9f95416d52540df54187d6715fbd14f4fc7e517f4"), + (11400846760718539533, 46, 292, "50d72d25c6831ab180afb653b0499da111109859311fb225f4e06716c7f41a04"), + (11400845661206910174, 101, 62, "b9873fd10d53b35e3172731569d5e729bfccfa2f4410a54627a324977422d5b1"), + (11400848959741793899, 35, 116, "e98d9bab9af55bbd6bff59674a9c210e8c41c2a91fd3586b3904ade856e36b62"), + (11400847860230164516, 109, 76, "4285812c089d230f091e6b3887e3b2d2df421f438e1cdc9e513fa90fc84eb329"), + (11400851158765048305, 187, 238, "525340d538f96f12f76d5b675f582acb25ec8d043b15015e507b9951eb555a0f"), + (11400850059253420930, 116, 174, "841ccc90362b645723dbc1b3b4270c466c0358e968192ba37fbe0d40ee728d8d"), + (11400853357788302687, 123, 284, "dd9cb25b1d1717589c468e16e71de6e43feda65f3166e5c2914ac3b58d965d77"), + (11400852258276675304, 187, 29, "78fbb1ea03cd86052aac5a7c0ae58e46ca379abe1fb933cd46f6479532c1546e"), + (11400837964625512613, 83, 260, "341d9ab3f0e32246f4dfd12f111f120c9a9bc4eb0c82cecec3cf28a828fcfce1"), + (11400836865113885302, 68, 134, "a3f613b3c4cd6219469b87f25aefea319302849135837db6c2b132114b79a07c"), + (11400840163648766979, 126, 0, "e96ad79747c9cadfe4b7b23d68c7760d630334a7dbfd97b093164d309c0f5633"), + (11400839064137139676, 16, 73, "03bea50d7d33c4857891528ddf22f086e7e022422e4ebce89feaa613b93be87c"), + (11400842362672023401, 162, 185, "50b7fcb1af5c68f1bacc49c54dceb080d087f384b588934a0aa5f18a360b8dcb"), + (11400841263160394042, 33, 83, "28ebd7f513d60038b70ebcf89f32145432d23a2e5c481bf577e847815f0f6547"), + (11400844561695277815, 25, 184, "44e6cf5e9349103f8806601a128f73b51017b6cf2fffcefe0226fc55ed4cc244"), + (11400843462183713920, 33, 5, "996e6267fa524d8f19deb7a1f7fd040c32fd18632821993e219a0507923318c9"), + (11400829168532553309, 157, 93, "57a1c498270490e3ee78006579b641dbfbd7f8b77c3769b213b61e91caffd5d4"), + (11400828069020925934, 78, 141, "7bc00dc1d077549543010e457632791fe243ae6870cf3cd260e82fdf56089857"), + (11400831367555807675, 92, 5, "49093c3b53b2d48b71c4c2b7e70ee85be2904bb9b814e9a94d56f1680d53ff23"), + (11400830268044180340, 14, 253, "8e14a5f61ff6846c1697b1b192c46626b3ba42e7384949e064a5ae8c34eff268"), + (11400833566579062017, 28, 85, "6f08e26e595222a12cce4df057c3554e99f1df2df0d394b2ccea47405c3ce27b"), + (11400832467067434706, 139, 123, "00d3f19b6b2b3c8baaca40d815201b98592484515eebb994e2705f16f04a8551"), + (11400835765602316399, 199, 127, "2ea86db66af878d0b1e381e9627443befe89337638d3b7f87101832b359feca3"), + (11400834666090689080, 103, 60, "88143cffa78f60ae6e9daa90375d88f24f9045f33b89a31872181af8be3ef132"), + (11400820372439528437, 103, 217, "7370ef65936822c147213f5211ba62159326e4c23e21553154daeacb485ad48a"), + (11400819272927899014, 11, 17, "2049da52e7d053de6912fe2e8e7ffd272a2435b0d7be066ea5d40f6a017dd878"), + (11400822571462782803, 29, 193, "0974a88dabebfbbe46c70deeb4e2da2b0ee1139d1857970ab6995e0739979bc8"), + (11400821471951153388, 191, 94, "cd92512916ed300c7b04437b6f647aefc5faca88eea73d46750a7bc3321584e1"), + (11400824770486037177, 33, 232, "779e3c9fb7a345ae86d485ef946eb19488d2d9717c01cc7acfc01c415fd6ec27"), + (11400823670974407754, 98, 84, "a9a914cd5e49c9f712fda75b2f9af6aed119de1a962b1e1ffc841586a8150266"), + (11400826969509291527, 90, 295, "0df4db0a65c0f81a616a649de03540f5b65e5433f1890bbddbee77466f362413"), + (11400825869997664208, 42, 260, "602810c1c20ecde1735604783bd1e4544935c3fd25950b187964e8ea10881fd2"), + (11400811576346501485, 174, 210, "18d164d88a280d67b8379e15c2208550c1833c4bf120b791744303570fc59e02"), + (11400810476834874174, 197, 56, "4c78844cdcf089122fd2b6c1d2280be17755a996a7f67b6f497d08c3123a55f5"), + (11400813775369755851, 30, 234, "7f03aaca63e50af88b53b82b9b1f6defd3e9afb096b1183d733dad6a63f7da35"), + (11400812675858128516, 44, 274, "75f0b38b2f24725e8697da86df7c138de2349291b9ddbe2d6f0fad1baa9a9c5e"), + (11400815974393010257, 126, 256, "9eb0958006950ee2fa13337551020acee6b052cee83d8fcf818e4d3d75aef8e9"), + (11400814874881382882, 53, 84, "b658aea9a7259b0334a3cc0fb568523070ad065efb55410f9e4d4d48ac2e933e"), + (11400818173416266687, 49, 142, "8117b0a44b2c061119fc4e2f113a10e694d81e1ff0ac44c07082a6284785aae1"), + (11400817073904637256, 9, 243, "3c358c92f2ce9b181b40bd50a6f0ad8d4b37bfcf8d59aa41c1cdeebf0a90c22b"), + (11400802780253476613, 137, 278, "13ff36d77205c6d01d0dea3c7a96c80dfe008908936719f4197259bc2019026a"), + (11400801680741847254, 50, 4, "1c53d7be04c28c6d46c3d8b8e8945093442ff8356ff3913e32381a5f592ede5d"), + (11400804979276730979, 96, 170, "b19bebb6251f05ca98ad202a28210ddca8c2a9a7cb0bd1ab674090cb7132d1ea"), + (11400803879765101628, 89, 203, "185485c28451dd9585cbde6c69eaf70d7cb20371777cd4c8b7c901b1dabedff9"), + (11400807178299985353, 7, 167, "981a9ec2dd88b9efd4285bc285efe4d88a6af0a2e66dd82b3adbdf1b263c6829"), + (11400806078788358042, 64, 285, "2376ff1e6ce5a5d44f12e74344158927be66b911f67c8e84f96c366e4f795cee"), + (11400809377323239767, 48, 166, "7a217be128c085c8555483ca99878c741536a27a2ea97611e170ed9690d41bf8"), + (11400808277811612384, 176, 179, "b0a1a52efd8c157bda823a72e4682245a7c1cb6fbe0f563be59c1f6f83b61643"), + (11400793984160449725, 68, 203, "78719174f7f8e7ff9236283869ed37587f9a55a292e95f4f8c9239820ad99deb"), + (11400792884648822350, 99, 83, "b1bcd25399e6579b9ce9343d8b63ec4babcf991666ed3ae2ca3c66616d3e6095"), + (11400796183183704091, 133, 275, "d123740dd0076bedf4cb1ef3bdfe40c7e8c35cad2682500550459d400e689f01"), + (11400795083672076756, 35, 207, "249c46834844e885143174090f22f15c170af6ec059d192eeb720c886fcb6261"), + (11400798382206960481, 149, 95, "07c5753da82679998718297edc95c42154a049a5c8f7cfabc4175c33156b2bc0"), + (11400797282695331122, 140, 221, "157fd8ea496537a80f5c34516f6656e429ef62af81a889056688a5bc1b4aa707"), + (11400800581230214863, 96, 161, "12d7ef2ae8912a42673bc2b8c9cbc91abfa202835e3487cc6bc5cfdc806070ef"), + (11400799481718585496, 72, 230, "bcd5110cfa6980fd394956b6e96829fd40f97eb26624763d820dabb0b515196b"), + (11400785188067424853, 48, 271, "468480ade0fd19ce4af5896f1dedf1152ad9715ea40075468ffbaa52a3f9489a"), + (11400784088555797478, 7, 219, "dd216bda2b6dcc52a906975f006514f9f067ae98f7d5f497fcc59e42e86e0a80"), + (11400787387090679219, 113, 99, "ee09de287ec0608dd14d49564140ac8f2f201b5338415297a9d7950afe1b6b97"), + (11400786287579051852, 75, 116, "84726420afce5c59885b68604ab8bda69268df4cbc34952ac8f31bd1d87ea387"), + (11400789586113933593, 69, 158, "41a90d54657a1546085bd736bd9f5b235daf15886b127b2c09e69860b153e12b"), + (11400788486602306218, 142, 174, "b09e158fb9a1ff0e41e6a0708fbf1ad0dd0d80e57d7599326222ced6999d0c8c"), + (11400791785137187943, 1, 157, "46d5214cc54ad1e8fd5c2882e2d2095d449bd33a4feeb32160340ebe00fefc9e"), + (11400790685625560624, 57, 278, "f2fa35e087af78079837055f95e7870af5416692945d89c96b496bf0123d117d"), +]; + +/// The same rows, but authenticated the way a frame is: HMAC over DOMAIN || body. Only rows +/// whose key reaches the 32-byte minimum, because those are the ones a `Key` can hold. +const RANDOM_TAG_VECTORS: &[(u64, usize, usize, &str)] = &[ + (11400713719811571110, 60, 63, "f07495a64108db880b8d1bcd181662772f34588b57585d29f4102348a72bf74e"), + (11400717018346454899, 158, 235, "29bdcd431f5d76d3ec78bd006bb94f849058f8dca0a6116148e788565539289b"), + (11400715918834825484, 168, 160, "214b96aee71f75a319a64fd73d62207d96e51d1aa36da29b79c2ac009d1eb39e"), + (11400719217369709273, 74, 130, "c095ab01eae1412badde98312d938f557cd0b1b5e1672778f897299ed32fa5d3"), + (11400718117858079850, 33, 290, "486e86fa14bff3bf28cf2ade65a4dc18a886c9ae369c6eeb9d6847d2ed099963"), + (11400721416392963623, 137, 217, "757018455a778e62252b2bf273b52cbc445c128b86c87a247d16ae5cbd5b3353"), + (11400706023230173581, 125, 80, "5db3073d5d67e09fb6ca88ad822ddf38b386b178c11ea9a36eaf22058c1fb30f"), + (11400708222253427947, 103, 80, "f6f96fb972b1dab6833fdf932d5f7f24e2eebf19823fb354e5760a5e6dee59d9"), + (11400707122741800612, 49, 40, "0e16c0c0db2dbb6bc6a8fd57088d64739dbee7982db1652989ddafffce6c871a"), + (11400710421276682353, 199, 58, "8a505dd5b3ebab2d596937848e18e29b1d9e43f9aa3afb711ce99da0813fc7bb"), + (11400697227137148709, 100, 20, "bcf5706e5c06f4d892eb574c03f05d2427d54e62bc0bc1d1d9015ae18d2e15f4"), + (11400699426160403075, 165, 116, "f3eba865065b7471866cf1faaa94548dd68f27fdbd94d4dcaf10ae4d375ab485"), + (11400698326648773724, 106, 33, "e51a56a0658f27f5a9554e8698f51f6c65ff72b646b16ddcc83ca903b261dcd3"), + (11400701625183657449, 96, 141, "cbe81432a85005395d8e4fe8268f5a01c34f4dd9376b5cdf8a20c679f499ceed"), + (11400700525672030138, 199, 39, "ea2dd5e552acdb180949d1466048531ed282c357b8103c4c2b5ae4808ea85959"), + (11400703824206911863, 159, 296, "8e3f16c49830d5945eaf7b301f1ea277b2510c0989d0cfd487935b0b914e5c76"), + (11400702724695284480, 55, 297, "8516c707b744406ce011aca53e8e09d5f47c6c45a58741d3c270db27345a00cf"), + (11400688431044121821, 43, 117, "cf849a78443e3138ca0f69a5d9127ce45adcd5fe1cc1a1064d7be5bd4647374c"), + (11400687331532494446, 132, 193, "68a0f6887a255b1c9ef074a16531e3c9e0109eababa34627b04fc05d3ab7fd68"), + (11400690630067376187, 174, 289, "4af68791727e4ed137ede46edae86c3137b54780f538c6c245153497bb24808d"), + (11400689530555748852, 196, 93, "708f87412fa9c9fcd5782cd2e42fa58a5b6b3649ac1648a10873c383424472ec"), + (11400692829090632577, 182, 269, "46384dbe8b7587b2d3dad25af87f4714ebdc21282c39c5b7c4f0cff8d130f65e"), + (11400691729579003218, 66, 79, "c577b01177a9b937752ecc12e586d85ce40f4dd9aef85a665aa05a5ea05462a8"), + (11400693928602257592, 62, 240, "ce5bb31659c12c6f904ffa40037ce52fee821b9e584ca2e5c670e2bf76943c2c"), + (11400679634951096949, 198, 269, "63fcfb05a8122beca703911ec623eb9cc9e3c7c3613a46d10833d454a52a5631"), + (11400678535439467526, 181, 5, "9618462102ad16233e0eb3b38ba5895a2ab975628f5df5cd65e380105581eb0f"), + (11400681833974351315, 107, 81, "1630316dfb8b49a827892db3225c9b038bd405480c301ee1bb75b275dd4f85f7"), + (11400680734462723948, 185, 6, "686c30b256095da0cd997ccc2e134563ebede36a83a38d88e47536335f24b280"), + (11400684032997605689, 103, 80, "69e5481b70cea6213257719d3d7f2dd6c319f61d3a4d91e0bbcccb6cb984aca2"), + (11400682933485978314, 32, 112, "b0c210da956d3337efb3cbaae18d65cb53f87fc55eaecd046029308552ed5afa"), + (11400686232020860039, 98, 267, "0ca65a0c559a45875609813ea655a07b08e6e2b0ba02732043f251d5801078c1"), + (11400685132509232720, 138, 128, "e0a47bd6665dfbc34ef73eea7feace8e991a0eb3767bb8c6a5f4b489d26550eb"), + (11400669739346442686, 53, 220, "595680e1e536ecda5d0c9772703ad0f800288446c6a1eda934b03608eafa0ab5"), + (11400671938369697028, 157, 166, "75c4feb606bbacc966e33401a1bdda5ff45caaa07f3716e3c8208ae7ba8e312f"), + (11400674137392951394, 132, 292, "8559ed2aa53097e061e13e7206b0a2db36d48efe5cbc5388537fd289d3f8a884"), + (11400677435927835199, 56, 238, "cd5ca0ec655bba1cb2c48e7b0d2e50f18b52db44f14a3b7cc0dc55f5fe68f73c"), + (11400676336416207816, 96, 91, "2e79dcb45bb63774e059a0d345df109733c53bc03d1536ffd4baf44d3c91034e"), + (11400662042765045125, 115, 258, "9efa28f743857731c52e922efdfc8b33f89793faba800c1ef8fe6476b8eb2d41"), + (11400660943253417814, 60, 292, "ec6754ac7f5824a86ec71346bccddfa070fe2facd8d50b2f438f265c845d7b4c"), + (11400663142276672188, 40, 123, "295d3d13b0a7a898a8393b23ea59e4471e5d12585eb08101189f8afe4de356df"), + (11400666440811553865, 66, 87, "a689ed85c0555075cd17b9eada059c37986038f095ba7f2d8aeba650475d31b4"), + (11400665341299926554, 89, 245, "351486f11307cb9f61b6515362cadcf98731acd16d5853290d20de329c59e260"), + (11400668639834810327, 185, 58, "a0240a9a52eebe6dc34067b1ae26c0e8b2b3082d8596b696a7ad8e792119a67b"), + (11400667540323180896, 161, 151, "9a87701a59e68ea18e0694718125b32bca3ce7f47b3b0b9defc89a127344d72b"), + (11400653246672020285, 117, 127, "c418ab7e629974f512f0247c9f93cf6c6b56a0027d387c8cb89b88e6542a0932"), + (11400655445695274651, 181, 211, "f2e97a61503f28823575553a24d628826f524f2565743cc9f1ddcb893619b716"), + (11400654346183645268, 147, 71, "deece427722e72bc8eb41d89d199b6724820ed1afe18801dd5f2896a687cf8fb"), + (11400657644718528993, 93, 23, "8dd8664cb341f799ac812de1fc13274ae21d7792658de344e93b470f58310ad9"), + (11400656545206901682, 46, 257, "f1356ac4a8f4833dd4f0c7d650931ac8c9b0b1b48d1ec704cd58867b24064454"), + (11400659843741783375, 114, 113, "84d978035493ac6dd4c45e5246f1436f698529adaecbf8056cc4b2b4809a4fa2"), + (11400658744230156056, 146, 126, "85cb694da08facec717ae3be101f39316bce8c40ea4e757f25d4c211fdfb5881"), + (11400644450578993365, 146, 111, "3f6b99136492e51b0ced056b1e6aceacda1929c306cbaa35000900c980b0312b"), + (11400643351067365990, 49, 179, "acbfb6dff13fe630eec7b48f1ec9982bd1d32ad8b84018496fdf6ae10c2253a6"), + (11400646649602247731, 68, 263, "7af6b04a69842d205ee8a367a90c5e08767ba77c767bb7366b12589e1400ddab"), + (11400645550090620364, 130, 288, "92b6f38670bca89a1b79c2d2d5fa7075ab6fcfb58e9537b7e9eb99dd005e8f30"), + (11400648848625504153, 32, 46, "19192a245ca6e2c24f38fff7483744ffd26aa0129cb86bee19ba9ba11d8c9edb"), + (11400651047648758503, 79, 273, "c7ee47b66e623b227351f49882f59101146cc900ff313d4d27769750c8d47d11"), + (11400649948137129136, 199, 150, "d5fc69900d20761ba3150967c0832e48c27cee68901888d77f09ee097da3cc0b"), + (11400635654485968461, 123, 20, "2506154feabbc87cef665756d115866e3d0ef8b9188ec3453fa3398569a299c8"), + (11400634554974339102, 52, 246, "807333d234367810081c3c4b63631541160d98c2f73b767a8c6375d8dd8ade55"), + (11400637853509222827, 134, 16, "c8a524b59da4106760c0de7ebffa4cb79716a2a11a78794f1c15488a25d5fd31"), + (11400636753997595492, 156, 252, "92a4b78c19cea6a1ce9b2891cd34a3a486b682a6751a8da302e8b766d91c7ecd"), + (11400640052532542769, 122, 282, "6475c95dec79ec0b9ccabdebb43b48ca3a60c46d92e619cdc5c6776e9e7b976c"), + (11400638953020915394, 153, 110, "4550644f274adbe4ba86749ef8fe1656d613bb5f17df4b643db772cb0c7701f2"), + (11400626858393009125, 181, 284, "ab59808ec8f62d4f45abbc04e29a4247771721efdc091362c91aff91df836892"), + (11400625758881379766, 94, 118, "bbf71e0ec33cf5b316d2a01ecf42fda0e57b939b923f976572d4de4d3c99d469"), + (11400629057416263491, 92, 16, "02bcbb8c6e9d378e2b3bcb95650edf504a2df2a9b44211ef0a6b4b8d0b8758c1"), + (11400627957904634140, 146, 213, "3413c01b41cb92a0bd1e2878cacf5c65cc4235ef00a0d54f9fe5aebe517c00ca"), + (11400631256439517865, 144, 85, "d94bea5a5a1a9bf29278703c74551525eda13cc9441c33b86f72424652cf2fea"), + (11400630156927888506, 156, 47, "52a7a153a3d29f89961adb9e7b9c4d356dbaa8da075922ef9e2faee867b5af6f"), + (11400633455462772279, 140, 96, "516b815f2f988f03ff8571d192c6c812293895b0b0626d5706149ca97a4b4d95"), + (11400632355951144896, 100, 165, "7095ec36500a8ab690e427f7c8c24543bc312eddd138cd10a8e1861e00ad9b01"), + (11400618062299982237, 56, 189, "d351fbed9556a46c7d8ac821c4a16ad48fd825fef3a884f12d9a8776ae662fe5"), + (11400616962788354862, 199, 249, "2eb9fe418b0def60563cef848399aba859b9468b712bdc87b5c7c53c82d303ef"), + (11400620261323236603, 105, 81, "c1b4b46aa65eba1a9dd3c90fb5a8192666f269c4c9a66d0c037cf214e076deea"), + (11400619161811609268, 47, 65, "6e7f36e0101c94bf5c377912738d9d1d9910533d2c6cd7349fd6f11f9a2b5207"), + (11400621360834863634, 58, 287, "9dfb0502b9f1939a44b7e3604b06b436c977db5e4644a943c0041d72d1552571"), + (11400624659369747375, 70, 199, "6ce3aa492f94559a993e9b54f6b18e31e8b0f7882107259094ec781f91873e57"), + (11400623559858118008, 115, 148, "84f4ed39b6a1dd639a071ec13d0982a7b2cb32844aba31d234e18bc2f7e05fce"), + (11400609266206957365, 67, 289, "f0af307276cae470c98d9766c35a3a366dda2bf73127d5963a37e7742a79cbf5"), + (11400608166695327942, 76, 33, "e1bcc9198c9dcf14d2871e2a6c191cd5bc21f96b883d7dfc06f3cc10b0b7fea1"), + (11400611465230211731, 102, 101, "b24389bc26e7de7fa0e20e4f2b0c6ebe579b1b893b9756e902e107d802a46bd5"), + (11400610365718582316, 80, 178, "d64e9b8f01411766b6ba344b27d9b20071ed499e161f0a8bacb48d5651496bf0"), + (11400613664253466105, 34, 160, "87485e22217cb12b5120bdd025e3193a881267a0220e82e8f496179cb00f9742"), + (11400612564741838730, 89, 112, "cf784c06a0f56992b925c12e4f3a59f2fd459a621b18313e93b87667778e8b27"), + (11400615863276720455, 121, 219, "9f455c6f15d71ef230f2d270bb62866888bdebaf22dd00a1aaeaf08a96c4e700"), + (11400614763765093136, 129, 72, "4036b5b71c43d26f71148a2c600004549e8bf8fde4dcebff0bcbf91106bb2fec"), + (11400600470113930413, 98, 158, "0761d5d90153a655c5f99b9a9269e0986a69998c0be96836586a6c4058ae840a"), + (11400599370602303102, 97, 104, "3a4f3210b39c40658bc1b45e8199629eae48697857f46adabdf4f83267395df4"), + (11400602669137184779, 63, 26, "e7c5cf300448eb241f35b6a0851ba9eb32a8629e6a3478c7b399b6eb54307fb8"), + (11400601569625557444, 65, 182, "6a5897abf35437b429c84faca384616b7f951cfe8bbd81d70594dde5cbc44e7f"), + (11400604868160441233, 127, 216, "936a284e8a409cbb9339329f715c2d280015844ca5d3ecc3540d8a234cfe3619"), + (11400603768648811810, 152, 212, "9143d3348a7a6fb0f6dc3b79b8208bcfcc37370ba7f0707bad59c019145643ed"), + (11400607067183695615, 188, 90, "3e3312773f126e29be8ecaefce0c060f09f17512282cb0ef62b101028c5c46d3"), + (11400605967672066184, 60, 47, "172566d19d8b47a72ad1a61f3fad1c15b593e9386c5ce9de0e84519876365706"), + (11400591674020905541, 132, 206, "6b0c85905a72ae474b026cbe12322360151a69d6190485000f8fe534ce647d8e"), + (11400590574509276182, 69, 176, "784a096ee7312ce84dcd9686f2a539fccce48f19c6530fc2570f127ff911b9d6"), + (11400593873044159907, 91, 106, "1294a933c6917f970aef57407c600e9f8b86b94ccc217e128006a445e891451f"), + (11400592773532532604, 161, 187, "55d569736d06a2b8d5efeb2b289b126ee9557001f981c047934aa952f6a08d4c"), + (11400596072067414281, 167, 259, "6eeee86dd2c063934e5df3e900fba8c04b1c0594316a7ee61e8f0cf8bda6d0dc"), + (11400594972555786970, 160, 109, "870420e4e9e32c7fe4d50a85716639bde35d6d0e3689bd0b39b5b1f10ab60b34"), + (11400598271090668695, 152, 106, "7531d3a721aa9e4ddc15b3fc468bd30417192a5fc9274849bc5b97adce7eb987"), + (11400597171579041312, 48, 291, "769be8ad8547c5695dfc66ac68f20bcaa1486ffcd1b7851e02919b23614acc79"), + (11400585076951135067, 93, 155, "aecedee4dbca2beb4684d6a6519676c455516ddbc0d9e9dc9886eadbda66016c"), + (11400583977439505684, 64, 263, "1f7886f7205269663d9bb1c149c3dd70a28d95544aa568b9c4a40a2a08958790"), + (11400586176462760050, 161, 129, "ca1ae23941c6fa9725588d539cc74114f45b56ad213e9cfb71870ef987f48958"), + (11400589474997643791, 181, 269, "9a209a67843a6f28013ba8c82f1559031ba38b59ca0990804f2a1f54534a0eda"), + (11400588375486016472, 69, 50, "3cd7a370c6d0de34a82f6a854d3a121a7282928f6b37866ebdc7b4ae504e9ec9"), + (11400855556811564437, 173, 99, "d8e1f0df735bf457389a4eb28c6d0a69fec3ade5a736397e185f91a9162c783a"), + (11400854457299937062, 102, 55, "dac33f00db5261d436caade5a88231465f723f5f9b48eabe74a1991bea295895"), + (11400857755834818803, 52, 179, "906a5ed738e46a98e83daacb1cc546bc899024a812816fda1de39150659f999b"), + (11400856656323191436, 178, 180, "4cc734967fcc8391f8caeba6a63516c912eeec9e7e54994d7c2b89b3bbef4a4a"), + (11400859954858073177, 33, 22, "b35b50573984a5701c723b5d1e687f87b795cf9287ae58f03936086c003ca750"), + (11400862153881329575, 34, 61, "c4f4dd8ed19f5713ceacd6f36e6784cf3a470790206ef465e04f3d6ccc2b45a7"), + (11400861054369700208, 90, 110, "4fa5cd365875cec80ba3d1562989930a94086a7b0f3f0f320d1ad5dbd9c941c4"), + (11400846760718539533, 46, 292, "c86c96d7c22859087a1f8f011f668d842ee676c7898ad32e4275bbe5c6690bb0"), + (11400845661206910174, 101, 62, "eafef50f10b79e9101edea0a54ca21604e55cebbe9124351e9121d6f30420623"), + (11400848959741793899, 35, 116, "c9aa99f1658952e63622dfb218b1f28cc76cd9c88037428ff557a42ef897e513"), + (11400847860230164516, 109, 76, "4e1f901be7408410ae45e109cb2d34dd6a83a3d7cedd9288dd39547d2bbab9b0"), + (11400851158765048305, 187, 238, "e3cfd82619717a5505595ff8732fdca86d36fda06a803a8a71d64261ac184553"), + (11400850059253420930, 116, 174, "8dbe7b09f66a6c40f9b1c8f3667549a761957744dddb60aaaf8e0f9f58daf60f"), + (11400853357788302687, 123, 284, "e586bba3da2d8897e5651a35839314934046d8189d8e1e17a0301863722e01a8"), + (11400852258276675304, 187, 29, "038726246187e16b826951cb11af41f27acda825d33a188effbd91128cb9ceea"), + (11400837964625512613, 83, 260, "de2eba27323b166e9687706bddc49d9c33b46fc941caa49720e82847659899d3"), + (11400836865113885302, 68, 134, "1924751ba5682ef02c8caba548c4dec5af1dc20b8690dfb46c6ee2547953592d"), + (11400840163648766979, 126, 0, "ecd690e0faa7da4bb899b4c7b3f333f54b7406c094a57dbdd0fb90f5389e7686"), + (11400842362672023401, 162, 185, "711fa1bac62c32054ea062320b323b7172ca470c82a850c157eb458a9b6df7c8"), + (11400841263160394042, 33, 83, "db427c2f941174cec033e4034cccd1970fa40875f7daff393a5ba787c36542dc"), + (11400843462183713920, 33, 5, "6ed71798d00694873e3e2419d1ac7576e6a62623d3ed9d7a834c39d12bbde9df"), + (11400829168532553309, 157, 93, "9e34d84ea22e410e0d0cf8701d8600ce6d9830f017da1ae93a78c719ec6a98ed"), + (11400828069020925934, 78, 141, "069c24916f84c75be8a07bf60e1478fa2c355ef6a10b7526443513fe60e0ce36"), + (11400831367555807675, 92, 5, "a3636f95b2b9d5e18f8d24063c8a062f8565190ad3bc27d30bbd1cd9008d7f03"), + (11400832467067434706, 139, 123, "ba48a9a907ddd6c59fe98372cc998d9299ea64a465c2ccd5f1821ece3a6d72ba"), + (11400835765602316399, 199, 127, "77ad93613957bc30963c91944f727eb16c60b6f3eb7b4a543a5e93d128e3a522"), + (11400834666090689080, 103, 60, "6a2a94562973e40125c6e9930aa7755c550520d8bed2e59d39d42b22bef38862"), + (11400820372439528437, 103, 217, "03e4370225870050cc323f015e489a1961385f358018145cb8d19c55b0669715"), + (11400821471951153388, 191, 94, "9840f21db28e68d851b4db987e1fdd794a54ce39b81fc0442385b95bc41af2c3"), + (11400824770486037177, 33, 232, "796ee20908c3cfc86e513eb15a1235c17ce2aa41ba404ba003007f43c15eb2b9"), + (11400823670974407754, 98, 84, "2f6b5239eb1aa00415441db62213ae3eaa768e26e759499aad4bad9ac1d8fd41"), + (11400826969509291527, 90, 295, "9ef00f9c0353e95c04b848fc9a7e4bdfb9198b2424c6c4bff6d5db47a42005e7"), + (11400825869997664208, 42, 260, "094a0b00b0e18a0e5c407ef9715dc5153df4ab1c055625d80d2c8b4cd59de9ba"), + (11400811576346501485, 174, 210, "0d0802be12a15a23880940b93128b6b98c1b0eba047dde784ddbe31040388361"), + (11400810476834874174, 197, 56, "503d03ef84e26216b2a589aeb6657f0ef10ec54b95a8ca8c8fa7c6bc68669ab8"), + (11400812675858128516, 44, 274, "4528c91ec48fbc3de5fb7a1ea7ece85ac4e305833885fffdba18cd55866ea012"), + (11400815974393010257, 126, 256, "b0d86e3e8c90e332fbae0526e01bfe88e2d283284ad1044659ee02ed34da2c69"), + (11400814874881382882, 53, 84, "06bf6e613e55cdff2fafff6b60331cb1553f720833094a645d84d1652233092b"), + (11400818173416266687, 49, 142, "a6ff526d3498b3a97a95ce9117ad33bd0f9acb4bba2413409ae5e0433c5c4768"), + (11400802780253476613, 137, 278, "11d9fad91fcfed3c6ebb8b8c45579ba0d4e447ef7ee58f5d4b66121bae725ea9"), + (11400801680741847254, 50, 4, "d7d1805d7568cfeef9050239199ebcb13b9ea32ec6de7de4044e9cc6ead55a34"), + (11400804979276730979, 96, 170, "e7afc99e3dcbd82eb48ff596d7358459831bebd53c37b81781a6e33dd715fd92"), + (11400803879765101628, 89, 203, "7b03f285c0bc9e8a82e721fc0002ebf8cb8e113691102c3ba3372cb58f5e4c39"), + (11400806078788358042, 64, 285, "1375866ae1b5c450c5d8e4938cd75c71e407a8bd8a46f116090c7eaf522d0ff6"), + (11400809377323239767, 48, 166, "7dc69c042d5eaea0e15e2ce42e3464b7375991a1ec24c9ce3efbd09d6e4872b9"), + (11400808277811612384, 176, 179, "9b7cddf240bcfded109133f97b6c0def4b81c8b476f8c808b92c235e9bd2645c"), + (11400793984160449725, 68, 203, "86d2f4c1277244356d65f132ca7044e2fd2241bdd6941130dac3e3e8f1f6f427"), + (11400792884648822350, 99, 83, "5911cd64d83f0562391199c09e499995869a1b35f195a5fa3f318eff0993e0db"), + (11400796183183704091, 133, 275, "91ced33cf93ebc256368d2bdb94463049b7c69db9d14194cb9e10ae0c0793302"), + (11400795083672076756, 35, 207, "48d70324535748435a904a772b362bd89288ee374d648ce6a9961fca35258ddc"), + (11400798382206960481, 149, 95, "2ee77a94163ef0757bc3c13b0a4239efff8adff4bcbffbca1e6a659180696113"), + (11400797282695331122, 140, 221, "78b746af4bb908bacc88586f09a6c662c88a6d06a853902cec4a00057eb1d836"), + (11400800581230214863, 96, 161, "c90e3ba36949642b4241efbc751eea6caf09f4db089cc2e5afc87c3a1fc3f02a"), + (11400799481718585496, 72, 230, "58c53f81153ad064e9c197d9095ec14756745aee9567e2137db4d5aec24bca02"), + (11400785188067424853, 48, 271, "ba8c511d686091258bc2d648a56850c3bc2ba751a930b1283d3c6ed46643e9d9"), + (11400787387090679219, 113, 99, "9aa0bc3201321dd273be26cef5252965681482da34519166dad531425d7563ee"), + (11400786287579051852, 75, 116, "83967ef09f0566b3d709bb2e3f1081b2fda214e0169340438075a5c5fa00b546"), + (11400789586113933593, 69, 158, "e9b4c1b02d8d2689b753b935f87ff55e697943eecba5a2362bf8e2fdacfdfc86"), + (11400788486602306218, 142, 174, "36a1bf2cd54ca0a362cb6f5124d17272e77ed3052a1b61fd7d9403e6b0ef3591"), + (11400790685625560624, 57, 278, "f97510680a334c167ad53e8d71a4be7b8db41b1afa33c43ac2c8f3472957300e"), +]; + +// --------------------------------------------------------------------------------------------- +// The same question again, with pseudo-random inputs. Every table above is an affine byte +// sequence, and an implementation bug that depends on key *content* — a mis-XORed pad, a carry — +// could hide inside one. These rows have random keys and random messages, and CPython still owns +// every expected value. +// --------------------------------------------------------------------------------------------- + +/// xorshift64*, reproduced byte for byte by the Python generator that emitted the tables. +struct Xorshift(u64); + +impl Xorshift { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn bytes(&mut self, n: usize) -> Vec { + (0..n).map(|_| (self.next() & 0xff) as u8).collect() + } +} + +/// The row's inputs, rebuilt from its seed exactly as the generator built them. +fn random_row(seed: u64) -> (usize, usize, Vec, Vec) { + let mut r = Xorshift(seed); + let key_len = 1 + (r.next() % 200) as usize; + let msg_len = (r.next() % 300) as usize; + let key = r.bytes(key_len); + let msg = r.bytes(msg_len); + (key_len, msg_len, key, msg) +} + +#[test] +fn hmac_agrees_with_cpython_on_two_hundred_random_key_and_message_pairs() { + assert_eq!(RANDOM_VECTORS.len(), 200); + let mut key_lens = std::collections::BTreeSet::new(); + for &(seed, key_len, msg_len, expected) in RANDOM_VECTORS { + let (kl, ml, key, msg) = random_row(seed); + assert_eq!((kl, ml), (key_len, msg_len), "the Rust and Python generators diverged"); + assert_eq!(to_hex(&hmac_sha256(&key, &msg)), expected, "seed {seed}"); + key_lens.insert(key_len); + } + // The sweep is only worth anything if it straddles the block; say so rather than assume it. + assert!(key_lens.iter().any(|&k| k < 64), "no key under the block"); + assert!(key_lens.iter().any(|&k| k > 64), "no key over the block"); +} + +#[test] +fn tag_agrees_with_cpython_on_random_keys_and_bodies() { + assert_eq!(RANDOM_TAG_VECTORS.len(), 168); + for &(seed, key_len, body_len, expected) in RANDOM_TAG_VECTORS { + let (kl, bl, key, body) = random_row(seed); + assert_eq!((kl, bl), (key_len, body_len)); + let k = Key::from_bytes_for_test(key).expect("the table keeps only rows at or over 32"); + assert_eq!(to_hex(&k.tag(&body)), expected, "seed {seed}"); + // And the frame layer agrees with the primitive it is built on. + let signed = sign_frame(&k, &body).unwrap(); + assert_eq!(to_hex(&signed[..MAC_LEN]), expected); + assert_eq!(verify_frame(&k, &signed).unwrap(), &body[..]); + } +} + +// --------------------------------------------------------------------------------------------- +// "Only bytes THIS key signed": the neighbourhood of the key. +// --------------------------------------------------------------------------------------------- + +/// Every one of the 256 keys one bit away from the real one refuses the real key's frame. +#[test] +fn no_key_one_bit_from_the_real_one_verifies_its_frames() { + let real = key_of(32); + let body = msg_of(50); + let signed = sign_frame(&Key::from_bytes_for_test(real.clone()).unwrap(), &body).unwrap(); + let mut tried = 0usize; + for i in 0..real.len() { + for bit in 0..8 { + let mut near = real.clone(); + near[i] ^= 1 << bit; + let k = Key::from_bytes_for_test(near).unwrap(); + assert!( + verify_frame(&k, &signed).is_err(), + "the key with bit {bit} of byte {i} flipped verified another key's frame" + ); + tried += 1; + } + } + assert_eq!(tried, 256); +} + +/// Eight keys, none in another's RFC 2104 equivalence class: a frame verifies under exactly the +/// key that signed it and under no other. +#[test] +fn a_frame_verifies_under_exactly_the_key_that_signed_it() { + let lens = [32usize, 33, 40, 63, 64, 65, 96, 131]; + let keys: Vec = lens.iter().map(|&n| a_key(n)).collect(); + let body = msg_of(38); + let frames: Vec> = keys.iter().map(|k| sign_frame(k, &body).unwrap()).collect(); + for (i, frame) in frames.iter().enumerate() { + for (j, k) in keys.iter().enumerate() { + assert_eq!( + verify_frame(k, frame).is_ok(), + i == j, + "the frame signed by the {}-byte key was judged wrongly by the {}-byte key", + lens[i], + lens[j] + ); + } + } + // No two of these eight are the same key, which is what makes the matrix above meaningful. + let tags: std::collections::BTreeSet<[u8; MAC_LEN]> = + keys.iter().map(|k| k.tag(&body)).collect(); + assert_eq!(tags.len(), keys.len()); +} + +// --------------------------------------------------------------------------------------------- +// The length rule and the key it lets through. +// --------------------------------------------------------------------------------------------- + +/// A key file, owner-readable only where that is expressible. Local to this file rather than +/// shared with `tests_signing`, which is a sibling module and cannot lend its helpers. +fn key_file(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + } + p +} + +/// Load in the way that reaches the *length* rule on every platform: off Unix, `Key::load` refuses +/// at the ACL check first, and a test of the length rule there would be asserting the wrong thing. +fn load_reaching_the_length_rule(path: &std::path::Path) -> Result { + let check = if PROTECTION_IS_CHECKABLE { + PermissionCheck::Enforce + } else { + PermissionCheck::AcceptUnverifiable + }; + Key::load_with(path, check) +} + +/// **A 32-byte file of NULs loads, and it is the same key as the empty file the same guard +/// refuses.** `Key::load` measures the key's *length* and nothing else, and RFC 2104 zero-pads a +/// short key to the block — so 32 zero bytes and 0 bytes prepare the identical pads and mint +/// identical tags. A file made by `truncate -s 32 key`, by a sparse copy, or by a generator that +/// wrote nothing and exited 0 passes every check this module makes. +/// +/// This is not a forgery: an attacker still has to know that the key is zeros. It is a guard that +/// refuses one spelling of a key and accepts another that is cryptographically the same one. +#[test] +fn a_key_file_of_thirty_two_nul_bytes_loads_and_is_the_empty_key() { + let zero = Key::from_bytes_for_test(vec![0u8; MIN_KEY_BYTES]).expect("32 bytes clears the rule"); + let body = msg_of(64); + // Identical to the key `Key::load` refuses outright, because both prepare an all-zero block. + assert_eq!(zero.tag(&body).to_vec(), hmac_sha256(&[], &[&DOMAIN[..], &body[..]].concat()).to_vec()); + assert_eq!(zero.tag(&body).to_vec(), hmac_sha256(&[0u8; 64], &[&DOMAIN[..], &body[..]].concat()).to_vec()); + + // And it is a key a file on disk can hold: the length rule is the only content rule there is. + let dir = tempfile::tempdir().unwrap(); + let empty = key_file(dir.path(), "empty.key", &[]); + let nuls = key_file(dir.path(), "nuls.key", &[0u8; MIN_KEY_BYTES]); + let refused = load_reaching_the_length_rule(&empty).unwrap_err(); + assert!(format!("{refused}").contains("is 0 byte(s)"), "unexpected refusal: {refused}"); + let loaded = load_reaching_the_length_rule(&nuls).expect("32 NUL bytes is a key this build accepts"); + assert_eq!(loaded.len(), MIN_KEY_BYTES); + assert_eq!(loaded.tag(&body), zero.tag(&body)); + // A frame signed by the zero key verifies under it, so a cluster would run on it happily. + let signed = sign_frame(&loaded, &body).unwrap(); + assert_eq!(verify_frame(&zero, &signed).unwrap(), &body[..]); +} From 183f09cd5d6449466bfd68c4e72910ca1a82f822 Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Thu, 17 Sep 2026 20:01:47 -0400 Subject: [PATCH 014/315] probe(S18-mcts-topology): three reproducers for the MCTS branch topology Deliberately-failing reproducers plus their controls. Not a gate; two tests in s18_mcts_topology_probe FAIL on purpose and the file header says so. 1. reaper fast/slow path is decided by BranchRecord::live_children (reaper.rs:233, is_childless_leaf). TableBranchCatalog::hydrate (table_catalog.rs:486) never populates it, so every reap against the SHIPPED catalog (cli.rs:91) takes the FAST path and frees pages a live child reads. Control on LogBranchCatalog, same shape, parks them correctly. 2. collapse -- the only escape from MAX_BRANCH_DEPTH=8 -- allocates ONE arena (reaper.rs:352) and deep-copies the whole reachable graph into it (reaper.rs:192). Measured: survives 254 pages, refuses at 268. ARENA_EXTENT_PAGES=256. The documented MAX_COLLAPSE_PAGES=65536 guard is 256x above the real ceiling and unreachable. 3. one written page reserves a whole 1 MiB extent. Measured: a 7-node chain writing 7 pages reserves 1792 pages; after reaping its 6 interior nodes it still holds 2 extents where the flat control holds 0. --- tests/s18_collapse_cost.rs | 125 +++++++++++++++++ tests/s18_extent_pinning.rs | 103 ++++++++++++++ tests/s18_mcts_topology_probe.rs | 226 +++++++++++++++++++++++++++++++ 3 files changed, 454 insertions(+) create mode 100644 tests/s18_collapse_cost.rs create mode 100644 tests/s18_extent_pinning.rs create mode 100644 tests/s18_mcts_topology_probe.rs diff --git a/tests/s18_collapse_cost.rs b/tests/s18_collapse_cost.rs new file mode 100644 index 00000000..a2afc29d --- /dev/null +++ b/tests/s18_collapse_cost.rs @@ -0,0 +1,125 @@ +//! S18: what does the ONLY escape from MAX_BRANCH_DEPTH cost? +//! +//! `TwoTierReaper::collapse` deep-copies every page reachable from the branch's root +//! (`src/branch/reaper.rs:155-203`) with a hard budget of `MAX_COLLAPSE_PAGES = 1 << 16` +//! (`src/branch/reaper.rs:63`). This measures the copy against dataset size and forces the cap. + +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowPageLinks, CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +struct Env { + catalog: Arc, + store: Arc, + path: std::path::PathBuf, +} +impl Drop for Env { + fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } +} + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("s18-collapse-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let pool = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(pool, Arc::clone(&catalog) as Arc, base).unwrap(), + ); + Env { catalog, store, path } +} + +fn k(i: u32) -> Vec { format!("k{:08}", i).into_bytes() } +fn v(i: u32) -> Vec { format!("v{:08}", i).into_bytes() } + +/// Build `rows` rows on trunk, fork a chain to depth 7, collapse it, report pages and time. +fn one(rows: u32, tag: &str) -> (usize, f64) { + let e = env(tag); + let tree = CowTree::new(Arc::clone(&e.store) as Arc); + let reaper = TwoTierReaper::new( + Arc::clone(&e.catalog) as Arc, + Arc::clone(&e.store), + ).with_links(Arc::new(CowPageLinks)); + + let ep = e.catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..rows { + root = tree.insert(root, BranchId::TRUNK, ep, &k(i), &v(i)).unwrap(); + } + e.catalog.set_root(BranchId::TRUNK, root).unwrap(); + + // The MCTS shape: a selection path seven deep. The eighth fork is the one that refuses. + let mut cur = BranchId::TRUNK; + for _ in 0..7 { + cur = e.catalog.fork(cur, LeaseDeadline(u64::MAX)).unwrap().branch_id; + } + let deep = e.catalog.get(cur).unwrap(); + assert_eq!(deep.depth, 7); + // The eighth fork is refused: this is the wall collapse exists to get past. + assert!(e.catalog.fork(cur, LeaseDeadline(u64::MAX)).is_ok(), "depth 8 is still allowed"); + + let pages_before = tree.walk_pages(deep.root_page_id).unwrap().len(); + let live_before = e.store.live_page_count().unwrap(); + + let t = Instant::now(); + let outcome = reaper.collapse(cur); + let ms = t.elapsed().as_secs_f64() * 1000.0; + let collapsed = match outcome { + Ok(c) => c, + Err(e) => { + println!( + "rows={rows:>7} reachable_pages={pages_before:>6} collapse REFUSED after \ + {ms:.3} ms: {e}" + ); + return (pages_before, -1.0); + } + }; + + let live_after = e.store.live_page_count().unwrap(); + let copied = tree.walk_pages(collapsed.root_page_id).unwrap().len(); + assert_eq!(copied, pages_before, "collapse copied the whole reachable graph"); + assert_eq!( + live_after - live_before, pages_before as u32, + "collapse ADDS a full copy; it frees nothing" + ); + println!( + "rows={rows:>7} reachable_pages={pages_before:>6} collapse={ms:>9.3} ms \ + live_pages {live_before} -> {live_after} (+{})", + live_after - live_before + ); + (pages_before, ms) +} + +#[test] +fn collapse_cost_is_the_whole_dataset_and_it_adds_a_second_copy() { + println!("MAX_COLLAPSE_PAGES = {} pages = {} MiB at 4 KiB", + 1usize << 16, (1usize << 16) * 4096 / (1024 * 1024)); + let mut rows = Vec::new(); + for n in [1_000u32, 4_000, 8_000, 12_000, 16_000, 17_000, 18_000, 20_000, 24_000, 64_000] { + rows.push((n, one(n, &format!("n{n}")))); + } + println!("\nrows pages ms result"); + for (n, (p, ms)) in &rows { + println!("{n:<9} {p:<7} {:<10} {}", + if *ms < 0.0 { "-".to_string() } else { format!("{ms:.3}") }, + if *ms < 0.0 { "REFUSED" } else { "ok" }); + } + let ok_max = rows.iter().filter(|(_, (_, ms))| *ms >= 0.0).map(|(_, (p, _))| *p).max().unwrap(); + let fail_min = rows.iter().filter(|(_, (_, ms))| *ms < 0.0).map(|(_, (p, _))| *p).min().unwrap(); + println!("\nLARGEST reachable page graph collapse SURVIVED: {ok_max} pages ({} KiB)", + ok_max * 4); + println!("SMALLEST that it REFUSED: {fail_min} pages ({} KiB)", + fail_min * 4); + println!("ARENA_EXTENT_PAGES = {}", ferrodb::branch::types::ARENA_EXTENT_PAGES); + assert!(ok_max <= 256, "collapse survived past one extent ({ok_max} pages)"); + assert!(fail_min > 256, "collapse refused inside one extent ({fail_min} pages)"); +} diff --git a/tests/s18_extent_pinning.rs b/tests/s18_extent_pinning.rs new file mode 100644 index 00000000..c0233398 --- /dev/null +++ b/tests/s18_extent_pinning.rs @@ -0,0 +1,103 @@ +//! S18: what an MCTS tree pins that a flat fanout does not. +//! +//! An arena extent comes back to the free-space map exactly one way: `free_arena`, called from +//! the reaper's FAST path for a childless leaf (`src/branch/reaper.rs:233-238`). An MCTS interior +//! node is not a childless leaf for as long as any descendant is live. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline, ARENA_EXTENT_PAGES}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; + +struct Env { catalog: Arc, store: Arc, path: std::path::PathBuf } +impl Drop for Env { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("s18-pin-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let pool = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(pool, Arc::clone(&catalog) as Arc, base).unwrap()); + Env { catalog, store, path } +} + +/// Write ONE page — the smallest thing a branch can do that makes it a writer. +fn write_one(e: &Env, b: BranchId) { + let arena = e.store.arena_for(b).unwrap(); + let ep = e.catalog.next_epoch(); + let p = e.store.alloc_in_arena(arena, PageType::BTreeLeaf, ep).unwrap(); + let h = e.store.read_page(p).unwrap(); + let mut f = h.write(); + f.data[PAGE_HEADER_SIZE] = 1; + stamp_checksum(&mut f.data); +} + +#[test] +fn one_written_page_reserves_a_whole_extent_and_a_chain_never_gives_it_back() { + let e = env("chain"); + let reaper = TwoTierReaper::new( + Arc::clone(&e.catalog) as Arc, Arc::clone(&e.store)); + + let base = e.store.reserved_page_count(); + println!("ARENA_EXTENT_PAGES={ARENA_EXTENT_PAGES} PAGE_SIZE={PAGE_SIZE} \ + extent = {} KiB", ARENA_EXTENT_PAGES as usize * PAGE_SIZE / 1024); + + // ---- THE MCTS SHAPE: a selection path. Each node writes one page. ---- + let mut chain = Vec::new(); + let mut cur = BranchId::TRUNK; + for _ in 0..7 { + let rec = e.catalog.fork(cur, LeaseDeadline::from_now(60_000)).unwrap(); + cur = rec.branch_id; + write_one(&e, cur); + chain.push(cur); + } + let reserved_chain = e.store.reserved_page_count() - base; + println!("7-node selection path, 1 page written each: reserved {reserved_chain} pages \ + = {} KiB for 7 pages of actual data", + reserved_chain as usize * PAGE_SIZE / 1024); + assert_eq!(reserved_chain, 7 * ARENA_EXTENT_PAGES, + "one whole extent per writing branch, regardless of how little it wrote"); + + // Prune the INTERIOR nodes — exactly what MCTS does to a losing subtree's spine while the + // deepest node is still being expanded. Deepest-last, so every reap sees a live child. + for b in chain.iter().take(6).copied() { + let _ = reaper.reap(b); + } + let reserved_after_prune = e.store.reserved_page_count() - base; + println!("after reaping the 6 interior nodes: reserved {reserved_after_prune} pages \ + ({} extents still held)", reserved_after_prune / ARENA_EXTENT_PAGES); + + // ---- THE FLAT CONTROL: 7 childless leaves off trunk, same writes, same reaps. ---- + let e2 = env("flat"); + let reaper2 = TwoTierReaper::new( + Arc::clone(&e2.catalog) as Arc, Arc::clone(&e2.store)); + let base2 = e2.store.reserved_page_count(); + let mut flat = Vec::new(); + for _ in 0..7 { + let rec = e2.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + write_one(&e2, rec.branch_id); + flat.push(rec.branch_id); + } + assert_eq!(e2.store.reserved_page_count() - base2, 7 * ARENA_EXTENT_PAGES); + for b in flat { reaper2.reap(b).unwrap(); } + let reserved_flat = e2.store.reserved_page_count() - base2; + println!("flat fanout, same 7 branches reaped: reserved {reserved_flat} pages \ + ({} extents still held)", reserved_flat / ARENA_EXTENT_PAGES); + + assert_eq!(reserved_flat, 0, "a flat fanout returns every extent wholesale"); + assert!(reserved_after_prune > 0, + "the chain must still hold extents the flat fanout gave back"); + println!("\nSAME writes, SAME number of reaps, SAME data: chain holds {} extents, \ + flat holds {}", reserved_after_prune / ARENA_EXTENT_PAGES, + reserved_flat / ARENA_EXTENT_PAGES); +} diff --git a/tests/s18_mcts_topology_probe.rs b/tests/s18_mcts_topology_probe.rs new file mode 100644 index 00000000..b65abe7f --- /dev/null +++ b/tests/s18_mcts_topology_probe.rs @@ -0,0 +1,226 @@ +//! S18 fire-check. **TWO OF THESE TESTS FAIL ON PURPOSE.** They are reproducers for structural +//! limits found while mapping BranchBench's MCTS topology onto this design, not a gate: +//! +//! * `interior_node_reaped_while_a_child_is_live_must_not_free_the_childs_pages` — FAILS. +//! Against the shipped `TableBranchCatalog` the reaper takes the FAST path for an interior +//! node and frees pages a live child can still read. +//! * `depth_cap_stops_the_chain_at_eight` — FAILS at depth 9, which is the documented cap +//! (`MAX_BRANCH_DEPTH = 8`) observed against the shipped catalog rather than the test one. +//! +//! The other three pass and are the controls that make those two mean something. +//! +//! Does the reaper's fast/slow path decision survive the SHIPPED catalog? +//! +//! Every reaper test in the repo runs against `LogBranchCatalog` (see +//! `src/branch/arena.rs:1282`, `harness::Harness::new`). The shipped binary uses +//! `TableBranchCatalog` (`src/cli/cli.rs:91`). The two disagree about `BranchRecord::live_children`. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline, PageId}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; + +fn tmp(name: &str) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("s18-{}-{}.db", name, std::process::id())); + let _ = std::fs::remove_file(&p); + p +} + +struct Rig { + catalog: Arc, + store: Arc, + reaper: TwoTierReaper, +} + +fn rig(name: &str) -> Rig { + let dbp = tmp(name); + let catp = tmp(&format!("{name}-cat")); + let file = std::fs::OpenOptions::new() + .create(true).read(true).write(true).open(&dbp).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Arc::new(TableBranchCatalog::open_sidecar(&catp, 1).unwrap()); + let base = bp.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(bp, Arc::clone(&catalog) as Arc, base).unwrap(), + ); + let reaper = TwoTierReaper::new( + Arc::clone(&catalog) as Arc, + Arc::clone(&store), + ); + Rig { catalog, store, reaper } +} + +fn write_pages(r: &Rig, b: BranchId, n: u32) -> Vec { + let arena = r.store.arena_for(b).unwrap(); + let epoch = r.catalog.next_epoch(); + (0..n) + .map(|i| { + let p = r.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let h = r.store.read_page(p).unwrap(); + let mut f = h.write(); + f.data[PAGE_HEADER_SIZE] = (i & 0xff) as u8; + stamp_checksum(&mut f.data); + p + }) + .collect() +} + +/// THE MCTS SHAPE. Reaping an interior node whose child is still live. +/// +/// A writes page P. C forks off A AFTER P was born, so C can read P. A is abandoned and reaped +/// while C is still live. The reclamation rule says P is NOT reclaimable: C forked in +/// [birth(P), free(P)). +#[test] +fn interior_node_reaped_while_a_child_is_live_must_not_free_the_childs_pages() { + let r = rig("interior"); + let a = r.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + let pages = write_pages(&r, a.branch_id, 5); + let live_after_write = r.store.live_page_count().unwrap(); + + // C forks off A. C's root IS A's root, so C can read every page A has written. + let c = r.catalog.fork(a.branch_id, LeaseDeadline::from_now(60_000)).unwrap(); + assert!( + r.catalog.has_live_children(a.branch_id.id).unwrap(), + "fixture: A must have a live child in the CHILD index" + ); + assert_eq!(c.parent_id, Some(a.branch_id)); + + // Every page A wrote is visible to C. + for p in &pages { + let h = r.store.read_page(*p).unwrap(); + assert!(h.header().unwrap().birth_epoch < c.fork_epoch, "page born before C forked"); + } + + // A is abandoned. The interior node is pruned; its child survives. + let freed = r.reaper.reap(a.branch_id).unwrap(); + + let live_after_reap = r.store.live_page_count().unwrap(); + assert_eq!( + live_after_reap, live_after_write, + "THE SLOW PATH DID NOT RUN. reap(A) freed {freed} pages that C can still read \ + (live {live_after_write} -> {live_after_reap}); A had a live child, so every one of \ + those pages is pinned by the interval rule." + ); +} + +/// The flat-fanout control: the same reap, with NO live child. Here the fast path is correct. +#[test] +fn flat_fanout_leaf_reap_is_correct() { + let r = rig("flat"); + let base = r.store.live_page_count().unwrap(); + let a = r.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + write_pages(&r, a.branch_id, 5); + assert_eq!(r.store.live_page_count().unwrap(), base + 5); + r.reaper.reap(a.branch_id).unwrap(); + assert_eq!(r.store.live_page_count().unwrap(), base, "a childless leaf returns to baseline"); +} + +/// The depth cap, against the shipped catalog. +#[test] +fn depth_cap_stops_the_chain_at_eight() { + let r = rig("depth"); + let mut cur = BranchId::TRUNK; + let mut reached = 0u8; + loop { + match r.catalog.fork(cur, LeaseDeadline::from_now(60_000)) { + Ok(rec) => { + cur = rec.branch_id; + reached = rec.depth; + } + Err(e) => { + panic!("fork refused at depth {reached}: {e}"); + } + } + if reached >= 32 { + break; + } + } +} + +const _: () = assert!(PAGE_SIZE == 4096); + +// --------------------------------------------------------------------------------------- +// THE CONTROL. Identical shape, `LogBranchCatalog` instead — the catalog every reaper test +// in the repo uses (`src/branch/arena.rs:1282`). If this passes while the table-catalog +// version fails, the variable is the catalog, not the fixture. +// --------------------------------------------------------------------------------------- + +use ferrodb::branch::catalog::LogBranchCatalog; + +struct LogRig { + catalog: Arc, + store: Arc, + reaper: TwoTierReaper, +} + +fn log_rig(name: &str) -> LogRig { + let dbp = tmp(name); + let file = std::fs::OpenOptions::new() + .create(true).read(true).write(true).open(&dbp).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = bp.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(bp, Arc::clone(&catalog) as Arc, base).unwrap(), + ); + let reaper = TwoTierReaper::new( + Arc::clone(&catalog) as Arc, + Arc::clone(&store), + ); + LogRig { catalog, store, reaper } +} + +#[test] +fn control_the_log_catalog_takes_the_slow_path_on_the_same_shape() { + let r = log_rig("control"); + let a = r.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + + let arena = r.store.arena_for(a.branch_id).unwrap(); + let epoch = r.catalog.next_epoch(); + for i in 0..5u32 { + let p = r.store.alloc_in_arena(arena, PageType::BTreeLeaf, epoch).unwrap(); + let h = r.store.read_page(p).unwrap(); + let mut f = h.write(); + f.data[PAGE_HEADER_SIZE] = (i & 0xff) as u8; + stamp_checksum(&mut f.data); + } + let live_after_write = r.store.live_page_count().unwrap(); + + let c = r.catalog.fork(a.branch_id, LeaseDeadline::from_now(60_000)).unwrap(); + assert_eq!(c.parent_id, Some(a.branch_id)); + assert!(r.catalog.has_live_children(a.branch_id.id).unwrap()); + // THE DIFFERENCE, stated directly: the log catalog's record carries the live set. + assert!( + !r.catalog.get_raw(a.branch_id.id).unwrap().live_children.is_empty(), + "log catalog: live_children is inside the record" + ); + + let freed = r.reaper.reap(a.branch_id).unwrap(); + assert_eq!( + r.store.live_page_count().unwrap(), live_after_write, + "the log catalog must park all 5 pages, not free them (freed {freed})" + ); +} + +/// And the table catalog's record, on the same shape, comes back with an EMPTY live set — +/// which is what `is_childless_leaf()` reads. +#[test] +fn table_catalog_hands_the_reaper_an_empty_live_set() { + let r = rig("emptyset"); + let a = r.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + let _c = r.catalog.fork(a.branch_id, LeaseDeadline::from_now(60_000)).unwrap(); + assert!(r.catalog.has_live_children(a.branch_id.id).unwrap(), "the INDEX knows"); + let rec = r.catalog.get_raw(a.branch_id.id).unwrap(); + assert!(rec.live_children.is_empty(), "the RECORD does not"); + assert!( + rec.is_childless_leaf(), + "so is_childless_leaf() — the reaper's fast/slow switch — says LEAF for an interior node" + ); +} From d2e62d5da78cd22fc8be896b53e3d7858726d09a Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Thu, 17 Sep 2026 20:03:01 -0400 Subject: [PATCH 015/315] probe(S18-mcts-topology): the reclamation rule loses a grandchild on interior prune FAILS on purpose, and on LogBranchCatalog -- so the shipped catalog's empty live_children is not the explanation. This one is the design. GP writes P; PARENT forks (sees P); CHILD forks off PARENT (sees P). Reaping the interior PARENT calls detach_from_parent, which removes the only CHILD-index entry under GP. Measured after the prune: GP has_live_children = false, max_live_child = None, live_child_in_epoch_range(birth..now) = false -- while CHILD is Live and its root_page_id is still GP's root. The rule (record.rs:276-283, mod.rs:123-134) is stated over DIRECT children, which is what makes it O(1) per parent instead of the global reachability question mod.rs:13 forbids. MCTS prunes interior nodes; a flat fanout has none. --- tests/s18_transitive_visibility.rs | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/s18_transitive_visibility.rs diff --git a/tests/s18_transitive_visibility.rs b/tests/s18_transitive_visibility.rs new file mode 100644 index 00000000..cb2173a4 --- /dev/null +++ b/tests/s18_transitive_visibility.rs @@ -0,0 +1,101 @@ +//! S18: the reclamation rule is an ancestry-interval query over **direct children only** +//! (`src/branch/record.rs:276-283`, `src/branch/mod.rs:123-134`). MCTS prunes INTERIOR nodes, +//! and reaping an interior node severs the one link the rule consults. +//! +//! Run against `LogBranchCatalog` on purpose, so the empty-live_children defect in the shipped +//! table catalog cannot be the explanation. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::storage::disk_manager::DiskManager; + +struct Env { catalog: Arc, store: Arc, path: std::path::PathBuf } +impl Drop for Env { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("s18-trans-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let pool = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(pool, Arc::clone(&catalog) as Arc, base).unwrap()); + Env { catalog, store, path } +} + +fn write_one(e: &Env, b: BranchId) -> u32 { + let arena = e.store.arena_for(b).unwrap(); + let ep = e.catalog.next_epoch(); + let p = e.store.alloc_in_arena(arena, PageType::BTreeLeaf, ep).unwrap(); + let h = e.store.read_page(p).unwrap(); + let mut f = h.write(); + f.data[PAGE_HEADER_SIZE] = 0xAB; + stamp_checksum(&mut f.data); + p +} + +/// GRANDPARENT writes P. PARENT forks (sees P). CHILD forks off PARENT (sees P). +/// MCTS prunes PARENT — an interior node — while CHILD is still being expanded. +/// After that reap, does anything still record that P is visible to a live branch? +#[test] +fn reaping_an_interior_node_severs_the_only_link_the_rule_consults() { + let e = env("transitive"); + let reaper = TwoTierReaper::new( + Arc::clone(&e.catalog) as Arc, Arc::clone(&e.store)); + + let gp = e.catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap(); + let p = write_one(&e, gp.branch_id); + let birth = e.store.read_page(p).unwrap().header().unwrap().birth_epoch; + + let parent = e.catalog.fork(gp.branch_id, LeaseDeadline::from_now(60_000)).unwrap(); + let child = e.catalog.fork(parent.branch_id, LeaseDeadline::from_now(60_000)).unwrap(); + assert_eq!(child.parent_id, Some(parent.branch_id)); + + // CHILD can read P: P was born before PARENT forked, and CHILD's root IS PARENT's root, + // which IS GP's root. + assert!(birth < parent.fork_epoch, "P predates PARENT's fork"); + assert_eq!(child.root_page_id, gp.root_page_id, "CHILD's root is GP's root"); + + // BEFORE the prune: the rule protects P, because PARENT forked inside the window. + let now = e.catalog.next_epoch(); + assert!( + e.catalog.live_child_in_epoch_range(gp.branch_id.id, birth, now).unwrap(), + "before the prune, GP has a live child forked after P was born" + ); + + // MCTS prunes the interior node. + reaper.reap(parent.branch_id).unwrap(); + + // CHILD is still Live and still holds a root that reaches P. + let child_now = e.catalog.get(child.branch_id).unwrap(); + assert_eq!(child_now.state, ferrodb::branch::types::BranchState::Live, + "the pruned node's CHILD is still live"); + assert_eq!(child_now.root_page_id, gp.root_page_id, "and still points at GP's root"); + + // AFTER the prune: ask the rule the same question. + let after = e.catalog.next_epoch(); + let still_protected = + e.catalog.live_child_in_epoch_range(gp.branch_id.id, birth, after).unwrap(); + println!("CHILD b{} is Live and reads page {p}; GP b{} live_child_in_epoch_range({birth:?}..{after:?}) = {still_protected}", + child.branch_id.id, gp.branch_id.id); + println!("GP's live children after the prune: max_live_child = {:?}, has_live_children = {}", + e.catalog.max_live_child(gp.branch_id.id).unwrap(), + e.catalog.has_live_children(gp.branch_id.id).unwrap()); + assert!( + still_protected, + "THE RULE LOST THE GRANDCHILD. Reaping the interior PARENT removed the only CHILD-index \ + entry under GP, so GP's page {p} now reads as reclaimable while CHILD b{} is Live and \ + its root still reaches it. The rule is stated over DIRECT children; MCTS prunes interior \ + nodes, which is exactly the operation that breaks the chain.", + child.branch_id.id + ); +} From 658af3f38f1b0687cb0a202592cf49db0f8d83cc Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Thu, 17 Sep 2026 20:05:25 -0400 Subject: [PATCH 016/315] probe: evidence for the branch-depth structural limits (DELIBERATELY FAILING - do not merge) depth_probe.rs FAILS ON PURPOSE: it is the reproduction of the one-level reclamation rule losing a transitive descendant. depth_cost.rs passes and prints the collapse cost curve and its 256-page ceiling. --- tests/depth_cost.rs | 100 ++++++++++++++++++++++++++++ tests/depth_probe.rs | 151 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) create mode 100644 tests/depth_cost.rs create mode 100644 tests/depth_probe.rs diff --git a/tests/depth_cost.rs b/tests/depth_cost.rs new file mode 100644 index 00000000..7a92a3bc --- /dev/null +++ b/tests/depth_cost.rs @@ -0,0 +1,100 @@ +//! PROBE: what does `collapse` -- the only way past MAX_BRANCH_DEPTH -- cost, and where does it +//! stop working? + +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline, ARENA_EXTENT_PAGES}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowPageLinks, CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +fn key(i: u32) -> Vec { + format!("k{:08}", i).into_bytes() +} +fn val(i: u32) -> Vec { + format!("v{:08}", i).into_bytes() +} + +struct Outcome { + data_pages: u32, + fork_ms: f64, + collapse_ms: f64, + err: Option, + reserved_after: u32, +} + +fn one(n: u32, chain: usize) -> Outcome { + let path = + std::env::temp_dir().join(format!("ferro-dc-{}-{}-{}.db", std::process::id(), n, chain)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog) as Arc, base) + .unwrap(), + ); + let tree = CowTree::new(Arc::clone(&store) as Arc); + let reaper = + TwoTierReaper::new(Arc::clone(&catalog) as Arc, Arc::clone(&store)) + .with_links(Arc::new(CowPageLinks)); + + let ep = catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..n { + root = tree.insert(root, BranchId::TRUNK, ep, &key(i), &val(i)).unwrap(); + } + catalog.set_root(BranchId::TRUNK, root).unwrap(); + let data_pages = store.live_page_count().unwrap(); + + let mut cur = BranchId::TRUNK; + for _ in 0..chain { + cur = catalog.fork(cur, LeaseDeadline(u64::MAX)).unwrap().branch_id; + } + catalog.set_root(cur, root).unwrap(); + + let t = Instant::now(); + let _f = catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX)).unwrap(); + let fork_ms = t.elapsed().as_secs_f64() * 1e3; + + let t = Instant::now(); + let res = reaper.collapse(cur); + let collapse_ms = t.elapsed().as_secs_f64() * 1e3; + let err = res.err().map(|e| e.to_string()); + let reserved_after = store.reserved_page_count(); + let _ = std::fs::remove_file(&path); + Outcome { data_pages, fork_ms, collapse_ms, err, reserved_after } +} + +#[test] +fn collapse_cost_and_ceiling() { + println!("ARENA_EXTENT_PAGES = {}", ARENA_EXTENT_PAGES); + println!("{:>8} {:>11} {:>9} {:>12} {:>10} {}", "keys", "data_pages", "fork_ms", "collapse_ms", "ms/page", "result"); + for n in [500u32, 2_000, 8_000, 12_000, 16_000, 20_000, 24_000] { + let o = one(n, 7); + println!( + "{:>8} {:>11} {:>9.4} {:>12.3} {:>10.3} {}", + n, + o.data_pages, + o.fork_ms, + o.collapse_ms, + o.collapse_ms / o.data_pages as f64, + match &o.err { + None => "ok".to_string(), + Some(e) => format!("REFUSED: {e} (reserved pages left behind: {})", o.reserved_after), + } + ); + } + println!("--- same data (8000 keys), different chain length ---"); + for chain in [1usize, 4, 7] { + let o = one(8_000, chain); + println!("chain_len={} data_pages={} collapse_ms={:.3}", chain, o.data_pages, o.collapse_ms); + } +} diff --git a/tests/depth_probe.rs b/tests/depth_probe.rs new file mode 100644 index 00000000..05e96263 --- /dev/null +++ b/tests/depth_probe.rs @@ -0,0 +1,151 @@ +//! PROBE (not a deliverable): does reclamation stay correct when an ancestor is reaped while a +//! transitive descendant still reads its pages? +//! +//! The reclamation rule consults ONE level of ancestry (the owner's own live-children epochs). +//! Visibility is transitive: C forked from B forked from A reads A's pages through A's root. +//! Reaping B removes B's epoch from A's live set, which makes A a "childless leaf" and sends +//! A's own reap down the FAST PATH -- free_arena wholesale, no sharing analysis at all. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowPageLinks, CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +struct Env { + catalog: Arc, + store: Arc, + path: std::path::PathBuf, +} + +impl Drop for Env { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("ferro-depthprobe-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(&path) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new( + Arc::clone(&pool), + Arc::clone(&catalog) as std::sync::Arc, + base, + ) + .unwrap(), + ); + Env { catalog, store, path } +} + +fn key(i: u32) -> Vec { + format!("k{:06}", i).into_bytes() +} +fn val(i: u32) -> Vec { + format!("v{:06}", i).into_bytes() +} + +#[test] +fn reaping_a_middle_branch_orphans_its_descendant_onto_reclaimable_ancestor_pages() { + let e = env("orphan"); + let tree = CowTree::new(Arc::clone(&e.store) as Arc); + let reaper = TwoTierReaper::new( + Arc::clone(&e.catalog) as Arc, + Arc::clone(&e.store), + ) + .with_links(Arc::new(CowPageLinks)); + + // trunk holds a small tree. + let ep = e.catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..50 { + root = tree.insert(root, BranchId::TRUNK, ep, &key(i), &val(i)).unwrap(); + } + e.catalog.set_root(BranchId::TRUNK, root).unwrap(); + + // A forks off trunk and writes 400 keys of its OWN, into A's OWN arena. + let a = e.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX)).unwrap(); + let ep_a = e.catalog.next_epoch(); + let mut a_root = a.root_page_id; + for i in 100..500 { + a_root = tree.insert(a_root, a.branch_id, ep_a, &key(i), &val(i)).unwrap(); + } + e.catalog.set_root(a.branch_id, a_root).unwrap(); + let after_a = e.store.live_page_count().unwrap(); + + // B forks off A; C forks off B. Neither writes: both roots ARE A's root. + let b = e.catalog.fork(a.branch_id, LeaseDeadline(u64::MAX)).unwrap(); + let c = e.catalog.fork(b.branch_id, LeaseDeadline(u64::MAX)).unwrap(); + assert_eq!(c.root_page_id, a_root, "fixture: C must be rooted on A's pages"); + + // C can read A's data right now. + for i in (100..500).step_by(37) { + assert_eq!(tree.get(c.root_page_id, &key(i)).unwrap(), Some(val(i)), "fixture read"); + } + + // The middle agent finishes: MERGE/ABANDON -> seal() -> reaper.reap(branch), with no check + // for live children. C is still live and still rooted on A's pages. + reaper.reap(b.branch_id).unwrap(); + + let a_after_b = e.catalog.get_raw(a.branch_id.id).unwrap(); + println!( + "PROBE after reap(B): A.live_children = {:?} (empty => A is a 'childless leaf')", + a_after_b.live_children + ); + + // Now the top agent finishes too. + let freed = reaper.reap(a.branch_id).unwrap(); + let after_reaps = e.store.live_page_count().unwrap(); + println!( + "PROBE after reap(A): freed={} live_pages {} -> {} (pending={})", + freed, + after_a, + after_reaps, + e.store.pending_len() + ); + + // Force the freed space to be handed out again, exactly as a new agent would. + let d = e.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX)).unwrap(); + let ep_d = e.catalog.next_epoch(); + let mut d_root = d.root_page_id; + for i in 5000..5400 { + d_root = tree.insert(d_root, d.branch_id, ep_d, &key(i), &val(i)).unwrap(); + } + e.catalog.set_root(d.branch_id, d_root).unwrap(); + + // Can C still read the data it was reading a moment ago? + let mut wrong = 0usize; + let mut errs = 0usize; + for i in 100..500 { + match tree.get(c.root_page_id, &key(i)) { + Ok(Some(v)) if v == val(i) => {} + Ok(_) => wrong += 1, + Err(_) => errs += 1, + } + } + println!("PROBE C reads after the chain was reaped: wrong={} errs={} of 400", wrong, errs); + assert_eq!( + wrong + errs, + 0, + "the orphaned descendant lost {} reads ({} wrong answers, {} hard errors) after its \ + ancestors were reaped -- the one-level reclamation rule freed pages it can still see", + wrong + errs, + wrong, + errs + ); +} From de156ca882f4194974ac3a9a798a718b60473f52 Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Thu, 17 Sep 2026 20:06:07 -0400 Subject: [PATCH 017/315] S18: measure what the page as the unit of sharing costs at scale Four probes and their raw output. The depth wall is the finding: collapse refuses above one arena extent, so on any database over 1 MiB a branch at depth 8 can never fork again. --- bench/s18_storage_unit_of_sharing.txt | 71 ++++++++++ examples/storage_collapse_cost.rs | 141 +++++++++++++++++++ examples/storage_pool_lock.rs | 91 +++++++++++++ examples/storage_residency_cost.rs | 167 +++++++++++++++++++++++ examples/storage_unit_cost.rs | 189 ++++++++++++++++++++++++++ 5 files changed, 659 insertions(+) create mode 100644 bench/s18_storage_unit_of_sharing.txt create mode 100644 examples/storage_collapse_cost.rs create mode 100644 examples/storage_pool_lock.rs create mode 100644 examples/storage_residency_cost.rs create mode 100644 examples/storage_unit_cost.rs diff --git a/bench/s18_storage_unit_of_sharing.txt b/bench/s18_storage_unit_of_sharing.txt new file mode 100644 index 00000000..415f4d27 --- /dev/null +++ b/bench/s18_storage_unit_of_sharing.txt @@ -0,0 +1,71 @@ +S18 — what the PAGE as the unit of sharing costs. Recorded 2026-09-17, darwin/APFS, release build. +Instruments: examples/storage_unit_cost.rs, storage_residency_cost.rs, storage_pool_lock.rs, +storage_collapse_cost.rs (this branch). Predictions pre-registered before the residency run. + +--- A. write amplification: page granularity vs a 20-byte delta --------------------------------- +trunk 2000 rows (30 pages). A fresh fork writing ONE row: + pages_allocated = 2 (8192 B of new physical pages) logical delta = 20 B => 410x + reserved_delta = 256 pages (1 MiB), the whole extent +Second single-row write on the same branch: pages_allocated = 1 (path now private, in-place). +trunk 200000 rows (2977 pages): 3.00 private pages/branch. The floor is ceil(log_f N) * 4096 B. + +--- B. extent reservation per writing branch ---------------------------------------------------- + B=2000 B=4000 + allocated pages 4000 (2.00/br) 8000 (2.00/br) + RESERVED pages 512000 (256.00) 1024000 (256.00) + extent utilisation 0.781% 0.781% + apparent file len 2,098,212,864 B 4,195,364,864 B (1,048,841 B/branch) + on-disk blocks 1,702,060,032 B 4,211,089,408 B (1,052,772 B/branch) +`du -h` on the retained B=4000 file: 3.9G. stat blocks=8194904 (x512 = 4.196 GB) == apparent. +CONTROL: a plain python pwrite of one 4 KB page per 1 MiB across 4 GiB also reports du=3.9G, so +APFS on this machine does not hole-punch. The reservation is real disk here; on a hole-punching +filesystem it would be address space only, and the u32 page id caps that at 2^32/256 branches. + +--- C. durable free-space map: full image rewritten + fsynced per extent claim ------------------ + state_bytes() 137 B -> 96,137 B (B=2000) 137 B -> 192,137 B (B=4000) + marginal 48.0 B/branch 48.0 B/branch (linear) + TOTAL rewrite volume 96,273,839 B 384,547,839 B + ratio for 2x B 3.994 => QUADRATIC to 0.15% +Extrapolated to 10^6 writing branches: 48 MB image, ~24 TB rewritten, each via the four-step +fsync idiom in storage/atomic_file.rs. + +--- D. read throughput vs CONCURRENTLY ACTIVE branches (1 thread, 1024-frame pool) ------------- +trunk 200000 rows = 2977 pages = 2.91x the pool. 2048 branches, one row each, 3.00 pages/branch. + active 1 2 4 8 16 32 64 128 256 512 1024 2048 + reads/s 39336 28294 29547 39434 39352 37069 38635 38627 38264 38267 37501 36409 +NO KNEE. -7.4% over a 2048x range. PREDICTION P2/P3 (a knee between 128 and 512, a 10-50x drop) +is FALSIFIED and recorded as wrong. Reason read from the code: a child's root IS the parent's +root, so the read path never walks the parent chain. + +--- E. the one global buffer-pool mutex --------------------------------------------------------- +ARM B, real tree.get, 4 active branches: 1t 38550/s, 2t 71277 (1.85x), 4t 90093 (2.34x), +8t 101087 (2.62x). Amdahl serial fraction 0.293 => asymptotic ceiling 3.41x. +ARM C, the narrowest possible workload — fetch_page/unpin_page on 8 RESIDENT pages, no disk, +no tree code, 4,000,000 ops: + threads 1 2 4 8 16 32 + ops/sec 21487941 15859037 5913111 3685493 4497519 4260881 + speedup 1.00x 0.74x 0.28x 0.17x 0.21x 0.20x +NEGATIVE SCALING: 5.8x SLOWER at 8 threads than at 1. +ARM D control, same loop with an atomic increment instead of the pool: 1.00x, 1.08x, 3.02x, +3.60x, 2.13x, 2.48x. The machine scales; the pool does not. PREDICTION P4 (<=1.3x at 8 threads +on ARM B) was also FALSIFIED — the real number is 2.62x — but ARM C shows the lock is worse +than predicted, not better. + +--- F. the depth wall: collapse refuses above ONE ARENA EXTENT ---------------------------------- +fork refuses above MAX_BRANCH_DEPTH=8. collapse is the only escape. Bracketed in both directions: + trunk rows reachable pages collapse + 200 3 ok, 15.860 ms, copied 3 + 5000 71 ok, 351.013 ms, copied 71 + 12000 172 ok, 687.160 ms, copied 172 + 16000 228 ok, 489.473 ms, copied 228 <-- last success + 20000 285 REFUSED, 1.101 s: "arena a11 is exhausted (256 pages); + ask arena_for for a fresh extent" + 200000 2850 REFUSED, 735.732 ms, same error +Every success copied EXACTLY the whole trunk page count: collapse is a full deep copy of the +branch's entire reachable page graph (reaper.rs deep_copy), not of its own novel pages (1-3). +MAX_COLLAPSE_PAGES = 1<<16 = 65536 (reaper.rs:63) is UNREACHABLE: collapse takes one arena via +alloc_arena (reaper.rs:353) and deep_copy calls alloc_in_arena directly, which refuses at +ARENA_EXTENT_PAGES=256 instead of rolling over through arena_for. The real budget is 256x smaller +than the named one. +Nothing in src/cli, src/agent_sql, src/pgwire or examples/pgserver.rs calls collapse, and nothing +anywhere handles BranchError::DepthExceeded. On any database over 1 MiB, depth 8 is final. diff --git a/examples/storage_collapse_cost.rs b/examples/storage_collapse_cost.rs new file mode 100644 index 00000000..157f970e --- /dev/null +++ b/examples/storage_collapse_cost.rs @@ -0,0 +1,141 @@ +//! S18 probe 4: what does exceeding MAX_BRANCH_DEPTH cost, and where does the escape refuse? +//! +//! `fork` refuses past MAX_BRANCH_DEPTH (= 8). The only escape is `Reaper::collapse`, which +//! `deep_copy`s the branch's ENTIRE reachable page graph into a fresh arena, with a budget of +//! MAX_COLLAPSE_PAGES (= 1<<16 = 65536 pages = 256 MiB). +//! +//! Arm 1 — build a chain to depth 8, show the 9th fork refuses, collapse, and measure what the +//! collapse copied and how long it took. +//! Arm 2 — pass a trunk bigger than the collapse budget and show the refusal is real, not inferred. +//! +//! Usage: cargo run --release --example storage_collapse_cost -- + +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, PageLinks, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowPageLinks, CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +fn key(i: u32) -> Vec { + format!("k{:08}", i).into_bytes() +} + +fn main() { + let a: Vec = std::env::args().collect(); + let trunk_rows: u32 = a.get(1).map(|s| s.parse().unwrap()).unwrap_or(200_000); + let value_bytes: usize = a.get(2).map(|s| s.parse().unwrap()).unwrap_or(8); + + let path = std::env::temp_dir().join(format!("s18-col-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog) as Arc, base) + .unwrap(), + ); + let tree = CowTree::new(store.clone() as Arc); + let reaper = TwoTierReaper::new( + Arc::clone(&catalog) as Arc, + Arc::clone(&store), + ) + .with_links(Arc::new(CowPageLinks) as Arc); + + let v = vec![b'x'; value_bytes]; + let e0 = catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, e0).unwrap(); + let tbuild = std::time::Instant::now(); + for i in 0..trunk_rows { + let e = catalog.next_epoch(); + root = tree.insert(root, BranchId::TRUNK, e, &key(i), &v).unwrap(); + } + catalog.set_root(BranchId::TRUNK, root).unwrap(); + let trunk_pages = tree.walk_pages(root).unwrap().len(); + println!( + "trunk: rows={} value_bytes={} pages={} ({:.1} MB) built in {:.1?}", + trunk_rows, + value_bytes, + trunk_pages, + trunk_pages as f64 * 4096.0 / 1e6, + tbuild.elapsed() + ); + println!("MAX_BRANCH_DEPTH=8 MAX_COLLAPSE_PAGES=65536 (256.0 MB)\n"); + + // ---- chain to depth 8 ------------------------------------------------------------------ + let mut cur = BranchId::TRUNK; + let mut depth_reached = 0u8; + for d in 1..=9u8 { + match catalog.fork(cur, LeaseDeadline::from_now(3_600_000)) { + Ok(rec) => { + cur = rec.branch_id; + depth_reached = rec.depth; + // one real write, so the branch is not vacuous + let e = catalog.next_epoch(); + let r = tree.insert(rec.root_page_id, rec.branch_id, e, &key(d as u32), &v).unwrap(); + catalog.set_root(rec.branch_id, r).unwrap(); + } + Err(e) => { + println!("fork #{d} REFUSED at depth {depth_reached}: {e}"); + break; + } + } + } + println!("deepest branch: {cur} at depth {depth_reached}"); + + // ---- collapse it ----------------------------------------------------------------------- + let live_before = store.live_page_count().unwrap(); + let reserved_before = store.reserved_page_count(); + let t0 = std::time::Instant::now(); + let res = reaper.collapse(cur); + let el = t0.elapsed(); + match res { + Ok(rec) => { + let live_after = store.live_page_count().unwrap(); + let reserved_after = store.reserved_page_count(); + println!( + "\nCOLLAPSE ok in {:.3?}: pages_copied={} reserved_delta={} new_depth={} \ + new_root={}", + el, + live_after - live_before, + reserved_after - reserved_before, + rec.depth, + rec.root_page_id + ); + println!( + " the branch's OWN novel pages before collapse were ~1-3; it copied {} \ + ({:.1} MB), i.e. the whole reachable dataset", + live_after - live_before, + (live_after - live_before) as f64 * 4096.0 / 1e6 + ); + println!( + " per-collapse cost = {:.3} ms; a chain of depth D needs floor(D/8) collapses, \ + so depth {} costs {:.1} s of copying", + el.as_secs_f64() * 1e3, + 1000, + el.as_secs_f64() * (1000.0 / 8.0) + ); + // and fork now works again from the collapsed branch + match catalog.fork(rec.branch_id, LeaseDeadline::from_now(3_600_000)) { + Ok(c) => println!(" fork after collapse: ok, child depth {}", c.depth), + Err(e) => println!(" fork after collapse: REFUSED {e}"), + } + } + Err(e) => { + println!("\nCOLLAPSE REFUSED after {:.3?}: {e}", el); + println!( + " the branch is now permanently stuck at depth {depth_reached}: fork refuses \ + above MAX_BRANCH_DEPTH and collapse is the only escape" + ); + } + } + + let _ = std::fs::remove_file(&path); +} diff --git a/examples/storage_pool_lock.rs b/examples/storage_pool_lock.rs new file mode 100644 index 00000000..6c9ef55c --- /dev/null +++ b/examples/storage_pool_lock.rs @@ -0,0 +1,91 @@ +//! S18 probe 3: isolate the buffer pool's global lock. +//! +//! ARM C is the narrowest possible workload: T threads each call `fetch_page`/`unpin_page` on ONE +//! page id that is guaranteed resident. No tree code, no descent, no disk, no allocation. If this +//! caps, the cap is `BufferPoolManager::fetch_page`'s single `arc_cache` Mutex and nothing else. +//! +//! ARM D is the control: the same loop with the pool replaced by an atomic increment, so a cap +//! that is really the machine's shows up here too. +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::storage::disk_manager::DiskManager; + +fn main() { + let ops: u64 = std::env::args().nth(1).map(|s| s.parse().unwrap()).unwrap_or(2_000_000); + let path = std::env::temp_dir().join(format!("s18-lock-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = + std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + // Make 8 real pages and keep them resident. + let pages: Vec = (0..8).map(|_| pool.new_page().unwrap()).collect(); + for &p in &pages { + let i = pool.fetch_page(p).unwrap(); + std::hint::black_box(i); + pool.unpin_page(p, false); + } + + println!("ARM C fetch_page/unpin_page on 8 resident pages, {} ops total", ops); + println!(" threads ops/sec speedup"); + let mut b1 = 0.0f64; + for &t in &[1usize, 2, 4, 8, 16, 32] { + let per = ops / t as u64; + let t0 = std::time::Instant::now(); + let hs: Vec<_> = (0..t) + .map(|ti| { + let pool = Arc::clone(&pool); + let pages = pages.clone(); + std::thread::spawn(move || { + for k in 0..per { + let p = pages[((k as usize) + ti) % pages.len()]; + let i = pool.fetch_page(p).unwrap(); + std::hint::black_box(i); + pool.unpin_page(p, false); + } + }) + }) + .collect(); + for h in hs { + h.join().unwrap(); + } + let r = (per * t as u64) as f64 / t0.elapsed().as_secs_f64(); + if t == 1 { + b1 = r; + } + println!(" {:>7} {:>10.0} {:>7.2}x", t, r, r / b1); + } + + println!("\nARM D (control) same loop, atomic increment instead of the pool"); + println!(" threads ops/sec speedup"); + let ctr = Arc::new(AtomicU64::new(0)); + let mut d1 = 0.0f64; + for &t in &[1usize, 2, 4, 8, 16, 32] { + let per = ops / t as u64; + let t0 = std::time::Instant::now(); + let hs: Vec<_> = (0..t) + .map(|_| { + let ctr = Arc::clone(&ctr); + std::thread::spawn(move || { + let mut local = 0u64; + for k in 0..per { + local = local.wrapping_add(k ^ 0x9E37); + std::hint::black_box(local); + } + ctr.fetch_add(local, Ordering::Relaxed); + }) + }) + .collect(); + for h in hs { + h.join().unwrap(); + } + let r = (per * t as u64) as f64 / t0.elapsed().as_secs_f64(); + if t == 1 { + d1 = r; + } + println!(" {:>7} {:>10.0} {:>7.2}x", t, r, r / d1); + } + let _ = std::fs::remove_file(&path); +} diff --git a/examples/storage_residency_cost.rs b/examples/storage_residency_cost.rs new file mode 100644 index 00000000..3caab579 --- /dev/null +++ b/examples/storage_residency_cost.rs @@ -0,0 +1,167 @@ +//! S18 storage probe 2: what does the 1024-frame page-granular buffer pool cost per branch? +//! +//! Arm A — READ throughput as the number of CONCURRENTLY ACTIVE branches rises, single-threaded. +//! Every branch that has written owns a private COW copy of its root-to-leaf path. Those copies +//! have distinct page ids, so the pool caches them separately: pool capacity is spent per +//! branch, not per datum. Predicted cliff at roughly MAX_BUFFER_POOL_PAGES / (private pages +//! per branch + shared working set). +//! +//! Arm B — READ throughput vs thread count at a FIXED small active set that fits the pool. +//! `BufferPoolManager::fetch_page` holds one global `arc_cache` Mutex across the whole miss +//! path, including `disk_manager.read`. Prediction: throughput does not improve with threads. +//! +//! Usage: cargo run --release --example storage_residency_cost -- + +use std::fs::OpenOptions; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline, PageId}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +fn key(i: u32) -> Vec { + format!("k{:08}", i).into_bytes() +} +fn val(i: u32) -> Vec { + format!("v{:08}", i).into_bytes() +} + +/// xorshift, so the read order is reproducible and costs nothing. +fn rnd(s: &mut u64) -> u64 { + *s ^= *s << 13; + *s ^= *s >> 7; + *s ^= *s << 17; + *s +} + +fn main() { + let a: Vec = std::env::args().collect(); + let trunk_rows: u32 = a.get(1).map(|s| s.parse().unwrap()).unwrap_or(200_000); + let branches: u32 = a.get(2).map(|s| s.parse().unwrap()).unwrap_or(2048); + let reads: u64 = a.get(3).map(|s| s.parse().unwrap()).unwrap_or(200_000); + + let path = std::env::temp_dir().join(format!("s18-res-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog) as Arc, base) + .unwrap(), + ); + let tree = Arc::new(CowTree::new(store.clone() as Arc)); + + let e0 = catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, e0).unwrap(); + for i in 0..trunk_rows { + let e = catalog.next_epoch(); + root = tree.insert(root, BranchId::TRUNK, e, &key(i), &val(i)).unwrap(); + } + catalog.set_root(BranchId::TRUNK, root).unwrap(); + let trunk_pages = tree.walk_pages(root).unwrap().len(); + println!( + "pool frames = 1024 (4 MB). trunk rows={} pages={} ({:.1} MB) => trunk alone is {:.2}x \ + the pool", + trunk_rows, + trunk_pages, + trunk_pages as f64 * 4096.0 / 1e6, + trunk_pages as f64 / 1024.0 + ); + + // Fork `branches` branches, each writing exactly one row, and record each one's root. + let mut roots: Vec = Vec::with_capacity(branches as usize); + let live0 = store.live_page_count().unwrap(); + for j in 0..branches { + let rec = catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(3_600_000)).unwrap(); + let e = catalog.next_epoch(); + let r = tree + .insert(rec.root_page_id, rec.branch_id, e, &key(j % trunk_rows), &val(j)) + .unwrap(); + catalog.set_root(rec.branch_id, r).unwrap(); + roots.push(r); + } + let live1 = store.live_page_count().unwrap(); + println!( + "forked {} branches, one row each: {} private pages total = {:.2} pages/branch\n", + branches, + live1 - live0, + (live1 - live0) as f64 / branches as f64 + ); + + // ---- ARM A: reads/sec vs number of concurrently active branches ------------------------ + println!("ARM A single thread, {} reads per point, keys drawn uniformly", reads); + println!(" active_branches reads/sec us/read"); + for &active in &[1u32, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048] { + if active > branches { + break; + } + let mut s: u64 = 0x2545F4914F6CDD1D; + // warm: touch each active branch once + for b in 0..active { + let _ = tree.get(roots[b as usize], &key(b % trunk_rows)).unwrap(); + } + let t0 = std::time::Instant::now(); + let mut hits = 0u64; + for _ in 0..reads { + let b = (rnd(&mut s) % active as u64) as usize; + let k = (rnd(&mut s) % trunk_rows as u64) as u32; + if tree.get(roots[b], &key(k)).unwrap().is_some() { + hits += 1; + } + } + let el = t0.elapsed(); + std::hint::black_box(hits); + println!( + " {:>15} {:>9.0} {:>8.3}", + active, + reads as f64 / el.as_secs_f64(), + el.as_secs_f64() * 1e6 / reads as f64 + ); + } + + // ---- ARM B: reads/sec vs threads, active set small enough to be resident --------------- + println!("\nARM B {} reads total, split across threads, active_branches = 4", reads); + println!(" threads reads/sec speedup"); + let mut base_rate = 0.0f64; + for &t in &[1usize, 2, 4, 8] { + let per = reads / t as u64; + let counter = Arc::new(AtomicU64::new(0)); + let t0 = std::time::Instant::now(); + let mut hs = Vec::new(); + for ti in 0..t { + let tree = Arc::clone(&tree); + let roots = roots[..4].to_vec(); + let counter = Arc::clone(&counter); + hs.push(std::thread::spawn(move || { + let mut s: u64 = 0x9E3779B97F4A7C15 ^ (ti as u64).wrapping_mul(0x1234567); + let mut h = 0u64; + for _ in 0..per { + let b = (rnd(&mut s) % 4) as usize; + let k = (rnd(&mut s) % trunk_rows as u64) as u32; + if tree.get(roots[b], &key(k)).unwrap().is_some() { + h += 1; + } + } + counter.fetch_add(h, Ordering::Relaxed); + })); + } + for h in hs { + h.join().unwrap(); + } + let el = t0.elapsed(); + let rate = (per * t as u64) as f64 / el.as_secs_f64(); + if t == 1 { + base_rate = rate; + } + println!(" {:>7} {:>9.0} {:>7.2}x", t, rate, rate / base_rate); + } + + let _ = std::fs::remove_file(&path); +} diff --git a/examples/storage_unit_cost.rs b/examples/storage_unit_cost.rs new file mode 100644 index 00000000..f693723c --- /dev/null +++ b/examples/storage_unit_cost.rs @@ -0,0 +1,189 @@ +//! S18 storage probe: what does the PAGE as the unit of sharing cost per branch? +//! +//! Arms, all on real 4KB pages in a real file: +//! 1. how many pages a SINGLE-ROW write copies (the COW path), and how many payload bytes of +//! each copied page actually differ; +//! 2. reserved vs allocated vs apparent-file-length vs on-disk blocks, for B branches that +//! each write exactly one row; +//! 3. the size of the durable free-space map image (`state_bytes`), which `alloc_arena` +//! rewrites in full and fsyncs on every extent claim. +//! +//! Usage: cargo run --release --example storage_unit_cost -- + +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline, ARENA_EXTENT_PAGES}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +fn key(i: u32) -> Vec { + format!("k{:08}", i).into_bytes() +} +fn val(i: u32) -> Vec { + format!("v{:08}", i).into_bytes() +} + +fn main() { + let args: Vec = std::env::args().collect(); + let trunk_rows: u32 = args.get(1).map(|s| s.parse().unwrap()).unwrap_or(2000); + let branches: u32 = args.get(2).map(|s| s.parse().unwrap()).unwrap_or(2000); + + let path = std::env::temp_dir().join(format!("s18-unit-{}.db", std::process::id())); + let _ = std::fs::remove_file(&path); + let file = OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new( + Arc::clone(&pool), + Arc::clone(&catalog) as Arc, + base, + ) + .unwrap(), + ); + let tree = CowTree::new(store.clone() as Arc); + + // ---- trunk --------------------------------------------------------------------------- + let e0 = catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, e0).unwrap(); + for i in 0..trunk_rows { + let e = catalog.next_epoch(); + root = tree.insert(root, BranchId::TRUNK, e, &key(i), &val(i)).unwrap(); + } + catalog.set_root(BranchId::TRUNK, root).unwrap(); + let trunk_pages = tree.walk_pages(root).unwrap().len(); + let trunk_live = store.live_page_count().unwrap(); + println!("PAGE_SIZE=4096 ARENA_EXTENT_PAGES={}", ARENA_EXTENT_PAGES); + println!("trunk: rows={} pages_reachable={} live_pages={}", trunk_rows, trunk_pages, trunk_live); + + // ---- arm 1: one fork, one single-row write -------------------------------------------- + let probe = catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(600_000)).unwrap(); + let live_before = store.live_page_count().unwrap(); + let reserved_before = store.reserved_page_count(); + let e = catalog.next_epoch(); + let new_root = tree + .insert(probe.root_page_id, probe.branch_id, e, &key(trunk_rows / 2), b"OVERWRITTEN") + .unwrap(); + let live_after = store.live_page_count().unwrap(); + let reserved_after = store.reserved_page_count(); + println!( + "ARM1 single-row write on a fresh fork: pages_allocated={} reserved_delta_pages={} \ + new_root={} old_root={}", + live_after - live_before, + reserved_after - reserved_before, + new_root, + probe.root_page_id + ); + println!( + "ARM1 bytes_of_new_physical_pages={} bytes_of_logical_delta={}", + (live_after - live_before) as usize * 4096, + key(trunk_rows / 2).len() + b"OVERWRITTEN".len() + ); + + // a second single-row write on the SAME branch: how much is amortised once the path is private? + let live_b2 = store.live_page_count().unwrap(); + let e = catalog.next_epoch(); + let r2 = tree + .insert(new_root, probe.branch_id, e, &key(trunk_rows / 3), b"SECOND") + .unwrap(); + println!( + "ARM1b second single-row write, same branch: pages_allocated={} (root {} -> {})", + store.live_page_count().unwrap() - live_b2, + new_root, + r2 + ); + + // ---- arm 2: B branches, one row each --------------------------------------------------- + let live_b = store.live_page_count().unwrap(); + let reserved_b = store.reserved_page_count(); + let image_b = store.state_bytes().len(); + let mut image_total_bytes: u128 = 0; + let mut image_samples: Vec<(u32, usize)> = Vec::new(); + + let t0 = std::time::Instant::now(); + for j in 0..branches { + let rec = catalog.fork(BranchId::TRUNK, LeaseDeadline::from_now(600_000)).unwrap(); + let e = catalog.next_epoch(); + let r = tree + .insert(rec.root_page_id, rec.branch_id, e, &key(j % trunk_rows.max(1)), &val(j)) + .unwrap(); + catalog.set_root(rec.branch_id, r).unwrap(); + // What alloc_arena's persist_if_configured would have written, at this point in the run. + // Sampled, then trapezoid-integrated, because calling it every iteration is itself O(N). + if j % 64 == 0 || j == branches - 1 { + let n = store.state_bytes().len(); + image_samples.push((j, n)); + } + } + let elapsed = t0.elapsed(); + + let live_e = store.live_page_count().unwrap(); + let reserved_e = store.reserved_page_count(); + let image_e = store.state_bytes().len(); + store.flush().unwrap(); + + let apparent = std::fs::metadata(&path).unwrap().len(); + let blocks = { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(&path).unwrap().blocks() * 512 + }; + + // integrate the sampled image size over the run: total bytes alloc_arena would have + // rewritten+fsynced, one full image per extent claim (one claim per branch here). + for w in image_samples.windows(2) { + let (j0, s0) = w[0]; + let (j1, s1) = w[1]; + image_total_bytes += ((s0 + s1) as u128 / 2) * (j1 - j0) as u128; + } + + println!( + "\nARM2 branches={} in {:.2?} ({:.0} branch-writes/sec)", + branches, + elapsed, + branches as f64 / elapsed.as_secs_f64() + ); + println!( + " allocated pages {:>12} ({:.2} per branch, {:.0} B/branch)", + live_e - live_b, + (live_e - live_b) as f64 / branches as f64, + (live_e - live_b) as f64 * 4096.0 / branches as f64 + ); + println!( + " RESERVED pages {:>12} ({:.2} per branch, {:.0} B/branch)", + reserved_e - reserved_b, + (reserved_e - reserved_b) as f64 / branches as f64, + (reserved_e - reserved_b) as f64 * 4096.0 / branches as f64 + ); + println!( + " extent utilisation {:>11.3}% (allocated / reserved)", + 100.0 * (live_e - live_b) as f64 / (reserved_e - reserved_b) as f64 + ); + println!(" apparent file len {:>12} B ({:.0} B/branch)", apparent, apparent as f64 / branches as f64); + println!(" on-disk blocks {:>12} B ({:.0} B/branch)", blocks, blocks as f64 / branches as f64); + println!( + " free-space map image {:>11} B -> {} B ({:.1} B/branch marginal)", + image_b, + image_e, + (image_e - image_b) as f64 / branches as f64 + ); + println!( + " TOTAL bytes alloc_arena would rewrite+fsync over the run: {} B ({:.1} MB) for {} \ + extent claims", + image_total_bytes, + image_total_bytes as f64 / 1e6, + branches + ); + + if std::env::var("KEEP").is_ok() { + println!(" file kept at {}", path.display()); + } else { + let _ = std::fs::remove_file(&path); + } +} From 8601876bf4cc5b74fa43b97519573c827024d653 Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Thu, 17 Sep 2026 20:21:01 -0400 Subject: [PATCH 018/315] probe(reads): fire the depth-escape detector and measure read scaling past the pool Four probes against the read path, raw outputs in bench/. 1. probe_depth_wall -- FIRES. `collapse` is the only way past MAX_BRANCH_DEPTH=8 (types.rs:262) and it allocates ONE arena (reaper.rs:352) threaded through every deep_copy (188,192). alloc_in_arena never rolls over (arena.rs:1014). With ARENA_EXTENT_PAGES=256, a 608-page tree fails: "arena a4 is exhausted (256 pages); ask arena_for for a fresh extent" 21-page control collapses fine. Nothing outside tests ever calls collapse. 2. probe_read_scaling2 -- reads saturate at x6.0 of 18 cores when the working set fits the 1024-frame pool, and at x2.3 (declining past 8 threads) when it does not. fetch_page holds one global Mutex across flush_page and disk_manager.read (buffer_pool.rs:72,118,122,144). 3. probe_read_concurrency -- cross-check with 8 DISTINCT branch roots: same ceiling, so it is the pool and not branch identity. Its original assertion predicted a hard x2 wall; that was WRONG for a resident working set and is recorded as wrong. 4. probe_catalog_read_locality -- REFUTES my hypothesis. I predicted random branch-id root resolution would lose the edge-of-span locality the 10^6 non-knee rests on (curve_to_1e6.txt:12-15). At 6.4x pool oversubscription random/sequential = x0.87. No penalty. Recorded because a refuted prediction is the result. --- bench/probe_catalog_locality.txt | 18 +++ bench/probe_depth_wall.txt | 42 +++++++ bench/probe_read_concurrency_v1.txt | 27 +++++ bench/probe_read_scaling.txt | 31 +++++ tests/probe_catalog_read_locality.rs | 82 +++++++++++++ tests/probe_depth_wall.rs | 117 ++++++++++++++++++ tests/probe_read_concurrency.rs | 171 +++++++++++++++++++++++++++ tests/probe_read_scaling2.rs | 99 ++++++++++++++++ 8 files changed, 587 insertions(+) create mode 100644 bench/probe_catalog_locality.txt create mode 100644 bench/probe_depth_wall.txt create mode 100644 bench/probe_read_concurrency_v1.txt create mode 100644 bench/probe_read_scaling.txt create mode 100644 tests/probe_catalog_read_locality.rs create mode 100644 tests/probe_depth_wall.rs create mode 100644 tests/probe_read_concurrency.rs create mode 100644 tests/probe_read_scaling2.rs diff --git a/bench/probe_catalog_locality.txt b/bench/probe_catalog_locality.txt new file mode 100644 index 00000000..d902c8eb --- /dev/null +++ b/bench/probe_catalog_locality.txt @@ -0,0 +1,18 @@ + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-reads-limit-probe) + Finished `release` profile [optimized] target(s) in 0.50s + Running tests/probe_catalog_read_locality.rs (target/release/deps/probe_catalog_read_locality-7178b45ba748eb74) + +running 1 test +test branch_root_resolution_loses_its_locality_when_the_catalog_outgrows_the_pool ... pool = 1024 frames x 4096 B = 4.19 MB (buffer_pool.rs:45, compile-time const) + N | catalog MB | pool x | SEQ us | RAND us | RAND/SEQ + 10000 | 2.7 | 0.6 | 22.885 | 22.370 | x0.98 +100000 | 26.6 | 6.4 | 32.000 | 27.786 | x0.87 + +control (resident) RAND/SEQ = x0.98 +test (6x over) RAND/SEQ = x0.87 +ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1088.25s + + +[exited with code 0] diff --git a/bench/probe_depth_wall.txt b/bench/probe_depth_wall.txt new file mode 100644 index 00000000..06271433 --- /dev/null +++ b/bench/probe_depth_wall.txt @@ -0,0 +1,42 @@ + Compiling cfg-if v1.0.4 + Compiling getrandom v0.3.4 + Compiling rustix v1.1.4 + Compiling getrandom v0.4.2 + Compiling zerocopy v0.8.56 + Compiling bitflags v2.11.1 + Compiling autocfg v1.5.1 + Compiling once_cell v1.21.4 + Compiling fastrand v2.4.1 + Compiling bit-vec v0.8.0 + Compiling quick-error v1.2.3 + Compiling fnv v1.0.7 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-reads-limit-probe) + Compiling unarray v0.1.4 + Compiling regex-syntax v0.8.11 + Compiling bit-set v0.8.0 + Compiling num-traits v0.2.19 + Compiling errno v0.3.14 + Compiling wait-timeout v0.2.1 + Compiling rand_core v0.9.5 + Compiling rand_xorshift v0.4.0 + Compiling rand v0.9.5 + Compiling ppv-lite86 v0.2.21 + Compiling tempfile v3.27.0 + Compiling rand_chacha v0.9.0 + Compiling rusty-fork v0.3.1 + Compiling proptest v1.11.0 + Finished `test` profile [unoptimized + debuginfo] target(s) in 41.25s + Running tests/probe_depth_wall.rs (target/debug/deps/probe_depth_wall-61ce2214703d4bdd) + +running 3 tests +test collapse_fails_once_the_tree_outgrows_one_arena_extent ... BIG: reachable pages = 608, collapse = Err("branch error: arena error: arena a4 is exhausted (256 pages); ask arena_for for a fresh extent") +ok +test collapse_survives_a_tree_that_fits_in_one_extent ... SMALL: reachable pages = 21, collapse = Ok(1) +ok +test the_ninth_fork_is_refused_so_collapse_is_the_only_way_past_depth_eight ... NINTH FORK: branch error: branch b8@g0 is at ancestry depth 9, max is 8 +ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.94s + + +[exited with code 0] diff --git a/bench/probe_read_concurrency_v1.txt b/bench/probe_read_concurrency_v1.txt new file mode 100644 index 00000000..33b0521d --- /dev/null +++ b/bench/probe_read_concurrency_v1.txt @@ -0,0 +1,27 @@ + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-reads-limit-probe) + Finished `release` profile [optimized] target(s) in 11.06s + Running tests/probe_read_concurrency.rs (target/release/deps/probe_read_concurrency-da8e21256a5eef97) + +running 1 test +test concurrent_reads_on_distinct_branches_do_not_scale_with_threads ... tree: 20000 rows, 298 pages, height 3; 8 diverged branches +buffer pool frames = 1024 (compile-time const, buffer_pool.rs:45) +CONTROL cpu : 1 thread 955091799 ops/s | 8 threads 8200519723 ops/s | x8.59 +READS : 1 thread 41781 rd/s | 4 threads 153621 rd/s | 8 threads 232858 rd/s +read scaling : x3.68 at 4 threads, x5.57 at 8 threads +cpu scaling : x8.59 at 8 threads + +thread 'concurrent_reads_on_distinct_branches_do_not_scale_with_threads' (345901500) panicked at tests/probe_read_concurrency.rs:157:5: +reads scaled x5.57 with 8 threads -- the serialisation prediction is WRONG +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +FAILED + +failures: + +failures: + concurrent_reads_on_distinct_branches_do_not_scale_with_threads + +test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.40s + +error: test failed, to rerun pass `--test probe_read_concurrency` + +[exited with code 0] diff --git a/bench/probe_read_scaling.txt b/bench/probe_read_scaling.txt new file mode 100644 index 00000000..9bede849 --- /dev/null +++ b/bench/probe_read_scaling.txt @@ -0,0 +1,31 @@ + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-reads-limit-probe) + Finished `release` profile [optimized] target(s) in 0.55s + Running tests/probe_read_scaling2.rs (target/release/deps/probe_read_scaling2-9fe8ec4ec249c42d) + +running 1 test +test read_scaling_across_threads_and_across_the_buffer_pool_boundary ... MAX_BUFFER_POOL_PAGES = 1024 frames (compile-time const, buffer_pool.rs:45) + +--- resident: 20000 rows, 298 pages, pool residency 100.0% + threads | reads/s | speedup + 1 | 40553 | x1.00 + 2 | 79822 | x1.97 + 4 | 148687 | x3.67 + 8 | 226444 | x5.58 + 12 | 244466 | x6.03 + 16 | 243494 | x6.00 + +--- oversubscribed: 400000 rows, 5953 pages, pool residency 17.2% + threads | reads/s | speedup + 1 | 37122 | x1.00 + 2 | 66967 | x1.80 + 4 | 69190 | x1.86 + 8 | 86061 | x2.32 + 12 | 83754 | x2.26 + 16 | 82090 | x2.21 + +ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 68.99s + + +[exited with code 0] diff --git a/tests/probe_catalog_read_locality.rs b/tests/probe_catalog_read_locality.rs new file mode 100644 index 00000000..d657dea6 --- /dev/null +++ b/tests/probe_catalog_read_locality.rs @@ -0,0 +1,82 @@ +//! PROBE: the branch->root lookup that EVERY read pays. +//! +//! `AgentRuntime::get_row` (runtime.rs:594) calls `root_of` (561) -> `self.branches.get(branch)`, +//! a full B+tree descent in the branch catalog, before the data tree is touched at all. +//! +//! bench/curve_to_1e6.txt line 12-15 explains why a 266 MB catalog does not thrash a 4.2 MB pool: +//! "every insert on the fork path lands at the EDGE of its span". Branch ids come from +//! `next_id.fetch_add(1)` (table_catalog.rs:742) and the RECORD key is `[0x00][id BE]` +//! (tree_keys.rs:75-78), so forks append to the rightmost leaf. TRUE FOR WRITES. +//! +//! A read resolves an ARBITRARY live branch id -- a uniformly located leaf. This probe asks +//! whether the non-thrash result transfers. +//! +//! Arms, same catalog, same code, differing only in id ORDER: +//! SEQ -- ascending ids (the fork path's access pattern) +//! RAND -- uniformly random live ids (the read path's access pattern) +//! Control: a small N whose whole catalog fits the pool. Both arms must agree there, or the +//! instrument is measuring id order rather than residency. + +use std::time::Instant; + +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::BranchCatalog; + +const SAMPLES: usize = 20_000; + +fn lcg(s: &mut u64) -> u64 { *s = s.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); *s >> 11 } + +fn run(n: usize) -> (f64, f64, u64) { + let dir = std::env::temp_dir().join(format!("ferro-catloc-{}-{}", std::process::id(), n)); + std::fs::create_dir_all(&dir).unwrap(); + let cat_path = dir.join("b.branchcat"); + let _ = std::fs::remove_file(&cat_path); + let cat = TableBranchCatalog::open_sidecar(&cat_path, 1).expect("open"); + + let mut ids = Vec::with_capacity(n); + for _ in 0..n { + ids.push(cat.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX)).expect("fork").branch_id); + } + let cat_bytes = std::fs::metadata(&cat_path).map(|m| m.len()).unwrap_or(0); + + // SEQ: ascending ids, the fork path's order. + let mut sink = 0u64; + let t0 = Instant::now(); + for i in 0..SAMPLES { + let b = ids[(i * ids.len()) / SAMPLES.max(1) % ids.len()]; + sink ^= cat.get(b).expect("seq get").root_page_id as u64; + } + let seq_us = t0.elapsed().as_secs_f64() * 1e6 / SAMPLES as f64; + + // RAND: uniformly random live ids, the read path's order. + let mut s = 0x243F6A8885A308D3u64; + let mut picks = Vec::with_capacity(SAMPLES); + for _ in 0..SAMPLES { picks.push(ids[(lcg(&mut s) as usize) % ids.len()]); } + let t1 = Instant::now(); + for b in &picks { sink ^= cat.get(*b).expect("rand get").root_page_id as u64; } + let rand_us = t1.elapsed().as_secs_f64() * 1e6 / SAMPLES as f64; + + assert_ne!(sink, 0xdeadbeef); + let _ = std::fs::remove_dir_all(&dir); + (seq_us, rand_us, cat_bytes) +} + +#[test] +fn branch_root_resolution_loses_its_locality_when_the_catalog_outgrows_the_pool() { + eprintln!("pool = 1024 frames x 4096 B = 4.19 MB (buffer_pool.rs:45, compile-time const)"); + eprintln!(" N | catalog MB | pool x | SEQ us | RAND us | RAND/SEQ"); + let mut results = Vec::new(); + for n in [10_000usize, 100_000] { + let (seq, rand, bytes) = run(n); + let mb = bytes as f64 / 1e6; + eprintln!("{:>6} | {:>11.1} | {:>6.1} | {:>8.3} | {:>8.3} | x{:.2}", + n, mb, mb * 1e6 / (1024.0 * 4096.0), seq, rand, rand / seq); + results.push((n, seq, rand, mb)); + } + // CONTROL: at 10k the catalog is ~2.7 MB, inside the pool -- the two arms must agree. + let (_, s0, r0, mb0) = results[0]; + assert!(mb0 * 1e6 < 1024.0 * 4096.0, "control N is not resident ({mb0:.1} MB); pick a smaller N"); + eprintln!("\ncontrol (resident) RAND/SEQ = x{:.2}", r0 / s0); + eprintln!("test (6x over) RAND/SEQ = x{:.2}", results[1].2 / results[1].1); +} diff --git a/tests/probe_depth_wall.rs b/tests/probe_depth_wall.rs new file mode 100644 index 00000000..db250282 --- /dev/null +++ b/tests/probe_depth_wall.rs @@ -0,0 +1,117 @@ +//! PROBE: force the depth-escape detector to fire. +//! +//! `MAX_BRANCH_DEPTH = 8` is the only cap on ancestry; `collapse` is the only way past it. +//! `collapse` allocates ONE arena (reaper.rs:352) and threads that single ArenaId through every +//! `deep_copy` recursion (reaper.rs:188,192). `ArenaPageStore::alloc_in_arena` hard-errors when +//! the extent is exhausted (arena.rs:1014) and never rolls over. `ARENA_EXTENT_PAGES = 256`. +//! +//! Prediction under test: a branch whose reachable tree exceeds 256 pages cannot be collapsed, +//! and is therefore permanently pinned at depth <= 8. + +use std::sync::Arc; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowPageLinks, CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +struct Env { + catalog: Arc, + store: Arc, + path: std::path::PathBuf, +} +impl Drop for Env { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("ferro-probe-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new() + .create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new( + Arc::clone(&pool), + Arc::clone(&catalog) as Arc, + base, + ).unwrap(), + ); + Env { catalog, store, path } +} + +fn key(i: u32) -> Vec { format!("k{:08}", i).into_bytes() } +fn val(i: u32) -> Vec { vec![(i & 0xff) as u8; 200] } + +/// Build a tree with `rows` keys and report how many pages it reaches, then try to collapse a +/// depth-limited branch that owns it. +fn collapse_at_size(tag: &str, rows: u32) -> (usize, Result) { + let e = env(tag); + let tree = CowTree::new(Arc::clone(&e.store) as Arc); + let reaper = TwoTierReaper::new( + Arc::clone(&e.catalog) as Arc, + Arc::clone(&e.store), + ).with_links(Arc::new(CowPageLinks)); + + let ep = e.catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..rows { + root = tree.insert(root, BranchId::TRUNK, ep, &key(i), &val(i)).unwrap(); + } + e.catalog.set_root(BranchId::TRUNK, root).unwrap(); + + // Fork to the depth cap, exactly as an agent chain would. + let mut cur = BranchId::TRUNK; + for _ in 0..4 { + cur = e.catalog.fork(cur, LeaseDeadline(u64::MAX)).unwrap().branch_id; + } + let pages = tree.walk_pages(e.catalog.get(cur).unwrap().root_page_id).unwrap().len(); + + match reaper.collapse(cur) { + Ok(rec) => (pages, Ok(rec.depth)), + Err(err) => (pages, Err(err.to_string())), + } +} + +#[test] +fn collapse_survives_a_tree_that_fits_in_one_extent() { + // NEGATIVE CONTROL. Without this, a failure below could be anything. + let (pages, r) = collapse_at_size("small", 200); + eprintln!("SMALL: reachable pages = {pages}, collapse = {r:?}"); + assert!(pages < 256, "control must fit one extent; got {pages} pages"); + assert_eq!(r.unwrap(), 1, "collapse must succeed below the extent size"); +} + +#[test] +fn collapse_fails_once_the_tree_outgrows_one_arena_extent() { + let (pages, r) = collapse_at_size("big", 6000); + eprintln!("BIG: reachable pages = {pages}, collapse = {r:?}"); + assert!(pages > 256, "probe must exceed one extent; got {pages} pages"); + let err = r.expect_err("PREDICTION: collapse cannot allocate past one 256-page extent"); + assert!( + err.contains("exhausted"), + "expected an arena-exhaustion refusal, got: {err}" + ); +} + +#[test] +fn the_ninth_fork_is_refused_so_collapse_is_the_only_way_past_depth_eight() { + let e = env("depth"); + let mut cur = BranchId::TRUNK; + for _ in 0..8 { + cur = e.catalog.fork(cur, LeaseDeadline(u64::MAX)).unwrap().branch_id; + } + assert_eq!(e.catalog.get(cur).unwrap().depth, 8); + let err = e.catalog.fork(cur, LeaseDeadline(u64::MAX)).unwrap_err().to_string(); + eprintln!("NINTH FORK: {err}"); + assert!(err.contains("depth"), "got {err}"); +} diff --git a/tests/probe_read_concurrency.rs b/tests/probe_read_concurrency.rs new file mode 100644 index 00000000..b8b9ffeb --- /dev/null +++ b/tests/probe_read_concurrency.rs @@ -0,0 +1,171 @@ +//! PROBE: does the "reads are flat" claim survive CONCURRENT readers on distinct branches? +//! +//! bench/branch_scaling.txt is single-threaded ("single process, warm cache"). The BranchBench +//! workloads are many agents reading their own branches at once. +//! +//! `BufferPoolManager::fetch_page` holds `self.arc_cache.lock()` across the WHOLE fetch, hit or +//! miss (src/buffer/buffer_pool.rs:72, and the comment at :68 says "This serialises fetches"). +//! A root-to-leaf descent of height h therefore takes one global mutex h times. +//! +//! Prediction: aggregate read throughput is ~flat in thread count; per-read latency rises ~linearly. +//! CONTROL: a pure-CPU loop on the same threads must scale, or the machine, not the pool, is the +//! bottleneck. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +const ROWS: u32 = 20_000; +const READS_PER_THREAD: u32 = 20_000; + +fn key(i: u32) -> Vec { format!("k{:08}", i).into_bytes() } +fn val(i: u32) -> Vec { format!("v{:08}", i).into_bytes() } + +struct Env { + catalog: Arc, + store: Arc, + path: std::path::PathBuf, +} +impl Drop for Env { + fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } +} + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("ferro-conc-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new( + ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog) as Arc, base).unwrap(), + ); + Env { catalog, store, path } +} + +/// Aggregate reads/sec with `threads` readers, each on its OWN branch. +fn read_throughput(e: &Env, roots: &[u32], threads: usize) -> f64 { + let tree = Arc::new(CowTree::new(Arc::clone(&e.store) as Arc)); + let barrier = Arc::new(Barrier::new(threads + 1)); + let hits = Arc::new(AtomicU64::new(0)); + let mut handles = Vec::new(); + for t in 0..threads { + let tree = Arc::clone(&tree); + let barrier = Arc::clone(&barrier); + let hits = Arc::clone(&hits); + let root = roots[t % roots.len()]; + handles.push(std::thread::spawn(move || { + // Warm this thread's path, then wait for everyone. + for i in 0..200u32 { let _ = tree.get(root, &key((i * 7919) % ROWS)).unwrap(); } + barrier.wait(); + let mut found = 0u64; + for i in 0..READS_PER_THREAD { + let k = key((i.wrapping_mul(7919)) % ROWS); + if tree.get(root, &k).unwrap().is_some() { found += 1; } + } + hits.fetch_add(found, Ordering::Relaxed); + })); + } + barrier.wait(); + let t0 = Instant::now(); + for h in handles { h.join().unwrap(); } + let secs = t0.elapsed().as_secs_f64(); + let total = hits.load(Ordering::Relaxed); + assert_eq!(total, threads as u64 * READS_PER_THREAD as u64, "a read missed; measuring nothing"); + total as f64 / secs +} + +/// CONTROL: pure CPU, no shared state. Must scale, or the box has no spare cores. +fn cpu_throughput(threads: usize) -> f64 { + const ITERS: u64 = 40_000_000; + let barrier = Arc::new(Barrier::new(threads + 1)); + let mut handles = Vec::new(); + for _ in 0..threads { + let barrier = Arc::clone(&barrier); + handles.push(std::thread::spawn(move || { + barrier.wait(); + let mut x = 1u64; + for i in 0..ITERS { x = x.wrapping_mul(6364136223846793005).wrapping_add(i); } + x + })); + } + barrier.wait(); + let t0 = Instant::now(); + let mut sink = 0u64; + for h in handles { sink ^= h.join().unwrap(); } + assert_ne!(sink, 12345); + (threads as u64 * ITERS) as f64 / t0.elapsed().as_secs_f64() +} + +#[test] +fn concurrent_reads_on_distinct_branches_scale_but_not_with_the_cores() { + let e = env("reads"); + let tree = CowTree::new(Arc::clone(&e.store) as Arc); + let ep = e.catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..ROWS { root = tree.insert(root, BranchId::TRUNK, ep, &key(i), &val(i)).unwrap(); } + e.catalog.set_root(BranchId::TRUNK, root).unwrap(); + let height = { + // report tree height so the per-read page count is on the record + let mut h = 1; let mut pid = root; + loop { + let hd = e.store.read_page(pid).unwrap(); + if hd.header().unwrap().page_type != ferrodb::cow::PageType::BTreeInternal { break; } + h += 1; + pid = tree.walk_pages(pid).unwrap()[1]; + } + h + }; + let pages = tree.walk_pages(root).unwrap().len(); + + // Eight branches, each diverged by one key, so each reads its own root. + let mut roots = Vec::new(); + for i in 0..8u32 { + let b = e.catalog.fork(BranchId::TRUNK, LeaseDeadline(u64::MAX)).unwrap(); + let ep2 = e.catalog.next_epoch(); + let r = tree.insert(b.root_page_id, b.branch_id, ep2, &key(i), b"mine").unwrap(); + e.catalog.set_root(b.branch_id, r).unwrap(); + roots.push(r); + } + + eprintln!("tree: {ROWS} rows, {pages} pages, height {height}; 8 diverged branches"); + eprintln!("buffer pool frames = 1024 (compile-time const, buffer_pool.rs:45)"); + + let c1 = cpu_throughput(1); + let c8 = cpu_throughput(8); + eprintln!("CONTROL cpu : 1 thread {:>12.0} ops/s | 8 threads {:>12.0} ops/s | x{:.2}", + c1, c8, c8 / c1); + + let r1 = read_throughput(&e, &roots, 1); + let r4 = read_throughput(&e, &roots, 4); + let r8 = read_throughput(&e, &roots, 8); + eprintln!("READS : 1 thread {:>12.0} rd/s | 4 threads {:>12.0} rd/s | 8 threads {:>12.0} rd/s", + r1, r4, r8); + eprintln!("read scaling : x{:.2} at 4 threads, x{:.2} at 8 threads", r4 / r1, r8 / r1); + eprintln!("cpu scaling : x{:.2} at 8 threads", c8 / c1); + + assert!(c8 / c1 > 3.0, "CONTROL FAILED: cpu did not scale (x{:.2}); the box, not the pool, is the limit", c8 / c1); + + // MEASURED 2026-09-17, 18 logical cores, release build: reads x5.57 at 8 threads against a + // cpu control of x8.59. The original prediction here -- that the global fetch mutex would + // hold reads under x2 -- was WRONG for a working set that FITS the pool, and is recorded as + // wrong rather than quietly restated. The lock costs ~30% of linear here, not a wall. + // probe_read_scaling2 sweeps further and finds the actual ceiling (x6.0, and x2.3 once the + // working set exceeds the 1024-frame pool). + assert!(r8 / r1 > 2.0, "reads did not scale at all (x{:.2}); v1 measured x5.57", r8 / r1); + assert!( + r8 / r1 < c8 / c1, + "reads scaled x{:.2} vs cpu x{:.2} -- reads would have to be scaling as well as pure cpu, \ + which would mean the shared buffer pool costs nothing", + r8 / r1, c8 / c1 + ); +} diff --git a/tests/probe_read_scaling2.rs b/tests/probe_read_scaling2.rs new file mode 100644 index 00000000..4eca4ce0 --- /dev/null +++ b/tests/probe_read_scaling2.rs @@ -0,0 +1,99 @@ +//! PROBE v2. Two axes the committed read benchmark never varied: +//! (a) THREADS -- bench/branch_scaling.txt is single-process, single-threaded. +//! (b) WORKING SET vs the buffer pool -- MAX_BUFFER_POOL_PAGES = 1024 is a compile-time const +//! (src/buffer/buffer_pool.rs:45), and the committed bench's trunk is 44 pages. +//! +//! Every fetch takes one global Mutex (buffer_pool.rs:72, "This serialises fetches"). +//! v1 measured x5.57 at 8 threads with a 298-page (fully resident) tree: contention is real but +//! not a wall. This run asks where the ceiling actually is, and what an over-subscribed pool does. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::BranchId; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; + +const READS_PER_THREAD: u32 = 30_000; + +fn key(i: u32) -> Vec { format!("k{:08}", i).into_bytes() } +fn val(i: u32) -> Vec { format!("v{:08}", i).into_bytes() } + +struct Env { catalog: Arc, store: Arc, path: std::path::PathBuf } +impl Drop for Env { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } + +fn env(tag: &str) -> Env { + let path = std::env::temp_dir().join(format!("ferro-rs2-{}-{}.db", std::process::id(), tag)); + let _ = std::fs::remove_file(&path); + let file = std::fs::OpenOptions::new().create(true).read(true).write(true).open(&path).unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let catalog = Arc::new(LogBranchCatalog::in_memory(1)); + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new(ArenaPageStore::new(Arc::clone(&pool), + Arc::clone(&catalog) as Arc, base).unwrap()); + Env { catalog, store, path } +} + +fn throughput(tree: &Arc, root: u32, rows: u32, threads: usize) -> f64 { + let barrier = Arc::new(Barrier::new(threads + 1)); + let hits = Arc::new(AtomicU64::new(0)); + let mut hs = Vec::new(); + for t in 0..threads { + let tree = Arc::clone(tree); + let barrier = Arc::clone(&barrier); + let hits = Arc::clone(&hits); + hs.push(std::thread::spawn(move || { + let off = (t as u32).wrapping_mul(104729); + for i in 0..200u32 { let _ = tree.get(root, &key((i.wrapping_mul(7919).wrapping_add(off)) % rows)); } + barrier.wait(); + let mut found = 0u64; + for i in 0..READS_PER_THREAD { + let k = key((i.wrapping_mul(7919).wrapping_add(off)) % rows); + if tree.get(root, &k).unwrap().is_some() { found += 1; } + } + hits.fetch_add(found, Ordering::Relaxed); + })); + } + barrier.wait(); + let t0 = Instant::now(); + for h in hs { h.join().unwrap(); } + let secs = t0.elapsed().as_secs_f64(); + let total = hits.load(Ordering::Relaxed); + assert_eq!(total, threads as u64 * READS_PER_THREAD as u64, "a read missed; measuring nothing"); + total as f64 / secs +} + +fn build(tag: &str, rows: u32) -> (Env, Arc, u32, usize) { + let e = env(tag); + let tree = Arc::new(CowTree::new(Arc::clone(&e.store) as Arc)); + let ep = e.catalog.next_epoch(); + let mut root = tree.create(BranchId::TRUNK, ep).unwrap(); + for i in 0..rows { root = tree.insert(root, BranchId::TRUNK, ep, &key(i), &val(i)).unwrap(); } + let pages = tree.walk_pages(root).unwrap().len(); + (e, tree, root, pages) +} + +#[test] +fn read_scaling_across_threads_and_across_the_buffer_pool_boundary() { + eprintln!("MAX_BUFFER_POOL_PAGES = 1024 frames (compile-time const, buffer_pool.rs:45)\n"); + // 20k rows ~ 300 pages (fits); 400k rows ~ 6000 pages (6x over the pool). + for (tag, rows) in [("resident", 20_000u32), ("oversubscribed", 400_000u32)] { + let (_e, tree, root, pages) = build(tag, rows); + eprintln!("--- {tag}: {rows} rows, {pages} pages, pool residency {:.1}%", + 100.0 * (pages.min(1024) as f64) / (pages as f64)); + let base = throughput(&tree, root, rows, 1); + eprintln!(" threads | reads/s | speedup"); + eprintln!(" 1 | {:>12.0} | x1.00", base); + for t in [2usize, 4, 8, 12, 16] { + let r = throughput(&tree, root, rows, t); + eprintln!(" {:>6} | {:>12.0} | x{:.2}", t, r, r / base); + } + eprintln!(); + } +} From af8ebbef3c37a137562c157a13cb3468b68456a2 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:12:55 -0400 Subject: [PATCH 019/315] =?UTF-8?q?bench(D65-REOPEN-WITH-DATA):=20INCOMPLE?= =?UTF-8?q?TE=20=E2=80=94=20the=20disk=20guard=20stopped=20it=20at=20N=3D2?= =?UTF-8?q?50,000,=20and=20that=20is=20not=20a=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free space fell from 39 GiB to 18.4 GiB in the eight minutes this run took; ~1 GB of that was its own database and the rest was other sessions. The FREE_FLOOR guard refused rather than filling a shared disk, which is exactly what it was added for, and its message says so: "Not this run's budget -- the DISK. Reported as a stop, not a result." Measured before the stop: reopen 1.144 ms at N=100,000 and 1.184 ms at N=250,000, with len B/branch reading 4096 at both — which reconciles with D61's five-point decade, as the pre-registration required. ⚠ NOTHING is claimed about reopen's complexity class. Two points cannot separate O(1) from O(log N) from a small O(N), and this file pre-registered that the COLUMN is the measurement and no single cell is. +3.5% across 2.5x is consistent with flat and consistent with a shallow slope; saying which would be inventing the result. The floor was NOT lowered to make the run fit. D65 stays open, blocked on disk — a resource this machine currently does not have, held by other sessions. --- bench/d65_reopen_curve_1e6.txt | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 bench/d65_reopen_curve_1e6.txt diff --git a/bench/d65_reopen_curve_1e6.txt b/bench/d65_reopen_curve_1e6.txt new file mode 100644 index 00000000..89df43bf --- /dev/null +++ b/bench/d65_reopen_curve_1e6.txt @@ -0,0 +1,80 @@ +D65 — DOES CATALOG REOPEN STAY FLAT TO 10^6 *WITH DATA PRESENT*? +[HERE] 2026-09-20T07:02:30Z examples/branch_curve_writes at 63236eb, 8 threads, 8 GB budget, 20 GiB disk floor + +WHY: S4's O(1)-reopen claim has only ever been checked by examples/branch_curve.rs, which is +FORK-ONLY — no branch in it calls arena_for or alloc_in_arena — so it reopens a catalog whose +branches own NO PAGES. This harness is the one that writes. D61 proved space is flat to 10^6 +(4096 B/branch, bench/d61_write_curve_1e6_run2.txt) but could not answer reopen: it did not +measure it and deletes its database at the end. A system that REACHES 10^6 branches but cannot +RESTART at 10^6 has not reached it in any sense a user would accept. + +PRE-REGISTERED, before the first number exists: reopen ms is FLAT across the decade. +If it CLIMBS, that is a new wall at exactly the scale D61 just reached, it is a SHAPE (catalog +reopen is linear in branch count), and it is worth MORE than the flat result. +⚠ THE COLUMN IS THE MEASUREMENT, NOT ANY ONE CELL. One reopen number at 10^6 cannot separate +O(1) from O(log N) from a small O(N) — only the slope across five checkpoints can. +⚠ reopen ms is WALL CLOCK on a SHARED box, so it is noisy upward; a climb must be monotone +and large to count, and a single high cell is load until the shape says otherwise. +⚠ forks/sec remains a FLOOR, not throughput (D61's own column fell to 290.5 then rose to 386.8). +Pre-registration also records: len B/branch must still read 4096, or this run disagrees with +D61 and BOTH are suspect until reconciled. + +free before: 39Gi load before: 2.14 2.49 2.41 + +D32: the curve to 10^6 WITH ONE PAGE WRITTEN PER BRANCH. 8 threads. +ARENA_EXTENT_PAGES = 256, so D31 predicts ~1024 KiB of DATA file per branch. +Budget: 8 GB. The run REFUSES to exceed it rather than fill the disk. + + N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms + 100000 492.5 409.6 419.5 4096 4195 334 100000 1.144 + 250000 456.4 1024.0 1040.3 4096 4161 334 250000 1.184 + STOPPING: 18.4 GiB free, floor is 20 GiB. Not this run's budget -- the DISK. Reported as a stop, not a result. + +Allocated (blocks*512) rather than merely addressed: 4161 B/branch, 1.04 GB total. + -> 10^6 branches x 4161 allocated B = 0.00 TB actually on disk. +STOPPED EARLY ON SPACE at N = 250000, data file 1.02 GB (4096 bytes/branch). +Extrapolated (ARITHMETIC, NOT MEASURED) to the objective: + 10^6 branches x 4096 B = 0.00 TB of data file. +bench/curve_to_1e6.txt was reached fork-only, on the one workload that does not +pay this. Fix is geometric extent growth (SCALE-DESIGN D31 option 1), NOT +lowering ARENA_EXTENT_PAGES -- reclamation is per-extent on purpose. + + reference: one page = 4096 B · one full extent = 1048576 B (256x apart) +VERDICT — AMPLIFICATION GONE. 4096 B/branch is within 8x of a +single page, so a branch's first page costs about a page. This is the tree WITH +geometric extent growth (D31) built; without it this number is ~1048576. +# rc=0 +free after: 18Gi load after: 28.96 39.77 22.99 + +=== READING =============================================================================== +[HERE] reduced by this session. ⛔ THIS RUN IS INCOMPLETE AND IS NOT A RESULT FOR THE ROW. + +WHY IT STOPPED, and it was not ferrodb: the harness's own disk guard fired — + "STOPPING: 18.4 GiB free, floor is 20 GiB. Not this run's budget -- the DISK." +Free space on this shared machine went from 39 GiB at launch to 18.4 GiB EIGHT MINUTES LATER; +this run's own database accounted for ~1 GB of that. The other ~20 GiB was other sessions. The +run did not exceed its 8 GB byte budget, did not fault, and did not find anything about ferrodb. +The FREE_FLOOR guard did exactly what it was added for: it refused rather than filling a disk +that other lanes are using. Reported as a stop, not a result — which is the guard's own wording. + +WHAT WAS MEASURED BEFORE THE STOP (2 points, and 2 points are not a slope) + N reopen ms len B/branch cat B/branch + 100000 1.144 4096 334 + 250000 1.184 4096 334 + + · reopen is ~1.1 ms at both, +3.5% across a 2.5x range. That is CONSISTENT with flat and + consistent with a very shallow slope; it cannot distinguish them. The pre-registration in + this file says so in advance: "THE COLUMN IS THE MEASUREMENT, NOT ANY ONE CELL. One reopen + number cannot separate O(1) from O(log N) from a small O(N)." Two cells is barely better + than one. NOTHING about reopen's complexity class is claimed from this. + · len B/branch reads 4096 at both points, AGREEING with D61's five-point decade. The + pre-registered reconciliation check passes: this run does not contradict D61. + +STATUS: D65 is BLOCKED ON DISK, not on anything about the system under test. The machine is at +99% capacity and the free space is owned by other sessions. Re-running needs ~4.1 GB of data plus +headroom above the 20 GiB floor, i.e. roughly 25 GiB free. + +⛔ WHAT WAS NOT DONE, AND WILL NOT BE: the floor was NOT lowered to make the run fit. Lowering a +safety guard to obtain a number is how a measurement becomes a story about the machine, and this +guard's whole purpose is that another lane's ENOSPC reads exactly like a real test failure. +=========================================================================================== From 1dc81cdfebfc6e8336df70b1612089ec697e2f36 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:38:20 -0400 Subject: [PATCH 020/315] =?UTF-8?q?bench(D65):=20run=202=20also=20stopped?= =?UTF-8?q?=20on=20the=20disk=20floor=20=E2=80=94=20and=20the=20two=20runs?= =?UTF-8?q?=20disagree=20about=20the=20DIRECTION=20at=20the=20same=20sizes?= =?UTF-8?q?,=20so=20the=20instrument=20is=20the=20problem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 100k: 1.144 vs 0.856 ms. 250k: 1.184 vs 1.729 ms. One wall-clock sample per checkpoint on a box at load 57 cannot separate a real cost from a scheduling artifact, and run 2's apparent 2x climb is contradicted by run 1 measuring flat at the same two sizes. No complexity class is claimed. Next run must take k samples per checkpoint and report MEDIAN and MIN (contention can only make a reopen slower, never faster, so MIN is the least contaminated estimate), and must use a 10k..250k span that fits inside the disk floor — 25x across five points answers the shape question better than two points at 10^6. len B/branch reads 4096 in both runs, so D61's space result is unaffected. --- bench/d65_reopen_curve_1e6_run2.txt | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 bench/d65_reopen_curve_1e6_run2.txt diff --git a/bench/d65_reopen_curve_1e6_run2.txt b/bench/d65_reopen_curve_1e6_run2.txt new file mode 100644 index 00000000..958cf174 --- /dev/null +++ b/bench/d65_reopen_curve_1e6_run2.txt @@ -0,0 +1,71 @@ +D65 RUN 2 — reopen to 10^6 WITH DATA. Run 1 was stopped by the DISK guard at N=250,000. +[HERE] 2026-09-20T07:19:12Z at af8ebbe, 8 threads, 8 GB budget, 20 GiB floor UNCHANGED. +Launched automatically once free space rose above 26 GiB; the floor was never lowered. +PRE-REGISTERED (unchanged from run 1): reopen ms is FLAT across the decade. If it CLIMBS that +is a new wall at the scale D61 reached and worth more than the flat result. THE COLUMN is the +measurement, never a single cell. len B/branch must still read 4096 or this disagrees with D61. +Run 1's two points, for comparison: 100000 -> 1.144 ms, 250000 -> 1.184 ms. +free before: 32Gi load before: 60.71 57.76 39.66 + +D32: the curve to 10^6 WITH ONE PAGE WRITTEN PER BRANCH. 8 threads. +ARENA_EXTENT_PAGES = 256, so D31 predicts ~1024 KiB of DATA file per branch. +Budget: 8 GB. The run REFUSES to exceed it rather than fill the disk. + + N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms + 100000 272.8 409.6 420.4 4096 4204 334 100000 0.856 + 250000 206.7 1024.0 1024.4 4096 4098 334 250000 1.729 + STOPPING: 18.4 GiB free, floor is 20 GiB. Not this run's budget -- the DISK. Reported as a stop, not a result. + +Allocated (blocks*512) rather than merely addressed: 4098 B/branch, 1.02 GB total. + -> 10^6 branches x 4098 allocated B = 0.00 TB actually on disk. +STOPPED EARLY ON SPACE at N = 250000, data file 1.02 GB (4096 bytes/branch). +Extrapolated (ARITHMETIC, NOT MEASURED) to the objective: + 10^6 branches x 4096 B = 0.00 TB of data file. +bench/curve_to_1e6.txt was reached fork-only, on the one workload that does not +pay this. Fix is geometric extent growth (SCALE-DESIGN D31 option 1), NOT +lowering ARENA_EXTENT_PAGES -- reclamation is per-extent on purpose. + + reference: one page = 4096 B · one full extent = 1048576 B (256x apart) +VERDICT — AMPLIFICATION GONE. 4096 B/branch is within 8x of a +single page, so a branch's first page costs about a page. This is the tree WITH +geometric extent growth (D31) built; without it this number is ~1048576. +# rc=0 +free after: 18Gi load after: 57.45 50.87 46.15 + +=== READING =============================================================================== +⛔ ALSO INCOMPLETE, AND THE INSTRUMENT IS THE PROBLEM, NOT JUST THE DISK. + +Stopped by the same floor guard at N=250,000: free space fell 32 GiB -> 18.4 GiB DURING the run, +with load reaching 57. Launched automatically when disk rose above 26 GiB; the floor was never +lowered. + +THE FOUR REOPEN POINTS ACROSS BOTH RUNS, AT THE SAME TWO SIZES: + N = 100,000 N = 250,000 + run 1 1.144 ms 1.184 ms (ratio 1.03 — reads as flat) + run 2 0.856 ms 1.729 ms (ratio 2.02 — reads as a climb) + +The two runs disagree by 34% at N=100,000 and 46% at N=250,000, and they disagree about the +DIRECTION. Run 2's apparent doubling is not evidence of O(N): run 1 measured the same two sizes +and found flat. ⛔ NOTHING about reopen's complexity class can be extracted from this, and +quoting run 2's 2x would be reporting this machine's load as a property of the catalog. + +THE FAULT IS THE INSTRUMENT. `reopen_ms` is a SINGLE wall-clock sample per checkpoint on a shared, +heavily loaded box. One sample cannot distinguish a real cost from a scheduling artifact, which is +the same lesson as [[a-benchmark-harness-can-be-the-wall]] in a different guise — and this file's +own pre-registration anticipated it ("noisy upward; a single high cell is load until the shape says +otherwise"). It was right, and one sample per point was not enough to act on it. + +WHAT THE NEXT RUN MUST CHANGE, both of them, before any reopen number is quoted again: + 1. REPEAT the reopen k times per checkpoint and report MEDIAN and MIN, not one sample. The MIN + is the least load-contaminated estimate available — a reopen cannot be made faster by + contention, only slower — so a flat MIN column is much stronger evidence than a flat single + sample, and a climbing MIN is much harder to dismiss. + 2. SHRINK THE SPAN TO WHAT THE DISK ALLOWS. 10k..250k is a 25x span for ~1 GB of data, which + fits inside the floor with room to spare, and 25x across five points answers the + complexity-class question far better than two points at 10^6 ever could + ([[measure-the-slope-not-the-ratio]]). Extending to 10^6 is then a separate, larger run for + when the machine is quiet. + +WHAT IS NOT IN DOUBT: len B/branch reads 4096 at both checkpoints in both runs, agreeing with +D61's five-point decade. The space result stands; only reopen is unmeasured. +=========================================================================================== From 68c6ff3aa00021b8af6c510bef51ceadd2664a34 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:39:31 -0400 Subject: [PATCH 021/315] D65: take k reopen samples per checkpoint and report MEDIAN and MIN, not one wall-clock sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs 1 and 2 took a single sample per checkpoint on a shared box and disagreed about the DIRECTION at the same two sizes: 100k gave 1.144 ms then 0.856 ms, 250k gave 1.184 ms then 1.729 ms. One sample cannot separate a real cost from a scheduling artifact, so neither run could say anything about reopen's complexity class — and quoting run 2's apparent 2x would have reported this machine's load as a property of the catalog. Five samples per checkpoint now, reporting median and MIN. Min is the one that matters: contention can only make a reopen slower, never faster, so it is the least contaminated estimate available. A flat min column is strong evidence; a climbing min is hard to dismiss as load. Smoke-tested: N=2,000 -> median 0.320 / min 0.265 ms; N=4,000 -> median 0.341 / min 0.325 ms. ⚠ Two points on a busy box, and NOT the result — the span run is what answers the question. --- examples/branch_curve_writes.rs | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/examples/branch_curve_writes.rs b/examples/branch_curve_writes.rs index da2ceb56..5da2b3ba 100644 --- a/examples/branch_curve_writes.rs +++ b/examples/branch_curve_writes.rs @@ -155,7 +155,7 @@ fn main() { // Two space columns on purpose. `data MB` is FILE LENGTH; `alloc MB` is blocks*512, what the // filesystem actually gave out. A reservation scheme can inflate length far past allocation, and // quoting only length would overstate the wall. Both are reported so neither can be cherry-picked. - println!(" N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms"); + println!(" N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms reopen min"); let mut done = 0usize; let mut stopped_early: Option<(usize, u64)> = None; @@ -205,21 +205,29 @@ fn main() { // // S4's O(1)-reopen claim has only ever been checked by `examples/branch_curve.rs`, which is // FORK-ONLY: no branch in it calls `arena_for` or `alloc_in_arena`, so it reopens a catalog - // whose branches own no pages. This harness is the one that writes, and it did not measure - // reopen at all — and it deletes its database at the end, so D61 could not answer this - // after the fact. The timing block is `branch_curve.rs`'s own, reused rather than rewritten. + // whose branches own no pages. This harness is the one that writes. // - // Reported PER CHECKPOINT on purpose: one reopen number at 10^6 cannot separate O(1) from - // O(log N) from a small O(N). The column across the decade is the measurement; a single - // cell is an anecdote. + // ⚠ **k SAMPLES, REPORTING MEDIAN AND MIN — not one wall-clock sample.** D65 runs 1 and 2 + // took a single sample per checkpoint on a shared box and DISAGREED ABOUT THE DIRECTION at + // the same two sizes (100k: 1.144 vs 0.856 ms; 250k: 1.184 vs 1.729 ms). One sample cannot + // separate a real cost from a scheduling artifact. MIN is reported because contention can + // only make a reopen SLOWER, never faster, so it is the least contaminated estimate + // available: a flat MIN column is strong evidence, and a climbing MIN is hard to dismiss. + const REOPEN_SAMPLES: usize = 5; let root = cat_concrete.root_page_id(); - let t_reopen = Instant::now(); - let re = TableBranchCatalog::open_sidecar(&cat_path, root).expect("reopen"); - let reopen_ms = t_reopen.elapsed().as_secs_f64() * 1000.0; - drop(re); + let mut samples = Vec::with_capacity(REOPEN_SAMPLES); + for _ in 0..REOPEN_SAMPLES { + let t_reopen = Instant::now(); + let re = TableBranchCatalog::open_sidecar(&cat_path, root).expect("reopen"); + samples.push(t_reopen.elapsed().as_secs_f64() * 1000.0); + drop(re); + } + samples.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let reopen_min = samples[0]; + let reopen_ms = samples[samples.len() / 2]; println!( - " {:>8} {:>9.1} {:>7.1} {:>8.1} {:>12.0} {:>14.0} {:>12.0} {:>10} {:>9.3}", + " {:>8} {:>9.1} {:>7.1} {:>8.1} {:>12.0} {:>14.0} {:>12.0} {:>10} {:>9.3} {:>9.3}", done, actually as f64 / secs, data as f64 / 1e6, @@ -229,6 +237,7 @@ fn main() { cbytes as f64 / done as f64, store.live_page_count().unwrap_or(0), reopen_ms, + reopen_min, ); if data >= budget { From f1955220dfc5f804f7478c5edbba85698b1de605 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 03:54:14 -0400 Subject: [PATCH 022/315] =?UTF-8?q?bench(D65):=20reopen=20is=20NOT=20linea?= =?UTF-8?q?r=20in=20branch=20count=20=E2=80=94=2025x=20more=20branches=20c?= =?UTF-8?q?ost=201.6x,=20measured=20with=20data=20present?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five points, 10k..250k, five samples each reporting median and MIN, rc=0 with no early stop. min: 0.204 / 0.213 / 0.223 / 0.200 / 0.325 ms. S4's O(1)-reopen claim had only ever been checked by the FORK-ONLY curve, on a catalog whose branches own no pages. This is the first measurement with data present, and linear is excluded by a factor of fifteen: O(N) would have put the last point at ~5 ms. ⚠ O(1) vs O(log N) is NOT settled and is not claimed. min is flat within ±6% across 10k..100k and then steps once at 250k — which is what load looks like AND what a threshold effect looks like, and one point cannot separate them. log(25) predicts 1.33x against an observed 1.6x, so the data excludes neither. Run 3 extends to 10^6 to ask whether that step reproduces. The instrument is the fix from the previous commit: runs 1 and 2 took one wall-clock sample each and disagreed about the DIRECTION at the same sizes. Min is reported because contention can only make a reopen slower; the median beside it shows the load (3-21% here). --- bench/d65_reopen_span.txt | 65 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 bench/d65_reopen_span.txt diff --git a/bench/d65_reopen_span.txt b/bench/d65_reopen_span.txt new file mode 100644 index 00000000..c80c86e7 --- /dev/null +++ b/bench/d65_reopen_span.txt @@ -0,0 +1,65 @@ +D65 — REOPEN vs BRANCH COUNT, 25x span, k=5 samples per point reporting MEDIAN and MIN. +[HERE] 2026-09-20T07:43:39Z at 68c6ff3, 8 threads, 8 GB budget, 20 GiB floor. + +WHY THIS SPAN AND NOT 10^6: runs 1 and 2 both tripped the disk floor at N=250,000 on a box +whose free space moves by tens of GiB in minutes. 10k..250k is ~1 GB and fits with room. +25x across FIVE points answers the complexity-class question better than two points at 10^6: +one ratio cannot separate a constant from a class, only a slope can. + +WHY MEDIAN AND MIN: runs 1 and 2 took ONE wall-clock sample per point and disagreed about the +DIRECTION at the same two sizes (100k: 1.144 vs 0.856 ms; 250k: 1.184 vs 1.729 ms). Contention +can only make a reopen SLOWER, never faster, so MIN is the least contaminated estimate here. +READ THE MIN COLUMN. The median is reported so the gap between them shows the load. + +PRE-REGISTERED: reopen MIN is flat across 25x. If MIN climbs monotonically and materially, +catalog reopen is linear in branch count — a SHAPE, and a new wall. Also pre-registered: +len B/branch must read ~4096 throughout or this run disagrees with D61. +free before: 72Gi load before: 42.66 46.91 46.33 + +D32: the curve to 10^6 WITH ONE PAGE WRITTEN PER BRANCH. 8 threads. +ARENA_EXTENT_PAGES = 256, so D31 predicts ~1024 KiB of DATA file per branch. +Budget: 8 GB. The run REFUSES to exceed it rather than fill the disk. + + N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms reopen min + 10000 524.4 41.0 42.0 4096 4198 333 10000 0.225 0.204 + 25000 545.9 102.4 117.5 4096 4699 334 25000 0.218 0.213 + 50000 529.6 204.8 218.1 4096 4363 334 50000 0.231 0.223 + 100000 408.7 409.6 419.5 4096 4195 334 100000 0.219 0.200 + 250000 413.4 1024.0 1040.2 4096 4161 334 250000 0.394 0.325 + +DID NOT stop early within the budget: N = 250000, 4096 bytes/branch. + + reference: one page = 4096 B · one full extent = 1048576 B (256x apart) +VERDICT — AMPLIFICATION GONE. 4096 B/branch is within 8x of a +single page, so a branch's first page costs about a page. This is the tree WITH +geometric extent growth (D31) built; without it this number is ~1048576. +# rc=0 +free after: 81Gi load after: 35.02 39.84 41.42 + +=== READING =============================================================================== +[HERE] reduced by this session from the table above. Five points, 25x, rc=0, no early stop. + +REOPEN IS NOT LINEAR IN BRANCH COUNT — measured WITH DATA PRESENT for the first time. + N 10,000 25,000 50,000 100,000 250,000 + min ms 0.204 0.213 0.223 0.200 0.325 + 25x more branches cost 1.6x reopen. If reopen were O(N) the last point would be ~5 ms, i.e. + 25x the first. It is 1.6x. That rules out linear by a factor of fifteen, which is the claim + S4 needed and which had never been checked on a catalog whose branches OWN PAGES. + +WHAT IS NOT SETTLED, and is not being dressed up as settled: + · min is flat across 10k..100k (0.204, 0.213, 0.223, 0.200 — a 10x span inside ±6%) and then + steps to 0.325 at 250k. ONE step at the last point is exactly what load looks like, and also + exactly what a real threshold effect looks like (a B+tree gaining a level, say). A single + point cannot tell those apart. + · O(1) versus O(log N) is therefore OPEN. log(250k)/log(10k) = 1.33, observed 1.6 — close + enough to log that the data does not exclude it, and close enough to flat-plus-noise that it + does not exclude that either. Claiming either from this would be inventing a result. + · The run that decides it is `d65_reopen_curve_1e6_run3.txt`, which extends to 10^6 and asks + whether the 250k step REPRODUCES and whether it keeps stepping. + +WHY THE MIN COLUMN: contention can only make a reopen SLOWER, never faster, so min is the least +load-contaminated estimate available. The median is reported beside it so the gap shows the load +— here median exceeds min by 3-21%, which is the machine, not the catalog. + +len B/branch reads 4096 at all five points, agreeing with D61's decade. Pre-registered check passes. +=========================================================================================== From ada9357da287afd52a620153c7461f079bc2368e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 04:40:02 -0400 Subject: [PATCH 023/315] =?UTF-8?q?bench(D65-REOPEN-WITH-DATA):=20catalog?= =?UTF-8?q?=20reopen=20is=20FLAT=20to=2010^6=20branches=20with=20data=20pr?= =?UTF-8?q?esent=20=E2=80=94=200.263=20->=200.264=20ms=20across=2010x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five checkpoints, rc=0, no early stop. reopen min: 0.263 / 0.219 / 0.214 / 0.311 / 0.264 ms at N = 100k / 250k / 500k / 750k / 1M. Ten times the branches costs 0.263 -> 0.264 ms, and the highest point sits at 750,000 with the lowest at 500,000 — NON-MONOTONE, which is load, not cost. O(N) would have put the last point at 2.6 ms. All four pre-registered questions answered. The 25x span's single step at 250,000 (0.325 ms) does NOT reproduce here (0.219 ms at the same size), so that step is withdrawn as load — which is exactly how the header said to read a single high cell before any number existed. len B/branch reads 4096 at all five points, reconciling with D61's decade. This is S4's O(1)-reopen claim checked for the first time on a catalog whose branches OWN PAGES: the only prior 10^6 reopen measurement is examples/branch_curve.rs, which is FORK-ONLY and reopens a catalog whose branches never called arena_for. Reopen being independent of branch count is a SHAPE — the open path reads a root, not a population — and transfers; it is not a BranchBench result and not a comparison against Dolt or Postgres. forks/sec again refutes itself as throughput (543.9 -> 244.2 -> 475.3, halving and recovering) and is reported as a floor. reopen min is load-robust by construction: contention can only make a reopen slower. Not measured: reopen under concurrent live sessions, and reopen after an unclean shutdown. --- bench/d65_reopen_curve_1e6_run3.txt | 80 +++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 bench/d65_reopen_curve_1e6_run3.txt diff --git a/bench/d65_reopen_curve_1e6_run3.txt b/bench/d65_reopen_curve_1e6_run3.txt new file mode 100644 index 00000000..8f4a5f04 --- /dev/null +++ b/bench/d65_reopen_curve_1e6_run3.txt @@ -0,0 +1,80 @@ +D65 RUN 3 — REOPEN TO 10^6 WITH DATA, k=5 samples per point, MEDIAN and MIN. +[HERE] 2026-09-20T07:53:56Z at 68c6ff3, 8 threads, 8 GB budget, 20 GiB floor UNCHANGED. +Runs 1 and 2 were stopped by the floor at N=250,000 when free space was 18 GiB; it is now far higher. + +WHAT THE 25x SPAN ALREADY ESTABLISHED (bench/d65_reopen_span.txt, five points, rc=0): + reopen MIN 10k 0.204 | 25k 0.213 | 50k 0.223 | 100k 0.200 | 250k 0.325 ms + 25x more branches cost 1.6x reopen. LINEAR would have been 25x (~5 ms), so reopen is + decisively NOT O(N). Flat across 10k..100k, then ONE step up at 250k. + +PRE-REGISTERED FOR THIS RUN, before its first number exists: + (1) Does the 250k step REPRODUCE? If it does not, it was load and the column is flat. + (2) If it does reproduce, does it keep stepping to 10^6, or settle? A monotone climb + across 500k/750k/1M is a real sub-linear growth term and gets reported as one. + (3) Either way reopen is NOT linear, which is the claim S4 needed and which has never + been checked WITH DATA PRESENT until now. + (4) len B/branch must read 4096 throughout or this disagrees with D61. +READ THE MIN COLUMN: contention can only make a reopen slower, never faster. +free before: 82Gi load before: 35.41 39.58 41.26 + +D32: the curve to 10^6 WITH ONE PAGE WRITTEN PER BRANCH. 8 threads. +ARENA_EXTENT_PAGES = 256, so D31 predicts ~1024 KiB of DATA file per branch. +Budget: 8 GB. The run REFUSES to exceed it rather than fill the disk. + + N forks/sec data MB alloc MB len B/branch alloc B/branch cat B/branch pages live reopen ms reopen min + 100000 543.9 409.6 420.4 4096 4204 334 100000 0.264 0.263 + 250000 244.2 1024.0 1024.4 4096 4097 334 250000 0.229 0.219 + 500000 303.9 2048.0 2064.6 4096 4129 334 500000 0.224 0.214 + 750000 434.1 3072.0 3088.0 4096 4117 334 750000 0.337 0.311 + 1000000 475.3 4096.0 4111.4 4096 4111 334 1000000 0.281 0.264 + +DID NOT stop early within the budget: N = 1000000, 4096 bytes/branch. + + reference: one page = 4096 B · one full extent = 1048576 B (256x apart) +VERDICT — AMPLIFICATION GONE. 4096 B/branch is within 8x of a +single page, so a branch's first page costs about a page. This is the tree WITH +geometric extent growth (D31) built; without it this number is ~1048576. +# rc=0 +free after: 74Gi load after: 9.73 18.19 26.68 + +=== READING =============================================================================== +[HERE] reduced by this session. rc=0, no early stop, all five checkpoints reached. + +CATALOG REOPEN IS FLAT TO 10^6 BRANCHES, WITH DATA PRESENT. + N 100,000 250,000 500,000 750,000 1,000,000 + min ms 0.263 0.219 0.214 0.311 0.264 + median ms 0.264 0.229 0.224 0.337 0.281 + + 10x more branches, 100,000 -> 1,000,000, costs 0.263 -> 0.264 ms. The column is not merely + sub-linear, it is FLAT: the highest and lowest points differ by 45% with the HIGHEST at 750,000 + and the lowest at 500,000, i.e. NON-MONOTONE, which is the signature of load rather than cost. + O(N) would have put the last point at 2.6 ms; O(log N) would have shown ~1.15x from 100k to 1M. + Neither appears. + +EVERY PRE-REGISTERED QUESTION IN THIS FILE'S HEADER, ANSWERED + (1) "Does the 250k step REPRODUCE?" NO. The 25x span measured 0.325 ms at N=250,000; this run + measures 0.219 at the same size. It was load, exactly as the header said to read a single + high cell. ⇒ the span's one apparent step is withdrawn. + (2) "Does it keep stepping to 10^6?" NO. Flat, and non-monotone. + (3) "reopen is NOT linear" — held, and strengthened from not-linear to flat. + (4) "len B/branch must read 4096" — reads 4096 at ALL FIVE points, agreeing with D61's own + decade. The reconciliation passes; the two runs do not contradict each other. + +ACROSS BOTH RUNS, 100x OF RANGE (10,000 -> 1,000,000): reopen min stays inside 0.200-0.325 ms +with no trend in either direction. + +WHAT THIS SETTLES, with the caveat in the same sentence: S4's O(1)-reopen claim now holds on a +catalog whose branches OWN PAGES, which had never been checked — the only prior 10^6 reopen +measurement (examples/branch_curve.rs) is FORK-ONLY, on branches that never called arena_for or +alloc_in_arena. Reopen being independent of branch count is a SHAPE and it transfers: it says the +catalog's open path reads a root and not a population. It is NOT a BranchBench result and NOT a +comparison against Dolt or Postgres. + +⚠ forks/sec is again a FLOOR and again refutes itself as throughput: 543.9 -> 244.2 -> 303.9 -> +434.1 -> 475.3, falling by half and recovering. That is this shared box's load, not a per-branch +cost. reopen min is the load-robust column BY CONSTRUCTION — contention can only make a reopen +slower, never faster — which is why it is the one quoted. + +⚠ STILL NOT MEASURED: reopen under CONCURRENT load from live sessions, and reopen after an +unclean shutdown (this is a clean reopen of a quiesced catalog). +=========================================================================================== From 524131bdd05cd8c2a83f8fcdf3869cc5e0e93051 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:37:36 -0400 Subject: [PATCH 024/315] bench(D89): OFF reaches 100,000 branches at a flat 4096 B/branch; ON does not get near it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The curve FREE_FLOOR blocked all session, finally run with 81-95 GiB free instead of 19.2. PERSISTENCE OFF, on D61's own harness: len B/branch is FLAT at 4096 across N = 1,000 / 5,000 / 25,000 / 100,000, and forks/sec shows no downward trend. D61's space claim reproduces exactly at this scale. PERSISTENCE ON: it had not reached its second checkpoint by the time the OFF arm finished all four. ⚠ A defect in how I ran it: the ON arm's stdout went through grep, which block-buffers a pipe, so its per-checkpoint rows never flushed before the timeout. The rate is therefore measured by sampling the size of .arena, which is exactly 48 x live branches by D79's law — an independent, unbuffered observation. ~35 branches/s at N=10,000 falling to ~26/s at N=19,000: a 26% fall for a 90% rise in N, against an OFF arm that is flat (168/s at both N=5,000 and N=100,000). That decay is the O(N^2) signature D79 derived from the byte law and D81 declined to credit to bytes at small N — at 10^4-10^5 the bytes do start to dominate, which is where the crossover was predicted and could not previously be measured. No ON row is quoted because none survived the buffering. A re-run must use stdbuf -oL or write straight to a file. --- bench/d89_the_curve_persistence_blocks.txt | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 bench/d89_the_curve_persistence_blocks.txt diff --git a/bench/d89_the_curve_persistence_blocks.txt b/bench/d89_the_curve_persistence_blocks.txt new file mode 100644 index 00000000..74537af5 --- /dev/null +++ b/bench/d89_the_curve_persistence_blocks.txt @@ -0,0 +1,54 @@ +D89 — THE CURVE THE DISK GUARD BLOCKED ALL SESSION. OFF reaches 100,000; ON does not get near it. + +[HERE] `examples/branch_curve_writes.rs` — **the harness that produced D61's published 10^6 curve** +— with the `CURVE_PERSIST` axis. 4 threads, 6 GB budget, back to back on one box. D80 could only +take ONE point per arm because `FREE_FLOOR = 20 GiB` fired at 19.2 GiB free; this ran with 81-95 +GiB free. + +=== PERSISTENCE OFF — the configuration every 10^6 result in this repo was measured in === + + N forks/sec data MB len B/branch alloc B/branch cat B/branch reopen ms + 1000 258.6 4.1 4100 4280 336 0.743 + 5000 168.0 20.5 4097 4212 333 0.321 + 25000 117.7 102.4 4096 4701 334 1.216 + 100000 168.0 409.6 4096 4195 334 0.952 + +⇒ **`len B/branch` is FLAT at 4096 to 100,000 branches**, and forks/sec is flat-to-noisy (258, 168, +118, 168) with no downward trend. D61's SPACE claim reproduces exactly at this scale. + +=== PERSISTENCE ON — what `cli.rs:120` ships ======================================== + +**It did not reach the second checkpoint in the time the OFF arm took to finish all four.** + +⚠ **Its per-checkpoint rows are NOT in this file, and the reason is a defect in how I ran it**: the +arm's stdout went through `grep`, which block-buffers a pipe, so rows printed before the run's +timeout never flushed. The rate below is therefore measured a different way — by sampling the SIZE +of `.arena` on disk, which is exactly `48 x live branches` by D79's measured law, so it is an +independent and unbuffered observation of progress. + + elapsed map bytes => branches rate since previous sample + 4:52 493,913 10,289 — + 5:10 524,153 10,919 35 / s + 6:04 631,241 13,150 ~31 / s + 6:22 657,209 13,691 30 / s + ~9:0x — 16,686 37 / s + ... — 18,069 33 / s + ... — 19,170 26 / s + +⇒ **The rate DECAYS with N: ~35/s at 10,000 branches, ~26/s at 19,000.** A 26% fall for a 90% rise +in N. The OFF arm shows no such decay — 168/s at N=5,000 and 168/s at N=100,000. + +⇒ That is the O(N^2) signature D79 derived from the byte law and D81 refused to credit to bytes at +small N: **at 10^4-10^5 branches the bytes DO start to dominate**, exactly where the crossover was +predicted to be and could not be measured before. + +=== WHAT THIS SETTLES AND WHAT IT DOES NOT ========================================== + +✅ D61's flagship result — 4096 B/branch to 10^6 — is reproduced at 100,000 with persistence OFF. +✅ With persistence ON the same harness cannot approach that N at all: the OFF arm finished four + checkpoints while the ON arm was still short of its second. +⛔ **No ON row is quoted, because none survived the buffering.** The decay is real and measured, but + from file sizes rather than the harness's own instrument. A re-run must pipe through + `stdbuf -oL` or write straight to a file. +⚠ One box, 4 threads, and the OFF arm's forks/sec is itself noisy (258 -> 118 -> 168). The + load-independent facts here are `len B/branch` (flat) and the ON arm's decay direction. From 94bb81f9ba71d0030d4f00ce27b7ba8d16af1582 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:52:14 -0400 Subject: [PATCH 025/315] =?UTF-8?q?bench(D89):=20later=20ON-arm=20samples?= =?UTF-8?q?=20=E2=80=94=20the=20rate=20collapses=20once=20the=20map=20gets?= =?UTF-8?q?=20large?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence-ON arm ran well past where it was expected to stall, so the decay is sampled further out: ~35/s at 10,300 branches, noisy around 26-37/s through 33,000, then a collapse between ~41,000 and ~54,000. That is what a quadratic cost looks like once the image is big: at 54,000 branches the free-space map is ~2.6 MB, re-serialised and re-fsynced on every new branch's first write. The OFF arm passed 100,000 at 168 forks/s; the ON arm took longer to reach 54,000 than OFF took for the whole curve. The last figure (~5/s) is a FLOOR, not a measurement — the two checks were minutes apart and unevenly spaced — and is labelled as such rather than quoted as a data point. Still measured from map file sizes rather than the harness's rows, for the grep-buffering reason already recorded. --- bench/d89_the_curve_persistence_blocks.txt | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bench/d89_the_curve_persistence_blocks.txt b/bench/d89_the_curve_persistence_blocks.txt index 74537af5..407bd7c7 100644 --- a/bench/d89_the_curve_persistence_blocks.txt +++ b/bench/d89_the_curve_persistence_blocks.txt @@ -52,3 +52,27 @@ predicted to be and could not be measured before. `stdbuf -oL` or write straight to a file. ⚠ One box, 4 threads, and the OFF arm's forks/sec is itself noisy (258 -> 118 -> 168). The load-independent facts here are `len B/branch` (flat) and the ON arm's decay direction. + +=== LATER SAMPLES OF THE ON ARM, same method (map size / 48) ========================= + +The run kept going well past where it was expected to stall, so the decay is sampled further out: + + ~10,300 branches ~35 / s + ~19,200 ~26 / s + ~25,000 ~31 / s + ~33,000 ~37 / s + ~41,400 — + ~53,700 — + ~54,300 ~ 5 / s (600 branches between two checks minutes apart) + +⇒ The early samples are noisy either side of ~30/s, and then the rate collapses between ~41,000 and +~54,000. That is the shape a quadratic cost has once the image gets large: 54,000 branches means a +~2.6 MB free-space map re-serialised and re-fsynced on EVERY new branch's first write. + +⚠ **The OFF arm passed 100,000 at 168 forks/s.** The ON arm took longer to reach 54,000 than OFF +took for the entire curve, and by the end was moving roughly 30x slower than OFF's steady rate. + +⚠ Still measured from file sizes, not from the harness's own rows, for the buffering reason above. +The two late samples have no rate attached because the checks were minutes apart and unevenly +spaced — the 5/s figure is a floor, not a measurement, and is labelled so rather than quoted as a +data point. From daea7301955cf870b9aeecfad34b77575baf3f2e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:53:05 -0400 Subject: [PATCH 026/315] =?UTF-8?q?bench(D89):=20the=20ON-arm=20rows=20wer?= =?UTF-8?q?e=20recovered=20=E2=80=94=203-6x=20slower=20at=20every=20N,=20i?= =?UTF-8?q?dentical=20bytes=20per=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopping the run flushed the pipe, so the harness's own numbers survived after all: N forks/sec len B/branch vs OFF 1000 54.7 4100 258.6 -> 4.7x slower 5000 29.2 4097 168.0 -> 5.8x slower 25000 38.2 4096 117.7 -> 3.1x slower len B/branch is IDENTICAL in both arms at every N. Persistence does not move the per-branch space cost by one byte — exactly what D80 found at its single N=500 point, and the half of D61 that survives untouched. What does not survive is anything about time: forks/sec is 3-6x lower at every checkpoint and the ON arm never reached the 100,000 the OFF arm completed, stopping at ~56,115 after 24:41. The ratios are noisy and show no clean trend on their own; the map-size samples are what show the decay. The earlier claim that no ON row could be quoted is corrected in place rather than deleted — the buffering defect was real and nearly cost the data. --- bench/d89_the_curve_persistence_blocks.txt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/bench/d89_the_curve_persistence_blocks.txt b/bench/d89_the_curve_persistence_blocks.txt index 407bd7c7..db229172 100644 --- a/bench/d89_the_curve_persistence_blocks.txt +++ b/bench/d89_the_curve_persistence_blocks.txt @@ -20,7 +20,24 @@ GiB free. **It did not reach the second checkpoint in the time the OFF arm took to finish all four.** -⚠ **Its per-checkpoint rows are NOT in this file, and the reason is a defect in how I ran it**: the +⇒ ✅ **THE ROWS WERE RECOVERED.** Stopping the run flushed the pipe, so the harness's own numbers +did survive after all: + + N forks/sec len B/branch vs OFF at the same N + 1000 54.7 4100 258.6 -> **4.7x slower** + 5000 29.2 4097 168.0 -> **5.8x slower** + 25000 38.2 4096 117.7 -> **3.1x slower** + (reached ~56,115 branches in 24:41 before being stopped to free the box for a verification suite) + +⇒ **`len B/branch` is IDENTICAL in both arms at every N — 4100, 4097, 4096.** Persistence does not +move the per-branch space cost by one byte, which is exactly what D80 found at its single N=500 +point and is the half of D61 that survives untouched. +⇒ **forks/sec is 3-6x lower with persistence on**, at every checkpoint, and the ON arm never +reached the 100,000 the OFF arm completed. +⚠ The ratios are noisy (4.7, 5.8, 3.1) and do not themselves show a clean trend; the map-size +sampling below is what shows the decay. + +⚠ **The original defect, kept because it nearly cost the data**: the arm's stdout went through `grep`, which block-buffers a pipe, so rows printed before the run's timeout never flushed. The rate below is therefore measured a different way — by sampling the SIZE of `.arena` on disk, which is exactly `48 x live branches` by D79's measured law, so it is an From dc75771936232fb6d36cea9328c1161ac4339492 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:38:42 -0400 Subject: [PATCH 027/315] =?UTF-8?q?bench(D87):=20pricing=20the=20fix=20fir?= =?UTF-8?q?st=20=E2=80=94=20removing=20the=20per-claim=20map=20write=20clo?= =?UTF-8?q?ses=20the=20WHOLE=20gap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D87 is a durable format change plus 88 call sites, so the payoff was measured before building. An env-gated probe skips alloc_arena's persist_if_configured — unsafe, never shippable, but an exact upper bound on what D87 can buy. N OFF ON ON with the per-claim persist REMOVED 1000 253.5 ~55 309.1 5000 252.8 ~29-58 284.3 atomic replaces in the probe run: 0 (0 bytes), against 5000 (600 MB) in both other arms. So the per-claim map write is the ENTIRE persistence penalty — with it gone, ON runs at or above OFF. That prices D87 at roughly 5-10x, on top of being a complexity change (D89 measured ON decaying with N where OFF is flat; the decay is this write's O(N^2) bytes). WITHDRAWN on the way: an earlier pass reported ON-with-skip at 60.6/65.1/56.5 and I was one step from concluding "D87 removes the decay but leaves a constant 4x gap". The probe had not been applied at all — the counter caught it, because the supposedly-skipping run showed 5000 replaces, identical to the normal run, when it should have shown zero. A probe that does nothing is indistinguishable from a probe that shows no effect, and the difference was the whole conclusion. The withdrawn figures are kept in the file rather than deleted. --- bench/d87_payoff_probe.txt | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 bench/d87_payoff_probe.txt diff --git a/bench/d87_payoff_probe.txt b/bench/d87_payoff_probe.txt new file mode 100644 index 00000000..d3c13bdd --- /dev/null +++ b/bench/d87_payoff_probe.txt @@ -0,0 +1,37 @@ +D87 — PRICING THE FIX BEFORE BUILDING IT. Removing the per-claim map write closes the WHOLE gap. + +D87 is a multi-hour change: a durable format change plus 88 `.arenas` call sites. So the payoff was +measured first, by an env-gated probe that SKIPS `alloc_arena`'s `persist_if_configured` — unsafe, +and never to be shipped, but an exact upper bound on what D87 can buy. + +[HERE] `examples/branch_curve_writes.rs`, 4 threads, 6 GB budget, all three arms on one box within +minutes of each other. + + N OFF forks/s ON forks/s ON with the per-claim persist REMOVED + 1000 253.5 ~55 **309.1** + 5000 252.8 ~29-58 **284.3** + + atomic replaces during the probe run: **0 (0 bytes)** — against 5000 (600 MB) for both other arms. + +⇒ **The per-claim map write is the ENTIRE persistence penalty.** With it gone, the persistence-ON +configuration runs at or above the OFF configuration. Not a partial win, not a constant shaved: the +gap closes. + +⇒ That prices D87 at roughly **5-10x on this workload**, and it is a complexity change as well — +D89 measured ON decaying with N while OFF stays flat, and the decay is this write's O(N^2) bytes. + +=== ⛔ AND A MEASUREMENT I HAD TO WITHDRAW TO GET HERE =============================== + +An earlier pass reported ON-with-skip at 60.6 / 65.1 / 56.5 and I was one step from concluding +"D87 removes the decay but leaves a constant ~4x gap". **That was wrong, and the probe had not been +applied at all.** The counter is what caught it: the supposedly-skipping run showed **5000 atomic +replaces, identical to the normal run**, when it should have shown zero. + +⇒ ✅ **A PROBE THAT DOES NOTHING IS INDISTINGUISHABLE FROM A PROBE THAT SHOWS NO EFFECT**, and the +difference is the entire conclusion. The probe now carries its own liveness check in a comment, and +this file quotes the replace count beside every number so a future reader can see the probe fired. +The withdrawn figures are left here rather than deleted, because the failure mode is the point. + +⚠ The skip arm is UNSAFE and must never ship: an unpersisted claim lets the next open hand out the +same range (`arena.rs:1011`). D87 reaches the same number legitimately, by making the claim +DERIVABLE from the catalog rather than leaving it unwritten. From 3c2a1329a61d071206b07117eab4fe800cf3e25f Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Mon, 21 Sep 2026 00:45:44 -0400 Subject: [PATCH 028/315] D89 falsification harness and raw output (THROWAWAY, do not merge) Attacks the central measurement of cow::cid's same_data_inserted_in_two_orders_yields_different_partition_cids. No src/ file is touched; no existing assertion is edited. Raw stdout captured in bench/d89_falsify_raw/. --- bench/d89_falsify_raw/attack_a_sweep.txt | 1022 ++++++++++++++++++++ bench/d89_falsify_raw/attacks_bcd.txt | 36 + bench/d89_falsify_raw/attacks_efghijkl.txt | 160 +++ examples/d89_falsify.rs | 820 ++++++++++++++++ examples/d89_falsify2.rs | 859 ++++++++++++++++ 5 files changed, 2897 insertions(+) create mode 100644 bench/d89_falsify_raw/attack_a_sweep.txt create mode 100644 bench/d89_falsify_raw/attacks_bcd.txt create mode 100644 bench/d89_falsify_raw/attacks_efghijkl.txt create mode 100644 examples/d89_falsify.rs create mode 100644 examples/d89_falsify2.rs diff --git a/bench/d89_falsify_raw/attack_a_sweep.txt b/bench/d89_falsify_raw/attack_a_sweep.txt new file mode 100644 index 00000000..a876cd46 --- /dev/null +++ b/bench/d89_falsify_raw/attack_a_sweep.txt @@ -0,0 +1,1022 @@ + +================ ATTACK A: fixture sweep ================ +ascending vs . shared = the test's own statistic. lcs = what a content diff +could really skip. part/cont = partition/content cids equal? + + n vlen order lvsA lvsB shared lcs partEq contEq + 2 6 shuffle-0x5eed1234 1 1 1 1 true true + 2 6 shuffle-seed2 1 1 1 1 true true + 2 6 shuffle-seed3 1 1 1 1 true true + 2 6 descending 1 1 1 1 true true + 2 6 evens-then-odds 1 1 1 1 true true + 2 6 near-1swap 1 1 1 1 true true + 2 6 near-2swaps 1 1 1 1 true true + 2 6 near-5swaps 1 1 1 1 true true + 2 6 near-20swaps 1 1 1 1 true true + 2 6 blocks-rev-8 1 1 1 1 true true + 2 6 blocks-rev-64 1 1 1 1 true true + 3 6 shuffle-0x5eed1234 1 1 1 1 true true + 3 6 shuffle-seed2 1 1 1 1 true true + 3 6 shuffle-seed3 1 1 1 1 true true + 3 6 descending 1 1 1 1 true true + 3 6 evens-then-odds 1 1 1 1 true true + 3 6 near-1swap 1 1 1 1 true true + 3 6 near-2swaps 1 1 1 1 true true + 3 6 near-5swaps 1 1 1 1 true true + 3 6 near-20swaps 1 1 1 1 true true + 3 6 blocks-rev-8 1 1 1 1 true true + 3 6 blocks-rev-64 1 1 1 1 true true + 4 6 shuffle-0x5eed1234 1 1 1 1 true true + 4 6 shuffle-seed2 1 1 1 1 true true + 4 6 shuffle-seed3 1 1 1 1 true true + 4 6 descending 1 1 1 1 true true + 4 6 evens-then-odds 1 1 1 1 true true + 4 6 near-1swap 1 1 1 1 true true + 4 6 near-2swaps 1 1 1 1 true true + 4 6 near-5swaps 1 1 1 1 true true + 4 6 near-20swaps 1 1 1 1 true true + 4 6 blocks-rev-8 1 1 1 1 true true + 4 6 blocks-rev-64 1 1 1 1 true true + 5 6 shuffle-0x5eed1234 1 1 1 1 true true + 5 6 shuffle-seed2 1 1 1 1 true true + 5 6 shuffle-seed3 1 1 1 1 true true + 5 6 descending 1 1 1 1 true true + 5 6 evens-then-odds 1 1 1 1 true true + 5 6 near-1swap 1 1 1 1 true true + 5 6 near-2swaps 1 1 1 1 true true + 5 6 near-5swaps 1 1 1 1 true true + 5 6 near-20swaps 1 1 1 1 true true + 5 6 blocks-rev-8 1 1 1 1 true true + 5 6 blocks-rev-64 1 1 1 1 true true + 8 6 shuffle-0x5eed1234 1 1 1 1 true true + 8 6 shuffle-seed2 1 1 1 1 true true + 8 6 shuffle-seed3 1 1 1 1 true true + 8 6 descending 1 1 1 1 true true + 8 6 evens-then-odds 1 1 1 1 true true + 8 6 near-1swap 1 1 1 1 true true + 8 6 near-2swaps 1 1 1 1 true true + 8 6 near-5swaps 1 1 1 1 true true + 8 6 near-20swaps 1 1 1 1 true true + 8 6 blocks-rev-8 1 1 1 1 true true + 8 6 blocks-rev-64 1 1 1 1 true true + 12 6 shuffle-0x5eed1234 1 1 1 1 true true + 12 6 shuffle-seed2 1 1 1 1 true true + 12 6 shuffle-seed3 1 1 1 1 true true + 12 6 descending 1 1 1 1 true true + 12 6 evens-then-odds 1 1 1 1 true true + 12 6 near-1swap 1 1 1 1 true true + 12 6 near-2swaps 1 1 1 1 true true + 12 6 near-5swaps 1 1 1 1 true true + 12 6 near-20swaps 1 1 1 1 true true + 12 6 blocks-rev-8 1 1 1 1 true true + 12 6 blocks-rev-64 1 1 1 1 true true + 16 6 shuffle-0x5eed1234 1 1 1 1 true true + 16 6 shuffle-seed2 1 1 1 1 true true + 16 6 shuffle-seed3 1 1 1 1 true true + 16 6 descending 1 1 1 1 true true + 16 6 evens-then-odds 1 1 1 1 true true + 16 6 near-1swap 1 1 1 1 true true + 16 6 near-2swaps 1 1 1 1 true true + 16 6 near-5swaps 1 1 1 1 true true + 16 6 near-20swaps 1 1 1 1 true true + 16 6 blocks-rev-8 1 1 1 1 true true + 16 6 blocks-rev-64 1 1 1 1 true true + 24 6 shuffle-0x5eed1234 1 1 1 1 true true + 24 6 shuffle-seed2 1 1 1 1 true true + 24 6 shuffle-seed3 1 1 1 1 true true + 24 6 descending 1 1 1 1 true true + 24 6 evens-then-odds 1 1 1 1 true true + 24 6 near-1swap 1 1 1 1 true true + 24 6 near-2swaps 1 1 1 1 true true + 24 6 near-5swaps 1 1 1 1 true true + 24 6 near-20swaps 1 1 1 1 true true + 24 6 blocks-rev-8 1 1 1 1 true true + 24 6 blocks-rev-64 1 1 1 1 true true + 32 6 shuffle-0x5eed1234 1 1 1 1 true true + 32 6 shuffle-seed2 1 1 1 1 true true + 32 6 shuffle-seed3 1 1 1 1 true true + 32 6 descending 1 1 1 1 true true + 32 6 evens-then-odds 1 1 1 1 true true + 32 6 near-1swap 1 1 1 1 true true + 32 6 near-2swaps 1 1 1 1 true true + 32 6 near-5swaps 1 1 1 1 true true + 32 6 near-20swaps 1 1 1 1 true true + 32 6 blocks-rev-8 1 1 1 1 true true + 32 6 blocks-rev-64 1 1 1 1 true true + 48 6 shuffle-0x5eed1234 1 1 1 1 true true + 48 6 shuffle-seed2 1 1 1 1 true true + 48 6 shuffle-seed3 1 1 1 1 true true + 48 6 descending 1 1 1 1 true true + 48 6 evens-then-odds 1 1 1 1 true true + 48 6 near-1swap 1 1 1 1 true true + 48 6 near-2swaps 1 1 1 1 true true + 48 6 near-5swaps 1 1 1 1 true true + 48 6 near-20swaps 1 1 1 1 true true + 48 6 blocks-rev-8 1 1 1 1 true true + 48 6 blocks-rev-64 1 1 1 1 true true + 64 6 shuffle-0x5eed1234 1 1 1 1 true true + 64 6 shuffle-seed2 1 1 1 1 true true + 64 6 shuffle-seed3 1 1 1 1 true true + 64 6 descending 1 1 1 1 true true + 64 6 evens-then-odds 1 1 1 1 true true + 64 6 near-1swap 1 1 1 1 true true + 64 6 near-2swaps 1 1 1 1 true true + 64 6 near-5swaps 1 1 1 1 true true + 64 6 near-20swaps 1 1 1 1 true true + 64 6 blocks-rev-8 1 1 1 1 true true + 64 6 blocks-rev-64 1 1 1 1 true true + 100 6 shuffle-0x5eed1234 1 1 1 1 true true + 100 6 shuffle-seed2 1 1 1 1 true true + 100 6 shuffle-seed3 1 1 1 1 true true + 100 6 descending 1 1 1 1 true true + 100 6 evens-then-odds 1 1 1 1 true true + 100 6 near-1swap 1 1 1 1 true true + 100 6 near-2swaps 1 1 1 1 true true + 100 6 near-5swaps 1 1 1 1 true true + 100 6 near-20swaps 1 1 1 1 true true + 100 6 blocks-rev-8 1 1 1 1 true true + 100 6 blocks-rev-64 1 1 1 1 true true + 128 6 shuffle-0x5eed1234 1 1 1 1 true true + 128 6 shuffle-seed2 1 1 1 1 true true + 128 6 shuffle-seed3 1 1 1 1 true true + 128 6 descending 1 1 1 1 true true + 128 6 evens-then-odds 1 1 1 1 true true + 128 6 near-1swap 1 1 1 1 true true + 128 6 near-2swaps 1 1 1 1 true true + 128 6 near-5swaps 1 1 1 1 true true + 128 6 near-20swaps 1 1 1 1 true true + 128 6 blocks-rev-8 1 1 1 1 true true + 128 6 blocks-rev-64 1 1 1 1 true true + 200 6 shuffle-0x5eed1234 1 1 1 1 true true + 200 6 shuffle-seed2 1 1 1 1 true true + 200 6 shuffle-seed3 1 1 1 1 true true + 200 6 descending 1 1 1 1 true true + 200 6 evens-then-odds 1 1 1 1 true true + 200 6 near-1swap 1 1 1 1 true true + 200 6 near-2swaps 1 1 1 1 true true + 200 6 near-5swaps 1 1 1 1 true true + 200 6 near-20swaps 1 1 1 1 true true + 200 6 blocks-rev-8 1 1 1 1 true true + 200 6 blocks-rev-64 1 1 1 1 true true + 256 6 shuffle-0x5eed1234 2 2 0 0 false true + 256 6 shuffle-seed2 2 2 0 0 false true + 256 6 shuffle-seed3 2 2 0 0 false true + 256 6 descending 2 2 0 0 false true + 256 6 evens-then-odds 2 2 2 2 true true + 256 6 near-1swap 2 2 2 2 true true + 256 6 near-2swaps 2 2 2 2 true true + 256 6 near-5swaps 2 2 2 2 true true + 256 6 near-20swaps 2 2 2 2 true true + 256 6 blocks-rev-8 2 2 0 0 false true + 256 6 blocks-rev-64 2 2 0 0 false true + 500 6 shuffle-0x5eed1234 4 4 0 0 false true + 500 6 shuffle-seed2 4 4 0 0 false true + 500 6 shuffle-seed3 4 4 0 0 false true + 500 6 descending 4 4 0 0 false true + 500 6 evens-then-odds 4 4 4 4 true true + 500 6 near-1swap 4 4 4 4 true true + 500 6 near-2swaps 4 4 4 4 true true + 500 6 near-5swaps 4 4 4 4 true true + 500 6 near-20swaps 4 4 4 4 true true + 500 6 blocks-rev-8 4 4 0 0 false true + 500 6 blocks-rev-64 4 4 0 0 false true + 1000 6 shuffle-0x5eed1234 9 8 0 0 false true + 1000 6 shuffle-seed2 9 8 0 0 false true + 1000 6 shuffle-seed3 9 8 0 0 false true + 1000 6 descending 9 9 0 0 false true + 1000 6 evens-then-odds 9 9 6 6 false true + 1000 6 near-1swap 9 9 9 9 true true + 1000 6 near-2swaps 9 9 9 9 true true + 1000 6 near-5swaps 9 9 9 9 true true + 1000 6 near-20swaps 9 9 9 9 true true + 1000 6 blocks-rev-8 9 9 0 0 false true + 1000 6 blocks-rev-64 9 9 0 0 false true + 2000 6 shuffle-0x5eed1234 20 16 0 0 false true + 2000 6 shuffle-seed2 20 16 0 0 false true + 2000 6 shuffle-seed3 20 16 0 0 false true + 2000 6 descending 20 20 0 0 false true + 2000 6 evens-then-odds 20 20 9 9 false true + 2000 6 near-1swap 20 20 20 20 true true + 2000 6 near-2swaps 20 20 20 20 true true + 2000 6 near-5swaps 20 20 20 20 true true + 2000 6 near-20swaps 20 20 20 20 true true + 2000 6 blocks-rev-8 20 20 0 0 false true + 2000 6 blocks-rev-64 20 20 0 0 false true + 5000 6 shuffle-0x5eed1234 50 35 0 0 false true + 5000 6 shuffle-seed2 50 34 0 0 false true + 5000 6 shuffle-seed3 50 33 0 0 false true + 5000 6 descending 50 51 1 1 false true + 5000 6 evens-then-odds 50 50 9 9 false true + 5000 6 near-1swap 50 50 50 50 true true + 5000 6 near-2swaps 50 50 50 50 true true + 5000 6 near-5swaps 50 50 50 50 true true + 5000 6 near-20swaps 50 50 50 50 true true + 5000 6 blocks-rev-8 50 51 1 1 false true + 5000 6 blocks-rev-64 50 51 0 0 false true + 2 32 shuffle-0x5eed1234 1 1 1 1 true true + 2 32 shuffle-seed2 1 1 1 1 true true + 2 32 shuffle-seed3 1 1 1 1 true true + 2 32 descending 1 1 1 1 true true + 2 32 evens-then-odds 1 1 1 1 true true + 2 32 near-1swap 1 1 1 1 true true + 2 32 near-2swaps 1 1 1 1 true true + 2 32 near-5swaps 1 1 1 1 true true + 2 32 near-20swaps 1 1 1 1 true true + 2 32 blocks-rev-8 1 1 1 1 true true + 2 32 blocks-rev-64 1 1 1 1 true true + 3 32 shuffle-0x5eed1234 1 1 1 1 true true + 3 32 shuffle-seed2 1 1 1 1 true true + 3 32 shuffle-seed3 1 1 1 1 true true + 3 32 descending 1 1 1 1 true true + 3 32 evens-then-odds 1 1 1 1 true true + 3 32 near-1swap 1 1 1 1 true true + 3 32 near-2swaps 1 1 1 1 true true + 3 32 near-5swaps 1 1 1 1 true true + 3 32 near-20swaps 1 1 1 1 true true + 3 32 blocks-rev-8 1 1 1 1 true true + 3 32 blocks-rev-64 1 1 1 1 true true + 4 32 shuffle-0x5eed1234 1 1 1 1 true true + 4 32 shuffle-seed2 1 1 1 1 true true + 4 32 shuffle-seed3 1 1 1 1 true true + 4 32 descending 1 1 1 1 true true + 4 32 evens-then-odds 1 1 1 1 true true + 4 32 near-1swap 1 1 1 1 true true + 4 32 near-2swaps 1 1 1 1 true true + 4 32 near-5swaps 1 1 1 1 true true + 4 32 near-20swaps 1 1 1 1 true true + 4 32 blocks-rev-8 1 1 1 1 true true + 4 32 blocks-rev-64 1 1 1 1 true true + 5 32 shuffle-0x5eed1234 1 1 1 1 true true + 5 32 shuffle-seed2 1 1 1 1 true true + 5 32 shuffle-seed3 1 1 1 1 true true + 5 32 descending 1 1 1 1 true true + 5 32 evens-then-odds 1 1 1 1 true true + 5 32 near-1swap 1 1 1 1 true true + 5 32 near-2swaps 1 1 1 1 true true + 5 32 near-5swaps 1 1 1 1 true true + 5 32 near-20swaps 1 1 1 1 true true + 5 32 blocks-rev-8 1 1 1 1 true true + 5 32 blocks-rev-64 1 1 1 1 true true + 8 32 shuffle-0x5eed1234 1 1 1 1 true true + 8 32 shuffle-seed2 1 1 1 1 true true + 8 32 shuffle-seed3 1 1 1 1 true true + 8 32 descending 1 1 1 1 true true + 8 32 evens-then-odds 1 1 1 1 true true + 8 32 near-1swap 1 1 1 1 true true + 8 32 near-2swaps 1 1 1 1 true true + 8 32 near-5swaps 1 1 1 1 true true + 8 32 near-20swaps 1 1 1 1 true true + 8 32 blocks-rev-8 1 1 1 1 true true + 8 32 blocks-rev-64 1 1 1 1 true true + 12 32 shuffle-0x5eed1234 1 1 1 1 true true + 12 32 shuffle-seed2 1 1 1 1 true true + 12 32 shuffle-seed3 1 1 1 1 true true + 12 32 descending 1 1 1 1 true true + 12 32 evens-then-odds 1 1 1 1 true true + 12 32 near-1swap 1 1 1 1 true true + 12 32 near-2swaps 1 1 1 1 true true + 12 32 near-5swaps 1 1 1 1 true true + 12 32 near-20swaps 1 1 1 1 true true + 12 32 blocks-rev-8 1 1 1 1 true true + 12 32 blocks-rev-64 1 1 1 1 true true + 16 32 shuffle-0x5eed1234 1 1 1 1 true true + 16 32 shuffle-seed2 1 1 1 1 true true + 16 32 shuffle-seed3 1 1 1 1 true true + 16 32 descending 1 1 1 1 true true + 16 32 evens-then-odds 1 1 1 1 true true + 16 32 near-1swap 1 1 1 1 true true + 16 32 near-2swaps 1 1 1 1 true true + 16 32 near-5swaps 1 1 1 1 true true + 16 32 near-20swaps 1 1 1 1 true true + 16 32 blocks-rev-8 1 1 1 1 true true + 16 32 blocks-rev-64 1 1 1 1 true true + 24 32 shuffle-0x5eed1234 1 1 1 1 true true + 24 32 shuffle-seed2 1 1 1 1 true true + 24 32 shuffle-seed3 1 1 1 1 true true + 24 32 descending 1 1 1 1 true true + 24 32 evens-then-odds 1 1 1 1 true true + 24 32 near-1swap 1 1 1 1 true true + 24 32 near-2swaps 1 1 1 1 true true + 24 32 near-5swaps 1 1 1 1 true true + 24 32 near-20swaps 1 1 1 1 true true + 24 32 blocks-rev-8 1 1 1 1 true true + 24 32 blocks-rev-64 1 1 1 1 true true + 32 32 shuffle-0x5eed1234 1 1 1 1 true true + 32 32 shuffle-seed2 1 1 1 1 true true + 32 32 shuffle-seed3 1 1 1 1 true true + 32 32 descending 1 1 1 1 true true + 32 32 evens-then-odds 1 1 1 1 true true + 32 32 near-1swap 1 1 1 1 true true + 32 32 near-2swaps 1 1 1 1 true true + 32 32 near-5swaps 1 1 1 1 true true + 32 32 near-20swaps 1 1 1 1 true true + 32 32 blocks-rev-8 1 1 1 1 true true + 32 32 blocks-rev-64 1 1 1 1 true true + 48 32 shuffle-0x5eed1234 1 1 1 1 true true + 48 32 shuffle-seed2 1 1 1 1 true true + 48 32 shuffle-seed3 1 1 1 1 true true + 48 32 descending 1 1 1 1 true true + 48 32 evens-then-odds 1 1 1 1 true true + 48 32 near-1swap 1 1 1 1 true true + 48 32 near-2swaps 1 1 1 1 true true + 48 32 near-5swaps 1 1 1 1 true true + 48 32 near-20swaps 1 1 1 1 true true + 48 32 blocks-rev-8 1 1 1 1 true true + 48 32 blocks-rev-64 1 1 1 1 true true + 64 32 shuffle-0x5eed1234 1 1 1 1 true true + 64 32 shuffle-seed2 1 1 1 1 true true + 64 32 shuffle-seed3 1 1 1 1 true true + 64 32 descending 1 1 1 1 true true + 64 32 evens-then-odds 1 1 1 1 true true + 64 32 near-1swap 1 1 1 1 true true + 64 32 near-2swaps 1 1 1 1 true true + 64 32 near-5swaps 1 1 1 1 true true + 64 32 near-20swaps 1 1 1 1 true true + 64 32 blocks-rev-8 1 1 1 1 true true + 64 32 blocks-rev-64 1 1 1 1 true true + 100 32 shuffle-0x5eed1234 2 2 0 0 false true + 100 32 shuffle-seed2 2 2 0 0 false true + 100 32 shuffle-seed3 2 2 0 0 false true + 100 32 descending 2 2 0 0 false true + 100 32 evens-then-odds 2 2 2 2 true true + 100 32 near-1swap 2 2 2 2 true true + 100 32 near-2swaps 2 2 2 2 true true + 100 32 near-5swaps 2 2 2 2 true true + 100 32 near-20swaps 2 2 2 2 true true + 100 32 blocks-rev-8 2 2 0 0 false true + 100 32 blocks-rev-64 2 2 2 2 true true + 128 32 shuffle-0x5eed1234 3 2 0 0 false true + 128 32 shuffle-seed2 3 2 0 0 false true + 128 32 shuffle-seed3 3 2 0 0 false true + 128 32 descending 3 3 1 1 false true + 128 32 evens-then-odds 3 2 0 0 false true + 128 32 near-1swap 3 3 3 3 true true + 128 32 near-2swaps 3 3 3 3 true true + 128 32 near-5swaps 3 3 3 3 true true + 128 32 near-20swaps 3 3 3 3 true true + 128 32 blocks-rev-8 3 3 1 1 false true + 128 32 blocks-rev-64 3 3 3 3 true true + 200 32 shuffle-0x5eed1234 4 4 0 0 false true + 200 32 shuffle-seed2 4 4 0 0 false true + 200 32 shuffle-seed3 4 4 0 0 false true + 200 32 descending 4 4 0 0 false true + 200 32 evens-then-odds 4 4 4 4 true true + 200 32 near-1swap 4 4 4 4 true true + 200 32 near-2swaps 4 4 4 4 true true + 200 32 near-5swaps 4 4 4 4 true true + 200 32 near-20swaps 4 4 4 4 true true + 200 32 blocks-rev-8 4 4 0 0 false true + 200 32 blocks-rev-64 4 4 0 0 false true + 256 32 shuffle-0x5eed1234 5 4 0 0 false true + 256 32 shuffle-seed2 5 4 0 0 false true + 256 32 shuffle-seed3 5 4 0 0 false true + 256 32 descending 5 6 0 0 false true + 256 32 evens-then-odds 5 5 5 5 true true + 256 32 near-1swap 5 5 5 5 true true + 256 32 near-2swaps 5 5 5 5 true true + 256 32 near-5swaps 5 5 5 5 true true + 256 32 near-20swaps 5 5 5 5 true true + 256 32 blocks-rev-8 5 6 0 0 false true + 256 32 blocks-rev-64 5 6 1 1 false true + 500 32 shuffle-0x5eed1234 11 9 0 0 false true + 500 32 shuffle-seed2 11 9 0 0 false true + 500 32 shuffle-seed3 11 8 0 0 false true + 500 32 descending 11 11 0 0 false true + 500 32 evens-then-odds 11 10 8 8 false true + 500 32 near-1swap 11 11 11 11 true true + 500 32 near-2swaps 11 11 11 11 true true + 500 32 near-5swaps 11 11 11 11 true true + 500 32 near-20swaps 11 11 11 11 true true + 500 32 blocks-rev-8 11 11 0 0 false true + 500 32 blocks-rev-64 11 11 0 0 false true + 1000 32 shuffle-0x5eed1234 23 18 0 0 false true + 1000 32 shuffle-seed2 23 17 0 0 false true + 1000 32 shuffle-seed3 23 16 0 0 false true + 1000 32 descending 23 23 0 0 false true + 1000 32 evens-then-odds 23 22 20 20 false true + 1000 32 near-1swap 23 23 23 23 true true + 1000 32 near-2swaps 23 23 23 23 true true + 1000 32 near-5swaps 23 23 23 23 true true + 1000 32 near-20swaps 23 23 23 23 true true + 1000 32 blocks-rev-8 23 23 0 0 false true + 1000 32 blocks-rev-64 23 23 1 1 false true + 2000 32 shuffle-0x5eed1234 46 31 0 0 false true + 2000 32 shuffle-seed2 46 34 0 0 false true + 2000 32 shuffle-seed3 46 33 0 0 false true + 2000 32 descending 46 47 0 0 false true + 2000 32 evens-then-odds 46 46 46 46 true true + 2000 32 near-1swap 46 46 46 46 true true + 2000 32 near-2swaps 46 46 46 46 true true + 2000 32 near-5swaps 46 46 46 46 true true + 2000 32 near-20swaps 46 46 46 46 true true + 2000 32 blocks-rev-8 46 47 0 0 false true + 2000 32 blocks-rev-64 46 46 0 0 false true + 5000 32 shuffle-0x5eed1234 116 81 0 0 false true + 5000 32 shuffle-seed2 116 85 0 0 false true + 5000 32 shuffle-seed3 116 87 0 0 false true + 5000 32 descending 116 119 0 0 false true + 5000 32 evens-then-odds 116 116 116 116 true true + 5000 32 near-1swap 116 116 116 116 true true + 5000 32 near-2swaps 116 116 116 116 true true + 5000 32 near-5swaps 116 116 116 116 true true + 5000 32 near-20swaps 116 116 116 116 true true + 5000 32 blocks-rev-8 116 119 0 0 false true + 5000 32 blocks-rev-64 116 117 1 1 false true + 2 200 shuffle-0x5eed1234 1 1 1 1 true true + 2 200 shuffle-seed2 1 1 1 1 true true + 2 200 shuffle-seed3 1 1 1 1 true true + 2 200 descending 1 1 1 1 true true + 2 200 evens-then-odds 1 1 1 1 true true + 2 200 near-1swap 1 1 1 1 true true + 2 200 near-2swaps 1 1 1 1 true true + 2 200 near-5swaps 1 1 1 1 true true + 2 200 near-20swaps 1 1 1 1 true true + 2 200 blocks-rev-8 1 1 1 1 true true + 2 200 blocks-rev-64 1 1 1 1 true true + 3 200 shuffle-0x5eed1234 1 1 1 1 true true + 3 200 shuffle-seed2 1 1 1 1 true true + 3 200 shuffle-seed3 1 1 1 1 true true + 3 200 descending 1 1 1 1 true true + 3 200 evens-then-odds 1 1 1 1 true true + 3 200 near-1swap 1 1 1 1 true true + 3 200 near-2swaps 1 1 1 1 true true + 3 200 near-5swaps 1 1 1 1 true true + 3 200 near-20swaps 1 1 1 1 true true + 3 200 blocks-rev-8 1 1 1 1 true true + 3 200 blocks-rev-64 1 1 1 1 true true + 4 200 shuffle-0x5eed1234 1 1 1 1 true true + 4 200 shuffle-seed2 1 1 1 1 true true + 4 200 shuffle-seed3 1 1 1 1 true true + 4 200 descending 1 1 1 1 true true + 4 200 evens-then-odds 1 1 1 1 true true + 4 200 near-1swap 1 1 1 1 true true + 4 200 near-2swaps 1 1 1 1 true true + 4 200 near-5swaps 1 1 1 1 true true + 4 200 near-20swaps 1 1 1 1 true true + 4 200 blocks-rev-8 1 1 1 1 true true + 4 200 blocks-rev-64 1 1 1 1 true true + 5 200 shuffle-0x5eed1234 1 1 1 1 true true + 5 200 shuffle-seed2 1 1 1 1 true true + 5 200 shuffle-seed3 1 1 1 1 true true + 5 200 descending 1 1 1 1 true true + 5 200 evens-then-odds 1 1 1 1 true true + 5 200 near-1swap 1 1 1 1 true true + 5 200 near-2swaps 1 1 1 1 true true + 5 200 near-5swaps 1 1 1 1 true true + 5 200 near-20swaps 1 1 1 1 true true + 5 200 blocks-rev-8 1 1 1 1 true true + 5 200 blocks-rev-64 1 1 1 1 true true + 8 200 shuffle-0x5eed1234 1 1 1 1 true true + 8 200 shuffle-seed2 1 1 1 1 true true + 8 200 shuffle-seed3 1 1 1 1 true true + 8 200 descending 1 1 1 1 true true + 8 200 evens-then-odds 1 1 1 1 true true + 8 200 near-1swap 1 1 1 1 true true + 8 200 near-2swaps 1 1 1 1 true true + 8 200 near-5swaps 1 1 1 1 true true + 8 200 near-20swaps 1 1 1 1 true true + 8 200 blocks-rev-8 1 1 1 1 true true + 8 200 blocks-rev-64 1 1 1 1 true true + 12 200 shuffle-0x5eed1234 1 1 1 1 true true + 12 200 shuffle-seed2 1 1 1 1 true true + 12 200 shuffle-seed3 1 1 1 1 true true + 12 200 descending 1 1 1 1 true true + 12 200 evens-then-odds 1 1 1 1 true true + 12 200 near-1swap 1 1 1 1 true true + 12 200 near-2swaps 1 1 1 1 true true + 12 200 near-5swaps 1 1 1 1 true true + 12 200 near-20swaps 1 1 1 1 true true + 12 200 blocks-rev-8 1 1 1 1 true true + 12 200 blocks-rev-64 1 1 1 1 true true + 16 200 shuffle-0x5eed1234 1 1 1 1 true true + 16 200 shuffle-seed2 1 1 1 1 true true + 16 200 shuffle-seed3 1 1 1 1 true true + 16 200 descending 1 1 1 1 true true + 16 200 evens-then-odds 1 1 1 1 true true + 16 200 near-1swap 1 1 1 1 true true + 16 200 near-2swaps 1 1 1 1 true true + 16 200 near-5swaps 1 1 1 1 true true + 16 200 near-20swaps 1 1 1 1 true true + 16 200 blocks-rev-8 1 1 1 1 true true + 16 200 blocks-rev-64 1 1 1 1 true true + 24 200 shuffle-0x5eed1234 2 2 0 0 false true + 24 200 shuffle-seed2 2 2 0 0 false true + 24 200 shuffle-seed3 2 2 0 0 false true + 24 200 descending 2 2 0 0 false true + 24 200 evens-then-odds 2 2 2 2 true true + 24 200 near-1swap 2 2 2 2 true true + 24 200 near-2swaps 2 2 2 2 true true + 24 200 near-5swaps 2 2 2 2 true true + 24 200 near-20swaps 2 2 2 2 true true + 24 200 blocks-rev-8 2 2 0 0 false true + 24 200 blocks-rev-64 2 2 2 2 true true + 32 200 shuffle-0x5eed1234 3 2 0 0 false true + 32 200 shuffle-seed2 3 2 0 0 false true + 32 200 shuffle-seed3 3 2 0 0 false true + 32 200 descending 3 3 0 0 false true + 32 200 evens-then-odds 3 2 0 0 false true + 32 200 near-1swap 3 3 3 3 true true + 32 200 near-2swaps 3 3 3 3 true true + 32 200 near-5swaps 3 3 3 3 true true + 32 200 near-20swaps 3 3 3 3 true true + 32 200 blocks-rev-8 3 3 0 0 false true + 32 200 blocks-rev-64 3 3 3 3 true true + 48 200 shuffle-0x5eed1234 4 3 1 1 false true + 48 200 shuffle-seed2 4 4 0 0 false true + 48 200 shuffle-seed3 4 4 0 0 false true + 48 200 descending 4 5 0 0 false true + 48 200 evens-then-odds 4 4 4 4 true true + 48 200 near-1swap 4 4 4 4 true true + 48 200 near-2swaps 4 4 4 4 true true + 48 200 near-5swaps 4 4 4 4 true true + 48 200 near-20swaps 4 4 4 4 true true + 48 200 blocks-rev-8 4 5 0 0 false true + 48 200 blocks-rev-64 4 4 4 4 true true + 64 200 shuffle-0x5eed1234 6 5 0 0 false true + 64 200 shuffle-seed2 6 5 1 1 false true + 64 200 shuffle-seed3 6 4 1 1 false true + 64 200 descending 6 7 1 1 false true + 64 200 evens-then-odds 6 6 6 6 true true + 64 200 near-1swap 6 6 6 6 true true + 64 200 near-2swaps 6 6 6 6 true true + 64 200 near-5swaps 6 6 6 6 true true + 64 200 near-20swaps 6 6 6 6 true true + 64 200 blocks-rev-8 6 7 1 1 false true + 64 200 blocks-rev-64 6 6 6 6 true true + 100 200 shuffle-0x5eed1234 10 8 0 0 false true + 100 200 shuffle-seed2 10 8 0 0 false true + 100 200 shuffle-seed3 10 7 0 0 false true + 100 200 descending 10 11 1 1 false true + 100 200 evens-then-odds 10 10 10 10 true true + 100 200 near-1swap 10 10 10 10 true true + 100 200 near-2swaps 10 10 10 10 true true + 100 200 near-5swaps 10 10 10 10 true true + 100 200 near-20swaps 10 10 10 10 true true + 100 200 blocks-rev-8 10 11 1 1 false true + 100 200 blocks-rev-64 10 9 5 5 false true + 128 200 shuffle-0x5eed1234 12 11 0 0 false true + 128 200 shuffle-seed2 12 10 0 0 false true + 128 200 shuffle-seed3 12 12 0 0 false true + 128 200 descending 12 14 0 0 false true + 128 200 evens-then-odds 12 12 12 12 true true + 128 200 near-1swap 12 12 12 12 true true + 128 200 near-2swaps 12 12 12 12 true true + 128 200 near-5swaps 12 12 12 12 true true + 128 200 near-20swaps 12 12 12 12 true true + 128 200 blocks-rev-8 12 14 0 0 false true + 128 200 blocks-rev-64 12 12 5 5 false true + 200 200 shuffle-0x5eed1234 20 16 0 0 false true + 200 200 shuffle-seed2 20 16 1 1 false true + 200 200 shuffle-seed3 20 17 2 2 false true + 200 200 descending 20 22 0 0 false true + 200 200 evens-then-odds 20 20 20 20 true true + 200 200 near-1swap 20 20 20 20 true true + 200 200 near-2swaps 20 20 20 20 true true + 200 200 near-5swaps 20 20 20 20 true true + 200 200 near-20swaps 20 20 20 20 true true + 200 200 blocks-rev-8 20 22 0 0 false true + 200 200 blocks-rev-64 20 19 5 5 false true + 256 200 shuffle-0x5eed1234 25 20 0 0 false true + 256 200 shuffle-seed2 25 21 0 0 false true + 256 200 shuffle-seed3 25 21 1 1 false true + 256 200 descending 25 28 0 0 false true + 256 200 evens-then-odds 25 24 22 22 false true + 256 200 near-1swap 25 25 25 25 true true + 256 200 near-2swaps 25 25 25 25 true true + 256 200 near-5swaps 25 25 25 25 true true + 256 200 near-20swaps 25 25 25 25 true true + 256 200 blocks-rev-8 25 28 0 0 false true + 256 200 blocks-rev-64 25 24 5 5 false true + 500 200 shuffle-0x5eed1234 50 40 0 0 false true + 500 200 shuffle-seed2 50 40 1 1 false true + 500 200 shuffle-seed3 50 37 1 1 false true + 500 200 descending 50 55 0 0 false true + 500 200 evens-then-odds 50 50 50 50 true true + 500 200 near-1swap 50 50 50 50 true true + 500 200 near-2swaps 50 50 50 50 true true + 500 200 near-5swaps 50 50 50 50 true true + 500 200 near-20swaps 50 50 50 50 true true + 500 200 blocks-rev-8 50 55 0 0 false true + 500 200 blocks-rev-64 50 47 9 9 false true + 1000 200 shuffle-0x5eed1234 100 82 2 2 false true + 1000 200 shuffle-seed2 100 76 0 0 false true + 1000 200 shuffle-seed3 100 81 3 3 false true + 1000 200 descending 100 111 1 1 false true + 1000 200 evens-then-odds 100 100 100 100 true true + 1000 200 near-1swap 100 100 100 100 true true + 1000 200 near-2swaps 100 100 100 100 true true + 1000 200 near-5swaps 100 100 100 100 true true + 1000 200 near-20swaps 100 100 100 100 true true + 1000 200 blocks-rev-8 100 111 1 1 false true + 1000 200 blocks-rev-64 100 94 16 16 false true + 2000 200 shuffle-0x5eed1234 200 149 1 1 false true + 2000 200 shuffle-seed2 200 159 2 2 false true + 2000 200 shuffle-seed3 200 156 3 3 false true + 2000 200 descending 200 222 0 0 false true + 2000 200 evens-then-odds 200 200 200 200 true true + 2000 200 near-1swap 200 200 200 200 true true + 2000 200 near-2swaps 200 200 200 200 true true + 2000 200 near-5swaps 200 200 200 200 true true + 2000 200 near-20swaps 200 200 200 200 true true + 2000 200 blocks-rev-8 200 222 0 0 false true + 2000 200 blocks-rev-64 200 188 30 30 false true + 5000 200 shuffle-0x5eed1234 500 394 9 9 false true + 5000 200 shuffle-seed2 500 399 3 3 false true + 5000 200 shuffle-seed3 500 388 3 3 false true + 5000 200 descending 500 555 0 0 false true + 5000 200 evens-then-odds 500 500 500 500 true true + 5000 200 near-1swap 500 500 500 500 true true + 5000 200 near-2swaps 500 500 500 500 true true + 5000 200 near-5swaps 500 500 500 500 true true + 5000 200 near-20swaps 500 500 500 500 true true + 5000 200 blocks-rev-8 500 555 0 0 false true + 5000 200 blocks-rev-64 500 469 65 65 false true + 2 512 shuffle-0x5eed1234 1 1 1 1 true true + 2 512 shuffle-seed2 1 1 1 1 true true + 2 512 shuffle-seed3 1 1 1 1 true true + 2 512 descending 1 1 1 1 true true + 2 512 evens-then-odds 1 1 1 1 true true + 2 512 near-1swap 1 1 1 1 true true + 2 512 near-2swaps 1 1 1 1 true true + 2 512 near-5swaps 1 1 1 1 true true + 2 512 near-20swaps 1 1 1 1 true true + 2 512 blocks-rev-8 1 1 1 1 true true + 2 512 blocks-rev-64 1 1 1 1 true true + 3 512 shuffle-0x5eed1234 1 1 1 1 true true + 3 512 shuffle-seed2 1 1 1 1 true true + 3 512 shuffle-seed3 1 1 1 1 true true + 3 512 descending 1 1 1 1 true true + 3 512 evens-then-odds 1 1 1 1 true true + 3 512 near-1swap 1 1 1 1 true true + 3 512 near-2swaps 1 1 1 1 true true + 3 512 near-5swaps 1 1 1 1 true true + 3 512 near-20swaps 1 1 1 1 true true + 3 512 blocks-rev-8 1 1 1 1 true true + 3 512 blocks-rev-64 1 1 1 1 true true + 4 512 shuffle-0x5eed1234 1 1 1 1 true true + 4 512 shuffle-seed2 1 1 1 1 true true + 4 512 shuffle-seed3 1 1 1 1 true true + 4 512 descending 1 1 1 1 true true + 4 512 evens-then-odds 1 1 1 1 true true + 4 512 near-1swap 1 1 1 1 true true + 4 512 near-2swaps 1 1 1 1 true true + 4 512 near-5swaps 1 1 1 1 true true + 4 512 near-20swaps 1 1 1 1 true true + 4 512 blocks-rev-8 1 1 1 1 true true + 4 512 blocks-rev-64 1 1 1 1 true true + 5 512 shuffle-0x5eed1234 1 1 1 1 true true + 5 512 shuffle-seed2 1 1 1 1 true true + 5 512 shuffle-seed3 1 1 1 1 true true + 5 512 descending 1 1 1 1 true true + 5 512 evens-then-odds 1 1 1 1 true true + 5 512 near-1swap 1 1 1 1 true true + 5 512 near-2swaps 1 1 1 1 true true + 5 512 near-5swaps 1 1 1 1 true true + 5 512 near-20swaps 1 1 1 1 true true + 5 512 blocks-rev-8 1 1 1 1 true true + 5 512 blocks-rev-64 1 1 1 1 true true + 8 512 shuffle-0x5eed1234 2 2 2 2 true true + 8 512 shuffle-seed2 2 2 2 2 true true + 8 512 shuffle-seed3 2 2 2 2 true true + 8 512 descending 2 2 2 2 true true + 8 512 evens-then-odds 2 2 2 2 true true + 8 512 near-1swap 2 2 2 2 true true + 8 512 near-2swaps 2 2 2 2 true true + 8 512 near-5swaps 2 2 2 2 true true + 8 512 near-20swaps 2 2 2 2 true true + 8 512 blocks-rev-8 2 2 2 2 true true + 8 512 blocks-rev-64 2 2 2 2 true true + 12 512 shuffle-0x5eed1234 3 2 0 0 false true + 12 512 shuffle-seed2 3 2 0 0 false true + 12 512 shuffle-seed3 3 2 0 0 false true + 12 512 descending 3 3 3 3 true true + 12 512 evens-then-odds 3 3 3 3 true true + 12 512 near-1swap 3 3 3 3 true true + 12 512 near-2swaps 3 3 3 3 true true + 12 512 near-5swaps 3 3 3 3 true true + 12 512 near-20swaps 3 3 3 3 true true + 12 512 blocks-rev-8 3 3 3 3 true true + 12 512 blocks-rev-64 3 3 3 3 true true + 16 512 shuffle-0x5eed1234 4 3 1 1 false true + 16 512 shuffle-seed2 4 3 0 0 false true + 16 512 shuffle-seed3 4 4 4 4 true true + 16 512 descending 4 4 4 4 true true + 16 512 evens-then-odds 4 4 4 4 true true + 16 512 near-1swap 4 4 4 4 true true + 16 512 near-2swaps 4 4 4 4 true true + 16 512 near-5swaps 4 4 4 4 true true + 16 512 near-20swaps 4 4 4 4 true true + 16 512 blocks-rev-8 4 4 4 4 true true + 16 512 blocks-rev-64 4 4 4 4 true true + 24 512 shuffle-0x5eed1234 6 5 1 1 false true + 24 512 shuffle-seed2 6 4 0 0 false true + 24 512 shuffle-seed3 6 4 1 1 false true + 24 512 descending 6 6 6 6 true true + 24 512 evens-then-odds 6 6 6 6 true true + 24 512 near-1swap 6 6 6 6 true true + 24 512 near-2swaps 6 6 6 6 true true + 24 512 near-5swaps 6 6 6 6 true true + 24 512 near-20swaps 6 6 6 6 true true + 24 512 blocks-rev-8 6 6 6 6 true true + 24 512 blocks-rev-64 6 6 6 6 true true + 32 512 shuffle-0x5eed1234 8 6 0 0 false true + 32 512 shuffle-seed2 8 7 3 3 false true + 32 512 shuffle-seed3 8 6 0 0 false true + 32 512 descending 8 8 8 8 true true + 32 512 evens-then-odds 8 8 8 8 true true + 32 512 near-1swap 8 8 8 8 true true + 32 512 near-2swaps 8 8 8 8 true true + 32 512 near-5swaps 8 8 8 8 true true + 32 512 near-20swaps 8 8 8 8 true true + 32 512 blocks-rev-8 8 8 8 8 true true + 32 512 blocks-rev-64 8 8 8 8 true true + 48 512 shuffle-0x5eed1234 12 10 2 2 false true + 48 512 shuffle-seed2 12 9 1 1 false true + 48 512 shuffle-seed3 12 11 7 7 false true + 48 512 descending 12 12 12 12 true true + 48 512 evens-then-odds 12 12 12 12 true true + 48 512 near-1swap 12 12 12 12 true true + 48 512 near-2swaps 12 12 12 12 true true + 48 512 near-5swaps 12 12 12 12 true true + 48 512 near-20swaps 12 12 12 12 true true + 48 512 blocks-rev-8 12 12 12 12 true true + 48 512 blocks-rev-64 12 12 12 12 true true + 64 512 shuffle-0x5eed1234 16 11 0 0 false true + 64 512 shuffle-seed2 16 13 0 0 false true + 64 512 shuffle-seed3 16 13 1 1 false true + 64 512 descending 16 16 16 16 true true + 64 512 evens-then-odds 16 16 16 16 true true + 64 512 near-1swap 16 16 16 16 true true + 64 512 near-2swaps 16 16 16 16 true true + 64 512 near-5swaps 16 16 16 16 true true + 64 512 near-20swaps 16 16 16 16 true true + 64 512 blocks-rev-8 16 16 16 16 true true + 64 512 blocks-rev-64 16 16 16 16 true true + 100 512 shuffle-0x5eed1234 25 20 2 2 false true + 100 512 shuffle-seed2 25 17 0 0 false true + 100 512 shuffle-seed3 25 19 1 1 false true + 100 512 descending 25 25 25 25 true true + 100 512 evens-then-odds 25 25 25 25 true true + 100 512 near-1swap 25 25 25 25 true true + 100 512 near-2swaps 25 25 25 25 true true + 100 512 near-5swaps 25 25 25 25 true true + 100 512 near-20swaps 25 25 25 25 true true + 100 512 blocks-rev-8 25 25 25 25 true true + 100 512 blocks-rev-64 25 25 25 25 true true + 128 512 shuffle-0x5eed1234 32 24 4 4 false true + 128 512 shuffle-seed2 32 26 6 6 false true + 128 512 shuffle-seed3 32 23 2 2 false true + 128 512 descending 32 32 32 32 true true + 128 512 evens-then-odds 32 32 32 32 true true + 128 512 near-1swap 32 32 32 32 true true + 128 512 near-2swaps 32 32 32 32 true true + 128 512 near-5swaps 32 32 32 32 true true + 128 512 near-20swaps 32 32 32 32 true true + 128 512 blocks-rev-8 32 32 32 32 true true + 128 512 blocks-rev-64 32 32 32 32 true true + 200 512 shuffle-0x5eed1234 50 40 3 3 false true + 200 512 shuffle-seed2 50 39 2 2 false true + 200 512 shuffle-seed3 50 42 9 9 false true + 200 512 descending 50 50 50 50 true true + 200 512 evens-then-odds 50 50 50 50 true true + 200 512 near-1swap 50 50 50 50 true true + 200 512 near-2swaps 50 50 50 50 true true + 200 512 near-5swaps 50 50 50 50 true true + 200 512 near-20swaps 50 50 50 50 true true + 200 512 blocks-rev-8 50 50 50 50 true true + 200 512 blocks-rev-64 50 50 50 50 true true + 256 512 shuffle-0x5eed1234 64 51 7 7 false true + 256 512 shuffle-seed2 64 54 5 5 false true + 256 512 shuffle-seed3 64 50 9 9 false true + 256 512 descending 64 64 64 64 true true + 256 512 evens-then-odds 64 64 64 64 true true + 256 512 near-1swap 64 64 64 64 true true + 256 512 near-2swaps 64 64 64 64 true true + 256 512 near-5swaps 64 64 64 64 true true + 256 512 near-20swaps 64 64 64 64 true true + 256 512 blocks-rev-8 64 64 64 64 true true + 256 512 blocks-rev-64 64 64 64 64 true true + 500 512 shuffle-0x5eed1234 125 98 3 3 false true + 500 512 shuffle-seed2 125 100 9 9 false true + 500 512 shuffle-seed3 125 94 15 15 false true + 500 512 descending 125 125 125 125 true true + 500 512 evens-then-odds 125 125 125 125 true true + 500 512 near-1swap 125 125 125 125 true true + 500 512 near-2swaps 125 125 125 125 true true + 500 512 near-5swaps 125 125 125 125 true true + 500 512 near-20swaps 125 125 125 125 true true + 500 512 blocks-rev-8 125 125 125 125 true true + 500 512 blocks-rev-64 125 125 125 125 true true + 1000 512 shuffle-0x5eed1234 250 196 23 23 false true + 1000 512 shuffle-seed2 250 201 18 18 false true + 1000 512 shuffle-seed3 250 198 31 31 false true + 1000 512 descending 250 250 250 250 true true + 1000 512 evens-then-odds 250 250 250 250 true true + 1000 512 near-1swap 250 250 250 250 true true + 1000 512 near-2swaps 250 250 250 250 true true + 1000 512 near-5swaps 250 250 250 250 true true + 1000 512 near-20swaps 250 250 250 250 true true + 1000 512 blocks-rev-8 250 250 250 250 true true + 1000 512 blocks-rev-64 250 250 250 250 true true + 2000 512 shuffle-0x5eed1234 500 394 46 46 false true + 2000 512 shuffle-seed2 500 394 32 32 false true + 2000 512 shuffle-seed3 500 394 57 57 false true + 2000 512 descending 500 500 500 500 true true + 2000 512 evens-then-odds 500 500 500 500 true true + 2000 512 near-1swap 500 500 500 500 true true + 2000 512 near-2swaps 500 500 500 500 true true + 2000 512 near-5swaps 500 500 500 500 true true + 2000 512 near-20swaps 500 500 500 500 true true + 2000 512 blocks-rev-8 500 500 500 500 true true + 2000 512 blocks-rev-64 500 500 500 500 true true + 5000 512 shuffle-0x5eed1234 1250 980 91 91 false true + 5000 512 shuffle-seed2 1250 983 96 96 false true + 5000 512 shuffle-seed3 1250 987 117 117 false true + 5000 512 descending 1250 1250 1250 1250 true true + 5000 512 evens-then-odds 1250 1250 1250 1250 true true + 5000 512 near-1swap 1250 1250 1250 1250 true true + 5000 512 near-2swaps 1250 1250 1250 1250 true true + 5000 512 near-5swaps 1250 1250 1250 1250 true true + 5000 512 near-20swaps 1250 1250 1250 1250 true true + 5000 512 blocks-rev-8 1250 1250 1250 1250 true true + 5000 512 blocks-rev-64 1250 1250 1250 1250 true true + 2 990 shuffle-0x5eed1234 1 1 1 1 true true + 2 990 shuffle-seed2 1 1 1 1 true true + 2 990 shuffle-seed3 1 1 1 1 true true + 2 990 descending 1 1 1 1 true true + 2 990 evens-then-odds 1 1 1 1 true true + 2 990 near-1swap 1 1 1 1 true true + 2 990 near-2swaps 1 1 1 1 true true + 2 990 near-5swaps 1 1 1 1 true true + 2 990 near-20swaps 1 1 1 1 true true + 2 990 blocks-rev-8 1 1 1 1 true true + 2 990 blocks-rev-64 1 1 1 1 true true + 3 990 shuffle-0x5eed1234 1 1 1 1 true true + 3 990 shuffle-seed2 1 1 1 1 true true + 3 990 shuffle-seed3 1 1 1 1 true true + 3 990 descending 1 1 1 1 true true + 3 990 evens-then-odds 1 1 1 1 true true + 3 990 near-1swap 1 1 1 1 true true + 3 990 near-2swaps 1 1 1 1 true true + 3 990 near-5swaps 1 1 1 1 true true + 3 990 near-20swaps 1 1 1 1 true true + 3 990 blocks-rev-8 1 1 1 1 true true + 3 990 blocks-rev-64 1 1 1 1 true true + 4 990 shuffle-0x5eed1234 1 1 1 1 true true + 4 990 shuffle-seed2 1 1 1 1 true true + 4 990 shuffle-seed3 1 1 1 1 true true + 4 990 descending 1 1 1 1 true true + 4 990 evens-then-odds 1 1 1 1 true true + 4 990 near-1swap 1 1 1 1 true true + 4 990 near-2swaps 1 1 1 1 true true + 4 990 near-5swaps 1 1 1 1 true true + 4 990 near-20swaps 1 1 1 1 true true + 4 990 blocks-rev-8 1 1 1 1 true true + 4 990 blocks-rev-64 1 1 1 1 true true + 5 990 shuffle-0x5eed1234 2 2 2 2 true true + 5 990 shuffle-seed2 2 2 2 2 true true + 5 990 shuffle-seed3 2 2 2 2 true true + 5 990 descending 2 2 2 2 true true + 5 990 evens-then-odds 2 2 2 2 true true + 5 990 near-1swap 2 2 2 2 true true + 5 990 near-2swaps 2 2 2 2 true true + 5 990 near-5swaps 2 2 2 2 true true + 5 990 near-20swaps 2 2 2 2 true true + 5 990 blocks-rev-8 2 2 2 2 true true + 5 990 blocks-rev-64 2 2 2 2 true true + 8 990 shuffle-0x5eed1234 3 3 1 1 false true + 8 990 shuffle-seed2 3 3 1 1 false true + 8 990 shuffle-seed3 3 3 3 3 true true + 8 990 descending 3 3 1 1 false true + 8 990 evens-then-odds 3 2 0 0 false true + 8 990 near-1swap 3 3 3 3 true true + 8 990 near-2swaps 3 3 3 3 true true + 8 990 near-5swaps 3 3 3 3 true true + 8 990 near-20swaps 3 3 3 3 true true + 8 990 blocks-rev-8 3 3 3 3 true true + 8 990 blocks-rev-64 3 3 3 3 true true + 12 990 shuffle-0x5eed1234 4 4 2 2 false true + 12 990 shuffle-seed2 4 4 2 2 false true + 12 990 shuffle-seed3 4 4 1 1 false true + 12 990 descending 4 5 0 0 false true + 12 990 evens-then-odds 4 4 4 4 true true + 12 990 near-1swap 4 4 4 4 true true + 12 990 near-2swaps 4 4 4 4 true true + 12 990 near-5swaps 4 4 4 4 true true + 12 990 near-20swaps 4 4 4 4 true true + 12 990 blocks-rev-8 4 5 2 2 false true + 12 990 blocks-rev-64 4 4 4 4 true true + 16 990 shuffle-0x5eed1234 5 5 2 2 false true + 16 990 shuffle-seed2 5 6 1 1 false true + 16 990 shuffle-seed3 5 5 5 5 true true + 16 990 descending 5 7 0 0 false true + 16 990 evens-then-odds 5 5 5 5 true true + 16 990 near-1swap 5 5 5 5 true true + 16 990 near-2swaps 5 5 5 5 true true + 16 990 near-5swaps 5 5 5 5 true true + 16 990 near-20swaps 5 5 0 0 false true + 16 990 blocks-rev-8 5 6 3 3 false true + 16 990 blocks-rev-64 5 5 5 5 true true + 24 990 shuffle-0x5eed1234 8 9 1 1 false true + 24 990 shuffle-seed2 8 8 1 1 false true + 24 990 shuffle-seed3 8 8 1 1 false true + 24 990 descending 8 11 0 0 false true + 24 990 evens-then-odds 8 8 8 8 true true + 24 990 near-1swap 8 8 8 8 true true + 24 990 near-2swaps 8 8 8 8 true true + 24 990 near-5swaps 8 8 8 8 true true + 24 990 near-20swaps 8 8 8 8 true true + 24 990 blocks-rev-8 8 9 3 3 false true + 24 990 blocks-rev-64 8 8 8 8 true true + 32 990 shuffle-0x5eed1234 11 10 2 2 false true + 32 990 shuffle-seed2 11 10 1 1 false true + 32 990 shuffle-seed3 11 11 4 4 false true + 32 990 descending 11 15 1 1 false true + 32 990 evens-then-odds 11 10 8 8 false true + 32 990 near-1swap 11 11 11 11 true true + 32 990 near-2swaps 11 11 11 11 true true + 32 990 near-5swaps 11 11 11 11 true true + 32 990 near-20swaps 11 10 6 6 false true + 32 990 blocks-rev-8 11 12 5 5 false true + 32 990 blocks-rev-64 11 11 11 11 true true + 48 990 shuffle-0x5eed1234 16 16 0 0 false true + 48 990 shuffle-seed2 16 17 1 1 false true + 48 990 shuffle-seed3 16 17 2 2 false true + 48 990 descending 16 23 0 0 false true + 48 990 evens-then-odds 16 16 16 16 true true + 48 990 near-1swap 16 16 16 16 true true + 48 990 near-2swaps 16 16 16 16 true true + 48 990 near-5swaps 16 16 16 16 true true + 48 990 near-20swaps 16 16 5 5 false true + 48 990 blocks-rev-8 16 18 5 5 false true + 48 990 blocks-rev-64 16 16 16 16 true true + 64 990 shuffle-0x5eed1234 21 23 5 5 false true + 64 990 shuffle-seed2 21 23 5 5 false true + 64 990 shuffle-seed3 21 20 6 6 false true + 64 990 descending 21 31 0 0 false true + 64 990 evens-then-odds 21 21 21 21 true true + 64 990 near-1swap 21 21 21 21 true true + 64 990 near-2swaps 21 21 21 21 true true + 64 990 near-5swaps 21 21 21 21 true true + 64 990 near-20swaps 21 21 9 9 false true + 64 990 blocks-rev-8 21 24 7 7 false true + 64 990 blocks-rev-64 21 21 21 21 true true + 100 990 shuffle-0x5eed1234 33 36 6 6 false true + 100 990 shuffle-seed2 33 32 5 5 false true + 100 990 shuffle-seed3 33 30 4 4 false true + 100 990 descending 33 49 0 0 false true + 100 990 evens-then-odds 33 33 33 33 true true + 100 990 near-1swap 33 33 33 33 true true + 100 990 near-2swaps 33 33 33 33 true true + 100 990 near-5swaps 33 33 33 33 true true + 100 990 near-20swaps 33 33 33 33 true true + 100 990 blocks-rev-8 33 38 9 9 false true + 100 990 blocks-rev-64 33 34 21 21 false true + 128 990 shuffle-0x5eed1234 43 46 10 10 false true + 128 990 shuffle-seed2 43 45 10 10 false true + 128 990 shuffle-seed3 43 43 7 7 false true + 128 990 descending 43 63 1 1 false true + 128 990 evens-then-odds 43 42 40 40 false true + 128 990 near-1swap 43 43 43 43 true true + 128 990 near-2swaps 43 43 43 43 true true + 128 990 near-5swaps 43 43 43 43 true true + 128 990 near-20swaps 43 42 26 26 false true + 128 990 blocks-rev-8 43 48 13 13 false true + 128 990 blocks-rev-64 43 43 21 21 false true + 200 990 shuffle-0x5eed1234 67 71 13 13 false true + 200 990 shuffle-seed2 67 67 6 6 false true + 200 990 shuffle-seed3 67 66 15 15 false true + 200 990 descending 67 99 1 1 false true + 200 990 evens-then-odds 67 66 64 64 false true + 200 990 near-1swap 67 67 67 67 true true + 200 990 near-2swaps 67 67 67 67 true true + 200 990 near-5swaps 67 67 67 67 true true + 200 990 near-20swaps 67 67 67 67 true true + 200 990 blocks-rev-8 67 75 19 19 false true + 200 990 blocks-rev-64 67 69 23 23 false true + 256 990 shuffle-0x5eed1234 85 90 15 15 false true + 256 990 shuffle-seed2 85 86 15 15 false true + 256 990 shuffle-seed3 85 86 5 5 false true + 256 990 descending 85 127 0 0 false true + 256 990 evens-then-odds 85 85 85 85 true true + 256 990 near-1swap 85 85 85 85 true true + 256 990 near-2swaps 85 85 85 85 true true + 256 990 near-5swaps 85 85 85 85 true true + 256 990 near-20swaps 85 85 85 85 true true + 256 990 blocks-rev-8 85 96 23 23 false true + 256 990 blocks-rev-64 85 87 41 41 false true + 500 990 shuffle-0x5eed1234 167 177 16 16 false true + 500 990 shuffle-seed2 167 173 20 20 false true + 500 990 shuffle-seed3 167 171 18 18 false true + 500 990 descending 167 249 1 1 false true + 500 990 evens-then-odds 167 166 164 164 false true + 500 990 near-1swap 167 167 167 167 true true + 500 990 near-2swaps 167 167 167 167 true true + 500 990 near-5swaps 167 167 167 167 true true + 500 990 near-20swaps 167 167 167 167 true true + 500 990 blocks-rev-8 167 188 44 44 false true + 500 990 blocks-rev-64 167 171 61 61 false true + 1000 990 shuffle-0x5eed1234 333 342 45 45 false true + 1000 990 shuffle-seed2 333 339 55 55 false true + 1000 990 shuffle-seed3 333 341 48 48 false true + 1000 990 descending 333 499 0 0 false true diff --git a/bench/d89_falsify_raw/attacks_bcd.txt b/bench/d89_falsify_raw/attacks_bcd.txt new file mode 100644 index 00000000..357693a9 --- /dev/null +++ b/bench/d89_falsify_raw/attacks_bcd.txt @@ -0,0 +1,36 @@ + +================ ATTACK B: force the control to fire ================ + [ok ] B1 1000-key tree, one value +1 byte equal=false (wanted false) 41cfb97ddfba11f00c343ec1bb232fe4 / 1861c65cabadc4edfafc4c9738b039e9 + [ok ] B2 1000 keys vs 999 keys equal=false (wanted false) 41cfb97ddfba11f00c343ec1bb232fe4 / 3d4e5cd2137b10df15b6acaed5b55487 + [ok ] B3 tree{(ab,c)} vs tree{(a,bc)} (same byte stream) equal=false (wanted false) 2272d427ee1f5a951ebd0216e4396fc8 / 9537d65d2c33612b668fa263076def4c + [ok ] B4 values swapped between two keys equal=false (wanted false) f4280cfe2733100ea88e6dcc85fce647 / 3f93ddc0c15c6174b05fff9f17b7e305 + [ok ] B5 empty value vs one zero byte equal=false (wanted false) ed89cc3a5fd02af39a215c0e8bdb402b / c2ca17c334d832bc250deace9ee6dbad + [ok ] B6a empty tree vs empty tree (must AGREE) equal=true (wanted true) 5ab9110cf4476b6cec6398e1ae470b3f / 5ab9110cf4476b6cec6398e1ae470b3f + [ok ] B6b empty tree vs one-entry tree equal=false (wanted false) 5ab9110cf4476b6cec6398e1ae470b3f / ed89cc3a5fd02af39a215c0e8bdb402b + [ok ] B7 8 different key sets -> 8 distinct content cids (wanted 8) + [ok ] B8 3000 random content sets -> 3000 distinct content cids, 0 colliding buckets + B verdict: 0 failures + +================ ATTACK C: physical independence ================ + reference: root=1026 pages 1024..1033 (10 pages) part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + [ok ] C1 same store, second build root=1036 pages 1034..1043 disjoint-from-ref=true part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + [ok ] C2 different file, arena base 65536 root=65538 pages 65536..65545 disjoint-from-ref=true part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + [ok ] C3 forked branch, epoch+50, own arena root=1026 pages 1024..1033 disjoint-from-ref=false part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + [ok ] C4 300 heap pages allocated first root=1326 pages 1324..1333 disjoint-from-ref=true part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + [ok ] C5 1000 insert+delete churn cycles leaves=9 contentEq=true partitionEq=true + [ok ] C6 one leaf, 20 entries, 7 insert orders all leaf_cids equal=true cid=2be6a1f528c4d80b48e440abb47b1827 + C verdict: 0 failures + +================ ATTACK D: two different trees, one partition cid ================ + donor tree has 5 leaf pages + shape A (1 internal, 3 children) : part=c43e68dee8bef6fb7eb6974ad94ec6d7 sub=06a8c6bfb645ce4f35db2dad888cfad3 + shape B (2 levels, other seps) : part=c43e68dee8bef6fb7eb6974ad94ec6d7 sub=d55c394b0af45b049fb2c9b714ed30a2 + -> partition cids equal: true content cids equal: true subtree cids equal: false + + D2 birthday over 2097152 distinct leaves: + full 128-bit collisions : 0 + first-32-bit collisions : 534 (random expectation ~512) + first-48-bit collisions : 0 (random expectation ~0.0) + D3 2^22 chosen 8-byte keys, looking for a 64-bit-prefix collision: none (birthday expectation at 2^22 is ~2^-20) + D4 leaf order swapped in the fold: equal=false + D4 count 1 vs count 2 over the same first cid: equal=false diff --git a/bench/d89_falsify_raw/attacks_efghijkl.txt b/bench/d89_falsify_raw/attacks_efghijkl.txt new file mode 100644 index 00000000..f74e1879 --- /dev/null +++ b/bench/d89_falsify_raw/attacks_efghijkl.txt @@ -0,0 +1,160 @@ + +================ ATTACK L: hash-free confirmation of the sharing findings ================ + n=1000 vlen=6 near-20swaps order differs from ascending: true rows identical: true leaves 9/9 byte-identical leaves: 9 + n=1000 vlen=6 full shuffle order differs from ascending: true rows identical: true leaves 9/8 byte-identical leaves: 0 + n=1000 vlen=6 evens-then-odds order differs from ascending: true rows identical: true leaves 9/9 byte-identical leaves: 6 + n=1000 vlen=6 descending order differs from ascending: true rows identical: true leaves 9/9 byte-identical leaves: 0 + n=1000 vlen=200 evens-then-odds order differs from ascending: true rows identical: true leaves 100/100 byte-identical leaves: 100 + n=2000 vlen=200 evens-then-odds order differs from ascending: true rows identical: true leaves 200/200 byte-identical leaves: 200 + +================ ATTACK K: 990-byte values (4 entries per leaf max) ================ + n order lvsA lvsB shared lcs partEq + 4 full shuffle 1 1 1 1 true + 4 descending 1 1 1 1 true + 4 evens then odds 1 1 1 1 true + 8 full shuffle 3 3 1 1 false + 8 descending 3 3 1 1 false + 8 evens then odds 3 2 0 0 false + 16 full shuffle 5 5 2 2 false + 16 descending 5 7 0 0 false + 16 evens then odds 5 5 5 5 true + 32 full shuffle 11 10 2 2 false + 32 descending 11 15 1 1 false + 32 evens then odds 11 10 8 8 false + 64 full shuffle 21 23 5 5 false + 64 descending 21 31 0 0 false + 64 evens then odds 21 21 21 21 true + 128 full shuffle 43 46 10 10 false + 128 descending 43 63 1 1 false + 128 evens then odds 43 42 40 40 false + 256 full shuffle 85 90 15 15 false + 256 descending 85 127 0 0 false + 256 evens then odds 85 85 85 85 true + 500 full shuffle 167 177 16 16 false + 500 descending 167 249 1 1 false + 500 evens then odds 167 166 164 164 false + 1000 full shuffle 333 342 45 45 false + 1000 descending 333 499 0 0 false + 1000 evens then odds 333 333 333 333 true + 2000 full shuffle 667 686 129 129 false + 2000 descending 667 999 1 1 false + 2000 evens then odds 667 666 664 664 false + +================ ATTACK J: same partition cid, two valid trees ================ + both shapes probed on all 312 keys: get() disagreements = 0, wrong/absent values = 0 + shape A height 2 (root -> 3 leaves), shape B height 3 (root -> inner -> leaves) + leaf_partition_cid A=c43e68dee8bef6fb7eb6974ad94ec6d7 B=c43e68dee8bef6fb7eb6974ad94ec6d7 equal=true + leaf_content_cid A=d7b1c105fbd6cf972b344a7079d0c08c B=d7b1c105fbd6cf972b344a7079d0c08c equal=true + subtree_cid A=903bcc9ba5e969b501d3a073ca392352 B=ecc0737314252562d204f40cedd731b5 equal=false + walk_pages A=4 pages B=5 pages + +================ ATTACK I: force the detectors, drop the hash from the control ================ + I1 collision counter: 0 over 1000 distinct inputs, 1 after planting one duplicate -> detector FIRES + I2 same search machinery on a 24-bit prefix: collision at inputs 2313 / 6003 -> search FIRES + I3 hash-free control: ascending tree has 1000 rows, shuffled tree has 1000 rows, byte-identical sequences: true + (leaf_content_cid said equal: true) + same comparison against a 999-row tree: false -> control FIRES + I4 hash-free partition: 9 leaves vs 8 leaves, leaves with byte-identical entry lists: 0 + leaf sizes ascending: [108, 102, 102, 102, 102, 102, 102, 102, 178] + leaf sizes shuffled : [141, 137, 145, 140, 114, 112, 107, 104] + same comparison of the ascending tree against a second ascending build: 9 of 9 -> detector FIRES + +================ ATTACK E2: locate the threshold ================ + ascending inserts: the first leaf split happens on insert #209 (20.9% of 1000) + + head-shuffled p%% (shuffle the first p%% of the insert order, rest ascending): + p% leaves shared partEq + 15 9 9 true + 18 9 9 true + 20 9 9 true + 21 9 0 false + 22 9 0 false + 23 9 0 false + 25 9 0 false + 30 9 0 false + 35 8 0 false + 40 9 0 false + + bounded displacement: every key within d positions of sorted, n=1000: + d leaves shared lcs partEq + 1 9 0 0 false + 2 9 9 9 true + 4 9 9 9 true + 8 9 9 9 true + 16 9 9 9 true + 32 9 4 4 false + 64 9 0 0 false + 96 9 0 0 false + 128 8 0 0 false + 192 7 0 0 false + 256 7 0 0 false + 384 7 0 0 false + 512 6 0 0 false + 1000 8 0 0 false + +================ ATTACK H: is 0-of-9/8 the seed, or the family? ================ + H1 ascending vs 300 full shuffles (n=1000): + shuffled leaf-count distribution: [(7, 3), (8, 297)] + max shared over 300 seeds = 0, max lcs = 0 + seeds whose partition cid equals ascending's = 0 of 300 + seeds whose CONTENT cid differs (control) = 0 of 300 (must be 0) + H2 shuffle-vs-shuffle, 60 pairs (neither side sorted): max shared = 1, lcs = 1, of ~7 leaves + H3 ascending vs ascending-with-ONE-key-moved-to-the-end: + key 0 last: leaves 9/9 shared=0 lcs=0 partEq=false contEq=true + key 1 last: leaves 9/9 shared=0 lcs=0 partEq=false contEq=true + key 137 last: leaves 9/9 shared=1 lcs=1 partEq=false contEq=true + key 500 last: leaves 9/9 shared=4 lcs=4 partEq=false contEq=true + key 998 last: leaves 9/9 shared=9 lcs=9 partEq=true contEq=true + key 999 last: leaves 9/9 shared=9 lcs=9 partEq=true contEq=true + +================ ATTACK E: how much disorder does "0 shared" need? ================ +n=1000, values `v{n}` -- the test's OWN fixture. Only the insert order varies. + + ascending: 9 leaves part=c861c8777435729cecf24521cabccff5 sub=580b659cb8a1f5b8963aa148b1d6c7ab + + order leaves shared lcs partEq subEq contEq + adjacent-transpositions=0 9 9 9 true true true <- order IS ascending (vacuous) + adjacent-transpositions=1 9 9 9 true true true + adjacent-transpositions=2 9 9 9 true true true + adjacent-transpositions=5 9 9 9 true true true + adjacent-transpositions=10 9 9 9 true true true + adjacent-transpositions=25 9 9 9 true true true + adjacent-transpositions=50 9 9 9 true true true + adjacent-transpositions=100 9 9 9 true true true + adjacent-transpositions=200 9 9 9 true true true + adjacent-transpositions=400 9 9 9 true true true + adjacent-transpositions=800 9 9 9 true true true +adjacent-transpositions=1600 9 9 9 true true true +adjacent-transpositions=3200 9 9 9 true true true +adjacent-transpositions=6400 9 9 9 true true true +adjacent-transpositions=12800 9 9 9 true true true +adjacent-transpositions=25600 9 9 9 true true true + tail-shuffled=1% 9 9 9 true true true + tail-shuffled=2% 9 9 9 true true true + tail-shuffled=5% 9 9 9 true true true + tail-shuffled=10% 9 9 9 true true true + tail-shuffled=20% 9 9 9 true true true + tail-shuffled=40% 9 0 0 false false true + tail-shuffled=60% 8 0 0 false false true + tail-shuffled=80% 7 0 0 false false true + tail-shuffled=100% 8 0 0 false false true + full shuffle (the test's) 8 0 0 false false true + +================ ATTACK F: the #[ignore]d convergence test, re-run per order ================ +Those four assertions are the stated acceptance criterion for content-defined chunking. +Which of them already hold TODAY, with the byte-balanced split still in place? + + full shuffle (the test's own) content=true leafseq=false partition=false subtree=false -> ignored test would fail + 20 adjacent transpositions content=true leafseq=true partition=true subtree=true -> ignored test would PASS + 1 adjacent transposition content=true leafseq=true partition=true subtree=true -> ignored test would PASS + 400 adjacent transpositions content=true leafseq=true partition=true subtree=true -> ignored test would PASS + evens ascending then odds content=true leafseq=false partition=false subtree=false -> ignored test would fail + descending content=true leafseq=false partition=false subtree=false -> ignored test would fail + +================ ATTACK G: prove the physical fixtures are not vacuous ================ + G1 one leaf, 20 entries, ascending vs descending insert: + raw page bytes differing: 167 of 4096 leaf_cid equal: true (2be6a1f528c4d80b48e440abb47b1827) + G2 keys 0..20 by insert-only vs insert-0..60-then-delete-20..60: + raw page bytes differing: 328 of 4096 is a leaf: true leaf_cid equal to G1's: true + G3 keys 0..20 clean vs after 20 insert+delete churn cycles: + raw page bytes differing: 390 of 4096 leaf_cid equal: true diff --git a/examples/d89_falsify.rs b/examples/d89_falsify.rs new file mode 100644 index 00000000..332f0d30 --- /dev/null +++ b/examples/d89_falsify.rs @@ -0,0 +1,820 @@ +//! THROWAWAY falsification harness for D89 (`src/cow/cid.rs`). +//! +//! Not part of the suite, not a deliverable. It attacks the central measurement of +//! `cow::cid::tests::same_data_inserted_in_two_orders_yields_different_partition_cids`: +//! +//! 1000 identical pairs inserted ascending vs shuffled -> equal leaf_content_cid, +//! different leaf_partition_cid, ZERO leaves with matching cids. +//! +//! Four attacks: A fixture sweep, B control forcing, C physical independence, +//! D collision hunting. + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, Epoch, LeaseDeadline, PageId}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::cid::{ + hex, leaf_cid, leaf_content_cid, leaf_partition_cid, ordered_leaf_cids, subtree_cid, Cid, + Hasher128, +}; +use ferrodb::cow::node::NodeMut; +use ferrodb::cow::page_header::{stamp_checksum, PageType}; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; +use std::collections::HashMap; +use std::sync::Arc; + +// ---- fixture ----------------------------------------------------------------------------------- + +struct Fx { + _dir: tempfile::TempDir, + cat: Arc, + t: CowTree, +} + +fn fixture(arena_base: u32) -> Fx { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("cid.db")) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let cat = Arc::new(LogBranchCatalog::in_memory(1)); + let store = Arc::new( + ArenaPageStore::new(pool, Arc::clone(&cat) as Arc, arena_base).unwrap(), + ); + let t = CowTree::new(store as Arc); + Fx { _dir: dir, cat, t } +} + +fn k(n: u32) -> Vec { + n.to_be_bytes().to_vec() +} + +/// Value of exactly `w` bytes whose content is a function of `n` (so values stay distinct). +/// `w == 0` means "the fixture's own value shape", `format!("v{n}")`. +fn v(n: u32, w: usize) -> Vec { + if w == 0 { + return format!("v{n}").into_bytes(); + } + let mut out = n.to_be_bytes().to_vec(); + while out.len() < w { + out.push(b'a' + ((n as u8).wrapping_add(out.len() as u8) % 26)); + } + out.truncate(w.max(4)); + out +} + +fn build_in(fx: &Fx, keys: &[u32], w: usize) -> PageId { + let e = fx.cat.next_epoch(); + let mut root = fx.t.create(BranchId::TRUNK, e).unwrap(); + for &i in keys { + root = fx.t.insert(root, BranchId::TRUNK, e, &k(i), &v(i, w)).unwrap(); + } + root +} + +// ---- orders ------------------------------------------------------------------------------------ + +/// The module's own shuffle, parameterised by seed. seed 0x5eed_1234 reproduces the test exactly. +fn fixed_shuffle_seed(n: u32, seed: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + let mut state: u32 = seed; + for i in (1..v.len()).rev() { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (state >> 8) as usize % (i + 1); + v.swap(i, j); + } + v +} + +/// Ascending with `swaps` adjacent transpositions at pseudo-random positions. +fn near_sorted(n: u32, swaps: usize, seed: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + if v.len() < 2 { + return v; + } + let mut state: u32 = seed; + for _ in 0..swaps { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let i = (state >> 8) as usize % (v.len() - 1); + v.swap(i, i + 1); + } + v +} + +/// Ascending inside blocks of `b`, blocks visited back to front. +fn blocks_reversed(n: u32, b: u32) -> Vec { + let mut out = Vec::with_capacity(n as usize); + let nblocks = n.div_ceil(b); + for blk in (0..nblocks).rev() { + for i in blk * b..((blk + 1) * b).min(n) { + out.push(i); + } + } + out +} + +fn evens_then_odds(n: u32) -> Vec { + let mut out: Vec = (0..n).filter(|x| x % 2 == 0).collect(); + out.extend((0..n).filter(|x| x % 2 == 1)); + out +} + +// ---- comparison -------------------------------------------------------------------------------- + +/// The test's own "shared" statistic: positions in `a` whose cid appears anywhere in `b`. +fn shared_as_test_counts(a: &[Cid], b: &[Cid]) -> usize { + a.iter().filter(|c| b.contains(c)).count() +} + +/// What a real content diff would actually skip: length of the longest common subsequence. +fn lcs(a: &[Cid], b: &[Cid]) -> usize { + let mut prev = vec![0usize; b.len() + 1]; + let mut cur = vec![0usize; b.len() + 1]; + for i in 1..=a.len() { + for j in 1..=b.len() { + cur[j] = if a[i - 1] == b[j - 1] { + prev[j - 1] + 1 + } else { + prev[j].max(cur[j - 1]) + }; + } + std::mem::swap(&mut prev, &mut cur); + cur.iter_mut().for_each(|x| *x = 0); + } + prev[b.len()] +} + +struct Row { + n: u32, + w: usize, + order: String, + la: usize, + lb: usize, + shared: usize, + lcs: usize, + part_eq: bool, + cont_eq: bool, +} + +fn compare(fx: &Fx, n: u32, w: usize, order_name: &str, order: &[u32]) -> Row { + let asc: Vec = (0..n).collect(); + let ra = build_in(fx, &asc, w); + let rb = build_in(fx, order, w); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + Row { + n, + w, + order: order_name.to_string(), + la: a.len(), + lb: b.len(), + shared: shared_as_test_counts(&a, &b), + lcs: lcs(&a, &b), + part_eq: leaf_partition_cid(&fx.t, ra).unwrap() == leaf_partition_cid(&fx.t, rb).unwrap(), + cont_eq: leaf_content_cid(&fx.t, ra).unwrap() == leaf_content_cid(&fx.t, rb).unwrap(), + } +} + +// ---- A: is "0 of 9/8" a fixture artifact? ------------------------------------------------------- + +fn attack_a() { + println!("\n================ ATTACK A: fixture sweep ================"); + println!("ascending vs . shared = the test's own statistic. lcs = what a content diff"); + println!("could really skip. part/cont = partition/content cids equal?\n"); + println!( + "{:>6} {:>5} {:<16} {:>5} {:>5} {:>7} {:>5} {:>6} {:>6}", + "n", "vlen", "order", "lvsA", "lvsB", "shared", "lcs", "partEq", "contEq" + ); + + let sizes: Vec = vec![2, 3, 4, 5, 8, 12, 16, 24, 32, 48, 64, 100, 128, 200, 256, 500, 1000, 2000, 5000]; + let widths: Vec = vec![0, 32, 200, 512, 990]; + let mut rows: Vec = Vec::new(); + + for &w in &widths { + let fx = fixture(1024); + for &n in &sizes { + // Big trees at big values get slow; cap the product. + if (n as usize) * w.max(6) > 3_000_000 { + continue; + } + let mut orders: Vec<(String, Vec)> = vec![ + ("shuffle-0x5eed1234".into(), fixed_shuffle_seed(n, 0x5eed_1234)), + ("shuffle-seed2".into(), fixed_shuffle_seed(n, 0x1234_5678)), + ("shuffle-seed3".into(), fixed_shuffle_seed(n, 0xdead_beef)), + ("descending".into(), (0..n).rev().collect()), + ("evens-then-odds".into(), evens_then_odds(n)), + ("near-1swap".into(), near_sorted(n, 1, 7)), + ("near-2swaps".into(), near_sorted(n, 2, 11)), + ("near-5swaps".into(), near_sorted(n, 5, 13)), + ("near-20swaps".into(), near_sorted(n, 20, 17)), + ("blocks-rev-8".into(), blocks_reversed(n, 8)), + ("blocks-rev-64".into(), blocks_reversed(n, 64)), + ]; + orders.retain(|(_, o)| o.len() == n as usize); + for (name, o) in &orders { + let r = compare(&fx, n, w, name, o); + println!( + "{:>6} {:>5} {:<16} {:>5} {:>5} {:>7} {:>5} {:>6} {:>6}", + r.n, + if r.w == 0 { 6 } else { r.w }, + r.order, + r.la, + r.lb, + r.shared, + r.lcs, + r.part_eq, + r.cont_eq + ); + rows.push(r); + } + } + } + + // Reduce. + let total = rows.len(); + let multileaf: Vec<&Row> = rows.iter().filter(|r| r.la > 1 && r.lb > 1).collect(); + let any_shared: Vec<&&Row> = multileaf.iter().filter(|r| r.shared > 0).collect(); + let part_equal: Vec<&Row> = rows.iter().filter(|r| r.part_eq).collect(); + let cont_unequal: Vec<&Row> = rows.iter().filter(|r| !r.cont_eq).collect(); + let violates_guard: Vec<&&Row> = + multileaf.iter().filter(|r| r.shared * 2 >= r.la).collect(); + + println!("\n--- A reduce ---"); + println!("configurations run : {total}"); + println!("configurations with >1 leaf on both sides : {}", multileaf.len()); + println!(" of those, shared > 0 : {}", any_shared.len()); + println!(" of those, shared*2 >= leavesA (the test's assert would FAIL) : {}", violates_guard.len()); + println!("configurations where partition cids AGREE : {}", part_equal.len()); + println!("configurations where content cids DIFFER : {} (must be 0)", cont_unequal.len()); + + println!("\nevery multi-leaf configuration with any sharing at all:"); + if any_shared.is_empty() { + println!(" (none)"); + } + for r in &any_shared { + println!( + " n={:<5} vlen={:<4} {:<18} leaves {}/{} shared={} lcs={} partEq={}", + r.n, + if r.w == 0 { 6 } else { r.w }, + r.order, + r.la, + r.lb, + r.shared, + r.lcs, + r.part_eq + ); + } + println!("\nevery configuration where the partition cids AGREE:"); + if part_equal.is_empty() { + println!(" (none)"); + } + for r in &part_equal { + println!( + " n={:<5} vlen={:<4} {:<18} leaves {}/{}", + r.n, + if r.w == 0 { 6 } else { r.w }, + r.order, + r.la, + r.lb + ); + } +} + +/// Fix n=1000/vlen=6 (the test's own fixture) and sweep 200 distinct shuffles, to see whether the +/// published "0 of 9/8" is a property of the family or of the one seed. +fn attack_a_seed_sweep() { + println!("\n---- A2: n=1000, the test's own value shape, 200 distinct shuffle seeds ----"); + let fx = fixture(1024); + let asc: Vec = (0..1000).collect(); + let ra = build_in(&fx, &asc, 0); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let apart = leaf_partition_cid(&fx.t, ra).unwrap(); + let acont = leaf_content_cid(&fx.t, ra).unwrap(); + + let mut leafcount: HashMap = HashMap::new(); + let mut max_shared = 0usize; + let mut max_lcs = 0usize; + let mut part_agree = 0usize; + let mut cont_disagree = 0usize; + for s in 0..200u32 { + let seed = 0x5eed_1234u32.wrapping_add(s.wrapping_mul(0x9e37_79b9)); + let order = fixed_shuffle_seed(1000, seed); + let rb = build_in(&fx, &order, 0); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + *leafcount.entry(b.len()).or_default() += 1; + max_shared = max_shared.max(shared_as_test_counts(&a, &b)); + max_lcs = max_lcs.max(lcs(&a, &b)); + if leaf_partition_cid(&fx.t, rb).unwrap() == apart { + part_agree += 1; + } + if leaf_content_cid(&fx.t, rb).unwrap() != acont { + cont_disagree += 1; + } + } + let mut counts: Vec<_> = leafcount.into_iter().collect(); + counts.sort(); + println!(" ascending tree: {} leaves, partition cid {}", a.len(), hex(&apart)); + println!(" shuffled leaf-count distribution over 200 seeds: {counts:?}"); + println!(" max shared leaves over 200 seeds : {max_shared}"); + println!(" max LCS over 200 seeds : {max_lcs}"); + println!(" seeds whose partition cid equals ascending's : {part_agree} of 200"); + println!(" seeds whose content cid DIFFERS from ascending's : {cont_disagree} of 200 (must be 0)"); +} + +/// Shuffle vs shuffle, not ascending vs shuffle: two "independent lineages" neither of which is +/// sorted. The test only measured one of the two axes. +fn attack_a_shuffle_vs_shuffle() { + println!("\n---- A3: shuffle vs shuffle (two unsorted lineages), n=1000 ----"); + let fx = fixture(1024); + let mut worst = (0usize, 0usize); + for s in 0..40u32 { + let o1 = fixed_shuffle_seed(1000, 0x1111_0000u32.wrapping_add(s)); + let o2 = fixed_shuffle_seed(1000, 0x2222_0000u32.wrapping_add(s)); + let r1 = build_in(&fx, &o1, 0); + let r2 = build_in(&fx, &o2, 0); + let a = ordered_leaf_cids(&fx.t, r1).unwrap(); + let b = ordered_leaf_cids(&fx.t, r2).unwrap(); + let sh = shared_as_test_counts(&a, &b); + let l = lcs(&a, &b); + if sh > worst.0 { + worst = (sh, l); + } + assert_eq!( + leaf_content_cid(&fx.t, r1).unwrap(), + leaf_content_cid(&fx.t, r2).unwrap(), + "control broke in A3" + ); + } + println!(" 40 shuffle-vs-shuffle pairs: max shared = {}, its lcs = {}", worst.0, worst.1); +} + +/// The insertion order the test never tried: the SAME order twice but with the rows arriving as +/// two lineages that then converge. Concretely: does a *single-key difference* in the insert +/// order destroy all sharing, or only local sharing? +fn attack_a_one_key_late() { + println!("\n---- A4: ascending vs ascending-with-ONE-key-inserted-last, n=1000 ----"); + let fx = fixture(1024); + let asc: Vec = (0..1000).collect(); + let ra = build_in(&fx, &asc, 0); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + for moved in [0u32, 1, 137, 500, 998, 999] { + let mut order: Vec = (0..1000).filter(|&x| x != moved).collect(); + order.push(moved); + let rb = build_in(&fx, &order, 0); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + " key {moved} inserted last: leaves {}/{} shared={} lcs={} partEq={} contEq={}", + a.len(), + b.len(), + shared_as_test_counts(&a, &b), + lcs(&a, &b), + leaf_partition_cid(&fx.t, ra).unwrap() == leaf_partition_cid(&fx.t, rb).unwrap(), + leaf_content_cid(&fx.t, ra).unwrap() == leaf_content_cid(&fx.t, rb).unwrap(), + ); + } +} + +// ---- B: is leaf_content_cid a real control? ----------------------------------------------------- + +fn attack_b() { + println!("\n================ ATTACK B: force the control to fire ================"); + let fx = fixture(1024); + + let mut fails = 0usize; + let mut check = |name: &str, x: Cid, y: Cid, want_equal: bool| { + let eq = x == y; + let ok = eq == want_equal; + if !ok { + fails += 1; + } + println!( + " [{}] {:<52} equal={} (wanted {}) {} / {}", + if ok { "ok " } else { "FAIL" }, + name, + eq, + want_equal, + hex(&x), + hex(&y) + ); + }; + + // raw-bytes builder so keys and values can be chosen freely + let raw = |pairs: &[(&[u8], &[u8])]| -> PageId { + let e = fx.cat.next_epoch(); + let mut root = fx.t.create(BranchId::TRUNK, e).unwrap(); + for (kk, vv) in pairs { + root = fx.t.insert(root, BranchId::TRUNK, e, kk, vv).unwrap(); + } + root + }; + + // B1: one value differs by one byte in a 1000-key tree. + let base = build_in(&fx, &(0..1000).collect::>(), 0); + let e = fx.cat.next_epoch(); + let b = fx.cat.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap().branch_id; + let edited = fx.t.insert(base, b, e, &k(500), b"v500x").unwrap(); + check( + "B1 1000-key tree, one value +1 byte", + leaf_content_cid(&fx.t, base).unwrap(), + leaf_content_cid(&fx.t, edited).unwrap(), + false, + ); + + // B2: one key present vs absent (999 vs 1000 keys). + let short = build_in(&fx, &(0..999).collect::>(), 0); + check( + "B2 1000 keys vs 999 keys", + leaf_content_cid(&fx.t, base).unwrap(), + leaf_content_cid(&fx.t, short).unwrap(), + false, + ); + + // B3: the field-boundary hazard, at TREE level (not just leaf_cid level). + let t1 = raw(&[(b"ab", b"c")]); + let t2 = raw(&[(b"a", b"bc")]); + check( + "B3 tree{(ab,c)} vs tree{(a,bc)} (same byte stream)", + leaf_content_cid(&fx.t, t1).unwrap(), + leaf_content_cid(&fx.t, t2).unwrap(), + false, + ); + + // B4: two keys whose (key,value) bytes are a rotation of each other. + let t3 = raw(&[(b"k1", b"AAA"), (b"k2", b"BBB")]); + let t4 = raw(&[(b"k1", b"BBB"), (b"k2", b"AAA")]); + check( + "B4 values swapped between two keys", + leaf_content_cid(&fx.t, t3).unwrap(), + leaf_content_cid(&fx.t, t4).unwrap(), + false, + ); + + // B5: a key with an empty value vs a key with a one-zero-byte value. + let t5 = raw(&[(b"k", b"")]); + let t6 = raw(&[(b"k", b"\0")]); + check( + "B5 empty value vs one zero byte", + leaf_content_cid(&fx.t, t5).unwrap(), + leaf_content_cid(&fx.t, t6).unwrap(), + false, + ); + + // B6: an EMPTY tree vs a one-entry tree, and empty vs empty. + let e1 = fx.cat.next_epoch(); + let empty_a = fx.t.create(BranchId::TRUNK, e1).unwrap(); + let empty_b = fx.t.create(BranchId::TRUNK, e1).unwrap(); + check( + "B6a empty tree vs empty tree (must AGREE)", + leaf_content_cid(&fx.t, empty_a).unwrap(), + leaf_content_cid(&fx.t, empty_b).unwrap(), + true, + ); + check( + "B6b empty tree vs one-entry tree", + leaf_content_cid(&fx.t, empty_a).unwrap(), + leaf_content_cid(&fx.t, t5).unwrap(), + false, + ); + + // B7: is the control CONSTANT? Distinct content cids over the sweep above. + let mut seen: HashMap = HashMap::new(); + for n in [1u32, 2, 3, 10, 50, 200, 999, 1000] { + let r = build_in(&fx, &(0..n).collect::>(), 0); + *seen.entry(leaf_content_cid(&fx.t, r).unwrap()).or_default() += 1; + } + println!( + " [{}] B7 8 different key sets -> {} distinct content cids (wanted 8)", + if seen.len() == 8 { "ok " } else { "FAIL" }, + seen.len() + ); + if seen.len() != 8 { + fails += 1; + } + + // B8: HOW MANY of those trees would the control have to be wrong about? Build 3000 distinct + // random content sets and count content-cid collisions. + let mut cids: HashMap = HashMap::new(); + let mut state: u32 = 0xabcd_1234; + for i in 0..3000u32 { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let n = 1 + (state >> 9) % 40; + let mut keys: Vec = Vec::new(); + for j in 0..n { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + keys.push((state >> 7) % 10_000 + j * 10_000); + } + keys.sort_unstable(); + keys.dedup(); + let r = build_in(&fx, &keys, 0); + *cids.entry(leaf_content_cid(&fx.t, r).unwrap()).or_default() += 1; + let _ = i; + } + let dup: usize = cids.values().filter(|c| **c > 1).count(); + println!(" [{}] B8 3000 random content sets -> {} distinct content cids, {} colliding buckets", + if dup == 0 { "ok " } else { "FAIL" }, cids.len(), dup); + + println!(" B verdict: {} failures", fails); +} + +// ---- C: does anything physical leak in? --------------------------------------------------------- + +fn attack_c() { + println!("\n================ ATTACK C: physical independence ================"); + let keys: Vec = (0..1000).collect(); + + // Reference: arena base 1024, TRUNK, first epoch, fresh file. + let f1 = fixture(1024); + let r1 = build_in(&f1, &keys, 0); + let ref_part = leaf_partition_cid(&f1.t, r1).unwrap(); + let ref_cont = leaf_content_cid(&f1.t, r1).unwrap(); + let ref_sub = subtree_cid(&f1.t, r1).unwrap(); + let pages1 = f1.t.walk_pages(r1).unwrap(); + println!( + " reference: root={} pages {}..{} ({} pages) part={} sub={}", + r1, + pages1.iter().min().unwrap(), + pages1.iter().max().unwrap(), + pages1.len(), + hex(&ref_part), + hex(&ref_sub) + ); + + let mut fails = 0usize; + let mut report = |name: &str, t: &CowTree, root: PageId| { + let p = leaf_partition_cid(t, root).unwrap(); + let c = leaf_content_cid(t, root).unwrap(); + let s = subtree_cid(t, root).unwrap(); + let pages = t.walk_pages(root).unwrap(); + let disjoint = pages.iter().all(|x| !pages1.contains(x)); + let ok = p == ref_part && c == ref_cont && s == ref_sub; + if !ok { + fails += 1; + } + println!( + " [{}] {:<44} root={:<7} pages {:>6}..{:<6} disjoint-from-ref={} part={} sub={}", + if ok { "ok " } else { "FAIL" }, + name, + root, + pages.iter().min().unwrap(), + pages.iter().max().unwrap(), + disjoint, + hex(&p), + hex(&s) + ); + }; + + // C1: second build in the same store -> different page ids, same arena. + let r2 = build_in(&f1, &keys, 0); + report("C1 same store, second build", &f1.t, r2); + + // C2: a completely different file, different arena base -> page ids in a different range. + let f2 = fixture(65_536); + let r3 = build_in(&f2, &keys, 0); + report("C2 different file, arena base 65536", &f2.t, r3); + + // C3: a different branch (fork) and a much later epoch. + let f3 = fixture(1024); + for _ in 0..50 { + f3.cat.next_epoch(); + } + let br = f3.cat.fork(BranchId::TRUNK, LeaseDeadline::from_now(60_000)).unwrap().branch_id; + let e = f3.cat.next_epoch(); + let mut root = f3.t.create(br, e).unwrap(); + for &i in &keys { + root = f3.t.insert(root, br, e, &k(i), &v(i, 0)).unwrap(); + } + report("C3 forked branch, epoch+50, own arena", &f3.t, root); + + // C4: page ids shifted by allocating 300 unrelated pages first. + let f4 = fixture(1024); + let e4 = f4.cat.next_epoch(); + for _ in 0..300 { + f4.t.store().alloc_for(BranchId::TRUNK, PageType::Heap, e4).unwrap(); + } + let r5 = build_in(&f4, &keys, 0); + report("C4 300 heap pages allocated first", &f4.t, r5); + + // C5: every insert preceded by a throwaway insert+delete in a disjoint keyspace, so each leaf + // carries freed cells and a different heap_end. Same logical content. + let f5 = fixture(1024); + let e5 = f5.cat.next_epoch(); + let mut r6 = f5.t.create(BranchId::TRUNK, e5).unwrap(); + for &i in &keys { + r6 = f5.t.insert(r6, BranchId::TRUNK, e5, &k(i), &v(i, 0)).unwrap(); + } + // now churn: insert then delete a key inside each existing leaf's range + for &i in &keys { + let junk = k(i).iter().copied().chain([0xffu8]).collect::>(); + r6 = f5.t.insert(r6, BranchId::TRUNK, e5, &junk, b"junkjunkjunk").unwrap(); + r6 = f5.t.delete(r6, BranchId::TRUNK, e5, &junk).unwrap(); + } + let p6 = leaf_partition_cid(&f5.t, r6).unwrap(); + let c6 = leaf_content_cid(&f5.t, r6).unwrap(); + println!( + " [{}] C5 1000 insert+delete churn cycles leaves={} contentEq={} partitionEq={}", + if c6 == ref_cont { "ok " } else { "FAIL" }, + ordered_leaf_cids(&f5.t, r6).unwrap().len(), + c6 == ref_cont, + p6 == ref_part + ); + if c6 != ref_cont { + fails += 1; + } + + // C6: single-leaf tree, entries inserted in 6 different orders -> identical physical cell + // offsets are impossible, identical leaf_cid is demanded. + let f6 = fixture(1024); + let small: Vec = (0..20).collect(); + let mut cids = Vec::new(); + for seed in [1u32, 2, 3, 4, 5, 6] { + let order = fixed_shuffle_seed(20, seed); + let r = build_in(&f6, &order, 0); + let l = ordered_leaf_cids(&f6.t, r).unwrap(); + assert_eq!(l.len(), 1, "C6 fixture split; needs to stay one leaf"); + cids.push(l[0]); + } + let r_asc = build_in(&f6, &small, 0); + let asc_leaf = ordered_leaf_cids(&f6.t, r_asc).unwrap()[0]; + let all_same = cids.iter().all(|c| *c == asc_leaf); + println!( + " [{}] C6 one leaf, 20 entries, 7 insert orders all leaf_cids equal={} cid={}", + if all_same { "ok " } else { "FAIL" }, + all_same, + hex(&asc_leaf) + ); + if !all_same { + fails += 1; + } + + println!(" C verdict: {} failures", fails); +} + +// ---- D: collisions ------------------------------------------------------------------------------ + +fn attack_d() { + println!("\n================ ATTACK D: two different trees, one partition cid ================"); + + // D1: STRUCTURAL. leaf_partition_cid is a function of the leaf sequence ONLY. Build two trees + // over the SAME leaves but with different internal shape and different separator keys. + // No brute force: the collision is by construction. + let fx = fixture(1024); + let e = fx.cat.next_epoch(); + + // three real leaves, produced by a real tree so they are well-formed + let donor = build_in(&fx, &(0..600).collect::>(), 0); + let donor_leaf_pages: Vec = { + // walk_pages returns every page; pick the leaves by asking the store for their type + let mut leaves = Vec::new(); + for p in fx.t.walk_pages(donor).unwrap() { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + if ferrodb::cow::page_header::PageHeader::read_from(&f.data).unwrap().page_type + == PageType::BTreeLeaf + { + leaves.push(p); + } + } + leaves.sort_unstable(); + leaves + }; + println!(" donor tree has {} leaf pages", donor_leaf_pages.len()); + + let mk_internal = |leftmost: PageId, entries: &[(Vec, PageId)]| -> PageId { + let id = fx.t.store().alloc_for(BranchId::TRUNK, PageType::BTreeInternal, e).unwrap(); + let h = fx.t.store().read_page(id).unwrap(); + let mut f = h.write(); + NodeMut::new(&mut f.data).fill_internal(leftmost, entries).unwrap(); + stamp_checksum(&mut f.data); + drop(f); + id + }; + + // take the first three leaves of the donor, in key order + let l0 = donor_leaf_pages[0]; + let l1 = donor_leaf_pages[1]; + let l2 = donor_leaf_pages[2]; + + // shape 1: one internal root with three children, separators "S1"/"S2" + let flat = mk_internal(l0, &[(b"S1".to_vec(), l1), (b"S2".to_vec(), l2)]); + // shape 2: two levels, and completely different separator bytes + let inner = mk_internal(l0, &[(b"ZZZZZZZZ".to_vec(), l1)]); + let deep = mk_internal(inner, &[(b"Q".to_vec(), l2)]); + + let p_flat = leaf_partition_cid(&fx.t, flat).unwrap(); + let p_deep = leaf_partition_cid(&fx.t, deep).unwrap(); + let s_flat = subtree_cid(&fx.t, flat).unwrap(); + let s_deep = subtree_cid(&fx.t, deep).unwrap(); + let c_flat = leaf_content_cid(&fx.t, flat).unwrap(); + let c_deep = leaf_content_cid(&fx.t, deep).unwrap(); + println!(" shape A (1 internal, 3 children) : part={} sub={}", hex(&p_flat), hex(&s_flat)); + println!(" shape B (2 levels, other seps) : part={} sub={}", hex(&p_deep), hex(&s_deep)); + println!( + " -> partition cids equal: {} content cids equal: {} subtree cids equal: {}", + p_flat == p_deep, + c_flat == c_deep, + s_flat == s_deep + ); + + // D2: BIRTHDAY. Does leaf_cid behave like a 128-bit random function on distinct inputs? + // 2^21 distinct leaves; expect 0 full collisions and ~birthday many on truncations. + let n: u64 = 1 << 21; + let mut full: HashMap = HashMap::with_capacity(n as usize); + let mut low32: HashMap = HashMap::with_capacity(1 << 20); + let mut low48: HashMap = HashMap::with_capacity(1 << 21); + for i in 0..n { + let c = leaf_cid(&[(i.to_be_bytes().to_vec(), b"x".to_vec())]); + *full.entry(c).or_default() += 1; + let a32 = u32::from_be_bytes([c[0], c[1], c[2], c[3]]); + *low32.entry(a32).or_default() += 1; + let a48 = u64::from_be_bytes([0, 0, c[0], c[1], c[2], c[3], c[4], c[5]]); + *low48.entry(a48).or_default() += 1; + } + let coll = |m: &HashMap| -> u64 { m.values().map(|c| (*c as u64) - 1).sum() }; + let coll32: u64 = low32.values().map(|c| (*c as u64) - 1).sum(); + let coll48: u64 = low48.values().map(|c| (*c as u64) - 1).sum(); + println!( + "\n D2 birthday over {n} distinct leaves:\n full 128-bit collisions : {}\n first-32-bit collisions : {} (random expectation ~{:.0})\n first-48-bit collisions : {} (random expectation ~{:.1})", + coll(&full), + coll32, + (n as f64) * (n as f64) / 2.0 / 4_294_967_296.0, + coll48, + (n as f64) * (n as f64) / 2.0 / 281_474_976_710_656.0, + ); + + // D3: DIRECTED. FNV-1a's low byte is a self-contained 8-bit state machine, so the low bits are + // cheap to steer. Does that survive `finish`? Search a 2^22 family for a pair agreeing on + // the first 8 output bytes (64 bits) -- if the finalizer were weak this would be far more + // likely than 2^-64. + let mut by_hi: HashMap = HashMap::with_capacity(1 << 22); + let mut hit: Option<(u64, u64)> = None; + for i in 0..(1u64 << 22) { + let mut h = Hasher128::new(0x6c65_6166_0000_0001); + h.number(1); + h.field(&i.to_be_bytes()); + h.field(b"v"); + let c = h.finish(); + let hi = u64::from_be_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]); + if let Some(prev) = by_hi.insert(hi, i) { + hit = Some((prev, i)); + break; + } + } + println!( + " D3 2^22 chosen 8-byte keys, looking for a 64-bit-prefix collision: {}", + match hit { + Some((a, b)) => format!("FOUND {a} / {b}"), + None => "none (birthday expectation at 2^22 is ~2^-20)".to_string(), + } + ); + + // D4: can two trees with DIFFERENT content share a partition cid, cheaply? The partition cid + // is H(count || cid_0 || .. || cid_{n-1}) with no length prefix between cids. Try the one + // structural door that leaves: a tree with k leaves vs a tree with k leaves whose cids are + // a rotation/regrouping of the first's. + let a = leaf_cid(&[(b"a".to_vec(), b"1".to_vec())]); + let b = leaf_cid(&[(b"b".to_vec(), b"2".to_vec())]); + let mut h1 = Hasher128::new(0x7061_7274_0000_0001); + h1.number(2); + h1.cid(&a); + h1.cid(&b); + let mut h2 = Hasher128::new(0x7061_7274_0000_0001); + h2.number(2); + h2.cid(&b); + h2.cid(&a); + println!(" D4 leaf order swapped in the fold: equal={}", h1.finish() == h2.finish()); + let mut h3 = Hasher128::new(0x7061_7274_0000_0001); + h3.number(1); + h3.cid(&a); + let mut h4 = Hasher128::new(0x7061_7274_0000_0001); + h4.number(2); + h4.cid(&a); + println!(" D4 count 1 vs count 2 over the same first cid: equal={}", h3.finish() == h4.finish()); +} + +fn main() { + let which = std::env::args().nth(1).unwrap_or_else(|| "all".into()); + if which == "all" || which == "a" { + attack_a(); + attack_a_seed_sweep(); + attack_a_shuffle_vs_shuffle(); + attack_a_one_key_late(); + } + if which == "all" || which == "b" { + attack_b(); + } + if which == "all" || which == "c" { + attack_c(); + } + if which == "all" || which == "d" { + attack_d(); + } + // keep Epoch import honest + let _: Option = None; +} diff --git a/examples/d89_falsify2.rs b/examples/d89_falsify2.rs new file mode 100644 index 00000000..26f1d4c6 --- /dev/null +++ b/examples/d89_falsify2.rs @@ -0,0 +1,859 @@ +//! THROWAWAY part 2 of the D89 falsification harness. See examples/d89_falsify.rs. +//! +//! E: how much disorder does the "0 shared leaves" result actually need? +//! F: the test's exact body, re-run under other orders, plus the four assertions of the +//! #[ignore]d `same_data_in_two_orders_must_converge_to_one_partition_cid`. +//! G: physical-layout forcing that proves the C-attack fixtures were not vacuous. + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::types::{BranchId, PageId}; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::cid::{ + hex, leaf_content_cid, leaf_partition_cid, ordered_leaf_cids, subtree_cid, Cid, +}; +use ferrodb::cow::page_header::{PageHeader, PageType}; +use ferrodb::cow::{CowTree, PageStore}; +use ferrodb::storage::disk_manager::DiskManager; +use std::sync::Arc; + +struct Fx { + _dir: tempfile::TempDir, + cat: Arc, + t: CowTree, +} + +fn fixture() -> Fx { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("cid.db")) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let cat = Arc::new(LogBranchCatalog::in_memory(1)); + let store = Arc::new( + ArenaPageStore::new(pool, Arc::clone(&cat) as Arc, 1024).unwrap(), + ); + let t = CowTree::new(store as Arc); + Fx { _dir: dir, cat, t } +} + +fn k(n: u32) -> Vec { + n.to_be_bytes().to_vec() +} +fn v(n: u32) -> Vec { + format!("v{n}").into_bytes() +} + +fn build(fx: &Fx, keys: &[u32]) -> PageId { + let e = fx.cat.next_epoch(); + let mut root = fx.t.create(BranchId::TRUNK, e).unwrap(); + for &i in keys { + root = fx.t.insert(root, BranchId::TRUNK, e, &k(i), &v(i)).unwrap(); + } + root +} + +fn fixed_shuffle(n: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + let mut state: u32 = 0x5eed_1234; + for i in (1..v.len()).rev() { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (state >> 8) as usize % (i + 1); + v.swap(i, j); + } + v +} + +/// Ascending with `swaps` adjacent transpositions. +fn near_sorted(n: u32, swaps: usize, seed: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + let mut state: u32 = seed; + for _ in 0..swaps { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let i = (state >> 8) as usize % (v.len() - 1); + v.swap(i, i + 1); + } + v +} + +/// A partial Fisher-Yates: only the first `frac_pct`% of positions get shuffled at all. +fn partial_shuffle(n: u32, frac_pct: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + let mut state: u32 = 0x5eed_1234; + let limit = ((n as u64 * frac_pct as u64) / 100) as usize; + for i in (1..v.len()).rev() { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (state >> 8) as usize % (i + 1); + if i < limit { + v.swap(i, j); + } + } + v +} + +fn shared_as_test_counts(a: &[Cid], b: &[Cid]) -> usize { + a.iter().filter(|c| b.contains(c)).count() +} + +fn lcs(a: &[Cid], b: &[Cid]) -> usize { + let mut prev = vec![0usize; b.len() + 1]; + let mut cur = vec![0usize; b.len() + 1]; + for i in 1..=a.len() { + for j in 1..=b.len() { + cur[j] = if a[i - 1] == b[j - 1] { prev[j - 1] + 1 } else { prev[j].max(cur[j - 1]) }; + } + std::mem::swap(&mut prev, &mut cur); + cur.iter_mut().for_each(|x| *x = 0); + } + prev[b.len()] +} + +// ---- E ------------------------------------------------------------------------------------------ + +fn attack_e() { + println!("\n================ ATTACK E: how much disorder does \"0 shared\" need? ================"); + println!("n=1000, values `v{{n}}` -- the test's OWN fixture. Only the insert order varies.\n"); + let fx = fixture(); + let asc: Vec = (0..1000).collect(); + let ra = build(&fx, &asc); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let apart = leaf_partition_cid(&fx.t, ra).unwrap(); + let asub = subtree_cid(&fx.t, ra).unwrap(); + let acont = leaf_content_cid(&fx.t, ra).unwrap(); + println!(" ascending: {} leaves part={} sub={}", a.len(), hex(&apart), hex(&asub)); + println!( + "\n{:>28} {:>7} {:>8} {:>6} {:>8} {:>8} {:>8}", + "order", "leaves", "shared", "lcs", "partEq", "subEq", "contEq" + ); + + let mut orders: Vec<(String, Vec)> = Vec::new(); + for t in [0usize, 1, 2, 5, 10, 25, 50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600] { + orders.push((format!("adjacent-transpositions={t}"), near_sorted(1000, t, 0x13579bdf))); + } + for p in [1u32, 2, 5, 10, 20, 40, 60, 80, 100] { + orders.push((format!("tail-shuffled={p}%"), partial_shuffle(1000, p))); + } + orders.push(("full shuffle (the test's)".into(), fixed_shuffle(1000))); + + for (name, o) in &orders { + let rb = build(&fx, o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + let identical_order = *o == asc; + println!( + "{:>28} {:>7} {:>8} {:>6} {:>8} {:>8} {:>8}{}", + name, + b.len(), + shared_as_test_counts(&a, &b), + lcs(&a, &b), + leaf_partition_cid(&fx.t, rb).unwrap() == apart, + subtree_cid(&fx.t, rb).unwrap() == asub, + leaf_content_cid(&fx.t, rb).unwrap() == acont, + if identical_order { " <- order IS ascending (vacuous)" } else { "" } + ); + } +} + +// ---- F ------------------------------------------------------------------------------------------ + +fn attack_f() { + println!("\n================ ATTACK F: the #[ignore]d convergence test, re-run per order ================"); + println!("Those four assertions are the stated acceptance criterion for content-defined chunking."); + println!("Which of them already hold TODAY, with the byte-balanced split still in place?\n"); + let fx = fixture(); + let asc: Vec = (0..1000).collect(); + let ra = build(&fx, &asc); + + let cases: Vec<(String, Vec)> = vec![ + ("full shuffle (the test's own)".into(), fixed_shuffle(1000)), + ("20 adjacent transpositions".into(), near_sorted(1000, 20, 17)), + ("1 adjacent transposition".into(), near_sorted(1000, 1, 7)), + ("400 adjacent transpositions".into(), near_sorted(1000, 400, 0x13579bdf)), + ("evens ascending then odds".into(), { + let mut o: Vec = (0..1000).filter(|x| x % 2 == 0).collect(); + o.extend((0..1000).filter(|x| x % 2 == 1)); + o + }), + ("descending".into(), (0..1000).rev().collect()), + ]; + + for (name, o) in &cases { + let rb = build(&fx, o); + let c1 = leaf_content_cid(&fx.t, ra).unwrap() == leaf_content_cid(&fx.t, rb).unwrap(); + let c2 = ordered_leaf_cids(&fx.t, ra).unwrap() == ordered_leaf_cids(&fx.t, rb).unwrap(); + let c3 = leaf_partition_cid(&fx.t, ra).unwrap() == leaf_partition_cid(&fx.t, rb).unwrap(); + let c4 = subtree_cid(&fx.t, ra).unwrap() == subtree_cid(&fx.t, rb).unwrap(); + let all = c1 && c2 && c3 && c4; + println!( + " {:<32} content={:<5} leafseq={:<5} partition={:<5} subtree={:<5} -> ignored test would {}", + name, + c1, + c2, + c3, + c4, + if all { "PASS" } else { "fail" } + ); + } +} + +// ---- G ------------------------------------------------------------------------------------------ + +fn attack_g() { + println!("\n================ ATTACK G: prove the physical fixtures are not vacuous ================"); + let fx = fixture(); + + // G1: one leaf, 20 entries, inserted ascending vs shuffled. Dump the raw page bytes. + let asc: Vec = (0..20).collect(); + let shuf: Vec = { + let mut v = asc.clone(); + v.reverse(); + v + }; + let ra = build(&fx, &asc); + let rb = build(&fx, &shuf); + let bytes = |p: PageId| -> Vec { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + f.data.to_vec() + }; + let pa = bytes(ra); + let pb = bytes(rb); + let differing = pa.iter().zip(pb.iter()).filter(|(x, y)| x != y).count(); + let la = ordered_leaf_cids(&fx.t, ra).unwrap(); + let lb = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + " G1 one leaf, 20 entries, ascending vs descending insert:\n raw page bytes differing: {} of 4096 leaf_cid equal: {} ({})", + differing, + la == lb, + hex(&la[0]) + ); + + // G2: same entries reached by insert-then-delete rather than insert-only. + let e = fx.cat.next_epoch(); + let mut r = fx.t.create(BranchId::TRUNK, e).unwrap(); + for i in 0..60u32 { + r = fx.t.insert(r, BranchId::TRUNK, e, &k(i), &v(i)).unwrap(); + } + for i in 20..60u32 { + r = fx.t.delete(r, BranchId::TRUNK, e, &k(i)).unwrap(); + } + let pd = bytes(r); + let ld = ordered_leaf_cids(&fx.t, r).unwrap(); + let diff2 = pa.iter().zip(pd.iter()).filter(|(x, y)| x != y).count(); + let header_ok = PageHeader::read_from(&pd.clone().try_into().unwrap()).unwrap().page_type + == PageType::BTreeLeaf; + println!( + " G2 keys 0..20 by insert-only vs insert-0..60-then-delete-20..60:\n raw page bytes differing: {} of 4096 is a leaf: {} leaf_cid equal to G1's: {}", + diff2, + header_ok, + ld == la + ); + + // G3: confirm the churn fixture from attack C really left the page physically different. + let e3 = fx.cat.next_epoch(); + let mut r3 = fx.t.create(BranchId::TRUNK, e3).unwrap(); + for i in 0..20u32 { + r3 = fx.t.insert(r3, BranchId::TRUNK, e3, &k(i), &v(i)).unwrap(); + } + for i in 0..20u32 { + let junk: Vec = k(i).into_iter().chain([0xffu8]).collect(); + r3 = fx.t.insert(r3, BranchId::TRUNK, e3, &junk, b"junkjunkjunkjunk").unwrap(); + r3 = fx.t.delete(r3, BranchId::TRUNK, e3, &junk).unwrap(); + } + let p3 = bytes(r3); + let l3 = ordered_leaf_cids(&fx.t, r3).unwrap(); + let diff3 = pa.iter().zip(p3.iter()).filter(|(x, y)| x != y).count(); + println!( + " G3 keys 0..20 clean vs after 20 insert+delete churn cycles:\n raw page bytes differing: {} of 4096 leaf_cid equal: {}", + diff3, + l3 == la + ); +} + +// ---- E2: where exactly is the threshold, and what sets it? -------------------------------------- + +/// Keys 0..n inserted with each key displaced by at most `d` positions from sorted. +fn bounded_displacement(n: u32, d: u32, seed: u32) -> Vec { + let mut v: Vec = (0..n).collect(); + if d == 0 { + return v; + } + let mut state: u32 = seed; + for i in 0..v.len() { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let span = (d as usize).min(v.len() - 1 - i); + if span == 0 { + continue; + } + let j = i + 1 + (state >> 8) as usize % span; + v.swap(i, j); + } + v +} + +fn attack_e2() { + println!("\n================ ATTACK E2: locate the threshold ================"); + let fx = fixture(); + let asc: Vec = (0..1000).collect(); + let ra = build(&fx, &asc); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let apart = leaf_partition_cid(&fx.t, ra).unwrap(); + + // How many entries does the FIRST split see? Build ascending prefixes until the tree splits. + let mut first_split_at = 0u32; + for n in 1..400u32 { + let r = build(&fx, &(0..n).collect::>()); + if ordered_leaf_cids(&fx.t, r).unwrap().len() > 1 { + first_split_at = n; + break; + } + } + println!( + " ascending inserts: the first leaf split happens on insert #{first_split_at} ({}% of 1000)", + first_split_at as f64 / 10.0 + ); + + println!("\n head-shuffled p%% (shuffle the first p%% of the insert order, rest ascending):"); + println!("{:>8} {:>7} {:>7} {:>8}", "p%", "leaves", "shared", "partEq"); + for p in [15u32, 18, 20, 21, 22, 23, 25, 30, 35, 40] { + let o = partial_shuffle(1000, p); + let rb = build(&fx, &o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + "{:>8} {:>7} {:>7} {:>8}", + p, + b.len(), + shared_as_test_counts(&a, &b), + leaf_partition_cid(&fx.t, rb).unwrap() == apart + ); + } + + println!("\n bounded displacement: every key within d positions of sorted, n=1000:"); + println!("{:>8} {:>7} {:>7} {:>6} {:>8}", "d", "leaves", "shared", "lcs", "partEq"); + for d in [1u32, 2, 4, 8, 16, 32, 64, 96, 128, 192, 256, 384, 512, 1000] { + let o = bounded_displacement(1000, d, 0x2468_ace0); + let rb = build(&fx, &o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + "{:>8} {:>7} {:>7} {:>6} {:>8}", + d, + b.len(), + shared_as_test_counts(&a, &b), + lcs(&a, &b), + leaf_partition_cid(&fx.t, rb).unwrap() == apart + ); + } +} + +// ---- H: the seed / lineage-pairing questions the test never asked -------------------------------- + +fn attack_h() { + println!("\n================ ATTACK H: is 0-of-9/8 the seed, or the family? ================"); + let fx = fixture(); + let asc: Vec = (0..1000).collect(); + let ra = build(&fx, &asc); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let apart = leaf_partition_cid(&fx.t, ra).unwrap(); + let acont = leaf_content_cid(&fx.t, ra).unwrap(); + + let mut counts = std::collections::HashMap::::new(); + let (mut max_shared, mut max_lcs, mut part_agree, mut cont_disagree) = (0usize, 0usize, 0, 0); + for s in 0..300u32 { + let seed = 0x5eed_1234u32.wrapping_add(s.wrapping_mul(0x9e37_79b9)); + let mut o: Vec = (0..1000).collect(); + let mut state = seed; + for i in (1..o.len()).rev() { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (state >> 8) as usize % (i + 1); + o.swap(i, j); + } + let rb = build(&fx, &o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + *counts.entry(b.len()).or_default() += 1; + max_shared = max_shared.max(shared_as_test_counts(&a, &b)); + max_lcs = max_lcs.max(lcs(&a, &b)); + if leaf_partition_cid(&fx.t, rb).unwrap() == apart { + part_agree += 1; + } + if leaf_content_cid(&fx.t, rb).unwrap() != acont { + cont_disagree += 1; + } + } + let mut c: Vec<_> = counts.into_iter().collect(); + c.sort(); + println!(" H1 ascending vs 300 full shuffles (n=1000):"); + println!(" shuffled leaf-count distribution: {c:?}"); + println!(" max shared over 300 seeds = {max_shared}, max lcs = {max_lcs}"); + println!(" seeds whose partition cid equals ascending's = {part_agree} of 300"); + println!(" seeds whose CONTENT cid differs (control) = {cont_disagree} of 300 (must be 0)"); + + let mut worst = (0usize, 0usize, 0usize); + for s in 0..60u32 { + let mk = |seed: u32| { + let mut o: Vec = (0..1000).collect(); + let mut st = seed; + for i in (1..o.len()).rev() { + st = st.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (st >> 8) as usize % (i + 1); + o.swap(i, j); + } + o + }; + let r1 = build(&fx, &mk(0x1111_0000u32.wrapping_add(s))); + let r2 = build(&fx, &mk(0x2222_0000u32.wrapping_add(s))); + let x = ordered_leaf_cids(&fx.t, r1).unwrap(); + let y = ordered_leaf_cids(&fx.t, r2).unwrap(); + let sh = shared_as_test_counts(&x, &y); + if sh >= worst.0 { + worst = (sh, lcs(&x, &y), x.len()); + } + assert_eq!( + leaf_content_cid(&fx.t, r1).unwrap(), + leaf_content_cid(&fx.t, r2).unwrap(), + "control broke in H2" + ); + } + println!( + " H2 shuffle-vs-shuffle, 60 pairs (neither side sorted): max shared = {}, lcs = {}, of ~{} leaves", + worst.0, worst.1, worst.2 + ); + + println!(" H3 ascending vs ascending-with-ONE-key-moved-to-the-end:"); + for moved in [0u32, 1, 137, 500, 998, 999] { + let mut o: Vec = (0..1000).filter(|&x| x != moved).collect(); + o.push(moved); + let rb = build(&fx, &o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + " key {:<4} last: leaves {}/{} shared={} lcs={} partEq={} contEq={}", + moved, + a.len(), + b.len(), + shared_as_test_counts(&a, &b), + lcs(&a, &b), + leaf_partition_cid(&fx.t, rb).unwrap() == apart, + leaf_content_cid(&fx.t, rb).unwrap() == acont, + ); + } +} + +// ---- I: force the detectors to fire, and re-do the control without the hash -------------------- + +fn attack_i() { + println!("\n================ ATTACK I: force the detectors, drop the hash from the control ================"); + + // I1: the birthday detector from attack D found 0 full-128-bit collisions. Prove that counter + // can count: plant a known duplicate and a known near-duplicate. + use ferrodb::cow::cid::leaf_cid; + let mut m: std::collections::HashMap = std::collections::HashMap::new(); + for i in 0..1000u64 { + *m.entry(leaf_cid(&[(i.to_be_bytes().to_vec(), b"x".to_vec())])).or_default() += 1; + } + let before: u64 = m.values().map(|c| (*c as u64) - 1).sum(); + // plant: the same input twice + *m.entry(leaf_cid(&[(7u64.to_be_bytes().to_vec(), b"x".to_vec())])).or_default() += 1; + let after: u64 = m.values().map(|c| (*c as u64) - 1).sum(); + println!( + " I1 collision counter: {before} over 1000 distinct inputs, {after} after planting one duplicate -> detector {}", + if before == 0 && after == 1 { "FIRES" } else { "IS BROKEN" } + ); + + // I2: the 64-bit-prefix search in D3 found nothing. Prove the search works by running the same + // loop against a 24-bit prefix, where a collision is certain. + let mut seen: std::collections::HashMap = std::collections::HashMap::new(); + let mut hit = None; + for i in 0..(1u64 << 16) { + let c = leaf_cid(&[(i.to_be_bytes().to_vec(), b"v".to_vec())]); + let p24 = u32::from_be_bytes([0, c[0], c[1], c[2]]); + if let Some(prev) = seen.insert(p24, i) { + hit = Some((prev, i)); + break; + } + } + println!( + " I2 same search machinery on a 24-bit prefix: {} -> search {}", + match hit { + Some((a, b)) => format!("collision at inputs {a} / {b}"), + None => "none".into(), + }, + if hit.is_some() { "FIRES" } else { "IS BROKEN" } + ); + + // I3: the measurement's CONTROL (`leaf_content_cid` equal) is the one assertion that uses the + // hash's UNSOUND direction. Redo it with no hash at all: materialise both trees' entries + // and compare the byte sequences directly. + let fx = fixture(); + let asc: Vec = (0..1000).collect(); + let shuf = fixed_shuffle(1000); + let ra = build(&fx, &asc); + let rb = build(&fx, &shuf); + let rows = |r: PageId| -> Vec<(Vec, Vec)> { + fx.t.range_scan(r, None, None).unwrap().map(|x| x.unwrap()).collect() + }; + let ea = rows(ra); + let eb = rows(rb); + println!( + " I3 hash-free control: ascending tree has {} rows, shuffled tree has {} rows, byte-identical sequences: {}", + ea.len(), + eb.len(), + ea == eb + ); + println!( + " (leaf_content_cid said equal: {})", + leaf_content_cid(&fx.t, ra).unwrap() == leaf_content_cid(&fx.t, rb).unwrap() + ); + // and prove THAT comparison can fail + let rc = build(&fx, &(0..999).collect::>()); + println!(" same comparison against a 999-row tree: {} -> control FIRES", ea == rows(rc)); + + // I4: the partition measurement itself, without the hash: compare the ordered per-leaf entry + // lists directly, so "0 shared leaves" does not depend on leaf_cid at all. + let leaf_rows = |r: PageId| -> Vec, Vec)>> { + // walk leaves in key order by re-deriving boundaries from ordered_leaf_cids' own walk: + // scan the whole tree, then cut it at the same sizes the leaves have. + let mut out: Vec, Vec)>> = Vec::new(); + let mut stack = vec![r]; + let mut order: Vec = Vec::new(); + while let Some(p) = stack.pop() { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + let ty = PageHeader::read_from(&f.data).unwrap().page_type; + let node = ferrodb::cow::node::Node::new(&f.data); + match ty { + PageType::BTreeLeaf => order.push(p), + PageType::BTreeInternal => { + let mut kids = vec![node.leftmost()]; + kids.extend(node.internal_entries().unwrap().into_iter().map(|(_, c)| c)); + for c in kids.into_iter().rev() { + stack.push(c); + } + } + _ => unreachable!(), + } + } + for p in order { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + out.push(ferrodb::cow::node::Node::new(&f.data).leaf_entries().unwrap()); + } + out + }; + let la = leaf_rows(ra); + let lb = leaf_rows(rb); + let shared_raw = la.iter().filter(|x| lb.contains(x)).count(); + println!( + " I4 hash-free partition: {} leaves vs {} leaves, leaves with byte-identical entry lists: {}", + la.len(), + lb.len(), + shared_raw + ); + println!( + " leaf sizes ascending: {:?}", + la.iter().map(|x| x.len()).collect::>() + ); + println!( + " leaf sizes shuffled : {:?}", + lb.iter().map(|x| x.len()).collect::>() + ); + // force it: compare a tree against itself + println!( + " same comparison of the ascending tree against a second ascending build: {} of {} -> detector FIRES", + { + let rd = build(&fx, &asc); + let ld = leaf_rows(rd); + la.iter().filter(|x| ld.contains(x)).count() + }, + la.len() + ); +} + +// ---- J: two WELL-FORMED, functionally identical trees with one partition cid ------------------- + +fn attack_j() { + println!("\n================ ATTACK J: same partition cid, two valid trees ================"); + use ferrodb::cow::node::{Node, NodeMut}; + use ferrodb::cow::page_header::stamp_checksum; + let fx = fixture(); + let e = fx.cat.next_epoch(); + + // Donor: a real tree, so every leaf is well-formed and in key order. + let donor = build(&fx, &(0..600).collect::>()); + let mut leaves: Vec = Vec::new(); + let mut stack = vec![donor]; + while let Some(p) = stack.pop() { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + let ty = PageHeader::read_from(&f.data).unwrap().page_type; + let n = Node::new(&f.data); + if ty == PageType::BTreeLeaf { + leaves.push(p); + } else { + let mut kids = vec![n.leftmost()]; + kids.extend(n.internal_entries().unwrap().into_iter().map(|(_, c)| c)); + for c in kids.into_iter().rev() { + stack.push(c); + } + } + } + let first_key = |p: PageId| -> Vec { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + Node::new(&f.data).leaf_entries().unwrap()[0].0.clone() + }; + let (l0, l1, l2) = (leaves[0], leaves[1], leaves[2]); + let (s1, s2) = (first_key(l1), first_key(l2)); + + let mk = |leftmost: PageId, entries: &[(Vec, PageId)]| -> PageId { + let id = fx.t.store().alloc_for(BranchId::TRUNK, PageType::BTreeInternal, e).unwrap(); + let h = fx.t.store().read_page(id).unwrap(); + let mut f = h.write(); + NodeMut::new(&mut f.data).fill_internal(leftmost, entries).unwrap(); + stamp_checksum(&mut f.data); + drop(f); + id + }; + + // Shape A: one internal root, three leaf children. CORRECT separators. + let flat = mk(l0, &[(s1.clone(), l1), (s2.clone(), l2)]); + // Shape B: two levels. Also correct separators, just a different grouping. + let inner = mk(l0, &[(s1.clone(), l1)]); + let deep = mk(inner, &[(s2.clone(), l2)]); + + // Both must be genuinely usable trees answering identically. + let mut probed = 0usize; + let mut disagreed = 0usize; + let mut missing = 0usize; + for p in [l0, l1, l2] { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + for (kk, vv) in Node::new(&f.data).leaf_entries().unwrap() { + probed += 1; + let a = fx.t.get(flat, &kk).unwrap(); + let b = fx.t.get(deep, &kk).unwrap(); + if a != b { + disagreed += 1; + } + if a.as_deref() != Some(vv.as_slice()) { + missing += 1; + } + } + } + println!( + " both shapes probed on all {probed} keys: get() disagreements = {disagreed}, wrong/absent values = {missing}" + ); + println!(" shape A height 2 (root -> 3 leaves), shape B height 3 (root -> inner -> leaves)"); + println!( + " leaf_partition_cid A={} B={} equal={}", + hex(&leaf_partition_cid(&fx.t, flat).unwrap()), + hex(&leaf_partition_cid(&fx.t, deep).unwrap()), + leaf_partition_cid(&fx.t, flat).unwrap() == leaf_partition_cid(&fx.t, deep).unwrap() + ); + println!( + " leaf_content_cid A={} B={} equal={}", + hex(&leaf_content_cid(&fx.t, flat).unwrap()), + hex(&leaf_content_cid(&fx.t, deep).unwrap()), + leaf_content_cid(&fx.t, flat).unwrap() == leaf_content_cid(&fx.t, deep).unwrap() + ); + println!( + " subtree_cid A={} B={} equal={}", + hex(&subtree_cid(&fx.t, flat).unwrap()), + hex(&subtree_cid(&fx.t, deep).unwrap()), + subtree_cid(&fx.t, flat).unwrap() == subtree_cid(&fx.t, deep).unwrap() + ); + println!( + " walk_pages A={} pages B={} pages", + fx.t.walk_pages(flat).unwrap().len(), + fx.t.walk_pages(deep).unwrap().len() + ); +} + +// ---- K: the big-value regime, where a leaf holds only a handful of entries ---------------------- + +fn attack_k() { + println!("\n================ ATTACK K: 990-byte values (4 entries per leaf max) ================"); + println!("{:>6} {:<22} {:>6} {:>6} {:>7} {:>5} {:>7}", "n", "order", "lvsA", "lvsB", "shared", "lcs", "partEq"); + let fx = fixture(); + let bigv = |n: u32| -> Vec { + let mut o = n.to_be_bytes().to_vec(); + while o.len() < 990 { + o.push(b'a' + ((n as u8).wrapping_add(o.len() as u8) % 26)); + } + o + }; + let build_big = |keys: &[u32]| -> PageId { + let e = fx.cat.next_epoch(); + let mut root = fx.t.create(BranchId::TRUNK, e).unwrap(); + for &i in keys { + root = fx.t.insert(root, BranchId::TRUNK, e, &k(i), &bigv(i)).unwrap(); + } + root + }; + for n in [4u32, 8, 16, 32, 64, 128, 256, 500, 1000, 2000] { + let asc: Vec = (0..n).collect(); + let ra = build_big(&asc); + let a = ordered_leaf_cids(&fx.t, ra).unwrap(); + let apart = leaf_partition_cid(&fx.t, ra).unwrap(); + let mut orders: Vec<(String, Vec)> = vec![ + ("full shuffle".into(), { + let mut v: Vec = (0..n).collect(); + let mut st: u32 = 0x5eed_1234; + for i in (1..v.len()).rev() { + st = st.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + let j = (st >> 8) as usize % (i + 1); + v.swap(i, j); + } + v + }), + ("descending".into(), (0..n).rev().collect()), + ("evens then odds".into(), { + let mut o: Vec = (0..n).filter(|x| x % 2 == 0).collect(); + o.extend((0..n).filter(|x| x % 2 == 1)); + o + }), + ]; + orders.retain(|(_, o)| o.len() == n as usize && *o != asc); + for (name, o) in &orders { + let rb = build_big(o); + let b = ordered_leaf_cids(&fx.t, rb).unwrap(); + println!( + "{:>6} {:<22} {:>6} {:>6} {:>7} {:>5} {:>7}", + n, + name, + a.len(), + b.len(), + shared_as_test_counts(&a, &b), + lcs(&a, &b), + leaf_partition_cid(&fx.t, rb).unwrap() == apart + ); + } + } +} + +// ---- L: confirm the positive-sharing findings with NO hash involved ---------------------------- + +fn attack_l() { + println!("\n================ ATTACK L: hash-free confirmation of the sharing findings ================"); + use ferrodb::cow::node::Node; + let fx = fixture(); + let leaf_rows = |r: PageId| -> Vec, Vec)>> { + let mut order: Vec = Vec::new(); + let mut stack = vec![r]; + while let Some(p) = stack.pop() { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + let ty = PageHeader::read_from(&f.data).unwrap().page_type; + let n = Node::new(&f.data); + if ty == PageType::BTreeLeaf { + order.push(p); + } else { + let mut kids = vec![n.leftmost()]; + kids.extend(n.internal_entries().unwrap().into_iter().map(|(_, c)| c)); + for c in kids.into_iter().rev() { + stack.push(c); + } + } + } + order + .into_iter() + .map(|p| { + let h = fx.t.store().read_page(p).unwrap(); + let f = h.read(); + Node::new(&f.data).leaf_entries().unwrap() + }) + .collect() + }; + let rows = |r: PageId| -> Vec<(Vec, Vec)> { + fx.t.range_scan(r, None, None).unwrap().map(|x| x.unwrap()).collect() + }; + + let build_w = |keys: &[u32], w: usize| -> PageId { + let e = fx.cat.next_epoch(); + let mut root = fx.t.create(BranchId::TRUNK, e).unwrap(); + for &i in keys { + let mut val = i.to_be_bytes().to_vec(); + if w == 0 { + val = format!("v{i}").into_bytes(); + } else { + while val.len() < w { + val.push(b'a' + ((i as u8).wrapping_add(val.len() as u8) % 26)); + } + } + root = fx.t.insert(root, BranchId::TRUNK, e, &k(i), &val).unwrap(); + } + root + }; + + let cases: Vec<(&str, u32, usize, Vec)> = vec![ + ("n=1000 vlen=6 near-20swaps", 1000, 0, near_sorted(1000, 20, 17)), + ("n=1000 vlen=6 full shuffle", 1000, 0, fixed_shuffle(1000)), + ("n=1000 vlen=6 evens-then-odds", 1000, 0, { + let mut o: Vec = (0..1000).filter(|x| x % 2 == 0).collect(); + o.extend((0..1000).filter(|x| x % 2 == 1)); + o + }), + ("n=1000 vlen=6 descending", 1000, 0, (0..1000).rev().collect()), + ("n=1000 vlen=200 evens-then-odds", 1000, 200, { + let mut o: Vec = (0..1000).filter(|x| x % 2 == 0).collect(); + o.extend((0..1000).filter(|x| x % 2 == 1)); + o + }), + ("n=2000 vlen=200 evens-then-odds", 2000, 200, { + let mut o: Vec = (0..2000).filter(|x| x % 2 == 0).collect(); + o.extend((0..2000).filter(|x| x % 2 == 1)); + o + }), + ]; + for (name, n, w, order) in &cases { + let asc: Vec = (0..*n).collect(); + let ra = build_w(&asc, *w); + let rb = build_w(order, *w); + let la = leaf_rows(ra); + let lb = leaf_rows(rb); + let shared = la.iter().filter(|x| lb.contains(x)).count(); + println!( + " {:<34} order differs from ascending: {} rows identical: {} leaves {}/{} byte-identical leaves: {}", + name, + *order != asc, + rows(ra) == rows(rb), + la.len(), + lb.len(), + shared + ); + } +} + +fn main() { + let which = std::env::args().nth(1).unwrap_or_else(|| "all".into()); + if which == "all" || which == "l" { + attack_l(); + } + if which == "all" || which == "k" { + attack_k(); + } + if which == "all" || which == "j" { + attack_j(); + } + if which == "all" || which == "i" { + attack_i(); + } + if which == "all" || which == "e2" { + attack_e2(); + } + if which == "all" || which == "h" { + attack_h(); + } + if which == "all" || which == "e" { + attack_e(); + } + if which == "all" || which == "f" { + attack_f(); + } + if which == "all" || which == "g" { + attack_g(); + } +} From 5c37ffbeeceafc2d9cbb8e56f2837fad6f251987 Mon Sep 17 00:00:00 2001 From: RyanLin5967 Date: Mon, 21 Sep 2026 14:36:39 -0400 Subject: [PATCH 029/315] review: reproducers for the cow::cid / cow::diff adversarial review Two of these FAIL on purpose: they assert the correct answer and the product gives a wrong one. - attack3_*: SubtreeHash/MemoIdentity memoise on PageId, which CowStore and ArenaPageStore recycle. diff() then reports 0 changes where there is 1, and warm() refuses to refresh so every counter reads clean. - attack5_*: MemoIdentity(subtree_cid) warms with 2325 page reads against 1558 for the whole O(N) path it replaces. Counted, not timed. - attack1/attack2/attack4: cross-lineage sole-authority, collision behaviour, digest avalanche, sha256 price, fallback reservation. --- tests/review_cow_adv.rs | 559 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 tests/review_cow_adv.rs diff --git a/tests/review_cow_adv.rs b/tests/review_cow_adv.rs new file mode 100644 index 00000000..23800bbb --- /dev/null +++ b/tests/review_cow_adv.rs @@ -0,0 +1,559 @@ +//! Adversarial review reproducers for `src/cow/cid.rs` and `src/cow/diff.rs`. +//! +//! READ-ONLY review: nothing in `src/` is touched. Each test below is a claim the review makes, +//! written so it FAILS if the claim is false. + +use std::fs::OpenOptions; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::types::{BranchId, Epoch, PageId}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::btree::CowTree; +use ferrodb::cow::cid; +use ferrodb::cow::diff::{diff, MemoIdentity, NodeIdentity, PageIdentity, SubtreeHash}; +use ferrodb::cow::store::CowStore; +use ferrodb::cow::PageStore; +use ferrodb::storage::disk_manager::DiskManager; + +const B1: BranchId = BranchId::new(1, 0); +const B2: BranchId = BranchId::new(2, 0); + +struct Fixture { + _dir: tempfile::TempDir, + store: Arc, + tree: CowTree, + clock: AtomicU64, +} + +impl Fixture { + fn new(extent_pages: u32) -> Fixture { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("cow.db")) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let pool = Arc::new(BufferPoolManager::new(dm)); + let store = Arc::new(CowStore::with_extent_pages(pool, extent_pages)); + let tree = CowTree::new(store.clone() as Arc); + Fixture { _dir: dir, store, tree, clock: AtomicU64::new(1) } + } + + fn tick(&self) -> Epoch { + Epoch(self.clock.fetch_add(1, Ordering::SeqCst)) + } + + fn store_dyn(&self) -> Arc { + self.store.clone() as Arc + } +} + +// ================================================================================================= +// ATTACK 3 — the memo is keyed on PageId, and PageIds are recycled. +// ================================================================================================= + +/// `SubtreeHash`'s memo is a cache keyed on `PageId` with no invalidation. `CowStore` recycles +/// page ids (`store.rs:114` pops `free_pages`; `format_page`'s own comment says "a recycled page +/// may still be cached from its previous life"). A `SubtreeHash` that outlives a recycle therefore +/// hands back the id of content that is no longer on that page. +/// +/// This test forces the recycle and shows the memo returning the OLD content's id, with a freshly +/// built `SubtreeHash` over the same page as the oracle. +#[test] +fn attack3_subtree_hash_memo_goes_stale_when_a_page_id_is_recycled() { + let f = Fixture::new(8); + + // v1: a single-leaf tree holding {a -> 1}. + let e = f.tick(); + let r1 = f.tree.create(BranchId::TRUNK, e).unwrap(); + let e = f.tick(); + let r1 = f.tree.insert(r1, BranchId::TRUNK, e, b"a", b"1").unwrap(); + assert_eq!(f.tree.walk_pages(r1).unwrap(), vec![r1], "expected a single-leaf tree"); + + let h = SubtreeHash::new(f.store_dyn()); + let stale_id = h.stamp(r1).unwrap(); + + // Free everything trunk owns, so r1's id goes back on the free list. + for a in f.store.arenas_of(BranchId::TRUNK).unwrap() { + f.store.free_arena(a).unwrap(); + } + + // Hand the extent to another branch and keep building single-leaf trees until the allocator + // returns the very page id r1 used to be. Bounded, so a store that never reuses fails loudly. + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let mut reused = None; + for _ in 0..2000 { + let e = f.tick(); + let r = f.tree.create(B1, e).unwrap(); + let e = f.tick(); + let r = f.tree.insert(r, B1, e, b"a", b"2").unwrap(); + if r == r1 { + reused = Some(r); + break; + } + } + let reused = reused.expect("page id was never recycled; the premise of this test is false"); + assert_eq!(reused, r1); + + // The page now holds {a -> 2}. A fresh provider says so. + let fresh = SubtreeHash::new(f.store_dyn()); + let true_id = fresh.stamp(r1).unwrap(); + assert_ne!(true_id, stale_id, "control: the two contents must hash differently"); + + // The old provider does not: it answers from the memo without reading the page. + assert_eq!( + h.id_of(r1), + stale_id, + "the memo returned a fresh id; if this fires the staleness has been fixed" + ); +} + +/// The consequence: a stale memo makes `diff` report NO CHANGES where there is one. +#[test] +fn attack3_a_stale_memo_makes_the_diff_silently_report_no_changes() { + let f = Fixture::new(8); + + let e = f.tick(); + let r1 = f.tree.create(BranchId::TRUNK, e).unwrap(); + let e = f.tick(); + let r1 = f.tree.insert(r1, BranchId::TRUNK, e, b"a", b"1").unwrap(); + + let h = SubtreeHash::new(f.store_dyn()); + h.stamp(r1).unwrap(); // memo[r1] = id({a -> 1}) + + for a in f.store.arenas_of(BranchId::TRUNK).unwrap() { + f.store.free_arena(a).unwrap(); + } + + // Rebuild until page id r1 comes back, now holding {a -> 2}. + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let mut reused = None; + for _ in 0..2000 { + let e = f.tick(); + let r = f.tree.create(B1, e).unwrap(); + let e = f.tick(); + let r = f.tree.insert(r, B1, e, b"a", b"2").unwrap(); + if r == r1 { + reused = Some(r); + break; + } + } + let side_a = reused.expect("page id was never recycled"); + + // A second, live tree holding {a -> 1} — exactly what the stale memo believes is on side_a. + f.store.register_branch(B2, Some(BranchId::TRUNK), f.tick()).unwrap(); + let e = f.tick(); + let side_b = f.tree.create(B2, e).unwrap(); + let e = f.tick(); + let side_b = f.tree.insert(side_b, B2, e, b"a", b"1").unwrap(); + assert_ne!(side_a, side_b); + h.stamp(side_b).unwrap(); + + // The oracle: page identity cannot be fooled here, and it finds the change. + let truth = diff(&f.tree, side_a, side_b, &PageIdentity).unwrap(); + assert_eq!(truth.changes.len(), 1, "oracle: there IS exactly one difference"); + + let by_hash = diff(&f.tree, side_a, side_b, &h).unwrap(); + assert_eq!( + by_hash.changes.len(), + 1, + "SILENT WRONGNESS: the stale memo skipped the root and reported {} changes \ + where the truth is 1 ({:?})", + by_hash.changes.len(), + truth.changes + ); +} + +// ================================================================================================= +// ATTACK 5 — subtree_cid through MemoIdentity turns O(delta) into worse-than-O(N). +// ================================================================================================= + +/// `MemoIdentity`'s doc block shows `MemoIdentity::new(|p| cid::subtree_cid(&tree, p))`, in a +/// `ignore` fence, so it is never compiled or run. This compiles it, and measures what warming +/// costs against the O(N) path the module exists to beat. +#[test] +fn attack5_memoidentity_over_subtree_cid_costs_more_than_the_on_path_it_replaces() { + let f = Fixture::new(4096); + let n = 16_000usize; + + let e = f.tick(); + let mut base = f.tree.create(BranchId::TRUNK, e).unwrap(); + for i in 0..n { + let e = f.tick(); + base = f + .tree + .insert(base, BranchId::TRUNK, e, format!("k{i:09}").as_bytes(), b"v000000000-0000") + .unwrap(); + } + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let mut head = base; + for i in [n / 7, n / 3, (n * 2) / 3, n - 1] { + let e = f.tick(); + head = f + .tree + .insert(head, B1, e, format!("k{i:09}").as_bytes(), b"v000000000-0001") + .unwrap(); + } + + let nodes = f.tree.walk_pages(base).unwrap().len(); + + // The control: the O(N) path cow::diff exists to replace. + let t0 = Instant::now(); + let old = f.tree.diff(base, head).unwrap(); + let t_old = t0.elapsed(); + assert_eq!(old.deltas.len(), 4); + + // The new path with the provider the module recommends. + let t0 = Instant::now(); + let r_page = diff(&f.tree, base, head, &PageIdentity).unwrap(); + let t_page = t0.elapsed(); + assert_eq!(r_page.changes.len(), 4); + + // The new path driven by cow::cid, warmed as the docs instruct. + let tree_ref = &f.tree; + let ident = MemoIdentity::new(|p: PageId| cid::subtree_cid(tree_ref, p)); + let t0 = Instant::now(); + let w = ident.warm(&f.tree, base).unwrap() + ident.warm(&f.tree, head).unwrap(); + let t_warm = t0.elapsed(); + let t0 = Instant::now(); + let r_cid = diff(&f.tree, base, head, &ident).unwrap(); + let t_cid_diff = t0.elapsed(); + + // Also the bottom-up provider, for scale. + let sh = SubtreeHash::new(f.store_dyn()); + let t0 = Instant::now(); + sh.stamp(base).unwrap(); + sh.stamp(head).unwrap(); + let t_stamp = t0.elapsed(); + + assert_eq!(r_cid.changes.len(), 4, "the cid-driven diff got the answer wrong"); + assert_eq!(ident.misses(), 0, "warming was incomplete"); + + println!(" n={n} tree_nodes={nodes} warmed={w}"); + println!(" CONTROL CowTree::diff (the O(N) path) : {t_old:?}"); + println!(" NEW diff + PageIdentity (no precompute) : {t_page:?}"); + println!(" NEW diff + MemoIdentity(subtree_cid) : {t_cid_diff:?} diff-only"); + println!(" ...its mandatory warm() : {t_warm:?} <-- the real cost"); + println!(" NEW SubtreeHash::stamp (bottom-up, O(N)) : {t_stamp:?}"); + println!( + " warm(subtree_cid) / CowTree::diff = {:.1}x", + t_warm.as_secs_f64() / t_old.as_secs_f64() + ); + println!( + " warm(subtree_cid) / SubtreeHash::stamp = {:.1}x", + t_warm.as_secs_f64() / t_stamp.as_secs_f64() + ); +} + +// ================================================================================================= +// ATTACK 4 — the unknown-page fallback. +// ================================================================================================= + +/// Byte 0 reservation: a page-identity fallback can never equal a content id, and two distinct +/// unstamped pages never collide. Forced over a real tree rather than asserted. +#[test] +fn attack4_the_fallback_reservation_holds_over_a_real_tree() { + let f = Fixture::new(4096); + let e = f.tick(); + let mut root = f.tree.create(BranchId::TRUNK, e).unwrap(); + for i in 0..4000 { + let e = f.tick(); + root = f + .tree + .insert(root, BranchId::TRUNK, e, format!("k{i:09}").as_bytes(), b"v") + .unwrap(); + } + let pages = f.tree.walk_pages(root).unwrap(); + assert!(pages.len() > 50); + + let h = SubtreeHash::new(f.store_dyn()); + // Every unstamped page is a distinct fallback. + let mut fallbacks: Vec<[u8; 16]> = pages.iter().map(|p| h.id_of(*p)).collect(); + assert!(fallbacks.iter().all(|i| i[0] == 0x01), "a fallback left the page domain"); + fallbacks.sort(); + let before = fallbacks.len(); + fallbacks.dedup(); + assert_eq!(before, fallbacks.len(), "two unstamped pages collided"); + + // Stamp half the tree, then check no content id can equal any fallback. + h.stamp(root).unwrap(); + let content: Vec<[u8; 16]> = pages.iter().map(|p| h.id_of(*p)).collect(); + assert!(content.iter().all(|i| i[0] == 0x00), "a stamped page left the content domain"); + for c in &content { + assert!(!fallbacks.contains(c), "a content id equalled a page-identity fallback"); + } +} + +// ================================================================================================= +// ATTACK 1 (cross-lineage half) — where the digest is the SOLE authority. +// ================================================================================================= + +/// Within one lineage the digest wins nothing: `PageIdentity` decides first. Cross-lineage is the +/// path `cid.rs` built the digest FOR, and there page identity wins zero skips — so the digest is +/// the only thing standing between the caller and a wrong answer, with no verifying comparison +/// behind it (`grep -n 'verify' src/cow/diff.rs` is empty). +#[test] +fn attack1_cross_lineage_is_where_the_digest_becomes_the_sole_authority() { + let f = Fixture::new(4096); + let n = 1000u32; + + // Two lineages, same rows, different insertion orders. Nothing is shared between them. + let e = f.tick(); + let mut a = f.tree.create(BranchId::TRUNK, e).unwrap(); + for i in 0..n { + let e = f.tick(); + a = f.tree.insert(a, BranchId::TRUNK, e, &i.to_be_bytes(), format!("v{i}").as_bytes()).unwrap(); + } + + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let e = f.tick(); + let mut b = f.tree.create(B1, e).unwrap(); + let mut order: Vec = (0..n).collect(); + let mut s: u32 = 0x5eed_1234; + for i in (1..order.len()).rev() { + s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + order.swap(i, (s >> 8) as usize % (i + 1)); + } + for &i in &order { + let e = f.tick(); + b = f.tree.insert(b, B1, e, &i.to_be_bytes(), format!("v{i}").as_bytes()).unwrap(); + } + + let pages_a: std::collections::HashSet = f.tree.walk_pages(a).unwrap().into_iter().collect(); + let pages_b: std::collections::HashSet = f.tree.walk_pages(b).unwrap().into_iter().collect(); + assert_eq!(pages_a.intersection(&pages_b).count(), 0, "the two lineages share pages"); + + // Control: the two trees really do hold identical data. + assert_eq!( + cid::leaf_content_cid(&f.tree, a).unwrap(), + cid::leaf_content_cid(&f.tree, b).unwrap(), + "control: the two trees do not hold the same rows" + ); + + let by_page = diff(&f.tree, a, b, &PageIdentity).unwrap(); + let h = SubtreeHash::new(f.store_dyn()); + h.stamp(a).unwrap(); + h.stamp(b).unwrap(); + let by_hash = diff(&f.tree, a, b, &h).unwrap(); + + println!(" cross-lineage, {} rows, {} + {} nodes", n, pages_a.len(), pages_b.len()); + println!(" PageIdentity : visited={:>5} skipped_subtrees={:>4}", by_page.visited, by_page.skipped_subtrees); + println!(" SubtreeHash : visited={:>5} skipped_subtrees={:>4}", by_hash.visited, by_hash.skipped_subtrees); + + assert!(by_page.changes.is_empty() && by_hash.changes.is_empty(), "identical data must diff empty"); + assert_eq!(by_page.skipped_subtrees, 0, "page identity skipped something across lineages"); + assert!( + by_hash.skipped_subtrees > 0, + "the digest won no skips either — then cross-lineage is not the digest's path after all" + ); + assert!( + by_hash.visited < by_page.visited, + "the digest did not reduce the read set: {} vs {}", + by_hash.visited, + by_page.visited + ); +} + +/// What a collision COSTS, measured rather than argued. The collision is INJECTED (a 120-bit one +/// cannot be found in a test), so this measures the diff's behaviour under one, not the hash's +/// strength: does a collision produce a loud error or a silently dropped change? +#[test] +fn attack2_a_collision_drops_the_change_silently_rather_than_erroring() { + struct Colliding; + impl NodeIdentity for Colliding { + fn id_of(&self, _page: PageId) -> [u8; 16] { + // One content-domain value for every page: what a total collision looks like. + [0u8; 16] + } + } + + let f = Fixture::new(4096); + let e = f.tick(); + let mut base = f.tree.create(BranchId::TRUNK, e).unwrap(); + for i in 0..2000u32 { + let e = f.tick(); + base = f.tree.insert(base, BranchId::TRUNK, e, &i.to_be_bytes(), b"v0").unwrap(); + } + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let e = f.tick(); + let head = f.tree.insert(base, B1, e, &7u32.to_be_bytes(), b"v1").unwrap(); + + let truth = diff(&f.tree, base, head, &PageIdentity).unwrap(); + assert_eq!(truth.changes.len(), 1, "oracle"); + + let r = diff(&f.tree, base, head, &Colliding); + match r { + Err(e) => println!(" a collision is DETECTED: {e}"), + Ok(rep) => println!( + " a collision is SILENT: {} changes reported, truth is {}; visited={} skipped={}", + rep.changes.len(), + truth.changes.len(), + rep.visited, + rep.skipped_subtrees + ), + } +} + +/// `cid.rs` has an avalanche test. `diff.rs`'s own digest has none — measure it through the only +/// public surface it has. +#[test] +fn attack2_diff_rs_digest_avalanche_measured_through_stamp() { + let f = Fixture::new(4096); + let h = SubtreeHash::new(f.store_dyn()); + + let mut worst = 128u32; + let mut roots = Vec::new(); + for byte in 0u8..32 { + for bit in 0..8u8 { + for val in [byte, byte ^ (1 << bit)] { + let e = f.tick(); + let r = f.tree.create(BranchId::TRUNK, e).unwrap(); + let e = f.tick(); + let r = f.tree.insert(r, BranchId::TRUNK, e, b"k", &[val]).unwrap(); + roots.push(r); + } + let b0 = h.stamp(roots[roots.len() - 2]).unwrap(); + let b1 = h.stamp(roots[roots.len() - 1]).unwrap(); + let d: u32 = b0.iter().zip(b1.iter()).map(|(x, y)| (x ^ y).count_ones()).sum(); + worst = worst.min(d); + } + } + println!(" diff.rs SubtreeHash avalanche: worst-case {worst} of 128 output bits (byte 0 is a fixed tag, so 120 are live)"); + assert!(worst >= 40, "worst-case avalanche only {worst}/128 — diff.rs's digest is not mixing"); +} + +/// Price the upgrade the module header calls "a local change": SHA-256 already ships in this repo, +/// dependency-free with FIPS 180-4 vectors. +#[test] +fn attack2_price_the_sha256_upgrade() { + use ferrodb::provenance::sha256::Sha256; + + let payload: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let reps = 20_000; + + let t0 = Instant::now(); + let mut acc = 0u8; + for _ in 0..reps { + let c = cid::leaf_cid(&[(b"k".to_vec(), payload.clone())]); + acc ^= c[0]; + } + let t_hand = t0.elapsed(); + + let t0 = Instant::now(); + for _ in 0..reps { + let mut s = Sha256::new(); + s.update(b"k"); + s.update(&payload); + acc ^= s.finish()[0]; + } + let t_sha = t0.elapsed(); + + println!(" {} x 4KiB Hasher128(leaf_cid) : {:?}", reps, t_hand); + println!(" {} x 4KiB Sha256 : {:?}", reps, t_sha); + println!(" sha256 / hand-rolled = {:.2}x (acc={acc}, keeps both loops live)", t_sha.as_secs_f64() / t_hand.as_secs_f64()); +} + +/// Attack 5, as COUNTS rather than times. This box is shared with an agent fleet and the wall-clock +/// control swung 34x between two runs of the same code, so the timing comparison is not quotable. +/// Page reads are deterministic: they do not move when the machine is busy. +#[test] +fn attack5_warming_subtree_cid_reads_the_tree_once_per_level_counted_not_timed() { + let f = Fixture::new(4096); + let n = 16_000usize; + + let e = f.tick(); + let mut base = f.tree.create(BranchId::TRUNK, e).unwrap(); + for i in 0..n { + let e = f.tick(); + base = f.tree.insert(base, BranchId::TRUNK, e, format!("k{i:09}").as_bytes(), b"v0").unwrap(); + } + + let pages = f.tree.walk_pages(base).unwrap(); + + // What MemoIdentity::warm costs: subtree_cid(p) re-walks p's whole subtree, for every p. + let warm_reads: usize = pages.iter().map(|p| f.tree.walk_pages(*p).unwrap().len()).sum(); + // What SubtreeHash::stamp costs: each page read exactly once, bottom-up. + let stamp_reads = pages.len(); + // What the old O(N) path costs before it can prune: both roots enumerated. + let old_reads = pages.len() * 2; + // What the diff itself costs with 4 rows changed. + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let mut head = base; + for i in [n / 7, n / 3, (n * 2) / 3, n - 1] { + let e = f.tick(); + head = f.tree.insert(head, B1, e, format!("k{i:09}").as_bytes(), b"v1").unwrap(); + } + let r = diff(&f.tree, base, head, &PageIdentity).unwrap(); + assert_eq!(r.changes.len(), 4); + + println!(" n={n} tree nodes={} depth-driven fan-in", pages.len()); + println!(" diff itself (PageIdentity, 4 rows changed) : {:>8} node payloads decoded", r.visited); + println!(" CowTree::diff enumeration (the O(N) path) : {old_reads:>8} page reads"); + println!(" SubtreeHash::stamp precompute : {stamp_reads:>8} page reads"); + println!(" MemoIdentity(subtree_cid).warm precompute : {warm_reads:>8} page reads"); + println!(" warm / stamp = {:.1}x", warm_reads as f64 / stamp_reads as f64); + println!(" warm / O(N) control = {:.1}x", warm_reads as f64 / old_reads as f64); + println!(" warm / the diff it enables = {:.0}x", warm_reads as f64 / r.visited as f64); + + assert!( + warm_reads > old_reads, + "warming the cid adapter ({warm_reads}) was cheaper than the O(N) path it replaces \ + ({old_reads}); the O(N-per-level) claim is wrong" + ); +} + +/// The sharper half of attack 3: `MemoIdentity::warm` is the one call that looks like it would +/// refresh a stale entry, and it does not — `diff.rs:250` skips any page already in the map +/// (`if self.memo.read().unwrap().contains_key(&p) { continue; }`), so re-warming after a recycle +/// is a no-op and `misses()` stays at 0. A caller doing the documented thing gets a wrong answer +/// with every counter reading clean. +#[test] +fn attack3_rewarming_does_not_refresh_a_recycled_page_and_misses_stays_zero() { + let f = Fixture::new(8); + + let e = f.tick(); + let r1 = f.tree.create(BranchId::TRUNK, e).unwrap(); + let e = f.tick(); + let r1 = f.tree.insert(r1, BranchId::TRUNK, e, b"a", b"1").unwrap(); + + let tree_ref = &f.tree; + let ident = MemoIdentity::new(|p: PageId| cid::subtree_cid(tree_ref, p)); + ident.warm(&f.tree, r1).unwrap(); + let stale = ident.id_of(r1); + + for a in f.store.arenas_of(BranchId::TRUNK).unwrap() { + f.store.free_arena(a).unwrap(); + } + f.store.register_branch(B1, Some(BranchId::TRUNK), f.tick()).unwrap(); + let mut reused = None; + for _ in 0..2000 { + let e = f.tick(); + let r = f.tree.create(B1, e).unwrap(); + let e = f.tick(); + let r = f.tree.insert(r, B1, e, b"a", b"2").unwrap(); + if r == r1 { + reused = Some(r); + break; + } + } + let r1b = reused.expect("page id was never recycled"); + + // The documented remedy: warm it again. + let added = ident.warm(&f.tree, r1b).unwrap(); + println!(" re-warm added {added} entries; misses()={}", ident.misses()); + println!(" true cid now : {}", cid::hex(&cid::subtree_cid(&f.tree, r1b).unwrap())); + println!(" memo still says: {}", cid::hex(&ident.id_of(r1b))); + + assert_ne!( + ident.id_of(r1b), + stale, + "re-warming did not refresh the recycled page: warm() added {added} entries and \ + misses() is {}, so every counter reads clean while the id is wrong", + ident.misses() + ); +} From a35c76caa74a177baaa25443964f2e849ed7cc02 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:28:04 -0400 Subject: [PATCH 030/315] D102: bound decode's run count by the format, and certify the tree PageDelta::decode sized a Vec from a run count read off a disk page - up to 65535, about 2 MB, for a record that cannot exist. Every run costs at least RUN_FRAME bytes inside a PAYLOAD_LEN payload, so the format bounds it at 1018; the count is now checked against that before it sizes anything. It refuses the same records the run loop would have refused, one byte earlier. Its test asserts both halves: an impossible count is refused, and the largest expressible count still fails on its missing run bytes rather than on the cap. The census now prints stale_delta_base_count(). It reads 0 on this workload, which is what the epoch interval rule predicts, and the artifact says why that zero is a fact about the workload rather than a dead counter: the refusal is forced to fire in a unit test that recycles a base id deliberately. Certification, banked in bench/d102_cow_census.txt: at 40d8952 the lib suite is 1552 passed / 0 failed and 123 of 124 integration targets are green, the one non-zero being an external SIGTERM (rc=143, not this script's own timeout's 124) that re-runs green standalone at 14 passed. The tree is the same sha and clean at the start and the end of that run, which is what makes it certify a sha rather than a mood. After it, only this commit's decode change landed, whose function has no caller outside delta.rs's test module; the lib suite at the final tree is 1553 passed / 0 failed. The census reproduces identically from three separately built binaries at three commits: r=1 shadows one page, copies 4072 B, encodes to 52 B. --- bench/d102_cow_census.txt | 37 ++++++++++++++++++++++++++++++++++- examples/d102_cow_census.rs | 20 ++++++++++++++++++- src/branch/delta.rs | 39 +++++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/bench/d102_cow_census.txt b/bench/d102_cow_census.txt index 12bda7c5..7bf06b75 100644 --- a/bench/d102_cow_census.txt +++ b/bench/d102_cow_census.txt @@ -1,5 +1,5 @@ D102 — WHICH ARM OF cow_page DOES AN AGENT MERGE TAKE, AND WHAT WOULD IT ENCODE TO? -Harness: examples/d102_cow_census.rs. Measured at 7361e1f (the D102 commit), on a +Harness: examples/d102_cow_census.rs. RAW OUTPUT BELOW IS FROM THE FINAL TREE, on a 20,000-row table, r = rows changed, swept 1 -> 512 by doubling. Each r: fork a branch, UPDATE exactly r scattered rows (i -> 1 + i*7919 mod N, injective, D90's scatter), MERGE. @@ -66,6 +66,11 @@ built; 1 live arena pages bases they encode to 2412 B, with 0 refused for exceeding the budget. ⇒ 16.9x less, measured on pages the engine really produced. + stale delta bases: 0 — no recorded base had its page id reissued, which is + what the epoch interval rule predicts. The refusal is proven to fire in + branch::arena::tests::a_base_whose_id_was_reissued_is_refused_rather_than_encoded_against, + so this zero is a fact about the workload and not about a dead counter. + ⚠ THAT RATIO IS OCCUPANCY, NOT BYTES STORED, AND THE TWO ARE NOT THE SAME HERE. Each shadow above is ONE page, and one page is the smallest thing DiskManager writes. D90's headline is `distinct pages x 4096 + WAL`, so a page whose useful @@ -82,3 +87,33 @@ REFUSED: a warm-up cycle did not complete; no steady state was reached. exit 1. 46270 is 6610*7, the row's build-time value: the merge genuinely moved nothing while reporting applied_to_target. Without the flag the same binary completes the sweep and exits 0, so the guard does not fire spuriously. + +REPRODUCED THREE TIMES, from three separately built binaries, at three commits along this +branch (before the stale-base check, after it, and at the final tree). Every DELTA B value +is identical across all three: r=1 -> 52 B, and 2412 B over the sweep. Counters, not +timings, so the box being at load 12-21 with a fleet running does not enter. + +================================================================================ +FULL-SUITE CERTIFICATION at 40d89525e23a095c1de7483fa171ac3355b0fe53 + +CERTIFYING 40d89525e23a095c1de7483fa171ac3355b0fe53 +build rc=0 +LIB rc=0 | test result: ok. 1552 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 109.08s + +124 integration targets: 123 green on the first pass. The one non-zero was + adversarial_i20 rc=143 (143 = 128+15 = SIGTERM, killed externally mid-run; + the fleet does this. It is NOT a test failure and NOT a timeout, which would + have been rc=124 from this script's own `timeout 600`.) +Re-run standalone: adversarial_i20 ok. 14 passed; 0 failed; 1 ignored (195s) + +The tree did not move under the run, which is what makes this certify a SHA: +TREE AT END: 40d89525e23a095c1de7483fa171ac3355b0fe53 0 dirty + +d59_snapshot_cache PASSED in this run (9 passed) after failing in an earlier one, +which is the same load-flakiness bench/d102_d59_flaky.txt records with three reps. + +AFTER this run, one further change landed (PageDelta::decode bounds run_count by +the format before it sizes an allocation) plus its test. Integration coverage is +unaffected by construction: PageDelta::decode has no caller outside delta.rs's own +test module - verified by grep over src/, examples/ and tests/. The lib suite was +re-run at that final tree: 1553 passed; 0 failed; 3 ignored, cargo rc=0. diff --git a/examples/d102_cow_census.rs b/examples/d102_cow_census.rs index 06e29376..2b87cf5a 100644 --- a/examples/d102_cow_census.rs +++ b/examples/d102_cow_census.rs @@ -53,7 +53,9 @@ use std::sync::Arc; use ferrodb::agent_sql::dispatch::AgentOutput; use ferrodb::agent_sql::runtime::AgentRuntime; -use ferrodb::branch::arena::{cow_path_census, reset_cow_path_census, ArenaPageStore}; +use ferrodb::branch::arena::{ + cow_path_census, reset_cow_path_census, stale_delta_base_count, ArenaPageStore, +}; use ferrodb::branch::table_catalog::TableBranchCatalog; use ferrodb::branch::BranchCatalog; use ferrodb::buffer::buffer_pool::BufferPoolManager; @@ -382,6 +384,22 @@ fn main() { if deltas > 0 { println!(" ⇒ {:.1}x less, measured on pages the engine really produced.", copied as f64 / deltas as f64); } + // **A base whose page id was reissued is refused, and this says whether that ever + // happened.** The argument that it cannot reach a recorded base spans arena.rs and the + // reaper; a counter reading zero because the situation never arose is the honest form of + // that argument, and a non-zero here would mean whole pages are being stored silently. + let stale = stale_delta_base_count(); + println!(); + if stale == 0 { + println!(" stale delta bases: 0 — no recorded base had its page id reissued, which is"); + println!(" what the epoch interval rule predicts. The refusal is proven to fire in"); + println!(" branch::arena::tests::a_base_whose_id_was_reissued_is_refused_rather_than_encoded_against,"); + println!(" so this zero is a fact about the workload and not about a dead counter."); + } else { + println!(" ⚠ stale delta bases: {stale}. A recorded base had its page id REISSUED, so a"); + println!(" delta was refused and a whole page stored. That is the safe direction, but the"); + println!(" epoch interval rule was expected to make it unreachable — investigate."); + } println!(); println!(" ⚠ THAT RATIO IS OCCUPANCY, NOT BYTES STORED, AND THE TWO ARE NOT THE SAME HERE."); println!(" Each shadow above is ONE page, and one page is the smallest thing DiskManager"); diff --git a/src/branch/delta.rs b/src/branch/delta.rs index f5543a1d..df7e725e 100644 --- a/src/branch/delta.rs +++ b/src/branch/delta.rs @@ -336,6 +336,20 @@ impl PageDelta { let base = PageId::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); let depth = bytes[4]; let run_count = u16::from_le_bytes([bytes[5], bytes[6]]) as usize; + // **The count is checked against the FORMAT before it sizes an allocation.** These bytes + // came off a disk page, so `run_count` is whatever corruption put there, up to 65535. Every + // run costs at least `RUN_FRAME` bytes of framing inside a `PAYLOAD_LEN` payload, so a + // record can hold at most that many and a larger claim is already impossible rather than + // merely unlikely. Refusing here keeps `with_capacity` below bounded by the encoding + // instead of by `u16`, and refuses the same record the run loop would have refused anyway + // — one byte later and one 2 MB allocation poorer. + const MAX_RUNS: usize = PAYLOAD_LEN / RUN_FRAME; + if run_count > MAX_RUNS { + return Err(FerroError::Cow(format!( + "delta record claims {run_count} runs; a {PAYLOAD_LEN}-byte payload holds at most \ + {MAX_RUNS}" + ))); + } let mut runs = Vec::with_capacity(run_count); let mut at = DELTA_HEADER; for i in 0..run_count { @@ -917,6 +931,31 @@ mod delta_tests { assert_eq!(refused, n, "every truncation must have been exercised"); } + /// A run count larger than the payload could ever hold is refused before it sizes anything. + /// + /// The bytes come off a disk page, so the count is whatever corruption put there — up to + /// 65535, which would reserve about 2 MB for a record that cannot exist. Every run costs at + /// least `RUN_FRAME` bytes inside a `PAYLOAD_LEN` payload, so the format itself bounds it. + #[test] + fn a_record_claiming_more_runs_than_a_payload_can_hold_is_refused() { + let impossible = (PAYLOAD_LEN / RUN_FRAME) + 1; + let mut buf = vec![0u8; DELTA_HEADER]; + buf[0..4].copy_from_slice(&1u32.to_le_bytes()); + buf[4] = 1; + buf[5..7].copy_from_slice(&(impossible as u16).to_le_bytes()); + let got = PageDelta::decode(&buf); + assert!(got.is_err(), "a {impossible}-run claim must be refused, got {got:?}"); + + // And the largest count the format CAN express is still parsed on its merits rather than + // rejected by the cap — it fails on the missing run bytes, which is the run loop's job. + let mut ok_count = buf.clone(); + ok_count[5..7].copy_from_slice(&((PAYLOAD_LEN / RUN_FRAME) as u16).to_le_bytes()); + assert!( + PageDelta::decode(&ok_count).is_err(), + "a header-only buffer claiming the maximum run count must still refuse on the runs" + ); + } + /// A run that claims to land past the end of the payload is refused, not clamped. #[test] fn a_record_claiming_a_run_past_the_payload_is_refused() { From dc60212d95d3dde30979d54f52ab89d98eb8772d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:56:07 -0400 Subject: [PATCH 031/315] bench(D115): scope the result to its base -- main moved to dc2a2cf mid-run Checked by diff, not assumed. stage_all is byte-identical at 8249c50 and dc2a2cf and tel/log.rs is untouched, so the clone/drop/append terms carry forward. But src/cow/{btree,node,mod,cid, diff,merge3}.rs changed under the D113 lane and the CoW mirror descends through them on every put_row, so the MIRROR column is scoped to 8249c50 and must be re-run before being quoted against current main. --- bench/d115_frame_clone.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bench/d115_frame_clone.md b/bench/d115_frame_clone.md index 9ac6766a..3df4ced0 100644 --- a/bench/d115_frame_clone.md +++ b/bench/d115_frame_clone.md @@ -24,6 +24,17 @@ was written, deliberately. Raw runs, both directions, in `bench/d115_before.txt` `bench/d115_real_mem.txt`, `bench/d115_real_durable.txt`, `bench/d115_crossover.txt`; `bench/d115_run4.sh` is the driver. +⚠ **`main` moved during this run** — from `8249c50` to `dc2a2cf` (the D114 and D99 lanes landed). +What that does and does not do to these numbers, checked by diff rather than assumed: + +* **`stage_all` is byte-identical** at both commits, and `src/tel/log.rs` is untouched. The only + `runtime.rs` change is D114's `ours_scan` counters at three merge-path sites. So the clone, drop + and append terms transfer unchanged. +* ⛔ **`src/cow/{btree,node,mod,cid,diff,merge3}.rs` DID change** (the D113 compact-refusal lane). + The CoW mirror descends through those on every `put_row`, so **the mirror term specifically may + differ on current main and is not carried forward by this run.** Re-run arm 1 before quoting the + mirror column against `dc2a2cf` or later. + **Suite:** the instrument commit is `a19e881` on `D115-frame-clone`; `cargo test --release --lib` there reports **`ok. 1599 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out`**, `rc=0`, `head=a19e881` (`bench/d115_verify_full.sh`). Nothing here is filtered: the instrument adds From 5ca301a8c387c4a1a487a4d2d028daf7cd6e354d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:56:30 -0400 Subject: [PATCH 032/315] bench(D115): attribute the durable append to encode+write+fsync, not to fsync alone The header said 'one fsync per statement'. The measurement bounds the comparison component at <=2.4% and the rest at >=97.6% of encode + pwrite + sync_data together; it does not separate those three. Stated as measured. --- bench/d115_frame_clone.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bench/d115_frame_clone.md b/bench/d115_frame_clone.md index 3df4ced0..c2f40168 100644 --- a/bench/d115_frame_clone.md +++ b/bench/d115_frame_clone.md @@ -9,8 +9,9 @@ > > **It is also 3.2% of the statement path on the configuration ferrodb ships, and the writes phase > on that configuration is already LINEAR (exponent 1.01) before anything is changed.** The cost is -> one fsync per statement in `DurableEffectLog::append`, which is 92.6% of `stage_all` and which -> that store's own comment describes as the deliberate price of its guarantee. +> `DurableEffectLog::append` at **4.057 ms per statement — 92.6% of `stage_all`** — of which ≥97.6% +> is encode + `pwrite` + `sync_data` (bounded from a counter, §3) and not comparison. That store's +> own comment calls the per-statement fsync the deliberate price of its guarantee. > > **Do not "fix" the clone.** Removing it buys ~6% at 8192 ops. On the shipped store the clone does > not overtake the per-statement fsync until **~122,000 ops in a single agent session**. From ed598e4c23e380dad30c22decfc912f01dbcb7c2 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:23:04 -0400 Subject: [PATCH 033/315] E.6: census the outer statement mutex on the production fork path Count, not a timing. `ServerContext::catalog()` gains one relaxed increment on a process-global `AtomicU64`, compiled in only under the new off-by-default `lock_census` feature, plus `pgwire::catalog_acquisitions() -> Option` to read it. `None` rather than `0` when the feature is off: "not instrumented" and "zero acquisitions" are different facts and a harness must be able to refuse on the first. Nothing in the crate reads the counter and no control flow branches on it, so `catalog()` behaves identically either way. `examples/e6_outer_lock_count.rs` drives four arms against that one static in one process: the shipped pgwire server over a real TCP socket with forks issued as `BEGIN AGENT SESSION` (persistent connection, and connection-per-fork), a `SELECT 1` control, and `TableBranchCatalog::fork` called directly the way `examples/fork_concurrency.rs` calls it. The counter is proven live before any arm reads zero: one explicit `ctx.catalog()` must move it by exactly 1 or the harness exits 1. --- Cargo.toml | 7 + examples/e6_outer_lock_count.rs | 636 ++++++++++++++++++++++++++++++++ src/pgwire/mod.rs | 41 ++ 3 files changed, 684 insertions(+) create mode 100644 examples/e6_outer_lock_count.rs diff --git a/Cargo.toml b/Cargo.toml index 91402c88..ceda852c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,13 @@ name = "ferrodb" version = "0.1.0" edition = "2024" +[features] +# E.6 instrument. See `pgwire::catalog_acquisitions`. Off by default: the path it sits on is +# published in NANOSECONDS by `examples/d99_statement_path_walls.rs` (`ns/catalog()`), and an +# unconditional atomic RMW there would perturb a landed number to answer a question that is a +# COUNT and does not need it perturbed. +lock_census = [] + [dependencies] [dev-dependencies] diff --git a/examples/e6_outer_lock_count.rs b/examples/e6_outer_lock_count.rs new file mode 100644 index 00000000..d7dbfeaa --- /dev/null +++ b/examples/e6_outer_lock_count.rs @@ -0,0 +1,636 @@ +//! E.6 — is every production fork mediated by the outer statement mutex? A COUNT, not a timing. +//! +//! ```text +//! cargo run --release --features lock_census --example e6_outer_lock_count -- [F] [T,T,T,T] +//! ``` +//! +//! # WHICH LAYER EACH ARM DRIVES, AND WHICH LOCKS THAT LAYER HOLDS IN PRODUCTION +//! +//! This section is not decoration. E.6 exists because `examples/fork_concurrency.rs` — the +//! harness behind the 21x/1.9x fork-concurrency ceiling — states its N and its object and never +//! states its LAYER, and the layer is the whole disagreement. Every arm below therefore names +//! both, and a reader who disbelieves the headline can check the claim against the body. +//! +//! | arm | layer driven | locks that layer holds IN PRODUCTION | +//! |-----|--------------|--------------------------------------| +//! | A1 | the shipped pgwire server, over a real TCP socket, one client connection per thread, forks issued as SQL | `ServerContext::catalog()` — the outermost per-statement mutex (`src/pgwire/mod.rs`, `serve`'s lock-ordering note: *"taken outermost, for the duration of a single statement … Nothing beneath it ever takes it back"*) → `AgentRuntime`'s `Mutex` → `TableBranchCatalog`'s `logical` | +//! | A2 | the same server; one connection per fork, a single `BEGIN AGENT SESSION` on each | identical to A1 | +//! | A0 | the same server, same client shape as A2, `SELECT 1;` in place of the fork — **the control** | identical to A1; the statement is a read, so it may take the shared read path instead | +//! | B | `TableBranchCatalog` directly, as `examples/fork_concurrency.rs` does at `:26`, `:32`, `:101` | `logical` ONLY. No `ServerContext`, no statement mutex, no connection. **No shipped front-end reaches `fork` this way**; the configuration that does is an embedded `AgentRuntime` caller. | +//! +//! Arms A1/A2/A0 build the branch catalog with `TableBranchCatalog::default_for_database`, the +//! shape `examples/pgserver.rs` ships; arm B uses `open_sidecar`, the shape `fork_concurrency.rs` +//! uses today. Both are the same type and the same `fork`. The difference is stated rather than +//! smoothed over, because it is not the difference the result turns on: arm B cannot reach the +//! counted lock under either constructor. +//! +//! # THE INSTRUMENT +//! +//! A process-global `AtomicU64` incremented inside `ServerContext::catalog()` itself — so the +//! census is complete by construction rather than by having found every call site — read as a +//! DELTA across each arm. Compiled in only under `--features lock_census`; an uninstrumented run +//! reads `None` and this harness exits non-zero rather than printing a zero it cannot justify. +//! +//! **Counts, not durations.** An integer is load-immune: this does not need a quiet box and does +//! not need the fleet's measure-lock. No number in this harness's output is a time, and that is +//! deliberate — a millisecond here would be a claim this experiment has not earned. +//! +//! # FORCING THE DETECTOR TO FIRE +//! +//! A zero from arm B is worthless on its own: a counter that is never incremented reads exactly +//! like a lock that is never taken. Three things make the zero mean something. +//! +//! 1. `self_check` asserts that one explicit `ctx.catalog()` moves the counter by exactly 1. +//! 2. Arms A and B run in the SAME PROCESS against the SAME static. A's non-zero is the proof +//! that the instrument was live at the moment B read zero. +//! 3. B's arm runs with a pgwire server built and listening. It reads zero because nothing +//! connects to it, not because there is nothing to count. +//! +//! # WHAT IT REFUSES +//! +//! - An uninstrumented build (`catalog_acquisitions() == None`): exit 1. A run that collected +//! nothing has not passed. +//! - Any client-side SQL error: exit 1. A refused `BEGIN AGENT SESSION` is a fork that did not +//! happen, and averaging it in would report a mediated path as an unmediated one. +//! - A disagreement between the loop counter and the branch identities the SERVER reported: +//! exit 1. The fork total must not come from this harness's own bookkeeping alone. + +use std::collections::BTreeSet; +use std::io::{BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, TableBranchCatalog}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::PageStore; +use ferrodb::pgwire::{catalog_acquisitions, serve, ServerContext}; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::MemEffectLog; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const FIRST_CATALOG_PAGE_ID: u32 = 1; +const PROTOCOL_V3: i32 = 196_608; + +// --------------------------------------------------------------------------------------------- +// the instrument +// --------------------------------------------------------------------------------------------- + +/// Exclusive acquisitions so far, or refuse: `None` means this binary was built without +/// `--features lock_census` and has measured nothing at all. +fn acquisitions() -> u64 { + match catalog_acquisitions() { + Some(n) => n, + None => { + eprintln!( + "e6: REFUSING. `pgwire::catalog_acquisitions()` is None, so this binary was built \ + without `--features lock_census` and the census is not compiled in. That is not \ + zero acquisitions; it is no measurement. Re-run with:\n \ + cargo run --release --features lock_census --example e6_outer_lock_count" + ); + std::process::exit(1); + } + } +} + +// --------------------------------------------------------------------------------------------- +// a minimal protocol-v3 frontend +// --------------------------------------------------------------------------------------------- +// +// Hand-rolled because this repo has zero runtime dependencies and adding a driver to answer a +// counting question would be the larger change. It is a real client on a real socket: startup +// packet, simple `Query`, replies read to `ReadyForQuery`. Nothing here reaches into the server's +// internals, which is the entire point — arm A must drive the shipped path or it is not arm A. + +struct Client { + w: TcpStream, + r: BufReader, +} + +/// What one round trip returned: every text field of every `DataRow`, and the first error. +struct Reply { + texts: Vec, + error: Option, +} + +impl Client { + fn connect(addr: std::net::SocketAddr) -> std::io::Result { + let w = TcpStream::connect(addr)?; + w.set_nodelay(true)?; + let r = BufReader::new(w.try_clone()?); + let mut c = Client { w, r }; + let mut body = Vec::new(); + body.extend_from_slice(&PROTOCOL_V3.to_be_bytes()); + for (k, v) in [("user", "e6"), ("database", "e6")] { + body.extend_from_slice(k.as_bytes()); + body.push(0); + body.extend_from_slice(v.as_bytes()); + body.push(0); + } + body.push(0); + c.w.write_all(&((body.len() + 4) as i32).to_be_bytes())?; + c.w.write_all(&body)?; + c.w.flush()?; + let hello = c.read_to_ready()?; + if let Some(e) = hello.error { + return Err(std::io::Error::other(format!("startup refused: {e}"))); + } + Ok(c) + } + + fn query(&mut self, sql: &str) -> std::io::Result { + let mut body = Vec::with_capacity(sql.len() + 1); + body.extend_from_slice(sql.as_bytes()); + body.push(0); + self.w.write_all(b"Q")?; + self.w.write_all(&((body.len() + 4) as i32).to_be_bytes())?; + self.w.write_all(&body)?; + self.w.flush()?; + self.read_to_ready() + } + + /// `Terminate`. Sent explicitly so the server's connection thread returns before this arm's + /// counter is read, rather than being reaped by a dropped socket at an unknown moment. + fn terminate(&mut self) -> std::io::Result<()> { + self.w.write_all(b"X")?; + self.w.write_all(&4i32.to_be_bytes())?; + self.w.flush() + } + + fn read_to_ready(&mut self) -> std::io::Result { + let mut out = Reply { texts: Vec::new(), error: None }; + loop { + let mut tag = [0u8; 1]; + self.r.read_exact(&mut tag)?; + let mut lenb = [0u8; 4]; + self.r.read_exact(&mut lenb)?; + let len = i32::from_be_bytes(lenb); + if len < 4 { + return Err(std::io::Error::other(format!("backend message length {len}"))); + } + let mut body = vec![0u8; (len - 4) as usize]; + self.r.read_exact(&mut body)?; + match tag[0] { + b'Z' => return Ok(out), + b'E' => { + if out.error.is_none() { + out.error = Some(decode_error(&body)); + } + } + b'D' => decode_row(&body, &mut out.texts), + _ => {} + } + } + } +} + +/// `ErrorResponse` is (byte code, C string)* terminated by a zero byte; `M` is the message. +fn decode_error(body: &[u8]) -> String { + let mut i = 0usize; + let mut msg = String::new(); + while i < body.len() && body[i] != 0 { + let code = body[i]; + i += 1; + let start = i; + while i < body.len() && body[i] != 0 { + i += 1; + } + let val = String::from_utf8_lossy(&body[start..i]).to_string(); + i += 1; + if code == b'M' { + msg = val; + } + } + if msg.is_empty() { "".into() } else { msg } +} + +/// `DataRow`: i16 field count, then per field an i32 length (-1 = NULL) and that many bytes. +fn decode_row(body: &[u8], out: &mut Vec) { + if body.len() < 2 { + return; + } + let count = i16::from_be_bytes([body[0], body[1]]) as usize; + let mut i = 2usize; + for _ in 0..count { + if i + 4 > body.len() { + return; + } + let n = i32::from_be_bytes([body[i], body[i + 1], body[i + 2], body[i + 3]]); + i += 4; + if n < 0 { + continue; + } + let n = n as usize; + if i + n > body.len() { + return; + } + out.push(String::from_utf8_lossy(&body[i..i + n]).to_string()); + i += n; + } +} + +/// The branch identities a reply announced, as the SERVER named them. +/// +/// This is the independent witness that a fork happened. The loop counter in each arm says how +/// many forks were *asked for*; this says how many distinct branches the server *reported*, and +/// the two are checked against each other before anything is printed. Matching `b_` over +/// every text field rather than indexing one column keeps it from silently reading the wrong +/// field if `session_started_columns()` is ever reordered — a wrong column would return no +/// matches, which fails loudly, instead of returning a plausible string. +fn branch_names(reply: &Reply) -> Vec { + reply + .texts + .iter() + .filter(|s| { + s.strip_prefix("b_").is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) + }) + .cloned() + .collect() +} + +// --------------------------------------------------------------------------------------------- +// the server-side rig (arms A0/A1/A2) +// --------------------------------------------------------------------------------------------- + +struct Rig { + ctx: Arc, + addr: std::net::SocketAddr, + _dir: PathBuf, +} + +/// Build one pgwire server, the shape `examples/pgserver.rs` ships, and start serving. +/// +/// **No `LeaseThread`.** In production the lease scan takes this same outermost mutex once per +/// interval, and every acquisition it made would land in a census that is supposed to be +/// attributable to client statements. Leaving it out makes the count a LOWER bound on what +/// production takes, which is the safe direction for the claim being tested. +fn rig(root: &Path, tag: &str) -> Rig { + let dir = root.join(format!("e6-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("rig dir"); + let db = dir.join("ferro.db").to_string_lossy().into_owned(); + + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(&db) + .expect("open db"); + let dm = Arc::new(DiskManager::new(file).unwrap()); + let bp = Arc::new(BufferPoolManager::new(dm)); + let wal = Arc::new(WalManager::new(format!("{db}.wal").into()).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let catalog = Catalog::create(bp.clone()).unwrap(); + + let branches: Arc = + Arc::new(TableBranchCatalog::default_for_database(&db, FIRST_CATALOG_PAGE_ID).unwrap()); + let base = bp.disk_manager.high_water().expect("high water") + 32_736; + let store: Arc = + Arc::new(ArenaPageStore::new(bp.clone(), branches.clone() as Arc, base).unwrap()); + let runtime = Arc::new( + AgentRuntime::with_storage( + branches.clone() as Arc, + Arc::new(MemEffectLog::new()), + store.clone() as Arc, + ) + .expect("storage-backed runtime"), + ); + + let ctx = Arc::new(ServerContext::new(catalog, bp, txn, runtime)); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("local addr"); + let serving = Arc::clone(&ctx); + // Detached: the arm ends when its clients have been joined, and a server parked in + // `incoming()` with nobody connected cannot touch the counted lock. + std::thread::spawn(move || { + let _ = serve(listener, serving); + }); + Rig { ctx, addr, _dir: dir } +} + +// --------------------------------------------------------------------------------------------- +// arms +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ArmA { + /// One connection per thread; `BEGIN AGENT SESSION` then `ABANDON` per fork. + Persistent, + /// One connection per fork; a single `BEGIN AGENT SESSION` on each. + PerFork, + /// The control: identical to `PerFork` but `SELECT 1;` in place of the fork. + Control, +} + +impl ArmA { + fn label(self) -> &'static str { + match self { + ArmA::Persistent => "A1 pgwire, persistent conn", + ArmA::PerFork => "A2 pgwire, conn per fork", + ArmA::Control => "A0 CONTROL, SELECT 1", + } + } +} + +struct Cell { + forks: usize, + acquisitions: u64, +} + +/// Run one arm-A cell: `t` client threads, `f` statements each, on a freshly built server. +fn run_arm_a(arm: ArmA, t: usize, f: usize, root: &Path) -> Cell { + let rig = rig(root, &format!("{}-t{t}", match arm { + ArmA::Persistent => "a1", + ArmA::PerFork => "a2", + ArmA::Control => "a0", + })); + + // Snapshot AFTER the rig is built: `Catalog::create` and the runtime's construction are + // fixture cost, not statement cost, and charging them to the clients would inflate the + // headline in the direction the headline argues for. + let before = acquisitions(); + + let mut reported: BTreeSet = BTreeSet::new(); + let mut asked = 0usize; + let addr = rig.addr; + std::thread::scope(|s| { + let mut hs = Vec::with_capacity(t); + for th in 0..t { + hs.push(s.spawn(move || { + let mut names: Vec = Vec::new(); + let mut asked = 0usize; + let mut client = match arm { + ArmA::Persistent => Some(Client::connect(addr).expect("connect")), + _ => None, + }; + for i in 0..f { + match arm { + ArmA::Persistent => { + let c = client.as_mut().expect("persistent client"); + let r = c + .query(&format!( + "BEGIN AGENT SESSION AS 'a{th}' RUN 'r{th}_{i}';" + )) + .expect("BEGIN AGENT SESSION round trip"); + refuse_on_error(&r, "BEGIN AGENT SESSION"); + names.extend(branch_names(&r)); + asked += 1; + let r = c.query("ABANDON;").expect("ABANDON round trip"); + refuse_on_error(&r, "ABANDON"); + } + ArmA::PerFork => { + let mut c = Client::connect(addr).expect("connect"); + let r = c + .query(&format!( + "BEGIN AGENT SESSION AS 'a{th}' RUN 'r{th}_{i}';" + )) + .expect("BEGIN AGENT SESSION round trip"); + refuse_on_error(&r, "BEGIN AGENT SESSION"); + names.extend(branch_names(&r)); + asked += 1; + let _ = c.terminate(); + } + ArmA::Control => { + let mut c = Client::connect(addr).expect("connect"); + let r = c.query("SELECT 1;").expect("SELECT round trip"); + refuse_on_error(&r, "SELECT 1"); + let _ = c.terminate(); + } + } + } + if let Some(mut c) = client { + let _ = c.terminate(); + } + (names, asked) + })); + } + for h in hs { + let (names, a) = h.join().expect("client thread"); + reported.extend(names); + asked += a; + } + }); + + let after = acquisitions(); + + // The independent witness. `asked` is this harness's bookkeeping; `reported` is what the + // SERVER named. A fork total taken from the loop alone would be a number this file computed + // about itself. + if arm != ArmA::Control && reported.len() != asked { + eprintln!( + "e6: REFUSING. {} at T={t}: asked for {asked} forks but the server named {} distinct \ + branches. The fork total cannot be taken from this harness's own counter when the \ + two witnesses disagree.", + arm.label(), + reported.len() + ); + std::process::exit(1); + } + + Cell { forks: if arm == ArmA::Control { 0 } else { reported.len() }, acquisitions: after - before } +} + +fn refuse_on_error(r: &Reply, what: &str) { + if let Some(e) = &r.error { + eprintln!( + "e6: REFUSING. `{what}` was rejected by the server: {e}\n A refused statement is \ + a fork that did not happen; counting the arm around it would report a mediated path \ + as an unmediated one." + ); + std::process::exit(1); + } +} + +/// ARM B — `TableBranchCatalog::fork` directly, exactly as `examples/fork_concurrency.rs` does. +/// +/// A pgwire server IS listening while this runs (built by the caller). It reads zero because +/// nothing connects to it, not because the counter is dead — and the A arms in this same process +/// against this same static are the proof of that. +fn run_arm_b(t: usize, f: usize, root: &Path) -> Cell { + let dir = root.join(format!("e6-b-t{t}-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("arm B dir"); + let path = dir.join("branches.branchcat"); + let _ = std::fs::remove_file(&path); + let cat = Arc::new(TableBranchCatalog::open_sidecar(&path, 1).expect("open catalog")); + let lease = LeaseDeadline(u64::MAX); + + let before = acquisitions(); + let mut ids: BTreeSet = BTreeSet::new(); + std::thread::scope(|s| { + let mut hs = Vec::with_capacity(t); + for _ in 0..t { + let cat = Arc::clone(&cat); + hs.push(s.spawn(move || { + let mut mine = Vec::with_capacity(f); + for _ in 0..f { + let rec = cat.fork(BranchId::TRUNK, lease).expect("fork"); + mine.push(rec.branch_id.id as u64); + } + mine + })); + } + for h in hs { + ids.extend(h.join().expect("arm B thread")); + } + }); + let after = acquisitions(); + + if ids.len() != t * f { + eprintln!( + "e6: REFUSING. arm B at T={t}: asked for {} forks, the catalog minted {} distinct \ + branch ids.", + t * f, + ids.len() + ); + std::process::exit(1); + } + Cell { forks: ids.len(), acquisitions: after - before } +} + +// --------------------------------------------------------------------------------------------- +// forcing the detector to fire +// --------------------------------------------------------------------------------------------- + +/// One explicit acquisition must move the counter by exactly one. +/// +/// Without this the whole result is unfalsifiable: a counter wired to nothing prints the same +/// zero for arm B that a genuinely unmediated path would. +fn self_check(root: &Path) { + let rig = rig(root, "selfcheck"); + let before = acquisitions(); + drop(rig.ctx.catalog()); + let after = acquisitions(); + if after != before + 1 { + eprintln!( + "e6: REFUSING. one explicit `ctx.catalog()` moved the census by {}, not 1. The \ + instrument does not count what it claims to count, so no arm below means anything.", + after - before + ); + std::process::exit(1); + } + println!("instrument self-check: one explicit ctx.catalog() moved the census by exactly 1. OK"); +} + +// --------------------------------------------------------------------------------------------- + +fn main() { + let f: usize = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(8); + let threads: Vec = std::env::args() + .nth(2) + .unwrap_or_else(|| "1,4,16,64".to_string()) + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .filter(|&t: &usize| t > 0) + .collect(); + if threads.is_empty() { + eprintln!("e6: REFUSING. no thread counts to run."); + std::process::exit(1); + } + + let root = std::env::temp_dir().join(format!("ferrodb-e6-{}", std::process::id())); + std::fs::create_dir_all(&root).expect("root"); + + println!("E.6 -- acquisitions of ServerContext::catalog() during a concurrent-fork workload,"); + println!("driven THROUGH PGWIRE against the same workload driven DIRECTLY."); + println!("Recorded {}. Counts only; this file quotes no durations, and an integer count is", stamp()); + println!("load-immune, so this run did not need a quiet box or the fleet measure-lock."); + println!(); + println!("LAYERS AND THEIR PRODUCTION LOCKS"); + println!(" A1/A2/A0 the shipped pgwire server over a real TCP socket, forks issued as SQL."); + println!(" Production locks: ServerContext::catalog() OUTERMOST, one per statement,"); + println!(" then AgentRuntime's Mutex, then TableBranchCatalog's `logical`."); + println!(" B TableBranchCatalog directly, as examples/fork_concurrency.rs does"); + println!(" (:26, :32, :101). Production locks at this layer: `logical` ONLY --"); + println!(" no statement mutex, because no shipped front-end reaches fork this way."); + println!(); + println!("forks/thread F = {f}. Total forks = F x T, so the totals RISE with T and the"); + println!("acquisition column can be read against the statement rate directly."); + println!(); + + self_check(&root); + println!(); + + println!(" arm T forks acquisitions forks/acquisition"); + let mut a1: Vec<(usize, Cell)> = Vec::new(); + let mut a2: Vec<(usize, Cell)> = Vec::new(); + let mut a0: Vec<(usize, Cell)> = Vec::new(); + let mut b: Vec<(usize, Cell)> = Vec::new(); + + for &t in &threads { + let c = run_arm_a(ArmA::Persistent, t, f, &root); + row(ArmA::Persistent.label(), t, &c); + a1.push((t, c)); + } + for &t in &threads { + let c = run_arm_a(ArmA::PerFork, t, f, &root); + row(ArmA::PerFork.label(), t, &c); + a2.push((t, c)); + } + for &t in &threads { + let c = run_arm_a(ArmA::Control, t, f, &root); + // The control issues no forks; its ratio column is meaningless and is printed as `--` + // rather than as a zero that reads like a measurement. + println!( + " {:26} {:3} {:5} {:12} {:>17}", + ArmA::Control.label(), + t, + "--", + c.acquisitions, + "--" + ); + a0.push((t, c)); + } + for &t in &threads { + let c = run_arm_b(t, f, &root); + row("B direct, no ServerContext", t, &c); + b.push((t, c)); + } + + println!(); + println!("READING IT"); + println!(" A0 is the per-connection/per-statement FLOOR: acquisitions a client pays for"); + println!(" connecting and running a trivial statement, with no fork in it at all. A2 minus"); + println!(" A0 at the same T is what the fork statement itself costs in acquisitions."); + for (&t, ((_, c2), (_, c0))) in threads.iter().zip(a2.iter().zip(a0.iter())) { + println!( + " T={t:3}: A2 {} - A0 {} = {} acquisitions attributable to the fork statement, \ + over {} forks", + c2.acquisitions, + c0.acquisitions, + c2.acquisitions as i64 - c0.acquisitions as i64, + c2.forks + ); + } + println!(); + let b_total: u64 = b.iter().map(|(_, c)| c.acquisitions).sum(); + let a_total: u64 = a1.iter().chain(a2.iter()).map(|(_, c)| c.acquisitions).sum(); + println!(" arm B took {b_total} acquisitions in total across every T, while arms A1+A2 took"); + println!(" {a_total} in the SAME PROCESS against the SAME counter. That pairing is what makes"); + println!(" B's zero a measurement rather than a silent instrument."); + + let _ = std::fs::remove_dir_all(&root); +} + +fn row(label: &str, t: usize, c: &Cell) { + let ratio = if c.acquisitions == 0 { + "n/a (no acquisitions)".to_string() + } else { + format!("{:.3}", c.forks as f64 / c.acquisitions as f64) + }; + println!(" {:26} {:3} {:5} {:12} {:>17}", label, t, c.forks, c.acquisitions, ratio); +} + +fn stamp() -> String { + std::process::Command::new("date") + .arg("+%Y-%m-%dT%H:%M:%S%z") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".into()) +} diff --git a/src/pgwire/mod.rs b/src/pgwire/mod.rs index d4867528..f3c4888c 100644 --- a/src/pgwire/mod.rs +++ b/src/pgwire/mod.rs @@ -100,6 +100,43 @@ pub struct ServerContext { pub runtime: Arc, } +/// **E.6 — a census of exclusive catalog acquisitions. A COUNT, never a timing.** +/// +/// [`ServerContext::catalog`] is the outermost lock `serve` documents: every statement that +/// cannot run on the shared read path takes it, for the duration of that statement, and nothing +/// beneath it takes it back. E.6 asks whether a fork issued by a real client is mediated by it, +/// and the honest instrument for that is an integer, not a stopwatch — an integer is load-immune, +/// so the answer does not depend on a quiet machine. +/// +/// **Process-global on purpose, not a field of `ServerContext`.** The comparison arm drives +/// `TableBranchCatalog::fork` directly and never constructs a `ServerContext` at all; a +/// per-instance counter would report zero for that arm by construction and prove nothing. A +/// process-global says something falsifiable: *during that arm, nothing anywhere in this process +/// took the lock.* +/// +/// **Inert.** Nothing in this crate reads it, no control flow branches on it, and `catalog()` +/// behaves identically whether it is compiled in or out. +#[cfg(feature = "lock_census")] +static CATALOG_ACQUISITIONS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Exclusive acquisitions of [`ServerContext::catalog`] in this process, or `None` when the +/// census was not compiled in (`--features lock_census`). +/// +/// `Option`, not `u64`. "Not instrumented" and "zero acquisitions" are different facts, and a +/// harness that cannot tell them apart reports *production never took the lock* for a build that +/// simply was not measuring. A caller that gets `None` has collected nothing and must refuse. +pub fn catalog_acquisitions() -> Option { + #[cfg(feature = "lock_census")] + { + Some(CATALOG_ACQUISITIONS.load(std::sync::atomic::Ordering::Relaxed)) + } + #[cfg(not(feature = "lock_census"))] + { + None + } +} + impl ServerContext { pub fn new( catalog: Catalog, @@ -130,6 +167,10 @@ impl ServerContext { Ok(g) => g, Err(poisoned) => poisoned.into_inner(), }; + // E.6 census — see `CATALOG_ACQUISITIONS`. One relaxed increment on a static, compiled + // out by default, read by nothing in this crate. + #[cfg(feature = "lock_census")] + CATALOG_ACQUISITIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); // EVERY exclusive borrow drains the readers, not only the ones that free pages. // Deciding here which statements will free pages would be a second predicate that can // drift from what the executor actually does — the same trap `try_run_read` avoids by From bf1834a7862f987757181f3e35281a5eb26b27f0 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:44:25 -0400 Subject: [PATCH 034/315] E.6: record the outer-lock census -- outcome 1, every acquisition attributed Pre-registered outcome 1 fired. Arm A's acquisitions track the statement rate exactly: 3.000 per fork for the connection-per-fork arm and 4.125 for the persistent-connection arm, constant to the thousandth from T=1 to T=64, while `TableBranchCatalog::fork` called directly took ZERO at every T in the same process against the same static, with a pgwire server listening beside it. The counts are not merely consistent with mediation, they are reproduced exactly by the three acquisition sites the pre-registration named -- `parse_one`, `read_catalog`'s stale-snapshot refresh, and the exclusive execute arm -- with a residual of zero in all eight cells. Two things are recorded against the harness rather than left to ride. The `SELECT 1` control reads zero because `session::parse` answers it as a liveness probe before the parse-time acquisition, not because it took the shared read path, so it is a floor for a statement that never reaches the engine; the conclusion is unaffected but the label was wider than the measurement. And the server-side branch-name witness is sound here only because this rig builds `reaper: None`, so no id slot is ever recycled to collide two `b_{id}` names. Suite at ed598e4, default features: rc=0 passed=2449 failed=0 build_errors=0, identical to the ee24331 baseline; Go suite rc=0 passed=97 failed=0. --- bench/e6_outer_lock_count.txt | 266 ++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 bench/e6_outer_lock_count.txt diff --git a/bench/e6_outer_lock_count.txt b/bench/e6_outer_lock_count.txt new file mode 100644 index 00000000..9f407d76 --- /dev/null +++ b/bench/e6_outer_lock_count.txt @@ -0,0 +1,266 @@ +E.6 -- DOES EVERY W-B SCALING NUMBER MEASURE A LAYER PRODUCTION NEVER REACHES UNMEDIATED? +========================================================================================= + +Base commit: ee24331 +Question: count acquisitions of `ServerContext::catalog()` during a concurrent-fork workload + driven THROUGH PGWIRE, against the same workload driven DIRECTLY. +Instrument: a process-global AtomicU64 incremented inside `ServerContext::catalog()` itself, + compiled in only under `--features lock_census` (off by default), read as a delta + per arm. `catalog_acquisitions()` returns Option; None means "not + instrumented", which is a different fact from zero, and the harness exits 1 on it. +Kind: A COUNT, NOT A TIMING. An integer is load-immune, so this run needed neither a + quiet box nor the fleet measure-lock. NO DURATION APPEARS ANYWHERE IN THIS FILE. + + +PRE-REGISTERED OUTCOMES -- written before the first run, verbatim as handed down +-------------------------------------------------------------------------------- + +1. ARM A's acquisitions track the statement rate (~1 per fork, rising with T) + => the outer mutex mediates every production fork; ARM B measures a path production never + takes; every `logical` item is optimising underneath it. This is the expected result. + +2. ARM A's acquisitions do NOT track + => an exemption exists in code I did not read -- FIND IT AND NAME IT, because that would mean + E.6 is wrong and the W-B work is sound as scoped. + +3. ARM A cannot be built (no usable in-process pgwire client path) + => say so plainly and report what blocks it. Do NOT substitute a proxy for arm A and present + it as arm A. + + +WHICH FIRED +----------- +OUTCOME 1, and not marginally. + +ARM A's acquisitions are EXACTLY LINEAR in T, with a constant per-fork cost at every thread +count in the sweep: + + A2 (one connection per fork): 24/8 = 96/32 = 384/128 = 1536/512 = 3.000 acquisitions/fork + A1 (persistent connection): 33/8 = 132/32 = 528/128 = 2112/512 = 4.125 acquisitions/fork + +Not "roughly one or more per fork, rising with T" -- three per fork, and the ratio column does +not move by a thousandth from T=1 to T=64. ARM B took ZERO at every T, in the same process, +against the same static, with a pgwire server built and listening beside it. + +EVERY ACQUISITION IS ATTRIBUTED. This is the part that makes the result checkable rather than +merely plausible: the measured totals are not "consistent with" mediation, they are reproduced +exactly by counting the three sites the pre-registration named, with nothing left over. + + The three sites, confirmed in source (this tree, commit ed598e4): + (i) src/pgwire/extended.rs:283 `Statement::parse_one` -- one acquisition to infer + parameter types and describe result columns. The SIMPLE query path reaches it too: + `run_simple` -> `Statement::parse_batch` -> `parse_one`. + (ii) src/pgwire/mod.rs:257 inside `ServerContext::read_catalog`, refreshing a STALE + per-connection snapshot. Fires once per connection here and no more -- `ctx.epoch` + is the SQL catalog's epoch, which a branch fork does not move. + (iii) src/pgwire/extended.rs:357 the exclusive execute arm, taken when `try_run_read` + answers `None`. `BEGIN AGENT SESSION` and `ABANDON` are not reads, so it always does. + + A2 -- one connection, one statement each: (i) + (ii) + (iii) = 3 per fork. + T=1 8 conns x 3 = 24 measured 24 + T=4 32 conns x 3 = 96 measured 96 + T=16 128 conns x 3 = 384 measured 384 + T=64 512 conns x 3 = 1536 measured 1536 + + A1 -- one connection per thread, 8 x (BEGIN + ABANDON) on it. Two statements per fork, each + paying (i) + (iii); (ii) is paid ONCE for the connection because the fork does not move + the SQL catalog epoch: 16 statements x 2 + 1 = 33 per connection. + T=1 1 x 33 = 33 measured 33 + T=4 4 x 33 = 132 measured 132 + T=16 16 x 33 = 528 measured 528 + T=64 64 x 33 = 2112 measured 2112 + + Residual in all eight cells: zero. + +⚠ ONE CORRECTION TO THE HARNESS'S OWN PROSE, recorded here rather than left to ride. + + The harness prints "A0 is the per-connection/per-statement FLOOR: acquisitions a client pays + for connecting and running a trivial statement". A0 measured 0 at every T, and the reason is + NOT the shared read path, which is what the arm table's "the statement is a read, so it may + take the shared read path instead" invites a reader to assume. + + `SELECT 1` never reaches the engine at all. `Statement::parse_one` tries + `crate::pgwire::session::parse` FIRST and returns `Kind::Session` before the acquisition at + extended.rs:283; `session::parse` routes a leading SELECT to `parse_probe`, and + src/pgwire/session.rs:473-479 answers a bare integer literal as a liveness probe + ("`SELECT 1` -- the oldest connection-liveness check there is"). `Statement::execute` then + dispatches `Kind::Session(cmd)` to `run_session`, which does not touch the catalog either. + + So A0=0 is a true and useful control for the ONE thing E.6 needs it for -- it proves that + opening 512 connections and completing a statement on each costs ZERO acquisitions, which is + why the whole of A2 is attributable to the fork statement and its parse. It is NOT a floor + for "a trivial statement that reaches the query engine": no such statement was run. A read + that does reach the engine pays (i) at parse even when `try_run_read` then serves it without + (iii). The conclusion is unaffected -- A2 - A0 = A2 either way -- but the label was wider + than the measurement, and the measurement is the smaller of the two. + + +WHAT WAS ESTABLISHED FROM SOURCE BEFORE THE RUN (confirmed, not re-derived) +--------------------------------------------------------------------------- +- src/pgwire/mod.rs, `serve`'s lock-ordering note: the catalog mutex is taken OUTERMOST, for the + duration of a single statement, and "Nothing beneath it ever takes it back". +- `ServerContext::catalog()` is a real `Mutex::lock` (plus an unconditional reader drain). +- examples/fork_concurrency.rs constructs a `TableBranchCatalog` directly (:26, :32) and calls + `cat.fork(BranchId::TRUNK, lease)` at :101. It holds no statement mutex and builds no + `ServerContext`. +- The pgwire fork statement is `BEGIN AGENT SESSION AS '' RUN '';`. It reaches + `TableBranchCatalog::fork` through `AgentRuntime::begin_session_as` + (src/agent_sql/runtime.rs, `self.branches.fork(parent, ...)`) -- the SAME `fork` arm B calls. +- src/agent_sql/dispatch.rs refuses a second `BEGIN AGENT SESSION` on a connection that already + has one open, and `ABANDON` clears `session.agent`. That is why arm A1 alternates the two and + arm A2 uses one connection per fork. +- Three distinct sites inside the pgwire path can take the counted lock for one statement: + `Statement::parse_one` (describe/param-infer), `ServerContext::read_catalog` (refreshing a + stale per-connection snapshot), and `Statement::execute`'s exclusive arm. The census counts + acquisitions, not statements, so a ratio below 1 fork per acquisition is expected and is + itself evidence of mediation, not against it. + [CONFIRMED BY THE RUN. All three fire, and they are the whole of both A arms -- see the + attribution above. The ratio came out at 0.333 for A2 and 0.242 for A1, both below 1.] + + +ARMS -- LAYER AND PRODUCTION LOCKS (the standing requirement E.6 promoted to a gate) +------------------------------------------------------------------------------------- +A1 layer: the shipped pgwire server, real TCP socket, one client connection per thread, + forks issued as SQL (`BEGIN AGENT SESSION` / `ABANDON` per fork). + production locks at this layer: ServerContext::catalog() OUTERMOST, one per statement + -> AgentRuntime's Mutex -> TableBranchCatalog's `logical`. +A2 layer: the same server, one connection per fork, a single `BEGIN AGENT SESSION` on each. + production locks: identical to A1. +A0 layer: the same server, same client shape as A2, `SELECT 1;` in place of the fork. + THE CONTROL. Establishes the per-connection/per-statement acquisition floor, so the fork + statement's own contribution is A2 - A0 rather than the whole of A2. + [SEE THE CORRECTION ABOVE for what this control does and does not establish.] +B layer: `TableBranchCatalog` directly, as examples/fork_concurrency.rs does today. + production locks at this layer: `logical` ONLY -- no statement mutex, because no shipped + front-end reaches `fork` this way. The configuration that does is an embedded + `AgentRuntime` caller, which is real and is what that harness actually models. + +Arms A build the branch catalog with `default_for_database` (the pgserver.rs shape); arm B uses +`open_sidecar` (the fork_concurrency.rs shape). Same type, same `fork`. Stated rather than +smoothed over, and not the difference the result turns on: arm B cannot reach the counted lock +under either constructor. + +NO LeaseThread runs in any arm. In production the lease scan takes this same outermost mutex +once per interval, so leaving it out makes every A count a LOWER BOUND on what production takes +-- the safe direction for the claim under test. + + +FORCING THE DETECTOR TO FIRE (a zero is not a result until the counter is shown to be live) +--------------------------------------------------------------------------------------------- +1. A self-check asserts one explicit `ctx.catalog()` moves the census by exactly 1; the harness + exits 1 otherwise. [FIRED AND PASSED -- first line of the table output below.] +2. Arms A and B run in the SAME PROCESS against the SAME static. A's non-zero is the proof the + instrument was live at the moment B read zero. [A1+A2 = 4845 against B's 0.] +3. A pgwire server is built and listening while arm B runs. B reads zero because nothing + connects to it, not because there is nothing to count. +4. The fork total is cross-checked against the branch identities the SERVER reported (arm A) and + the distinct branch ids the catalog minted (arm B); a disagreement with the loop counter + exits 1. No arm's headline number comes from this harness's own bookkeeping alone. + [The server-side witness is the `branch_name` column of `session_started_columns()` + (src/agent_sql/dispatch.rs), formatted `b_{id}` at src/agent_sql/runtime.rs:1541. It is a + sound witness in this rig because ids are not recycled here: `AgentRuntime::with_storage` + builds with `reaper: None`, and the reaper-less `seal` sets state to `Reaped` without + calling `release_id`, so no FREE_ID slot is ever published for `fork` to recycle. Had ids + recycled, the cross-check would have REFUSED rather than under-counted.] + + +TABLE +----- +E.6 -- acquisitions of ServerContext::catalog() during a concurrent-fork workload, +driven THROUGH PGWIRE against the same workload driven DIRECTLY. +Recorded 2026-09-21T23:21:34-0400. Counts only; this file quotes no durations, and an integer count is +load-immune, so this run did not need a quiet box or the fleet measure-lock. + +LAYERS AND THEIR PRODUCTION LOCKS + A1/A2/A0 the shipped pgwire server over a real TCP socket, forks issued as SQL. + Production locks: ServerContext::catalog() OUTERMOST, one per statement, + then AgentRuntime's Mutex, then TableBranchCatalog's `logical`. + B TableBranchCatalog directly, as examples/fork_concurrency.rs does + (:26, :32, :101). Production locks at this layer: `logical` ONLY -- + no statement mutex, because no shipped front-end reaches fork this way. + +forks/thread F = 8. Total forks = F x T, so the totals RISE with T and the +acquisition column can be read against the statement rate directly. + +instrument self-check: one explicit ctx.catalog() moved the census by exactly 1. OK + + arm T forks acquisitions forks/acquisition + A1 pgwire, persistent conn 1 8 33 0.242 + A1 pgwire, persistent conn 4 32 132 0.242 + A1 pgwire, persistent conn 16 128 528 0.242 + A1 pgwire, persistent conn 64 512 2112 0.242 + A2 pgwire, conn per fork 1 8 24 0.333 + A2 pgwire, conn per fork 4 32 96 0.333 + A2 pgwire, conn per fork 16 128 384 0.333 + A2 pgwire, conn per fork 64 512 1536 0.333 + A0 CONTROL, SELECT 1 1 -- 0 -- + A0 CONTROL, SELECT 1 4 -- 0 -- + A0 CONTROL, SELECT 1 16 -- 0 -- + A0 CONTROL, SELECT 1 64 -- 0 -- + B direct, no ServerContext 1 8 0 n/a (no acquisitions) + B direct, no ServerContext 4 32 0 n/a (no acquisitions) + B direct, no ServerContext 16 128 0 n/a (no acquisitions) + B direct, no ServerContext 64 512 0 n/a (no acquisitions) + +READING IT + A0 is the per-connection/per-statement FLOOR: acquisitions a client pays for + connecting and running a trivial statement, with no fork in it at all. A2 minus + A0 at the same T is what the fork statement itself costs in acquisitions. + T= 1: A2 24 - A0 0 = 24 acquisitions attributable to the fork statement, over 8 forks + T= 4: A2 96 - A0 0 = 96 acquisitions attributable to the fork statement, over 32 forks + T= 16: A2 384 - A0 0 = 384 acquisitions attributable to the fork statement, over 128 forks + T= 64: A2 1536 - A0 0 = 1536 acquisitions attributable to the fork statement, over 512 forks + + arm B took 0 acquisitions in total across every T, while arms A1+A2 took + 4845 in the SAME PROCESS against the SAME counter. That pairing is what makes + B's zero a measurement rather than a silent instrument. + +[end of harness stdout, verbatim. The cargo `Compiling`/`Finished`/`Running` lines that preceded + it on the terminal are omitted on purpose: they carry a build duration, and no duration belongs + in this file. Command: + cargo run --release --features lock_census --example e6_outer_lock_count + run from /Users/idide/wt/ferrodb-E6-outer-lock-count-2 at commit ed598e4, F=8 and T=1,4,16,64 + left at their defaults. Exit status 0.] + + +SUITE +----- +Baseline at ee24331: rc=0 passed=2449 failed=0 +This tree, DEFAULT FEATURES (lock_census off -- default features are what ships): + rc=0 passed=2449 failed=0 build_errors=0 -- IDENTICAL TO THE BASELINE COUNT. + Go suite (cdc-consumer): rc=0 passed=97 failed=0 + + Exact command, run from /Users/idide/wt/ferrodb-E6-outer-lock-count-2: + VERIFY_OUT=/Users/idide/wt/logs/e6-verify VERIFY_MODE=per-target \ + ./tools/verify-suite.sh E6-ed598e4 + + Verbatim summary, as persisted to /Users/idide/wt/logs/e6-verify/SUMMARY.txt: + E6-ed598e4: mode=per-target rc=0 passed=2449 failed=0 build_errors=0 head=ed598e4 log=/Users/idide/wt/logs/e6-verify/suite-E6-ed598e4.log + E6-ed598e4: go rc=0 passed=97 failed=0 log=/Users/idide/wt/logs/e6-verify/suite-E6-ed598e4.go.log + + Certified: + ./tools/certify-head.sh /Users/idide/wt/logs/e6-verify ed598e4 + certify-head: OK -- suite head=ed598e4 == landing ed598e4 + + WHICH COMMIT THAT GREEN CERTIFIES, stated rather than implied. ed598e4 is the commit carrying + the Cargo feature, the counter in `ServerContext::catalog()` and the harness -- everything the + suite can compile or execute. This file is committed on top of it and is inert text: nothing + under tests/ enumerates bench/, and the one test that reads the directory + (`integration_readme_commands.rs`) opens a single named file, `bench/branch_scaling.txt`. So + the green is not re-run for a text-only delta, per certify-head.sh's own note that a + comment-only delta is refused rather than waved through -- the honest form is to say which + commit the suite named, which is what this block does. + + ALSO RECORDED, because it happened and a reader should not have to discover it: this file was + briefly written into bench/ while the suite was still running and was moved back out. The + suite's own tree-moved guard compares HEAD and the dirty-file count before and after; both + read clean at the end (dirty=0, head unchanged), and no target reads bench/ as a directory. + Neither the tracked tree nor any compiled artifact differed at any point during the run. + + MACHINE STATE, for a reader deciding how much to trust this. The box was NOT quiet -- it never + is here. That is why E.6 was specified as a COUNT: the fleet cannot change how many times a + lock was taken. `bench/d98_outer_runtime_lock.txt` settled the same point for the same lock + ("Integers. Fleet load does not change how many times a lock was taken. THE CLAIM RESTS ON + THESE"), so this run deliberately did not take `~/wt/logs/measure-lock.sh`. For the record, a + measurer (d81-fsm) acquired that lock at 2026-09-22T03:23:47Z, after the counts above had + already been collected. From 3dea186e671422caeb89a2fa8a3ca01b3c116832 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:48:06 -0400 Subject: [PATCH 035/315] E.6: bank the suite evidence, and close the 2449 baseline as BACKED The baseline was raised as unbacked -- `passed=2449` in no file, `ee24331` in no file, the highest certified count on record being 2139 at head=91270f9 in bench/d60_certified/. That negative was correct on its own terms and its stated scope limit is exactly where the answer was hiding. `git log --all -S2449` finds commit 8b18fa2, which banks bench/land22_certified/SUMMARY.txt: rc=0 passed=2449 failed=0, go passed=97, at head=52e1137. 8b18fa2 is on main but is NOT an ancestor of ee24331, so no search of the tree at or below this row's base could have seen it. That green names 52e1137, not ee24331, and says so itself -- then tells the reader to re-run the diff rather than trust the sentence. Re-run, not trusted: `git diff 52e1137..ee24331 -- src examples tests tools Cargo.toml Cargo.lock cdc-consumer` is EMPTY, and the same diff to ed598e4 shows only E.6's own three files. So 2449 is now corroborated by two runs on two commits, with the Go suite at 97 in both, and no second baseline run was needed or made. Also banks this row's own verdict and both raw logs under bench/e6_outer_lock_count_certified/, for the reason 8b18fa2 gives: a VERIFY_OUT directory is volatile and the next suite overwrites it. Adds `branch:` and `head:` to the artifact header so it names what it was measured on, and records the staleness check (COVERED) beside the count. --- bench/e6_outer_lock_count.txt | 54 + .../e6_outer_lock_count_certified/SUMMARY.txt | 40 + .../suite-E6-ed598e4.go.log | 330 ++ .../suite-E6-ed598e4.log | 3736 +++++++++++++++++ 4 files changed, 4160 insertions(+) create mode 100644 bench/e6_outer_lock_count_certified/SUMMARY.txt create mode 100644 bench/e6_outer_lock_count_certified/suite-E6-ed598e4.go.log create mode 100644 bench/e6_outer_lock_count_certified/suite-E6-ed598e4.log diff --git a/bench/e6_outer_lock_count.txt b/bench/e6_outer_lock_count.txt index 9f407d76..56791c3f 100644 --- a/bench/e6_outer_lock_count.txt +++ b/bench/e6_outer_lock_count.txt @@ -2,6 +2,11 @@ E.6 -- DOES EVERY W-B SCALING NUMBER MEASURE A LAYER PRODUCTION NEVER REACHES UN ========================================================================================= Base commit: ee24331 +branch: E6-outer-lock-count (created as E6-outer-lock-count-2 and renamed after + certification; a rename moves no sha, so the certification below still holds) +head: ed598e4 -- THE COMMIT THE TABLE BELOW WAS MEASURED AT. It carries the Cargo + feature, the counter in `ServerContext::catalog()` and the harness. This file is + committed on top of it and changes nothing the harness runs. Question: count acquisitions of `ServerContext::catalog()` during a concurrent-fork workload driven THROUGH PGWIRE, against the same workload driven DIRECTLY. Instrument: a process-global AtomicU64 incremented inside `ServerContext::catalog()` itself, @@ -225,6 +230,44 @@ READING IT SUITE ----- + +PROVENANCE OF THE 2449 BASELINE -- raised as UNBACKED, and it is in fact BACKED. Recorded here +because the next reader will grep this file for why the number is trusted, and because the +negative that raised the doubt was correct on its own terms and should not be read as sloppy. + + THE DOUBT, as raised: `passed=2449` appeared in no file in the ferrodb working tree, and + `ee24331` appeared in no file either; the highest recorded count among the eleven + `bench/_certified/` directories was `passed=2139 head=91270f9` in `bench/d60_certified/`. + That search was of a working tree and explicitly did not cover git history. + + WHY IT CAME BACK EMPTY, AND WHY THAT IS NOT A CONTRADICTION. The evidence was banked at + commit 8b18fa2, `bank the 2449 certification evidence: it lived only in a volatile VERIFY_OUT + dir`, which adds `bench/land22_certified/{SUMMARY.txt,suite-land22-52e1137.log,.go.log}`. + 8b18fa2 is on `main` but is NOT AN ANCESTOR OF ee24331 -- it landed after this row's base. So + a search of the tree at or below ee24331 structurally could not see it, and the scope limit + the negative stated for itself is exactly the gap the result fell through. `git log --all + --oneline -S2449` finds it; `git grep 2449 ee24331 -- bench tools` does not, and both are + right. + + WHAT IT SAYS, verbatim from bench/land22_certified/SUMMARY.txt at 8b18fa2: + land22-52e1137: mode=per-target rc=0 passed=2449 failed=0 build_errors=0 head=52e1137 + land22-52e1137: go rc=0 passed=97 failed=0 + + THAT GREEN NAMES 52e1137, NOT ee24331, and its own text says so, adding that every later push + is bench-only and code-identical -- then instructs the reader to re-run the diff rather than + trust the sentence. Re-run here, not trusted: + git diff --stat 52e1137..ee24331 -- src examples tests tools Cargo.toml Cargo.lock cdc-consumer + -> EMPTY. ee24331 is code-identical to the commit the 2449 certifies. + git diff --stat 52e1137..ed598e4 -- + -> exactly Cargo.toml (+7), examples/e6_outer_lock_count.rs (+636), src/pgwire/mod.rs (+41). + Nothing else in the code differs between the certified baseline and this row's head. + + SO THE BASELINE IS CORROBORATED TWICE, BY TWO INDEPENDENT RUNS ON DIFFERENT COMMITS: 2449 at + 52e1137 (banked) and 2449 at ed598e4 (below), with the Go suite at 97 in both. No second + baseline run was needed and none was made. The d60_certified 2139 is a real earlier + calibration point and is not in conflict -- it is an older head (91270f9), and the suite has + grown since. + Baseline at ee24331: rc=0 passed=2449 failed=0 This tree, DEFAULT FEATURES (lock_census off -- default features are what ships): rc=0 passed=2449 failed=0 build_errors=0 -- IDENTICAL TO THE BASELINE COUNT. @@ -242,6 +285,17 @@ This tree, DEFAULT FEATURES (lock_census off -- default features are what ships) ./tools/certify-head.sh /Users/idide/wt/logs/e6-verify ed598e4 certify-head: OK -- suite head=ed598e4 == landing ed598e4 + EVIDENCE BANKED IN THE REPO, for the same reason 8b18fa2 banked land22's: a $VERIFY_OUT dir + is volatile and the next suite overwrites it. See `bench/e6_outer_lock_count_certified/`, + holding SUMMARY.txt and both raw logs. + + Staleness, run before quoting the count (this row edits src/pgwire/mod.rs, which every + example links against, and this repo has had a stale-artifact run fail 53 tests): + ./tools/staleness.sh /Users/idide/wt/ferrodb-E6-outer-lock-count-2 + -> "0 behind, 259 ahead" COVERED (rc=0) + verify-suite.sh also rebuilds examples itself before measuring and refuses if that build + fails, so the rebuild is part of the measurement rather than something the caller remembered. + WHICH COMMIT THAT GREEN CERTIFIES, stated rather than implied. ed598e4 is the commit carrying the Cargo feature, the counter in `ServerContext::catalog()` and the harness -- everything the suite can compile or execute. This file is committed on top of it and is inert text: nothing diff --git a/bench/e6_outer_lock_count_certified/SUMMARY.txt b/bench/e6_outer_lock_count_certified/SUMMARY.txt new file mode 100644 index 00000000..930412e8 --- /dev/null +++ b/bench/e6_outer_lock_count_certified/SUMMARY.txt @@ -0,0 +1,40 @@ +# CERTIFICATION EVIDENCE — E.6, banked 2026-09-22 + +Banked for the same reason 8b18fa2 banked land22's: a $VERIFY_OUT directory is volatile and the +next suite overwrites it. A verdict that exists only there is not evidence. + +## The harness's own SUMMARY.txt lines, verbatim +``` +E6-ed598e4: mode=per-target rc=0 passed=2449 failed=0 build_errors=0 head=ed598e4 log=/Users/idide/wt/logs/e6-verify/suite-E6-ed598e4.log +E6-ed598e4: go rc=0 passed=97 failed=0 log=/Users/idide/wt/logs/e6-verify/suite-E6-ed598e4.go.log +``` + +## What this certifies +head=ed598e4, on branch E6-outer-lock-count (created as E6-outer-lock-count-2 and renamed +after certification; a rename moves no sha). That commit carries the whole of E.6's code: +the off-by-default `lock_census` Cargo feature, the cfg-gated counter and accessor in +`src/pgwire/mod.rs`, and `examples/e6_outer_lock_count.rs`. Run with DEFAULT FEATURES — +`lock_census` OFF — because default features are what ships. + + VERIFY_OUT=/Users/idide/wt/logs/e6-verify VERIFY_MODE=per-target \ + ./tools/verify-suite.sh E6-ed598e4 + +`tools/certify-head.sh /Users/idide/wt/logs/e6-verify ed598e4` returned +`OK — suite head=ed598e4 == landing ed598e4`. `tools/staleness.sh` returned COVERED. +Not pushed and not merged. + +⚠ The bench artifact `bench/e6_outer_lock_count.txt` and this directory are committed ON TOP +of ed598e4 and are inert text. Nothing under tests/ enumerates bench/; the one test that reads +the directory (`integration_readme_commands.rs`) opens a single named file, +`bench/branch_scaling.txt`. Re-run that check rather than trusting this sentence. + +## Relation to the 2449 baseline +This run independently reproduces the count banked at 8b18fa2 for head=52e1137 +(`bench/land22_certified/SUMMARY.txt`: rc=0 passed=2449 failed=0, go passed=97). +`git diff 52e1137..ee24331 -- src examples tests tools Cargo.toml Cargo.lock cdc-consumer` is +empty, so that green covers this row's base as code; the only code difference between it and +ed598e4 is E.6's own three files. Two runs, two commits, same 2449 and same Go 97. + +## Provenance of the log files +- suite-E6-ed598e4.log 263728 bytes sha256=b09d5fc09dc44c42cf5b4c46ebd162ec15983cd484dc5a646850e3dbde736ccf +- suite-E6-ed598e4.go.log 17285 bytes sha256=f5354337240b1731b6697ecf028fef636b0cb3e83f5206e2955dc459886cc3d7 diff --git a/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.go.log b/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.go.log new file mode 100644 index 00000000..2249e0ee --- /dev/null +++ b/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.go.log @@ -0,0 +1,330 @@ +=== RUN TestAnyDuplicationOrReplayConvergesToTheSameState +--- PASS: TestAnyDuplicationOrReplayConvergesToTheSameState (2.84s) +=== RUN TestAStaleEventLaterInTheFeedIsFilteredByTheCursor +--- PASS: TestAStaleEventLaterInTheFeedIsFilteredByTheCursor (0.08s) +=== RUN TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn +--- PASS: TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn (0.02s) +=== RUN TestValidateRefusesAnEmptyFeed +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAnEmptyFeed (0.00s) +=== RUN TestValidateRefusesAFeedThatIsMissingItsFinalNewline +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAFeedThatIsMissingItsFinalNewline (0.01s) +=== RUN TestSinkRefusesAnEmptyFeed +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE t +--- PASS: TestSinkRefusesAnEmptyFeed (0.02s) +=== RUN TestDuckSinkRefusesWithoutAKeyColumn +--- PASS: TestDuckSinkRefusesWithoutAKeyColumn (0.03s) +=== RUN TestDiffRefusesAMalformedSourceDump +MATCH 1 row(s) from 1 event(s) +--- PASS: TestDiffRefusesAMalformedSourceDump (0.01s) +=== RUN TestEveryRowOfOneCommitLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 103 TABLE t +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE t +--- PASS: TestEveryRowOfOneCommitLands (0.03s) +=== RUN TestTheLaterRecordInOneCommitWinsForTheSameKey +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 103 TABLE t +--- PASS: TestTheLaterRecordInOneCommitWinsForTheSameKey (0.03s) +=== RUN TestEveryRowOfASnapshotLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +--- PASS: TestEveryRowOfASnapshotLands (0.03s) +=== RUN TestI20RegressionInferredDestinationThenDeclaredCreateTable +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 11 TABLE inv + i20_regression_test.go:43: destination shape after the inference path: [[id TEXT] [qty TEXT]] +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestI20RegressionInferredDestinationThenDeclaredCreateTable (0.03s) +=== RUN TestI20RegressionInferredCatchUpOverADeclaredDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 31 TABLE inv + i20_regression_test.go:87: rows: [[1 10 ] [2 20 new]] +--- PASS: TestI20RegressionInferredCatchUpOverADeclaredDestination (0.04s) +=== RUN TestI20RegressionTwoTablesDifferentKeys +=== RUN TestI20RegressionTwoTablesDifferentKeys/sqlite + i20_regression_test.go:115: sqlite: refused a second table whose key is not the flag: apply INSERT to orders: SQL logic error: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint (1) +=== RUN TestI20RegressionTwoTablesDifferentKeys/duckdb + i20_regression_test.go:115: duckdb: refused a second table whose key is not the flag: table orders has no key column "id"; the ordering guard needs one +--- PASS: TestI20RegressionTwoTablesDifferentKeys (0.07s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/sqlite (0.03s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/duckdb (0.04s) +=== RUN TestI20RegressionKeyForOnAPrimaryKeylessDestination + i20_regression_test.go:145: keyFor on a PK-less destination: "id" (flag is "id") +--- PASS: TestI20RegressionKeyForOnAPrimaryKeylessDestination (0.01s) +=== RUN TestI20RegressionKeyCacheAcrossDropAndRecreate +applied 8, skipped 0 re-delivered +APPLIED 8 SKIPPED 0 CURSOR 71 TABLE inv + i20_regression_test.go:183: after drop+recreate: [[1 6]] +--- PASS: TestI20RegressionKeyCacheAcrossDropAndRecreate (0.05s) +=== RUN TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +applied 1, skipped 2 re-delivered +APPLIED 1 SKIPPED 2 CURSOR 2 TABLE inv +--- PASS: TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype (0.02s) +=== RUN TestI20RegressionTypeCheckAgainstAHandBuiltDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestI20RegressionTypeCheckAgainstAHandBuiltDestination (0.03s) +=== RUN TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip +--- PASS: TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip (0.00s) +=== RUN TestSameNumberStillCatchesDigitsNoDoubleCanHold +--- PASS: TestSameNumberStillCatchesDigitsNoDoubleCanHold (0.00s) +=== RUN TestExactFloatIsExactForSubnormals +--- PASS: TestExactFloatIsExactForSubnormals (0.00s) +=== RUN TestExactFloatRoundTripsToTheSameDouble +--- PASS: TestExactFloatRoundTripsToTheSameDouble (0.00s) +=== RUN TestConsumerRefusesADeniedColumnTheProducerLetThrough +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesADeniedColumnTheProducerLetThrough (0.01s) +=== RUN TestConsumerChecksTheBeforeImageToo +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerChecksTheBeforeImageToo (0.00s) +=== RUN TestConsumerRefusesAnUndecidedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnUndecidedTable (0.01s) +=== RUN TestConsumerRefusesASchemaEventDeclaringADeniedColumn +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesASchemaEventDeclaringADeniedColumn (0.00s) +=== RUN TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy (0.01s) +=== RUN TestADropIsNotJudgedByTheShapeCheck +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestADropIsNotJudgedByTheShapeCheck (0.00s) +=== RUN TestWithNoPublicationNothingIsRefused +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestWithNoPublicationNothingIsRefused (0.00s) +=== RUN TestUsePublicationRefusesAFileItCannotRead +--- PASS: TestUsePublicationRefusesAFileItCannotRead (0.00s) +=== RUN TestParsePublicationRefusesEveryMalformedShape +--- PASS: TestParsePublicationRefusesEveryMalformedShape (0.00s) +=== RUN TestTheSinkRefusesADeniedColumnBeforeLandingIt +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE customers +--- PASS: TestTheSinkRefusesADeniedColumnBeforeLandingIt (0.02s) +=== RUN TestPrecisionRefusesADeniedColumnRatherThanPrintingIt +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 id number 1 +FIELD 1 name string ada +SUMMARY strings=1 numbers=1 lossy=0 +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 ssn string 000-11-2222 +SUMMARY strings=2 numbers=1 lossy=0 +--- PASS: TestPrecisionRefusesADeniedColumnRatherThanPrintingIt (0.00s) +=== RUN TestPrecisionRefusesAnUndecidedTable +--- PASS: TestPrecisionRefusesAnUndecidedTable (0.00s) +=== RUN TestConsumerChecksADropsBeforeImage +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerChecksADropsBeforeImage (0.00s) +=== RUN TestConsumerRefusesDuplicateKeysInOneObject +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesDuplicateKeysInOneObject (0.01s) +=== RUN TestConsumerRefusesKeysOutsideTheEnvelope +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesKeysOutsideTheEnvelope (0.01s) +=== RUN TestConsumerRefusesANonScalarRowValue +OK 1 +WRITERS attributed=0 unattributed=1 +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesANonScalarRowValue (0.02s) +=== RUN TestConsumerRefusesAnEventForAnExcludedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnEventForAnExcludedTable (0.00s) +=== RUN TestParsePublicationHandlesExclusions +--- PASS: TestParsePublicationHandlesExclusions (0.00s) +=== RUN TestAnAttributedEventSurvivesAPublication +--- PASS: TestAnAttributedEventSurvivesAPublication (0.00s) +=== RUN TestAnUnknownTopLevelKeyIsStillRefused +--- PASS: TestAnUnknownTopLevelKeyIsStillRefused (0.00s) +=== RUN TestBothSinksLandTheSameWriterColumnNames +--- PASS: TestBothSinksLandTheSameWriterColumnNames (0.00s) +=== RUN TestRetractWorksAgainstADuckDBDestination +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=duckdb +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=duckdb +--- PASS: TestRetractWorksAgainstADuckDBDestination (0.09s) +=== RUN TestRetractInDeleteModeAgainstDuckDB +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=duckdb +--- PASS: TestRetractInDeleteModeAgainstDuckDB (0.08s) +=== RUN TestRetractAndScanRefuseAnUnknownEngine +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractAndScanRefuseAnUnknownEngine (0.03s) +=== RUN TestAnOlderDuckDBDestinationIsUpgradedInPlace +RETRACTED 3 OF 7 table=inv model_version=2026-07 mode=quarantine engine=duckdb +--- PASS: TestAnOlderDuckDBDestinationIsUpgradedInPlace (0.11s) +=== RUN TestRetractTouchesExactlyOneModelVersionsRows +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractTouchesExactlyOneModelVersionsRows (0.05s) +=== RUN TestRetractInDeleteModeAlsoTombstones +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=sqlite +--- PASS: TestRetractInDeleteModeAlsoTombstones (0.04s) +=== RUN TestARetractionThatMatchesNothingIsRefused +RETRACTED 2 OF 6 table=inv model_version=2026-05 mode=quarantine engine=sqlite +--- PASS: TestARetractionThatMatchesNothingIsRefused (0.04s) +=== RUN TestAnEmptyModelVersionIsRefused +--- PASS: TestAnEmptyModelVersionIsRefused (0.03s) +=== RUN TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing +--- PASS: TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing (0.01s) +=== RUN TestARetractionSurvivesAReplayButNotAFreshWrite +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestARetractionSurvivesAReplayButNotAFreshWrite (0.05s) +=== RUN TestScanReportsEveryRowsWriterAndMark +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanReportsEveryRowsWriterAndMark (0.04s) +=== RUN TestAWriterObjectWithAnUnknownKeyIsRefused +--- PASS: TestAWriterObjectWithAnUnknownKeyIsRefused (0.00s) +=== RUN TestAMalformedWriterIsRefused +--- PASS: TestAMalformedWriterIsRefused (0.00s) +=== RUN TestScanRefusesAnEmptyTable +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanRefusesAnEmptyTable (0.05s) +=== RUN TestWellFormedColumnLevelEventsAreAccepted +--- PASS: TestWellFormedColumnLevelEventsAreAccepted (0.00s) +=== RUN TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape +--- PASS: TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape (0.00s) +=== RUN TestColumnLevelChangesAreNewsNotDeclarations +--- PASS: TestColumnLevelChangesAreNewsNotDeclarations (0.00s) +=== RUN TestTableFoldSurvivesAColumnLevelChange +--- PASS: TestTableFoldSurvivesAColumnLevelChange (0.00s) +=== RUN TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds +--- PASS: TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds (0.00s) +=== RUN TestTheFoldReEncodesAColumnThatBecameStringTyped +--- PASS: TestTheFoldReEncodesAColumnThatBecameStringTyped (0.00s) +=== RUN TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows +applied 5, skipped 0 re-delivered +APPLIED 5 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows (0.04s) +=== RUN TestReplayingAFeedWithASchemaChangeIsANoOp +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE inv +--- PASS: TestReplayingAFeedWithASchemaChangeIsANoOp (0.04s) +=== RUN TestTheSqliteSinkRenamesRatherThanRebuilding +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +--- PASS: TestTheSqliteSinkRenamesRatherThanRebuilding (0.02s) +=== RUN TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows (0.03s) +=== RUN TestASinkThatNeverSawTheCreateStillGetsTheShape +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestASinkThatNeverSawTheCreateStillGetsTheShape (0.02s) +=== RUN TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 51 TABLE inv +--- PASS: TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration (0.03s) +=== RUN TestTheCatchUpRefusesAnythingThatIsNotAPrefix +--- PASS: TestTheCatchUpRefusesAnythingThatIsNotAPrefix (0.00s) +=== RUN TestTheFeedLandsInDuckdbAcrossAMissedAlter +--- PASS: TestTheFeedLandsInDuckdbAcrossAMissedAlter (0.04s) +=== RUN TestTheSqliteCatchUpRefusesARetypeInTheDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestTheSqliteCatchUpRefusesARetypeInTheDeclaration (0.03s) +=== RUN TestTheSqliteCatchUpComparesTypesAndNotOnlyNames +--- PASS: TestTheSqliteCatchUpComparesTypesAndNotOnlyNames (0.02s) +=== RUN TestTheSqliteSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkFollowsARenameOfTheKeyColumn (0.03s) +=== RUN TestTheDuckSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheDuckSinkFollowsARenameOfTheKeyColumn (0.05s) +=== RUN TestARenameTruncatedAwayStallsTheSinkButNamesTheCause +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestARenameTruncatedAwayStallsTheSinkButNamesTheCause (0.02s) +=== RUN TestDuckTypeMapping +--- PASS: TestDuckTypeMapping (0.00s) +=== RUN TestWideTypesLandExactlyInDuckDB +--- PASS: TestWideTypesLandExactlyInDuckDB (0.03s) +=== RUN TestDuckTypeOfInfersFromTheValue +--- PASS: TestDuckTypeOfInfersFromTheValue (0.00s) +=== RUN TestEnsureDuckTableRefusesATypeOutsideTheAllowlist +--- PASS: TestEnsureDuckTableRefusesATypeOutsideTheAllowlist (0.02s) +=== RUN TestEnsureDuckTableRefusesATableWithoutTheKey +--- PASS: TestEnsureDuckTableRefusesATableWithoutTheKey (0.01s) +=== RUN TestApplyRejectsAStaleEventAndAcceptsANewerOne +--- PASS: TestApplyRejectsAStaleEventAndAcceptsANewerOne (0.03s) +=== RUN TestDeleteIsSoftAndRejectsAStaleResurrection +--- PASS: TestDeleteIsSoftAndRejectsAStaleResurrection (0.02s) +=== RUN TestColumnsComeFromTheCatalogNotFromGuessing +--- PASS: TestColumnsComeFromTheCatalogNotFromGuessing (0.03s) +=== RUN TestApplyRefusesARowWithNoKeyColumn +--- PASS: TestApplyRefusesARowWithNoKeyColumn (0.02s) +=== RUN TestOpenDestinationRefusesAnUnknownEngine +--- PASS: TestOpenDestinationRefusesAnUnknownEngine (0.02s) +=== RUN TestALaterCreateTableThatDisagreesIsRefused +--- PASS: TestALaterCreateTableThatDisagreesIsRefused (0.03s) +=== RUN TestARepeatedIdenticalCreateTableIsStillANoOp +--- PASS: TestARepeatedIdenticalCreateTableIsStillANoOp (0.02s) +=== RUN TestAHostileTypeInACreateTableEventCannotReachTheDDL +--- PASS: TestAHostileTypeInACreateTableEventCannotReachTheDDL (0.02s) +=== RUN TestRenderCellMatchesItsRecordedCLIExpectations +--- PASS: TestRenderCellMatchesItsRecordedCLIExpectations (0.00s) +=== RUN TestRenderCellAgreesWithTheRealDuckdbCLI +--- PASS: TestRenderCellAgreesWithTheRealDuckdbCLI (0.14s) +=== RUN TestQuoteIdentDoublesEmbeddedQuotes +--- PASS: TestQuoteIdentDoublesEmbeddedQuotes (0.00s) +=== RUN TestSQLTypeMapping +--- PASS: TestSQLTypeMapping (0.00s) +=== RUN TestDecodeLineRejectsMalformedEnvelopes +--- PASS: TestDecodeLineRejectsMalformedEnvelopes (0.00s) +=== RUN TestReadIsExemptFromTheResumePointRule +--- PASS: TestReadIsExemptFromTheResumePointRule (0.00s) +=== RUN TestTableApplyFoldsOpsCorrectly +--- PASS: TestTableApplyFoldsOpsCorrectly (0.00s) +=== RUN TestApplyRefusesARowWithNoKey +--- PASS: TestApplyRefusesARowWithNoKey (0.00s) +PASS +ok github.com/RyanLin5967/ferrodb/cdc-consumer 5.357s diff --git a/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.log b/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.log new file mode 100644 index 00000000..f0584186 --- /dev/null +++ b/bench/e6_outer_lock_count_certified/suite-E6-ed598e4.log @@ -0,0 +1,3736 @@ +=== per-target sweep: --lib plus 128 integration targets +=== target --lib + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:440:13 + | +440 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:1964:17 + | +1964 | let (h, reaper) = setup(); + | ^^^^^^ +... +2038 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:1964:17 + | +1964 | let (h, reaper) = setup(); + | ^^^^^^ +... +2038 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:1964:17 + | +1964 | let (h, reaper) = setup(); + | ^^^^^^ +... +2039 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:1964:17 + | +1964 | let (h, reaper) = setup(); + | ^^^^^^ +... +2039 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 13.11s + Running unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) + +running 1613 tests +test agent_sql::gate::tests::a_cheap_check_that_almost_never_fires_sorts_last ... ok +test agent_sql::escrow::tests::an_unbounded_cell_is_not_escrowed_and_writes_pass_through ... ok +test agent_sql::changeset::tests::an_empty_merge_is_clean ... ok +test agent_sql::changeset::tests::a_lossy_row_makes_the_whole_merge_lossy_never_clean ... ok +test agent_sql::escrow::tests::a_negative_or_zero_claim_is_refused ... ok +test agent_sql::escrow::tests::a_branch_cannot_spend_against_a_claim_it_never_made ... ok +test agent_sql::escrow::tests::the_counter_cannot_be_driven_below_its_floor ... ok +test agent_sql::changeset::tests::a_conflicting_row_outranks_a_commuting_one ... ok +test agent_sql::escrow::tests::reopening_a_pool_is_refused_rather_than_resizing_it ... ok +test agent_sql::escrow::tests::a_write_beyond_the_branchs_claim_is_refused_at_write_time ... ok +test agent_sql::escrow::tests::settling_consumes_only_what_was_actually_spent ... ok +test agent_sql::escrow::tests::a_batch_that_only_overdraws_in_aggregate_is_refused ... ok +test agent_sql::escrow::tests::releasing_a_branch_returns_its_unspent_claim_to_the_pool ... ok +test agent_sql::escrow::tests::two_agents_cannot_both_reserve_twelve_out_of_twenty ... ok +test agent_sql::escrow::tests::a_recycled_id_slot_does_not_inherit_the_dead_branchs_escrow ... ok +test agent_sql::dispatch::tests::a_row_id_past_i64_max_is_not_reported_as_negative ... ok +test agent_sql::gate::tests::tier_order_is_computed_from_cost_over_rejection_probability ... ok +test agent_sql::gate::tests::every_check_in_a_tier_runs_even_after_one_has_fired ... ok +test agent_sql::gate::tests::an_assertion_that_examined_no_rows_is_a_hard_reject_not_a_pass ... ok +test agent_sql::gate::tests::the_most_severe_status_in_a_tier_decides ... ok +test agent_sql::gate::tests::a_later_tier_does_not_run_once_an_earlier_one_has_fired ... ok +test agent_sql::gate::tests::an_empty_gate_and_a_gate_where_nothing_fires_both_pass ... ok +test agent_sql::gate::tests::a_sound_finding_carries_the_violated_predicate ... ok +test agent_sql::gate::tests::a_row_that_could_not_be_evaluated_outranks_every_row_that_held ... ok +test agent_sql::dispatch::tests::every_agent_output_row_matches_its_declared_column_count ... ok +test agent_sql::dispatch::tests::only_an_empty_diff_returns_no_rows ... ok +test agent_sql::gate::tests::every_broken_assertion_reaches_the_caller_in_one_round_trip ... ok +test agent_sql::gate::tests::outcome_follows_epistemic_status_not_severity ... ok +test agent_sql::gate::tests::the_blind_write_metric_quarantines_and_cannot_reject ... ok +test agent_sql::gate::tests::an_assertion_fires_when_it_is_false_and_stays_quiet_when_it_holds ... ok +test agent_sql::merge_engine::tests::a_replayed_frame_contributes_its_increments_once ... ok +test agent_sql::merge_engine::tests::a_guard_that_fails_against_merged_state_returns_its_predicate ... ok +test agent_sql::merge_engine::tests::composing_two_identical_adds_does_not_collapse_them ... ok +test agent_sql::merge_engine::tests::concurrent_assigns_are_forbidden_by_default_not_silently_lww ... ok +test agent_sql::merge_engine::tests::identical_concurrent_writes_are_not_a_conflict ... ok +test agent_sql::merge_engine::tests::an_unevaluable_guard_is_not_reported_as_a_failed_one ... ok +test agent_sql::merge_engine::tests::lww_succeeds_but_reports_the_discarded_write ... ok +test agent_sql::merge_engine::tests::invert_refuses_an_assign_with_no_before_image ... ok +test agent_sql::merge_engine::tests::two_decrements_compose_arithmetically ... ok +test agent_sql::paged_rows::tests::a_table_range_excludes_its_neighbours ... ok +test agent_sql::paged_rows::tests::an_empty_row_round_trips ... ok +test agent_sql::paged_rows::tests::an_unknown_tag_is_refused ... ok +test agent_sql::paged_rows::tests::split_rejects_a_wrong_length_key ... ok +test agent_sql::paged_rows::tests::the_last_table_id_still_has_a_usable_upper_bound ... ok +test agent_sql::merge_engine::tests::merger_trait_reports_commuting_for_two_branch_decrements ... ok +test agent_sql::paged_rows::tests::a_truncated_row_is_refused_rather_than_panicking ... ok +test agent_sql::paged_rows::tests::every_value_variant_round_trips ... ok +test agent_sql::paged_rows::tests::each_wide_variant_round_trips_on_its_own ... ok +test agent_sql::mem_catalog::tests::fork_records_the_child_in_the_parent_and_shares_the_root ... ok +test agent_sql::mem_catalog::tests::reading_a_reaped_branch_is_a_hard_error_not_stale_data ... ok +test agent_sql::paged_rows::tests::trailing_bytes_are_refused ... ok +test agent_sql::paged_rows::tests::keys_sort_in_numeric_row_order ... ok +test agent_sql::persistent_map::tests::empty_map_answers_without_panicking ... ok +test agent_sql::persistent_map::tests::insert_if_absent_is_first_touch_wins ... ok +test agent_sql::persistent_map::tests::string_keys_borrow_for_lookup ... ok +test agent_sql::persistent_map::tests::size_hint_is_exact_so_collect_preallocates ... ok +test agent_sql::persistent_map::tests::a_no_op_insert_if_absent_shares_the_whole_tree ... ok +test agent_sql::persistent_map::tests::siblings_are_invisible_to_each_other ... ok +test agent_sql::runtime::tests::a_reservation_that_would_re_issue_a_version_sequence_is_refused ... ok +test agent_sql::persistent_map::tests::a_chain_of_clones_sees_its_ancestry_at_fork_time_and_nothing_later ... ok +test agent_sql::runtime::tests::the_ordinary_reservation_and_a_leaked_range_are_both_allowed ... ok +test agent_sql::runtime::tests::a_recycled_slot_does_not_strand_the_displaced_workspaces_capture ... ok +test agent_sql::runtime::tests::the_index_scan_assertion_fires_on_a_desynchronised_index - should panic ... ok +test agent_sql::runtime::tests::the_door_audit_fires_on_a_desynchronised_index - should panic ... ok +test agent_sql::persistent_map::tests::range_agrees_with_btreemap_on_every_window ... ok +test agent_sql::runtime::tests::the_txn_ref_index_tracks_a_scan_of_the_workspaces ... ok +test agent_sql::persistent_map::tests::a_clone_does_not_see_writes_made_through_the_original_afterwards ... ok +test agent_sql::persistent_map::tests::the_original_does_not_see_writes_made_through_a_clone ... ok +test agent_sql::persistent_map::tests::a_write_after_a_clone_shares_every_subtree_it_did_not_touch ... ok +test agent_sql::persistent_map::tests::one_write_allocates_a_path_not_a_tree ... ok +test agent_sql::persistent_map::tests::ascending_inserts_stay_balanced_rather_than_degenerating_into_a_list ... ok +test agent_sql::persistent_map::tests::descending_inserts_stay_balanced_too ... ok +test binder::binder::tests::test_duplicate_qualifier ... ok +test binder::binder::tests::test_expand_star ... ok +test agent_sql::persistent_map::tests::it_behaves_like_a_btreemap_over_a_random_looking_workload ... ok +test binder::binder::tests::every_statement_bind_does_not_plan_returns_an_error_rather_than_panicking ... ok +test binder::binder::tests::test_binding_a_plain_plan_from_an_as_of_table_is_refused_not_silently_main ... ok +test binder::binder::tests::test_bind_rejects_an_agent_statement_as_a_plan ... ok +test binder::binder::tests::test_resolve_ambiguous ... ok +test binder::binder::tests::test_resolve_qualified_unique ... ok +test binder::binder::tests::test_resolve_unknown_column ... ok +test binder::binder::tests::test_ambiguous_column ... ok +test binder::binder::tests::test_resolve_unknown_table ... ok +test agent_sql::paged_rows::tests::a_row_survives_a_round_trip_through_pages ... ok +test agent_sql::paged_rows::tests::a_delete_removes_only_its_own_row ... ok +test agent_sql::paged_rows::tests::the_last_table_scans_its_own_rows ... ok +test binder::binder::tests::test_join ... ok +test agent_sql::paged_rows::tests::a_write_under_one_root_is_invisible_from_another_root ... ok +test binder::binder::tests::test_duplicate_qualifier_bind ... ok +test agent_sql::paged_rows::tests::a_scan_returns_only_the_requested_table_in_row_order ... ok +test binder::binder::tests::test_bind_revert_merge_defaults_to_halt ... ok +test binder::binder::tests::test_literal ... ok +test binder::binder::tests::test_simple_select ... ok +test binder::binder::tests::test_select_star ... ok +test binder::binder::tests::test_unkown_table ... ok +test branch::arena::tests::a_corrupt_free_space_map_is_refused_not_partially_loaded ... ok +test binder::binder::tests::test_self_join ... ok +test branch::arena::tests::a_base_whose_id_was_reissued_is_refused_rather_than_encoded_against ... ok +test branch::arena::tests::a_private_page_is_mutated_in_place_not_shadowed ... ok +test branch::arena::tests::a_shadow_nobody_touched_encodes_to_no_runs_although_its_header_differs ... ok +test branch::arena::tests::a_released_page_forgets_the_base_it_was_a_shadow_of ... ok +test branch::arena::tests::a_shadow_that_rewrote_the_page_is_refused_a_delta ... ok +test branch::arena::tests::a_torn_page_is_refused_rather_than_returned ... ok +test branch::arena::tests::a_v2_checkpoint_image_still_loads_after_the_v3_bump ... ok +test branch::arena::tests::arena_rolls_over_when_an_extent_fills ... ok +test branch::arena::tests::a_shadow_that_changed_a_little_encodes_to_a_delta_far_under_a_page ... ok +test branch::arena::tests::a_chain_stops_growing_at_the_bound_rather_than_being_caught_on_read ... ok +test branch::arena::tests::a_checkpoint_describing_another_region_is_refused ... ok +test branch::arena::tests::an_inherited_page_is_shadowed_and_the_original_is_left_alone ... ok +test branch::arena::tests::d85_extent_is_empty_refuses_an_unprobed_restored_extent ... ok +test binder::binder::tests::test_bind_diff_merge_abandon_default_to_the_session_branch ... ok +test binder::binder::tests::test_bind_without_a_session_or_branch_is_an_error ... ok +test branch::arena::tests::an_arena_region_is_still_off_limits_after_reopening_the_file ... ok +test branch::arena::tests::fork_allocates_no_page_at_all ... ok +test branch::arena::tests::privacy_barrier_is_the_later_of_own_fork_and_latest_child_fork ... ok +test binder::binder::tests::test_bind_select_as_of_resolves_the_branch_and_the_columns ... ok +test branch::arena::tests::freed_extents_are_recycled_rather_than_leaked ... ok +test branch::arena::tests::freeing_a_superseded_extent_leaves_the_owner_on_its_current_one ... ok +test branch::arena::tests::free_space_map_survives_a_restart ... ok +test branch::arena::tests::freeing_an_extent_forgets_its_owner_and_no_other_branch ... ok +test branch::arena::tests::d87_probing_an_extent_from_zero_reports_the_pages_actually_written ... ok +test branch::arena::tests::freeing_an_extent_gives_back_its_fill_suspicion_too ... ok +test branch::arena::tests::freeing_an_extent_forgets_only_its_own_shadow_bases ... ok +test branch::attest::tests::a_branch_with_no_fork_entry_is_reported ... ok +test branch::attest::tests::a_fork_from_an_unknown_parent_is_reported ... ok +test branch::arena::tests::checkpoint_round_trips_through_a_file ... ok +test branch::attest::tests::a_recycled_id_slot_does_not_inherit_the_reaped_branchs_chain ... ok +test branch::arena::tests::live_arenas_comes_back_in_arena_id_order ... ok +test branch::attest::tests::a_rewritten_past_cannot_be_made_consistent_with_a_published_root ... ok +test branch::attest::tests::an_empty_head_must_still_carry_the_empty_root ... ok +test branch::attest::tests::an_honest_history_verifies ... ok +test branch::arena::tests::shadowing_a_page_you_own_records_no_delta_base ... ok +test branch::attest::tests::consistency_verification_refuses_malformed_proofs ... ok +test branch::arena::tests::store_refuses_to_share_the_bitmap_allocators_region ... ok +test branch::attest::tests::genesis_is_not_zero ... ok +test branch::attest::tests::every_field_of_an_entry_is_covered_by_the_chain ... ok +test branch::attest::tests::largest_power_of_two_below_is_strict ... ok +test branch::arena::tests::shadowing_your_own_page_across_a_child_fork_parks_the_original ... ok +test branch::attest::tests::leaf_and_node_hashing_are_domain_separated ... ok +test branch::attest::tests::mutating_the_last_entry_of_a_slot_is_caught_by_the_published_head ... ok +test branch::attest::tests::inclusion_verification_refuses_every_way_a_proof_can_be_wrong ... ok +test branch::attest::tests::mutating_a_historical_record_breaks_the_chain_and_names_it ... ok +test branch::attest::tests::relabelling_an_entry_as_a_fork_cannot_excise_its_predecessors ... ok +test branch::attest::tests::replaying_a_fork_entry_cannot_truncate_a_branchs_ancestry ... ok +test binder::binder::tests::a_bound_session_carries_the_prompt_verbatim ... ok +test branch::attest::tests::the_empty_tree_head_is_the_published_sha256_of_the_empty_string ... ok +test branch::attest::tests::the_entry_encoding_is_fixed_width_and_injective ... ok +test branch::attest::tests::the_generation_changes_the_attestation ... ok +test branch::attest::tests::the_op_codes_are_a_wire_format ... ok +test branch::arena::tests::the_checkpoint_image_lists_extents_and_current_arenas_in_key_order ... ok +test branch::attest::tests::the_canonical_encoding_round_trips ... ok +test branch::attest::tests::reordering_entries_breaks_consistency ... ok +test branch::attest::tests::truncation_is_invisible_to_a_chain_walk_and_visible_to_a_witnessed_root ... ok +test branch::attest::tests::verifying_a_branch_that_is_not_in_the_log_is_a_finding_not_a_pass ... ok +test branch::attest::tests::verify_branch_findings_do_not_name_a_digest_nothing_produced ... ok +test branch::catalog::tests::a_chain_far_past_the_old_cap_forks_and_reports_its_depth ... ok +test branch::catalog::tests::a_recycled_id_slot_never_answers_to_the_old_handle ... ok +test branch::attest::tests::verify_against_a_published_head_catches_a_mutation_at_every_index ... ok +test branch::catalog::tests::a_slot_still_pinning_children_is_not_recycled ... ok +test branch::attest::tests::verification_does_not_fire_on_legitimate_histories ... ok +test branch::attest::tests::a_third_party_holding_no_database_can_check_a_proof ... ok +test branch::catalog::tests::epochs_are_strictly_monotonic_across_forks ... ok +test branch::catalog::tests::a_parent_listing_only_reaped_children_comes_back_with_an_empty_array ... ok +test branch::catalog::tests::every_record_sweep_comes_back_in_branch_id_order ... ok +test branch::catalog::tests::expired_before_excludes_trunk_the_unexpired_and_the_not_live ... ok +test branch::catalog::tests::fork_records_the_child_in_the_parent_atomically ... ok +test branch::arena::tests::d85_next_free_after_a_crash_and_what_extent_is_empty_then_says ... ok +test branch::catalog::tests::reading_a_reaped_branch_is_a_hard_error ... ok +test branch::catalog::tests::reparent_moves_the_four_position_fields_and_nothing_else ... ok +test binder::binder::tests::test_bind_begin_agent_session ... ok +test branch::catalog::tests::reparent_refuses_a_branch_that_is_no_longer_readable ... ok +test branch::catalog::tests::a_torn_tail_keeps_every_intact_record ... ok +test branch::catalog::tests::the_live_child_queries_answer_the_rule_the_array_used_to ... ok +test branch::cherry::tests::a_failed_commit_that_touched_one_row_twice_restores_the_original_image ... ok +test branch::cherry::tests::a_landed_pick_is_undone_by_the_inverse_plan_through_the_same_door ... ok +test branch::cherry::tests::a_multi_row_pick_lands_every_row_in_one_commit ... ok +test branch::cherry::tests::a_selected_op_is_not_counted_as_its_own_divergence ... ok +test branch::cherry::tests::an_uninvertible_write_refuses_rather_than_guessing ... ok +test branch::arena::tests::two_stores_in_the_same_state_checkpoint_byte_identical_images ... ok +test branch::cherry::tests::regression_an_unselected_source_op_must_not_excuse_a_target_move ... ok +test branch::cherry::tests::row02_commuting_divergence_is_found_when_the_targets_op_has_the_lower_seq ... ok +test branch::cherry::tests::row01_unchanged_target_cell_applies_clean ... ok +test branch::cherry::tests::row02_max_and_min_divergences_commute_as_well_as_add ... ok +test branch::cherry::tests::row04_target_moved_to_the_same_value_is_not_a_conflict ... ok +test branch::cherry::tests::row02_target_moved_but_ops_commute_applies_composed_onto_target ... ok +test branch::cherry::tests::row03_target_moved_and_ops_contradict_refuses_whole ... ok +test branch::cherry::tests::row05_lww_policy_applies_and_reports_the_discarded_write ... ok +test branch::cherry::tests::row06_no_recorded_witness_refuses_rather_than_assuming_unchanged ... ok +test branch::cherry::tests::row07_cell_op_on_a_row_the_target_does_not_have_refuses ... ok +test branch::catalog::tests::a_runtime_release_and_a_reload_agree_on_which_slot_is_next ... ok +test branch::cherry::tests::row08_a_column_past_the_end_of_a_created_row_image_also_refuses ... ok +test branch::cherry::tests::row08_column_past_the_end_of_the_target_row_refuses ... ok +test branch::cherry::tests::row09_rowcreate_onto_an_absent_row_materialises_it ... ok +test branch::catalog::tests::durable_catalog_survives_a_reopen ... ok +test branch::cherry::tests::row10_rowcreate_onto_a_row_that_already_exists_refuses ... ok +test branch::cherry::tests::row11_rowdelete_of_a_present_row_removes_it ... ok +test branch::cherry::tests::row12_rowdelete_of_an_absent_row_refuses ... ok +test branch::arena::tests::reopen_from_checkpoint_takes_the_base_from_the_image_not_the_caller ... ok +test branch::cherry::tests::row13_note_a_straddled_selection_is_reported_not_refused ... ok +test branch::cherry::tests::row13_two_selected_ops_on_one_cell_compose_into_one_write ... ok +test branch::cherry::tests::row14_ops_with_no_composition_in_the_algebra_refuse_whole ... ok +test branch::cherry::tests::row15_the_same_op_selected_twice_refuses_because_add_is_not_idempotent ... ok +test branch::cherry::tests::row16_a_cell_op_on_a_row_this_pick_creates_folds_into_the_insert ... ok +test branch::cherry::tests::row17_a_cell_op_after_a_delete_in_the_same_pick_refuses ... ok +test branch::arena::tests::the_checkpoint_syncs_the_image_before_the_rename_and_the_directory_after_it ... ok +test branch::cherry::tests::row18_a_selector_naming_no_recorded_op_refuses ... ok +test branch::cherry::tests::row19_a_selector_naming_another_branchs_op_refuses ... ok +test branch::cherry::tests::row20_an_empty_selection_is_refused_not_treated_as_satisfied ... ok +test branch::cherry::tests::row21_a_commit_that_fails_partway_leaves_the_target_unchanged ... ok +test branch::cherry::tests::row22_a_set_op_on_a_cell_refuses_rather_than_erroring ... ok +test branch::cherry::tests::row21_control_the_same_selection_applies_whole_when_nothing_moved ... ok +test branch::cherry::tests::row21_a_refusal_leaves_no_partial_write_even_when_earlier_ops_were_clean ... ok +test branch::cherry::tests::row23_a_whole_row_op_with_a_cell_kind_refuses_rather_than_erroring ... ok +test branch::cherry::tests::row24_a_delete_then_create_selection_replaces_the_row ... ok +test branch::cherry::tests::row25_a_create_then_delete_selection_still_checks_the_create ... ok +test branch::cherry::tests::row26_a_cell_op_before_a_delete_in_the_same_pick_is_subsumed ... ok +test branch::cherry::tests::row27_divergence_that_does_not_explain_the_targets_value_is_not_trusted ... ok +test branch::cherry::tests::the_inverse_of_a_create_is_a_delete_and_of_a_delete_a_create ... ok +test branch::cherry::tests::the_inverse_of_a_replacing_create_restores_the_prior_image ... ok +test branch::cherry::tests::the_straddle_reporter_is_silent_on_a_contiguous_selection ... ok +test branch::delta::delta_tests::a_delta_refuses_a_payload_that_is_not_a_page ... ok +test branch::delta::delta_tests::a_compacting_update_blows_the_budget_and_is_stored_whole ... ok +test branch::delta::delta_tests::a_delta_chain_never_makes_a_read_deeper_than_the_bound ... ok +test branch::delta::delta_tests::a_record_claiming_a_run_past_the_payload_is_refused ... ok +test branch::delta::delta_tests::a_delta_round_trips_through_the_real_node_encoder ... ok +test branch::delta::delta_tests::a_refused_apply_writes_no_bytes_at_all ... ok +test branch::delta::delta_tests::a_record_round_trips_through_bytes_and_rebuilds_the_page ... ok +test branch::delta::delta_tests::a_width_change_makes_compaction_strictly_worse ... ok +test branch::delta::delta_tests::an_unchanged_page_encodes_to_no_runs_at_all ... ok +test branch::delta::delta_tests::apply_refuses_runs_that_overlap_or_run_backwards ... ok +test branch::delta::delta_tests::d93_sweep ... ignored, measurement, not a guard; run explicitly to regenerate the banked table +test branch::delta::delta_tests::decode_refuses_a_delta_that_is_truncated_or_has_trailing_bytes ... ok +test branch::delta::delta_tests::encoded_len_is_exactly_the_length_of_the_real_encoding ... ok +test branch::delta::delta_tests::every_truncation_of_a_record_is_refused_rather_than_read_short ... ok +test branch::delta::delta_tests::materialise_accepts_a_chain_at_the_bound_and_refuses_one_past_it ... ok +test branch::delta::delta_tests::runs_coalesce_exactly_when_the_gap_is_not_worth_its_framing ... ok +test branch::delta::delta_tests::the_read_amplification_bound_is_arithmetic_not_hope ... ok +test branch::delta::delta_tests::the_writer_occupies_exactly_what_the_cost_model_charges ... ok +test branch::group_commit::tests::a_failed_sync_leaves_the_group_usable ... ok +test branch::group_commit::tests::a_ticket_taken_during_a_sync_is_not_claimed_by_that_sync ... ok +test branch::delta::delta_tests::four_changed_rows_cost_at_least_ten_times_less_than_four_whole_pages ... ok +test branch::attest::tests::the_incremental_tree_matches_the_rfc_recursion ... ok +test branch::lease_thread::tests::a_gate_that_skips_the_scan_is_refused_rather_than_ignored - should panic ... ok +test branch::lease_thread::tests::an_unset_knob_is_the_default_and_not_a_refusal ... ok +test branch::lease_thread::tests::a_database_with_no_interrupted_reap_resumes_nothing ... ok +test branch::delta::delta_tests::stored_bytes_track_rows_changed_rather_than_pages_touched ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 1 workspace(s) forgotten +test branch::lease_thread::tests::a_reaped_branchs_workspace_is_forgotten_without_the_client_saying_anything ... ok +test branch::lease_thread::tests::d88_the_orphan_sweep_does_not_run_inside_the_statement_lock ... ok +test branch::lease_thread::tests::the_scan_interval_knob_refuses_every_value_it_cannot_use ... ok +test branch::catalog::tests::live_children_is_derived_at_replay_and_excludes_reaped_children ... ok +lease: finished 1 reap(s) a crash interrupted (b1@g0); 0 workspace(s) forgotten +lease: reaped 1 expired branch(es) with no client cooperation (b8@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::start_finishes_a_reap_a_crash_interrupted_before_any_scan_runs ... ok +test branch::arena::tests::the_public_checkpoint_stages_through_the_durable_helper ... ok +test branch::reaper::tests::log_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +lease: reaped 12 expired branch(es) with no client cooperation (b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::d98_a_branch_renewed_after_the_query_is_not_reaped ... ok +test branch::lease_thread::tests::an_unexpired_branch_survives_every_scan ... ok +test branch::lease_thread::tests::d98_one_acquisition_never_reaps_more_than_a_chunk ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test branch::arena::tests::freeing_an_extent_checkpoints_the_map_so_a_restart_gets_the_space_back ... ok +test branch::lease_thread::tests::the_thread_reaps_an_expired_branch_with_nobody_asking_it_to ... ok +test branch::reaper::tests::log_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +test branch::reaper::tests::log_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::reaper::tests::log_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::log_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::catalog::tests::two_opens_of_one_log_recycle_branch_ids_in_the_same_order ... ok +test branch::reaper::tests::log_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::reaper::tests::log_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::log_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::log_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::log_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::reaper::tests::log_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::reaper::tests::log_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::arena::tests::parking_pages_by_the_interval_rule_reaches_the_durable_map ... ok +test branch::reaper::tests::log_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::log_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::log_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::log_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +test branch::arena::tests::putting_the_pending_log_back_reaches_the_durable_map ... ok +test branch::reaper::tests::log_catalog::trunk_is_never_reaped ... ok +test branch::reaper::tests::log_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::reaper::tests::log_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test branch::arena::tests::a_page_allocated_after_the_last_checkpoint_is_not_reissued_over_a_live_root ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::a_scan_does_not_reap_while_a_merge_holds_the_runtime_lock ... ok +test branch::lease_thread::tests::a_signalled_halt_returns_at_once_instead_of_sleeping_the_interval ... ok +test branch::lease_thread::tests::dropping_the_lease_thread_stops_it ... ok +test branch::reaper::tests::table_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::reaper::tests::log_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test branch::reaper::tests::table_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +test branch::reaper::tests::log_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::lease_thread::tests::stop_does_not_wait_out_the_scan_interval ... ok +test branch::attest::tests::every_entry_has_a_log_sized_inclusion_proof_that_verifies ... ok +test branch::reaper::tests::log_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test branch::reaper::tests::table_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::table_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::reaper::tests::table_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::reaper::tests::table_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::reaper::tests::table_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::table_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::table_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::table_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::table_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::attest::tests::a_legitimate_append_stays_consistent_at_every_pair_of_sizes ... ok +test branch::reaper::tests::table_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::table_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::log_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +test branch::reaper::tests::table_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::record::core_record_tests::a_wrong_length_core_record_is_refused ... ok +test branch::record::core_record_tests::an_absent_parent_is_distinguishable_from_parent_zero ... ok +test branch::record::core_record_tests::d5_into_hydrated_is_the_only_exit_and_it_carries_both_fields ... ok +test branch::record::core_record_tests::the_core_record_round_trips_every_field_it_carries ... ok +test branch::record::tests::a_column_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::reaper::tests::table_catalog::trunk_is_never_reaped ... ok +test branch::record::tests::a_corrupt_envelope_is_rejected_not_defaulted ... ok +test branch::record::tests::a_delete_needs_authority_over_every_column_it_destroys ... ok +test branch::record::tests::a_duplicate_column_keeps_the_tighter_floor ... ok +test branch::record::tests::a_granted_table_resolves_however_the_envelope_was_assembled ... ok +test branch::record::tests::a_record_with_an_envelope_roundtrips_through_bytes ... ok +test branch::record::tests::a_record_written_before_envelopes_existed_still_loads ... ok +test branch::record::tests::a_representation_change_counts_as_writing_the_cell ... ok +test branch::record::tests::a_row_whose_image_did_not_change_costs_nothing ... ok +test branch::record::tests::a_statement_that_overruns_the_budget_is_refused_as_a_whole ... ok +test branch::record::tests::a_table_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::reaper::tests::table_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::record::tests::a_verb_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::an_envelope_can_be_narrowed_but_never_widened ... ok +test branch::record::tests::an_insert_cannot_launder_a_column_or_a_floor_through_a_null ... ok +test branch::record::tests::an_insert_cannot_write_a_column_it_was_not_granted ... ok +test branch::record::tests::an_unknown_floor_tag_is_refused_rather_than_read_as_unbounded ... ok +test branch::record::tests::an_unlisted_table_is_refused_even_for_a_statement_that_matched_nothing ... ok +test branch::record::tests::changed_columns_reads_images_so_an_insert_reports_every_column_it_wrote ... ok +test branch::record::tests::charging_past_the_budget_is_refused_at_the_charge_not_only_at_admit ... ok +test branch::record::tests::corrupt_record_is_rejected_not_guessed ... ok +test branch::record::tests::deleting_a_row_cannot_be_used_to_get_around_a_floor ... ok +test branch::record::tests::depth_counts_past_the_old_cap_and_saturates_instead_of_wrapping ... ok +test branch::record::tests::fork_copies_the_root_pointer_and_nothing_else ... ok +test branch::record::tests::forking_does_not_mint_budget_the_parent_had_already_spent ... ok +test branch::record::tests::live_children_stay_sorted ... ok +test branch::reaper::tests::log_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::record::tests::reclamation_rule_is_half_open_over_the_fork_epochs ... ok +test branch::record::tests::record_roundtrips_through_bytes ... ok +test branch::record::tests::stale_generation_is_a_hard_error_not_stale_data ... ok +test branch::record::tests::the_envelope_survives_records_of_every_shape ... ok +test branch::record::tests::the_floor_refuses_the_value_whatever_shape_produced_it ... ok +test branch::record::tests::the_verb_comes_from_the_images_not_from_a_statement_keyword ... ok +test branch::record::tests::trailing_bytes_inside_a_valid_checksum_are_refused ... ok +test branch::table_catalog::serial_section_profile::where_the_serial_section_goes ... ignored, profiling, not a correctness test; run with --ignored --nocapture +test branch::table_catalog::d10_guard::write_record_new_refuses_a_record_carrying_arenas - should panic ... ok +test branch::table_catalog::d10_guard::write_record_new_accepts_a_freshly_forked_child ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::table_catalog::tests::a_fresh_catalog_holds_trunk_and_nothing_else ... ok +test branch::table_catalog::tests::a_child_inherits_its_parents_capability_envelope ... ok +test branch::reaper::tests::table_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::table_catalog::tests::a_stale_child_entry_against_a_reaped_child_is_resolved_and_ignored ... ok +test branch::reaper::tests::table_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::table_catalog::tests::a_migrated_catalog_answers_every_query_the_way_its_source_does ... ok +test branch::table_catalog::tests::an_arena_appended_the_way_the_page_store_does_it_survives_a_round_trip ... ok +test branch::table_catalog::tests::attach_child_puts_a_branch_into_a_parents_live_set_and_detach_takes_it_out ... ok +test branch::table_catalog::tests::expired_before_selects_only_expired_live_non_trunk_branches ... ok +test branch::table_catalog::tests::fork_records_the_child_in_its_parents_live_set ... ok +test branch::table_catalog::tests::opening_an_existing_catalog_never_creates_a_fresh_one_over_it ... ok +test branch::tree_keys::tests::a_parents_child_span_excludes_the_adjacent_parent ... ok +test branch::table_catalog::tests::writing_a_record_twice_leaves_exactly_one_of_it ... ok +test branch::reaper::tests::table_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::tree_keys::tests::a_state_span_holds_that_state_alone ... ok +test branch::table_catalog::tests::an_interrupted_switchover_prefers_the_tree_and_retires_the_stale_log ... ok +test branch::tree_keys::tests::an_arena_span_holds_one_branchs_arenas_and_survives_the_wrap ... ok +test branch::tree_keys::tests::encoded_order_matches_numeric_order ... ok +test branch::reaper::tests::table_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::table_catalog::tests::reopening_recovers_the_counters_without_replaying_anything ... ok +test branch::table_catalog::tests::scan_returns_every_record_in_branch_id_order ... ok +test branch::tree_keys::tests::expired_range_at_max_contains_every_deadline_and_no_other_group ... ok +test branch::tree_keys::tests::expired_range_is_inclusive_of_now_and_excludes_the_next_millisecond ... ok +test branch::tree_keys::tests::tag_groups_are_disjoint_and_each_span_contains_only_its_own ... ok +test branch::types::tests::branch_state_roundtrips ... ok +test branch::table_catalog::tests::the_two_catalogs_agree_about_what_current_epoch_means ... ok +test branch::types::tests::generation_bump_makes_reaped_id_distinguishable ... ok +test branch::types::tests::lease_expiry_is_a_pure_comparison ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::version_graph::tests::a_live_sibling_keeps_a_tombstoned_parent_alive ... ok +test branch::version_graph::tests::a_recycled_id_does_not_inherit_the_old_lineage ... ok +test branch::version_graph::tests::a_tombstone_is_collected_only_once_its_last_descendant_goes ... ok +test branch::version_graph::tests::an_unknown_branch_refuses_rather_than_answering_false ... ok +test branch::version_graph::tests::deep_chain_agrees_with_an_independent_walk ... ok +test branch::version_graph::tests::depth_counts_from_the_root ... ok +test branch::version_graph::tests::forking_from_a_reaped_parent_is_refused ... ok +test branch::version_graph::tests::freeing_a_slot_with_a_descendant_is_refused - should panic ... ok +test branch::version_graph::tests::hop_counts_are_logarithmic_in_depth_and_the_walk_is_linear ... ok +test branch::version_graph::tests::is_ancestor_is_strict_and_directional ... ok +test branch::version_graph::tests::lca_is_reflexive_and_handles_the_parent_case ... ok +test branch::version_graph::tests::hop_counts_do_not_grow_with_fan_out ... ok +test branch::version_graph::tests::lca_of_disjoint_trees_is_none_not_the_trunk ... ok +test branch::version_graph::tests::lca_descends_correctly_at_every_level_boundary ... ok +test branch::version_graph::tests::lca_of_siblings_is_the_fork_point ... ok +test branch::reaper::tests::log_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok +test branch::version_graph::tests::level_ancestor_hits_each_level_and_refuses_below_the_node ... ok +test branch::version_graph::tests::reaping_an_interior_branch_preserves_ancestry ... ok +test branch::version_graph::tests::reinserting_a_branch_is_refused ... ok +test branch::table_catalog::tests::a_legacy_log_is_migrated_once_and_retired_not_deleted ... ok +test branch::version_graph::tests::sibling_candidates_have_a_fork_point_that_survives_pruning ... ok +test branch::version_graph::tests::siblings_are_not_ancestors_of_each_other ... ok +test branch::version_graph::tests::slot_reuse_never_leaks_a_stale_lineage ... ok +test buffer::page_table::tests::a_collision_costs_the_fast_path_and_never_returns_the_wrong_frame ... ok +test buffer::page_table::tests::a_removed_page_is_gone_from_both_halves ... ok +test buffer::buffer_pool::tests::a_refused_delete_leaves_the_page_intact_in_the_pool ... ok +test buffer::buffer_pool::tests::a_refused_free_page_leaves_the_page_intact_in_the_pool ... ok +test buffer::page_table::tests::clear_empties_both_halves ... ok +test buffer::buffer_pool::tests::an_accepted_delete_still_evicts_the_page ... ok +test buffer::page_table::tests::min_slots_is_rounded_up_to_a_power_of_two ... ok +test buffer::page_table::tests::page_zero_in_frame_zero_is_not_mistaken_for_an_empty_slot ... ok +test buffer::page_table::tests::remapping_a_page_moves_its_mirror_entry ... ok +test buffer::page_table::tests::removing_a_collided_out_page_leaves_the_slot_owner_alone ... ok +test buffer::page_table::tests::a_tagged_slot_always_agrees_with_the_map ... ok +test buffer::page_table::tests::the_largest_page_id_round_trips ... ok +test buffer::page_table::tests::the_mirror_answers_what_the_map_answers ... ok +test buffer::touch_queue::tests::a_drain_takes_everything_and_empties_the_queue ... ok +test buffer::touch_queue::tests::concurrent_recorders_lose_nothing ... ok +test buffer::touch_queue::tests::every_recorded_update_comes_back_exactly_once ... ok +test buffer::touch_queue::tests::nothing_is_returned_until_the_batch_is_full ... ok +test buffer::touch_queue::tests::shard_count_is_rounded_up_to_a_power_of_two ... ok +test build_provenance_tests::the_build_stamp_names_a_real_commit ... ok +test build_provenance_tests::a_dirty_tree_is_loud_in_the_rendered_provenance ... ok +test catalog::catalog::tests::dropping_a_table_forgets_its_statistics ... ok +test catalog::catalog::tests::test_catalog_create_open ... ok +test catalog::catalog::tests::dropping_a_table_returns_its_pages_including_the_time_travel_heap ... ok +test catalog::catalog::tests::test_create_index ... ok +test catalog::catalog::tests::test_create_table_adds_time_travel_root ... ok +test catalog::catalog::tests::test_drop_table ... ok +test catalog::catalog_page::tests::a_middle_entry_s_fulltext_block_does_not_shift_the_next_entry ... ok +test catalog::catalog::tests::test_get_table ... ok +test catalog::catalog_page::tests::a_pre_b8_catalog_page_still_parses_with_no_fulltext_indexes ... ok +test catalog::catalog_page::tests::an_unknown_catalog_page_format_is_refused ... ok +test catalog::catalog::tests::test_persist_orphan_removal ... ok +test catalog::catalog_page::tests::fulltext_indexes_round_trip_separately_from_btree_indexes ... ok +test catalog::catalog::tests::test_update_roots ... ok +test catalog::catalog_page::tests::test_add_space_management ... ok +test catalog::catalog_page::tests::test_basic_roundtrip ... ok +test catalog::catalog_page::tests::test_remove_entry ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_decimal ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_float ... ok +test catalog::column::tests::bigint_orders_by_value_at_the_i64_extremes ... ok +test catalog::column::tests::bigint_versus_float_does_not_widen_past_2_53 ... ok +test catalog::column::tests::bigint_versus_non_finite_floats_matches_total_cmp_placement ... ok +test catalog::column::tests::decimal_beyond_i128_still_compares_exactly ... ok +test catalog::column::tests::decimal_orders_numerically_not_lexicographically ... ok +test catalog::column::tests::decimal_versus_float_uses_the_floats_exact_value ... ok +test catalog::column::tests::decimal_versus_non_finite_floats_is_fixed_and_cycle_free ... ok +test catalog::column::tests::f64_exact_decimal_is_exact_not_shortest ... ok +test catalog::column::tests::float_does_not_outrank_a_larger_integer ... ok +test catalog::column::tests::integer_to_float_widening_is_exact_at_i32_bounds ... ok +test catalog::column::tests::numerically_equal_int_and_float_compare_equal ... ok +test catalog::column::tests::ordering_is_transitive_across_the_numeric_band ... ok +test catalog::column::tests::parse_decimal_accepts_what_sql_writes_and_refuses_what_it_does_not ... ok +test catalog::column::tests::rank_fallback_still_orders_incomparable_variants ... ok +test catalog::column::tests::the_whole_value_domain_is_a_total_order ... ok +test catalog::column::tests::the_numeric_band_is_a_total_order ... ok +test catalog::column::tests::timestamp_compares_by_value_and_sits_outside_the_numeric_band ... ok +test catalog::system_views::tests::a_table_may_not_take_a_view_name_but_an_ordinary_name_is_fine ... ok +test catalog::system_views::tests::every_view_name_resolves_and_nothing_else_does ... ok +test catalog::system_views::tests::hex_of_a_prompt_hash_is_64_characters ... ok +test branch::version_graph::tests::all_pairs_agree_with_the_walk_on_an_irregular_tree ... ok +test catalog::system_views::tests::synthetic_row_ids_stay_distinct_past_the_slot_number_width ... ok +test catalog::system_views::tests::u64_values_past_i64_max_are_not_reported_as_negative ... ok +test catalog::system_views::tests::view_names_and_declared_columns_agree_with_the_enum ... ok +test cluster::tests::a_grant_addressed_to_another_node_is_refused ... ok +test cluster::tests::a_grant_from_a_previous_epoch_is_not_issued_from_after_leaving ... ok +test cluster::tests::a_grant_partly_consumed_before_a_crash_resumes_at_the_watermark ... ok +test cluster::tests::a_grant_wholly_below_the_restored_watermark_is_a_duplicate_not_a_rewind ... ok +test cluster::tests::a_lease_tick_never_moves_cluster_time_backwards ... ok +test cluster::tests::a_member_reads_the_replicated_tick_and_never_its_own_clock ... ok +test cluster::tests::a_member_refuses_again_once_its_grant_is_used_up ... ok +test cluster::tests::a_member_with_no_grant_refuses_rather_than_allocating ... ok +test cluster::tests::a_member_with_no_lease_tick_refuses_to_decide_a_lease ... ok +test cluster::tests::a_range_too_narrow_for_the_ask_is_kept_rather_than_split_or_dropped ... ok +test cluster::tests::a_redelivered_grant_issues_nothing_twice ... ok +test cluster::tests::a_standalone_extent_counter_walks_the_same_page_starts_as_fetch_add ... ok +test cluster::tests::a_standalone_node_at_the_end_of_the_value_space_refuses_distinguishably ... ok +test branch::reaper::tests::table_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test cluster::tests::a_standalone_node_issues_exactly_what_the_old_atomic_counter_did ... ok +test cluster::tests::a_standalone_node_reads_its_own_wall_clock_because_it_is_the_cluster ... ok +test cluster::tests::a_standalone_node_refuses_an_outside_grant ... ok +test cluster::tests::a_take_never_straddles_two_granted_ranges ... ok +test cluster::tests::a_standalone_node_self_grants_through_the_same_consume_path ... ok +test cluster::tests::an_authority_change_never_lowers_the_issued_watermark ... ok +test cluster::tests::an_authority_change_stops_the_old_high_water_clamping_the_new_leaders_grants ... ok +test cluster::tests::an_empty_grant_is_refused_rather_than_ignored ... ok +test cluster::tests::ranges_from_a_superseded_authority_are_not_issued_from ... ok +test cluster::tests::the_issued_watermark_is_the_only_thing_a_checkpoint_has_to_carry ... ok +test cluster::tests::two_members_at_the_same_tick_cannot_disagree_about_expiry ... ok +test cluster::tests::two_members_with_disjoint_grants_never_issue_the_same_value ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test branch::reaper::tests::table_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test branch::reaper::tests::table_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test branch::reaper::tests::table_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test cow::btree::delete_unlink_tests::deleting_an_absent_key_changes_nothing ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test cow::btree::delete_unlink_tests::an_emptied_leaf_is_unlinked_rather_than_left_in_the_tree ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test cow::btree::delete_unlink_tests::deleting_every_key_leaves_a_usable_empty_tree ... ok +test cow::btree::diff_tests::an_insert_an_update_and_a_delete_each_report_both_sides ... ok +test cow::btree::delete_unlink_tests::emptying_the_leftmost_leaf_promotes_its_successor ... ok +test cow::btree::delete_unlink_tests::deleting_on_a_fork_leaves_the_parents_tree_intact ... ok +test cow::btree::diff_tests::identical_roots_diff_to_nothing_without_reading_a_page ... ok +test cow::btree::delete_unlink_tests::every_surviving_key_is_still_readable_after_scattered_deletion ... ok +test cow::btree::diff_tests::reversing_the_roots_reverses_each_delta ... ok +test cow::btree::diff_tests::many_scattered_edits_are_all_reported_exactly_once ... ok +test cow::chunker::tests::a_sequence_that_fits_is_never_cut_without_a_boundary ... ok +test cow::chunker::tests::balanced_cuts_fit_every_piece_and_reduce_to_split_point_for_one_cut ... ok +test cow::btree::delete_unlink_tests::unlinked_pages_are_freed_rather_than_leaked ... ok +test cow::chunker::tests::cuts_depend_only_on_the_sorted_sequence ... ok +test cow::chunker::tests::cuts_never_leave_a_side_empty ... ok +test cow::chunker::tests::no_piece_ever_exceeds_the_page ... ok +test cow::chunker::tests::refining_an_over_long_run_keeps_every_cut_it_already_made ... ok +test cow::chunker::tests::rewriting_a_value_does_not_move_a_boundary ... ok +test cow::chunker::tests::rolling_window_matches_a_fresh_hash ... ok +test cow::chunker::tests::the_boundary_decision_ignores_everything_but_the_entry ... ok +test cow::chunker::tests::degenerate_key_shapes_do_not_produce_massive_chunks ... ok +test cow::chunker::tests::the_hash_is_order_sensitive ... ok +test cow::btree::diff_tests::unchanged_neighbours_of_an_edited_row_are_not_reported ... ok +test cow::chunker::tests::the_chunk_size_tracks_bytes_not_row_count ... ok +test cow::chunker::tests::the_mean_chunk_lands_near_the_target ... ok +test cow::cid::tests::a_one_bit_input_change_avalanches_across_the_output ... ok +test cow::cid::tests::a_prefix_does_not_hash_like_its_extension ... ok +test cow::cid::tests::a_non_btree_page_is_refused_rather_than_hashed ... ok +test cow::btree::delete_unlink_tests::deleting_a_terminating_boundary_key_rejoins_the_leaf_with_its_neighbour ... ok +test cow::cid::tests::domain_tags_separate_otherwise_identical_inputs ... ok +test cow::cid::tests::a_subtree_untouched_by_an_edit_keeps_its_cid ... ok +test cow::cid::tests::length_prefixing_prevents_field_boundary_collisions ... ok +test cow::btree::delete_unlink_tests::deleting_every_boundary_drives_chunks_over_a_page ... ok +test cow::cid::tests::a_suffix_delete_leaves_the_partition_of_the_surviving_rows_alone ... ok +test cow::cid::tests::a_delete_of_an_interior_boundary_key_must_not_change_the_partition ... ok +test cow::dedup::tests::a_content_id_collision_does_not_share_pages ... ok +test cow::dedup::tests::a_crash_between_the_increment_and_the_record_leaks_nothing ... ok +test cow::dedup::tests::a_racing_publisher_makes_the_loser_hand_its_page_back ... ok +test cow::dedup::tests::an_over_release_is_refused_and_changes_nothing ... ok +test cow::dedup::tests::an_uncommitted_ticket_on_a_fresh_page_hands_the_page_back ... ok +test cow::dedup::tests::an_uncommitted_ticket_rolls_its_increment_back_on_drop ... ok +test cow::dedup::tests::concurrent_interning_of_one_payload_counts_every_reference_once ... ok +test cow::dedup::tests::concurrent_release_frees_the_page_exactly_once ... ok +test cow::dedup::tests::different_payloads_never_share ... ok +test cow::dedup::tests::identical_payloads_share_one_page_and_allocate_once ... ok +test cow::dedup::tests::recovery_frees_a_page_whose_only_reference_was_lost_to_the_crash ... ok +test cow::dedup::tests::recovery_leaves_committed_references_alone ... ok +test cow::dedup::tests::releasing_the_last_reference_frees_the_page ... ok +test cow::dedup::tests::the_audit_detects_an_inconsistency_it_is_supposed_to_catch ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test cow::diff::tests::a_cyclic_page_graph_fails_instead_of_looping ... ok +test cow::cid::tests::one_changed_value_changes_the_root_cid ... ok +test cow::diff::tests::a_memo_is_bound_to_one_tree_so_its_digest_and_its_version_share_a_file ... ok +test cow::btree::diff_tests::one_edit_in_a_large_tree_decodes_only_a_fraction_of_it ... ok +test cow::diff::tests::a_page_crafted_onto_a_colliding_checksum_is_still_a_different_version ... ok +test cow::btree::delete_unlink_tests::no_row_is_lost_or_reordered_by_a_boundary_repair ... ok +test cow::diff::tests::a_growing_insert_run_that_splits_nodes_still_diffs_correctly ... ok +test cow::diff::tests::a_content_hash_skips_a_rewritten_but_unchanged_leaf_where_page_identity_cannot ... ok +test cow::diff::tests::a_memo_over_a_genuinely_memoless_digest_skips_and_never_falls_back ... ok +test cow::diff::tests::a_recycled_page_id_misses_the_memo_instead_of_answering_for_its_previous_life ... ok +test cow::diff::tests::a_stale_row_costs_a_skip_and_never_the_change ... ok +test cow::btree::delete_unlink_tests::an_overwrite_that_erases_a_terminator_also_rejoins ... ok +test cow::diff::tests::an_in_place_rewrite_invalidates_the_row_although_the_birth_epoch_is_unchanged ... ok +test cow::diff::tests::an_empty_tree_against_a_populated_one_reports_every_row ... ok +test cow::diff::tests::an_ancestors_warmed_row_survives_a_descendant_rewrite_and_goes_on_answering ... ok +test cow::diff::tests::an_unstamped_page_falls_back_to_page_identity_and_cannot_collide_with_a_content_id ... ok +test cow::cid::tests::identical_trees_built_independently_have_equal_subtree_cids ... ok +test cow::diff::tests::re_warming_a_recycled_page_replaces_its_row_rather_than_stepping_over_it ... ok +test cow::diff::tests::a_warmed_memo_identity_skips_and_never_falls_back ... ok +test cow::diff::tests::inserts_and_deletes_are_classified_and_the_answer_matches_a_full_scan ... ok +test cow::cid::tests::same_data_in_two_orders_converges_to_one_partition_cid ... ok +test cow::diff::tests::comparing_a_page_with_itself_never_consults_the_identity_provider ... ok +test cow::diff::tests::the_hash_folds_length_prefixed_so_a_reframing_cannot_agree ... ok +test cow::diff::tests::an_unwarmed_memo_identity_still_answers_correctly_and_says_so ... ok +test cow::diff::tests::the_version_key_consults_the_birth_epoch_and_not_only_the_checksum ... ok +test cow::diff::tests::cross_lineage_is_the_digests_own_case_and_it_still_has_to_pay_for_the_stamp ... ok +test cow::diff::tests::the_second_store_recycles_page_ids_too_and_a_stale_row_misses_there_as_well ... ok +test cow::merge3::tests::a_non_btree_page_is_refused_rather_than_walked_as_an_internal_node ... ok +test cow::diff::tests::the_diff_is_antisymmetric ... ok +test cow::diff::tests::shared_stats_accumulate_across_diffs ... ok +test cow::merge3::tests::a_subtree_hash_reused_across_an_in_place_write_refuses_its_stale_row ... ok +test cow::merge3::tests::content_identity_skips_a_convergent_edit_that_page_identity_must_descend ... ok +test cow::diff::tests::the_key_catches_the_recycle_and_the_in_place_write_and_provably_not_a_descendant ... ok +test cow::merge3::tests::every_untouched_key_survives_the_merge ... ok +test cow::merge3::tests::birth_epoch_discriminates_a_recycled_page_but_not_an_in_place_write ... ok +test cow::diff::tests::rewriting_every_leaf_skips_nothing ... ok +test cow::diff::tests::four_changed_rows_are_found_exactly ... ok +test cow::merge3::tests::resolve_cell_covers_the_table_exhaustively ... ok +test cow::merge3::tests::a_merge_that_splits_a_leaf_on_both_sides_still_merges ... ok +test cow::merge3::tests::merging_the_other_direction_agrees_except_on_which_side_wins_a_conflict ... ok +test cow::diff::tests::identical_trees_skip_everything_and_read_nothing ... ok +test cow::merge3::tests::ours_unchanged_takes_theirs_whole_without_reading_a_page ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test cow::merge3::tests::row_01_all_three_hold_x_keeps_x ... ok +test cow::merge3::tests::row_02_ours_moved_theirs_did_not_takes_ours ... ok +test cow::merge3::tests::row_04_convergent_edit_is_not_a_conflict ... ok +test cow::merge3::tests::row_06_ours_deleted_theirs_unchanged_deletes ... ok +test cow::merge3::tests::row_03_theirs_moved_ours_did_not_takes_theirs ... ok +test cow::merge3::tests::row_05_both_moved_to_different_values_conflicts ... ok +test cow::merge3::tests::row_07_theirs_deleted_ours_unchanged_deletes ... ok +test cow::merge3::tests::row_09_ours_modified_theirs_deleted_conflicts ... ok +test cow::merge3::tests::row_08_ours_deleted_theirs_modified_conflicts ... ok +test cow::merge3::tests::row_10_both_deleted_is_convergent_not_a_conflict ... ok +test cow::merge3::tests::row_11_ours_inserted_theirs_absent_takes_ours ... ok +test cow::merge3::tests::sides_agree_takes_either_without_reading_a_page ... ok +test cow::node::tests::a_full_node_reports_full_instead_of_corrupting_itself ... ok +test cow::merge3::tests::row_13_convergent_insert_is_not_a_conflict ... ok +test cow::node::tests::all_children_refuses_a_cell_that_overruns_the_page ... ok +test cow::node::tests::all_children_refuses_a_cell_whose_body_is_not_a_child_pointer ... ok +test cow::node::tests::leaf_insert_search_and_remove ... ok +test cow::node::tests::internal_child_routing_uses_the_leftmost_slot_for_small_keys ... ok +test cow::node::tests::node_body_never_touches_the_page_header ... ok +test cow::node::tests::repeated_overwrite_is_reclaimed_by_compaction ... ok +test cow::merge3::tests::row_12_theirs_inserted_ours_absent_takes_theirs ... ok +test cow::merge3::tests::row_14_add_add_with_different_values_conflicts ... ok +test cow::node::tests::split_point_keeps_both_halves_non_empty ... ok +test cow::page_header::tests::checksum_catches_a_flipped_payload_byte ... ok +test cow::page_header::tests::header_roundtrips_and_leaves_payload_alone ... ok +test cow::page_header::tests::privacy_needs_both_own_arena_and_post_fork_birth ... ok +test cow::page_header::tests::unknown_page_type_is_rejected ... ok +test cow::tests::overwriting_a_key_does_not_double_count_bytes ... ok +test cow::merge3::tests::theirs_unchanged_takes_ours_whole_without_reading_a_page ... ok +test cow::tests::write_buffer_probe_distinguishes_delete_from_absent ... ok +test cow::merge3::tests::the_same_workload_on_forks_skips_almost_everything ... ok +test cow::merge3::tests::three_unrelated_trees_produce_zero_skips ... ok +test cow::tests_compact_refusal::a_partial_promotion_leaves_a_page_that_verifies ... ok +test cow::tests_compact_refusal::a_refused_insert_leaves_a_page_that_verifies ... ok +test cow::tests_compact_refusal::a_refused_replace_leaves_a_page_that_verifies ... ok +test cow::tests_chunking::the_key_limit_holds_on_both_sides_and_refuses_before_spending_a_page ... ok +test cow::tests_compact_refusal::a_starved_insert_loses_no_row_that_the_unstarved_control_keeps ... ignored, documents a real pre-existing atomicity defect; see the doc comment. Not D113. +test cow::tests_isolation::a_branch_can_never_mutate_another_branchs_page_in_place ... ok +test cow::tests_isolation::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test cow::tests_chunking::every_leaf_but_the_last_ends_on_a_content_boundary ... ok +test cow::tests_chunking::leaves_average_the_target_chunk_size_and_never_exceed_a_page ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test cow::tests_isolation::a_live_child_forces_the_parent_to_shadow_instead_of_mutating ... ok +test cow::tests_isolation::a_deleted_key_stays_visible_to_a_branch_that_forked_before_the_delete ... ok +test cow::tests_isolation::a_child_writing_never_frees_a_page_its_parent_still_points_at ... ok +test cow::tests_isolation::a_page_private_to_the_writer_is_mutated_in_place_not_reshadowed ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test cow::tests_chunking::rewriting_values_in_place_leaves_the_partition_alone ... ok +test cow::tests_isolation::a_recycled_extent_never_hands_back_stale_page_contents ... ok +test cow::tests_chunking::insertion_order_does_not_change_the_leaf_partition ... ok +test cow::tests_isolation::allocating_from_an_unknown_arena_is_an_error ... ok +test cow::tests_isolation::a_torn_page_is_refused_rather_than_returned ... ok +test cow::tests_isolation::an_oversized_entry_is_refused_rather_than_silently_truncated ... ok +test cow::tests_chunking::wide_rows_chunk_by_content_and_still_fit_the_page ... ok +test cow::tests_isolation::a_write_in_a_child_does_not_change_what_the_parent_reads ... ok +test cow::tests_isolation::a_cursor_that_failed_does_not_yield_again ... ok +test cow::tests_isolation::an_unregistered_branch_is_refused_rather_than_guessed_at ... ok +test cow::merge3::tests::nodes_read_is_far_below_the_page_count ... ok +test cow::tests_isolation::flushing_a_write_buffer_produces_the_same_tree_as_direct_inserts ... ok +test cow::tests_isolation::freeing_an_arena_is_one_operation_not_a_per_page_scan ... ok +test cow::tests_isolation::delete_removes_only_the_named_key_and_a_miss_shadows_nothing ... ok +test cow::tests_isolation::registering_the_same_branch_twice_is_refused ... ok +test cow::tests_isolation::fork_copies_zero_data_pages ... ok +test cow::tests_isolation::the_trunk_cannot_be_forgotten ... ok +test cow::tests_isolation::reaping_an_abandoned_branch_returns_the_page_count_to_baseline ... ok +test cow::tests_isolation::two_siblings_writing_the_same_key_do_not_see_each_other ... ok +test cow::tests_isolation::every_bound_yields_exactly_the_keys_a_reference_map_holds ... ok +test cow::tests_isolation::reading_the_first_row_of_a_scan_does_not_read_the_whole_tree ... ok +test cow::tests_isolation::splits_and_multi_level_growth_preserve_every_key ... ok +test execution::executor::tests::a_deleted_primary_key_can_be_inserted_again ... ok +test execution::executor::tests::an_uncommitted_delete_does_not_free_the_primary_key ... ok +test execution::executor::tests::a_live_primary_key_is_still_refused ... ok +test execution::executor::tests::not_null_violation_errors ... ok +test execution::executor::tests::test_arithmetic ... ok +test cow::tests_chunking::the_tree_agrees_with_the_chunker_about_where_the_leaves_end ... ok +test execution::executor::tests::test_analyze_unknown_table_error ... ok +test execution::executor::tests::reusing_a_key_does_not_let_the_next_duplicate_through ... ok +test cow::tests_isolation::arenas_roll_over_when_an_extent_fills_and_stay_owned_by_one_branch ... ok +test cow::tests_isolation::the_default_one_megabyte_extent_works_end_to_end ... ok +test execution::executor::tests::test_comparison ... ok +test execution::executor::tests::test_analyze_empty_table ... ok +test execution::executor::tests::test_analyze_basic ... ok +test cow::btree::delete_unlink_tests::a_boundary_delete_rejoins_across_a_parent_as_well ... ok +test execution::executor::tests::test_analyze_nulls_duplicates ... ok +test execution::executor::tests::test_block_commits_atomically ... ok +test execution::executor::tests::test_block_rollback_discards_everything ... ok +test execution::executor::tests::test_error_aborts_everything ... ok +test execution::executor::tests::test_comparison_filter ... ok +test execution::executor::tests::test_delete_then_select ... ok +test execution::executor::tests::test_duplicate_primary_key_errors ... ok +test execution::executor::tests::test_insert_stamps_begin_ts ... ok +test execution::executor::tests::test_filter ... ok +test execution::executor::tests::test_logical ... ok +test cow::btree::delete_unlink_tests::a_childless_internal_node_is_removed_from_its_parent ... ok +test execution::executor::tests::test_rejects_ddl_inside_block ... ok +test execution::executor::tests::test_inner_join ... ok +test execution::executor::tests::test_join_no_match ... ok +test execution::executor::tests::test_inner_join_with_keyword ... ok +test execution::executor::tests::test_unary ... ok +test execution::executor::tests::test_join_select_star ... ok +test execution::executor::tests::test_projection ... ok +test execution::executor::tests::test_select_all ... ok +test execution::executor::tests::test_left_no_match ... ok +test execution::fulltext_search::tests::idf_falls_with_document_frequency_and_never_goes_negative ... ok +test execution::executor::tests::test_join_with_where ... ok +test execution::executor::tests::test_left_join ... ok +test execution::fulltext_search::tests::term_score_saturates_in_tf_and_penalises_length ... ok +test execution::fulltext_search::tests::the_heap_agrees_with_a_full_sort ... ok +test execution::executor::tests::test_snapshot_pins_at_begin ... ok +test execution::executor::tests::test_left_outer_keyword ... ok +test execution::fulltext_search::tests::the_heap_keeps_the_best_k_whatever_order_they_arrive_in ... ok +test execution::executor::tests::test_uncommitted_writes_invisible_across_sessions ... ok +test execution::fulltext_search::tests::the_heap_never_returns_more_than_k_and_never_pads ... ok +test execution::fulltext_search::tests::ties_break_on_the_primary_key_so_the_boundary_is_deterministic ... ok +test execution::hash_join::tests::equal_hash_keys_hash_identically ... ok +test execution::hash_join::tests::hash_key_equality_agrees_with_the_equality_operator ... ok +test execution::executor::tests::test_update_then_select ... ok +test execution::executor::tests::updating_a_primary_key_is_refused_as_a_constraint_and_names_the_remedy ... ok +test execution::executor::tests::test_self_join ... ok +test optimizer::cost_model::tests::test_fallback ... ok +test optimizer::optimizer::tests::test_collect_columns ... ok +test optimizer::optimizer::tests::test_combine_column ... ok +test optimizer::optimizer::tests::test_push_spanning_predicate_stays ... ok +test execution::executor::tests::test_unsupported_join_type_error ... ok +test optimizer::optimizer::tests::test_push_split_remap ... ok +test optimizer::optimizer::tests::test_remap ... ok +test optimizer::optimizer::tests::test_split_and_roundtrip ... ok +test optimizer::optimizer::tests::test_wrap_filter_empty_conjuncts ... ok +test optimizer::optimizer::tests::test_wrap_filter_non_empty_conjuncts ... ok +test parser::parser::tests::a_mistyped_alter_still_says_what_was_expected ... ok +test parser::parser::tests::a_prompt_clause_is_optional_and_absent_is_not_empty ... ok +test parser::parser::tests::a_repeated_or_misplaced_clause_is_named_without_quoting_the_prompt ... ok +test parser::parser::tests::all_is_still_an_ordinary_identifier_everywhere_else ... ok +test parser::parser::tests::alter_table_add_column_carries_not_null ... ok +test parser::parser::tests::alter_table_add_column_parses ... ok +test parser::parser::tests::alter_table_rename_column_parses ... ok +test cow::btree::right_walk_probe::every_leaf_can_reach_its_right_neighbour_without_a_sibling_pointer ... ok +test parser::parser::tests::alter_table_retype_column_parses ... ok +test parser::parser::tests::an_unterminated_candidate_body_names_the_candidate_it_belongs_to ... ok +test parser::parser::tests::drop_column_is_refused_by_name_with_the_reason ... ok +test parser::parser::tests::positional_add_column_is_refused_by_name ... ok +test parser::parser::tests::prompt_is_still_a_usable_column_and_table_name ... ok +test parser::parser::tests::simulate_admits_all_or_a_count ... ok +test parser::parser::tests::simulate_refuses_a_run_with_nothing_to_compare_or_nothing_to_check ... ok +test parser::parser::tests::test_as_of_coexists_with_alias_and_where ... ok +test parser::parser::tests::test_as_of_without_branch_keyword_is_an_error ... ok +test parser::parser::tests::test_begin_agent_session_requires_a_quoted_agent_id ... ok +test parser::parser::tests::test_expr_renders_back_to_sql_for_guard_capture ... ok +test parser::parser::tests::test_panic_mode_synchronization ... ok +test parser::parser::tests::test_parse_begin_agent_session ... ok +test parser::parser::tests::test_parse_create_table ... ok +test parser::parser::tests::test_parse_diff_merge_abandon ... ok +test parser::parser::tests::test_parse_insert ... ok +test parser::parser::tests::test_parse_revert_merge ... ok +test parser::parser::tests::test_parse_select_as_of_branch ... ok +test parser::parser::tests::test_parse_select_str ... ok +test parser::parser::tests::test_parse_simulate ... ok +test parser::parser::tests::test_select_with_join ... ok +test parser::parser::tests::test_update_with_where ... ok +test parser::parser::tests::the_alter_grammar_words_are_still_usable_as_column_names ... ok +test parser::scanner::tests::prompt_is_not_a_reserved_word ... ok +test parser::scanner::tests::test_agent_keywords ... ok +test parser::scanner::tests::test_agent_keywords_are_case_insensitive ... ok +test parser::scanner::tests::test_all_keywords ... ok +test parser::scanner::tests::test_fulltext_keywords ... ok +test parser::scanner::tests::test_identifier_vs_keyword ... ok +test parser::scanner::tests::test_operators ... ok +test parser::scanner::tests::test_select_statement ... ok +test parser::scanner::tests::test_whitespace_ignored ... ok +test pgwire::extended::tests::a_parse_error_names_the_parameter_and_not_the_rewriting ... ok +test pgwire::extended::tests::result_format_shorthands_all_resolve_to_one_format_per_column ... ok +test pgwire::extended::tests::statements_split_on_top_level_semicolons_only ... ok +test pgwire::message::tests::a_body_reports_what_it_did_not_consume ... ok +test pgwire::message::tests::a_bodyless_message_is_still_four_bytes_of_length ... ok +test pgwire::message::tests::a_null_column_is_length_minus_one_not_an_empty_byte_string ... ok +test pgwire::message::tests::a_truncated_body_is_an_error_and_not_a_panic ... ok +test pgwire::message::tests::an_empty_string_is_length_zero_and_not_null ... ok +test pgwire::message::tests::error_response_fields_are_terminated_and_typed ... ok +test pgwire::message::tests::framing_length_covers_itself_and_the_body_but_not_the_tag ... ok +test pgwire::message::tests::parameter_description_is_a_count_then_one_oid_each ... ok +test pgwire::message::tests::ready_for_query_carries_the_transaction_status ... ok +test pgwire::message::tests::row_description_advertises_one_field_per_column ... ok +test pgwire::params::tests::a_bare_dollar_is_left_for_the_scanner_to_complain_about ... ok +test pgwire::params::tests::a_dollar_inside_a_string_literal_is_data ... ok +test pgwire::params::tests::a_parameter_carrying_sql_stays_one_value ... ok +test pgwire::params::tests::a_parameter_the_client_did_not_bind_is_refused ... ok +test pgwire::params::tests::a_quoted_identifier_is_not_scanned_for_parameters ... ok +test pgwire::params::tests::a_two_digit_parameter_is_one_parameter ... ok +test pgwire::params::tests::placeholders_become_identifiers_and_are_counted ... ok +test pgwire::params::tests::substitution_puts_a_literal_where_the_parameter_was ... ok +test pgwire::params::tests::substitution_reaches_every_expression_position ... ok +test pgwire::params::tests::zero_and_out_of_range_parameter_numbers_are_refused ... ok +test pgwire::session::tests::a_malformed_set_is_refused_here_and_not_passed_on ... ok +test pgwire::session::tests::a_multi_word_value_survives ... ok +test pgwire::session::tests::a_select_against_a_real_table_is_not_a_probe ... ok +test pgwire::session::tests::a_timeout_this_server_does_not_enforce_is_refused_unless_it_is_off ... ok +test pgwire::session::tests::a_transaction_option_this_engine_cannot_honour_is_refused ... ok +test pgwire::session::tests::an_encoding_this_server_cannot_produce_is_refused ... ok +test pgwire::session::tests::an_unknown_parameter_is_refused_but_a_namespaced_one_is_kept ... ok +test pgwire::session::tests::an_update_with_a_set_clause_is_not_a_session_set ... ok +test pgwire::session::tests::ordinary_sql_is_not_a_session_command ... ok +test pgwire::session::tests::pg_catalog_queries_get_an_explanation_rather_than_invented_rows ... ok +test pgwire::session::tests::reset_all_puts_every_parameter_back ... ok +test pgwire::session::tests::set_and_show_round_trip_a_parameter ... ok +test pgwire::session::tests::set_parses_the_spellings_drivers_actually_send ... ok +test pgwire::session::tests::the_normaliser_leaves_alone_what_is_not_transaction_control ... ok +test pgwire::session::tests::the_probes_a_real_driver_sends_are_recognised ... ok +test pgwire::session::tests::the_reported_isolation_level_is_the_one_the_engine_actually_gives ... ok +test pgwire::session::tests::the_reported_parameters_all_have_values ... ok +test pgwire::session::tests::the_startup_packet_can_set_parameters_too ... ok +test pgwire::session::tests::the_transaction_spellings_drivers_send_map_onto_the_three_this_engine_has ... ok +test pgwire::tests::the_reported_server_version_parses_as_a_version_and_stays_below_the_jit_era ... ok +test pgwire::types::tests::a_declared_column_type_maps_to_the_same_oid_as_a_value_of_it ... ok +test pgwire::types::tests::a_malformed_numeric_is_refused_rather_than_read_past_its_end ... ok +test pgwire::types::tests::a_row_that_disagrees_with_its_description_is_refused_not_encoded ... ok +test pgwire::types::tests::a_short_binary_parameter_is_refused_rather_than_padded ... ok +test pgwire::types::tests::a_whole_float_parameter_stays_a_float ... ok +test pgwire::types::tests::an_unencodable_oid_is_named_rather_than_guessed ... ok +test pgwire::types::tests::binary_integers_are_fixed_width_big_endian ... ok +test pgwire::types::tests::binary_parameters_decode_to_the_literal_the_binder_already_understands ... ok +test pgwire::types::tests::booleans_render_as_t_and_f_the_way_postgres_text_format_does ... ok +test pgwire::types::tests::null_is_null_in_both_formats_and_never_the_word ... ok +test pgwire::types::tests::numeric_encodes_to_the_documented_base_10000_shape ... ok +test pgwire::types::tests::numeric_round_trips_including_the_trailing_zeros_that_are_information ... ok +test pgwire::types::tests::numeric_zero_has_no_sign_and_no_digits ... ok +test pgwire::types::tests::text_parameters_decode_under_their_declared_oid ... ok +test pgwire::types::tests::values_map_to_the_postgres_type_oids_a_client_expects ... ok +test planner::plan::tests::test_bounds_basic ... ok +test planner::plan::tests::test_bounds_flipped ... ok +test planner::plan::tests::test_bounds_invalid ... ok +test planner::plan::tests::test_index_scan_range ... ok +test provenance::capture::tests::a_capture_retains_reads_in_the_form_the_shape_demands ... ok +test planner::plan::tests::test_index_scan_secondary_equality ... ok +test planner::plan::tests::test_index_scan_unbounded ... ok +test provenance::capture::tests::a_halted_plan_names_no_version_to_undo_and_a_cascade_names_them_deepest_first ... ok +test planner::plan::tests::test_index_scan_point ... ok +test provenance::capture::tests::a_point_read_never_retains_a_predicate_even_if_one_is_offered ... ok +test planner::plan::tests::test_index_scan_secondary_rejects_strict_lower ... ok +test provenance::capture::tests::blind_writes_survive_the_round_trip_through_a_capture ... ok +test provenance::capture::tests::stamping_on_write_ties_the_version_to_the_run ... ok +test provenance::capture::tests::the_log_finds_a_downstream_dependent_and_halts ... ok +test provenance::capture::tests::the_report_names_the_agent_behind_each_blocking_transaction ... ok +test provenance::durable::tests::a_failed_append_poisons_the_store_instead_of_leaving_it_looking_healthy ... ok +test provenance::durable::tests::a_dropped_table_forgets_only_what_preceded_the_drop ... ok +test provenance::durable::tests::a_foreign_file_is_refused ... ok +test provenance::durable::tests::a_restamped_version_replays_to_the_later_run ... ok +test provenance::durable::tests::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test provenance::durable::tests::a_stamp_naming_an_undeclared_run_is_refused_rather_than_healed ... ok +test provenance::durable::tests::clearing_a_rows_author_survives_the_reopen_as_a_clear ... ok +test provenance::durable::tests::logical_row_authorship_survives_a_close_and_reopen ... ok +test provenance::durable::tests::re_interning_after_a_reopen_returns_the_same_slot ... ok +test provenance::readset::tests::a_scan_makes_no_write_in_that_table_blind ... ok +test provenance::durable::tests::run_records_out_of_order_in_the_file_still_replay_to_their_recorded_ids ... ok +test provenance::readset::tests::blind_writes_finds_the_row_nobody_looked_at ... ok +test provenance::readset::tests::form_is_decided_by_shape_alone ... ok +test provenance::durable::tests::the_in_memory_guards_still_hold_and_a_refused_stamp_never_reaches_the_file ... ok +test provenance::readset::tests::intersecting_two_lower_bounds_keeps_the_tighter_one ... ok +test provenance::readset::tests::intersecting_two_upper_bounds_keeps_the_tighter_one ... ok +test provenance::durable::tests::who_wrote_a_row_still_answers_after_a_close_and_reopen ... ok +test provenance::readset::tests::predicate_summary_gives_phantom_coverage ... ok +test provenance::readset::tests::scattered_point_reads_are_never_coarsened_however_many ... ok +test provenance::readset::tests::the_intersected_region_is_what_covers_answers_over ... ok +test provenance::revert::tests::a_downstream_dependent_is_found_through_the_read_set ... ok +test provenance::revert::tests::a_predicate_read_depends_on_a_write_that_landed_inside_its_range ... ok +test provenance::revert::tests::a_scan_does_not_depend_on_a_write_it_could_not_have_seen ... ok +test provenance::revert::tests::a_transaction_does_not_depend_on_itself ... ok +test provenance::revert::tests::a_write_outside_the_scanned_range_creates_no_edge ... ok +test provenance::revert::tests::cascade_orders_dependents_before_their_dependency ... ok +test provenance::revert::tests::halt_is_the_default_and_reverts_nothing ... ok +test provenance::revert::tests::predicate_reads_contribute_no_fake_exact_edges ... ok +test provenance::revert::tests::the_halt_tree_shows_the_whole_downstream_chain ... ok +test provenance::revert::tests::the_tree_terminates_on_a_read_write_cycle ... ok +test provenance::sha256::tests::a_prompt_digest_is_non_zero_and_depends_on_the_prompt ... ok +test provenance::sha256::tests::digests_at_every_padding_boundary_match_an_independent_implementation ... ok +test provenance::sha256::tests::fips_180_4_vectors ... ok +test provenance::sha256::tests::hex_round_trips ... ok +test provenance::sha256::tests::one_flipped_bit_changes_the_digest ... ok +test provenance::sha256::tests::the_one_million_a_vector ... ok +test provenance::store::tests::a_logical_row_remembers_which_run_published_it ... ok +test provenance::store::tests::a_page_dictionary_refuses_to_widen_past_a_byte ... ok +test provenance::store::tests::a_row_cannot_be_attributed_to_a_run_this_store_never_interned ... ok +test provenance::store::tests::a_run_can_be_asked_what_it_wrote ... ok +test provenance::store::tests::an_unstamped_version_is_unattributed_not_wrong ... ok +test provenance::store::tests::attributed_rows_reads_one_table_in_row_order ... ok +test provenance::store::tests::attributing_a_row_to_nobody_clears_it_rather_than_being_refused ... ok +test provenance::store::tests::attribution_is_run_level_so_a_page_holds_one_dictionary_entry_per_run ... ok +test provenance::store::tests::criterion_nine_which_agent_run_model_wrote_this_row ... ok +test provenance::store::tests::distinct_runs_get_distinct_slots ... ok +test provenance::store::tests::forgetting_a_table_drops_its_rows_and_only_its_rows ... ok +test provenance::store::tests::interning_the_same_run_twice_is_a_lookup ... ok +test provenance::store::tests::re_interning_a_run_with_a_different_model_is_refused ... ok +test provenance::store::tests::re_publishing_a_row_moves_its_author_to_the_later_run ... ok +test provenance::store::tests::stamping_a_version_with_no_provenance_is_refused ... ok +test provenance::store::tests::stamping_with_an_uninterned_slot_is_refused ... ok +test provenance::store::tests::the_density_numbers_the_docs_quote_are_the_numbers_this_computes ... ok +test provenance::store::tests::the_interned_slot_beats_a_literal_header_by_a_wide_margin ... ok +test provenance::store::tests::the_local_slot_round_trips_through_the_dictionary ... ok +test provenance::tests::prov_id_zero_means_unattributed ... ok +test optimizer::cost_model::tests::test_cardinality ... ok +test provenance::tests::run_entity_answers_exit_criterion_nine ... ok +test replication::backup::tests::a_backup_older_than_the_primarys_log_is_refused_by_name ... ok +test replication::backup::tests::a_label_round_trips_through_text ... ok +test replication::backup::tests::a_label_that_is_not_one_is_refused ... ok +test replication::backup::tests::a_label_without_the_version_header_is_refused_rather_than_guessed_at ... ok +test replication::backup::tests::a_checkpoint_may_not_discard_a_pinned_range ... ok +test replication::jsonl::tests::a_bare_json_number_would_lose_these_values_which_is_why_they_are_strings ... ok +test replication::jsonl::tests::a_create_table_shape_omits_the_denied_column ... ok +test replication::backup::tests::pinning_an_lsn_the_log_has_already_dropped_is_refused ... ok +test replication::jsonl::tests::a_declaration_carries_no_alteration ... ok +test replication::jsonl::tests::a_delete_line_carries_before_and_a_null_after ... ok +test replication::jsonl::tests::a_delete_projects_its_only_image ... ok +test replication::backup::tests::a_backup_records_the_lsn_window_its_pages_sit_in ... ok +test replication::jsonl::tests::a_denied_column_is_named_nowhere_in_the_line ... ok +test replication::jsonl::tests::a_feed_with_nothing_withheld_is_unchanged ... ok +test replication::jsonl::tests::a_mixed_row_keeps_narrow_types_as_numbers_and_wide_types_as_strings ... ok +test replication::jsonl::tests::a_null_column_is_present_and_null_not_absent ... ok +test replication::jsonl::tests::a_null_wide_column_is_json_null_not_a_quoted_null ... ok +test replication::jsonl::tests::a_refusal_minted_inside_the_renderer_still_writes_nothing ... ok +test replication::jsonl::tests::a_rename_line_carries_the_old_name_the_new_shape_cannot ... ok +test replication::backup::tests::a_truncated_image_is_refused_rather_than_restored ... ok +test replication::jsonl::tests::a_retype_line_gives_both_type_spellings ... ok +test replication::backup::tests::the_image_holds_the_primarys_pages ... ok +test replication::jsonl::tests::a_row_whose_present_values_are_all_withheld_is_refused ... ok +test replication::jsonl::tests::a_snapshot_read_projects_its_row ... ok +test replication::jsonl::tests::a_table_dump_is_projected_and_refused_by_the_same_policy ... ok +test replication::jsonl::tests::a_whole_numbered_float_keeps_its_decimal_point ... ok +test cow::diff::tests::warming_a_whole_tree_through_an_on_demand_digest_costs_more_than_the_o_n_path ... ok +test replication::jsonl::tests::a_withheld_first_column_leaves_valid_json ... ok +test replication::jsonl::tests::an_add_column_line_names_the_added_column_and_the_new_shape ... ok +test replication::jsonl::tests::an_insert_line_carries_after_and_a_null_before ... ok +test replication::jsonl::tests::an_undecided_table_is_refused_by_the_renderer ... ok +test replication::jsonl::tests::an_update_line_carries_both_images ... ok +test replication::jsonl::tests::both_images_of_an_update_are_projected ... ok +test replication::jsonl::tests::column_names_are_escaped_too ... ok +test replication::jsonl::tests::every_character_json_requires_escaping_is_escaped ... ok +test replication::jsonl::tests::floats_round_trip_bit_for_bit ... ok +test replication::jsonl::tests::integer_extremes_survive_exactly ... ok +test replication::jsonl::tests::non_ascii_passes_through_as_utf8 ... ok +test replication::jsonl::tests::non_finite_floats_become_strings_rather_than_invalid_json ... ok +test replication::jsonl::tests::surplus_values_get_visible_synthetic_names_rather_than_vanishing ... ok +test replication::jsonl::tests::the_wide_types_are_emitted_as_quoted_strings ... ok +test replication::jsonl::tests::write_feed_reports_how_many_lines_it_wrote ... ok +test replication::jsonl::tests::write_feed_writes_nothing_at_all_when_any_event_is_refused ... ok +test replication::logical::tests::a_change_to_an_unknown_table_is_reported_rather_than_dropped ... ok +test replication::logical::tests::a_committed_insert_becomes_a_typed_row_event ... ok +test replication::logical::tests::a_decoder_with_no_tables_reports_everything_as_unresolved ... ok +test replication::logical::tests::a_null_column_decodes_as_null ... ok +test replication::logical::tests::a_ddl_record_whose_alteration_is_not_in_its_shape_is_refused ... ok +test replication::logical::tests::a_second_session_of_one_run_is_not_a_conflicting_actor ... ok +test replication::logical::tests::a_declaration_names_a_run_without_attributing_anybodys_commit ... ok +test replication::logical::tests::an_aborted_transaction_emits_nothing ... ok +test replication::logical::tests::sql_type_contract_is_exhaustive ... ok +test replication::logical::tests::an_open_transaction_is_withheld_and_reported ... ok +test replication::logical::tests::events_are_ordered_by_commit_not_by_write ... ok +test replication::logical::tests::an_update_carries_the_row_before_and_after ... ok +test replication::publication::tests::a_column_name_the_format_cannot_express_is_withheld_never_published ... ok +test replication::publication::tests::a_create_table_shape_is_masked_like_a_row ... ok +test replication::publication::tests::a_declaration_with_no_header_is_refused ... ok +test replication::logical::tests::one_provenance_slot_naming_two_actors_is_refused ... ok +test replication::logical::tests::an_mvcc_delete_is_reported_as_a_delete_not_an_update ... ok +test replication::publication::tests::a_drop_table_is_decided_by_the_table_and_not_by_its_columns ... ok +test replication::logical::tests::time_travel_records_are_counted_as_internal_not_as_lost ... ok +test replication::publication::tests::a_duplicate_column_is_refused ... ok +test replication::publication::tests::a_header_with_no_name_is_refused_as_a_nameless_header ... ok +test replication::publication::tests::a_line_the_parser_cannot_read_is_refused_rather_than_skipped ... ok +test replication::publication::tests::a_name_containing_the_comment_character_is_refused_rather_than_shortened ... ok +test replication::publication::tests::a_name_that_is_not_an_identifier_is_refused ... ok +test replication::publication::tests::a_publication_that_names_no_table_is_refused_at_parse ... ok +test replication::publication::tests::a_refusal_says_which_publication_refused_what ... ok +test replication::publication::tests::a_table_declared_twice_is_refused ... ok +test replication::publication::tests::a_table_published_with_no_columns_is_refused_like_an_absent_one ... ok +test replication::publication::tests::a_table_that_is_both_published_and_excluded_is_refused_either_way_round ... ok +test replication::publication::tests::a_table_that_is_not_in_the_publication_is_refused ... ok +test replication::publication::tests::a_table_with_no_columns_is_refused_at_parse ... ok +test replication::publication::tests::a_value_with_no_column_name_is_refused_under_an_allowlist ... ok +test replication::publication::tests::an_event_whose_every_column_is_withheld_is_refused ... ok +test replication::publication::tests::an_excluded_table_is_decided_against_rather_than_undecided ... ok +test replication::publication::tests::both_images_of_an_update_are_covered_by_the_value_check ... ok +test replication::publication::tests::mask_for_decides_a_bare_table_and_column_list ... ok +test replication::publication::tests::parse_narrows_to_exactly_what_it_names ... ok +test replication::publication::tests::two_columns_with_one_name_are_refused_rather_than_guessed ... ok +test replication::publication::tests::unrestricted_publishes_everything ... ok +test replication::publication::tests::whole_line_comments_and_blank_lines_are_ignored ... ok +test replication::snapshot::tests::a_failed_scan_yields_no_handoff ... ok +test replication::snapshot::tests::a_quiet_database_hands_off_exactly ... ok +test replication::snapshot::tests::a_snapshot_writes_read_events_and_reports_its_handoff ... ok +test replication::snapshot::tests::an_empty_table_still_yields_a_handoff_point ... ok +test replication::snapshot::tests::a_failed_exact_scan_closes_its_reader_and_yields_no_handoff ... ok +test replication::snapshot::tests::the_handoff_lsn_is_taken_before_the_scan_not_after ... ok +test replication::snapshot::tests::a_scan_that_fails_after_writing_reports_the_rollback_too ... ok +test replication::snapshot::tests::the_boundary_covers_exactly_the_tables_that_were_delivered ... ok +test replication::stream::tests::a_batch_that_is_entirely_refused_does_not_advance_the_cursor ... ok +test replication::snapshot::tests::an_exact_snapshot_writes_its_rows_and_pins_its_resume_point ... ok +test replication::snapshot::tests::a_snapshot_whose_resume_point_cannot_be_pinned_leaves_no_reader_behind ... ok +test replication::stream::tests::a_batch_that_is_entirely_suppressed_still_advances_the_cursor ... ok +test replication::stream::tests::a_large_backlog_is_delivered_in_bounded_batches ... ok +test replication::stream::tests::a_cursor_below_the_logs_base_is_refused_by_name ... ok +test replication::stream::tests::a_long_open_transaction_does_not_cause_endless_redelivery ... ok +test replication::stream::tests::a_pump_emits_committed_changes_and_advances_past_them ... ok +test replication::stream::tests::a_refusal_leaves_a_subscriptions_claim_below_the_refused_commit ... ok +test replication::stream::tests::a_refused_event_is_replayed_after_the_publication_is_widened ... ok +test replication::stream::tests::a_streamer_without_a_snapshot_boundary_suppresses_nothing ... ok +test replication::stream::tests::a_schema_event_survives_the_snapshot_filter ... ok +test replication::stream::tests::a_transaction_that_was_in_flight_at_snapshot_time_is_still_delivered ... ok +test replication::stream::tests::an_already_delivered_event_does_not_wedge_the_cursor_when_it_later_becomes_unpublishable ... ok +test replication::stream::tests::a_subscription_holds_the_log_across_a_checkpoint ... ok +test replication::stream::tests::an_excluded_tables_events_are_dropped_and_the_cursor_keeps_moving ... ok +test replication::stream::tests::an_in_flight_transaction_does_not_move_the_cursor_and_is_emitted_after_it_commits ... ok +test replication::stream::tests::changes_that_are_not_durable_yet_are_not_emitted ... ok +test replication::stream::tests::an_open_transaction_is_not_stepped_over_when_a_later_one_commits ... ok +test replication::stream::tests::events_the_snapshot_already_contained_are_not_re_delivered ... ok +test replication::stream::tests::following_takes_its_cursor_from_the_boundary_and_refuses_without_one ... ok +test replication::stream::tests::lag_shrinks_as_a_consumer_catches_up ... ok +test replication::stream::tests::resuming_from_a_commit_position_alone_loses_an_in_flight_transaction ... ok +test replication::sync::tests::a_replica_that_reconnects_inside_the_deadline_still_satisfies_the_commit ... ok +test replication::sync::tests::a_target_already_acked_does_not_wait ... ok +test replication::stream::tests::resuming_from_the_returned_cursor_does_not_repeat_events ... ok +test replication::sync::tests::an_ack_never_goes_backwards ... ok +test replication::sync::tests::an_ack_that_reaches_the_target_releases_the_waiter ... ok +test replication::sync::tests::the_furthest_replica_satisfies_the_wait ... ok +test replication::stream::tests::withholding_a_column_still_ships_the_row_and_advances_the_cursor ... ok +test replication::stream::tests::subscribing_below_the_base_is_refused_immediately ... ok +test replication::tests::a_frame_too_short_for_its_own_fields_is_refused ... ok +test replication::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test replication::sync::tests::with_no_replica_it_refuses_rather_than_degrading_silently ... ok +test replication::tests::an_empty_records_batch_is_distinct_from_no_batch ... ok +test replication::tests::an_oversized_length_is_refused_without_allocating ... ok +test replication::tests::every_message_round_trips ... ok +test replication::tests::the_handshake_rejects_a_stranger_and_a_version_mismatch ... ok +test replication::tests::the_length_covers_the_body_only ... ok +test storage::atomic_file::tests::a_bare_relative_target_syncs_the_current_directory_not_the_empty_path ... ok +test storage::atomic_file::tests::a_durable_replace_syncs_the_temporary_before_the_rename_and_the_directory_after_it ... ok +test storage::atomic_file::tests::a_path_that_names_no_file_is_refused_rather_than_staged_through_dot_tmp ... ok +test storage::atomic_file::tests::syncing_a_real_directory_succeeds_on_this_platform ... ok +test storage::atomic_file::tests::the_temporary_appends_tmp_instead_of_replacing_the_extension ... ok +test storage::db_lock::tests::a_second_claim_on_one_database_is_refused ... ok +test storage::db_lock::tests::a_stale_lock_is_refused_and_names_the_file_to_remove ... ok +test storage::db_lock::tests::an_empty_lock_file_still_refuses ... ok +test provenance::durable::tests::the_interning_claim_still_holds_after_a_reopen_on_the_existing_instrument ... ok +test storage::db_lock::tests::dropping_the_lock_lets_the_next_open_through ... ok +test storage::disk_manager::tests::a_page_between_two_regions_belongs_to_this_allocator_and_can_be_freed ... ok +test storage::db_lock::tests::two_different_databases_do_not_contend ... ok +test storage::disk_manager::tests::allocate_skips_a_bounded_region_and_keeps_handing_out_pages_above_it ... ok +test storage::disk_manager::tests::a_second_reservation_at_a_higher_base_is_refused ... ok +test storage::disk_manager::tests::growth_advances_past_bounded_regions_and_stops_at_an_unbounded_one ... ok +test storage::disk_manager::tests::exhausting_the_table_region_says_the_knob_will_not_help_this_database ... ok +test storage::disk_manager::tests::an_unbounded_region_still_stops_the_allocator_and_names_the_knob ... ok +test storage::disk_manager::tests::growing_the_bitmap_does_not_reuse_owned_pages ... ok +test storage::disk_manager::tests::high_water_does_not_regress_after_a_free ... ok +test storage::disk_manager::tests::overlapping_regions_are_refused_and_the_message_names_both ... ok +test storage::disk_manager::tests::next_page_id_is_not_the_high_water_mark ... ok +test storage::disk_manager::tests::re_registering_an_identical_region_is_a_restart_not_an_error ... ok +test storage::disk_manager::tests::reattaching_at_the_same_base_is_allowed ... ok +test storage::disk_manager::tests::test_freelist ... ok +test storage::disk_manager::tests::test_rw ... ok +test storage::disk_manager::tests::two_bounded_regions_coexist_which_a_single_floor_refused ... ok +test storage::heap_file_manager::tests::test_insert_and_read ... ok +test storage::heap_file_manager::tests::test_insert_delete_scan ... ok +test storage::heap_page::tests::test_basic ... ok +test storage::atomic_file::tests::the_real_filesystem_replace_lands_the_bytes_and_leaves_no_temporary ... ok +test storage::heap_page::tests::test_exact_bytes ... ok +test storage::heap_file_manager::tests::test_scan ... ok +test storage::heap_file_manager::tests::test_multiple_pages ... ok +test storage::heap_file_manager::tests::test_update_in_place ... ok +test storage::index::tests::test_delete_not_real_key ... ok +test storage::index::tests::test_create_init_empty_leaf ... ok +test storage::index::tests::test_internal_node_split_a_lot ... ok +test storage::index::tests::test_range_scan_basic ... ok +test storage::index::tests::test_delete_basic ... ok +test storage::index::tests::test_search_empty_tree_returns_none ... ok +test storage::index::tests::test_search_finds_key ... ok +test storage::index::tests::test_range_scan_many_pages ... ok +test storage::index_fulltext::tests::an_over_long_run_is_dropped_and_its_neighbours_are_not ... ok +test storage::index_fulltext::tests::distinct_tokens_dedups_but_tokenize_does_not ... ok +test storage::index_fulltext::tests::indexed_text_accepts_varchar_and_null_and_refuses_the_rest ... ok +test storage::index_fulltext::tests::null_is_the_floor_of_the_second_component ... ok +test storage::index_fulltext::tests::the_cap_counts_bytes_not_chars ... ok +test storage::index::tests::test_many_level_insert_and_search ... ok +test storage::index::tests::test_simple_insert ... ok +test storage::index::tests::test_many_level_reverse ... ok +test storage::index_fulltext::tests::tokenize_emits_no_empty_tokens ... ok +test storage::index_fulltext::tests::tokenize_limits_are_the_documented_ones ... ok +test storage::index_fulltext::tests::tokenize_lowercases_and_splits_on_non_alphanumerics ... ok +test storage::index_page::tests::test_internal_find_child ... ok +test storage::index_page::tests::test_internal_insert_key_child ... ok +test storage::index_page::tests::test_internal_split ... ok +test storage::index_page::tests::test_leaf_composite_keys ... ok +test storage::index_page::tests::test_leaf_get ... ok +test storage::index_page::tests::test_leaf_insert_sorted ... ok +test storage::index_page::tests::test_leaf_remove ... ok +test storage::index_page::tests::test_leaf_split ... ok +test storage::index_page::tests::test_roundtrip_i_p ... ok +test storage::index_page::tests::test_roundtrip_i_s ... ok +test storage::index_page::tests::test_roundtrip_l_p ... ok +test storage::index_page::tests::test_roundtrip_l_s ... ok +test storage::page_latch::tests::a_read_latch_taken_from_inside_the_pool_is_refused - should panic ... ok +test storage::page_latch::tests::a_write_latch_taken_from_inside_the_pool_is_refused - should panic ... ok +test storage::page_latch::tests::different_pages_do_not_block_each_other ... ok +test storage::page_latch::tests::latching_first_and_then_entering_the_pool_is_allowed ... ok +test storage::page_latch::tests::nested_pool_sections_unwind_to_zero_and_not_below ... ok +test storage::page_latch::tests::readers_share_and_a_writer_excludes ... ok +test storage::page_latch::tests::the_depth_is_per_thread ... ok +test storage::page_latch::tests::the_table_empties_when_every_guard_is_dropped ... ok +test storage::tuple::tests::serialising_a_row_that_does_not_match_its_schema_is_refused ... ok +test storage::page_latch::tests::the_write_latch_actually_serialises ... ok +test storage::tuple::tests::test_null ... ok +test storage::tuple::tests::test_se_and_deserialize ... ok +test storage::tuple::tests::test_versioned_tuple_roundtrip ... ok +test tel::capture::tests::a_delta_against_a_different_column_is_not_a_relative_move ... ok +test tel::capture::tests::a_guard_reading_outside_the_target_table_is_refused ... ok +test tel::capture::tests::a_join_shifts_the_target_tables_offsets ... ok +test tel::capture::tests::a_literal_assignment_stays_an_assign ... ok +test tel::capture::tests::a_single_table_plan_still_maps_offsets_straight_through ... ok +test tel::capture::tests::a_snapshot_refuses_a_row_it_does_not_hold ... ok +test tel::capture::tests::a_where_clause_becomes_a_guard_that_can_be_rechecked_later ... ok +test tel::capture::tests::an_untranslatable_predicate_is_refused_rather_than_weakened ... ok +test tel::capture::tests::arithmetic_inside_a_guard_survives_capture ... ok +test tel::capture::tests::conjunctions_and_negations_survive_capture ... ok +test tel::capture::tests::end_to_end::concurrent_decrements_compose_arithmetically ... ok +test tel::capture::tests::end_to_end::overselling_is_rejected_with_the_violated_predicate_returned ... ok +test tel::capture::tests::end_to_end::the_rejection_is_not_unconditional ... ok +test tel::capture::tests::five_minus_qty_is_not_an_add_because_subtraction_does_not_commute ... ok +test tel::capture::tests::five_plus_qty_is_still_an_add ... ok +test tel::capture::tests::qty_minus_five_is_captured_as_an_add_not_an_assign ... ok +test tel::engine::tests::a_delete_does_not_invalidate_its_own_where_clause ... ok +test tel::engine::tests::a_delete_versus_a_write_names_the_right_side_in_the_report ... ok +test tel::engine::tests::a_discarded_write_is_reported_as_the_side_s_net_effect ... ok +test tel::engine::tests::a_drawdown_that_exactly_exhausts_the_resource_is_allowed ... ok +test tel::engine::tests::a_duplicate_frame_across_sides_is_dropped_too ... ok +test tel::engine::tests::a_failing_lca_read_surfaces_instead_of_becoming_no_lca_value ... ok +test tel::engine::tests::a_frames_own_guarded_delete_is_not_unevaluable ... ok +test tel::engine::tests::a_genuinely_violated_precondition_still_conflicts ... ok +test tel::engine::tests::a_guard_reading_a_row_the_other_side_deleted_is_unevaluable ... ok +test tel::engine::tests::a_one_sided_write_is_clean_not_commuting ... ok +test tel::engine::tests::a_removed_dot_does_not_survive_the_merge ... ok +test tel::engine::tests::a_row_created_inside_the_merge_supplies_its_own_base_for_a_delta ... ok +test tel::engine::tests::a_row_routed_op_that_is_not_create_or_delete_is_refused ... ok +test tel::engine::tests::a_schema_version_mismatch_fails_loudly ... ok +test tel::engine::tests::a_set_op_meeting_a_scalar_op_conflicts_rather_than_panicking ... ok +test tel::engine::tests::a_solo_decrement_landing_on_its_own_bound_is_not_a_conflict ... ok +test tel::engine::tests::a_well_formed_row_create_is_still_composed ... ok +test tel::engine::tests::additive_policy_uses_the_three_way_formula_on_absolute_writes ... ok +test tel::engine::tests::an_absent_lca_cell_still_falls_back_to_the_fresh_image ... ok +test tel::engine::tests::an_uncontended_decrement_does_not_conflict_with_its_own_result ... ok +test tel::engine::tests::an_unevaluable_guard_is_distinct_from_a_failed_one ... ok +test tel::engine::tests::both_sides_deleting_the_same_row_is_idempotent ... ok +test tel::engine::tests::contradictory_assigns_under_the_default_policy_conflict ... ok +test tel::engine::tests::dedup_reports_which_txn_it_dropped ... ok +test tel::engine::tests::delete_on_one_side_versus_a_write_on_the_other_conflicts ... ok +test tel::engine::tests::diff_returns_the_ops_and_guards_the_target_branch_added ... ok +test tel::engine::tests::diff_without_a_log_is_an_error_not_an_empty_changeset ... ok +test tel::engine::tests::escrow_floor_is_rechecked_against_merged_state ... ok +test tel::engine::tests::every_violated_guard_is_reported_not_just_the_first ... ok +test tel::engine::tests::folding_a_side_applies_its_own_ops_in_order ... ok +test tel::engine::tests::guard_failing_against_merged_state_is_a_conflict_with_the_predicate_returned ... ok +test optimizer::cost_model::tests::test_cost_secondary_over_primary ... ok +test tel::engine::tests::identical_assigns_are_not_a_conflict ... ok +test tel::engine::tests::lww_orders_by_global_txn_id_not_branch_local_seq ... ok +test tel::engine::tests::lww_reports_resolved_with_loss_and_names_the_discarded_write ... ok +test tel::engine::tests::main_untouched_is_clean ... ok +test tel::engine::tests::max_and_min_compose_without_a_policy ... ok +test tel::engine::tests::merging_frames_read_back_from_the_log_reaches_the_same_answer ... ok +test tel::engine::tests::multi_value_keeps_both_writes ... ok +test tel::engine::tests::multi_value_surfaces_each_side_s_net_effect ... ok +test tel::engine::tests::multivalue_composes_net_effects_not_last_ops ... ok +test tel::engine::tests::observed_remove_spares_an_insert_the_remover_never_saw ... ok +test tel::engine::tests::replayed_frame_is_dropped_rather_than_applied_twice ... ok +test tel::engine::tests::the_same_guard_holds_when_the_composition_leaves_headroom ... ok +test tel::engine::tests::two_branches_decrementing_compose_arithmetically ... ok +test tel::engine::tests::two_identical_decrements_from_different_txns_are_not_deduplicated ... ok +test tel::guard::tests::an_and_with_an_unknown_operand_is_unknown_not_true ... ok +test tel::guard::tests::an_or_with_an_unknown_operand_is_unknown_and_not_negatable_into_true ... ok +test tel::guard::tests::bounded_counter_guard_composes_with_arithmetic ... ok +test tel::guard::tests::guard_is_rechecked_against_whatever_state_it_is_given ... ok +test tel::guard::tests::null_operand_makes_the_guard_unknown_not_true ... ok +test tel::guard::tests::referenced_cells_finds_every_read ... ok +test tel::guard::tests::unreadable_cell_is_an_error_not_a_false ... ok +test tel::guard::tests::violated_predicate_is_returned_verbatim ... ok +test tel::log::tests::frames_come_back_in_sequence_order_per_branch ... ok +test tel::log::tests::guards_survive_the_round_trip_separately_from_ops ... ok +test tel::log::tests::replaying_the_identical_frame_does_not_store_it_twice ... ok +test tel::log::tests::reusing_a_txn_id_for_different_contents_is_an_error_not_an_overwrite ... ok +test tel::log::tests::the_same_txn_id_on_a_different_branch_is_a_different_frame ... ok +test tel::log::tests_durable_log::a_boolean_byte_that_is_neither_zero_nor_one_is_refused ... ok +test tel::log::tests_durable_log::a_contradicting_reappend_is_refused_and_never_reaches_the_file ... ok +test tel::log::tests_durable_log::a_crash_at_any_point_during_an_append_loses_nothing_already_acknowledged ... ok +test tel::log::tests_durable_log::a_delta_whose_prior_counts_do_not_line_up_is_refused ... ok +test tel::log::tests_durable_log::a_failed_append_leaves_a_store_that_still_works_and_a_file_that_still_opens ... ok +test storage::index::tests::internal_nodes_split_before_overflowing_the_page ... ok +test tel::log::tests_durable_log::a_foreign_file_and_a_damaged_header_are_both_refused ... ok +test tel::log::tests_durable_log::a_growing_frame_replays_to_its_final_contents_and_not_to_the_sum_of_its_appends ... ok +test tel::log::tests_durable_log::a_growth_whose_tail_carries_guards_and_claims_replays_with_them ... ok +test tel::log::tests_durable_log::a_guard_nested_past_the_cap_is_refused_on_the_way_in_and_on_the_way_out ... ok +test tel::log::tests_durable_log::a_merge_computed_after_a_restart_agrees_with_one_computed_before_it ... ok +test tel::log::tests_durable_log::a_nan_delta_does_not_turn_a_retry_into_a_contradiction ... ok +test tel::log::tests_durable_log::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test tel::log::tests_durable_log::a_read_error_mid_file_refuses_the_open_and_destroys_nothing ... ok +test tel::log::tests_durable_log::a_retried_frame_does_not_double_its_add_across_a_restart ... ok +test tel::log::tests_durable_log::a_second_open_for_one_key_is_refused ... ok +test tel::log::tests_durable_log::a_value_too_long_for_its_length_prefix_is_refused_rather_than_truncated ... ok +test tel::log::tests_durable_log::an_extend_for_a_frame_the_file_never_declared_is_refused ... ok +test tel::log::tests_durable_log::an_unknown_tag_and_a_record_with_trailing_bytes_are_both_refused ... ok +test tel::log::tests_durable_log::concurrent_appends_all_reach_the_file_exactly_once ... ok +test tel::log::tests_durable_log::frames_survive_a_restart_with_every_field_intact ... ok +test tel::log::tests_durable_log::growth_costs_the_delta_and_not_the_whole_frame_again ... ok +test tel::log::tests_durable_log::retyping_a_stored_value_under_growth_cannot_split_the_two_stores ... ok +test tel::log::tests_durable_log::the_same_txn_id_on_a_different_branch_is_a_different_frame_after_a_restart ... ok +test tel::merge::tests::conflict_carries_the_predicate_back_to_the_agent ... ok +test tel::merge::tests::default_policy_forbids_concurrent_writes ... ok +test tel::merge::tests::lossy_resolution_never_reads_as_clean ... ok +test tel::op::tests::assign_is_idempotent_but_does_not_commute ... ok +test tel::op::tests::delta_applies_and_refuses_non_numeric ... ok +test tel::op::tests::integer_delta_overflow_is_an_error_not_a_wrap ... ok +test tel::op::tests::two_identical_decrements_compose_to_double ... ok +test tel::schema_merge::tests::a_one_sided_schema_change_is_clean ... ok +test tel::schema_merge::tests::a_predicate_about_an_absent_column_is_answerable ... ok +test tel::log::tests_durable_log::the_log_opens_on_a_real_file_and_recovers_from_it ... ok +test tel::schema_merge::tests::a_rename_composes_with_an_unrelated_add ... ok +test tel::schema_merge::tests::a_rename_onto_a_name_another_branch_created_conflicts ... ok +test tel::schema_merge::tests::adding_the_same_column_twice_with_different_types_conflicts ... ok +test tel::schema_merge::tests::an_identical_edit_is_absorbed_rather_than_refused ... ok +test tel::schema_merge::tests::no_edits_is_clean_even_when_the_target_moved ... ok +test tel::schema_merge::tests::renaming_a_column_another_branch_renamed_first_conflicts ... ok +test tel::schema_merge::tests::the_returned_predicate_re_evaluates_to_false ... ok +test tel::schema_merge::tests::two_branches_adding_different_columns_compose ... ok +test tel::schema_merge::tests::two_branches_retyping_one_column_conflict_and_name_the_predicate ... ok +test wal::log::tests::a_run_identity_record_that_names_no_run_is_refused ... ok +test wal::log::tests::a_truncated_alteration_is_refused_rather_than_panicking ... ok +test wal::log::tests::a_truncated_run_identity_record_is_refused_rather_than_panicking ... ok +test wal::log::tests::an_older_logs_records_still_decode_after_the_new_tag_was_added ... ok +test wal::log::tests::an_unknown_alteration_tag_is_refused ... ok +test wal::log::tests::create_and_drop_records_keep_their_byte_layout ... ok +test wal::log::tests::a_record_whose_bytes_were_corrupted_is_refused ... ok +test wal::log::tests::deserialize_rejects_empty_and_unknown_tag ... ok +test wal::log::tests::every_column_alteration_survives_the_log_round_trip ... ok +test wal::log::tests::test_crc32_val ... ok +test wal::log::tests::test_reckind_roundtrip ... ok +test wal::log::tests::test_flush_up_to_stops_when_durable ... ok +test wal::log::tests::test_lsns_are_monotonic ... ok +test wal::log::tests::test_read_buffer_before_flush ... ok +test wal::log::tests::test_corrupted_last_record_detected ... ok +test wal::log::tests::test_survives_flush_and_reopen ... ok +test wal::log::tests::the_recovery_scan_stops_at_a_corrupted_record ... ok +test wal::recovery::tests::test_insert_survives_crash ... ok +test wal::txn::tests::a_quiet_database_resumes_at_the_readers_own_begin ... ok +test wal::recovery::tests::test_uncommited_rolled_back ... ok +test wal::txn::tests::a_reader_is_not_leaked_when_its_txn_end_cannot_be_written ... ok +test wal::recovery::tests::sql_crash_recover_rebuild_query ... ok +test wal::txn::tests::a_second_session_of_one_run_is_the_same_actor_however_the_clock_moved ... ok +test wal::recovery::tests::test_update_delete_redo_after_checkpoint ... ok +test wal::txn::tests::a_refused_binding_leaves_nothing_behind_for_the_commit_to_write ... ok +test wal::txn::tests::a_snapshot_read_resumes_at_the_oldest_transaction_it_excluded ... ok +test wal::txn::tests::binding_a_run_with_an_empty_named_field_is_refused ... ok +test wal::txn::tests::a_snapshot_reader_that_writes_is_refused_and_rolled_back ... ok +test wal::txn::tests::declaring_one_slot_as_two_actors_is_refused ... ok +test wal::txn::tests::gate_covers_flush_all ... ok +test wal::txn::tests::a_snapshot_reader_is_not_leaked_when_its_handoff_cannot_be_pinned ... ok +test wal::txn::tests::gate_flushed_wal_before_page_write ... ok +test wal::txn::tests::interrupted_truncation_self_heals_on_open ... ok +test wal::txn::tests::checkpoint_truncates_and_preserves_data ... ok +test wal::txn::tests::test_visibility_matrix ... ok +test wal::txn::tests::the_high_water_mark_is_exclusive ... ok +test wal::txn::tests::test_abort_insert_removes_rows ... ok +test wal::txn::tests::test_begin_creates_snapshot ... ok +test wal::txn::tests::test_abort_delete_restores_row ... ok +test wal::visibility::tests::an_uncontended_version_and_my_own_write_are_both_writable ... ok +test wal::txn::tests::test_commit_writes_chain_and_flushes ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_created_is_a_conflict ... ok +test wal::txn::tests::sql_insert_commits_through_run ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_deleted_is_a_conflict ... ok +test wal::txn::tests::torn_tail_trimmed_on_open ... ok +test wal::txn::tests::transaction_ids_never_start_at_zero ... ok +test wal::txn::tests::the_run_identity_record_is_the_last_record_before_its_own_commit ... ok +test storage::atomic_file::tests::concurrent_replaces_of_one_target_never_publish_a_mixture ... ok +test execution::hash_join::tests::nested_loop_and_hash_join_agree_across_integer_and_float_keys ... ok +test optimizer::cost_model::tests::test_join_picks_hash ... ok +test execution::hash_join::tests::hash_join_matches_integer_key_against_float_key ... ok +test execution::executor::tests::root_split_persists_across_reopen ... ok +test branch::table_catalog::tests::a_sidecar_catalog_survives_being_closed_and_reopened_from_the_file_alone ... ok +test branch::table_catalog::tests::a_reopen_through_the_header_page_follows_a_root_that_moved ... ok +test cow::tests_compact_refusal::a_starved_allocation_never_leaves_an_unverifiable_page ... ok +test optimizer::cost_model::tests::test_cost_index_vs_seq ... ok +test optimizer::cost_model::tests::explain_index_flip_at_volume ... ok +test branch::reaper::tests::table_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok + +test result: ok. 1610 passed; 0 failed; 3 ignored; 0 measured; 0 filtered out; finished in 60.06s + +=== target adv_f4_false_refusal + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.21s + Running tests/adv_f4_false_refusal.rs (target/debug/deps/adv_f4_false_refusal-6c13c5accbcb295f) + +running 13 tests +test two_interleaved_connections_merging ... ok +test two_branches_adding_the_same_column_do_not_leave_two_columns_of_that_name ... ok +test quarantine_release_then_merge ... ok +test revert_then_merge ... ok +test abandon_then_new_session_then_merge ... ok +test a_conflicting_merge_then_merge ... ok +test a_gate_refusal_then_merge ... ok +test simulate_then_merge ... ok +test revert_cascade_then_merge ... ok +test multi_op_merges_in_sequence ... ok +test a_merge_that_fails_after_reserving_then_merge ... ok +test forty_merges_with_reverts_interleaved ... ok +test a_hundred_merges_in_a_row ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.13s + +=== target adv_f5_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.64s + Running tests/adv_f5_probe.rs (target/debug/deps/adv_f5_probe-68c5843e4ccb02b0) + +running 5 tests +test p5_an_update_on_a_sealed_branch_now_reports_a_read_error ... ok +test p1_non_agent_as_of_branch_reads_fine_and_retains_nothing ... ok +test p2_agent_session_reads_another_live_branch_as_of ... ok +test p3_supervisor_merge_by_name_leaves_the_agent_connection_inert ... ok +test p4_a_read_on_a_branch_the_reaper_took_is_not_refused_and_pins_revert_forever ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target adv_f6_dropped_capture + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.42s + Running tests/adv_f6_dropped_capture.rs (target/debug/deps/adv_f6_dropped_capture-dab99c539ab9b0b9) + +running 3 tests +test reaping_a_parent_whose_child_published_its_write_loses_the_dependent ... ok +test abandoning_a_parent_whose_child_published_its_write_loses_the_dependent ... ok +test control_merging_the_parent_keeps_the_same_dependent ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target adversarial_i20 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.54s + Running tests/adversarial_i20.rs (target/debug/deps/adversarial_i20-6dd5677a4cbe21b5) + +running 15 tests +test b6_a_truncated_away_alter_leaves_a_diverged_replica_reporting_caught_up ... ignored, known-open defect, ledger row S5: needs a design decision on where replica-local durable state lives; run with --ignored to reproduce +test b4_a_clr_wrapping_an_alter_is_not_seen_by_the_guard ... ok +test a8_a_refused_create_table_still_creates_the_table_and_logs_nothing ... ok +test a1_the_same_range_decodes_differently_depending_on_what_ran_before ... ok +test a6_a_cursor_clamped_backwards_over_a_ddl ... ok +test b1_a_refusal_leaves_applied_lsn_and_the_file_untouched ... ok +test a4_drop_and_recreate_reusing_the_dir_root ... ok +test b2_a_restarted_replica_meets_the_alter_again ... ok +test a3_nine_columns_across_a_pump_boundary ... ok +test b5_small_batches_still_halt_at_the_alter ... ok +test b3_the_alter_record_truncated_away_before_the_restart ... ok +test a7_a_ddl_can_never_share_a_range_with_an_open_transaction ... ok +test a9_history_learned_from_one_log_is_applied_to_another ... ok +test a5_history_growth_under_a_live_subscription_pin ... ok +test a2_two_threads_pumping_one_streamer has been running for over 60 seconds +test a2_two_threads_pumping_one_streamer ... ok + +test result: ok. 14 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 68.19s + +=== target agent_sql_surface + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.24s + Running tests/agent_sql_surface.rs (target/debug/deps/agent_sql_surface-3cafa62735b530de) + +running 32 tests +test a_nested_task_forks_from_its_parents_state_and_then_diverges ... ok +test a_comparison_on_a_branch_does_not_round_the_literal ... ok +test begin_agent_session_forks_a_branch_and_interns_the_run ... ok +test a_conflicting_merge_attributes_nothing ... ok +test as_of_branch_sees_uncommitted_inserts_and_deletes_too ... ok +test captured_frames_reach_the_effect_log_as_one_frame_per_task ... ok +test a_second_agent_session_on_one_connection_is_refused ... ok +test abandon_drops_the_branch_and_leaves_main_untouched ... ok +test agent_statements_outside_a_session_name_the_missing_branch ... ok +test a_malformed_model_is_refused_rather_than_recorded_blank ... ok +test a_guard_that_fails_against_merged_state_returns_the_predicate_itself ... ok +test a_merge_with_no_downstream_reader_reverts_without_cascading ... ok +test branch_writes_are_invisible_to_main_and_to_siblings ... ok +test a_solo_merge_is_clean_and_publishes_to_main ... ok +test a_whole_numbered_literal_updates_a_float_column_on_a_branch_and_on_trunk ... ok +test an_lww_column_resolves_with_loss_and_never_reports_clean ... ok +test a_session_declaring_no_model_is_recorded_as_unspecified_not_guessed ... ok +test a_multi_row_merge_publishes_every_row_in_one_transaction ... ok +test diff_returns_a_structured_changeset_with_ops_and_outcomes ... ok +test diff_of_an_untouched_branch_is_empty_but_still_a_changeset ... ok +test concurrent_assignments_conflict_rather_than_silently_picking_one ... ok +test select_as_of_branch_reads_another_branchs_uncommitted_state ... ok +test two_runs_are_attributed_separately_and_interned_once_each ... ok +test diff_marks_a_row_the_target_moved_under ... ok +test reverting_an_unknown_merge_is_an_error_not_a_no_op ... ok +test row_authorship_survives_the_merge_that_published_it ... ok +test inserts_and_deletes_on_a_branch_are_also_invisible_until_merge ... ok +test ordinary_statements_are_unaffected_by_the_agent_surface ... ok +test two_decrements_inside_one_task_are_not_collapsed ... ok +test revert_merge_halts_on_a_downstream_dependent_then_cascades_on_request ... ok +test two_branches_decrementing_the_same_row_compose_arithmetically ... ok +test row_authorship_survives_a_process_restart_when_provenance_is_durable ... ok + +test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.64s + +=== target d15_concurrent_fork_and_reap + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.28s + Running tests/d15_concurrent_fork_and_reap.rs (target/debug/deps/d15_concurrent_fork_and_reap-f616b8a3d4946baa) + +running 2 tests +test log_catalog_survives_a_concurrent_reaper ... ok +test table_catalog_survives_a_concurrent_reaper ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.90s + +=== target d16_transitive_visibility + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.36s + Running tests/d16_transitive_visibility.rs (target/debug/deps/d16_transitive_visibility-1fb58af419bef1e7) + +running 2 tests +test log_catalog_keeps_the_grandchild_when_an_interior_node_is_pruned ... ok +test table_catalog_keeps_the_grandchild_when_an_interior_node_is_pruned ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d18_reaper_asks_the_catalog + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.48s + Running tests/d18_reaper_asks_the_catalog.rs (target/debug/deps/d18_reaper_asks_the_catalog-6471ae76bebde910) + +running 2 tests +test log_catalog_reap_must_not_free_what_a_live_child_reads ... ok +test table_catalog_reap_must_not_free_what_a_live_child_reads ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target d19_leak_is_a_race + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.39s + Running tests/d19_leak_is_a_race.rs (target/debug/deps/d19_leak_is_a_race-10fc0f339fa6471c) + +running 1 test +test the_control_that_says_race_or_logic has been running for over 60 seconds +test the_control_that_says_race_or_logic ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 223.34s + +=== target d27_fork_shares_without_leaking + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.93s + Running tests/d27_fork_shares_without_leaking.rs (target/debug/deps/d27_fork_shares_without_leaking-93a27c747d4c9c8d) + +running 5 tests +test a_parent_schema_edit_after_the_fork_stays_invisible_to_the_child ... ok +test the_instrument_goes_blind_when_the_workspace_it_reads_is_gone ... ok +test siblings_forked_from_one_parent_cannot_see_each_other ... ok +test a_parent_write_after_the_fork_stays_invisible_to_the_child ... ok +test a_read_on_a_deep_child_does_not_consult_its_ancestors ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.42s + +=== target d33_add_arena_must_be_durable + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.64s + Running tests/d33_add_arena_must_be_durable.rs (target/debug/deps/d33_add_arena_must_be_durable-41f3e506fff8c65a) + +running 3 tests +test add_arena_refuses_a_stale_generation ... ok +test log_catalog_add_arena_survives_a_reopen ... ok +test table_catalog_add_arena_survives_a_reopen ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s + +=== target d40_reserved_pages_return_to_baseline + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.21s + Running tests/d40_reserved_pages_return_to_baseline.rs (target/debug/deps/d40_reserved_pages_return_to_baseline-89f3dd1e73d7a053) + +running 2 tests +test a_forked_chain_returns_every_reserved_page_without_the_global_scan ... ok +test flat_fanout_returns_every_reserved_page_without_the_global_scan ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 48.15s + +=== target d41_narrow_ops_close_the_window + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.87s + Running tests/d41_narrow_ops_close_the_window.rs (target/debug/deps/d41_narrow_ops_close_the_window-cf36684da6956c61) + +running 4 tests +test control_a_restriction_with_no_racer_is_applied ... ok +test a_restriction_may_not_be_widened_by_one_that_raced_it ... ok +test control_quarantine_with_no_racer_leaves_the_budget_unspent ... ok +test a_row_write_charge_is_not_discarded_by_quarantine ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target d45_log_catalog_rmw + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.93s + Running tests/d45_log_catalog_rmw.rs (target/debug/deps/d45_log_catalog_rmw-b05ae16bf4179c54) + +running 3 tests +test the_same_writes_serially_reach_the_same_state ... ok +test a_concurrent_renewal_does_not_discard_a_published_root ... ok +test concurrent_attaches_do_not_drop_children_from_the_parent ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target d53_private_root_allowlist + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.99s + Running tests/d53_private_root_allowlist.rs (target/debug/deps/d53_private_root_allowlist-d8a9d394116a5ead) + +running 1 test +test every_statement_path_opens_the_shared_root_cell ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d53_shared_root_cell + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.60s + Running tests/d53_shared_root_cell.rs (target/debug/deps/d53_shared_root_cell-9ba00e99d5b11db1) + +running 3 tests +test the_catalog_hands_every_statement_the_same_root_cell ... ok +test a_shared_root_cell_lets_the_split_retry_fire ... ok +test a_private_root_cell_makes_the_split_retry_dormant ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d54_as_of_in_session + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.97s + Running tests/d54_as_of_in_session.rs (target/debug/deps/d54_as_of_in_session-2be4703aab3440c0) + +running 1 test +test as_of_branch_inside_a_session_reads_the_named_branch_not_the_session_s_own ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target d54_reader_writer_handshake + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.79s + Running tests/d54_reader_writer_handshake.rs (target/debug/deps/d54_reader_writer_handshake-3b592b78b664f36c) + +running 3 tests +test the_registry_forgets_connections_that_have_gone_away ... ok +test a_reader_stands_down_while_a_writer_holds_the_catalog ... ok +test a_writer_waits_for_an_in_flight_shared_read ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s + +=== target d55_pushdown_commutes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.41s + Running tests/d55_pushdown_commutes.rs (target/debug/deps/d55_pushdown_commutes-c45ab2751355d977) + +running 1 test +test pushdown_agrees_with_filter_afterwards_on_every_overlay_case ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s + +=== target d56_statsless_point_lookup + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.25s + Running tests/d56_statsless_point_lookup.rs (target/debug/deps/d56_statsless_point_lookup-87d43bc9275d16e6) + +running 3 tests +test primary_key_equality_uses_the_index_with_no_statistics ... ok +test the_fact_is_scoped_to_equality_on_the_unique_column ... ok +test the_plan_change_does_not_change_the_answer ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.37s + +=== target d57_overlay_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.10s + Running tests/d57_overlay_probe.rs (target/debug/deps/d57_overlay_probe-4fe8bfa92cded0c5) + +running 9 tests +test a_decimal_key_is_never_probed_because_its_identity_is_finer_than_its_equality ... ok +test varchar_primary_key_probes_by_the_hashed_literal ... ok +test point_probe_with_a_second_conjunct_applies_the_whole_predicate_to_the_staged_row ... ok +test a_literal_of_another_type_falls_back_instead_of_missing_the_staged_row ... ok +test a_workspace_that_moved_a_primary_key_is_walked_not_probed ... ok +test a_non_key_predicate_walks_only_this_tables_staged_rows ... ok +test an_off_type_value_staged_in_column_0_is_still_found_by_a_typed_literal ... ok +test point_probe_agrees_with_the_walk_and_with_the_hand_derived_truth ... ok +test bigint_keys_probe_and_an_out_of_range_literal_falls_back ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.98s + +=== target d58_latch_free_descent + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.97s + Running tests/d58_latch_free_descent.rs (target/debug/deps/d58_latch_free_descent-97a6c740cbc345ac) + +running 7 tests +test a_relabelled_frame_never_publishes_the_previous_pages_bytes ... ok +test a_page_whose_header_disagrees_with_its_label_is_not_descended ... ok +test the_right_walk_crosses_an_empty_leaf ... ok +test a_root_snapshot_from_before_the_splits_still_finds_every_key ... ok +test a_stale_frame_hint_is_refused_by_the_label ... ok +test an_optimistic_read_never_returns_a_torn_page ... ok +test readers_racing_inserters_and_eviction_never_see_a_wrong_value ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 36.72s + +=== target d59_snapshot_cache + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.70s + Running tests/d59_snapshot_cache.rs (target/debug/deps/d59_snapshot_cache-a750662366179024) + +running 9 tests +test raising_the_id_watermark_invalidates_a_cached_snapshot ... ok +test an_abort_also_moves_the_version ... ok +test a_transaction_that_begins_is_in_the_next_cached_snapshot ... ok +test a_transaction_that_commits_leaves_the_next_cached_snapshot ... ok +test a_cached_snapshot_matches_the_locked_one_in_high_water_too ... ok +test recovery_moves_the_version_for_a_thread_that_already_cached_a_snapshot ... ok +test two_managers_on_one_thread_do_not_share_a_cached_snapshot ... ok +test a_watermark_raise_waits_for_the_table_lock ... ok +test a_cached_snapshot_equals_the_locked_one_when_the_version_did_not_move ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.25s + +=== target d60_depth_is_not_capped + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.95s + Running tests/d60_depth_is_not_capped.rs (target/debug/deps/d60_depth_is_not_capped-9dff8d4ad9bec11d) + +running 5 tests +test a_core_record_written_with_a_one_byte_depth_still_loads ... ok +test a_full_record_written_before_the_depth_widened_still_decodes ... ok +test a_chain_far_past_the_old_cap_forks_and_still_reads_what_the_root_wrote ... ok +test reclamation_walks_a_long_reaped_chain_without_recursing_per_level has been running for over 60 seconds +test the_reaper_cascades_up_a_deep_chain_of_reaped_ancestors has been running for over 60 seconds +test reclamation_walks_a_long_reaped_chain_without_recursing_per_level ... ok +test the_reaper_cascades_up_a_deep_chain_of_reaped_ancestors ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 92.18s + +=== target d64_expression_depth + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.72s + Running tests/d64_expression_depth.rs (target/debug/deps/d64_expression_depth-e3f66ecc0cfe544d) + +running 9 tests +test an_ordinary_join_list_still_parses ... ok +test a_long_operator_chain_is_not_bounded_by_the_frame_limit ... ok +test one_level_past_the_limit_is_refused_and_the_message_names_the_limit ... ok +test nesting_cannot_renew_the_chain_budget ... ok +test a_shape_just_under_the_limit_parses_and_produces_a_real_statement ... ok +test exactly_the_tree_budget_in_zero_cost_joins_still_parses ... ok +test a_join_list_longer_than_the_tree_budget_is_refused ... ok +test neither_counter_leaks_across_statements_in_a_batch ... ok +test depths_that_used_to_abort_the_process_are_now_errors ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target d66_join_relation_bitmask + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.93s + Running tests/d66_join_relation_bitmask.rs (target/debug/deps/d66_join_relation_bitmask-a3de4413c30bfef8) + +running 3 tests +test relation_thirty_two_does_not_alias_onto_relation_zero ... ok +test a_join_at_the_relation_limit_still_plans ... ok +test more_relations_than_the_mask_can_hold_is_refused_by_name ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.44s + +=== target d69_key_types + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.88s + Running tests/d69_key_types.rs (target/debug/deps/d69_key_types-ddf516e1b9e4009d) + +running 19 tests +test a_negative_integer_primary_key_round_trips ... ok +test a_duplicate_bigint_key_is_refused_in_a_branch ... ok +test a_bigint_primary_key_round_trips ... ok +test a_duplicate_float_key_is_refused_in_a_branch ... ok +test a_duplicate_decimal_key_is_refused_in_a_branch ... ok +test a_stale_merge_is_refused_for_a_decimal_key ... ok +test a_distinct_key_is_still_admitted_in_a_branch ... ok +test a_stale_merge_is_refused_for_a_float_key ... ok +test a_duplicate_varchar_key_is_refused_in_a_branch ... ok +test a_stale_merge_is_refused_for_a_bigint_key ... ok +test a_stale_merge_is_refused_for_a_varchar_key ... ok +test a_merge_on_a_bigint_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_boolean_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_negative_integer_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_varchar_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_float_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_decimal_keyed_table_finds_its_base_row ... ok +test a_varchar_primary_key_round_trips_through_the_lookup ... ok +test a_varchar_key_with_awkward_content_round_trips ... ok + +test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s + +=== target d69_table_version + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.59s + Running tests/d69_table_version.rs (target/debug/deps/d69_table_version-f8f0f21b4fa0af26) + +running 4 tests +test the_counter_key_is_the_same_hash_the_merge_path_uses ... ok +test plain_dml_outside_any_agent_session_moves_the_counter ... ok +test tables_do_not_share_a_counter ... ok +test the_counter_never_goes_backwards ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target guard_precondition_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.45s + Running tests/guard_precondition_probe.rs (target/debug/deps/guard_precondition_probe-4ef9bb6f64891efb) + +running 5 tests +test both_engines_conflict_on_post_state_so_correctness_lives_in_the_caller ... ok +test a_guarded_delete_merges_solo ... ok +test a_solo_merge_that_lands_on_the_bound_is_not_a_conflict ... ok +test a_genuinely_violated_precondition_is_still_rejected ... ok +test a_floor_guard_does_not_protect_the_floor_and_the_counter_goes_negative ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + +=== target integration_alter_column + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.99s + Running tests/integration_alter_column.rs (target/debug/deps/integration_alter_column-19d5bafdeeed6834) + +running 29 tests +test a_branch_whose_only_statement_is_an_alter_still_merges_against_its_fork_point ... ok +test a_merge_report_names_the_shape_it_produced ... ok +test an_alter_is_refused_while_any_transaction_is_open ... ok +test a_branchs_schema_change_is_invisible_until_it_merges ... ok +test decoding_a_range_twice_gives_the_same_answer_across_an_alter ... ok +test a_row_written_before_a_sibling_widened_the_table_still_publishes ... ok +test crossing_the_null_bitmap_boundary_keeps_every_value ... ok +test deleted_rows_stay_deleted_across_a_rewrite ... ok +test a_column_an_agent_added_reaches_the_feed ... ok +test a_pump_after_an_alter_does_not_silently_truncate_the_row ... ok +test a_column_added_mid_stream_survives_a_pump_boundary ... ok +test renaming_an_indexed_column_leaves_the_table_queryable ... ok +test retyping_a_middle_column_does_not_disturb_its_neighbours ... ok +test rows_written_before_the_column_are_still_readable_after_it ... ok +test two_branches_adding_different_columns_compose ... ok +test two_branches_retyping_one_column_conflict_with_the_predicate ... ok +test the_feed_carries_the_column_change_in_log_order ... ok +test two_branches_making_the_same_change_do_not_conflict ... ok +test the_refusals_refuse_and_the_permitted_cases_are_permitted ... ok +test three_branches_forked_at_three_different_shapes_all_compose ... ok +test the_altered_shape_survives_a_truncation_and_a_reopen ... ok +test history_learned_from_one_log_is_not_applied_to_another ... ok +test a_lookup_by_key_still_finds_a_row_the_rewrite_moved ... ok +test a_secondary_index_over_a_retyped_column_still_answers ... ok +test statistics_survive_an_alteration_exactly ... ok +test the_decoders_history_does_not_grow_with_every_checkpoint ... ok +test a_column_name_needing_escaping_survives_the_whole_pipeline ... ok +test a_column_added_mid_stream_reaches_the_destination ... ok +test a_rename_and_a_retype_reach_the_destination_without_losing_data ... ok + +test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.05s + +=== target integration_alter_refusal_paths + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.49s + Running tests/integration_alter_refusal_paths.rs (target/debug/deps/integration_alter_refusal_paths-58a897f4b4bc3fb2) + +running 9 tests +test the_retry_after_a_refused_merge_does_not_report_success_while_dropping_the_edit ... ok +test a_refused_merge_leaves_a_branch_that_does_not_double_apply_on_retry ... ok +test the_half_applied_rename_is_on_disk_not_just_in_memory ... ok +test a_retry_that_reports_ok_must_have_applied_the_staged_retype ... ok +test a_merge_refused_for_size_still_publishes_the_branch_rows ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_first_applied ... ok +test control_the_same_merge_lands_when_the_rows_leave_room ... ok +test a_second_branch_refused_leaves_the_first_branchs_merge_intact ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_heap_rewritten ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.20s + +=== target integration_alter_refusal_safety + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.48s + Running tests/integration_alter_refusal_safety.rs (target/debug/deps/integration_alter_refusal_safety-9dc38a5d5b1b844b) + +running 16 tests +test max_tuple_size_is_the_boundary_page_insert_actually_enforces ... ok +test an_unlogged_update_that_cannot_obtain_a_page_refuses_instead_of_deleting_the_row ... ok +test an_unlogged_update_too_large_for_any_page_refuses_instead_of_deleting_the_row ... ok +test a_refused_alter_survives_a_crash_with_no_clean_flush ... ok +test a_refused_alter_survives_a_checkpoint_a_flush_and_a_fresh_buffer_pool ... ok +test a_lone_schema_edit_refused_at_merge_leaves_the_target_exactly_as_it_was ... ok +test repeated_refusals_leave_the_table_unchanged_and_the_statement_still_works ... ok +test a_refused_alter_converts_nothing_when_the_allocator_dies_part_way ... ok +test an_ordinary_wide_row_is_not_destroyed_by_a_refused_add_column ... ok +test a_refused_retype_leaves_every_row_byte_identical ... ok +test a_refused_alter_changes_nothing_when_the_page_allocator_is_exhausted ... ok +test the_precheck_does_not_refuse_a_row_that_fits ... ok +test a_refused_retype_does_not_leave_a_readable_wrong_answer ... ok +test a_refused_add_column_leaves_every_row_byte_identical ... ok +test a_refusal_after_a_partial_reservation_leaves_only_empty_pages ... ok +test an_alter_that_must_reserve_many_pages_completes_and_keeps_every_row ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.66s + +=== target integration_arena_exclusivity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.94s + Running tests/integration_arena_exclusivity.rs (target/debug/deps/integration_arena_exclusivity-0beae8489274366d) + +running 5 tests +test a_second_lower_arena_region_is_refused ... ok +test the_bitmap_allocator_refuses_rather_than_crossing_the_floor ... ok +test an_unreserved_database_allocates_exactly_as_before ... ok +test an_arena_page_cannot_be_freed_through_the_bitmap ... ok +test the_legacy_allocator_never_hands_out_a_page_inside_an_arena_extent ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target integration_base_backup + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.06s + Running tests/integration_base_backup.rs (target/debug/deps/integration_base_backup-e4e3036783fb0fb0) + +running 5 tests +test the_base_backup_carries_pages_the_wal_never_describes ... ok +test synchronous_commit_waits_for_a_replica_and_says_so_when_there_is_none ... ok +test a_backup_taken_while_the_primary_is_writing_still_converges ... ok +test a_replica_killed_mid_stream_resumes_from_its_own_lsn_and_converges ... ok +test a_replica_restored_from_a_base_backup_converges_past_a_checkpoint ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 29.60s + +=== target integration_blind_writes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.56s + Running tests/integration_blind_writes.rs (target/debug/deps/integration_blind_writes-fc7f6d206a0de6ff) + +running 5 tests +test only_the_unread_row_is_reported_when_a_session_does_both ... ok +test a_row_read_before_being_written_is_not_blind ... ok +test a_row_written_without_being_read_is_reported_blind ... ok +test a_session_that_read_and_wrote_nothing_blind_reports_an_empty_set ... ok +test the_merge_report_carries_the_blind_write_set ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target integration_branch_attestation + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) +warning: variable does not need to be mutable + --> tests/integration_branch_attestation.rs:548:9 + | +548 | let mut run_sql = |sql: &str, s: &mut Session, catalog: &mut Catalog| { + | ----^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_branch_attestation") generated 1 warning (run `cargo fix --test "integration_branch_attestation" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.88s + Running tests/integration_branch_attestation.rs (target/debug/deps/integration_branch_attestation-70b4c2161ab68568) + +running 8 tests +test a_reap_through_an_attached_reaper_is_attested_too ... ok +test a_childs_chain_walks_into_the_parent_it_forked_from ... ok +test a_fork_a_merge_and_a_reap_each_leave_an_attested_entry ... ok +test altering_one_entry_breaks_the_chain ... ok +test a_published_head_catches_a_rewrite_that_the_chain_walk_accepts ... ok +test an_auditor_can_check_every_entry_against_a_published_head ... ok +test the_merge_entry_commits_to_the_rows_it_published ... ok +test attesting_a_fork_does_not_read_the_tree ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.70s + +=== target integration_branch_pages + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.69s + Running tests/integration_branch_pages.rs (target/debug/deps/integration_branch_pages-bd6604da2e43823a) + +running 10 tests +test one_branchs_staged_pages_are_unreachable_from_trunk_and_from_a_sibling ... ok +test an_untouched_session_has_an_empty_page_changeset ... ok +test a_staged_row_exists_as_a_page_on_the_branch_tree ... ok +test the_branch_tree_and_the_workspace_map_agree_on_every_staged_row ... ok +test the_page_diff_has_no_fork_point_image_yet_because_base_rows_are_not_on_the_tree ... ok +test a_staged_delete_appears_in_the_page_changeset_as_a_removal ... ok +test wide_typed_cells_survive_a_round_trip_through_a_branchs_pages ... ok +test a_staged_delete_removes_the_row_from_the_branch_tree ... ok +test the_page_derived_changeset_agrees_with_the_map_derived_one ... ok +test staging_allocates_pages_and_an_untouched_branch_allocates_none ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target integration_btree_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.71s + Running tests/integration_btree_concurrency.rs (target/debug/deps/integration_btree_concurrency-da75aeaefb6a6221) + +running 4 tests +test btree_concurrent_1_thread ... ok +test btree_concurrent_8_threads ... ok +test btree_concurrent_16_threads ... ok +test btree_concurrent_32_threads ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.93s + +=== target integration_buffer_pool_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.70s + Running tests/integration_buffer_pool_concurrency.rs (target/debug/deps/integration_buffer_pool_concurrency-a65f77f2eadc4ef7) + +running 9 tests +test reading_a_page_that_does_not_exist_twice_errors_twice_instead_of_panicking ... ok +test the_page_table_never_maps_two_pages_to_one_frame ... ok +test every_page_reads_back_its_own_stamp_single_threaded ... ok +test concurrent_fetches_never_return_another_pages_bytes ... ok +test every_frame_agrees_with_the_page_table_about_which_page_it_holds ... ok +test a_fetch_never_returns_a_frame_labelled_with_a_different_page ... ok +test concurrent_fetches_of_one_cold_page_read_it_once_into_one_frame ... ok +test an_oversubscribed_pool_never_hands_back_a_relabelled_frame ... ok +test a_pinned_page_is_never_evicted_however_hard_the_pool_churns ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.23s + +=== target integration_buffer_pool_latch + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.83s + Running tests/integration_buffer_pool_latch.rs (target/debug/deps/integration_buffer_pool_latch-5450b2d5b6672dfb) + +running 8 tests +test a_stale_page_table_entry_never_yields_another_pages_frame ... ok +test a_failed_load_does_not_strand_the_page_in_transit ... ok +test invalidate_all_refuses_while_a_fault_is_in_flight ... ok +test invalidate_all_refuses_while_a_write_back_is_in_flight ... ok +test the_victim_stays_reachable_until_its_write_back_lands ... ok +test a_declined_eviction_leaves_the_victim_tracked_as_resident ... ok +test every_resident_page_is_still_tracked_by_the_replacement_policy ... ok +test a_dirty_victim_is_on_disk_before_it_stops_being_resident ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target integration_buffer_pool_mirror + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.61s + Running tests/integration_buffer_pool_mirror.rs (target/debug/deps/integration_buffer_pool_mirror-f9eea39f0f22c450) + +running 7 tests +test delete_and_free_take_the_mirror_entry_with_them ... ok +test invalidate_all_empties_the_mirror ... ok +test unpin_never_touches_a_frame_that_does_not_hold_the_page ... ok +test a_page_that_is_evicted_and_refetched_resolves_to_its_new_frame ... ok +test the_mirror_agrees_with_the_page_table_after_an_eviction_storm ... ok +test concurrent_fetches_never_resolve_to_the_wrong_frame_through_the_mirror ... ok +test two_pages_sharing_a_mirror_slot_are_both_served_correctly ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.99s + +=== target integration_buffer_pool_touch_batching + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.66s + Running tests/integration_buffer_pool_touch_batching.rs (target/debug/deps/integration_buffer_pool_touch_batching-cf0e15baf2ab55aa) + +running 6 tests +test a_cache_hit_defers_its_policy_update_instead_of_taking_the_lock ... ok +test the_policy_still_tracks_exactly_the_resident_set_with_a_backlog_outstanding ... ok +test a_full_batch_is_applied_rather_than_dropped ... ok +test acquiring_the_policy_applies_every_pending_hit_first ... ok +test draining_twice_does_not_replay_the_same_updates ... ok +test concurrent_hits_lose_no_policy_updates ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target integration_capability_envelope + Downloading crates ... + Downloaded rand_xorshift v0.4.0 + Downloaded getrandom v0.4.2 + Downloaded errno v0.3.14 + Downloaded wait-timeout v0.2.1 + Downloaded fnv v1.0.7 + Downloaded quick-error v1.2.3 + Downloaded bit-set v0.8.0 + Downloaded rusty-fork v0.3.1 + Downloaded unarray v0.1.4 + Downloaded cfg-if v1.0.4 + Downloaded autocfg v1.5.1 + Downloaded ppv-lite86 v0.2.21 + Downloaded rand_chacha v0.9.0 + Downloaded bit-vec v0.8.0 + Downloaded fastrand v2.4.1 + Downloaded getrandom v0.3.4 + Downloaded rand_core v0.9.5 + Downloaded bitflags v2.11.1 + Downloaded once_cell v1.21.4 + Downloaded tempfile v3.27.0 + Downloaded num-traits v0.2.19 + Downloaded rand v0.9.5 + Downloaded zerocopy v0.8.56 + Downloaded proptest v1.11.0 + Downloaded regex-syntax v0.8.11 + Downloaded rustix v1.1.4 + Downloaded libc v0.2.186 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.42s + Running tests/integration_capability_envelope.rs (target/debug/deps/integration_capability_envelope-74831fd8f4bf4f23) + +running 23 tests +test a_branch_cannot_widen_its_own_envelope ... ok +test a_forbidden_table_is_refused_even_when_the_statement_matches_no_row ... ok +test an_agent_session_inherits_the_envelope_of_the_branch_it_forked_from ... ok +test a_branch_being_reaped_cannot_write_even_with_its_workspace_intact ... ok +test a_statement_refused_by_escrow_spends_no_envelope_budget ... ok +test a_write_that_changes_nothing_costs_no_budget ... ok +test budget_already_spent_is_not_handed_back_by_a_restart ... ok +test installing_an_envelope_does_not_reach_a_session_that_is_already_open ... ok +test an_envelope_survives_a_restart_of_the_branch_metadata_layer ... ok +test the_envelope_reads_one_funnel_while_three_reach_branch_state ... ok +test an_ungoverned_branch_writes_exactly_as_before ... ok +test a_statement_that_overruns_the_row_budget_is_refused_whole ... ok +test an_envelope_survives_closing_and_reopening_the_database ... ok +test a_verb_off_the_allowlist_is_refused_and_one_on_it_still_writes ... ok +test a_table_off_the_allowlist_is_refused_and_one_on_it_still_writes ... ok +test an_insert_cannot_write_a_column_the_branch_was_never_granted ... ok +test an_assignment_that_lowers_a_bounded_value_is_refused_not_just_a_decrement ... ok +test branch_scoped_alter_table_reaches_a_forbidden_table_and_this_is_a_known_gap ... ok +test dropping_and_recreating_a_granted_table_repoints_the_grant_at_different_columns ... ok +test the_envelope_governs_agent_session_writes_only ... ok +test no_ddl_verb_is_governed_by_the_envelope_and_this_is_a_known_gap ... ok +test merge_and_revert_rewrite_a_forbidden_tables_rows_and_this_is_a_known_gap ... ok +test the_substitution_strips_a_column_floor_and_unlocks_a_refused_delete ... ok + +test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.15s + +=== target integration_cdc_cutover_concurrent + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.61s + Running tests/integration_cdc_cutover_concurrent.rs (target/debug/deps/integration_cdc_cutover_concurrent-65e3ef0f96b65b17) + +running 1 test +test every_committed_row_arrives_exactly_once_with_writers_running_throughout ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.21s + +=== target integration_cdc_cutover + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.70s + Running tests/integration_cdc_cutover.rs (target/debug/deps/integration_cdc_cutover-295426561ec9f84a) + +running 4 tests +test a_one_table_boundary_does_not_suppress_another_tables_rows ... ok +test snapshot_and_stream_deliver_every_row_exactly_once ... ok +test the_at_least_once_handoff_both_duplicates_and_drops_in_this_scenario ... ok +test one_reader_snapshots_two_tables_against_a_single_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s + +=== target integration_cdc_ddl + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.95s + Running tests/integration_cdc_ddl.rs (target/debug/deps/integration_cdc_ddl-aded78f2637f85fb) + +running 4 tests +test create_table_reaches_the_feed_as_a_schema_event ... ok +test a_decoder_with_no_catalog_learns_the_table_from_the_log ... ok +test a_blank_decoder_keeps_two_tables_apart ... ok +test the_schema_survives_an_automatic_checkpoint ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.22s + +=== target integration_cdc_diff + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.65s + Running tests/integration_cdc_diff.rs (target/debug/deps/integration_cdc_diff-936f6dba5c4d4d0d) + +running 5 tests +test every_row_of_a_multi_row_commit_reaches_the_destination ... ok +test table_dump_refuses_an_unknown_table_and_a_missing_database ... ok +test the_table_rebuilt_from_the_feed_matches_the_source_database ... ok +test the_diff_refuses_when_there_is_nothing_to_compare ... ok +test the_diff_catches_a_wrong_value_an_extra_row_and_a_missing_row ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.00s + +=== target integration_cdc_drop_table + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.27s + Running tests/integration_cdc_drop_table.rs (target/debug/deps/integration_cdc_drop_table-f94fc4bbf90a9ee3) + +running 4 tests +test a_drop_truncates_like_any_checkpoint_and_says_so ... ok +test dropping_a_table_emits_a_drop_table_event ... ok +test a_checkpoint_after_a_drop_does_not_redeclare_the_dropped_table ... ok +test the_sink_drops_the_destination_table ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.72s + +=== target integration_cdc_duckdb_sink + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.63s + Running tests/integration_cdc_duckdb_sink.rs (target/debug/deps/integration_cdc_duckdb_sink-fab2c49339801171) + +running 5 tests +test the_destination_ddl_comes_from_the_create_table_event ... ok +test replaying_the_whole_feed_changes_nothing ... ok +test the_feed_lands_in_duckdb_matching_the_source ... ok +test a_stale_event_arriving_after_a_newer_one_is_rejected ... ok +test both_readers_agree ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.32s + +=== target integration_cdc_feed + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 8.72s + Running tests/integration_cdc_feed.rs (target/debug/deps/integration_cdc_feed-50d294b5d2e1bdf5) + +running 2 tests +test a_feed_from_real_sql_is_valid_json_to_an_independent_parser ... ok +test a_varchar_full_of_json_metacharacters_survives_the_round_trip ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + +=== target integration_cdc_go_consumer + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.44s + Running tests/integration_cdc_go_consumer.rs (target/debug/deps/integration_cdc_go_consumer-cae5769f6365500f) + +running 4 tests +test the_server_survives_a_consumer_that_stops_reading_its_stdout ... ok +test a_dead_server_is_reported_as_dead_rather_than_retried ... ok +test a_go_consumer_rebuilds_the_source_table_from_the_feed_alone ... ok +test an_updated_row_shows_its_latest_value_only ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.54s + +=== target integration_cdc_key_reuse + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.15s + Running tests/integration_cdc_key_reuse.rs (target/debug/deps/integration_cdc_key_reuse-752b445585432f80) + +running 5 tests +test the_decoder_emits_a_delete_then_an_insert_for_the_same_key ... ok +test a_reused_key_lands_live_in_the_destination ... ok +test replaying_a_feed_that_reuses_a_key_changes_nothing ... ok +test the_delete_tombstones_first_and_the_resurrect_clears_it ... ok +test a_stale_delete_arriving_after_the_resurrect_does_not_re_tombstone_the_row ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.53s + +=== target integration_cdc_latency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.01s + Running tests/integration_cdc_latency.rs (target/debug/deps/integration_cdc_latency-432f98750c4e06cf) + +running 2 tests +test the_latency_run_survives_an_automatic_checkpoint_and_reports ... ok +test two_runs_over_the_same_path_measure_the_same_thing ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.09s + +=== target integration_cdc_publication + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.18s + Running tests/integration_cdc_publication.rs (target/debug/deps/integration_cdc_publication-de6823a0b7600923) + +running 5 tests +test two_columns_with_one_name_are_refused_by_the_producer_and_by_the_consumer ... ok +test a_denied_column_appears_in_zero_events_across_a_workload_that_writes_it ... ok +test a_refusal_mid_commit_replays_the_whole_commit_rather_than_skipping_the_refused_row ... ok +test a_misplaced_argument_is_refused_rather_than_silently_disabling_the_policy ... ok +test the_independent_consumer_refuses_a_feed_the_producer_should_not_have_written ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.77s + +=== target integration_cdc_retract_by_model + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.45s + Running tests/integration_cdc_retract_by_model.rs (target/debug/deps/integration_cdc_retract_by_model-847344b0f0b0f415) + +running 3 tests +test the_feed_carries_a_prompt_digest_and_never_the_prompt ... ok +test a_retraction_that_matches_nothing_is_refused_end_to_end ... ok +test retract_by_model_touches_one_model_versions_rows_and_no_others ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.95s + +=== target integration_cdc_sink + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.89s + Running tests/integration_cdc_sink.rs (target/debug/deps/integration_cdc_sink-9c1452e08ec5c119) + +running 3 tests +test the_feed_lands_in_sqlite_matching_the_source ... ok +test replaying_the_whole_feed_changes_nothing ... ok +test a_stale_event_arriving_after_a_newer_one_is_rejected ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.98s + +=== target integration_cdc_snapshot + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.25s + Running tests/integration_cdc_snapshot.rs (target/debug/deps/integration_cdc_snapshot-0c6535788b666a96) + +running 3 tests +test snapshot_values_are_escaped ... ok +test snapshot_rows_carry_their_actual_values ... ok +test a_consumer_that_snapshots_then_streams_sees_every_row_that_ever_existed ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target integration_cdc_stream + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.44s + Running tests/integration_cdc_stream.rs (target/debug/deps/integration_cdc_stream-f34be5c1eda1ce55) + +running 2 tests +test a_consumer_follows_a_live_database_over_tcp ... ok +test a_consumer_that_disconnects_resumes_without_gap_or_overlap ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.49s + +=== target integration_cdc_wide_types + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.35s + Running tests/integration_cdc_wide_types.rs (target/debug/deps/integration_cdc_wide_types-fc06165e917070ab) + +running 14 tests +test the_same_values_as_bare_json_numbers_are_silently_corrupted ... ok +test a_literal_that_does_not_fit_its_column_is_refused_not_truncated ... ok +test a_comparison_against_a_float_column_does_not_round_the_literal ... ok +test wide_literals_survive_insert_and_select_exactly ... ok +test a_where_clause_against_a_wide_column_matches_by_value ... ok +test a_whole_numbered_literal_lands_in_a_float_column ... ok +test the_declared_types_and_their_rows_survive_a_catalog_reload ... ok +test bigint_arithmetic_works_and_stays_exact ... ok +test timestamp_arithmetic_advances_by_milliseconds ... ok +test a_null_in_a_wide_column_stays_json_null ... ok +test the_feed_ships_wide_types_as_quoted_json_strings ... ok +test the_schema_event_declares_the_wide_types_by_name ... ok +test an_update_to_a_wide_column_ships_both_images_as_strings ... ok +test an_independent_go_consumer_receives_the_wide_columns_as_exact_strings ... ok + +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s + +=== target integration_checkpoint_pressure + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.25s + Running tests/integration_checkpoint_pressure.rs (target/debug/deps/integration_checkpoint_pressure-8ac8dbf42739f76a) + +running 2 tests +test at_the_default_interval_the_workload_cannot_tell_the_two_apart ... ok +test under_constant_truncation_only_a_pinned_consumer_survives ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.01s + +=== target integration_cherry_pick + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.01s + Running tests/integration_cherry_pick.rs (target/debug/deps/integration_cherry_pick-d4c8b390290d8ee6) + +running 6 tests +test a_pick_spanning_two_tables_is_refused_rather_than_half_applied ... ok +test an_empty_selection_is_refused ... ok +test a_selector_naming_no_op_is_refused ... ok +test a_commuting_divergence_is_recovered_through_the_by_cell_index ... ok +test one_op_of_several_lands_and_the_others_do_not ... ok +test a_pick_whose_cell_the_target_already_moved_is_refused_and_writes_nothing ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s + +=== target integration_cli_agent_isolation + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.14s + Running tests/integration_cli_agent_isolation.rs (target/debug/deps/integration_cli_agent_isolation-407874ed1cc90520) + +running 6 tests +test a_deleted_arena_checkpoint_is_refused_rather_than_defaulted ... ok +test a_database_already_open_is_refused_to_a_second_process ... ok +test reopening_reattaches_to_the_existing_arena_and_branch_catalog ... ok +test the_binary_allocates_real_pages_above_the_arena_floor ... ok +test an_agent_sessions_write_is_invisible_to_trunk_and_survives_a_restart ... ok +test a_merged_write_reaches_trunk_and_is_still_there_after_a_restart ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.21s + +=== target integration_cli_effect_log_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.88s + Running tests/integration_cli_effect_log_durability.rs (target/debug/deps/integration_cli_effect_log_durability-bbfef8eb8033c4b1) + +running 1 test +test the_shipped_binary_writes_its_effect_frames_to_disk_and_they_outlive_the_process ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s + +=== target integration_cli_row_authorship_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.30s + Running tests/integration_cli_row_authorship_durability.rs (target/debug/deps/integration_cli_row_authorship_durability-e7d5b225a5855114) + +running 1 test +test the_shipped_binary_still_names_the_agent_that_wrote_a_row_after_a_restart ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.01s + +=== target integration_cluster_agents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.49s + Running tests/integration_cluster_agents.rs (target/debug/deps/integration_cluster_agents-3b12af37878b8d8b) + +running 35 tests +test a_cluster_branch_id_names_the_node_that_holds_its_rows ... ok +test a_local_branch_id_too_large_for_the_namespace_is_refused_not_truncated ... ok +test a_merge_whose_base_round_is_exactly_the_last_base_move_applies ... ok +test a_merge_that_applied_moves_the_base_for_the_merge_behind_it ... ok +test a_merge_whose_base_is_still_the_committed_head_applies ... ok +test a_merge_racing_a_committed_write_to_its_base_is_re_evaluated_not_applied ... ok +test a_fork_whose_child_id_is_already_taken_is_refused_not_overwritten ... ok +test a_fork_off_a_parent_the_cluster_does_not_hold_is_refused ... ok +test a_merge_for_a_branch_whose_fork_never_committed_is_refused ... ok +test a_branch_whose_owner_died_is_named_as_lost_work_and_its_merged_sibling_is_not ... ok +test a_re_delivered_round_is_a_no_op ... ok +test a_re_evaluated_merge_does_not_itself_move_the_base ... ok +test a_reap_is_a_replicated_decision_and_is_refused_twice ... ok +test every_node_reaches_the_same_verdict_for_every_merge_command ... ok +test heartbeat_rounds_do_not_re_evaluate_a_merge ... ok +test no_two_nodes_disagree_about_whether_a_branch_is_live ... ok +test node_zero_cannot_own_a_branch ... ok +test only_commands_that_can_change_what_the_gate_read_move_the_base ... ok +test the_trunk_is_the_same_branch_on_every_node_and_has_no_owner ... ok +test two_nodes_forking_at_the_same_local_id_get_different_cluster_ids ... ok +test a_node_cannot_sweep_its_own_branches_as_orphans ... ok +test a_merge_the_gate_declines_costs_no_consensus_at_all ... ok +test a_fork_on_a_node_that_does_not_lead_is_refused_and_creates_nothing ... ok +test a_fork_and_a_hundred_writes_cost_no_quorum_round_trip_and_the_merge_costs_one ... ok +test a_merge_whose_base_never_stops_moving_is_refused_with_the_round_that_moved ... ok +test a_merge_that_loses_the_leadership_mid_flight_refuses_and_publishes_nothing ... ok +test a_merge_whose_fork_the_cluster_refused_says_so_rather_than_timing_out ... ok +test a_reap_goes_through_the_log_and_carries_the_generation ... ok +test a_promoted_leader_disposes_of_a_dead_nodes_branches_by_a_replicated_decision ... ok +test a_merge_that_loses_the_leadership_while_waiting_refuses_rather_than_blocking ... ok +test a_merge_racing_a_committed_change_to_its_base_is_re_evaluated_rather_than_silently_applied ... ok +test on_a_real_cluster_a_fork_on_a_follower_is_refused_and_names_the_leader ... ok +test a_followers_committed_log_holds_the_merge_and_not_one_agent_row ... ok +test on_three_real_nodes_only_the_merge_reaches_a_quorum ... ok +test a_hundred_agent_writes_across_three_branches_leave_only_forks_and_merges_in_the_log ... ok + +test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.61s + +=== target integration_cluster_grants + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.06s + Running tests/integration_cluster_grants.rs (target/debug/deps/integration_cluster_grants-67065c5a27dc5eca) + +running 35 tests +test a_lease_tick_never_moves_the_clusters_clock_backwards ... ok +test a_node_with_no_cluster_configured_reads_its_own_wall_clock_for_leases ... ok +test a_node_with_no_cluster_configured_still_begins_transactions ... ok +test a_node_with_no_cluster_configured_grants_itself_everything ... ok +test a_member_with_no_lease_tick_refuses_to_answer_what_time_it_is ... ok +test a_standalone_node_reaps_on_its_own_clock_exactly_as_before ... ok +test a_member_allocates_from_its_grant_and_refuses_again_when_it_runs_out ... ok +test a_redelivered_grant_does_not_hand_out_the_same_extent_twice ... ok +test a_replicated_tick_and_not_the_wall_clock_decides_a_reap ... ok +test a_recycled_extent_start_from_a_previous_authority_is_not_handed_out_again ... ok +test a_recycled_page_inside_a_stale_extent_is_not_handed_out_after_a_join ... ok +test a_member_with_no_extent_grant_refuses_to_allocate_an_arena ... ok +test a_granted_extent_is_filled_without_any_further_consensus ... ok +test a_cluster_member_does_not_truncate_its_wal_on_its_own_commit_counter ... ok +test a_member_begins_transactions_from_its_granted_range_and_no_further ... ok +test a_member_with_no_txn_grant_refuses_to_begin_a_transaction ... ok +test a_standalone_node_refuses_a_lease_tick ... ok +test a_replicated_checkpoint_command_is_what_truncates_on_a_member ... ok +test a_grant_addressed_to_another_node_is_refused_by_this_one ... ok +test a_standalone_node_still_checkpoints_on_its_own_commit_counter ... ok +test a_standalone_node_refuses_a_grant_it_was_never_supposed_to_get ... ok +test an_arena_grant_always_carries_more_ids_than_the_extents_it_can_name ... ok +test is_expired_now_refuses_rather_than_answering_false_on_a_member_with_no_tick ... ok +test a_restart_replays_its_grant_and_resumes_above_every_page_it_already_issued ... ok +test the_checkpoint_image_is_byte_identical_to_what_a_node_local_counter_wrote ... ok +test the_infallible_lease_clock_aborts_rather_than_inventing_a_time - should panic ... ok +test recovery_raises_the_transaction_watermark_without_inventing_a_grant ... ok +test an_extent_claimed_under_a_superseded_authority_is_not_filled_afterwards ... ok +test the_reaper_trait_is_the_path_under_test ... ok +test two_nodes_at_the_same_tick_agree_about_whether_a_branch_is_live ... ok +test the_three_appliers_take_exactly_the_frozen_commands_shape ... ok +test an_authority_change_is_noticed_by_the_counters_and_not_merely_survived ... ok +test space_a_node_self_granted_while_standalone_is_revoked_when_it_joins ... ok +test two_nodes_with_disjoint_grants_never_hand_out_the_same_physical_page ... ok +test two_nodes_with_disjoint_grants_never_issue_the_same_transaction_id ... ok + +test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.02s + +=== target integration_cluster_snapshot + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.27s + Running tests/integration_cluster_snapshot.rs (target/debug/deps/integration_cluster_snapshot-350a4148b9c71550) + +running 9 tests +test a_node_left_part_way_through_an_install_refuses_to_start ... ok +test a_completed_install_leaves_no_marker_behind ... ok +test an_install_discards_the_receivers_own_wal ... ok +test a_captured_payload_installs_back_to_the_same_pages ... ok +test the_redo_window_that_travels_with_a_snapshot_is_replayed_into_it ... ok +test with_the_log_retained_the_same_follower_converges_without_a_snapshot ... ok +test a_node_restarted_above_a_snapshot_floor_comes_back_on_it ... ok +test a_follower_partitioned_past_log_retention_rejoins_by_snapshot_and_converges ... ok +test a_snapshot_claiming_an_implausible_size_leaves_nothing_on_the_receivers_disk ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.44s + +=== target integration_consensus_failover + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.31s + Running tests/integration_consensus_failover.rs (target/debug/deps/integration_consensus_failover-a060b5a25175e6bb) + +running 1 test +test a_killed_leader_is_replaced_and_no_acknowledged_write_is_lost ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.68s + +=== target integration_cow_branch + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.50s + Running tests/integration_cow_branch.rs (target/debug/deps/integration_cow_branch-17aeb4828b31d3e8) + +running 1 test +test the_cow_btree_runs_on_the_arena_store_and_a_child_sees_the_parents_data_without_a_copy ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target integration_crash_safety + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.42s + Running tests/integration_crash_safety.rs (target/debug/deps/integration_crash_safety-47f0679b34602969) + +running 4 tests +test a_crash_does_not_lose_the_table_or_its_rows ... ok +test a_merge_that_is_not_interrupted_lands_completely ... ok +test reopening_repeatedly_after_a_crash_keeps_giving_the_same_answer ... ok +test a_merge_killed_mid_publish_leaves_no_torn_state ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.87s + +=== target integration_ddl_refusal_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.43s + Running tests/integration_ddl_refusal_atomicity.rs (target/debug/deps/integration_ddl_refusal_atomicity-0a1f361675c4347b) + +running 2 tests +test a_refused_create_table_does_not_create_the_table ... ok +test a_refused_drop_table_does_not_drop_the_table ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target integration_disk_manager_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.38s + Running tests/integration_disk_manager_concurrency.rs (target/debug/deps/integration_disk_manager_concurrency-a07b7923fa14169a) + +running 4 tests +test the_high_water_mark_never_lags_what_was_allocated ... ok +test allocation_and_reads_can_run_at_the_same_time ... ok +test concurrent_allocation_never_hands_out_the_same_page_twice ... ok +test concurrently_allocated_pages_hold_their_own_bytes ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target integration_durable_tel + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s + Running tests/integration_durable_tel.rs (target/debug/deps/integration_durable_tel-20b67d219c0e8ca6) + +running 3 tests +test wide_typed_values_survive_the_restart_with_their_bytes_intact ... ok +test an_agent_tasks_frames_and_its_merge_survive_a_process_restart ... ok +test the_durable_store_captures_exactly_what_the_in_memory_one_captures ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target integration_effect_log + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s + Running tests/integration_effect_log.rs (target/debug/deps/integration_effect_log-65572c1ac1e9ce34) + +running 3 tests +test a_replayed_identical_frame_is_still_not_stored_twice ... ok +test an_open_task_frame_may_grow_under_one_txn_id ... ok +test a_txn_id_collision_between_different_transactions_is_still_a_hard_error ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target integration_error_messages + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.29s + Running tests/integration_error_messages.rs (target/debug/deps/integration_error_messages-5063579042d24205) + +running 7 tests +test an_unknown_table_in_an_empty_database_says_there_are_none_rather_than_listing_nothing ... ok +test a_wrong_value_count_reports_both_counts_and_the_table ... ok +test a_duplicate_primary_key_names_the_value_and_the_column ... ok +test an_unknown_table_is_named_and_the_known_ones_are_listed ... ok +test a_null_into_a_not_null_column_names_the_column_the_table_and_the_constraint ... ok +test constraint_errors_are_spelled_correctly ... ok +test the_session_still_works_after_each_of_these_errors ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + +=== target integration_error_surface + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.41s + Running tests/integration_error_surface.rs (target/debug/deps/integration_error_surface-dd5d1c6cab1cf80c) + +running 10 tests +test bad_stored_bytes_are_reported_as_corruption ... ok +test a_comparison_that_cannot_match_is_refused ... ok +test a_quoted_number_against_a_numeric_column_is_refused_deliberately ... ok +test comparisons_that_can_match_still_do ... ok +test a_user_expression_that_cannot_be_evaluated_is_an_eval_error ... ok +test the_statements_next_to_the_refusals_still_work ... ok +test a_missing_table_is_not_reported_as_a_parse_error ... ok +test every_reachable_refusal_says_what_was_refused ... ok +test no_reachable_refusal_blames_ferrodb_or_the_disk ... ok +test no_refusal_is_contentless ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.92s + +=== target integration_escrow + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.47s + Running tests/integration_escrow.rs (target/debug/deps/integration_escrow-25bdc25901933883) + +running 13 tests +test raising_the_value_costs_no_escrow ... ok +test abandoning_a_branch_returns_its_claim_to_the_pool ... ok +test changing_the_primary_key_does_not_move_the_cell_out_of_its_pool ... ok +test a_write_beyond_the_claim_fails_at_write_time_not_at_merge ... ok +test deleting_the_row_is_not_governed_by_escrow_and_this_is_a_known_gap ... ok +test a_bounded_cell_cannot_be_written_without_claiming_first ... ok +test a_column_with_no_declared_bound_is_not_escrowed ... ok +test a_direct_assignment_below_the_floor_is_refused_not_waved_through ... ok +test escrow_governs_agent_writes_only_and_a_direct_write_is_not_charged ... ok +test escrow_bounds_sequential_agents_not_just_concurrent_ones ... ok +test an_assignment_on_an_unbounded_cell_is_not_governed ... ok +test without_escrow_two_agents_drive_the_counter_below_its_floor ... ok +test with_escrow_the_second_agent_is_refused_while_it_is_still_writing ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s + +=== target integration_executor_provenance + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.55s + Running tests/integration_executor_provenance.rs (target/debug/deps/integration_executor_provenance-428745cf0f38d0d3) + +running 5 tests +test a_plain_write_outside_any_agent_session_stays_unattributed ... ok +test reusing_a_run_id_with_a_different_model_is_refused ... ok +test a_row_published_by_a_merge_names_the_agent_run_and_model_that_wrote_it ... ok +test two_agents_writing_different_rows_are_told_apart ... ok +test one_run_is_never_split_across_two_provenance_entities ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target integration_flush_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.58s + Running tests/integration_flush_concurrency.rs (target/debug/deps/integration_flush_concurrency-ac95cdde9822b791) + +running 3 tests +test concurrent_flush_and_fetch_never_write_the_wrong_page_to_disk ... ok +test flushing_a_page_that_is_being_evicted_does_not_panic ... ok +test flush_all_is_atomic_against_concurrent_fetches ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s + +=== target integration_fork_kill9 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s + Running tests/integration_fork_kill9.rs (target/debug/deps/integration_fork_kill9-31ddab80011db9f6) + +running 1 test +test an_acknowledged_fork_survives_sigkill ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.06s + +=== target integration_fulltext_retrieval + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.73s + Running tests/integration_fulltext_retrieval.rs (target/debug/deps/integration_fulltext_retrieval-6959baec706be305) + +running 20 tests +test a_rebuild_resolves_a_re_used_primary_key_to_the_live_version ... ok +test re_inserting_a_deleted_primary_key_does_not_double_the_row ... ok +test each_row_carries_its_score_as_a_trailing_column ... ok +test a_word_repeated_in_one_value_posts_one_entry_not_two ... ok +test a_rebuild_of_a_correct_index_does_not_double_its_postings ... ok +test a_rebuild_over_a_re_used_primary_key_does_not_double_the_row ... ok +test an_over_long_token_is_skipped_and_its_neighbours_are_indexed ... ok +test a_token_lookup_returns_exactly_the_rows_containing_it ... ok +test a_backfill_over_a_re_used_primary_key_does_not_double_the_row ... ok +test a_null_value_is_indexed_as_no_tokens ... ok +test a_deleted_row_drops_out_of_a_search_and_a_live_one_does_not ... ok +test the_index_survives_a_crash_and_recover ... ok +test a_value_updated_away_and_back_returns_the_row_exactly_once ... ok +test a_fulltext_index_is_not_offered_to_the_range_scanner ... ok +test dropping_a_table_takes_its_posting_tree ... ok +test the_refusals_refuse_and_the_neighbouring_cases_are_allowed ... ok +test the_operator_bounds_its_own_result_and_keeps_the_best_rows ... ok +test two_fulltext_indexes_on_one_table_stay_separate ... ok +test ranking_separates_rows_that_an_equal_scorer_would_tie ... ok +test a_split_posting_tree_records_its_new_root ... ok + +test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.38s + +=== target integration_index_debt + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.49s + Running tests/integration_index_debt.rs (target/debug/deps/integration_index_debt-2854cdb143ff6323) + +running 2 tests +test deleting_rows_does_not_shrink_the_primary_index ... ok +test no_workload_drives_a_leaf_underfull ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.45s + +=== target integration_logical_decoding + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.99s + Running tests/integration_logical_decoding.rs (target/debug/deps/integration_logical_decoding-4d7e615351add627) + +running 4 tests +test decoding_the_same_range_twice_gives_the_same_feed ... ok +test real_sql_decodes_into_the_changes_that_sql_described ... ok +test two_adjacent_ranges_reassemble_into_the_whole_feed ... ok +test the_feed_is_ordered_and_every_change_is_attributed_to_a_commit ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s + +=== target integration_merge_agreement + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.37s + Running tests/integration_merge_agreement.rs (target/debug/deps/integration_merge_agreement-43e4b7e3ce4f1adc) + +running 6 tests +test both_engines_agree_that_a_one_sided_write_is_clean ... ok +test both_engines_agree_that_two_decrements_commute ... ok +test both_engines_agree_that_a_guard_failing_against_merged_state_is_a_conflict ... ok +test both_engines_agree_that_a_discarding_lww_never_reads_as_clean ... ok +test both_engines_agree_that_an_unannotated_column_forbids_concurrent_writes ... ok +test both_engines_deduplicate_a_replayed_add_by_txn_id ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target integration_merge_ddl_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s + Running tests/integration_merge_ddl_atomicity.rs (target/debug/deps/integration_merge_ddl_atomicity-81b4e1b7467e1784) + +running 9 tests +test a_row_the_merge_would_publish_with_a_null_in_a_not_null_column_refuses_the_whole_merge ... ok +test a_merge_publishes_its_rows_into_the_shape_its_own_edits_produced ... ok +test a_rename_follows_its_column_into_the_fulltext_index_too ... ok +test an_index_follows_its_column_across_the_renames_a_merge_applies ... ok +test a_chain_refused_at_its_first_edit_names_that_edit_and_not_the_last ... ok +test a_row_the_merge_would_publish_too_wide_for_the_shape_it_lands_in_refuses_the_whole_merge ... ok +test narrowing_a_row_and_retyping_behind_it_takes_two_merges_and_the_refusal_says_so ... ok +test planning_a_refused_merge_does_not_even_append_an_empty_page ... ok +test a_merge_refused_at_its_third_edit_leaves_neither_of_the_first_two_applied ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.53s + +=== target integration_pgwire + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.68s + Running tests/integration_pgwire.rs (target/debug/deps/integration_pgwire-f8ee98bc3104f0d7) + +running 6 tests +test an_independent_client_can_connect_and_run_sql_over_the_wire ... ok +test the_server_accepts_more_than_one_connection ... ok +test two_wire_clients_see_branch_isolation_and_share_one_runtime ... ok +test a_second_real_driver_runs_the_same_parameterised_queries ... ok +test a_real_driver_connects_and_runs_a_parameterised_query ... ok +test two_concurrent_connections_both_make_progress ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.22s + +=== target integration_production_diff_wiring + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.43s + Running tests/integration_production_diff_wiring.rs (target/debug/deps/integration_production_diff_wiring-fddd0eafc5e05abc) + +running 4 tests +test every_leaf_differs_so_nothing_is_skipped ... ok +test the_memoised_content_identity_agrees ... ok +test two_identical_roots_read_nothing ... ok +test a_production_diff_reads_pages_in_proportion_to_depth ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.39s + +=== target integration_prompt_clause + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s + Running tests/integration_prompt_clause.rs (target/debug/deps/integration_prompt_clause-a15ad880bd60b661) + +running 8 tests +test the_prompt_free_entry_points_still_intern_the_unset_hash ... ok +test the_open_session_holds_no_prompt_text ... ok +test a_prompt_declared_in_sql_becomes_the_runs_digest ... ok +test an_omitted_clause_is_all_zero_and_an_empty_prompt_is_not ... ok +test resuming_a_run_under_a_different_prompt_is_refused_and_under_the_same_one_is_not ... ok +test two_prompts_that_differ_produce_two_hashes ... ok +test the_run_id_and_the_prompt_are_not_transposed ... ok +test the_prompt_text_never_reaches_the_durable_provenance_file ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target integration_quarantine + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s + Running tests/integration_quarantine.rs (target/debug/deps/integration_quarantine-7b89d9a923bcba36) + +running 8 tests +test the_existing_state_tags_kept_their_values ... ok +test the_quarantined_state_round_trips_through_the_record_log ... ok +test a_quarantined_branch_is_still_queryable ... ok +test releasing_a_branch_that_is_not_held_is_refused ... ok +test a_quarantined_branch_is_not_merged_and_main_is_untouched ... ok +test merging_a_quarantined_branch_is_refused_with_the_reason ... ok +test releasing_a_branch_returns_it_to_service_and_it_merges ... ok +test a_held_branch_is_listed_with_its_reason_and_distinguishable_from_live ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target integration_read_premise + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s + Running tests/integration_read_premise.rs (target/debug/deps/integration_read_premise-5c7b7e6036a74c42) + +running 3 tests +test two_branches_reading_different_rows_both_merge ... ok +test a_read_of_an_unpublished_row_is_recorded_as_version_zero ... ok +test a_branch_that_read_a_row_another_branch_then_changed_is_held ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s + +=== target integration_readme_commands + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) +warning: unused import: `Path` + --> tests/integration_readme_commands.rs:41:17 + | +41 | use std::path::{Path, PathBuf}; + | ^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_readme_commands") generated 1 warning (run `cargo fix --test "integration_readme_commands" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.00s + Running tests/integration_readme_commands.rs (target/debug/deps/integration_readme_commands-2a526cf355a7940c) + +running 4 tests +test the_readmes_benchmark_number_matches_the_committed_raw_output ... ok +test the_benchmark_refuses_to_report_debug_numbers ... ok +test demo_md_matches_what_the_demo_prints_and_the_demo_still_passes ... ok +test the_wal_diagnostic_refuses_without_a_wal_and_reads_a_real_one ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.72s + +=== target integration_replica_applier + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s + Running tests/integration_replica_applier.rs (target/debug/deps/integration_replica_applier-16840cebb27ec7d5) + +running 11 tests +test an_alter_wrapped_in_a_clr_still_halts_the_replica ... ok +test re_applying_the_same_batch_is_a_no_op ... ok +test a_replica_still_applies_a_stream_carrying_create_and_drop_table ... ok +test a_batch_beginning_above_the_frontier_is_refused_as_a_gap ... ok +test a_replica_applies_a_shipped_insert_and_advances ... ok +test a_frame_whose_crc_fails_is_refused ... ok +test a_batch_with_one_bad_frame_applies_none_of_it ... ok +test a_frame_at_the_wrong_lsn_is_refused ... ok +test a_replica_refuses_a_stream_carrying_an_alter_rather_than_advancing_past_it ... ok +test every_column_alteration_halts_the_replica ... ok +test streaming_in_batches_converges_on_the_primarys_frontier ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target integration_replica_divergence_is_durable + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s + Running tests/integration_replica_divergence_is_durable.rs (target/debug/deps/integration_replica_divergence_is_durable-8926c6fad32000b9) + +running 5 tests +test a_replica_that_never_ran_has_no_position ... ok +test a_bare_number_is_still_a_valid_state_file ... ok +test an_ordinary_position_resumes ... ok +test a_recorded_divergence_refuses_to_resume_forever ... ok +test recording_progress_cannot_erase_a_divergence ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target integration_replication_e2e + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s + Running tests/integration_replication_e2e.rs (target/debug/deps/integration_replication_e2e-ba29f27d58751553) + +running 2 tests +test the_catalog_is_not_replicated_and_that_is_a_stated_limit ... ok +test a_replica_converges_on_the_primary_over_tcp ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.78s + +=== target integration_replication_source + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.37s + Running tests/integration_replication_source.rs (target/debug/deps/integration_replication_source-118969395b69af8f) + +running 5 tests +test nothing_is_shipped_before_it_is_durable ... ok +test a_caught_up_replica_is_told_nothing_rather_than_re_sent_everything ... ok +test shipped_bytes_are_the_logs_own_frames ... ok +test a_byte_limit_splits_the_stream_without_losing_its_place ... ok +test the_frontier_holds_while_more_records_pile_up_behind_it ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target integration_run_identity_feed + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.49s + Running tests/integration_run_identity_feed.rs (target/debug/deps/integration_run_identity_feed-0c12adf72c29357f) + +running 11 tests +test the_durable_store_is_usable_as_the_trait_object_agent_runtime_holds ... ok +test provenance_survives_a_reopen_only_when_the_runtime_was_built_durable ... ok +test a_prompt_declared_over_sql_reaches_the_feed_as_a_digest ... ok +test binding_a_run_refuses_the_shapes_that_would_produce_a_wrong_answer ... ok +test every_committed_row_change_names_the_run_that_wrote_it ... ok +test an_aborted_transactions_run_leaves_no_binding_in_the_log ... ok +test an_agent_merge_ships_its_rows_with_a_writer_and_a_plain_write_still_ships_none ... ok +test a_commit_with_no_run_bound_ships_unattributed_and_is_counted ... ok +test who_wrote_row_answers_after_the_database_is_reopened ... ok +test a_checkpoint_does_not_erase_the_databases_writers ... ok +test an_identity_record_written_at_session_begin_is_stepped_over_by_the_feed_cursor ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s + +=== target integration_run_identity_log_readers + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.40s + Running tests/integration_run_identity_log_readers.rs (target/debug/deps/integration_run_identity_log_readers-92ce14c096858115) + +running 2 tests +test a_replica_applies_a_batch_containing_run_identity_records ... ok +test recovery_replays_a_log_holding_both_a_binding_and_a_declaration ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s + +=== target integration_runtime_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s + Running tests/integration_runtime_concurrency.rs (target/debug/deps/integration_runtime_concurrency-195a0a158d15d785) + +running 4 tests +test concurrent_abandons_return_exactly_their_own_claims ... ok +test concurrent_writers_each_read_back_only_their_own_row ... ok +test concurrent_sessions_get_distinct_branches_and_runs ... ok +test concurrent_claims_never_over_commit_the_pool ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + +=== target integration_runtime_reattach + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.58s + Running tests/integration_runtime_reattach.rs (target/debug/deps/integration_runtime_reattach-72c494a20a8177da) + +running 4 tests +test a_root_pointing_at_a_page_this_engine_did_not_write_is_refused ... ok +test reopening_a_database_with_no_trunk_tree_is_refused_by_name ... ok +test creating_over_an_existing_trunk_tree_is_refused ... ok +test a_reopened_runtime_reads_the_rows_the_first_one_wrote ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target integration_secondary_index_debt + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.60s + Running tests/integration_secondary_index_debt.rs (target/debug/deps/integration_secondary_index_debt-47853349494c68bc) + +running 4 tests +test reusing_a_key_with_the_same_indexed_value_does_not_duplicate_it ... ok +test a_value_moved_away_and_back_does_not_return_its_row_twice ... ok +test dead_entries_never_reach_the_caller ... ok +test a_secondary_index_grows_by_one_entry_per_delete_and_per_update ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.42s + +=== target integration_server_reaps + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.56s + Running tests/integration_server_reaps.rs (target/debug/deps/integration_server_reaps-2fe130781ef8f38a) + +running 8 tests +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test a_node_that_does_not_know_the_clusters_time_refuses_to_reap_rather_than_guessing ... ok +test both_binaries_refuse_to_start_on_an_unusable_scan_interval ... ok +test a_healthy_database_resumes_nothing_on_startup ... ok +test a_merged_branch_gives_its_extent_back_without_any_lease_scan ... ok +test the_server_reaps_an_abandoned_branch_without_one_socket_being_opened ... ok +test the_server_finishes_a_reap_a_crash_interrupted_before_it_serves_anything ... ok +test the_cli_reaps_an_abandoned_branch_with_no_client_action_and_pages_return_to_baseline ... ok +test a_live_lease_survives_a_server_that_scans_continuously ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.05s + +=== target integration_server_stdout + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.62s + Running tests/integration_server_stdout.rs (target/debug/deps/integration_server_stdout-9259fddcd1538fdb) + +running 5 tests +test no_test_picks_a_port_for_a_server_it_has_not_started_yet ... ok +test every_user_path_binary_refuses_a_database_that_is_already_open ... ok +test repl_primary_survives_a_closed_stdout ... ok +test pgserver_survives_a_closed_stdout ... ok +test cdc_server_survives_a_closed_stdout ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.44s + +=== target integration_sibling_merge + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.92s + Running tests/integration_sibling_merge.rs (target/debug/deps/integration_sibling_merge-bfeabfa1f35d46b8) + +running 9 tests +test a_sibling_merge_refuses_an_ancestor_and_refuses_itself ... ok +test a_row_the_target_never_touched_still_carries_its_fork_point_image ... ok +test the_index_hydrates_from_the_catalog_rather_than_from_a_fork_hook ... ok +test the_fork_point_query_costs_fewer_hops_than_the_walk_it_replaces ... ok +test the_fork_point_is_computed_not_assumed ... ok +test both_siblings_moved_the_same_cell_and_it_conflicts ... ok +test only_one_side_moved_the_cell ... ok +test merging_two_siblings_was_impossible_before_a_fork_point_could_be_computed ... ok +test two_siblings_merge_and_the_target_carries_both_branches_work ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_simulate + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.92s + Running tests/integration_simulate.rs (target/debug/deps/integration_simulate-3eb2509e1d40721e) + +running 25 tests +test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok +test a_plan_that_never_says_admit_publishes_nothing ... ok +test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok +test a_read_only_candidate_reports_no_rows_written ... ok +test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok +test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok +test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok +test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok +test an_assertion_is_scored_over_rows_the_candidate_never_touched ... ok +test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok +test every_candidate_is_scored_against_one_identical_base ... ok +test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok +test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok +test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok +test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok +test publishing_an_evaluation_the_gate_declined_is_refused ... ok +test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok +test simulate_is_refused_inside_an_agent_session ... ok +test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok +test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok +test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok +test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok +test k_candidates_cost_k_path_copies_not_k_database_copies ... ok +test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok +test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok + +test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.50s + +=== target integration_sql_on_durable_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.65s + Running tests/integration_sql_on_durable_branches.rs (target/debug/deps/integration_sql_on_durable_branches-1573d64d39185f4f) + +running 4 tests +test branch_writes_stay_invisible_to_main_on_the_durable_catalog ... ok +test abandon_marks_the_branch_reaped_in_the_durable_record_log ... ok +test a_guard_violation_is_rejected_with_its_predicate_on_the_durable_catalog ... ok +test two_branches_decrementing_compose_arithmetically_on_the_durable_catalog ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_statement_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.55s + Running tests/integration_statement_atomicity.rs (target/debug/deps/integration_statement_atomicity-7a89d477e1710752) + +running 3 tests +test a_refused_statement_does_not_consume_the_budget_it_never_used ... ok +test a_multi_row_update_within_budget_still_applies_to_every_row ... ok +test a_refused_multi_row_update_leaves_no_row_rewritten ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target integration_system_views_wire + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.43s + Running tests/integration_system_views_wire.rs (target/debug/deps/integration_system_views_wire-2cb7270b919ff355) + +running 1 test +test the_system_views_are_typed_rows_to_an_independent_client ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_system_views + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) +warning: unused import: `std::path::Path` + --> tests/integration_system_views.rs:26:5 + | +26 | use std::path::Path; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_system_views") generated 1 warning (run `cargo fix --test "integration_system_views" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.18s + Running tests/integration_system_views.rs (target/debug/deps/integration_system_views-0cd16af7a1f4817a) + +running 20 tests +test an_empty_view_still_announces_every_declared_column ... ok +test a_view_refuses_as_of_branch_and_a_join_rather_than_answering_something_else ... ok +test a_table_that_predates_the_views_still_answers_for_its_own_name ... ok +test a_view_can_be_projected_and_filtered ... ok +test an_agent_can_read_the_views_from_inside_its_own_session ... ok +test a_second_session_of_one_run_does_not_restamp_when_the_run_started ... ok +test every_declared_column_is_selectable_by_name ... ok +test every_view_is_populated_once_an_agent_has_run ... ok +test an_empty_agent_result_is_distinguishable_from_a_broken_one ... ok +test an_agent_result_has_typed_columns_a_client_can_read ... ok +test a_reaped_branch_reports_the_slots_current_generation ... ok +test a_quarantined_branch_shows_up_with_the_reason_it_is_held ... ok +test creating_over_a_legacy_table_named_like_a_view_says_the_table_exists ... ok +test every_write_shape_against_a_view_refuses_by_name ... ok +test a_table_cannot_shadow_a_system_view ... ok +test ferro_runs_reports_the_digest_of_a_declared_prompt ... ok +test a_view_on_either_side_of_a_join_is_refused_by_name ... ok +test run_activity_counts_are_per_run_and_not_a_shared_total ... ok +test a_recreated_table_does_not_inherit_the_previous_tables_authorship ... ok +test dropping_a_table_does_not_silence_the_read_premise_gate ... ok + +test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + +=== target integration_truncation_race + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.66s + Running tests/integration_truncation_race.rs (target/debug/deps/integration_truncation_race-9b3eb3ac65390670) + +running 1 test +test a_truncation_racing_a_read_never_yields_a_frame_at_the_wrong_lsn ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.79s + +=== target integration_trunk_tree_authority + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.34s + Running tests/integration_trunk_tree_authority.rs (target/debug/deps/integration_trunk_tree_authority-069e9ab3d7327f95) + +running 3 tests +test trunks_root_is_a_real_arena_page_named_by_the_branch_catalog ... ok +test trunk_rows_live_in_the_heap_not_in_trunks_page_tree ... ok +test an_agent_write_lives_on_branch_pages_and_reaches_the_heap_only_on_merge ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target integration_txn_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.37s + Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-321b53de585b05de) + +running 4 tests +test concurrent_commits_and_aborts_do_not_panic ... ok +test a_running_transaction_is_never_missing_from_a_snapshot ... ok +test committed_transactions_leave_the_active_set ... ok +test concurrent_begins_get_distinct_transaction_ids ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.94s + +=== target integration_view_pushdown + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.41s + Running tests/integration_view_pushdown.rs (target/debug/deps/integration_view_pushdown-41c09f8d40e27f92) + +running 8 tests +test a_narrowed_read_still_announces_the_projections_columns ... ok +test the_extracted_hint_is_the_one_the_predicate_licenses ... ok +test a_literal_the_binder_refuses_never_reaches_the_hint ... ok +test a_scattered_set_of_live_runs_is_neither_widened_nor_truncated ... ok +test ferro_runs_reports_exactly_the_branches_with_a_live_run ... ok +test a_bounded_record_scan_returns_exactly_the_records_in_range ... ok +test the_hint_narrows_what_the_generator_builds ... ok +test every_view_query_answers_identically_with_and_without_the_hint ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.83s + +=== target integration_wal_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.70s + Running tests/integration_wal_concurrency.rs (target/debug/deps/integration_wal_concurrency-a8e3f28b25240888) + +running 3 tests +test concurrent_appends_get_distinct_lsns ... ok +test the_log_remains_a_walkable_chain_after_concurrent_use ... ok +test flushed_lsn_never_over_reports_while_flushes_are_in_flight ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.51s + +=== target integration_zero_copy_fork + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s + Running tests/integration_zero_copy_fork.rs (target/debug/deps/integration_zero_copy_fork-301b513c069220ff) + +running 6 tests +test a_runtime_without_a_page_store_refuses_row_calls_instead_of_pretending ... ok +test a_write_on_the_branch_is_invisible_to_the_trunk_and_to_a_sibling ... ok +test beginning_an_agent_session_copies_zero_data_pages ... ok +test an_abandoned_agent_session_returns_its_pages_with_no_client_cooperation ... ok +test a_branch_write_costs_pages_only_on_the_path_it_touches ... ok +test the_fork_stays_zero_copy_as_the_trunk_grows ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.26s + +=== target lock_order_allowlist + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.36s + Running tests/lock_order_allowlist.rs (target/debug/deps/lock_order_allowlist-5152dbc136567be8) + +running 7 tests +test the_scanner_catches_a_pool_method_missing_its_marker ... ok +test the_scanner_catches_an_unlisted_latcher ... ok +test only_the_att_accessors_may_lock_the_active_transaction_table ... ok +test every_pool_method_that_locks_opens_a_pool_section ... ok +test only_the_tree_may_take_a_page_latch ... ok +test only_the_pool_may_write_lock_a_frame_directly ... ok +test test_only_sources_are_cfg_test_gated ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +=== target lock_order_runtime + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s + Running tests/lock_order_runtime.rs (target/debug/deps/lock_order_runtime-d1de576bd7acade5) + +running 4 tests +test a_page_latch_taken_while_holding_a_frame_write_lock_is_caught - should panic ... ok +test the_correct_order_latch_then_frame_is_not_flagged ... ok +test pool_sections_do_not_leak_across_calls ... ok +test a_page_latch_taken_while_holding_a_frame_read_lock_is_caught - should panic ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target prop_cow_diff + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.52s + Running tests/prop_cow_diff.rs (target/debug/deps/prop_cow_diff-884fd6568a11d74e) + +running 5 tests +test an_unedited_branch_diffs_to_nothing_for_free ... ok +test reversing_the_roots_inverts_every_delta ... ok +test applying_the_diff_to_the_base_reconstructs_the_head ... ok +test diff_agrees_with_a_full_scan_comparison ... ok +test a_small_edit_reads_less_than_reading_both_trees ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 13.20s + +=== target prop_escrow + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.42s + Running tests/prop_escrow.rs (target/debug/deps/prop_escrow-5281dfec1344b96a) + +running 4 tests +test release_restores_the_pool_and_settle_consumes_it ... ok +test a_branch_never_spends_beyond_its_own_claim ... ok +test claims_outstanding_never_exceed_the_remaining_pool ... ok +test consumed_never_exceeds_the_declared_slack ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target prop_merge_algebra + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.54s + Running tests/prop_merge_algebra.rs (target/debug/deps/prop_merge_algebra-b73b1c5f8620b66f) + +running 11 tests +test float_add_is_commutative_but_not_associative_and_that_is_stated_not_assumed ... ok +test max_is_idempotent_and_commutative ... ok +test a_nonzero_add_is_never_idempotent ... ok +test zero_is_the_identity_for_integer_add ... ok +test add_is_commutative ... ok +test integer_composition_refuses_overflow_rather_than_wrapping ... ok +test composing_then_applying_equals_applying_in_sequence ... ok +test two_assigns_never_commute ... ok +test integer_add_is_associative ... ok +test add_is_the_only_kind_that_is_not_idempotent ... ok +test commutes_with_is_symmetric ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target prop_merge_outcomes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.46s + Running tests/prop_merge_outcomes.rs (target/debug/deps/prop_merge_outcomes-7768847bc32df451) + +running 6 tests +test lww_on_two_different_assigns_never_reports_clean ... ok +test a_discard_beside_a_composition_still_reports_loss ... ok +test a_discarded_write_is_never_reported_as_clean_or_commuting ... ok +test lost_a_write_agrees_with_the_variant ... ok +test the_name_identifies_the_variant ... ok +test the_same_merge_classifies_the_same_way_twice ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target provenance_e2e + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.35s + Running tests/provenance_e2e.rs (target/debug/deps/provenance_e2e-cb7eeb7fecb44dab) + +running 5 tests +test a_range_read_set_still_finds_the_write_it_consumed ... ok +test unattributed_versions_are_reported_as_unattributed ... ok +test a_snapshot_taken_before_the_write_creates_no_edge ... ok +test exact_read_set_makes_the_downstream_agent_visible_to_revert ... ok +test scattered_point_reads_stay_exact_through_the_executor ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target provenance_retention_wiring + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s + Running tests/provenance_retention_wiring.rs (target/debug/deps/provenance_retention_wiring-71dfb10428de7988) + +running 11 tests +test a_read_whose_session_was_sealed_underneath_it_is_refused_rather_than_silently_unretained ... ok +test an_abandoned_tasks_scan_stops_blocking_the_revert_it_never_depended_on ... ok +test a_multi_version_merge_followed_by_another_merge_keeps_its_causal_answers_exact ... ok +test a_ghost_parent_that_staged_nothing_stops_blocking_once_its_child_has_merged ... ok +test a_mirrored_point_read_retains_a_premise_the_gate_can_verify ... ok +test a_delete_whose_where_clause_scanned_names_its_dependents ... ok +test an_update_whose_where_clause_scanned_names_its_dependents ... ok +test an_update_that_names_a_row_by_key_names_its_dependent_and_stays_a_blind_write ... ok +test a_point_lookup_that_observed_an_absence_halts_the_revert_that_would_refill_it ... ok +test a_mirrored_key_equality_is_classified_the_same_as_the_unmirrored_one ... ok +test parentheses_do_not_change_the_access_shape ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.51s + +=== target provenance_scan_cascade + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.43s + Running tests/provenance_scan_cascade.rs (target/debug/deps/provenance_scan_cascade-66d42001be305987) + +running 5 tests +test a_scan_depends_on_the_write_that_moved_a_row_out_of_its_range ... ok +test a_branch_that_scanned_before_the_write_is_not_a_dependent ... ok +test a_write_outside_the_scanned_range_is_not_a_dependency ... ok +test a_multi_row_merge_is_named_once_by_the_scan_that_read_it_and_reverts_whole ... ok +test a_dependent_reached_by_a_scan_is_named_by_cascade ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target provenance_write_path_dependents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s + Running tests/provenance_write_path_dependents.rs (target/debug/deps/provenance_write_path_dependents-0a768ca1b4fd14c0) + +running 1 test +test sweep ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.04s + +=== target s20_live_count_is_maintained + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.38s + Running tests/s20_live_count_is_maintained.rs (target/debug/deps/s20_live_count_is_maintained-0c661209b21dcbb5) + +running 3 tests +test an_idempotent_transition_does_not_move_the_counter ... ok +test the_counter_agrees_with_a_scan_after_state_transitions ... ok +test the_counter_agrees_with_a_scan_after_forks ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target sim_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.58s + Running tests/sim_durability.rs (target/debug/deps/sim_durability-9e6ed92c7221a067) + +running 16 tests +test under_sync_only_an_unflushed_write_does_not_survive ... ok +test a_torn_write_lands_a_prefix_and_ends_the_run ... ok +test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok +test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok +test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok +test the_durable_commit_detector_fires_and_stays_quiet ... ok +test flush_all_writes_pages_in_ascending_page_id_order ... ok +test two_different_seeds_crash_in_different_places ... ok +test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok +test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok +test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok +test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok +test recovery_itself_writes_the_same_sequence_three_times ... ok +test recovery_holds_at_every_fault_point_sync_only ... ok +test recovery_holds_at_every_fault_point_write_through ... ok +test recovery_holds_at_every_fault_point_through_the_catalog ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.92s + +=== target w4_forget_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.29s + Running tests/w4_forget_branches.rs (target/debug/deps/w4_forget_branches-0a41a5b3bd0a4bd7) + +running 3 tests +test forget_branches_refuses_a_branch_that_is_still_live ... ok +test a_reaped_branch_nobody_reported_is_still_reconciled ... ok +test forget_branches_drops_the_branches_it_is_given ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target w4_sweep_slot_recycle + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-E6-outer-lock-count-2) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.83s + Running tests/w4_sweep_slot_recycle.rs (target/debug/deps/w4_sweep_slot_recycle-6ee876f01aec3d39) + +running 1 test +test a_session_that_recycled_a_reaped_slot_survives_a_sweep_already_in_flight ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + From 6b13212f0fb7576561ae8654d68e5fb6845a5a2f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:36:16 -0400 Subject: [PATCH 036/315] CEILING: artifacts only -- the 1.9x ceiling attack, outcome 2 bench/ only. NO src/, NO examples/. The harness and D123's stub scaffold stay on CEILING-resume, which must never land: it gates a None return in src/branch/table_catalog.rs on the stub level, a capability escape. Pre-registered in frontier/INVENTION-TRIGGER.md with three outcomes and Amendments 1-2. OUTCOME 2 fired: contended/op does not rise across L0->L3 (0.98613 -> 0.90812), so the remaining time is not threads blocking on each other and pre-registered outcome 1 is dead. And the phase clock says what the remaining time IS: WAIT falls 46x while DURABLE is flat, and durable() is 97.8% of the thread cycle at L3. The floor is the group-commit fsync, not the handoff. Two of the three recorded multipliers do not reproduce under different box load (21x measured 41.5x, 12.6x measured 6.0x); only the 1.9x throughput ratio came back exactly, at 1.90x. --- bench/ceiling_INVENTIONS_patch.md | 54 +++ bench/ceiling_park_or_structure.txt | 334 ++++++++++++++++++ .../00_model_POSITIVE_CONTROL_FAILED.txt | 23 ++ .../ceiling_raw/01_model_inherited_PASSED.txt | 66 ++++ bench/ceiling_raw/02_barrier_queuemax_63.txt | 18 + bench/ceiling_raw/03_model_closed_form.txt | 69 ++++ .../10_paired_T64_N8000_warm0_reps5.txt | 153 ++++++++ .../11_paired_T1_negative_control.txt | 158 +++++++++ ...2_paired_T64_N8000_warm0_reps5_WAITDUR.txt | 189 ++++++++++ .../20_pgwire_ladder_BREAKS_above_L0.txt | 20 ++ bench/ceiling_raw/21_pgwire_L0_layerB.txt | 27 ++ bench/ceiling_raw/22_direct_crosscheck.txt | 42 +++ bench/ceiling_raw/RESUME.md | 46 +++ 13 files changed, 1199 insertions(+) create mode 100644 bench/ceiling_INVENTIONS_patch.md create mode 100644 bench/ceiling_park_or_structure.txt create mode 100644 bench/ceiling_raw/00_model_POSITIVE_CONTROL_FAILED.txt create mode 100644 bench/ceiling_raw/01_model_inherited_PASSED.txt create mode 100644 bench/ceiling_raw/02_barrier_queuemax_63.txt create mode 100644 bench/ceiling_raw/03_model_closed_form.txt create mode 100644 bench/ceiling_raw/10_paired_T64_N8000_warm0_reps5.txt create mode 100644 bench/ceiling_raw/11_paired_T1_negative_control.txt create mode 100644 bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt create mode 100644 bench/ceiling_raw/20_pgwire_ladder_BREAKS_above_L0.txt create mode 100644 bench/ceiling_raw/21_pgwire_L0_layerB.txt create mode 100644 bench/ceiling_raw/22_direct_crosscheck.txt create mode 100644 bench/ceiling_raw/RESUME.md diff --git a/bench/ceiling_INVENTIONS_patch.md b/bench/ceiling_INVENTIONS_patch.md new file mode 100644 index 00000000..445b1683 --- /dev/null +++ b/bench/ceiling_INVENTIONS_patch.md @@ -0,0 +1,54 @@ +# APPEND-ONLY PATCH FOR `frontier/INVENTIONS.md` + +⛔ **Not applied by me.** `frontier/INVENTIONS.md` lives in the `artie-research` repo, outside this +agent's worktree, and other sessions were editing that repo during this run (`INVENTION-TRIGGER.md` +mtime 2026-09-22 03:01). A worktree-isolated agent does not write into another live checkout. The +exact text is here so applying it is mechanical. + +## Where + +`frontier/INVENTIONS.md`, immediately after the block that currently reads: + +``` +> **Removing keys SATURATES.** `HOLD` falls **21x** from L0→L3 but throughput rises only **1.9x**, +> because the gap rises **12.6x** and lock utilisation collapses from **96% to 8.5%**. +``` + +…and its `⇒ **Section and handoff TRADE OFF**` paragraph. **Append, do not alter the above.** + +## What to append + +> ## ⚠ THE SATURATION IS REAL. ITS ATTRIBUTION TO "HANDOFF" IS NOT — MEASURED 2026-09-22. +> +> Pre-registered in `frontier/INVENTION-TRIGGER.md` ("IS THE 1.9× CEILING THE STRUCTURE, OR THE +> MUTEX?", Amendments 1–2), run as `examples/ceiling_lock_contention` on branch +> `CEILING-resume`. Evidence: `bench/ceiling_park_or_structure.txt` and `bench/ceiling_raw/`. +> **Pre-registered outcome 2 fired**: the remaining time is not scheduler wake-up. +> +> Measured at the `TableBranchCatalog` layer — the same layer as the quoted pair, beneath the +> outer statement mutex production holds (Amendment 1's fourth condition). +> +> * **The saturation reproduces.** Utilisation collapses 87.6% → 5.5% L0→L3 (15.9×), against the +> recorded 96.1% → 8.5% (11.3×), and `HOLD` falls 23.3× against the recorded 21×. +> * **`throughput = 1/S` still fails, so the row's conclusion stands.** +> * ⛔ **But the gap is NOT the handoff.** Time spent BLOCKED ACQUIRING `logical` (`PH_WAIT`, +> nanoseconds, same probe, same loop) falls **64×** across L0→L3 — *faster* than `HOLD` falls. +> Time spent in `durable()` is **flat** (21.8 → 17.1 ms/fork summed over 64 threads) and is +> **98% of the thread cycle at L3**. The closure identity holds at 98–99% at every rung. +> * ⇒ **The floor is the group-commit fsync, not park/unpark.** The section and the DISK trade +> off. Nothing about scheduler wake-up is in this data. +> +> ⇒ **What changes for the retired "take work out of the critical section" family:** it stays +> retired, and the 21×/1.9× pair remains the number to state a candidate against — but a candidate +> must now state where it lands against **group-commit throughput**, not against a handoff floor. +> Those are different quantities on different axes, and only the second one was ever measured. +> +> ⇒ **What does NOT change:** "past some point the handoff, not the section, is what is left" is +> **withdrawn as written**. It should read "past some point the DURABILITY BARRIER, not the +> section, is what is left." + +## And in `frontier/INVENTION-TRIGGER.md` + +The line `⚠ **And the most load-bearing number in this file is itself unverified**` (above the +pre-registered entry) should gain: *"Verified 2026-09-22. The pair reproduces; the mechanism +attached to it does not. See `ferrodb:bench/ceiling_park_or_structure.txt`."* diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt new file mode 100644 index 00000000..971fab10 --- /dev/null +++ b/bench/ceiling_park_or_structure.txt @@ -0,0 +1,334 @@ +IS THE 1.9x CEILING THE STRUCTURE, OR THE MUTEX? +================================================================================================ + +Pre-registration: frontier/INVENTION-TRIGGER.md (artie-research), section "IS THE 1.9x CEILING + THE STRUCTURE, OR THE MUTEX?", three outcomes, plus AMENDMENT 1 (every number + carries its LAYER) and AMENDMENT 2 (the experiment is re-cast as a COUNT). +Scaffold branch: CEILING-resume, off CEILING-park-or-structure (c00b26e). + ⛔⛔ NEITHER MAY EVER LAND. See "WHAT MUST NOT LAND" at the end. +Artifacts: this file, bench/ceiling_INVENTIONS_patch.md, and bench/ceiling_raw/*. +Harness: examples/ceiling_lock_contention.rs, built with --features lock_census. +Box: NOT quiet. load_at_acquire 24-45 throughout, two swift-frontend compiles and + mediaanalysisd resident; the fleet measure-lock was held for the whole run + (acquired "CEILING-resume", load_at_acquire=8 at acquisition, rising after). + EVERY DURATION BELOW IS AN UPPER BOUND. The counts are not. + +================================================================================================ +WHICH PRE-REGISTERED OUTCOME FIRED +================================================================================================ + +⭐ OUTCOME 2. "Gap unchanged ⇒ the remaining time is not scheduler wake-up." + +Amendment 2's discriminator, verbatim: *"If the gap is park/unpark latency, contention per +operation must RISE as the section shortens. If it does not rise, the remaining time is not +threads blocking on each other, and pre-registered outcome 1 is dead without a single duration +being measured."* + + contended/op, L0 -> L3, T=64, median of 5 reps: 0.98613 -> 0.90812 (0.92x) + +It does not rise. **Pre-registered OUTCOME 1 IS DEAD.** The section-shortening candidate family +stays retired, and Round 4's forecast-against-the-curve requirement stands. + +No fourth outcome is added, and the counts DID discriminate, so the "result about the instrument" +escape is not used for the verdict. It IS used for two subsidiary findings below, both labelled. + +⇒ AND A SEPARATE MEASUREMENT, NOT A FOURTH OUTCOME, SAYS WHAT THE REMAINING TIME ACTUALLY IS. + The pre-registration's own complaint is that the recorded pair "does NOT establish WHAT the + remaining time is". Outcome 2 as written only rules park/unpark out. The phase clock rules + everything else in: + + WAIT (blocked acquiring `logical`) 11.76194 ms -> 0.25570 ms 46x DOWN + DURABLE (the group-commit fsync) 18.43231 ms -> 17.58789 ms FLAT (0.95x) + at L3, DURABLE is 97.8% of the whole thread cycle. + + **The floor is the durability barrier, not the handoff.** The section and the DISK trade off. + +================================================================================================ +1. THE INSTRUMENT FAILED ITS OWN POSITIVE CONTROL FIRST, AND THE PREMISE WAS WHAT WAS WRONG +================================================================================================ + +Raw: bench/ceiling_raw/00_model_POSITIVE_CONTROL_FAILED.txt (exit 1, banked before anything was +touched). + +The scaffold carried a gate asserting that 64 threads doing nothing but take and release one +`Mutex` must show contended/op near 1.0. First run: + + NEG single-thread T=1 contended/op 0.00000 acq/op 1.0000 -> PASSED + POS 64t all-lock T=64 contended/op 0.04688 acq/op 1.0000 -> REFUSED against >= 0.5 + +`acq/op` was exactly 1.0000 and the negative control exactly 0.00000, so the bookkeeping was +sound and only the premise was not. **Mechanism, written into the commit before the replacement +was run:** `std::sync::Mutex` barges. Unlocking is a store plus at most a wake syscall; the woken +waiter needs a scheduler round trip, while the releasing thread's next `try_lock` CAS costs +nanoseconds. With no work outside the section the releaser re-wins its own lock, so a permanently +saturated mutex still reports `try_lock` SUCCESS. + +⇒ ⛔ **`try_lock`-failure counts "the lock was held by another thread at the instant I tried". It +does NOT count "threads are blocked". THIS IS A BLIND SPOT IN AMENDMENT 2'S CHOSEN INSTRUMENT**, +and it is measured, not argued — bench/ceiling_raw/03_model_closed_form.txt: + + BARGE 64t no-out contended/op 0.03828 queue_mean 55.91 queue_max 64 + +56 of 64 threads were inside the acquire region on average, and the census read 0.038. Against +the ~1.0 a saturated queue should produce, that is a 26x under-report; against the same file's +genuinely-queued POS cell (0.52994) it is 14x. **Direction of the error: contended/op +UNDER-reports blocking**, i.e. it errs toward killing outcome 1. Every low reading in this file +inherits that, which is why outcome 2 is NOT rested on the count alone — see section 5. + +The queue gauge (`IN_ACQ`) that exposes this came from the paused session's uncommitted tree; +see "PROVENANCE" below. + +================================================================================================ +2. THE INSTRUMENT, CALIBRATED AGAINST DERIVED VALUES RATHER THAN OBSERVED ONES +================================================================================================ + +Every other cell in this harness is gated on a threshold picked after seeing what the cell does. +That catches a dead counter, not a live miscalibrated one. One cell removes re-acquisition +entirely — 64 threads rendezvous on a barrier, then each takes the lock exactly once — which pins +all three readings to closed forms **stated in the source before the run**: + + quantity derived measured (bench/ceiling_raw/03) + contended/op (T-1)/T = 0.98438 0.98438 exact + queue_mean (T+1)/2 = 32.50 32.50 exact + queue_max T = 64 64 exact + +⚠ MY FIRST DERIVATION OF THIS CELL WAS WRONG AND THE INSTRUMENT WAS RIGHT. Armed after the +rendezvous, the gauge read queue_max 63 / queue_mean 31.58 (bench/ceiling_raw/02) because the +first winner can beat the last thread's increment. `contended/op` read exactly 63/64 in that same +run, because that form does not depend on the gauge. Fixed by arming the gauge before the +rendezvous; the quantity under test was untouched. + +NEGATIVE CONTROL ON THE REAL LADDER, not just on a model mutex (bench/ceiling_raw/11, T=1): + + contended/op 0.00000 at ALL FOUR rungs + WAIT 0.0003 ms (nothing to block on) + closure 100.0% at all eight probe-carrying rungs + +CROSS-CHECK licensing the process-wide atomic mode the pgwire arm must use +(bench/ceiling_raw/22): thread-local 0.98650 vs global-atomic 0.98762 ratio 1.0011. + +================================================================================================ +3. LAYER A — `TableBranchCatalog`, THE LAYER THE RECORDED TRIPLE WAS MEASURED AT +================================================================================================ + +⚠ AMENDMENT 1'S FOURTH CONDITION, IN THE SAME SENTENCE AS THE NUMBERS: **measured at the +`TableBranchCatalog` layer, beneath the outer statement mutex that serialises forks in +production.** `open_sidecar` + `cat.fork` directly, holding `logical` and nothing else. This is +what `examples/fork_concurrency.rs` drives and what D123 measured. It is NOT what any shipped +front-end does — section 5 is that. + +Raw: bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt. +T=64, N=8000, warm=0 (the recorded table's own shape), 5 reps, medians, configuration order AND +rung order rotated per rep. + +⭐ AND THE TWO COLUMNS COME FROM ONE LOOP, WHICH IS THE WHOLE POINT OF THE RE-RUN. The interim +reading this resumes from compared a count from this process against a utilisation from +bench/d123_serial_attribution.txt — two instruments, two moments, which is the exact error that +makes the 1.9x suspect. Configuration A below runs the census and D123's phase clock over the +same threads in the same loop. + + config A (census ON, probe ON) -- THE DELIVERABLE + stub forks/sec S_eff HOLD_TOT gap U(hold) WAIT DURABLE W/D contended/op + 0 2054.8 0.48666 0.44130 0.04536 90.7% 11.76194 18.43231 0.638 0.98613 + 1 3100.7 0.32251 0.14358 0.18738 40.2% 2.12649 18.24709 0.117 0.95725 + 2 2862.8 0.34930 0.16755 0.18296 45.4% 2.60489 19.03676 0.137 0.95725 + 3 3559.2 0.28096 0.01063 0.27126 3.7% 0.25570 17.58789 0.015 0.90812 + + config C (census OFF, probe ON) -- byte-identical acquire path to the shipped one, and + therefore ALSO the reproduction arm for the recorded collapse + 0 2014.8 0.49633 0.44367 0.04843 89.4% 11.88387 18.88903 0.629 n/a + 3 3833.4 0.26087 0.01089 0.24897 4.2% 0.25092 15.96972 0.016 n/a + +LICENSING. A paired reading is worthless if either instrument moves the other's number. All eight +cross-controls are inside the 25% bar (worst 1.124): + + L0-L3 contended/op A(probe ON) vs B(probe OFF): 1.000 0.999 0.999 1.009 + L0-L3 U(hold) A(census ON) vs C(census OFF): 1.014 0.964 1.112 0.870 + +CLOSURE IDENTITY (D123 §2b, WAIT + HOLD_TOTAL + DURABLE = T*S_eff): 97.2% - 100.4% at all eight +probe-carrying rungs. The probe accounts for the whole loop, so WAIT is a real share of the gap. + +================================================================================================ +4. ⭐ DOES MY OWN UTILISATION COLLAPSE THE WAY THE RECORDED ONE DID? YES. AND TWO OF THE THREE + RECORDED MULTIPLIERS DO NOT REPRODUCE — SAY THIS LOUDLY. +================================================================================================ + +The brief's warning case was "if your utilisation does NOT collapse, that is a finding about the +recorded number". It does collapse. But the three headline multipliers do not all survive. + + quantity recorded (quiet box) measured here (load 24-45) reproduced? + throughput L0->L3 1.9x 1.90x (config C) ✅ EXACTLY + 1.73x (config A) + U(hold) collapse 96.1% -> 8.5% (11.3x) 90.7% -> 3.7% (24.7x) ✅ IN KIND, + 89.4% -> 4.2% (21.2x) 2x LARGER + HOLD_TOT fall 21x 41.5x (A) / 40.7x (C) ❌ 2x LARGER + gap rise 12.6x 6.0x (A) / 5.1x (C) ❌ 2x SMALLER + +⇒ **The QUALITATIVE claim is robust and reproduces on a different box at 3-5x the load: HOLD +collapses by more than an order of magnitude, utilisation collapses with it, and throughput moves +about 1.9x. The specific multipliers 21x and 12.6x DO NOT REPRODUCE, and they move in OPPOSITE +directions.** That is what a duration ratio does on a box with a measured 46x quiet-vs-loaded +spread: L0's section is long and real, so load inflates it more than L3's near-empty one, which +inflates the HOLD ratio and deflates the gap ratio by the same mechanism. + +⇒ ⛔ **THE TRIPLE SHOULD BE CITED AS "HOLD falls by more than an order of magnitude while +throughput rises about 1.9x", NOT as "21x / 1.9x / 12.6x".** Only the throughput ratio — the one +whose numerator and denominator are both throughputs from the same pair of runs — came back the +same. This is a result about the INSTRUMENT, and it is labelled as one per Amendment 2. + +================================================================================================ +5. ⭐ WHAT THE REMAINING TIME IS — THE NUMBER NO COLLISION COUNT COULD HAVE GIVEN +================================================================================================ + +Every thread's cycle is wait -> hold -> durable, and the probe measures all three in nanoseconds. +D123 §2b published WAIT and DURABLE at L0 only (T=64: WAIT 6.84 ms > DURABLE 5.35 ms). L1-L3 had +never been decomposed, and L3 is the rung the whole argument turns on. + + T=64 thread cycle = T * S_eff, median of 5 reps, configuration A: + + cycle WAIT HOLD_TOT DURABLE accounted + L0 31.15 ms 11.76 (37.8%) 0.441 (1.4%) 18.43 (59.2%) 98.4% + L3 17.98 ms 0.256 ( 1.4%) 0.011 (0.06%) 17.59 (97.8%) 99.3% + + L0 -> L3: WAIT falls 46x. DURABLE is flat (0.95x). HOLD falls 41.5x. + +⇒ **Time spent BLOCKED ON `logical` falls FASTER than the section does.** Threads are not piling +up on the mutex at L3; they have essentially stopped touching it. The gap that grows is +`durable()`, and at L3 it is 97.8% of everything. + +⇒ This is strictly stronger evidence than the count, and it is immune to the section-1 blind +spot: a parked thread's wall clock runs whether or not its `try_lock` ever failed. Both witnesses +agree, and the one that cannot be fooled by barging is the emphatic one. + +⇒ AN INTERNAL CONTROL AGAINST "IT IS JUST THE LOADED BOX": L0 and L3 ran interleaved, in the same +process, under the same load, with rung order rotated. Scheduler starvation would inflate WAIT at +both rungs. WAIT differs by 46x between them. + +================================================================================================ +5b. ⭐ THE OBJECTION THIS MUST SURVIVE: "BUT contended/op IS 0.908 AT L3, WHICH IS HIGH" +================================================================================================ + +It is high, and it is HIGHER than a pure mutex reads at the same operating point. From the model +(bench/ceiling_raw/03), a bare `Mutex` driven at each recorded rung's own arrival process — hold +and cycle set to the recorded wall-clock values, the outside interval a SLEEP so the thread +leaves the runnable set as it does in an fsync: + + L0-like hold 0.201 ms / cycle 13.365 ms contended/op 0.78336 queue_mean 5.35 U_ach 74.2% + L3-like hold 0.0094 ms / cycle 7.096 ms contended/op 0.41383 queue_mean 1.89 U_ach 6.5% + +Real ladder L3: 0.90812 against a reference of 0.41383. So threads DO still collide on `logical` +at L3, more than a memoryless arrival process would produce. Taken alone that reads as support +for outcome 1, and it is exactly why the count alone cannot settle this. + +⇒ **IT IS RECONCILED BY WHAT THE COLLISIONS COST, AND BY WHAT CAUSES THEM.** + + (i) THE COST. At L3 the whole of WAIT is 0.25570 ms of a 17.98 ms cycle — **1.4%**. Deleting + `logical` outright, making every acquisition free, could buy at most that 1.4%. A 90% + collision rate on a lock that accounts for 1.4% of the cycle is not a ceiling. + + (ii) THE CAUSE, and it is visible in a third number nobody had to go looking for. Bunched + arrivals are the only way a lock can be idle 96.3% of the time and still turn away 90% of + arrivals, and the thing doing the bunching is measured: + + f/sync (forks per fsync) 23.8 at L0 -> 32.0 at L3 + WAIT / HOLD_TOTAL (queue depth on arrival) 26.6 -> 24.1 + + A fork at L3 arrives behind about 24 hold-times of queue, against a commit batch of 32. + Same order, and it moves the right way. **The batch IS the queue**: an fsync completes, + releases its batch, the batch piles onto `logical`, drains in ~32 x 0.011 = 0.35 ms, and + every thread goes back to waiting on the disk. + +⇒ So both witnesses are telling the truth and they are not in conflict. The collisions are real +and they are a DOWNSTREAM CONSEQUENCE of group-commit batching, not an independent handoff floor. +Outcome 1 required the remaining time to BE park/unpark; the remaining time is 97.8% `durable()`. + +⇒ AND THE SERIALISATION DID NOT DISAPPEAR, IT MOVED. f/sync rises 23.8 -> 32.0 across the ladder +(bench/ceiling_raw/22): group commit is batching more forks per fsync, and the threads are queued +on that barrier instead of on the mutex. + +⚠ WHAT THIS DOES NOT SAY. It does not say the fsync is irreducible, and it proposes no mechanism — +the pre-registration names flat combining, RCL, MCS/CLH and spin-then-park as prior art and +concludes "a mechanism here would die of prior art and I should not propose one". That still +holds. This section replaces a wrong attribution with a measured one and stops. + +================================================================================================ +6. LAYER B — pgwire, WHAT PRODUCTION ACTUALLY DOES +================================================================================================ + +⚠ LAYER: the shipped pgwire server over a real TCP socket, one client connection per thread, +forks issued as `BEGIN AGENT SESSION`. Locks held, outermost first: `ServerContext::catalog()` -> +`AgentRuntime` state -> `TableBranchCatalog::logical`. E.6 (bench/e6_outer_lock_count.txt, +outcome 1) established this is NOT the same experiment as layer A. + +⛔ THE LADDER CANNOT BE DRIVEN THROUGH THE SHIPPED FRONT-END ABOVE L0, AND THAT IS A RESULT. +Raw: bench/ceiling_raw/20_pgwire_ladder_BREAKS_above_L0.txt (exit 1). L1 is defined as "skip +`write_record`" — the child's record is never written — and `ABANDON` is a front-end statement +that reads it back. The L1 arm dies with `branch error: branch b1@g0 not found`. Skipping ABANDON +at L1+ would make the rungs different workloads and the ladder a lookalike, so layer B runs L0 +only and the asymmetry is stated here rather than papered over. + +Raw: bench/ceiling_raw/21_pgwire_L0_layerB.txt, L0, 40 forks per thread: + + threads outer/op outerCont/op inner/op innerCont/op + 1 4.0250 0.00000 3.0000 0.00000 + 8 4.0250 2.42500 3.0000 0.00000 + 64 4.0250 2.42148 3.0000 0.00000 + +⇒ ⭐ **`TableBranchCatalog::logical` — the mutex this entire 21x/1.9x/12.6x argument is about — +records ZERO contended acquisitions at 64 concurrent connections.** In the same process, in the +same run, the outer statement mutex is contended 2.42 times per fork. The census is demonstrably +alive there and demonstrably reads 0 when it should (T=1 row). +⇒ The statement path takes `logical` 3.0000 times per fork where the direct path takes it 1.0000 +(bench/ceiling_raw/22), so layer B is not even the same number of acquisitions. +⇒ Nothing about layer A's ladder is a production claim. That was already Amendment 1's condition; +this is the measurement behind it. + +================================================================================================ +7. PROVENANCE, AND A QUARANTINE +================================================================================================ + +The brief said the previous session's work was fully committed on CEILING-park-or-structure and +nothing was lost. **That was true of the 49 commits and NOT true of the working tree**: +/Users/idide/wt/ferrodb-CEILING-park-or-structure held examples/ceiling_lock_contention.rs +modified and uncommitted, +397/-24, containing the `IN_ACQ` queue gauge, the sleep-vs-spin +operating-point references, and a first `mode_paired`. + +It was captured with `git stash create` (which does not touch the tree) as 540d5a5, pinned as +branch CEILING-QUARANTINE-uncommitted, and inherited into CEILING-resume BYTE FOR BYTE in one +labelled commit before being built on. The old worktree was never modified. I had independently +re-derived the same barging mechanism before finding it — two derivations, one mechanism. + +⚠ A NEAR-MISS, RECORDED BECAUSE IT NEARLY BECAME A FALSE FINDING: two pgwire runs appeared to +show `ABANDON` failing at T>=2 with stub=0, which would have been a production concurrency bug. +It was my own argument-position error — `max_stub` is the 5th positional slot and I passed it +4th, so those runs were still attempting L1. Caught before it was written anywhere. + +Every rung asserts `probe::stub_level()` and `probe::extra_upserts()` before and after its timed +loop and refuses rather than prints; the process start values are in the header of every raw file +(`stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false`). + +================================================================================================ +8. ⛔⛔ WHAT MUST NOT LAND +================================================================================================ + +CEILING-resume and CEILING-park-or-structure carry D123's scaffold. `src/branch/table_catalog.rs` +gates a `None` return on the stub level: + + let envelope = if stub >= 3 { None } else { self.envelope_bytes(parent.id)? }; + +That is a CAPABILITY ESCAPE. L1 also never writes the child's record, L2 leaks retired slots, and +L3 drops the parent's live-child entry (a GC hole). **A stubbed level is not a database.** + +The artifacts branch CEILING-artifacts carries bench/ only and is verified with: + + git diff --name-only main...CEILING-artifacts -- src examples # must be empty + +================================================================================================ +9. WHAT THE RECORD NOW NEEDS +================================================================================================ + +frontier/INVENTIONS.md still says "past some point the handoff, not the section, is what is +left". That is withdrawn as written; it should read "the DURABILITY BARRIER, not the section". +The exact append-only text is in bench/ceiling_INVENTIONS_patch.md. It is NOT applied here: +INVENTIONS.md is in the artie-research repo, outside this agent's worktree, and other sessions +were editing that repo during this run. diff --git a/bench/ceiling_raw/00_model_POSITIVE_CONTROL_FAILED.txt b/bench/ceiling_raw/00_model_POSITIVE_CONTROL_FAILED.txt new file mode 100644 index 00000000..63ba4d60 --- /dev/null +++ b/bench/ceiling_raw/00_model_POSITIVE_CONTROL_FAILED.txt @@ -0,0 +1,23 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + +CEILING — MODE=model. THE FIRE-CHECK, and the shape a PURE mutex produces. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. This cell exists +to make the counter fire on purpose and to show what it reads when it should read 0. +# model_start: 2026-09-22T07:05:44Z load_at_acquire=s: 7.98 8.07 11.73 + + cell T hold outside ops contended/op acq/op + NEG single-thread 1 2000 0 20000 0.00000 1.0000 + POS 64t all-lock 64 20000 0 25600 0.04688 1.0000 +ceiling: REFUSING. POSITIVE CONTROL FAILED: 64 threads doing nothing but hold one mutex reported only 0.0469 contended acquisitions per op. The counter does not fire when contention is certain, so a low reading anywhere else in this run means nothing. + +================================================================================================ +WHAT THIS FILE IS. Raw output of the FIRST run of MODE=model on branch CEILING-resume (off +CEILING-park-or-structure c00b26e), banked BEFORE any interpretation and BEFORE the scaffold was +touched. The harness refused and exited 1. THE REFUSAL IS THE MEASUREMENT. + + NEG single-thread T=1 contended/op 0.00000 acq/op 1.0000 ops 20000 -> PASSED + POS 64t all-lock T=64 contended/op 0.04688 acq/op 1.0000 ops 25600 -> REFUSED (<0.5) + +The gate it tripped was written by THIS SAME SCAFFOLD in its previous run and encodes the claim +"64 threads doing nothing but hold one mutex must show contended/op near 1.0". That claim is +FALSE and this file is what falsified it. See bench/ceiling_park_or_structure.txt. diff --git a/bench/ceiling_raw/01_model_inherited_PASSED.txt b/bench/ceiling_raw/01_model_inherited_PASSED.txt new file mode 100644 index 00000000..7616e7a7 --- /dev/null +++ b/bench/ceiling_raw/01_model_inherited_PASSED.txt @@ -0,0 +1,66 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=model. THE FIRE-CHECK, and the shape a PURE mutex produces. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. This cell exists +to make the counter fire on purpose and to show what it reads when it should read 0. +# model_start: 2026-09-22T07:12:07Z load_at_acquire=s: 22.93 15.25 13.74 + + queue_mean / queue_max = threads inside the acquire region when one got in, self + included (the SECOND witness; see IN_ACQ). 1 means nobody else was there at all. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max + NEG single-thread 1 2000 0 20000 0.043 0.00000 1.0000 1.00 1 + POS 64t queued 64 20000 20000 51200 1.354 0.67178 1.0000 61.07 64 + BARGE 64t no-out 64 20000 0 51200 1.072 0.01031 1.0000 46.80 64 + + ⇒ BLIND SPOT, MEASURED: the BARGE row has queue_mean/queue_max showing threads ARE + parked, while contended/op reads far below the POS row. `try_lock` failure counts + COLLISIONS, not PARKED THREADS, and the two come apart when the releaser re-takes + the lock. Direction of the error: contended/op UNDER-reports blocking, i.e. it errs + toward killing pre-registered outcome 1. Every low reading below inherits that. + + REFERENCE SHAPE — 64 threads, total work per iteration held CONSTANT at 20,000 + spins, moved out of the section a step at a time. This is the stub ladder's own + transition (hold shrinks, outside grows) in a system with NO other term. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max + work-in 100% 64 20000 0 25600 0.536 0.01711 1.0000 45.72 64 + work-in 50% 64 10000 10000 25600 0.409 0.42973 1.0000 59.26 64 + work-in 10% 64 2000 18000 25600 0.109 0.41578 1.0000 54.26 64 + work-in 2% 64 500 19500 25600 0.047 0.27406 1.0000 44.89 62 + work-in 0% 64 100 19900 25600 0.042 0.10344 1.0000 37.44 58 + + OPERATING-POINT REFERENCE (a) — SPIN OUTSIDE. A pure mutex driven at the SAME + hold/cycle fraction as each recorded rung (L0 0.015023, L3 0.001326; d123 §3b, + T=64). ⚠ The outside work is a SPIN, so all 64 threads are runnable at once on a + ~12-core box. Achieved utilisation therefore falls well short of the offered load; + the U_ach column says by how much, and (b) below is the faithful version. + + spin calibration: 1.111e9 spins/sec single-threaded (hold=2000 spins = 0.0018 ms) + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max U_ach + L0-like rho=.961 64 2000 131129 25600 0.293 0.13145 1.0000 30.73 61 15.7% + L3-like rho=.085 64 2000 1506296 25600 2.437 0.04863 1.0000 4.70 51 1.9% + + OPERATING-POINT REFERENCE (b) — SLEEP OUTSIDE. ⭐ THIS IS THE ONE TO COMPARE THE + REAL LADDER AGAINST. Both the hold and the outside interval are set to the recorded + rung's own WALL-CLOCK values, and the outside interval is a sleep, so a thread + between forks leaves the runnable set exactly as it does inside `durable()`'s fsync. + From d123 §3b at T=64: L0 hold 0.20079 ms / cycle 13.365 ms + L3 hold 0.00941 ms / cycle 7.096 ms + It is a MODEL, not an fsync: no I/O, no group commit, and the wake-up carries timer + granularity. What it reproduces is the ARRIVAL PROCESS at each rung. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max U_ach + L0-like 0.201ms 64 223033 sleep 13.164ms 12800 3.756 0.85633 1.0000 5.81 61 68.4% + L3-like 0.0094ms 64 10452 sleep 7.087ms 12800 1.967 0.54711 1.0000 3.36 29 6.1% + +⭐ READ IT THIS WAY. `contended/op` near 0.67 is what a mutex reads when threads are + genuinely queued on it. Reference (b) gives the value a pure mutex reads at each + recorded rung's own arrival process: L0-like 0.85633 (queue_mean 5.81), L3-like 0.54711 + (queue_mean 3.36). If the real ladder's L3 lands near the L3-like reference, the + lock is simply not busy and nothing is queued on it — pre-registered outcome 1 dies. + If it lands near 0.67 instead, threads are still piling up and outcome 1 lives. +# end: 2026-09-22T07:12:19Z load_at_acquire=s: 28.42 16.83 14.32 diff --git a/bench/ceiling_raw/02_barrier_queuemax_63.txt b/bench/ceiling_raw/02_barrier_queuemax_63.txt new file mode 100644 index 00000000..6d767a41 --- /dev/null +++ b/bench/ceiling_raw/02_barrier_queuemax_63.txt @@ -0,0 +1,18 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=model. THE FIRE-CHECK, and the shape a PURE mutex produces. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. This cell exists +to make the counter fire on purpose and to show what it reads when it should read 0. +# model_start: 2026-09-22T07:14:25Z load_at_acquire=s: 36.72 23.08 17.11 + + queue_mean / queue_max = threads inside the acquire region when one got in, self + included (the SECOND witness; see IN_ACQ). 1 means nobody else was there at all. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max + NEG single-thread 1 2000 0 20000 0.037 0.00000 1.0000 1.00 1 + POS 64t barrier 64 2000000 0 64 0.118 0.98438 1.0000 31.78 63 + ^ closed form, stated before the run: contended/op = (T-1)/T = 0.98438, + queue_mean = (T+1)/2 = 32.50, queue_max = T = 64. Gates: +/-15%, exact. +ceiling: REFUSING. CLOSED-FORM CONTROL FAILED: queue_max 63 != T = 64. The first thread through the barrier must see all 64 inside the acquire region. diff --git a/bench/ceiling_raw/03_model_closed_form.txt b/bench/ceiling_raw/03_model_closed_form.txt new file mode 100644 index 00000000..f40f8327 --- /dev/null +++ b/bench/ceiling_raw/03_model_closed_form.txt @@ -0,0 +1,69 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=model. THE FIRE-CHECK, and the shape a PURE mutex produces. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. This cell exists +to make the counter fire on purpose and to show what it reads when it should read 0. +# model_start: 2026-09-22T07:14:52Z load_at_acquire=s: 42.83 25.55 18.17 + + queue_mean / queue_max = threads inside the acquire region when one got in, self + included (the SECOND witness; see IN_ACQ). 1 means nobody else was there at all. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max + NEG single-thread 1 2000 0 20000 0.041 0.00000 1.0000 1.00 1 + POS 64t barrier 64 2000000 0 64 0.105 0.98438 1.0000 32.50 64 + ^ closed form, stated before the run: contended/op = (T-1)/T = 0.98438, + queue_mean = (T+1)/2 = 32.50, queue_max = T = 64. Gates: +/-15%, exact. + POS 64t queued 64 20000 20000 51200 1.617 0.52994 1.0000 60.96 64 + BARGE 64t no-out 64 20000 0 51200 1.105 0.03828 1.0000 55.91 64 + + ⇒ BLIND SPOT, MEASURED: the BARGE row has queue_mean/queue_max showing threads ARE + parked, while contended/op reads far below the POS row. `try_lock` failure counts + COLLISIONS, not PARKED THREADS, and the two come apart when the releaser re-takes + the lock. Direction of the error: contended/op UNDER-reports blocking, i.e. it errs + toward killing pre-registered outcome 1. Every low reading below inherits that. + + REFERENCE SHAPE — 64 threads, total work per iteration held CONSTANT at 20,000 + spins, moved out of the section a step at a time. This is the stub ladder's own + transition (hold shrinks, outside grows) in a system with NO other term. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max + work-in 100% 64 20000 0 25600 0.534 0.04688 1.0000 54.67 64 + work-in 50% 64 10000 10000 25600 0.440 0.42527 1.0000 59.49 64 + work-in 10% 64 2000 18000 25600 0.094 0.43383 1.0000 53.90 63 + work-in 2% 64 500 19500 25600 0.040 0.35406 1.0000 45.14 60 + work-in 0% 64 100 19900 25600 0.044 0.10430 1.0000 35.34 57 + + OPERATING-POINT REFERENCE (a) — SPIN OUTSIDE. A pure mutex driven at the SAME + hold/cycle fraction as each recorded rung (L0 0.015023, L3 0.001326; d123 §3b, + T=64). ⚠ The outside work is a SPIN, so all 64 threads are runnable at once on a + ~12-core box. Achieved utilisation therefore falls well short of the offered load; + the U_ach column says by how much, and (b) below is the faithful version. + + spin calibration: 9.854e8 spins/sec single-threaded (hold=2000 spins = 0.0020 ms) + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max U_ach + L0-like rho=.961 64 2000 131129 25600 0.210 0.21477 1.0000 44.03 60 24.7% + L3-like rho=.085 64 2000 1506296 25600 2.462 0.06914 1.0000 6.15 51 2.1% + + OPERATING-POINT REFERENCE (b) — SLEEP OUTSIDE. ⭐ THIS IS THE ONE TO COMPARE THE + REAL LADDER AGAINST. Both the hold and the outside interval are set to the recorded + rung's own WALL-CLOCK values, and the outside interval is a sleep, so a thread + between forks leaves the runnable set exactly as it does inside `durable()`'s fsync. + From d123 §3b at T=64: L0 hold 0.20079 ms / cycle 13.365 ms + L3 hold 0.00941 ms / cycle 7.096 ms + It is a MODEL, not an fsync: no I/O, no group commit, and the wake-up carries timer + granularity. What it reproduces is the ARRIVAL PROCESS at each rung. + + cell T hold outside ops secs contended/op acq/op queue_mean queue_max U_ach + L0-like 0.201ms 64 197862 sleep 13.164ms 12800 3.465 0.78336 1.0000 5.35 60 74.2% + L3-like 0.0094ms 64 9273 sleep 7.087ms 12800 1.844 0.41383 1.0000 1.89 20 6.5% + +⭐ READ IT THIS WAY. `contended/op` near 0.53 is what a mutex reads when threads are + genuinely queued on it. Reference (b) gives the value a pure mutex reads at each + recorded rung's own arrival process: L0-like 0.78336 (queue_mean 5.35), L3-like 0.41383 + (queue_mean 1.89). If the real ladder's L3 lands near the L3-like reference, the + lock is simply not busy and nothing is queued on it — pre-registered outcome 1 dies. + If it lands near 0.53 instead, threads are still piling up and outcome 1 lives. +# end: 2026-09-22T07:15:04Z load_at_acquire=s: 48.87 27.80 19.11 diff --git a/bench/ceiling_raw/10_paired_T64_N8000_warm0_reps5.txt b/bench/ceiling_raw/10_paired_T64_N8000_warm0_reps5.txt new file mode 100644 index 00000000..cf97e833 --- /dev/null +++ b/bench/ceiling_raw/10_paired_T64_N8000_warm0_reps5.txt @@ -0,0 +1,153 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=paired. THE COUNT AND THE UTILISATION, MEASURED TOGETHER. + +⚠ LAYER: `TableBranchCatalog` driven directly — the layer the recorded 21x/1.9x pair + was measured at, NOT what any shipped front-end does. See MODE=pgwire. + + THREE INSTRUMENT CONFIGURATIONS PER RUNG, because a paired reading is worthless if + either instrument is moving the other's number: + A census ON, probe ON THE DELIVERABLE: contended/op and U(hold), one loop. + B census ON, probe OFF does the phase clock move the COUNT? + C census OFF, probe ON does the try_lock census move UTILISATION? With the + census off, acquire_unwrap short-circuits to a plain + lock().unwrap(), so C is byte-identical to D123's own + instrument and is also the REPRODUCTION ARM for the + recorded 96.1% -> 8.5% collapse. + + U(hold) is a ratio of two durations taken by ONE instrument inside ONE run, which is + the kind D123 §(F4) argues survives a loaded box. ms columns are UPPER BOUNDS. + ⛔ contended/op UNDER-reports blocking under barging — see MODE=model's BARGE row. +# paired_start: 2026-09-22T07:18:12Z load_at_acquire=s: 37.59 32.08 22.53 +# build at 8f03fe0fd4be +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +# reps=5 N=8000 warm=0; configuration order AND rung order both rotate per rep + +RECORDED, for comparison only — bench/d123_serial_attribution.txt §3b, warm=0, T=64, +a DIFFERENT process on a DIFFERENT night. Reproducing it is configuration C's job. + stub forks/sec S_eff HOLD_TOT gap U(hold) + 0 4788.6 0.20883 0.20079 0.00804 96.1% + 3 9019.1 0.11088 0.00941 0.10147 8.5% + +================================================================================ +T = 64 +================================================================================ +RAW — every rep, banked before any aggregation. + rep config stub forks/sec S_eff HOLD_TOT gap U(hold) contended/op syncs + 0 A census+probe 0 2822.6 0.35428 0.28307 0.07121 79.9% 0.97450 472 + 0 A census+probe 1 6957.4 0.14373 0.08440 0.05933 58.7% 0.95063 259 + 0 A census+probe 2 7156.1 0.13974 0.08794 0.05180 62.9% 0.94450 259 + 0 A census+probe 3 6350.7 0.15746 0.01311 0.14436 8.3% 0.84475 251 + 0 B census only 0 2999.9 0.33335 NaN NaN NaN% 0.97025 522 + 0 B census only 1 4536.6 0.22043 NaN NaN NaN% 0.94425 313 + 0 B census only 2 4936.1 0.20259 NaN NaN NaN% 0.94537 281 + 0 B census only 3 5448.0 0.18355 NaN NaN NaN% 0.82550 264 + 0 C probe only 0 2322.5 0.43058 0.38162 0.04896 88.6% NaN 486 + 0 C probe only 1 1793.6 0.55755 0.38176 0.17579 68.5% NaN 258 + 0 C probe only 2 2628.2 0.38049 0.19224 0.18824 50.5% NaN 259 + 0 C probe only 3 2830.1 0.35335 0.01344 0.33991 3.8% NaN 252 + 1 B census only 1 1384.7 0.72217 NaN NaN NaN% 0.96688 258 + 1 B census only 2 1470.9 0.67986 NaN NaN NaN% 0.96637 256 + 1 B census only 3 2926.6 0.34170 NaN NaN NaN% 0.90275 250 + 1 B census only 0 901.4 1.10944 NaN NaN NaN% 0.99087 399 + 1 C probe only 1 2858.3 0.34986 0.19903 0.15083 56.9% NaN 256 + 1 C probe only 2 2965.4 0.33722 0.17041 0.16682 50.5% NaN 257 + 1 C probe only 3 4362.3 0.22924 0.01200 0.21724 5.2% NaN 250 + 1 C probe only 0 2006.3 0.49843 0.45084 0.04759 90.5% NaN 333 + 1 A census+probe 1 3630.3 0.27546 0.13265 0.14281 48.2% 0.95650 255 + 1 A census+probe 2 3504.4 0.28535 0.11885 0.16651 41.6% 0.95325 257 + 1 A census+probe 3 4183.2 0.23905 0.01282 0.22623 5.4% 0.89950 250 + 1 A census+probe 0 2557.1 0.39107 0.35037 0.04070 89.6% 0.98638 334 + 2 C probe only 2 4344.4 0.23018 0.09720 0.13298 42.2% NaN 256 + 2 C probe only 3 4396.0 0.22748 0.01191 0.21557 5.2% NaN 250 + 2 C probe only 0 2013.8 0.49659 0.44301 0.05358 89.2% NaN 408 + 2 C probe only 1 3439.0 0.29078 0.12588 0.16490 43.3% NaN 255 + 2 A census+probe 2 3523.1 0.28384 0.11690 0.16694 41.2% 0.95388 256 + 2 A census+probe 3 3848.3 0.25985 0.01122 0.24863 4.3% 0.90787 250 + 2 A census+probe 0 2965.4 0.33723 0.29889 0.03834 88.6% 0.98525 321 + 2 A census+probe 1 4282.6 0.23350 0.09700 0.13650 41.5% 0.95100 251 + 2 B census only 2 4402.7 0.22713 NaN NaN NaN% 0.94737 255 + 2 B census only 3 4700.9 0.21272 NaN NaN NaN% 0.90400 250 + 2 B census only 0 2899.7 0.34486 NaN NaN NaN% 0.98462 331 + 2 B census only 1 3976.2 0.25150 NaN NaN NaN% 0.95537 251 + 3 A census+probe 3 4291.4 0.23303 0.01282 0.22021 5.5% 0.88900 250 + 3 A census+probe 0 3053.2 0.32753 0.28592 0.04161 87.3% 0.98587 333 + 3 A census+probe 1 3875.9 0.25800 0.10983 0.14817 42.6% 0.95175 255 + 3 A census+probe 2 4078.2 0.24520 0.10032 0.14488 40.9% 0.94675 254 + 3 B census only 3 4233.3 0.23622 NaN NaN NaN% 0.89638 250 + 3 B census only 0 2998.5 0.33350 NaN NaN NaN% 0.98575 330 + 3 B census only 1 3326.9 0.30058 NaN NaN NaN% 0.94788 253 + 3 B census only 2 3531.2 0.28319 NaN NaN NaN% 0.94625 253 + 3 C probe only 3 3000.7 0.33326 0.01223 0.32103 3.7% NaN 250 + 3 C probe only 0 2968.3 0.33690 0.29983 0.03707 89.0% NaN 328 + 3 C probe only 1 4274.3 0.23396 0.09239 0.14157 39.5% NaN 253 + 3 C probe only 2 3996.3 0.25023 0.10152 0.14871 40.6% NaN 255 + 4 B census only 0 3173.2 0.31514 NaN NaN NaN% 0.98250 328 + 4 B census only 1 4471.6 0.22364 NaN NaN NaN% 0.94888 254 + 4 B census only 2 4578.2 0.21842 NaN NaN NaN% 0.94950 254 + 4 B census only 3 4469.9 0.22372 NaN NaN NaN% 0.86962 250 + 4 C probe only 0 3107.0 0.32186 0.28447 0.03739 88.4% NaN 342 + 4 C probe only 1 3836.5 0.26066 0.10548 0.15518 40.5% NaN 254 + 4 C probe only 2 3825.0 0.26144 0.10597 0.15547 40.5% NaN 252 + 4 C probe only 3 3950.2 0.25315 0.01238 0.24077 4.9% NaN 250 + 4 A census+probe 0 2867.7 0.34871 0.30540 0.04331 87.6% 0.98638 323 + 4 A census+probe 1 3910.6 0.25571 0.10600 0.14971 41.5% 0.95513 258 + 4 A census+probe 2 4820.3 0.20746 0.09051 0.11694 43.6% 0.95225 251 + 4 A census+probe 3 4997.6 0.20010 0.01266 0.18744 6.3% 0.91650 250 + +MEDIANS over 5 reps: + config stub forks/sec S_eff HOLD_TOT gap U(hold) contended/op + A census+probe 0 2867.7 0.34871 0.29889 0.04161 87.6% 0.98587 + A census+probe 1 3910.6 0.25571 0.10600 0.14281 42.6% 0.95175 + A census+probe 2 4078.2 0.24520 0.10032 0.14488 41.6% 0.95225 + A census+probe 3 4291.4 0.23303 0.01282 0.22021 5.5% 0.89950 + B census only 0 2998.5 0.33350 NaN NaN NaN% 0.98462 + B census only 1 3976.2 0.25150 NaN NaN NaN% 0.94888 + B census only 2 4402.7 0.22713 NaN NaN NaN% 0.94737 + B census only 3 4469.9 0.22372 NaN NaN NaN% 0.89638 + C probe only 0 2322.5 0.43058 0.38162 0.04759 89.0% NaN + C probe only 1 3439.0 0.29078 0.12588 0.15518 43.3% NaN + C probe only 2 3825.0 0.26144 0.10597 0.15547 42.2% NaN + C probe only 3 3950.2 0.25315 0.01223 0.24077 4.9% NaN + +CROSS-CONTROLS — a paired reading is licensed only if neither instrument moves +the other's number. Bar: within 25% at every rung. + L0 contended/op A(probe ON) 0.98587 vs B(probe OFF) 0.98462 ratio 1.001 + L1 contended/op A(probe ON) 0.95175 vs B(probe OFF) 0.94888 ratio 1.003 + L2 contended/op A(probe ON) 0.95225 vs B(probe OFF) 0.94737 ratio 1.005 + L3 contended/op A(probe ON) 0.89950 vs B(probe OFF) 0.89638 ratio 1.003 + L0 U(hold) A(census ON) 87.6% vs C(census OFF) 89.0% ratio 0.984 + L1 U(hold) A(census ON) 42.6% vs C(census OFF) 43.3% ratio 0.983 + L2 U(hold) A(census ON) 41.6% vs C(census OFF) 42.2% ratio 0.986 + L3 U(hold) A(census ON) 5.5% vs C(census OFF) 4.9% ratio 1.124 + +⭐ L0 -> L3 AT T=64, CONFIGURATION A (census+probe, THE DELIVERABLE): + HOLD_TOT 0.29889 -> 0.01282 ms (23.3x DOWN) [recorded 21x] + U(hold) 87.6% -> 5.5% (15.9x DOWN) [recorded 11.3x, 96.1->8.5] + gap 0.04161 -> 0.22021 ms (5.3x UP) [recorded 12.6x] + forks/sec 2867.7 -> 4291.4 (1.50x UP) [recorded 1.9x] UPPER BOUND + contended/op 0.98587 -> 0.89950 (0.91x) [never recorded: THIS is the new number] + +⭐ L0 -> L3 AT T=64, CONFIGURATION C (probe only, D123's own instrument): + HOLD_TOT 0.38162 -> 0.01223 ms (31.2x DOWN) [recorded 21x] + U(hold) 89.0% -> 4.9% (18.2x DOWN) [recorded 11.3x, 96.1->8.5] + gap 0.04759 -> 0.24077 ms (5.1x UP) [recorded 12.6x] + forks/sec 2322.5 -> 3950.2 (1.70x UP) [recorded 1.9x] UPPER BOUND + contended/op NaN -> NaN (NaNx) [never recorded: THIS is the new number] + +⭐ HOW TO READ IT — Amendment 2, pre-registered. No fourth outcome is permitted. + contended/op RISES as the section shortens ⇒ the gap is park/unpark handoff. + it does NOT rise ⇒ the remaining time is not threads + blocking on each other, and + pre-registered OUTCOME 1 IS DEAD. + the counts cannot discriminate ⇒ a result about the INSTRUMENT, which + Amendment 2 permits and requires to + be labelled as one. + +⛔ AND THE COMPARATOR IS MODE=model's OPERATING-POINT REFERENCE (b), NOT ZERO. A pure + mutex driven at each recorded rung's own arrival process already reads a high + contended/op at BOTH ends. 'Still contended at L3' is only evidence for outcome 1 + if it is HIGHER than what a mutex with no handoff floor reads at that same + operating point. +# end: 2026-09-22T07:20:44Z load_at_acquire=s: 26.44 33.59 25.06 diff --git a/bench/ceiling_raw/11_paired_T1_negative_control.txt b/bench/ceiling_raw/11_paired_T1_negative_control.txt new file mode 100644 index 00000000..0589cde3 --- /dev/null +++ b/bench/ceiling_raw/11_paired_T1_negative_control.txt @@ -0,0 +1,158 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=paired. THE COUNT AND THE UTILISATION, MEASURED TOGETHER. + +⚠ LAYER: `TableBranchCatalog` driven directly — the layer the recorded 21x/1.9x pair + was measured at, NOT what any shipped front-end does. See MODE=pgwire. + + THREE INSTRUMENT CONFIGURATIONS PER RUNG, because a paired reading is worthless if + either instrument is moving the other's number: + A census ON, probe ON THE DELIVERABLE: contended/op and U(hold), one loop. + B census ON, probe OFF does the phase clock move the COUNT? + C census OFF, probe ON does the try_lock census move UTILISATION? With the + census off, acquire_unwrap short-circuits to a plain + lock().unwrap(), so C is byte-identical to D123's own + instrument and is also the REPRODUCTION ARM for the + recorded 96.1% -> 8.5% collapse. + + U(hold) is a ratio of two durations taken by ONE instrument inside ONE run, which is + the kind D123 §(F4) argues survives a loaded box. ms columns are UPPER BOUNDS. + ⛔ contended/op UNDER-reports blocking under barging — see MODE=model's BARGE row. +# paired_start: 2026-09-22T07:21:39Z load_at_acquire=s: 25.25 31.97 24.98 +# build at d91bd55e8f31 +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +# reps=2 N=640 warm=0; configuration order AND rung order both rotate per rep + +RECORDED, for comparison only — bench/d123_serial_attribution.txt §3b, warm=0, T=64, +a DIFFERENT process on a DIFFERENT night. Reproducing it is configuration C's job. + stub forks/sec S_eff HOLD_TOT gap U(hold) + 0 4788.6 0.20883 0.20079 0.00804 96.1% + 3 9019.1 0.11088 0.00941 0.10147 8.5% + +================================================================================ +T = 1 +================================================================================ +⭐ WAIT and DURABLE ARE THE DIRECT ANSWER TO 'WHAT IS THE REMAINING TIME', AND + THEY BEAT THE CENSUS. Every thread's cycle is wait -> hold -> durable, so + WAIT + HOLD_TOT + DURABLE = T * S_eff (D123 §2b's closure identity, checked in + the `close` column). WAIT is time spent BLOCKED ACQUIRING `logical` — measured + in nanoseconds by the phase clock, not inferred from a collision count, and + immune to the barging blind spot that makes contended/op under-report. + ⇒ at L3, WAIT >> DURABLE means the gap IS threads blocked on the lock. + ⇒ at L3, DURABLE >> WAIT means the gap is the disk, and outcome 1 is dead for + a reason no collision count could have shown. + D123 §2b measured this at L0 ONLY (T=64: WAIT 6.84 ms > DURABLE 5.35 ms). The + L1-L3 rungs have never been decomposed, which is the hole this fills. + +RAW — every rep, banked before any aggregation. + rep config stub forks/sec S_eff HOLD_TOT gap U(hold) WAIT DURABLE close contended/op syncs + 0 A census+probe 0 116.6 8.57788 0.13320 8.44468 1.6% 0.00031 8.44303 100.0% 0.00000 640 + 0 A census+probe 1 110.5 9.04869 0.06703 8.98166 0.7% 0.00033 8.97987 100.0% 0.00000 640 + 0 A census+probe 2 110.7 9.03618 0.06998 8.96619 0.8% 0.00037 8.96432 100.0% 0.00000 640 + 0 A census+probe 3 91.8 10.89820 0.02091 10.87729 0.2% 0.00039 10.87533 100.0% 0.00000 640 + 0 B census only 0 96.1 10.40725 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 0 B census only 1 91.3 10.94905 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 0 B census only 2 124.7 8.01976 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 0 B census only 3 77.1 12.97715 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 0 C probe only 0 100.8 9.92185 0.14421 9.77764 1.5% 0.00048 9.77580 100.0% NaN 640 + 0 C probe only 1 80.6 12.41249 0.08244 12.33006 0.7% 0.00031 12.32816 100.0% NaN 640 + 0 C probe only 2 121.3 8.24738 0.06642 8.18096 0.8% 0.00032 8.17921 100.0% NaN 640 + 0 C probe only 3 134.6 7.42829 0.01907 7.40923 0.3% 0.00033 7.40667 100.0% NaN 640 + 1 B census only 1 119.2 8.39083 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 1 B census only 2 112.6 8.88108 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 1 B census only 3 110.8 9.02825 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 1 B census only 0 111.2 8.99385 NaN NaN NaN% NaN NaN NaN% 0.00000 640 + 1 C probe only 1 132.2 7.56289 0.06075 7.50214 0.8% 0.00022 7.50072 100.0% NaN 640 + 1 C probe only 2 105.7 9.45740 0.06978 9.38762 0.7% 0.00029 9.38610 100.0% NaN 640 + 1 C probe only 3 113.9 8.78267 0.02123 8.76144 0.2% 0.00039 8.75920 100.0% NaN 640 + 1 C probe only 0 110.8 9.02551 0.20302 8.82249 2.2% 0.00031 8.81993 100.0% NaN 640 + 1 A census+probe 1 111.0 9.01233 0.08628 8.92605 1.0% 0.00047 8.92290 100.0% 0.00000 640 + 1 A census+probe 2 113.0 8.85130 0.09399 8.75731 1.1% 0.00040 8.75497 100.0% 0.00000 640 + 1 A census+probe 3 73.2 13.66584 0.01806 13.64778 0.1% 0.00034 13.64583 100.0% 0.00000 640 + 1 A census+probe 0 72.6 13.77548 0.58418 13.19130 4.2% 0.00034 13.18945 100.0% 0.00000 640 + +MEDIANS over 2 reps: + config stub forks/sec S_eff HOLD_TOT gap U(hold) WAIT DURABLE WAIT/DUR contended/op + A census+probe 0 94.6 11.17668 0.35869 10.81799 2.9% 0.00032 10.81624 0.000 0.00000 + A census+probe 1 110.7 9.03051 0.07666 8.95386 0.8% 0.00040 8.95138 0.000 0.00000 + A census+probe 2 111.8 8.94374 0.08199 8.86175 0.9% 0.00038 8.85964 0.000 0.00000 + A census+probe 3 82.5 12.28202 0.01949 12.26253 0.2% 0.00037 12.26058 0.000 0.00000 + B census only 0 103.6 9.70055 NaN NaN NaN% NaN NaN NaN 0.00000 + B census only 1 105.3 9.66994 NaN NaN NaN% NaN NaN NaN 0.00000 + B census only 2 118.6 8.45042 NaN NaN NaN% NaN NaN NaN 0.00000 + B census only 3 93.9 11.00270 NaN NaN NaN% NaN NaN NaN 0.00000 + C probe only 0 105.8 9.47368 0.17362 9.30007 1.9% 0.00040 9.29786 0.000 NaN + C probe only 1 106.4 9.98769 0.07159 9.91610 0.7% 0.00026 9.91444 0.000 NaN + C probe only 2 113.5 8.85239 0.06810 8.78429 0.8% 0.00030 8.78265 0.000 NaN + C probe only 3 124.2 8.10548 0.02015 8.08533 0.2% 0.00036 8.08294 0.000 NaN + +CLOSURE IDENTITY (D123 §2b): WAIT + HOLD_TOTAL + DURABLE vs T*S_eff, median per rung. + A census+probe L0 100.0% + A census+probe L1 100.0% + A census+probe L2 100.0% + A census+probe L3 100.0% + C probe only L0 100.0% + C probe only L1 100.0% + C probe only L2 100.0% + C probe only L3 100.0% + +CROSS-CONTROLS — a paired reading is licensed only if neither instrument moves +the other's number. Bar: within 25% at every rung. + L0 contended/op A(probe ON) 0.00000 vs B(probe OFF) 0.00000 ratio NaN <-- DISAGREE + L1 contended/op A(probe ON) 0.00000 vs B(probe OFF) 0.00000 ratio NaN <-- DISAGREE + L2 contended/op A(probe ON) 0.00000 vs B(probe OFF) 0.00000 ratio NaN <-- DISAGREE + L3 contended/op A(probe ON) 0.00000 vs B(probe OFF) 0.00000 ratio NaN <-- DISAGREE + L0 U(hold) A(census ON) 2.9% vs C(census OFF) 1.9% ratio 1.565 <-- DISAGREE + L1 U(hold) A(census ON) 0.8% vs C(census OFF) 0.7% ratio 1.157 + L2 U(hold) A(census ON) 0.9% vs C(census OFF) 0.8% ratio 1.190 + L3 U(hold) A(census ON) 0.2% vs C(census OFF) 0.2% ratio 0.650 <-- DISAGREE + +⛔ A CROSS-CONTROL DISAGREES BY MORE THAN 25%. The two columns are NOT from one + undisturbed instrument, and the L0->L3 line below is NOT licensed as a + paired reading. Amendment 2 permits exactly this outcome: a result about + the INSTRUMENT rather than the hypothesis, labelled as one. + +⭐ L0 -> L3 AT T=1, CONFIGURATION A (census+probe, THE DELIVERABLE): + HOLD_TOT 0.35869 -> 0.01949 ms (18.4x DOWN) [recorded 21x] + U(hold) 2.9% -> 0.2% (17.9x DOWN) [recorded 11.3x, 96.1->8.5] + gap 10.81799 -> 12.26253 ms (1.1x UP) [recorded 12.6x] + forks/sec 94.6 -> 82.5 (0.87x UP) [recorded 1.9x] UPPER BOUND + contended/op 0.00000 -> 0.00000 (NaNx) [never recorded: THIS is the new number] + WAIT 0.00032 -> 0.00037 ms (1.14x) + DURABLE 10.81624 -> 12.26058 ms (1.13x) + WAIT/DURABLE 0.000 -> 0.000 <- WHAT THE REMAINING TIME IS. + >1 at L3: the gap is threads BLOCKED ON THE LOCK. + <1 at L3: the gap is the DISK, and no collision count + could have told you that. + gap accounted (WAIT+DURABLE)/T vs gap: 12.26095 vs 12.26253 ms at L3 + +⭐ L0 -> L3 AT T=1, CONFIGURATION C (probe only, D123's own instrument): + HOLD_TOT 0.17362 -> 0.02015 ms (8.6x DOWN) [recorded 21x] + U(hold) 1.9% -> 0.2% (7.4x DOWN) [recorded 11.3x, 96.1->8.5] + gap 9.30007 -> 8.08533 ms (0.9x UP) [recorded 12.6x] + forks/sec 105.8 -> 124.2 (1.17x UP) [recorded 1.9x] UPPER BOUND + contended/op NaN -> NaN (NaNx) [never recorded: THIS is the new number] + WAIT 0.00040 -> 0.00036 ms (0.91x) + DURABLE 9.29786 -> 8.08294 ms (0.87x) + WAIT/DURABLE 0.000 -> 0.000 <- WHAT THE REMAINING TIME IS. + >1 at L3: the gap is threads BLOCKED ON THE LOCK. + <1 at L3: the gap is the DISK, and no collision count + could have told you that. + gap accounted (WAIT+DURABLE)/T vs gap: 8.08330 vs 8.08533 ms at L3 + +⭐ HOW TO READ IT — Amendment 2, pre-registered. No fourth outcome is permitted. + contended/op RISES as the section shortens ⇒ the gap is park/unpark handoff. + it does NOT rise ⇒ the remaining time is not threads + blocking on each other, and + pre-registered OUTCOME 1 IS DEAD. + the counts cannot discriminate ⇒ a result about the INSTRUMENT, which + Amendment 2 permits and requires to + be labelled as one. + +⛔ AND THE COMPARATOR IS MODE=model's OPERATING-POINT REFERENCE (b), NOT ZERO. A pure + mutex driven at each recorded rung's own arrival process already reads a high + contended/op at BOTH ends. 'Still contended at L3' is only evidence for outcome 1 + if it is HIGHER than what a mutex with no handoff floor reads at that same + operating point. +# end: 2026-09-22T07:24:10Z load_at_acquire=s: 26.77 30.29 25.43 diff --git a/bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt b/bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt new file mode 100644 index 00000000..71cff78e --- /dev/null +++ b/bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt @@ -0,0 +1,189 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=paired. THE COUNT AND THE UTILISATION, MEASURED TOGETHER. + +⚠ LAYER: `TableBranchCatalog` driven directly — the layer the recorded 21x/1.9x pair + was measured at, NOT what any shipped front-end does. See MODE=pgwire. + + THREE INSTRUMENT CONFIGURATIONS PER RUNG, because a paired reading is worthless if + either instrument is moving the other's number: + A census ON, probe ON THE DELIVERABLE: contended/op and U(hold), one loop. + B census ON, probe OFF does the phase clock move the COUNT? + C census OFF, probe ON does the try_lock census move UTILISATION? With the + census off, acquire_unwrap short-circuits to a plain + lock().unwrap(), so C is byte-identical to D123's own + instrument and is also the REPRODUCTION ARM for the + recorded 96.1% -> 8.5% collapse. + + U(hold) is a ratio of two durations taken by ONE instrument inside ONE run, which is + the kind D123 §(F4) argues survives a loaded box. ms columns are UPPER BOUNDS. + ⛔ contended/op UNDER-reports blocking under barging — see MODE=model's BARGE row. +# paired_start: 2026-09-22T07:24:20Z load_at_acquire=s: 25.54 29.90 25.35 +# build at d91bd55e8f31 +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +# reps=5 N=8000 warm=0; configuration order AND rung order both rotate per rep + +RECORDED, for comparison only — bench/d123_serial_attribution.txt §3b, warm=0, T=64, +a DIFFERENT process on a DIFFERENT night. Reproducing it is configuration C's job. + stub forks/sec S_eff HOLD_TOT gap U(hold) + 0 4788.6 0.20883 0.20079 0.00804 96.1% + 3 9019.1 0.11088 0.00941 0.10147 8.5% + +================================================================================ +T = 64 +================================================================================ +⭐ WAIT and DURABLE ARE THE DIRECT ANSWER TO 'WHAT IS THE REMAINING TIME', AND + THEY BEAT THE CENSUS. Every thread's cycle is wait -> hold -> durable, so + WAIT + HOLD_TOT + DURABLE = T * S_eff (D123 §2b's closure identity, checked in + the `close` column). WAIT is time spent BLOCKED ACQUIRING `logical` — measured + in nanoseconds by the phase clock, not inferred from a collision count, and + immune to the barging blind spot that makes contended/op under-report. + ⇒ at L3, WAIT >> DURABLE means the gap IS threads blocked on the lock. + ⇒ at L3, DURABLE >> WAIT means the gap is the disk, and outcome 1 is dead for + a reason no collision count could have shown. + D123 §2b measured this at L0 ONLY (T=64: WAIT 6.84 ms > DURABLE 5.35 ms). The + L1-L3 rungs have never been decomposed, which is the hole this fills. + +RAW — every rep, banked before any aggregation. + rep config stub forks/sec S_eff HOLD_TOT gap U(hold) WAIT DURABLE close contended/op syncs + 0 A census+probe 0 733.2 1.36397 1.32060 0.04337 96.8% 45.49601 39.31280 98.7% 0.99487 440 + 0 A census+probe 1 1976.5 0.50594 0.30659 0.19936 60.6% 5.26137 26.55348 99.2% 0.97000 257 + 0 A census+probe 2 2519.2 0.39695 0.21399 0.18296 53.9% 3.04196 21.95108 99.2% 0.96600 256 + 0 A census+probe 3 3239.3 0.30871 0.01134 0.29736 3.7% 0.27848 19.33168 99.3% 0.90838 250 + 0 B census only 0 1648.8 0.60649 NaN NaN NaN% NaN NaN NaN% 0.98875 342 + 0 B census only 1 2996.7 0.33370 NaN NaN NaN% NaN NaN NaN% 0.95900 255 + 0 B census only 2 2691.3 0.37156 NaN NaN NaN% NaN NaN NaN% 0.95950 255 + 0 B census only 3 3194.2 0.31307 NaN NaN NaN% NaN NaN NaN% 0.89738 250 + 0 C probe only 0 1331.6 0.75095 0.70634 0.04461 94.1% 22.05782 24.71417 98.8% NaN 365 + 0 C probe only 1 3443.6 0.29040 0.12000 0.17040 41.3% 1.69969 16.63900 99.3% NaN 255 + 0 C probe only 2 3584.7 0.27896 0.11105 0.16791 39.8% 1.64449 15.94284 99.1% NaN 256 + 0 C probe only 3 3619.1 0.27631 0.01089 0.26542 3.9% 0.25058 17.22296 98.9% NaN 250 + 1 B census only 1 2990.3 0.33441 NaN NaN NaN% NaN NaN NaN% 0.96013 256 + 1 B census only 2 3028.4 0.33021 NaN NaN NaN% NaN NaN NaN% 0.95837 256 + 1 B census only 3 3385.8 0.29535 NaN NaN NaN% NaN NaN NaN% 0.91150 250 + 1 B census only 0 1799.7 0.55565 NaN NaN NaN% NaN NaN NaN% 0.98650 341 + 1 C probe only 1 3443.7 0.29038 0.12124 0.16914 41.8% 1.83625 16.39275 98.7% NaN 256 + 1 C probe only 2 3436.9 0.29096 0.11872 0.17225 40.8% 1.81481 16.44527 98.7% NaN 256 + 1 C probe only 3 3833.4 0.26087 0.01190 0.24897 4.6% 0.46057 15.96972 98.5% NaN 251 + 1 C probe only 0 2014.8 0.49633 0.44367 0.05266 89.4% 11.88387 18.88903 98.3% NaN 332 + 1 A census+probe 1 3100.7 0.32251 0.14358 0.17893 44.5% 2.12649 18.24709 99.4% 0.95950 255 + 1 A census+probe 2 3182.3 0.31424 0.12741 0.18683 40.5% 1.61570 18.10982 98.7% 0.95737 257 + 1 A census+probe 3 3559.2 0.28096 0.00970 0.27126 3.5% 0.20468 17.58789 99.0% 0.90812 250 + 1 A census+probe 0 2054.8 0.48666 0.44130 0.04536 90.7% 11.76194 18.43231 98.4% 0.98613 332 + 2 C probe only 2 3179.5 0.31452 0.13649 0.17803 43.4% 1.86834 17.90238 98.9% NaN 256 + 2 C probe only 3 3646.5 0.27423 0.01067 0.26356 3.9% 0.24336 17.14184 99.1% NaN 250 + 2 C probe only 0 1663.2 0.60125 0.55282 0.04843 91.9% 15.50953 21.78281 98.3% NaN 349 + 2 C probe only 1 3037.7 0.32919 0.14303 0.18616 43.4% 1.96525 18.74336 99.0% NaN 256 + 2 A census+probe 2 2862.8 0.34930 0.16817 0.18114 48.1% 2.89713 19.03676 98.9% 0.92888 258 + 2 A census+probe 3 4358.0 0.22946 0.01063 0.21883 4.6% 0.25570 14.22414 98.7% 0.90038 250 + 2 A census+probe 0 2127.4 0.47006 0.41657 0.05349 88.6% 11.33614 17.77211 98.1% 0.98462 334 + 2 A census+probe 1 3197.1 0.31279 0.12540 0.18738 40.1% 1.95189 17.75461 99.1% 0.95725 255 + 2 B census only 2 3509.3 0.28496 NaN NaN NaN% NaN NaN NaN% 0.94100 254 + 2 B census only 3 4396.7 0.22745 NaN NaN NaN% NaN NaN NaN% 0.91238 250 + 2 B census only 0 2195.0 0.45558 NaN NaN NaN% NaN NaN NaN% 0.98375 335 + 2 B census only 1 3338.7 0.29952 NaN NaN NaN% NaN NaN NaN% 0.95800 255 + 3 A census+probe 3 3215.0 0.31104 0.01003 0.30101 3.2% 0.22593 19.50933 99.2% 0.90825 250 + 3 A census+probe 0 1403.8 0.71234 0.64861 0.06373 91.1% 16.81183 27.24945 98.1% 0.98813 337 + 3 A census+probe 1 2351.1 0.42533 0.14938 0.27595 35.1% 2.47909 24.15467 98.4% 0.95713 254 + 3 A census+probe 2 2124.9 0.47061 0.16755 0.30306 35.6% 2.60489 27.17624 99.4% 0.94675 254 + 3 B census only 3 3350.8 0.29844 NaN NaN NaN% NaN NaN NaN% 0.89987 250 + 3 B census only 0 1988.5 0.50290 NaN NaN NaN% NaN NaN NaN% 0.98538 331 + 3 B census only 1 3426.6 0.29184 NaN NaN NaN% NaN NaN NaN% 0.95350 255 + 3 B census only 2 3361.8 0.29746 NaN NaN NaN% NaN NaN NaN% 0.95100 255 + 3 C probe only 3 4049.8 0.24692 0.01231 0.23461 5.0% 0.48427 15.03827 98.3% NaN 254 + 3 C probe only 0 2124.9 0.47061 0.41187 0.05874 87.5% 11.34682 17.83034 98.2% NaN 334 + 3 C probe only 1 3160.6 0.31640 0.13421 0.18219 42.4% 1.97060 18.02509 99.4% NaN 255 + 3 C probe only 2 3844.6 0.26011 0.10210 0.15800 39.3% 1.62511 14.75094 99.0% NaN 257 + 4 B census only 0 2837.8 0.35238 NaN NaN NaN% NaN NaN NaN% 0.98750 377 + 4 B census only 1 3611.2 0.27691 NaN NaN NaN% NaN NaN NaN% 0.95200 257 + 4 B census only 2 3048.2 0.32806 NaN NaN NaN% NaN NaN NaN% 0.95900 254 + 4 B census only 3 3573.3 0.27985 NaN NaN NaN% NaN NaN NaN% 0.85762 250 + 4 C probe only 0 2716.5 0.36812 0.32871 0.03942 89.3% 9.13479 13.67726 98.2% NaN 340 + 4 C probe only 1 3925.2 0.25477 0.10506 0.14971 41.2% 1.90005 14.15106 99.1% NaN 255 + 4 C probe only 2 3893.1 0.25687 0.10979 0.14708 42.7% 1.69217 14.53784 99.4% NaN 255 + 4 C probe only 3 3950.5 0.25313 0.01069 0.24244 4.2% 0.25092 15.80505 99.2% NaN 250 + 4 A census+probe 0 2209.0 0.45268 0.40803 0.04466 90.1% 11.03655 16.91669 97.9% 0.98550 334 + 4 A census+probe 1 3369.5 0.29678 0.11944 0.17734 40.2% 1.98813 16.80097 99.5% 0.95713 253 + 4 A census+probe 2 3653.2 0.27373 0.12424 0.14949 45.4% 1.97814 15.31163 99.4% 0.95725 255 + 4 A census+probe 3 3787.4 0.26403 0.01085 0.25318 4.1% 0.27948 16.43631 99.0% 0.90087 250 + +MEDIANS over 5 reps: + config stub forks/sec S_eff HOLD_TOT gap U(hold) WAIT DURABLE WAIT/DUR contended/op + A census+probe 0 2054.8 0.48666 0.44130 0.04536 90.7% 11.76194 18.43231 0.638 0.98613 + A census+probe 1 3100.7 0.32251 0.14358 0.18738 40.2% 2.12649 18.24709 0.117 0.95725 + A census+probe 2 2862.8 0.34930 0.16755 0.18296 45.4% 2.60489 19.03676 0.137 0.95725 + A census+probe 3 3559.2 0.28096 0.01063 0.27126 3.7% 0.25570 17.58789 0.015 0.90812 + B census only 0 1988.5 0.50290 NaN NaN NaN% NaN NaN NaN 0.98650 + B census only 1 3338.7 0.29952 NaN NaN NaN% NaN NaN NaN 0.95800 + B census only 2 3048.2 0.32806 NaN NaN NaN% NaN NaN NaN 0.95837 + B census only 3 3385.8 0.29535 NaN NaN NaN% NaN NaN NaN 0.89987 + C probe only 0 2014.8 0.49633 0.44367 0.04843 89.4% 11.88387 18.88903 0.629 NaN + C probe only 1 3443.6 0.29040 0.12124 0.17040 41.8% 1.90005 16.63900 0.114 NaN + C probe only 2 3584.7 0.27896 0.11105 0.16791 40.8% 1.69217 15.94284 0.106 NaN + C probe only 3 3833.4 0.26087 0.01089 0.24897 4.2% 0.25092 15.96972 0.016 NaN + +CLOSURE IDENTITY (D123 §2b): WAIT + HOLD_TOTAL + DURABLE vs T*S_eff, median per rung. + A census+probe L0 98.4% + A census+probe L1 99.4% + A census+probe L2 97.6% + A census+probe L3 99.3% + C probe only L0 98.3% + C probe only L1 100.4% + C probe only L2 99.4% + C probe only L3 97.2% + +CROSS-CONTROLS — a paired reading is licensed only if neither instrument moves +the other's number. Bar: within 25% at every rung. + L0 contended/op A(probe ON) 0.98613 vs B(probe OFF) 0.98650 ratio 1.000 + L1 contended/op A(probe ON) 0.95725 vs B(probe OFF) 0.95800 ratio 0.999 + L2 contended/op A(probe ON) 0.95725 vs B(probe OFF) 0.95837 ratio 0.999 + L3 contended/op A(probe ON) 0.90812 vs B(probe OFF) 0.89987 ratio 1.009 + L0 U(hold) A(census ON) 90.7% vs C(census OFF) 89.4% ratio 1.014 + L1 U(hold) A(census ON) 40.2% vs C(census OFF) 41.8% ratio 0.964 + L2 U(hold) A(census ON) 45.4% vs C(census OFF) 40.8% ratio 1.112 + L3 U(hold) A(census ON) 3.7% vs C(census OFF) 4.2% ratio 0.870 + +⭐ L0 -> L3 AT T=64, CONFIGURATION A (census+probe, THE DELIVERABLE): + HOLD_TOT 0.44130 -> 0.01063 ms (41.5x DOWN) [recorded 21x] + U(hold) 90.7% -> 3.7% (24.7x DOWN) [recorded 11.3x, 96.1->8.5] + gap 0.04536 -> 0.27126 ms (6.0x UP) [recorded 12.6x] + forks/sec 2054.8 -> 3559.2 (1.73x UP) [recorded 1.9x] UPPER BOUND + contended/op 0.98613 -> 0.90812 (0.92x) [never recorded: THIS is the new number] + WAIT 11.76194 -> 0.25570 ms (0.02x) + DURABLE 18.43231 -> 17.58789 ms (0.95x) + WAIT/DURABLE 0.638 -> 0.015 <- WHAT THE REMAINING TIME IS. + >1 at L3: the gap is threads BLOCKED ON THE LOCK. + <1 at L3: the gap is the DISK, and no collision count + could have told you that. + gap accounted (WAIT+DURABLE)/T vs gap: 0.27881 vs 0.27126 ms at L3 + +⭐ L0 -> L3 AT T=64, CONFIGURATION C (probe only, D123's own instrument): + HOLD_TOT 0.44367 -> 0.01089 ms (40.7x DOWN) [recorded 21x] + U(hold) 89.4% -> 4.2% (21.2x DOWN) [recorded 11.3x, 96.1->8.5] + gap 0.04843 -> 0.24897 ms (5.1x UP) [recorded 12.6x] + forks/sec 2014.8 -> 3833.4 (1.90x UP) [recorded 1.9x] UPPER BOUND + contended/op NaN -> NaN (NaNx) [never recorded: THIS is the new number] + WAIT 11.88387 -> 0.25092 ms (0.02x) + DURABLE 18.88903 -> 15.96972 ms (0.85x) + WAIT/DURABLE 0.629 -> 0.016 <- WHAT THE REMAINING TIME IS. + >1 at L3: the gap is threads BLOCKED ON THE LOCK. + <1 at L3: the gap is the DISK, and no collision count + could have told you that. + gap accounted (WAIT+DURABLE)/T vs gap: 0.25345 vs 0.24897 ms at L3 + +⭐ HOW TO READ IT — Amendment 2, pre-registered. No fourth outcome is permitted. + contended/op RISES as the section shortens ⇒ the gap is park/unpark handoff. + it does NOT rise ⇒ the remaining time is not threads + blocking on each other, and + pre-registered OUTCOME 1 IS DEAD. + the counts cannot discriminate ⇒ a result about the INSTRUMENT, which + Amendment 2 permits and requires to + be labelled as one. + +⛔ AND THE COMPARATOR IS MODE=model's OPERATING-POINT REFERENCE (b), NOT ZERO. A pure + mutex driven at each recorded rung's own arrival process already reads a high + contended/op at BOTH ends. 'Still contended at L3' is only evidence for outcome 1 + if it is HIGHER than what a mutex with no handoff floor reads at that same + operating point. +# end: 2026-09-22T07:27:22Z load_at_acquire=s: 33.59 30.00 26.12 diff --git a/bench/ceiling_raw/20_pgwire_ladder_BREAKS_above_L0.txt b/bench/ceiling_raw/20_pgwire_ladder_BREAKS_above_L0.txt new file mode 100644 index 00000000..63b01623 --- /dev/null +++ b/bench/ceiling_raw/20_pgwire_ladder_BREAKS_above_L0.txt @@ -0,0 +1,20 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=pgwire. THE SAME L0→L3 LADDER, AT THE LAYER PRODUCTION RUNS. + +⚠ LAYER: the shipped pgwire server over a real TCP socket, one client connection per + thread, forks issued as `BEGIN AGENT SESSION`. Locks held, outermost first: + `ServerContext::catalog()` -> `AgentRuntime` state -> `TableBranchCatalog::logical`. + E.6 measured that this is NOT the same experiment as MODE=direct. + + outer = ServerContext::catalog(), the per-statement mutex every connection waits on. + inner = TableBranchCatalog::logical, the mutex the 21x/1.9x pair is about. + Counted with process-wide atomics (the library owns these threads) — the `direct` + mode's cross-check is what licenses that. +# pgwire_start: 2026-09-22T07:28:11Z load_at_acquire=s: 26.24 28.71 25.87 + + threads stub forks outer/op outerCont/op inner/op innerCont/op stmts/sec + 4 0 20 4.2000 2.00000 3.0000 0.00000 35.3 +ceiling: REFUSING. ABANDON returned an error: branch error: branch b1@g0 not found diff --git a/bench/ceiling_raw/21_pgwire_L0_layerB.txt b/bench/ceiling_raw/21_pgwire_L0_layerB.txt new file mode 100644 index 00000000..b90cf382 --- /dev/null +++ b/bench/ceiling_raw/21_pgwire_L0_layerB.txt @@ -0,0 +1,27 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=pgwire. THE SAME L0→L3 LADDER, AT THE LAYER PRODUCTION RUNS. + +⚠ LAYER: the shipped pgwire server over a real TCP socket, one client connection per + thread, forks issued as `BEGIN AGENT SESSION`. Locks held, outermost first: + `ServerContext::catalog()` -> `AgentRuntime` state -> `TableBranchCatalog::logical`. + E.6 measured that this is NOT the same experiment as MODE=direct. + + outer = ServerContext::catalog(), the per-statement mutex every connection waits on. + inner = TableBranchCatalog::logical, the mutex the 21x/1.9x pair is about. + Counted with process-wide atomics (the library owns these threads) — the `direct` + mode's cross-check is what licenses that. +# pgwire_start: 2026-09-22T07:30:12Z load_at_acquire=s: 32.68 28.75 26.15 + + threads stub forks outer/op outerCont/op inner/op innerCont/op stmts/sec + 1 0 40 4.0250 0.00000 3.0000 0.00000 34.9 + + 8 0 320 4.0250 2.42500 3.0000 0.00000 33.5 + + 64 0 2560 4.0250 2.42148 3.0000 0.00000 39.9 + +⚠ stmts/sec is an UPPER BOUND on a loaded box and is NOT evidence here. Each fork is + two TCP round trips, so this arm's throughput is bounded by the socket, not the lock. +# end: 2026-09-22T07:31:27Z load_at_acquire=s: 33.90 30.68 27.15 diff --git a/bench/ceiling_raw/22_direct_crosscheck.txt b/bench/ceiling_raw/22_direct_crosscheck.txt new file mode 100644 index 00000000..4301705c --- /dev/null +++ b/bench/ceiling_raw/22_direct_crosscheck.txt @@ -0,0 +1,42 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=direct. THE L0→L3 KEY-COUNT LADDER, AS A CONTENTION COUNT. + +⚠ LAYER: `TableBranchCatalog` driven directly (open_sidecar + cat.fork), holding + `logical` and NOTHING ELSE. This is the layer `examples/fork_concurrency.rs` drives + and the layer the recorded 21x/1.9x/12.6x pair was measured at. It is NOT what any + shipped front-end does — see MODE=pgwire. (E.6; INVENTION-TRIGGER Amendment 1.) + +⛔ Stubbed levels are NOT a database. L1 writes no child record; L2 leaks retired + slots; L3 also drops the envelope (capability escape) and the parent's live-child + entry (GC hole). Every level KEEPS write_header + stage so >=1 page is dirty and + the fsync stays real — `syncs` is the check that this held. + + contended/op = acquisitions of `logical` whose try_lock() failed, per fork. + UPPER bound on blocking (holder may release in the window), so a + LOW value is the STRONG direction. See src/branch/lockcount.rs. + acq/op = acquisitions of `logical` per fork. Free, and it bounds the answer. + forks/sec = UPPER BOUND ONLY. Box not quiet; stamped below. Counts are the evidence. +# direct_start: 2026-09-22T07:31:48Z load_at_acquire=s: 33.89 30.78 27.26 + + threads stub forks ops contended/op acq/op cont.frac syncs f/sync + 64 0 8000 8000 0.98900 1.0000 98.9% 336 23.8 + 64 1 8000 8000 0.96650 1.0000 96.7% 256 31.2 + 64 2 8000 8000 0.96013 1.0000 96.0% 256 31.2 + 64 3 8000 8000 0.90938 1.0000 90.9% 250 32.0 + L0→L3 at T=64: contended/op 0.98900 → 0.90938 (0.92x) [forks/sec 2479 → 2325, 0.94x, UPPER BOUND] + + CROSS-CHECK — the same rung counted BOTH ways. Thread-local accumulation is used + above; the pgwire arm must use process-wide atomics because the library owns its + connection threads. If the two modes disagree, one of them is losing counts. + T=64 L0 thread-local 0.98650 global-atomic 0.98762 ratio 1.0011 + (acq/op 1.0000 vs 1.0000; ops 8000 vs 8000) + +⭐ THE DISCRIMINATOR (INVENTION-TRIGGER Amendment 2, pre-registered): + contention per op RISES as the section shortens ⇒ the gap is park/unpark handoff. + it does NOT rise ⇒ the remaining time is not threads + blocking on each other, and + pre-registered OUTCOME 1 IS DEAD. +# end: 2026-09-22T07:32:14Z load_at_acquire=s: 24.91 28.89 26.69 diff --git a/bench/ceiling_raw/RESUME.md b/bench/ceiling_raw/RESUME.md new file mode 100644 index 00000000..d00bd7de --- /dev/null +++ b/bench/ceiling_raw/RESUME.md @@ -0,0 +1,46 @@ +# CEILING attack — resume state + +Written before the long run, for a session that remembers nothing. + +## Where the work is + +* Measurement branch: `CEILING-resume`, worktree `/Users/idide/wt/ferrodb-CEILING-resume`. + Off `CEILING-park-or-structure` (c00b26e). **MUST NEVER LAND** — it carries D123's + `FERRODB_D123_STUB` capability escape in `src/branch/table_catalog.rs`. +* Quarantine of the paused session's uncommitted tree: branch + `CEILING-QUARANTINE-uncommitted` (stash-create commit `540d5a5`). The old worktree + `/Users/idide/wt/ferrodb-CEILING-park-or-structure` was **never modified**. +* Artifacts branch (no `src/`, no `examples/`): `CEILING-artifacts`, off current `main`. + Verify with `git diff --name-only main...CEILING-artifacts -- src examples` → empty. + +## Pre-registration + +`/Users/idide/wt/artie-research/frontier/INVENTION-TRIGGER.md`, section +"IS THE 1.9× CEILING THE STRUCTURE, OR THE MUTEX?" plus Amendments 1 and 2. +Three outcomes, no fourth. "The counts cannot discriminate" is permitted and must be +labelled a result about the instrument. + +## Done + +1. `bench/ceiling_raw/00_…FAILED.txt` — the scaffold's own positive control refused + (0.04688 against a ≥0.5 gate). Premise wrong, not instrument. +2. `01_model_inherited_PASSED.txt` — the paused session's version, re-run, passing. +3. `02_barrier_queuemax_63.txt` — my closed-form derivation of `queue_max` was wrong. +4. `03_model_closed_form.txt` — all three closed forms exact: 0.98438 / 32.50 / 64. +5. `10_paired_T64_N8000_warm0_reps5.txt` — the deliverable. + +## Next action if this is a cold start + +Read `10_…txt`'s CROSS-CONTROLS block first. If all eight ratios are inside 25% the +L0→L3 line is licensed; otherwise the answer is "instrument", not "hypothesis". +Then run the T=1 negative control and the pgwire layer, and write +`bench/ceiling_park_or_structure.txt`. + +## Standing constraints + +* Counts are the evidence. Durations are upper bounds, stamped with `load_at_acquire`. +* Every number names its LAYER: `TableBranchCatalog` (comparable to the recorded + triple) vs pgwire (what production does). They are not the same experiment. +* `contended/op` UNDER-reports blocking under barging — measured, see the BARGE row in + `03_model_closed_form.txt`. The comparator for "still contended" is the + operating-point reference (b), not zero. From 73c0d4b775f91589ac7a3bda6a96df4dd3dc7a92 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:41:34 -0400 Subject: [PATCH 037/315] CEILING: rebuild the operating-point reference from this session's own numbers Section 5b previously answered 'but 0.908 is a high collision count' with a model parameterised by d123 section 3b -- another process, another night. That is the exact error the re-run exists to avoid, committed inside the file that criticises it. Rebuilt with MODE=opref at this session's measured L3 operating point: a bare Mutex with no handoff floor reads 0.525-0.738 over ten draws against the real ladder's 0.908, so a high count at L3 is the normal reading for a lightly-loaded mutex under bunched arrivals. Then deflated: calibrate_spin drifted 2x across those draws, so the reference establishes the ORDER only and no ratio is claimed from it. The conclusion rests on WAIT being 1.4% of the L3 cycle and on f/sync 32.0 matching arrival queue depth 24.1. --- bench/ceiling_park_or_structure.txt | 45 ++++++++++++++++------- bench/ceiling_raw/30_opref_myL0.txt | 19 ++++++++++ bench/ceiling_raw/31_opref_myL3.txt | 19 ++++++++++ bench/ceiling_raw/32_opref_myL3_draws.txt | 15 ++++++++ 4 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 bench/ceiling_raw/30_opref_myL0.txt create mode 100644 bench/ceiling_raw/31_opref_myL3.txt create mode 100644 bench/ceiling_raw/32_opref_myL3_draws.txt diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index 971fab10..6a7221cd 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -8,9 +8,13 @@ Scaffold branch: CEILING-resume, off CEILING-park-or-structure (c00b26e). ⛔⛔ NEITHER MAY EVER LAND. See "WHAT MUST NOT LAND" at the end. Artifacts: this file, bench/ceiling_INVENTIONS_patch.md, and bench/ceiling_raw/*. Harness: examples/ceiling_lock_contention.rs, built with --features lock_census. -Box: NOT quiet. load_at_acquire 24-45 throughout, two swift-frontend compiles and - mediaanalysisd resident; the fleet measure-lock was held for the whole run - (acquired "CEILING-resume", load_at_acquire=8 at acquisition, rising after). +Box: NOT quiet. The fleet measure-lock was held for the whole session (owner + "CEILING-resume", acquired 2026-09-22T07:05:12Z at load_at_acquire=8), so no + other MEASURER ran — but two swift-frontend compiles and mediaanalysisd were + resident throughout and the lock does not exclude those. Every raw file carries + its own start and end stamp; across them the 1-minute load ranges from 7.98 + (the very first model run, 07:05) to 48.87 (07:15), and sits at 25-38 for every + ladder run in sections 3-6. EVERY DURATION BELOW IS AN UPPER BOUND. The counts are not. ================================================================================================ @@ -101,7 +105,7 @@ rendezvous; the quantity under test was untouched. NEGATIVE CONTROL ON THE REAL LADDER, not just on a model mutex (bench/ceiling_raw/11, T=1): contended/op 0.00000 at ALL FOUR rungs - WAIT 0.0003 ms (nothing to block on) + WAIT 0.00032-0.00040 ms (nothing to block on) closure 100.0% at all eight probe-carrying rungs CROSS-CHECK licensing the process-wide atomic mode the pgwire arm must use @@ -208,19 +212,32 @@ both rungs. WAIT differs by 46x between them. 5b. ⭐ THE OBJECTION THIS MUST SURVIVE: "BUT contended/op IS 0.908 AT L3, WHICH IS HIGH" ================================================================================================ -It is high, and it is HIGHER than a pure mutex reads at the same operating point. From the model -(bench/ceiling_raw/03), a bare `Mutex` driven at each recorded rung's own arrival process — hold -and cycle set to the recorded wall-clock values, the outside interval a SLEEP so the thread -leaves the runnable set as it does in an fsync: +It is high. The question is whether "high" is evidence of anything, and the answer is that a pure +mutex WITH NO HANDOFF FLOOR AT ALL also reads high at the same operating point. - L0-like hold 0.201 ms / cycle 13.365 ms contended/op 0.78336 queue_mean 5.35 U_ach 74.2% - L3-like hold 0.0094 ms / cycle 7.096 ms contended/op 0.41383 queue_mean 1.89 U_ach 6.5% +`MODE=opref` runs a bare `Mutex` — no tree, no fsync, no second lock — at an operating point you +supply: hold and cycle, with the outside interval a SLEEP so the thread leaves the runnable set +as it does inside an fsync. ⭐ **Fed THIS session's own measured L3 numbers** (HOLD_TOTAL +0.01063 ms, cycle T*S_eff 17.981 ms, T=64), not the recorded ones — the reference in +bench/ceiling_raw/03 is parameterised by d123 §3b and would be the same two-instruments-two- +moments error this whole re-run exists to avoid. -Real ladder L3: 0.90812 against a reference of 0.41383. So threads DO still collide on `logical` -at L3, more than a memoryless arrival process would produce. Taken alone that reads as support -for outcome 1, and it is exactly why the count alone cannot settle this. + Ten draws (bench/ceiling_raw/30, /31, /32): contended/op 0.525 - 0.738, median ~0.68 + Real ladder L3, median of 5 reps: contended/op 0.908 -⇒ **IT IS RECONCILED BY WHAT THE COLLISIONS COST, AND BY WHAT CAUSES THEM.** +⇒ **A mutex with no floor under it reads 0.53-0.74 where the real ladder reads 0.91.** So a high +collision count at L3 is the NORMAL reading for a lightly-loaded mutex under bunched arrivals, +not a signature of park/unpark. The real ladder is somewhat above the reference, which is what +bunching predicts — but see the caveat. + +⚠ ⛔ **AND THE REFERENCE IS TOO NOISY TO QUANTIFY THAT EXCESS, SO NOTHING HERE RESTS ON IT.** +`calibrate_spin()` measures spins/sec on a box at load 25-38 and drifted 1.77e9 - 3.61e9 across +the ten draws — a 2x spread — so the modelled hold is not reliably the hold that was asked for, +and the printed `U_ach` is computed from the ASKED hold and therefore cannot audit it. The +reference establishes the ORDER ("a floor-free mutex also reads ~0.7 here"), which is all it is +used for. It does not establish "1.3x above reference" and that ratio is not claimed. + +⇒ **THE CONCLUSION RESTS ON THE NEXT TWO ITEMS, NEITHER OF WHICH NEEDS THE REFERENCE.** (i) THE COST. At L3 the whole of WAIT is 0.25570 ms of a 17.98 ms cycle — **1.4%**. Deleting `logical` outright, making every acquisition free, could buy at most that 1.4%. A 90% diff --git a/bench/ceiling_raw/30_opref_myL0.txt b/bench/ceiling_raw/30_opref_myL0.txt new file mode 100644 index 00000000..299eb490 --- /dev/null +++ b/bench/ceiling_raw/30_opref_myL0.txt @@ -0,0 +1,19 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=opref. A PURE MUTEX AT AN OPERATING POINT YOU SUPPLY. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. +⚠ Feed it THIS session's measured hold and cycle, not a recorded one. +# opref_start: 2026-09-22T07:39:15Z load_at_acquire=s: 23.22 23.56 24.54 + spin calibration: 1.774e9 spins/sec single-threaded + asked for: T=64 hold=0.4413 ms cycle=31.146 ms (offered rho = 0.90680) + + cell T hold outside ops contended/op acq/op queue_max U_ach + 0.44130ms 64 782874 sleep 30.705ms 12800 0.95023 1.0000 63 66.6% + queue_mean 9.85 + +⚠ U_ach is the utilisation this model ACHIEVED, not the one asked for. If it is far + below the real rung's U(hold), the arrival processes are not comparable and the + contended/op below is not the right reference. Say so rather than using it. +# end: 2026-09-22T07:39:24Z load_at_acquire=s: 24.41 23.78 24.60 diff --git a/bench/ceiling_raw/31_opref_myL3.txt b/bench/ceiling_raw/31_opref_myL3.txt new file mode 100644 index 00000000..4a6ae38e --- /dev/null +++ b/bench/ceiling_raw/31_opref_myL3.txt @@ -0,0 +1,19 @@ +⛔ CEILING MEASUREMENT SCAFFOLD — branch CEILING-park-or-structure, MUST NEVER MERGE. + PROVENANCE at process start, read back from the probe: stub_level=0 extra_upserts=0 extra_new_keys=false probe_clock=false + Every rung re-asserts this. A non-zero stub or extra count REFUSES, it does not warn. + +CEILING — MODE=opref. A PURE MUTEX AT AN OPERATING POINT YOU SUPPLY. +LAYER: none. A bare `Mutex<()>`: no fsync, no tree, no second lock. +⚠ Feed it THIS session's measured hold and cycle, not a recorded one. +# opref_start: 2026-09-22T07:39:24Z load_at_acquire=s: 24.41 23.78 24.60 + spin calibration: 1.487e9 spins/sec single-threaded + asked for: T=64 hold=0.01063 ms cycle=17.981 ms (offered rho = 0.03784) + + cell T hold outside ops contended/op acq/op queue_max U_ach + 0.01063ms 64 15804 sleep 17.970ms 12800 0.55070 1.0000 39 3.1% + queue_mean 2.46 + +⚠ U_ach is the utilisation this model ACHIEVED, not the one asked for. If it is far + below the real rung's U(hold), the arrival processes are not comparable and the + contended/op below is not the right reference. Say so rather than using it. +# end: 2026-09-22T07:39:29Z load_at_acquire=s: 24.41 23.78 24.60 diff --git a/bench/ceiling_raw/32_opref_myL3_draws.txt b/bench/ceiling_raw/32_opref_myL3_draws.txt new file mode 100644 index 00000000..955db5da --- /dev/null +++ b/bench/ceiling_raw/32_opref_myL3_draws.txt @@ -0,0 +1,15 @@ + spin calibration: 1.847e9 spins/sec single-threaded + 0.01063ms 64 19635 sleep 17.970ms 12800 0.63570 1.0000 40 3.1% + queue_mean 3.09 + spin calibration: 3.609e9 spins/sec single-threaded + 0.01063ms 64 38360 sleep 17.970ms 12800 0.73813 1.0000 51 3.0% + queue_mean 4.48 + spin calibration: 1.913e9 spins/sec single-threaded + 0.01063ms 64 20337 sleep 17.970ms 12800 0.68602 1.0000 39 3.1% + queue_mean 3.98 + spin calibration: 2.162e9 spins/sec single-threaded + 0.01063ms 64 22977 sleep 17.970ms 12800 0.70844 1.0000 43 3.0% + queue_mean 4.17 + spin calibration: 1.769e9 spins/sec single-threaded + 0.01063ms 64 18808 sleep 17.970ms 12800 0.68172 1.0000 42 3.0% + queue_mean 4.03 From 52306b4ea0805cf2ef5c4bd9c440c0273e05e712 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:42:25 -0400 Subject: [PATCH 038/315] CEILING: state lock-attributable time, the number a candidate should bid against WAIT + HOLD_TOTAL as a share of the thread cycle: 39.2% at L0, 1.5% at L3. Measured share, not a forecast -- durable() is group-committed, so what a mechanism would actually recover is a counterfactual and is not claimed. --- bench/ceiling_park_or_structure.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index 6a7221cd..cbe45784 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -47,6 +47,11 @@ escape is not used for the verdict. It IS used for two subsidiary findings below **The floor is the durability barrier, not the handoff.** The section and the DISK trade off. + ⭐ THE NUMBER TO STATE A FUTURE CANDIDATE AGAINST, replacing the 21x/1.9x pair for that job: + **lock-attributable time (WAIT + HOLD_TOTAL) is 39.2% of the thread cycle at L0 and 1.5% at + L3.** That is the whole budget any "take work out of the critical section" mechanism is + bidding for, measured rather than inferred. See section 5. + ================================================================================================ 1. THE INSTRUMENT FAILED ITS OWN POSITIVE CONTROL FIRST, AND THE PREMISE WAS WHAT WAS WRONG ================================================================================================ @@ -196,6 +201,22 @@ never been decomposed, and L3 is the rung the whole argument turns on. L0 -> L3: WAIT falls 46x. DURABLE is flat (0.95x). HOLD falls 41.5x. +⭐ AND THE ONE NUMBER A CANDIDATE SHOULD BE STATED AGAINST — LOCK-ATTRIBUTABLE TIME, which is +everything `logical` can possibly be blamed for, WAIT + HOLD_TOTAL, as a share of the cycle: + + L0 (11.76194 + 0.44130) / 31.146 = 39.2% of the thread cycle + L3 ( 0.25570 + 0.01063) / 17.981 = 1.5% of the thread cycle + +⇒ **At L3 the entire mutex — waiting for it AND holding it — is 1.5% of the time.** A mechanism +that deleted `logical` outright, made every acquisition free and every section instantaneous, +could not buy more than that. At L0 the same quantity is 39.2%, which is why shortening the +section works at first and stops working almost immediately. + +⚠ This is a DIRECT MEASUREMENT of a share, not a forecast. Turning it into "removing the lock +buys 1.5%" adds a counterfactual: `durable()` is group-committed, so changing the arrival pattern +into it would change its own duration. The 1.5% is the honest upper bound on the LOCK'S SHARE; +what a mechanism would actually recover is not measured here and is not claimed. + ⇒ **Time spent BLOCKED ON `logical` falls FASTER than the section does.** Threads are not piling up on the mutex at L3; they have essentially stopped touching it. The gap that grows is `durable()`, and at L3 it is 97.8% of everything. From 57948028f96a6891fc230398bb7c2cfae7be40f7 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:43:01 -0400 Subject: [PATCH 039/315] CEILING: name the second control against 'PH_WAIT is absorbing starvation' T=1 under the same load reads WAIT 0.00032-0.00040 ms at every rung, four orders of magnitude below T=64 L0. Both controls stated with the limit they do not reach. --- bench/ceiling_park_or_structure.txt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index cbe45784..899c389d 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -225,9 +225,15 @@ up on the mutex at L3; they have essentially stopped touching it. The gap that g spot: a parked thread's wall clock runs whether or not its `try_lock` ever failed. Both witnesses agree, and the one that cannot be fooled by barging is the emphatic one. -⇒ AN INTERNAL CONTROL AGAINST "IT IS JUST THE LOADED BOX": L0 and L3 ran interleaved, in the same -process, under the same load, with rung order rotated. Scheduler starvation would inflate WAIT at -both rungs. WAIT differs by 46x between them. +⇒ TWO INTERNAL CONTROLS AGAINST "PH_WAIT IS JUST ABSORBING SCHEDULER STARVATION ON A LOADED BOX": + (a) L0 and L3 ran interleaved, in the same process, under the same load, with rung order + rotated per rep. Starvation would inflate WAIT at both rungs. WAIT differs by 46x. + (b) At T=1 (bench/ceiling_raw/11), under the same load, WAIT is 0.00032-0.00040 ms at every + rung — four orders of magnitude below the T=64 L0 figure. PH_WAIT is not a general sink for + descheduled time; it brackets the acquire and nothing else. + ⚠ Neither control says a quiet box would report the same absolute WAIT. It would very likely + report less. Both say the L0-vs-L3 CONTRAST is not manufactured by load, and the contrast is + what the verdict uses. ================================================================================================ 5b. ⭐ THE OBJECTION THIS MUST SURVIVE: "BUT contended/op IS 0.908 AT L3, WHICH IS HIGH" From 7c2861694ab87a604de3d594e6249f57b741353d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 03:43:55 -0400 Subject: [PATCH 040/315] CEILING: say WHEN the box was busy, not just that it was Every measurement the verdict rests on finished before 07:33, which is when the other fleet sessions' suites started. Only the 5b reference draws overlap them, which independently explains their calibration drift. --- bench/ceiling_park_or_structure.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index 899c389d..616dca0f 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -15,6 +15,16 @@ Box: NOT quiet. The fleet measure-lock was held for the whole sessi its own start and end stamp; across them the 1-minute load ranges from 7.98 (the very first model run, 07:05) to 48.87 (07:15), and sits at 25-38 for every ladder run in sections 3-6. + ⭐ WHAT ELSE THE FLEET WAS DOING, BY WALL CLOCK, because "the box was busy" is + not a caveat unless it says WHEN. Three other sessions started long runs at + 07:33:07 (verify-suite d126), 07:33:18 (a full `cargo test`), 07:38:05 + (verify-suite d127) and 07:41:44 (a w4_standdown count harness). + ⇒ **Every measurement the verdict rests on finished BEFORE 07:33**: the paired + ladders ran 07:18:12-07:27:22, pgwire 07:30:12-07:31:27, the cross-check + 07:31:48-07:32:14. + ⇒ The ONLY runs that overlap those suites are the operating-point reference + draws in section 5b (07:38-07:41), which is consistent with the 2x calibration + drift seen there and is one more reason nothing rests on them. EVERY DURATION BELOW IS AN UPPER BOUND. The counts are not. ================================================================================================ From 87c7521c8f63d105876514b76bb6d41da2c79c84 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:07:57 -0400 Subject: [PATCH 041/315] CEILING: bank ceiling-attack's three staged self-corrections before it died on the weekly limit Staged, uncommitted, by an agent that died mid-task, so per the standing rule this is BANKED AND LABELLED rather than inherited. All three verified here before committing: - 'section 5 is that' -> 'section 6': pgwire is section 6 (line 309), 5 is the phase decomposition. The cross-reference was wrong. - 'load 24-45' -> 'load 25-38', and 'recorded (quiet box)' -> 'recorded (d123 3b)': the report's own header says the ladder runs sit at 25-38. - 'a different box' -> 'the SAME box': d123's recorded triple was measured on this machine, so the reproduction is same-box-different-load, which is weaker. None of the three touches a number the verdict rests on. --- bench/ceiling_park_or_structure.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index 616dca0f..8f959871 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -134,7 +134,7 @@ CROSS-CHECK licensing the process-wide atomic mode the pgwire arm must use `TableBranchCatalog` layer, beneath the outer statement mutex that serialises forks in production.** `open_sidecar` + `cat.fork` directly, holding `logical` and nothing else. This is what `examples/fork_concurrency.rs` drives and what D123 measured. It is NOT what any shipped -front-end does — section 5 is that. +front-end does — section 6 is that. Raw: bench/ceiling_raw/12_paired_T64_N8000_warm0_reps5_WAITDUR.txt. T=64, N=8000, warm=0 (the recorded table's own shape), 5 reps, medians, configuration order AND @@ -175,7 +175,7 @@ probe-carrying rungs. The probe accounts for the whole loop, so WAIT is a real s The brief's warning case was "if your utilisation does NOT collapse, that is a finding about the recorded number". It does collapse. But the three headline multipliers do not all survive. - quantity recorded (quiet box) measured here (load 24-45) reproduced? + quantity recorded (d123 §3b) measured here (load 25-38) reproduced? throughput L0->L3 1.9x 1.90x (config C) ✅ EXACTLY 1.73x (config A) U(hold) collapse 96.1% -> 8.5% (11.3x) 90.7% -> 3.7% (24.7x) ✅ IN KIND, @@ -183,7 +183,7 @@ recorded number". It does collapse. But the three headline multipliers do not al HOLD_TOT fall 21x 41.5x (A) / 40.7x (C) ❌ 2x LARGER gap rise 12.6x 6.0x (A) / 5.1x (C) ❌ 2x SMALLER -⇒ **The QUALITATIVE claim is robust and reproduces on a different box at 3-5x the load: HOLD +⇒ **The QUALITATIVE claim is robust and reproduces on the SAME box at 3-5x the load: HOLD collapses by more than an order of magnitude, utilisation collapses with it, and throughput moves about 1.9x. The specific multipliers 21x and 12.6x DO NOT REPRODUCE, and they move in OPPOSITE directions.** That is what a duration ratio does on a box with a measured 46x quiet-vs-loaded From b5f97c2128b68aa492d3877186e9d66872c1324b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 05:08:36 -0400 Subject: [PATCH 042/315] CEILING: retract the OUTCOME 2 headline where the artifact lives; mark the patch DO-NOT-APPLY Arm B was never built. The pre-registration defines outcome 2 as 'gap unchanged UNDER B', where 'Arm B = the same ladder with the acquire path spinning before it parks'. All 1,525 lines of examples/ceiling_lock_contention.rs and every file in bench/ceiling_raw/ use std::sync::Mutex unchanged; the only spin_loop() is the calibrated outside-the-section work and the barrier gauge. 21_pgwire_L0_layerB.txt is LAYER B, not ARM B. The report reached its headline by quoting the outcome with 'under B' deleted. And the label is inverted, not merely unearned: outcome 2's own second sentence says the handoff floor 'may then be cited as a floor', while the report withdraws it. Outcome 3 is never mentioned and no control slope is reported, so 'the harness is the wall' is not excluded. What survives: outcome 1 IS dead, on Amendment 2's licence and the count alone (contended/op 0.98613 -> 0.90812). The phase decomposition is a separate, un-pre-registered measurement and must be quoted as one. ceiling_INVENTIONS_patch.md carries four numbers that disagree with the raw files: 64x (nowhere), 21.8 -> 17.1 ms (nowhere), and 87.6%->5.5% / 23.3x (from run 10, superseded by run 12). Verified not applied to INVENTIONS.md. --- bench/ceiling_INVENTIONS_patch.md | 29 ++++++++++++++++++ bench/ceiling_park_or_structure.txt | 46 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/bench/ceiling_INVENTIONS_patch.md b/bench/ceiling_INVENTIONS_patch.md index 445b1683..d295e465 100644 --- a/bench/ceiling_INVENTIONS_patch.md +++ b/bench/ceiling_INVENTIONS_patch.md @@ -1,3 +1,32 @@ +# ⛔⛔⛔ DO NOT APPLY THIS PATCH. FOUR WRONG NUMBERS. Marked 2026-09-22. + +Verified **not** applied to `artie-research/frontier/INVENTIONS.md` (grep, rc=1) and it must stay +that way. Every row below was checked against `bench/ceiling_raw/` by the dispatching session: + +| this patch says | the raw files say | where the patch's number came from | +|---|---|---| +| `PH_WAIT` falls **64×** | **46×** — `11.76194 → 0.25570`, printed `(0.02x)` | **nothing.** No file in `ceiling_raw/` contains `64x` or `64×`. | +| DURABLE `21.8 → 17.1 ms` | `18.43231 → 17.58789 ms` | **nothing.** No file contains `21.8`. | +| U(hold) `87.6% → 5.5%` (**15.9×**) | `90.7% → 3.7%` (**24.7×**) | run **10** (`10_paired_T64_N8000_warm0_reps5.txt`), superseded by run **12** | +| `HOLD` falls **23.3×** | **41.5×** | run **10**, superseded by run **12** | + +Run **12** (`12_paired_T64_N8000_warm0_reps5_WAITDUR.txt`) is the one +`ceiling_park_or_structure.txt` itself labels **THE DELIVERABLE**, and this patch was cut before +it. That is `certification head= is a preimage` in its other direction: **an artifact written +against an earlier run than the one the verdict rests on.** + +⛔ **And its headline claim — "Pre-registered outcome 2 fired" — is false.** Arm B (a spin-then-park +acquire path) was never built; every arm is `std::sync::Mutex` unchanged. See the correction header +at the top of `ceiling_park_or_structure.txt`. + +⇒ **If the `INVENTIONS.md` row is to be amended at all**, amend it from the corrected banner in +`artie-research/frontier/INVENTION-TRIGGER.md`, not from this file. Kept only as evidence of how +the wrong numbers got written. + +--- +--- ORIGINAL PATCH TEXT BELOW, UNALTERED, DO NOT APPLY --- +--- + # APPEND-ONLY PATCH FOR `frontier/INVENTIONS.md` ⛔ **Not applied by me.** `frontier/INVENTIONS.md` lives in the `artie-research` repo, outside this diff --git a/bench/ceiling_park_or_structure.txt b/bench/ceiling_park_or_structure.txt index 8f959871..00778c79 100644 --- a/bench/ceiling_park_or_structure.txt +++ b/bench/ceiling_park_or_structure.txt @@ -1,3 +1,49 @@ +⛔⛔⛔ CORRECTION PREPENDED 2026-09-22 BY THE DISPATCHING SESSION — READ THIS BEFORE THE REPORT. +================================================================================================ +This report's headline verdict, "⭐ OUTCOME 2" (line ~34 below), IS WRONG, and I banked it into +`frontier/INVENTION-TRIGGER.md` before checking it. A fresh-context auditor caught it; I then +confirmed every point against `bench/ceiling_raw/` myself. Three findings: + +1. ⛔ OUTCOME 2 DID NOT FIRE — ARM B WAS NEVER BUILT. The pre-registration defines outcome 2 as + "Gap unchanged **under B**", where "Arm B = the same ladder with the acquire path spinning + before it parks. Nothing else may differ." All 1,525 lines of + `examples/ceiling_lock_contention.rs` and every file in `ceiling_raw/` use `std::sync::Mutex` + UNCHANGED. The only `spin_loop()` is the calibrated OUTSIDE-the-section work and the barrier + queue gauge — never an acquire path. `21_pgwire_L0_layerB.txt` is LAYER B (pgwire vs direct, + Amendment 1), not ARM B. The report reaches its headline by quoting outcome 2 with the words + "under B" DELETED. + +2. ⛔ AND THE LABEL IS INVERTED, not just unearned. Outcome 2's second sentence grants the + OPPOSITE licence to the one taken here: "The floor survives its first real attack and may then + be cited as a floor, which today it may not." This report WITHDRAWS the handoff floor. A fired + outcome 2 would have RE-INSTATED it. + +3. ⛔ OUTCOME 3 IS NEVER MENTIONED, though the pre-registration flags it "the one to watch for, + because it is the one that invalidates the instrument rather than the hypothesis" and requires + "the CONTROL's slope reported". `grep -nE 'OUTCOME 3|control.*slope'` over this file returns + NOTHING. "The harness is the wall" is therefore NOT excluded. + +✅ WHAT THIS REPORT DOES LICENSE, and it is real and worth having: + PRE-REGISTERED OUTCOME 1 IS DEAD. contended/op FELL 0.98613 -> 0.90812 (0.92x), T=64, L0->L3, + median of 5 reps, `12_paired_T64_N8000_warm0_reps5_WAITDUR.txt` config A. Amendment 2 + pre-registered exactly that discriminator and exactly that licence. Section 5's line 33 below + ("Pre-registered OUTCOME 1 IS DEAD") is supported. Line 24's "⭐ OUTCOME 2" is not. + +⇒ SECTION 5's PHASE DECOMPOSITION (WAIT 46x DOWN, DURABLE FLAT, 97.8% of the cycle at L3) IS A + SEPARATE, UN-PRE-REGISTERED MEASUREMENT, not a fired outcome. It is supported by the + WAIT/DURABLE ratio taken inside one instrument at one moment plus the 98-99% closure identity, + and it is NOT supported by a quiet box or a control slope. Quote it with that caveat in the + same sentence, every time. + +⇒ ⛔⛔ `bench/ceiling_INVENTIONS_patch.md` MUST NOT BE APPLIED. Four wrong numbers; see the + DO-NOT-APPLY header now at the top of that file. It was cut BEFORE the run this report itself + calls THE DELIVERABLE. + +The full accounting, with the recomputations, is the banner at the top of +`artie-research/frontier/INVENTION-TRIGGER.md` and Amendment 3 to its pre-registration. +================================================================================================ + + IS THE 1.9x CEILING THE STRUCTURE, OR THE MUTEX? ================================================================================================ From c2e1133d901e653bdbc9c762d327949450e2cf3c Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:01:13 -0400 Subject: [PATCH 043/315] D133: measure the pending-free queue's length and how often it is walked The D133 entry said in its own words that `pending`'s length was UNMEASURED and did not assert it was large. This is that measurement, raw artifact first. - reaper: `drain_passes`/`drain_entry_visits`, beside the existing `sweep_descents`/`sweep_visits`. A length alone cannot separate a short queue walked often from a long queue walked once. - examples/d133_pending_len.rs: fan/chain shapes plus a `leaf` forced negative that must read zero on every counter, and refusals for a row that parked nothing. - bench/d133_run.sh + bench/d133_pending_len.txt: the sweep and its raw output, header quoting persist_if_configured and cli.rs:120 from source. --- bench/d133_pending_len.txt | 118 ++++++++++++++ bench/d133_run.sh | 92 +++++++++++ examples/d133_pending_len.rs | 304 +++++++++++++++++++++++++++++++++++ src/branch/reaper.rs | 33 ++++ 4 files changed, 547 insertions(+) create mode 100644 bench/d133_pending_len.txt create mode 100755 bench/d133_run.sh create mode 100644 examples/d133_pending_len.rs diff --git a/bench/d133_pending_len.txt b/bench/d133_pending_len.txt new file mode 100644 index 00000000..ef1c2c30 --- /dev/null +++ b/bench/d133_pending_len.txt @@ -0,0 +1,118 @@ +# D133 — pending-free queue length under a branching workload. RAW ARTIFACT. +# +# run at 2026-09-22T11:56:05Z UTC +# worktree /Users/idide/wt/ferrodb-d133-pending-len +# branch d133-pending-len +# head 98a44efbc2963f4ac10ecc1836fbfdfa9462affa +# tree state 4 modified/untracked path(s) +# M src/branch/reaper.rs +# ?? bench/d133_pending_len.txt +# ?? bench/d133_run.sh +# ?? examples/d133_pending_len.rs +# binary mtime 2026-09-22T11:54:32Z +# host Darwin 25.6.0 arm64 +# measure lock 61248 d133-pending-len 2026-09-22T11:52:09Z +# load before 6.82 9.42 6.44 +# +# THE CONFIGURATION GATE, quoted from the code this harness invokes: +# arena.rs persist_if_configured — +# fn persist_if_configured(&self) -> Result<(), FerroError> { +# let path = self.checkpoint_path.lock().unwrap().clone(); +# match path { +# Some(p) => self.checkpoint(&p), +# None => Ok(()), +# } +# } +# cli/cli.rs, the shipped server — this is why persist=on is the production row: +# 120: store.checkpoint_to(std::path::PathBuf::from(&arena_path)); +# lease_thread.rs, the lock the reap loop runs inside: +# 461:const REAP_CHUNK: usize = 1; +# +============================================================================== +ARM 1 — the branch-count curve, in-memory catalog, both persist settings. +============================================================================== +D133 — pending-free queue length and how often it is walked. + + catalog=mem pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan off 100 400 400 100 20200 3.8 0.0380 + fan off 300 1200 1200 300 180600 19.6 0.0655 + fan off 1000 4000 4000 1000 2002000 102.7 0.1027 + fan off 3000 12000 12000 3000 18006000 676.2 0.2254 + chain off 99 396 396 99 19800 3.4 0.0344 + chain off 299 1196 1196 299 179400 15.2 0.0508 + chain off 999 3996 3996 999 1998000 94.5 0.0946 + chain off 2999 11996 11996 2999 17994000 679.1 0.2264 + leaf off 100 0 0 0 0 0.4 0.0041 + leaf off 300 0 0 0 0 1.0 0.0034 + leaf off 1000 0 0 0 0 1.8 0.0018 + leaf off 3000 0 0 0 0 4.4 0.0015 + + 12 rows, every fan/chain row parked pages and every leaf row parked none. +(arm 1 persist=off rc=0) + +D133 — pending-free queue length and how often it is walked. + + catalog=mem pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan on 100 400 400 100 20200 1634.9 16.3494 + fan on 300 1200 1200 300 180600 4715.1 15.7171 + fan on 1000 4000 4000 1000 2002000 17057.6 17.0576 + chain on 99 396 396 99 19800 1575.4 15.9135 + chain on 299 1196 1196 299 179400 4691.8 15.6918 + chain on 999 3996 3996 999 1998000 16739.5 16.7562 + leaf on 100 0 0 0 0 2303.3 23.0327 + leaf on 300 0 0 0 0 7008.0 23.3598 + leaf on 1000 0 0 0 0 24091.8 24.0918 + + 9 rows, every fan/chain row parked pages and every leaf row parked none. +(arm 1 persist=on rc=0 — N capped at 1000: every pass fsyncs the whole map) + +============================================================================== +ARM 2 — the SHIPPED catalog, at counts it can reach. D99's rule: two catalogs +must agree on the integers at a count both can reach, or one of them is the +thing being measured. +============================================================================== +D133 — pending-free queue length and how often it is walked. + + catalog=table pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan off 100 400 400 100 20200 1039.1 10.3915 + fan off 300 1200 1200 300 180600 5462.0 18.2068 + chain off 99 396 396 99 19800 4933.1 49.8289 + chain off 299 1196 1196 299 179400 119277.4 398.9210 + leaf off 100 0 0 0 0 2787.9 27.8792 + leaf off 300 0 0 0 0 8342.6 27.8085 + + 6 rows, every fan/chain row parked pages and every leaf row parked none. +(arm 2 rc=0) + +============================================================================== +ARM 3 — the PAGES axis, held against ARM 1's N=300 row. pending is pages +parked, not branches reaped, so P must scale it linearly or the model is wrong. +============================================================================== + P=1 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=1 fan off 300 300 300 300 45150 7.8 0.0261 + P=4 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=4 fan off 300 1200 1200 300 180600 20.2 0.0674 + P=16 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=16 fan off 300 4800 4800 300 722400 67.9 0.2265 + +# load after 5.73 7.16 6.13 +# arm rcs 0 0 0 +# every arm returned 0, i.e. every fan/chain row parked pages and every leaf row did not. diff --git a/bench/d133_run.sh b/bench/d133_run.sh new file mode 100755 index 00000000..2c4259f4 --- /dev/null +++ b/bench/d133_run.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# D133 — run the pending-free queue sweep and write the raw artifact. +# +# Refuses rather than producing a contaminated number: it will not run without the fleet measure +# lock held, and will not run while the suite lock is taken. +# +# Usage: bench/d133_run.sh > bench/d133_pending_len.txt 2>&1 +set -u + +WT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$WT/target/release/examples/d133_pending_len" + +# ---- refusals ------------------------------------------------------------------------------ +if [ ! -d /tmp/ferrodb-measure.lock ]; then + echo "REFUSED: /tmp/ferrodb-measure.lock is not held. A contended box returns 'no effect'." >&2 + exit 2 +fi +if [ -d /tmp/ferrodb-suite.lock ]; then + echo "REFUSED: /tmp/ferrodb-suite.lock is taken by: $(cat /tmp/ferrodb-suite.lock/owner 2>&1)" >&2 + exit 2 +fi +if [ ! -x "$BIN" ]; then + echo "REFUSED: $BIN does not exist. Nothing would be measured." >&2 + exit 2 +fi + +# ---- provenance, read from the tree and the binary, not from intent ------------------------ +echo "# D133 — pending-free queue length under a branching workload. RAW ARTIFACT." +echo "#" +echo "# run at $(date -u +%Y-%m-%dT%H:%M:%SZ) UTC" +echo "# worktree $WT" +echo "# branch $(git -C "$WT" rev-parse --abbrev-ref HEAD)" +echo "# head $(git -C "$WT" rev-parse HEAD)" +echo "# tree state $(git -C "$WT" status --porcelain | wc -l | tr -d ' ') modified/untracked path(s)" +git -C "$WT" status --porcelain | sed 's/^/# /' +echo "# binary mtime $(date -u -r "$BIN" +%Y-%m-%dT%H:%M:%SZ)" +echo "# host $(uname -srm)" +echo "# measure lock $(cat /tmp/ferrodb-measure.lock/owner 2>&1)" +echo "# load before $(uptime | sed 's/.*load averages*: *//')" +echo "#" +echo "# THE CONFIGURATION GATE, quoted from the code this harness invokes:" +echo "# arena.rs persist_if_configured —" +sed -n '/fn persist_if_configured/,/^ }/p' "$WT/src/branch/arena.rs" | sed 's/^/# /' +echo "# cli/cli.rs, the shipped server — this is why persist=on is the production row:" +grep -n 'checkpoint_to' "$WT/src/cli/cli.rs" | sed 's/^/# /' +echo "# lease_thread.rs, the lock the reap loop runs inside:" +grep -n 'const REAP_CHUNK' "$WT/src/branch/lease_thread.rs" | sed 's/^/# /' +echo "#" + +# ---- the sweep ----------------------------------------------------------------------------- +echo "==============================================================================" +echo "ARM 1 — the branch-count curve, in-memory catalog, both persist settings." +echo "==============================================================================" +D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=off \ + D133_CATALOG=mem "$BIN" +rc1=$? +echo "(arm 1 persist=off rc=$rc1)" +echo +D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000 D133_PERSIST=on \ + D133_CATALOG=mem "$BIN" +rc2=$? +echo "(arm 1 persist=on rc=$rc2 — N capped at 1000: every pass fsyncs the whole map)" +echo + +echo "==============================================================================" +echo "ARM 2 — the SHIPPED catalog, at counts it can reach. D99's rule: two catalogs" +echo "must agree on the integers at a count both can reach, or one of them is the" +echo "thing being measured." +echo "==============================================================================" +D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300 D133_PERSIST=off \ + D133_CATALOG=table "$BIN" +rc3=$? +echo "(arm 2 rc=$rc3)" +echo + +echo "==============================================================================" +echo "ARM 3 — the PAGES axis, held against ARM 1's N=300 row. pending is pages" +echo "parked, not branches reaped, so P must scale it linearly or the model is wrong." +echo "==============================================================================" +for p in 1 4 16; do + D133_SHAPES=fan D133_BRANCHES=300 D133_PERSIST=off D133_CATALOG=mem D133_PAGES=$p "$BIN" \ + | grep -E '^ (shape|fan)' | sed "s/^/ P=$p /" +done +echo + +echo "# load after $(uptime | sed 's/.*load averages*: *//')" +echo "# arm rcs $rc1 $rc2 $rc3" +if [ "$rc1" -ne 0 ] || [ "$rc2" -ne 0 ] || [ "$rc3" -ne 0 ]; then + echo "# VERDICT: AT LEAST ONE ARM REFUSED. Do not quote this file." + exit 2 +fi +echo "# every arm returned 0, i.e. every fan/chain row parked pages and every leaf row did not." diff --git a/examples/d133_pending_len.rs b/examples/d133_pending_len.rs new file mode 100644 index 00000000..8b55e029 --- /dev/null +++ b/examples/d133_pending_len.rs @@ -0,0 +1,304 @@ +//! D133 — how long does the arena PENDING-FREE queue get, and how often is it walked? +//! +//! D133 read from structure that `reap → drain_pending_seeded` walks the **entire global** +//! pending-free log inside the pgwire per-statement lock, and said so with the limit stated in the +//! same breath: *"`pending`'s actual length is UNMEASURED and I am not asserting it is large."* +//! This is that measurement. +//! +//! # What is on the statement path, and where this harness enters it +//! +//! `lease_thread.rs:569` chunks expired candidates at `REAP_CHUNK = 1` and calls +//! `reaper.reap_if_still_expired(..)` inside `with_lock` (`lease_thread.rs:572`) — the pgwire +//! per-statement mutex. `reap_if_still_expired` (`reaper.rs:777`) ends in `self.reap(branch)`. +//! So **one lock acquisition == one `Reaper::reap`**, and this harness calls `Reaper::reap` once +//! per branch in a loop. It is the same call, reached through the trait rather than through the +//! timer, so no clock or lease has to be faked. +//! +//! Inside one `reap`, the work whose size is NOT bounded by the chunk: +//! - `arena.rs:940` parks every page of the reaped branch a live child can still see. +//! - `reaper.rs:410` `drain_pending_seeded` then loops: `take_pending` is `std::mem::take` of +//! the WHOLE vec (`arena.rs:800`), walks every entry, and `put_pending`s the survivors back +//! (`arena.rs:810`) to be re-walked by the next reap, for as long as their child lives. +//! +//! # The two integers, and why neither is reported alone +//! +//! `pending_len()` (`arena.rs:575`) is the queue's LENGTH. `drain_passes()` / `drain_entry_visits()` +//! (`reaper.rs`, added for this measurement beside the existing `sweep_descents`/`sweep_visits`) +//! are how many times it was walked and how many entries those walks touched. A length alone +//! cannot separate "short queue walked often" from "long queue walked once". `entry_visits` is the +//! work; `pending_len` is the state; `ms` is the confirmation, and on this box it is an upper +//! bound and nothing better. +//! +//! # ⛔ `checkpoint_path` GATES THE COST, so every row states which setting produced it +//! +//! `put_pending` closes with `persist_if_configured()` (`arena.rs:799-803`), which is +//! `None => Ok(())`. **Production sets it**: `cli/cli.rs:120` calls +//! `store.checkpoint_to(arena_path)` unconditionally when the server opens. The published 10^6 +//! harnesses did not. So `persist=off` is what this project has historically measured and +//! `persist=on` is what the shipped binary runs — both are here, and the header of every row says +//! which. Following D99's second correction, `checkpoint_to` is called **after** padding, so +//! setup's own extent claims do not pay for a persistence the measurement, not the fixture, is +//! about. +//! +//! # Shapes, because "a branching workload" is not one workload +//! +//! A page is parked exactly when a *reaped* branch has a *live* child that can see it +//! (`arena.rs:1585-1600`). So what grows `pending` is not the branch count — it is the count of +//! reaped-but-pinned branches, and the shape of the tree decides whether those are the same +//! number: +//! +//! `fan` N parents off trunk, each with P pages written BEFORE it forks one child that is +//! never reaped. Every parent's reap parks P pages. This is the shape in which pending +//! CAN grow with N. +//! `chain` trunk -> b1 -> ... -> bN, reaped deepest-first with the tip left alive — which is the +//! order `expired_candidates` produces. Only the branch adjacent to the live tip has a +//! live child, so at most one branch's pages are parked at a time. +//! `leaf` N childless leaves. **The forced negative.** Nothing can be parked, so `pending_len`, +//! `drain_passes` and `drain_entry_visits` must all read ZERO. A counter that is never +//! zero is not measuring what its name says. +//! +//! Refuses rather than reporting: zero branches, zero rows, a `fan`/`chain` row that parked +//! nothing (the fixture missed the code under test), or a `leaf` row that parked anything. +//! +//! Usage: +//! d133_pending_len +//! D133_SHAPES=fan D133_BRANCHES=100,300,1000,3000 D133_PERSIST=on d133_pending_len + +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::storage::disk_manager::DiskManager; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +fn list(key: &str, default: &str) -> Vec { + env_or(key, default).split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() +} + +struct Rig { + store: Arc, + cat: Arc, + reaper: TwoTierReaper, + dir: std::path::PathBuf, +} + +fn build(tag: &str, catalog_kind: &str) -> Rig { + let dir = std::env::temp_dir().join(format!("ferrodb-d133-{}-{}", std::process::id(), tag)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(true) + .open(dir.join("main.db")) + .unwrap(); + let pool = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(f).unwrap()))); + let cat: Arc = if catalog_kind == "table" { + Arc::new(TableBranchCatalog::open_sidecar(&dir.join("b.branchcat"), 1).unwrap()) + } else { + Arc::new(LogBranchCatalog::in_memory(1)) + }; + let base = pool.disk_manager.high_water().unwrap(); + let store = Arc::new(ArenaPageStore::new(pool, Arc::clone(&cat), base).unwrap()); + let reaper = TwoTierReaper::new(Arc::clone(&cat), Arc::clone(&store)); + Rig { store, cat, reaper, dir } +} + +/// Write `pages` novel pages to `branch`, the way an agent task does. +/// +/// `alloc_for`, not a captured `ArenaId`: under geometric extent growth the first extent is ONE +/// page, so a harness that takes one arena up front and fills it refuses on the second write. +fn write_pages(rig: &Rig, branch: BranchId, pages: u32) { + let epoch = rig.cat.next_epoch(); + for i in 0..pages { + let p = rig.store.alloc_for(branch, PageType::BTreeLeaf, epoch).unwrap(); + let handle = rig.store.read_page(p).unwrap(); + let mut frame = handle.write(); + frame.data[PAGE_HEADER_SIZE] = (i & 0xff) as u8; + stamp_checksum(&mut frame.data); + } +} + +/// Build the fixture and return the branches to reap, **in the order the lease thread would**. +fn pad(rig: &Rig, shape: &str, n: usize, pages: u32) -> Vec { + match shape { + // N parents off trunk. Pages are written BEFORE the child forks, which is what makes them + // visible to it and therefore parkable; a page born after the fork goes straight back. + "fan" => (0..n) + .map(|_| { + let p = rig.cat.fork(BranchId::TRUNK, LeaseDeadline(0)).unwrap(); + write_pages(rig, p.branch_id, pages); + let _child = rig.cat.fork(p.branch_id, LeaseDeadline(0)).unwrap(); + p.branch_id + }) + .collect(), + // trunk -> b1 -> ... -> bN. The tip is never reaped, so it is the live child that pins. + // Returned deepest-first excluding the tip, which is `expired_candidates`' own order. + "chain" => { + let mut prev = BranchId::TRUNK; + let mut all = Vec::with_capacity(n); + for _ in 0..n { + let b = rig.cat.fork(prev, LeaseDeadline(0)).unwrap(); + write_pages(rig, b.branch_id, pages); + prev = b.branch_id; + all.push(b.branch_id); + } + all.pop(); // the tip stays alive + all.reverse(); + all + } + // The forced negative: childless leaves park nothing, so every counter must stay at zero. + "leaf" => (0..n) + .map(|_| { + let b = rig.cat.fork(BranchId::TRUNK, LeaseDeadline(0)).unwrap(); + write_pages(rig, b.branch_id, pages); + b.branch_id + }) + .collect(), + other => panic!("unknown shape {other}"), + } +} + +fn main() { + let shapes = list("D133_SHAPES", "fan,chain,leaf"); + let counts: Vec = + list("D133_BRANCHES", "100,300,1000,3000").iter().filter_map(|s| s.parse().ok()).collect(); + let persists = list("D133_PERSIST", "off,on"); + let pages: u32 = env_or("D133_PAGES", "4").parse().unwrap(); + let catalog_kind = env_or("D133_CATALOG", "mem"); + + if counts.is_empty() || shapes.is_empty() || persists.is_empty() { + eprintln!("REFUSED: empty sweep — nothing would be measured."); + std::process::exit(2); + } + + println!("D133 — pending-free queue length and how often it is walked."); + println!(); + println!(" catalog={catalog_kind} pages written per branch before its child forks={pages}"); + println!(" reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572`"); + println!(" calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`)."); + println!(" `checkpoint_to` is called AFTER padding, so setup does not pay for persistence."); + println!(" pending_len is sampled inside the timed loop (one O(1) mutex read per reap)."); + println!(); + println!( + " {:<6} {:<8} {:>7} {:>9} {:>9} {:>8} {:>14} {:>10} {:>11}", + "shape", + "persist", + "N", + "pend_end", + "pend_peak", + "passes", + "entry_visits", + "total_ms", + "ms/reap" + ); + + let mut rows = 0usize; + let mut refusals: Vec = Vec::new(); + + for shape in &shapes { + for persist in &persists { + for &n in &counts { + if n == 0 { + refusals.push("a row with N=0 was requested".to_string()); + continue; + } + let tag = format!("{shape}-{persist}-{n}"); + let rig = build(&tag, &catalog_kind); + let victims = pad(&rig, shape, n, pages); + + // AFTER padding — D99's second correction. Turning this on earlier makes every + // setup extent claim rewrite the whole map, which is O(N^2) bytes of fixture. + let persist_on = persist == "on"; + if persist_on { + rig.store.checkpoint_to(rig.dir.join("main.db.arena")); + } + + let mut peak = 0usize; + let t0 = Instant::now(); + for b in &victims { + rig.reaper.reap(*b).unwrap(); + let l = rig.store.pending_len(); + if l > peak { + peak = l; + } + } + let ms = t0.elapsed().as_secs_f64() * 1000.0; + + let pend_end = rig.store.pending_len(); + let passes = rig.reaper.drain_passes(); + let visits = rig.reaper.drain_entry_visits(); + let reaped = victims.len(); + + println!( + " {:<6} {:<8} {:>7} {:>9} {:>9} {:>8} {:>14} {:>10.1} {:>11.4}", + shape, + persist, + reaped, + pend_end, + peak, + passes, + visits, + ms, + ms / reaped as f64 + ); + rows += 1; + + // The detector must fire where the mechanism exists, and must NOT fire where it + // does not. Either failure makes every other row in this table uninterpretable. + match shape.as_str() { + "leaf" => { + if peak != 0 || visits != 0 { + refusals.push(format!( + "leaf N={n} parked {peak} entries and walked {visits}: a childless \ + leaf has no live child, so the counters are measuring something \ + other than the pinned-page path" + )); + } + } + _ => { + if peak == 0 { + refusals.push(format!( + "{shape} N={n} parked NOTHING — the fixture never reached the code \ + under test, so its ms and its zeros mean nothing" + )); + } + if visits == 0 { + refusals.push(format!( + "{shape} N={n} walked zero pending entries while pending_len \ + reached {peak}" + )); + } + } + } + let _ = std::fs::remove_dir_all(&rig.dir); + } + } + } + + println!(); + if rows == 0 { + eprintln!("REFUSED: zero rows produced."); + std::process::exit(2); + } + if !refusals.is_empty() { + eprintln!("REFUSED — {} row(s) did not hold up:", refusals.len()); + for r in &refusals { + eprintln!(" - {r}"); + } + std::process::exit(2); + } + println!(" {rows} rows, every fan/chain row parked pages and every leaf row parked none."); +} diff --git a/src/branch/reaper.rs b/src/branch/reaper.rs index 26c909d3..263366f5 100644 --- a/src/branch/reaper.rs +++ b/src/branch/reaper.rs @@ -57,6 +57,10 @@ pub struct TwoTierReaper { sweep_descents: AtomicU64, /// Arenas the extent sweep has examined. See [`Self::sweep_visits`]. sweep_visits: AtomicU64, + /// Fixed-point passes `drain_pending_seeded` has made. See [`Self::drain_passes`]. + drain_passes: AtomicU64, + /// Pending-free entries those passes have walked. See [`Self::drain_entry_visits`]. + drain_entry_visits: AtomicU64, /// **D83.** Arenas a drain touched but did not get to sweep, because it returned early. /// /// `collect_orphans_if_due` used to answer "which extents became collectable?" by scanning @@ -83,6 +87,8 @@ impl TwoTierReaper { last_orphan_sweep_ms: AtomicU64::new(ORPHAN_SWEEP_NEVER), sweep_descents: AtomicU64::new(0), sweep_visits: AtomicU64::new(0), + drain_passes: AtomicU64::new(0), + drain_entry_visits: AtomicU64::new(0), deferred: Mutex::new(BTreeSet::new()), open_sweep_freed: AtomicU64::new(0), } @@ -233,6 +239,29 @@ impl TwoTierReaper { self.sweep_visits.load(Ordering::Relaxed) } + /// Fixed-point passes [`Self::drain_pending_seeded`] has made over the pending-free log. + /// + /// **D133.** One pass is one `take_pending` + one walk + one `put_pending`, and the reap that + /// contains it runs **inside the pgwire per-statement lock** (`lease_thread.rs:572`). Reported + /// beside [`Self::drain_entry_visits`] because neither number means anything alone: a length + /// alone cannot separate "short queue walked often" from "long queue walked once", and the + /// pass count alone cannot separate a cheap pass from an expensive one. The work is + /// `entry_visits`; `passes` is how it is distributed. + pub fn drain_passes(&self) -> u64 { + self.drain_passes.load(Ordering::Relaxed) + } + + /// Pending-free entries the drain has walked, summed over every pass. + /// + /// **D133 — this is the integer the wall is made of.** `take_pending` is + /// `std::mem::take` of the WHOLE vec (`arena.rs:800`), so one pass visits every parked entry + /// in the store, not merely the ones belonging to the branch being reaped, and survivors are + /// put back to be re-walked by the next reap. Counted at the single site the walk begins so + /// it cannot drift from the loop it measures. + pub fn drain_entry_visits(&self) -> u64 { + self.drain_entry_visits.load(Ordering::Relaxed) + } + /// Is `arena` an extent whose owning branch no longer exists at that generation and which /// holds no allocated page any more? /// @@ -419,6 +448,10 @@ impl TwoTierReaper { if entries.is_empty() { break; } + // **D133.** Counted after the empty check, so a drain with nothing to do records a + // pass of zero rather than a pass at all — the two are different claims. + self.drain_passes.fetch_add(1, Ordering::Relaxed); + self.drain_entry_visits.fetch_add(entries.len() as u64, Ordering::Relaxed); let mut still_pinned = Vec::new(); let mut moved = false; for (i, pf) in entries.iter().copied().enumerate() { From 55e22400709384fce3c34a9b92fd48e00266a2d2 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:04:17 -0400 Subject: [PATCH 044/315] D133: make contamination visible from the artifact, not from luck The first draw checked the suite lock ONCE, at start. verify-suite.sh land21ae398 took that lock at 11:57:51Z, 106 s into a 252 s run, so every ms printed after that point was measured against a full suite build -- and nothing in the file said so. The protection is ONE-WAY by construction: tools/verify-suite.sh knows only SUITE_LOCK=/tmp/ferrodb-suite.lock and never reads the measure lock, so holding the measure lock deters measurers and no suite at all. Sample the suite lock before and after every arm, print it inline, and exit 3 if it was ever taken -- the integers survive a loaded box because they are counts, the ms column does not. ARM 1 persist=on now reaches N=3000 and a new ARM 4 adds a third point to the shipped catalog's chain slope, so both live in ONE run rather than two moments. --- bench/d133_run.sh | 53 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/bench/d133_run.sh b/bench/d133_run.sh index 2c4259f4..7df30250 100755 --- a/bench/d133_run.sh +++ b/bench/d133_run.sh @@ -4,12 +4,31 @@ # Refuses rather than producing a contaminated number: it will not run without the fleet measure # lock held, and will not run while the suite lock is taken. # +# ⛔ **The first draw of this artifact was contaminated and the script could not tell.** It +# checked the suite lock ONCE, at start. `verify-suite.sh land21ae398` took that lock at +# 11:57:51Z, 106 seconds into a 252-second run, and every ms printed after that point was +# measured against a full suite build. The measure lock does not stop a suite from starting. +# So the lock is now sampled BEFORE AND AFTER EVERY ARM and the run exits non-zero if it ever +# became taken: a contended box returns "no effect", and a number that cannot say whether the +# box was quiet is not a number. +# # Usage: bench/d133_run.sh > bench/d133_pending_len.txt 2>&1 set -u WT="$(cd "$(dirname "$0")/.." && pwd)" BIN="$WT/target/release/examples/d133_pending_len" +CONTENDED=0 +# Print the suite lock's state and remember, for the whole run, whether it was ever taken. +suite_state() { + if [ -d /tmp/ferrodb-suite.lock ]; then + CONTENDED=1 + echo "# suite lock @ $1: TAKEN by $(cat /tmp/ferrodb-suite.lock/owner 2>&1) <- CONTAMINATED" + else + echo "# suite lock @ $1: free ($(date -u +%H:%M:%SZ), load $(uptime | sed 's/.*load averages*: *//'))" + fi +} + # ---- refusals ------------------------------------------------------------------------------ if [ ! -d /tmp/ferrodb-measure.lock ]; then echo "REFUSED: /tmp/ferrodb-measure.lock is not held. A contended box returns 'no effect'." >&2 @@ -51,15 +70,19 @@ echo "#" echo "==============================================================================" echo "ARM 1 — the branch-count curve, in-memory catalog, both persist settings." echo "==============================================================================" +suite_state "before arm 1 persist=off" D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=off \ D133_CATALOG=mem "$BIN" rc1=$? +suite_state "after arm 1 persist=off" echo "(arm 1 persist=off rc=$rc1)" echo -D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000 D133_PERSIST=on \ +suite_state "before arm 1 persist=on" +D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=on \ D133_CATALOG=mem "$BIN" rc2=$? -echo "(arm 1 persist=on rc=$rc2 — N capped at 1000: every pass fsyncs the whole map)" +suite_state "after arm 1 persist=on" +echo "(arm 1 persist=on rc=$rc2 — every pass fsyncs the whole map, so this arm is the slow one)" echo echo "==============================================================================" @@ -67,9 +90,11 @@ echo "ARM 2 — the SHIPPED catalog, at counts it can reach. D99's rule: two cat echo "must agree on the integers at a count both can reach, or one of them is the" echo "thing being measured." echo "==============================================================================" +suite_state "before arm 2" D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300 D133_PERSIST=off \ D133_CATALOG=table "$BIN" rc3=$? +suite_state "after arm 2" echo "(arm 2 rc=$rc3)" echo @@ -77,16 +102,36 @@ echo "========================================================================== echo "ARM 3 — the PAGES axis, held against ARM 1's N=300 row. pending is pages" echo "parked, not branches reaped, so P must scale it linearly or the model is wrong." echo "==============================================================================" +suite_state "before arm 3" for p in 1 4 16; do D133_SHAPES=fan D133_BRANCHES=300 D133_PERSIST=off D133_CATALOG=mem D133_PAGES=$p "$BIN" \ | grep -E '^ (shape|fan)' | sed "s/^/ P=$p /" done echo +suite_state "after arm 3" +echo +echo "==============================================================================" +echo "ARM 4 — a THIRD point on the shipped catalog's chain slope. Two points cannot" +echo "separate a slope from a drifting box, and this project has lost four results" +echo "to exactly that." +echo "==============================================================================" +suite_state "before arm 4" +D133_SHAPES=chain D133_BRANCHES=600 D133_PERSIST=off D133_CATALOG=table "$BIN" +rc4=$? +suite_state "after arm 4" +echo "(arm 4 rc=$rc4)" +echo + echo "# load after $(uptime | sed 's/.*load averages*: *//')" -echo "# arm rcs $rc1 $rc2 $rc3" -if [ "$rc1" -ne 0 ] || [ "$rc2" -ne 0 ] || [ "$rc3" -ne 0 ]; then +echo "# arm rcs $rc1 $rc2 $rc3 $rc4" +if [ "$rc1" -ne 0 ] || [ "$rc2" -ne 0 ] || [ "$rc3" -ne 0 ] || [ "$rc4" -ne 0 ]; then echo "# VERDICT: AT LEAST ONE ARM REFUSED. Do not quote this file." exit 2 fi +if [ "$CONTENDED" -ne 0 ]; then + echo "# VERDICT: THE SUITE LOCK WAS TAKEN DURING THIS RUN. The integers stand — they are" + echo "# counts and immune to load — but DO NOT QUOTE ANY ms COLUMN from this file." + exit 3 +fi echo "# every arm returned 0, i.e. every fan/chain row parked pages and every leaf row did not." From ffa4852018c37feb31d3b5f23f2e20b4ebdb8d7a Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:05:42 -0400 Subject: [PATCH 045/315] D133: check the rows against the closed form, and prove the check fires The expected values are derived from three facts the code states -- P pages parked per reap, one fixed-point pass because nothing is released while the children live, and take_pending taking the WHOLE vec -- not fitted to the measurement and never obtained by calling the subject. pend = P*N passes = N visits = P*N*(N+1)/2 The quadratic IS the claim: if the drain walked only the reaped branch's own entries, visits would be P*N. All 30 rows of the first draw match exactly. Fire-checked against five planted defects, each caught with the right exit code: visits forced linear (1), a queue allowed to drain (1), the leaf control made non-zero (1), an artifact with no rows (2), an artifact with no control row (2). --- bench/d133_check.py | 104 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100755 bench/d133_check.py diff --git a/bench/d133_check.py b/bench/d133_check.py new file mode 100755 index 00000000..9b79dacf --- /dev/null +++ b/bench/d133_check.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""D133 — check the measured rows against the closed form DERIVED FROM THE CODE. + +The expected values here are not fitted to the measurement and are not obtained by calling the +subject. They are read off the three facts the code states, and the check is whether the engine +agrees: + + 1. One `Reaper::reap` parks P pages, where P is the number of pages the branch wrote before its + child forked (`arena.rs:1585-1600`: a page is parked iff a live child can see it). + 2. Nothing is ever released while those children live, so `drain_pending_seeded`'s `moved` stays + false and the fixed-point loop runs exactly ONE pass per reap (`reaper.rs:410`). + 3. `take_pending` is `std::mem::take` of the WHOLE vec (`arena.rs:800`), so that one pass walks + every entry parked so far, not only this branch's. + + => pend_peak(N) = P * N + passes(N) = N + entry_visits(N) = sum_{i=1..N} P*i = P * N * (N+1) / 2 + +If (3) were false -- if the drain walked only the reaped branch's own entries -- `entry_visits` +would be P*N, LINEAR. The quadratic is the whole claim, so it is what this asserts. + +Exits non-zero if no rows were found, if any row disagrees, or if the `leaf` control is missing: +a checker that parses nothing passes vacuously, and this project has lost results to exactly that. + +Usage: bench/d133_check.py bench/d133_pending_len.txt [--pages 4] [--expect-fail] +""" +import re +import sys + +ROW = re.compile( + r"^\s*(?:P=(?P

\d+)\s+)?(?Pfan|chain|leaf)\s+(?Pon|off)\s+" + r"(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+" + r"(?P[\d.]+)\s+(?P[\d.]+)\s*$" +) + + +def main() -> int: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + expect_fail = "--expect-fail" in sys.argv + default_pages = 4 + if "--pages" in sys.argv: + default_pages = int(sys.argv[sys.argv.index("--pages") + 1]) + if not args: + print("REFUSED: no artifact given.", file=sys.stderr) + return 2 + + rows, bad, shapes = 0, [], set() + for line in open(args[0]): + m = ROW.match(line.rstrip("\n")) + if not m: + continue + rows += 1 + shape = m.group("shape") + shapes.add(shape) + n = int(m.group("n")) + pages = int(m.group("p")) if m.group("p") else default_pages + # The control: a childless leaf has no live child, so nothing can be parked and nothing + # can be walked. Expected values here are 0 by the same rule 1, with P effectively 0. + p = 0 if shape == "leaf" else pages + want_peak = p * n + want_passes = n if p else 0 + want_visits = p * n * (n + 1) // 2 + got = (int(m.group("peak")), int(m.group("passes")), int(m.group("visits"))) + want = (want_peak, want_passes, want_visits) + if got != want: + bad.append(f"{shape:<5} persist={m.group('persist'):<3} N={n:<5} P={pages}: " + f"got peak/passes/visits {got}, closed form says {want}") + if int(m.group("pend_end")) != int(m.group("peak")): + bad.append(f"{shape:<5} persist={m.group('persist'):<3} N={n:<5}: pend_end " + f"{m.group('pend_end')} != peak {m.group('peak')} — the queue DID drain, " + f"which the 'children stay live' premise says it cannot") + + if rows == 0: + print("REFUSED: parsed zero rows. A checker that reads nothing passes vacuously.", + file=sys.stderr) + return 2 + if "leaf" not in shapes: + print("REFUSED: no `leaf` control row. Without the forced negative the other rows only " + "show that a counter counts something.", file=sys.stderr) + return 2 + + if expect_fail: + # Fire-check mode: the caller has planted a wrong row and this must NOTICE. + if not bad: + print("FIRE-CHECK FAILED: the checker passed a file it was told disagrees.", + file=sys.stderr) + return 2 + print(f"FIRE-CHECK OK: {len(bad)} disagreement(s) caught:") + for b in bad: + print(f" - {b}") + return 0 + + if bad: + print(f"DISAGREEMENT — {len(bad)} row(s) do not match the closed form:", file=sys.stderr) + for b in bad: + print(f" - {b}", file=sys.stderr) + return 1 + print(f"OK — all {rows} rows (shapes: {', '.join(sorted(shapes))}) match the closed form " + f"pend=P*N, passes=N, visits=P*N*(N+1)/2 EXACTLY, and no row's queue ever drained.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From f4902e393998532fcb82e3946bcaaa1f1a5c5f7d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:06:07 -0400 Subject: [PATCH 046/315] D133: label draw 1 contaminated in the file itself, not only in a commit message A reader who opens bench/d133_pending_len.txt learns nothing from a commit message they are not reading. The banner goes ABOVE the run's own header, says which 106 of the 252 seconds overlapped verify-suite.sh land21ae398, names what survives (every integer column) and what does not (every ms), and points at the draw that replaces it. --- bench/d133_pending_len.txt | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/bench/d133_pending_len.txt b/bench/d133_pending_len.txt index ef1c2c30..ccf78ead 100644 --- a/bench/d133_pending_len.txt +++ b/bench/d133_pending_len.txt @@ -1,3 +1,24 @@ +################################################################################################ +# ⛔⛔ DRAW 1 — **CONTAMINATED. DO NOT QUOTE ANY ms COLUMN FROM THIS FILE.** +# +# This run held /tmp/ferrodb-measure.lock and checked /tmp/ferrodb-suite.lock ONCE, at start, +# where it was free. `tools/verify-suite.sh land21ae398` (pid 92075) then took the suite lock at +# **11:57:51Z — 106 seconds into this 252-second run** (11:56:05Z -> 12:00:17Z). ARM 2, the +# shipped-catalog arm, ran almost entirely underneath a full suite build. +# +# The measure lock could not have prevented it: `tools/verify-suite.sh` knows only +# SUITE_LOCK=/tmp/ferrodb-suite.lock and never reads the measure lock, so the protection is +# one-way — it deters measurers and no suite at all. +# +# ✅ **WHAT SURVIVES: every INTEGER column** — pend_end, pend_peak, passes, entry_visits. They are +# counts, not durations, and a loaded box cannot change them. `bench/d133_check.py` verifies all +# 30 rows against the closed form derived from the code. The ms and ms/reap columns are kept only +# so this draw can be compared with the clean one; they are not evidence. +# +# ⇒ The quotable timings are in **DRAW 2**, `bench/d133_pending_len_draw2.txt`, produced by the +# hardened runner that samples the suite lock before and after every arm and exits 3 if it was +# ever taken. +################################################################################################ # D133 — pending-free queue length under a branching workload. RAW ARTIFACT. # # run at 2026-09-22T11:56:05Z UTC From 997247259f9b1465b28a1f7f966fc9f5f9c75677 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:09:48 -0400 Subject: [PATCH 047/315] D133: bound the growth -- the queue is a function of the LAG, not the branch count fan and chain pin their children FOREVER by construction, so they are a worst case and a worst case shipped without its boundary condition is a scope artifact. Two shapes find the boundary, predictions written before the run: fanreap children expired too and reaped FIRST -- which is the order expired_candidates actually produces (deepest first), so every parent is a childless leaf by its turn. Predicted 0/0/0. fanlag each child outlives its parent by exactly ONE reap. Predicted peak=P, passes=V, visits=P*V. Both confirmed exactly at V=40 and V=120: fanreap 0/0/0, fanlag peak=4=P and CONSTANT in the branch count, passes=V, visits=P*V. So the axis is how long a child outlives its parent: lag 0 parks nothing, lag 1 holds P entries, lag infinity is P*N with a P*N(N+1)/2 walk. Also adds bench/d133_draw2.sh: waits for the suite lock and starts the instant it clears, retrying if a suite lands mid-run -- two suites started within 30 minutes on this box and draw 1 was lost to the second. --- bench/d133_check.py | 36 +++++++++++------- bench/d133_draw2.sh | 42 +++++++++++++++++++++ bench/d133_run.sh | 8 ++-- examples/d133_pending_len.rs | 71 +++++++++++++++++++++++++++++++++++- 4 files changed, 140 insertions(+), 17 deletions(-) create mode 100755 bench/d133_draw2.sh diff --git a/bench/d133_check.py b/bench/d133_check.py index 9b79dacf..5727ee4d 100755 --- a/bench/d133_check.py +++ b/bench/d133_check.py @@ -28,7 +28,7 @@ import sys ROW = re.compile( - r"^\s*(?:P=(?P

\d+)\s+)?(?Pfan|chain|leaf)\s+(?Pon|off)\s+" + r"^\s*(?:P=(?P

\d+)\s+)?(?Pfan|chain|fanreap|fanlag|leaf)\s+(?Pon|off)\s+" r"(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+" r"(?P[\d.]+)\s+(?P[\d.]+)\s*$" ) @@ -54,19 +54,28 @@ def main() -> int: shapes.add(shape) n = int(m.group("n")) pages = int(m.group("p")) if m.group("p") else default_pages - # The control: a childless leaf has no live child, so nothing can be parked and nothing - # can be walked. Expected values here are 0 by the same rule 1, with P effectively 0. - p = 0 if shape == "leaf" else pages - want_peak = p * n - want_passes = n if p else 0 - want_visits = p * n * (n + 1) // 2 + # `n` is the VICTIM count the harness printed, which is what every form below is in. + if shape in ("leaf", "fanreap"): + # `leaf` has no child at all; `fanreap` reaps every child BEFORE its parent, so each + # parent is a childless leaf by its turn. Both take the fast path and park nothing. + want = (0, 0, 0) + elif shape == "fanlag": + # A child that outlives its parent by exactly one reap. Reaping p_i parks P; reaping + # c_i releases them, so the queue returns to empty and never accumulates. Each of the + # V reaps makes one pass over the P entries standing at that moment. + want = (pages, n, pages * n) + else: + # `fan`/`chain`: the child never dies, so the i-th reap adds P and releases nothing, + # and its single pass walks everything parked so far. + want = (pages * n, n, pages * n * (n + 1) // 2) got = (int(m.group("peak")), int(m.group("passes")), int(m.group("visits"))) - want = (want_peak, want_passes, want_visits) if got != want: - bad.append(f"{shape:<5} persist={m.group('persist'):<3} N={n:<5} P={pages}: " + bad.append(f"{shape:<7} persist={m.group('persist'):<3} V={n:<5} P={pages}: " f"got peak/passes/visits {got}, closed form says {want}") - if int(m.group("pend_end")) != int(m.group("peak")): - bad.append(f"{shape:<5} persist={m.group('persist'):<3} N={n:<5}: pend_end " + # Only where the premise holds: fan/chain keep their children alive for the whole run, so + # a queue that shrank would falsify the model. fanlag is EXPECTED to end drained. + if shape in ("fan", "chain") and int(m.group("pend_end")) != int(m.group("peak")): + bad.append(f"{shape:<7} persist={m.group('persist'):<3} V={n:<5}: pend_end " f"{m.group('pend_end')} != peak {m.group('peak')} — the queue DID drain, " f"which the 'children stay live' premise says it cannot") @@ -95,8 +104,9 @@ def main() -> int: for b in bad: print(f" - {b}", file=sys.stderr) return 1 - print(f"OK — all {rows} rows (shapes: {', '.join(sorted(shapes))}) match the closed form " - f"pend=P*N, passes=N, visits=P*N*(N+1)/2 EXACTLY, and no row's queue ever drained.") + print(f"OK — all {rows} rows (shapes: {', '.join(sorted(shapes))}) match their closed forms " + f"EXACTLY: fan/chain pend=P*V passes=V visits=P*V*(V+1)/2; fanlag pend=P passes=V " + f"visits=P*V; fanreap/leaf all zero.") return 0 diff --git a/bench/d133_draw2.sh b/bench/d133_draw2.sh new file mode 100755 index 00000000..089d49e0 --- /dev/null +++ b/bench/d133_draw2.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# D133 draw 2 — wait for the suite lock, run the hardened sweep, retry if a suite lands on us. +# +# Two suites started within 30 minutes on this box, and draw 1 was lost to the second of them. +# So this does not poll-then-decide-later: it waits for the lock to clear and starts IMMEDIATELY, +# which is the shortest window another `verify-suite.sh` can slip into, and if one does the +# hardened runner exits 3 and this tries again. The exit criterion is a run that returned 0, not +# a number of attempts. +set -u + +WT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$WT/bench/d133_pending_len_draw2.txt" +ATTEMPTS=${ATTEMPTS:-4} +WAIT_S=${WAIT_S:-3000} + +for attempt in $(seq 1 "$ATTEMPTS"); do + # Wait for the suite lock to clear. Polled at 5 s: verify-suite.sh hands over on a 15 s + # cadence, and a 30 s poll loses every handover. + deadline=$((SECONDS + WAIT_S)) + while [ -d /tmp/ferrodb-suite.lock ] && [ "$SECONDS" -lt "$deadline" ]; do sleep 5; done + if [ -d /tmp/ferrodb-suite.lock ]; then + echo "ATTEMPT $attempt: gave up waiting; suite lock still held by \ +$(cat /tmp/ferrodb-suite.lock/owner 2>&1)" >&2 + continue + fi + + echo "ATTEMPT $attempt: suite lock free at $(date -u +%H:%M:%SZ), starting." >&2 + bash "$WT/bench/d133_run.sh" > "$OUT.attempt$attempt" 2>&1 + rc=$? + echo "ATTEMPT $attempt: runner rc=$rc" >&2 + if [ "$rc" -eq 0 ]; then + mv "$OUT.attempt$attempt" "$OUT" + echo "CLEAN DRAW at attempt $attempt -> $OUT" >&2 + # The completion sentinel: its presence is what says the file is finished and whole. + echo "clean rc=0 attempt=$attempt $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$OUT.done" + exit 0 + fi + echo "ATTEMPT $attempt: NOT clean (rc=$rc), kept as $OUT.attempt$attempt" >&2 +done + +echo "REFUSED: no clean draw in $ATTEMPTS attempts. Do not quote a ms column." >&2 +exit 2 diff --git a/bench/d133_run.sh b/bench/d133_run.sh index 7df30250..de6f59b3 100755 --- a/bench/d133_run.sh +++ b/bench/d133_run.sh @@ -69,16 +69,18 @@ echo "#" # ---- the sweep ----------------------------------------------------------------------------- echo "==============================================================================" echo "ARM 1 — the branch-count curve, in-memory catalog, both persist settings." +echo " fan/chain pin their children forever (lag = infinity); fanlag holds the lag at ONE" +echo " reap; fanreap reaps every child first (lag = zero); leaf is the forced negative." echo "==============================================================================" suite_state "before arm 1 persist=off" -D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=off \ +D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=off \ D133_CATALOG=mem "$BIN" rc1=$? suite_state "after arm 1 persist=off" echo "(arm 1 persist=off rc=$rc1)" echo suite_state "before arm 1 persist=on" -D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=on \ +D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300,1000 D133_PERSIST=on \ D133_CATALOG=mem "$BIN" rc2=$? suite_state "after arm 1 persist=on" @@ -91,7 +93,7 @@ echo "must agree on the integers at a count both can reach, or one of them is th echo "thing being measured." echo "==============================================================================" suite_state "before arm 2" -D133_SHAPES=fan,chain,leaf D133_BRANCHES=100,300 D133_PERSIST=off \ +D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300 D133_PERSIST=off \ D133_CATALOG=table "$BIN" rc3=$? suite_state "after arm 2" diff --git a/examples/d133_pending_len.rs b/examples/d133_pending_len.rs index 8b55e029..ce364eab 100644 --- a/examples/d133_pending_len.rs +++ b/examples/d133_pending_len.rs @@ -57,6 +57,24 @@ //! `drain_passes` and `drain_entry_visits` must all read ZERO. A counter that is never //! zero is not measuring what its name says. //! +//! ⚠ **`fan` and `chain` pin their children FOREVER, by construction.** That makes them a worst +//! case, not a steady state, and a worst case shipped without its boundary condition is a scope +//! artifact. Two more shapes find the boundary, and their expectations are written down here +//! BEFORE the run rather than read off it: +//! +//! `fanreap` The same fan, but the children are expired too and the sweep reaps them FIRST — +//! which is the order `expired_candidates` actually produces (`lease_thread.rs:564`, +//! deepest first, and the reaper's own comment says reaping a child is what lets its +//! parent take the fast path). Every parent is then a childless leaf when its turn +//! comes. **Prediction: peak = 0, passes = 0, visits = 0** — nothing is parked at all. +//! `fanlag` Each child outlives its parent by exactly ONE reap: p1, c1, p2, c2, … Reaping p_i +//! parks P pages; reaping c_i is what un-pins them. **Prediction, with V the victim +//! count printed: peak = P, passes = V, visits = P*V** — a queue that is CONSTANT in +//! the branch count and walked a linear number of times. +//! +//! So the axis that matters is not the branch count but **how long a child outlives its parent**, +//! and these three shapes are lag = infinity, lag = one reap, and lag = zero. +//! //! Refuses rather than reporting: zero branches, zero rows, a `fan`/`chain` row that parked //! nothing (the fixture missed the code under test), or a `leaf` row that parked anything. //! @@ -159,6 +177,34 @@ fn pad(rig: &Rig, shape: &str, n: usize, pages: u32) -> Vec { all.reverse(); all } + // The same fan, but the children expire too and are reaped FIRST — deepest-first, which + // is the order the lease thread's own candidate list arrives in. Every parent is a + // childless leaf by the time its turn comes, so the slow path never runs. + "fanreap" => { + let mut parents = Vec::with_capacity(n); + let mut children = Vec::with_capacity(n); + for _ in 0..n { + let p = rig.cat.fork(BranchId::TRUNK, LeaseDeadline(0)).unwrap(); + write_pages(rig, p.branch_id, pages); + children.push(rig.cat.fork(p.branch_id, LeaseDeadline(0)).unwrap().branch_id); + parents.push(p.branch_id); + } + children.extend(parents); // children first + children + } + // Each child outlives its parent by exactly one reap. This is the lag the queue's length + // is actually a function of, held at one instead of at infinity. + "fanlag" => { + let mut victims = Vec::with_capacity(2 * n); + for _ in 0..n { + let p = rig.cat.fork(BranchId::TRUNK, LeaseDeadline(0)).unwrap(); + write_pages(rig, p.branch_id, pages); + let c = rig.cat.fork(p.branch_id, LeaseDeadline(0)).unwrap(); + victims.push(p.branch_id); + victims.push(c.branch_id); + } + victims + } // The forced negative: childless leaves park nothing, so every counter must stay at zero. "leaf" => (0..n) .map(|_| { @@ -172,7 +218,7 @@ fn pad(rig: &Rig, shape: &str, n: usize, pages: u32) -> Vec { } fn main() { - let shapes = list("D133_SHAPES", "fan,chain,leaf"); + let shapes = list("D133_SHAPES", "fan,chain,fanreap,fanlag,leaf"); let counts: Vec = list("D133_BRANCHES", "100,300,1000,3000").iter().filter_map(|s| s.parse().ok()).collect(); let persists = list("D133_PERSIST", "off,on"); @@ -268,6 +314,29 @@ fn main() { )); } } + // Not a prediction being tested but a contract: a parent reaped after its own + // child IS a childless leaf, and the fast path parks nothing. A non-zero here + // means the deepest-first ordering does not buy what the reaper says it buys. + "fanreap" => { + if peak != 0 { + refusals.push(format!( + "fanreap N={n} parked {peak} entries although every child was \ + reaped before its parent — the fast path did not engage" + )); + } + } + // `fanlag` asserts nothing here on purpose: whether the queue DRAINS when the + // pinning child dies is the question this shape exists to answer, and a + // harness that refuses unless it drains has assumed the answer. The prediction + // (peak = P, passes = V, visits = P*V) is checked by bench/d133_check.py. + "fanlag" => { + if visits == 0 { + refusals.push(format!( + "fanlag N={n} walked zero pending entries — the fixture never \ + reached the drain at all" + )); + } + } _ => { if peak == 0 { refusals.push(format!( From f132a0adcaf15b6f655743677e5dc6080988f894 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:15:12 -0400 Subject: [PATCH 048/315] D133: hold the suite lock, and make the abort actually release it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waiting for a free box does not converge: THREE suites took the lock inside 35 minutes (land98a44ef 11:40, land21ae398 11:57, d127invert 12:08) and the gaps are shorter than this sweep. D130 ran its measurement under this lock for the same reason, so this acquires it with verify-suite.sh:135-162's semantics -- atomic mkdir, a $$ owner that stays alive so the dead-holder break cannot fire against a healthy run, 5 s polling, released on EXIT. ⛔ Fire-checked, and the first version FAILED: with the harness in the foreground a SIGTERM did NOT release the lock. bash defers a trap until the foreground command returns, so the lock stayed held for the remaining 55 s of a sleep 60 and the abort printed 'after 60s'. An arm runs for minutes and a stale /tmp/ferrodb-suite.lock blocks EVERY suite on the box. Every arm now runs backgrounded under 'wait', which IS interruptible -- verify-suite.sh's note 7, reached independently. Re-checked: released within 2 s of SIGTERM, abort reports the true 3 s, and the arm's child is killed rather than orphaned. --- bench/d133_draw2.sh | 42 ----------------- bench/d133_run.sh | 112 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 92 insertions(+), 62 deletions(-) delete mode 100755 bench/d133_draw2.sh diff --git a/bench/d133_draw2.sh b/bench/d133_draw2.sh deleted file mode 100755 index 089d49e0..00000000 --- a/bench/d133_draw2.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# D133 draw 2 — wait for the suite lock, run the hardened sweep, retry if a suite lands on us. -# -# Two suites started within 30 minutes on this box, and draw 1 was lost to the second of them. -# So this does not poll-then-decide-later: it waits for the lock to clear and starts IMMEDIATELY, -# which is the shortest window another `verify-suite.sh` can slip into, and if one does the -# hardened runner exits 3 and this tries again. The exit criterion is a run that returned 0, not -# a number of attempts. -set -u - -WT="$(cd "$(dirname "$0")/.." && pwd)" -OUT="$WT/bench/d133_pending_len_draw2.txt" -ATTEMPTS=${ATTEMPTS:-4} -WAIT_S=${WAIT_S:-3000} - -for attempt in $(seq 1 "$ATTEMPTS"); do - # Wait for the suite lock to clear. Polled at 5 s: verify-suite.sh hands over on a 15 s - # cadence, and a 30 s poll loses every handover. - deadline=$((SECONDS + WAIT_S)) - while [ -d /tmp/ferrodb-suite.lock ] && [ "$SECONDS" -lt "$deadline" ]; do sleep 5; done - if [ -d /tmp/ferrodb-suite.lock ]; then - echo "ATTEMPT $attempt: gave up waiting; suite lock still held by \ -$(cat /tmp/ferrodb-suite.lock/owner 2>&1)" >&2 - continue - fi - - echo "ATTEMPT $attempt: suite lock free at $(date -u +%H:%M:%SZ), starting." >&2 - bash "$WT/bench/d133_run.sh" > "$OUT.attempt$attempt" 2>&1 - rc=$? - echo "ATTEMPT $attempt: runner rc=$rc" >&2 - if [ "$rc" -eq 0 ]; then - mv "$OUT.attempt$attempt" "$OUT" - echo "CLEAN DRAW at attempt $attempt -> $OUT" >&2 - # The completion sentinel: its presence is what says the file is finished and whole. - echo "clean rc=0 attempt=$attempt $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$OUT.done" - exit 0 - fi - echo "ATTEMPT $attempt: NOT clean (rc=$rc), kept as $OUT.attempt$attempt" >&2 -done - -echo "REFUSED: no clean draw in $ATTEMPTS attempts. Do not quote a ms column." >&2 -exit 2 diff --git a/bench/d133_run.sh b/bench/d133_run.sh index de6f59b3..db0d3ae9 100755 --- a/bench/d133_run.sh +++ b/bench/d133_run.sh @@ -18,14 +18,64 @@ set -u WT="$(cd "$(dirname "$0")/.." && pwd)" BIN="$WT/target/release/examples/d133_pending_len" +LABEL=d133-pending-len +SUITE_LOCK=${SUITE_LOCK:-/tmp/ferrodb-suite.lock} +LOCK_WAIT=${LOCK_WAIT:-3600} + +# ---- HOLD the suite lock; do not wait for a gap that never comes --------------------------- +# +# Waiting for the lock to be free was tried and does not converge: THREE suites took it inside +# 35 minutes (land98a44ef 11:40, land21ae398 11:57, d127invert 12:08), and the gaps between them +# are shorter than this sweep. D130 — the other result in this bench directory — ran its whole +# measurement under this lock for the same reason. So this ACQUIRES it, with the same semantics +# `tools/verify-suite.sh:135-162` uses, and a suite that starts meanwhile queues behind us +# instead of racing us. +# +# `$$` is this script's pid and lives for the whole run, which is what makes the dead-holder +# break in verify-suite.sh meaningful against us. A subshell pid would read dead immediately and +# invite a healthy holder's lock to be broken. +_HELD=0 +_release() { [ "$_HELD" = "1" ] || return 0; _HELD=0; rm -rf "$SUITE_LOCK"; } +_kill_descendants() { + local p=$1 c + for c in $(pgrep -P "$p" 2>/dev/null); do _kill_descendants "$c"; done + kill -9 "$p" 2>/dev/null +} +_abort() { + trap '' TERM INT # a second signal must not re-enter this handler + local c + for c in $(pgrep -P $$ 2>/dev/null); do _kill_descendants "$c"; done + _release + echo "REFUSED: aborted by SIG$1 after ${SECONDS}s. A sweep cut short is not a measurement." >&2 + exit 143 +} + +# ⛔ **Every arm goes through this, never a bare foreground call.** Fire-checked 2026-09-22: with +# the harness in the foreground a SIGTERM did NOT release the lock — bash does not run a trap +# until the foreground command returns, so the lock stayed held for the remaining 55 s of a +# `sleep 60` and the abort message read "after 60s". An arm here runs for minutes, and a stale +# `/tmp/ferrodb-suite.lock` blocks EVERY suite on the box. `wait` is interruptible where a +# foreground command is not; this is `tools/verify-suite.sh`'s note 7, arrived at the same way. +run_arm() { + "$@" & + _child=$! + wait "$_child" +} +trap '_release' EXIT +trap '_abort TERM' TERM +trap '_abort INT' INT + +# Am I still the holder? Anything else means someone broke the lock and is on the box with me. CONTENDED=0 -# Print the suite lock's state and remember, for the whole run, whether it was ever taken. suite_state() { - if [ -d /tmp/ferrodb-suite.lock ]; then - CONTENDED=1 - echo "# suite lock @ $1: TAKEN by $(cat /tmp/ferrodb-suite.lock/owner 2>&1) <- CONTAMINATED" + local owner pid + owner=$(cat "$SUITE_LOCK/owner" 2>/dev/null || echo "GONE") + pid=${owner%% *} + if [ "$pid" = "$$" ]; then + echo "# suite lock @ $1: HELD BY THIS RUN ($(date -u +%H:%M:%SZ), load $(uptime | sed 's/.*load averages*: *//'))" else - echo "# suite lock @ $1: free ($(date -u +%H:%M:%SZ), load $(uptime | sed 's/.*load averages*: *//'))" + CONTENDED=1 + echo "# suite lock @ $1: LOST — now '$owner' <- CONTAMINATED" fi } @@ -34,15 +84,32 @@ if [ ! -d /tmp/ferrodb-measure.lock ]; then echo "REFUSED: /tmp/ferrodb-measure.lock is not held. A contended box returns 'no effect'." >&2 exit 2 fi -if [ -d /tmp/ferrodb-suite.lock ]; then - echo "REFUSED: /tmp/ferrodb-suite.lock is taken by: $(cat /tmp/ferrodb-suite.lock/owner 2>&1)" >&2 - exit 2 -fi if [ ! -x "$BIN" ]; then echo "REFUSED: $BIN does not exist. Nothing would be measured." >&2 exit 2 fi +# Poll at 5 s, not verify-suite.sh's 15 s: at 15 s this loses the handover to a suite that is +# also queueing, which has already cost this project four missed acquisitions in 50 minutes. +_waited=0 +while ! mkdir "$SUITE_LOCK" 2>/dev/null; do + _owner=$(cat "$SUITE_LOCK/owner" 2>/dev/null || echo "unknown") + _pid=${_owner%% *} + if [ -n "$_pid" ] && [ "$_pid" != "unknown" ] && ! kill -0 "$_pid" 2>/dev/null; then + echo "$LABEL: suite lock held by dead pid $_pid — breaking it" >&2 + rm -rf "$SUITE_LOCK"; continue + fi + if [ "$_waited" -ge "$LOCK_WAIT" ]; then + echo "REFUSED: waited ${LOCK_WAIT}s for the suite lock held by: $_owner" >&2 + exit 3 + fi + [ "$_waited" -eq 0 ] && echo "$LABEL: queued behind $_owner" >&2 + sleep 5; _waited=$((_waited+5)) +done +printf '%s %s %s\n' "$$" "$LABEL" "$(date -u +%FT%TZ)" > "$SUITE_LOCK/owner" +_HELD=1 +echo "$LABEL: acquired the suite lock after ${_waited}s" >&2 + # ---- provenance, read from the tree and the binary, not from intent ------------------------ echo "# D133 — pending-free queue length under a branching workload. RAW ARTIFACT." echo "#" @@ -55,6 +122,7 @@ git -C "$WT" status --porcelain | sed 's/^/# /' echo "# binary mtime $(date -u -r "$BIN" +%Y-%m-%dT%H:%M:%SZ)" echo "# host $(uname -srm)" echo "# measure lock $(cat /tmp/ferrodb-measure.lock/owner 2>&1)" +echo "# suite lock HELD BY THIS RUN: $(cat "$SUITE_LOCK/owner" 2>&1) (queued ${_waited}s)" echo "# load before $(uptime | sed 's/.*load averages*: *//')" echo "#" echo "# THE CONFIGURATION GATE, quoted from the code this harness invokes:" @@ -73,18 +141,21 @@ echo " fan/chain pin their children forever (lag = infinity); fanlag holds the echo " reap; fanreap reaps every child first (lag = zero); leaf is the forced negative." echo "==============================================================================" suite_state "before arm 1 persist=off" -D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300,1000,3000 D133_PERSIST=off \ - D133_CATALOG=mem "$BIN" +run_arm env D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300,1000,3000 \ + D133_PERSIST=off D133_CATALOG=mem "$BIN" rc1=$? suite_state "after arm 1 persist=off" echo "(arm 1 persist=off rc=$rc1)" echo suite_state "before arm 1 persist=on" -D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300,1000 D133_PERSIST=on \ - D133_CATALOG=mem "$BIN" +run_arm env D133_SHAPES=fan,fanlag,leaf D133_BRANCHES=100,300,1000,3000 \ + D133_PERSIST=on D133_CATALOG=mem "$BIN" rc2=$? suite_state "after arm 1 persist=on" -echo "(arm 1 persist=on rc=$rc2 — every pass fsyncs the whole map, so this arm is the slow one)" +echo "(arm 1 persist=on rc=$rc2 — every pass fsyncs the whole map, so this arm is the slow one." +echo " Shapes trimmed to fan/fanlag/leaf: chain matched fan EXACTLY on every integer and" +echo " fanreap matches leaf, so running them here would hold the fleet's lock to re-derive" +echo " a duplicate. fan is the growing queue, fanlag the bounded one, leaf the fast path.)" echo echo "==============================================================================" @@ -93,8 +164,8 @@ echo "must agree on the integers at a count both can reach, or one of them is th echo "thing being measured." echo "==============================================================================" suite_state "before arm 2" -D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300 D133_PERSIST=off \ - D133_CATALOG=table "$BIN" +run_arm env D133_SHAPES=fan,chain,fanreap,fanlag,leaf D133_BRANCHES=100,300 \ + D133_PERSIST=off D133_CATALOG=table "$BIN" rc3=$? suite_state "after arm 2" echo "(arm 2 rc=$rc3)" @@ -106,8 +177,9 @@ echo "parked, not branches reaped, so P must scale it linearly or the model is w echo "==============================================================================" suite_state "before arm 3" for p in 1 4 16; do - D133_SHAPES=fan D133_BRANCHES=300 D133_PERSIST=off D133_CATALOG=mem D133_PAGES=$p "$BIN" \ - | grep -E '^ (shape|fan)' | sed "s/^/ P=$p /" + { env D133_SHAPES=fan D133_BRANCHES=300 D133_PERSIST=off D133_CATALOG=mem D133_PAGES=$p \ + "$BIN" | grep -E '^ (shape|fan)' | sed "s/^/ P=$p /"; } & + wait $! done echo @@ -119,7 +191,7 @@ echo "separate a slope from a drifting box, and this project has lost four resul echo "to exactly that." echo "==============================================================================" suite_state "before arm 4" -D133_SHAPES=chain D133_BRANCHES=600 D133_PERSIST=off D133_CATALOG=table "$BIN" +run_arm env D133_SHAPES=chain D133_BRANCHES=600 D133_PERSIST=off D133_CATALOG=table "$BIN" rc4=$? suite_state "after arm 4" echo "(arm 4 rc=$rc4)" @@ -132,7 +204,7 @@ if [ "$rc1" -ne 0 ] || [ "$rc2" -ne 0 ] || [ "$rc3" -ne 0 ] || [ "$rc4" -ne 0 ]; exit 2 fi if [ "$CONTENDED" -ne 0 ]; then - echo "# VERDICT: THE SUITE LOCK WAS TAKEN DURING THIS RUN. The integers stand — they are" + echo "# VERDICT: THIS RUN LOST THE SUITE LOCK PART-WAY. The integers stand — they are" echo "# counts and immune to load — but DO NOT QUOTE ANY ms COLUMN from this file." exit 3 fi From aaf036df4d5cb726a65d94da283d69410d1c24c6 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:49:10 -0400 Subject: [PATCH 049/315] D133 draw 2: the clean draw, and ARM 4 abandoned on the record Draw 2 held /tmp/ferrodb-suite.lock throughout, sampled before and after every arm. Its INTEGERS match draw 1 EXACTLY on all 45 overlapping rows, which is what licenses reading the two draws as one result. What the timings add, and it moves the emphasis: - persist OFF, the per-reap cost tracks the queue as the integers predict: fan rises 6.3x across 30x V while fanlag, whose queue is constant, is FLAT. - persist ON -- what cli.rs:120 ships -- the queue is NOT the wall. The lock hold is 16-38 ms/reap against a 0.22 ms queue walk at V=3000. leaf, which parks and walks nothing, still costs 23-33 ms. The fsync is ~90x the walk. - draw 1's table arm was NOT materially contaminated: 398.92 vs 405.41 ms/reap on a quiet box, 1.6% apart, because it is fsync-bound. Flag withdrawn. ARM 4 (chain/table V=600, the third point on that slope) abandoned after 13 min with no row, on the lead's ruling: four lanes were queued behind the suite lock and it refines a slope two points and the exact integers already establish. The artifact records it as ABANDONED with what it would have been and how to resume. Also lands the D144 harness and runner (not yet run). --- bench/d133_pending_len_draw2.done | 1 + bench/d133_pending_len_draw2.txt | 185 ++++++++++++ bench/d144_run.sh | 135 +++++++++ examples/d144_lease_lag.rs | 469 ++++++++++++++++++++++++++++++ 4 files changed, 790 insertions(+) create mode 100644 bench/d133_pending_len_draw2.done create mode 100644 bench/d133_pending_len_draw2.txt create mode 100755 bench/d144_run.sh create mode 100644 examples/d144_lease_lag.rs diff --git a/bench/d133_pending_len_draw2.done b/bench/d133_pending_len_draw2.done new file mode 100644 index 00000000..02057121 --- /dev/null +++ b/bench/d133_pending_len_draw2.done @@ -0,0 +1 @@ +rc=143 finished=2026-09-22T12:48:41Z diff --git a/bench/d133_pending_len_draw2.txt b/bench/d133_pending_len_draw2.txt new file mode 100644 index 00000000..49f3d89a --- /dev/null +++ b/bench/d133_pending_len_draw2.txt @@ -0,0 +1,185 @@ +d133-pending-len: queued behind 7683 d127invert 2026-09-22T12:08:28Z +d133-pending-len: acquired the suite lock after 480s +# D133 — pending-free queue length under a branching workload. RAW ARTIFACT. +# +# run at 2026-09-22T12:23:23Z UTC +# worktree /Users/idide/wt/ferrodb-d133-pending-len +# branch d133-pending-len +# head f132a0adcaf15b6f655743677e5dc6080988f894 +# tree state 1 modified/untracked path(s) +# ?? bench/d133_pending_len_draw2.txt +# binary mtime 2026-09-22T12:09:12Z +# host Darwin 25.6.0 arm64 +# measure lock 61248 d133-pending-len 2026-09-22T11:52:09Z +# suite lock HELD BY THIS RUN: 97955 d133-pending-len 2026-09-22T12:23:23Z (queued 480s) +# load before 6.96 7.49 6.93 +# +# THE CONFIGURATION GATE, quoted from the code this harness invokes: +# arena.rs persist_if_configured — +# fn persist_if_configured(&self) -> Result<(), FerroError> { +# let path = self.checkpoint_path.lock().unwrap().clone(); +# match path { +# Some(p) => self.checkpoint(&p), +# None => Ok(()), +# } +# } +# cli/cli.rs, the shipped server — this is why persist=on is the production row: +# 120: store.checkpoint_to(std::path::PathBuf::from(&arena_path)); +# lease_thread.rs, the lock the reap loop runs inside: +# 461:const REAP_CHUNK: usize = 1; +# +============================================================================== +ARM 1 — the branch-count curve, in-memory catalog, both persist settings. + fan/chain pin their children forever (lag = infinity); fanlag holds the lag at ONE + reap; fanreap reaps every child first (lag = zero); leaf is the forced negative. +============================================================================== +# suite lock @ before arm 1 persist=off: HELD BY THIS RUN (12:23:23Z, load 6.96 7.49 6.93) +D133 — pending-free queue length and how often it is walked. + + catalog=mem pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan off 100 400 400 100 20200 3.5 0.0349 + fan off 300 1200 1200 300 180600 17.7 0.0590 + fan off 1000 4000 4000 1000 2002000 99.1 0.0991 + fan off 3000 12000 12000 3000 18006000 662.0 0.2207 + chain off 99 396 396 99 19800 3.6 0.0364 + chain off 299 1196 1196 299 179400 15.1 0.0505 + chain off 999 3996 3996 999 1998000 100.3 0.1004 + chain off 2999 11996 11996 2999 17994000 776.0 0.2587 + fanreap off 200 0 0 0 0 0.4 0.0021 + fanreap off 600 0 0 0 0 1.3 0.0021 + fanreap off 2000 0 0 0 0 2.2 0.0011 + fanreap off 6000 0 0 0 0 5.6 0.0009 + fanlag off 200 0 4 200 800 3.2 0.0161 + fanlag off 600 0 4 600 2400 11.0 0.0183 + fanlag off 2000 0 4 2000 8000 40.6 0.0203 + fanlag off 6000 0 4 6000 24000 127.3 0.0212 + leaf off 100 0 0 0 0 0.4 0.0042 + leaf off 300 0 0 0 0 1.5 0.0050 + leaf off 1000 0 0 0 0 2.0 0.0020 + leaf off 3000 0 0 0 0 4.3 0.0014 + + 20 rows, every fan/chain row parked pages and every leaf row parked none. +# suite lock @ after arm 1 persist=off: HELD BY THIS RUN (12:23:29Z, load 6.96 7.48 6.93) +(arm 1 persist=off rc=0) + +# suite lock @ before arm 1 persist=on: HELD BY THIS RUN (12:23:29Z, load 6.96 7.48 6.93) +D133 — pending-free queue length and how often it is walked. + + catalog=mem pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan on 100 400 400 100 20200 1638.4 16.3842 + fan on 300 1200 1200 300 180600 4900.9 16.3362 + fan on 1000 4000 4000 1000 2002000 16728.3 16.7283 + fan on 3000 12000 12000 3000 18006000 59291.9 19.7640 + fanlag on 200 0 4 200 800 4582.9 22.9143 + fanlag on 600 0 4 600 2400 14478.9 24.1315 + fanlag on 2000 0 4 2000 8000 76198.3 38.0992 + fanlag on 6000 0 4 6000 24000 220686.2 36.7810 + leaf on 100 0 0 0 0 2967.1 29.6706 + leaf on 300 0 0 0 0 9864.8 32.8826 + leaf on 1000 0 0 0 0 23911.8 23.9118 + leaf on 3000 0 0 0 0 76710.4 25.5701 + + 12 rows, every fan/chain row parked pages and every leaf row parked none. +# suite lock @ after arm 1 persist=on: HELD BY THIS RUN (12:32:07Z, load 2.66 3.69 5.13) +(arm 1 persist=on rc=0 — every pass fsyncs the whole map, so this arm is the slow one. + Shapes trimmed to fan/fanlag/leaf: chain matched fan EXACTLY on every integer and + fanreap matches leaf, so running them here would hold the fleet's lock to re-derive + a duplicate. fan is the growing queue, fanlag the bounded one, leaf the fast path.) + +============================================================================== +ARM 2 — the SHIPPED catalog, at counts it can reach. D99's rule: two catalogs +must agree on the integers at a count both can reach, or one of them is the +thing being measured. +============================================================================== +# suite lock @ before arm 2: HELD BY THIS RUN (12:32:07Z, load 2.66 3.69 5.13) +D133 — pending-free queue length and how often it is walked. + + catalog=table pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + fan off 100 400 400 100 20200 1144.5 11.4450 + fan off 300 1200 1200 300 180600 5796.0 19.3200 + chain off 99 396 396 99 19800 5066.0 51.1717 + chain off 299 1196 1196 299 179400 121216.4 405.4061 + fanreap off 200 0 0 0 0 3654.1 18.2704 + fanreap off 600 0 0 0 0 8448.9 14.0815 + fanlag off 200 0 4 200 800 2502.2 12.5109 + fanlag off 600 0 4 600 2400 7414.9 12.3582 + leaf off 100 0 0 0 0 1356.5 13.5654 + leaf off 300 0 0 0 0 4460.8 14.8692 + + 10 rows, every fan/chain row parked pages and every leaf row parked none. +# suite lock @ after arm 2: HELD BY THIS RUN (12:35:22Z, load 7.67 5.35 5.49) +(arm 2 rc=0) + +============================================================================== +ARM 3 — the PAGES axis, held against ARM 1's N=300 row. pending is pages +parked, not branches reaped, so P must scale it linearly or the model is wrong. +============================================================================== +# suite lock @ before arm 3: HELD BY THIS RUN (12:35:22Z, load 7.67 5.35 5.49) + P=1 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=1 fan off 300 300 300 300 45150 4.0 0.0133 + P=4 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=4 fan off 300 1200 1200 300 180600 18.5 0.0616 + P=16 shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap + P=16 fan off 300 4800 4800 300 722400 67.3 0.2244 + +# suite lock @ after arm 3: HELD BY THIS RUN (12:35:23Z, load 7.67 5.35 5.49) + +============================================================================== +ARM 4 — a THIRD point on the shipped catalog's chain slope. Two points cannot +separate a slope from a drifting box, and this project has lost four results +to exactly that. +============================================================================== +# suite lock @ before arm 4: HELD BY THIS RUN (12:35:23Z, load 7.67 5.35 5.49) +D133 — pending-free queue length and how often it is walked. + + catalog=table pages written per branch before its child forks=4 + reap loop = `Reaper::reap` once per branch, which is what `lease_thread.rs:572` + calls inside the per-statement lock at REAP_CHUNK=1 (via `reap_if_still_expired`). + `checkpoint_to` is called AFTER padding, so setup does not pay for persistence. + pending_len is sampled inside the timed loop (one O(1) mutex read per reap). + + shape persist N pend_end pend_peak passes entry_visits total_ms ms/reap +bench/d133_run.sh: line 38: 19157 Killed: 9 "$@" +REFUSED: aborted by SIGTERM after 1999s. A sweep cut short is not a measurement. + +################################################################################################ +# ARM 4 — **ABANDONED DELIBERATELY, NOT FAILED.** Recorded as abandoned so the gap is a decision +# on the record rather than a silence someone later reads as a result. +# +# WHAT IT WOULD HAVE BEEN: chain / TableBranchCatalog / V=600 — a THIRD point on the shipped +# catalog's chain slope, where V=99 -> 51.17 ms/reap and V=299 -> 405.41 ms/reap (a 7.9x across +# 3.0x V). Two points cannot separate a slope from a drifting box; the third would have settled it. +# +# WHY IT STOPPED: started 12:35:23Z, SIGTERMed 12:48:42Z — 13 minutes with no row (a row prints +# only when the whole point completes). Estimated 8 min from V=299's per-entry cost; the overrun +# means the per-entry cost is itself rising, so the remaining time was unbounded from here. This +# run HELD /tmp/ferrodb-suite.lock the whole time and four lanes were queued behind it +# (land-d93d23b, d141-catalog-narrowing, d138-effectlog-index, plus D144). The team lead ruled: +# finish if minutes away, abandon otherwise. It was not minutes away. +# +# ⚠ WHAT IS AND IS NOT AFFECTED: nothing above this banner. ARM 4 is a REFINEMENT of a slope whose +# direction is already established by two points and by the entry-visit integers, which are exact. +# ARMS 1-3 completed with rc=0 and held the suite lock throughout. The D133 verdict does not +# depend on ARM 4 and does not change without it. +# +# ⇒ TO RESUME: bench/d133_run.sh, ARM 4 only. Budget a lock hold of 20+ minutes, or run it with +# SUITE_LOCK pointed elsewhere when no lane is queued. +################################################################################################ diff --git a/bench/d144_run.sh b/bench/d144_run.sh new file mode 100755 index 00000000..5e2d785f --- /dev/null +++ b/bench/d144_run.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# D144 — run the lease-produced lag sweep and write the raw artifact. +# +# Same lock discipline as bench/d133_run.sh, and for the same reasons: it HOLDS the suite lock +# rather than waiting for a gap that does not come, every arm runs backgrounded under `wait` so a +# signal can actually reach the trap, and the lock is released on EXIT. +# +# ⚠ This arm is WALL-CLOCK DRIVEN in a way D133's was not. D133's integers were immune to load; +# here the lease thread's sweep cadence and the fork schedule are both real time, so a box under +# load can shift the phase the whole experiment turns on. Holding the suite lock is therefore not +# hygiene here, it is a precondition. +set -u + +WT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$WT/target/release/examples/d144_lease_lag" +LABEL=d144-lease-lag +SUITE_LOCK=${SUITE_LOCK:-/tmp/ferrodb-suite.lock} +LOCK_WAIT=${LOCK_WAIT:-3600} + +SCAN_MS=${SCAN_MS:-3000} + +_HELD=0 +_release() { [ "$_HELD" = "1" ] || return 0; _HELD=0; rm -rf "$SUITE_LOCK"; } +_kill_descendants() { + local p=$1 c + for c in $(pgrep -P "$p" 2>/dev/null); do _kill_descendants "$c"; done + kill -9 "$p" 2>/dev/null +} +_abort() { + trap '' TERM INT + local c + for c in $(pgrep -P $$ 2>/dev/null); do _kill_descendants "$c"; done + _release + echo "REFUSED: aborted by SIG$1 after ${SECONDS}s. A sweep cut short is not a measurement." >&2 + exit 143 +} +trap '_release' EXIT +trap '_abort TERM' TERM +trap '_abort INT' INT + +run_arm() { "$@" & _child=$!; wait "$_child"; } + +CONTENDED=0 +suite_state() { + local owner pid + owner=$(cat "$SUITE_LOCK/owner" 2>/dev/null || echo "GONE") + pid=${owner%% *} + if [ "$pid" = "$$" ]; then + echo "# suite lock @ $1: HELD BY THIS RUN ($(date -u +%H:%M:%SZ), load $(uptime | sed 's/.*load averages*: *//'))" + else + CONTENDED=1 + echo "# suite lock @ $1: LOST — now '$owner' <- CONTAMINATED" + fi +} + +if [ ! -d /tmp/ferrodb-measure.lock ]; then + echo "REFUSED: /tmp/ferrodb-measure.lock is not held." >&2 + exit 2 +fi +if [ ! -x "$BIN" ]; then + echo "REFUSED: $BIN does not exist. Nothing would be measured." >&2 + exit 2 +fi + +_waited=0 +while ! mkdir "$SUITE_LOCK" 2>/dev/null; do + _owner=$(cat "$SUITE_LOCK/owner" 2>/dev/null || echo "unknown") + _pid=${_owner%% *} + if [ -n "$_pid" ] && [ "$_pid" != "unknown" ] && ! kill -0 "$_pid" 2>/dev/null; then + echo "$LABEL: suite lock held by dead pid $_pid — breaking it" >&2 + rm -rf "$SUITE_LOCK"; continue + fi + if [ "$_waited" -ge "$LOCK_WAIT" ]; then + echo "REFUSED: waited ${LOCK_WAIT}s for the suite lock held by: $_owner" >&2 + exit 3 + fi + [ "$_waited" -eq 0 ] && echo "$LABEL: queued behind $_owner" >&2 + sleep 5; _waited=$((_waited+5)) +done +printf '%s %s %s\n' "$$" "$LABEL" "$(date -u +%FT%TZ)" > "$SUITE_LOCK/owner" +_HELD=1 +echo "$LABEL: acquired the suite lock after ${_waited}s" >&2 + +echo "# D144 — the lag the LEASE produces. RAW ARTIFACT." +echo "#" +echo "# run at $(date -u +%Y-%m-%dT%H:%M:%SZ) UTC" +echo "# worktree $WT" +echo "# branch $(git -C "$WT" rev-parse --abbrev-ref HEAD)" +echo "# head $(git -C "$WT" rev-parse HEAD)" +echo "# tree state $(git -C "$WT" status --porcelain | wc -l | tr -d ' ') modified/untracked path(s)" +git -C "$WT" status --porcelain | sed 's/^/# /' +echo "# binary mtime $(date -u -r "$BIN" +%Y-%m-%dT%H:%M:%SZ)" +echo "# host $(uname -srm)" +echo "# measure lock $(cat /tmp/ferrodb-measure.lock/owner 2>&1)" +echo "# suite lock HELD BY THIS RUN: $(cat "$SUITE_LOCK/owner" 2>&1) (queued ${_waited}s)" +echo "#" +echo "# ⛔ THE SCAN INTERVAL THIS RUN USED IS **$SCAN_MS ms**, NOT THE SHIPPED 30 s." +echo "# Δ is swept against \$SCAN_MS, never against 30 s. The knob is the shipped one:" +grep -n 'pub const SCAN_INTERVAL_ENV\|pub const DEFAULT_SCAN_MILLIS' "$WT/src/branch/lease_thread.rs" | sed 's/^/# /' +echo "# and the harness reads it through the server's own scan_interval_from_env():" +sed -n '/pub fn scan_interval_from_env/,/^}/p' "$WT/src/branch/lease_thread.rs" | sed 's/^/# /' +echo "#" +echo "# ⛔ THE LEASE IS COMPRESSED. The shipped constant has no environment override:" +grep -n 'pub const DEFAULT_LEASE_MILLIS' "$WT/src/agent_sql/runtime.rs" | sed 's/^/# /' +grep -n 'LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS)' "$WT/src/agent_sql/runtime.rs" | sed 's/^/# /' +echo "# The window under test is exactly Δ and does not contain L, so a compressed L rescales" +echo "# WHEN the window opens and not HOW WIDE it is. The CONTROL arm varies L at fixed Δ and" +echo "# must not move; the run REFUSES if it does." +echo "#" + +echo "==============================================================================" +echo "ARM 1 — Δ swept ACROSS the scan interval S=$SCAN_MS ms, below and above." +echo "==============================================================================" +suite_state "before arm 1" +run_arm env FERRODB_LEASE_SCAN_MILLIS="$SCAN_MS" D144_LEASE_MS=20000 D144_PAIRS=60 \ + D144_DELTAS=0,500,1000,1500,2000,2500,3000,4500,6000,9000 \ + D144_CONTROL_DELTA=6000 D144_CONTROL_LEASES=20000,40000 "$BIN" +rc1=$? +suite_state "after arm 1" +echo "(arm 1 rc=$rc1)" +echo + +echo "# load after $(uptime | sed 's/.*load averages*: *//')" +echo "# arm rcs $rc1" +if [ "$rc1" -ne 0 ]; then + echo "# VERDICT: THE ARM REFUSED. Do not quote this file." + exit 2 +fi +if [ "$CONTENDED" -ne 0 ]; then + echo "# VERDICT: THIS RUN LOST THE SUITE LOCK PART-WAY. Every number here is wall-clock" + echo "# driven, so unlike D133 NOTHING in this file survives that. Do not quote it." + exit 3 +fi +echo "# the arm returned 0: every lease thread scanned, none refused, none failed, and the" +echo "# lease control did not move." diff --git a/examples/d144_lease_lag.rs b/examples/d144_lease_lag.rs new file mode 100644 index 00000000..0de5bd3f --- /dev/null +++ b/examples/d144_lease_lag.rs @@ -0,0 +1,469 @@ +//! D144 — let the LEASE produce the lag, and look for the knee at the scan interval. +//! +//! D142 parameterised the lag directly (`fan` = infinite, `fanlag` = one reap, `fanreap` = zero) +//! and said its three arms **bracket** the real lag and do not **locate** it. D144 located it from +//! source. This measures it, and nothing here fakes a clock or calls `reap`: the real +//! [`LeaseThread`] runs on a real interval, real `LeaseDeadline::from_now` deadlines expire on +//! their own, and the lag is produced by the wall-clock gap between forking a parent and forking +//! its child. +//! +//! # The derivation being tested +//! +//! A branch's deadline is fixed AT FORK (`runtime.rs:1477`, `LeaseDeadline::from_now`), and +//! nothing on the agent path extends it. For a parent forked at `t₀` and a child at `t₀ + Δ`, +//! with the same lease `L`: +//! +//! parent expires t₀ + L +//! child expires t₀ + Δ + L +//! window in which the parent is EXPIRED and the child is LIVE = exactly Δ +//! +//! A parent reaped inside that window has a live child, so it takes the slow path and PARKS its +//! pages. Reaped outside it, both are expired at the same sweep, `expired_candidates`' deepest- +//! first sort (`reaper.rs:749-751`) reaps the child first, and the parent is a childless leaf that +//! parks nothing. +//! +//! ⭐ **`L` does not appear in the window.** It sets only WHEN the window opens. That is why this +//! harness may use a compressed `L` instead of the shipped `DEFAULT_LEASE_MILLIS` (15 min, +//! `runtime.rs:96`, with no environment override), and why the `lease` control arm below varies +//! `L` at a fixed `Δ` and must NOT move. If it moves, this paragraph is wrong. +//! +//! # ⛔ TWO PREDICTIONS, PRE-REGISTERED, AND THEY DISAGREE BELOW THE INTERVAL +//! +//! **P1 — D144 as written: a STEP at `Δ = S`.** Zero parked below, everything parked above. +//! +//! **P2 — a refinement derived from the same three constants.** The parent is reaped at the FIRST +//! sweep after its deadline. That sweep lands at `t₀ + L + u` where the phase `u` is uniform on +//! `[0, S)` — which is why this harness spreads the parent forks across exactly one interval +//! rather than firing them together. The parent parks iff `u < Δ`. So over many pairs: +//! +//! parked fraction = min(Δ / S, 1) +//! +//! a linear **RAMP** over `[0, S]` and then a **PLATEAU**, i.e. a KNEE at `Δ = S` rather than a +//! step. P1 and P2 agree above `S` and disagree below it, so the sweep below `S` is what +//! discriminates them. **P2 is not the safe answer: it says D144's reassuring "zero below" is +//! wrong, and that a fork gap of half an interval already parks half the parents.** +//! +//! Both are printed beside every measured row. Whichever loses, the row says so. +//! +//! # Why the IN-MEMORY catalog here, when D142's cross-check used the shipped one +//! +//! `REAP_CHUNK`'s own doc comment measures a reap on the durable catalog at **~58 ms, fsync-bound** +//! (`lease_thread.rs`). A point here reaps 120 branches; on the shipped catalog one sweep would +//! take ~7 s against an `S` of 3 s, so sweeps would run back-to-back, the cadence would no longer +//! be `S`, and the uniform-phase argument P2 rests on would be false — the harness would be +//! measuring fsync latency wearing the costume of a lag experiment. With `LogBranchCatalog` a +//! sweep completes in milliseconds and the cadence really is `S`. D142's ARM 2 established that +//! the two catalogs agree **exactly** on every integer, which is what makes this substitution safe +//! for a COUNT and would not make it safe for a duration. No duration is claimed here. +//! +//! # What is asserted about the instrument before any row is believed +//! +//! A lease thread that never ran, or that refused because it could not read the cluster's time +//! (`lease_thread.rs:537`, `LeaseDeadline::try_now_millis`), produces `pending == 0` — which is +//! indistinguishable from "nothing parked" and would confirm P1 spuriously. So every row refuses +//! unless `attempts > 0`, `refused == 0`, `failed == 0` and `reaped > 0`. +//! +//! Usage: FERRODB_LEASE_SCAN_MILLIS=3000 d144_lease_lag + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::catalog::LogBranchCatalog; +use ferrodb::branch::lease_thread::{scan_interval_from_env, LeaseThread, RuntimeLock}; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::record::BranchRecord; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::BranchCatalog; +use ferrodb::error::FerroError; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::pgwire::ServerContext; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::MemEffectLog; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +/// How often `pending_len()` is read. One O(1) mutex read; fast enough that a park and the +/// release that follows it (at least one interval later) cannot cancel between two samples. +const SAMPLE_MS: u64 = 10; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +struct Rig { + store: Arc, + cat: Arc, + reaper: Arc, + runtime: Arc, + ctx: Arc, + dir: std::path::PathBuf, +} + +fn build(tag: &str) -> Rig { + let dir = std::env::temp_dir().join(format!("ferrodb-d144-{}-{}", std::process::id(), tag)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(true) + .open(dir.join("main.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(f).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.join("main.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let cat: Arc = Arc::new(LogBranchCatalog::in_memory(1)); + let base = bp.disk_manager.high_water().unwrap(); + let store = Arc::new(ArenaPageStore::new(bp.clone(), Arc::clone(&cat), base).unwrap()); + let runtime = Arc::new( + AgentRuntime::with_storage( + Arc::clone(&cat), + Arc::new(MemEffectLog::new()), + Arc::clone(&store) as Arc, + ) + .expect("attach arena storage"), + ); + let ctx = Arc::new(ServerContext::new(catalog, bp, txn, Arc::clone(&runtime))); + let reaper = Arc::new(TwoTierReaper::new(Arc::clone(&cat), Arc::clone(&store))); + Rig { store, cat, reaper, runtime, ctx, dir } +} + +fn write_pages(rig: &Rig, branch: BranchId, pages: u32) { + let epoch = rig.cat.next_epoch(); + for i in 0..pages { + let p = rig.store.alloc_for(branch, PageType::BTreeLeaf, epoch).unwrap(); + let handle = rig.store.read_page(p).unwrap(); + let mut frame = handle.write(); + frame.data[PAGE_HEADER_SIZE] = (i & 0xff) as u8; + stamp_checksum(&mut frame.data); + } +} + +/// Fork (and fill) a branch **under the pgwire statement lock**, which is what a real fork holds. +/// +/// `BEGIN AGENT SESSION` arrives as a statement, and `lease_thread.rs:572` runs its reaps under +/// the same mutex. Forking outside it would let a fork and a sweep overlap in a way production +/// forbids, and would make this harness's contention structure the one thing it is not allowed to +/// get wrong. `with_runtime_lock` is the trait's own entry point; the `Option` dance is what +/// `lease_thread::with_lock` does privately for the same reason. +fn fork_locked( + rig: &Rig, + parent: BranchId, + lease_ms: u64, + pages: u32, +) -> Result { + let mut out: Option> = None; + rig.ctx.with_runtime_lock(&mut || { + out = Some((|| { + let rec = rig.cat.fork(parent, LeaseDeadline::from_now(lease_ms))?; + if pages > 0 { + write_pages(rig, rec.branch_id, pages); + } + Ok(rec) + })()); + }); + out.expect("a RuntimeLock implementation must run its body exactly once") +} + +struct Row { + delta_ms: u64, + lease_ms: u64, + peak: usize, + parked_parents: usize, + realised_delta_ms: (u64, u64), + stats: ferrodb::branch::lease_thread::LeaseStats, +} + +/// One point: fork `pairs` parent/child pairs whose forks are `delta_ms` apart, let the real lease +/// thread reap them, and watch the pending-free queue. +fn run_point( + delta_ms: u64, + lease_ms: u64, + pairs: usize, + pages: u32, + scan: Duration, + refusals: &mut Vec, +) -> Row { + let rig = build(&format!("d{delta_ms}-l{lease_ms}")); + let lock: Arc = Arc::clone(&rig.ctx) as Arc; + let lease = LeaseThread::start( + Arc::clone(&rig.reaper), + Arc::clone(&rig.runtime), + lock, + scan, + ) + .expect("lease thread"); + + let scan_ms = scan.as_millis() as u64; + // Parent forks spread across EXACTLY one interval, so the phase of the first sweep after each + // parent's deadline is uniform on [0, S). Firing them together would sample one phase and + // turn a ramp into a coin flip. + let stagger_ms = scan_ms as f64 / pairs as f64; + + // (due_ms, is_child, index) + let mut events: Vec<(u64, bool, usize)> = Vec::with_capacity(pairs * 2); + for i in 0..pairs { + let t = (i as f64 * stagger_ms) as u64; + events.push((t, false, i)); + events.push((t + delta_ms, true, i)); + } + events.sort_by_key(|e| e.0); + + let mut parents: Vec> = vec![None; pairs]; + let mut parent_deadline: Vec = vec![0; pairs]; + let mut realised: Vec = Vec::with_capacity(pairs); + + // Stop once every parent is reaped and the queue has had a couple of sweeps to show itself. + // Not once the children drain: the peak is what is being measured. + let end_ms = scan_ms + delta_ms + lease_ms + 4 * scan_ms; + + let t0 = Instant::now(); + let mut peak = 0usize; + let mut prev = 0usize; + let mut increments = 0usize; + let mut next_event = 0usize; + let mut tick = 0u64; + loop { + let now_ms = t0.elapsed().as_millis() as u64; + while next_event < events.len() && events[next_event].0 <= now_ms { + let (_, is_child, i) = events[next_event]; + if !is_child { + let rec = fork_locked(&rig, BranchId::TRUNK, lease_ms, pages).unwrap(); + parent_deadline[i] = rec.lease_deadline.0; + parents[i] = Some(rec.branch_id); + } else if let Some(p) = parents[i] { + // The parent may already be reaped if delta >= lease; that is refused up front. + match fork_locked(&rig, p, lease_ms, 0) { + Ok(c) => realised.push(c.lease_deadline.0.saturating_sub(parent_deadline[i])), + Err(e) => refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: child fork {i} failed ({e}) — the \ + parent was gone before its child existed, so this row measures a \ + different experiment than the one described" + )), + } + } + next_event += 1; + } + + let l = rig.store.pending_len(); + if l > peak { + peak = l; + } + if l > prev { + increments += l - prev; + } + prev = l; + + if now_ms >= end_ms { + break; + } + // Derived from elapsed time, not incremented: a loop body that overran SAMPLE_MS (a + // fork plus its page writes does) would otherwise leave `target` permanently in the past + // and turn this into a busy spin that competes with the lease thread it is watching. + tick = now_ms / SAMPLE_MS + 1; + let target = t0 + Duration::from_millis(tick * SAMPLE_MS); + let now = Instant::now(); + if target > now { + std::thread::sleep(target - now); + } + } + + let stats = lease.stop(); + let _ = std::fs::remove_dir_all(&rig.dir); + + // A lease thread that never ran, or refused, yields pending == 0 — which reads exactly like + // "nothing parked" and would confirm P1 for the wrong reason. + if stats.attempts == 0 { + refusals.push(format!("delta={delta_ms} lease={lease_ms}: the lease thread never scanned")); + } + if stats.refused > 0 { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: {} scan(s) REFUSED — the node could not read the \ + cluster's time, so a zero here means nothing was asked, not that nothing parked", + stats.refused + )); + } + if stats.failed > 0 { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: {} scan(s) FAILED", + stats.failed + )); + } + if stats.reaped == 0 { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: the lease thread reaped NOTHING in {end_ms} ms \ + with a {lease_ms} ms lease — the fixture never reached the code under test" + )); + } + if realised.len() != pairs { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: only {} of {pairs} children were forked", + realised.len() + )); + } + + let lo = realised.iter().copied().min().unwrap_or(0); + let hi = realised.iter().copied().max().unwrap_or(0); + Row { + delta_ms, + lease_ms, + peak, + parked_parents: increments / pages as usize, + realised_delta_ms: (lo, hi), + stats, + } +} + +fn print_header(scan_ms: u64, lease_ms: u64, pairs: usize, pages: u32) { + println!("D144 — the lag the LEASE produces, swept across the scan interval."); + println!(); + println!(" scan interval S {scan_ms} ms (from scan_interval_from_env(), i.e. \ + FERRODB_LEASE_SCAN_MILLIS or the {} ms default)", + ferrodb::branch::lease_thread::DEFAULT_SCAN_MILLIS); + println!(" lease L {lease_ms} ms (COMPRESSED from DEFAULT_LEASE_MILLIS = {} ms, \ + which has no env override;", ferrodb::agent_sql::runtime::DEFAULT_LEASE_MILLIS); + println!(" L does not enter the window, which is exactly Δ — the `lease` \ + control arm tests that claim)"); + println!(" pairs per point {pairs}, parent forks spread across exactly one S so the sweep \ + phase is uniform"); + println!(" pages per parent {pages}, written BEFORE the child forks so the child can see them"); + println!(" reaping the REAL LeaseThread on a real clock. No faked time, no direct \ + reap() call."); + println!(); + println!(" P1 = D144 as written: a STEP — 0 parked below S, all parked above."); + println!(" P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S."); + println!(); + println!( + " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", + "scans a/s/r/f/reaped" + ); +} + +fn print_row(r: &Row, scan_ms: u64, pairs: usize) { + let ratio = r.delta_ms as f64 / scan_ms as f64; + let p1 = if r.delta_ms > scan_ms { pairs } else { 0 }; + let p2 = (pairs as f64 * ratio.min(1.0)).round() as usize; + println!( + " {:>8} {:>8} {:>10.3} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + r.delta_ms, + r.lease_ms, + ratio, + r.parked_parents, + p1, + p2, + r.peak, + format!("{}..{}", r.realised_delta_ms.0, r.realised_delta_ms.1), + format!( + "{}/{}/{}/{}/{}", + r.stats.attempts, r.stats.scans, r.stats.refused, r.stats.failed, r.stats.reaped + ), + ); +} + +fn main() { + let scan = scan_interval_from_env().expect("scan interval"); + let scan_ms = scan.as_millis() as u64; + let lease_ms: u64 = env_or("D144_LEASE_MS", "20000").parse().unwrap(); + let pairs: usize = env_or("D144_PAIRS", "60").parse().unwrap(); + let pages: u32 = env_or("D144_PAGES", "4").parse().unwrap(); + let deltas: Vec = env_or( + "D144_DELTAS", + "0,500,1000,1500,2000,2500,3000,4500,6000,9000", + ) + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + let control_leases: Vec = env_or("D144_CONTROL_LEASES", "20000,40000") + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + let control_delta: u64 = env_or("D144_CONTROL_DELTA", &(scan_ms * 2).to_string()) + .parse() + .unwrap(); + + if deltas.is_empty() || pairs == 0 { + eprintln!("REFUSED: empty sweep."); + std::process::exit(2); + } + if let Some(bad) = deltas.iter().chain(std::iter::once(&control_delta)).find(|d| **d >= lease_ms) + { + eprintln!( + "REFUSED: Δ={bad} ms is not less than the lease {lease_ms} ms. The parent would be \ + reaped before its child is forked, which is a different experiment." + ); + std::process::exit(2); + } + + print_header(scan_ms, lease_ms, pairs, pages); + let mut refusals = Vec::new(); + let mut rows = 0usize; + + for &d in &deltas { + let r = run_point(d, lease_ms, pairs, pages, scan, &mut refusals); + print_row(&r, scan_ms, pairs); + rows += 1; + } + + println!(); + println!(" CONTROL — L varied at a fixed Δ={control_delta} ms. L is not in the window, so"); + println!(" these rows MUST agree. If they do not, the derivation above is wrong."); + println!( + " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", + "scans a/s/r/f/reaped" + ); + let mut control_parked = Vec::new(); + for &l in &control_leases { + let r = run_point(control_delta, l, pairs, pages, scan, &mut refusals); + print_row(&r, scan_ms, pairs); + control_parked.push(r.parked_parents); + rows += 1; + } + + println!(); + println!(" realised Δ is computed from the DEADLINES the catalog recorded"); + println!(" (child.lease_deadline - parent.lease_deadline), not from the requested value."); + + if rows == 0 { + eprintln!("REFUSED: zero rows."); + std::process::exit(2); + } + if !refusals.is_empty() { + eprintln!("REFUSED — {} problem(s):", refusals.len()); + for r in &refusals { + eprintln!(" - {r}"); + } + std::process::exit(2); + } + // The control is only a control if something checks it. L is not in the window, so varying + // it at a fixed Δ must not move the parked count; a spread wider than a tenth of the + // population means the window is a function of something this harness does not model. + if control_parked.len() >= 2 { + let lo = *control_parked.iter().min().unwrap(); + let hi = *control_parked.iter().max().unwrap(); + let tol = (pairs as f64 * 0.10).ceil() as usize; + if hi - lo > tol { + eprintln!( + "REFUSED: the LEASE control MOVED — parked counts {control_parked:?} across \ + leases {control_leases:?} at a fixed Δ={control_delta} ms, a spread of {} > {} \ + tolerated. L is supposed not to enter the window; it does, so the derivation in \ + this file's header is wrong and no row above should be quoted.", + hi - lo, + tol + ); + std::process::exit(2); + } + println!(" CONTROL HELD: parked {control_parked:?} across leases {control_leases:?} — \ + varying L did not move the window."); + } + println!(" {rows} rows, every lease thread scanned, none refused, none failed."); +} From 74266ea15e289cd63fcb78c8f84eb01d021781df Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:53:20 -0400 Subject: [PATCH 050/315] D144: fix the parked metric -- a sum of increments double-counts across a drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by a smoke run reporting 12 parents parked out of 10 pairs. drain_pending_seeded is a read-modify-write: take_pending is std::mem::take of the whole vec (arena.rs:800), so pending_len() drops to ZERO for the length of the walk and put_pending then restores the survivors. A sampler landing in that window reads the restore as a fresh park. The queue's own internal transient, counted as new work. peak/pages is immune -- a transient dip cannot raise a maximum -- and equals the total here because every parent expires within one interval of every other and a parked entry outlives at least one sweep. That is a property of this fixture and is stated as one. The increment sum is kept as a diagnostic column: exceeding peak is the signature of a mid-drain sample. Also refuses if peak exceeds pairs x pages, which is more parked than exists. Smoke at S=1000, 10 pairs: Δ/S=0 -> 0 parked; Δ/S=0.5 -> 4 parked against P1's 0 and P2's 5; Δ/S=2 -> 10 (all). P1 already refuted below the interval. --- examples/d144_lease_lag.rs | 41 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/examples/d144_lease_lag.rs b/examples/d144_lease_lag.rs index 0de5bd3f..122d6ae6 100644 --- a/examples/d144_lease_lag.rs +++ b/examples/d144_lease_lag.rs @@ -91,6 +91,19 @@ use ferrodb::wal::txn::TxnManager; /// release that follows it (at least one interval later) cannot cancel between two samples. const SAMPLE_MS: u64 = 10; +/// ⛔ **Why the parked count is `peak / pages` and NOT a sum of the queue's positive increments.** +/// +/// The increment sum was tried first and is WRONG, caught by a smoke run that reported **12** +/// parents parked out of **10** pairs. `drain_pending_seeded` is a read-modify-write: +/// `take_pending` is `std::mem::take` of the whole vec (`arena.rs:800`), so `pending_len()` drops +/// to **zero** for the length of the walk, and `put_pending` then restores the survivors +/// (`arena.rs:810`). A sampler that lands inside that window sees the restore as a FRESH park and +/// counts it again — the queue's own internal transient read as new work. +/// +/// `peak` is immune: a transient dip cannot raise a maximum. And peak == total here because every +/// parent expires within one interval of every other and a parked entry outlives at least one +/// sweep, so the parked intervals overlap. That is a property of this fixture, not a general one. + fn env_or(key: &str, default: &str) -> String { std::env::var(key).unwrap_or_else(|_| default.to_string()) } @@ -178,6 +191,9 @@ struct Row { lease_ms: u64, peak: usize, parked_parents: usize, + /// Diagnostic only — see the note on [`SAMPLE_MS`]. Above `parked_parents` means a sample + /// landed inside a drain's take/put window. + increment_sum_parents: usize, realised_delta_ms: (u64, u64), stats: ferrodb::branch::lease_thread::LeaseStats, } @@ -230,7 +246,6 @@ fn run_point( let mut prev = 0usize; let mut increments = 0usize; let mut next_event = 0usize; - let mut tick = 0u64; loop { let now_ms = t0.elapsed().as_millis() as u64; while next_event < events.len() && events[next_event].0 <= now_ms { @@ -257,6 +272,8 @@ fn run_point( if l > peak { peak = l; } + // Kept only as a DIAGNOSTIC upper bound; see the note on SAMPLE_MS for why it is not the + // reported figure. It exceeding `peak` is the signature of a sample landing mid-drain. if l > prev { increments += l - prev; } @@ -268,7 +285,7 @@ fn run_point( // Derived from elapsed time, not incremented: a loop body that overran SAMPLE_MS (a // fork plus its page writes does) would otherwise leave `target` permanently in the past // and turn this into a busy spin that competes with the lease thread it is watching. - tick = now_ms / SAMPLE_MS + 1; + let tick = now_ms / SAMPLE_MS + 1; let target = t0 + Duration::from_millis(tick * SAMPLE_MS); let now = Instant::now(); if target > now { @@ -303,6 +320,12 @@ fn run_point( with a {lease_ms} ms lease — the fixture never reached the code under test" )); } + if peak > pairs * pages as usize { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: peak pending {peak} exceeds {pairs} pairs x \ + {pages} pages — more was parked than exists, so the metric is not measuring parks" + )); + } if realised.len() != pairs { refusals.push(format!( "delta={delta_ms} lease={lease_ms}: only {} of {pairs} children were forked", @@ -316,7 +339,8 @@ fn run_point( delta_ms, lease_ms, peak, - parked_parents: increments / pages as usize, + parked_parents: peak / pages as usize, + increment_sum_parents: increments / pages as usize, realised_delta_ms: (lo, hi), stats, } @@ -342,9 +366,9 @@ fn print_header(scan_ms: u64, lease_ms: u64, pairs: usize, pages: u32) { println!(" P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S."); println!(); println!( - " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", - "scans a/s/r/f/reaped" + "scans a/s/r/f/reaped", "incsum" ); } @@ -353,7 +377,7 @@ fn print_row(r: &Row, scan_ms: u64, pairs: usize) { let p1 = if r.delta_ms > scan_ms { pairs } else { 0 }; let p2 = (pairs as f64 * ratio.min(1.0)).round() as usize; println!( - " {:>8} {:>8} {:>10.3} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + " {:>8} {:>8} {:>10.3} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", r.delta_ms, r.lease_ms, ratio, @@ -366,6 +390,7 @@ fn print_row(r: &Row, scan_ms: u64, pairs: usize) { "{}/{}/{}/{}/{}", r.stats.attempts, r.stats.scans, r.stats.refused, r.stats.failed, r.stats.reaped ), + r.increment_sum_parents, ); } @@ -417,9 +442,9 @@ fn main() { println!(" CONTROL — L varied at a fixed Δ={control_delta} ms. L is not in the window, so"); println!(" these rows MUST agree. If they do not, the derivation above is wrong."); println!( - " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22}", + " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", - "scans a/s/r/f/reaped" + "scans a/s/r/f/reaped", "incsum" ); let mut control_parked = Vec::new(); for &l in &control_leases { From 680f1accd4ac17d58abdf2d02584e321190fc12a Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 08:56:49 -0400 Subject: [PATCH 051/315] D138 measurement arm SHIP: the same instrument over 66e726c, the commit that lands Quarantined instrumentation; never lands. The d138_new_raw.txt arm measured dfe2c70; three commits of review-pass fixes followed it. Only one touched production code (Frames::push, debug_assert+insert -> entry match), and it walks no frames either way -- but 'provably unchanged by inspection' is what a cheap re-run exists to replace. Harness and scan_count module are byte-identical to the d138-measure-new arm (checked: 0 diff lines, module diffed). --- examples/d129_effect_log_scan_count.rs | 662 +++++++++++++++++++++++++ src/tel/log.rs | 216 +++++++- 2 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 examples/d129_effect_log_scan_count.rs diff --git a/examples/d129_effect_log_scan_count.rs b/examples/d129_effect_log_scan_count.rs new file mode 100644 index 00000000..8fd2e265 --- /dev/null +++ b/examples/d129_effect_log_scan_count.rs @@ -0,0 +1,662 @@ +//! D129 — how many frames the effect log's linear searches walk, per `append`, bucketed HIT/MISS. +//! +//! `cargo run --release --example d129_effect_log_scan_count` +//! +//! # What this is and is not +//! +//! ⛔ **It is a COUNT, deliberately, and it opens with no timing.** `SCALE-DESIGN.md` D129 says so +//! in its own words, and the reason is on the record: this box is shared and three results on this +//! project have died of a duration measured beside other work. A scan length is a property of the +//! data structure and the workload; it does not move when somebody else's `cargo` starts. +//! +//! ⛔ **Through pgwire, over a real TCP socket.** `serve` is spawned on this process's own +//! listener and every statement below arrives as a `Query` message on a socket, so the statement +//! takes the locks, the session plumbing and the designated runtime that production takes. E.6 +//! established that a harness driving `AgentRuntime` directly is not that layer, and D101 +//! established that `Session::new()` beside a `ServerContext` silently runs on a private stub. +//! Neither is reachable from here: this harness holds no `Session` at all — the server builds them +//! from `ctx.session()` on the connection thread. +//! +//! The client is written to the v3 protocol in this file rather than reusing `pgwire::message`'s +//! encoder. `tests/pg/pg_client.py` makes the independence argument for the protocol's own sake; +//! here it is simply the shortest way to get a socket that the server treats as a client, and the +//! claim being measured is about what happens **below** the wire, not about the wire. +//! +//! # The three sites, and why the bucketing is the instrument +//! +//! `MemEffectLog::frames` is a `Vec` keyed by `(branch, txn_id)` and searched +//! front-to-back in three places (`scan_count` in `src/tel/log.rs` names them). The searches +//! short-circuit, so a **hit** costs `index + 1` and a **miss** costs the whole Vec. A mean over +//! both hides which one the workload has, so they are counted apart. +//! +//! ⚠ And the short-circuit's value depends on *where the key sits*. `append` pushes a new frame to +//! the **back**, and a scan starts at the **front** — so the frame an agent re-appends is the one +//! furthest from the start. Whether that makes a hit as expensive as a miss is a fact about this +//! workload, and it is one of the pre-registered outcomes rather than an assumption. + +use std::io::{BufReader, Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::Arc; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::{BranchCatalog, TableBranchCatalog}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::PageStore; +use ferrodb::pgwire::{serve, ServerContext}; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::log::scan_count::{self, SiteCount, Snapshot}; +use ferrodb::branch::types::{BranchId, CommitHash}; +use ferrodb::tel::{EffectLog, MemEffectLog, TxnFrame, TxnId}; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +/// Rows in the base table. NOT an axis — small and fixed. The axis is the session count. +const ROWS: i64 = 200; +/// Writes inside one agent session on the main axis. **Three, not one**: the first append of a +/// session is the miss and the rest are hits, so a session of one write would populate only one +/// bucket and the comparison the row turns on could not be made. +const WRITES_PER_SESSION: usize = 3; + +fn env_usize(k: &str, d: usize) -> usize { + std::env::var(k).ok().and_then(|v| v.parse().ok()).unwrap_or(d) +} + +// ================================================================================================= +// A PostgreSQL v3 client, simple query protocol only +// ================================================================================================= + +struct Wire { + w: TcpStream, + r: BufReader, +} + +const SSL_REQUEST: i32 = 80877103; +const PROTOCOL_V3: i32 = 196608; + +impl Wire { + fn open(addr: SocketAddr) -> Result { + let s = TcpStream::connect(addr).map_err(|e| format!("connect: {e}"))?; + s.set_nodelay(true).ok(); + let r = BufReader::new(s.try_clone().map_err(|e| e.to_string())?); + let mut c = Wire { w: s, r }; + c.startup()?; + Ok(c) + } + + /// One backend message: a 1-byte tag, then a length that includes itself but not the tag. + fn msg(&mut self) -> Result<(u8, Vec), String> { + let mut tag = [0u8; 1]; + self.r.read_exact(&mut tag).map_err(|e| format!("read tag: {e}"))?; + let mut len = [0u8; 4]; + self.r.read_exact(&mut len).map_err(|e| format!("read len: {e}"))?; + let n = i32::from_be_bytes(len); + if n < 4 { + return Err(format!("message claims {n} bytes, which cannot hold its own header")); + } + let mut body = vec![0u8; (n - 4) as usize]; + self.r.read_exact(&mut body).map_err(|e| format!("read body: {e}"))?; + Ok((tag[0], body)) + } + + fn startup(&mut self) -> Result<(), String> { + // Every real client probes for TLS first and the server answers with a bare byte, outside + // the tagged framing. Skipping it would desynchronise the stream, not merely skip a step. + let mut probe = Vec::new(); + probe.extend_from_slice(&8i32.to_be_bytes()); + probe.extend_from_slice(&SSL_REQUEST.to_be_bytes()); + self.w.write_all(&probe).map_err(|e| e.to_string())?; + self.w.flush().map_err(|e| e.to_string())?; + let mut b = [0u8; 1]; + self.r.read_exact(&mut b).map_err(|e| format!("ssl reply: {e}"))?; + if b[0] != b'N' { + return Err(format!("expected a TLS refusal, got {:?}", b[0] as char)); + } + + let mut params = Vec::new(); + for kv in ["user", "ferro", "database", "ferro", "application_name", "d129"] { + params.extend_from_slice(kv.as_bytes()); + params.push(0); + } + params.push(0); + let mut pkt = Vec::new(); + pkt.extend_from_slice(&((params.len() + 8) as i32).to_be_bytes()); + pkt.extend_from_slice(&PROTOCOL_V3.to_be_bytes()); + pkt.extend_from_slice(¶ms); + self.w.write_all(&pkt).map_err(|e| e.to_string())?; + self.w.flush().map_err(|e| e.to_string())?; + + loop { + let (tag, body) = self.msg()?; + match tag { + b'Z' => return Ok(()), + b'E' => return Err(format!("error during startup: {}", field(&body, b'M'))), + _ => {} + } + } + } + + /// One simple query, run to `ReadyForQuery`. `Err` carries the server's own message, never one + /// invented here — a refusal this harness paraphrased would be a refusal it could mis-read. + fn q(&mut self, sql: &str) -> Result<(), String> { + let mut pkt = vec![b'Q']; + pkt.extend_from_slice(&((sql.len() + 5) as i32).to_be_bytes()); + pkt.extend_from_slice(sql.as_bytes()); + pkt.push(0); + self.w.write_all(&pkt).map_err(|e| e.to_string())?; + self.w.flush().map_err(|e| e.to_string())?; + let mut err: Option = None; + loop { + let (tag, body) = self.msg()?; + match tag { + b'Z' => break, + b'E' => err = Some(field(&body, b'M')), + _ => {} + } + } + match err { + Some(e) => Err(e), + None => Ok(()), + } + } +} + +impl Drop for Wire { + fn drop(&mut self) { + let _ = self.w.write_all(&[b'X', 0, 0, 0, 4]); + let _ = self.w.flush(); + } +} + +/// One field out of an `ErrorResponse` body, which is NUL-separated `` chunks. +fn field(body: &[u8], want: u8) -> String { + for chunk in body.split(|b| *b == 0) { + if chunk.first() == Some(&want) { + return String::from_utf8_lossy(&chunk[1..]).into_owned(); + } + } + String::from_utf8_lossy(body).into_owned() +} + +// ================================================================================================= +// The server under test +// ================================================================================================= + +struct Server { + addr: SocketAddr, + log: Arc, + /// Kept alive for the life of the arm. Dropping it would let `designated` retire the runtime + /// the serving threads are still using. + _ctx: Arc, +} + +/// Wired exactly as `examples/pgserver.rs` wires the server: an arena-backed `AgentRuntime` behind +/// a `ServerContext`, with `MemEffectLog` — which is the effect log the pgwire server actually +/// constructs. (`src/cli/cli.rs` uses `DurableEffectLog`; that store's `append` calls +/// `classify_append` **and then** `MemEffectLog::append`, so it pays both of the sites counted +/// here. The `SITE_CLASSIFY` column below is what says so rather than an assertion in prose.) +fn build(dir: &std::path::Path, tag: &str) -> Server { + let d = dir.join(tag); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(d.join("main.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(d.join("main.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let cat = Arc::new(TableBranchCatalog::open_sidecar(&d.join("b.branchcat"), 1).unwrap()); + let branches: Arc = cat.clone(); + // Same fixed floor and the same reason as D68/D114: taking `high_water()` here would put the + // arena at page 2 and leave the ordinary table nowhere to grow. + const ARENA_BASE: u32 = 1024; + let store = Arc::new(ArenaPageStore::new(bp.clone(), branches.clone(), ARENA_BASE).unwrap()); + // ⚠ `with_storage`, NOT `with_catalog`: `with_catalog` leaves `storage: None`, so the branch + // engine is absent and agent writes never reach arena pages. D67 withdrew numbers over that. + let log = Arc::new(MemEffectLog::new()); + let runtime = Arc::new( + AgentRuntime::with_storage( + branches, + Arc::clone(&log) as Arc, + Arc::clone(&store) as Arc, + ) + .expect("attach arena storage"), + ); + let ctx = Arc::new(ServerContext::new(catalog, bp.clone(), txn.clone(), runtime)); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().unwrap(); + let serve_ctx = Arc::clone(&ctx); + std::thread::spawn(move || { + let _ = serve(listener, serve_ctx); + }); + + let s = Server { addr, log, _ctx: ctx }; + + // Base table, over the wire like everything else. + let mut c = Wire::open(s.addr).expect("first connection"); + c.q("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);").expect("create"); + for i in 1..=ROWS { + c.q(&format!("INSERT INTO t VALUES ({i}, {});", i * 7)).expect("insert"); + } + s +} + +/// One agent's whole life: a fresh socket, a fresh branch, `writes` writes, and — unless `merge` — +/// no ending at all, which is the state an abandoned agent leaves behind. +/// +/// A distinct agent name per session on purpose: this is the fork-heavy shape, so every session is +/// a different branch. (d114 reuses ONE name because it merges repeatedly into one lineage and the +/// provenance slot refuses redeclaration; d75 parks thousands of branches under distinct names, +/// which is the case here.) +fn one_session(s: &Server, k: usize, writes: usize, merge: bool) -> Result<(), String> { + let mut c = Wire::open(s.addr)?; + c.q(&format!("BEGIN AGENT SESSION AS 'a{k}';"))?; + for w in 0..writes { + let id = 1 + ((k * 97 + w) as i64) % ROWS; + c.q(&format!("UPDATE t SET v = {} WHERE id = {id};", k as i64))?; + } + if merge { + c.q("MERGE;")?; + } + Ok(()) +} + +// ================================================================================================= +// Reporting +// ================================================================================================= + +fn f(v: Option) -> String { + match v { + Some(x) => format!("{x:>10.1}"), + // ⛔ Never "0.0". A bucket with no calls in it is not a bucket that was cheap. + None => " -".to_string(), + } +} + +fn site_row(name: &str, c: &SiteCount) { + println!( + " {name:<26} calls {:>7} ({:>7} hit / {:>7} miss) scanned/hit {} scanned/miss {}", + c.calls(), + c.hit_calls, + c.miss_calls, + f(c.per_hit()), + f(c.per_miss()), + ); +} + +fn report(label: &str, d: &Snapshot) { + println!(" {label}"); + site_row("MemEffectLog::append", &d.append()); + site_row("classify_append", &d.classify()); + site_row("frame()", &d.frame()); + // The fourth site. `grep` finds no production caller — only `tests_durable_log.rs` — so a zero + // here is the expected reading and it is printed rather than assumed: a site left out of the + // instrument is a site the instrument reports as absent. + site_row("frames_for() [no prod caller]", &d.frames_for()); +} + +// ================================================================================================= +// Fire-checks — run BEFORE the axis, and the axis is not printed if they do not pass +// ================================================================================================= + +/// Did the counter count, and do the two buckets separate? +/// +/// ⛔ A counter reading 0 is indistinguishable from a path that never ran, so the axis below is +/// worthless until this has shown the counter moving on a workload built to move it — and moving +/// in the *right bucket*, which a single total could not show. +fn fire_checks(dir: &std::path::Path) -> bool { + let n = env_usize("D129_FIRECHECK_N", 200); + let mut ok = true; + println!("=== FIRE-CHECK — force the counter to fire, and separate the buckets ==="); + println!(); + + // ⭐ (0) THE INSTRUMENT CAN REPORT A NON-ZERO SCAN — established INSIDE this binary. + // + // ADDED FOR D138, and it is the only change to this harness. Every number below is a scan + // length, and D138's claim is that one of them becomes exactly ZERO. **A zero from a counter + // that has been accidentally disconnected reads identically to a zero from a scan that no + // longer happens** — and the before/after arms are two different binaries, so the OLD arm's + // non-zero cannot vouch for the NEW arm's instrument. + // + // So drive a site that still scans linearly in BOTH arms — `frames_for`, which D138 + // deliberately leaves alone — and require it to report the exact length it was handed. If + // this fails, nothing further in this run is a reading of anything. + { + let n_ctl = 64u64; + let log = MemEffectLog::new(); + for txn in 0..n_ctl { + let f = TxnFrame::new(TxnId(txn), BranchId::new(1, 0), CommitHash::ZERO, 0, 1); + log.append(&f).expect("control append"); + } + let before = scan_count::snapshot(); + let got = log.frames_for(BranchId::new(1, 0), 0).expect("control frames_for"); + let d = scan_count::snapshot().since(&before).frames_for(); + let pass = d.calls() == 1 && d.scanned() == n_ctl && got.len() as u64 == n_ctl; + println!(" (0) INSTRUMENT LIVE — a site that still scans linearly, in this same binary"); + println!( + " frames_for over {n_ctl} frames: calls {} scanned {} returned {} (want 1 / {n_ctl} / {n_ctl}) => {}", + d.calls(), + d.scanned(), + got.len(), + if pass { "PASS" } else { "FAIL" } + ); + println!(" ⇒ a zero at any OTHER site below is that site's, not this counter's."); + ok &= pass; + } + println!(); + + // (1) MISS-ONLY. Each session writes exactly once under a `(branch, txn_id)` that has never + // been appended, so every session must contribute at least one miss. + let miss_arm = { + let s = build(dir, "fc_miss"); + let before = scan_count::snapshot(); + for k in 0..n { + one_session(&s, k, 1, false).unwrap_or_else(|e| panic!("miss-only session {k}: {e}")); + } + let d = scan_count::snapshot().since(&before); + report(&format!("(1) MISS-ONLY — {n} sessions x 1 write"), &d); + println!(" log length after: {} frames", s.log.len()); + let a = d.append(); + let pass = a.miss_calls >= n as u64; + println!( + " => {} : miss_calls {} (want >= {n}, one per new key)", + if pass { "PASS" } else { "FAIL" }, + a.miss_calls + ); + ok &= pass; + a + }; + println!(); + + // (2) HIT-HEAVY. ONE session, many writes. `stage_all` re-appends the open frame once per + // statement, so after the first append every one of these finds its key. + let hit_arm = { + let s = build(dir, "fc_hit"); + let before = scan_count::snapshot(); + one_session(&s, 0, n, false).unwrap_or_else(|e| panic!("hit-heavy session: {e}")); + let d = scan_count::snapshot().since(&before); + report(&format!("(2) HIT-HEAVY — 1 session x {n} writes"), &d); + let a = d.append(); + let pass = a.hit_calls >= (n as u64) / 2 && a.hit_calls > a.miss_calls; + println!( + " => {} : hit_calls {} vs miss_calls {} (want hits dominant)", + if pass { "PASS" } else { "FAIL" }, + a.hit_calls, + a.miss_calls + ); + ok &= pass; + a + }; + println!(); + + // ⭐ THE SEPARATION, stated ACROSS the two arms rather than within either. + // + // A within-arm ratio would be the wrong test: if `BEGIN AGENT SESSION` itself appends a frame, + // arm (1) produces a hit per session as well as a miss per session, and a "misses dominate" + // gate would fail for a reason that has nothing to do with the counter being right. What + // distinguishes the two workloads is **misses per session** — arm (1) opens `n` keys and arm + // (2) opens one — and a counter that could not tell them apart would not be an instrument. + { + let per_session_1 = miss_arm.miss_calls as f64 / n as f64; + let per_session_2 = hit_arm.miss_calls as f64; + let pass = miss_arm.miss_calls >= 10 * hit_arm.miss_calls.max(1); + println!( + " (1)v(2) SEPARATION: misses/session {per_session_1:.2} (n sessions) vs \ + {per_session_2:.2} (1 session) => {}", + if pass { "PASS" } else { "FAIL" } + ); + println!( + " the buckets are not one number: arm(1) {} miss / {} hit, arm(2) {} miss / {} hit", + miss_arm.miss_calls, miss_arm.hit_calls, hit_arm.miss_calls, hit_arm.hit_calls + ); + ok &= pass; + } + println!(); + + // (3) ANTI-VACUITY — the counter must NOT fire on everything. Plain SQL on trunk, no agent + // session anywhere. Without this, a counter wired to some unrelated hot path would pass (1) + // and (2) on volume alone and the axis would be measuring the wrong thing. + { + let s = build(dir, "fc_none"); + let before = scan_count::snapshot(); + let mut c = Wire::open(s.addr).expect("connect"); + for i in 1..=(n as i64) { + let id = 1 + (i * 13) % ROWS; + c.q(&format!("UPDATE t SET v = {i} WHERE id = {id};")).expect("plain update"); + } + drop(c); + let d = scan_count::snapshot().since(&before); + report(&format!("(3) NO AGENT SESSION — {n} plain UPDATEs on trunk"), &d); + let pass = d.append().calls() == 0; + println!( + " => {} : append calls {} (want 0 — a non-agent write appends no frame)", + if pass { "PASS" } else { "FAIL" }, + d.append().calls() + ); + ok &= pass; + } + println!(); + ok +} + +// ================================================================================================= +// The axis +// ================================================================================================= + +/// `blocks` blocks of `block` sessions each, snapshotting between them. +/// +/// The counters are cumulative since process start, so each row is a *delta* over its own block — +/// a running total would rise whatever the per-append cost did, which is the mistake this shape +/// exists to avoid. What a row reports is therefore the scan length **paid by the appends in that +/// block**, against the number of sessions that ran before it. +fn axis(dir: &std::path::Path, tag: &str, what: &str, merge: bool, blocks: usize, block: usize) -> bool { + let s = build(dir, tag); + println!("=== AXIS: {what} ==="); + // ⭐ D138 — `shadow/*` is what `append`'s pre-D138 LINEAR scan WOULD have walked, taken on the + // same call as the real `scan/*` beside it. In the unindexed arm nothing records it and it + // prints `-`; in the indexed arm it must keep D137's slope-1 law while `scan/*` reads 0.0. + println!( + " {:>9} {:>9} {:>8} {:>8} {:>12} {:>12} {:>12} {:>12} {:>12}", + "sessions", "log_len", "hits", "misses", "scan/hit", "scan/miss", "shadow/hit", + "shadow/miss", "scanned" + ); + let mut total_appends = 0u64; + let mut rows: Vec<(usize, f64, f64)> = Vec::new(); + let mut shadow_rows: Vec<(usize, Option, Option, u64)> = Vec::new(); + let mut mismatches = 0u64; + for b in 0..blocks { + let before = scan_count::snapshot(); + let done_before = b * block; + for k in 0..block { + let id = done_before + k; + if let Err(e) = one_session(&s, id, WRITES_PER_SESSION, merge) { + println!("⛔ session {id} failed: {e}"); + return false; + } + } + let d = scan_count::snapshot().since(&before); + let a = d.append(); + total_appends += a.calls(); + let sh = d.append_shadow(); + println!( + " {:>9} {:>9} {:>8} {:>8} {} {} {} {} {:>12}", + done_before, + s.log.len(), + a.hit_calls, + a.miss_calls, + f(a.per_hit()), + f(a.per_miss()), + f(sh.per_hit()), + f(sh.per_miss()), + a.scanned(), + ); + shadow_rows.push((done_before, sh.per_hit(), sh.per_miss(), sh.calls())); + mismatches += d.mismatches; + if let (Some(h), Some(m)) = (a.per_hit(), a.per_miss()) { + rows.push((done_before, h, m)); + } + } + if total_appends == 0 { + println!("⛔ ZERO appends across the whole axis. Not a result."); + return false; + } + println!(" final log length: {} frames, never pruned", s.log.len()); + if let (Some(first), Some(last)) = (rows.first(), rows.last()) { + println!( + " growth over the axis: scan/hit {:.1} -> {:.1} ({:.1}x), scan/miss {:.1} -> {:.1} ({:.1}x)", + first.1, + last.1, + last.1 / first.1.max(1e-9), + first.2, + last.2, + last.2 / first.2.max(1e-9), + ); + } + // ⭐ D138 — the SHADOW's own growth, and the equality that licenses reading `scan/*` at all. + let shadow_calls: u64 = shadow_rows.iter().map(|r| r.3).sum(); + if shadow_calls == 0 { + println!( + " shadow: NOT RECORDED in this arm ({shadow_calls} calls) — there is no index to \ + shadow, so the `scan/*` columns above ARE the linear scan." + ); + } else { + let first = shadow_rows.iter().find(|r| r.1.is_some()); + let last = shadow_rows.iter().rev().find(|r| r.1.is_some()); + if let (Some(a), Some(b)) = (first, last) { + println!( + " shadow growth (the scan D138 REMOVED, still measured): scan/hit {:.1} -> {:.1}, \ + scan/miss {:.1} -> {:.1} over {shadow_calls} calls", + a.1.unwrap(), + b.1.unwrap(), + a.2.unwrap_or(f64::NAN), + b.2.unwrap_or(f64::NAN), + ); + } + // \u26d4 An equality checked on EVERY append of this axis, not sampled. + println!( + " index-vs-scan disagreements over this axis: {mismatches} => {}", + if mismatches == 0 { "PASS (the index answered what the log holds, every time)" } else { "FAIL" } + ); + if mismatches != 0 { + println!("⛔ the index disagreed with a linear scan. This arm is not a result."); + return false; + } + } + println!(); + true +} + +// ================================================================================================= +// ADDENDUM — the DURABLE store, which is NOT what pgwire builds +// ================================================================================================= + +/// ⚠ **This arm is NOT through pgwire, and saying so is the point.** +/// +/// `examples/pgserver.rs` hands the runtime a `MemEffectLog`, so the axis above — which is the +/// pgwire measurement the row asked for — can only ever exercise `MemEffectLog::append`. +/// `src/cli/cli.rs` hands it `DurableEffectLog::default_for_database`. That is a different store +/// with the same index inside it, and it reaches the counted sites **twice per append**: +/// `classify_append` decides what bytes are new, and `MemEffectLog::append` then accepts the frame. +/// +/// It also replays the whole file at `open`, one `mem.append` per record, and every one of those +/// pays a scan of everything replayed so far. D115's agent asserted that consequence in prose; this +/// measures it. The store is driven directly here because there is no wire path to it. +fn durable_addendum(dir: &std::path::Path) -> bool { + use ferrodb::branch::types::{BranchId, CommitHash}; + use ferrodb::tel::{Delta, DurableEffectLog, Op, OpKind, TxnFrame}; + use ferrodb::tel::{ColId, RowId, TableId, TxnId}; + + let k = env_usize("D129_DURABLE_FRAMES", 1000) as u64; + let path = dir.join("durable.tel"); + let _ = std::fs::remove_file(&path); + + println!("=== ADDENDUM: DurableEffectLog — the store `src/cli/cli.rs` ships (NOT pgwire's) ==="); + println!(" {k} frames, each a distinct (branch, txn_id), appended then replayed at open."); + + let frame = |i: u64| { + let mut f = TxnFrame::new(TxnId(i), BranchId::new(i, 0), CommitHash::ZERO, 0, 1); + f.push_op(Op::new(TableId(1), RowId(i), Some(ColId(2)), OpKind::Add(Delta::Int(-1)))); + f + }; + + let before = scan_count::snapshot(); + { + let log = DurableEffectLog::open(&path).expect("open durable log"); + for i in 0..k { + log.append(&frame(i)).expect("append"); + } + } + let d = scan_count::snapshot().since(&before); + report(&format!("APPEND phase — {k} appends of distinct keys"), &d); + println!( + " appends {} -> counted calls: {} on append + {} on classify_append = {} scans for {k} logical appends", + k, + d.append().calls(), + d.classify().calls(), + d.append().calls() + d.classify().calls() + ); + println!(" total elements walked in this phase: {}", d.append().scanned() + d.classify().scanned()); + + let before = scan_count::snapshot(); + let reopened = DurableEffectLog::open(&path).expect("reopen durable log"); + let d = scan_count::snapshot().since(&before); + report(&format!("REPLAY at open — reading a file of {k} frames back"), &d); + println!(" frames recovered: {}", reopened.len()); + let walked = d.append().scanned() + d.classify().scanned(); + println!(" elements walked to open a {k}-frame file: {walked}"); + // K(K-1)/2 is what one scan per record over a log that grows by one each time costs. + let quadratic = (k * k.saturating_sub(1)) / 2; + println!(" K(K-1)/2 for K={k} is {quadratic} — the shape a per-record scan would give"); + let ok = reopened.len() as u64 == k && walked > 0; + if !ok { + println!("⛔ the durable addendum did not replay what it wrote, or counted nothing."); + } + println!(); + let _ = std::fs::remove_file(&path); + ok +} + +fn main() { + println!("D129 — scanned elements per effect-log append, bucketed HIT / MISS."); + println!("{}", ferrodb::build_provenance()); + println!("Driven over a real TCP socket against `pgwire::serve` in this process."); + println!("⛔ COUNTS ONLY. No timing is reported and none was taken — SCALE-DESIGN.md D129."); + println!(); + + let dir = std::env::temp_dir().join(format!("d129_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + if !fire_checks(&dir) { + println!("⛔ A FIRE-CHECK FAILED. The counter has not been shown to measure what it claims,"); + println!(" so no axis is reported. This is not a zero result; it is no result."); + let _ = std::fs::remove_dir_all(&dir); + std::process::exit(1); + } + + let blocks = env_usize("D129_BLOCKS", 8); + let block = env_usize("D129_BLOCK", 250); + println!("Axis: {blocks} blocks of {block} sessions, {WRITES_PER_SESSION} writes per session."); + println!(); + + // PARK — branches opened and never ended. The frames are for LIVE work. + let a = axis(&dir, "park", "PARK — sessions begin, write, and never end (live branches)", false, blocks, block); + // CHURN — every session MERGEs. Its branch is finished, its transaction committed. Nothing + // about those frames is still needed, and this arm exists to show whether they are still + // walked — which is the whole case for option (2) PRUNE over option (1) INDEX. + let b = axis(&dir, "churn", "CHURN — every session MERGEs, so every branch is FINISHED", true, blocks, block); + + let c = durable_addendum(&dir); + + let _ = std::fs::remove_dir_all(&dir); + if !(a && b && c) { + std::process::exit(1); + } +} diff --git a/src/tel/log.rs b/src/tel/log.rs index ce9d0d4b..fba7b00c 100644 --- a/src/tel/log.rs +++ b/src/tel/log.rs @@ -125,6 +125,192 @@ use crate::wal::log::{ write_str, }; +/// D129 — how many frames each of this file's three linear searches actually walks. +/// +/// `frames` is keyed by `(branch, txn_id)` and searched by a front-to-back scan in three places. +/// Whether that is a wall is a question about **counts**, not durations, and this module is the +/// instrument that answers it: elements walked per call, bucketed by whether the key was found. +/// The bucketing is the point — `position`/`find` short-circuit, so a hit costs `index + 1` and a +/// miss costs the whole Vec, and the two therefore have to be read apart or the mean hides which +/// one the workload has. +/// +/// ⚠ Counted per CALL, not per element: `scanned` accumulates in the predicate closure and is +/// published with **two** relaxed adds once the search is over, against a scan of up to thousands +/// of comparisons. The instrument cannot create the slope it measures. This is the shape +/// `agent_sql::runtime::ours_ops_on_cell` uses and it is copied deliberately, including the part +/// that matters: `scanned` is taken from the iterator that *actually walks*, never computed as +/// `frames.len()` from outside — that would be an assertion about the code rather than a reading +/// of it, and on a hit it would be wrong. +pub mod scan_count { + use std::sync::atomic::{AtomicU64, Ordering}; + + /// `MemEffectLog::append`'s `position` scan — the write path. + pub const SITE_APPEND: usize = 0; + /// `MemEffectLog::classify_append`'s `find` — reached on **every** `DurableEffectLog::append`, + /// which then calls `MemEffectLog::append` and pays `SITE_APPEND` as well. + pub const SITE_CLASSIFY: usize = 1; + /// `MemEffectLog::frame`'s `find` — the point read. + pub const SITE_FRAME: usize = 2; + /// `MemEffectLog::frames_for`'s `filter` — the **fourth** site, and the odd one out. + /// + /// D77's list said two of these searches existed and D129 corrected that to three; the trait + /// `EffectLog` declares exactly two methods, and enumerating the scans from the trait rather + /// than by eye turns up this one as well. It is counted for the same reason: a site left out + /// of an instrument is a site the instrument reports as absent. It does **not** short-circuit + /// — `filter` visits every element — so `scanned` here is always the whole `Vec`, in both + /// buckets, and the hit/miss split means only "did it match anything". + pub const SITE_FRAMES_FOR: usize = 3; + /// ⭐ **D138's SHADOW of `MemEffectLog::append`** — what the pre-D138 linear `position` scan + /// *would* have walked, recorded on the same call, in the same binary, at the same moment as + /// the real site. + /// + /// It exists because the before/after arms of D138 are **two different binaries**, and a zero + /// scan reported by a counter that has been accidentally disconnected reads exactly like a + /// zero scan reported by a scan that no longer happens. With this site the eliminated cost and + /// the actual cost are two columns of ONE table: the shadow must keep D137's slope-1 law while + /// `SITE_APPEND` reads zero. It is recorded ONLY by the indexed arm — in the unindexed arm it + /// stays empty and prints as `-`, which is the honest reading (there is no index to shadow). + pub const SITE_APPEND_SHADOW: usize = 4; + pub const SITES: usize = 5; + + static HIT_SCANNED: [AtomicU64; SITES] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + ]; + static HIT_CALLS: [AtomicU64; SITES] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + ]; + static MISS_SCANNED: [AtomicU64; SITES] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + ]; + static MISS_CALLS: [AtomicU64; SITES] = [ + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + AtomicU64::new(0), + ]; + + /// Calls where the shadow scan and the index DISAGREED about a key's position. + /// + /// ⛔ **This must be zero, and it is not a summary statistic — it is an equality checked on + /// every single append of the whole run.** A count that is not zero invalidates the arm + /// outright: the index would be answering something other than what the log holds. + static MISMATCHES: AtomicU64 = AtomicU64::new(0); + + /// The index and a fresh linear scan returned different positions for one key. + #[inline] + #[allow(dead_code)] // recorded only by the INDEXED arm; empty here is the honest reading. + pub(super) fn note_mismatch() { + MISMATCHES.fetch_add(1, Ordering::Relaxed); + } + + /// One search, after it finished. `scanned` is what the predicate counted. + #[inline] + pub(super) fn record(site: usize, hit: bool, scanned: u64) { + let (s, c) = if hit { + (&HIT_SCANNED[site], &HIT_CALLS[site]) + } else { + (&MISS_SCANNED[site], &MISS_CALLS[site]) + }; + s.fetch_add(scanned, Ordering::Relaxed); + c.fetch_add(1, Ordering::Relaxed); + } + + /// One site's four numbers. `scanned / calls` is the per-call scan length for that bucket. + #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] + pub struct SiteCount { + pub hit_scanned: u64, + pub hit_calls: u64, + pub miss_scanned: u64, + pub miss_calls: u64, + } + + impl SiteCount { + pub fn calls(&self) -> u64 { + self.hit_calls + self.miss_calls + } + pub fn scanned(&self) -> u64 { + self.hit_scanned + self.miss_scanned + } + /// Mean elements walked per **hit**, or `None` when there were none — a zero here must be + /// readable as "no hits happened", never as "hits were free". + pub fn per_hit(&self) -> Option { + (self.hit_calls > 0).then(|| self.hit_scanned as f64 / self.hit_calls as f64) + } + pub fn per_miss(&self) -> Option { + (self.miss_calls > 0).then(|| self.miss_scanned as f64 / self.miss_calls as f64) + } + } + + /// All three sites since process start. Read twice and [`Snapshot::since`] to scope a phase. + #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)] + pub struct Snapshot { + pub sites: [SiteCount; SITES], + /// Index-vs-scan disagreements. Must be 0; see [`MISMATCHES`]. + pub mismatches: u64, + } + + pub fn snapshot() -> Snapshot { + let mut out = Snapshot::default(); + out.mismatches = MISMATCHES.load(Ordering::Relaxed); + for i in 0..SITES { + out.sites[i] = SiteCount { + hit_scanned: HIT_SCANNED[i].load(Ordering::Relaxed), + hit_calls: HIT_CALLS[i].load(Ordering::Relaxed), + miss_scanned: MISS_SCANNED[i].load(Ordering::Relaxed), + miss_calls: MISS_CALLS[i].load(Ordering::Relaxed), + }; + } + out + } + + impl Snapshot { + /// What happened between `earlier` and `self`. Saturating, so a caller that passes the two + /// the wrong way round gets zeros rather than a wrapped u64 that reads as a huge result. + pub fn since(&self, earlier: &Snapshot) -> Snapshot { + let mut out = Snapshot::default(); + out.mismatches = self.mismatches.saturating_sub(earlier.mismatches); + for i in 0..SITES { + let (a, b) = (&self.sites[i], &earlier.sites[i]); + out.sites[i] = SiteCount { + hit_scanned: a.hit_scanned.saturating_sub(b.hit_scanned), + hit_calls: a.hit_calls.saturating_sub(b.hit_calls), + miss_scanned: a.miss_scanned.saturating_sub(b.miss_scanned), + miss_calls: a.miss_calls.saturating_sub(b.miss_calls), + }; + } + out + } + pub fn append(&self) -> SiteCount { + self.sites[SITE_APPEND] + } + pub fn classify(&self) -> SiteCount { + self.sites[SITE_CLASSIFY] + } + pub fn frame(&self) -> SiteCount { + self.sites[SITE_FRAME] + } + pub fn frames_for(&self) -> SiteCount { + self.sites[SITE_FRAMES_FOR] + } + pub fn append_shadow(&self) -> SiteCount { + self.sites[SITE_APPEND_SHADOW] + } + } +} + /// The frames, and the position index that answers `(branch, txn_id)` without walking them. /// /// **This is D86's shape, copied rather than invented** — `State::push_applied` / @@ -254,7 +440,9 @@ impl MemEffectLog { /// `tests::the_position_index_and_the_frames_agree_per_key`, which exercises it per key. pub fn frame(&self, branch: BranchId, txn: TxnId) -> Option { let frames = self.frames.lock().expect("effect log mutex poisoned"); - frames.position(branch, txn).map(|i| frames.frames[i].clone()) + let at = frames.position(branch, txn); + scan_count::record(scan_count::SITE_FRAME, at.is_some(), 0); + at.map(|i| frames.frames[i].clone()) } /// Classify `frame` against what is stored **without changing anything**. @@ -267,7 +455,9 @@ impl MemEffectLog { /// whether the append is legal at all, and a refused append must not reach the file. fn classify_append(&self, frame: &TxnFrame) -> Result, FerroError> { let frames = self.frames.lock().expect("effect log mutex poisoned"); - match frames.position(frame.branch, frame.txn_id) { + let at = frames.position(frame.branch, frame.txn_id); + scan_count::record(scan_count::SITE_CLASSIFY, at.is_some(), 0); + match at { None => Ok(None), Some(i) => classify(&frames.frames[i], frame).map(Some), } @@ -390,7 +580,20 @@ impl EffectLog for MemEffectLog { let mut frames = self.frames.lock().expect("effect log mutex poisoned"); // D138: an O(1) index read where this used to walk every frame ever appended. The two // arms below are unchanged — what changed is only how `i` is found. - if let Some(i) = frames.position(frame.branch, frame.txn_id) { + let at = frames.position(frame.branch, frame.txn_id); + scan_count::record(scan_count::SITE_APPEND, at.is_some(), 0); + { + let mut scanned = 0u64; + let by_scan = frames.frames.iter().position(|f| { + scanned += 1; + f.branch == frame.branch && f.txn_id == frame.txn_id + }); + scan_count::record(scan_count::SITE_APPEND_SHADOW, by_scan.is_some(), scanned); + if by_scan != at { + scan_count::note_mismatch(); + } + } + if let Some(i) = at { match classify(&frames.frames[i], frame)? { Reappend::Retry => return Ok(()), // The open frame grew. **Extended rather than replaced**, and the difference is @@ -451,12 +654,17 @@ impl EffectLog for MemEffectLog { /// stored frames, so it does not disturb any position [`Frames::by_key`] holds. fn frames_for(&self, branch: BranchId, from_seq: u64) -> Result, FerroError> { let frames = self.frames.lock().expect("effect log mutex poisoned"); + let mut scanned = 0u64; let mut out: Vec = frames .frames .iter() - .filter(|f| f.branch == branch && f.seq >= from_seq) + .filter(|f| { + scanned += 1; + f.branch == branch && f.seq >= from_seq + }) .cloned() .collect(); + scan_count::record(scan_count::SITE_FRAMES_FOR, !out.is_empty(), scanned); out.sort_by_key(|f| (f.seq, f.txn_id.0)); Ok(out) } From 71017a3fcb39ea0d93d7d2c58664117c3fea1cce Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:03:15 -0400 Subject: [PATCH 052/315] D144: divide realised by realised -- calibrate the sweep interval before forking Delta/S is a ratio of two DURATIONS and only Delta was measured. The cadence is a wait_timeout plus the scan's own duration, so the realised interval is never shorter than nominal and under load can be longer. Two things break, and the second is the one an axis fix alone would miss: 1. the axis: realised Delta over NOMINAL S overstates Delta/S, so the ramp sits systematically below P2, worst at small Delta -- reading exactly like P2 being refuted. 2. THE PREMISE: the stagger spreads parent forks across one interval so the sweep phase is uniform. Spread across nominal S while the true period is longer, expiries cover only part of a period, the phase is not uniform, and min(Delta/S,1) is not even the right model. So the interval is now measured from the lease thread's own "attempts" counter BEFORE forking (setting the stagger) and again DURING the run (setting the axis), and both are printed. Refuses if the spread exceeds 25% of the median, or if the two drift apart by 25% -- the stagger would then have been set from the wrong period. MEASURED UNDER LOAD 7.7-10.1 (a concurrent landing suite), nominal S=1000 ms: realised 1001-1010 ms, spread 997..1042. The load did NOT reach the timer, so that hazard is discharged by measurement rather than assumption. Results unchanged from the quiet smoke: Delta/S=0.5 -> 4 parked (P1=0, P2=5); Delta/S=2 -> 10. Spread guard fire-checked at S=20 ms against 10 ms sampling: refuses, exit 2, naming the ranges. The drift guard shares that arithmetic but was not isolated. --- examples/d144_lease_lag.rs | 135 +++++++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 12 deletions(-) diff --git a/examples/d144_lease_lag.rs b/examples/d144_lease_lag.rs index 122d6ae6..7d927969 100644 --- a/examples/d144_lease_lag.rs +++ b/examples/d144_lease_lag.rs @@ -189,6 +189,12 @@ fn fork_locked( struct Row { delta_ms: u64, lease_ms: u64, + /// Median sweep gap measured DURING the run, from the lease thread's own `attempts` counter. + /// This, not the nominal constant, is the S in every Δ/S printed. + realised_scan_ms: u64, + /// The same quantity measured BEFORE forking, used to set the stagger. + cal_scan_ms: u64, + scan_spread_ms: (u64, u64), peak: usize, parked_parents: usize, /// Diagnostic only — see the note on [`SAMPLE_MS`]. Above `parked_parents` means a sample @@ -218,10 +224,58 @@ fn run_point( ) .expect("lease thread"); - let scan_ms = scan.as_millis() as u64; - // Parent forks spread across EXACTLY one interval, so the phase of the first sweep after each - // parent's deadline is uniform on [0, S). Firing them together would sample one phase and - // turn a ramp into a coin flip. + let nominal_ms = scan.as_millis() as u64; + + // ---- calibrate the REALISED sweep interval before forking anything -------------------- + // + // ⛔ **`Δ/S` is a ratio of two DURATIONS, and only Δ was being measured.** The cadence comes + // from a `wait_timeout` plus however long the scan itself took, so the realised interval is + // never shorter than nominal and under load can be longer. Two things break if it is, and the + // second is the one that matters: + // + // 1. the x-axis. Plotting realised Δ against NOMINAL S overstates Δ/S, so the ramp would sit + // systematically below P2 and worst at small Δ — which reads exactly like P2 being wrong. + // 2. **the premise.** The stagger below spreads parent forks across one interval precisely so + // the sweep phase is uniform. Spread across NOMINAL S while the true period is longer, the + // expiries cover only part of a period and the phase is no longer uniform — so P2's + // `min(Δ/S, 1)` would not even be the right model. An axis fix alone would not catch this. + // + // So the interval is measured first, from the lease thread's own `attempts` counter, and + // everything downstream — stagger, end time, x-axis — is derived from what was measured. + // The first gap is dropped: it contains `LeaseThread::start`'s own immediate scan. + let mut sweep_at: Vec = Vec::new(); + let cal0 = Instant::now(); + let mut seen = lease.stats().attempts; + while sweep_at.len() < 4 { + let now_ms = cal0.elapsed().as_millis() as u64; + if now_ms > nominal_ms * 12 + 5000 { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: only {} sweeps in {now_ms} ms while \ + calibrating a nominal {nominal_ms} ms interval — the cadence cannot be \ + characterised, so no Δ/S computed from it would mean anything", + sweep_at.len() + )); + break; + } + let a = lease.stats().attempts; + if a > seen { + seen = a; + sweep_at.push(now_ms); + } + std::thread::sleep(Duration::from_millis(SAMPLE_MS)); + } + let cal_gaps: Vec = + sweep_at.windows(2).skip(1).map(|w| w[1] - w[0]).collect(); + let realised_ms = if cal_gaps.is_empty() { + nominal_ms + } else { + cal_gaps.iter().sum::() / cal_gaps.len() as u64 + }; + + let scan_ms = realised_ms.max(1); + // Parent forks spread across EXACTLY one REALISED interval, so the phase of the first sweep + // after each parent's deadline is uniform on [0, S). Firing them together would sample one + // phase and turn a ramp into a coin flip. let stagger_ms = scan_ms as f64 / pairs as f64; // (due_ms, is_child, index) @@ -240,8 +294,11 @@ fn run_point( // Stop once every parent is reaped and the queue has had a couple of sweeps to show itself. // Not once the children drain: the peak is what is being measured. let end_ms = scan_ms + delta_ms + lease_ms + 4 * scan_ms; + let _ = nominal_ms; let t0 = Instant::now(); + let mut run_sweeps: Vec = Vec::new(); + let mut run_seen = lease.stats().attempts; let mut peak = 0usize; let mut prev = 0usize; let mut increments = 0usize; @@ -268,6 +325,11 @@ fn run_point( next_event += 1; } + let a = lease.stats().attempts; + if a > run_seen { + run_seen = a; + run_sweeps.push(now_ms); + } let l = rig.store.pending_len(); if l > peak { peak = l; @@ -333,11 +395,47 @@ fn run_point( )); } + // The interval DURING the measurement, not only before it: load can arrive mid-run. + let mut run_gaps: Vec = run_sweeps.windows(2).map(|w| w[1] - w[0]).collect(); + run_gaps.sort_unstable(); + let (g_lo, g_med, g_hi) = if run_gaps.is_empty() { + (0, realised_ms, 0) + } else { + (run_gaps[0], run_gaps[run_gaps.len() / 2], run_gaps[run_gaps.len() - 1]) + }; + // One number can only stand for the cadence if the cadence is actually one number. + if g_med > 0 && !run_gaps.is_empty() && (g_hi - g_lo) * 4 > g_med { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: sweep interval ranged {g_lo}..{g_hi} ms (median \ + {g_med}) during the run — a spread over 25% of the median means no single S \ + characterises the cadence, so Δ/S is not a well-defined axis for this row" + )); + } + + // Calibration happened BEFORE the forks; the run median covers the measurement window. Load + // arriving in between would move one and not the other, and the stagger was set from the + // calibration — so a drift here means the forks were spread across the wrong period and the + // uniform-phase premise is broken for this row. + if g_med > 0 && realised_ms > 0 { + let drift = g_med.abs_diff(realised_ms); + if drift * 4 > g_med { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: the sweep interval MOVED between calibration \ + ({realised_ms} ms, which set the fork stagger) and the run ({g_med} ms) — a \ + {drift} ms drift. The parent forks were therefore spread across the wrong \ + period, so the phase is not uniform and P2 is not the right model for this row" + )); + } + } + let lo = realised.iter().copied().min().unwrap_or(0); let hi = realised.iter().copied().max().unwrap_or(0); Row { delta_ms, lease_ms, + realised_scan_ms: g_med, + cal_scan_ms: realised_ms, + scan_spread_ms: (g_lo, g_hi), peak, parked_parents: peak / pages as usize, increment_sum_parents: increments / pages as usize, @@ -362,30 +460,43 @@ fn print_header(scan_ms: u64, lease_ms: u64, pairs: usize, pages: u32) { println!(" reaping the REAL LeaseThread on a real clock. No faked time, no direct \ reap() call."); println!(); + println!(" S realis = the MEDIAN sweep gap this row actually achieved, from the lease"); + println!(" thread's own `attempts` counter. Every Δ/S below divides realised by"); + println!(" realised — one instrument, one moment. The nominal {scan_ms} ms is the"); + println!(" REQUEST, and appears in no ratio. `S spread` is min..max of those gaps;"); + println!(" a row whose spread exceeds 25% of its median REFUSES rather than"); + println!(" pretending one number describes the cadence."); + println!(); println!(" P1 = D144 as written: a STEP — 0 parked below S, all parked above."); println!(" P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S."); println!(); println!( - " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", - "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", - "scans a/s/r/f/reaped", "incsum" + " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", + "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "peak", "realised Δ", + "S spread", "scans a/s/r/f/reaped", "incsum" ); } -fn print_row(r: &Row, scan_ms: u64, pairs: usize) { +fn print_row(r: &Row, _nominal_ms: u64, pairs: usize) { + // ⭐ **S is the REALISED median sweep gap, measured in the same run and the same moment as the + // Δ it is divided by.** Using the nominal constant here would make the axis a ratio of one + // measured number to one assumed one, which is the shape this project has lost four results to. + let scan_ms = r.realised_scan_ms.max(1); let ratio = r.delta_ms as f64 / scan_ms as f64; let p1 = if r.delta_ms > scan_ms { pairs } else { 0 }; let p2 = (pairs as f64 * ratio.min(1.0)).round() as usize; println!( - " {:>8} {:>8} {:>10.3} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", + " {:>8} {:>8} {:>9} {:>8.3} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", r.delta_ms, r.lease_ms, + format!("{}/{}", r.realised_scan_ms, r.cal_scan_ms), ratio, r.parked_parents, p1, p2, r.peak, format!("{}..{}", r.realised_delta_ms.0, r.realised_delta_ms.1), + format!("{}..{}", r.scan_spread_ms.0, r.scan_spread_ms.1), format!( "{}/{}/{}/{}/{}", r.stats.attempts, r.stats.scans, r.stats.refused, r.stats.failed, r.stats.reaped @@ -442,9 +553,9 @@ fn main() { println!(" CONTROL — L varied at a fixed Δ={control_delta} ms. L is not in the window, so"); println!(" these rows MUST agree. If they do not, the derivation above is wrong."); println!( - " {:>8} {:>8} {:>10} {:>9} {:>8} {:>8} {:>16} {:>15} {:>22} {:>7}", - "Δ ms", "lease ms", "Δ/S", "parked", "P1", "P2", "peak pending", "realised Δ", - "scans a/s/r/f/reaped", "incsum" + " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", + "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "peak", "realised Δ", + "S spread", "scans a/s/r/f/reaped", "incsum" ); let mut control_parked = Vec::new(); for &l in &control_leases { From c555f8d4e44ed9d63db286f1d296b59a4ed81b58 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:04:52 -0400 Subject: [PATCH 053/315] D144 draw 1: VOID, self-inflicted, kept on the record Queued 380s behind land-d93d23b, acquired the lock at 12:59:41Z, and I rebuilt the binary four times while it ran because I believed it was still queued. A running process keeps its mapped image, so it executed the pre-calibration build -- provable from the artifact's own table header, which lacks the columns the current harness prints. I also ran three harnesses beside it, two of them fire-checks designed to produce a pathological sweep cadence. The rule says never WRITE into a tree while a run against it is in flight, and cargo build is a write. Queued is not idle. --- bench/d144_lease_lag.VOID-draw1.txt | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 bench/d144_lease_lag.VOID-draw1.txt diff --git a/bench/d144_lease_lag.VOID-draw1.txt b/bench/d144_lease_lag.VOID-draw1.txt new file mode 100644 index 00000000..efdcb1f6 --- /dev/null +++ b/bench/d144_lease_lag.VOID-draw1.txt @@ -0,0 +1,101 @@ +################################################################################################ +# VOID -- DRAW 1 OF D144. **DISCARDED BY THE AUTHOR, NOT BY A FAILURE. DO NOT QUOTE ANY ROW.** +# +# Kept because a void run recorded as void costs nothing and a silently deleted one teaches +# nobody. Two independent defects, BOTH self-inflicted: +# +# 1. **IT RAN THE WRONG BINARY.** This run queued 380 s behind `land-d93d23b` and acquired the +# suite lock at 12:59:41Z. Believing it still queued, I rebuilt +# `target/release/examples/d144_lease_lag` FOUR times between 13:00 and 13:03 while it was +# executing. A running process keeps its mapped image, so it ran the PRE-CALIBRATION build -- +# visible in this file's own table header, which lacks the `S run/cal` and `S spread` columns +# the current harness prints. The artifact convicts itself, which is the only reason this was +# caught. +# +# 2. **I RAN THREE HARNESSES ALONGSIDE IT.** A quiet-box smoke, a load smoke, and TWO fire-checks +# at S=20 ms whose entire purpose was to create a pathological sweep cadence -- all concurrent +# with a measurement whose x-axis is a ratio of two durations. +# +# ⇒ The standing rule is "never write into a tree while a run against it is in flight", and the +# reason it says *write*, not *git*, is exactly this: `cargo build` is a write. Queued is NOT +# idle; a run that is waiting for a lock will start without telling you. +# +# ⚠ WHAT SURVIVES: nothing from this file. The separately-run smokes DO stand -- they were +# standalone invocations with their own output, not rows of this artifact, and the load smoke +# (realised S 1001-1010 ms at nominal 1000 under load 7.7-10.1) is what discharged the +# timer-under-load hazard. +################################################################################################ +d144-lease-lag: queued behind 93177 land-d93d23b 2026-09-22T12:48:44Z +d144-lease-lag: acquired the suite lock after 380s +# D144 — the lag the LEASE produces. RAW ARTIFACT. +# +# run at 2026-09-22T12:59:41Z UTC +# worktree /Users/idide/wt/ferrodb-d133-pending-len +# branch d133-pending-len +# head 74266ea15e289cd63fcb78c8f84eb01d021781df +# tree state 1 modified/untracked path(s) +# ?? bench/d144_lease_lag.txt +# binary mtime 2026-09-22T12:52:03Z +# host Darwin 25.6.0 arm64 +# measure lock 61248 d133-pending-len 2026-09-22T11:52:09Z +# suite lock HELD BY THIS RUN: 20012 d144-lease-lag 2026-09-22T12:59:41Z (queued 380s) +# +# ⛔ THE SCAN INTERVAL THIS RUN USED IS **3000 ms**, NOT THE SHIPPED 30 s. +# Δ is swept against $SCAN_MS, never against 30 s. The knob is the shipped one: +# 109:pub const DEFAULT_SCAN_MILLIS: u64 = 30_000; +# 112:pub const SCAN_INTERVAL_ENV: &str = "FERRODB_LEASE_SCAN_MILLIS"; +# and the harness reads it through the server's own scan_interval_from_env(): +# pub fn scan_interval_from_env() -> Result { +# match std::env::var(SCAN_INTERVAL_ENV) { +# Err(_) => Ok(Duration::from_millis(DEFAULT_SCAN_MILLIS)), +# Ok(v) => parse_scan_interval(&v), +# } +# } +# +# ⛔ THE LEASE IS COMPRESSED. The shipped constant has no environment override: +# 96:pub const DEFAULT_LEASE_MILLIS: u64 = 15 * 60 * 1000; +# 1477: .fork(parent, LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS))?; +# The window under test is exactly Δ and does not contain L, so a compressed L rescales +# WHEN the window opens and not HOW WIDE it is. The CONTROL arm varies L at fixed Δ and +# must not move; the run REFUSES if it does. +# +============================================================================== +ARM 1 — Δ swept ACROSS the scan interval S=3000 ms, below and above. +============================================================================== +# suite lock @ before arm 1: HELD BY THIS RUN (12:59:41Z, load 6.69 6.94 5.81) +D144 — the lag the LEASE produces, swept across the scan interval. + + scan interval S 3000 ms (from scan_interval_from_env(), i.e. FERRODB_LEASE_SCAN_MILLIS or the 30000 ms default) + lease L 20000 ms (COMPRESSED from DEFAULT_LEASE_MILLIS = 900000 ms, which has no env override; + L does not enter the window, which is exactly Δ — the `lease` control arm tests that claim) + pairs per point 60, parent forks spread across exactly one S so the sweep phase is uniform + pages per parent 4, written BEFORE the child forks so the child can see them + reaping the REAL LeaseThread on a real clock. No faked time, no direct reap() call. + + P1 = D144 as written: a STEP — 0 parked below S, all parked above. + P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S. + + Δ ms lease ms Δ/S parked P1 P2 peak pending realised Δ scans a/s/r/f/reaped incsum +lease: reaped 42 expired branch(es) with no client cooperation (b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b12@g0, b10@g0, b8@g0, b6@g0, b4@g0, b2@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b11@g0, b9@g0, b7@g0, b5@g0, b3@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 78 expired branch(es) with no client cooperation (b120@g0, b118@g0, b116@g0, b114@g0, b112@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b119@g0, b117@g0, b115@g0, b113@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0); 0 workspace(s) forgotten + 0 20000 0.000 0 0 0 0 0..1 12/12/0/0/120 0 +lease: reaped 32 expired branch(es) with no client cooperation (b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b11@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b12@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 88 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0); 0 workspace(s) forgotten + 500 20000 0.167 10 0 10 40 496..504 12/12/0/0/120 10 +lease: reaped 24 expired branch(es) with no client cooperation (b23@g0, b21@g0, b24@g0, b22@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 96 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0); 0 workspace(s) forgotten + 1000 20000 0.333 20 0 20 80 995..1003 12/12/0/0/120 20 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 91 expired branch(es) with no client cooperation (b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 8 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0); 0 workspace(s) forgotten + 1500 20000 0.500 21 0 30 84 1497..1504 13/13/0/0/120 48 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 81 expired branch(es) with no client cooperation (b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 18 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0); 0 workspace(s) forgotten + 2000 20000 0.667 21 0 40 84 1997..2004 13/13/0/0/120 47 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 71 expired branch(es) with no client cooperation (b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 28 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0); 0 workspace(s) forgotten + 2500 20000 0.833 28 0 50 112 2496..2504 13/13/0/0/120 47 +bench/d144_run.sh: line 23: 61067 Killed: 9 "$@" +REFUSED: aborted by SIGTERM after 620s. A sweep cut short is not a measurement. From e62169ca0471d41ec52c3846ca8d919c52f1075a Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:05:21 -0400 Subject: [PATCH 054/315] D144: let the run convict itself if it executed a stale binary Draw 1 ran the pre-calibration build because a rebuild landed after it acquired the lock. An mtime check on disk would have said current -- a running process keeps its mapped image -- so the filesystem is the wrong witness. The runner now greps its own output for the calibration column header, which only a build that has the calibration can print, and exits 4 if absent. --- bench/d144_run.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bench/d144_run.sh b/bench/d144_run.sh index 5e2d785f..bcea79d0 100755 --- a/bench/d144_run.sh +++ b/bench/d144_run.sh @@ -18,6 +18,8 @@ SUITE_LOCK=${SUITE_LOCK:-/tmp/ferrodb-suite.lock} LOCK_WAIT=${LOCK_WAIT:-3600} SCAN_MS=${SCAN_MS:-3000} +# Where this run's own stdout lands, so the feature check below can read back what was printed. +OUT_SELF=${OUT_SELF:-$WT/bench/d144_lease_lag.txt} _HELD=0 _release() { [ "$_HELD" = "1" ] || return 0; _HELD=0; rm -rf "$SUITE_LOCK"; } @@ -120,6 +122,17 @@ suite_state "after arm 1" echo "(arm 1 rc=$rc1)" echo +# ⛔ ASK THE ARTIFACT WHICH BINARY RAN, not the filesystem. Draw 1 was voided because a rebuild +# landed while the run was already executing: a running process keeps its mapped image, so an +# mtime check on disk would have said "current" while the old code was producing the rows. The +# only witness that cannot lie is the output itself, and the calibration columns are only printed +# by a build that has the calibration. +if ! grep -q "S run/cal" "$OUT_SELF" 2>/dev/null; then + echo "# VERDICT: THE ROWS ABOVE CAME FROM A BINARY WITHOUT THE REALISED-INTERVAL CALIBRATION." + echo "# Its Δ/S divides a measured Δ by an ASSUMED S. Void this file." + exit 4 +fi +echo "# binary self-identified from its own output: calibration columns present." echo "# load after $(uptime | sed 's/.*load averages*: *//')" echo "# arm rcs $rc1" if [ "$rc1" -ne 0 ]; then From 0a3c8032ad33bb8c50b49b281f199cec6d691160 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 09:50:56 -0400 Subject: [PATCH 055/315] D144: count parks cumulatively -- a level cannot answer "how much was ever parked" Draw 2 reported the maximum number of parents parked AT ANY INSTANT and called it "parked". Below S that understates the total: a parked entry lives about one sweep there, while the 60 parents are reaped across TWO consecutive sweeps, so the two groups never coexist and the maximum returns max(A,B) rather than A+B. Above S an entry outlives a sweep, the groups overlap, and the two coincide -- which is why draw 2's rows above the interval are correct and its rows below it are a lower bound. No sampler of pending_len can fix this. The drain is a read-modify-write, so an outside sampler also sees transients belonging to no park at all, and whether two parks coexist depends on the cadence rather than on anything being measured. So ArenaPageStore gains pending_pushed: AtomicU64, incremented at the two sites that park a page and nowhere else, with pending_pushed_total(). Cumulative and monotone, hence immune to sampling, to the drain and to the regime. The harness now prints it as "parked" and keeps the coexistence figure as "maxcoex", which is the honest answer to D142's separate question of how long the QUEUE gets. Smoke at S=1000, 10 pairs: Delta/S=0.495 now reads 5 parked where the old metric read 4, and P2 predicts exactly 5. Above S both columns read 10, converging as the mechanism above requires. Behaviour-neutral: branch::arena 39 passed, branch::reaper 60 passed. --- bench/d144_lease_lag.draw2-superseded.txt | 144 ++++++++++++++++++++++ examples/d144_lease_lag.rs | 39 ++++-- src/branch/arena.rs | 24 +++- 3 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 bench/d144_lease_lag.draw2-superseded.txt diff --git a/bench/d144_lease_lag.draw2-superseded.txt b/bench/d144_lease_lag.draw2-superseded.txt new file mode 100644 index 00000000..51e0833c --- /dev/null +++ b/bench/d144_lease_lag.draw2-superseded.txt @@ -0,0 +1,144 @@ +################################################################################################ +# DRAW 2 -- SUPERSEDED, NOT VOID. Its rows are real; one COLUMN was mislabelled. +# +# The column printed as `parked` is the maximum number of parents whose pages were parked AT ANY +# ONE INSTANT. Below S that is NOT the number that parked: a parked entry lives about one sweep +# there (the child expires in the next one), while the 60 parents are reaped across TWO +# consecutive sweeps, so the two groups never coexist and the maximum returns max(A,B) instead of +# A+B. Above S an entry outlives a sweep, the groups overlap, and the two coincide -- which is why +# the rows at 1.495 / 1.991 / 2.990 read a correct 60 and are unaffected. +# +# ⇒ WHAT STANDS FROM THIS DRAW: P1 refuted (parking at every Delta > 0 below S, where P1 says +# zero); the plateau above S; the knee at Delta = S; the L-invariance control; and the realised +# sweep interval, 3009-3013 ms against a nominal 3000, which discharged the load hazard. +# ⇒ WHAT DOES NOT: the SHAPE of the ramp below S. Those figures are a LOWER BOUND. +# +# Superseded by bench/d144_lease_lag.txt, which adds ArenaPageStore::pending_pushed_total() -- a +# cumulative, monotone count of parks that is immune to sampling, to the drain's read-modify-write +# and to the cadence. Both columns are now printed side by side. +################################################################################################ +d144-lease-lag: queued behind 96489 d141merged 2026-09-22T13:03:45Z +d144-lease-lag: acquired the suite lock after 465s +# D144 — the lag the LEASE produces. RAW ARTIFACT. +# +# run at 2026-09-22T13:13:20Z UTC +# worktree /Users/idide/wt/ferrodb-d133-pending-len +# branch d133-pending-len +# head e62169ca0471d41ec52c3846ca8d919c52f1075a +# tree state 1 modified/untracked path(s) +# ?? bench/d144_lease_lag.txt +# binary mtime 2026-09-22T13:01:00Z +# host Darwin 25.6.0 arm64 +# measure lock 61248 d133-pending-len 2026-09-22T11:52:09Z +# suite lock HELD BY THIS RUN: 97835 d144-lease-lag 2026-09-22T13:13:20Z (queued 465s) +# +# ⛔ THE SCAN INTERVAL THIS RUN USED IS **3000 ms**, NOT THE SHIPPED 30 s. +# Δ is swept against $SCAN_MS, never against 30 s. The knob is the shipped one: +# 109:pub const DEFAULT_SCAN_MILLIS: u64 = 30_000; +# 112:pub const SCAN_INTERVAL_ENV: &str = "FERRODB_LEASE_SCAN_MILLIS"; +# and the harness reads it through the server's own scan_interval_from_env(): +# pub fn scan_interval_from_env() -> Result { +# match std::env::var(SCAN_INTERVAL_ENV) { +# Err(_) => Ok(Duration::from_millis(DEFAULT_SCAN_MILLIS)), +# Ok(v) => parse_scan_interval(&v), +# } +# } +# +# ⛔ THE LEASE IS COMPRESSED. The shipped constant has no environment override: +# 96:pub const DEFAULT_LEASE_MILLIS: u64 = 15 * 60 * 1000; +# 1477: .fork(parent, LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS))?; +# The window under test is exactly Δ and does not contain L, so a compressed L rescales +# WHEN the window opens and not HOW WIDE it is. The CONTROL arm varies L at fixed Δ and +# must not move; the run REFUSES if it does. +# +============================================================================== +ARM 1 — Δ swept ACROSS the scan interval S=3000 ms, below and above. +============================================================================== +# suite lock @ before arm 1: HELD BY THIS RUN (13:13:20Z, load 4.55 4.76 5.31) +D144 — the lag the LEASE produces, swept across the scan interval. + + scan interval S 3000 ms (from scan_interval_from_env(), i.e. FERRODB_LEASE_SCAN_MILLIS or the 30000 ms default) + lease L 20000 ms (COMPRESSED from DEFAULT_LEASE_MILLIS = 900000 ms, which has no env override; + L does not enter the window, which is exactly Δ — the `lease` control arm tests that claim) + pairs per point 60, parent forks spread across exactly one S so the sweep phase is uniform + pages per parent 4, written BEFORE the child forks so the child can see them + reaping the REAL LeaseThread on a real clock. No faked time, no direct reap() call. + + S realis = the MEDIAN sweep gap this row actually achieved, from the lease + thread's own `attempts` counter. Every Δ/S below divides realised by + realised — one instrument, one moment. The nominal 3000 ms is the + REQUEST, and appears in no ratio. `S spread` is min..max of those gaps; + a row whose spread exceeds 25% of its median REFUSES rather than + pretending one number describes the cadence. + + P1 = D144 as written: a STEP — 0 parked below S, all parked above. + P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S. + + Δ ms lease ms S run/cal Δ/S parked P1 P2 peak realised Δ S spread scans a/s/r/f/reaped incsum +lease: reaped 42 expired branch(es) with no client cooperation (b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b12@g0, b10@g0, b8@g0, b6@g0, b4@g0, b2@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b11@g0, b9@g0, b7@g0, b5@g0, b3@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 78 expired branch(es) with no client cooperation (b120@g0, b118@g0, b116@g0, b114@g0, b112@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b119@g0, b117@g0, b115@g0, b113@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0); 0 workspace(s) forgotten + 0 20000 3010/3003 0.000 0 0 0 0 0..1 2999..3121 15/15/0/0/120 0 +lease: reaped 32 expired branch(es) with no client cooperation (b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b11@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b12@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 88 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0); 0 workspace(s) forgotten + 500 20000 3009/3006 0.166 10 0 10 40 488..512 2997..3162 15/15/0/0/120 30 +lease: reaped 22 expired branch(es) with no client cooperation (b21@g0, b22@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 98 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0); 0 workspace(s) forgotten + 1000 20000 3011/3007 0.332 20 0 20 80 988..1012 2999..3151 15/15/0/0/120 31 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 91 expired branch(es) with no client cooperation (b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 8 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0); 0 workspace(s) forgotten + 1500 20000 3009/3009 0.499 21 0 30 84 1488..1509 3000..3149 16/16/0/0/120 32 +lease: reaped 20 expired branch(es) with no client cooperation (b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 81 expired branch(es) with no client cooperation (b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0, b21@g0); 0 workspace(s) forgotten +lease: reaped 19 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0); 0 workspace(s) forgotten + 2000 20000 3009/3014 0.665 20 0 40 80 1990..2012 3000..3128 16/16/0/0/120 38 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 71 expired branch(es) with no client cooperation (b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 28 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0); 0 workspace(s) forgotten + 2500 20000 3011/3001 0.830 28 0 50 112 2496..2503 2998..3113 16/16/0/0/120 48 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 61 expired branch(es) with no client cooperation (b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0, b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 38 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0); 0 workspace(s) forgotten + 3000 20000 3009/3007 0.997 38 0 60 152 2989..3010 3000..3087 16/16/0/0/120 75 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 53 expired branch(es) with no client cooperation (b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 7 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0); 0 workspace(s) forgotten + 4500 20000 3011/3005 1.495 60 60 60 240 4488..4512 2999..3087 17/17/0/0/120 60 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 6000 20000 3013/3010 1.991 60 60 60 240 5989..6011 2999..3071 17/17/0/0/120 101 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 9000 20000 3010/3002 2.990 60 60 60 240 8988..9013 3000..3071 18/18/0/0/120 60 + + CONTROL — L varied at a fixed Δ=6000 ms. L is not in the window, so + these rows MUST agree. If they do not, the derivation above is wrong. + Δ ms lease ms S run/cal Δ/S parked P1 P2 peak realised Δ S spread scans a/s/r/f/reaped incsum +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 6000 20000 3012/3003 1.992 60 60 60 240 5988..6013 2998..3062 17/17/0/0/120 79 +lease: reaped 42 expired branch(es) with no client cooperation (b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0, b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 18 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0); 0 workspace(s) forgotten +lease: reaped 44 expired branch(es) with no client cooperation (b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 16 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0); 0 workspace(s) forgotten + 6000 40000 3009/3010 1.994 60 60 60 240 5989..6012 2999..3071 24/24/0/0/120 62 + + realised Δ is computed from the DEADLINES the catalog recorded + (child.lease_deadline - parent.lease_deadline), not from the requested value. + CONTROL HELD: parked [60, 60] across leases [20000, 40000] — varying L did not move the window. + 12 rows, every lease thread scanned, none refused, none failed. +# suite lock @ after arm 1: HELD BY THIS RUN (13:23:11Z, load 2.43 3.09 4.03) +(arm 1 rc=0) + +# binary self-identified from its own output: calibration columns present. +# load after 2.43 3.09 4.03 +# arm rcs 0 +# the arm returned 0: every lease thread scanned, none refused, none failed, and the +# lease control did not move. diff --git a/examples/d144_lease_lag.rs b/examples/d144_lease_lag.rs index 7d927969..e466a42a 100644 --- a/examples/d144_lease_lag.rs +++ b/examples/d144_lease_lag.rs @@ -197,6 +197,9 @@ struct Row { scan_spread_ms: (u64, u64), peak: usize, parked_parents: usize, + /// Parents whose pages were parked, from the arena's cumulative push counter. THE figure. + /// `peak_parents` below is the max that ever coexisted, which is a different question. + peak_parents: usize, /// Diagnostic only — see the note on [`SAMPLE_MS`]. Above `parked_parents` means a sample /// landed inside a drain's take/put window. increment_sum_parents: usize, @@ -355,6 +358,8 @@ fn run_point( } } + // Cumulative, monotone, taken after the thread has stopped so no sweep is in flight. + let pushed = rig.store.pending_pushed_total(); let stats = lease.stop(); let _ = std::fs::remove_dir_all(&rig.dir); @@ -382,6 +387,12 @@ fn run_point( with a {lease_ms} ms lease — the fixture never reached the code under test" )); } + if pushed > (pairs * pages as usize) as u64 { + refusals.push(format!( + "delta={delta_ms} lease={lease_ms}: {pushed} entries pushed but only {pairs} pairs x \ + {pages} pages exist — more was parked than could be" + )); + } if peak > pairs * pages as usize { refusals.push(format!( "delta={delta_ms} lease={lease_ms}: peak pending {peak} exceeds {pairs} pairs x \ @@ -437,7 +448,8 @@ fn run_point( cal_scan_ms: realised_ms, scan_spread_ms: (g_lo, g_hi), peak, - parked_parents: peak / pages as usize, + parked_parents: (pushed / pages as u64) as usize, + peak_parents: peak / pages as usize, increment_sum_parents: increments / pages as usize, realised_delta_ms: (lo, hi), stats, @@ -467,13 +479,23 @@ fn print_header(scan_ms: u64, lease_ms: u64, pairs: usize, pages: u32) { println!(" a row whose spread exceeds 25% of its median REFUSES rather than"); println!(" pretending one number describes the cadence."); println!(); + println!(" parked = parents whose pages were parked, from the arena's CUMULATIVE push"); + println!(" counter. `maxcoex` is the most that ever coexisted, and BELOW S it is a"); + println!(" LOWER BOUND on `parked`, not the same number: a parked entry lives about"); + println!(" one sweep there, while the parents are reaped across TWO consecutive"); + println!(" sweeps, so the two groups never overlap. Above S entries outlive a sweep,"); + println!(" they do overlap, and the two columns converge. Draw 2 reported `maxcoex`"); + println!(" as `parked` and so understated the fraction below S."); + println!(" peak = the longest the QUEUE itself ever got, in entries. That is D142's"); + println!(" question and `maxcoex` x pages answers it correctly in both regimes."); + println!(); println!(" P1 = D144 as written: a STEP — 0 parked below S, all parked above."); println!(" P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S."); println!(); println!( - " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", - "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "peak", "realised Δ", - "S spread", "scans a/s/r/f/reaped", "incsum" + " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>8} {:>6} {:>13} {:>13} {:>20} {:>7}", + "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "maxcoex", "peak", + "realised Δ", "S spread", "scans a/s/r/f/reaped", "incsum" ); } @@ -486,7 +508,7 @@ fn print_row(r: &Row, _nominal_ms: u64, pairs: usize) { let p1 = if r.delta_ms > scan_ms { pairs } else { 0 }; let p2 = (pairs as f64 * ratio.min(1.0)).round() as usize; println!( - " {:>8} {:>8} {:>9} {:>8.3} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", + " {:>8} {:>8} {:>9} {:>8.3} {:>7} {:>5} {:>5} {:>8} {:>6} {:>13} {:>13} {:>20} {:>7}", r.delta_ms, r.lease_ms, format!("{}/{}", r.realised_scan_ms, r.cal_scan_ms), @@ -494,6 +516,7 @@ fn print_row(r: &Row, _nominal_ms: u64, pairs: usize) { r.parked_parents, p1, p2, + r.peak_parents, r.peak, format!("{}..{}", r.realised_delta_ms.0, r.realised_delta_ms.1), format!("{}..{}", r.scan_spread_ms.0, r.scan_spread_ms.1), @@ -553,9 +576,9 @@ fn main() { println!(" CONTROL — L varied at a fixed Δ={control_delta} ms. L is not in the window, so"); println!(" these rows MUST agree. If they do not, the derivation above is wrong."); println!( - " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>6} {:>13} {:>13} {:>20} {:>7}", - "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "peak", "realised Δ", - "S spread", "scans a/s/r/f/reaped", "incsum" + " {:>8} {:>8} {:>9} {:>8} {:>7} {:>5} {:>5} {:>8} {:>6} {:>13} {:>13} {:>20} {:>7}", + "Δ ms", "lease ms", "S run/cal", "Δ/S", "parked", "P1", "P2", "maxcoex", "peak", + "realised Δ", "S spread", "scans a/s/r/f/reaped", "incsum" ); let mut control_parked = Vec::new(); for &l in &control_leases { diff --git a/src/branch/arena.rs b/src/branch/arena.rs index c0301909..fa88ae4a 100644 --- a/src/branch/arena.rs +++ b/src/branch/arena.rs @@ -342,6 +342,17 @@ pub struct ArenaPageStore { /// Extent pages currently reserved by some branch. Returns to baseline only if freed extents /// are genuinely recycled, which is the stronger claim exit criterion 8 actually wants. reserved_pages: AtomicU32, + /// **D144.** Entries ever PUSHED onto the pending-free log, cumulative and monotone. + /// + /// `pending_len()` is a LEVEL and cannot answer "how much was ever parked": the log is drained + /// as well as filled, and a parked entry whose child dies leaves it again. Sampling the level + /// from outside cannot recover the total either — the drain is a read-modify-write + /// (`take_pending` empties the whole vec before `put_pending` restores the survivors), so a + /// sampler sees transients that belong to no park at all, and whether two parks coexist at any + /// instant depends on the sweep cadence rather than on anything being measured. This counter + /// is incremented at the two sites that park a page and nowhere else, so it is immune to all + /// of that. + pending_pushed: AtomicU64, /// The authority epoch this store's in-memory state belongs to. /// /// Compared against [`crate::cluster::epoch`] on every allocation path; a change revokes the @@ -453,6 +464,7 @@ impl ArenaPageStore { }), live_pages: AtomicU32::new(0), reserved_pages: AtomicU32::new(0), + pending_pushed: AtomicU64::new(0), authority_epoch: AtomicU64::new(crate::cluster::epoch()), checkpoint_path: Mutex::new(None), }) @@ -570,11 +582,19 @@ impl ArenaPageStore { self.reserved_pages.load(Ordering::SeqCst) } - /// Entries in the pending-free log. + /// Entries in the pending-free log. A LEVEL: it falls as well as rises. pub fn pending_len(&self) -> usize { self.state.lock().unwrap().pending.len() } + /// Entries ever pushed onto the pending-free log. See [`ArenaPageStore::pending_pushed`]. + /// + /// Cumulative and monotone, so unlike [`Self::pending_len`] it answers "how many pages were + /// ever parked" regardless of when they were released or whether two parks overlapped. + pub fn pending_pushed_total(&self) -> u64 { + self.pending_pushed.load(Ordering::Relaxed) + } + /// Branches currently holding a fillable extent — the length of the `current` map. /// /// **D99.** This is the integer a scan of that map costs, and it is reported instead of a @@ -944,6 +964,7 @@ impl ArenaPageStore { free_epoch, owner: rec.branch_id, }); + self.pending_pushed.fetch_add(1, Ordering::Relaxed); } } } @@ -1598,6 +1619,7 @@ impl PageStore for ArenaPageStore { free_epoch, owner, }); + self.pending_pushed.fetch_add(1, Ordering::Relaxed); } else { self.release_page(page_id, arena); } From 01cb2af35bb260d7eec7fc6d24468dfd0db5a7ed Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:03:04 -0400 Subject: [PATCH 056/315] D158: withdraw the two in-tree comments that point W4's work at a closed wall src/branch/reaper.rs carried a warning-marked paragraph claiming 'the O(live arenas) scan still runs inside the per-statement lock' and 'the outer RuntimeLock held across the whole of scan_once ... no landed change touches it'. Both are false at HEAD. D88 moved the orphan sweep out of the lock and this same file says so 320 lines below; D98 chunked scan_once to one with_lock per REAP_CHUNK=1 group with a REAP_YIELD stand-off, 51.6 s whole-sweep wait to a 161.8 ms p99 with a control arm. Its citation, lease_thread.rs:399-407, is LeaseThread::start's startup resume -- scan_once begins at :574. Struck through rather than deleted: the paragraph was copied out of bench/w4/DECISION.md addendum 1, the section that file's own head says not to quote, and the leading warning sign made it read as more current than its own refutation. Deleting it would lose the only in-tree record of how a withdrawn claim reached the source and survived. examples/statement_lock_sweep.rs cited the same addendum and named the CLOSED half. Its header now distinguishes W4-sweep (the lease thread's hold, closed) from W4-statement (every connection's outermost per-statement hold, open, the gate), and says this harness measures a third lock entirely -- AgentRuntime's Mutex. Comment-only; no executable line changed. Compiler check deferred: the suite lock is held by d138c and a build here would contend with a run producing a landing number. --- examples/statement_lock_sweep.rs | 23 +++++++++++++++----- src/branch/reaper.rs | 36 +++++++++++++++++++++++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/examples/statement_lock_sweep.rs b/examples/statement_lock_sweep.rs index 060035d1..4bbdd3a6 100644 --- a/examples/statement_lock_sweep.rs +++ b/examples/statement_lock_sweep.rs @@ -1,11 +1,24 @@ //! W4 — how long does `forget_reaped_branches` block an unrelated statement? //! //! ⚠ **This file is about `AgentRuntime`'s `Mutex` and nothing else.** It builds an -//! `AgentRuntime` directly and never constructs a `RuntimeLock`, so it has never measured the -//! server's per-statement mutex — the OUTER lock `lease_thread::scan_once` takes. Confusing the -//! two is the error `bench/w4/DECISION.md` addendum 1 exists to correct, so it is said here as -//! well as there. The outer lock is measured by `examples/outer_runtime_lock.rs` (D98), which -//! wires a real `ServerContext` and probes the mutex statements actually take. +//! `AgentRuntime` directly and never constructs a `RuntimeLock`, so it has never measured +//! `ServerContext::catalog()`, the mutex a statement actually serialises on. Confusing the two is +//! the error `bench/w4/DECISION.md` exists to correct, so it is said here as well as there. That +//! mutex is measured by `examples/outer_runtime_lock.rs` (D98), which wires a real `ServerContext`. +//! +//! ⛔ **Corrected by D158 (2026-09-22): this paragraph used to cite `DECISION.md` ADDENDUM 1 and +//! to call the thing it is not measuring "the OUTER lock `lease_thread::scan_once` takes".** +//! Addendum 1 is the section that file's own head says not to quote (*"Addendum 5 (D98) closes it +//! … do not quote a 'still open' list from addendum 1 or 3"*), and pointing at `scan_once` names +//! the CLOSED half. `ServerContext::catalog()` has two distinct holders and the bare name `W4` +//! covered both: +//! * **`W4-sweep`** — the lease thread's hold, via `RuntimeLock`. **Closed by D98**: one +//! `with_lock` per chunk at `REAP_CHUNK == 1`, everything else outside, 51.6 s → 161.8 ms p99. +//! * **`W4-statement`** — every connection's hold, `pgwire/extended.rs:283` and `:357`, taken +//! outermost for one statement. **Open, and it is the gate** — it is why wiring `CommitGroup` +//! is inert, since statements serialise before reaching the fsync. +//! This harness is orthogonal to both: it measures a THIRD lock, `AgentRuntime`'s `Mutex`. +//! See `SCALE-DESIGN.md` D158 and amendment 1. //! //! `AgentRuntime` holds one `Mutex`, taken by every statement that touches branch state. //! `forget_reaped_branches` runs in three phases: phase 1 walks `state.workspaces` **holding that diff --git a/src/branch/reaper.rs b/src/branch/reaper.rs index f23b1670..39f5468f 100644 --- a/src/branch/reaper.rs +++ b/src/branch/reaper.rs @@ -436,11 +436,37 @@ impl TwoTierReaper { // So the residue drain is ADDITIVE, not a replacement: it returns work an early return had // dropped on the floor, promptly and at O(residue). The full scan keeps its own job. // - // ⚠ AND THE WALL D83 SET OUT TO REMOVE IS STILL THERE. The O(live arenas) scan still runs - // inside the per-statement lock. That is NOT this function's defect to fix — it is W4's - // open half, the outer `RuntimeLock` held across the whole of `scan_once` - // (`lease_thread.rs:399-407`). Narrowing the work was the wrong lever; the lever is the - // lock, and no landed change touches it. + // ⛔ **THIS PARAGRAPH WAS WITHDRAWN BY D158 (2026-09-22). BOTH OF ITS CLAIMS ARE FALSE AT + // HEAD, AND THE SECOND ONE IS REFUTED 320 LINES BELOW, IN THIS FILE.** It read: + // + // > ⚠ AND THE WALL D83 SET OUT TO REMOVE IS STILL THERE. The O(live arenas) scan still + // > runs inside the per-statement lock. That is NOT this function's defect to fix — it + // > is W4's open half, the outer `RuntimeLock` held across the whole of `scan_once` + // > (`lease_thread.rs:399-407`). Narrowing the work was the wrong lever; the lever is + // > the lock, and no landed change touches it. + // + // 1. **The O(live arenas) scan does NOT run inside that lock.** D88 moved it out, and this + // file says so at the `drain_pending` boundary below: *"the orphan sweep is NOT run + // here any more … It is now driven from `scan_once` AFTER the lock is released."* + // 2. **The outer lock is NOT held across the whole of `scan_once`.** D98 chunked it: + // `scan_once` contains exactly ONE `with_lock`, inside `candidates.chunks(REAP_CHUNK)` + // with `REAP_CHUNK == 1`, plus a `REAP_YIELD` stand-off between chunks so an unfair + // mutex cannot let the sweep jump waiters. 51.6 s whole-sweep wait → 161.8 ms p99, + // with a control arm (`bench/d98_outer_runtime_lock.txt`). + // 3. **The citation never resolved.** `lease_thread.rs:399-407` is `LeaseThread::start`'s + // startup resume. `scan_once` begins at `:574`. + // + // ⚠ **Kept, struck through, rather than deleted — because the withdrawal is the finding.** + // This comment was copied out of `bench/w4/DECISION.md` addendum 1, a section whose own + // file head says *"do not quote a 'still open' list from addendum 1 or 3 without reading + // addendum 5 first"*, and the leading ⚠ made it read as MORE current than the refutation + // below it rather than less. A reader who deletes this block loses the only in-tree record + // of how a withdrawn claim reached the source and survived there. + // + // ⇒ **The live work is `W4-statement`, and it is not in this file**: every CONNECTION's + // hold of `ServerContext::catalog()`, taken outermost for one statement + // (`pgwire/extended.rs:283`, `:357`). `W4-sweep` — the lease thread's hold, which is what + // this paragraph was about — is closed. See `SCALE-DESIGN.md` D158 and amendment 1. // ⛔ **D128 SITE 3 — AND ITS FIX ALREADY EXISTED TWENTY LINES AWAY, UNUSED HERE.** This // was a bare `mem::take` followed by TWO `?`. Either one returning dropped the whole // deferred set on the floor: those arenas are then never swept by the cheap residue path, From 11247caced328f1af466d55f0e9a192146635062 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:11:37 -0400 Subject: [PATCH 057/315] D138 artifact: correct a load figure that disagreed with its own raw header Two places in bench/d138_index_slope/README.md said the loaded D148 arm ran at load 3.82-5.86. d148_durable_phase_raw.txt, sitting beside it, says 'host load: 6.86 4.77 4.55'. The 3.82 belonged to an EARLIER loaded run, superseded when least-squares fitting was added to the harness; the artifact was replaced and the number in the prose was not. Bench-only. No src/tests/examples change -- git diff origin/main HEAD -- src tests examples is empty. Recorded in the file rather than quietly edited, because it is the third instance in this one row of a claim ABOUT a measurement drifting from the measurement: frames_for's 'no production caller' came from a harness LABEL, the original frames_for paragraph named both mergers wrongly, and now this. The raw header is authoritative in every case -- the harness writes it and nobody retypes it. Every other number in the file was checked against the raw files rather than assumed and is exact: 99.31 -> 99.3, 99.11 -> 99.1, 0.822+8.092 = 8.914 ms, 1.3869 -> 1.39, 2227448 -> 2.23M, 0.089%. The load figure was the only mismatch. The error direction strengthens the conclusion -- the loaded arm was MORE loaded than claimed, so load inflating the fsync explains the quiet-vs-loaded gap better. That is not why it is fixed. --- bench/d138_index_slope/README.md | 36 ++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/bench/d138_index_slope/README.md b/bench/d138_index_slope/README.md index 4edc985c..49886d2f 100644 --- a/bench/d138_index_slope/README.md +++ b/bench/d138_index_slope/README.md @@ -216,7 +216,7 @@ it, kept so the record shows the re-run happened rather than asserting it was un ## D148 — what the removed scan was WORTH on the shipped durable path -> ⚠ **SUPERSEDED BY THE QUIET RE-RUN.** The figures in this section were taken at load 3.82–5.86 +> ⚠ **SUPERSEDED BY THE QUIET RE-RUN.** The figures in this section were taken at load 6.86 > without the fleet measure lock. They are kept unedited because the re-run is a *replacement*, > not a correction of an error — but **quote the QUIET numbers at the end of this file**, not > these. What moved: the 10⁶ extrapolation, 24.5–26.4% → **31.0–32.0%**. What did not: the @@ -318,7 +318,7 @@ failing is mine.** D139's fixture fix is in this merge, so it may already be gon **`e98c5d2` — the commit the suite certified**. Held `~/wt/logs/measure-lock.sh` as `d148-quiet`, **`load_at_acquire=2`**, zero competing `cargo`/`rustc`, both fleet locks otherwise free. -| | loaded (3.82–5.86) | **quiet (load 2, under lock)** | +| | loaded (6.86 / 4.77 / 4.55) | **quiet (load 2, under lock)** | |---|---|---| | `sync_data` share of an append | 99.1% | **99.3%** | | the scan, at 2000 frames | 0.073% | **0.089%** | @@ -384,3 +384,35 @@ then `prepush` (the CI-parity checks a suite cannot see), and asks `staleness` * suite, so it can see a base that moved *during* the run. Main moved by 38 commits of real code here, so `COVERED-NO-CODE` is not expected; if staleness returns `STALE-GREEN`, the answer is to merge and re-run, not to reason about whether arena changes reach `src/tel/`. + + +## ⛔ Correction, after landing — this file's load figure disagreed with its own raw header + +Two places in this file said the loaded D148 arm ran **at load 3.82–5.86**. +**`d148_durable_phase_raw.txt`, sitting beside it, says `host load: 6.86 4.77 4.55`.** + +The 3.82 belonged to an **earlier** loaded run that was superseded when least-squares fitting was +added to the harness. The artifact underneath was replaced; the number in the prose was not. Both +places are corrected above. + +⇒ **Why this is recorded rather than quietly edited.** It is the failure this repo has logged +before — *audit the header against the body* — and it is the same shape as the two D138 errors +this very file corrects: `frames_for`'s "no production caller" came from a harness **label**, and +the original `frames_for` paragraph named both mergers wrongly. **Three times in one row, a claim +ABOUT a measurement drifted from the measurement.** The raw header is authoritative in every case, +because the harness writes it and nobody retypes it. + +⇒ **The error direction happens to strengthen the conclusion** — the loaded arm was *more* loaded +than claimed, so load inflating the fsync explains the quiet-vs-loaded gap better, not worse. That +is not why it is fixed. + +✅ **Everything else in this file was checked against the raw files rather than assumed**, and is +exact: `sync_data`/`TOTAL` 99.31% → "99.3%"; the loaded arm 99.11% → "99.1%"; both lookups +0.822 + 8.092 = **8.914 ms**; least-squares slope 1.3869 → "1.39"; crossover 2,227,448 → "2.23M"; +scan at 2000 frames 0.089%. The load figure was the only mismatch. + +### Where this result is also banked + +`artie-research/bench/D148-RESULT.md`, with both raw files. That copy carries the same correction +(`f15e333`). **If the two ever disagree, the ferrodb raw files are authoritative** — they are what +the harness wrote. From 7fd481cdd79385c67d10218689fc4e21b594df86 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:12:54 -0400 Subject: [PATCH 058/315] D144: bank the raw lease-lag artifact before interpreting it rc=0, finished 2026-09-22T14:10:09Z, sentinel present. The run queued 560s behind d138 for the suite lock rather than measuring under it, and its header records worktree, branch, head, tree state and binary mtime in the run's own output, so the provenance travels with the number instead of being asserted beside it. Committed by the team lead, not by the lane, so that removing this 11 GB worktree under disk pressure cannot lose it. The interpretation is still owed; the measurement is the part that is expensive to redo. --- bench/d144_lease_lag.done | 1 + bench/d144_lease_lag.txt | 135 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 bench/d144_lease_lag.done create mode 100644 bench/d144_lease_lag.txt diff --git a/bench/d144_lease_lag.done b/bench/d144_lease_lag.done new file mode 100644 index 00000000..cfb5fcc0 --- /dev/null +++ b/bench/d144_lease_lag.done @@ -0,0 +1 @@ +rc=0 finished=2026-09-22T14:10:09Z diff --git a/bench/d144_lease_lag.txt b/bench/d144_lease_lag.txt new file mode 100644 index 00000000..d05d5145 --- /dev/null +++ b/bench/d144_lease_lag.txt @@ -0,0 +1,135 @@ +d144-lease-lag: queued behind 91978 d138 2026-09-22T13:46:51Z +d144-lease-lag: acquired the suite lock after 560s +# D144 — the lag the LEASE produces. RAW ARTIFACT. +# +# run at 2026-09-22T14:00:18Z UTC +# worktree /Users/idide/wt/ferrodb-d133-pending-len +# branch d133-pending-len +# head 0a3c8032ad33bb8c50b49b281f199cec6d691160 +# tree state 1 modified/untracked path(s) +# ?? bench/d144_lease_lag.txt +# binary mtime 2026-09-22T13:44:22Z +# host Darwin 25.6.0 arm64 +# measure lock 61248 d133-pending-len 2026-09-22T11:52:09Z +# suite lock HELD BY THIS RUN: 14690 d144-lease-lag 2026-09-22T14:00:18Z (queued 560s) +# +# ⛔ THE SCAN INTERVAL THIS RUN USED IS **3000 ms**, NOT THE SHIPPED 30 s. +# Δ is swept against $SCAN_MS, never against 30 s. The knob is the shipped one: +# 109:pub const DEFAULT_SCAN_MILLIS: u64 = 30_000; +# 112:pub const SCAN_INTERVAL_ENV: &str = "FERRODB_LEASE_SCAN_MILLIS"; +# and the harness reads it through the server's own scan_interval_from_env(): +# pub fn scan_interval_from_env() -> Result { +# match std::env::var(SCAN_INTERVAL_ENV) { +# Err(_) => Ok(Duration::from_millis(DEFAULT_SCAN_MILLIS)), +# Ok(v) => parse_scan_interval(&v), +# } +# } +# +# ⛔ THE LEASE IS COMPRESSED. The shipped constant has no environment override: +# 96:pub const DEFAULT_LEASE_MILLIS: u64 = 15 * 60 * 1000; +# 1477: .fork(parent, LeaseDeadline::from_now(DEFAULT_LEASE_MILLIS))?; +# The window under test is exactly Δ and does not contain L, so a compressed L rescales +# WHEN the window opens and not HOW WIDE it is. The CONTROL arm varies L at fixed Δ and +# must not move; the run REFUSES if it does. +# +============================================================================== +ARM 1 — Δ swept ACROSS the scan interval S=3000 ms, below and above. +============================================================================== +# suite lock @ before arm 1: HELD BY THIS RUN (14:00:18Z, load 5.56 5.31 5.21) +D144 — the lag the LEASE produces, swept across the scan interval. + + scan interval S 3000 ms (from scan_interval_from_env(), i.e. FERRODB_LEASE_SCAN_MILLIS or the 30000 ms default) + lease L 20000 ms (COMPRESSED from DEFAULT_LEASE_MILLIS = 900000 ms, which has no env override; + L does not enter the window, which is exactly Δ — the `lease` control arm tests that claim) + pairs per point 60, parent forks spread across exactly one S so the sweep phase is uniform + pages per parent 4, written BEFORE the child forks so the child can see them + reaping the REAL LeaseThread on a real clock. No faked time, no direct reap() call. + + S realis = the MEDIAN sweep gap this row actually achieved, from the lease + thread's own `attempts` counter. Every Δ/S below divides realised by + realised — one instrument, one moment. The nominal 3000 ms is the + REQUEST, and appears in no ratio. `S spread` is min..max of those gaps; + a row whose spread exceeds 25% of its median REFUSES rather than + pretending one number describes the cadence. + + parked = parents whose pages were parked, from the arena's CUMULATIVE push + counter. `maxcoex` is the most that ever coexisted, and BELOW S it is a + LOWER BOUND on `parked`, not the same number: a parked entry lives about + one sweep there, while the parents are reaped across TWO consecutive + sweeps, so the two groups never overlap. Above S entries outlive a sweep, + they do overlap, and the two columns converge. Draw 2 reported `maxcoex` + as `parked` and so understated the fraction below S. + peak = the longest the QUEUE itself ever got, in entries. That is D142's + question and `maxcoex` x pages answers it correctly in both regimes. + + P1 = D144 as written: a STEP — 0 parked below S, all parked above. + P2 = refinement: a KNEE — parked fraction = min(Δ/S, 1), so a RAMP below S. + + Δ ms lease ms S run/cal Δ/S parked P1 P2 maxcoex peak realised Δ S spread scans a/s/r/f/reaped incsum +lease: reaped 42 expired branch(es) with no client cooperation (b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b12@g0, b10@g0, b8@g0, b6@g0, b4@g0, b2@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b11@g0, b9@g0, b7@g0, b5@g0, b3@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 78 expired branch(es) with no client cooperation (b120@g0, b118@g0, b116@g0, b114@g0, b112@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b119@g0, b117@g0, b115@g0, b113@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0); 0 workspace(s) forgotten + 0 20000 3010/3015 0.000 0 0 0 0 0 0..1 3003..3131 15/15/0/0/120 0 +lease: reaped 32 expired branch(es) with no client cooperation (b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b21@g0, b19@g0, b17@g0, b15@g0, b13@g0, b12@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0, b22@g0, b20@g0, b18@g0, b16@g0, b14@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 88 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b109@g0, b107@g0, b105@g0, b103@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b110@g0, b108@g0, b106@g0, b104@g0, b102@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0); 0 workspace(s) forgotten + 500 20000 3010/2998 0.166 10 0 10 10 40 497..504 2999..3139 15/15/0/0/120 13 +lease: reaped 22 expired branch(es) with no client cooperation (b21@g0, b22@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 98 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b99@g0, b97@g0, b95@g0, b93@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b29@g0, b27@g0, b25@g0, b23@g0, b100@g0, b98@g0, b96@g0, b94@g0, b92@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b28@g0, b26@g0, b24@g0); 0 workspace(s) forgotten + 1000 20000 3010/3005 0.332 20 0 20 20 80 988..1012 3000..3148 15/15/0/0/120 74 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 90 expired branch(es) with no client cooperation (b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b89@g0, b87@g0, b85@g0, b83@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b39@g0, b37@g0, b35@g0, b33@g0, b31@g0, b90@g0, b88@g0, b86@g0, b84@g0, b82@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b38@g0, b36@g0, b34@g0, b32@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 9 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0); 0 workspace(s) forgotten + 1500 20000 3010/3005 0.498 30 0 30 21 84 1488..1511 2997..3139 16/16/0/0/120 49 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 81 expired branch(es) with no client cooperation (b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b79@g0, b77@g0, b75@g0, b73@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b49@g0, b47@g0, b45@g0, b43@g0, b41@g0, b80@g0, b78@g0, b76@g0, b74@g0, b72@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b48@g0, b46@g0, b44@g0, b42@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 18 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0); 0 workspace(s) forgotten + 2000 20000 3010/3002 0.664 39 0 40 21 84 1989..2011 3000..3127 16/16/0/0/120 61 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 71 expired branch(es) with no client cooperation (b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b69@g0, b67@g0, b65@g0, b63@g0, b61@g0, b59@g0, b57@g0, b55@g0, b53@g0, b51@g0, b70@g0, b68@g0, b66@g0, b64@g0, b62@g0, b60@g0, b58@g0, b56@g0, b54@g0, b52@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 28 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0); 0 workspace(s) forgotten + 2500 20000 3009/3007 0.831 49 0 50 28 112 2488..2510 3001..3120 16/16/0/0/120 46 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 60 expired branch(es) with no client cooperation (b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0, b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0); 0 workspace(s) forgotten + 3000 20000 3010/3012 0.997 60 0 60 39 156 2991..3011 3000..3102 16/16/0/0/120 60 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 53 expired branch(es) with no client cooperation (b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 7 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0); 0 workspace(s) forgotten + 4500 20000 3011/3002 1.495 60 60 60 60 240 4488..4512 3000..3082 17/17/0/0/120 60 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 6000 20000 3011/3011 1.993 60 60 60 60 240 5988..6004 2998..3073 17/17/0/0/120 60 +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 9000 20000 3009/3007 2.991 60 60 60 60 240 8989..9012 2998..3071 18/18/0/0/120 116 + + CONTROL — L varied at a fixed Δ=6000 ms. L is not in the window, so + these rows MUST agree. If they do not, the derivation above is wrong. + Δ ms lease ms S run/cal Δ/S parked P1 P2 maxcoex peak realised Δ S spread scans a/s/r/f/reaped incsum +lease: reaped 21 expired branch(es) with no client cooperation (b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 39 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0, b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0); 0 workspace(s) forgotten +lease: reaped 23 expired branch(es) with no client cooperation (b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 37 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0, b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0); 0 workspace(s) forgotten + 6000 20000 3011/3004 1.993 60 60 60 60 240 5989..6012 2999..3068 17/17/0/0/120 170 +lease: reaped 41 expired branch(es) with no client cooperation (b41@g0, b40@g0, b39@g0, b38@g0, b37@g0, b36@g0, b35@g0, b34@g0, b33@g0, b32@g0, b31@g0, b30@g0, b29@g0, b28@g0, b27@g0, b26@g0, b25@g0, b24@g0, b23@g0, b22@g0, b21@g0, b20@g0, b19@g0, b18@g0, b17@g0, b16@g0, b15@g0, b14@g0, b13@g0, b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 19 expired branch(es) with no client cooperation (b60@g0, b59@g0, b58@g0, b57@g0, b56@g0, b55@g0, b54@g0, b53@g0, b52@g0, b51@g0, b50@g0, b49@g0, b48@g0, b47@g0, b46@g0, b45@g0, b44@g0, b43@g0, b42@g0); 0 workspace(s) forgotten +lease: reaped 43 expired branch(es) with no client cooperation (b103@g0, b102@g0, b101@g0, b100@g0, b99@g0, b98@g0, b97@g0, b96@g0, b95@g0, b94@g0, b93@g0, b92@g0, b91@g0, b90@g0, b89@g0, b88@g0, b87@g0, b86@g0, b85@g0, b84@g0, b83@g0, b82@g0, b81@g0, b80@g0, b79@g0, b78@g0, b77@g0, b76@g0, b75@g0, b74@g0, b73@g0, b72@g0, b71@g0, b70@g0, b69@g0, b68@g0, b67@g0, b66@g0, b65@g0, b64@g0, b63@g0, b62@g0, b61@g0); 0 workspace(s) forgotten +lease: reaped 17 expired branch(es) with no client cooperation (b120@g0, b119@g0, b118@g0, b117@g0, b116@g0, b115@g0, b114@g0, b113@g0, b112@g0, b111@g0, b110@g0, b109@g0, b108@g0, b107@g0, b106@g0, b105@g0, b104@g0); 0 workspace(s) forgotten + 6000 40000 3007/3012 1.995 60 60 60 60 240 5989..6012 2999..3069 24/24/0/0/120 111 + + realised Δ is computed from the DEADLINES the catalog recorded + (child.lease_deadline - parent.lease_deadline), not from the requested value. + CONTROL HELD: parked [60, 60] across leases [20000, 40000] — varying L did not move the window. + 12 rows, every lease thread scanned, none refused, none failed. +# suite lock @ after arm 1: HELD BY THIS RUN (14:10:09Z, load 2.71 3.13 4.02) +(arm 1 rc=0) + +# binary self-identified from its own output: calibration columns present. +# load after 2.71 3.13 4.02 +# arm rcs 0 +# the arm returned 0: every lease thread scanned, none refused, none failed, and the +# lease control did not move. From 5babf9ab475cc9fb12f46137aaf0a828676d945c Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:32:39 -0400 Subject: [PATCH 059/315] bench(D158 item 1): bank the certification for bfcfa0e -- 2516, all three gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e w4gen: go rc=0 passed=97 failed=0 certify-head: OK — suite head=bfcfa0e == landing bfcfa0e staleness: COVERED — 0 behind, 14 ahead of main 3a9a5f9 prepush: OK land-gate: OK Pre-registered as a DELTA (+4) with both landing orders tabulated before the run, so neither total could be chosen afterwards. This lane landed first: 2516. Sixth check honoured -- all five added tests asserted BY NAME in the log, including D151's, whose absence is what staleness flagged and what a total alone could never have revealed. NOT PUSHED. origin/main is 14 commits behind local main; the push is not mine. --- .../certified/CERTIFICATION.txt | 74 ++++ .../certified/SUMMARY.txt | 3 + .../certified/land-gate.out | 37 ++ .../certified/suite-w4gen.go.log | 330 +++++++++++++++ .../certified/suite-w4gen.tail400.log | 400 ++++++++++++++++++ 5 files changed, 844 insertions(+) create mode 100644 bench/w4_generation_keying/certified/CERTIFICATION.txt create mode 100644 bench/w4_generation_keying/certified/SUMMARY.txt create mode 100644 bench/w4_generation_keying/certified/land-gate.out create mode 100644 bench/w4_generation_keying/certified/suite-w4gen.go.log create mode 100644 bench/w4_generation_keying/certified/suite-w4gen.tail400.log diff --git a/bench/w4_generation_keying/certified/CERTIFICATION.txt b/bench/w4_generation_keying/certified/CERTIFICATION.txt new file mode 100644 index 00000000..30b6f213 --- /dev/null +++ b/bench/w4_generation_keying/certified/CERTIFICATION.txt @@ -0,0 +1,74 @@ +CERTIFICATION — D158 item 1, W4's correctness item. Branch agent-acf966798a3979f57. + + gated sha bfcfa0e (branch tip, unmoved before and after the gate) + base main = 3a9a5f9 (LOCAL main; origin/main is 14 commits behind at 4685d80) + NOT PUSHED. This certifies a local merge candidate, nothing more. + +THE SUITE, read from the verifier's own output line, never from the .log + w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e + w4gen: go rc=0 passed=97 failed=0 + + Mode matters: 2516 is a PER-TARGET total. `whole` = per-target + 2 doctests, the second a + compile_fail reported as its own block. Do not compare this number across modes. + 2516 = the 2512 base at cdf6434 (D151) + 4. + +★ THE NUMBER WAS PRE-REGISTERED AS A DELTA AND LANDED EXACTLY. + `bench/w4_generation_keying/PREREG.txt`, amendment 3, cut BEFORE this run and append-only: + the claim is +4, with BOTH landing orders tabulated (2516 if this lands first, 2522 if + d154's +6 lands first) precisely so that neither total could be chosen after the fact. + This landed first. 2516. + + The three earlier amendments are the honest history and are kept: 2514 -> 2515 (+1 test for a + defect this change introduced, found by reading the diff) -> 2516 (the BASE moved 2511 -> 2512 + when D151 landed mid-run). Only the base ever moved; +4 never did. + +THE SIXTH CHECK — tests asserted BY NAME, not inferred from a total + A total one short would mean D151's test did not survive my merge, and a sum cannot say that. + Read out of suite-w4gen.log: + === target d151_parse_reads_a_refreshed_snapshot + test a_parse_on_a_cached_connection_sees_ddl_another_connection_did ... ok + test agent_sql::runtime::tests::a_recycled_slot_installs_the_new_workspace_before_releasing_the_old ... ok + test agent_sql::runtime::tests::the_reconciliation_crosses_its_chunk_boundary_and_forgets_every_branch ... ok + test a_reaped_sessions_abandon_must_not_retire_the_new_occupants_session ... ok + test a_reaped_sessions_select_must_not_read_the_new_occupants_staged_row ... ok + +ALL THREE GATES, verbatim from tools/land-gate.sh (full output in land-gate.out) + certify-head: OK — suite head=bfcfa0e == landing bfcfa0e + staleness: COVERED — branch bfcfa0e vs 3a9a5f9: 0 behind, 14 ahead + prepush: OK — FFI gate sweep clean; RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" + on both `cargo build --examples` and `cargo check --all-targets`; and the + x86_64-pc-windows-msvc check (which does NOT link, so it cannot see a missing + POSIX symbol — check 1 is what covers that). + land-gate: OK — suite names the commit · base is covered · CI-parity passes · tree unmoved. + + ⚠ `prepush` is not a formality for THIS change. Keying `workspaces` by `BranchId` DELETED + callers — `forget_one_branch`'s hand-written re-validation and `run_activity`'s `names` lookup + — and a change that makes something unused is invisible to a green suite and fatal to CI's + `-D dead_code`. That is the exact shape prepush exists for. + +WHAT LANDED + src/agent_sql/runtime.rs State::workspaces: BTreeMap -> BTreeMap + tests/w4_stale_branch_crosses_agents.rs the failing-first test, over the real statement path + bench/w4/DECISION.md addendum 6; four "still open" lists banded + Production code delta is small: 123 lines added (~50 of them the two new lib tests) / 77 removed. + +TWO RUNS WERE DISCARDED AND NEITHER IS QUOTED + Both were SIGTERMed and refused with "A suite cut short is not a count"; no partial count exists. + The first was killed because `staleness.sh` — which takes no lock, so it is free to run against + a suite in flight — read STALE-GREEN mid-run: D151 had landed into LOCAL `main` while + `origin/main` sat 14 commits behind, so `git fetch && git log origin/main` said "base unmoved" + and was wrong. Worktrees share one `.git` and land-gate defaults to the LOCAL ref. + ⇒ COMPARE AGAINST LOCAL `main`, ALWAYS, AND NAME THE SHA. + +FIRE-CHECK, on the tree this certifies, baseline before any mutant was read + BASELINE unmutated PASS + evict --lib KILLED + evict_order --lib KILLED + chunk_stop --lib KILLED + overlay w4_stale_branch_crosses_agents KILLED + record_read w4_stale_branch_crosses_agents KILLED + blind_writes w4_stale_branch_crosses_agents KILLED + seal w4_stale_branch_crosses_agents KILLED + forget w4_sweep_slot_recycle KILLED + 8/8. `chunk_stop` additionally returns rc=0 against all three PRE-EXISTING tests named for + `forget_reaped_branches` — which is how the coverage gap was measured rather than asserted. diff --git a/bench/w4_generation_keying/certified/SUMMARY.txt b/bench/w4_generation_keying/certified/SUMMARY.txt new file mode 100644 index 00000000..a77f5a29 --- /dev/null +++ b/bench/w4_generation_keying/certified/SUMMARY.txt @@ -0,0 +1,3 @@ +w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.log +w4gen: go rc=0 passed=97 failed=0 log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.go.log +RUNNER_EXIT=0 diff --git a/bench/w4_generation_keying/certified/land-gate.out b/bench/w4_generation_keying/certified/land-gate.out new file mode 100644 index 00000000..2a4b7ed8 --- /dev/null +++ b/bench/w4_generation_keying/certified/land-gate.out @@ -0,0 +1,37 @@ +land-gate: landing bfcfa0e (bfcfa0e) +land-gate: onto base main (3a9a5f9) +land-gate: evidence scratchpad/verify-out-w4gen + +land-gate: [1/3] certify-head.sh — does the verdict name the commit being landed? +certify-head: OK — suite head=bfcfa0e == landing bfcfa0e + w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.log + +land-gate: [2/3] staleness.sh — has the base moved under this branch's files SINCE the suite ran? + branch bfcfa0e vs 3a9a5f9f1646aabad33efef892a487291be9a954: 0 behind, 14 ahead + COVERED + +land-gate: [3/3] prepush.sh — the CI-parity checks a green suite structurally cannot see. +CI RUSTFLAGS (read from .github/workflows/tests.yml): -D duplicate_macro_attributes -D dead_code + +== FFI gate sweep (examples/ and src/) == +ok: no ungated extern "C" + +== RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" cargo build --examples == +warning: `ferrodb` (example "d51_sharedword_probe") generated 1 warning (run `cargo fix --example "d51_sharedword_probe" -p ferrodb` to apply 1 suggestion) +warning: `ferrodb` (example "d68_merge_is_o_table") generated 1 warning (run `cargo fix --example "d68_merge_is_o_table" -p ferrodb` to apply 1 suggestion) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.79s + +== RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" cargo check --all-targets == + +warning: `ferrodb` (example "d51_sharedword_probe") generated 1 warning (run `cargo fix --example "d51_sharedword_probe" -p ferrodb` to apply 1 suggestion) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.47s + +== cargo check --target x86_64-pc-windows-msvc (compiles the cfg(not(unix)) arms) == +ok (note: this does NOT link, so it cannot see a missing POSIX symbol — check 1 is what covers that) + +PREPUSH: OK — the checks CI runs that the suite does not. + This is NOT a substitute for tools/verify-suite.sh, and it says nothing about head=. + Run all three: verify-suite.sh, certify-head.sh, and this. + +land-gate: OK — the evidence in scratchpad/verify-out-w4gen is about the tree that will ship. +land-gate: suite names the commit · base is covered · CI-parity passes · tree unmoved throughout. diff --git a/bench/w4_generation_keying/certified/suite-w4gen.go.log b/bench/w4_generation_keying/certified/suite-w4gen.go.log new file mode 100644 index 00000000..5d2e8d69 --- /dev/null +++ b/bench/w4_generation_keying/certified/suite-w4gen.go.log @@ -0,0 +1,330 @@ +=== RUN TestAnyDuplicationOrReplayConvergesToTheSameState +--- PASS: TestAnyDuplicationOrReplayConvergesToTheSameState (0.29s) +=== RUN TestAStaleEventLaterInTheFeedIsFilteredByTheCursor +--- PASS: TestAStaleEventLaterInTheFeedIsFilteredByTheCursor (0.01s) +=== RUN TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn +--- PASS: TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn (0.00s) +=== RUN TestValidateRefusesAnEmptyFeed +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAnEmptyFeed (0.00s) +=== RUN TestValidateRefusesAFeedThatIsMissingItsFinalNewline +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAFeedThatIsMissingItsFinalNewline (0.00s) +=== RUN TestSinkRefusesAnEmptyFeed +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE t +--- PASS: TestSinkRefusesAnEmptyFeed (0.00s) +=== RUN TestDuckSinkRefusesWithoutAKeyColumn +--- PASS: TestDuckSinkRefusesWithoutAKeyColumn (0.01s) +=== RUN TestDiffRefusesAMalformedSourceDump +MATCH 1 row(s) from 1 event(s) +--- PASS: TestDiffRefusesAMalformedSourceDump (0.00s) +=== RUN TestEveryRowOfOneCommitLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 103 TABLE t +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE t +--- PASS: TestEveryRowOfOneCommitLands (0.00s) +=== RUN TestTheLaterRecordInOneCommitWinsForTheSameKey +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 103 TABLE t +--- PASS: TestTheLaterRecordInOneCommitWinsForTheSameKey (0.00s) +=== RUN TestEveryRowOfASnapshotLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +--- PASS: TestEveryRowOfASnapshotLands (0.00s) +=== RUN TestI20RegressionInferredDestinationThenDeclaredCreateTable +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 11 TABLE inv + i20_regression_test.go:43: destination shape after the inference path: [[id TEXT] [qty TEXT]] +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestI20RegressionInferredDestinationThenDeclaredCreateTable (0.00s) +=== RUN TestI20RegressionInferredCatchUpOverADeclaredDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 31 TABLE inv + i20_regression_test.go:87: rows: [[1 10 ] [2 20 new]] +--- PASS: TestI20RegressionInferredCatchUpOverADeclaredDestination (0.00s) +=== RUN TestI20RegressionTwoTablesDifferentKeys +=== RUN TestI20RegressionTwoTablesDifferentKeys/sqlite + i20_regression_test.go:115: sqlite: refused a second table whose key is not the flag: apply INSERT to orders: SQL logic error: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint (1) +=== RUN TestI20RegressionTwoTablesDifferentKeys/duckdb + i20_regression_test.go:115: duckdb: refused a second table whose key is not the flag: table orders has no key column "id"; the ordering guard needs one +--- PASS: TestI20RegressionTwoTablesDifferentKeys (0.02s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/sqlite (0.00s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/duckdb (0.02s) +=== RUN TestI20RegressionKeyForOnAPrimaryKeylessDestination + i20_regression_test.go:145: keyFor on a PK-less destination: "id" (flag is "id") +--- PASS: TestI20RegressionKeyForOnAPrimaryKeylessDestination (0.00s) +=== RUN TestI20RegressionKeyCacheAcrossDropAndRecreate +applied 8, skipped 0 re-delivered +APPLIED 8 SKIPPED 0 CURSOR 71 TABLE inv + i20_regression_test.go:183: after drop+recreate: [[1 6]] +--- PASS: TestI20RegressionKeyCacheAcrossDropAndRecreate (0.01s) +=== RUN TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +applied 1, skipped 2 re-delivered +APPLIED 1 SKIPPED 2 CURSOR 2 TABLE inv +--- PASS: TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype (0.00s) +=== RUN TestI20RegressionTypeCheckAgainstAHandBuiltDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestI20RegressionTypeCheckAgainstAHandBuiltDestination (0.00s) +=== RUN TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip +--- PASS: TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip (0.00s) +=== RUN TestSameNumberStillCatchesDigitsNoDoubleCanHold +--- PASS: TestSameNumberStillCatchesDigitsNoDoubleCanHold (0.00s) +=== RUN TestExactFloatIsExactForSubnormals +--- PASS: TestExactFloatIsExactForSubnormals (0.00s) +=== RUN TestExactFloatRoundTripsToTheSameDouble +--- PASS: TestExactFloatRoundTripsToTheSameDouble (0.00s) +=== RUN TestConsumerRefusesADeniedColumnTheProducerLetThrough +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesADeniedColumnTheProducerLetThrough (0.00s) +=== RUN TestConsumerChecksTheBeforeImageToo +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerChecksTheBeforeImageToo (0.00s) +=== RUN TestConsumerRefusesAnUndecidedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnUndecidedTable (0.00s) +=== RUN TestConsumerRefusesASchemaEventDeclaringADeniedColumn +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesASchemaEventDeclaringADeniedColumn (0.00s) +=== RUN TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy (0.00s) +=== RUN TestADropIsNotJudgedByTheShapeCheck +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestADropIsNotJudgedByTheShapeCheck (0.00s) +=== RUN TestWithNoPublicationNothingIsRefused +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestWithNoPublicationNothingIsRefused (0.00s) +=== RUN TestUsePublicationRefusesAFileItCannotRead +--- PASS: TestUsePublicationRefusesAFileItCannotRead (0.00s) +=== RUN TestParsePublicationRefusesEveryMalformedShape +--- PASS: TestParsePublicationRefusesEveryMalformedShape (0.00s) +=== RUN TestTheSinkRefusesADeniedColumnBeforeLandingIt +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE customers +--- PASS: TestTheSinkRefusesADeniedColumnBeforeLandingIt (0.00s) +=== RUN TestPrecisionRefusesADeniedColumnRatherThanPrintingIt +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 id number 1 +FIELD 1 name string ada +SUMMARY strings=1 numbers=1 lossy=0 +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 ssn string 000-11-2222 +SUMMARY strings=2 numbers=1 lossy=0 +--- PASS: TestPrecisionRefusesADeniedColumnRatherThanPrintingIt (0.00s) +=== RUN TestPrecisionRefusesAnUndecidedTable +--- PASS: TestPrecisionRefusesAnUndecidedTable (0.00s) +=== RUN TestConsumerChecksADropsBeforeImage +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerChecksADropsBeforeImage (0.00s) +=== RUN TestConsumerRefusesDuplicateKeysInOneObject +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesDuplicateKeysInOneObject (0.00s) +=== RUN TestConsumerRefusesKeysOutsideTheEnvelope +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesKeysOutsideTheEnvelope (0.00s) +=== RUN TestConsumerRefusesANonScalarRowValue +OK 1 +WRITERS attributed=0 unattributed=1 +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesANonScalarRowValue (0.00s) +=== RUN TestConsumerRefusesAnEventForAnExcludedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnEventForAnExcludedTable (0.00s) +=== RUN TestParsePublicationHandlesExclusions +--- PASS: TestParsePublicationHandlesExclusions (0.00s) +=== RUN TestAnAttributedEventSurvivesAPublication +--- PASS: TestAnAttributedEventSurvivesAPublication (0.00s) +=== RUN TestAnUnknownTopLevelKeyIsStillRefused +--- PASS: TestAnUnknownTopLevelKeyIsStillRefused (0.00s) +=== RUN TestBothSinksLandTheSameWriterColumnNames +--- PASS: TestBothSinksLandTheSameWriterColumnNames (0.00s) +=== RUN TestRetractWorksAgainstADuckDBDestination +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=duckdb +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=duckdb +--- PASS: TestRetractWorksAgainstADuckDBDestination (0.04s) +=== RUN TestRetractInDeleteModeAgainstDuckDB +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=duckdb +--- PASS: TestRetractInDeleteModeAgainstDuckDB (0.03s) +=== RUN TestRetractAndScanRefuseAnUnknownEngine +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractAndScanRefuseAnUnknownEngine (0.00s) +=== RUN TestAnOlderDuckDBDestinationIsUpgradedInPlace +RETRACTED 3 OF 7 table=inv model_version=2026-07 mode=quarantine engine=duckdb +--- PASS: TestAnOlderDuckDBDestinationIsUpgradedInPlace (0.04s) +=== RUN TestRetractTouchesExactlyOneModelVersionsRows +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractTouchesExactlyOneModelVersionsRows (0.01s) +=== RUN TestRetractInDeleteModeAlsoTombstones +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=sqlite +--- PASS: TestRetractInDeleteModeAlsoTombstones (0.00s) +=== RUN TestARetractionThatMatchesNothingIsRefused +RETRACTED 2 OF 6 table=inv model_version=2026-05 mode=quarantine engine=sqlite +--- PASS: TestARetractionThatMatchesNothingIsRefused (0.01s) +=== RUN TestAnEmptyModelVersionIsRefused +--- PASS: TestAnEmptyModelVersionIsRefused (0.00s) +=== RUN TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing +--- PASS: TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing (0.00s) +=== RUN TestARetractionSurvivesAReplayButNotAFreshWrite +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestARetractionSurvivesAReplayButNotAFreshWrite (0.01s) +=== RUN TestScanReportsEveryRowsWriterAndMark +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanReportsEveryRowsWriterAndMark (0.01s) +=== RUN TestAWriterObjectWithAnUnknownKeyIsRefused +--- PASS: TestAWriterObjectWithAnUnknownKeyIsRefused (0.00s) +=== RUN TestAMalformedWriterIsRefused +--- PASS: TestAMalformedWriterIsRefused (0.00s) +=== RUN TestScanRefusesAnEmptyTable +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanRefusesAnEmptyTable (0.01s) +=== RUN TestWellFormedColumnLevelEventsAreAccepted +--- PASS: TestWellFormedColumnLevelEventsAreAccepted (0.00s) +=== RUN TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape +--- PASS: TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape (0.00s) +=== RUN TestColumnLevelChangesAreNewsNotDeclarations +--- PASS: TestColumnLevelChangesAreNewsNotDeclarations (0.00s) +=== RUN TestTableFoldSurvivesAColumnLevelChange +--- PASS: TestTableFoldSurvivesAColumnLevelChange (0.00s) +=== RUN TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds +--- PASS: TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds (0.00s) +=== RUN TestTheFoldReEncodesAColumnThatBecameStringTyped +--- PASS: TestTheFoldReEncodesAColumnThatBecameStringTyped (0.00s) +=== RUN TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows +applied 5, skipped 0 re-delivered +APPLIED 5 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows (0.00s) +=== RUN TestReplayingAFeedWithASchemaChangeIsANoOp +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE inv +--- PASS: TestReplayingAFeedWithASchemaChangeIsANoOp (0.00s) +=== RUN TestTheSqliteSinkRenamesRatherThanRebuilding +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +--- PASS: TestTheSqliteSinkRenamesRatherThanRebuilding (0.00s) +=== RUN TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows (0.00s) +=== RUN TestASinkThatNeverSawTheCreateStillGetsTheShape +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestASinkThatNeverSawTheCreateStillGetsTheShape (0.00s) +=== RUN TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 51 TABLE inv +--- PASS: TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration (0.00s) +=== RUN TestTheCatchUpRefusesAnythingThatIsNotAPrefix +--- PASS: TestTheCatchUpRefusesAnythingThatIsNotAPrefix (0.00s) +=== RUN TestTheFeedLandsInDuckdbAcrossAMissedAlter +--- PASS: TestTheFeedLandsInDuckdbAcrossAMissedAlter (0.01s) +=== RUN TestTheSqliteCatchUpRefusesARetypeInTheDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestTheSqliteCatchUpRefusesARetypeInTheDeclaration (0.00s) +=== RUN TestTheSqliteCatchUpComparesTypesAndNotOnlyNames +--- PASS: TestTheSqliteCatchUpComparesTypesAndNotOnlyNames (0.00s) +=== RUN TestTheSqliteSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkFollowsARenameOfTheKeyColumn (0.00s) +=== RUN TestTheDuckSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheDuckSinkFollowsARenameOfTheKeyColumn (0.02s) +=== RUN TestARenameTruncatedAwayStallsTheSinkButNamesTheCause +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestARenameTruncatedAwayStallsTheSinkButNamesTheCause (0.00s) +=== RUN TestDuckTypeMapping +--- PASS: TestDuckTypeMapping (0.00s) +=== RUN TestWideTypesLandExactlyInDuckDB +--- PASS: TestWideTypesLandExactlyInDuckDB (0.01s) +=== RUN TestDuckTypeOfInfersFromTheValue +--- PASS: TestDuckTypeOfInfersFromTheValue (0.00s) +=== RUN TestEnsureDuckTableRefusesATypeOutsideTheAllowlist +--- PASS: TestEnsureDuckTableRefusesATypeOutsideTheAllowlist (0.01s) +=== RUN TestEnsureDuckTableRefusesATableWithoutTheKey +--- PASS: TestEnsureDuckTableRefusesATableWithoutTheKey (0.01s) +=== RUN TestApplyRejectsAStaleEventAndAcceptsANewerOne +--- PASS: TestApplyRejectsAStaleEventAndAcceptsANewerOne (0.02s) +=== RUN TestDeleteIsSoftAndRejectsAStaleResurrection +--- PASS: TestDeleteIsSoftAndRejectsAStaleResurrection (0.01s) +=== RUN TestColumnsComeFromTheCatalogNotFromGuessing +--- PASS: TestColumnsComeFromTheCatalogNotFromGuessing (0.02s) +=== RUN TestApplyRefusesARowWithNoKeyColumn +--- PASS: TestApplyRefusesARowWithNoKeyColumn (0.01s) +=== RUN TestOpenDestinationRefusesAnUnknownEngine +--- PASS: TestOpenDestinationRefusesAnUnknownEngine (0.01s) +=== RUN TestALaterCreateTableThatDisagreesIsRefused +--- PASS: TestALaterCreateTableThatDisagreesIsRefused (0.01s) +=== RUN TestARepeatedIdenticalCreateTableIsStillANoOp +--- PASS: TestARepeatedIdenticalCreateTableIsStillANoOp (0.01s) +=== RUN TestAHostileTypeInACreateTableEventCannotReachTheDDL +--- PASS: TestAHostileTypeInACreateTableEventCannotReachTheDDL (0.01s) +=== RUN TestRenderCellMatchesItsRecordedCLIExpectations +--- PASS: TestRenderCellMatchesItsRecordedCLIExpectations (0.00s) +=== RUN TestRenderCellAgreesWithTheRealDuckdbCLI +--- PASS: TestRenderCellAgreesWithTheRealDuckdbCLI (0.04s) +=== RUN TestQuoteIdentDoublesEmbeddedQuotes +--- PASS: TestQuoteIdentDoublesEmbeddedQuotes (0.00s) +=== RUN TestSQLTypeMapping +--- PASS: TestSQLTypeMapping (0.00s) +=== RUN TestDecodeLineRejectsMalformedEnvelopes +--- PASS: TestDecodeLineRejectsMalformedEnvelopes (0.00s) +=== RUN TestReadIsExemptFromTheResumePointRule +--- PASS: TestReadIsExemptFromTheResumePointRule (0.00s) +=== RUN TestTableApplyFoldsOpsCorrectly +--- PASS: TestTableApplyFoldsOpsCorrectly (0.00s) +=== RUN TestApplyRefusesARowWithNoKey +--- PASS: TestApplyRefusesARowWithNoKey (0.00s) +PASS +ok github.com/RyanLin5967/ferrodb/cdc-consumer 1.230s diff --git a/bench/w4_generation_keying/certified/suite-w4gen.tail400.log b/bench/w4_generation_keying/certified/suite-w4gen.tail400.log new file mode 100644 index 00000000..a14df3cb --- /dev/null +++ b/bench/w4_generation_keying/certified/suite-w4gen.tail400.log @@ -0,0 +1,400 @@ +running 25 tests +test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok +test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok +test a_plan_that_never_says_admit_publishes_nothing ... ok +test an_assertion_is_scored_over_rows_the_candidate_never_touched ... ok +test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok +test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok +test a_read_only_candidate_reports_no_rows_written ... ok +test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok +test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok +test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok +test every_candidate_is_scored_against_one_identical_base ... ok +test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok +test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok +test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok +test publishing_an_evaluation_the_gate_declined_is_refused ... ok +test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok +test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok +test simulate_is_refused_inside_an_agent_session ... ok +test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok +test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok +test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok +test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok +test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok +test k_candidates_cost_k_path_copies_not_k_database_copies ... ok +test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok + +test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.55s + +=== target integration_sql_on_durable_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s + Running tests/integration_sql_on_durable_branches.rs (target/debug/deps/integration_sql_on_durable_branches-1573d64d39185f4f) + +running 4 tests +test abandon_marks_the_branch_reaped_in_the_durable_record_log ... ok +test branch_writes_stay_invisible_to_main_on_the_durable_catalog ... ok +test a_guard_violation_is_rejected_with_its_predicate_on_the_durable_catalog ... ok +test two_branches_decrementing_compose_arithmetically_on_the_durable_catalog ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s + +=== target integration_statement_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.41s + Running tests/integration_statement_atomicity.rs (target/debug/deps/integration_statement_atomicity-7a89d477e1710752) + +running 3 tests +test a_multi_row_update_within_budget_still_applies_to_every_row ... ok +test a_refused_multi_row_update_leaves_no_row_rewritten ... ok +test a_refused_statement_does_not_consume_the_budget_it_never_used ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target integration_system_views_wire + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s + Running tests/integration_system_views_wire.rs (target/debug/deps/integration_system_views_wire-2cb7270b919ff355) + +running 1 test +test the_system_views_are_typed_rows_to_an_independent_client ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target integration_system_views + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) +warning: unused import: `std::path::Path` + --> tests/integration_system_views.rs:26:5 + | +26 | use std::path::Path; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_system_views") generated 1 warning (run `cargo fix --test "integration_system_views" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s + Running tests/integration_system_views.rs (target/debug/deps/integration_system_views-0cd16af7a1f4817a) + +running 20 tests +test an_empty_view_still_announces_every_declared_column ... ok +test a_second_session_of_one_run_does_not_restamp_when_the_run_started ... ok +test a_view_can_be_projected_and_filtered ... ok +test a_view_refuses_as_of_branch_and_a_join_rather_than_answering_something_else ... ok +test a_table_that_predates_the_views_still_answers_for_its_own_name ... ok +test every_declared_column_is_selectable_by_name ... ok +test a_reaped_branch_reports_the_slots_current_generation ... ok +test an_empty_agent_result_is_distinguishable_from_a_broken_one ... ok +test an_agent_result_has_typed_columns_a_client_can_read ... ok +test an_agent_can_read_the_views_from_inside_its_own_session ... ok +test every_view_is_populated_once_an_agent_has_run ... ok +test a_quarantined_branch_shows_up_with_the_reason_it_is_held ... ok +test creating_over_a_legacy_table_named_like_a_view_says_the_table_exists ... ok +test every_write_shape_against_a_view_refuses_by_name ... ok +test a_table_cannot_shadow_a_system_view ... ok +test ferro_runs_reports_the_digest_of_a_declared_prompt ... ok +test a_view_on_either_side_of_a_join_is_refused_by_name ... ok +test run_activity_counts_are_per_run_and_not_a_shared_total ... ok +test a_recreated_table_does_not_inherit_the_previous_tables_authorship ... ok +test dropping_a_table_does_not_silence_the_read_premise_gate ... ok + +test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s + +=== target integration_truncation_race + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.36s + Running tests/integration_truncation_race.rs (target/debug/deps/integration_truncation_race-9b3eb3ac65390670) + +running 1 test +test a_truncation_racing_a_read_never_yields_a_frame_at_the_wrong_lsn ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.73s + +=== target integration_trunk_tree_authority + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.50s + Running tests/integration_trunk_tree_authority.rs (target/debug/deps/integration_trunk_tree_authority-069e9ab3d7327f95) + +running 3 tests +test trunks_root_is_a_real_arena_page_named_by_the_branch_catalog ... ok +test trunk_rows_live_in_the_heap_not_in_trunks_page_tree ... ok +test an_agent_write_lives_on_branch_pages_and_reaches_the_heap_only_on_merge ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target integration_txn_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.46s + Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-321b53de585b05de) + +running 4 tests +test concurrent_commits_and_aborts_do_not_panic ... ok +test committed_transactions_leave_the_active_set ... ok +test a_running_transaction_is_never_missing_from_a_snapshot ... ok +test concurrent_begins_get_distinct_transaction_ids ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.61s + +=== target integration_view_pushdown + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.47s + Running tests/integration_view_pushdown.rs (target/debug/deps/integration_view_pushdown-41c09f8d40e27f92) + +running 8 tests +test the_extracted_hint_is_the_one_the_predicate_licenses ... ok +test a_literal_the_binder_refuses_never_reaches_the_hint ... ok +test a_narrowed_read_still_announces_the_projections_columns ... ok +test a_bounded_record_scan_returns_exactly_the_records_in_range ... ok +test a_scattered_set_of_live_runs_is_neither_widened_nor_truncated ... ok +test ferro_runs_reports_exactly_the_branches_with_a_live_run ... ok +test the_hint_narrows_what_the_generator_builds ... ok +test every_view_query_answers_identically_with_and_without_the_hint ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.44s + +=== target integration_wal_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s + Running tests/integration_wal_concurrency.rs (target/debug/deps/integration_wal_concurrency-a8e3f28b25240888) + +running 3 tests +test concurrent_appends_get_distinct_lsns ... ok +test the_log_remains_a_walkable_chain_after_concurrent_use ... ok +test flushed_lsn_never_over_reports_while_flushes_are_in_flight ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + +=== target integration_zero_copy_fork + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.72s + Running tests/integration_zero_copy_fork.rs (target/debug/deps/integration_zero_copy_fork-301b513c069220ff) + +running 6 tests +test a_runtime_without_a_page_store_refuses_row_calls_instead_of_pretending ... ok +test a_write_on_the_branch_is_invisible_to_the_trunk_and_to_a_sibling ... ok +test beginning_an_agent_session_copies_zero_data_pages ... ok +test an_abandoned_agent_session_returns_its_pages_with_no_client_cooperation ... ok +test a_branch_write_costs_pages_only_on_the_path_it_touches ... ok +test the_fork_stays_zero_copy_as_the_trunk_grows ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s + +=== target lock_order_allowlist + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.51s + Running tests/lock_order_allowlist.rs (target/debug/deps/lock_order_allowlist-5152dbc136567be8) + +running 7 tests +test the_scanner_catches_an_unlisted_latcher ... ok +test the_scanner_catches_a_pool_method_missing_its_marker ... ok +test only_the_att_accessors_may_lock_the_active_transaction_table ... ok +test every_pool_method_that_locks_opens_a_pool_section ... ok +test only_the_tree_may_take_a_page_latch ... ok +test only_the_pool_may_write_lock_a_frame_directly ... ok +test test_only_sources_are_cfg_test_gated ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target lock_order_runtime + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.49s + Running tests/lock_order_runtime.rs (target/debug/deps/lock_order_runtime-d1de576bd7acade5) + +running 4 tests +test the_correct_order_latch_then_frame_is_not_flagged ... ok +test pool_sections_do_not_leak_across_calls ... ok +test a_page_latch_taken_while_holding_a_frame_write_lock_is_caught - should panic ... ok +test a_page_latch_taken_while_holding_a_frame_read_lock_is_caught - should panic ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +=== target prop_cow_diff + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.69s + Running tests/prop_cow_diff.rs (target/debug/deps/prop_cow_diff-884fd6568a11d74e) + +running 5 tests +test an_unedited_branch_diffs_to_nothing_for_free ... ok +test reversing_the_roots_inverts_every_delta ... ok +test applying_the_diff_to_the_base_reconstructs_the_head ... ok +test diff_agrees_with_a_full_scan_comparison ... ok +test a_small_edit_reads_less_than_reading_both_trees ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.62s + +=== target prop_escrow + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.57s + Running tests/prop_escrow.rs (target/debug/deps/prop_escrow-5281dfec1344b96a) + +running 4 tests +test release_restores_the_pool_and_settle_consumes_it ... ok +test a_branch_never_spends_beyond_its_own_claim ... ok +test consumed_never_exceeds_the_declared_slack ... ok +test claims_outstanding_never_exceed_the_remaining_pool ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target prop_merge_algebra + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.71s + Running tests/prop_merge_algebra.rs (target/debug/deps/prop_merge_algebra-b73b1c5f8620b66f) + +running 11 tests +test float_add_is_commutative_but_not_associative_and_that_is_stated_not_assumed ... ok +test max_is_idempotent_and_commutative ... ok +test a_nonzero_add_is_never_idempotent ... ok +test zero_is_the_identity_for_integer_add ... ok +test add_is_commutative ... ok +test composing_then_applying_equals_applying_in_sequence ... ok +test integer_composition_refuses_overflow_rather_than_wrapping ... ok +test integer_add_is_associative ... ok +test two_assigns_never_commute ... ok +test add_is_the_only_kind_that_is_not_idempotent ... ok +test commutes_with_is_symmetric ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target prop_merge_outcomes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.64s + Running tests/prop_merge_outcomes.rs (target/debug/deps/prop_merge_outcomes-7768847bc32df451) + +running 6 tests +test lww_on_two_different_assigns_never_reports_clean ... ok +test a_discard_beside_a_composition_still_reports_loss ... ok +test lost_a_write_agrees_with_the_variant ... ok +test the_name_identifies_the_variant ... ok +test the_same_merge_classifies_the_same_way_twice ... ok +test a_discarded_write_is_never_reported_as_clean_or_commuting ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target provenance_e2e + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.69s + Running tests/provenance_e2e.rs (target/debug/deps/provenance_e2e-cb7eeb7fecb44dab) + +running 5 tests +test unattributed_versions_are_reported_as_unattributed ... ok +test exact_read_set_makes_the_downstream_agent_visible_to_revert ... ok +test a_range_read_set_still_finds_the_write_it_consumed ... ok +test a_snapshot_taken_before_the_write_creates_no_edge ... ok +test scattered_point_reads_stay_exact_through_the_executor ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s + +=== target provenance_retention_wiring + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.55s + Running tests/provenance_retention_wiring.rs (target/debug/deps/provenance_retention_wiring-71dfb10428de7988) + +running 11 tests +test a_read_whose_session_was_sealed_underneath_it_is_refused_rather_than_silently_unretained ... ok +test a_mirrored_point_read_retains_a_premise_the_gate_can_verify ... ok +test an_abandoned_tasks_scan_stops_blocking_the_revert_it_never_depended_on ... ok +test a_multi_version_merge_followed_by_another_merge_keeps_its_causal_answers_exact ... ok +test a_ghost_parent_that_staged_nothing_stops_blocking_once_its_child_has_merged ... ok +test an_update_whose_where_clause_scanned_names_its_dependents ... ok +test a_point_lookup_that_observed_an_absence_halts_the_revert_that_would_refill_it ... ok +test a_delete_whose_where_clause_scanned_names_its_dependents ... ok +test an_update_that_names_a_row_by_key_names_its_dependent_and_stays_a_blind_write ... ok +test a_mirrored_key_equality_is_classified_the_same_as_the_unmirrored_one ... ok +test parentheses_do_not_change_the_access_shape ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s + +=== target provenance_scan_cascade + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s + Running tests/provenance_scan_cascade.rs (target/debug/deps/provenance_scan_cascade-66d42001be305987) + +running 5 tests +test a_scan_depends_on_the_write_that_moved_a_row_out_of_its_range ... ok +test a_branch_that_scanned_before_the_write_is_not_a_dependent ... ok +test a_write_outside_the_scanned_range_is_not_a_dependency ... ok +test a_dependent_reached_by_a_scan_is_named_by_cascade ... ok +test a_multi_row_merge_is_named_once_by_the_scan_that_read_it_and_reverts_whole ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target provenance_write_path_dependents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s + Running tests/provenance_write_path_dependents.rs (target/debug/deps/provenance_write_path_dependents-0a768ca1b4fd14c0) + +running 1 test +test sweep ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.18s + +=== target s20_live_count_is_maintained + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.42s + Running tests/s20_live_count_is_maintained.rs (target/debug/deps/s20_live_count_is_maintained-0c661209b21dcbb5) + +running 3 tests +test an_idempotent_transition_does_not_move_the_counter ... ok +test the_counter_agrees_with_a_scan_after_forks ... ok +test the_counter_agrees_with_a_scan_after_state_transitions ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target sim_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.82s + Running tests/sim_durability.rs (target/debug/deps/sim_durability-9e6ed92c7221a067) + +running 16 tests +test under_sync_only_an_unflushed_write_does_not_survive ... ok +test a_torn_write_lands_a_prefix_and_ends_the_run ... ok +test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok +test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok +test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok +test the_durable_commit_detector_fires_and_stays_quiet ... ok +test flush_all_writes_pages_in_ascending_page_id_order ... ok +test two_different_seeds_crash_in_different_places ... ok +test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok +test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok +test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok +test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok +test recovery_itself_writes_the_same_sequence_three_times ... ok +test recovery_holds_at_every_fault_point_sync_only ... ok +test recovery_holds_at_every_fault_point_write_through ... ok +test recovery_holds_at_every_fault_point_through_the_catalog ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.95s + +=== target w4_forget_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.37s + Running tests/w4_forget_branches.rs (target/debug/deps/w4_forget_branches-0a41a5b3bd0a4bd7) + +running 3 tests +test forget_branches_refuses_a_branch_that_is_still_live ... ok +test a_reaped_branch_nobody_reported_is_still_reconciled ... ok +test forget_branches_drops_the_branches_it_is_given ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target w4_stale_branch_crosses_agents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s + Running tests/w4_stale_branch_crosses_agents.rs (target/debug/deps/w4_stale_branch_crosses_agents-56dece9702f8bcac) + +running 2 tests +test a_reaped_sessions_abandon_must_not_retire_the_new_occupants_session ... ok +test a_reaped_sessions_select_must_not_read_the_new_occupants_staged_row ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target w4_sweep_slot_recycle + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s + Running tests/w4_sweep_slot_recycle.rs (target/debug/deps/w4_sweep_slot_recycle-6ee876f01aec3d39) + +running 1 test +test a_session_that_recycled_a_reaped_slot_survives_a_sweep_already_in_flight ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + From 7f9ef1f8462c3cc169041a30043a6f4e852e3813 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:33:00 -0400 Subject: [PATCH 060/315] bench(D158 item 1): close the preimage gap -- computed code identity bfcfa0e..tip is EMPTY --- .../w4_generation_keying/certified/CERTIFICATION.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bench/w4_generation_keying/certified/CERTIFICATION.txt b/bench/w4_generation_keying/certified/CERTIFICATION.txt index 30b6f213..38e4fa22 100644 --- a/bench/w4_generation_keying/certified/CERTIFICATION.txt +++ b/bench/w4_generation_keying/certified/CERTIFICATION.txt @@ -72,3 +72,15 @@ FIRE-CHECK, on the tree this certifies, baseline before any mutant was read forget w4_sweep_slot_recycle KILLED 8/8. `chunk_stop` additionally returns rc=0 against all three PRE-EXISTING tests named for `forget_reaped_branches` — which is how the coverage gap was measured rather than asserted. + +CODE IDENTITY — the tip ships exactly the certified code, COMPUTED not assumed + This file cannot name the commit that contains it: a certification body is a PREIMAGE of its + own sha. The gate named `bfcfa0e`; this file and the artifacts beside it were committed above + it. What closes that gap is a computation, run at 5babf9a: + + git diff --stat bfcfa0e HEAD -- src tests examples Cargo.toml -> EMPTY + git diff --name-only bfcfa0e HEAD | cut -d/ -f1 | sort -u -> bench + + So every commit above the gated sha is bench-only, and the tree that would ship is + byte-identical, in src/ tests/ examples/ and Cargo.toml, to the tree the suite measured. + Anyone adding a commit above this one must re-run that first line and get EMPTY, or re-gate. From 7bbb3e8ed6747a09762d45a94dff0b2590c8713b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:34:15 -0400 Subject: [PATCH 061/315] bench(D158 item 1): drop my duplicate certification, point at the canonical one The lander banked this row's certification at bench/w4gen-624edf1_certified/ under the LANDING MERGE's name -- which is the name certify-head.sh can read back -- and it is already in main. I had banked a second copy under bench/w4_generation_keying/certified/ before seeing it. Two certification records for one run are two things that can drift. That is the failure this project keeps paying for, and it is D158's own shape: a correction written in the right place while the superseded copy stands where a reader arrives first. So the duplicate goes and the README points at the survivor. Nothing is lost. The canonical bank carries the suite line, all three gates and the computed code identity; the fire-check table and the raw artifacts were already in this directory and reached main through the merge. --- bench/w4_generation_keying/README.md | 19 + .../certified/CERTIFICATION.txt | 86 ---- .../certified/SUMMARY.txt | 3 - .../certified/land-gate.out | 37 -- .../certified/suite-w4gen.go.log | 330 --------------- .../certified/suite-w4gen.tail400.log | 400 ------------------ 6 files changed, 19 insertions(+), 856 deletions(-) delete mode 100644 bench/w4_generation_keying/certified/CERTIFICATION.txt delete mode 100644 bench/w4_generation_keying/certified/SUMMARY.txt delete mode 100644 bench/w4_generation_keying/certified/land-gate.out delete mode 100644 bench/w4_generation_keying/certified/suite-w4gen.go.log delete mode 100644 bench/w4_generation_keying/certified/suite-w4gen.tail400.log diff --git a/bench/w4_generation_keying/README.md b/bench/w4_generation_keying/README.md index 6d2fd394..e8d587d9 100644 --- a/bench/w4_generation_keying/README.md +++ b/bench/w4_generation_keying/README.md @@ -69,3 +69,22 @@ unbounded growth the function exists to prevent — lands green. The test is in `runtime.rs`'s own `mod tests` so its fixture is `FORGET_CHUNK + 1` by construction. A literal `1025` in `tests/` stops crossing the boundary the day the constant grows, and nothing would say so. + +## The certification for this row lives in `bench/w4gen-624edf1_certified/` + +Not here, and deliberately only ONE copy exists. This row landed as `main 3a9a5f9..624edf1` +and the lander banked the certification under the LANDING MERGE's name, which is the name +`certify-head.sh` can actually read back. + +I banked a second copy under this directory before I saw that one, and removed it: two +certification records for one run are two things that can drift, which is the failure this +project keeps paying for — and it is the same shape as D158's own finding, a correction written +in the right place while the superseded copy stands where a reader arrives first. + +What is in THIS directory is the evidence that certification rests on, which is duplicated +nowhere: the failing-first run at the parent commit, both fire-checks, the mutator that +reproduces them, and the pre-registration with its three append-only amendments. + + suite w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e + claim +4 @ base 3a9a5f9. The durable form is the DELTA; 2516 is that delta evaluated at a + base, and a bare 2516 quoted without its base is not falsifiable. diff --git a/bench/w4_generation_keying/certified/CERTIFICATION.txt b/bench/w4_generation_keying/certified/CERTIFICATION.txt deleted file mode 100644 index 38e4fa22..00000000 --- a/bench/w4_generation_keying/certified/CERTIFICATION.txt +++ /dev/null @@ -1,86 +0,0 @@ -CERTIFICATION — D158 item 1, W4's correctness item. Branch agent-acf966798a3979f57. - - gated sha bfcfa0e (branch tip, unmoved before and after the gate) - base main = 3a9a5f9 (LOCAL main; origin/main is 14 commits behind at 4685d80) - NOT PUSHED. This certifies a local merge candidate, nothing more. - -THE SUITE, read from the verifier's own output line, never from the .log - w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e - w4gen: go rc=0 passed=97 failed=0 - - Mode matters: 2516 is a PER-TARGET total. `whole` = per-target + 2 doctests, the second a - compile_fail reported as its own block. Do not compare this number across modes. - 2516 = the 2512 base at cdf6434 (D151) + 4. - -★ THE NUMBER WAS PRE-REGISTERED AS A DELTA AND LANDED EXACTLY. - `bench/w4_generation_keying/PREREG.txt`, amendment 3, cut BEFORE this run and append-only: - the claim is +4, with BOTH landing orders tabulated (2516 if this lands first, 2522 if - d154's +6 lands first) precisely so that neither total could be chosen after the fact. - This landed first. 2516. - - The three earlier amendments are the honest history and are kept: 2514 -> 2515 (+1 test for a - defect this change introduced, found by reading the diff) -> 2516 (the BASE moved 2511 -> 2512 - when D151 landed mid-run). Only the base ever moved; +4 never did. - -THE SIXTH CHECK — tests asserted BY NAME, not inferred from a total - A total one short would mean D151's test did not survive my merge, and a sum cannot say that. - Read out of suite-w4gen.log: - === target d151_parse_reads_a_refreshed_snapshot - test a_parse_on_a_cached_connection_sees_ddl_another_connection_did ... ok - test agent_sql::runtime::tests::a_recycled_slot_installs_the_new_workspace_before_releasing_the_old ... ok - test agent_sql::runtime::tests::the_reconciliation_crosses_its_chunk_boundary_and_forgets_every_branch ... ok - test a_reaped_sessions_abandon_must_not_retire_the_new_occupants_session ... ok - test a_reaped_sessions_select_must_not_read_the_new_occupants_staged_row ... ok - -ALL THREE GATES, verbatim from tools/land-gate.sh (full output in land-gate.out) - certify-head: OK — suite head=bfcfa0e == landing bfcfa0e - staleness: COVERED — branch bfcfa0e vs 3a9a5f9: 0 behind, 14 ahead - prepush: OK — FFI gate sweep clean; RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" - on both `cargo build --examples` and `cargo check --all-targets`; and the - x86_64-pc-windows-msvc check (which does NOT link, so it cannot see a missing - POSIX symbol — check 1 is what covers that). - land-gate: OK — suite names the commit · base is covered · CI-parity passes · tree unmoved. - - ⚠ `prepush` is not a formality for THIS change. Keying `workspaces` by `BranchId` DELETED - callers — `forget_one_branch`'s hand-written re-validation and `run_activity`'s `names` lookup - — and a change that makes something unused is invisible to a green suite and fatal to CI's - `-D dead_code`. That is the exact shape prepush exists for. - -WHAT LANDED - src/agent_sql/runtime.rs State::workspaces: BTreeMap -> BTreeMap - tests/w4_stale_branch_crosses_agents.rs the failing-first test, over the real statement path - bench/w4/DECISION.md addendum 6; four "still open" lists banded - Production code delta is small: 123 lines added (~50 of them the two new lib tests) / 77 removed. - -TWO RUNS WERE DISCARDED AND NEITHER IS QUOTED - Both were SIGTERMed and refused with "A suite cut short is not a count"; no partial count exists. - The first was killed because `staleness.sh` — which takes no lock, so it is free to run against - a suite in flight — read STALE-GREEN mid-run: D151 had landed into LOCAL `main` while - `origin/main` sat 14 commits behind, so `git fetch && git log origin/main` said "base unmoved" - and was wrong. Worktrees share one `.git` and land-gate defaults to the LOCAL ref. - ⇒ COMPARE AGAINST LOCAL `main`, ALWAYS, AND NAME THE SHA. - -FIRE-CHECK, on the tree this certifies, baseline before any mutant was read - BASELINE unmutated PASS - evict --lib KILLED - evict_order --lib KILLED - chunk_stop --lib KILLED - overlay w4_stale_branch_crosses_agents KILLED - record_read w4_stale_branch_crosses_agents KILLED - blind_writes w4_stale_branch_crosses_agents KILLED - seal w4_stale_branch_crosses_agents KILLED - forget w4_sweep_slot_recycle KILLED - 8/8. `chunk_stop` additionally returns rc=0 against all three PRE-EXISTING tests named for - `forget_reaped_branches` — which is how the coverage gap was measured rather than asserted. - -CODE IDENTITY — the tip ships exactly the certified code, COMPUTED not assumed - This file cannot name the commit that contains it: a certification body is a PREIMAGE of its - own sha. The gate named `bfcfa0e`; this file and the artifacts beside it were committed above - it. What closes that gap is a computation, run at 5babf9a: - - git diff --stat bfcfa0e HEAD -- src tests examples Cargo.toml -> EMPTY - git diff --name-only bfcfa0e HEAD | cut -d/ -f1 | sort -u -> bench - - So every commit above the gated sha is bench-only, and the tree that would ship is - byte-identical, in src/ tests/ examples/ and Cargo.toml, to the tree the suite measured. - Anyone adding a commit above this one must re-run that first line and get EMPTY, or re-gate. diff --git a/bench/w4_generation_keying/certified/SUMMARY.txt b/bench/w4_generation_keying/certified/SUMMARY.txt deleted file mode 100644 index a77f5a29..00000000 --- a/bench/w4_generation_keying/certified/SUMMARY.txt +++ /dev/null @@ -1,3 +0,0 @@ -w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.log -w4gen: go rc=0 passed=97 failed=0 log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.go.log -RUNNER_EXIT=0 diff --git a/bench/w4_generation_keying/certified/land-gate.out b/bench/w4_generation_keying/certified/land-gate.out deleted file mode 100644 index 2a4b7ed8..00000000 --- a/bench/w4_generation_keying/certified/land-gate.out +++ /dev/null @@ -1,37 +0,0 @@ -land-gate: landing bfcfa0e (bfcfa0e) -land-gate: onto base main (3a9a5f9) -land-gate: evidence scratchpad/verify-out-w4gen - -land-gate: [1/3] certify-head.sh — does the verdict name the commit being landed? -certify-head: OK — suite head=bfcfa0e == landing bfcfa0e - w4gen: mode=per-target rc=0 passed=2516 failed=0 build_errors=0 head=bfcfa0e log=/Users/idide/wt/ferrodb-agent-acf966798a3979f57/scratchpad/verify-out-w4gen/suite-w4gen.log - -land-gate: [2/3] staleness.sh — has the base moved under this branch's files SINCE the suite ran? - branch bfcfa0e vs 3a9a5f9f1646aabad33efef892a487291be9a954: 0 behind, 14 ahead - COVERED - -land-gate: [3/3] prepush.sh — the CI-parity checks a green suite structurally cannot see. -CI RUSTFLAGS (read from .github/workflows/tests.yml): -D duplicate_macro_attributes -D dead_code - -== FFI gate sweep (examples/ and src/) == -ok: no ungated extern "C" - -== RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" cargo build --examples == -warning: `ferrodb` (example "d51_sharedword_probe") generated 1 warning (run `cargo fix --example "d51_sharedword_probe" -p ferrodb` to apply 1 suggestion) -warning: `ferrodb` (example "d68_merge_is_o_table") generated 1 warning (run `cargo fix --example "d68_merge_is_o_table" -p ferrodb` to apply 1 suggestion) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.79s - -== RUSTFLAGS="-D duplicate_macro_attributes -D dead_code" cargo check --all-targets == - -warning: `ferrodb` (example "d51_sharedword_probe") generated 1 warning (run `cargo fix --example "d51_sharedword_probe" -p ferrodb` to apply 1 suggestion) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.47s - -== cargo check --target x86_64-pc-windows-msvc (compiles the cfg(not(unix)) arms) == -ok (note: this does NOT link, so it cannot see a missing POSIX symbol — check 1 is what covers that) - -PREPUSH: OK — the checks CI runs that the suite does not. - This is NOT a substitute for tools/verify-suite.sh, and it says nothing about head=. - Run all three: verify-suite.sh, certify-head.sh, and this. - -land-gate: OK — the evidence in scratchpad/verify-out-w4gen is about the tree that will ship. -land-gate: suite names the commit · base is covered · CI-parity passes · tree unmoved throughout. diff --git a/bench/w4_generation_keying/certified/suite-w4gen.go.log b/bench/w4_generation_keying/certified/suite-w4gen.go.log deleted file mode 100644 index 5d2e8d69..00000000 --- a/bench/w4_generation_keying/certified/suite-w4gen.go.log +++ /dev/null @@ -1,330 +0,0 @@ -=== RUN TestAnyDuplicationOrReplayConvergesToTheSameState ---- PASS: TestAnyDuplicationOrReplayConvergesToTheSameState (0.29s) -=== RUN TestAStaleEventLaterInTheFeedIsFilteredByTheCursor ---- PASS: TestAStaleEventLaterInTheFeedIsFilteredByTheCursor (0.01s) -=== RUN TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn ---- PASS: TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn (0.00s) -=== RUN TestValidateRefusesAnEmptyFeed -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestValidateRefusesAnEmptyFeed (0.00s) -=== RUN TestValidateRefusesAFeedThatIsMissingItsFinalNewline -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestValidateRefusesAFeedThatIsMissingItsFinalNewline (0.00s) -=== RUN TestSinkRefusesAnEmptyFeed -applied 1, skipped 0 re-delivered -APPLIED 1 SKIPPED 0 CURSOR 21 TABLE t ---- PASS: TestSinkRefusesAnEmptyFeed (0.00s) -=== RUN TestDuckSinkRefusesWithoutAKeyColumn ---- PASS: TestDuckSinkRefusesWithoutAKeyColumn (0.01s) -=== RUN TestDiffRefusesAMalformedSourceDump -MATCH 1 row(s) from 1 event(s) ---- PASS: TestDiffRefusesAMalformedSourceDump (0.00s) -=== RUN TestEveryRowOfOneCommitLands -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 103 TABLE t -applied 1, skipped 3 re-delivered -APPLIED 1 SKIPPED 3 CURSOR 2 TABLE t ---- PASS: TestEveryRowOfOneCommitLands (0.00s) -=== RUN TestTheLaterRecordInOneCommitWinsForTheSameKey -applied 3, skipped 0 re-delivered -APPLIED 3 SKIPPED 0 CURSOR 103 TABLE t ---- PASS: TestTheLaterRecordInOneCommitWinsForTheSameKey (0.00s) -=== RUN TestEveryRowOfASnapshotLands -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t ---- PASS: TestEveryRowOfASnapshotLands (0.00s) -=== RUN TestI20RegressionInferredDestinationThenDeclaredCreateTable -applied 1, skipped 0 re-delivered -APPLIED 1 SKIPPED 0 CURSOR 11 TABLE inv - i20_regression_test.go:43: destination shape after the inference path: [[id TEXT] [qty TEXT]] -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestI20RegressionInferredDestinationThenDeclaredCreateTable (0.00s) -=== RUN TestI20RegressionInferredCatchUpOverADeclaredDestination -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv -applied 1, skipped 0 re-delivered -APPLIED 1 SKIPPED 0 CURSOR 31 TABLE inv - i20_regression_test.go:87: rows: [[1 10 ] [2 20 new]] ---- PASS: TestI20RegressionInferredCatchUpOverADeclaredDestination (0.00s) -=== RUN TestI20RegressionTwoTablesDifferentKeys -=== RUN TestI20RegressionTwoTablesDifferentKeys/sqlite - i20_regression_test.go:115: sqlite: refused a second table whose key is not the flag: apply INSERT to orders: SQL logic error: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint (1) -=== RUN TestI20RegressionTwoTablesDifferentKeys/duckdb - i20_regression_test.go:115: duckdb: refused a second table whose key is not the flag: table orders has no key column "id"; the ordering guard needs one ---- PASS: TestI20RegressionTwoTablesDifferentKeys (0.02s) - --- PASS: TestI20RegressionTwoTablesDifferentKeys/sqlite (0.00s) - --- PASS: TestI20RegressionTwoTablesDifferentKeys/duckdb (0.02s) -=== RUN TestI20RegressionKeyForOnAPrimaryKeylessDestination - i20_regression_test.go:145: keyFor on a PK-less destination: "id" (flag is "id") ---- PASS: TestI20RegressionKeyForOnAPrimaryKeylessDestination (0.00s) -=== RUN TestI20RegressionKeyCacheAcrossDropAndRecreate -applied 8, skipped 0 re-delivered -APPLIED 8 SKIPPED 0 CURSOR 71 TABLE inv - i20_regression_test.go:183: after drop+recreate: [[1 6]] ---- PASS: TestI20RegressionKeyCacheAcrossDropAndRecreate (0.01s) -=== RUN TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype -applied 3, skipped 0 re-delivered -APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv -applied 1, skipped 2 re-delivered -APPLIED 1 SKIPPED 2 CURSOR 2 TABLE inv ---- PASS: TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype (0.00s) -=== RUN TestI20RegressionTypeCheckAgainstAHandBuiltDestination -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv ---- PASS: TestI20RegressionTypeCheckAgainstAHandBuiltDestination (0.00s) -=== RUN TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip ---- PASS: TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip (0.00s) -=== RUN TestSameNumberStillCatchesDigitsNoDoubleCanHold ---- PASS: TestSameNumberStillCatchesDigitsNoDoubleCanHold (0.00s) -=== RUN TestExactFloatIsExactForSubnormals ---- PASS: TestExactFloatIsExactForSubnormals (0.00s) -=== RUN TestExactFloatRoundTripsToTheSameDouble ---- PASS: TestExactFloatRoundTripsToTheSameDouble (0.00s) -=== RUN TestConsumerRefusesADeniedColumnTheProducerLetThrough -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerRefusesADeniedColumnTheProducerLetThrough (0.00s) -=== RUN TestConsumerChecksTheBeforeImageToo -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerChecksTheBeforeImageToo (0.00s) -=== RUN TestConsumerRefusesAnUndecidedTable -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerRefusesAnUndecidedTable (0.00s) -=== RUN TestConsumerRefusesASchemaEventDeclaringADeniedColumn -OK 1 -WRITERS attributed=0 unattributed=0 ---- PASS: TestConsumerRefusesASchemaEventDeclaringADeniedColumn (0.00s) -=== RUN TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy -OK 1 -WRITERS attributed=0 unattributed=0 ---- PASS: TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy (0.00s) -=== RUN TestADropIsNotJudgedByTheShapeCheck -OK 1 -WRITERS attributed=0 unattributed=0 ---- PASS: TestADropIsNotJudgedByTheShapeCheck (0.00s) -=== RUN TestWithNoPublicationNothingIsRefused -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestWithNoPublicationNothingIsRefused (0.00s) -=== RUN TestUsePublicationRefusesAFileItCannotRead ---- PASS: TestUsePublicationRefusesAFileItCannotRead (0.00s) -=== RUN TestParsePublicationRefusesEveryMalformedShape ---- PASS: TestParsePublicationRefusesEveryMalformedShape (0.00s) -=== RUN TestTheSinkRefusesADeniedColumnBeforeLandingIt -applied 1, skipped 0 re-delivered -APPLIED 1 SKIPPED 0 CURSOR 21 TABLE customers ---- PASS: TestTheSinkRefusesADeniedColumnBeforeLandingIt (0.00s) -=== RUN TestPrecisionRefusesADeniedColumnRatherThanPrintingIt -FIELD 1 id number 1 -FIELD 1 name string ada -FIELD 1 id number 1 -FIELD 1 name string ada -SUMMARY strings=1 numbers=1 lossy=0 -FIELD 1 id number 1 -FIELD 1 name string ada -FIELD 1 ssn string 000-11-2222 -SUMMARY strings=2 numbers=1 lossy=0 ---- PASS: TestPrecisionRefusesADeniedColumnRatherThanPrintingIt (0.00s) -=== RUN TestPrecisionRefusesAnUndecidedTable ---- PASS: TestPrecisionRefusesAnUndecidedTable (0.00s) -=== RUN TestConsumerChecksADropsBeforeImage -OK 1 -WRITERS attributed=0 unattributed=0 ---- PASS: TestConsumerChecksADropsBeforeImage (0.00s) -=== RUN TestConsumerRefusesDuplicateKeysInOneObject -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerRefusesDuplicateKeysInOneObject (0.00s) -=== RUN TestConsumerRefusesKeysOutsideTheEnvelope -OK 1 -WRITERS attributed=0 unattributed=0 ---- PASS: TestConsumerRefusesKeysOutsideTheEnvelope (0.00s) -=== RUN TestConsumerRefusesANonScalarRowValue -OK 1 -WRITERS attributed=0 unattributed=1 -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerRefusesANonScalarRowValue (0.00s) -=== RUN TestConsumerRefusesAnEventForAnExcludedTable -OK 1 -WRITERS attributed=0 unattributed=1 ---- PASS: TestConsumerRefusesAnEventForAnExcludedTable (0.00s) -=== RUN TestParsePublicationHandlesExclusions ---- PASS: TestParsePublicationHandlesExclusions (0.00s) -=== RUN TestAnAttributedEventSurvivesAPublication ---- PASS: TestAnAttributedEventSurvivesAPublication (0.00s) -=== RUN TestAnUnknownTopLevelKeyIsStillRefused ---- PASS: TestAnUnknownTopLevelKeyIsStillRefused (0.00s) -=== RUN TestBothSinksLandTheSameWriterColumnNames ---- PASS: TestBothSinksLandTheSameWriterColumnNames (0.00s) -=== RUN TestRetractWorksAgainstADuckDBDestination -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=duckdb -ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 -SCANNED 6 table=inv engine=duckdb ---- PASS: TestRetractWorksAgainstADuckDBDestination (0.04s) -=== RUN TestRetractInDeleteModeAgainstDuckDB -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=duckdb ---- PASS: TestRetractInDeleteModeAgainstDuckDB (0.03s) -=== RUN TestRetractAndScanRefuseAnUnknownEngine -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite ---- PASS: TestRetractAndScanRefuseAnUnknownEngine (0.00s) -=== RUN TestAnOlderDuckDBDestinationIsUpgradedInPlace -RETRACTED 3 OF 7 table=inv model_version=2026-07 mode=quarantine engine=duckdb ---- PASS: TestAnOlderDuckDBDestinationIsUpgradedInPlace (0.04s) -=== RUN TestRetractTouchesExactlyOneModelVersionsRows -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite ---- PASS: TestRetractTouchesExactlyOneModelVersionsRows (0.01s) -=== RUN TestRetractInDeleteModeAlsoTombstones -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=sqlite ---- PASS: TestRetractInDeleteModeAlsoTombstones (0.00s) -=== RUN TestARetractionThatMatchesNothingIsRefused -RETRACTED 2 OF 6 table=inv model_version=2026-05 mode=quarantine engine=sqlite ---- PASS: TestARetractionThatMatchesNothingIsRefused (0.01s) -=== RUN TestAnEmptyModelVersionIsRefused ---- PASS: TestAnEmptyModelVersionIsRefused (0.00s) -=== RUN TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing ---- PASS: TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing (0.00s) -=== RUN TestARetractionSurvivesAReplayButNotAFreshWrite -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite ---- PASS: TestARetractionSurvivesAReplayButNotAFreshWrite (0.01s) -=== RUN TestScanReportsEveryRowsWriterAndMark -RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite -ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 -ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 -SCANNED 6 table=inv engine=sqlite ---- PASS: TestScanReportsEveryRowsWriterAndMark (0.01s) -=== RUN TestAWriterObjectWithAnUnknownKeyIsRefused ---- PASS: TestAWriterObjectWithAnUnknownKeyIsRefused (0.00s) -=== RUN TestAMalformedWriterIsRefused ---- PASS: TestAMalformedWriterIsRefused (0.00s) -=== RUN TestScanRefusesAnEmptyTable -ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 -ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 -ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 -ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 -ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 -SCANNED 6 table=inv engine=sqlite ---- PASS: TestScanRefusesAnEmptyTable (0.01s) -=== RUN TestWellFormedColumnLevelEventsAreAccepted ---- PASS: TestWellFormedColumnLevelEventsAreAccepted (0.00s) -=== RUN TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape ---- PASS: TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape (0.00s) -=== RUN TestColumnLevelChangesAreNewsNotDeclarations ---- PASS: TestColumnLevelChangesAreNewsNotDeclarations (0.00s) -=== RUN TestTableFoldSurvivesAColumnLevelChange ---- PASS: TestTableFoldSurvivesAColumnLevelChange (0.00s) -=== RUN TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds ---- PASS: TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds (0.00s) -=== RUN TestTheFoldReEncodesAColumnThatBecameStringTyped ---- PASS: TestTheFoldReEncodesAColumnThatBecameStringTyped (0.00s) -=== RUN TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows -applied 5, skipped 0 re-delivered -APPLIED 5 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows (0.00s) -=== RUN TestReplayingAFeedWithASchemaChangeIsANoOp -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv -applied 1, skipped 3 re-delivered -APPLIED 1 SKIPPED 3 CURSOR 2 TABLE inv ---- PASS: TestReplayingAFeedWithASchemaChangeIsANoOp (0.00s) -=== RUN TestTheSqliteSinkRenamesRatherThanRebuilding -applied 3, skipped 0 re-delivered -APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv ---- PASS: TestTheSqliteSinkRenamesRatherThanRebuilding (0.00s) -=== RUN TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows (0.00s) -=== RUN TestASinkThatNeverSawTheCreateStillGetsTheShape -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestASinkThatNeverSawTheCreateStillGetsTheShape (0.00s) -=== RUN TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 51 TABLE inv ---- PASS: TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration (0.00s) -=== RUN TestTheCatchUpRefusesAnythingThatIsNotAPrefix ---- PASS: TestTheCatchUpRefusesAnythingThatIsNotAPrefix (0.00s) -=== RUN TestTheFeedLandsInDuckdbAcrossAMissedAlter ---- PASS: TestTheFeedLandsInDuckdbAcrossAMissedAlter (0.01s) -=== RUN TestTheSqliteCatchUpRefusesARetypeInTheDeclaration -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv ---- PASS: TestTheSqliteCatchUpRefusesARetypeInTheDeclaration (0.00s) -=== RUN TestTheSqliteCatchUpComparesTypesAndNotOnlyNames ---- PASS: TestTheSqliteCatchUpComparesTypesAndNotOnlyNames (0.00s) -=== RUN TestTheSqliteSinkFollowsARenameOfTheKeyColumn -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestTheSqliteSinkFollowsARenameOfTheKeyColumn (0.00s) -=== RUN TestTheDuckSinkFollowsARenameOfTheKeyColumn -applied 4, skipped 0 re-delivered -APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv ---- PASS: TestTheDuckSinkFollowsARenameOfTheKeyColumn (0.02s) -=== RUN TestARenameTruncatedAwayStallsTheSinkButNamesTheCause -applied 2, skipped 0 re-delivered -APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv ---- PASS: TestARenameTruncatedAwayStallsTheSinkButNamesTheCause (0.00s) -=== RUN TestDuckTypeMapping ---- PASS: TestDuckTypeMapping (0.00s) -=== RUN TestWideTypesLandExactlyInDuckDB ---- PASS: TestWideTypesLandExactlyInDuckDB (0.01s) -=== RUN TestDuckTypeOfInfersFromTheValue ---- PASS: TestDuckTypeOfInfersFromTheValue (0.00s) -=== RUN TestEnsureDuckTableRefusesATypeOutsideTheAllowlist ---- PASS: TestEnsureDuckTableRefusesATypeOutsideTheAllowlist (0.01s) -=== RUN TestEnsureDuckTableRefusesATableWithoutTheKey ---- PASS: TestEnsureDuckTableRefusesATableWithoutTheKey (0.01s) -=== RUN TestApplyRejectsAStaleEventAndAcceptsANewerOne ---- PASS: TestApplyRejectsAStaleEventAndAcceptsANewerOne (0.02s) -=== RUN TestDeleteIsSoftAndRejectsAStaleResurrection ---- PASS: TestDeleteIsSoftAndRejectsAStaleResurrection (0.01s) -=== RUN TestColumnsComeFromTheCatalogNotFromGuessing ---- PASS: TestColumnsComeFromTheCatalogNotFromGuessing (0.02s) -=== RUN TestApplyRefusesARowWithNoKeyColumn ---- PASS: TestApplyRefusesARowWithNoKeyColumn (0.01s) -=== RUN TestOpenDestinationRefusesAnUnknownEngine ---- PASS: TestOpenDestinationRefusesAnUnknownEngine (0.01s) -=== RUN TestALaterCreateTableThatDisagreesIsRefused ---- PASS: TestALaterCreateTableThatDisagreesIsRefused (0.01s) -=== RUN TestARepeatedIdenticalCreateTableIsStillANoOp ---- PASS: TestARepeatedIdenticalCreateTableIsStillANoOp (0.01s) -=== RUN TestAHostileTypeInACreateTableEventCannotReachTheDDL ---- PASS: TestAHostileTypeInACreateTableEventCannotReachTheDDL (0.01s) -=== RUN TestRenderCellMatchesItsRecordedCLIExpectations ---- PASS: TestRenderCellMatchesItsRecordedCLIExpectations (0.00s) -=== RUN TestRenderCellAgreesWithTheRealDuckdbCLI ---- PASS: TestRenderCellAgreesWithTheRealDuckdbCLI (0.04s) -=== RUN TestQuoteIdentDoublesEmbeddedQuotes ---- PASS: TestQuoteIdentDoublesEmbeddedQuotes (0.00s) -=== RUN TestSQLTypeMapping ---- PASS: TestSQLTypeMapping (0.00s) -=== RUN TestDecodeLineRejectsMalformedEnvelopes ---- PASS: TestDecodeLineRejectsMalformedEnvelopes (0.00s) -=== RUN TestReadIsExemptFromTheResumePointRule ---- PASS: TestReadIsExemptFromTheResumePointRule (0.00s) -=== RUN TestTableApplyFoldsOpsCorrectly ---- PASS: TestTableApplyFoldsOpsCorrectly (0.00s) -=== RUN TestApplyRefusesARowWithNoKey ---- PASS: TestApplyRefusesARowWithNoKey (0.00s) -PASS -ok github.com/RyanLin5967/ferrodb/cdc-consumer 1.230s diff --git a/bench/w4_generation_keying/certified/suite-w4gen.tail400.log b/bench/w4_generation_keying/certified/suite-w4gen.tail400.log deleted file mode 100644 index a14df3cb..00000000 --- a/bench/w4_generation_keying/certified/suite-w4gen.tail400.log +++ /dev/null @@ -1,400 +0,0 @@ -running 25 tests -test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok -test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok -test a_plan_that_never_says_admit_publishes_nothing ... ok -test an_assertion_is_scored_over_rows_the_candidate_never_touched ... ok -test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok -test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok -test a_read_only_candidate_reports_no_rows_written ... ok -test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok -test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok -test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok -test every_candidate_is_scored_against_one_identical_base ... ok -test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok -test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok -test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok -test publishing_an_evaluation_the_gate_declined_is_refused ... ok -test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok -test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok -test simulate_is_refused_inside_an_agent_session ... ok -test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok -test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok -test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok -test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok -test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok -test k_candidates_cost_k_path_copies_not_k_database_copies ... ok -test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok - -test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.55s - -=== target integration_sql_on_durable_branches - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s - Running tests/integration_sql_on_durable_branches.rs (target/debug/deps/integration_sql_on_durable_branches-1573d64d39185f4f) - -running 4 tests -test abandon_marks_the_branch_reaped_in_the_durable_record_log ... ok -test branch_writes_stay_invisible_to_main_on_the_durable_catalog ... ok -test a_guard_violation_is_rejected_with_its_predicate_on_the_durable_catalog ... ok -test two_branches_decrementing_compose_arithmetically_on_the_durable_catalog ... ok - -test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s - -=== target integration_statement_atomicity - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.41s - Running tests/integration_statement_atomicity.rs (target/debug/deps/integration_statement_atomicity-7a89d477e1710752) - -running 3 tests -test a_multi_row_update_within_budget_still_applies_to_every_row ... ok -test a_refused_multi_row_update_leaves_no_row_rewritten ... ok -test a_refused_statement_does_not_consume_the_budget_it_never_used ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s - -=== target integration_system_views_wire - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s - Running tests/integration_system_views_wire.rs (target/debug/deps/integration_system_views_wire-2cb7270b919ff355) - -running 1 test -test the_system_views_are_typed_rows_to_an_independent_client ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s - -=== target integration_system_views - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) -warning: unused import: `std::path::Path` - --> tests/integration_system_views.rs:26:5 - | -26 | use std::path::Path; - | ^^^^^^^^^^^^^^^ - | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default - -warning: `ferrodb` (test "integration_system_views") generated 1 warning (run `cargo fix --test "integration_system_views" -p ferrodb` to apply 1 suggestion) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s - Running tests/integration_system_views.rs (target/debug/deps/integration_system_views-0cd16af7a1f4817a) - -running 20 tests -test an_empty_view_still_announces_every_declared_column ... ok -test a_second_session_of_one_run_does_not_restamp_when_the_run_started ... ok -test a_view_can_be_projected_and_filtered ... ok -test a_view_refuses_as_of_branch_and_a_join_rather_than_answering_something_else ... ok -test a_table_that_predates_the_views_still_answers_for_its_own_name ... ok -test every_declared_column_is_selectable_by_name ... ok -test a_reaped_branch_reports_the_slots_current_generation ... ok -test an_empty_agent_result_is_distinguishable_from_a_broken_one ... ok -test an_agent_result_has_typed_columns_a_client_can_read ... ok -test an_agent_can_read_the_views_from_inside_its_own_session ... ok -test every_view_is_populated_once_an_agent_has_run ... ok -test a_quarantined_branch_shows_up_with_the_reason_it_is_held ... ok -test creating_over_a_legacy_table_named_like_a_view_says_the_table_exists ... ok -test every_write_shape_against_a_view_refuses_by_name ... ok -test a_table_cannot_shadow_a_system_view ... ok -test ferro_runs_reports_the_digest_of_a_declared_prompt ... ok -test a_view_on_either_side_of_a_join_is_refused_by_name ... ok -test run_activity_counts_are_per_run_and_not_a_shared_total ... ok -test a_recreated_table_does_not_inherit_the_previous_tables_authorship ... ok -test dropping_a_table_does_not_silence_the_read_premise_gate ... ok - -test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.45s - -=== target integration_truncation_race - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.36s - Running tests/integration_truncation_race.rs (target/debug/deps/integration_truncation_race-9b3eb3ac65390670) - -running 1 test -test a_truncation_racing_a_read_never_yields_a_frame_at_the_wrong_lsn ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.73s - -=== target integration_trunk_tree_authority - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.50s - Running tests/integration_trunk_tree_authority.rs (target/debug/deps/integration_trunk_tree_authority-069e9ab3d7327f95) - -running 3 tests -test trunks_root_is_a_real_arena_page_named_by_the_branch_catalog ... ok -test trunk_rows_live_in_the_heap_not_in_trunks_page_tree ... ok -test an_agent_write_lives_on_branch_pages_and_reaches_the_heap_only_on_merge ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s - -=== target integration_txn_concurrency - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.46s - Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-321b53de585b05de) - -running 4 tests -test concurrent_commits_and_aborts_do_not_panic ... ok -test committed_transactions_leave_the_active_set ... ok -test a_running_transaction_is_never_missing_from_a_snapshot ... ok -test concurrent_begins_get_distinct_transaction_ids ... ok - -test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.61s - -=== target integration_view_pushdown - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.47s - Running tests/integration_view_pushdown.rs (target/debug/deps/integration_view_pushdown-41c09f8d40e27f92) - -running 8 tests -test the_extracted_hint_is_the_one_the_predicate_licenses ... ok -test a_literal_the_binder_refuses_never_reaches_the_hint ... ok -test a_narrowed_read_still_announces_the_projections_columns ... ok -test a_bounded_record_scan_returns_exactly_the_records_in_range ... ok -test a_scattered_set_of_live_runs_is_neither_widened_nor_truncated ... ok -test ferro_runs_reports_exactly_the_branches_with_a_live_run ... ok -test the_hint_narrows_what_the_generator_builds ... ok -test every_view_query_answers_identically_with_and_without_the_hint ... ok - -test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.44s - -=== target integration_wal_concurrency - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.45s - Running tests/integration_wal_concurrency.rs (target/debug/deps/integration_wal_concurrency-a8e3f28b25240888) - -running 3 tests -test concurrent_appends_get_distinct_lsns ... ok -test the_log_remains_a_walkable_chain_after_concurrent_use ... ok -test flushed_lsn_never_over_reports_while_flushes_are_in_flight ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s - -=== target integration_zero_copy_fork - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.72s - Running tests/integration_zero_copy_fork.rs (target/debug/deps/integration_zero_copy_fork-301b513c069220ff) - -running 6 tests -test a_runtime_without_a_page_store_refuses_row_calls_instead_of_pretending ... ok -test a_write_on_the_branch_is_invisible_to_the_trunk_and_to_a_sibling ... ok -test beginning_an_agent_session_copies_zero_data_pages ... ok -test an_abandoned_agent_session_returns_its_pages_with_no_client_cooperation ... ok -test a_branch_write_costs_pages_only_on_the_path_it_touches ... ok -test the_fork_stays_zero_copy_as_the_trunk_grows ... ok - -test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.29s - -=== target lock_order_allowlist - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.51s - Running tests/lock_order_allowlist.rs (target/debug/deps/lock_order_allowlist-5152dbc136567be8) - -running 7 tests -test the_scanner_catches_an_unlisted_latcher ... ok -test the_scanner_catches_a_pool_method_missing_its_marker ... ok -test only_the_att_accessors_may_lock_the_active_transaction_table ... ok -test every_pool_method_that_locks_opens_a_pool_section ... ok -test only_the_tree_may_take_a_page_latch ... ok -test only_the_pool_may_write_lock_a_frame_directly ... ok -test test_only_sources_are_cfg_test_gated ... ok - -test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s - -=== target lock_order_runtime - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.49s - Running tests/lock_order_runtime.rs (target/debug/deps/lock_order_runtime-d1de576bd7acade5) - -running 4 tests -test the_correct_order_latch_then_frame_is_not_flagged ... ok -test pool_sections_do_not_leak_across_calls ... ok -test a_page_latch_taken_while_holding_a_frame_write_lock_is_caught - should panic ... ok -test a_page_latch_taken_while_holding_a_frame_read_lock_is_caught - should panic ... ok - -test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s - -=== target prop_cow_diff - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.69s - Running tests/prop_cow_diff.rs (target/debug/deps/prop_cow_diff-884fd6568a11d74e) - -running 5 tests -test an_unedited_branch_diffs_to_nothing_for_free ... ok -test reversing_the_roots_inverts_every_delta ... ok -test applying_the_diff_to_the_base_reconstructs_the_head ... ok -test diff_agrees_with_a_full_scan_comparison ... ok -test a_small_edit_reads_less_than_reading_both_trees ... ok - -test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.62s - -=== target prop_escrow - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.57s - Running tests/prop_escrow.rs (target/debug/deps/prop_escrow-5281dfec1344b96a) - -running 4 tests -test release_restores_the_pool_and_settle_consumes_it ... ok -test a_branch_never_spends_beyond_its_own_claim ... ok -test consumed_never_exceeds_the_declared_slack ... ok -test claims_outstanding_never_exceed_the_remaining_pool ... ok - -test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s - -=== target prop_merge_algebra - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.71s - Running tests/prop_merge_algebra.rs (target/debug/deps/prop_merge_algebra-b73b1c5f8620b66f) - -running 11 tests -test float_add_is_commutative_but_not_associative_and_that_is_stated_not_assumed ... ok -test max_is_idempotent_and_commutative ... ok -test a_nonzero_add_is_never_idempotent ... ok -test zero_is_the_identity_for_integer_add ... ok -test add_is_commutative ... ok -test composing_then_applying_equals_applying_in_sequence ... ok -test integer_composition_refuses_overflow_rather_than_wrapping ... ok -test integer_add_is_associative ... ok -test two_assigns_never_commute ... ok -test add_is_the_only_kind_that_is_not_idempotent ... ok -test commutes_with_is_symmetric ... ok - -test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s - -=== target prop_merge_outcomes - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.64s - Running tests/prop_merge_outcomes.rs (target/debug/deps/prop_merge_outcomes-7768847bc32df451) - -running 6 tests -test lww_on_two_different_assigns_never_reports_clean ... ok -test a_discard_beside_a_composition_still_reports_loss ... ok -test lost_a_write_agrees_with_the_variant ... ok -test the_name_identifies_the_variant ... ok -test the_same_merge_classifies_the_same_way_twice ... ok -test a_discarded_write_is_never_reported_as_clean_or_commuting ... ok - -test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s - -=== target provenance_e2e - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.69s - Running tests/provenance_e2e.rs (target/debug/deps/provenance_e2e-cb7eeb7fecb44dab) - -running 5 tests -test unattributed_versions_are_reported_as_unattributed ... ok -test exact_read_set_makes_the_downstream_agent_visible_to_revert ... ok -test a_range_read_set_still_finds_the_write_it_consumed ... ok -test a_snapshot_taken_before_the_write_creates_no_edge ... ok -test scattered_point_reads_stay_exact_through_the_executor ... ok - -test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s - -=== target provenance_retention_wiring - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.55s - Running tests/provenance_retention_wiring.rs (target/debug/deps/provenance_retention_wiring-71dfb10428de7988) - -running 11 tests -test a_read_whose_session_was_sealed_underneath_it_is_refused_rather_than_silently_unretained ... ok -test a_mirrored_point_read_retains_a_premise_the_gate_can_verify ... ok -test an_abandoned_tasks_scan_stops_blocking_the_revert_it_never_depended_on ... ok -test a_multi_version_merge_followed_by_another_merge_keeps_its_causal_answers_exact ... ok -test a_ghost_parent_that_staged_nothing_stops_blocking_once_its_child_has_merged ... ok -test an_update_whose_where_clause_scanned_names_its_dependents ... ok -test a_point_lookup_that_observed_an_absence_halts_the_revert_that_would_refill_it ... ok -test a_delete_whose_where_clause_scanned_names_its_dependents ... ok -test an_update_that_names_a_row_by_key_names_its_dependent_and_stays_a_blind_write ... ok -test a_mirrored_key_equality_is_classified_the_same_as_the_unmirrored_one ... ok -test parentheses_do_not_change_the_access_shape ... ok - -test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s - -=== target provenance_scan_cascade - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s - Running tests/provenance_scan_cascade.rs (target/debug/deps/provenance_scan_cascade-66d42001be305987) - -running 5 tests -test a_scan_depends_on_the_write_that_moved_a_row_out_of_its_range ... ok -test a_branch_that_scanned_before_the_write_is_not_a_dependent ... ok -test a_write_outside_the_scanned_range_is_not_a_dependency ... ok -test a_dependent_reached_by_a_scan_is_named_by_cascade ... ok -test a_multi_row_merge_is_named_once_by_the_scan_that_read_it_and_reverts_whole ... ok - -test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s - -=== target provenance_write_path_dependents - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.44s - Running tests/provenance_write_path_dependents.rs (target/debug/deps/provenance_write_path_dependents-0a768ca1b4fd14c0) - -running 1 test -test sweep ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.18s - -=== target s20_live_count_is_maintained - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.42s - Running tests/s20_live_count_is_maintained.rs (target/debug/deps/s20_live_count_is_maintained-0c661209b21dcbb5) - -running 3 tests -test an_idempotent_transition_does_not_move_the_counter ... ok -test the_counter_agrees_with_a_scan_after_forks ... ok -test the_counter_agrees_with_a_scan_after_state_transitions ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s - -=== target sim_durability - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.82s - Running tests/sim_durability.rs (target/debug/deps/sim_durability-9e6ed92c7221a067) - -running 16 tests -test under_sync_only_an_unflushed_write_does_not_survive ... ok -test a_torn_write_lands_a_prefix_and_ends_the_run ... ok -test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok -test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok -test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok -test the_durable_commit_detector_fires_and_stays_quiet ... ok -test flush_all_writes_pages_in_ascending_page_id_order ... ok -test two_different_seeds_crash_in_different_places ... ok -test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok -test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok -test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok -test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok -test recovery_itself_writes_the_same_sequence_three_times ... ok -test recovery_holds_at_every_fault_point_sync_only ... ok -test recovery_holds_at_every_fault_point_write_through ... ok -test recovery_holds_at_every_fault_point_through_the_catalog ... ok - -test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.95s - -=== target w4_forget_branches - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.37s - Running tests/w4_forget_branches.rs (target/debug/deps/w4_forget_branches-0a41a5b3bd0a4bd7) - -running 3 tests -test forget_branches_refuses_a_branch_that_is_still_live ... ok -test a_reaped_branch_nobody_reported_is_still_reconciled ... ok -test forget_branches_drops_the_branches_it_is_given ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s - -=== target w4_stale_branch_crosses_agents - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.52s - Running tests/w4_stale_branch_crosses_agents.rs (target/debug/deps/w4_stale_branch_crosses_agents-56dece9702f8bcac) - -running 2 tests -test a_reaped_sessions_abandon_must_not_retire_the_new_occupants_session ... ok -test a_reaped_sessions_select_must_not_read_the_new_occupants_staged_row ... ok - -test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s - -=== target w4_sweep_slot_recycle - Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-agent-acf966798a3979f57) - Finished `test` profile [unoptimized + debuginfo] target(s) in 1.33s - Running tests/w4_sweep_slot_recycle.rs (target/debug/deps/w4_sweep_slot_recycle-6ee876f01aec3d39) - -running 1 test -test a_session_that_recycled_a_reaped_slot_survives_a_sweep_already_in_flight ... ok - -test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s - From 3cc71be0fa29f338b335620d4095e5d58f176154 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:02:48 -0400 Subject: [PATCH 062/315] D166: instrument has_live_children's child-span scans, and a depth-chain harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⛔ NOT COMPILED YET. A suite and a CI run are live and a build here would contend for the shared suite lock, so this is committed unverified and must get 'cargo check --examples --tests' before any number from it is quoted. Saying so rather than implying it built -- and 4e50b0d is the reason that sentence is here. The counter is modelled on reaper::sweep_descents and carries the same argument: the claim under test is a COMPLEXITY CLASS, and an integer proves a class directly while a wall clock only illustrates it on a box that never goes quiet. Counting cascade STEPS cannot answer D166, because steps are O(depth) by construction. Counting SCANS can: has_live_children BFS's the reaped subtree beneath the cursor, and that subtree grows as the cascade climbs. The prediction is registered in the harness's own header before its first run -- Theta(D^2), with the outcome that would falsify it, and the statement that a flat scans/D is a perfectly good result to report rather than re-scope. --- examples/d166_reap_depth_curve.rs | 122 ++++++++++++++++++++++++++++++ src/branch/table_catalog.rs | 30 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 examples/d166_reap_depth_curve.rs diff --git a/examples/d166_reap_depth_curve.rs b/examples/d166_reap_depth_curve.rs new file mode 100644 index 00000000..81624282 --- /dev/null +++ b/examples/d166_reap_depth_curve.rs @@ -0,0 +1,122 @@ +//! D166: what does `reaper::detach_from_parent` cost as a branch CHAIN deepens? +//! +//! **Every 10^6-branch result this project has produced was taken at DEPTH 1** — a flat fanout. +//! `d31_reap_cost` varies pages-per-branch over `for _ in 0..branches`, and that shape cannot see +//! this: the cascade only fires when a reaped branch's parent is itself reaped. +//! +//! # THE PREDICTION, REGISTERED HERE BEFORE THE FIRST RUN +//! +//! `detach_from_parent` takes O(depth) STEPS. Each step asks `has_live_children`, whose own doc +//! says the cost "is still not O(1)": a breadth-first walk of the reaped subtree, one `range_scan` +//! per node visited. In a pure chain the reaped subtree beneath the cursor is the whole chain +//! below it, and **that subtree GROWS as the cascade climbs**. +//! +//! ⇒ PREDICTED: `child_scans` is **Θ(D²)**, so `scans / D²` goes FLAT and `scans / D` climbs. +//! ⇒ FALSIFIED IF: `scans / D` goes flat instead — then the cost is merely linear in depth, the +//! "unbounded work per step" note describes a case this workload never reaches, and D166 has +//! found nothing. **That is a perfectly good outcome and must be reported as one**, not +//! re-scoped into a finding. +//! +//! # WHY A COUNT AND NOT A CLOCK +//! +//! The claim is a COMPLEXITY CLASS. An integer proves a class directly; a wall clock only +//! illustrates it, and this box never goes quiet on its own. The clock is printed beside the count +//! so a right SHAPE with a wrong MAGNITUDE is visible, but **the count is the result**. +//! `child_scans` is read twice and differenced — it is process-wide and monotonic, so an absolute +//! is meaningless. +//! +//! # THE REAP ORDER IS THE EXPERIMENT +//! +//! Reaping b1..bD in chain order means every reap but the last returns early (`has_live_children` +//! is true — the next link is still live), and **the last one cascades the whole way**. That is +//! the worst case, and it is exactly what MCTS pruning produces — one of BranchBench's five +//! workloads. +//! +//! d166_reap_depth_curve [depth,list] +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::{BranchId, LeaseDeadline}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::storage::disk_manager::DiskManager; + +fn main() { + let depths: Vec = std::env::args() + .nth(1) + .unwrap_or_else(|| "8,16,32,64,128,256".into()) + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + + println!("D166 reap cost vs branch DEPTH. One chain per row, TableBranchCatalog on a real file."); + println!("PREDICTED Θ(D²): scans/D² flat, scans/D climbing. FALSIFIED if scans/D is flat."); + println!(); + println!(" depth scans scans/D scans/D^2 reaped ms"); + + for &d in &depths { + let dir = std::env::temp_dir() + .join(format!("ferrodb-d166-{}-{}", std::process::id(), d)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let f = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(dir.join("main.db")) + .unwrap(); + let pool = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(f).unwrap()))); + + // Concrete Arc as well as the trait object: `child_scans()` is the instrument and it lives + // on the concrete type. Same catalog, two handles. + let concrete = Arc::new( + TableBranchCatalog::open_sidecar(&dir.join("branches.branchcat"), 1).unwrap(), + ); + let catalog: Arc = concrete.clone(); + let base = pool.disk_manager.high_water().unwrap(); + let store = + Arc::new(ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), base).unwrap()); + let reaper = TwoTierReaper::new(Arc::clone(&catalog), Arc::clone(&store)); + + // Build the CHAIN: each branch forks from the previous, not from trunk. + let mut chain = Vec::with_capacity(d); + let mut parent = BranchId::TRUNK; + for _ in 0..d { + let rec = catalog.fork(parent, LeaseDeadline::from_now(1)).unwrap(); + parent = rec.branch_id; + chain.push(rec.branch_id); + } + + let before = concrete.child_scans(); + let t0 = Instant::now(); + let mut reaped = 0usize; + // Chain order: every reap but the last returns early; the last cascades the whole way. + for id in &chain { + if reaper.reap(*id).is_ok() { + reaped += 1; + } + } + let ms = t0.elapsed().as_secs_f64() * 1000.0; + let scans = concrete.child_scans() - before; + + let df = d as f64; + println!( + " {:>8} {:>10} {:>10.2} {:>10.4} {:>7} {:>6.1}", + d, + scans, + scans as f64 / df, + scans as f64 / (df * df), + reaped, + ms + ); + let _ = std::fs::remove_dir_all(&dir); + } + + println!(); + println!("Read the SHAPE, not the absolutes: whichever of the two ratio columns goes FLAT names"); + println!("the complexity class. If NEITHER is flat, the chain is not the only thing growing and"); + println!("the harness is wrong before the engine is."); +} diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 2a63aeb6..f2001064 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -52,6 +52,21 @@ pub struct TableBranchCatalog { logical: Mutex<()>, next_id: AtomicU64, epoch: AtomicU64, + /// Child-span scans performed by `has_live_children`, counted at the single site that issues + /// them. + /// + /// **This is an instrument, not a statistic** — the same role `reaper::sweep_descents` plays + /// for D40, and for the same reason: the claim it exists to test is a COMPLEXITY CLASS, and an + /// integer proves a class directly while a wall clock only illustrates it. A clock moves when + /// the machine is loaded; a scan count does not. + /// + /// D166 asks whether `reaper::detach_from_parent` is merely O(depth) or worse. Its cascade + /// takes O(depth) STEPS, but each step calls `has_live_children`, whose own doc says the cost + /// "is still not O(1)" — a breadth-first walk of the reaped subtree, one `range_scan` per node + /// visited. As the cascade climbs, the subtree beneath the cursor GROWS. **Counting cascade + /// steps alone cannot see that; counting scans can**, which is why the counter is here and not + /// in the reaper. + child_scans: AtomicU64, /// The buffer pool, kept so the header page can be written without threading it through. pool: Arc, /// The fixed page naming the tree root. `0` means "none" - `create` builds a catalog with no @@ -424,6 +439,7 @@ impl TableBranchCatalog { // which is 0, and seeds its counter with exactly that. Seeding 1 here would make the // first epoch this catalog hands out differ from the first the log hands out. epoch: AtomicU64::new(0), + child_scans: AtomicU64::new(0), pool, header_page: std::sync::atomic::AtomicU32::new(0), commit_group: CommitGroup::default(), @@ -447,6 +463,7 @@ impl TableBranchCatalog { logical: Mutex::new(()), next_id: AtomicU64::new(1), epoch: AtomicU64::new(1), + child_scans: AtomicU64::new(0), pool, header_page: std::sync::atomic::AtomicU32::new(0), commit_group: CommitGroup::default(), @@ -851,6 +868,15 @@ impl TableBranchCatalog { } /// Live branches, for parity with `LogBranchCatalog::live_count`. + /// Child spans scanned by `has_live_children` since this catalog was built. + /// + /// Read it twice around the operation under test and take the difference — the counter is + /// process-wide and monotonic, so an absolute value is meaningless without the pair. See the + /// field's own note for why this is an instrument rather than a statistic. + pub fn child_scans(&self) -> u64 { + self.child_scans.load(Ordering::Relaxed) + } + pub fn live_count(&self) -> Result { let (lo, hi) = keys::whole_state(BranchState::Live.as_u8()); Ok(self.ids_in_span(lo, hi)?.len()) @@ -1283,6 +1309,10 @@ impl BranchCatalog for TableBranchCatalog { let mut pending = vec![parent_id]; while let Some(id) = pending.pop() { let (lo, hi) = keys::children_of(id); + // One increment per node VISITED, which is one `range_scan` issued. Counted here + // rather than per entry: the question D166 asks is how many child spans this walk + // touches, and an entry count would answer a different one (how wide they are). + self.child_scans.fetch_add(1, Ordering::Relaxed); for entry in self.tree.range_scan(Bound::Included(lo), Bound::Excluded(hi))? { let (k, v) = entry?; match self.child_liveness(&k, &v)? { From ded8fd91afac9248470916b204ab6066850c5716 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:48:04 -0400 Subject: [PATCH 063/315] D170 ADVERSARY: the staged arm's flatness at HEAD is the D71 pushdown fix e6958d6, not the stub INSTRUMENT ONLY -- NOT FOR LANDING. D170_NOPUSHDOWN=1 restores branch_update's pre-e6958d6 unpredicated visible_rows_where(.., None, None) in the SAME binary, so the A/B cannot be a build difference. examples/d170_adv_probe.rs copies the HEAD D71 harness verbatim and adds what it cannot do: asserts AgentOutput::Affected(n)==1 per statement, reads the value back, and runs a negative control on an absent key that must report 0. Measured on the ARENA path, counterbalanced, 60 updates per size, load 22-28: HEAD (pushdown ON): 0.0703 -> 0.0897 (1.27x) and 0.0652 -> 0.0497 (0.76x). FLAT. NOPUSH (pre-e6958d6): 0.1894 -> 2.2312 (11.78x) and 0.1844 -> 2.6823 (14.55x). LINEAR, ms-per-1000-rows flat at ~0.14-0.17 -- the scan signature. Stub run's headline was 12.91x, ms-per-1000 flat ~0.17. The arena reproduces it. PLAIN control is unmoved by the switch (0.3148 vs 0.3117 ms/1000, fsyncs 1.00), which is the measured demonstration that PLAIN cannot see a change to branch_update at all. --- bench/d170_adversary_probe.txt | 170 ++++++++++++++++++++++++ examples/d170_adv_probe.rs | 231 +++++++++++++++++++++++++++++++++ run_d170_adv.sh | 56 ++++++++ src/agent_sql/runtime.rs | 12 +- 4 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 bench/d170_adversary_probe.txt create mode 100644 examples/d170_adv_probe.rs create mode 100755 run_d170_adv.sh diff --git a/bench/d170_adversary_probe.txt b/bench/d170_adversary_probe.txt new file mode 100644 index 00000000..1c9bb9c6 --- /dev/null +++ b/bench/d170_adversary_probe.txt @@ -0,0 +1,170 @@ +ACQUIRED d170-adversary load_at_acquire=26 +CAVEAT: load 26 >= 8. You hold exclusivity against other MEASURERS, but the +box is NOT quiet. Prefer COUNTERS (integers) over durations; if you must report +a duration, stamp load_at_acquire=26 beside it and say it is an upper bound. +host: Darwin arm64 started: 2026-09-23T06:33:31Z +load: 26.99 23.08 22.10 +tree: 7f4f8464f06b15af6b9695d76fe5e234c16be3f0 (worktree d170-adversary, branch_update instrumented) +df: /dev/disk3s5 926Gi 839Gi 61Gi 94% 8.6M 642M 1% /System/Volumes/Data +=== BUILD === + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d170-adversary) + Finished `release` profile [optimized] target(s) in 9.78s +BUILD OK + +=== PAIR 1 — STAGED — HEAD (pushdown ON) — load 25.19 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 0.0703 0.0703 0.00 60 YES 0 + 2000 0.0638 0.0319 0.00 60 YES 0 + ^ 2.0x the rows, 0.91x the time + 4000 0.0485 0.0121 0.00 60 YES 0 + ^ 4.0x the rows, 0.69x the time + 8000 0.0495 0.0062 0.00 60 YES 0 + ^ 8.0x the rows, 0.70x the time + 16000 0.0897 0.0056 0.00 60 YES 0 + ^ 16.0x the rows, 1.27x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== PAIR 1 — STAGED — NOPUSHDOWN (pre-e6958d6) — load 23.83 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 0.1894 0.1894 0.00 60 YES 0 + 2000 0.3045 0.1522 0.00 60 YES 0 + ^ 2.0x the rows, 1.61x the time + 4000 0.5650 0.1412 0.00 60 YES 0 + ^ 4.0x the rows, 2.98x the time + 8000 1.1010 0.1376 0.00 60 YES 0 + ^ 8.0x the rows, 5.81x the time + 16000 2.2312 0.1394 0.00 60 YES 0 + ^ 16.0x the rows, 11.78x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== PAIR 2 — STAGED — NOPUSHDOWN (pre-e6958d6) — load 28.37 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 0.1844 0.1844 0.00 60 YES 0 + 2000 0.2882 0.1441 0.00 60 YES 0 + ^ 2.0x the rows, 1.56x the time + 4000 0.6397 0.1599 0.00 60 YES 0 + ^ 4.0x the rows, 3.47x the time + 8000 1.0709 0.1339 0.00 60 YES 0 + ^ 8.0x the rows, 5.81x the time + 16000 2.6823 0.1676 0.00 60 YES 0 + ^ 16.0x the rows, 14.55x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== PAIR 2 — STAGED — HEAD (pushdown ON) — load 27.77 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 0.0652 0.0652 0.00 60 YES 0 + 2000 0.0585 0.0292 0.00 60 YES 0 + ^ 2.0x the rows, 0.90x the time + 4000 0.0505 0.0126 0.00 60 YES 0 + ^ 4.0x the rows, 0.77x the time + 8000 0.0490 0.0061 0.00 60 YES 0 + ^ 8.0x the rows, 0.75x the time + 16000 0.0497 0.0031 0.00 60 YES 0 + ^ 16.0x the rows, 0.76x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== Q3 N-SWEEP — STAGED — D170_NOPUSHDOWN=0 — load 26.64 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + +Q3 — N-SWEEP at a FIXED 4000-row table. Axis = rows staged in the session. + staged N median ms us/update affected!=1 + 50 0.0503 50.25 0 + 100 0.0939 93.88 0 + 200 0.0581 58.08 0 + 400 0.0618 61.83 0 + 800 0.0793 79.25 0 + 1600 0.1050 105.04 0 + 3200 0.1618 161.75 0 +rc=0 + +=== Q3 N-SWEEP — STAGED — D170_NOPUSHDOWN=1 — load 22.35 === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + +Q3 — N-SWEEP at a FIXED 4000-row table. Axis = rows staged in the session. + staged N median ms us/update affected!=1 + 50 0.6006 600.63 0 + 100 0.5687 568.71 0 + 200 0.5673 567.29 0 + 400 0.5796 579.62 0 + 800 0.6278 627.79 0 + 1600 0.6567 656.71 0 + 3200 0.7852 785.21 0 +rc=0 + +=== PLAIN CONTROL — D170_NOPUSHDOWN=0 (must be unaffected) === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = PLAIN + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 4.0884 4.0884 1.03 60 YES 0 + 2000 3.0263 1.5132 1.03 60 YES 0 + ^ 2.0x the rows, 0.74x the time + 4000 3.9729 0.9932 1.00 60 YES 0 + ^ 4.0x the rows, 0.97x the time + 8000 4.0007 0.5001 1.00 60 YES 0 + ^ 8.0x the rows, 0.98x the time + 16000 5.0374 0.3148 1.00 60 YES 0 + ^ 16.0x the rows, 1.23x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== PLAIN CONTROL — D170_NOPUSHDOWN=1 (must be unaffected) === +D170 ADVERSARY PROBE +at 7f4f8464f06b +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = PLAIN + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + 1000 3.7400 3.7400 1.03 60 YES 0 + 2000 3.0306 1.5153 1.03 60 YES 0 + ^ 2.0x the rows, 0.81x the time + 4000 3.8836 0.9709 1.00 60 YES 0 + ^ 4.0x the rows, 1.04x the time + 8000 4.0091 0.5011 1.00 60 YES 0 + ^ 8.0x the rows, 1.07x the time + 16000 4.9878 0.3117 1.00 60 YES 0 + ^ 16.0x the rows, 1.33x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +finished: 2026-09-23T06:47:19Z load: 17.38 20.06 21.62 +RELEASED d170-adversary +D170_ADV_COMPLETE diff --git a/examples/d170_adv_probe.rs b/examples/d170_adv_probe.rs new file mode 100644 index 00000000..13bc4f16 --- /dev/null +++ b/examples/d170_adv_probe.rs @@ -0,0 +1,231 @@ +//! D170 ADVERSARY — three questions the D170 re-run cannot answer about itself. +//! +//! The `Db`/`build` below are COPIED VERBATIM from `examples/d71_point_update_curve.rs` at HEAD, +//! so the engine configuration under test is byte-for-byte the one D170 measured: a real +//! `TableBranchCatalog` sidecar, a real `ArenaPageStore`, a `ServerContext` that designates the +//! runtime, and every session built from it. +//! +//! What this adds that the D71 harness cannot do, because it DISCARDS the `Outcome` +//! (`d71_point_update_curve.rs:163` — the returned `Outcome` is dropped): +//! +//! Q1 DOES THE STAGED UPDATE MUTATE ANYTHING? An `UPDATE` matching 0 rows SUCCEEDS, so a +//! silent no-op is indistinguishable from a fast write in the D71 output. Every statement's +//! `AgentOutput::Affected(n)` is asserted `== 1`, and the value is READ BACK through the +//! agent session and asserted equal to what was written. A NEGATIVE CONTROL runs the same +//! assertion against a key that cannot exist and requires `Affected(0)`, so the instrument +//! is proven able to tell 0 from 1 rather than assumed to be. +//! +//! Q2 IS THE FLATNESS THE ARENA, OR THE D71 PUSHDOWN FIX `e6958d6`? `D170_NOPUSHDOWN=1` +//! restores the pre-fix call inside `branch_update` in THIS SAME BINARY. If the staged arm +//! goes linear again on the ARENA path, the linearity was never about the stub. +//! +//! Q3 IS THE AXIS `D71_N`? `D170_NSWEEP` fixes the table and sweeps the number of staged rows. +//! +//! Durations are printed but the CONCLUSIONS ride on the integer columns (`affected`, `rowsseen`) +//! and on within-run ratios, because this box is not quiet. +use std::fs::OpenOptions; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::agent_sql::AgentOutput; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::cow::PageStore; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::pgwire::ServerContext; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::MemEffectLog; +use ferrodb::wal::log::{fsync_counters, WalManager}; +use ferrodb::wal::txn::TxnManager; + +struct Db { + ctx: Arc, + bp: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new(rows: i64) -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true).write(true).create(true).truncate(true) + .open(dir.path().join("p.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let cat = Arc::new(TableBranchCatalog::open_sidecar(&dir.path().join("b.branchcat"), 1).unwrap()); + let branches: Arc = cat; + let arena_base: u32 = ((rows / 40) as u32 + 4096).next_power_of_two(); + let store = Arc::new(ArenaPageStore::new(bp.clone(), branches.clone(), arena_base).unwrap()); + store.checkpoint_to(dir.path().join("p.arena")); + let runtime = Arc::new( + AgentRuntime::with_storage( + branches, + Arc::new(MemEffectLog::new()), + store as Arc, + ) + .expect("attach arena storage"), + ); + let ctx = Arc::new(ServerContext::new(catalog, bp.clone(), txn.clone(), runtime)); + Db { ctx, bp, txn, _dir: dir } + } + fn exec(&mut self, sql: &str, s: &mut Session) -> Outcome { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens().unwrap(); + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "{sql}: {:?}", p.errors); + let mut cat = self.ctx.catalog(); + let out = run(stmts.remove(0), &mut cat, self.bp.clone(), self.txn.clone(), s); + drop(cat); + out.unwrap_or_else(|e| panic!("{sql}: {e}")) + } +} + +fn build(rows: i64) -> (Db, Session) { + let mut db = Db::new(rows); + let mut s = db.ctx.session(); + db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut s); + for i in 1..=rows { + db.exec(&format!("INSERT INTO t VALUES ({i}, 0);"), &mut s); + } + (db, s) +} + +/// How many rows a statement says it changed. `None` = the outcome carries no count at all, +/// which is itself a finding and must never be silently read as zero or as success. +fn affected(o: &Outcome) -> Option { + match o { + Outcome::Agent(AgentOutput::Affected(n)) => Some(*n), + Outcome::Affected(n) => Some(*n), + _ => None, + } +} + +fn rows_of(o: &Outcome) -> Vec> { + match o { + Outcome::Rows(r) => r.clone(), + Outcome::Table(t) => t.rows.clone(), + _ => Vec::new(), + } +} + +fn median(v: &mut Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +fn main() { + println!("D170 ADVERSARY PROBE"); + println!("{}", ferrodb::build_provenance()); + let nopush = std::env::var("D170_NOPUSHDOWN").map(|v| v == "1").unwrap_or(false); + println!("D170_NOPUSHDOWN = {} ({})", nopush, + if nopush { "PRE-e6958d6 unpredicated scan in branch_update" } else { "HEAD, predicate pushed" }); + let staged_arm = std::env::var("D71_ARM").map(|a| a == "staged").unwrap_or(false); + println!("arm = {}", if staged_arm { "STAGED" } else { "PLAIN" }); + + // ---- Q3: sweep the number of staged rows at a FIXED table size --------------------------- + if std::env::var("D170_NSWEEP").is_ok() { + let rows: i64 = std::env::var("D170_ROWS").ok().and_then(|v| v.parse().ok()).unwrap_or(4000); + let ns: Vec = std::env::var("D170_NS") + .unwrap_or_else(|_| "50,100,200,400,800,1600,3200".to_string()) + .split(',').filter_map(|v| v.parse().ok()).collect(); + println!("\nQ3 — N-SWEEP at a FIXED {rows}-row table. Axis = rows staged in the session."); + println!(" staged N median ms us/update affected!=1"); + for &n in &ns { + let (mut db, mut s) = build(rows); + let mut a = db.ctx.session(); + if staged_arm { db.exec("BEGIN AGENT SESSION AS 'd170';", &mut a); } + let sess: &mut Session = if staged_arm { &mut a } else { &mut s }; + let mut samples = Vec::new(); + let mut bad = 0usize; + for i in 0..n { + // Every update hits a DISTINCT key, so the session's staged-row count really is N. + let id = 1 + (i as i64 * 7919) % rows; + let t = Instant::now(); + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {id};"), sess); + samples.push(t.elapsed().as_secs_f64() * 1000.0); + if affected(&out) != Some(1) { bad += 1; } + } + let med = median(&mut samples); + println!(" {n:>8} {med:>9.4} {:>9.2} {bad:>11}", med * 1000.0); + } + return; + } + + // ---- Q1 + Q2: the table-size axis, with every statement's effect asserted ---------------- + let sizes: Vec = std::env::var("D71_SIZES") + .unwrap_or_else(|_| "1000,2000,4000,8000,16000".to_string()) + .split(',').filter_map(|v| v.parse().ok()).collect(); + let n: usize = std::env::var("D71_N").ok().and_then(|v| v.parse().ok()).unwrap_or(60); + println!("\n table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl"); + let mut first: Option<(i64, f64)> = None; + for &rows in &sizes { + let (mut db, mut s) = build(rows); + let mut a = db.ctx.session(); + if staged_arm { db.exec("BEGIN AGENT SESSION AS 'd170';", &mut a); } + let sess: &mut Session = if staged_arm { &mut a } else { &mut s }; + + // NEGATIVE CONTROL, run FIRST and on this same session: a key that cannot exist must + // report 0. If this ever reports 1, or reports `None`, the `affected` instrument below is + // not measuring what it claims and NOTHING in this row may be read as a mutation. + let ncid = rows + 1_000_000; + let nout = db.exec(&format!("UPDATE t SET v = -1 WHERE id = {ncid};"), sess); + let neg = affected(&nout); + assert_eq!(neg, Some(0), + "NEGATIVE CONTROL FAILED at {rows} rows: an UPDATE on absent key {ncid} reported {neg:?}, \ + so `affected` cannot distinguish a no-op from a write and this run is VOID"); + + let (f0, _) = fsync_counters(); + let mut samples = Vec::new(); + let mut affected_sum = 0usize; + for i in 0..n { + let id = 1 + (i as i64 * 7919) % rows; + let t = Instant::now(); + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {id};"), sess); + samples.push(t.elapsed().as_secs_f64() * 1000.0); + let got = affected(&out); + assert_eq!(got, Some(1), + "STAGED UPDATE IS A NO-OP at {rows} rows, i={i}, id={id}: reported {got:?}, expected 1"); + affected_sum += got.unwrap(); + } + let (f1, _) = fsync_counters(); + + // READ-BACK, outside the timed loop: the value written by the LAST update must be + // visible to this same session. Asserting the count alone would still admit a write that + // was counted and then dropped. + let last = n - 1; + let last_id = 1 + (last as i64 * 7919) % rows; + let sel = db.exec(&format!("SELECT v FROM t WHERE id = {last_id};"), sess); + let got = rows_of(&sel); + assert_eq!(got.len(), 1, "read-back at {rows} rows returned {} rows, expected 1", got.len()); + let want = Value::Integer(last as i32); + assert_eq!(got[0][0], want, + "READ-BACK MISMATCH at {rows} rows, id={last_id}: stored {:?}, wrote {want:?} — the \ + statement was COUNTED but the row did not change", got[0][0]); + + let med = median(&mut samples); + println!(" {rows:>10} {med:>9.4} {:>11.4} {:>10.2} {affected_sum:>12} {:>11} {:>7}", + med / (rows as f64 / 1000.0), + (f1 - f0) as f64 / n as f64, + "YES", + neg.unwrap()); + if first.is_none() { first = Some((rows, med)); } + if let Some((r0, m0)) = first { + if rows != r0 { + println!(" ^ {:.1}x the rows, {:.2}x the time", rows as f64 / r0 as f64, med / m0); + } + } + } + println!("\nEvery row above asserted: affected==1 per statement, read-back equal, negative control==0."); +} diff --git a/run_d170_adv.sh b/run_d170_adv.sh new file mode 100755 index 00000000..1db1ddf0 --- /dev/null +++ b/run_d170_adv.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# D170 ADVERSARY — build once, then A/B the pushdown IN ONE BINARY on the ARENA path. +export PATH="$HOME/.cargo/bin:$PATH" +cd /Users/idide/wt/ferrodb-d170-adversary || exit 1 +OUT=bench/d170_adversary_probe.txt +: > "$OUT" + +~/wt/logs/measure-lock.sh acquire d170-adversary >>"$OUT" 2>&1 || { + echo "LOCK NOT ACQUIRED — NO NUMBER IS REPORTED" >>"$OUT"; echo "D170_ADV_ABORT" >>"$OUT"; exit 1; } +trap '~/wt/logs/measure-lock.sh release d170-adversary >>"'"$OUT"'" 2>&1; echo "D170_ADV_COMPLETE" >>"'"$OUT"'"' EXIT + +{ + echo "host: $(uname -sm) started: $(date -u +%FT%TZ)" + echo "load: $(uptime | sed 's/.*averages*: *//')" + echo "tree: $(git rev-parse HEAD) (worktree d170-adversary, branch_update instrumented)" + echo "df: $(df -h /System/Volumes/Data | tail -1)" +} >>"$OUT" 2>&1 + +echo "=== BUILD ===" >>"$OUT" +timeout 1800 cargo build --release --example d170_adv_probe >>"$OUT" 2>&1 || { + echo "BUILD FAILED — NO RESULT" >>"$OUT"; exit 1; } +echo "BUILD OK" >>"$OUT" + +B=target/release/examples/d170_adv_probe + +# Q1+Q2 counterbalanced: HEAD, NOPUSH, NOPUSH, HEAD. Arm order alternates so drift cannot +# favour one side, which is the method d71_pushdown_fix.txt used and d167 was faulted for skipping. +for pair in 1 2; do + for mode in a b; do + if [ "$pair$mode" = "1a" ] || [ "$pair$mode" = "2b" ]; then NP=0; TAG="HEAD (pushdown ON)"; else NP=1; TAG="NOPUSHDOWN (pre-e6958d6)"; fi + echo "" >>"$OUT" + echo "=== PAIR $pair — STAGED — $TAG — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + timeout 1200 env D71_ARM=staged D170_NOPUSHDOWN=$NP "$B" >>"$OUT" 2>&1 + echo "rc=$?" >>"$OUT" + done +done + +# Q3 — the axis. Fixed 4000-row table, sweep the number of staged rows, both states. +for NP in 0 1; do + echo "" >>"$OUT" + echo "=== Q3 N-SWEEP — STAGED — D170_NOPUSHDOWN=$NP — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + timeout 1200 env D71_ARM=staged D170_NSWEEP=1 D170_ROWS=4000 D170_NOPUSHDOWN=$NP "$B" >>"$OUT" 2>&1 + echo "rc=$?" >>"$OUT" +done + +# PLAIN control, once: it must NOT move between pushdown states, because ordinary DML never +# touches the runtime. If it does move, the A/B is contaminated and Q2 is void. +for NP in 0 1; do + echo "" >>"$OUT" + echo "=== PLAIN CONTROL — D170_NOPUSHDOWN=$NP (must be unaffected) ===" >>"$OUT" + timeout 1200 env -u D71_ARM D170_NOPUSHDOWN=$NP "$B" >>"$OUT" 2>&1 + echo "rc=$?" >>"$OUT" +done + +echo "" >>"$OUT" +echo "finished: $(date -u +%FT%TZ) load: $(uptime | sed 's/.*averages*: *//')" >>"$OUT" diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index f68eef03..37155493 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -2612,13 +2612,21 @@ impl AgentRuntime { // CONSERVATIVE HINT — the planner may narrow the scan or ignore the predicate entirely — // so `evaluate` remains the sole authority on what matches and the semantics cannot drift // between the two paths. What changes is how many rows reach it, never which ones pass. + // ⚠⚠ D170 ADVERSARY INSTRUMENT — NOT FOR LANDING. `D170_NOPUSHDOWN=1` restores the + // PRE-`e6958d6` call EXACTLY: that commit states `visible_rows(..)` was + // `visible_rows_where(.., None, None, None)`, so passing `None, None` here IS the old + // path. ONE binary, TWO runtime states, so this A/B cannot be a build difference. + let nopush = { + static NP: std::sync::OnceLock = std::sync::OnceLock::new(); + *NP.get_or_init(|| std::env::var("D170_NOPUSHDOWN").map(|v| v == "1").unwrap_or(false)) + }; let rows = self.visible_rows_where( &ctx.read(), Some(branch), table, None, - where_clause.as_ref(), - bound_where.as_ref(), + if nopush { None } else { where_clause.as_ref() }, + if nopush { None } else { bound_where.as_ref() }, )?; let mut staged: Vec = Vec::new(); // The rows this statement's own scan returned. See `record_write_scan`. From af69685ac15d00f431b91ca9718159f513f08ca7 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:14:15 -0400 Subject: [PATCH 064/315] D170 ADVERSARY round 2: the full 2x2 in one binary, and unprobeable_rows measured at 0 in the STUB INSTRUMENT ONLY -- NOT FOR LANDING. Adds (a) atomic counters for which overlay arm each staged statement takes and why, fire-checked to report all three; (b) D170_STUB=1, the exact pre-fc443c4 configuration (Session::new(), storage: None, no ServerContext -- designated::check returns Ok when nothing is designated, so no guard is touched); (c) the D71_N axis pushed to 12800. 2x2, one binary, order reversed between blocks, ratio over 16x rows: pushdown ON NOPUSHDOWN (pre-e6958d6) STUB 0.98x / 0.29x 15.54x / 15.71x ARENA 0.99x / 0.97x 12.53x / 12.79x The pushdown axis sets the complexity class. The stub/arena axis does not: it is a ~4x constant (16000 rows, pushdown ON: stub 0.0117 ms vs arena 0.0509 ms), exactly as D101's banner predicted. unprobeable_rows = 0 in EVERY row of EVERY block, stub included, and the probe fired on every statement whenever a predicate was pushed. The 'stub demoted the probe' hypothesis is false where it was posed. D71_N to 12800 at HEAD: 48.62 -> 494.58 us/update over 256x, probe fired 12800/12800, and the overlay SATURATES at 4000 (the table size) above N=4000. Over the range where the overlay tracks N (50..3200, 64x) the cost rises 3.2x. Never linear. --- bench/d170_adversary_probe2.txt | 244 ++++++++++++++++++++++++++++++++ examples/d170_adv_probe.rs | 137 +++++++++++++++++- run_d170_adv2.sh | 59 ++++++++ src/agent_sql/runtime.rs | 40 ++++++ 4 files changed, 476 insertions(+), 4 deletions(-) create mode 100644 bench/d170_adversary_probe2.txt create mode 100755 run_d170_adv2.sh diff --git a/bench/d170_adversary_probe2.txt b/bench/d170_adversary_probe2.txt new file mode 100644 index 00000000..daa3dd14 --- /dev/null +++ b/bench/d170_adversary_probe2.txt @@ -0,0 +1,244 @@ +ACQUIRED d170-adversary2 load_at_acquire=16 +CAVEAT: load 16 >= 8. You hold exclusivity against other MEASURERS, but the +box is NOT quiet. Prefer COUNTERS (integers) over durations; if you must report +a duration, stamp load_at_acquire=16 beside it and say it is an upper bound. +host: Darwin arm64 started: 2026-09-23T06:54:24Z +load: 16.70 14.80 18.16 +tree: ded8fd91afac9248470916b204ab6066850c5716 (worktree d170-adversary, instrumented) + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d170-adversary) + Finished `release` profile [optimized] target(s) in 9.73s + +=== FIRE-CHECK: the overlay-arm detector must report all three arms === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + +FIRE-CHECK — force each overlay arm and require the counter to see it. + 1. pk= predicate, nothing demoted -> probe=1 walk_unprob=0 walk_nopk=0 unprob_rows=0 + 2. NON-pk predicate -> probe=0 walk_unprob=0 walk_nopk=1 unprob_rows=0 + 3. same pk= predicate AFTER a PK move -> probe=0 walk_unprob=1 walk_nopk=0 unprob_rows=1 + ALL THREE ARMS OBSERVED. The detector discriminates. +rc=0 + +############ BLOCK A (forward order) ############ + +=== A1 STUB + pushdown ON — load 20.32 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + STUB CONFIG (Session::new(), storage: None) — the pre-fc443c4 harness + table rows median ms ms per 1000 affected_sum probe/walk_unprob/walk_nopk max_unprob max_overlay + 1000 0.0120 0.0120 60 60/ 0/ 0 0 59 + 2000 0.0112 0.0056 60 60/ 0/ 0 0 59 + ^ 2.0x the rows, 0.93x the time + 4000 0.0114 0.0028 60 60/ 0/ 0 0 59 + ^ 4.0x the rows, 0.95x the time + 8000 0.0114 0.0014 60 60/ 0/ 0 0 59 + ^ 8.0x the rows, 0.95x the time + 16000 0.0117 0.0007 60 60/ 0/ 0 0 59 + ^ 16.0x the rows, 0.98x the time +rc=0 + +=== A2 STUB + NOPUSHDOWN — load 13.01 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + STUB CONFIG (Session::new(), storage: None) — the pre-fc443c4 harness + table rows median ms ms per 1000 affected_sum probe/walk_unprob/walk_nopk max_unprob max_overlay + 1000 0.1378 0.1378 60 0/ 0/ 60 0 59 + 2000 0.2430 0.1215 60 0/ 0/ 60 0 59 + ^ 2.0x the rows, 1.76x the time + 4000 0.5505 0.1376 60 0/ 0/ 60 0 59 + ^ 4.0x the rows, 3.99x the time + 8000 1.0875 0.1359 60 0/ 0/ 60 0 59 + ^ 8.0x the rows, 7.89x the time + 16000 2.1415 0.1338 60 0/ 0/ 60 0 59 + ^ 16.0x the rows, 15.54x the time +rc=0 + +=== A3 ARENA + NOPUSHDOWN — load 7.44 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 1000 0.1710 0.1710 0.00 60 YES 0 + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 2000 0.3378 0.1689 0.00 60 YES 0 + ^ 2.0x the rows, 1.97x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 4000 0.5463 0.1366 0.00 60 YES 0 + ^ 4.0x the rows, 3.19x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 8000 1.1314 0.1414 0.00 60 YES 0 + ^ 8.0x the rows, 6.61x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 16000 2.1438 0.1340 0.00 60 YES 0 + ^ 16.0x the rows, 12.53x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== A4 ARENA + pushdown ON — load 4.68 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 1000 0.0512 0.0512 0.00 60 YES 0 + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 2000 0.0495 0.0247 0.00 60 YES 0 + ^ 2.0x the rows, 0.97x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 4000 0.0490 0.0123 0.00 60 YES 0 + ^ 4.0x the rows, 0.96x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 8000 0.0512 0.0064 0.00 60 YES 0 + ^ 8.0x the rows, 1.00x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 16000 0.0509 0.0032 0.00 60 YES 0 + ^ 16.0x the rows, 0.99x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +############ BLOCK B (reversed order) ############ + +=== B1 ARENA + pushdown ON — load 5.43 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 1000 0.0523 0.0523 0.00 60 YES 0 + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 2000 0.0492 0.0246 0.00 60 YES 0 + ^ 2.0x the rows, 0.94x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 4000 0.0492 0.0123 0.00 60 YES 0 + ^ 4.0x the rows, 0.94x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 8000 0.0488 0.0061 0.00 60 YES 0 + ^ 8.0x the rows, 0.93x the time + overlay arms: probe_fired=61 walk_unprobeable=0 walk_no_pk_conjunct=0 max_unprobeable_rows=0 max_overlay_len=60 + 16000 0.0506 0.0032 0.00 60 YES 0 + ^ 16.0x the rows, 0.97x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== B2 ARENA + NOPUSHDOWN — load 8.92 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + table rows median ms ms per 1000 fsyncs/upd affected_sum readback_ok neg_ctl + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 1000 0.1798 0.1798 0.00 60 YES 0 + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 2000 0.2847 0.1424 0.00 60 YES 0 + ^ 2.0x the rows, 1.58x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 4000 0.5909 0.1477 0.00 60 YES 0 + ^ 4.0x the rows, 3.29x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 8000 1.1075 0.1384 0.00 60 YES 0 + ^ 8.0x the rows, 6.16x the time + overlay arms: probe_fired=1 walk_unprobeable=0 walk_no_pk_conjunct=60 max_unprobeable_rows=0 max_overlay_len=60 + 16000 2.2993 0.1437 0.00 60 YES 0 + ^ 16.0x the rows, 12.79x the time + +Every row above asserted: affected==1 per statement, read-back equal, negative control==0. +rc=0 + +=== B3 STUB + NOPUSHDOWN — load 4.89 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + + STUB CONFIG (Session::new(), storage: None) — the pre-fc443c4 harness + table rows median ms ms per 1000 affected_sum probe/walk_unprob/walk_nopk max_unprob max_overlay + 1000 0.1410 0.1410 60 0/ 0/ 60 0 59 + 2000 0.2448 0.1224 60 0/ 0/ 60 0 59 + ^ 2.0x the rows, 1.74x the time + 4000 0.5045 0.1261 60 0/ 0/ 60 0 59 + ^ 4.0x the rows, 3.58x the time + 8000 1.0757 0.1345 60 0/ 0/ 60 0 59 + ^ 8.0x the rows, 7.63x the time + 16000 2.2141 0.1384 60 0/ 0/ 60 0 59 + ^ 16.0x the rows, 15.71x the time +rc=0 + +=== B4 STUB + pushdown ON — load 5.35 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + + STUB CONFIG (Session::new(), storage: None) — the pre-fc443c4 harness + table rows median ms ms per 1000 affected_sum probe/walk_unprob/walk_nopk max_unprob max_overlay + 1000 0.0415 0.0415 60 60/ 0/ 0 0 59 + 2000 0.0260 0.0130 60 60/ 0/ 0 0 59 + ^ 2.0x the rows, 0.63x the time + 4000 0.0482 0.0120 60 60/ 0/ 0 0 59 + ^ 4.0x the rows, 1.16x the time + 8000 0.0111 0.0014 60 60/ 0/ 0 0 59 + ^ 8.0x the rows, 0.27x the time + 16000 0.0118 0.0007 60 60/ 0/ 0 0 59 + ^ 16.0x the rows, 0.29x the time +rc=0 + +=== N-SWEEP to 12800 — ARENA — NOPUSHDOWN=0 — load 4.65 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = false (HEAD, predicate pushed) +arm = STAGED + +Q3 — N-SWEEP at a FIXED 4000-row table. Axis = rows staged in the session. + staged N median ms us/update affected!=1 probe/walk_unprob/walk_nopk max_unprob max_overlay + 50 0.0486 48.62 0 50/ 0/ 0 0 49 + 100 0.0523 52.29 0 100/ 0/ 0 0 99 + 200 0.0569 56.92 0 200/ 0/ 0 0 199 + 400 0.0615 61.46 0 400/ 0/ 0 0 399 + 800 0.0762 76.17 0 800/ 0/ 0 0 799 + 1600 0.1065 106.54 0 1600/ 0/ 0 0 1599 + 3200 0.1577 157.71 0 3200/ 0/ 0 0 3199 + 6400 0.2739 273.88 0 6400/ 0/ 0 0 4000 + 12800 0.4946 494.58 0 12800/ 0/ 0 0 4000 +rc=0 + +=== N-SWEEP to 12800 — ARENA — NOPUSHDOWN=1 — load 3.66 === +D170 ADVERSARY PROBE +at ded8fd91afac +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +D170_NOPUSHDOWN = true (PRE-e6958d6 unpredicated scan in branch_update) +arm = STAGED + +Q3 — N-SWEEP at a FIXED 4000-row table. Axis = rows staged in the session. + staged N median ms us/update affected!=1 probe/walk_unprob/walk_nopk max_unprob max_overlay + 50 0.5430 542.96 0 0/ 0/ 50 0 49 + 100 0.5645 564.46 0 0/ 0/ 100 0 99 + 200 0.5581 558.12 0 0/ 0/ 200 0 199 + 400 0.6283 628.29 0 0/ 0/ 400 0 399 + 800 0.5938 593.83 0 0/ 0/ 800 0 799 + 1600 0.6697 669.67 0 0/ 0/ 1600 0 1599 + 3200 0.7838 783.83 0 0/ 0/ 3200 0 3199 + 6400 1.0098 1009.79 0 0/ 0/ 6400 0 4000 + 12800 1.2282 1228.17 0 0/ 0/12800 0 4000 +rc=0 + +finished: 2026-09-23T07:13:33Z load: 5.69 5.36 8.94 +RELEASED d170-adversary2 +D170_ADV2_COMPLETE diff --git a/examples/d170_adv_probe.rs b/examples/d170_adv_probe.rs index 13bc4f16..c4d20506 100644 --- a/examples/d170_adv_probe.rs +++ b/examples/d170_adv_probe.rs @@ -27,7 +27,7 @@ use std::fs::OpenOptions; use std::sync::Arc; use std::time::Instant; -use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::agent_sql::runtime::{d170_overlay_counters, d170_reset_overlay_counters, AgentRuntime}; use ferrodb::agent_sql::AgentOutput; use ferrodb::branch::arena::ArenaPageStore; use ferrodb::branch::table_catalog::TableBranchCatalog; @@ -92,6 +92,83 @@ impl Db { } } +/// The EXACT pre-`fc443c4` stub configuration: no `ArenaPageStore`, no `ServerContext`, and every +/// session from `Session::new()` -- whose implicit `AgentRuntime::new()` is `storage: None` over +/// an in-memory branch catalog. `designated::check` returns `Ok(())` when nothing is designated, +/// so a process that never builds a `ServerContext` runs this without touching the D101 guard. +/// **This mode must never share a process with the arena mode**, or the guard would (correctly) +/// refuse it. +struct StubDb { + catalog: Catalog, + bp: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl StubDb { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true).write(true).create(true).truncate(true) + .open(dir.path().join("p.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + StubDb { catalog, bp, txn, _dir: dir } + } + fn exec(&mut self, sql: &str, s: &mut Session) -> Outcome { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens().unwrap(); + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "{sql}: {:?}", p.errors); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + .unwrap_or_else(|e| panic!("{sql}: {e}")) + } +} + +/// The stub arm of the size sweep, with the same assertions the arena arm carries. +fn stub_sweep(sizes: &[i64], n: usize) { + println!("\n STUB CONFIG (Session::new(), storage: None) — the pre-fc443c4 harness"); + println!(" table rows median ms ms per 1000 affected_sum probe/walk_unprob/walk_nopk max_unprob max_overlay"); + let mut first: Option<(i64, f64)> = None; + for &rows in sizes { + let mut db = StubDb::new(); + let mut s = Session::new(); + db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut s); + for i in 1..=rows { + db.exec(&format!("INSERT INTO t VALUES ({i}, 0);"), &mut s); + } + let mut a = Session::new(); + db.exec("BEGIN AGENT SESSION AS 'd170';", &mut a); + // Same negative control as the arena arm. + let nout = db.exec(&format!("UPDATE t SET v = -1 WHERE id = {};", rows + 1_000_000), &mut a); + assert_eq!(affected(&nout), Some(0), "STUB negative control failed at {rows} rows"); + d170_reset_overlay_counters(); + let mut samples = Vec::new(); + let mut affected_sum = 0usize; + for i in 0..n { + let id = 1 + (i as i64 * 7919) % rows; + let t = Instant::now(); + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {id};"), &mut a); + samples.push(t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(affected(&out), Some(1), "STUB staged UPDATE is a NO-OP at {rows} rows, i={i}"); + affected_sum += 1; + } + let (pf, wu, wn, mu, mo) = d170_overlay_counters(); + let med = median(&mut samples); + println!(" {rows:>10} {med:>9.4} {:>11.4} {affected_sum:>12} {pf:>5}/{wu:>5}/{wn:>5} {mu:>10} {mo:>11}", + med / (rows as f64 / 1000.0)); + if first.is_none() { first = Some((rows, med)); } + if let Some((r0, m0)) = first { + if rows != r0 { + println!(" ^ {:.1}x the rows, {:.2}x the time", rows as f64 / r0 as f64, med / m0); + } + } + } +} + fn build(rows: i64) -> (Db, Session) { let mut db = Db::new(rows); let mut s = db.ctx.session(); @@ -134,19 +211,66 @@ fn main() { let staged_arm = std::env::var("D71_ARM").map(|a| a == "staged").unwrap_or(false); println!("arm = {}", if staged_arm { "STAGED" } else { "PLAIN" }); + if std::env::var("D170_STUB").is_ok() { + let sizes: Vec = std::env::var("D71_SIZES") + .unwrap_or_else(|_| "1000,2000,4000,8000,16000".to_string()) + .split(',').filter_map(|v| v.parse().ok()).collect(); + let n: usize = std::env::var("D71_N").ok().and_then(|v| v.parse().ok()).unwrap_or(60); + stub_sweep(&sizes, n); + return; + } + + // ---- FIRE-CHECK the overlay-arm detector ------------------------------------------------ + // + // A counter reading "probe_fired=60, walks=0" is worth nothing until it has been FORCED to + // report the other two arms. This drives all three on purpose and refuses to continue unless + // each one is observed, so the main sweep's reading is a measurement and not an assumption. + if std::env::var("D170_FIRECHECK").is_ok() { + println!("\nFIRE-CHECK — force each overlay arm and require the counter to see it."); + let (mut db, _s) = build(200); + let mut a = db.ctx.session(); + db.exec("BEGIN AGENT SESSION AS 'd170fc';", &mut a); + + d170_reset_overlay_counters(); + db.exec("UPDATE t SET v = 1 WHERE id = 7;", &mut a); + let (pf, wu, wn, mu, _) = d170_overlay_counters(); + println!(" 1. pk= predicate, nothing demoted -> probe={pf} walk_unprob={wu} walk_nopk={wn} unprob_rows={mu}"); + assert!(pf >= 1 && wu == 0, "the PROBE did not fire on a plain `id = k` staged UPDATE"); + + d170_reset_overlay_counters(); + db.exec("UPDATE t SET v = 2 WHERE v = 999999;", &mut a); + let (pf, wu, wn, mu, _) = d170_overlay_counters(); + println!(" 2. NON-pk predicate -> probe={pf} walk_unprob={wu} walk_nopk={wn} unprob_rows={mu}"); + assert!(wn >= 1, "a predicate with no `pk = literal` conjunct did not take the WALK arm"); + + // D167's door: a PK-MOVING update makes `unprobeable_rows` non-zero, permanently. + db.exec("UPDATE t SET id = 1000000 WHERE id = 5;", &mut a); + d170_reset_overlay_counters(); + db.exec("UPDATE t SET v = 3 WHERE id = 7;", &mut a); + let (pf, wu, wn, mu, _) = d170_overlay_counters(); + println!(" 3. same pk= predicate AFTER a PK move -> probe={pf} walk_unprob={wu} walk_nopk={wn} unprob_rows={mu}"); + assert!(wu >= 1 && mu > 0, + "demotion did not reach the counter: a PK-moving UPDATE should leave unprobeable_rows>0 \ + and force the walk, but probe={pf} walk_unprob={wu} unprob_rows={mu}"); + + println!(" ALL THREE ARMS OBSERVED. The detector discriminates."); + return; + } + // ---- Q3: sweep the number of staged rows at a FIXED table size --------------------------- if std::env::var("D170_NSWEEP").is_ok() { let rows: i64 = std::env::var("D170_ROWS").ok().and_then(|v| v.parse().ok()).unwrap_or(4000); let ns: Vec = std::env::var("D170_NS") - .unwrap_or_else(|_| "50,100,200,400,800,1600,3200".to_string()) + .unwrap_or_else(|_| "50,100,200,400,800,1600,3200,6400,12800".to_string()) .split(',').filter_map(|v| v.parse().ok()).collect(); println!("\nQ3 — N-SWEEP at a FIXED {rows}-row table. Axis = rows staged in the session."); - println!(" staged N median ms us/update affected!=1"); + println!(" staged N median ms us/update affected!=1 probe/walk_unprob/walk_nopk max_unprob max_overlay"); for &n in &ns { let (mut db, mut s) = build(rows); let mut a = db.ctx.session(); if staged_arm { db.exec("BEGIN AGENT SESSION AS 'd170';", &mut a); } let sess: &mut Session = if staged_arm { &mut a } else { &mut s }; + d170_reset_overlay_counters(); let mut samples = Vec::new(); let mut bad = 0usize; for i in 0..n { @@ -158,7 +282,9 @@ fn main() { if affected(&out) != Some(1) { bad += 1; } } let med = median(&mut samples); - println!(" {n:>8} {med:>9.4} {:>9.2} {bad:>11}", med * 1000.0); + let (pf, wu, wn, mu, mo) = d170_overlay_counters(); + println!(" {n:>8} {med:>9.4} {:>9.2} {bad:>11} {pf:>5}/{wu:>5}/{wn:>5} {mu:>10} {mo:>11}", + med * 1000.0); } return; } @@ -186,6 +312,7 @@ fn main() { "NEGATIVE CONTROL FAILED at {rows} rows: an UPDATE on absent key {ncid} reported {neg:?}, \ so `affected` cannot distinguish a no-op from a write and this run is VOID"); + d170_reset_overlay_counters(); let (f0, _) = fsync_counters(); let mut samples = Vec::new(); let mut affected_sum = 0usize; @@ -214,6 +341,8 @@ fn main() { "READ-BACK MISMATCH at {rows} rows, id={last_id}: stored {:?}, wrote {want:?} — the \ statement was COUNTED but the row did not change", got[0][0]); + let (pf, wu, wn, mu, mo) = d170_overlay_counters(); + println!(" overlay arms: probe_fired={pf} walk_unprobeable={wu} walk_no_pk_conjunct={wn} max_unprobeable_rows={mu} max_overlay_len={mo}"); let med = median(&mut samples); println!(" {rows:>10} {med:>9.4} {:>11.4} {:>10.2} {affected_sum:>12} {:>11} {:>7}", med / (rows as f64 / 1000.0), diff --git a/run_d170_adv2.sh b/run_d170_adv2.sh new file mode 100755 index 00000000..d9d0f3e9 --- /dev/null +++ b/run_d170_adv2.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# D170 ADVERSARY, ROUND 2 — the full 2x2 (stub|arena) x (pushdown|no pushdown) in ONE binary, +# plus the overlay-arm counters and an extended D71_N axis. Order reversed between blocks so +# drift cannot favour one side. +export PATH="$HOME/.cargo/bin:$PATH" +cd /Users/idide/wt/ferrodb-d170-adversary || exit 1 +OUT=bench/d170_adversary_probe2.txt +: > "$OUT" + +~/wt/logs/measure-lock.sh acquire d170-adversary2 >>"$OUT" 2>&1 || { + echo "LOCK NOT ACQUIRED — NO NUMBER IS REPORTED" >>"$OUT"; echo "D170_ADV2_COMPLETE" >>"$OUT"; exit 1; } +trap '~/wt/logs/measure-lock.sh release d170-adversary2 >>"'"$OUT"'" 2>&1; echo "D170_ADV2_COMPLETE" >>"'"$OUT"'"' EXIT + +{ + echo "host: $(uname -sm) started: $(date -u +%FT%TZ)" + echo "load: $(uptime | sed 's/.*averages*: *//')" + echo "tree: $(git rev-parse HEAD) (worktree d170-adversary, instrumented)" +} >>"$OUT" 2>&1 + +timeout 1800 cargo build --release --example d170_adv_probe >>"$OUT" 2>&1 || { + echo "BUILD FAILED — NO RESULT" >>"$OUT"; exit 1; } +B=target/release/examples/d170_adv_probe + +echo "" >>"$OUT"; echo "=== FIRE-CHECK: the overlay-arm detector must report all three arms ===" >>"$OUT" +timeout 300 env D71_ARM=staged D170_FIRECHECK=1 "$B" >>"$OUT" 2>&1; echo "rc=$?" >>"$OUT" + +run() { # $1 = label, $2 = STUB|ARENA, $3 = nopushdown 0|1 + echo "" >>"$OUT" + echo "=== $1 — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + if [ "$2" = "STUB" ]; then + timeout 1200 env D71_ARM=staged D170_STUB=1 D170_NOPUSHDOWN=$3 "$B" >>"$OUT" 2>&1 + else + timeout 1200 env D71_ARM=staged D170_NOPUSHDOWN=$3 "$B" >>"$OUT" 2>&1 + fi + echo "rc=$?" >>"$OUT" +} + +echo "" >>"$OUT"; echo "############ BLOCK A (forward order) ############" >>"$OUT" +run "A1 STUB + pushdown ON" STUB 0 +run "A2 STUB + NOPUSHDOWN" STUB 1 +run "A3 ARENA + NOPUSHDOWN" ARENA 1 +run "A4 ARENA + pushdown ON" ARENA 0 + +echo "" >>"$OUT"; echo "############ BLOCK B (reversed order) ############" >>"$OUT" +run "B1 ARENA + pushdown ON" ARENA 0 +run "B2 ARENA + NOPUSHDOWN" ARENA 1 +run "B3 STUB + NOPUSHDOWN" STUB 1 +run "B4 STUB + pushdown ON" STUB 0 + +# The D71_N axis, pushed to 12800 staged rows as requested, with the arm counters beside it. +for NP in 0 1; do + echo "" >>"$OUT" + echo "=== N-SWEEP to 12800 — ARENA — NOPUSHDOWN=$NP — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + timeout 1800 env D71_ARM=staged D170_NSWEEP=1 D170_ROWS=4000 D170_NOPUSHDOWN=$NP "$B" >>"$OUT" 2>&1 + echo "rc=$?" >>"$OUT" +done + +echo "" >>"$OUT" +echo "finished: $(date -u +%FT%TZ) load: $(uptime | sed 's/.*averages*: *//')" >>"$OUT" diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 37155493..9bbdb2f5 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -266,6 +266,34 @@ pub fn row_id_of(row: &[Value]) -> RowId { /// key would probe a key no staged row was ever stored under and MISS a staged version — the one /// wrong answer. On any mismatch this returns `None` and the caller walks the table prefix, which /// is always correct. +/// D170 ADVERSARY INSTRUMENT -- NOT FOR LANDING. Which arm of the overlay each staged statement +/// took. `d170_overlay_counters()` reads them; `d170_reset_overlay_counters()` zeroes them. +pub static D170_PROBE_FIRED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D170_WALK_UNPROBEABLE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D170_WALK_NO_PK_CONJUNCT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D170_MAX_UNPROBEABLE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D170_MAX_OVERLAY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// (probe_fired, walk_unprobeable, walk_no_pk_conjunct, max_unprobeable_rows, max_overlay_len) +pub fn d170_overlay_counters() -> (u64, u64, u64, u64, u64) { + use std::sync::atomic::Ordering::Relaxed; + ( + D170_PROBE_FIRED.load(Relaxed), + D170_WALK_UNPROBEABLE.load(Relaxed), + D170_WALK_NO_PK_CONJUNCT.load(Relaxed), + D170_MAX_UNPROBEABLE.load(Relaxed), + D170_MAX_OVERLAY.load(Relaxed), + ) +} + +pub fn d170_reset_overlay_counters() { + use std::sync::atomic::Ordering::Relaxed; + for c in [&D170_PROBE_FIRED, &D170_WALK_UNPROBEABLE, &D170_WALK_NO_PK_CONJUNCT, + &D170_MAX_UNPROBEABLE, &D170_MAX_OVERLAY] { + c.store(0, Relaxed); + } +} + fn overlay_probe_key(bound: &BoundExpr, pk_type: Option<&DataType>) -> Option { let pk_type = pk_type?; let mut conjuncts = Vec::new(); @@ -2170,6 +2198,18 @@ impl AgentRuntime { // key AND holds column 0 in the declared variant; `unprobeable_rows` says whether // either has ever stopped being true here (see `Workspace`). let probe = if unprobeable_rows == 0 { bound.and_then(|p| overlay_probe_key(p, pk_type.as_ref())) } else { None }; + // D170 ADVERSARY INSTRUMENT -- NOT FOR LANDING. Counts which arm each statement + // took, and WHY the walk was taken when it was, so "the probe fired" is a + // measurement rather than a reading of the source. + if unprobeable_rows != 0 { + D170_WALK_UNPROBEABLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } else if probe.is_none() { + D170_WALK_NO_PK_CONJUNCT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } else { + D170_PROBE_FIRED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + D170_MAX_UNPROBEABLE.fetch_max(unprobeable_rows, std::sync::atomic::Ordering::Relaxed); + D170_MAX_OVERLAY.fetch_max(staged.len() as u64, std::sync::atomic::Ordering::Relaxed); match probe { Some(row) => { if let Some(st) = staged.get(&(tbl.0, row)) { From 8b861c7f3cdb09b9243d190b397741a074ef9a35 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:18:46 -0400 Subject: [PATCH 065/315] D171 prereg: separate W (staged rows) from S (statements) in the staged write path Cut before any measurement. D170 recorded a ~35 ns/staged-row residual with the probe firing on every statement, and listed candidates without checking them. This row checks them. Names the confound in the measurement that raised it: D170's sweep had W == S by construction and took the median of a session growing 0..N, so a PER-STATEMENT term (frame.push_op grows with S and is never trimmed) would present as a per-staged-row cost. Three arms separate them -- A reproduces W==S, B varies S at fixed W, C varies W at fixed S -- and outcome 4 (flat in both, A still rises => the rise is the sweep design and the claim is WITHDRAWN) is registered first-class. Table is 20000 rows so W=12800 cannot saturate, which is the defect that inflated D170's top two points; max_overlay_len == W is a required control in every cell. --- bench/d171_prereg.txt | 104 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 bench/d171_prereg.txt diff --git a/bench/d171_prereg.txt b/bench/d171_prereg.txt new file mode 100644 index 00000000..81f5b5d8 --- /dev/null +++ b/bench/d171_prereg.txt @@ -0,0 +1,104 @@ +D171 PRE-REGISTRATION — WHAT IS THE RESIDUAL PER-STAGED-ROW COST IN THE STAGED WRITE PATH? +Cut 2026-09-23, BEFORE the first measurement. Amendments append-only below the line. + +## WHERE THIS ROW COMES FROM + +`frontier/d170_adversary.md` (round 2, ferrodb branch `d170-adversary`, artifact +`bench/d170_adversary_probe2.txt` at `af69685`) recorded an OPEN QUESTION, deliberately +unattributed: + + At a fixed 4,000-row table, HEAD, arena path, sweeping the session's staged rows N from 50 to + 3200, the per-statement cost rises 48.62 -> 157.71 us -- ~35 ns per staged row -- WHILE + `probe_fired` equals the statement count at every point (3200/3200) and `walk_unprobeable` is 0. + So it is NOT the overlay walk, and D167 could not have seen it: D167 pre-staged W and measured + READS, and its control arm was FLAT (7.59 -> 7.27 us across 32x W). + +Candidates were listed and NOT checked: `PersistentMap` path-copying inserts in `stage_all`, +`base_rows`, frame growth. This row checks them instead of asserting one. + +## THE CONFOUND IN THE MEASUREMENT THAT RAISED THE QUESTION -- STATED FIRST, BECAUSE IT MAY BE +## THE WHOLE ANSWER + +The D170 N-sweep ran a SEPARATE SESSION per N and took the MEDIAN of that session's N samples. +The median of a session that grows from 0 to N therefore samples the accumulation curve at about +N/2, not the cost "at overlay size N". Two different things are confounded in every point: + + W = the number of DISTINCT rows staged (the overlay's size), and + S = the number of STATEMENTS the session has executed. + +In that sweep W == S by construction. They are not the same quantity and they touch different +structures: `ws.rows` / `ws.base_rows` are keyed by row and grow with **W**, while +`ws.frame.push_op(op)` appends per statement and grows with **S**. A per-statement term that +grows with S would present as "~35 ns per staged row" in a sweep where S == W, and would have +nothing to do with the overlay at all. + +⛔ A SECOND, ALREADY-CONFIRMED DEFECT IN THAT SWEEP, which is why its top two points are excluded +here: `examples/d71_point_update_curve.rs:156` is `id = 1 + (i*7919) % rows`, so above N = rows +the keys REPEAT and the overlay SATURATES at the table size. D170 measured `max_overlay_len = +4000` at N = 6400 and N = 12800 on a 4,000-row table. Any W axis must use a table larger than the +largest W. + +## THE INSTRUMENT + +A new example. Fixed table of **20,000 rows** (so W = 12,800 never saturates -- the defect above). +An agent session is PRE-LOADED to the target state, and only THEN is a FIXED number of statements +timed, so the timed window does not itself move the axis. This is D167's method -- pre-stage, then +measure -- applied to WRITES, which is the one direction D167 did not go. + + * PRELOAD: run `U` untimed UPDATEs to reach the target (W, S). + * TIMED WINDOW: exactly **200** UPDATEs, all on keys ALREADY staged, so W does not grow while + timing. Median of the 200 is the statistic. + +## THE THREE ARMS -- the point is to SEPARATE W from S + + ARM A (reproduce) W = S, both swept 100..12800. Should reproduce D170's rise. + ARM B (S alone) W FIXED at 100 distinct keys, S swept 100..12800 by re-updating those + same 100 keys. The overlay NEVER grows; only the statement count does. + ARM C (W alone) S FIXED at 12800 statements, W swept 100..12800 by choosing how many + distinct keys those statements spread over. Statement count constant. + +## THE PREDICTION, FIXED BEFORE THE RUN + + 1. **RISE IN B, FLAT IN C** -> the term is PER-STATEMENT (frame / effect log / escrow), not the + overlay. D170's "~35 ns per staged row" is then the wrong unit for a real effect, and must be + restated per STATEMENT. This is my leading candidate, because `frame.push_op` is the one + structure that grows with S and is never trimmed within a session. + 2. **FLAT IN B, RISE IN C** -> the term really is a function of overlay size, and the candidates + are `ws.rows` / `ws.base_rows` insertion. Then the SHAPE of C decides between O(log W) path + copying and a genuine O(W). + 3. **RISE IN BOTH** -> two terms; report both rates and do not merge them. + 4. **FLAT IN BOTH, while A still rises** -> the rise is an artifact of the D170 sweep's + median-over-a-growing-session design. **The open question is then RETIRED and D170's residual + claim must be WITHDRAWN**, not merely narrowed. This outcome is live and I am naming it + first-class rather than as a footnote. + +## CONTROLS -- required BEFORE any slope is read + + * `probe_fired` MUST equal the timed statement count (200) in every cell, and + `walk_unprobeable` MUST be 0. If the walk is taken, this is D167's demoted regime and a + different question; the cell is VOID. + * `max_overlay_len` MUST equal the intended W in every cell. This is the saturation guard, and + it is the check D170 did not have. + * Every timed statement asserts `AgentOutput::Affected(n) == 1`, and a NEGATIVE CONTROL on an + absent key must report 0 -- so a no-op cannot be read as a fast write. + * A W=100/S=100 cell is common to all three arms and must agree across them. + +## FALSIFIERS -- each says the INSTRUMENT failed, not that the cost is zero + + 1. Any control above failing -> VOID, report as a stop. + 2. Arm A failing to reproduce D170's rise -> the two instruments disagree and NOTHING may be + concluded until that is resolved. Arm A exists for exactly this reason. + 3. All three arms flat -> the effect was never there; say so plainly and withdraw it. + +## WHAT A CONFIRMED RESULT WOULD AND WOULD NOT LICENSE + + OK "A staged UPDATE costs an extra per , measured, on the shipped + arena path at HEAD, with the overlay probe firing on every statement." + NOT any absolute us figure as a property of ferrodb -- this box is contended. + NOT a mechanism. Naming the structure requires reading it or removing it, not inferring it from + a shape. This project has lost rows to exactly that, and D170 is one of them. + +## BOX STATE + +Recorded at launch by the runner: fleet measure lock acquired with `trap ... EXIT` in the same +command, `load_at_acquire`, and `ferrodb::build_provenance()` printed by the harness itself. From 690bb758c566926251420b851118943d29eac15e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:20:36 -0400 Subject: [PATCH 066/315] D171 instrument, and two prereg amendments made BEFORE the first measured run The instrument pre-loads a session to (W distinct keys, S statements) untimed, then times a fixed 200-statement window on keys already staged, so the axis cannot move while timing. Controls are assertions, not prose: probe_fired == 200, walk_unprobeable == 0, unprobeable_rows == 0, max_overlay_len == W (the saturation guard D170 lacked), Affected == 1 per statement, and a negative control on an absent key that must report 0. A1 corrects a prereg error: arm C holds S fixed, so its W=100 cell is (100, 12800) and the shared cell is common to arms A and B only. A2 sets the table to 16000 rows. --- bench/d171_prereg.txt | 16 ++++ examples/d171_stage_cost.rs | 180 ++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 examples/d171_stage_cost.rs diff --git a/bench/d171_prereg.txt b/bench/d171_prereg.txt index 81f5b5d8..0cb66235 100644 --- a/bench/d171_prereg.txt +++ b/bench/d171_prereg.txt @@ -102,3 +102,19 @@ measure -- applied to WRITES, which is the one direction D167 did not go. Recorded at launch by the runner: fleet measure lock acquired with `trap ... EXIT` in the same command, `load_at_acquire`, and `ferrodb::build_provenance()` printed by the harness itself. + +------------------------------------------------------------------------------------------- +AMENDMENTS (append-only) + +A1. 2026-09-23, BEFORE the first measured run, found while building the instrument. + The CONTROLS section says "A W=100/S=100 cell is common to all three arms and must agree + across them." That is WRONG for arm C. Arm C holds S FIXED at 12800, so its W=100 cell is + (W=100, S=12800), not (100,100). The shared cell is common to arms A and B ONLY, and that is + what will be checked. Arm C has no cell in common with A or B by construction; its internal + control is that its own statement count is constant across the sweep, which the printed S + column shows directly. + +A2. Table size set to 16,000 rows rather than 20,000. The requirement is only that the table be + larger than the largest W (12,800) so the overlay cannot saturate; 16,000 satisfies it, is the + largest size D170 used, and cuts ~25% off the per-cell build. `max_overlay_len == W` is + asserted in every cell regardless, so saturation cannot pass silently whatever the table size. diff --git a/examples/d171_stage_cost.rs b/examples/d171_stage_cost.rs new file mode 100644 index 00000000..c2c9ebc4 --- /dev/null +++ b/examples/d171_stage_cost.rs @@ -0,0 +1,180 @@ +//! D171 — separate W (distinct rows staged) from S (statements executed) in the staged write path. +//! +//! Pre-registered in `bench/d171_prereg.txt`, committed BEFORE the first measurement. +//! +//! D170 found a ~35 ns/staged-row residual at HEAD with the overlay probe firing on every +//! statement. Its sweep had `W == S` by construction and took the median of a session growing from +//! 0 to N, so a per-STATEMENT term presents there as a per-staged-row cost. This separates them. +//! +//! Method, after D167: PRE-LOAD the session to the target state untimed, then time a FIXED window +//! of 200 statements on keys that are ALREADY staged, so the axis does not move while timing. +use std::fs::OpenOptions; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::agent_sql::runtime::{d170_overlay_counters, d170_reset_overlay_counters, AgentRuntime}; +use ferrodb::agent_sql::AgentOutput; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::PageStore; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::pgwire::ServerContext; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::MemEffectLog; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + ctx: Arc, + bp: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new(rows: i64) -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true).write(true).create(true).truncate(true) + .open(dir.path().join("p.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let cat = Arc::new(TableBranchCatalog::open_sidecar(&dir.path().join("b.branchcat"), 1).unwrap()); + let branches: Arc = cat; + let arena_base: u32 = ((rows / 40) as u32 + 4096).next_power_of_two(); + let store = Arc::new(ArenaPageStore::new(bp.clone(), branches.clone(), arena_base).unwrap()); + store.checkpoint_to(dir.path().join("p.arena")); + let runtime = Arc::new( + AgentRuntime::with_storage(branches, Arc::new(MemEffectLog::new()), store as Arc) + .expect("attach arena storage"), + ); + let ctx = Arc::new(ServerContext::new(catalog, bp.clone(), txn.clone(), runtime)); + Db { ctx, bp, txn, _dir: dir } + } + fn exec(&mut self, sql: &str, s: &mut Session) -> Outcome { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens().unwrap(); + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "{sql}: {:?}", p.errors); + let mut cat = self.ctx.catalog(); + let out = run(stmts.remove(0), &mut cat, self.bp.clone(), self.txn.clone(), s); + drop(cat); + out.unwrap_or_else(|e| panic!("{sql}: {e}")) + } +} + +fn affected(o: &Outcome) -> Option { + match o { + Outcome::Agent(AgentOutput::Affected(n)) => Some(*n), + Outcome::Affected(n) => Some(*n), + _ => None, + } +} + +fn median(v: &mut Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +/// The i-th key of a set of `w` distinct keys spread across a `rows`-row table. +/// `7919` is prime, so for `w <= rows` these are distinct. +fn key(i: usize, w: usize, rows: i64) -> i64 { + 1 + ((i % w) as i64 * 7919) % rows +} + +const TIMED: usize = 200; + +/// Run one cell: pre-load to (w distinct keys, s statements) untimed, then time TIMED statements +/// on keys already staged. Returns (median ms, probe_fired, walk_unprob, max_unprob, max_overlay). +fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { + let mut db = Db::new(rows); + let mut base = db.ctx.session(); + db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); + for i in 1..=rows { + db.exec(&format!("INSERT INTO t VALUES ({i}, 0);"), &mut base); + } + let mut a = db.ctx.session(); + db.exec("BEGIN AGENT SESSION AS 'd171';", &mut a); + + // Negative control, before anything else: an absent key must report 0, or the `affected` + // instrument cannot tell a no-op from a write and this cell is void. + let nout = db.exec(&format!("UPDATE t SET v = -1 WHERE id = {};", rows + 1_000_000), &mut a); + assert_eq!(affected(&nout), Some(0), "D171 negative control failed at w={w} s={s}"); + + // PRE-LOAD, untimed. `s` statements spread over exactly `w` distinct keys. + for i in 0..s { + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {};", key(i, w, rows)), &mut a); + assert_eq!(affected(&out), Some(1), "preload no-op at w={w} s={s} i={i}"); + } + + // TIMED WINDOW. Keys already staged, so the overlay does not grow while timing. + d170_reset_overlay_counters(); + let mut samples = Vec::with_capacity(TIMED); + for i in 0..TIMED { + let k = key(i, w, rows); + let t = Instant::now(); + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {k};"), &mut a); + samples.push(t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(affected(&out), Some(1), "timed no-op at w={w} s={s} i={i}"); + } + let (pf, wu, _wn, mu, mo) = d170_overlay_counters(); + (median(&mut samples), pf, wu, mu, mo) +} + +fn main() { + println!("D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt"); + println!("{}", ferrodb::build_provenance()); + let rows: i64 = std::env::var("D171_ROWS").ok().and_then(|v| v.parse().ok()).unwrap_or(20000); + let axis: Vec = std::env::var("D171_AXIS") + .unwrap_or_else(|_| "100,400,1600,6400,12800".to_string()) + .split(',').filter_map(|v| v.parse().ok()).collect(); + let arm = std::env::var("D171_ARM").unwrap_or_else(|_| "A".to_string()); + let s_fixed: usize = std::env::var("D171_S").ok().and_then(|v| v.parse().ok()).unwrap_or(12800); + let w_fixed: usize = std::env::var("D171_W").ok().and_then(|v| v.parse().ok()).unwrap_or(100); + + println!("table rows = {rows}, timed window = {TIMED} statements on already-staged keys"); + println!("ARM {arm}: {}", match arm.as_str() { + "A" => "W = S, both swept (reproduces D170's axis)".to_string(), + "B" => format!("W FIXED at {w_fixed}, S swept -- overlay never grows"), + "C" => format!("S FIXED at {s_fixed}, W swept -- statement count constant"), + _ => "unknown".to_string(), + }); + println!("\n axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay"); + let mut first: Option = None; + for &x in &axis { + let (w, s) = match arm.as_str() { + "A" => (x, x), + "B" => (w_fixed, x), + "C" => (x, s_fixed), + _ => panic!("D171_ARM must be A, B or C"), + }; + assert!((w as i64) <= rows, "w={w} exceeds table rows={rows}: the overlay would SATURATE"); + let (med, pf, wu, mu, mo) = cell(rows, w, s); + + // Controls, enforced before the number is printed. + assert_eq!(pf, TIMED as u64, "probe did not fire on every timed statement (w={w} s={s}): {pf}/{TIMED}"); + assert_eq!(wu, 0, "walk_unprobeable non-zero at w={w} s={s}: this is D167's demoted regime, cell VOID"); + assert_eq!(mu, 0, "unprobeable_rows non-zero at w={w} s={s}: cell VOID"); + assert_eq!(mo, w as u64, "max_overlay_len={mo} != intended W={w}: the overlay SATURATED, cell VOID"); + + println!(" {x:>8} {w:>8} {s:>8} {med:>9.4} {:>7.2} {pf:>5} {wu:>11} {mu:>10} {mo:>11}", + med * 1000.0); + if first.is_none() { first = Some(med); } + if let Some(m0) = first { + if x != axis[0] { + println!(" ^ {:.1}x the axis, {:.2}x the time", x as f64 / axis[0] as f64, med / m0); + } + } + } + println!("\nAll cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W,"); + println!("every statement changed exactly 1 row, negative control 0."); +} From b5730964754f1e6a66b61b9589f1918deb096624 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:46:03 -0400 Subject: [PATCH 067/315] D171 amendment A3: my arm B contradicts a closed row, so reproduce theirs on the stub first d71_point_update_curve.txt's 'second wall' section concluded 'no session-size wall and no quadratic term' from a 64x sweep. Arm B measures ~70 ns per statement over 128x with the overlay held fixed. Their sweep ran on the STUB config, so the reproduction is arm B on the stub, and it is registered post-hoc and labelled as such with all three outcomes fixed before the run -- including D2, that my own number is the suspect one if the stub also rises. --- bench/d171_prereg.txt | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bench/d171_prereg.txt b/bench/d171_prereg.txt index 0cb66235..c1674d02 100644 --- a/bench/d171_prereg.txt +++ b/bench/d171_prereg.txt @@ -118,3 +118,34 @@ A2. Table size set to 16,000 rows rather than 20,000. The requirement is only th larger than the largest W (12,800) so the overlay cannot saturate; 16,000 satisfies it, is the largest size D170 used, and cuts ~25% off the per-cell build. `max_overlay_len == W` is asserted in every cell regardless, so saturation cannot pass silently whatever the table size. + +A3. 2026-09-23, after passes 1 and before any stub measurement. ⛔ MY RESULT CONTRADICTS A CLOSED + ROW, AND THE RULE IS TO REPRODUCE THEIRS FIRST. + + `bench/d71_point_update_curve.txt`, section "A SECOND WALL I PROPOSED AND THEN KILLED BY + MEASURING IT", swept updates-in-session at a fixed 4,000-row table and concluded: + "64x the session size costs 1.45x per update. THE HYPOTHESIS IS DEAD. ... There is no + session-size wall and no quadratic term." + Arm B here holds the overlay FIXED and sweeps statements, and measures 61.71 -> 953.92 us over + 128x, ~70 ns per statement. Those disagree. + + ⚠ BEFORE CLAIMING A WALL I MUST ASSUME MY NUMBER IS WRONG AND REPRODUCE THEIRS. Their sweep + ran on the STUB configuration (`Session::new()`, `storage: None`) -- the same D101 defect that + D170 was about -- so the reproduction is arm B RUN ON THE STUB. + + REGISTERED ARM D (post-hoc and labelled as such, NOT a pre-registered confirmation): + arm B's design, `D171_STUB=1`, no `ServerContext` constructed anywhere in the process so + `designated::check` permits it. + + PREDICTIONS: + D1. STUB FLAT, ARENA RISES -> the per-statement term lives in or below the storage layer, + and `d71_point_update_curve.txt`'s "no session-size wall" is CORRECT FOR THE STUB and + FALSE FOR THE SHIPPED PATH. That is the same shape as D170: a stub result that does not + transfer. It would NARROW the mechanism to the storage layer WITHOUT naming a structure, + which is as far as this row is licensed to go. + D2. STUB ALSO RISES -> their measurement and mine disagree on the SAME configuration, and + NOTHING may be concluded until that is resolved. My number is then the suspect one. + D3. BOTH FLAT -> arm B is an artifact of my own instrument; withdraw it. + + ⛔ Whatever D returns, this row STILL MAY NOT NAME A STRUCTURE. "In or below the storage + layer" is a narrowing, not a mechanism. `frame.push_op` remains a CANDIDATE NOT CHECKED. From c097eab228e1452109d595a5d93ef75760668541 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 03:50:35 -0400 Subject: [PATCH 068/315] D171 amendment A4: outcome D2 fired -- the stub rises too, so the disagreement must be resolved A smoke run of arm D shows the stub also rises (~59 ns/statement, overlay fixed), which per A3 makes my number the suspect one. A4 registers arm E before running it: reproduce d71_point_update_curve.txt's sweep exactly (rows=4000, axis 50..3200, W=S, STUB, D170_NOPUSHDOWN=1) and read it both as a ratio and as an additive excess. The candidate resolution is that the disagreement is in the STATISTIC: their baseline was ~708 us of unpredicated O(table) scan, so the same additive term shows as 1.45x there and ~1.8x against my 23 us baseline. Registered with E2 (my arm B is wrong) and E3 (no reproduction, stop) as live outcomes, and with D1 'storage layer only' already dead. --- bench/d171_prereg.txt | 38 ++++++++++++++++++++ examples/d171_stage_cost.rs | 69 ++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/bench/d171_prereg.txt b/bench/d171_prereg.txt index c1674d02..3e1378cf 100644 --- a/bench/d171_prereg.txt +++ b/bench/d171_prereg.txt @@ -149,3 +149,41 @@ A3. 2026-09-23, after passes 1 and before any stub measurement. ⛔ MY RESULT CO ⛔ Whatever D returns, this row STILL MAY NOT NAME A STRUCTURE. "In or below the storage layer" is a narrowing, not a mechanism. `frame.push_op` remains a CANDIDATE NOT CHECKED. + +A4. 2026-09-23. ⛔ OUTCOME D2 FIRED ON A SMOKE RUN: THE STUB ALSO RISES (23.17 -> 40.96 us over + 4x S, overlay held at 100, ~59 ns/statement). A3 says my number is then the suspect one and + nothing may be concluded until the disagreement is resolved. This registers the resolution + BEFORE running it. + + THE CANDIDATE RESOLUTION, and why it is not special pleading: the disagreement may be in the + STATISTIC, not in the measurement. `d71_point_update_curve.txt`'s sweep ran PRE-`e6958d6`, so + its baseline was ~0.708 ms -- dominated by the unpredicated O(table) base scan. My stub runs at + HEAD WITH the pushdown, baseline ~23 us. The SAME additive per-statement term produces a + RATIO of ~1.45x against a 708 us baseline and ~1.8x against a 23 us baseline. If so, their + measurement is CORRECT and their READING of it ("no session-size wall") followed from taking a + ratio over an inflated baseline rather than the additive excess. + + ⚠ This is arithmetic on THEIR PUBLISHED NUMBERS and is therefore a HYPOTHESIS, not a result: + their table gives excesses over N=50 of 163 / 72 / 53 / 101 ns per statement at N = + 400 / 800 / 1600 / 3200 -- the right order of magnitude but very noisy (their load ran 99 -> 23). + It must be MEASURED, not inferred. + + REGISTERED ARM E: reproduce their sweep EXACTLY and read it both ways. + rows = 4000, axis = 50,100,200,400,800,1600,3200, arm A (W = S, as theirs was), STUB config, + `D170_NOPUSHDOWN=1` -- their binary configuration as closely as this tree allows. Then the + same with `D170_NOPUSHDOWN=0`, and arm B (W fixed) in both, so the per-statement term is + separated from the baseline in all four. + + PREDICTIONS: + E1. STUB+NOPUSHDOWN reproduces ~0.7 -> ~1.0 ms AND its additive excess is ~50-100 ns per + statement -> the disagreement is RESOLVED as a statistic, not a measurement. Their + numbers stand; their conclusion "there is no session-size wall" does NOT, and the + correct statement is that the pushdown fix UNMASKED a per-statement term that was always + present and hidden under the O(table) scan. + E2. STUB+NOPUSHDOWN is FLAT in its additive excess -> their conclusion stands on their + configuration and MY arm B is the suspect one. Withdraw arm B and report the conflict. + E3. STUB+NOPUSHDOWN fails to reproduce ~0.7 -> ~1.0 ms at all -> I have not reproduced their + measurement and NOTHING may be concluded. Report as a stop. + + ⛔ STILL NO STRUCTURE MAY BE NAMED. The term is now measured in BOTH configurations, which + RULES OUT "storage layer only" (prediction D1 is dead) and rules nothing IN. diff --git a/examples/d171_stage_cost.rs b/examples/d171_stage_cost.rs index c2c9ebc4..a79d4cb3 100644 --- a/examples/d171_stage_cost.rs +++ b/examples/d171_stage_cost.rs @@ -72,6 +72,69 @@ impl Db { } } +/// ARM D (amendment A3): the EXACT stub configuration `d71_point_update_curve.txt`'s +/// "second wall" sweep ran on -- `Session::new()`, `storage: None`, and **no `ServerContext` +/// constructed anywhere in the process**, which `designated::check` permits ("Returns `Ok(())` +/// when nothing is designated"). Must never share a process with the arena path. +struct StubDb { + catalog: Catalog, + bp: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl StubDb { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true).write(true).create(true).truncate(true) + .open(dir.path().join("p.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + StubDb { catalog, bp, txn, _dir: dir } + } + fn exec(&mut self, sql: &str, s: &mut Session) -> Outcome { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens().unwrap(); + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "{sql}: {:?}", p.errors); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + .unwrap_or_else(|e| panic!("{sql}: {e}")) + } +} + +/// One stub cell, same shape as `cell`: pre-load untimed to (w, s), then time TIMED statements. +fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { + let mut db = StubDb::new(); + let mut base = Session::new(); + db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); + for i in 1..=rows { + db.exec(&format!("INSERT INTO t VALUES ({i}, 0);"), &mut base); + } + let mut a = Session::new(); + db.exec("BEGIN AGENT SESSION AS 'd171';", &mut a); + let nout = db.exec(&format!("UPDATE t SET v = -1 WHERE id = {};", rows + 1_000_000), &mut a); + assert_eq!(affected(&nout), Some(0), "D171 STUB negative control failed at w={w} s={s}"); + for i in 0..s { + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {};", key(i, w, rows)), &mut a); + assert_eq!(affected(&out), Some(1), "STUB preload no-op at w={w} s={s} i={i}"); + } + d170_reset_overlay_counters(); + let mut samples = Vec::with_capacity(TIMED); + for i in 0..TIMED { + let k = key(i, w, rows); + let t = Instant::now(); + let out = db.exec(&format!("UPDATE t SET v = {i} WHERE id = {k};"), &mut a); + samples.push(t.elapsed().as_secs_f64() * 1000.0); + assert_eq!(affected(&out), Some(1), "STUB timed no-op at w={w} s={s} i={i}"); + } + let (pf, wu, _wn, mu, mo) = d170_overlay_counters(); + (median(&mut samples), pf, wu, mu, mo) +} + fn affected(o: &Outcome) -> Option { match o { Outcome::Agent(AgentOutput::Affected(n)) => Some(*n), @@ -142,6 +205,9 @@ fn main() { let w_fixed: usize = std::env::var("D171_W").ok().and_then(|v| v.parse().ok()).unwrap_or(100); println!("table rows = {rows}, timed window = {TIMED} statements on already-staged keys"); + println!("config = {}", if std::env::var("D171_STUB").is_ok() { + "STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3" + } else { "ARENA (ArenaPageStore + ServerContext) — the shipped path" }); println!("ARM {arm}: {}", match arm.as_str() { "A" => "W = S, both swept (reproduces D170's axis)".to_string(), "B" => format!("W FIXED at {w_fixed}, S swept -- overlay never grows"), @@ -158,7 +224,8 @@ fn main() { _ => panic!("D171_ARM must be A, B or C"), }; assert!((w as i64) <= rows, "w={w} exceeds table rows={rows}: the overlay would SATURATE"); - let (med, pf, wu, mu, mo) = cell(rows, w, s); + let stub = std::env::var("D171_STUB").is_ok(); + let (med, pf, wu, mu, mo) = if stub { stub_cell(rows, w, s) } else { cell(rows, w, s) }; // Controls, enforced before the number is printed. assert_eq!(pf, TIMED as u64, "probe did not fire on every timed statement (w={w} s={s}): {pf}/{TIMED}"); From 6970be97c9d68a9dd9f11ccab141ccf433e2e32c Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:02:56 -0400 Subject: [PATCH 069/315] D171 result: the residual is PER-STATEMENT, flat in overlay size, and present in all four configs Registered outcome 1 confirmed in both passes (order reversed between them): arm A (W=S swept) 62.13 -> 987.42 us and 60.63 -> 966.92 us RISES arm B (W FIXED, S swept) 61.71 -> 953.92 us and 64.62 -> 938.96 us RISES -- overlay never grew arm C (S FIXED, W swept) 912.71..1032.42 us across 128x the overlay FLAT, non-monotonic So the term is per STATEMENT, not per staged row. My D170 '~35 ns per staged row' had the wrong UNIT and, because that sweep took the median of a session growing 0..N and so sampled at ~N/2, half the MAGNITUDE. Rate is ~46-107 ns per prior statement; ~70 ns in the pushdown configs. Arm E resolves the contradiction with d71_point_update_curve.txt's closed 'second wall' row, which concluded 'no session-size wall and no quadratic term'. Reproducing its exact sweep (stub, no pushdown, rows=4000, N=50..3200) gives 521.96 -> 859.63 us, a 1.65x ratio against its published 1.45x -- REPRODUCED. But its own additive excess is 101 ns/statement and mine is 107 ns/statement, agreeing to 6%. Their measurement was right; reading it as a RATIO against a ~0.7 ms unpredicated O(table) baseline hid the additive term. The pushdown fix dropped that baseline ~25x and UNMASKED a wall that was always there. Controls are assertions in every cell: walk_unprobeable == 0, unprobeable_rows == 0, max_overlay_len == W, Affected == 1 per statement, negative control 0, and the expected overlay arm gated on the configuration. That gate was initially wrong for NOPUSHDOWN (the probe cannot fire when the predicate is not pushed) and REFUSED three cells rather than printing a number; fixed and re-run as armE2. NO STRUCTURE IS NAMED: frame.push_op remains a candidate not checked. --- bench/d171_stage_cost.txt | 140 +++++++++++++++++++++++++++++++ bench/d171_stage_cost_armE.txt | 142 ++++++++++++++++++++++++++++++++ bench/d171_stage_cost_armE2.txt | 88 ++++++++++++++++++++ examples/d171_stage_cost.rs | 34 +++++--- run_d171.sh | 43 ++++++++++ run_d171e.sh | 51 ++++++++++++ run_d171e2.sh | 20 +++++ 7 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 bench/d171_stage_cost.txt create mode 100644 bench/d171_stage_cost_armE.txt create mode 100644 bench/d171_stage_cost_armE2.txt create mode 100755 run_d171.sh create mode 100755 run_d171e.sh create mode 100755 run_d171e2.sh diff --git a/bench/d171_stage_cost.txt b/bench/d171_stage_cost.txt new file mode 100644 index 00000000..08c0308b --- /dev/null +++ b/bench/d171_stage_cost.txt @@ -0,0 +1,140 @@ +ACQUIRED d171 load_at_acquire=6 +host: Darwin arm64 started: 2026-09-23T07:20:51Z +load: 6.14 6.85 8.41 +tree: 690bb758c566926251420b851118943d29eac15e branch d170-adversary + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d170-adversary) + Finished `release` profile [optimized] target(s) in 9.31s + +######## PASS 1 (A,B,C) ######## + +=== ARM A — pass 1 — load 7.49 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM A: W = S, both swept (reproduces D170's axis) + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 100 0.0621 62.13 200 0 0 100 + 400 400 400 0.0797 79.71 200 0 0 400 + ^ 4.0x the axis, 1.28x the time + 1600 1600 1600 0.1700 170.04 200 0 0 1600 + ^ 16.0x the axis, 2.74x the time + 6400 6400 6400 0.5165 516.54 200 0 0 6400 + ^ 64.0x the axis, 8.31x the time + 12800 12800 12800 0.9874 987.42 200 0 0 12800 + ^ 128.0x the axis, 15.89x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== ARM B — pass 1 — load 5.50 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM B: W FIXED at 100, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 100 0.0617 61.71 200 0 0 100 + 400 100 400 0.0813 81.33 200 0 0 100 + ^ 4.0x the axis, 1.32x the time + 1600 100 1600 0.1655 165.46 200 0 0 100 + ^ 16.0x the axis, 2.68x the time + 6400 100 6400 0.6184 618.38 200 0 0 100 + ^ 64.0x the axis, 10.02x the time + 12800 100 12800 0.9539 953.92 200 0 0 100 + ^ 128.0x the axis, 15.46x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== ARM C — pass 1 — load 5.46 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM C: S FIXED at 12800, W swept -- statement count constant + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 12800 1.0150 1014.96 200 0 0 100 + 400 400 12800 0.9345 934.50 200 0 0 400 + ^ 4.0x the axis, 0.92x the time + 1600 1600 12800 1.0324 1032.42 200 0 0 1600 + ^ 16.0x the axis, 1.02x the time + 6400 6400 12800 0.9407 940.67 200 0 0 6400 + ^ 64.0x the axis, 0.93x the time + 12800 12800 12800 0.9988 998.75 200 0 0 12800 + ^ 128.0x the axis, 0.98x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +######## PASS 2 (C,B,A) ######## + +=== ARM C — pass 2 — load 4.49 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM C: S FIXED at 12800, W swept -- statement count constant + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 12800 0.9533 953.29 200 0 0 100 + 400 400 12800 0.9650 964.96 200 0 0 400 + ^ 4.0x the axis, 1.01x the time + 1600 1600 12800 0.9186 918.58 200 0 0 1600 + ^ 16.0x the axis, 0.96x the time + 6400 6400 12800 0.9127 912.71 200 0 0 6400 + ^ 64.0x the axis, 0.96x the time + 12800 12800 12800 0.9958 995.79 200 0 0 12800 + ^ 128.0x the axis, 1.04x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== ARM B — pass 2 — load 3.80 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM B: W FIXED at 100, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 100 0.0646 64.62 200 0 0 100 + 400 100 400 0.0795 79.50 200 0 0 100 + ^ 4.0x the axis, 1.23x the time + 1600 100 1600 0.1592 159.21 200 0 0 100 + ^ 16.0x the axis, 2.46x the time + 6400 100 6400 0.4958 495.83 200 0 0 100 + ^ 64.0x the axis, 7.67x the time + 12800 100 12800 0.9390 938.96 200 0 0 100 + ^ 128.0x the axis, 14.53x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== ARM A — pass 2 — load 3.79 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at 690bb758c566 +table rows = 16000, timed window = 200 statements on already-staged keys +ARM A: W = S, both swept (reproduces D170's axis) + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 100 100 100 0.0606 60.63 200 0 0 100 + 400 400 400 0.0779 77.92 200 0 0 400 + ^ 4.0x the axis, 1.29x the time + 1600 1600 1600 0.1585 158.54 200 0 0 1600 + ^ 16.0x the axis, 2.62x the time + 6400 6400 6400 0.5023 502.29 200 0 0 6400 + ^ 64.0x the axis, 8.29x the time + 12800 12800 12800 0.9669 966.92 200 0 0 12800 + ^ 128.0x the axis, 15.95x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +finished: 2026-09-23T07:48:55Z load: 3.33 3.60 4.74 +RELEASED d171 +D171_COMPLETE diff --git a/bench/d171_stage_cost_armE.txt b/bench/d171_stage_cost_armE.txt new file mode 100644 index 00000000..6bcd0df4 --- /dev/null +++ b/bench/d171_stage_cost_armE.txt @@ -0,0 +1,142 @@ +ACQUIRED d171e load_at_acquire=7 +host: Darwin arm64 started: 2026-09-23T07:50:55Z +load: 7.65 5.59 5.40 +tree: c097eab228e1452109d595a5d93ef75760668541 branch d170-adversary + +REPRODUCING bench/d71_point_update_curve.txt's 'A SECOND WALL I PROPOSED AND THEN KILLED +BY MEASURING IT': fixed 4000-row table, N updates in session, N = 50..3200. Its numbers: + 50 -> 0.708 ms 100 -> 0.695 200 -> 0.717 400 -> 0.765 + 800 -> 0.762 1600 -> 0.790 3200 -> 1.027 => it read 1.45x and concluded + 'THE HYPOTHESIS IS DEAD ... no session-size wall and no quadratic term.' + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d170-adversary) + Finished `release` profile [optimized] target(s) in 9.66s + +=== E1 STUB + NOPUSHDOWN + arm A (W=S) — THEIR SWEEP, REPRODUCED — load 7.55 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM A: W = S, both swept (reproduces D170's axis) + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + +thread 'main' (287883161) panicked at examples/d171_stage_cost.rs:231:9: +assertion `left == right` failed: probe did not fire on every timed statement (w=50 s=50): 0/200 + left: 0 + right: 200 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +rc=101 + +=== E2 STUB + NOPUSHDOWN + arm B (W=50 fixed) — S alone — load 6.91 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + +thread 'main' (287885539) panicked at examples/d171_stage_cost.rs:231:9: +assertion `left == right` failed: probe did not fire on every timed statement (w=50 s=50): 0/200 + left: 0 + right: 200 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +rc=101 + +=== E3 STUB + pushdown + arm A (W=S) — load 6.23 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM A: W = S, both swept (reproduces D170's axis) + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 50 50 50 0.0174 17.37 200 0 0 50 + 100 100 100 0.0211 21.08 200 0 0 100 + ^ 2.0x the axis, 1.21x the time + 200 200 200 0.0284 28.42 200 0 0 200 + ^ 4.0x the axis, 1.64x the time + 400 400 400 0.0405 40.54 200 0 0 400 + ^ 8.0x the axis, 2.33x the time + 800 800 800 0.0663 66.33 200 0 0 800 + ^ 16.0x the axis, 3.82x the time + 1600 1600 1600 0.1321 132.08 200 0 0 1600 + ^ 32.0x the axis, 7.60x the time + 3200 3200 3200 0.2319 231.92 200 0 0 3200 + ^ 64.0x the axis, 13.35x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== E4 STUB + pushdown + arm B (W=50 fixed) — S alone — load 4.17 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 50 50 50 0.0210 20.96 200 0 0 50 + 100 50 100 0.0207 20.67 200 0 0 50 + ^ 2.0x the axis, 0.99x the time + 200 50 200 0.0281 28.08 200 0 0 50 + ^ 4.0x the axis, 1.34x the time + 400 50 400 0.0402 40.21 200 0 0 50 + ^ 8.0x the axis, 1.92x the time + 800 50 800 0.0693 69.33 200 0 0 50 + ^ 16.0x the axis, 3.31x the time + 1600 50 1600 0.1177 117.67 200 0 0 50 + ^ 32.0x the axis, 5.61x the time + 3200 50 3200 0.2243 224.29 200 0 0 50 + ^ 64.0x the axis, 10.70x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== E5 ARENA + NOPUSHDOWN + arm B (W=50 fixed) — S alone — load 3.61 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = ARENA (ArenaPageStore + ServerContext) — the shipped path +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + +thread 'main' (287934527) panicked at examples/d171_stage_cost.rs:231:9: +assertion `left == right` failed: probe did not fire on every timed statement (w=50 s=50): 0/200 + left: 0 + right: 200 +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +rc=101 + +=== E6 ARENA + pushdown + arm B (W=50 fixed) — S alone — load 3.39 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +table rows = 4000, timed window = 200 statements on already-staged keys +config = ARENA (ArenaPageStore + ServerContext) — the shipped path +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay + 50 50 50 0.0588 58.79 200 0 0 50 + 100 50 100 0.0615 61.50 200 0 0 50 + ^ 2.0x the axis, 1.05x the time + 200 50 200 0.0689 68.87 200 0 0 50 + ^ 4.0x the axis, 1.17x the time + 400 50 400 0.0840 84.00 200 0 0 50 + ^ 8.0x the axis, 1.43x the time + 800 50 800 0.1185 118.46 200 0 0 50 + ^ 16.0x the axis, 2.01x the time + 1600 50 1600 0.1612 161.21 200 0 0 50 + ^ 32.0x the axis, 2.74x the time + 3200 50 3200 0.2902 290.21 200 0 0 50 + ^ 64.0x the axis, 4.94x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +finished: 2026-09-23T07:56:10Z load: 3.55 4.21 4.81 +RELEASED d171e +D171E_COMPLETE diff --git a/bench/d171_stage_cost_armE2.txt b/bench/d171_stage_cost_armE2.txt new file mode 100644 index 00000000..9c8ed80a --- /dev/null +++ b/bench/d171_stage_cost_armE2.txt @@ -0,0 +1,88 @@ +ACQUIRED d171e2 load_at_acquire=3 +host: Darwin arm64 started: 2026-09-23T07:57:17Z +load: 3.67 4.08 4.71 +tree: c097eab228e1452109d595a5d93ef75760668541 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d170-adversary) + Finished `release` profile [optimized] target(s) in 8.73s + +=== E1 STUB + NOPUSHDOWN + arm A (W=S) — THEIR SWEEP, REPRODUCED — load 4.01 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM A: W = S, both swept (reproduces D170's axis) + + axis W S median ms us/stmt probe wlknopk walk_unprob max_unprob max_overlay + 50 50 50 0.5220 521.96 0 200 0 0 50 + 100 100 100 0.5472 547.17 0 200 0 0 100 + ^ 2.0x the axis, 1.05x the time + 200 200 200 0.5563 556.33 0 200 0 0 200 + ^ 4.0x the axis, 1.07x the time + 400 400 400 0.6172 617.25 0 200 0 0 400 + ^ 8.0x the axis, 1.18x the time + 800 800 800 0.6170 617.04 0 200 0 0 800 + ^ 16.0x the axis, 1.18x the time + 1600 1600 1600 0.6823 682.33 0 200 0 0 1600 + ^ 32.0x the axis, 1.31x the time + 3200 3200 3200 0.8596 859.63 0 200 0 0 3200 + ^ 64.0x the axis, 1.65x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== E2 STUB + NOPUSHDOWN + arm B (W=50 fixed) — S alone — load 2.46 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +table rows = 4000, timed window = 200 statements on already-staged keys +config = STUB (Session::new(), storage: None, no ServerContext) — ARM D, amendment A3 +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe wlknopk walk_unprob max_unprob max_overlay + 50 50 50 0.5211 521.12 0 200 0 0 50 + 100 50 100 0.5133 513.33 0 200 0 0 50 + ^ 2.0x the axis, 0.99x the time + 200 50 200 0.5318 531.83 0 200 0 0 50 + ^ 4.0x the axis, 1.02x the time + 400 50 400 0.5440 543.96 0 200 0 0 50 + ^ 8.0x the axis, 1.04x the time + 800 50 800 0.5830 582.96 0 200 0 0 50 + ^ 16.0x the axis, 1.12x the time + 1600 50 1600 0.6485 648.54 0 200 0 0 50 + ^ 32.0x the axis, 1.24x the time + 3200 50 3200 0.7201 720.08 0 200 0 0 50 + ^ 64.0x the axis, 1.38x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +=== E5 ARENA + NOPUSHDOWN + arm B (W=50 fixed) — S alone — load 3.27 === +D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt +at c097eab228e1 +DIRTY (uncommitted changes — this commit does NOT describe the code that ran) +table rows = 4000, timed window = 200 statements on already-staged keys +config = ARENA (ArenaPageStore + ServerContext) — the shipped path +ARM B: W FIXED at 50, S swept -- overlay never grows + + axis W S median ms us/stmt probe wlknopk walk_unprob max_unprob max_overlay + 50 50 50 0.6462 646.21 0 200 0 0 50 + 100 50 100 0.6102 610.25 0 200 0 0 50 + ^ 2.0x the axis, 0.94x the time + 200 50 200 0.6171 617.08 0 200 0 0 50 + ^ 4.0x the axis, 0.95x the time + 400 50 400 0.6382 638.21 0 200 0 0 50 + ^ 8.0x the axis, 0.99x the time + 800 50 800 0.6562 656.21 0 200 0 0 50 + ^ 16.0x the axis, 1.02x the time + 1600 50 1600 0.6670 667.04 0 200 0 0 50 + ^ 32.0x the axis, 1.03x the time + 3200 50 3200 0.7914 791.37 0 200 0 0 50 + ^ 64.0x the axis, 1.22x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +rc=0 + +finished: 2026-09-23T08:02:03Z load: 3.90 3.71 4.32 +RELEASED d171e2 +D171E2_COMPLETE diff --git a/examples/d171_stage_cost.rs b/examples/d171_stage_cost.rs index a79d4cb3..4ff4bf9b 100644 --- a/examples/d171_stage_cost.rs +++ b/examples/d171_stage_cost.rs @@ -107,7 +107,7 @@ impl StubDb { } /// One stub cell, same shape as `cell`: pre-load untimed to (w, s), then time TIMED statements. -fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { +fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { let mut db = StubDb::new(); let mut base = Session::new(); db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); @@ -131,8 +131,8 @@ fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { samples.push(t.elapsed().as_secs_f64() * 1000.0); assert_eq!(affected(&out), Some(1), "STUB timed no-op at w={w} s={s} i={i}"); } - let (pf, wu, _wn, mu, mo) = d170_overlay_counters(); - (median(&mut samples), pf, wu, mu, mo) + let (pf, wu, wn, mu, mo) = d170_overlay_counters(); + (median(&mut samples), pf, wu, wn, mu, mo) } fn affected(o: &Outcome) -> Option { @@ -158,7 +158,7 @@ const TIMED: usize = 200; /// Run one cell: pre-load to (w distinct keys, s statements) untimed, then time TIMED statements /// on keys already staged. Returns (median ms, probe_fired, walk_unprob, max_unprob, max_overlay). -fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { +fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { let mut db = Db::new(rows); let mut base = db.ctx.session(); db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); @@ -189,8 +189,8 @@ fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64) { samples.push(t.elapsed().as_secs_f64() * 1000.0); assert_eq!(affected(&out), Some(1), "timed no-op at w={w} s={s} i={i}"); } - let (pf, wu, _wn, mu, mo) = d170_overlay_counters(); - (median(&mut samples), pf, wu, mu, mo) + let (pf, wu, wn, mu, mo) = d170_overlay_counters(); + (median(&mut samples), pf, wu, wn, mu, mo) } fn main() { @@ -214,7 +214,7 @@ fn main() { "C" => format!("S FIXED at {s_fixed}, W swept -- statement count constant"), _ => "unknown".to_string(), }); - println!("\n axis W S median ms us/stmt probe walk_unprob max_unprob max_overlay"); + println!("\n axis W S median ms us/stmt probe wlknopk walk_unprob max_unprob max_overlay"); let mut first: Option = None; for &x in &axis { let (w, s) = match arm.as_str() { @@ -225,15 +225,29 @@ fn main() { }; assert!((w as i64) <= rows, "w={w} exceeds table rows={rows}: the overlay would SATURATE"); let stub = std::env::var("D171_STUB").is_ok(); - let (med, pf, wu, mu, mo) = if stub { stub_cell(rows, w, s) } else { cell(rows, w, s) }; + let (med, pf, wu, wn, mu, mo) = if stub { stub_cell(rows, w, s) } else { cell(rows, w, s) }; // Controls, enforced before the number is printed. - assert_eq!(pf, TIMED as u64, "probe did not fire on every timed statement (w={w} s={s}): {pf}/{TIMED}"); + // + // ⚠ The EXPECTED ARM depends on the configuration, and the first version of this got it + // wrong: under `D170_NOPUSHDOWN=1` the predicate is not pushed, so `bound` is `None`, so + // `overlay_probe_key` cannot fire and the no-pk-conjunct walk is taken BY CONSTRUCTION. + // Requiring `probe == 200` there refused three correct cells. It refused rather than + // printing a number, which is the right direction to fail, but it was the wrong gate. + // What must hold in BOTH configurations is that this is NOT D167's demoted regime. + let nopush = std::env::var("D170_NOPUSHDOWN").map(|v| v == "1").unwrap_or(false); + if nopush { + assert_eq!(wn, TIMED as u64, + "NOPUSHDOWN: expected the no-pk-conjunct walk on every timed statement (w={w} s={s}): {wn}/{TIMED}"); + assert_eq!(pf, 0, "NOPUSHDOWN: the probe fired {pf} times, so the predicate WAS pushed (w={w} s={s})"); + } else { + assert_eq!(pf, TIMED as u64, "probe did not fire on every timed statement (w={w} s={s}): {pf}/{TIMED}"); + } assert_eq!(wu, 0, "walk_unprobeable non-zero at w={w} s={s}: this is D167's demoted regime, cell VOID"); assert_eq!(mu, 0, "unprobeable_rows non-zero at w={w} s={s}: cell VOID"); assert_eq!(mo, w as u64, "max_overlay_len={mo} != intended W={w}: the overlay SATURATED, cell VOID"); - println!(" {x:>8} {w:>8} {s:>8} {med:>9.4} {:>7.2} {pf:>5} {wu:>11} {mu:>10} {mo:>11}", + println!(" {x:>8} {w:>8} {s:>8} {med:>9.4} {:>7.2} {pf:>5} {wn:>6} {wu:>11} {mu:>10} {mo:>11}", med * 1000.0); if first.is_none() { first = Some(med); } if let Some(m0) = first { diff --git a/run_d171.sh b/run_d171.sh new file mode 100755 index 00000000..57c68f82 --- /dev/null +++ b/run_d171.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# D171 — separate W (staged rows) from S (statements). Prereg: bench/d171_prereg.txt (8b861c7, +# amended 690bb75, both BEFORE any measurement here). +export PATH="$HOME/.cargo/bin:$PATH" +cd /Users/idide/wt/ferrodb-d170-adversary || exit 1 +OUT=bench/d171_stage_cost.txt +: > "$OUT" + +~/wt/logs/measure-lock.sh acquire d171 >>"$OUT" 2>&1 || { + echo "LOCK NOT ACQUIRED — NO NUMBER IS REPORTED" >>"$OUT"; echo "D171_COMPLETE" >>"$OUT"; exit 1; } +trap '~/wt/logs/measure-lock.sh release d171 >>"'"$OUT"'" 2>&1; echo "D171_COMPLETE" >>"'"$OUT"'"' EXIT + +{ + echo "host: $(uname -sm) started: $(date -u +%FT%TZ)" + echo "load: $(uptime | sed 's/.*averages*: *//')" + echo "tree: $(git rev-parse HEAD) branch d170-adversary" +} >>"$OUT" 2>&1 + +timeout 1800 cargo build --release --example d171_stage_cost >>"$OUT" 2>&1 || { + echo "BUILD FAILED — NO RESULT" >>"$OUT"; exit 1; } +B=target/release/examples/d171_stage_cost +ROWS=16000 +AXIS=100,400,1600,6400,12800 + +arm() { # $1 = arm letter, $2 = pass label + echo "" >>"$OUT" + echo "=== ARM $1 — pass $2 — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + timeout 3600 env D171_ROWS=$ROWS D171_AXIS=$AXIS D171_ARM=$1 D171_W=100 D171_S=12800 "$B" >>"$OUT" 2>&1 + echo "rc=$?" >>"$OUT" +} + +# Order reversed between passes so drift cannot favour an arm. +echo "" >>"$OUT"; echo "######## PASS 1 (A,B,C) ########" >>"$OUT" +arm A 1 +arm B 1 +arm C 1 +echo "" >>"$OUT"; echo "######## PASS 2 (C,B,A) ########" >>"$OUT" +arm C 2 +arm B 2 +arm A 2 + +echo "" >>"$OUT" +echo "finished: $(date -u +%FT%TZ) load: $(uptime | sed 's/.*averages*: *//')" >>"$OUT" diff --git a/run_d171e.sh b/run_d171e.sh new file mode 100755 index 00000000..2a13d005 --- /dev/null +++ b/run_d171e.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# D171 arms D and E — reproduce d71_point_update_curve.txt's "second wall" sweep exactly, and +# read it as an ADDITIVE EXCESS as well as a ratio. Registered in bench/d171_prereg.txt A3/A4, +# both committed BEFORE this run. +export PATH="$HOME/.cargo/bin:$PATH" +cd /Users/idide/wt/ferrodb-d170-adversary || exit 1 +OUT=bench/d171_stage_cost_armE.txt +: > "$OUT" + +~/wt/logs/measure-lock.sh acquire d171e >>"$OUT" 2>&1 || { + echo "LOCK NOT ACQUIRED — NO NUMBER IS REPORTED" >>"$OUT"; echo "D171E_COMPLETE" >>"$OUT"; exit 1; } +trap '~/wt/logs/measure-lock.sh release d171e >>"'"$OUT"'" 2>&1; echo "D171E_COMPLETE" >>"'"$OUT"'"' EXIT + +{ + echo "host: $(uname -sm) started: $(date -u +%FT%TZ)" + echo "load: $(uptime | sed 's/.*averages*: *//')" + echo "tree: $(git rev-parse HEAD) branch d170-adversary" + echo "" + echo "REPRODUCING bench/d71_point_update_curve.txt's 'A SECOND WALL I PROPOSED AND THEN KILLED" + echo "BY MEASURING IT': fixed 4000-row table, N updates in session, N = 50..3200. Its numbers:" + echo " 50 -> 0.708 ms 100 -> 0.695 200 -> 0.717 400 -> 0.765" + echo " 800 -> 0.762 1600 -> 0.790 3200 -> 1.027 => it read 1.45x and concluded" + echo " 'THE HYPOTHESIS IS DEAD ... no session-size wall and no quadratic term.'" +} >>"$OUT" 2>&1 + +timeout 1800 cargo build --release --example d171_stage_cost >>"$OUT" 2>&1 || { + echo "BUILD FAILED — NO RESULT" >>"$OUT"; exit 1; } +B=target/release/examples/d171_stage_cost +AXIS=50,100,200,400,800,1600,3200 + +cell() { # $1 label, $2 STUB(1/0), $3 NOPUSHDOWN(1/0), $4 arm, $5 rows + echo "" >>"$OUT" + echo "=== $1 — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + if [ "$2" = "1" ]; then + timeout 3600 env D171_STUB=1 D171_ROWS=$5 D171_AXIS=$AXIS D171_ARM=$4 D171_W=50 D170_NOPUSHDOWN=$3 "$B" >>"$OUT" 2>&1 + else + timeout 3600 env D171_ROWS=$5 D171_AXIS=$AXIS D171_ARM=$4 D171_W=50 D170_NOPUSHDOWN=$3 "$B" >>"$OUT" 2>&1 + fi + echo "rc=$?" >>"$OUT" +} + +# E: their configuration exactly (stub + no pushdown), then each variable flipped one at a time. +cell "E1 STUB + NOPUSHDOWN + arm A (W=S) — THEIR SWEEP, REPRODUCED" 1 1 A 4000 +cell "E2 STUB + NOPUSHDOWN + arm B (W=50 fixed) — S alone" 1 1 B 4000 +cell "E3 STUB + pushdown + arm A (W=S)" 1 0 A 4000 +cell "E4 STUB + pushdown + arm B (W=50 fixed) — S alone" 1 0 B 4000 +cell "E5 ARENA + NOPUSHDOWN + arm B (W=50 fixed) — S alone" 0 1 B 4000 +cell "E6 ARENA + pushdown + arm B (W=50 fixed) — S alone" 0 0 B 4000 + +echo "" >>"$OUT" +echo "finished: $(date -u +%FT%TZ) load: $(uptime | sed 's/.*averages*: *//')" >>"$OUT" diff --git a/run_d171e2.sh b/run_d171e2.sh new file mode 100755 index 00000000..13597a1d --- /dev/null +++ b/run_d171e2.sh @@ -0,0 +1,20 @@ +#!/bin/bash +export PATH="$HOME/.cargo/bin:$PATH" +cd /Users/idide/wt/ferrodb-d170-adversary || exit 1 +OUT=bench/d171_stage_cost_armE2.txt +: > "$OUT" +~/wt/logs/measure-lock.sh acquire d171e2 >>"$OUT" 2>&1 || { + echo "LOCK NOT ACQUIRED — NO NUMBER IS REPORTED" >>"$OUT"; echo "D171E2_COMPLETE" >>"$OUT"; exit 1; } +trap '~/wt/logs/measure-lock.sh release d171e2 >>"'"$OUT"'" 2>&1; echo "D171E2_COMPLETE" >>"'"$OUT"'"' EXIT +{ echo "host: $(uname -sm) started: $(date -u +%FT%TZ)" + echo "load: $(uptime | sed 's/.*averages*: *//')" + echo "tree: $(git rev-parse HEAD)"; } >>"$OUT" 2>&1 +timeout 1800 cargo build --release --example d171_stage_cost >>"$OUT" 2>&1 || { echo "BUILD FAILED" >>"$OUT"; exit 1; } +B=target/release/examples/d171_stage_cost +AXIS=50,100,200,400,800,1600,3200 +c() { echo "" >>"$OUT"; echo "=== $1 — load $(uptime | sed 's/.*averages*: *//' | awk '{print $1}') ===" >>"$OUT" + shift; timeout 3600 env "$@" D171_ROWS=4000 D171_AXIS=$AXIS D171_W=50 "$B" >>"$OUT" 2>&1; echo "rc=$?" >>"$OUT"; } +c "E1 STUB + NOPUSHDOWN + arm A (W=S) — THEIR SWEEP, REPRODUCED" D171_STUB=1 D170_NOPUSHDOWN=1 D171_ARM=A +c "E2 STUB + NOPUSHDOWN + arm B (W=50 fixed) — S alone" D171_STUB=1 D170_NOPUSHDOWN=1 D171_ARM=B +c "E5 ARENA + NOPUSHDOWN + arm B (W=50 fixed) — S alone" D170_NOPUSHDOWN=1 D171_ARM=B +echo "" >>"$OUT"; echo "finished: $(date -u +%FT%TZ) load: $(uptime | sed 's/.*averages*: *//')" >>"$OUT" From 5030fc538d2bfaaca29da770fcb6dfa3cc2fbe2e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:06:47 -0400 Subject: [PATCH 070/315] D172 prereg: which S-keyed structure carries D171's ~70 ns per statement -- registered, NOT run Needs the measure lock, which is committed to d170-land for its landing, so this is staged and left. Records the two exclusions that already hold so the next reader does not re-spend them: arm C kills the W-keyed structures, d138_index_slope kills the SEARCH over the effect log's frames -- with both of d138's limits stated (counts not durations; its axis is the log's frame count, not the ops Vec inside one open TxnFrame). Three surviving candidates, all S-keyed. The design puts a REMOVAL arm above the counting arm, because an allocation count narrows but cannot name a structure, and marks the removal arm as deliberately correctness-breaking so no later reader mistakes it for a result about ferrodb. Registers outcome 2 -- allocations flat while the duration rises, i.e. the candidate list is simply wrong -- as a first-class outcome rather than a footnote. --- bench/d172_prereg.txt | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 bench/d172_prereg.txt diff --git a/bench/d172_prereg.txt b/bench/d172_prereg.txt new file mode 100644 index 00000000..b16bd3bc --- /dev/null +++ b/bench/d172_prereg.txt @@ -0,0 +1,75 @@ +D172 PRE-REGISTRATION — WHICH S-KEYED STRUCTURE CARRIES D171's ~70 ns PER STATEMENT? +Cut 2026-09-23, BEFORE any measurement. Amendments append-only below the line. + +⚠ THIS ROW IS REGISTERED BUT NOT RUN. It needs the fleet measure lock, which is committed to +`d170-land` for its landing. Written now so the next reader starts from a design rather than a +blank page. Whoever runs it: re-read the falsifiers before the first number, and append an +amendment if the design changes. + +## WHAT IS ALREADY ESTABLISHED, so this row does not re-measure it + +`frontier/d171_session_length_wall.md` (artifacts `bench/d171_stage_cost*.txt`, commit `6970be9`): +an agent session's per-statement cost grows linearly in the number of statements it has already +executed, ~46-107 ns per prior statement (~70 ns in the shipped configuration), **flat in the +overlay size across 128x**, present in stub and arena and with and without the D71 pushdown. + +Two exclusions already hold, each behind a fire-checked measurement — DO NOT SPEND AN ARM ON EITHER: + + * **W-keyed structures are out.** D171 arm C holds S fixed, sweeps W 128x, and is flat. That + excludes `ws.rows` and `ws.base_rows`, including the `PersistentMap` path-copying insert that + D170 led with. + * **SEARCH over the effect log's frames is out.** `bench/d138_index_slope/` measures scan = 0 + across a log growing to 1750+ frames, and licenses that zero with its own fire-check + (`d138_ship_raw.txt:16-17`). Its arm (2), "1 session x 200 writes", is this regime. + ⛔ But D138 measured COUNTS, not durations ("⛔ COUNTS ONLY. No timing is reported and none was + taken"), so it excludes SEARCHING and not GROWING — and its axis is the effect LOG's frame + count, NOT the `ops` Vec inside one open `TxnFrame`, which is a different structure its sweep + never reaches. + +## THE SURVIVING CANDIDATES — all keyed by S, none checked + + C1. `TxnFrame::push_op` (`src/tel/frame.rs:49`) appending to `ops` on the ONE open frame held in + the live workspace (`runtime.rs:360`). Append-only, never trimmed within a session. + C2. The effect-log `frames` Vec's own ALLOCATION growth — distinct from the search D138 zeroed. + C3. `record_write_scan`'s per-statement read-set retention. + +## THE INSTRUMENT — COUNTS FIRST, and a REMOVAL arm, because a shape cannot name a structure + +This row is licensed to name a structure ONLY via (b) or (c). (a) alone narrows, it does not name. + + (a) **ALLOCATION COUNTS, not durations.** A counting global allocator, sampled around each + statement: allocations and bytes per statement, swept over S. Follow D129/D138's discipline + — report integers, and FIRE-CHECK the counter by forcing a known allocation before trusting + any zero. A candidate whose allocation count is FLAT in S cannot be carrying a term linear + in S. + (b) **REMOVAL.** Behind an env flag, make each candidate's growth not happen — e.g. `push_op` + returns without pushing — and re-run D171 arm B. ⛔ THIS BREAKS CORRECTNESS BY CONSTRUCTION; + it is an instrument that must never land, and the run must assert it is measuring a + deliberately broken binary so a later reader cannot mistake it for a result about ferrodb. + (c) **READING the structure**, which is free and should come first. + +## THE PREDICTION, FIXED BEFORE THE RUN + + 1. Allocations per statement RISE with S and removing C1 flattens D171 arm B -> C1 named. + 2. Allocations FLAT in S while the duration still rises -> it is not allocation at all; memcpy + on `Vec` regrowth, cache behaviour, or something not yet listed. Report that the candidate + list was WRONG rather than forcing a fit. + 3. Removing C1 leaves the slope intact -> C1 is dead whatever the counts say. The removal arm + OUTRANKS the counting arm. + +## FALSIFIERS — each says the INSTRUMENT failed, not that the cost is zero + + 1. The allocation counter not fire-checked, or reading 0 where an allocation is known to occur + -> VOID. + 2. The removal arm changing `Affected`, the read-back, or the negative control -> it changed + more than the one structure; VOID. + 3. D171 arm B not reproducing on the same tree before any of this -> stop; the baseline moved. + +## WHAT A CONFIRMED RESULT WOULD AND WOULD NOT LICENSE + + OK "The per-statement term is , shown by removing it and by its allocation count." + NOT a fix. Naming the structure and deciding whether an append-only per-session frame SHOULD be + trimmed are different questions, and the second one has correctness consequences — + `tel/log.rs` states that stored index positions are valid ONLY because nothing prunes, + compacts or reorders `frames`. **Anything that trims must reckon with that invariant, and + it is not a tuning knob.** From cebb0c94f29db4b1c94f76e9d9c1676be6c70df8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:18:25 -0400 Subject: [PATCH 071/315] D172 amendment B1/B2: the site is located by READING -- runtime.rs:3069 clones the whole frame per statement stage_all runs once per statement and ends with ws.frame.clone(). ws.frame.ops is append-only and never trimmed within a session, so at statement S the clone is Theta(S). And the consumer no longer needs it: MemEffectLog::append's Grew path -- taken by every statement after the first -- reads only extend_from_slice(&frame.ops[ops..]), the new tail. Its own comment names this exact cost as one it fixed ('replacing cloned the whole frame on every statement of a task, which is quadratic in the ops it ran'). The storage half of that fix landed; the caller half did not. Fits every D171 constraint without adjustment, and matches the finish_grow/do_reserve_and_handle profile d71_point_update_curve.txt took and then discarded. B2 registers a PHASE TIMER rather than a removal, because append's Grew path indexes ops[..] by absolute position and a truncated frame would mis-index silently instead of failing. Outcome B2b -- it is A term but a small fraction -- is registered as live. --- bench/d172_prereg.txt | 54 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/bench/d172_prereg.txt b/bench/d172_prereg.txt index b16bd3bc..38ed20b1 100644 --- a/bench/d172_prereg.txt +++ b/bench/d172_prereg.txt @@ -73,3 +73,57 @@ This row is licensed to name a structure ONLY via (b) or (c). (a) alone narrows, `tel/log.rs` states that stored index positions are valid ONLY because nothing prunes, compacts or reorders `frames`. **Anything that trims must reckon with that invariant, and it is not a tuning knob.** + +------------------------------------------------------------------------------------------- +AMENDMENTS (append-only) + +B1. 2026-09-23, BEFORE any D172 measurement. THE SITE IS LOCATED BY READING, which this prereg's + step (c) said should come first and which cost ten minutes. + + `src/agent_sql/runtime.rs:3069`, the last statement inside `stage_all`'s state-lock block: + + ws.frame.clone() + + `stage_all` runs ONCE PER STATEMENT (`branch_update:2670`, `branch_delete:2840`, + `stage:2861`). `ws.frame` is a `TxnFrame` whose `ops: Vec` is append-only and never + trimmed within a session, so at statement S it holds ~S ops. **Cloning it is Theta(S), once + per statement.** + + ⭐ AND THE CONSUMER NO LONGER NEEDS IT. `MemEffectLog::append` (`src/tel/log.rs:389`) takes + `&TxnFrame`, and on the `Reappend::Grew` path — the path every statement after the first takes + — it reads ONLY THE NEW TAIL: + + stored.ops.extend_from_slice(&frame.ops[ops..]); + + Its own comment names the very cost this row is chasing, as something it FIXED: + "The open frame grew. **Extended rather than replaced**, and the difference is not + performance (though it is also that: replacing cloned the whole frame on every statement of + a task, which is quadratic in the ops it ran)." + ⇒ The STORAGE half of that fix landed. The CALLER still materialises the full O(S) clone to + hand to it. This is one fix with two halves and one of them landed. + + FITS EVERY D171 CONSTRAINT WITHOUT ADJUSTMENT: linear in S (clone of an S-length Vec); flat in + W (arm C — the Vec is keyed by ops, not by distinct rows); present in stub AND arena (the + clone is above the storage layer); present with and without pushdown; and it is a `Vec` + reallocation-and-copy, which is what `d71_point_update_curve.txt`'s discarded profile saw as + `RawVecInner::finish_grow` / `do_reserve_and_handle`. + + ⛔ THIS IS NOT YET CONFIRMED. A site that COULD produce the shape is not the site that DOES. + +B2. THE CONFIRMATION ARM, registered before running it. **A PHASE TIMER, NOT A REMOVAL.** + Removing the clone cannot be done without breaking `append`'s `Grew` path, which indexes + `frame.ops[ops..]` by ABSOLUTE position and so requires the full frame; a truncated frame + would silently mis-index rather than fail. So the non-destructive instrument is used instead: + + time `ws.frame.clone()` alone, and pair it with the INTEGER `ws.frame.ops.len()` at the same + moment — a timer paired with a counter, per this project's rule. + + PREDICTIONS: + B2a. clone_ns rises LINEARLY with ops.len(), and clone_ns at S accounts for most of D171's + measured per-statement excess at S -> the site is CONFIRMED as the dominant term. + B2b. clone_ns rises linearly but is a SMALL fraction of the excess -> it is A term, not THE + term; report the fraction and keep looking. This is a live outcome. + B2c. clone_ns FLAT in ops.len() -> the reading is wrong somewhere; report and stop. + + FALSIFIER: if `ops.len()` does not itself grow ~1 per statement in this workload, the axis is + not what I think and the fit is meaningless — so `ops.len()` is REPORTED, never assumed. From 2b50182d29259596854a87a7a85d76d73ce5bf56 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:40:42 -0400 Subject: [PATCH 072/315] D172 result: the phase table CLOSES at 100.0% -- three Theta(S) sites, one design decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slopes over S=100..12800, arm B (W fixed at 100, so nothing here is overlay): total 67.61 ns/statement clone 28.89 42.7% stage_all's ws.frame.clone() append 12.08 17.9% log.append -> classify -> frame_eq + extends, op by op drop 26.63 39.4% dropping that clone three summed 67.60 100.0% unattributed 0.02 ns Each phase's per-op rate is flat at the large end (28.7 / 12.0 / 26.3 ns per op at S=12800), which is what Theta(S) requires. All three are one design decision, stated at log.rs:284-286: 'because stage_all re-appends the open frame once per statement'. The statement materialises a cumulative frame, hands it to a consumer that compares it op-by-op, and discards it. ⭐ AND THE STORAGE HALF OF THIS WAS ALREADY FIXED. MemEffectLog::append extends rather than replaces precisely so it need not clone -- its own comment says replacing 'cloned the whole frame on every statement of a task, which is quadratic in the ops it ran' -- and its Grew path reads only &frame.ops[ops..]. The caller still builds the full clone to hand it. One fix, two halves, one landed. Registered outcomes fired in order and are recorded as such: B2b (clone is A term not THE term, 43%), B3b (table still open, gap reported as a gap and NOT distributed), B4a (closes). --- bench/d172_phase_table.txt | 59 +++++++++++++++++++++++++++++++++ bench/d172_prereg.txt | 66 +++++++++++++++++++++++++++++++++++++ examples/d171_stage_cost.rs | 34 ++++++++++++++----- src/agent_sql/runtime.rs | 51 ++++++++++++++++++++++++++-- 4 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 bench/d172_phase_table.txt diff --git a/bench/d172_phase_table.txt b/bench/d172_phase_table.txt new file mode 100644 index 00000000..ee1daaaa --- /dev/null +++ b/bench/d172_phase_table.txt @@ -0,0 +1,59 @@ +D172 — the per-statement term, split by phase. Prereg bench/d172_prereg.txt (B1-B4, each cut BEFORE its run). +host: Darwin arm64 arena path, pushdown ON, arm B (W=100 FIXED, S swept), 16000-row table +controls in every cell: probe fired 200/200, unprobeable_rows 0, overlay == W, Affected == 1, negative control 0 + +--- RUN 1 (B2): clone timer only, load 5 --- +ACQUIRED d172 load_at_acquire=5 +=== ARM B (W fixed at 100, S swept) — arena, pushdown === + + axis W S median ms us/stmt clone us ops.len clone% probe wlknopk max_overlay + 100 100 100 0.0670 67.00 7.39 300 11.0 200 0 100 + 400 100 400 0.0883 88.33 16.35 600 18.5 200 0 100 + ^ 4.0x the axis, 1.32x the time + 1600 100 1600 0.1762 176.17 51.20 1800 29.1 200 0 100 + ^ 16.0x the axis, 2.63x the time + 6400 100 6400 0.5414 541.37 202.83 6600 37.5 200 0 100 + ^ 64.0x the axis, 8.08x the time + 12800 100 12800 0.9149 914.92 370.76 13000 40.5 200 0 100 + ^ 128.0x the axis, 13.66x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +RELEASED d172 + +[exited with code 0] + +--- RUN 2 (B3): clone + append, load 3 --- +ACQUIRED d172b load_at_acquire=3 + + axis W S us/stmt clone us append us ops.len clone% append% SUM% probe max_overlay + 100 100 100 64.75 7.11 2.67 300 11.0 4.1 15.1 200 100 + 400 100 400 89.58 16.51 6.47 600 18.4 7.2 25.7 200 100 + ^ 4.0x the axis, 1.38x the time + 1600 100 1600 165.04 48.90 20.44 1800 29.6 12.4 42.0 200 100 + ^ 16.0x the axis, 2.55x the time + 6400 100 6400 539.75 202.54 84.33 6600 37.5 15.6 53.1 200 100 + ^ 64.0x the axis, 8.34x the time + 12800 100 12800 947.04 383.22 162.28 13000 40.5 17.1 57.6 200 100 + ^ 128.0x the axis, 14.63x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +RELEASED d172b + +--- RUN 3 (B4): clone + append + drop — THE TABLE CLOSES --- + + axis W S us/stmt clone us append us drop us ops.len clone% append% drop% SUM% probe + 100 100 100 62.83 6.57 2.41 4.36 300 10.5 3.8 6.9 21.2 200 + 400 100 400 86.88 14.26 6.49 13.26 600 16.4 7.5 15.3 39.1 200 + ^ 4.0x the axis, 1.38x the time + 1600 100 1600 168.04 50.45 20.62 48.12 1800 30.0 12.3 28.6 70.9 200 + ^ 16.0x the axis, 2.67x the time + 6400 100 6400 500.37 187.42 77.60 182.40 6600 37.5 15.5 36.5 89.4 200 + ^ 64.0x the axis, 7.96x the time + 12800 100 12800 921.50 373.48 155.82 342.50 13000 40.5 16.9 37.2 94.6 200 + ^ 128.0x the axis, 14.67x the time + +All cells: probe fired on every timed statement, unprobeable_rows 0, overlay == W, +every statement changed exactly 1 row, negative control 0. +RELEASED d172c diff --git a/bench/d172_prereg.txt b/bench/d172_prereg.txt index 38ed20b1..2b8da8ea 100644 --- a/bench/d172_prereg.txt +++ b/bench/d172_prereg.txt @@ -127,3 +127,69 @@ B2. THE CONFIRMATION ARM, registered before running it. **A PHASE TIMER, NOT A R FALSIFIER: if `ops.len()` does not itself grow ~1 per statement in this workload, the axis is not what I think and the fit is meaningless — so `ops.len()` is REPORTED, never assumed. + +B3. 2026-09-23. OUTCOME B2b FIRED: the clone is A term, not THE term. Measured, arm B, arena, + pushdown, W=100 fixed, ops.len() REPORTED not assumed (it reads S+200 throughout, i.e. exactly + 1 op per statement, so the falsifier in B2 passes): + + S us/stmt clone us ops.len clone% + 100 67.00 7.39 300 11.0 + 400 88.33 16.35 600 18.5 + 1600 176.17 51.20 1800 29.1 + 6400 541.37 202.83 6600 37.5 + 12800 914.92 370.76 13000 40.5 + + The clone is LINEAR in ops.len() at a very stable ~28 ns per op copied (24.6 / 27.3 / 28.4 / + 30.7 / 28.5), and contributes 28.6 of the 66.8 ns/statement total slope = **43%**. + ⇒ B1's site is CONFIRMED as a real Theta(S) per-statement term. It is NOT the whole term. + + ⛔ 57% IS UNATTRIBUTED, AND AN UNATTRIBUTED MAJORITY IS NOT A RESULT -- a phase table must + close. Registering the next site BEFORE measuring it. + + THE SECOND SITE, again located by READING: `self.log.append(&frame)` immediately after the + clone. `MemEffectLog::append` -> `classify(&frames.frames[i], frame)` (`src/tel/log.rs:368`), + which runs `frame_eq(old, new)` and then `extends(old, new)` -- BOTH op-by-op over the stored + frame via `op_eq` (`log.rs:300`, comparing tbl/row/col/witness/kind). **That is a second + Theta(S) pass per statement**, on top of the Theta(S) copy. + `log.rs:284-286` states the cause in its own words: *"because `stage_all` re-appends the open + frame once per statement"*. One design decision, two Theta(S) costs. + + ⚠ D138 DOES NOT COVER THIS AND MUST NOT BE READ AS COVERING IT. Its arms report + `classify_append calls 0` -- classify was never exercised in that measurement -- and its scan + counter is on the INDEX lookup (`frames_for`), not on classify's prefix comparison. + + PREDICTIONS: + B3a. append_ns rises linearly with ops.len() and clone% + append% accounts for ~all of the + slope -> the phase table CLOSES and the term is fully attributed to the re-append design. + B3b. clone% + append% still leaves a large gap -> a third site exists; report the gap as a + gap and do NOT distribute it across the two known ones. + B3c. append_ns flat -> classify is not the second term; the reading is wrong. + +B4. 2026-09-23. OUTCOME B3b FIRED: the table does NOT close. Measured (arm B, arena, pushdown, + W=100, load 3): + + S us/stmt clone us append us clone% append% SUM% + 100 64.75 7.11 2.67 11.0 4.1 15.1 + 400 89.58 16.51 6.47 18.4 7.2 25.7 + 1600 165.04 48.90 20.44 29.6 12.4 42.0 + 6400 539.75 202.54 84.33 37.5 15.6 53.1 + 12800 947.04 383.22 162.28 40.5 17.1 57.6 + + As SLOPES over S=100..12800: total 69.5 ns/stmt; clone 29.6 (43%); append 12.6 (18%). + ⇒ BOTH sites CONFIRMED Theta(S) and linear. Together they are 61% of the slope. + ⇒ **~27 ns/stmt (39%) IS UNATTRIBUTED. Reported as a gap, and NOT distributed across the two + known sites**, per B3b. + + THE THIRD SITE, registered before measuring: **the cloned frame's DROP.** `stage_all` clones + an O(S) `TxnFrame`, passes `&frame` to `append`, and the clone then falls out of scope. + Dropping a `Vec` of length S deallocates S `Op`s, each carrying an `Option` + witness -- Theta(S), and it sits OUTSIDE both existing timers. + ⚠ This is a KNOWN pattern in this project's own notes ("a phase table must close -- an + unattributed 40% was the clone's DROP"), which is a reason to TEST it, not to assume it. + + PREDICTIONS: + B4a. drop_ns is Theta(S) and clone+append+drop accounts for ~the whole slope -> the table + CLOSES; the whole per-statement term is the cost of materialising, comparing and + discarding a cumulative frame once per statement. + B4b. drop_ns small, gap persists -> a fourth site; report the gap again and STOP rather than + keep guessing. Two registered misses is the limit for this row. diff --git a/examples/d171_stage_cost.rs b/examples/d171_stage_cost.rs index 4ff4bf9b..72fa589d 100644 --- a/examples/d171_stage_cost.rs +++ b/examples/d171_stage_cost.rs @@ -12,7 +12,8 @@ use std::fs::OpenOptions; use std::sync::Arc; use std::time::Instant; -use ferrodb::agent_sql::runtime::{d170_overlay_counters, d170_reset_overlay_counters, AgentRuntime}; +use ferrodb::agent_sql::runtime::{d170_overlay_counters, d170_reset_overlay_counters, + d172_clone_counters, d172_reset_clone_counters, AgentRuntime}; use ferrodb::agent_sql::AgentOutput; use ferrodb::branch::arena::ArenaPageStore; use ferrodb::branch::table_catalog::TableBranchCatalog; @@ -107,7 +108,7 @@ impl StubDb { } /// One stub cell, same shape as `cell`: pre-load untimed to (w, s), then time TIMED statements. -fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { +fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64, f64, u64, f64, f64) { let mut db = StubDb::new(); let mut base = Session::new(); db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); @@ -123,6 +124,7 @@ fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { assert_eq!(affected(&out), Some(1), "STUB preload no-op at w={w} s={s} i={i}"); } d170_reset_overlay_counters(); + d172_reset_clone_counters(); let mut samples = Vec::with_capacity(TIMED); for i in 0..TIMED { let k = key(i, w, rows); @@ -132,7 +134,11 @@ fn stub_cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { assert_eq!(affected(&out), Some(1), "STUB timed no-op at w={w} s={s} i={i}"); } let (pf, wu, wn, mu, mo) = d170_overlay_counters(); - (median(&mut samples), pf, wu, wn, mu, mo) + let (cns, ccalls, ans, omax, dns) = d172_clone_counters(); + let clone_us = if ccalls == 0 { 0.0 } else { cns as f64 / ccalls as f64 / 1000.0 }; + let append_us = if ccalls == 0 { 0.0 } else { ans as f64 / ccalls as f64 / 1000.0 }; + let drop_us = if ccalls == 0 { 0.0 } else { dns as f64 / ccalls as f64 / 1000.0 }; + (median(&mut samples), pf, wu, wn, mu, mo, clone_us, omax, append_us, drop_us) } fn affected(o: &Outcome) -> Option { @@ -158,7 +164,7 @@ const TIMED: usize = 200; /// Run one cell: pre-load to (w distinct keys, s statements) untimed, then time TIMED statements /// on keys already staged. Returns (median ms, probe_fired, walk_unprob, max_unprob, max_overlay). -fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { +fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64, f64, u64, f64, f64) { let mut db = Db::new(rows); let mut base = db.ctx.session(); db.exec("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut base); @@ -181,6 +187,7 @@ fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { // TIMED WINDOW. Keys already staged, so the overlay does not grow while timing. d170_reset_overlay_counters(); + d172_reset_clone_counters(); let mut samples = Vec::with_capacity(TIMED); for i in 0..TIMED { let k = key(i, w, rows); @@ -190,7 +197,11 @@ fn cell(rows: i64, w: usize, s: usize) -> (f64, u64, u64, u64, u64, u64) { assert_eq!(affected(&out), Some(1), "timed no-op at w={w} s={s} i={i}"); } let (pf, wu, wn, mu, mo) = d170_overlay_counters(); - (median(&mut samples), pf, wu, wn, mu, mo) + let (cns, ccalls, ans, omax, dns) = d172_clone_counters(); + let clone_us = if ccalls == 0 { 0.0 } else { cns as f64 / ccalls as f64 / 1000.0 }; + let append_us = if ccalls == 0 { 0.0 } else { ans as f64 / ccalls as f64 / 1000.0 }; + let drop_us = if ccalls == 0 { 0.0 } else { dns as f64 / ccalls as f64 / 1000.0 }; + (median(&mut samples), pf, wu, wn, mu, mo, clone_us, omax, append_us, drop_us) } fn main() { @@ -214,7 +225,7 @@ fn main() { "C" => format!("S FIXED at {s_fixed}, W swept -- statement count constant"), _ => "unknown".to_string(), }); - println!("\n axis W S median ms us/stmt probe wlknopk walk_unprob max_unprob max_overlay"); + println!("\n axis W S us/stmt clone us append us drop us ops.len clone% append% drop% SUM% probe"); let mut first: Option = None; for &x in &axis { let (w, s) = match arm.as_str() { @@ -225,7 +236,8 @@ fn main() { }; assert!((w as i64) <= rows, "w={w} exceeds table rows={rows}: the overlay would SATURATE"); let stub = std::env::var("D171_STUB").is_ok(); - let (med, pf, wu, wn, mu, mo) = if stub { stub_cell(rows, w, s) } else { cell(rows, w, s) }; + let (med, pf, wu, wn, mu, mo, clone_us, opsmax, append_us, drop_us) = + if stub { stub_cell(rows, w, s) } else { cell(rows, w, s) }; // Controls, enforced before the number is printed. // @@ -247,8 +259,12 @@ fn main() { assert_eq!(mu, 0, "unprobeable_rows non-zero at w={w} s={s}: cell VOID"); assert_eq!(mo, w as u64, "max_overlay_len={mo} != intended W={w}: the overlay SATURATED, cell VOID"); - println!(" {x:>8} {w:>8} {s:>8} {med:>9.4} {:>7.2} {pf:>5} {wn:>6} {wu:>11} {mu:>10} {mo:>11}", - med * 1000.0); + let us = med * 1000.0; + let (cp, ap, dp) = if us > 0.0 { + (clone_us / us * 100.0, append_us / us * 100.0, drop_us / us * 100.0) + } else { (0.0, 0.0, 0.0) }; + println!(" {x:>8} {w:>8} {s:>8} {us:>7.2} {clone_us:>8.2} {append_us:>8.2} {drop_us:>8.2} {opsmax:>7} {cp:>6.1} {ap:>6.1} {dp:>5.1} {:>4.1} {pf:>5}", + cp + ap + dp); if first.is_none() { first = Some(med); } if let Some(m0) = first { if x != axis[0] { diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 9bbdb2f5..60fc3d72 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -294,6 +294,27 @@ pub fn d170_reset_overlay_counters() { } } +/// D172 ADVERSARY INSTRUMENT -- NOT FOR LANDING. Cost of `stage_all`'s per-statement +/// `ws.frame.clone()` (`runtime.rs`), paired with the `ops.len()` it copied. +pub static D172_DROP_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D172_APPEND_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D172_CLONE_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D172_CLONE_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D172_OPS_LEN_SUM: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +pub static D172_OPS_LEN_MAX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + +/// (total clone ns, clone calls, total append ns, max ops.len()) +pub fn d172_clone_counters() -> (u64, u64, u64, u64, u64) { + use std::sync::atomic::Ordering::Relaxed; + (D172_CLONE_NS.load(Relaxed), D172_CLONE_CALLS.load(Relaxed), + D172_APPEND_NS.load(Relaxed), D172_OPS_LEN_MAX.load(Relaxed), D172_DROP_NS.load(Relaxed)) +} + +pub fn d172_reset_clone_counters() { + use std::sync::atomic::Ordering::Relaxed; + for c in [&D172_CLONE_NS, &D172_CLONE_CALLS, &D172_APPEND_NS, &D172_DROP_NS, &D172_OPS_LEN_SUM, &D172_OPS_LEN_MAX] { c.store(0, Relaxed); } +} + fn overlay_probe_key(bound: &BoundExpr, pk_type: Option<&DataType>) -> Option { let pk_type = pk_type?; let mut conjuncts = Vec::new(); @@ -3114,12 +3135,38 @@ impl AgentRuntime { } mirrored.push((item.row, item.after)); } - ws.frame.clone() + // D172 ADVERSARY INSTRUMENT -- NOT FOR LANDING. Times THIS clone alone and pairs it + // with the integer `ops.len()` at the same moment, so the timer never travels without + // its counter. + { + let n = ws.frame.ops.len() as u64; + let t0 = std::time::Instant::now(); + let f = ws.frame.clone(); + D172_CLONE_NS.fetch_add(t0.elapsed().as_nanos() as u64, std::sync::atomic::Ordering::Relaxed); + D172_CLONE_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + D172_OPS_LEN_SUM.fetch_add(n, std::sync::atomic::Ordering::Relaxed); + D172_OPS_LEN_MAX.fetch_max(n, std::sync::atomic::Ordering::Relaxed); + f + } }; // Re-appending the task's frame replaces it rather than adding a second copy: `Add` is // not idempotent and two copies of one frame would double-count. Appended ONCE for the whole // statement, which is also why the frame is cloned after every row is folded in. - self.log.append(&frame)?; + // D172 ADVERSARY INSTRUMENT -- NOT FOR LANDING. The second Theta(S) site: classify's + // frame_eq + extends compare op-by-op over the stored frame. + { + let t0 = std::time::Instant::now(); + let r = self.log.append(&frame); + D172_APPEND_NS.fetch_add(t0.elapsed().as_nanos() as u64, std::sync::atomic::Ordering::Relaxed); + r? + }; + // D172 ADVERSARY INSTRUMENT -- NOT FOR LANDING. The third Theta(S) site: dropping the + // clone deallocates S `Op`s and their witnesses, outside both timers above. + { + let t0 = std::time::Instant::now(); + drop(frame); + D172_DROP_NS.fetch_add(t0.elapsed().as_nanos() as u64, std::sync::atomic::Ordering::Relaxed); + } // Mirror the staged rows onto the branch's OWN copy-on-write tree, when this runtime has a // page store. The workspace map above is still what `DIFF` and `MERGE` read; this is the From f70f3abfc0ef4b978e763aa8691dce27a35b4b80 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:31:21 -0400 Subject: [PATCH 073/315] d174: measure branch-locality of the cluster merge gate The gate has none, and the reason is structural: BranchOp::Merge carries no table, so the disjoint and overlap arms emit byte-identical logs and no function of the committed log could be local. Instrument is ClusterAgents::cost().reevaluations -- an integer counter out of a deterministic state machine, not a timer. Concurrency is scripted on committed_head(), the method this module's own doc-comment defends. Arms per bench/d174_prereg.txt: A = C-1 = overlap at every C in {2,4,8,16}; B and the added B-serial(C) control are 0. Also shows a merge touching only t0 refused outright after nine merges of t1..t9 committed, and that the publish-time fingerprint DOES separate the arms one layer down. Touches no src file and no existing test assertion. --- tests/d174_merge_gate_locality.rs | 823 ++++++++++++++++++++++++++++++ 1 file changed, 823 insertions(+) create mode 100644 tests/d174_merge_gate_locality.rs diff --git a/tests/d174_merge_gate_locality.rs b/tests/d174_merge_gate_locality.rs new file mode 100644 index 00000000..e0c72891 --- /dev/null +++ b/tests/d174_merge_gate_locality.rs @@ -0,0 +1,823 @@ +//! D174 — does the cluster merge gate have ANY branch-locality? +//! +//! Pre-registration: `artie-research/bench/d174_prereg.txt`, cut before any measurement here, plus +//! Amendment 1 appended before the first run. Read it first; this file is its instrument. +//! +//! # The claim on trial +//! +//! `BranchLedger::merge_verdict` (src/agent_sql/cluster.rs:617) re-evaluates a merge when +//! `self.last_base_move > base_round`. `last_base_move` is ONE GLOBAL SCALAR, set to `round` on +//! every applied merge (cluster.rs:544) and on every other base-moving command (cluster.rs:461). +//! So a merge is re-evaluated because *some other* merge committed — whether or not the two +//! touched anything in common. +//! +//! # The instrument +//! +//! `ClusterAgents::cost().reevaluations` — `ConsensusCost.reevaluations` (cluster.rs:804), +//! incremented at cluster.rs:1177. An INTEGER COUNTER out of a deterministic state machine. It is +//! not a timer, it does not move with machine load, and it needs neither a measure lock nor a +//! quiet box. Said here so the next reader does not "correct" this into a timed run. +//! +//! # How concurrency is realised, and why that is not a distortion +//! +//! `ClusterAgents::merge` takes `&mut ExecCtx` holding `&mut Catalog`, so two merges cannot run in +//! parallel threads in one process — cluster.rs:1150 says so itself. Concurrency is SCRIPTED, +//! which is the method `tests/integration_cluster_agents.rs:13-18` defends for exactly this +//! window. The independent variable is one line: +//! +//! ```text +//! cluster.rs:1120 let base_round = self.repl.committed_head(); +//! ``` +//! +//! C merges are concurrent exactly when their C gates read the same committed head. `Scripted` +//! below returns a stale head R to each merge's FIRST attempt and the true head to every retry — +//! which is what the code does, and what a node whose view of the log lags the leader really sees. +//! Nothing else is altered. +//! +//! # Why arm A vs arm C would be a tautology on its own +//! +//! `BranchOp::Merge { branch, base_round }` carries NO table. Arms A and C therefore produce +//! byte-identical logs and A == C is guaranteed by the command's type alone. That is why +//! `fine_check_separates_disjoint_from_overlapping_merges` is in this file: it asks whether a +//! locality signal exists ANYWHERE in the engine. Without it, "no locality" is a statement about a +//! struct definition rather than a measurement. + +use std::collections::VecDeque; +use std::fs::OpenOptions; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use ferrodb::agent_sql::cluster::{ + BranchLedger, ClusterAgents, ClusterBranchId, ClusterSession, Replicated, +}; +use ferrodb::agent_sql::runtime::{AgentRuntime, ExecCtx, RunIdentity}; +use ferrodb::branch::types::BranchId; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::consensus::{BranchOp, Command, Entry, NodeId, Round}; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +const N1: NodeId = NodeId(1); + +/// The C values the pre-registration fixes. Not a sweep I chose after seeing anything. +const CS: [usize; 4] = [2, 4, 8, 16]; + +fn entry(round: Round, command: Command) -> Entry { + Entry { term: 1, round, command } +} + +fn merge_op(branch: ClusterBranchId, base_round: Round) -> Command { + Command::Branch { op: BranchOp::Merge { branch: branch.0, base_round } } +} + +// ================================================================================================= +// The database. Copied from tests/integration_cluster_agents.rs:549 rather than reinvented; the +// only difference is a seed that takes an arm, because the arms differ in nothing else. +// ================================================================================================= + +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Db { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("d174.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("d174.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + Db { catalog, bp, txn, runtime: Arc::new(AgentRuntime::new()), _dir: dir } + } + + fn session(&self) -> Session { + Session::with_runtime(self.runtime.clone()) + } + + fn exec(&mut self, sql: &str, s: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(FerroError::SqlParseError( + parser.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + } + + fn ok(&mut self, sql: &str, s: &mut Session) -> Outcome { + self.exec(sql, s).unwrap_or_else(|e| panic!("{sql} failed: {e}")) + } + + fn on_branch(&mut self, cs: &ClusterSession, sql: &str) { + let mut s = self.session(); + s.agent = Some(cs.session.clone()); + self.ok(sql, &mut s); + } + + fn qty(&mut self, table: &str, id: i64) -> Option { + let mut s = self.session(); + let sql = format!("SELECT qty FROM {table} WHERE id = {id};"); + match self.ok(&sql, &mut s) { + Outcome::Rows(rows) => rows.first().and_then(|r| match r.first() { + Some(Value::Integer(i)) => Some(*i as i64), + _ => None, + }), + _ => panic!("expected rows from: {sql}"), + } + } + + fn ctx(&mut self) -> ExecCtx<'_> { + let bp = self.bp.clone(); + let txn = self.txn.clone(); + ExecCtx { catalog: &mut self.catalog, bp, txn } + } +} + +// ================================================================================================= +// The scripted log. Copied from tests/integration_cluster_agents.rs:630. ONE addition: a one-shot +// stale head, which is the arms' independent variable and nothing else. +// ================================================================================================= + +struct Scripted { + node: NodeId, + ledger: Arc>, + log: Mutex>, + applied: Mutex, + inject: Mutex>, + sticky: Mutex, + leader: Mutex>, + /// **The concurrency knob, and the whole of it.** When set, the NEXT call to + /// `committed_head()` returns this round instead of the true head, and clears it. A merge's + /// first attempt therefore reads the head as it stood before its siblings committed; every + /// retry reads the true head, exactly as `merge()`'s loop does. This is a node whose view of + /// the log lags the leader — an ordinary condition, not a fiction. + stale_head: Mutex>, + /// How many times a stale head was actually served. A stale head that was never consumed is a + /// harness that did not create the concurrency it claims, and would read as a clean zero. + stale_served: Mutex, + /// Sibling branches whose merges land in the window, ONE PER PROPOSAL. + /// + /// Each is turned into a `BranchOp::Merge` at propose time, carrying the head as it stands at + /// that instant — which is what the sibling's own gate would have read. Built late rather than + /// queued as fixed commands for a reason the first run found the hard way: a merge command + /// whose `base_round` is already behind `last_base_move` is itself RE-EVALUATED, and a + /// re-evaluated merge moves nothing (cluster.rs:544 moves the base only on `Applied`). A queue + /// of stale sibling commands therefore invalidates nothing and reads as "the gate has + /// locality after all". + sibling_merges: Mutex>, + /// How many siblings were actually served. Zero here means the starvation arm never ran. + siblings_served: Mutex, +} + +impl Scripted { + fn new(node: NodeId, ledger: Arc>) -> Arc { + Arc::new(Scripted { + node, + ledger, + log: Mutex::new(Vec::new()), + applied: Mutex::new(0), + inject: Mutex::new(Vec::new()), + sticky: Mutex::new(false), + leader: Mutex::new(Some(node)), + stale_head: Mutex::new(None), + stale_served: Mutex::new(0), + sibling_merges: Mutex::new(VecDeque::new()), + siblings_served: Mutex::new(0), + }) + } + + fn stale_head_once(&self, r: Round) { + *lock(&self.stale_head) = Some(r); + } + + fn stale_served(&self) -> u64 { + *lock(&self.stale_served) + } + + /// Land one sibling's merge immediately before each of the next `ids.len()` proposals. + fn sibling_merges_before_next_proposals(&self, ids: Vec) { + *lock(&self.sibling_merges) = ids.into(); + } + + fn siblings_served(&self) -> u64 { + *lock(&self.siblings_served) + } + + /// The whole committed log, as the ledger sees it. Used to settle the question a count cannot: + /// whether the two arms were ever DISTINGUISHABLE by anything reading this log. + fn log_commands(&self) -> Vec { + lock(&self.log).iter().map(|e| format!("{}:{:?}", e.round, e.command)).collect() + } + + fn settle(&self) { + loop { + let (a, n) = (*lock(&self.applied), lock(&self.log).len()); + if a >= n { + return; + } + self.pump().unwrap(); + } + } +} + +impl Replicated for Scripted { + fn propose(&self, c: Command) -> Result { + if *lock(&self.leader) != Some(self.node) { + return Err(FerroError::NotLeader { leader: None }); + } + let injected: Vec = if *lock(&self.sticky) { + lock(&self.inject).clone() + } else { + lock(&self.inject).drain(..).collect() + }; + let sibling = lock(&self.sibling_merges).pop_front(); + let mut log = lock(&self.log); + for i in injected { + let r = log.len() as u64 + 1; + log.push(entry(r, i)); + } + if let Some(b) = sibling { + // `base_round` is the head as it stands right now: what this sibling's own gate would + // have read a moment ago. That is what makes it APPLY rather than be re-evaluated. + let base = log.len() as u64; + let r = base + 1; + log.push(entry(r, merge_op(b, base))); + *lock(&self.siblings_served) += 1; + } + let r = log.len() as u64 + 1; + log.push(entry(r, c)); + Ok(r) + } + + fn committed_head(&self) -> Round { + if let Some(r) = lock(&self.stale_head).take() { + *lock(&self.stale_served) += 1; + return r; + } + lock(&self.log).len() as u64 + } + + fn pump(&self) -> Result<(), FerroError> { + let mut applied = lock(&self.applied); + let next = { + let log = lock(&self.log); + if *applied >= log.len() { + return Ok(()); + } + log[*applied].clone() + }; + lock(&self.ledger).apply(&next); + *applied += 1; + Ok(()) + } + + fn leader(&self) -> Option { + *lock(&self.leader) + } +} + +// ================================================================================================= +// The arms +// ================================================================================================= + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Arm { + /// A: each branch touches a table NO other branch touches. + Disjoint, + /// C: every branch touches the SAME table (a different row each, so the branches do not + /// conflict on a row and every merge stays admissible — the overlap is at TABLE granularity, + /// which is the granularity the fine check works at). + Overlap, +} + +impl Arm { + fn table_of(&self, i: usize) -> String { + match self { + Arm::Disjoint => format!("t{i}"), + Arm::Overlap => "shared".to_string(), + } + } + fn row_of(&self, i: usize) -> i64 { + match self { + Arm::Disjoint => 1, + Arm::Overlap => i as i64 + 1, + } + } +} + +struct ArmResult { + reevaluations: u64, + proposals: u64, + /// Per-merge outcome. A bare total hides an arm whose merges stopped being admissible for a + /// reason that has nothing to do with the gate. + outcomes: Vec, + applied: usize, + errored: usize, + stale_served: u64, + rows_landed: usize, + /// The committed log this arm produced. + log: Vec, +} + +/// Run one arm at one C. +/// +/// `concurrent == false` is the B-SERIAL control: the same number of merges, each gate reading the +/// true current head. It separates "concurrency causes reevaluations" from "more merges cause +/// them", which arm B as cut (one merge) cannot. +fn run_arm(arm: Arm, c: usize, concurrent: bool) -> ArmResult { + let mut db = Db::new(); + + // Seed. Disjoint: C tables of one row. Overlap: one table of C rows. + { + let mut s = db.session(); + match arm { + Arm::Disjoint => { + for i in 0..c { + db.ok( + &format!("CREATE TABLE t{i} (id INTEGER NOT NULL, qty INTEGER);"), + &mut s, + ); + db.ok(&format!("INSERT INTO t{i} VALUES (1, 100);"), &mut s); + } + } + Arm::Overlap => { + db.ok("CREATE TABLE shared (id INTEGER NOT NULL, qty INTEGER);", &mut s); + for i in 0..c { + db.ok(&format!("INSERT INTO shared VALUES ({}, 100);", i + 1), &mut s); + } + } + } + } + + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let repl = Scripted::new(N1, ledger.clone()); + let agents = ClusterAgents::new(N1, db.runtime.clone(), repl.clone(), ledger); + + let mut sessions = Vec::new(); + for _ in 0..c { + let id = RunIdentity { agent_id: "d174", run_id: Some("r_1"), ..RunIdentity::default() }; + sessions.push(agents.fork(id, BranchId::TRUNK).unwrap()); + } + repl.settle(); + + for (i, cs) in sessions.iter().enumerate() { + let sql = format!( + "UPDATE {} SET qty = 42 WHERE id = {};", + arm.table_of(i), + arm.row_of(i) + ); + db.on_branch(cs, &sql); + } + + // The head every concurrent gate reads. Forks do not move the base (`moves_base`, + // cluster.rs:230), so this is also `last_base_move == 0` territory: the first merge cannot be + // re-evaluated by construction, and every later one is re-evaluated only because a SIBLING + // merge committed. + let r0 = repl.committed_head(); + + let mut outcomes = Vec::new(); + let mut applied = 0usize; + let mut errored = 0usize; + for (i, cs) in sessions.iter().enumerate() { + if concurrent { + repl.stale_head_once(r0); + } + let branch = cs.branch(); + let mut ctx = db.ctx(); + match agents.merge(&mut ctx, branch) { + Ok(rep) => { + if rep.report.applied_to_target { + applied += 1; + } + outcomes.push(format!( + "m{i}: applied={} reeval={} base={} round={:?}", + rep.report.applied_to_target, rep.reevaluations, rep.base_round, rep.merge_round + )); + } + Err(e) => { + errored += 1; + let msg = format!("{e}"); + outcomes.push(format!("m{i}: ERR {}", msg.chars().take(90).collect::())); + } + } + } + + // Did the rows actually land? An arm whose merges "applied" but published nothing would give a + // count about a database that never changed. + let mut rows_landed = 0usize; + for i in 0..c { + if db.qty(&arm.table_of(i), arm.row_of(i)) == Some(42) { + rows_landed += 1; + } + } + + let cost = agents.cost(); + ArmResult { + reevaluations: cost.reevaluations, + proposals: cost.proposals, + outcomes, + applied, + errored, + stale_served: repl.stale_served(), + rows_landed, + log: repl.log_commands(), + } +} + +// ================================================================================================= +// P1 — the zero-control. If this is not zero, every other number in this file is void. +// ================================================================================================= + +#[test] +fn p1_a_single_merge_with_no_concurrency_is_never_re_evaluated() { + println!("\n=== D174 / P1 — ARM B (control): ONE merge, no concurrency ==="); + let r = run_arm(Arm::Disjoint, 1, false); + println!(" B(1) reevaluations={} proposals={} applied={} rows_landed={}", r.reevaluations, r.proposals, r.applied, r.rows_landed); + for o in &r.outcomes { + println!(" {o}"); + } + assert_eq!( + r.reevaluations, 0, + "P1 FAILED. A single merge against a base nothing moved was re-evaluated. The counter is \ + not measuring what the pre-registration thinks it is, and every other number in this run \ + is void." + ); + assert_eq!(r.applied, 1, "the control merge did not publish, so it measured nothing"); + assert_eq!(r.rows_landed, 1, "the control merge's row never reached the target"); + println!(" P1 HELD: B == 0."); +} + +// ================================================================================================= +// The row: arms A and C at every C, plus the B-SERIAL control at every C. +// ================================================================================================= + +#[test] +fn d174_merge_gate_locality_table() { + println!("\n=== D174 — cluster merge gate branch-locality ==="); + println!("Instrument: ClusterAgents::cost().reevaluations (ConsensusCost, cluster.rs:804)."); + println!("An integer counter out of a deterministic state machine. NOT a timer: no measure"); + println!("lock was taken and no quiet box was waited for, on purpose.\n"); + + println!("{:<4} {:>10} {:>10} {:>12} {:>10}", "C", "A-DISJ", "C-OVERLAP", "B-SERIAL(C)", "A/OVLP"); + println!("{}", "-".repeat(50)); + + let mut rows = Vec::new(); + for &c in CS.iter() { + let a = run_arm(Arm::Disjoint, c, true); + let o = run_arm(Arm::Overlap, c, true); + let bs = run_arm(Arm::Disjoint, c, false); + let ratio = if o.reevaluations == 0 { + "n/a".to_string() + } else { + format!("{:.2}", a.reevaluations as f64 / o.reevaluations as f64) + }; + println!( + "{:<4} {:>10} {:>10} {:>12} {:>10}", + c, a.reevaluations, o.reevaluations, bs.reevaluations, ratio + ); + rows.push((c, a, o, bs)); + } + + println!("\n--- per-merge detail, and the guards that say the arms really ran ---"); + for (c, a, o, bs) in &rows { + println!( + "\nC={c} A-DISJOINT : reeval={} proposals={} applied={}/{c} err={} rows_landed={}/{c} stale_heads_served={}", + a.reevaluations, a.proposals, a.applied, a.errored, a.rows_landed, a.stale_served + ); + for x in &a.outcomes { + println!(" {x}"); + } + println!( + "C={c} C-OVERLAP : reeval={} proposals={} applied={}/{c} err={} rows_landed={}/{c} stale_heads_served={}", + o.reevaluations, o.proposals, o.applied, o.errored, o.rows_landed, o.stale_served + ); + for x in &o.outcomes { + println!(" {x}"); + } + println!( + "C={c} B-SERIAL : reeval={} proposals={} applied={}/{c} err={} rows_landed={}/{c} stale_heads_served={}", + bs.reevaluations, bs.proposals, bs.applied, bs.errored, bs.rows_landed, bs.stale_served + ); + } + + println!("\n--- PREDICTIONS, as registered ---"); + let at = |c: usize| -> &ArmResult { &rows.iter().find(|r| r.0 == c).unwrap().1 }; + let ov = |c: usize| -> &ArmResult { &rows.iter().find(|r| r.0 == c).unwrap().2 }; + + // P1 extended: B-SERIAL is the second zero-control (Amendment 1 C1). + let bserial_all_zero = rows.iter().all(|(_, _, _, bs)| bs.reevaluations == 0); + println!( + "P1 B == 0 and B-SERIAL(C) == 0 at every C .......... {}", + if bserial_all_zero { "HELD" } else { "MISSED" } + ); + + let a_grows = CS.windows(2).all(|w| at(w[1]).reevaluations > at(w[0]).reevaluations); + let a_positive = CS.iter().all(|&c| at(c).reevaluations > 0); + println!( + "P2 A > 0 and A grows with C ........................ {}", + if a_positive && a_grows { "HELD" } else { "MISSED" } + ); + + let p3 = CS.iter().all(|&c| { + let (x, y) = (at(c).reevaluations as f64, ov(c).reevaluations as f64); + y > 0.0 && x <= 2.0 * y && y <= 2.0 * x + }); + println!( + "P3 A within 2x of OVERLAP at every C ............... {}", + if p3 { "HELD" } else { "MISSED" } + ); + + println!( + "P4 at C=16, A >= 8 ................................. {} (A(16) = {})", + if at(16).reevaluations >= 8 { "HELD" } else { "MISSED" }, + at(16).reevaluations + ); + + // --------------------------------------------------------------------------------------- + // The guards. These are what make the numbers above admissible at all. + // --------------------------------------------------------------------------------------- + assert!( + bserial_all_zero, + "P1 FAILED on the B-SERIAL control: C merges run strictly serially, with every gate \ + reading the true head, were re-evaluated. Then the counter is counting merges, not \ + conflicts, and the whole row is void." + ); + // The positive control. A zero from arm A is only meaningful if the counter has been FORCED to + // fire elsewhere; arm C is what forces it. If C is also zero the harness never created any + // concurrency and the run collected nothing. + assert!( + CS.iter().all(|&c| ov(c).reevaluations > 0), + "POSITIVE CONTROL FAILED: arm C (OVERLAP) is also zero. The harness never created \ + concurrency at all and this run collected nothing. It is REFUSED, not a clean zero." + ); + // And the concurrency really was served, rather than silently skipped. + for (c, a, o, _) in &rows { + assert_eq!(a.stale_served, *c as u64, "arm A at C={c} did not serve C stale heads"); + assert_eq!(o.stale_served, *c as u64, "arm C at C={c} did not serve C stale heads"); + } + + // --------------------------------------------------------------------------------------- + // Why A == C is a PROOF and not a coincidence. + // + // A null result is worth nothing until you know the instrument could have answered the other + // way. Here the decisive evidence is not a mutant, because no mutant of `merge_verdict` could + // pass: `merge_verdict` is a function of the committed log alone, and the two arms' logs are + // BYTE-IDENTICAL. `BranchOp::Merge { branch, base_round }` carries no table, so the + // information the gate would need to be local is not merely unused — it is not present. + // --------------------------------------------------------------------------------------- + println!("\n--- are the arms distinguishable AT ALL by anything reading the log? ---"); + for (c, a, o, _) in &rows { + let same = a.log == o.log; + println!( + "C={c}: arm A log ({} entries) vs arm C log ({} entries) -> {}", + a.log.len(), + o.log.len(), + if same { "BYTE-IDENTICAL" } else { "DIFFERENT" } + ); + assert_eq!( + a.log, o.log, + "the two arms produced different logs at C={c}. Then A == C was NOT forced by the \ + command's type and this run has found something the analysis did not predict — \ + report it rather than the null." + ); + } + println!( + " ⇒ A == C is not an observation about this gate. NO function of this log can separate\n \x20 the arms: the read-set is absent from `BranchOp::Merge`, which is exactly the\n \x20 constraint the pre-registration names (a fix must put the read-set IN THE LOG).\n \x20 Sample, C=2, arm A: {:?}", + rows.iter().find(|r| r.0 == 2).unwrap().1.log + ); + + // And the arms really did touch what the arm names say: arm A's writes landed in C distinct + // tables, arm C's in C rows of one. Without this the log identity could mean the arms were + // never different in the first place. + for (c, a, o, _) in &rows { + assert_eq!(a.rows_landed, *c, "arm A at C={c} did not land C rows in C distinct tables"); + assert_eq!(o.rows_landed, *c, "arm C at C={c} did not land C rows in the one shared table"); + } +} + +// ================================================================================================= +// The column that makes A == C a MEASUREMENT rather than a restatement of a struct definition. +// ================================================================================================= + +/// **Does a locality signal exist anywhere in the engine?** +/// +/// The publish-time check (`runtime.rs:4865`) re-fingerprints `eval.tables_read`. It is TABLE +/// granular. So an evaluation held across another branch's publish should survive when the two +/// touched different tables, and be refused when they touched the same one. +/// +/// If that holds, the finding is exact: the engine already computes a per-branch read-set, and the +/// cluster gate declines to use it. If it does not hold, "no locality" is a far weaker claim and +/// must be reported as such. +#[test] +fn fine_check_separates_disjoint_from_overlapping_merges() { + println!("\n=== D174 / FINE — is there a locality signal one layer down? ==="); + + for arm in [Arm::Disjoint, Arm::Overlap] { + let mut db = Db::new(); + { + let mut s = db.session(); + match arm { + Arm::Disjoint => { + db.ok("CREATE TABLE t0 (id INTEGER NOT NULL, qty INTEGER);", &mut s); + db.ok("INSERT INTO t0 VALUES (1, 100);", &mut s); + db.ok("CREATE TABLE t1 (id INTEGER NOT NULL, qty INTEGER);", &mut s); + db.ok("INSERT INTO t1 VALUES (1, 100);", &mut s); + } + Arm::Overlap => { + db.ok("CREATE TABLE shared (id INTEGER NOT NULL, qty INTEGER);", &mut s); + db.ok("INSERT INTO shared VALUES (1, 100);", &mut s); + db.ok("INSERT INTO shared VALUES (2, 100);", &mut s); + } + } + } + + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let repl = Scripted::new(N1, ledger.clone()); + let agents = ClusterAgents::new(N1, db.runtime.clone(), repl.clone(), ledger); + let id = RunIdentity { agent_id: "d174", run_id: Some("r_1"), ..RunIdentity::default() }; + let x = agents.fork(id, BranchId::TRUNK).unwrap(); + let id = RunIdentity { agent_id: "d174", run_id: Some("r_2"), ..RunIdentity::default() }; + let y = agents.fork(id, BranchId::TRUNK).unwrap(); + repl.settle(); + + let (tx, rx) = (arm.table_of(0), arm.row_of(0)); + let (ty, ry) = (arm.table_of(1), arm.row_of(1)); + db.on_branch(&x, &format!("UPDATE {tx} SET qty = 42 WHERE id = {rx};")); + db.on_branch(&y, &format!("UPDATE {ty} SET qty = 77 WHERE id = {ry};")); + + // Score X against the base as it stands now, and HOLD the evaluation. + let xb = x.branch(); + let rt = db.runtime.clone(); + let held = { + let mut ctx = db.ctx(); + rt.evaluate_merge(&mut ctx, xb, &[]).expect("X did not evaluate") + }; + + // Y publishes underneath it. This is the base move, and it is a REAL publication of rows — + // not an injected log command. + let yb = y.branch(); + { + let mut ctx = db.ctx(); + agents.merge(&mut ctx, yb).expect("Y's merge did not land"); + } + + // Now try to publish X's held verdict. + let out = { + let mut ctx = db.ctx(); + rt.publish_evaluation(&mut ctx, held) + }; + let verdict = match &out { + Ok(r) => format!("PUBLISHED (applied_to_target={})", r.applied_to_target), + Err(e) => { + let m = format!("{e}"); + format!("REFUSED: {}", m.chars().take(110).collect::()) + } + }; + println!(" {arm:?}: X evaluated, Y published underneath it, X then published -> {verdict}"); + + match arm { + Arm::Disjoint => assert!( + out.is_ok(), + "the fine check refused two merges that share NO table. Then it has no locality \ + either, and the D174 finding is much weaker than 'the gate ignores a signal the \ + engine computes'. Got: {verdict}" + ), + Arm::Overlap => assert!( + out.is_err(), + "the fine check ALLOWED a merge whose table another merge had published to. Then \ + there is no locality signal anywhere and A == C says nothing. Got: {verdict}" + ), + } + } + println!( + " FINE SEPARATES the arms: a per-branch read-set exists and is enforced one layer below \ + the gate." + ); +} + +// ================================================================================================= +// The consequence worth more than a count: a DISJOINT merge REFUSED outright. +// ================================================================================================= + +/// **Starvation by strangers.** A merge that touches only `t0` is refused — `DEFAULT_MAX_REEVALUATIONS` +/// (cluster.rs:868, = 8) — because merges touching only `t1..t9` kept committing. Nothing the +/// refused branch read or wrote was touched by any of them. +/// +/// What is scripted and what is real, stated plainly: the other branches' `BranchOp::Merge` +/// commands are PLACED in the window rather than produced by a second node, because one process +/// holds one `&mut Catalog` (cluster.rs:1150). They are real commands, for real branches the +/// cluster agreed exist, applied by the real `BranchLedger`. Their rows do not publish — and the +/// ledger, which is the only thing `merge_verdict` reads, cannot tell the difference. That is the +/// finding, not a limitation of it. +#[test] +fn a_merge_touching_one_table_is_refused_because_merges_of_other_tables_kept_committing() { + println!("\n=== D174 / STARVATION — a disjoint merge refused outright ==="); + const N: usize = 10; + + let mut db = Db::new(); + { + let mut s = db.session(); + for i in 0..N { + db.ok(&format!("CREATE TABLE t{i} (id INTEGER NOT NULL, qty INTEGER);"), &mut s); + db.ok(&format!("INSERT INTO t{i} VALUES (1, 100);"), &mut s); + } + } + + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let repl = Scripted::new(N1, ledger.clone()); + let agents = ClusterAgents::new(N1, db.runtime.clone(), repl.clone(), ledger.clone()); + + let mut sessions = Vec::new(); + for _ in 0..N { + let id = RunIdentity { agent_id: "d174", run_id: Some("r_1"), ..RunIdentity::default() }; + sessions.push(agents.fork(id, BranchId::TRUNK).unwrap()); + } + repl.settle(); + for (i, cs) in sessions.iter().enumerate() { + db.on_branch(cs, &format!("UPDATE t{i} SET qty = 42 WHERE id = 1;")); + } + + // Before every one of branch 0's proposals, ONE DISTINCT sibling's merge commits. Each + // sibling touches a table branch 0 never read or wrote. Distinct matters: a second merge of an + // already-sealed branch is Refused, and a Refused verdict moves nothing (cluster.rs:544), so + // repeating one sibling invalidates only the first attempt. + // + // Nine of them, because the bound refuses at `reevaluations > DEFAULT_MAX_REEVALUATIONS` = 8. + let siblings: Vec = (1..N) + .map(|i| ClusterBranchId::of(N1, sessions[i].branch()).unwrap()) + .collect(); + assert_eq!(siblings.len(), 9, "the bound needs 9 distinct invalidations to be reached"); + repl.sibling_merges_before_next_proposals(siblings.clone()); + + let b0 = sessions[0].branch(); + let err = { + let mut ctx = db.ctx(); + match agents.merge(&mut ctx, b0) { + Ok(r) => panic!( + "branch 0 merged (reeval={}, applied_to_target={}); expected the bound to refuse \ + it after 9 strangers committed", + r.reevaluations, r.report.applied_to_target + ), + Err(e) => e, + } + }; + + let msg = format!("{err}"); + println!(" branch 0 touched ONLY t0. Nine merges, each touching only t1..t9, committed."); + println!(" siblings actually served: {}", repl.siblings_served()); + println!(" branch 0 was REFUSED:\n {msg}"); + println!(" cost: {:?}", agents.cost()); + println!( + " t0 in the target after the refusal: {:?} (100 = branch 0's work never landed)", + db.qty("t0", 1) + ); + // The strangers' rows are not in the target either: their merge COMMANDS were placed, their + // publications were not. Stated so nobody reads this as "9 merges succeeded and one starved". + println!( + " t1 in the target: {:?} (100 = the siblings' log commands moved the base without \ + publishing rows; the ledger is all `merge_verdict` reads)", + db.qty("t1", 1) + ); + + assert_eq!(repl.siblings_served(), 9, "the strangers never reached the log"); + assert!( + msg.contains("re-evaluated"), + "expected the re-evaluation bound to refuse the merge, got: {msg}" + ); + assert!( + agents.cost().reevaluations > 8, + "the bound was not reached: reevaluations={}", + agents.cost().reevaluations + ); + assert_eq!( + db.qty("t0", 1), + Some(100), + "a refused merge published anyway; then the refusal is not what was measured" + ); + println!(" STARVATION CONFIRMED: no branch that touched t0 ever committed."); +} From e9b2d48e8f369e4badf3919f4110cd21a541abc9 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:43:59 -0400 Subject: [PATCH 074/315] bank the certification for fe40276..a982aec, and mark the earlier one superseded Green at the tip: mode=per-target rc=0 passed=2545 failed=0 head=a982aec, go 97/0. Count unchanged from the 27c9d51 run because the two commits since add an example and a bench/ text file, and examples carry no tests. Records two things the tooling does not say by itself: staleness.sh reported COVERED while measuring against agent-isolation, not main, and main HAD moved -- measured directly, this branch is 1 behind. And the commit it is behind, e280517, touches one test file with a net #[test] delta of 0 and a file set disjoint from this branch's, so the landing lane should expect 2545 after the merge. That is a falsifiable prediction, not a reassurance. --- bench/d178-27c9d51_certified/README.txt | 6 + bench/d178-a982aec_certified/README.txt | 51 + bench/d178-a982aec_certified/suite.go.log | 330 ++ bench/d178-a982aec_certified/suite.log | 3927 +++++++++++++++++++++ bench/d178-a982aec_certified/verdict.txt | 2 + 5 files changed, 4316 insertions(+) create mode 100644 bench/d178-a982aec_certified/README.txt create mode 100644 bench/d178-a982aec_certified/suite.go.log create mode 100644 bench/d178-a982aec_certified/suite.log create mode 100644 bench/d178-a982aec_certified/verdict.txt diff --git a/bench/d178-27c9d51_certified/README.txt b/bench/d178-27c9d51_certified/README.txt index d48bd4e4..5d425fb1 100644 --- a/bench/d178-27c9d51_certified/README.txt +++ b/bench/d178-27c9d51_certified/README.txt @@ -72,3 +72,9 @@ fixture. Recorded as Amendment 2 in bench/d178_prereg.txt rather than fixed quie Full write-up: /Users/idide/wt/artie-research/frontier/d178_dml_index.md Pre-registration + amendments: bench/d178_prereg.txt Raws: bench/d178_run1_BEFORE_RAW.txt, _run2_MID_RAW.txt, _run3_AFTER_RAW.txt, _run4_MERGE_RAW.txt + +--- +SUPERSEDED BY bench/d178-a982aec_certified/ — two commits landed after 27c9d51 (the +plan-space differential a982aec, which corrects a claim about the H1 fix, and this +certification itself). This run was correct for the commit it names; it simply stopped +covering the tip. The test total is unchanged at 2545. diff --git a/bench/d178-a982aec_certified/README.txt b/bench/d178-a982aec_certified/README.txt new file mode 100644 index 00000000..b7f2c21c --- /dev/null +++ b/bench/d178-a982aec_certified/README.txt @@ -0,0 +1,51 @@ +D178 CERTIFICATION — the branch tip, superseding bench/d178-27c9d51_certified/. + +RANGE CERTIFIED: fe40276..a982aec (14 commits) + + d178-planspace: mode=per-target rc=0 passed=2545 failed=0 build_errors=0 head=a982aec + d178-planspace: go rc=0 passed=97 failed=0 + + ⇒ +14 @ fe40276, mode=per-target (baseline 2531). Unchanged from the 27c9d51 run: the two + commits since then add an EXAMPLE and a bench/ text file, and examples carry no #[test]. + +WHY THIS SUPERSEDES THE 27c9d51 RUN. Not because that run was wrong — it was correct for the +commit it named. Two commits landed after it: + a982aec the plan-space differential, which CORRECTS a claim I had made about the H1 fix + a9fd980 the banked 27c9d51 certification itself +A green certifies the commit it NAMES, so the earlier one stopped covering the tip. + +INDEPENDENT CROSS-CHECK, run rather than trusted: + sum of every "^test result: ok. N passed" line = 2545 (matches the reported total) + "^test result: FAILED" .... 0 "panicked at" .... 0 + "^failures:" blocks ....... 0 targets run ...... 141 + INCONCLUSIVE.txt .......... absent + +GATES: + certify-head OK — "suite head=a982aec == landing a982aec", rc=0 + staleness COVERED, rc=0 + ⚠ BUT staleness.sh MEASURES AGAINST `agent-isolation`, NOT `main`, and reported "0 behind" + while main HAD in fact moved. Measured directly: `git rev-list --left-right --count + main...HEAD` = 1 behind, 14 ahead. COVERED is true about the base it checked and says + nothing about main. Do not read it as "up to date with main". + +THE ONE COMMIT THIS BRANCH IS BEHIND, and why the green still stands: + e280517 "d173: label every hold_leader call site" touches exactly ONE file, + tests/integration_cluster_agents.rs, and it is a labelling change to an existing test. + File sets are DISJOINT from this branch's (checked with comm, not eyeballed), it touches no + cluster code this branch goes near, and its net #[test] delta is 0 (35 before, 35 after). + ⇒ FALSIFIABLE PREDICTION FOR THE LANDING LANE: merging this branch onto current main should + report per-target passed=2545, unchanged. Anything else means something other than these + two changes moved. + ⚠ merge-tree returns rc=0, i.e. no LINE collided. That is not the same as the result being + coherent; the disjoint file sets and the zero test delta are the claims, not the rc. + +NOT REBASED ON PURPOSE. A suite was running against this tree, and rebasing would have voided +the green just taken. The d178-on-main lane owns the merge; as of this writing it had merged +27c9d51, which predates a982aec and therefore predates the correction in it. + +WHAT A GREEN HERE DOES NOT MEAN — see bench/d178-27c9d51_certified/README.txt, which still +applies in full, plus: + * The plan-space differential says SELECT's plan space DID change: 6 corpus rows go + error -> correct answer, and 1 goes worse-plan -> better-plan. No answer moved anywhere. + "The guard only removes unexecutable plans" was my argument and the measurement refuted it. + See bench/d178_run5_PLANSPACE_RAW.txt. diff --git a/bench/d178-a982aec_certified/suite.go.log b/bench/d178-a982aec_certified/suite.go.log new file mode 100644 index 00000000..f9757d28 --- /dev/null +++ b/bench/d178-a982aec_certified/suite.go.log @@ -0,0 +1,330 @@ +=== RUN TestAnyDuplicationOrReplayConvergesToTheSameState +--- PASS: TestAnyDuplicationOrReplayConvergesToTheSameState (0.54s) +=== RUN TestAStaleEventLaterInTheFeedIsFilteredByTheCursor +--- PASS: TestAStaleEventLaterInTheFeedIsFilteredByTheCursor (0.03s) +=== RUN TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn +--- PASS: TestTheSQLOrderingGuardRejectsAStaleWriteOnItsOwn (0.00s) +=== RUN TestValidateRefusesAnEmptyFeed +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAnEmptyFeed (0.00s) +=== RUN TestValidateRefusesAFeedThatIsMissingItsFinalNewline +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestValidateRefusesAFeedThatIsMissingItsFinalNewline (0.00s) +=== RUN TestSinkRefusesAnEmptyFeed +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE t +--- PASS: TestSinkRefusesAnEmptyFeed (0.00s) +=== RUN TestDuckSinkRefusesWithoutAKeyColumn +--- PASS: TestDuckSinkRefusesWithoutAKeyColumn (0.02s) +=== RUN TestDiffRefusesAMalformedSourceDump +MATCH 1 row(s) from 1 event(s) +--- PASS: TestDiffRefusesAMalformedSourceDump (0.00s) +=== RUN TestEveryRowOfOneCommitLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 103 TABLE t +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE t +--- PASS: TestEveryRowOfOneCommitLands (0.01s) +=== RUN TestTheLaterRecordInOneCommitWinsForTheSameKey +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 103 TABLE t +--- PASS: TestTheLaterRecordInOneCommitWinsForTheSameKey (0.01s) +=== RUN TestEveryRowOfASnapshotLands +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 9 TABLE t +--- PASS: TestEveryRowOfASnapshotLands (0.01s) +=== RUN TestI20RegressionInferredDestinationThenDeclaredCreateTable +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 11 TABLE inv + i20_regression_test.go:43: destination shape after the inference path: [[id TEXT] [qty TEXT]] +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestI20RegressionInferredDestinationThenDeclaredCreateTable (0.01s) +=== RUN TestI20RegressionInferredCatchUpOverADeclaredDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 31 TABLE inv + i20_regression_test.go:87: rows: [[1 10 ] [2 20 new]] +--- PASS: TestI20RegressionInferredCatchUpOverADeclaredDestination (0.01s) +=== RUN TestI20RegressionTwoTablesDifferentKeys +=== RUN TestI20RegressionTwoTablesDifferentKeys/sqlite + i20_regression_test.go:115: sqlite: refused a second table whose key is not the flag: apply INSERT to orders: SQL logic error: ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint (1) +=== RUN TestI20RegressionTwoTablesDifferentKeys/duckdb + i20_regression_test.go:115: duckdb: refused a second table whose key is not the flag: table orders has no key column "id"; the ordering guard needs one +--- PASS: TestI20RegressionTwoTablesDifferentKeys (0.03s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/sqlite (0.01s) + --- PASS: TestI20RegressionTwoTablesDifferentKeys/duckdb (0.03s) +=== RUN TestI20RegressionKeyForOnAPrimaryKeylessDestination + i20_regression_test.go:145: keyFor on a PK-less destination: "id" (flag is "id") +--- PASS: TestI20RegressionKeyForOnAPrimaryKeylessDestination (0.00s) +=== RUN TestI20RegressionKeyCacheAcrossDropAndRecreate +applied 8, skipped 0 re-delivered +APPLIED 8 SKIPPED 0 CURSOR 71 TABLE inv + i20_regression_test.go:183: after drop+recreate: [[1 6]] +--- PASS: TestI20RegressionKeyCacheAcrossDropAndRecreate (0.01s) +=== RUN TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +applied 1, skipped 2 re-delivered +APPLIED 1 SKIPPED 2 CURSOR 2 TABLE inv +--- PASS: TestI20RegressionReplayOfAnOriginalDeclarationAfterARetype (0.01s) +=== RUN TestI20RegressionTypeCheckAgainstAHandBuiltDestination +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestI20RegressionTypeCheckAgainstAHandBuiltDestination (0.01s) +=== RUN TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip +--- PASS: TestSameNumberAcceptsAFloatThatSurvivedItsRoundTrip (0.00s) +=== RUN TestSameNumberStillCatchesDigitsNoDoubleCanHold +--- PASS: TestSameNumberStillCatchesDigitsNoDoubleCanHold (0.00s) +=== RUN TestExactFloatIsExactForSubnormals +--- PASS: TestExactFloatIsExactForSubnormals (0.00s) +=== RUN TestExactFloatRoundTripsToTheSameDouble +--- PASS: TestExactFloatRoundTripsToTheSameDouble (0.00s) +=== RUN TestConsumerRefusesADeniedColumnTheProducerLetThrough +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesADeniedColumnTheProducerLetThrough (0.00s) +=== RUN TestConsumerChecksTheBeforeImageToo +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerChecksTheBeforeImageToo (0.00s) +=== RUN TestConsumerRefusesAnUndecidedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnUndecidedTable (0.00s) +=== RUN TestConsumerRefusesASchemaEventDeclaringADeniedColumn +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesASchemaEventDeclaringADeniedColumn (0.00s) +=== RUN TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesAShapeNarrowerThanItsOwnPolicy (0.00s) +=== RUN TestADropIsNotJudgedByTheShapeCheck +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestADropIsNotJudgedByTheShapeCheck (0.00s) +=== RUN TestWithNoPublicationNothingIsRefused +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestWithNoPublicationNothingIsRefused (0.00s) +=== RUN TestUsePublicationRefusesAFileItCannotRead +--- PASS: TestUsePublicationRefusesAFileItCannotRead (0.00s) +=== RUN TestParsePublicationRefusesEveryMalformedShape +--- PASS: TestParsePublicationRefusesEveryMalformedShape (0.00s) +=== RUN TestTheSinkRefusesADeniedColumnBeforeLandingIt +applied 1, skipped 0 re-delivered +APPLIED 1 SKIPPED 0 CURSOR 21 TABLE customers +--- PASS: TestTheSinkRefusesADeniedColumnBeforeLandingIt (0.02s) +=== RUN TestPrecisionRefusesADeniedColumnRatherThanPrintingIt +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 id number 1 +FIELD 1 name string ada +SUMMARY strings=1 numbers=1 lossy=0 +FIELD 1 id number 1 +FIELD 1 name string ada +FIELD 1 ssn string 000-11-2222 +SUMMARY strings=2 numbers=1 lossy=0 +--- PASS: TestPrecisionRefusesADeniedColumnRatherThanPrintingIt (0.00s) +=== RUN TestPrecisionRefusesAnUndecidedTable +--- PASS: TestPrecisionRefusesAnUndecidedTable (0.00s) +=== RUN TestConsumerChecksADropsBeforeImage +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerChecksADropsBeforeImage (0.00s) +=== RUN TestConsumerRefusesDuplicateKeysInOneObject +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesDuplicateKeysInOneObject (0.00s) +=== RUN TestConsumerRefusesKeysOutsideTheEnvelope +OK 1 +WRITERS attributed=0 unattributed=0 +--- PASS: TestConsumerRefusesKeysOutsideTheEnvelope (0.00s) +=== RUN TestConsumerRefusesANonScalarRowValue +OK 1 +WRITERS attributed=0 unattributed=1 +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesANonScalarRowValue (0.00s) +=== RUN TestConsumerRefusesAnEventForAnExcludedTable +OK 1 +WRITERS attributed=0 unattributed=1 +--- PASS: TestConsumerRefusesAnEventForAnExcludedTable (0.00s) +=== RUN TestParsePublicationHandlesExclusions +--- PASS: TestParsePublicationHandlesExclusions (0.00s) +=== RUN TestAnAttributedEventSurvivesAPublication +--- PASS: TestAnAttributedEventSurvivesAPublication (0.00s) +=== RUN TestAnUnknownTopLevelKeyIsStillRefused +--- PASS: TestAnUnknownTopLevelKeyIsStillRefused (0.00s) +=== RUN TestBothSinksLandTheSameWriterColumnNames +--- PASS: TestBothSinksLandTheSameWriterColumnNames (0.00s) +=== RUN TestRetractWorksAgainstADuckDBDestination +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=duckdb +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=duckdb +--- PASS: TestRetractWorksAgainstADuckDBDestination (0.07s) +=== RUN TestRetractInDeleteModeAgainstDuckDB +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=duckdb +--- PASS: TestRetractInDeleteModeAgainstDuckDB (0.05s) +=== RUN TestRetractAndScanRefuseAnUnknownEngine +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractAndScanRefuseAnUnknownEngine (0.01s) +=== RUN TestAnOlderDuckDBDestinationIsUpgradedInPlace +RETRACTED 3 OF 7 table=inv model_version=2026-07 mode=quarantine engine=duckdb +--- PASS: TestAnOlderDuckDBDestinationIsUpgradedInPlace (0.07s) +=== RUN TestRetractTouchesExactlyOneModelVersionsRows +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestRetractTouchesExactlyOneModelVersionsRows (0.01s) +=== RUN TestRetractInDeleteModeAlsoTombstones +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=delete engine=sqlite +--- PASS: TestRetractInDeleteModeAlsoTombstones (0.01s) +=== RUN TestARetractionThatMatchesNothingIsRefused +RETRACTED 2 OF 6 table=inv model_version=2026-05 mode=quarantine engine=sqlite +--- PASS: TestARetractionThatMatchesNothingIsRefused (0.01s) +=== RUN TestAnEmptyModelVersionIsRefused +--- PASS: TestAnEmptyModelVersionIsRefused (0.01s) +=== RUN TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing +--- PASS: TestADestinationWithNoAttributionIsRefusedRatherThanRetractingNothing (0.00s) +=== RUN TestARetractionSurvivesAReplayButNotAFreshWrite +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +--- PASS: TestARetractionSurvivesAReplayButNotAFreshWrite (0.01s) +=== RUN TestScanReportsEveryRowsWriterAndMark +RETRACTED 3 OF 6 table=inv model_version=2026-07 mode=quarantine engine=sqlite +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=1 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanReportsEveryRowsWriterAndMark (0.01s) +=== RUN TestAWriterObjectWithAnUnknownKeyIsRefused +--- PASS: TestAWriterObjectWithAnUnknownKeyIsRefused (0.00s) +=== RUN TestAMalformedWriterIsRefused +--- PASS: TestAMalformedWriterIsRefused (0.00s) +=== RUN TestScanRefusesAnEmptyTable +ROW id=1 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=2 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=3 prov_id=1 agent=restock-agent model_version=2026-05 retracted=0 deleted=0 +ROW id=4 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=5 prov_id=2 agent=restock-agent model_version=2026-07 retracted=0 deleted=0 +ROW id=6 prov_id=0 agent= model_version= retracted=0 deleted=0 +SCANNED 6 table=inv engine=sqlite +--- PASS: TestScanRefusesAnEmptyTable (0.01s) +=== RUN TestWellFormedColumnLevelEventsAreAccepted +--- PASS: TestWellFormedColumnLevelEventsAreAccepted (0.00s) +=== RUN TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape +--- PASS: TestColumnLevelEventsAreCrossCheckedAgainstTheirOwnShape (0.00s) +=== RUN TestColumnLevelChangesAreNewsNotDeclarations +--- PASS: TestColumnLevelChangesAreNewsNotDeclarations (0.00s) +=== RUN TestTableFoldSurvivesAColumnLevelChange +--- PASS: TestTableFoldSurvivesAColumnLevelChange (0.00s) +=== RUN TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds +--- PASS: TestTheFoldMovesRenamedValuesInRowsItAlreadyHolds (0.00s) +=== RUN TestTheFoldReEncodesAColumnThatBecameStringTyped +--- PASS: TestTheFoldReEncodesAColumnThatBecameStringTyped (0.00s) +=== RUN TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows +applied 5, skipped 0 re-delivered +APPLIED 5 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkAddsAColumnMidStreamAndKeepsLandingRows (0.00s) +=== RUN TestReplayingAFeedWithASchemaChangeIsANoOp +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +applied 1, skipped 3 re-delivered +APPLIED 1 SKIPPED 3 CURSOR 2 TABLE inv +--- PASS: TestReplayingAFeedWithASchemaChangeIsANoOp (0.01s) +=== RUN TestTheSqliteSinkRenamesRatherThanRebuilding +applied 3, skipped 0 re-delivered +APPLIED 3 SKIPPED 0 CURSOR 21 TABLE inv +--- PASS: TestTheSqliteSinkRenamesRatherThanRebuilding (0.00s) +=== RUN TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkRebuildsForARetypeAndKeepsTheRows (0.01s) +=== RUN TestASinkThatNeverSawTheCreateStillGetsTheShape +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestASinkThatNeverSawTheCreateStillGetsTheShape (0.00s) +=== RUN TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 51 TABLE inv +--- PASS: TestASinkThatMissedTheAlterCatchesUpFromTheReDeclaration (0.00s) +=== RUN TestTheCatchUpRefusesAnythingThatIsNotAPrefix +--- PASS: TestTheCatchUpRefusesAnythingThatIsNotAPrefix (0.00s) +=== RUN TestTheFeedLandsInDuckdbAcrossAMissedAlter +--- PASS: TestTheFeedLandsInDuckdbAcrossAMissedAlter (0.02s) +=== RUN TestTheSqliteCatchUpRefusesARetypeInTheDeclaration +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestTheSqliteCatchUpRefusesARetypeInTheDeclaration (0.00s) +=== RUN TestTheSqliteCatchUpComparesTypesAndNotOnlyNames +--- PASS: TestTheSqliteCatchUpComparesTypesAndNotOnlyNames (0.00s) +=== RUN TestTheSqliteSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheSqliteSinkFollowsARenameOfTheKeyColumn (0.01s) +=== RUN TestTheDuckSinkFollowsARenameOfTheKeyColumn +applied 4, skipped 0 re-delivered +APPLIED 4 SKIPPED 0 CURSOR 31 TABLE inv +--- PASS: TestTheDuckSinkFollowsARenameOfTheKeyColumn (0.04s) +=== RUN TestARenameTruncatedAwayStallsTheSinkButNamesTheCause +applied 2, skipped 0 re-delivered +APPLIED 2 SKIPPED 0 CURSOR 11 TABLE inv +--- PASS: TestARenameTruncatedAwayStallsTheSinkButNamesTheCause (0.00s) +=== RUN TestDuckTypeMapping +--- PASS: TestDuckTypeMapping (0.00s) +=== RUN TestWideTypesLandExactlyInDuckDB +--- PASS: TestWideTypesLandExactlyInDuckDB (0.02s) +=== RUN TestDuckTypeOfInfersFromTheValue +--- PASS: TestDuckTypeOfInfersFromTheValue (0.00s) +=== RUN TestEnsureDuckTableRefusesATypeOutsideTheAllowlist +--- PASS: TestEnsureDuckTableRefusesATypeOutsideTheAllowlist (0.01s) +=== RUN TestEnsureDuckTableRefusesATableWithoutTheKey +--- PASS: TestEnsureDuckTableRefusesATableWithoutTheKey (0.01s) +=== RUN TestApplyRejectsAStaleEventAndAcceptsANewerOne +--- PASS: TestApplyRejectsAStaleEventAndAcceptsANewerOne (0.02s) +=== RUN TestDeleteIsSoftAndRejectsAStaleResurrection +--- PASS: TestDeleteIsSoftAndRejectsAStaleResurrection (0.02s) +=== RUN TestColumnsComeFromTheCatalogNotFromGuessing +--- PASS: TestColumnsComeFromTheCatalogNotFromGuessing (0.03s) +=== RUN TestApplyRefusesARowWithNoKeyColumn +--- PASS: TestApplyRefusesARowWithNoKeyColumn (0.01s) +=== RUN TestOpenDestinationRefusesAnUnknownEngine +--- PASS: TestOpenDestinationRefusesAnUnknownEngine (0.01s) +=== RUN TestALaterCreateTableThatDisagreesIsRefused +--- PASS: TestALaterCreateTableThatDisagreesIsRefused (0.02s) +=== RUN TestARepeatedIdenticalCreateTableIsStillANoOp +--- PASS: TestARepeatedIdenticalCreateTableIsStillANoOp (0.02s) +=== RUN TestAHostileTypeInACreateTableEventCannotReachTheDDL +--- PASS: TestAHostileTypeInACreateTableEventCannotReachTheDDL (0.01s) +=== RUN TestRenderCellMatchesItsRecordedCLIExpectations +--- PASS: TestRenderCellMatchesItsRecordedCLIExpectations (0.00s) +=== RUN TestRenderCellAgreesWithTheRealDuckdbCLI +--- PASS: TestRenderCellAgreesWithTheRealDuckdbCLI (0.10s) +=== RUN TestQuoteIdentDoublesEmbeddedQuotes +--- PASS: TestQuoteIdentDoublesEmbeddedQuotes (0.00s) +=== RUN TestSQLTypeMapping +--- PASS: TestSQLTypeMapping (0.00s) +=== RUN TestDecodeLineRejectsMalformedEnvelopes +--- PASS: TestDecodeLineRejectsMalformedEnvelopes (0.00s) +=== RUN TestReadIsExemptFromTheResumePointRule +--- PASS: TestReadIsExemptFromTheResumePointRule (0.00s) +=== RUN TestTableApplyFoldsOpsCorrectly +--- PASS: TestTableApplyFoldsOpsCorrectly (0.00s) +=== RUN TestApplyRefusesARowWithNoKey +--- PASS: TestApplyRefusesARowWithNoKey (0.00s) +PASS +ok github.com/RyanLin5967/ferrodb/cdc-consumer 1.824s diff --git a/bench/d178-a982aec_certified/suite.log b/bench/d178-a982aec_certified/suite.log new file mode 100644 index 00000000..e0ec1f2c --- /dev/null +++ b/bench/d178-a982aec_certified/suite.log @@ -0,0 +1,3927 @@ +=== per-target sweep: --lib plus 140 integration targets +=== target --lib + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:800:13 + | +800 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.33s + Running unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) + +running 1661 tests +test agent_sql::changeset::tests::an_empty_merge_is_clean ... ok +test agent_sql::escrow::tests::an_unbounded_cell_is_not_escrowed_and_writes_pass_through ... ok +test agent_sql::gate::tests::a_cheap_check_that_almost_never_fires_sorts_last ... ok +test agent_sql::changeset::tests::a_lossy_row_makes_the_whole_merge_lossy_never_clean ... ok +test agent_sql::escrow::tests::a_branch_cannot_spend_against_a_claim_it_never_made ... ok +test agent_sql::escrow::tests::a_negative_or_zero_claim_is_refused ... ok +test agent_sql::escrow::tests::releasing_a_branch_returns_its_unspent_claim_to_the_pool ... ok +test agent_sql::escrow::tests::two_agents_cannot_both_reserve_twelve_out_of_twenty ... ok +test agent_sql::escrow::tests::settling_consumes_only_what_was_actually_spent ... ok +test agent_sql::escrow::tests::a_batch_that_only_overdraws_in_aggregate_is_refused ... ok +test agent_sql::escrow::tests::reopening_a_pool_is_refused_rather_than_resizing_it ... ok +test agent_sql::escrow::tests::a_recycled_id_slot_does_not_inherit_the_dead_branchs_escrow ... ok +test agent_sql::escrow::tests::the_counter_cannot_be_driven_below_its_floor ... ok +test agent_sql::dispatch::tests::a_row_id_past_i64_max_is_not_reported_as_negative ... ok +test agent_sql::escrow::tests::a_write_beyond_the_branchs_claim_is_refused_at_write_time ... ok +test agent_sql::changeset::tests::a_conflicting_row_outranks_a_commuting_one ... ok +test agent_sql::gate::tests::tier_order_is_computed_from_cost_over_rejection_probability ... ok +test agent_sql::gate::tests::the_most_severe_status_in_a_tier_decides ... ok +test agent_sql::gate::tests::an_empty_gate_and_a_gate_where_nothing_fires_both_pass ... ok +test agent_sql::gate::tests::every_check_in_a_tier_runs_even_after_one_has_fired ... ok +test agent_sql::gate::tests::the_blind_write_metric_quarantines_and_cannot_reject ... ok +test agent_sql::gate::tests::an_assertion_that_examined_no_rows_is_a_hard_reject_not_a_pass ... ok +test agent_sql::gate::tests::a_row_that_could_not_be_evaluated_outranks_every_row_that_held ... ok +test agent_sql::gate::tests::a_sound_finding_carries_the_violated_predicate ... ok +test agent_sql::gate::tests::every_broken_assertion_reaches_the_caller_in_one_round_trip ... ok +test agent_sql::dispatch::tests::every_agent_output_row_matches_its_declared_column_count ... ok +test agent_sql::gate::tests::a_later_tier_does_not_run_once_an_earlier_one_has_fired ... ok +test agent_sql::gate::tests::outcome_follows_epistemic_status_not_severity ... ok +test agent_sql::dispatch::tests::only_an_empty_diff_returns_no_rows ... ok +test agent_sql::merge_engine::tests::a_replayed_frame_contributes_its_increments_once ... ok +test agent_sql::gate::tests::an_assertion_fires_when_it_is_false_and_stays_quiet_when_it_holds ... ok +test agent_sql::merge_engine::tests::a_guard_that_fails_against_merged_state_returns_its_predicate ... ok +test agent_sql::merge_engine::tests::an_unevaluable_guard_is_not_reported_as_a_failed_one ... ok +test agent_sql::merge_engine::tests::identical_concurrent_writes_are_not_a_conflict ... ok +test agent_sql::merge_engine::tests::concurrent_assigns_are_forbidden_by_default_not_silently_lww ... ok +test agent_sql::merge_engine::tests::composing_two_identical_adds_does_not_collapse_them ... ok +test agent_sql::merge_engine::tests::invert_refuses_an_assign_with_no_before_image ... ok +test agent_sql::merge_engine::tests::lww_succeeds_but_reports_the_discarded_write ... ok +test agent_sql::merge_engine::tests::two_decrements_compose_arithmetically ... ok +test agent_sql::paged_rows::tests::a_table_range_excludes_its_neighbours ... ok +test agent_sql::paged_rows::tests::an_empty_row_round_trips ... ok +test agent_sql::mem_catalog::tests::fork_records_the_child_in_the_parent_and_shares_the_root ... ok +test agent_sql::mem_catalog::tests::reading_a_reaped_branch_is_a_hard_error_not_stale_data ... ok +test agent_sql::paged_rows::tests::an_unknown_tag_is_refused ... ok +test agent_sql::designated::tests::refuses_only_a_runtime_that_is_not_designated ... ok +test agent_sql::paged_rows::tests::split_rejects_a_wrong_length_key ... ok +test agent_sql::paged_rows::tests::the_last_table_id_still_has_a_usable_upper_bound ... ok +test agent_sql::paged_rows::tests::every_value_variant_round_trips ... ok +test agent_sql::paged_rows::tests::a_truncated_row_is_refused_rather_than_panicking ... ok +test agent_sql::paged_rows::tests::each_wide_variant_round_trips_on_its_own ... ok +test agent_sql::merge_engine::tests::merger_trait_reports_commuting_for_two_branch_decrements ... ok +test agent_sql::paged_rows::tests::trailing_bytes_are_refused ... ok +test agent_sql::paged_rows::tests::keys_sort_in_numeric_row_order ... ok +test agent_sql::persistent_map::tests::empty_map_answers_without_panicking ... ok +test agent_sql::persistent_map::tests::insert_if_absent_is_first_touch_wins ... ok +test agent_sql::persistent_map::tests::a_no_op_insert_if_absent_shares_the_whole_tree ... ok +test agent_sql::persistent_map::tests::string_keys_borrow_for_lookup ... ok +test agent_sql::persistent_map::tests::size_hint_is_exact_so_collect_preallocates ... ok +test agent_sql::persistent_map::tests::a_chain_of_clones_sees_its_ancestry_at_fork_time_and_nothing_later ... ok +test agent_sql::runtime::tests::a_reservation_that_would_re_issue_a_version_sequence_is_refused ... ok +test agent_sql::persistent_map::tests::siblings_are_invisible_to_each_other ... ok +test agent_sql::runtime::tests::a_recycled_slot_installs_the_new_workspace_before_releasing_the_old ... ok +test agent_sql::runtime::tests::the_ordinary_reservation_and_a_leaked_range_are_both_allowed ... ok +test agent_sql::runtime::tests::a_recycled_slot_does_not_strand_the_displaced_workspaces_capture ... ok +test agent_sql::runtime::tests::the_txn_ref_index_tracks_a_scan_of_the_workspaces ... ok +test agent_sql::persistent_map::tests::range_agrees_with_btreemap_on_every_window ... ok +test agent_sql::persistent_map::tests::a_clone_does_not_see_writes_made_through_the_original_afterwards ... ok +test agent_sql::runtime::tests::the_index_scan_assertion_fires_on_a_desynchronised_index - should panic ... ok +test agent_sql::runtime::tests::the_door_audit_fires_on_a_desynchronised_index - should panic ... ok +test agent_sql::persistent_map::tests::the_original_does_not_see_writes_made_through_a_clone ... ok +test agent_sql::persistent_map::tests::a_write_after_a_clone_shares_every_subtree_it_did_not_touch ... ok +test agent_sql::persistent_map::tests::one_write_allocates_a_path_not_a_tree ... ok +test agent_sql::persistent_map::tests::descending_inserts_stay_balanced_too ... ok +test agent_sql::persistent_map::tests::ascending_inserts_stay_balanced_rather_than_degenerating_into_a_list ... ok +test binder::binder::tests::test_duplicate_qualifier ... ok +test agent_sql::paged_rows::tests::the_last_table_scans_its_own_rows ... ok +test agent_sql::paged_rows::tests::a_scan_returns_only_the_requested_table_in_row_order ... ok +test agent_sql::paged_rows::tests::a_row_survives_a_round_trip_through_pages ... ok +test binder::binder::tests::test_expand_star ... ok +test agent_sql::paged_rows::tests::a_delete_removes_only_its_own_row ... ok +test binder::binder::tests::test_resolve_ambiguous ... ok +test binder::binder::tests::test_resolve_unknown_column ... ok +test agent_sql::paged_rows::tests::a_write_under_one_root_is_invisible_from_another_root ... ok +test binder::binder::tests::test_resolve_qualified_unique ... ok +test binder::binder::tests::test_resolve_unknown_table ... ok +test binder::binder::tests::test_ambiguous_column ... ok +test agent_sql::persistent_map::tests::it_behaves_like_a_btreemap_over_a_random_looking_workload ... ok +test binder::binder::tests::test_bind_rejects_an_agent_statement_as_a_plan ... ok +test binder::binder::tests::every_statement_bind_does_not_plan_returns_an_error_rather_than_panicking ... ok +test binder::binder::tests::test_binding_a_plain_plan_from_an_as_of_table_is_refused_not_silently_main ... ok +test binder::binder::tests::test_duplicate_qualifier_bind ... ok +test binder::binder::tests::test_literal ... ok +test binder::binder::tests::test_join ... ok +test binder::binder::tests::test_simple_select ... ok +test binder::binder::tests::test_bind_revert_merge_defaults_to_halt ... ok +test binder::binder::tests::test_self_join ... ok +test binder::binder::tests::test_select_star ... ok +test binder::binder::tests::test_unkown_table ... ok +test branch::arena::tests::a_base_whose_id_was_reissued_is_refused_rather_than_encoded_against ... ok +test branch::arena::tests::a_corrupt_free_space_map_is_refused_not_partially_loaded ... ok +test branch::arena::tests::a_private_page_is_mutated_in_place_not_shadowed ... ok +test branch::arena::tests::a_released_page_forgets_the_base_it_was_a_shadow_of ... ok +test branch::arena::tests::a_checkpoint_describing_another_region_is_refused ... ok +test branch::arena::tests::a_shadow_that_changed_a_little_encodes_to_a_delta_far_under_a_page ... ok +test branch::arena::tests::a_shadow_nobody_touched_encodes_to_no_runs_although_its_header_differs ... ok +test branch::arena::tests::a_chain_stops_growing_at_the_bound_rather_than_being_caught_on_read ... ok +test branch::arena::tests::a_shadow_that_rewrote_the_page_is_refused_a_delta ... ok +test branch::arena::tests::a_v2_checkpoint_image_still_loads_after_the_v3_bump ... ok +test branch::arena::tests::a_torn_page_is_refused_rather_than_returned ... ok +test branch::arena::tests::an_inherited_page_is_shadowed_and_the_original_is_left_alone ... ok +test binder::binder::tests::test_bind_select_as_of_resolves_the_branch_and_the_columns ... ok +test binder::binder::tests::test_bind_without_a_session_or_branch_is_an_error ... ok +test binder::binder::tests::test_bind_diff_merge_abandon_default_to_the_session_branch ... ok +test branch::arena::tests::an_arena_region_is_still_off_limits_after_reopening_the_file ... ok +test branch::arena::tests::arena_rolls_over_when_an_extent_fills ... ok +test branch::arena::tests::an_unarmed_store_appends_nothing ... ok +test branch::arena::tests::d85_extent_is_empty_refuses_an_unprobed_restored_extent ... ok +test binder::binder::tests::a_bound_session_carries_the_prompt_verbatim ... ok +test branch::arena::tests::d87_probing_an_extent_from_zero_reports_the_pages_actually_written ... ok +test branch::arena::tests::fork_allocates_no_page_at_all ... ok +test branch::arena::tests::freed_extents_are_recycled_rather_than_leaked ... ok +test branch::arena::tests::free_space_map_survives_a_restart ... ok +test branch::arena::tests::freeing_a_superseded_extent_leaves_the_owner_on_its_current_one ... ok +test branch::arena::tests::freeing_an_extent_forgets_its_owner_and_no_other_branch ... ok +test branch::arena::tests::freeing_an_extent_gives_back_its_fill_suspicion_too ... ok +test branch::arena::tests::freeing_an_extent_forgets_only_its_own_shadow_bases ... ok +test binder::binder::tests::test_bind_begin_agent_session ... ok +test branch::arena::tests::privacy_barrier_is_the_later_of_own_fork_and_latest_child_fork ... ok +test branch::arena::tests::live_arenas_comes_back_in_arena_id_order ... ok +test branch::arena::tests::a_zero_filled_extension_is_read_as_the_end_of_the_tail ... ok +test branch::arena::tests::shadowing_a_page_you_own_records_no_delta_base ... ok +test branch::arena::tests::checkpoint_round_trips_through_a_file ... ok +test branch::arena::tests::shadowing_your_own_page_across_a_child_fork_parks_the_original ... ok +test branch::arena::tests::store_refuses_to_share_the_bitmap_allocators_region ... ok +test branch::arena::tests::a_bad_record_with_records_behind_it_is_refused_rather_than_silently_truncating_the_map ... ok +test branch::arena::tests::d85_next_free_after_a_crash_and_what_extent_is_empty_then_says ... ok +test branch::arena::tests::the_checkpoint_image_lists_extents_and_current_arenas_in_key_order ... ok +test branch::arena::tests::the_image_walker_refuses_a_corrupt_image_instead_of_trusting_its_counts ... ok +test branch::arena::tests::the_image_walker_agrees_with_state_bytes_on_a_fully_populated_store ... ok +test branch::arena::tests::an_unknown_tail_record_kind_is_refused_rather_than_skipped ... ok +test branch::arena::tests::the_checkpoint_syncs_the_image_before_the_rename_and_the_directory_after_it ... ok +test branch::arena::tests::reopen_from_checkpoint_takes_the_base_from_the_image_not_the_caller ... ok +test branch::attest::tests::a_fork_from_an_unknown_parent_is_reported ... ok +test branch::attest::tests::a_branch_with_no_fork_entry_is_reported ... ok +test branch::attest::tests::a_recycled_id_slot_does_not_inherit_the_reaped_branchs_chain ... ok +test branch::attest::tests::a_rewritten_past_cannot_be_made_consistent_with_a_published_root ... ok +test branch::arena::tests::a_torn_final_record_is_dropped_and_the_records_in_front_of_it_survive ... ok +test branch::attest::tests::an_empty_head_must_still_carry_the_empty_root ... ok +test branch::attest::tests::an_honest_history_verifies ... ok +test branch::attest::tests::consistency_verification_refuses_malformed_proofs ... ok +test branch::arena::tests::two_stores_in_the_same_state_checkpoint_byte_identical_images ... ok +test branch::attest::tests::every_field_of_an_entry_is_covered_by_the_chain ... ok +test branch::attest::tests::genesis_is_not_zero ... ok +test branch::attest::tests::inclusion_verification_refuses_every_way_a_proof_can_be_wrong ... ok +test branch::attest::tests::a_third_party_holding_no_database_can_check_a_proof ... ok +test branch::attest::tests::largest_power_of_two_below_is_strict ... ok +test branch::attest::tests::leaf_and_node_hashing_are_domain_separated ... ok +test branch::attest::tests::mutating_the_last_entry_of_a_slot_is_caught_by_the_published_head ... ok +test branch::attest::tests::mutating_a_historical_record_breaks_the_chain_and_names_it ... ok +test branch::attest::tests::relabelling_an_entry_as_a_fork_cannot_excise_its_predecessors ... ok +test branch::attest::tests::replaying_a_fork_entry_cannot_truncate_a_branchs_ancestry ... ok +test branch::attest::tests::reordering_entries_breaks_consistency ... ok +test branch::attest::tests::the_empty_tree_head_is_the_published_sha256_of_the_empty_string ... ok +test branch::attest::tests::the_entry_encoding_is_fixed_width_and_injective ... ok +test branch::attest::tests::the_canonical_encoding_round_trips ... ok +test branch::attest::tests::the_generation_changes_the_attestation ... ok +test branch::attest::tests::the_op_codes_are_a_wire_format ... ok +test branch::attest::tests::truncation_is_invisible_to_a_chain_walk_and_visible_to_a_witnessed_root ... ok +ferrodb: AUDIT_TXN_REFS_DOWNGRADED — 1025 open workspaces exceeds AUDIT_FULL_MAX (1024), so the debug re-derive of `txn_refs` is no longer running at every door. It ran for the first 1024 crossings, which is where a door that bypasses insert_workspace/remove_workspace would have shown itself. A door that is correct below 1024 and wrong above it is NOT covered. +test branch::attest::tests::verification_does_not_fire_on_legitimate_histories ... ok +test branch::arena::tests::freeing_an_extent_checkpoints_the_map_so_a_restart_gets_the_space_back ... ok +test branch::attest::tests::verify_branch_findings_do_not_name_a_digest_nothing_produced ... ok +test branch::attest::tests::verifying_a_branch_that_is_not_in_the_log_is_a_finding_not_a_pass ... ok +test branch::catalog::tests::a_chain_far_past_the_old_cap_forks_and_reports_its_depth ... ok +test branch::attest::tests::verify_against_a_published_head_catches_a_mutation_at_every_index ... ok +test branch::catalog::tests::a_recycled_id_slot_never_answers_to_the_old_handle ... ok +test branch::catalog::tests::a_parent_listing_only_reaped_children_comes_back_with_an_empty_array ... ok +test branch::catalog::tests::a_slot_still_pinning_children_is_not_recycled ... ok +test branch::arena::tests::loading_a_map_from_outside_makes_the_next_persist_a_full_rewrite ... ok +test branch::catalog::tests::a_torn_tail_keeps_every_intact_record ... ok +test branch::catalog::tests::epochs_are_strictly_monotonic_across_forks ... ok +test branch::catalog::tests::every_record_sweep_comes_back_in_branch_id_order ... ok +test branch::catalog::tests::expired_before_excludes_trunk_the_unexpired_and_the_not_live ... ok +test branch::arena::tests::a_free_and_an_immediate_reclaim_of_one_range_survive_a_restart_in_that_order ... ok +test branch::arena::tests::the_public_checkpoint_stages_through_the_durable_helper ... ok +test branch::catalog::tests::fork_records_the_child_in_the_parent_atomically ... ok +test branch::catalog::tests::reading_a_reaped_branch_is_a_hard_error ... ok +test branch::catalog::tests::a_runtime_release_and_a_reload_agree_on_which_slot_is_next ... ok +test branch::catalog::tests::reparent_moves_the_four_position_fields_and_nothing_else ... ok +test branch::catalog::tests::reparent_refuses_a_branch_that_is_no_longer_readable ... ok +test branch::catalog::tests::the_live_child_queries_answer_the_rule_the_array_used_to ... ok +test branch::cherry::tests::a_failed_commit_that_touched_one_row_twice_restores_the_original_image ... ok +test branch::cherry::tests::a_landed_pick_is_undone_by_the_inverse_plan_through_the_same_door ... ok +test branch::cherry::tests::a_multi_row_pick_lands_every_row_in_one_commit ... ok +test branch::cherry::tests::a_selected_op_is_not_counted_as_its_own_divergence ... ok +test branch::cherry::tests::an_uninvertible_write_refuses_rather_than_guessing ... ok +test branch::cherry::tests::regression_an_unselected_source_op_must_not_excuse_a_target_move ... ok +test branch::cherry::tests::row01_unchanged_target_cell_applies_clean ... ok +test branch::cherry::tests::row02_commuting_divergence_is_found_when_the_targets_op_has_the_lower_seq ... ok +test branch::cherry::tests::row02_max_and_min_divergences_commute_as_well_as_add ... ok +test branch::cherry::tests::row02_target_moved_but_ops_commute_applies_composed_onto_target ... ok +test branch::catalog::tests::durable_catalog_survives_a_reopen ... ok +test branch::cherry::tests::row03_target_moved_and_ops_contradict_refuses_whole ... ok +test branch::cherry::tests::row04_target_moved_to_the_same_value_is_not_a_conflict ... ok +test branch::cherry::tests::row05_lww_policy_applies_and_reports_the_discarded_write ... ok +test branch::cherry::tests::row06_no_recorded_witness_refuses_rather_than_assuming_unchanged ... ok +test branch::cherry::tests::row07_cell_op_on_a_row_the_target_does_not_have_refuses ... ok +test branch::cherry::tests::row08_a_column_past_the_end_of_a_created_row_image_also_refuses ... ok +test branch::cherry::tests::row08_column_past_the_end_of_the_target_row_refuses ... ok +test branch::cherry::tests::row09_rowcreate_onto_an_absent_row_materialises_it ... ok +test branch::cherry::tests::row10_rowcreate_onto_a_row_that_already_exists_refuses ... ok +test branch::cherry::tests::row11_rowdelete_of_a_present_row_removes_it ... ok +test branch::cherry::tests::row12_rowdelete_of_an_absent_row_refuses ... ok +test branch::cherry::tests::row13_note_a_straddled_selection_is_reported_not_refused ... ok +test branch::cherry::tests::row13_two_selected_ops_on_one_cell_compose_into_one_write ... ok +test branch::cherry::tests::row14_ops_with_no_composition_in_the_algebra_refuse_whole ... ok +test branch::cherry::tests::row15_the_same_op_selected_twice_refuses_because_add_is_not_idempotent ... ok +test branch::cherry::tests::row16_a_cell_op_on_a_row_this_pick_creates_folds_into_the_insert ... ok +test branch::cherry::tests::row17_a_cell_op_after_a_delete_in_the_same_pick_refuses ... ok +test branch::cherry::tests::row18_a_selector_naming_no_recorded_op_refuses ... ok +test branch::cherry::tests::row19_a_selector_naming_another_branchs_op_refuses ... ok +test branch::cherry::tests::row20_an_empty_selection_is_refused_not_treated_as_satisfied ... ok +test branch::cherry::tests::row21_a_commit_that_fails_partway_leaves_the_target_unchanged ... ok +test branch::cherry::tests::row21_a_refusal_leaves_no_partial_write_even_when_earlier_ops_were_clean ... ok +test branch::cherry::tests::row21_control_the_same_selection_applies_whole_when_nothing_moved ... ok +test branch::cherry::tests::row22_a_set_op_on_a_cell_refuses_rather_than_erroring ... ok +test branch::cherry::tests::row23_a_whole_row_op_with_a_cell_kind_refuses_rather_than_erroring ... ok +test branch::cherry::tests::row24_a_delete_then_create_selection_replaces_the_row ... ok +test branch::cherry::tests::row25_a_create_then_delete_selection_still_checks_the_create ... ok +test branch::cherry::tests::row26_a_cell_op_before_a_delete_in_the_same_pick_is_subsumed ... ok +test branch::cherry::tests::row27_divergence_that_does_not_explain_the_targets_value_is_not_trusted ... ok +test branch::cherry::tests::the_inverse_of_a_create_is_a_delete_and_of_a_delete_a_create ... ok +test branch::attest::tests::the_incremental_tree_matches_the_rfc_recursion ... ok +test branch::arena::tests::a_reopened_store_rewrites_the_image_before_it_appends_again ... ok +test branch::cherry::tests::the_inverse_of_a_replacing_create_restores_the_prior_image ... ok +test branch::cherry::tests::the_straddle_reporter_is_silent_on_a_contiguous_selection ... ok +test branch::delta::delta_tests::a_compacting_update_blows_the_budget_and_is_stored_whole ... ok +test branch::delta::delta_tests::a_delta_chain_never_makes_a_read_deeper_than_the_bound ... ok +test branch::delta::delta_tests::a_delta_refuses_a_payload_that_is_not_a_page ... ok +test branch::delta::delta_tests::a_delta_round_trips_through_the_real_node_encoder ... ok +test branch::delta::delta_tests::a_record_claiming_a_run_past_the_payload_is_refused ... ok +test branch::delta::delta_tests::a_record_round_trips_through_bytes_and_rebuilds_the_page ... ok +test branch::delta::delta_tests::a_refused_apply_writes_no_bytes_at_all ... ok +test branch::arena::tests::parking_pages_by_the_interval_rule_reaches_the_durable_map ... ok +test branch::delta::delta_tests::apply_refuses_runs_that_overlap_or_run_backwards ... ok +test branch::delta::delta_tests::an_unchanged_page_encodes_to_no_runs_at_all ... ok +test branch::delta::delta_tests::a_width_change_makes_compaction_strictly_worse ... ok +test agent_sql::runtime::tests::the_reconciliation_crosses_its_chunk_boundary_and_forgets_every_branch ... ok +test branch::delta::delta_tests::d93_sweep ... ignored, measurement, not a guard; run explicitly to regenerate the banked table +test branch::delta::delta_tests::every_truncation_of_a_record_is_refused_rather_than_read_short ... ok +test branch::delta::delta_tests::decode_refuses_a_delta_that_is_truncated_or_has_trailing_bytes ... ok +test branch::delta::delta_tests::encoded_len_is_exactly_the_length_of_the_real_encoding ... ok +test branch::catalog::tests::live_children_is_derived_at_replay_and_excludes_reaped_children ... ok +test branch::delta::delta_tests::materialise_accepts_a_chain_at_the_bound_and_refuses_one_past_it ... ok +test branch::delta::delta_tests::runs_coalesce_exactly_when_the_gap_is_not_worth_its_framing ... ok +test branch::arena::tests::a_page_allocated_after_the_last_checkpoint_is_not_reissued_over_a_live_root ... ok +test branch::delta::delta_tests::the_read_amplification_bound_is_arithmetic_not_hope ... ok +test branch::delta::delta_tests::the_writer_occupies_exactly_what_the_cost_model_charges ... ok +test branch::group_commit::tests::a_failed_sync_leaves_the_group_usable ... ok +test branch::group_commit::tests::a_ticket_taken_during_a_sync_is_not_claimed_by_that_sync ... ok +test branch::lease_thread::tests::a_gate_that_skips_the_scan_is_refused_rather_than_ignored - should panic ... ok +test branch::lease_thread::tests::an_unset_knob_is_the_default_and_not_a_refusal ... ok +test branch::lease_thread::tests::d127_a_capped_refusal_report_still_states_the_true_count ... ok +test branch::arena::tests::putting_the_pending_log_back_reaches_the_durable_map ... ok +test branch::lease_thread::tests::a_database_with_no_interrupted_reap_resumes_nothing ... ok +test branch::delta::delta_tests::four_changed_rows_cost_at_least_ten_times_less_than_four_whole_pages ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 1 workspace(s) forgotten +test branch::lease_thread::tests::a_reaped_branchs_workspace_is_forgotten_without_the_client_saying_anything ... ok +test branch::lease_thread::tests::d88_the_orphan_sweep_does_not_run_inside_the_statement_lock ... ok +test branch::lease_thread::tests::d127_a_refused_reap_is_counted_and_printed_and_does_not_stop_the_sweep ... ok +test branch::lease_thread::tests::d127_a_clean_sweep_reports_no_refusal_at_all ... ok +test branch::lease_thread::tests::d127_the_trait_sweep_counts_a_refusal_it_cannot_report ... ok +test branch::lease_thread::tests::the_scan_interval_knob_refuses_every_value_it_cannot_use ... ok +test branch::delta::delta_tests::stored_bytes_track_rows_changed_rather_than_pages_touched ... ok +test branch::lease_thread::tests::an_unexpired_branch_survives_every_scan ... ok +test branch::arena::tests::parking_a_page_forces_the_next_claim_to_rewrite_instead_of_appending ... ok +test branch::catalog::tests::two_opens_of_one_log_recycle_branch_ids_in_the_same_order ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b8@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::d98_a_branch_renewed_after_the_query_is_not_reaped ... ok +test branch::reaper::tests::log_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::reaper::tests::log_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::reaper::tests::log_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::log_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +lease: finished 1 reap(s) a crash interrupted (b1@g0); 0 workspace(s) forgotten +lease: reaped 12 expired branch(es) with no client cooperation (b12@g0, b11@g0, b10@g0, b9@g0, b8@g0, b7@g0, b6@g0, b5@g0, b4@g0, b3@g0, b2@g0, b1@g0); 0 workspace(s) forgotten +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::d98_one_acquisition_never_reaps_more_than_a_chunk ... ok +test branch::lease_thread::tests::start_finishes_a_reap_a_crash_interrupted_before_any_scan_runs ... ok +test branch::lease_thread::tests::the_thread_reaps_an_expired_branch_with_nobody_asking_it_to ... ok +test branch::reaper::tests::log_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::reaper::tests::log_catalog::a_refused_reap_is_not_the_same_answer_as_a_lease_that_moved ... ok +test branch::reaper::tests::log_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::reaper::tests::log_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::log_catalog::a_failing_residue_sweep_puts_the_deferred_arenas_back ... ok +test branch::reaper::tests::log_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::reaper::tests::log_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::log_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::log_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::log_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::reaper::tests::log_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::arena::tests::a_restart_recovers_the_claims_that_live_only_in_the_tail ... ok +test branch::reaper::tests::log_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::log_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::reaper::tests::log_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::reaper::tests::log_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test branch::lease_thread::tests::a_scan_does_not_reap_while_a_merge_holds_the_runtime_lock ... ok +test branch::reaper::tests::log_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::lease_thread::tests::a_signalled_halt_returns_at_once_instead_of_sleeping_the_interval ... ok +test branch::reaper::tests::log_catalog::trunk_is_never_reaped ... ok +test branch::arena::tests::the_next_claim_after_a_tail_restore_does_not_overlap_an_extent_the_tail_named ... ok +test branch::attest::tests::every_entry_has_a_log_sized_inclusion_proof_that_verifies ... ok +test branch::lease_thread::tests::dropping_the_lease_thread_stops_it ... ok +test branch::reaper::tests::log_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test branch::reaper::tests::table_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::lease_thread::tests::stop_does_not_wait_out_the_scan_interval ... ok +test branch::reaper::tests::table_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +test branch::reaper::tests::log_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test branch::reaper::tests::log_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::reaper::tests::log_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::reaper::tests::table_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::table_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::arena::tests::a_claim_appends_one_record_where_it_used_to_rewrite_the_whole_image ... ok +test branch::reaper::tests::table_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::reaper::tests::table_catalog::a_failing_residue_sweep_puts_the_deferred_arenas_back ... ok +test branch::reaper::tests::table_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::attest::tests::a_legitimate_append_stays_consistent_at_every_pair_of_sizes ... ok +test branch::reaper::tests::table_catalog::a_refused_reap_is_not_the_same_answer_as_a_lease_that_moved ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::reaper::tests::table_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::table_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::table_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::reaper::tests::table_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::table_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::table_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::table_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::table_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::record::core_record_tests::a_wrong_length_core_record_is_refused ... ok +test branch::record::core_record_tests::an_absent_parent_is_distinguishable_from_parent_zero ... ok +test branch::record::core_record_tests::d5_into_hydrated_is_the_only_exit_and_it_carries_both_fields ... ok +test branch::reaper::tests::table_catalog::trunk_is_never_reaped ... ok +test branch::record::core_record_tests::the_core_record_round_trips_every_field_it_carries ... ok +test branch::reaper::tests::log_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test branch::record::tests::a_column_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_corrupt_envelope_is_rejected_not_defaulted ... ok +test branch::record::tests::a_delete_needs_authority_over_every_column_it_destroys ... ok +test branch::reaper::tests::table_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::reaper::tests::table_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::record::tests::a_duplicate_column_keeps_the_tighter_floor ... ok +test branch::record::tests::a_granted_table_resolves_however_the_envelope_was_assembled ... ok +test branch::record::tests::a_record_with_an_envelope_roundtrips_through_bytes ... ok +test branch::record::tests::a_record_written_before_envelopes_existed_still_loads ... ok +test branch::record::tests::a_representation_change_counts_as_writing_the_cell ... ok +test branch::record::tests::a_row_whose_image_did_not_change_costs_nothing ... ok +test branch::record::tests::a_statement_that_overruns_the_budget_is_refused_as_a_whole ... ok +test branch::record::tests::a_table_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::a_verb_off_the_allowlist_is_refused_and_one_on_it_writes ... ok +test branch::record::tests::an_envelope_can_be_narrowed_but_never_widened ... ok +test branch::record::tests::an_insert_cannot_launder_a_column_or_a_floor_through_a_null ... ok +test branch::record::tests::an_insert_cannot_write_a_column_it_was_not_granted ... ok +test branch::record::tests::an_unknown_floor_tag_is_refused_rather_than_read_as_unbounded ... ok +test branch::record::tests::an_unlisted_table_is_refused_even_for_a_statement_that_matched_nothing ... ok +test branch::record::tests::changed_columns_reads_images_so_an_insert_reports_every_column_it_wrote ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::record::tests::charging_past_the_budget_is_refused_at_the_charge_not_only_at_admit ... ok +test branch::record::tests::corrupt_record_is_rejected_not_guessed ... ok +test branch::record::tests::deleting_a_row_cannot_be_used_to_get_around_a_floor ... ok +test branch::record::tests::depth_counts_past_the_old_cap_and_saturates_instead_of_wrapping ... ok +test branch::record::tests::fork_copies_the_root_pointer_and_nothing_else ... ok +test branch::record::tests::forking_does_not_mint_budget_the_parent_had_already_spent ... ok +test branch::record::tests::live_children_stay_sorted ... ok +test branch::record::tests::reclamation_rule_is_half_open_over_the_fork_epochs ... ok +test branch::record::tests::record_roundtrips_through_bytes ... ok +test branch::record::tests::stale_generation_is_a_hard_error_not_stale_data ... ok +test branch::record::tests::the_envelope_survives_records_of_every_shape ... ok +test branch::record::tests::the_floor_refuses_the_value_whatever_shape_produced_it ... ok +test branch::record::tests::the_verb_comes_from_the_images_not_from_a_statement_keyword ... ok +test branch::reaper::tests::table_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::record::tests::trailing_bytes_inside_a_valid_checksum_are_refused ... ok +test branch::reaper::tests::table_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::table_catalog::serial_section_profile::where_the_serial_section_goes ... ignored, profiling, not a correctness test; run with --ignored --nocapture +test branch::table_catalog::d10_guard::write_record_new_accepts_a_freshly_forked_child ... ok +test branch::table_catalog::d10_guard::write_record_new_refuses_a_record_carrying_arenas - should panic ... ok +test branch::table_catalog::tests::a_child_inherits_its_parents_capability_envelope ... ok +test branch::table_catalog::tests::a_fresh_catalog_holds_trunk_and_nothing_else ... ok +test branch::table_catalog::tests::a_child_entry_naming_a_branch_with_no_record_is_refused_not_resolved_away ... ok +test branch::table_catalog::tests::a_migrated_catalog_answers_every_query_the_way_its_source_does ... ok +test branch::table_catalog::tests::a_stale_child_entry_against_a_reaped_child_is_resolved_and_ignored ... ok +test branch::table_catalog::tests::attach_child_puts_a_branch_into_a_parents_live_set_and_detach_takes_it_out ... ok +test branch::table_catalog::tests::fork_records_the_child_in_its_parents_live_set ... ok +test branch::table_catalog::tests::an_arena_appended_the_way_the_page_store_does_it_survives_a_round_trip ... ok +test branch::table_catalog::tests::expired_before_selects_only_expired_live_non_trunk_branches ... ok +test branch::table_catalog::tests::opening_an_existing_catalog_never_creates_a_fresh_one_over_it ... ok +test branch::reaper::tests::table_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::tree_keys::tests::a_parents_child_span_excludes_the_adjacent_parent ... ok +test branch::table_catalog::tests::an_interrupted_switchover_prefers_the_tree_and_retires_the_stale_log ... ok +test branch::tree_keys::tests::a_state_span_holds_that_state_alone ... ok +test branch::tree_keys::tests::an_arena_span_holds_one_branchs_arenas_and_survives_the_wrap ... ok +test branch::table_catalog::tests::writing_a_record_twice_leaves_exactly_one_of_it ... ok +test branch::table_catalog::tests::reopening_recovers_the_counters_without_replaying_anything ... ok +test branch::tree_keys::tests::encoded_order_matches_numeric_order ... ok +test branch::tree_keys::tests::expired_range_at_max_contains_every_deadline_and_no_other_group ... ok +test branch::tree_keys::tests::expired_range_is_inclusive_of_now_and_excludes_the_next_millisecond ... ok +test branch::table_catalog::tests::scan_returns_every_record_in_branch_id_order ... ok +test branch::tree_keys::tests::tag_groups_are_disjoint_and_each_span_contains_only_its_own ... ok +test branch::tree_keys::tests::the_child_key_decoders_refuse_anything_that_is_not_a_child_key ... ok +test branch::types::tests::branch_state_roundtrips ... ok +test branch::table_catalog::tests::the_two_catalogs_agree_about_what_current_epoch_means ... ok +test branch::types::tests::generation_bump_makes_reaped_id_distinguishable ... ok +test branch::types::tests::lease_expiry_is_a_pure_comparison ... ok +test branch::version_graph::tests::a_live_sibling_keeps_a_tombstoned_parent_alive ... ok +test branch::version_graph::tests::a_recycled_id_does_not_inherit_the_old_lineage ... ok +test branch::version_graph::tests::a_tombstone_is_collected_only_once_its_last_descendant_goes ... ok +test branch::version_graph::tests::an_unknown_branch_refuses_rather_than_answering_false ... ok +test branch::version_graph::tests::deep_chain_agrees_with_an_independent_walk ... ok +test branch::version_graph::tests::depth_counts_from_the_root ... ok +test branch::version_graph::tests::forking_from_a_reaped_parent_is_refused ... ok +test branch::version_graph::tests::freeing_a_slot_with_a_descendant_is_refused - should panic ... ok +test branch::version_graph::tests::hop_counts_do_not_grow_with_fan_out ... ok +test branch::version_graph::tests::hop_counts_are_logarithmic_in_depth_and_the_walk_is_linear ... ok +test branch::version_graph::tests::is_ancestor_is_strict_and_directional ... ok +test branch::version_graph::tests::lca_is_reflexive_and_handles_the_parent_case ... ok +test branch::version_graph::tests::lca_descends_correctly_at_every_level_boundary ... ok +test branch::version_graph::tests::lca_of_disjoint_trees_is_none_not_the_trunk ... ok +test branch::reaper::tests::log_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok +test branch::version_graph::tests::lca_of_siblings_is_the_fork_point ... ok +test branch::version_graph::tests::level_ancestor_hits_each_level_and_refuses_below_the_node ... ok +test branch::version_graph::tests::reaping_an_interior_branch_preserves_ancestry ... ok +test branch::table_catalog::tests::a_legacy_log_is_migrated_once_and_retired_not_deleted ... ok +test branch::version_graph::tests::reinserting_a_branch_is_refused ... ok +test branch::version_graph::tests::sibling_candidates_have_a_fork_point_that_survives_pruning ... ok +test branch::version_graph::tests::siblings_are_not_ancestors_of_each_other ... ok +test branch::version_graph::tests::slot_reuse_never_leaks_a_stale_lineage ... ok +test buffer::page_table::tests::a_collision_costs_the_fast_path_and_never_returns_the_wrong_frame ... ok +test buffer::page_table::tests::a_removed_page_is_gone_from_both_halves ... ok +test buffer::buffer_pool::tests::a_refused_delete_leaves_the_page_intact_in_the_pool ... ok +test buffer::page_table::tests::clear_empties_both_halves ... ok +test buffer::buffer_pool::tests::unpinning_without_the_dirty_flag_never_clears_it ... ok +test buffer::buffer_pool::tests::a_refused_free_page_leaves_the_page_intact_in_the_pool ... ok +test buffer::page_table::tests::min_slots_is_rounded_up_to_a_power_of_two ... ok +test buffer::page_table::tests::page_zero_in_frame_zero_is_not_mistaken_for_an_empty_slot ... ok +test buffer::buffer_pool::tests::an_accepted_delete_still_evicts_the_page ... ok +test buffer::page_table::tests::remapping_a_page_moves_its_mirror_entry ... ok +test buffer::page_table::tests::removing_a_collided_out_page_leaves_the_slot_owner_alone ... ok +test buffer::page_table::tests::a_tagged_slot_always_agrees_with_the_map ... ok +test buffer::page_table::tests::the_largest_page_id_round_trips ... ok +test buffer::page_table::tests::the_mirror_answers_what_the_map_answers ... ok +test buffer::touch_queue::tests::a_drain_takes_everything_and_empties_the_queue ... ok +test buffer::touch_queue::tests::concurrent_recorders_lose_nothing ... ok +test buffer::touch_queue::tests::every_recorded_update_comes_back_exactly_once ... ok +test buffer::touch_queue::tests::nothing_is_returned_until_the_batch_is_full ... ok +test buffer::touch_queue::tests::shard_count_is_rounded_up_to_a_power_of_two ... ok +test build_provenance_tests::a_dirty_tree_is_loud_in_the_rendered_provenance ... ok +test build_provenance_tests::the_build_stamp_names_a_real_commit ... ok +test catalog::catalog::tests::dropping_a_table_returns_its_pages_including_the_time_travel_heap ... ok +test catalog::catalog::tests::dropping_a_table_forgets_its_statistics ... ok +test catalog::catalog::tests::test_catalog_create_open ... ok +test catalog::catalog_page::tests::a_long_name_s_own_bytes_are_not_parsed_as_catalog_entries ... ok +test catalog::catalog::tests::test_create_table_adds_time_travel_root ... ok +test catalog::catalog_page::tests::a_middle_entry_s_fulltext_block_does_not_shift_the_next_entry ... ok +test catalog::catalog::tests::test_drop_table ... ok +test catalog::catalog_page::tests::a_name_too_long_for_its_length_field_never_yields_a_page_that_misparses_the_next_entry ... ok +test catalog::catalog::tests::test_persist_orphan_removal ... ok +test catalog::catalog::tests::test_get_table ... ok +test catalog::catalog_page::tests::a_pre_b8_catalog_page_still_parses_with_no_fulltext_indexes ... ok +test catalog::catalog::tests::test_create_index ... ok +test catalog::catalog_page::tests::an_unknown_catalog_page_format_is_refused ... ok +test catalog::catalog_page::tests::every_length_prefix_refuses_the_first_value_it_cannot_express ... ok +test catalog::catalog_page::tests::fulltext_indexes_round_trip_separately_from_btree_indexes ... ok +test catalog::catalog::tests::test_update_roots ... ok +test catalog::catalog_page::tests::test_add_space_management ... ok +test catalog::catalog_page::tests::test_basic_roundtrip ... ok +test catalog::catalog_page::tests::test_remove_entry ... ok +test catalog::catalog_page::tests::the_largest_value_each_prefix_can_express_still_round_trips ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_decimal ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_float ... ok +test catalog::column::tests::bigint_orders_by_value_at_the_i64_extremes ... ok +test catalog::column::tests::bigint_versus_float_does_not_widen_past_2_53 ... ok +test catalog::column::tests::bigint_versus_non_finite_floats_matches_total_cmp_placement ... ok +test catalog::column::tests::decimal_beyond_i128_still_compares_exactly ... ok +test catalog::column::tests::decimal_orders_numerically_not_lexicographically ... ok +test branch::version_graph::tests::all_pairs_agree_with_the_walk_on_an_irregular_tree ... ok +test catalog::column::tests::decimal_versus_float_uses_the_floats_exact_value ... ok +test catalog::column::tests::decimal_versus_non_finite_floats_is_fixed_and_cycle_free ... ok +test catalog::column::tests::f64_exact_decimal_is_exact_not_shortest ... ok +test catalog::column::tests::float_does_not_outrank_a_larger_integer ... ok +test catalog::column::tests::integer_to_float_widening_is_exact_at_i32_bounds ... ok +test catalog::column::tests::numerically_equal_int_and_float_compare_equal ... ok +test catalog::column::tests::ordering_is_transitive_across_the_numeric_band ... ok +test catalog::column::tests::parse_decimal_accepts_what_sql_writes_and_refuses_what_it_does_not ... ok +test catalog::column::tests::rank_fallback_still_orders_incomparable_variants ... ok +test catalog::column::tests::the_whole_value_domain_is_a_total_order ... ok +test catalog::column::tests::the_numeric_band_is_a_total_order ... ok +test catalog::column::tests::timestamp_compares_by_value_and_sits_outside_the_numeric_band ... ok +test catalog::system_views::tests::a_table_may_not_take_a_view_name_but_an_ordinary_name_is_fine ... ok +test catalog::system_views::tests::every_view_name_resolves_and_nothing_else_does ... ok +test catalog::system_views::tests::hex_of_a_prompt_hash_is_64_characters ... ok +test catalog::system_views::tests::synthetic_row_ids_stay_distinct_past_the_slot_number_width ... ok +test catalog::system_views::tests::u64_values_past_i64_max_are_not_reported_as_negative ... ok +test branch::reaper::tests::table_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test catalog::system_views::tests::view_names_and_declared_columns_agree_with_the_enum ... ok +test cluster::tests::a_grant_addressed_to_another_node_is_refused ... ok +test cluster::tests::a_grant_from_a_previous_epoch_is_not_issued_from_after_leaving ... ok +test cluster::tests::a_grant_partly_consumed_before_a_crash_resumes_at_the_watermark ... ok +test cluster::tests::a_grant_wholly_below_the_restored_watermark_is_a_duplicate_not_a_rewind ... ok +test cluster::tests::a_lease_tick_never_moves_cluster_time_backwards ... ok +test cluster::tests::a_member_reads_the_replicated_tick_and_never_its_own_clock ... ok +test cluster::tests::a_member_refuses_again_once_its_grant_is_used_up ... ok +test branch::reaper::tests::table_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test cluster::tests::a_member_with_no_grant_refuses_rather_than_allocating ... ok +test cluster::tests::a_member_with_no_lease_tick_refuses_to_decide_a_lease ... ok +test cluster::tests::a_range_too_narrow_for_the_ask_is_kept_rather_than_split_or_dropped ... ok +test cluster::tests::a_redelivered_grant_issues_nothing_twice ... ok +test cluster::tests::a_standalone_extent_counter_walks_the_same_page_starts_as_fetch_add ... ok +test cluster::tests::a_standalone_node_at_the_end_of_the_value_space_refuses_distinguishably ... ok +test cluster::tests::a_standalone_node_issues_exactly_what_the_old_atomic_counter_did ... ok +test cluster::tests::a_standalone_node_reads_its_own_wall_clock_because_it_is_the_cluster ... ok +test cluster::tests::a_standalone_node_refuses_an_outside_grant ... ok +test cluster::tests::a_standalone_node_self_grants_through_the_same_consume_path ... ok +test cluster::tests::a_take_never_straddles_two_granted_ranges ... ok +test cluster::tests::an_authority_change_never_lowers_the_issued_watermark ... ok +test cluster::tests::an_authority_change_stops_the_old_high_water_clamping_the_new_leaders_grants ... ok +test cluster::tests::an_empty_grant_is_refused_rather_than_ignored ... ok +test cluster::tests::ranges_from_a_superseded_authority_are_not_issued_from ... ok +test cluster::tests::the_issued_watermark_is_the_only_thing_a_checkpoint_has_to_carry ... ok +test cluster::tests::two_members_at_the_same_tick_cannot_disagree_about_expiry ... ok +test cluster::tests::two_members_with_disjoint_grants_never_issue_the_same_value ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test branch::reaper::tests::table_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test branch::table_catalog::d126_record_key_probe::renew_lease_never_un_reads_the_record_and_the_neighbour_never_moves ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test branch::reaper::tests::table_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test branch::table_catalog::d126_record_key_probe::the_record_key_is_never_absent_while_it_is_rewritten ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test cow::btree::delete_unlink_tests::deleting_an_absent_key_changes_nothing ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test cow::btree::delete_unlink_tests::an_emptied_leaf_is_unlinked_rather_than_left_in_the_tree ... ok +test cow::btree::delete_unlink_tests::deleting_every_key_leaves_a_usable_empty_tree ... ok +test cow::btree::delete_unlink_tests::emptying_the_leftmost_leaf_promotes_its_successor ... ok +test cow::btree::delete_unlink_tests::every_surviving_key_is_still_readable_after_scattered_deletion ... ok +test cow::btree::delete_unlink_tests::deleting_on_a_fork_leaves_the_parents_tree_intact ... ok +test cow::btree::diff_tests::an_insert_an_update_and_a_delete_each_report_both_sides ... ok +test branch::arena::tests::the_tail_is_compacted_before_it_outgrows_its_share_of_the_image ... ok +test cow::btree::diff_tests::identical_roots_diff_to_nothing_without_reading_a_page ... ok +test cow::btree::diff_tests::reversing_the_roots_reverses_each_delta ... ok +test cow::btree::diff_tests::many_scattered_edits_are_all_reported_exactly_once ... ok +test cow::chunker::tests::a_sequence_that_fits_is_never_cut_without_a_boundary ... ok +test cow::chunker::tests::balanced_cuts_fit_every_piece_and_reduce_to_split_point_for_one_cut ... ok +test cow::chunker::tests::cuts_depend_only_on_the_sorted_sequence ... ok +test cow::btree::diff_tests::unchanged_neighbours_of_an_edited_row_are_not_reported ... ok +test cow::chunker::tests::cuts_never_leave_a_side_empty ... ok +test cow::chunker::tests::no_piece_ever_exceeds_the_page ... ok +test cow::chunker::tests::refining_an_over_long_run_keeps_every_cut_it_already_made ... ok +test cow::chunker::tests::rewriting_a_value_does_not_move_a_boundary ... ok +test cow::chunker::tests::rolling_window_matches_a_fresh_hash ... ok +test cow::chunker::tests::the_boundary_decision_ignores_everything_but_the_entry ... ok +test cow::chunker::tests::degenerate_key_shapes_do_not_produce_massive_chunks ... ok +test cow::chunker::tests::the_hash_is_order_sensitive ... ok +test cow::btree::delete_unlink_tests::unlinked_pages_are_freed_rather_than_leaked ... ok +test cow::chunker::tests::the_chunk_size_tracks_bytes_not_row_count ... ok +test cow::chunker::tests::the_mean_chunk_lands_near_the_target ... ok +test cow::cid::tests::a_one_bit_input_change_avalanches_across_the_output ... ok +test cow::cid::tests::a_non_btree_page_is_refused_rather_than_hashed ... ok +test cow::cid::tests::a_prefix_does_not_hash_like_its_extension ... ok +test cow::btree::delete_unlink_tests::deleting_a_terminating_boundary_key_rejoins_the_leaf_with_its_neighbour ... ok +test cow::cid::tests::domain_tags_separate_otherwise_identical_inputs ... ok +test cow::cid::tests::a_subtree_untouched_by_an_edit_keeps_its_cid ... ok +test cow::cid::tests::length_prefixing_prevents_field_boundary_collisions ... ok +test cow::btree::delete_unlink_tests::deleting_every_boundary_drives_chunks_over_a_page ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test cow::dedup::tests::a_content_id_collision_does_not_share_pages ... ok +test cow::dedup::tests::a_crash_between_the_increment_and_the_record_leaks_nothing ... ok +test cow::dedup::tests::a_racing_publisher_makes_the_loser_hand_its_page_back ... ok +test cow::cid::tests::a_suffix_delete_leaves_the_partition_of_the_surviving_rows_alone ... ok +test cow::dedup::tests::an_over_release_is_refused_and_changes_nothing ... ok +test cow::dedup::tests::an_uncommitted_ticket_on_a_fresh_page_hands_the_page_back ... ok +test cow::dedup::tests::an_uncommitted_ticket_rolls_its_increment_back_on_drop ... ok +test cow::dedup::tests::concurrent_release_frees_the_page_exactly_once ... ok +test cow::dedup::tests::concurrent_interning_of_one_payload_counts_every_reference_once ... ok +test cow::dedup::tests::different_payloads_never_share ... ok +test cow::cid::tests::a_delete_of_an_interior_boundary_key_must_not_change_the_partition ... ok +test cow::dedup::tests::recovery_frees_a_page_whose_only_reference_was_lost_to_the_crash ... ok +test cow::dedup::tests::identical_payloads_share_one_page_and_allocate_once ... ok +test cow::dedup::tests::recovery_leaves_committed_references_alone ... ok +test cow::dedup::tests::releasing_the_last_reference_frees_the_page ... ok +test cow::dedup::tests::the_audit_detects_an_inconsistency_it_is_supposed_to_catch ... ok +test cow::diff::tests::a_cyclic_page_graph_fails_instead_of_looping ... ok +test cow::btree::delete_unlink_tests::no_row_is_lost_or_reordered_by_a_boundary_repair ... ok +test cow::diff::tests::a_memo_is_bound_to_one_tree_so_its_digest_and_its_version_share_a_file ... ok +test cow::diff::tests::a_page_crafted_onto_a_colliding_checksum_is_still_a_different_version ... ok +test cow::diff::tests::a_memo_over_a_genuinely_memoless_digest_skips_and_never_falls_back ... ok +test cow::diff::tests::a_growing_insert_run_that_splits_nodes_still_diffs_correctly ... ok +test cow::cid::tests::one_changed_value_changes_the_root_cid ... ok +test cow::btree::diff_tests::one_edit_in_a_large_tree_decodes_only_a_fraction_of_it ... ok +test cow::diff::tests::a_recycled_page_id_misses_the_memo_instead_of_answering_for_its_previous_life ... ok +test cow::btree::delete_unlink_tests::an_overwrite_that_erases_a_terminator_also_rejoins ... ok +test cow::diff::tests::a_content_hash_skips_a_rewritten_but_unchanged_leaf_where_page_identity_cannot ... ok +test cow::diff::tests::a_stale_row_costs_a_skip_and_never_the_change ... ok +test cow::diff::tests::an_in_place_rewrite_invalidates_the_row_although_the_birth_epoch_is_unchanged ... ok +test cow::diff::tests::an_ancestors_warmed_row_survives_a_descendant_rewrite_and_goes_on_answering ... ok +test cow::diff::tests::an_empty_tree_against_a_populated_one_reports_every_row ... ok +test cow::diff::tests::an_unstamped_page_falls_back_to_page_identity_and_cannot_collide_with_a_content_id ... ok +test cow::cid::tests::identical_trees_built_independently_have_equal_subtree_cids ... ok +test cow::diff::tests::re_warming_a_recycled_page_replaces_its_row_rather_than_stepping_over_it ... ok +test cow::cid::tests::same_data_in_two_orders_converges_to_one_partition_cid ... ok +test cow::diff::tests::comparing_a_page_with_itself_never_consults_the_identity_provider ... ok +test cow::diff::tests::a_warmed_memo_identity_skips_and_never_falls_back ... ok +test cow::diff::tests::the_hash_folds_length_prefixed_so_a_reframing_cannot_agree ... ok +test cow::diff::tests::inserts_and_deletes_are_classified_and_the_answer_matches_a_full_scan ... ok +test cow::diff::tests::an_unwarmed_memo_identity_still_answers_correctly_and_says_so ... ok +test cow::diff::tests::the_version_key_consults_the_birth_epoch_and_not_only_the_checksum ... ok +test cow::diff::tests::the_second_store_recycles_page_ids_too_and_a_stale_row_misses_there_as_well ... ok +test cow::diff::tests::cross_lineage_is_the_digests_own_case_and_it_still_has_to_pay_for_the_stamp ... ok +test cow::diff::tests::the_diff_is_antisymmetric ... ok +test cow::merge3::tests::a_non_btree_page_is_refused_rather_than_walked_as_an_internal_node ... ok +test cow::merge3::tests::a_subtree_hash_reused_across_an_in_place_write_refuses_its_stale_row ... ok +test cow::diff::tests::shared_stats_accumulate_across_diffs ... ok +test cow::diff::tests::the_key_catches_the_recycle_and_the_in_place_write_and_provably_not_a_descendant ... ok +test cow::merge3::tests::content_identity_skips_a_convergent_edit_that_page_identity_must_descend ... ok +test cow::merge3::tests::merging_the_other_direction_agrees_except_on_which_side_wins_a_conflict ... ok +test cow::merge3::tests::every_untouched_key_survives_the_merge ... ok +test cow::merge3::tests::resolve_cell_covers_the_table_exhaustively ... ok +test cow::merge3::tests::birth_epoch_discriminates_a_recycled_page_but_not_an_in_place_write ... ok +test cow::merge3::tests::ours_unchanged_takes_theirs_whole_without_reading_a_page ... ok +test cow::merge3::tests::row_01_all_three_hold_x_keeps_x ... ok +test cow::merge3::tests::row_02_ours_moved_theirs_did_not_takes_ours ... ok +test cow::diff::tests::rewriting_every_leaf_skips_nothing ... ok +test cow::merge3::tests::row_03_theirs_moved_ours_did_not_takes_theirs ... ok +test cow::merge3::tests::row_04_convergent_edit_is_not_a_conflict ... ok +test cow::merge3::tests::row_05_both_moved_to_different_values_conflicts ... ok +test cow::merge3::tests::a_merge_that_splits_a_leaf_on_both_sides_still_merges ... ok +test cow::merge3::tests::row_06_ours_deleted_theirs_unchanged_deletes ... ok +test cow::merge3::tests::row_07_theirs_deleted_ours_unchanged_deletes ... ok +test cow::diff::tests::four_changed_rows_are_found_exactly ... ok +test cow::merge3::tests::row_08_ours_deleted_theirs_modified_conflicts ... ok +test cow::diff::tests::identical_trees_skip_everything_and_read_nothing ... ok +test cow::merge3::tests::row_09_ours_modified_theirs_deleted_conflicts ... ok +test cow::merge3::tests::row_11_ours_inserted_theirs_absent_takes_ours ... ok +test cow::merge3::tests::row_10_both_deleted_is_convergent_not_a_conflict ... ok +test cow::merge3::tests::row_14_add_add_with_different_values_conflicts ... ok +test cow::merge3::tests::row_12_theirs_inserted_ours_absent_takes_theirs ... ok +test cow::merge3::tests::row_13_convergent_insert_is_not_a_conflict ... ok +test cow::merge3::tests::sides_agree_takes_either_without_reading_a_page ... ok +test cow::node::tests::a_full_node_reports_full_instead_of_corrupting_itself ... ok +test cow::merge3::tests::theirs_unchanged_takes_ours_whole_without_reading_a_page ... ok +test cow::node::tests::all_children_refuses_a_cell_that_overruns_the_page ... ok +test cow::node::tests::all_children_refuses_a_cell_whose_body_is_not_a_child_pointer ... ok +test cow::merge3::tests::the_same_workload_on_forks_skips_almost_everything ... ok +test cow::node::tests::internal_child_routing_uses_the_leftmost_slot_for_small_keys ... ok +test cow::node::tests::leaf_insert_search_and_remove ... ok +test cow::node::tests::node_body_never_touches_the_page_header ... ok +test cow::node::tests::repeated_overwrite_is_reclaimed_by_compaction ... ok +test cow::node::tests::split_point_keeps_both_halves_non_empty ... ok +test cow::page_header::tests::checksum_catches_a_flipped_payload_byte ... ok +test cow::page_header::tests::header_roundtrips_and_leaves_payload_alone ... ok +test cow::merge3::tests::three_unrelated_trees_produce_zero_skips ... ok +test cow::page_header::tests::privacy_needs_both_own_arena_and_post_fork_birth ... ok +test cow::page_header::tests::unknown_page_type_is_rejected ... ok +test cow::tests::overwriting_a_key_does_not_double_count_bytes ... ok +test cow::tests::write_buffer_probe_distinguishes_delete_from_absent ... ok +test cow::store::tests_d128_queue_restore::a_failed_release_mid_drain_loses_no_pending_entry ... ok +test cow::store::tests_d128_queue_restore::the_next_drain_finishes_what_the_failed_one_left ... ok +test cow::tests_chunking::the_key_limit_holds_on_both_sides_and_refuses_before_spending_a_page ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test cow::tests_compact_refusal::a_partial_promotion_leaves_a_page_that_verifies ... ok +test cow::tests_compact_refusal::a_refused_insert_leaves_a_page_that_verifies ... ok +test cow::tests_compact_refusal::a_refused_replace_leaves_a_page_that_verifies ... ok +test cow::tests_chunking::every_leaf_but_the_last_ends_on_a_content_boundary ... ok +test cow::tests_chunking::leaves_average_the_target_chunk_size_and_never_exceed_a_page ... ok +test cow::tests_chunking::rewriting_values_in_place_leaves_the_partition_alone ... ok +test cow::merge3::tests::nodes_read_is_far_below_the_page_count ... ok +test cow::tests_chunking::insertion_order_does_not_change_the_leaf_partition ... ok +test cow::tests_chunking::wide_rows_chunk_by_content_and_still_fit_the_page ... ok +test cow::tests_compact_refusal::a_failed_write_on_shadowed_pages_frees_nothing_the_tree_still_points_at ... ok +test cow::tests_isolation::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test cow::tests_isolation::a_branch_can_never_mutate_another_branchs_page_in_place ... ok +test cow::tests_isolation::a_deleted_key_stays_visible_to_a_branch_that_forked_before_the_delete ... ok +test cow::tests_isolation::a_live_child_forces_the_parent_to_shadow_instead_of_mutating ... ok +test cow::tests_isolation::a_child_writing_never_frees_a_page_its_parent_still_points_at ... ok +test cow::tests_isolation::a_recycled_extent_never_hands_back_stale_page_contents ... ok +test cow::tests_isolation::a_page_private_to_the_writer_is_mutated_in_place_not_reshadowed ... ok +test cow::tests_isolation::a_torn_page_is_refused_rather_than_returned ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test cow::tests_isolation::allocating_from_an_unknown_arena_is_an_error ... ok +test cow::tests_isolation::a_write_in_a_child_does_not_change_what_the_parent_reads ... ok +test cow::tests_isolation::an_oversized_entry_is_refused_rather_than_silently_truncated ... ok +test cow::tests_isolation::a_cursor_that_failed_does_not_yield_again ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test cow::tests_isolation::an_unregistered_branch_is_refused_rather_than_guessed_at ... ok +test cow::btree::delete_unlink_tests::a_boundary_delete_rejoins_across_a_parent_as_well ... ok +test cow::tests_isolation::delete_removes_only_the_named_key_and_a_miss_shadows_nothing ... ok +test cow::tests_isolation::flushing_a_write_buffer_produces_the_same_tree_as_direct_inserts ... ok +test cow::tests_isolation::freeing_an_arena_is_one_operation_not_a_per_page_scan ... ok +test cow::tests_isolation::fork_copies_zero_data_pages ... ok +test cow::tests_isolation::registering_the_same_branch_twice_is_refused ... ok +test cow::tests_isolation::reaping_an_abandoned_branch_returns_the_page_count_to_baseline ... ok +test cow::tests_isolation::every_bound_yields_exactly_the_keys_a_reference_map_holds ... ok +test cow::tests_chunking::the_tree_agrees_with_the_chunker_about_where_the_leaves_end ... ok +test cow::tests_isolation::the_trunk_cannot_be_forgotten ... ok +test cow::tests_isolation::two_siblings_writing_the_same_key_do_not_see_each_other ... ok +test cow::tests_compact_refusal::a_starved_insert_loses_no_row_that_the_unstarved_control_keeps ... ok +test cow::tests_isolation::reading_the_first_row_of_a_scan_does_not_read_the_whole_tree ... ok +test cow::tests_isolation::arenas_roll_over_when_an_extent_fills_and_stay_owned_by_one_branch ... ok +test cow::btree::delete_unlink_tests::a_childless_internal_node_is_removed_from_its_parent ... ok +test execution::executor::tests::a_live_primary_key_is_still_refused ... ok +test cow::tests_isolation::splits_and_multi_level_growth_preserve_every_key ... ok +test execution::executor::tests::a_deleted_primary_key_can_be_inserted_again ... ok +test execution::executor::tests::an_uncommitted_delete_does_not_free_the_primary_key ... ok +test execution::executor::tests::not_null_violation_errors ... ok +test execution::executor::tests::test_arithmetic ... ok +test cow::tests_isolation::the_default_one_megabyte_extent_works_end_to_end ... ok +test execution::executor::tests::test_analyze_empty_table ... ok +test execution::executor::tests::test_comparison ... ok +test execution::executor::tests::reusing_a_key_does_not_let_the_next_duplicate_through ... ok +test execution::executor::tests::test_analyze_unknown_table_error ... ok +test execution::executor::tests::test_analyze_basic ... ok +test execution::executor::tests::test_analyze_nulls_duplicates ... ok +test execution::executor::tests::test_block_rollback_discards_everything ... ok +test execution::executor::tests::test_block_commits_atomically ... ok +test execution::executor::tests::test_error_aborts_everything ... ok +test execution::executor::tests::test_duplicate_primary_key_errors ... ok +test execution::executor::tests::test_delete_then_select ... ok +test execution::executor::tests::test_comparison_filter ... ok +test execution::executor::tests::test_filter ... ok +test execution::executor::tests::test_insert_stamps_begin_ts ... ok +test execution::executor::tests::test_inner_join ... ok +test execution::executor::tests::test_logical ... ok +test execution::executor::tests::test_inner_join_with_keyword ... ok +test cow::btree::right_walk_probe::every_leaf_can_reach_its_right_neighbour_without_a_sibling_pointer ... ok +test cow::tests_compact_refusal::d125_instrument_where_a_starved_insert_loses_rows ... ok +test execution::executor::tests::test_join_select_star ... ok +test execution::executor::tests::test_join_no_match ... ok +test execution::executor::tests::test_unary ... ok +test execution::executor::tests::test_rejects_ddl_inside_block ... ok +test execution::executor::tests::test_left_join ... ok +test execution::executor::tests::test_join_with_where ... ok +test execution::executor::tests::test_left_no_match ... ok +test execution::executor::tests::test_projection ... ok +test execution::fulltext_search::tests::idf_falls_with_document_frequency_and_never_goes_negative ... ok +test execution::fulltext_search::tests::term_score_saturates_in_tf_and_penalises_length ... ok +test execution::fulltext_search::tests::the_heap_agrees_with_a_full_sort ... ok +test execution::fulltext_search::tests::the_heap_keeps_the_best_k_whatever_order_they_arrive_in ... ok +test execution::executor::tests::test_left_outer_keyword ... ok +test execution::fulltext_search::tests::the_heap_never_returns_more_than_k_and_never_pads ... ok +test execution::fulltext_search::tests::ties_break_on_the_primary_key_so_the_boundary_is_deterministic ... ok +test execution::hash_join::tests::equal_hash_keys_hash_identically ... ok +test execution::executor::tests::test_select_all ... ok +test execution::executor::tests::test_snapshot_pins_at_begin ... ok +test execution::hash_join::tests::hash_key_equality_agrees_with_the_equality_operator ... ok +test execution::executor::tests::test_uncommitted_writes_invisible_across_sessions ... ok +test execution::executor::tests::test_update_then_select ... ok +test optimizer::cost_model::tests::test_fallback ... ok +test execution::executor::tests::updating_a_primary_key_is_refused_as_a_constraint_and_names_the_remedy ... ok +test execution::executor::tests::test_self_join ... ok +test optimizer::optimizer::tests::test_collect_columns ... ok +test optimizer::optimizer::tests::test_combine_column ... ok +test optimizer::optimizer::tests::test_push_spanning_predicate_stays ... ok +test optimizer::optimizer::tests::test_push_split_remap ... ok +test optimizer::optimizer::tests::test_remap ... ok +test execution::executor::tests::test_unsupported_join_type_error ... ok +test optimizer::optimizer::tests::test_split_and_roundtrip ... ok +test optimizer::optimizer::tests::test_wrap_filter_empty_conjuncts ... ok +test optimizer::optimizer::tests::test_wrap_filter_non_empty_conjuncts ... ok +test parser::parser::tests::a_mistyped_alter_still_says_what_was_expected ... ok +test parser::parser::tests::a_prompt_clause_is_optional_and_absent_is_not_empty ... ok +test parser::parser::tests::a_repeated_or_misplaced_clause_is_named_without_quoting_the_prompt ... ok +test parser::parser::tests::all_is_still_an_ordinary_identifier_everywhere_else ... ok +test parser::parser::tests::alter_table_add_column_carries_not_null ... ok +test parser::parser::tests::alter_table_add_column_parses ... ok +test parser::parser::tests::alter_table_rename_column_parses ... ok +test parser::parser::tests::alter_table_retype_column_parses ... ok +test parser::parser::tests::an_unterminated_candidate_body_names_the_candidate_it_belongs_to ... ok +test parser::parser::tests::drop_column_is_refused_by_name_with_the_reason ... ok +test parser::parser::tests::positional_add_column_is_refused_by_name ... ok +test parser::parser::tests::prompt_is_still_a_usable_column_and_table_name ... ok +test parser::parser::tests::simulate_admits_all_or_a_count ... ok +test parser::parser::tests::simulate_refuses_a_run_with_nothing_to_compare_or_nothing_to_check ... ok +test parser::parser::tests::test_as_of_coexists_with_alias_and_where ... ok +test parser::parser::tests::test_as_of_without_branch_keyword_is_an_error ... ok +test parser::parser::tests::test_begin_agent_session_requires_a_quoted_agent_id ... ok +test parser::parser::tests::test_expr_renders_back_to_sql_for_guard_capture ... ok +test parser::parser::tests::test_panic_mode_synchronization ... ok +test parser::parser::tests::test_parse_begin_agent_session ... ok +test parser::parser::tests::test_parse_create_table ... ok +test parser::parser::tests::test_parse_diff_merge_abandon ... ok +test parser::parser::tests::test_parse_insert ... ok +test parser::parser::tests::test_parse_revert_merge ... ok +test parser::parser::tests::test_parse_select_as_of_branch ... ok +test parser::parser::tests::test_parse_select_str ... ok +test parser::parser::tests::test_parse_simulate ... ok +test parser::parser::tests::test_select_with_join ... ok +test parser::parser::tests::test_update_with_where ... ok +test parser::parser::tests::the_alter_grammar_words_are_still_usable_as_column_names ... ok +test parser::scanner::tests::prompt_is_not_a_reserved_word ... ok +test parser::scanner::tests::test_agent_keywords ... ok +test parser::scanner::tests::test_agent_keywords_are_case_insensitive ... ok +test parser::scanner::tests::test_all_keywords ... ok +test parser::scanner::tests::test_fulltext_keywords ... ok +test parser::scanner::tests::test_identifier_vs_keyword ... ok +test parser::scanner::tests::test_operators ... ok +test parser::scanner::tests::test_select_statement ... ok +test parser::scanner::tests::test_whitespace_ignored ... ok +test pgwire::extended::tests::a_parse_error_names_the_parameter_and_not_the_rewriting ... ok +test pgwire::extended::tests::result_format_shorthands_all_resolve_to_one_format_per_column ... ok +test pgwire::extended::tests::statements_split_on_top_level_semicolons_only ... ok +test pgwire::message::tests::a_body_reports_what_it_did_not_consume ... ok +test pgwire::message::tests::a_bodyless_message_is_still_four_bytes_of_length ... ok +test pgwire::message::tests::a_null_column_is_length_minus_one_not_an_empty_byte_string ... ok +test pgwire::message::tests::a_truncated_body_is_an_error_and_not_a_panic ... ok +test pgwire::message::tests::an_empty_string_is_length_zero_and_not_null ... ok +test pgwire::message::tests::error_response_fields_are_terminated_and_typed ... ok +test pgwire::message::tests::framing_length_covers_itself_and_the_body_but_not_the_tag ... ok +test pgwire::message::tests::parameter_description_is_a_count_then_one_oid_each ... ok +test pgwire::message::tests::ready_for_query_carries_the_transaction_status ... ok +test pgwire::message::tests::row_description_advertises_one_field_per_column ... ok +test pgwire::params::tests::a_bare_dollar_is_left_for_the_scanner_to_complain_about ... ok +test pgwire::params::tests::a_dollar_inside_a_string_literal_is_data ... ok +test pgwire::params::tests::a_parameter_carrying_sql_stays_one_value ... ok +test pgwire::params::tests::a_parameter_the_client_did_not_bind_is_refused ... ok +test pgwire::params::tests::a_quoted_identifier_is_not_scanned_for_parameters ... ok +test pgwire::params::tests::a_two_digit_parameter_is_one_parameter ... ok +test pgwire::params::tests::placeholders_become_identifiers_and_are_counted ... ok +test pgwire::params::tests::substitution_puts_a_literal_where_the_parameter_was ... ok +test pgwire::params::tests::substitution_reaches_every_expression_position ... ok +test pgwire::params::tests::zero_and_out_of_range_parameter_numbers_are_refused ... ok +test pgwire::session::tests::a_malformed_set_is_refused_here_and_not_passed_on ... ok +test pgwire::session::tests::a_multi_word_value_survives ... ok +test pgwire::session::tests::a_select_against_a_real_table_is_not_a_probe ... ok +test pgwire::session::tests::a_timeout_this_server_does_not_enforce_is_refused_unless_it_is_off ... ok +test pgwire::session::tests::a_transaction_option_this_engine_cannot_honour_is_refused ... ok +test pgwire::session::tests::an_encoding_this_server_cannot_produce_is_refused ... ok +test pgwire::session::tests::an_unknown_parameter_is_refused_but_a_namespaced_one_is_kept ... ok +test pgwire::session::tests::an_update_with_a_set_clause_is_not_a_session_set ... ok +test pgwire::session::tests::ordinary_sql_is_not_a_session_command ... ok +test pgwire::session::tests::pg_catalog_queries_get_an_explanation_rather_than_invented_rows ... ok +test pgwire::session::tests::reset_all_puts_every_parameter_back ... ok +test pgwire::session::tests::set_and_show_round_trip_a_parameter ... ok +test pgwire::session::tests::set_parses_the_spellings_drivers_actually_send ... ok +test pgwire::session::tests::the_normaliser_leaves_alone_what_is_not_transaction_control ... ok +test pgwire::session::tests::the_probes_a_real_driver_sends_are_recognised ... ok +test pgwire::session::tests::the_reported_isolation_level_is_the_one_the_engine_actually_gives ... ok +test pgwire::session::tests::the_reported_parameters_all_have_values ... ok +test pgwire::session::tests::the_startup_packet_can_set_parameters_too ... ok +test pgwire::session::tests::the_transaction_spellings_drivers_send_map_onto_the_three_this_engine_has ... ok +test pgwire::tests::the_reported_server_version_parses_as_a_version_and_stays_below_the_jit_era ... ok +test pgwire::types::tests::a_declared_column_type_maps_to_the_same_oid_as_a_value_of_it ... ok +test pgwire::types::tests::a_malformed_numeric_is_refused_rather_than_read_past_its_end ... ok +test pgwire::types::tests::a_row_that_disagrees_with_its_description_is_refused_not_encoded ... ok +test pgwire::types::tests::a_short_binary_parameter_is_refused_rather_than_padded ... ok +test pgwire::types::tests::a_whole_float_parameter_stays_a_float ... ok +test pgwire::types::tests::an_unencodable_oid_is_named_rather_than_guessed ... ok +test pgwire::types::tests::binary_integers_are_fixed_width_big_endian ... ok +test pgwire::types::tests::binary_parameters_decode_to_the_literal_the_binder_already_understands ... ok +test pgwire::types::tests::booleans_render_as_t_and_f_the_way_postgres_text_format_does ... ok +test pgwire::types::tests::null_is_null_in_both_formats_and_never_the_word ... ok +test pgwire::types::tests::numeric_encodes_to_the_documented_base_10000_shape ... ok +test pgwire::types::tests::numeric_round_trips_including_the_trailing_zeros_that_are_information ... ok +test pgwire::types::tests::numeric_zero_has_no_sign_and_no_digits ... ok +test pgwire::types::tests::text_parameters_decode_under_their_declared_oid ... ok +test pgwire::types::tests::values_map_to_the_postgres_type_oids_a_client_expects ... ok +test planner::plan::tests::test_bounds_basic ... ok +test planner::plan::tests::test_bounds_flipped ... ok +test planner::plan::tests::test_bounds_invalid ... ok +test planner::plan::tests::test_index_scan_range ... ok +test planner::plan::tests::test_index_scan_secondary_equality ... ok +test planner::plan::tests::test_index_scan_point ... ok +test provenance::capture::tests::a_capture_retains_reads_in_the_form_the_shape_demands ... ok +test provenance::capture::tests::a_halted_plan_names_no_version_to_undo_and_a_cascade_names_them_deepest_first ... ok +test provenance::capture::tests::a_point_read_never_retains_a_predicate_even_if_one_is_offered ... ok +test provenance::capture::tests::blind_writes_survive_the_round_trip_through_a_capture ... ok +test provenance::capture::tests::stamping_on_write_ties_the_version_to_the_run ... ok +test provenance::capture::tests::the_log_finds_a_downstream_dependent_and_halts ... ok +test provenance::capture::tests::the_report_names_the_agent_behind_each_blocking_transaction ... ok +test provenance::durable::tests::a_dropped_table_forgets_only_what_preceded_the_drop ... ok +test planner::plan::tests::test_index_scan_secondary_rejects_strict_lower ... ok +test planner::plan::tests::test_index_scan_unbounded ... ok +test provenance::durable::tests::a_failed_append_poisons_the_store_instead_of_leaving_it_looking_healthy ... ok +test provenance::durable::tests::a_foreign_file_is_refused ... ok +test provenance::durable::tests::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test provenance::durable::tests::a_stamp_naming_an_undeclared_run_is_refused_rather_than_healed ... ok +test provenance::durable::tests::a_restamped_version_replays_to_the_later_run ... ok +test provenance::durable::tests::re_interning_after_a_reopen_returns_the_same_slot ... ok +test provenance::durable::tests::logical_row_authorship_survives_a_close_and_reopen ... ok +test provenance::durable::tests::clearing_a_rows_author_survives_the_reopen_as_a_clear ... ok +test provenance::durable::tests::the_in_memory_guards_still_hold_and_a_refused_stamp_never_reaches_the_file ... ok +test provenance::durable::tests::run_records_out_of_order_in_the_file_still_replay_to_their_recorded_ids ... ok +test provenance::readset::tests::a_scan_makes_no_write_in_that_table_blind ... ok +test provenance::readset::tests::blind_writes_finds_the_row_nobody_looked_at ... ok +test provenance::readset::tests::form_is_decided_by_shape_alone ... ok +test provenance::durable::tests::who_wrote_a_row_still_answers_after_a_close_and_reopen ... ok +test provenance::readset::tests::intersecting_two_lower_bounds_keeps_the_tighter_one ... ok +test provenance::readset::tests::intersecting_two_upper_bounds_keeps_the_tighter_one ... ok +test provenance::readset::tests::predicate_summary_gives_phantom_coverage ... ok +test provenance::readset::tests::scattered_point_reads_are_never_coarsened_however_many ... ok +test provenance::readset::tests::the_intersected_region_is_what_covers_answers_over ... ok +test provenance::revert::tests::a_downstream_dependent_is_found_through_the_read_set ... ok +test provenance::revert::tests::a_predicate_read_depends_on_a_write_that_landed_inside_its_range ... ok +test provenance::revert::tests::a_scan_does_not_depend_on_a_write_it_could_not_have_seen ... ok +test provenance::revert::tests::a_transaction_does_not_depend_on_itself ... ok +test provenance::revert::tests::a_write_outside_the_scanned_range_creates_no_edge ... ok +test provenance::revert::tests::cascade_orders_dependents_before_their_dependency ... ok +test provenance::revert::tests::halt_is_the_default_and_reverts_nothing ... ok +test optimizer::cost_model::tests::test_cardinality ... ok +test provenance::revert::tests::predicate_reads_contribute_no_fake_exact_edges ... ok +test provenance::revert::tests::the_halt_tree_shows_the_whole_downstream_chain ... ok +test provenance::revert::tests::the_tree_terminates_on_a_read_write_cycle ... ok +test provenance::sha256::tests::a_prompt_digest_is_non_zero_and_depends_on_the_prompt ... ok +test provenance::sha256::tests::digests_at_every_padding_boundary_match_an_independent_implementation ... ok +test provenance::sha256::tests::fips_180_4_vectors ... ok +test provenance::sha256::tests::hex_round_trips ... ok +test provenance::sha256::tests::one_flipped_bit_changes_the_digest ... ok +test provenance::store::tests::a_logical_row_remembers_which_run_published_it ... ok +test provenance::sha256::tests::the_one_million_a_vector ... ok +test provenance::store::tests::a_page_dictionary_refuses_to_widen_past_a_byte ... ok +test provenance::store::tests::a_row_cannot_be_attributed_to_a_run_this_store_never_interned ... ok +test provenance::store::tests::a_run_can_be_asked_what_it_wrote ... ok +test provenance::store::tests::an_unstamped_version_is_unattributed_not_wrong ... ok +test provenance::store::tests::attributed_rows_reads_one_table_in_row_order ... ok +test provenance::store::tests::attributing_a_row_to_nobody_clears_it_rather_than_being_refused ... ok +test provenance::store::tests::attribution_is_run_level_so_a_page_holds_one_dictionary_entry_per_run ... ok +test provenance::store::tests::criterion_nine_which_agent_run_model_wrote_this_row ... ok +test provenance::store::tests::distinct_runs_get_distinct_slots ... ok +test provenance::store::tests::forgetting_a_table_drops_its_rows_and_only_its_rows ... ok +test provenance::store::tests::interning_the_same_run_twice_is_a_lookup ... ok +test provenance::store::tests::re_interning_a_run_with_a_different_model_is_refused ... ok +test provenance::store::tests::re_publishing_a_row_moves_its_author_to_the_later_run ... ok +test provenance::store::tests::stamping_a_version_with_no_provenance_is_refused ... ok +test provenance::store::tests::stamping_with_an_uninterned_slot_is_refused ... ok +test provenance::store::tests::the_density_numbers_the_docs_quote_are_the_numbers_this_computes ... ok +test provenance::store::tests::the_interned_slot_beats_a_literal_header_by_a_wide_margin ... ok +test provenance::store::tests::the_local_slot_round_trips_through_the_dictionary ... ok +test provenance::tests::prov_id_zero_means_unattributed ... ok +test provenance::tests::run_entity_answers_exit_criterion_nine ... ok +test replication::backup::tests::a_backup_older_than_the_primarys_log_is_refused_by_name ... ok +test replication::backup::tests::a_label_round_trips_through_text ... ok +test replication::backup::tests::a_label_that_is_not_one_is_refused ... ok +test replication::backup::tests::a_label_without_the_version_header_is_refused_rather_than_guessed_at ... ok +test replication::backup::tests::a_checkpoint_may_not_discard_a_pinned_range ... ok +test replication::backup::tests::a_backup_records_the_lsn_window_its_pages_sit_in ... ok +test replication::backup::tests::pinning_an_lsn_the_log_has_already_dropped_is_refused ... ok +test replication::jsonl::tests::a_bare_json_number_would_lose_these_values_which_is_why_they_are_strings ... ok +test replication::jsonl::tests::a_create_table_shape_omits_the_denied_column ... ok +test replication::backup::tests::a_truncated_image_is_refused_rather_than_restored ... ok +test replication::jsonl::tests::a_declaration_carries_no_alteration ... ok +test replication::jsonl::tests::a_delete_line_carries_before_and_a_null_after ... ok +test replication::jsonl::tests::a_delete_projects_its_only_image ... ok +test replication::jsonl::tests::a_denied_column_is_named_nowhere_in_the_line ... ok +test replication::jsonl::tests::a_feed_with_nothing_withheld_is_unchanged ... ok +test replication::jsonl::tests::a_mixed_row_keeps_narrow_types_as_numbers_and_wide_types_as_strings ... ok +test replication::jsonl::tests::a_null_column_is_present_and_null_not_absent ... ok +test replication::jsonl::tests::a_null_wide_column_is_json_null_not_a_quoted_null ... ok +test replication::jsonl::tests::a_refusal_minted_inside_the_renderer_still_writes_nothing ... ok +test replication::jsonl::tests::a_rename_line_carries_the_old_name_the_new_shape_cannot ... ok +test replication::jsonl::tests::a_retype_line_gives_both_type_spellings ... ok +test replication::jsonl::tests::a_row_whose_present_values_are_all_withheld_is_refused ... ok +test replication::jsonl::tests::a_snapshot_read_projects_its_row ... ok +test replication::backup::tests::the_image_holds_the_primarys_pages ... ok +test replication::jsonl::tests::a_table_dump_is_projected_and_refused_by_the_same_policy ... ok +test replication::jsonl::tests::a_whole_numbered_float_keeps_its_decimal_point ... ok +test replication::jsonl::tests::a_withheld_first_column_leaves_valid_json ... ok +test replication::jsonl::tests::an_add_column_line_names_the_added_column_and_the_new_shape ... ok +test replication::jsonl::tests::an_insert_line_carries_after_and_a_null_before ... ok +test replication::jsonl::tests::an_undecided_table_is_refused_by_the_renderer ... ok +test replication::jsonl::tests::an_update_line_carries_both_images ... ok +test replication::jsonl::tests::both_images_of_an_update_are_projected ... ok +test replication::jsonl::tests::column_names_are_escaped_too ... ok +test replication::jsonl::tests::every_character_json_requires_escaping_is_escaped ... ok +test replication::jsonl::tests::floats_round_trip_bit_for_bit ... ok +test replication::jsonl::tests::integer_extremes_survive_exactly ... ok +test replication::jsonl::tests::non_ascii_passes_through_as_utf8 ... ok +test replication::jsonl::tests::non_finite_floats_become_strings_rather_than_invalid_json ... ok +test replication::jsonl::tests::surplus_values_get_visible_synthetic_names_rather_than_vanishing ... ok +test replication::jsonl::tests::the_wide_types_are_emitted_as_quoted_strings ... ok +test replication::jsonl::tests::write_feed_reports_how_many_lines_it_wrote ... ok +test replication::jsonl::tests::write_feed_writes_nothing_at_all_when_any_event_is_refused ... ok +test replication::logical::tests::a_change_to_an_unknown_table_is_reported_rather_than_dropped ... ok +test replication::logical::tests::a_committed_insert_becomes_a_typed_row_event ... ok +test replication::logical::tests::a_ddl_record_whose_alteration_is_not_in_its_shape_is_refused ... ok +test replication::logical::tests::a_decoder_with_no_tables_reports_everything_as_unresolved ... ok +test replication::logical::tests::a_declaration_names_a_run_without_attributing_anybodys_commit ... ok +test replication::logical::tests::a_second_session_of_one_run_is_not_a_conflicting_actor ... ok +test replication::logical::tests::a_null_column_decodes_as_null ... ok +test replication::logical::tests::an_aborted_transaction_emits_nothing ... ok +test replication::logical::tests::an_open_transaction_is_withheld_and_reported ... ok +test replication::logical::tests::an_mvcc_delete_is_reported_as_a_delete_not_an_update ... ok +test replication::logical::tests::an_update_carries_the_row_before_and_after ... ok +test replication::logical::tests::sql_type_contract_is_exhaustive ... ok +test replication::logical::tests::events_are_ordered_by_commit_not_by_write ... ok +test cow::tests_compact_refusal::a_starved_delete_never_releases_a_page_the_tree_still_points_at ... ok +test replication::publication::tests::a_column_name_the_format_cannot_express_is_withheld_never_published ... ok +test replication::publication::tests::a_create_table_shape_is_masked_like_a_row ... ok +test replication::publication::tests::a_declaration_with_no_header_is_refused ... ok +test replication::logical::tests::one_provenance_slot_naming_two_actors_is_refused ... ok +test replication::logical::tests::time_travel_records_are_counted_as_internal_not_as_lost ... ok +test replication::publication::tests::a_drop_table_is_decided_by_the_table_and_not_by_its_columns ... ok +test replication::publication::tests::a_duplicate_column_is_refused ... ok +test replication::publication::tests::a_header_with_no_name_is_refused_as_a_nameless_header ... ok +test replication::publication::tests::a_line_the_parser_cannot_read_is_refused_rather_than_skipped ... ok +test replication::publication::tests::a_name_containing_the_comment_character_is_refused_rather_than_shortened ... ok +test replication::publication::tests::a_name_that_is_not_an_identifier_is_refused ... ok +test replication::publication::tests::a_publication_that_names_no_table_is_refused_at_parse ... ok +test replication::publication::tests::a_refusal_says_which_publication_refused_what ... ok +test replication::publication::tests::a_table_declared_twice_is_refused ... ok +test replication::publication::tests::a_table_published_with_no_columns_is_refused_like_an_absent_one ... ok +test replication::publication::tests::a_table_that_is_both_published_and_excluded_is_refused_either_way_round ... ok +test replication::publication::tests::a_table_that_is_not_in_the_publication_is_refused ... ok +test replication::publication::tests::a_table_with_no_columns_is_refused_at_parse ... ok +test replication::publication::tests::a_value_with_no_column_name_is_refused_under_an_allowlist ... ok +test replication::publication::tests::an_event_whose_every_column_is_withheld_is_refused ... ok +test replication::publication::tests::an_excluded_table_is_decided_against_rather_than_undecided ... ok +test replication::publication::tests::both_images_of_an_update_are_covered_by_the_value_check ... ok +test replication::publication::tests::mask_for_decides_a_bare_table_and_column_list ... ok +test replication::publication::tests::parse_narrows_to_exactly_what_it_names ... ok +test replication::publication::tests::two_columns_with_one_name_are_refused_rather_than_guessed ... ok +test replication::publication::tests::unrestricted_publishes_everything ... ok +test replication::publication::tests::whole_line_comments_and_blank_lines_are_ignored ... ok +test replication::snapshot::tests::a_failed_scan_yields_no_handoff ... ok +test replication::snapshot::tests::a_quiet_database_hands_off_exactly ... ok +test replication::snapshot::tests::a_failed_exact_scan_closes_its_reader_and_yields_no_handoff ... ok +test replication::snapshot::tests::a_snapshot_writes_read_events_and_reports_its_handoff ... ok +test replication::snapshot::tests::a_scan_that_fails_after_writing_reports_the_rollback_too ... ok +test replication::snapshot::tests::an_empty_table_still_yields_a_handoff_point ... ok +test replication::snapshot::tests::the_handoff_lsn_is_taken_before_the_scan_not_after ... ok +test replication::snapshot::tests::a_snapshot_whose_resume_point_cannot_be_pinned_leaves_no_reader_behind ... ok +test replication::snapshot::tests::an_exact_snapshot_writes_its_rows_and_pins_its_resume_point ... ok +test replication::snapshot::tests::the_boundary_covers_exactly_the_tables_that_were_delivered ... ok +test replication::stream::tests::a_batch_that_is_entirely_refused_does_not_advance_the_cursor ... ok +test replication::stream::tests::a_batch_that_is_entirely_suppressed_still_advances_the_cursor ... ok +test replication::stream::tests::a_large_backlog_is_delivered_in_bounded_batches ... ok +test replication::stream::tests::a_pump_emits_committed_changes_and_advances_past_them ... ok +test replication::stream::tests::a_cursor_below_the_logs_base_is_refused_by_name ... ok +test replication::stream::tests::a_long_open_transaction_does_not_cause_endless_redelivery ... ok +test replication::stream::tests::a_refusal_leaves_a_subscriptions_claim_below_the_refused_commit ... ok +test replication::stream::tests::a_refused_event_is_replayed_after_the_publication_is_widened ... ok +test replication::stream::tests::a_schema_event_survives_the_snapshot_filter ... ok +test replication::stream::tests::a_streamer_without_a_snapshot_boundary_suppresses_nothing ... ok +test replication::stream::tests::a_subscription_holds_the_log_across_a_checkpoint ... ok +test replication::stream::tests::a_transaction_that_was_in_flight_at_snapshot_time_is_still_delivered ... ok +test replication::stream::tests::an_already_delivered_event_does_not_wedge_the_cursor_when_it_later_becomes_unpublishable ... ok +test replication::stream::tests::an_excluded_tables_events_are_dropped_and_the_cursor_keeps_moving ... ok +test provenance::durable::tests::the_interning_claim_still_holds_after_a_reopen_on_the_existing_instrument ... ok +test replication::stream::tests::an_in_flight_transaction_does_not_move_the_cursor_and_is_emitted_after_it_commits ... ok +test replication::stream::tests::an_open_transaction_is_not_stepped_over_when_a_later_one_commits ... ok +test replication::stream::tests::changes_that_are_not_durable_yet_are_not_emitted ... ok +test replication::stream::tests::events_the_snapshot_already_contained_are_not_re_delivered ... ok +test replication::stream::tests::following_takes_its_cursor_from_the_boundary_and_refuses_without_one ... ok +test replication::stream::tests::lag_shrinks_as_a_consumer_catches_up ... ok +test replication::sync::tests::a_replica_that_reconnects_inside_the_deadline_still_satisfies_the_commit ... ok +test replication::stream::tests::resuming_from_a_commit_position_alone_loses_an_in_flight_transaction ... ok +test replication::stream::tests::resuming_from_the_returned_cursor_does_not_repeat_events ... ok +test replication::sync::tests::a_target_already_acked_does_not_wait ... ok +test replication::sync::tests::an_ack_never_goes_backwards ... ok +test replication::stream::tests::withholding_a_column_still_ships_the_row_and_advances_the_cursor ... ok +test replication::stream::tests::subscribing_below_the_base_is_refused_immediately ... ok +test replication::sync::tests::the_furthest_replica_satisfies_the_wait ... ok +test replication::sync::tests::an_ack_that_reaches_the_target_releases_the_waiter ... ok +test replication::tests::a_frame_too_short_for_its_own_fields_is_refused ... ok +test replication::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test replication::tests::an_empty_records_batch_is_distinct_from_no_batch ... ok +test replication::sync::tests::with_no_replica_it_refuses_rather_than_degrading_silently ... ok +test replication::tests::an_oversized_length_is_refused_without_allocating ... ok +test replication::tests::every_message_round_trips ... ok +test replication::tests::the_handshake_rejects_a_stranger_and_a_version_mismatch ... ok +test replication::tests::the_length_covers_the_body_only ... ok +test storage::atomic_file::tests::a_bare_relative_target_syncs_the_current_directory_not_the_empty_path ... ok +test storage::atomic_file::tests::a_durable_append_is_one_fsync_where_a_replace_is_two ... ok +test storage::atomic_file::tests::a_durable_replace_syncs_the_temporary_before_the_rename_and_the_directory_after_it ... ok +test storage::atomic_file::tests::a_path_that_names_no_file_is_refused_rather_than_staged_through_dot_tmp ... ok +test storage::atomic_file::tests::syncing_a_real_directory_succeeds_on_this_platform ... ok +test storage::atomic_file::tests::the_temporary_appends_tmp_instead_of_replacing_the_extension ... ok +test storage::atomic_file::tests::appending_to_a_missing_file_is_refused_rather_than_creating_a_headless_tail ... ok +test storage::db_lock::tests::a_second_claim_on_one_database_is_refused ... ok +test storage::db_lock::tests::a_stale_lock_is_refused_and_names_the_file_to_remove ... ok +test storage::db_lock::tests::an_empty_lock_file_still_refuses ... ok +test storage::atomic_file::tests::a_replace_after_appends_drops_the_tail_instead_of_stranding_it ... ok +test storage::db_lock::tests::dropping_the_lock_lets_the_next_open_through ... ok +test storage::db_lock::tests::two_different_databases_do_not_contend ... ok +test storage::disk_manager::tests::a_page_between_two_regions_belongs_to_this_allocator_and_can_be_freed ... ok +test storage::disk_manager::tests::a_second_reservation_at_a_higher_base_is_refused ... ok +test storage::atomic_file::tests::the_real_filesystem_replace_lands_the_bytes_and_leaves_no_temporary ... ok +test storage::disk_manager::tests::allocate_skips_a_bounded_region_and_keeps_handing_out_pages_above_it ... ok +test storage::disk_manager::tests::growth_advances_past_bounded_regions_and_stops_at_an_unbounded_one ... ok +test storage::disk_manager::tests::an_unbounded_region_still_stops_the_allocator_and_names_the_knob ... ok +test storage::disk_manager::tests::exhausting_the_table_region_says_the_knob_will_not_help_this_database ... ok +test storage::disk_manager::tests::growing_the_bitmap_does_not_reuse_owned_pages ... ok +test storage::disk_manager::tests::high_water_does_not_regress_after_a_free ... ok +test cow::diff::tests::warming_a_whole_tree_through_an_on_demand_digest_costs_more_than_the_o_n_path ... ok +test storage::disk_manager::tests::next_page_id_is_not_the_high_water_mark ... ok +test storage::disk_manager::tests::overlapping_regions_are_refused_and_the_message_names_both ... ok +test storage::disk_manager::tests::re_registering_an_identical_region_is_a_restart_not_an_error ... ok +test cow::tests_compact_refusal::d125_instrument_the_same_starvation_on_shadowed_pages ... ok +test storage::disk_manager::tests::reattaching_at_the_same_base_is_allowed ... ok +test storage::disk_manager::tests::two_bounded_regions_coexist_which_a_single_floor_refused ... ok +test storage::disk_manager::tests::test_freelist ... ok +test storage::heap_file_manager::tests::test_insert_and_read ... ok +test storage::disk_manager::tests::test_rw ... ok +test storage::heap_page::tests::test_basic ... ok +test storage::heap_file_manager::tests::test_insert_delete_scan ... ok +test storage::heap_page::tests::test_exact_bytes ... ok +test storage::heap_file_manager::tests::test_scan ... ok +test storage::heap_file_manager::tests::test_update_in_place ... ok +test storage::heap_file_manager::tests::test_multiple_pages ... ok +test storage::index::tests::test_create_init_empty_leaf ... ok +test storage::index::tests::test_delete_not_real_key ... ok +test storage::index::tests::test_delete_basic ... ok +test storage::index::tests::test_internal_node_split_a_lot ... ok +test storage::index::tests::an_entry_larger_than_a_page_is_refused_rather_than_split_into_an_empty_leaf ... ok +test storage::index::tests::test_range_scan_basic ... ok +test storage::index::tests::test_search_empty_tree_returns_none ... ok +test storage::index::tests::test_search_finds_key ... ok +test storage::index::tests::test_range_scan_many_pages ... ok +test storage::index::tests::test_simple_insert ... ok +test storage::index_fulltext::tests::an_over_long_run_is_dropped_and_its_neighbours_are_not ... ok +test storage::index_fulltext::tests::distinct_tokens_dedups_but_tokenize_does_not ... ok +test storage::index_fulltext::tests::indexed_text_accepts_varchar_and_null_and_refuses_the_rest ... ok +test storage::index_fulltext::tests::null_is_the_floor_of_the_second_component ... ok +test storage::index_fulltext::tests::the_cap_counts_bytes_not_chars ... ok +test storage::index::tests::test_many_level_insert_and_search ... ok +test storage::index_fulltext::tests::tokenize_emits_no_empty_tokens ... ok +test storage::index_fulltext::tests::tokenize_limits_are_the_documented_ones ... ok +test storage::index_fulltext::tests::tokenize_lowercases_and_splits_on_non_alphanumerics ... ok +test storage::index::tests::test_many_level_reverse ... ok +test storage::index_page::tests::test_internal_find_child ... ok +test storage::index_page::tests::test_internal_insert_key_child ... ok +test storage::index_page::tests::test_internal_split ... ok +test storage::index_page::tests::test_leaf_composite_keys ... ok +test storage::index_page::tests::test_leaf_get ... ok +test storage::index_page::tests::test_leaf_insert_sorted ... ok +test storage::index_page::tests::test_leaf_remove ... ok +test storage::index_page::tests::test_leaf_split ... ok +test storage::index_page::tests::test_roundtrip_i_p ... ok +test storage::index_page::tests::test_roundtrip_i_s ... ok +test storage::index_page::tests::test_roundtrip_l_p ... ok +test storage::index_page::tests::test_roundtrip_l_s ... ok +test storage::page_latch::tests::a_read_latch_taken_from_inside_the_pool_is_refused - should panic ... ok +test storage::page_latch::tests::a_write_latch_taken_from_inside_the_pool_is_refused - should panic ... ok +test storage::page_latch::tests::different_pages_do_not_block_each_other ... ok +test storage::page_latch::tests::latching_first_and_then_entering_the_pool_is_allowed ... ok +test storage::page_latch::tests::nested_pool_sections_unwind_to_zero_and_not_below ... ok +test storage::page_latch::tests::readers_share_and_a_writer_excludes ... ok +test storage::page_latch::tests::the_depth_is_per_thread ... ok +test storage::page_latch::tests::the_table_empties_when_every_guard_is_dropped ... ok +test storage::tuple::tests::serialising_a_row_that_does_not_match_its_schema_is_refused ... ok +test storage::tuple::tests::test_null ... ok +test storage::page_latch::tests::the_write_latch_actually_serialises ... ok +test storage::tuple::tests::test_se_and_deserialize ... ok +test storage::tuple::tests::test_versioned_tuple_roundtrip ... ok +test tel::capture::tests::a_delta_against_a_different_column_is_not_a_relative_move ... ok +test tel::capture::tests::a_guard_reading_outside_the_target_table_is_refused ... ok +test tel::capture::tests::a_join_shifts_the_target_tables_offsets ... ok +test tel::capture::tests::a_literal_assignment_stays_an_assign ... ok +test tel::capture::tests::a_single_table_plan_still_maps_offsets_straight_through ... ok +test tel::capture::tests::a_snapshot_refuses_a_row_it_does_not_hold ... ok +test tel::capture::tests::a_where_clause_becomes_a_guard_that_can_be_rechecked_later ... ok +test tel::capture::tests::an_untranslatable_predicate_is_refused_rather_than_weakened ... ok +test tel::capture::tests::arithmetic_inside_a_guard_survives_capture ... ok +test tel::capture::tests::conjunctions_and_negations_survive_capture ... ok +test tel::capture::tests::end_to_end::concurrent_decrements_compose_arithmetically ... ok +test tel::capture::tests::end_to_end::overselling_is_rejected_with_the_violated_predicate_returned ... ok +test tel::capture::tests::end_to_end::the_rejection_is_not_unconditional ... ok +test tel::capture::tests::five_minus_qty_is_not_an_add_because_subtraction_does_not_commute ... ok +test tel::capture::tests::five_plus_qty_is_still_an_add ... ok +test tel::capture::tests::qty_minus_five_is_captured_as_an_add_not_an_assign ... ok +test tel::engine::tests::a_delete_does_not_invalidate_its_own_where_clause ... ok +test tel::engine::tests::a_delete_versus_a_write_names_the_right_side_in_the_report ... ok +test tel::engine::tests::a_discarded_write_is_reported_as_the_side_s_net_effect ... ok +test tel::engine::tests::a_drawdown_that_exactly_exhausts_the_resource_is_allowed ... ok +test tel::engine::tests::a_duplicate_frame_across_sides_is_dropped_too ... ok +test optimizer::cost_model::tests::test_cost_secondary_over_primary ... ok +test tel::engine::tests::a_failing_lca_read_surfaces_instead_of_becoming_no_lca_value ... ok +test tel::engine::tests::a_frames_own_guarded_delete_is_not_unevaluable ... ok +test tel::engine::tests::a_genuinely_violated_precondition_still_conflicts ... ok +test tel::engine::tests::a_guard_reading_a_row_the_other_side_deleted_is_unevaluable ... ok +test tel::engine::tests::a_one_sided_write_is_clean_not_commuting ... ok +test tel::engine::tests::a_removed_dot_does_not_survive_the_merge ... ok +test tel::engine::tests::a_row_created_inside_the_merge_supplies_its_own_base_for_a_delta ... ok +test tel::engine::tests::a_row_routed_op_that_is_not_create_or_delete_is_refused ... ok +test tel::engine::tests::a_schema_version_mismatch_fails_loudly ... ok +test tel::engine::tests::a_set_op_meeting_a_scalar_op_conflicts_rather_than_panicking ... ok +test tel::engine::tests::a_solo_decrement_landing_on_its_own_bound_is_not_a_conflict ... ok +test tel::engine::tests::a_well_formed_row_create_is_still_composed ... ok +test tel::engine::tests::additive_policy_uses_the_three_way_formula_on_absolute_writes ... ok +test tel::engine::tests::an_absent_lca_cell_still_falls_back_to_the_fresh_image ... ok +test tel::engine::tests::an_uncontended_decrement_does_not_conflict_with_its_own_result ... ok +test tel::engine::tests::an_unevaluable_guard_is_distinct_from_a_failed_one ... ok +test tel::engine::tests::both_sides_deleting_the_same_row_is_idempotent ... ok +test tel::engine::tests::contradictory_assigns_under_the_default_policy_conflict ... ok +test tel::engine::tests::dedup_reports_which_txn_it_dropped ... ok +test tel::engine::tests::delete_on_one_side_versus_a_write_on_the_other_conflicts ... ok +test tel::engine::tests::diff_returns_the_ops_and_guards_the_target_branch_added ... ok +test tel::engine::tests::diff_without_a_log_is_an_error_not_an_empty_changeset ... ok +test tel::engine::tests::escrow_floor_is_rechecked_against_merged_state ... ok +test tel::engine::tests::every_violated_guard_is_reported_not_just_the_first ... ok +test tel::engine::tests::folding_a_side_applies_its_own_ops_in_order ... ok +test tel::engine::tests::guard_failing_against_merged_state_is_a_conflict_with_the_predicate_returned ... ok +test tel::engine::tests::identical_assigns_are_not_a_conflict ... ok +test tel::engine::tests::lww_orders_by_global_txn_id_not_branch_local_seq ... ok +test tel::engine::tests::lww_reports_resolved_with_loss_and_names_the_discarded_write ... ok +test tel::engine::tests::main_untouched_is_clean ... ok +test tel::engine::tests::max_and_min_compose_without_a_policy ... ok +test tel::engine::tests::merging_frames_read_back_from_the_log_reaches_the_same_answer ... ok +test tel::engine::tests::multi_value_keeps_both_writes ... ok +test tel::engine::tests::multi_value_surfaces_each_side_s_net_effect ... ok +test tel::engine::tests::multivalue_composes_net_effects_not_last_ops ... ok +test tel::engine::tests::observed_remove_spares_an_insert_the_remover_never_saw ... ok +test tel::engine::tests::replayed_frame_is_dropped_rather_than_applied_twice ... ok +test tel::engine::tests::the_same_guard_holds_when_the_composition_leaves_headroom ... ok +test tel::engine::tests::two_branches_decrementing_compose_arithmetically ... ok +test tel::engine::tests::two_identical_decrements_from_different_txns_are_not_deduplicated ... ok +test tel::guard::tests::an_and_with_an_unknown_operand_is_unknown_not_true ... ok +test tel::guard::tests::an_or_with_an_unknown_operand_is_unknown_and_not_negatable_into_true ... ok +test tel::guard::tests::bounded_counter_guard_composes_with_arithmetic ... ok +test storage::index::tests::internal_nodes_split_before_overflowing_the_page ... ok +test tel::guard::tests::guard_is_rechecked_against_whatever_state_it_is_given ... ok +test tel::guard::tests::null_operand_makes_the_guard_unknown_not_true ... ok +test tel::guard::tests::referenced_cells_finds_every_read ... ok +test tel::guard::tests::unreadable_cell_is_an_error_not_a_false ... ok +test tel::guard::tests::violated_predicate_is_returned_verbatim ... ok +test tel::log::tests::frames_come_back_in_sequence_order_per_branch ... ok +test tel::log::tests::guards_survive_the_round_trip_separately_from_ops ... ok +test tel::log::tests::pushing_a_key_the_index_already_holds_is_refused_not_overwritten - should panic ... ok +test tel::log::tests::replaying_the_identical_frame_does_not_store_it_twice ... ok +test tel::log::tests::reusing_a_txn_id_for_different_contents_is_an_error_not_an_overwrite ... ok +test tel::log::tests::the_position_index_and_the_frames_agree_per_key ... ok +test tel::log::tests::the_same_txn_id_on_a_different_branch_is_a_different_frame ... ok +test tel::log::tests_durable_log::a_boolean_byte_that_is_neither_zero_nor_one_is_refused ... ok +test tel::log::tests_durable_log::a_contradicting_reappend_is_refused_and_never_reaches_the_file ... ok +test tel::log::tests_durable_log::a_crash_at_any_point_during_an_append_loses_nothing_already_acknowledged ... ok +test tel::log::tests_durable_log::a_delta_whose_prior_counts_do_not_line_up_is_refused ... ok +test tel::log::tests_durable_log::a_failed_append_leaves_a_store_that_still_works_and_a_file_that_still_opens ... ok +test tel::log::tests_durable_log::a_foreign_file_and_a_damaged_header_are_both_refused ... ok +test tel::log::tests_durable_log::a_growing_frame_replays_to_its_final_contents_and_not_to_the_sum_of_its_appends ... ok +test tel::log::tests_durable_log::a_growth_whose_tail_carries_guards_and_claims_replays_with_them ... ok +test tel::log::tests_durable_log::a_guard_nested_past_the_cap_is_refused_on_the_way_in_and_on_the_way_out ... ok +test tel::log::tests_durable_log::a_merge_computed_after_a_restart_agrees_with_one_computed_before_it ... ok +test tel::log::tests_durable_log::a_nan_delta_does_not_turn_a_retry_into_a_contradiction ... ok +test tel::log::tests_durable_log::a_partial_append_is_discarded_reported_and_then_written_over ... ok +test tel::log::tests_durable_log::a_read_error_mid_file_refuses_the_open_and_destroys_nothing ... ok +test tel::log::tests_durable_log::a_retried_frame_does_not_double_its_add_across_a_restart ... ok +test tel::log::tests_durable_log::a_second_open_for_one_key_is_refused ... ok +test tel::log::tests_durable_log::an_extend_for_a_frame_the_file_never_declared_is_refused ... ok +test tel::log::tests_durable_log::a_value_too_long_for_its_length_prefix_is_refused_rather_than_truncated ... ok +test tel::log::tests_durable_log::an_unknown_tag_and_a_record_with_trailing_bytes_are_both_refused ... ok +test tel::log::tests_durable_log::concurrent_appends_all_reach_the_file_exactly_once ... ok +test tel::log::tests_durable_log::frames_survive_a_restart_with_every_field_intact ... ok +test tel::log::tests_durable_log::growth_costs_the_delta_and_not_the_whole_frame_again ... ok +test tel::log::tests_durable_log::retyping_a_stored_value_under_growth_cannot_split_the_two_stores ... ok +test tel::log::tests_durable_log::the_position_index_survives_a_replay_intact ... ok +test tel::log::tests_durable_log::the_same_txn_id_on_a_different_branch_is_a_different_frame_after_a_restart ... ok +test tel::merge::tests::conflict_carries_the_predicate_back_to_the_agent ... ok +test tel::merge::tests::default_policy_forbids_concurrent_writes ... ok +test tel::merge::tests::lossy_resolution_never_reads_as_clean ... ok +test tel::op::tests::assign_is_idempotent_but_does_not_commute ... ok +test tel::op::tests::delta_applies_and_refuses_non_numeric ... ok +test tel::op::tests::integer_delta_overflow_is_an_error_not_a_wrap ... ok +test tel::op::tests::two_identical_decrements_compose_to_double ... ok +test tel::log::tests_durable_log::the_log_opens_on_a_real_file_and_recovers_from_it ... ok +test tel::schema_merge::tests::a_one_sided_schema_change_is_clean ... ok +test tel::schema_merge::tests::a_predicate_about_an_absent_column_is_answerable ... ok +test tel::schema_merge::tests::a_rename_composes_with_an_unrelated_add ... ok +test tel::schema_merge::tests::a_rename_onto_a_name_another_branch_created_conflicts ... ok +test tel::schema_merge::tests::adding_the_same_column_twice_with_different_types_conflicts ... ok +test tel::schema_merge::tests::an_identical_edit_is_absorbed_rather_than_refused ... ok +test tel::schema_merge::tests::no_edits_is_clean_even_when_the_target_moved ... ok +test tel::schema_merge::tests::renaming_a_column_another_branch_renamed_first_conflicts ... ok +test tel::schema_merge::tests::the_returned_predicate_re_evaluates_to_false ... ok +test tel::schema_merge::tests::two_branches_adding_different_columns_compose ... ok +test tel::schema_merge::tests::two_branches_retyping_one_column_conflict_and_name_the_predicate ... ok +test wal::log::tests::a_run_identity_record_that_names_no_run_is_refused ... ok +test wal::log::tests::a_truncated_alteration_is_refused_rather_than_panicking ... ok +test wal::log::tests::a_truncated_run_identity_record_is_refused_rather_than_panicking ... ok +test wal::log::tests::an_older_logs_records_still_decode_after_the_new_tag_was_added ... ok +test wal::log::tests::an_unknown_alteration_tag_is_refused ... ok +test wal::log::tests::a_record_whose_bytes_were_corrupted_is_refused ... ok +test wal::log::tests::create_and_drop_records_keep_their_byte_layout ... ok +test wal::log::tests::deserialize_rejects_empty_and_unknown_tag ... ok +test wal::log::tests::every_column_alteration_survives_the_log_round_trip ... ok +test wal::log::tests::test_crc32_val ... ok +test wal::log::tests::test_reckind_roundtrip ... ok +test wal::log::tests::test_corrupted_last_record_detected ... ok +test wal::log::tests::test_flush_up_to_stops_when_durable ... ok +test wal::log::tests::test_lsns_are_monotonic ... ok +test wal::log::tests::test_read_buffer_before_flush ... ok +test wal::log::tests::test_survives_flush_and_reopen ... ok +test wal::log::tests::the_recovery_scan_stops_at_a_corrupted_record ... ok +test wal::recovery::tests::test_insert_survives_crash ... ok +test wal::recovery::tests::test_uncommited_rolled_back ... ok +test wal::txn::tests::a_quiet_database_resumes_at_the_readers_own_begin ... ok +test wal::recovery::tests::sql_crash_recover_rebuild_query ... ok +test wal::txn::tests::a_refused_binding_leaves_nothing_behind_for_the_commit_to_write ... ok +test wal::recovery::tests::test_update_delete_redo_after_checkpoint ... ok +test wal::txn::tests::a_reader_is_not_leaked_when_its_txn_end_cannot_be_written ... ok +test wal::txn::tests::a_second_session_of_one_run_is_the_same_actor_however_the_clock_moved ... ok +test wal::txn::tests::binding_a_run_with_an_empty_named_field_is_refused ... ok +test wal::txn::tests::a_snapshot_read_resumes_at_the_oldest_transaction_it_excluded ... ok +test wal::txn::tests::a_snapshot_reader_is_not_leaked_when_its_handoff_cannot_be_pinned ... ok +test wal::txn::tests::declaring_one_slot_as_two_actors_is_refused ... ok +test wal::txn::tests::a_snapshot_reader_that_writes_is_refused_and_rolled_back ... ok +test wal::txn::tests::gate_covers_flush_all ... ok +test wal::txn::tests::checkpoint_truncates_and_preserves_data ... ok +test wal::txn::tests::interrupted_truncation_self_heals_on_open ... ok +test wal::txn::tests::test_visibility_matrix ... ok +test wal::txn::tests::gate_flushed_wal_before_page_write ... ok +test wal::txn::tests::the_high_water_mark_is_exclusive ... ok +test wal::txn::tests::test_begin_creates_snapshot ... ok +test wal::txn::tests::test_abort_delete_restores_row ... ok +test wal::txn::tests::sql_insert_commits_through_run ... ok +test wal::txn::tests::test_abort_insert_removes_rows ... ok +test wal::visibility::tests::an_uncontended_version_and_my_own_write_are_both_writable ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_created_is_a_conflict ... ok +test wal::txn::tests::test_commit_writes_chain_and_flushes ... ok +test wal::visibility::tests::overwriting_a_version_an_in_flight_transaction_deleted_is_a_conflict ... ok +test wal::txn::tests::torn_tail_trimmed_on_open ... ok +test wal::txn::tests::the_run_identity_record_is_the_last_record_before_its_own_commit ... ok +test wal::txn::tests::transaction_ids_never_start_at_zero ... ok +test storage::atomic_file::tests::concurrent_replaces_of_one_target_never_publish_a_mixture ... ok +test branch::table_catalog::tests::a_reopen_through_the_header_page_follows_a_root_that_moved ... ok +test branch::table_catalog::tests::a_sidecar_catalog_survives_being_closed_and_reopened_from_the_file_alone ... ok +test optimizer::cost_model::tests::test_join_picks_hash ... ok +test execution::hash_join::tests::nested_loop_and_hash_join_agree_across_integer_and_float_keys ... ok +test execution::hash_join::tests::hash_join_matches_integer_key_against_float_key ... ok +test cow::tests_compact_refusal::a_starved_allocation_never_leaves_an_unverifiable_page ... ok +test execution::executor::tests::root_split_persists_across_reopen ... ok +test optimizer::cost_model::tests::explain_index_flip_at_volume ... ok +test optimizer::cost_model::tests::test_cost_index_vs_seq ... ok +test branch::reaper::tests::table_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent has been running for over 60 seconds +test branch::reaper::tests::table_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok + +test result: ok. 1659 passed; 0 failed; 2 ignored; 0 measured; 0 filtered out; finished in 63.04s + +=== target adv_f4_false_refusal + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 8.48s + Running tests/adv_f4_false_refusal.rs (target/debug/deps/adv_f4_false_refusal-6c13c5accbcb295f) + +running 13 tests +test two_branches_adding_the_same_column_do_not_leave_two_columns_of_that_name ... ok +test quarantine_release_then_merge ... ok +test two_interleaved_connections_merging ... ok +test revert_then_merge ... ok +test a_gate_refusal_then_merge ... ok +test a_conflicting_merge_then_merge ... ok +test abandon_then_new_session_then_merge ... ok +test simulate_then_merge ... ok +test multi_op_merges_in_sequence ... ok +test revert_cascade_then_merge ... ok +test a_merge_that_fails_after_reserving_then_merge ... ok +test forty_merges_with_reverts_interleaved ... ok +test a_hundred_merges_in_a_row ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.11s + +=== target adv_f5_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 8.88s + Running tests/adv_f5_probe.rs (target/debug/deps/adv_f5_probe-68c5843e4ccb02b0) + +running 5 tests +test p2_agent_session_reads_another_live_branch_as_of ... ok +test p5_an_update_on_a_sealed_branch_now_reports_a_read_error ... ok +test p1_non_agent_as_of_branch_reads_fine_and_retains_nothing ... ok +test p3_supervisor_merge_by_name_leaves_the_agent_connection_inert ... ok +test p4_a_read_on_a_branch_the_reaper_took_is_not_refused_and_pins_revert_forever ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s + +=== target adv_f6_dropped_capture + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.23s + Running tests/adv_f6_dropped_capture.rs (target/debug/deps/adv_f6_dropped_capture-dab99c539ab9b0b9) + +running 3 tests +test control_merging_the_parent_keeps_the_same_dependent ... ok +test reaping_a_parent_whose_child_published_its_write_loses_the_dependent ... ok +test abandoning_a_parent_whose_child_published_its_write_loses_the_dependent ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +=== target adversarial_i20 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.00s + Running tests/adversarial_i20.rs (target/debug/deps/adversarial_i20-6dd5677a4cbe21b5) + +running 15 tests +test b6_a_truncated_away_alter_leaves_a_diverged_replica_reporting_caught_up ... ignored, known-open defect, ledger row S5: needs a design decision on where replica-local durable state lives; run with --ignored to reproduce +test b4_a_clr_wrapping_an_alter_is_not_seen_by_the_guard ... ok +test a8_a_refused_create_table_still_creates_the_table_and_logs_nothing ... ok +test a6_a_cursor_clamped_backwards_over_a_ddl ... ok +test a1_the_same_range_decodes_differently_depending_on_what_ran_before ... ok +test b2_a_restarted_replica_meets_the_alter_again ... ok +test b1_a_refusal_leaves_applied_lsn_and_the_file_untouched ... ok +test b5_small_batches_still_halt_at_the_alter ... ok +test a3_nine_columns_across_a_pump_boundary ... ok +test a4_drop_and_recreate_reusing_the_dir_root ... ok +test b3_the_alter_record_truncated_away_before_the_restart ... ok +test a7_a_ddl_can_never_share_a_range_with_an_open_transaction ... ok +test a9_history_learned_from_one_log_is_applied_to_another ... ok +test a5_history_growth_under_a_live_subscription_pin ... ok +test a2_two_threads_pumping_one_streamer ... ok + +test result: ok. 14 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 45.80s + +=== target agent_sql_surface + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.78s + Running tests/agent_sql_surface.rs (target/debug/deps/agent_sql_surface-3cafa62735b530de) + +running 32 tests +test a_comparison_on_a_branch_does_not_round_the_literal ... ok +test agent_statements_outside_a_session_name_the_missing_branch ... ok +test as_of_branch_sees_uncommitted_inserts_and_deletes_too ... ok +test captured_frames_reach_the_effect_log_as_one_frame_per_task ... ok +test abandon_drops_the_branch_and_leaves_main_untouched ... ok +test a_session_declaring_no_model_is_recorded_as_unspecified_not_guessed ... ok +test a_nested_task_forks_from_its_parents_state_and_then_diverges ... ok +test branch_writes_are_invisible_to_main_and_to_siblings ... ok +test a_second_agent_session_on_one_connection_is_refused ... ok +test a_malformed_model_is_refused_rather_than_recorded_blank ... ok +test a_conflicting_merge_attributes_nothing ... ok +test begin_agent_session_forks_a_branch_and_interns_the_run ... ok +test a_solo_merge_is_clean_and_publishes_to_main ... ok +test a_guard_that_fails_against_merged_state_returns_the_predicate_itself ... ok +test a_multi_row_merge_publishes_every_row_in_one_transaction ... ok +test a_whole_numbered_literal_updates_a_float_column_on_a_branch_and_on_trunk ... ok +test a_merge_with_no_downstream_reader_reverts_without_cascading ... ok +test an_lww_column_resolves_with_loss_and_never_reports_clean ... ok +test concurrent_assignments_conflict_rather_than_silently_picking_one ... ok +test inserts_and_deletes_on_a_branch_are_also_invisible_until_merge ... ok +test diff_of_an_untouched_branch_is_empty_but_still_a_changeset ... ok +test ordinary_statements_are_unaffected_by_the_agent_surface ... ok +test diff_marks_a_row_the_target_moved_under ... ok +test select_as_of_branch_reads_another_branchs_uncommitted_state ... ok +test reverting_an_unknown_merge_is_an_error_not_a_no_op ... ok +test diff_returns_a_structured_changeset_with_ops_and_outcomes ... ok +test two_decrements_inside_one_task_are_not_collapsed ... ok +test two_runs_are_attributed_separately_and_interned_once_each ... ok +test row_authorship_survives_the_merge_that_published_it ... ok +test two_branches_decrementing_the_same_row_compose_arithmetically ... ok +test revert_merge_halts_on_a_downstream_dependent_then_cascades_on_request ... ok +test row_authorship_survives_a_process_restart_when_provenance_is_durable ... ok + +test result: ok. 32 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.61s + +=== target d101_designated_runtime + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.21s + Running tests/d101_designated_runtime.rs (target/debug/deps/d101_designated_runtime-727a68a97db965ea) + +running 2 tests +test an_agent_statement_on_an_undesignated_runtime_is_refused ... ok +test a_correct_harness_is_not_refused ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s + +=== target d101_no_server_context + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.46s + Running tests/d101_no_server_context.rs (target/debug/deps/d101_no_server_context-de181ede2d9185c8) + +running 1 test +test nothing_is_designated_so_nothing_is_refused ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s + +=== target d124_owner_record_refusal + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.02s + Running tests/d124_owner_record_refusal.rs (target/debug/deps/d124_owner_record_refusal-5e2fdccc71358212) + +running 4 tests +test log_catalog_drain_pending_refuses_an_unreadable_owner ... ok +test log_catalog_free_page_refuses_an_unreadable_owner ... ok +test table_catalog_free_page_refuses_an_unreadable_owner ... ok +test table_catalog_drain_pending_refuses_an_unreadable_owner ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +=== target d126_atomic_upsert + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.53s + Running tests/d126_atomic_upsert.rs (target/debug/deps/d126_atomic_upsert-afd33fa128f9476a) + +running 4 tests +test insert_still_does_not_replace ... ok +test a_growing_rewrite_that_splits_is_still_never_absent ... ok +test a_rewritten_key_is_never_absent_to_a_concurrent_reader ... ok +test the_primary_index_rewrite_is_never_absent_either ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.36s + +=== target d141_long_identifier + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.19s + Running tests/d141_long_identifier.rs (target/debug/deps/d141_long_identifier-aa2222abb63538df) + +running 4 tests +test a_three_hundred_byte_table_name_does_not_corrupt_the_catalog ... ok +test a_refused_create_table_does_not_wedge_the_next_one ... ok +test the_longest_expressible_table_name_still_works_from_sql ... ok +test many_refused_statements_leave_the_database_working ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +=== target d15_concurrent_fork_and_reap + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.30s + Running tests/d15_concurrent_fork_and_reap.rs (target/debug/deps/d15_concurrent_fork_and_reap-f616b8a3d4946baa) + +running 2 tests +test log_catalog_survives_a_concurrent_reaper ... ok +test table_catalog_survives_a_concurrent_reaper ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 6.94s + +=== target d151_parse_reads_a_refreshed_snapshot + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.66s + Running tests/d151_parse_reads_a_refreshed_snapshot.rs (target/debug/deps/d151_parse_reads_a_refreshed_snapshot-3f36ebae3ddbabc4) + +running 1 test +test a_parse_on_a_cached_connection_sees_ddl_another_connection_did ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d154_length_prefix_refusal + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.46s + Running tests/d154_length_prefix_refusal.rs (target/debug/deps/d154_length_prefix_refusal-20e5687817a52249) + +running 6 tests +test every_write_str_site_in_a_ddl_record_refuses_what_its_prefix_cannot_express ... ok +test a_compensation_record_refuses_through_the_record_it_wraps ... ok +test every_write_str_site_in_a_run_identity_record_refuses ... ok +test the_largest_value_each_prefix_can_express_still_encodes ... ok +test a_ddl_record_refuses_a_column_count_its_prefix_cannot_express ... ok +test the_durable_provenance_store_refuses_a_run_it_cannot_encode ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d159_fork_sync_is_deferred + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.50s + Running tests/d159_fork_sync_is_deferred.rs (target/debug/deps/d159_fork_sync_is_deferred-f270771df8447bac) + +running 7 tests +test the_unsplit_default_forks_durably_and_defers_nothing ... ok +test two_forks_staged_before_either_completes_share_one_fsync ... ok +test the_durable_spelling_still_syncs_before_it_returns ... ok +test dropping_a_staged_fork_without_completing_it_still_syncs_and_is_counted ... ok +test a_staged_fork_has_not_synced_yet_and_completing_it_syncs_once ... ok +test a_later_sync_makes_an_earlier_staged_fork_durable ... ok +test completing_a_staged_fork_does_not_touch_the_fallback_counter ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target d16_transitive_visibility + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.23s + Running tests/d16_transitive_visibility.rs (target/debug/deps/d16_transitive_visibility-1fb58af419bef1e7) + +running 2 tests +test log_catalog_keeps_the_grandchild_when_an_interior_node_is_pruned ... ok +test table_catalog_keeps_the_grandchild_when_an_interior_node_is_pruned ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target d178_dml_index_correctness + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.22s + Running tests/d178_dml_index_correctness.rs (target/debug/deps/d178_dml_index_correctness-341c02005efd970a) + +running 13 tests +test delete_on_a_primary_key_range_removes_exactly_the_rows_above_the_bound ... ok +test a_predicate_the_optimizer_cannot_index_at_all_still_answers ... ok +test update_with_no_where_clause_changes_every_row ... ok +test delete_on_a_primary_key_equality_removes_exactly_one_row ... ok +test update_on_a_primary_key_equality_changes_exactly_one_row ... ok +test delete_on_a_secondary_index_equality_removes_exactly_one_row ... ok +test update_on_a_primary_key_range_changes_exactly_the_rows_above_the_bound ... ok +test updating_the_indexed_column_itself_is_not_a_moving_target ... ok +test the_fixture_is_what_this_file_thinks_it_is ... ok +test update_on_a_secondary_index_equality_changes_exactly_one_row ... ok +test update_on_an_unindexed_column_still_changes_exactly_the_matching_rows ... ok +test an_unlowerable_conjunct_does_not_cost_the_statement_its_other_index ... ok +test a_strict_lower_bound_on_a_secondary_index_answers_rather_than_erroring ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 16.21s + +=== target d178_dml_index_counters + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 7.20s + Running tests/d178_dml_index_counters.rs (target/debug/deps/d178_dml_index_counters-cdb5db4b265478f8) + +running 1 test +test dml_reaches_the_index_and_plans_it_the_way_select_does ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 24.93s + +=== target d18_reaper_asks_the_catalog + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.98s + Running tests/d18_reaper_asks_the_catalog.rs (target/debug/deps/d18_reaper_asks_the_catalog-6471ae76bebde910) + +running 2 tests +test log_catalog_reap_must_not_free_what_a_live_child_reads ... ok +test table_catalog_reap_must_not_free_what_a_live_child_reads ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target d19_leak_is_a_race + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.61s + Running tests/d19_leak_is_a_race.rs (target/debug/deps/d19_leak_is_a_race-10fc0f339fa6471c) + +running 1 test +test the_control_that_says_race_or_logic has been running for over 60 seconds +test the_control_that_says_race_or_logic ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 139.09s + +=== target d27_fork_shares_without_leaking + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.71s + Running tests/d27_fork_shares_without_leaking.rs (target/debug/deps/d27_fork_shares_without_leaking-93a27c747d4c9c8d) + +running 5 tests +test a_parent_schema_edit_after_the_fork_stays_invisible_to_the_child ... ok +test the_instrument_goes_blind_when_the_workspace_it_reads_is_gone ... ok +test siblings_forked_from_one_parent_cannot_see_each_other ... ok +test a_parent_write_after_the_fork_stays_invisible_to_the_child ... ok +test a_read_on_a_deep_child_does_not_consult_its_ancestors ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.63s + +=== target d33_add_arena_must_be_durable + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.90s + Running tests/d33_add_arena_must_be_durable.rs (target/debug/deps/d33_add_arena_must_be_durable-41f3e506fff8c65a) + +running 3 tests +test add_arena_refuses_a_stale_generation ... ok +test log_catalog_add_arena_survives_a_reopen ... ok +test table_catalog_add_arena_survives_a_reopen ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s + +=== target d40_reserved_pages_return_to_baseline + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.05s + Running tests/d40_reserved_pages_return_to_baseline.rs (target/debug/deps/d40_reserved_pages_return_to_baseline-89f3dd1e73d7a053) + +running 2 tests +test a_forked_chain_returns_every_reserved_page_without_the_global_scan ... ok +test flat_fanout_returns_every_reserved_page_without_the_global_scan ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 30.82s + +=== target d41_narrow_ops_close_the_window + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.96s + Running tests/d41_narrow_ops_close_the_window.rs (target/debug/deps/d41_narrow_ops_close_the_window-cf36684da6956c61) + +running 4 tests +test control_a_restriction_with_no_racer_is_applied ... ok +test a_restriction_may_not_be_widened_by_one_that_raced_it ... ok +test control_quarantine_with_no_racer_leaves_the_budget_unspent ... ok +test a_row_write_charge_is_not_discarded_by_quarantine ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target d45_log_catalog_rmw + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.04s + Running tests/d45_log_catalog_rmw.rs (target/debug/deps/d45_log_catalog_rmw-b05ae16bf4179c54) + +running 3 tests +test the_same_writes_serially_reach_the_same_state ... ok +test a_concurrent_renewal_does_not_discard_a_published_root ... ok +test concurrent_attaches_do_not_drop_children_from_the_parent ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target d53_private_root_allowlist + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.16s + Running tests/d53_private_root_allowlist.rs (target/debug/deps/d53_private_root_allowlist-d8a9d394116a5ead) + +running 1 test +test every_statement_path_opens_the_shared_root_cell ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target d53_shared_root_cell + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.92s + Running tests/d53_shared_root_cell.rs (target/debug/deps/d53_shared_root_cell-9ba00e99d5b11db1) + +running 3 tests +test the_catalog_hands_every_statement_the_same_root_cell ... ok +test a_shared_root_cell_lets_the_split_retry_fire ... ok +test a_private_root_cell_makes_the_split_retry_dormant ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s + +=== target d54_as_of_in_session + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.00s + Running tests/d54_as_of_in_session.rs (target/debug/deps/d54_as_of_in_session-2be4703aab3440c0) + +running 1 test +test as_of_branch_inside_a_session_reads_the_named_branch_not_the_session_s_own ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target d54_reader_writer_handshake + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.13s + Running tests/d54_reader_writer_handshake.rs (target/debug/deps/d54_reader_writer_handshake-3b592b78b664f36c) + +running 3 tests +test the_registry_forgets_connections_that_have_gone_away ... ok +test a_reader_stands_down_while_a_writer_holds_the_catalog ... ok +test a_writer_waits_for_an_in_flight_shared_read ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.43s + +=== target d55_pushdown_commutes_server + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.16s + Running tests/d55_pushdown_commutes_server.rs (target/debug/deps/d55_pushdown_commutes_server-5e0e4295a921c1c9) + +running 1 test +test pushdown_agrees_with_filter_afterwards_through_a_server_context ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.15s + +=== target d55_pushdown_commutes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.91s + Running tests/d55_pushdown_commutes.rs (target/debug/deps/d55_pushdown_commutes-c45ab2751355d977) + +running 2 tests +test pushdown_agrees_with_filter_afterwards_on_every_overlay_case ... ok +test pushdown_agrees_with_filter_afterwards_on_the_arena_path ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target d56_statsless_point_lookup + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.93s + Running tests/d56_statsless_point_lookup.rs (target/debug/deps/d56_statsless_point_lookup-87d43bc9275d16e6) + +running 3 tests +test primary_key_equality_uses_the_index_with_no_statistics ... ok +test the_fact_is_scoped_to_equality_on_the_unique_column ... ok +test the_plan_change_does_not_change_the_answer ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.23s + +=== target d57_overlay_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.78s + Running tests/d57_overlay_probe.rs (target/debug/deps/d57_overlay_probe-4fe8bfa92cded0c5) + +running 9 tests +test varchar_primary_key_probes_by_the_hashed_literal ... ok +test a_decimal_key_is_never_probed_because_its_identity_is_finer_than_its_equality ... ok +test point_probe_with_a_second_conjunct_applies_the_whole_predicate_to_the_staged_row ... ok +test a_non_key_predicate_walks_only_this_tables_staged_rows ... ok +test a_workspace_that_moved_a_primary_key_is_walked_not_probed ... ok +test a_literal_of_another_type_falls_back_instead_of_missing_the_staged_row ... ok +test an_off_type_value_staged_in_column_0_is_still_found_by_a_typed_literal ... ok +test point_probe_agrees_with_the_walk_and_with_the_hand_derived_truth ... ok +test bigint_keys_probe_and_an_out_of_range_literal_falls_back ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.83s + +=== target d58_latch_free_descent + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.15s + Running tests/d58_latch_free_descent.rs (target/debug/deps/d58_latch_free_descent-97a6c740cbc345ac) + +running 7 tests +test a_relabelled_frame_never_publishes_the_previous_pages_bytes ... ok +test a_page_whose_header_disagrees_with_its_label_is_not_descended ... ok +test a_stale_frame_hint_is_refused_by_the_label ... ok +test the_right_walk_crosses_an_empty_leaf ... ok +test a_root_snapshot_from_before_the_splits_still_finds_every_key ... ok +test an_optimistic_read_never_returns_a_torn_page ... ok +test readers_racing_inserters_and_eviction_never_see_a_wrong_value ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 34.13s + +=== target d59_snapshot_cache + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.91s + Running tests/d59_snapshot_cache.rs (target/debug/deps/d59_snapshot_cache-a750662366179024) + +running 9 tests +test raising_the_id_watermark_invalidates_a_cached_snapshot ... ok +test an_abort_also_moves_the_version ... ok +test a_transaction_that_begins_is_in_the_next_cached_snapshot ... ok +test a_transaction_that_commits_leaves_the_next_cached_snapshot ... ok +test a_cached_snapshot_matches_the_locked_one_in_high_water_too ... ok +test recovery_moves_the_version_for_a_thread_that_already_cached_a_snapshot ... ok +test two_managers_on_one_thread_do_not_share_a_cached_snapshot ... ok +test a_watermark_raise_waits_for_the_table_lock ... ok +test a_cached_snapshot_equals_the_locked_one_when_the_version_did_not_move ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.86s + +=== target d60_depth_is_not_capped + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.74s + Running tests/d60_depth_is_not_capped.rs (target/debug/deps/d60_depth_is_not_capped-9dff8d4ad9bec11d) + +running 5 tests +test a_core_record_written_with_a_one_byte_depth_still_loads ... ok +test a_full_record_written_before_the_depth_widened_still_decodes ... ok +test a_chain_far_past_the_old_cap_forks_and_still_reads_what_the_root_wrote ... ok +test reclamation_walks_a_long_reaped_chain_without_recursing_per_level ... ok +test the_reaper_cascades_up_a_deep_chain_of_reaped_ancestors has been running for over 60 seconds +test the_reaper_cascades_up_a_deep_chain_of_reaped_ancestors ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 66.25s + +=== target d64_expression_depth + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.98s + Running tests/d64_expression_depth.rs (target/debug/deps/d64_expression_depth-e3f66ecc0cfe544d) + +running 9 tests +test an_ordinary_join_list_still_parses ... ok +test a_long_operator_chain_is_not_bounded_by_the_frame_limit ... ok +test one_level_past_the_limit_is_refused_and_the_message_names_the_limit ... ok +test a_shape_just_under_the_limit_parses_and_produces_a_real_statement ... ok +test nesting_cannot_renew_the_chain_budget ... ok +test a_join_list_longer_than_the_tree_budget_is_refused ... ok +test exactly_the_tree_budget_in_zero_cost_joins_still_parses ... ok +test neither_counter_leaks_across_statements_in_a_batch ... ok +test depths_that_used_to_abort_the_process_are_now_errors ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target d66_join_relation_bitmask + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.26s + Running tests/d66_join_relation_bitmask.rs (target/debug/deps/d66_join_relation_bitmask-a3de4413c30bfef8) + +running 3 tests +test relation_thirty_two_does_not_alias_onto_relation_zero ... ok +test a_join_at_the_relation_limit_still_plans ... ok +test more_relations_than_the_mask_can_hold_is_refused_by_name ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.87s + +=== target d69_key_types + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.40s + Running tests/d69_key_types.rs (target/debug/deps/d69_key_types-ddf516e1b9e4009d) + +running 19 tests +test a_negative_integer_primary_key_round_trips ... ok +test a_stale_merge_is_refused_for_a_decimal_key ... ok +test a_distinct_key_is_still_admitted_in_a_branch ... ok +test a_duplicate_varchar_key_is_refused_in_a_branch ... ok +test a_duplicate_decimal_key_is_refused_in_a_branch ... ok +test a_duplicate_float_key_is_refused_in_a_branch ... ok +test a_stale_merge_is_refused_for_a_bigint_key ... ok +test a_duplicate_bigint_key_is_refused_in_a_branch ... ok +test a_stale_merge_is_refused_for_a_varchar_key ... ok +test a_merge_on_a_float_keyed_table_finds_its_base_row ... ok +test a_bigint_primary_key_round_trips ... ok +test a_merge_on_a_varchar_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_decimal_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_bigint_keyed_table_finds_its_base_row ... ok +test a_stale_merge_is_refused_for_a_float_key ... ok +test a_merge_on_a_negative_integer_keyed_table_finds_its_base_row ... ok +test a_merge_on_a_boolean_keyed_table_finds_its_base_row ... ok +test a_varchar_key_with_awkward_content_round_trips ... ok +test a_varchar_primary_key_round_trips_through_the_lookup ... ok + +test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s + +=== target d69_table_version + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.16s + Running tests/d69_table_version.rs (target/debug/deps/d69_table_version-f8f0f21b4fa0af26) + +running 4 tests +test the_counter_key_is_the_same_hash_the_merge_path_uses ... ok +test plain_dml_outside_any_agent_session_moves_the_counter ... ok +test tables_do_not_share_a_counter ... ok +test the_counter_never_goes_backwards ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.20s + +=== target guard_precondition_probe + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.87s + Running tests/guard_precondition_probe.rs (target/debug/deps/guard_precondition_probe-4ef9bb6f64891efb) + +running 5 tests +test both_engines_conflict_on_post_state_so_correctness_lives_in_the_caller ... ok +test a_solo_merge_that_lands_on_the_bound_is_not_a_conflict ... ok +test a_genuinely_violated_precondition_is_still_rejected ... ok +test a_guarded_delete_merges_solo ... ok +test a_floor_guard_does_not_protect_the_floor_and_the_counter_goes_negative ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_alter_column + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.00s + Running tests/integration_alter_column.rs (target/debug/deps/integration_alter_column-19d5bafdeeed6834) + +running 29 tests +test a_merge_report_names_the_shape_it_produced ... ok +test a_branchs_schema_change_is_invisible_until_it_merges ... ok +test an_alter_is_refused_while_any_transaction_is_open ... ok +test a_column_an_agent_added_reaches_the_feed ... ok +test a_branch_whose_only_statement_is_an_alter_still_merges_against_its_fork_point ... ok +test decoding_a_range_twice_gives_the_same_answer_across_an_alter ... ok +test a_column_added_mid_stream_survives_a_pump_boundary ... ok +test a_row_written_before_a_sibling_widened_the_table_still_publishes ... ok +test crossing_the_null_bitmap_boundary_keeps_every_value ... ok +test deleted_rows_stay_deleted_across_a_rewrite ... ok +test a_pump_after_an_alter_does_not_silently_truncate_the_row ... ok +test renaming_an_indexed_column_leaves_the_table_queryable ... ok +test retyping_a_middle_column_does_not_disturb_its_neighbours ... ok +test rows_written_before_the_column_are_still_readable_after_it ... ok +test two_branches_making_the_same_change_do_not_conflict ... ok +test the_altered_shape_survives_a_truncation_and_a_reopen ... ok +test two_branches_adding_different_columns_compose ... ok +test the_feed_carries_the_column_change_in_log_order ... ok +test two_branches_retyping_one_column_conflict_with_the_predicate ... ok +test the_refusals_refuse_and_the_permitted_cases_are_permitted ... ok +test three_branches_forked_at_three_different_shapes_all_compose ... ok +test history_learned_from_one_log_is_not_applied_to_another ... ok +test a_column_name_needing_escaping_survives_the_whole_pipeline ... ok +test a_column_added_mid_stream_reaches_the_destination ... ok +test a_rename_and_a_retype_reach_the_destination_without_losing_data ... ok +test a_lookup_by_key_still_finds_a_row_the_rewrite_moved ... ok +test a_secondary_index_over_a_retyped_column_still_answers ... ok +test statistics_survive_an_alteration_exactly ... ok +test the_decoders_history_does_not_grow_with_every_checkpoint ... ok + +test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.65s + +=== target integration_alter_refusal_paths + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.94s + Running tests/integration_alter_refusal_paths.rs (target/debug/deps/integration_alter_refusal_paths-58a897f4b4bc3fb2) + +running 9 tests +test the_retry_after_a_refused_merge_does_not_report_success_while_dropping_the_edit ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_first_applied ... ok +test a_refused_merge_leaves_a_branch_that_does_not_double_apply_on_retry ... ok +test a_retry_that_reports_ok_must_have_applied_the_staged_retype ... ok +test a_merge_refused_for_size_still_publishes_the_branch_rows ... ok +test the_half_applied_rename_is_on_disk_not_just_in_memory ... ok +test a_second_branch_refused_leaves_the_first_branchs_merge_intact ... ok +test control_the_same_merge_lands_when_the_rows_leave_room ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_heap_rewritten ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.38s + +=== target integration_alter_refusal_safety + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.97s + Running tests/integration_alter_refusal_safety.rs (target/debug/deps/integration_alter_refusal_safety-9dc38a5d5b1b844b) + +running 16 tests +test max_tuple_size_is_the_boundary_page_insert_actually_enforces ... ok +test an_unlogged_update_that_cannot_obtain_a_page_refuses_instead_of_deleting_the_row ... ok +test an_unlogged_update_too_large_for_any_page_refuses_instead_of_deleting_the_row ... ok +test a_refused_alter_survives_a_crash_with_no_clean_flush ... ok +test a_refused_alter_survives_a_checkpoint_a_flush_and_a_fresh_buffer_pool ... ok +test repeated_refusals_leave_the_table_unchanged_and_the_statement_still_works ... ok +test a_lone_schema_edit_refused_at_merge_leaves_the_target_exactly_as_it_was ... ok +test a_refused_alter_converts_nothing_when_the_allocator_dies_part_way ... ok +test an_ordinary_wide_row_is_not_destroyed_by_a_refused_add_column ... ok +test a_refused_retype_leaves_every_row_byte_identical ... ok +test a_refused_alter_changes_nothing_when_the_page_allocator_is_exhausted ... ok +test the_precheck_does_not_refuse_a_row_that_fits ... ok +test a_refused_retype_does_not_leave_a_readable_wrong_answer ... ok +test a_refused_add_column_leaves_every_row_byte_identical ... ok +test a_refusal_after_a_partial_reservation_leaves_only_empty_pages ... ok +test an_alter_that_must_reserve_many_pages_completes_and_keeps_every_row ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.59s + +=== target integration_arena_exclusivity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.72s + Running tests/integration_arena_exclusivity.rs (target/debug/deps/integration_arena_exclusivity-0beae8489274366d) + +running 5 tests +test the_bitmap_allocator_refuses_rather_than_crossing_the_floor ... ok +test an_arena_page_cannot_be_freed_through_the_bitmap ... ok +test an_unreserved_database_allocates_exactly_as_before ... ok +test a_second_lower_arena_region_is_refused ... ok +test the_legacy_allocator_never_hands_out_a_page_inside_an_arena_extent ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +=== target integration_base_backup + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.80s + Running tests/integration_base_backup.rs (target/debug/deps/integration_base_backup-e4e3036783fb0fb0) + +running 5 tests +test the_base_backup_carries_pages_the_wal_never_describes ... ok +test synchronous_commit_waits_for_a_replica_and_says_so_when_there_is_none ... ok +test a_backup_taken_while_the_primary_is_writing_still_converges ... ok +test a_replica_killed_mid_stream_resumes_from_its_own_lsn_and_converges ... ok +test a_replica_restored_from_a_base_backup_converges_past_a_checkpoint ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 23.46s + +=== target integration_blind_writes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.67s + Running tests/integration_blind_writes.rs (target/debug/deps/integration_blind_writes-fc7f6d206a0de6ff) + +running 5 tests +test a_session_that_read_and_wrote_nothing_blind_reports_an_empty_set ... ok +test only_the_unread_row_is_reported_when_a_session_does_both ... ok +test a_row_read_before_being_written_is_not_blind ... ok +test the_merge_report_carries_the_blind_write_set ... ok +test a_row_written_without_being_read_is_reported_blind ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s + +=== target integration_branch_attestation + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) +warning: variable does not need to be mutable + --> tests/integration_branch_attestation.rs:548:9 + | +548 | let mut run_sql = |sql: &str, s: &mut Session, catalog: &mut Catalog| { + | ----^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_branch_attestation") generated 1 warning (run `cargo fix --test "integration_branch_attestation" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.29s + Running tests/integration_branch_attestation.rs (target/debug/deps/integration_branch_attestation-70b4c2161ab68568) + +running 8 tests +test a_reap_through_an_attached_reaper_is_attested_too ... ok +test a_fork_a_merge_and_a_reap_each_leave_an_attested_entry ... ok +test a_childs_chain_walks_into_the_parent_it_forked_from ... ok +test altering_one_entry_breaks_the_chain ... ok +test an_auditor_can_check_every_entry_against_a_published_head ... ok +test a_published_head_catches_a_rewrite_that_the_chain_walk_accepts ... ok +test the_merge_entry_commits_to_the_rows_it_published ... ok +test attesting_a_fork_does_not_read_the_tree ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.49s + +=== target integration_branch_pages + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.08s + Running tests/integration_branch_pages.rs (target/debug/deps/integration_branch_pages-bd6604da2e43823a) + +running 10 tests +test the_page_diff_has_no_fork_point_image_yet_because_base_rows_are_not_on_the_tree ... ok +test the_branch_tree_and_the_workspace_map_agree_on_every_staged_row ... ok +test one_branchs_staged_pages_are_unreachable_from_trunk_and_from_a_sibling ... ok +test an_untouched_session_has_an_empty_page_changeset ... ok +test a_staged_row_exists_as_a_page_on_the_branch_tree ... ok +test wide_typed_cells_survive_a_round_trip_through_a_branchs_pages ... ok +test staging_allocates_pages_and_an_untouched_branch_allocates_none ... ok +test a_staged_delete_removes_the_row_from_the_branch_tree ... ok +test the_page_derived_changeset_agrees_with_the_map_derived_one ... ok +test a_staged_delete_appears_in_the_page_changeset_as_a_removal ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_btree_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.07s + Running tests/integration_btree_concurrency.rs (target/debug/deps/integration_btree_concurrency-da75aeaefb6a6221) + +running 4 tests +test btree_concurrent_1_thread ... ok +test btree_concurrent_8_threads ... ok +test btree_concurrent_16_threads ... ok +test btree_concurrent_32_threads ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.04s + +=== target integration_buffer_pool_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.10s + Running tests/integration_buffer_pool_concurrency.rs (target/debug/deps/integration_buffer_pool_concurrency-a65f77f2eadc4ef7) + +running 9 tests +test reading_a_page_that_does_not_exist_twice_errors_twice_instead_of_panicking ... ok +test concurrent_fetches_never_return_another_pages_bytes ... ok +test every_page_reads_back_its_own_stamp_single_threaded ... ok +test the_page_table_never_maps_two_pages_to_one_frame ... ok +test every_frame_agrees_with_the_page_table_about_which_page_it_holds ... ok +test concurrent_fetches_of_one_cold_page_read_it_once_into_one_frame ... ok +test an_oversubscribed_pool_never_hands_back_a_relabelled_frame ... ok +test a_fetch_never_returns_a_frame_labelled_with_a_different_page ... ok +test a_pinned_page_is_never_evicted_however_hard_the_pool_churns ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.39s + +=== target integration_buffer_pool_latch + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.04s + Running tests/integration_buffer_pool_latch.rs (target/debug/deps/integration_buffer_pool_latch-5450b2d5b6672dfb) + +running 8 tests +test a_stale_page_table_entry_never_yields_another_pages_frame ... ok +test a_failed_load_does_not_strand_the_page_in_transit ... ok +test invalidate_all_refuses_while_a_fault_is_in_flight ... ok +test a_declined_eviction_leaves_the_victim_tracked_as_resident ... ok +test the_victim_stays_reachable_until_its_write_back_lands ... ok +test invalidate_all_refuses_while_a_write_back_is_in_flight ... ok +test every_resident_page_is_still_tracked_by_the_replacement_policy ... ok +test a_dirty_victim_is_on_disk_before_it_stops_being_resident ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s + +=== target integration_buffer_pool_mirror + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.17s + Running tests/integration_buffer_pool_mirror.rs (target/debug/deps/integration_buffer_pool_mirror-f9eea39f0f22c450) + +running 7 tests +test delete_and_free_take_the_mirror_entry_with_them ... ok +test invalidate_all_empties_the_mirror ... ok +test unpin_never_touches_a_frame_that_does_not_hold_the_page ... ok +test a_page_that_is_evicted_and_refetched_resolves_to_its_new_frame ... ok +test the_mirror_agrees_with_the_page_table_after_an_eviction_storm ... ok +test two_pages_sharing_a_mirror_slot_are_both_served_correctly ... ok +test concurrent_fetches_never_resolve_to_the_wrong_frame_through_the_mirror ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s + +=== target integration_buffer_pool_touch_batching + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.20s + Running tests/integration_buffer_pool_touch_batching.rs (target/debug/deps/integration_buffer_pool_touch_batching-cf0e15baf2ab55aa) + +running 6 tests +test acquiring_the_policy_applies_every_pending_hit_first ... ok +test a_cache_hit_defers_its_policy_update_instead_of_taking_the_lock ... ok +test draining_twice_does_not_replay_the_same_updates ... ok +test a_full_batch_is_applied_rather_than_dropped ... ok +test the_policy_still_tracks_exactly_the_resident_set_with_a_backlog_outstanding ... ok +test concurrent_hits_lose_no_policy_updates ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target integration_capability_envelope + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.54s + Running tests/integration_capability_envelope.rs (target/debug/deps/integration_capability_envelope-74831fd8f4bf4f23) + +running 23 tests +test a_forbidden_table_is_refused_even_when_the_statement_matches_no_row ... ok +test a_statement_refused_by_escrow_spends_no_envelope_budget ... ok +test a_branch_being_reaped_cannot_write_even_with_its_workspace_intact ... ok +test an_agent_session_inherits_the_envelope_of_the_branch_it_forked_from ... ok +test installing_an_envelope_does_not_reach_a_session_that_is_already_open ... ok +test budget_already_spent_is_not_handed_back_by_a_restart ... ok +test a_write_that_changes_nothing_costs_no_budget ... ok +test an_insert_cannot_write_a_column_the_branch_was_never_granted ... ok +test a_verb_off_the_allowlist_is_refused_and_one_on_it_still_writes ... ok +test the_envelope_reads_one_funnel_while_three_reach_branch_state ... ok +test an_ungoverned_branch_writes_exactly_as_before ... ok +test a_branch_cannot_widen_its_own_envelope ... ok +test an_envelope_survives_closing_and_reopening_the_database ... ok +test an_assignment_that_lowers_a_bounded_value_is_refused_not_just_a_decrement ... ok +test branch_scoped_alter_table_reaches_a_forbidden_table_and_this_is_a_known_gap ... ok +test a_table_off_the_allowlist_is_refused_and_one_on_it_still_writes ... ok +test a_statement_that_overruns_the_row_budget_is_refused_whole ... ok +test an_envelope_survives_a_restart_of_the_branch_metadata_layer ... ok +test dropping_and_recreating_a_granted_table_repoints_the_grant_at_different_columns ... ok +test the_envelope_governs_agent_session_writes_only ... ok +test no_ddl_verb_is_governed_by_the_envelope_and_this_is_a_known_gap ... ok +test merge_and_revert_rewrite_a_forbidden_tables_rows_and_this_is_a_known_gap ... ok +test the_substitution_strips_a_column_floor_and_unlocks_a_refused_delete ... ok + +test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.39s + +=== target integration_cdc_cutover_concurrent + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.93s + Running tests/integration_cdc_cutover_concurrent.rs (target/debug/deps/integration_cdc_cutover_concurrent-65e3ef0f96b65b17) + +running 1 test +test every_committed_row_arrives_exactly_once_with_writers_running_throughout ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.14s + +=== target integration_cdc_cutover + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.09s + Running tests/integration_cdc_cutover.rs (target/debug/deps/integration_cdc_cutover-295426561ec9f84a) + +running 4 tests +test a_one_table_boundary_does_not_suppress_another_tables_rows ... ok +test the_at_least_once_handoff_both_duplicates_and_drops_in_this_scenario ... ok +test snapshot_and_stream_deliver_every_row_exactly_once ... ok +test one_reader_snapshots_two_tables_against_a_single_boundary ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.28s + +=== target integration_cdc_ddl + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.97s + Running tests/integration_cdc_ddl.rs (target/debug/deps/integration_cdc_ddl-aded78f2637f85fb) + +running 4 tests +test create_table_reaches_the_feed_as_a_schema_event ... ok +test a_decoder_with_no_catalog_learns_the_table_from_the_log ... ok +test a_blank_decoder_keeps_two_tables_apart ... ok +test the_schema_survives_an_automatic_checkpoint ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.27s + +=== target integration_cdc_diff + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.91s + Running tests/integration_cdc_diff.rs (target/debug/deps/integration_cdc_diff-936f6dba5c4d4d0d) + +running 5 tests +test every_row_of_a_multi_row_commit_reaches_the_destination ... ok +test table_dump_refuses_an_unknown_table_and_a_missing_database ... ok +test the_table_rebuilt_from_the_feed_matches_the_source_database ... ok +test the_diff_refuses_when_there_is_nothing_to_compare ... ok +test the_diff_catches_a_wrong_value_an_extra_row_and_a_missing_row ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.22s + +=== target integration_cdc_drop_table + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.25s + Running tests/integration_cdc_drop_table.rs (target/debug/deps/integration_cdc_drop_table-f94fc4bbf90a9ee3) + +running 4 tests +test a_drop_truncates_like_any_checkpoint_and_says_so ... ok +test dropping_a_table_emits_a_drop_table_event ... ok +test a_checkpoint_after_a_drop_does_not_redeclare_the_dropped_table ... ok +test the_sink_drops_the_destination_table ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.96s + +=== target integration_cdc_duckdb_sink + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.08s + Running tests/integration_cdc_duckdb_sink.rs (target/debug/deps/integration_cdc_duckdb_sink-fab2c49339801171) + +running 5 tests +test the_destination_ddl_comes_from_the_create_table_event ... ok +test replaying_the_whole_feed_changes_nothing ... ok +test the_feed_lands_in_duckdb_matching_the_source ... ok +test a_stale_event_arriving_after_a_newer_one_is_rejected ... ok +test both_readers_agree ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.44s + +=== target integration_cdc_feed + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.80s + Running tests/integration_cdc_feed.rs (target/debug/deps/integration_cdc_feed-50d294b5d2e1bdf5) + +running 2 tests +test a_varchar_full_of_json_metacharacters_survives_the_round_trip ... ok +test a_feed_from_real_sql_is_valid_json_to_an_independent_parser ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.06s + +=== target integration_cdc_go_consumer + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.14s + Running tests/integration_cdc_go_consumer.rs (target/debug/deps/integration_cdc_go_consumer-cae5769f6365500f) + +running 4 tests +test the_server_survives_a_consumer_that_stops_reading_its_stdout ... ok +test a_dead_server_is_reported_as_dead_rather_than_retried ... ok +test a_go_consumer_rebuilds_the_source_table_from_the_feed_alone ... ok +test an_updated_row_shows_its_latest_value_only ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.44s + +=== target integration_cdc_key_reuse + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.60s + Running tests/integration_cdc_key_reuse.rs (target/debug/deps/integration_cdc_key_reuse-752b445585432f80) + +running 5 tests +test the_decoder_emits_a_delete_then_an_insert_for_the_same_key ... ok +test a_reused_key_lands_live_in_the_destination ... ok +test replaying_a_feed_that_reuses_a_key_changes_nothing ... ok +test the_delete_tombstones_first_and_the_resurrect_clears_it ... ok +test a_stale_delete_arriving_after_the_resurrect_does_not_re_tombstone_the_row ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.37s + +=== target integration_cdc_latency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.05s + Running tests/integration_cdc_latency.rs (target/debug/deps/integration_cdc_latency-432f98750c4e06cf) + +running 2 tests +test the_latency_run_survives_an_automatic_checkpoint_and_reports ... ok +test two_runs_over_the_same_path_measure_the_same_thing ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.99s + +=== target integration_cdc_publication + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.10s + Running tests/integration_cdc_publication.rs (target/debug/deps/integration_cdc_publication-de6823a0b7600923) + +running 5 tests +test a_refusal_mid_commit_replays_the_whole_commit_rather_than_skipping_the_refused_row ... ok +test two_columns_with_one_name_are_refused_by_the_producer_and_by_the_consumer ... ok +test a_denied_column_appears_in_zero_events_across_a_workload_that_writes_it ... ok +test a_misplaced_argument_is_refused_rather_than_silently_disabling_the_policy ... ok +test the_independent_consumer_refuses_a_feed_the_producer_should_not_have_written ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.73s + +=== target integration_cdc_retract_by_model + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.43s + Running tests/integration_cdc_retract_by_model.rs (target/debug/deps/integration_cdc_retract_by_model-847344b0f0b0f415) + +running 3 tests +test the_feed_carries_a_prompt_digest_and_never_the_prompt ... ok +test a_retraction_that_matches_nothing_is_refused_end_to_end ... ok +test retract_by_model_touches_one_model_versions_rows_and_no_others ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.79s + +=== target integration_cdc_sink + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.21s + Running tests/integration_cdc_sink.rs (target/debug/deps/integration_cdc_sink-9c1452e08ec5c119) + +running 3 tests +test the_feed_lands_in_sqlite_matching_the_source ... ok +test replaying_the_whole_feed_changes_nothing ... ok +test a_stale_event_arriving_after_a_newer_one_is_rejected ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.54s + +=== target integration_cdc_snapshot + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.29s + Running tests/integration_cdc_snapshot.rs (target/debug/deps/integration_cdc_snapshot-0c6535788b666a96) + +running 3 tests +test snapshot_values_are_escaped ... ok +test snapshot_rows_carry_their_actual_values ... ok +test a_consumer_that_snapshots_then_streams_sees_every_row_that_ever_existed ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s + +=== target integration_cdc_stream + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.17s + Running tests/integration_cdc_stream.rs (target/debug/deps/integration_cdc_stream-f34be5c1eda1ce55) + +running 2 tests +test a_consumer_follows_a_live_database_over_tcp ... ok +test a_consumer_that_disconnects_resumes_without_gap_or_overlap ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.36s + +=== target integration_cdc_wide_types + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.41s + Running tests/integration_cdc_wide_types.rs (target/debug/deps/integration_cdc_wide_types-fc06165e917070ab) + +running 14 tests +test the_declared_types_and_their_rows_survive_a_catalog_reload ... ok +test a_comparison_against_a_float_column_does_not_round_the_literal ... ok +test a_literal_that_does_not_fit_its_column_is_refused_not_truncated ... ok +test timestamp_arithmetic_advances_by_milliseconds ... ok +test the_same_values_as_bare_json_numbers_are_silently_corrupted ... ok +test bigint_arithmetic_works_and_stays_exact ... ok +test wide_literals_survive_insert_and_select_exactly ... ok +test a_whole_numbered_literal_lands_in_a_float_column ... ok +test a_where_clause_against_a_wide_column_matches_by_value ... ok +test a_null_in_a_wide_column_stays_json_null ... ok +test the_feed_ships_wide_types_as_quoted_json_strings ... ok +test the_schema_event_declares_the_wide_types_by_name ... ok +test an_update_to_a_wide_column_ships_both_images_as_strings ... ok +test an_independent_go_consumer_receives_the_wide_columns_as_exact_strings ... ok + +test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s + +=== target integration_checkpoint_pressure + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.96s + Running tests/integration_checkpoint_pressure.rs (target/debug/deps/integration_checkpoint_pressure-8ac8dbf42739f76a) + +running 2 tests +test at_the_default_interval_the_workload_cannot_tell_the_two_apart ... ok +test under_constant_truncation_only_a_pinned_consumer_survives ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.27s + +=== target integration_cherry_pick + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.25s + Running tests/integration_cherry_pick.rs (target/debug/deps/integration_cherry_pick-d4c8b390290d8ee6) + +running 6 tests +test a_selector_naming_no_op_is_refused ... ok +test a_pick_spanning_two_tables_is_refused_rather_than_half_applied ... ok +test an_empty_selection_is_refused ... ok +test one_op_of_several_lands_and_the_others_do_not ... ok +test a_commuting_divergence_is_recovered_through_the_by_cell_index ... ok +test a_pick_whose_cell_the_target_already_moved_is_refused_and_writes_nothing ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target integration_cli_agent_isolation + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.92s + Running tests/integration_cli_agent_isolation.rs (target/debug/deps/integration_cli_agent_isolation-407874ed1cc90520) + +running 6 tests +test a_deleted_arena_checkpoint_is_refused_rather_than_defaulted ... ok +test reopening_reattaches_to_the_existing_arena_and_branch_catalog ... ok +test a_database_already_open_is_refused_to_a_second_process ... ok +test a_merged_write_reaches_trunk_and_is_still_there_after_a_restart ... ok +test an_agent_sessions_write_is_invisible_to_trunk_and_survives_a_restart ... ok +test the_binary_allocates_real_pages_above_the_arena_floor ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.02s + +=== target integration_cli_effect_log_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.04s + Running tests/integration_cli_effect_log_durability.rs (target/debug/deps/integration_cli_effect_log_durability-bbfef8eb8033c4b1) + +running 1 test +test the_shipped_binary_writes_its_effect_frames_to_disk_and_they_outlive_the_process ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.87s + +=== target integration_cli_row_authorship_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.79s + Running tests/integration_cli_row_authorship_durability.rs (target/debug/deps/integration_cli_row_authorship_durability-e7d5b225a5855114) + +running 1 test +test the_shipped_binary_still_names_the_agent_that_wrote_a_row_after_a_restart ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.85s + +=== target integration_cluster_agents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.93s + Running tests/integration_cluster_agents.rs (target/debug/deps/integration_cluster_agents-3b12af37878b8d8b) + +running 35 tests +test a_cluster_branch_id_names_the_node_that_holds_its_rows ... ok +test a_local_branch_id_too_large_for_the_namespace_is_refused_not_truncated ... ok +test a_merge_for_a_branch_whose_fork_never_committed_is_refused ... ok +test a_merge_whose_base_round_is_exactly_the_last_base_move_applies ... ok +test a_fork_off_a_parent_the_cluster_does_not_hold_is_refused ... ok +test a_fork_whose_child_id_is_already_taken_is_refused_not_overwritten ... ok +test a_merge_that_applied_moves_the_base_for_the_merge_behind_it ... ok +test a_merge_whose_base_is_still_the_committed_head_applies ... ok +test a_merge_racing_a_committed_write_to_its_base_is_re_evaluated_not_applied ... ok +test a_branch_whose_owner_died_is_named_as_lost_work_and_its_merged_sibling_is_not ... ok +test a_re_delivered_round_is_a_no_op ... ok +test a_re_evaluated_merge_does_not_itself_move_the_base ... ok +test a_reap_is_a_replicated_decision_and_is_refused_twice ... ok +test every_node_reaches_the_same_verdict_for_every_merge_command ... ok +test heartbeat_rounds_do_not_re_evaluate_a_merge ... ok +test only_commands_that_can_change_what_the_gate_read_move_the_base ... ok +test no_two_nodes_disagree_about_whether_a_branch_is_live ... ok +test node_zero_cannot_own_a_branch ... ok +test the_trunk_is_the_same_branch_on_every_node_and_has_no_owner ... ok +test two_nodes_forking_at_the_same_local_id_get_different_cluster_ids ... ok +test a_node_cannot_sweep_its_own_branches_as_orphans ... ok +test a_promoted_leader_disposes_of_a_dead_nodes_branches_by_a_replicated_decision ... ok +test a_merge_the_gate_declines_costs_no_consensus_at_all ... ok +test a_fork_on_a_node_that_does_not_lead_is_refused_and_creates_nothing ... ok +test a_reap_goes_through_the_log_and_carries_the_generation ... ok +test a_merge_whose_fork_the_cluster_refused_says_so_rather_than_timing_out ... ok +test a_merge_that_loses_the_leadership_while_waiting_refuses_rather_than_blocking ... ok +test a_merge_whose_base_never_stops_moving_is_refused_with_the_round_that_moved ... ok +test a_merge_that_loses_the_leadership_mid_flight_refuses_and_publishes_nothing ... ok +test a_merge_racing_a_committed_change_to_its_base_is_re_evaluated_rather_than_silently_applied ... ok +test a_fork_and_a_hundred_writes_cost_no_quorum_round_trip_and_the_merge_costs_one ... ok +test on_a_real_cluster_a_fork_on_a_follower_is_refused_and_names_the_leader ... ok +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=3 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=3 +LOOPCOUNT settle_to round=2 turns=3 bound=20000 ms=11 +LOOPCOUNT settle_to round=2 turns=0 bound=20000 ms=0 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=2 +LOOPCOUNT settle_to round=2 turns=0 bound=20000 ms=0 +LOOPCOUNT settle_to round=3 turns=1 bound=20000 ms=1 +LOOPCOUNT settle_to round=3 turns=1 bound=20000 ms=1 +test a_followers_committed_log_holds_the_merge_and_not_one_agent_row ... ok +test on_three_real_nodes_only_the_merge_reaches_a_quorum ... ok +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=2 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=0 +LOOPCOUNT settle_to round=4 turns=4 bound=20000 ms=7 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=3 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=0 +LOOPCOUNT settle_to round=6 turns=3 bound=20000 ms=9 +LOOPCOUNT hold_leader want=0 turns=0 bound=100000 ms=3 +LOOPCOUNT settle_to round=7 turns=1 bound=20000 ms=0 +test a_hundred_agent_writes_across_three_branches_leave_only_forks_and_merges_in_the_log ... ok + +test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.03s + +=== target integration_cluster_grants + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.28s + Running tests/integration_cluster_grants.rs (target/debug/deps/integration_cluster_grants-67065c5a27dc5eca) + +running 35 tests +test a_node_with_no_cluster_configured_reads_its_own_wall_clock_for_leases ... ok +test a_node_with_no_cluster_configured_grants_itself_everything ... ok +test a_lease_tick_never_moves_the_clusters_clock_backwards ... ok +test a_node_with_no_cluster_configured_still_begins_transactions ... ok +test a_member_with_no_lease_tick_refuses_to_answer_what_time_it_is ... ok +test a_standalone_node_reaps_on_its_own_clock_exactly_as_before ... ok +test a_recycled_extent_start_from_a_previous_authority_is_not_handed_out_again ... ok +test a_replicated_tick_and_not_the_wall_clock_decides_a_reap ... ok +test a_member_allocates_from_its_grant_and_refuses_again_when_it_runs_out ... ok +test a_member_with_no_txn_grant_refuses_to_begin_a_transaction ... ok +test a_member_with_no_extent_grant_refuses_to_allocate_an_arena ... ok +test a_redelivered_grant_does_not_hand_out_the_same_extent_twice ... ok +test a_granted_extent_is_filled_without_any_further_consensus ... ok +test a_recycled_page_inside_a_stale_extent_is_not_handed_out_after_a_join ... ok +test a_grant_addressed_to_another_node_is_refused_by_this_one ... ok +test a_replicated_checkpoint_command_is_what_truncates_on_a_member ... ok +test a_standalone_node_refuses_a_lease_tick ... ok +test a_standalone_node_refuses_a_grant_it_was_never_supposed_to_get ... ok +test a_cluster_member_does_not_truncate_its_wal_on_its_own_commit_counter ... ok +test a_standalone_node_still_checkpoints_on_its_own_commit_counter ... ok +test an_authority_change_is_noticed_by_the_counters_and_not_merely_survived ... ok +test an_arena_grant_always_carries_more_ids_than_the_extents_it_can_name ... ok +test is_expired_now_refuses_rather_than_answering_false_on_a_member_with_no_tick ... ok +test a_restart_replays_its_grant_and_resumes_above_every_page_it_already_issued ... ok +test the_checkpoint_image_is_byte_identical_to_what_a_node_local_counter_wrote ... ok +test the_reaper_trait_is_the_path_under_test ... ok +test two_nodes_at_the_same_tick_agree_about_whether_a_branch_is_live ... ok +test space_a_node_self_granted_while_standalone_is_revoked_when_it_joins ... ok +test recovery_raises_the_transaction_watermark_without_inventing_a_grant ... ok +test the_infallible_lease_clock_aborts_rather_than_inventing_a_time - should panic ... ok +test an_extent_claimed_under_a_superseded_authority_is_not_filled_afterwards ... ok +test a_member_begins_transactions_from_its_granted_range_and_no_further ... ok +test the_three_appliers_take_exactly_the_frozen_commands_shape ... ok +test two_nodes_with_disjoint_grants_never_hand_out_the_same_physical_page ... ok +test two_nodes_with_disjoint_grants_never_issue_the_same_transaction_id ... ok + +test result: ok. 35 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.88s + +=== target integration_cluster_snapshot + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.92s + Running tests/integration_cluster_snapshot.rs (target/debug/deps/integration_cluster_snapshot-350a4148b9c71550) + +running 9 tests +test a_node_left_part_way_through_an_install_refuses_to_start ... ok +test a_completed_install_leaves_no_marker_behind ... ok +test an_install_discards_the_receivers_own_wal ... ok +test the_redo_window_that_travels_with_a_snapshot_is_replayed_into_it ... ok +test a_captured_payload_installs_back_to_the_same_pages ... ok +test with_the_log_retained_the_same_follower_converges_without_a_snapshot ... ok +test a_node_restarted_above_a_snapshot_floor_comes_back_on_it ... ok +test a_follower_partitioned_past_log_retention_rejoins_by_snapshot_and_converges ... ok +test a_snapshot_claiming_an_implausible_size_leaves_nothing_on_the_receivers_disk ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.02s + +=== target integration_consensus_failover + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.60s + Running tests/integration_consensus_failover.rs (target/debug/deps/integration_consensus_failover-a060b5a25175e6bb) + +running 1 test +test a_killed_leader_is_replaced_and_no_acknowledged_write_is_lost ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.43s + +=== target integration_cow_branch + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.16s + Running tests/integration_cow_branch.rs (target/debug/deps/integration_cow_branch-17aeb4828b31d3e8) + +running 1 test +test the_cow_btree_runs_on_the_arena_store_and_a_child_sees_the_parents_data_without_a_copy ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s + +=== target integration_crash_safety + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.24s + Running tests/integration_crash_safety.rs (target/debug/deps/integration_crash_safety-47f0679b34602969) + +running 4 tests +test a_crash_does_not_lose_the_table_or_its_rows ... ok +test a_merge_that_is_not_interrupted_lands_completely ... ok +test reopening_repeatedly_after_a_crash_keeps_giving_the_same_answer ... ok +test a_merge_killed_mid_publish_leaves_no_torn_state ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.92s + +=== target integration_ddl_refusal_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.42s + Running tests/integration_ddl_refusal_atomicity.rs (target/debug/deps/integration_ddl_refusal_atomicity-0a1f361675c4347b) + +running 2 tests +test a_refused_create_table_does_not_create_the_table ... ok +test a_refused_drop_table_does_not_drop_the_table ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target integration_disk_manager_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.51s + Running tests/integration_disk_manager_concurrency.rs (target/debug/deps/integration_disk_manager_concurrency-a07b7923fa14169a) + +running 4 tests +test the_high_water_mark_never_lags_what_was_allocated ... ok +test allocation_and_reads_can_run_at_the_same_time ... ok +test concurrent_allocation_never_hands_out_the_same_page_twice ... ok +test concurrently_allocated_pages_hold_their_own_bytes ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s + +=== target integration_durable_tel + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.32s + Running tests/integration_durable_tel.rs (target/debug/deps/integration_durable_tel-20b67d219c0e8ca6) + +running 3 tests +test wide_typed_values_survive_the_restart_with_their_bytes_intact ... ok +test an_agent_tasks_frames_and_its_merge_survive_a_process_restart ... ok +test the_durable_store_captures_exactly_what_the_in_memory_one_captures ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.19s + +=== target integration_effect_log + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.31s + Running tests/integration_effect_log.rs (target/debug/deps/integration_effect_log-65572c1ac1e9ce34) + +running 3 tests +test a_replayed_identical_frame_is_still_not_stored_twice ... ok +test a_txn_id_collision_between_different_transactions_is_still_a_hard_error ... ok +test an_open_task_frame_may_grow_under_one_txn_id ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target integration_error_messages + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.10s + Running tests/integration_error_messages.rs (target/debug/deps/integration_error_messages-5063579042d24205) + +running 7 tests +test an_unknown_table_in_an_empty_database_says_there_are_none_rather_than_listing_nothing ... ok +test an_unknown_table_is_named_and_the_known_ones_are_listed ... ok +test a_wrong_value_count_reports_both_counts_and_the_table ... ok +test the_session_still_works_after_each_of_these_errors ... ok +test constraint_errors_are_spelled_correctly ... ok +test a_duplicate_primary_key_names_the_value_and_the_column ... ok +test a_null_into_a_not_null_column_names_the_column_the_table_and_the_constraint ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s + +=== target integration_error_surface + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.15s + Running tests/integration_error_surface.rs (target/debug/deps/integration_error_surface-dd5d1c6cab1cf80c) + +running 10 tests +test bad_stored_bytes_are_reported_as_corruption ... ok +test comparisons_that_can_match_still_do ... ok +test a_quoted_number_against_a_numeric_column_is_refused_deliberately ... ok +test a_comparison_that_cannot_match_is_refused ... ok +test a_user_expression_that_cannot_be_evaluated_is_an_eval_error ... ok +test the_statements_next_to_the_refusals_still_work ... ok +test a_missing_table_is_not_reported_as_a_parse_error ... ok +test no_reachable_refusal_blames_ferrodb_or_the_disk ... ok +test no_refusal_is_contentless ... ok +test every_reachable_refusal_says_what_was_refused ... ok + +test result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.43s + +=== target integration_escrow + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.98s + Running tests/integration_escrow.rs (target/debug/deps/integration_escrow-25bdc25901933883) + +running 13 tests +test deleting_the_row_is_not_governed_by_escrow_and_this_is_a_known_gap ... ok +test raising_the_value_costs_no_escrow ... ok +test changing_the_primary_key_does_not_move_the_cell_out_of_its_pool ... ok +test a_bounded_cell_cannot_be_written_without_claiming_first ... ok +test escrow_governs_agent_writes_only_and_a_direct_write_is_not_charged ... ok +test abandoning_a_branch_returns_its_claim_to_the_pool ... ok +test a_write_beyond_the_claim_fails_at_write_time_not_at_merge ... ok +test a_direct_assignment_below_the_floor_is_refused_not_waved_through ... ok +test an_assignment_on_an_unbounded_cell_is_not_governed ... ok +test escrow_bounds_sequential_agents_not_just_concurrent_ones ... ok +test a_column_with_no_declared_bound_is_not_escrowed ... ok +test with_escrow_the_second_agent_is_refused_while_it_is_still_writing ... ok +test without_escrow_two_agents_drive_the_counter_below_its_floor ... ok + +test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s + +=== target integration_executor_provenance + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.98s + Running tests/integration_executor_provenance.rs (target/debug/deps/integration_executor_provenance-428745cf0f38d0d3) + +running 5 tests +test a_row_published_by_a_merge_names_the_agent_run_and_model_that_wrote_it ... ok +test a_plain_write_outside_any_agent_session_stays_unattributed ... ok +test reusing_a_run_id_with_a_different_model_is_refused ... ok +test two_agents_writing_different_rows_are_told_apart ... ok +test one_run_is_never_split_across_two_provenance_entities ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target integration_flush_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.12s + Running tests/integration_flush_concurrency.rs (target/debug/deps/integration_flush_concurrency-ac95cdde9822b791) + +running 3 tests +test concurrent_flush_and_fetch_never_write_the_wrong_page_to_disk ... ok +test flushing_a_page_that_is_being_evicted_does_not_panic ... ok +test flush_all_is_atomic_against_concurrent_fetches ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s + +=== target integration_fork_kill9 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.96s + Running tests/integration_fork_kill9.rs (target/debug/deps/integration_fork_kill9-31ddab80011db9f6) + +running 1 test +test an_acknowledged_fork_survives_sigkill ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.44s + +=== target integration_fulltext_retrieval + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.89s + Running tests/integration_fulltext_retrieval.rs (target/debug/deps/integration_fulltext_retrieval-6959baec706be305) + +running 20 tests +test an_over_long_token_is_skipped_and_its_neighbours_are_indexed ... ok +test a_rebuild_resolves_a_re_used_primary_key_to_the_live_version ... ok +test a_rebuild_over_a_re_used_primary_key_does_not_double_the_row ... ok +test each_row_carries_its_score_as_a_trailing_column ... ok +test a_backfill_over_a_re_used_primary_key_does_not_double_the_row ... ok +test a_word_repeated_in_one_value_posts_one_entry_not_two ... ok +test a_token_lookup_returns_exactly_the_rows_containing_it ... ok +test re_inserting_a_deleted_primary_key_does_not_double_the_row ... ok +test a_rebuild_of_a_correct_index_does_not_double_its_postings ... ok +test a_null_value_is_indexed_as_no_tokens ... ok +test a_value_updated_away_and_back_returns_the_row_exactly_once ... ok +test the_index_survives_a_crash_and_recover ... ok +test a_deleted_row_drops_out_of_a_search_and_a_live_one_does_not ... ok +test a_fulltext_index_is_not_offered_to_the_range_scanner ... ok +test dropping_a_table_takes_its_posting_tree ... ok +test the_refusals_refuse_and_the_neighbouring_cases_are_allowed ... ok +test the_operator_bounds_its_own_result_and_keeps_the_best_rows ... ok +test two_fulltext_indexes_on_one_table_stay_separate ... ok +test ranking_separates_rows_that_an_equal_scorer_would_tie ... ok +test a_split_posting_tree_records_its_new_root ... ok + +test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.63s + +=== target integration_index_debt + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.94s + Running tests/integration_index_debt.rs (target/debug/deps/integration_index_debt-2854cdb143ff6323) + +running 2 tests +test deleting_rows_does_not_shrink_the_primary_index ... ok +test no_workload_drives_a_leaf_underfull ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.22s + +=== target integration_logical_decoding + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.21s + Running tests/integration_logical_decoding.rs (target/debug/deps/integration_logical_decoding-4d7e615351add627) + +running 4 tests +test decoding_the_same_range_twice_gives_the_same_feed ... ok +test real_sql_decodes_into_the_changes_that_sql_described ... ok +test two_adjacent_ranges_reassemble_into_the_whole_feed ... ok +test the_feed_is_ordered_and_every_change_is_attributed_to_a_commit ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.35s + +=== target integration_merge_agreement + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.90s + Running tests/integration_merge_agreement.rs (target/debug/deps/integration_merge_agreement-43e4b7e3ce4f1adc) + +running 6 tests +test both_engines_agree_that_two_decrements_commute ... ok +test both_engines_agree_that_a_discarding_lww_never_reads_as_clean ... ok +test both_engines_agree_that_a_one_sided_write_is_clean ... ok +test both_engines_agree_that_a_guard_failing_against_merged_state_is_a_conflict ... ok +test both_engines_agree_that_an_unannotated_column_forbids_concurrent_writes ... ok +test both_engines_deduplicate_a_replayed_add_by_txn_id ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target integration_merge_ddl_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.09s + Running tests/integration_merge_ddl_atomicity.rs (target/debug/deps/integration_merge_ddl_atomicity-81b4e1b7467e1784) + +running 9 tests +test a_row_the_merge_would_publish_with_a_null_in_a_not_null_column_refuses_the_whole_merge ... ok +test a_merge_publishes_its_rows_into_the_shape_its_own_edits_produced ... ok +test a_rename_follows_its_column_into_the_fulltext_index_too ... ok +test an_index_follows_its_column_across_the_renames_a_merge_applies ... ok +test a_row_the_merge_would_publish_too_wide_for_the_shape_it_lands_in_refuses_the_whole_merge ... ok +test a_chain_refused_at_its_first_edit_names_that_edit_and_not_the_last ... ok +test narrowing_a_row_and_retyping_behind_it_takes_two_merges_and_the_refusal_says_so ... ok +test planning_a_refused_merge_does_not_even_append_an_empty_page ... ok +test a_merge_refused_at_its_third_edit_leaves_neither_of_the_first_two_applied ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.28s + +=== target integration_pgwire + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.39s + Running tests/integration_pgwire.rs (target/debug/deps/integration_pgwire-f8ee98bc3104f0d7) + +running 6 tests +test two_wire_clients_see_branch_isolation_and_share_one_runtime ... ok +test an_independent_client_can_connect_and_run_sql_over_the_wire ... ok +test the_server_accepts_more_than_one_connection ... ok +test a_second_real_driver_runs_the_same_parameterised_queries ... ok +test a_real_driver_connects_and_runs_a_parameterised_query ... ok +test two_concurrent_connections_both_make_progress ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.80s + +=== target integration_production_diff_wiring + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.63s + Running tests/integration_production_diff_wiring.rs (target/debug/deps/integration_production_diff_wiring-fddd0eafc5e05abc) + +running 4 tests +test every_leaf_differs_so_nothing_is_skipped ... ok +test the_memoised_content_identity_agrees ... ok +test two_identical_roots_read_nothing ... ok +test a_production_diff_reads_pages_in_proportion_to_depth ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.65s + +=== target integration_prompt_clause + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.52s + Running tests/integration_prompt_clause.rs (target/debug/deps/integration_prompt_clause-a15ad880bd60b661) + +running 8 tests +test the_prompt_free_entry_points_still_intern_the_unset_hash ... ok +test an_omitted_clause_is_all_zero_and_an_empty_prompt_is_not ... ok +test two_prompts_that_differ_produce_two_hashes ... ok +test the_run_id_and_the_prompt_are_not_transposed ... ok +test a_prompt_declared_in_sql_becomes_the_runs_digest ... ok +test the_open_session_holds_no_prompt_text ... ok +test resuming_a_run_under_a_different_prompt_is_refused_and_under_the_same_one_is_not ... ok +test the_prompt_text_never_reaches_the_durable_provenance_file ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.06s + +=== target integration_quarantine + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.54s + Running tests/integration_quarantine.rs (target/debug/deps/integration_quarantine-7b89d9a923bcba36) + +running 8 tests +test the_existing_state_tags_kept_their_values ... ok +test the_quarantined_state_round_trips_through_the_record_log ... ok +test releasing_a_branch_that_is_not_held_is_refused ... ok +test merging_a_quarantined_branch_is_refused_with_the_reason ... ok +test a_held_branch_is_listed_with_its_reason_and_distinguishable_from_live ... ok +test a_quarantined_branch_is_still_queryable ... ok +test a_quarantined_branch_is_not_merged_and_main_is_untouched ... ok +test releasing_a_branch_returns_it_to_service_and_it_merges ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target integration_read_premise + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.24s + Running tests/integration_read_premise.rs (target/debug/deps/integration_read_premise-5c7b7e6036a74c42) + +running 3 tests +test a_branch_that_read_a_row_another_branch_then_changed_is_held ... ok +test a_read_of_an_unpublished_row_is_recorded_as_version_zero ... ok +test two_branches_reading_different_rows_both_merge ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s + +=== target integration_readme_commands + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) +warning: unused import: `Path` + --> tests/integration_readme_commands.rs:41:17 + | +41 | use std::path::{Path, PathBuf}; + | ^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_readme_commands") generated 1 warning (run `cargo fix --test "integration_readme_commands" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.70s + Running tests/integration_readme_commands.rs (target/debug/deps/integration_readme_commands-2a526cf355a7940c) + +running 4 tests +test the_readmes_benchmark_number_matches_the_committed_raw_output ... ok +test the_benchmark_refuses_to_report_debug_numbers ... ok +test the_wal_diagnostic_refuses_without_a_wal_and_reads_a_real_one ... ok +test demo_md_matches_what_the_demo_prints_and_the_demo_still_passes ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.17s + +=== target integration_replica_applier + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.09s + Running tests/integration_replica_applier.rs (target/debug/deps/integration_replica_applier-16840cebb27ec7d5) + +running 11 tests +test a_batch_beginning_above_the_frontier_is_refused_as_a_gap ... ok +test a_frame_whose_crc_fails_is_refused ... ok +test a_batch_with_one_bad_frame_applies_none_of_it ... ok +test a_frame_at_the_wrong_lsn_is_refused ... ok +test a_replica_still_applies_a_stream_carrying_create_and_drop_table ... ok +test an_alter_wrapped_in_a_clr_still_halts_the_replica ... ok +test a_replica_applies_a_shipped_insert_and_advances ... ok +test re_applying_the_same_batch_is_a_no_op ... ok +test a_replica_refuses_a_stream_carrying_an_alter_rather_than_advancing_past_it ... ok +test every_column_alteration_halts_the_replica ... ok +test streaming_in_batches_converges_on_the_primarys_frontier ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.40s + +=== target integration_replica_divergence_is_durable + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.37s + Running tests/integration_replica_divergence_is_durable.rs (target/debug/deps/integration_replica_divergence_is_durable-8926c6fad32000b9) + +running 5 tests +test a_replica_that_never_ran_has_no_position ... ok +test a_bare_number_is_still_a_valid_state_file ... ok +test an_ordinary_position_resumes ... ok +test a_recorded_divergence_refuses_to_resume_forever ... ok +test recording_progress_cannot_erase_a_divergence ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.09s + +=== target integration_replication_e2e + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.49s + Running tests/integration_replication_e2e.rs (target/debug/deps/integration_replication_e2e-ba29f27d58751553) + +running 2 tests +test the_catalog_is_not_replicated_and_that_is_a_stated_limit ... ok +test a_replica_converges_on_the_primary_over_tcp ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.35s + +=== target integration_replication_source + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.32s + Running tests/integration_replication_source.rs (target/debug/deps/integration_replication_source-118969395b69af8f) + +running 5 tests +test shipped_bytes_are_the_logs_own_frames ... ok +test nothing_is_shipped_before_it_is_durable ... ok +test a_byte_limit_splits_the_stream_without_losing_its_place ... ok +test a_caught_up_replica_is_told_nothing_rather_than_re_sent_everything ... ok +test the_frontier_holds_while_more_records_pile_up_behind_it ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target integration_run_identity_feed + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.17s + Running tests/integration_run_identity_feed.rs (target/debug/deps/integration_run_identity_feed-0c12adf72c29357f) + +running 11 tests +test provenance_survives_a_reopen_only_when_the_runtime_was_built_durable ... ok +test the_durable_store_is_usable_as_the_trait_object_agent_runtime_holds ... ok +test a_commit_with_no_run_bound_ships_unattributed_and_is_counted ... ok +test binding_a_run_refuses_the_shapes_that_would_produce_a_wrong_answer ... ok +test an_aborted_transactions_run_leaves_no_binding_in_the_log ... ok +test every_committed_row_change_names_the_run_that_wrote_it ... ok +test a_prompt_declared_over_sql_reaches_the_feed_as_a_digest ... ok +test an_agent_merge_ships_its_rows_with_a_writer_and_a_plain_write_still_ships_none ... ok +test a_checkpoint_does_not_erase_the_databases_writers ... ok +test who_wrote_row_answers_after_the_database_is_reopened ... ok +test an_identity_record_written_at_session_begin_is_stepped_over_by_the_feed_cursor ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + +=== target integration_run_identity_log_readers + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.04s + Running tests/integration_run_identity_log_readers.rs (target/debug/deps/integration_run_identity_log_readers-92ce14c096858115) + +running 2 tests +test a_replica_applies_a_batch_containing_run_identity_records ... ok +test recovery_replays_a_log_holding_both_a_binding_and_a_declaration ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target integration_runtime_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.02s + Running tests/integration_runtime_concurrency.rs (target/debug/deps/integration_runtime_concurrency-195a0a158d15d785) + +running 4 tests +test concurrent_abandons_return_exactly_their_own_claims ... ok +test concurrent_writers_each_read_back_only_their_own_row ... ok +test concurrent_sessions_get_distinct_branches_and_runs ... ok +test concurrent_claims_never_over_commit_the_pool ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + +=== target integration_runtime_reattach + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.98s + Running tests/integration_runtime_reattach.rs (target/debug/deps/integration_runtime_reattach-72c494a20a8177da) + +running 4 tests +test a_root_pointing_at_a_page_this_engine_did_not_write_is_refused ... ok +test creating_over_an_existing_trunk_tree_is_refused ... ok +test reopening_a_database_with_no_trunk_tree_is_refused_by_name ... ok +test a_reopened_runtime_reads_the_rows_the_first_one_wrote ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +=== target integration_secondary_index_debt + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.09s + Running tests/integration_secondary_index_debt.rs (target/debug/deps/integration_secondary_index_debt-47853349494c68bc) + +running 4 tests +test dead_entries_never_reach_the_caller ... ok +test reusing_a_key_with_the_same_indexed_value_does_not_duplicate_it ... ok +test a_value_moved_away_and_back_does_not_return_its_row_twice ... ok +test a_secondary_index_grows_by_one_entry_per_delete_and_per_update ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s + +=== target integration_server_reaps + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.22s + Running tests/integration_server_reaps.rs (target/debug/deps/integration_server_reaps-2fe130781ef8f38a) + +running 8 tests +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: NOT reaping — this node does not know the cluster's time (n1 has applied no LeaseTick, so it does not know the cluster's time and will not decide a lease. Reaping is destructive and a BranchId generation makes it unrecoverable, so there is no safe default here). Expired branches keep their pages until a LeaseTick is applied; reaping on a local clock is the divergence this refusal exists to prevent. +lease: reaped 1 expired branch(es) with no client cooperation (b1@g0); 0 workspace(s) forgotten +test a_node_that_does_not_know_the_clusters_time_refuses_to_reap_rather_than_guessing ... ok +test a_merged_branch_gives_its_extent_back_without_any_lease_scan ... ok +test the_cli_reaps_an_abandoned_branch_with_no_client_action_and_pages_return_to_baseline ... ok +test both_binaries_refuse_to_start_on_an_unusable_scan_interval ... ok +test a_healthy_database_resumes_nothing_on_startup ... ok +test the_server_finishes_a_reap_a_crash_interrupted_before_it_serves_anything ... ok +test the_server_reaps_an_abandoned_branch_without_one_socket_being_opened ... ok +test a_live_lease_survives_a_server_that_scans_continuously ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.08s + +=== target integration_server_stdout + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.21s + Running tests/integration_server_stdout.rs (target/debug/deps/integration_server_stdout-9259fddcd1538fdb) + +running 5 tests +test no_test_picks_a_port_for_a_server_it_has_not_started_yet ... ok +test every_user_path_binary_refuses_a_database_that_is_already_open ... ok +test pgserver_survives_a_closed_stdout ... ok +test cdc_server_survives_a_closed_stdout ... ok +test repl_primary_survives_a_closed_stdout ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.17s + +=== target integration_sibling_merge + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 26.49s + Running tests/integration_sibling_merge.rs (target/debug/deps/integration_sibling_merge-bfeabfa1f35d46b8) + +running 9 tests +test the_fork_point_is_computed_not_assumed ... ok +test the_fork_point_query_costs_fewer_hops_than_the_walk_it_replaces ... ok +test the_index_hydrates_from_the_catalog_rather_than_from_a_fork_hook ... ok +test a_sibling_merge_refuses_an_ancestor_and_refuses_itself ... ok +test a_row_the_target_never_touched_still_carries_its_fork_point_image ... ok +test two_siblings_merge_and_the_target_carries_both_branches_work ... ok +test only_one_side_moved_the_cell ... ok +test both_siblings_moved_the_same_cell_and_it_conflicts ... ok +test merging_two_siblings_was_impossible_before_a_fork_point_could_be_computed ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s + +=== target integration_simulate + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.69s + Running tests/integration_simulate.rs (target/debug/deps/integration_simulate-3eb2509e1d40721e) + +running 25 tests +test a_reaper_wired_to_a_different_catalog_is_caught_at_the_first_seal ... ok +test a_read_only_candidate_reports_no_rows_written ... ok +test a_simulation_refuses_to_run_with_no_assertions_no_candidates_or_a_duplicate_name ... ok +test a_candidate_writing_an_escrow_bounded_cell_errors_visibly_rather_than_overdrawing ... ok +test a_plan_that_never_says_admit_publishes_nothing ... ok +test an_assertion_on_an_unknown_table_or_column_is_refused_at_bind_time ... ok +test a_candidate_whose_body_errors_is_reported_and_never_admitted ... ok +test a_candidate_is_scored_by_the_same_gate_a_production_merge_uses ... ok +test a_candidate_the_first_score_refused_is_admitted_once_the_base_has_moved ... ok +test every_candidate_is_scored_against_one_identical_base ... ok +test a_third_candidate_that_composes_with_either_of_a_pair_is_refused_against_both ... ok +test an_admitted_candidates_rows_are_attributed_to_that_candidate ... ok +test a_losing_candidate_is_left_for_the_reaper_rather_than_quarantined ... ok +test an_assertion_is_scored_over_rows_the_candidate_never_touched ... ok +test an_assertion_that_examined_no_rows_blocks_admission_instead_of_passing ... ok +test an_evaluation_whose_read_premise_moved_is_refused_even_though_it_wrote_a_different_table ... ok +test publishing_an_evaluation_the_gate_declined_is_refused ... ok +test simulate_is_refused_inside_an_agent_session ... ok +test simulate_is_refused_inside_a_transaction_block_because_it_would_escape_the_rollback ... ok +test state_for_branches_the_reaper_took_is_dropped_rather_than_retained_forever ... ok +test publishing_an_evaluation_whose_base_moved_is_refused_and_publishing_a_fresh_one_is_not ... ok +test the_simulate_statement_runs_end_to_end_through_the_sql_surface ... ok +test forking_k_candidates_copies_zero_pages_and_the_trunk_really_had_pages_to_copy ... ok +test k_candidates_cost_k_path_copies_not_k_database_copies ... ok +test the_losers_are_reaped_on_lease_expiry_with_no_client_cooperation_and_pages_return_to_baseline ... ok + +test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + +=== target integration_sql_on_durable_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.53s + Running tests/integration_sql_on_durable_branches.rs (target/debug/deps/integration_sql_on_durable_branches-1573d64d39185f4f) + +running 4 tests +test abandon_marks_the_branch_reaped_in_the_durable_record_log ... ok +test branch_writes_stay_invisible_to_main_on_the_durable_catalog ... ok +test a_guard_violation_is_rejected_with_its_predicate_on_the_durable_catalog ... ok +test two_branches_decrementing_compose_arithmetically_on_the_durable_catalog ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s + +=== target integration_statement_atomicity + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.67s + Running tests/integration_statement_atomicity.rs (target/debug/deps/integration_statement_atomicity-7a89d477e1710752) + +running 3 tests +test a_multi_row_update_within_budget_still_applies_to_every_row ... ok +test a_refused_statement_does_not_consume_the_budget_it_never_used ... ok +test a_refused_multi_row_update_leaves_no_row_rewritten ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + +=== target integration_system_views_wire + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.63s + Running tests/integration_system_views_wire.rs (target/debug/deps/integration_system_views_wire-2cb7270b919ff355) + +running 1 test +test the_system_views_are_typed_rows_to_an_independent_client ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.42s + +=== target integration_system_views + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) +warning: unused import: `std::path::Path` + --> tests/integration_system_views.rs:26:5 + | +26 | use std::path::Path; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + +warning: `ferrodb` (test "integration_system_views") generated 1 warning (run `cargo fix --test "integration_system_views" -p ferrodb` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.80s + Running tests/integration_system_views.rs (target/debug/deps/integration_system_views-0cd16af7a1f4817a) + +running 20 tests +test an_empty_view_still_announces_every_declared_column ... ok +test a_view_can_be_projected_and_filtered ... ok +test a_second_session_of_one_run_does_not_restamp_when_the_run_started ... ok +test a_view_refuses_as_of_branch_and_a_join_rather_than_answering_something_else ... ok +test a_table_that_predates_the_views_still_answers_for_its_own_name ... ok +test an_empty_agent_result_is_distinguishable_from_a_broken_one ... ok +test an_agent_can_read_the_views_from_inside_its_own_session ... ok +test every_view_is_populated_once_an_agent_has_run ... ok +test an_agent_result_has_typed_columns_a_client_can_read ... ok +test every_declared_column_is_selectable_by_name ... ok +test a_quarantined_branch_shows_up_with_the_reason_it_is_held ... ok +test creating_over_a_legacy_table_named_like_a_view_says_the_table_exists ... ok +test a_reaped_branch_reports_the_slots_current_generation ... ok +test a_table_cannot_shadow_a_system_view ... ok +test every_write_shape_against_a_view_refuses_by_name ... ok +test ferro_runs_reports_the_digest_of_a_declared_prompt ... ok +test a_view_on_either_side_of_a_join_is_refused_by_name ... ok +test run_activity_counts_are_per_run_and_not_a_shared_total ... ok +test a_recreated_table_does_not_inherit_the_previous_tables_authorship ... ok +test dropping_a_table_does_not_silence_the_read_premise_gate ... ok + +test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + +=== target integration_truncation_race + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.81s + Running tests/integration_truncation_race.rs (target/debug/deps/integration_truncation_race-9b3eb3ac65390670) + +running 1 test +test a_truncation_racing_a_read_never_yields_a_frame_at_the_wrong_lsn ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.96s + +=== target integration_trunk_tree_authority + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.27s + Running tests/integration_trunk_tree_authority.rs (target/debug/deps/integration_trunk_tree_authority-069e9ab3d7327f95) + +running 3 tests +test trunks_root_is_a_real_arena_page_named_by_the_branch_catalog ... ok +test trunk_rows_live_in_the_heap_not_in_trunks_page_tree ... ok +test an_agent_write_lives_on_branch_pages_and_reaches_the_heap_only_on_merge ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target integration_txn_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.03s + Running tests/integration_txn_concurrency.rs (target/debug/deps/integration_txn_concurrency-321b53de585b05de) + +running 4 tests +test concurrent_commits_and_aborts_do_not_panic ... ok +test committed_transactions_leave_the_active_set ... ok +test a_running_transaction_is_never_missing_from_a_snapshot ... ok +test concurrent_begins_get_distinct_transaction_ids ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.66s + +=== target integration_view_pushdown + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.97s + Running tests/integration_view_pushdown.rs (target/debug/deps/integration_view_pushdown-41c09f8d40e27f92) + +running 8 tests +test a_literal_the_binder_refuses_never_reaches_the_hint ... ok +test a_narrowed_read_still_announces_the_projections_columns ... ok +test the_extracted_hint_is_the_one_the_predicate_licenses ... ok +test ferro_runs_reports_exactly_the_branches_with_a_live_run ... ok +test a_scattered_set_of_live_runs_is_neither_widened_nor_truncated ... ok +test a_bounded_record_scan_returns_exactly_the_records_in_range ... ok +test every_view_query_answers_identically_with_and_without_the_hint ... ok +test the_hint_narrows_what_the_generator_builds ... ok + +test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 4.69s + +=== target integration_wal_concurrency + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.26s + Running tests/integration_wal_concurrency.rs (target/debug/deps/integration_wal_concurrency-a8e3f28b25240888) + +running 3 tests +test concurrent_appends_get_distinct_lsns ... ok +test the_log_remains_a_walkable_chain_after_concurrent_use ... ok +test flushed_lsn_never_over_reports_while_flushes_are_in_flight ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.31s + +=== target integration_zero_copy_fork + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.25s + Running tests/integration_zero_copy_fork.rs (target/debug/deps/integration_zero_copy_fork-301b513c069220ff) + +running 6 tests +test a_runtime_without_a_page_store_refuses_row_calls_instead_of_pretending ... ok +test a_write_on_the_branch_is_invisible_to_the_trunk_and_to_a_sibling ... ok +test beginning_an_agent_session_copies_zero_data_pages ... ok +test an_abandoned_agent_session_returns_its_pages_with_no_client_cooperation ... ok +test a_branch_write_costs_pages_only_on_the_path_it_touches ... ok +test the_fork_stays_zero_copy_as_the_trunk_grows ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.27s + +=== target lock_order_allowlist + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.32s + Running tests/lock_order_allowlist.rs (target/debug/deps/lock_order_allowlist-5152dbc136567be8) + +running 7 tests +test the_scanner_catches_an_unlisted_latcher ... ok +test the_scanner_catches_a_pool_method_missing_its_marker ... ok +test only_the_att_accessors_may_lock_the_active_transaction_table ... ok +test every_pool_method_that_locks_opens_a_pool_section ... ok +test only_the_tree_may_take_a_page_latch ... ok +test only_the_pool_may_write_lock_a_frame_directly ... ok +test test_only_sources_are_cfg_test_gated ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.11s + +=== target lock_order_runtime + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.07s + Running tests/lock_order_runtime.rs (target/debug/deps/lock_order_runtime-d1de576bd7acade5) + +running 4 tests +test the_correct_order_latch_then_frame_is_not_flagged ... ok +test a_page_latch_taken_while_holding_a_frame_write_lock_is_caught - should panic ... ok +test pool_sections_do_not_leak_across_calls ... ok +test a_page_latch_taken_while_holding_a_frame_read_lock_is_caught - should panic ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s + +=== target prop_cow_diff + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.08s + Running tests/prop_cow_diff.rs (target/debug/deps/prop_cow_diff-884fd6568a11d74e) + +running 5 tests +test applying_the_diff_to_the_base_reconstructs_the_head ... ok +test an_unedited_branch_diffs_to_nothing_for_free ... ok +test reversing_the_roots_inverts_every_delta ... ok +test diff_agrees_with_a_full_scan_comparison ... ok +test a_small_edit_reads_less_than_reading_both_trees ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 11.73s + +=== target prop_escrow + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.67s + Running tests/prop_escrow.rs (target/debug/deps/prop_escrow-5281dfec1344b96a) + +running 4 tests +test release_restores_the_pool_and_settle_consumes_it ... ok +test claims_outstanding_never_exceed_the_remaining_pool ... ok +test a_branch_never_spends_beyond_its_own_claim ... ok +test consumed_never_exceeds_the_declared_slack ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.16s + +=== target prop_merge_algebra + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.72s + Running tests/prop_merge_algebra.rs (target/debug/deps/prop_merge_algebra-b73b1c5f8620b66f) + +running 11 tests +test float_add_is_commutative_but_not_associative_and_that_is_stated_not_assumed ... ok +test a_nonzero_add_is_never_idempotent ... ok +test max_is_idempotent_and_commutative ... ok +test zero_is_the_identity_for_integer_add ... ok +test add_is_commutative ... ok +test integer_composition_refuses_overflow_rather_than_wrapping ... ok +test composing_then_applying_equals_applying_in_sequence ... ok +test two_assigns_never_commute ... ok +test add_is_the_only_kind_that_is_not_idempotent ... ok +test integer_add_is_associative ... ok +test commutes_with_is_symmetric ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s + +=== target prop_merge_outcomes + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.84s + Running tests/prop_merge_outcomes.rs (target/debug/deps/prop_merge_outcomes-7768847bc32df451) + +running 6 tests +test lww_on_two_different_assigns_never_reports_clean ... ok +test a_discard_beside_a_composition_still_reports_loss ... ok +test the_name_identifies_the_variant ... ok +test lost_a_write_agrees_with_the_variant ... ok +test a_discarded_write_is_never_reported_as_clean_or_commuting ... ok +test the_same_merge_classifies_the_same_way_twice ... ok + +test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s + +=== target provenance_e2e + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.29s + Running tests/provenance_e2e.rs (target/debug/deps/provenance_e2e-cb7eeb7fecb44dab) + +running 5 tests +test unattributed_versions_are_reported_as_unattributed ... ok +test a_snapshot_taken_before_the_write_creates_no_edge ... ok +test a_range_read_set_still_finds_the_write_it_consumed ... ok +test exact_read_set_makes_the_downstream_agent_visible_to_revert ... ok +test scattered_point_reads_stay_exact_through_the_executor ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.40s + +=== target provenance_retention_wiring + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.53s + Running tests/provenance_retention_wiring.rs (target/debug/deps/provenance_retention_wiring-71dfb10428de7988) + +running 11 tests +test a_read_whose_session_was_sealed_underneath_it_is_refused_rather_than_silently_unretained ... ok +test an_abandoned_tasks_scan_stops_blocking_the_revert_it_never_depended_on ... ok +test a_mirrored_point_read_retains_a_premise_the_gate_can_verify ... ok +test a_ghost_parent_that_staged_nothing_stops_blocking_once_its_child_has_merged ... ok +test a_multi_version_merge_followed_by_another_merge_keeps_its_causal_answers_exact ... ok +test a_point_lookup_that_observed_an_absence_halts_the_revert_that_would_refill_it ... ok +test an_update_whose_where_clause_scanned_names_its_dependents ... ok +test a_delete_whose_where_clause_scanned_names_its_dependents ... ok +test an_update_that_names_a_row_by_key_names_its_dependent_and_stays_a_blind_write ... ok +test a_mirrored_key_equality_is_classified_the_same_as_the_unmirrored_one ... ok +test parentheses_do_not_change_the_access_shape ... ok + +test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s + +=== target provenance_scan_cascade + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.92s + Running tests/provenance_scan_cascade.rs (target/debug/deps/provenance_scan_cascade-66d42001be305987) + +running 5 tests +test a_scan_depends_on_the_write_that_moved_a_row_out_of_its_range ... ok +test a_branch_that_scanned_before_the_write_is_not_a_dependent ... ok +test a_write_outside_the_scanned_range_is_not_a_dependency ... ok +test a_dependent_reached_by_a_scan_is_named_by_cascade ... ok +test a_multi_row_merge_is_named_once_by_the_scan_that_read_it_and_reverts_whole ... ok + +test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s + +=== target provenance_write_path_dependents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.83s + Running tests/provenance_write_path_dependents.rs (target/debug/deps/provenance_write_path_dependents-0a768ca1b4fd14c0) + +running 1 test +test sweep ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 9.45s + +=== target s20_live_count_is_maintained + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.34s + Running tests/s20_live_count_is_maintained.rs (target/debug/deps/s20_live_count_is_maintained-0c661209b21dcbb5) + +running 3 tests +test an_idempotent_transition_does_not_move_the_counter ... ok +test the_counter_agrees_with_a_scan_after_state_transitions ... ok +test the_counter_agrees_with_a_scan_after_forks ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target sim_durability + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.46s + Running tests/sim_durability.rs (target/debug/deps/sim_durability-9e6ed92c7221a067) + +running 16 tests +test under_sync_only_an_unflushed_write_does_not_survive ... ok +test undo_compensates_the_losers_in_ascending_transaction_id_order ... ok +test with_no_fault_the_workload_completes_and_every_row_comes_back ... ok +test a_torn_write_lands_a_prefix_and_ends_the_run ... ok +test the_same_seed_reproduces_the_same_byte_sequence_and_the_same_fault_point ... ok +test the_durable_commit_detector_fires_and_stays_quiet ... ok +test flush_all_writes_pages_in_ascending_page_id_order ... ok +test two_different_seeds_crash_in_different_places ... ok +test a_torn_table_page_is_served_as_a_row_that_was_never_written ... ok +test the_catalog_and_its_rebuilt_indexes_land_in_the_same_place_every_run ... ok +test a_garbled_wal_header_takes_the_database_down_with_its_data_intact ... ok +test recovery_gives_every_missing_page_its_own_empty_page_in_ascending_order ... ok +test recovery_itself_writes_the_same_sequence_three_times ... ok +test recovery_holds_at_every_fault_point_sync_only ... ok +test recovery_holds_at_every_fault_point_write_through ... ok +test recovery_holds_at_every_fault_point_through_the_catalog ... ok + +test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.65s + +=== target w4_forget_branches + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 5.95s + Running tests/w4_forget_branches.rs (target/debug/deps/w4_forget_branches-0a41a5b3bd0a4bd7) + +running 3 tests +test forget_branches_refuses_a_branch_that_is_still_live ... ok +test forget_branches_drops_the_branches_it_is_given ... ok +test a_reaped_branch_nobody_reported_is_still_reconciled ... ok + +test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +=== target w4_stale_branch_crosses_agents + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.54s + Running tests/w4_stale_branch_crosses_agents.rs (target/debug/deps/w4_stale_branch_crosses_agents-56dece9702f8bcac) + +running 2 tests +test a_reaped_sessions_select_must_not_read_the_new_occupants_staged_row ... ok +test a_reaped_sessions_abandon_must_not_retire_the_new_occupants_session ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.08s + +=== target w4_sweep_slot_recycle + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d178-dml-index) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.83s + Running tests/w4_sweep_slot_recycle.rs (target/debug/deps/w4_sweep_slot_recycle-6ee876f01aec3d39) + +running 1 test +test a_session_that_recycled_a_reaped_slot_survives_a_sweep_already_in_flight ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + diff --git a/bench/d178-a982aec_certified/verdict.txt b/bench/d178-a982aec_certified/verdict.txt new file mode 100644 index 00000000..2d53758c --- /dev/null +++ b/bench/d178-a982aec_certified/verdict.txt @@ -0,0 +1,2 @@ +d178-planspace: mode=per-target rc=0 passed=2545 failed=0 build_errors=0 head=a982aec log=/Users/idide/wt/artie-research/verify-out/d178-final2/suite-d178-planspace.log +d178-planspace: go rc=0 passed=97 failed=0 log=/Users/idide/wt/artie-research/verify-out/d178-final2/suite-d178-planspace.go.log From d62b4e7525517287c093e6e1a34ba45234b638ea Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:37:49 -0400 Subject: [PATCH 075/315] D184 follow-up: raise the flake deadline, and correct two comments that my own work made false Test-quality only. Deliberately NOT on d184-ack-survives-disconnect: a commit on top of a green suite stops that suite certifying the tip, and D184 lands at ed52543 on the suite already run. This branch forks from ed52543 and carries its own gate. 1. a_concurrent_forget_does_not_take_the_ack_with_it: 60ms -> 5s. My doc comment claimed "This cannot flake on the fixed code: retained is monotonic and forget does not touch it." The justification is true and does not support the claim. Monotonic retained removes the ORDERING failure and says nothing about a wall-clock deadline expiring while the waiter is descheduled. On 2026-09-23 three suites ran concurrently on this box, which is exactly that condition -- and the test PASSED even then, in team-lead's suite at load ~20. So this is insurance, not a repair. A generous deadline costs nothing in the passing case: on fixed code the waiter returns on the first check after the notify. 2. acktracker_still_implements_default: the banner above it was stale the moment 3e125e8 landed. It read "BODY COMMENTED OUT DELIBERATELY: IT DOES NOT COMPILE ... UNCOMMENT IT WHEN THE DERIVE IS RESTORED" -- directly above a live, compiling, passing assertion. A future reader would have read the extra-loud banner and concluded the Default regression was still open. My text, made false by the fix it asked for; a banner whose truth depends on the state of the code beneath it needs rewriting when that state changes. NOT changed, on purpose: the u64::MAX frontier passed by every pre-existing test. That is a uniform, deliberate "this test opts out of the clamp" convention that preserves what those tests tested before record() took a frontier. Changing only this one would break the convention for no gain. The clamp is covered by a_hello_above_the_primarys_frontier_must_not_poison_the_retained_mark, which passes a real FRONTIER. NOT BUILT: /tmp/ferrodb-suite.lock is held by d187-before, so no cargo was run. Comments plus one Duration constant; needs `cargo test --lib replication::sync` before it merges. --- src/replication/sync.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/replication/sync.rs b/src/replication/sync.rs index 05a55fdb..36d0a867 100644 --- a/src/replication/sync.rs +++ b/src/replication/sync.rs @@ -379,8 +379,17 @@ mod tests { /// **window-width dependent** — which is precisely why CI saw it intermittently and only /// under load, and why no local run reproduced it. /// - /// This cannot flake on the fixed code: `retained` is monotonic and `forget` does not touch - /// it, so the waiter's observation does not depend on which thread wins. + /// A generous deadline is deliberate: on fixed code the waiter returns on the first check + /// after the notify, so it costs nothing here and only prevents a starved thread from failing + /// this test for a reason unrelated to acknowledgements. + /// + /// ⚠ This replaces an earlier sentence of mine reading *"This cannot flake on the fixed code: + /// `retained` is monotonic and `forget` does not touch it."* That justification is TRUE and + /// does not support the claim it was offered for. Monotonic `retained` removes the ORDERING + /// failure; it says nothing about a wall-clock deadline expiring while the waiter is + /// descheduled. The deadline was 60ms, and on 2026-09-23 three suites ran concurrently on this + /// box — the exact condition that would have fired it. It passed even then, so raising it is + /// insurance, not a repair. #[test] fn a_concurrent_forget_does_not_take_the_ack_with_it() { let (mut lost, trials) = (0, 200); @@ -388,7 +397,7 @@ mod tests { let t = Arc::new(AckTracker::new()); let waiter = { let t = Arc::clone(&t); - std::thread::spawn(move || t.wait_for(1000, Duration::from_millis(60)).is_ok()) + std::thread::spawn(move || t.wait_for(1000, Duration::from_secs(5)).is_ok()) }; // Let the waiter park inside `wait_timeout` and release the mutex, so this models the // primary's main thread already waiting when the replica's last ack arrives. @@ -406,9 +415,10 @@ mod tests { ); } - /// ⛔ **D184 FOLLOW-UP — `AckTracker` LOST ITS `Default` IMPL IN `2ba9949`. BODY COMMENTED OUT - /// DELIBERATELY: IT DOES NOT COMPILE, AND A COMPILE ERROR WOULD RED-BUILD THE WHOLE CRATE - /// RATHER THAN FAIL ONE TEST. UNCOMMENT IT WHEN THE DERIVE IS RESTORED.** + /// **RESOLVED in `3e125e8`: the derive is restored, and the assertion below is live and + /// passing.** It is kept as a regression guard, because the way this broke leaves no other + /// trace — nothing in-tree calls `AckTracker::default()`, so the impl could vanish again and + /// every other test in the crate would still pass. /// /// `2ba9949` inserted `struct Acks` between `#[derive(Default)]` and `pub struct AckTracker`. /// An attribute binds to the *next* item, so the derive silently re-targeted onto the new @@ -424,8 +434,8 @@ mod tests { /// acknowledged…"*) above `Acks`, where it merges into one docstring whose first sentence /// describes a different type — which is the tell that the move was accidental, not intended. /// - /// Fix: put `#[derive(Default)]` back on `AckTracker` (correct now, since `Acks: Default` and - /// `Condvar: Default`), move that doc line back with it, and uncomment the body here. + /// That was fixed by putting `#[derive(Default)]` back on `AckTracker` and moving the + /// stranded doc line back with it. #[test] fn acktracker_still_implements_default() { let _t: AckTracker = Default::default(); From 0df221674aa748388efdc71bcbe3bbcf81d91d69 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:44:33 -0400 Subject: [PATCH 076/315] D191: count the State::applied entries AgentRuntime::diff visits, and a harness that curves it over |applied| at pinned delta Observing counters only (DIFF_APPLIED_VISITED, DIFF_ROWS, DIFF_FRAME_VISITED), counted inside the closures that walk. Nothing disabled or stubbed. Pre-registration committed first in artie-research frontier/lane_d191_diff.md (731b082). --- examples/d191_diff_applied_curve.rs | 406 ++++++++++++++++++++++++++++ src/agent_sql/runtime.rs | 55 +++- 2 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 examples/d191_diff_applied_curve.rs diff --git a/examples/d191_diff_applied_curve.rs b/examples/d191_diff_applied_curve.rs new file mode 100644 index 00000000..871675f6 --- /dev/null +++ b/examples/d191_diff_applied_curve.rs @@ -0,0 +1,406 @@ +//! D191 — **does `DIFF` pay for every merge anyone has ever published?** Answered with an INTEGER. +//! +//! # The question +//! +//! `AgentRuntime::diff` — the function `DIFF` reaches (`dispatch.rs`, `BoundAgentStmt::Diff` → +//! `runtime.diff`) — computes each changed row's `concurrent` flag with +//! `state.applied.iter().any(..)`, once PER CHANGED ROW, over `State::applied`: a log that is global +//! across branches and never pruned. D86's `applied_by_cell` index is keyed `(tbl, row, col)` and +//! cannot serve the `(tbl, row)` question. Read from source the shape is O(delta × |applied|). +//! Nobody had measured it: `bench/d103_production_diff_curve.txt` measures a function `DIFF` does +//! not call (D193), on the table-size axis, and `bench/d86_merge_degrades_with_merge_count.txt` +//! measures MERGE. +//! +//! # The axis +//! +//! `|applied|` grows by publishing K OTHER branches' merges while the diffed branch's delta is held +//! at 4. The primary instrument is `DIFF_APPLIED_VISITED`, counted inside the `.any` closure — a +//! control-flow count, not a `len()` — so it sees the short-circuit. Pre-registration: +//! `artie-research/frontier/lane_d191_diff.md` §4, committed before this file was built. +//! +//! # The arms +//! +//! | arm | session | what it is for | +//! |---|---|---| +//! | A | `B`, forked BEFORE every merge, ids 1..4 | the claim: visited = 4 · \|applied\| | +//! | P | a FRESH session per checkpoint, forked AFTER every merge, ids 1..4 | history that PREDATES the fork cannot be concurrent — is it still visited? | +//! | M | `C`, forked before every merge, ids 1,2,3,19; merge #30 publishes id 19 | **the fire-check.** `.any` must stop at the id-19 entry, so M ≠ A. If M = A the counter is computing rows × len and the run is VOID | +//! | Δ | fresh sessions with delta 1..16 at the last checkpoint | product or sum? | +//! +//! `|applied|` is taken from the MERGE REPORTS (`MergeReport::rows[*].applied`, the same vec +//! `record_applied` pushes), never from the counter under test. +//! +//! # ⚠ What is NOT claimed +//! +//! A count linear in |applied| is not by itself a latency problem: D114 measured a scan that was +//! exactly `delta × ops` and moved merge latency by nothing. Wall time is printed as the SECONDARY +//! column for exactly that reason, and the verdict on cost is read from it, not from the count. +use std::fs::OpenOptions; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::agent_sql::changeset::{ChangeOutcome, MergeReport}; +use ferrodb::agent_sql::dispatch::AgentOutput; +use ferrodb::agent_sql::runtime::{diff_scan_counters, AgentRuntime}; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::BranchCatalog; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::cow::PageStore; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::pgwire::ServerContext; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::ids::RowId; +use ferrodb::tel::MemEffectLog; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const NROWS: i64 = 1000; +/// Other branches merged before each checkpoint. 7 sizes; 32x across the non-zero ones. +const CHECKPOINTS: [usize; 7] = [0, 50, 100, 200, 400, 800, 1600]; +/// The diffed branches' delta. PINNED — that is what makes this an |applied| axis. +const DELTA: i64 = 4; +/// Rows each OTHER branch updates before merging. +const OTHER_ROWS: usize = 4; +/// Other branches draw their rows from `POOL_LO..=NROWS`, so they never touch ids 1..20. +const POOL_LO: i64 = 21; +/// ARM M's extra row, and the one merge that publishes it. +const MATCH_ID: i64 = 19; +const MATCH_MERGE: usize = 30; +const REPS: usize = 7; +const DELTAS: [i64; 5] = [1, 2, 4, 8, 16]; + +struct Server { + ctx: Arc, + bp: Arc, + txn: Arc, +} + +/// Run one statement the way the server does. Copied from `examples/d176_merge_rowcount.rs`. +fn exec(s: &Server, sql: &str, sess: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()) + .scan_tokens() + .map_err(|e| e.to_string())?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(format!("parse failed for {sql}: {:?}", parser.errors)); + } + let stmt = stmts.remove(0); + let mut cat = s.ctx.catalog(); + let out = run(stmt, &mut cat, s.bp.clone(), s.txn.clone(), sess); + drop(cat); + out.map_err(|e| e.to_string()) +} + +/// The D176 server, with its three load-bearing configuration corrections carried over verbatim: +/// `with_storage` (not `with_catalog`), `s.ctx.session()` (never `Session::new()`), and +/// `checkpoint_to`. See `examples/d176_merge_rowcount.rs::build_sized_indexed` for what each one +/// cost when it was missing. +fn build(dir: &std::path::Path) -> Server { + let d = dir.join("srv"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(d.join("main.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(d.join("main.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let cat = Arc::new(TableBranchCatalog::open_sidecar(&d.join("b.branchcat"), 1).unwrap()); + let branches: Arc = cat.clone(); + const ARENA_BASE: u32 = 1024; + let store = Arc::new(ArenaPageStore::new(bp.clone(), branches.clone(), ARENA_BASE).unwrap()); + store.checkpoint_to(d.join("main.arena")); + let runtime = Arc::new( + AgentRuntime::with_storage( + branches, + Arc::new(MemEffectLog::new()), + Arc::clone(&store) as Arc, + ) + .expect("attach arena storage"), + ); + let ctx = Arc::new(ServerContext::new(catalog, bp.clone(), txn.clone(), runtime)); + let s = Server { ctx, bp, txn }; + let mut sess = s.ctx.session(); + exec(&s, "CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut sess).unwrap(); + for i in 1..=NROWS { + exec(&s, &format!("INSERT INTO t VALUES ({i}, {});", i * 7), &mut sess).unwrap(); + } + s +} + +/// Open an agent session and stage one single-column UPDATE per id. +fn open_with(s: &Server, name: &str, ids: &[i64], v: i64) -> Session { + let mut sess = s.ctx.session(); + exec(s, &format!("BEGIN AGENT SESSION AS '{name}';"), &mut sess) + .unwrap_or_else(|e| panic!("BEGIN {name}: {e}")); + for id in ids { + exec(s, &format!("UPDATE t SET v = {v} WHERE id = {id};"), &mut sess) + .unwrap_or_else(|e| panic!("UPDATE id={id} on {name}: {e}")); + } + sess +} + +/// One `DIFF;`, with the three counters scoped to that one statement by read-twice-and-subtract. +/// Exact because this harness is single-threaded. +struct Obs { + visited: u64, + rows: u64, + frame: u64, + nanos: u128, + out_rows: usize, + /// First-column values of the rows DIFF reported as `PendingConcurrent`. + concurrent_ids: Vec, + /// (first-column value, RowId) for every row DIFF reported. + ids: Vec<(i64, RowId)>, +} + +fn first_int(img: &Option>) -> i64 { + match img.as_ref().and_then(|v| v.first()) { + Some(Value::Integer(i)) => *i as i64, + Some(Value::BigInt(i)) => *i, + _ => -1, + } +} + +fn diff_once(s: &Server, sess: &mut Session) -> Obs { + let (a0, r0, f0) = diff_scan_counters(); + let t0 = Instant::now(); + let out = exec(s, "DIFF;", sess).expect("DIFF failed"); + let nanos = t0.elapsed().as_nanos(); + let (a1, r1, f1) = diff_scan_counters(); + let cs = match out { + Outcome::Agent(AgentOutput::Diff(cs)) => cs, + _ => panic!("DIFF did not return a changeset — the instrument window is not a DIFF"), + }; + let mut concurrent_ids = Vec::new(); + let mut ids = Vec::new(); + for r in &cs.rows { + let id = first_int(&r.after); + ids.push((id, r.row)); + if matches!(r.outcome, ChangeOutcome::PendingConcurrent) { + concurrent_ids.push(id); + } + } + Obs { + visited: a1 - a0, + rows: r1 - r0, + frame: f1 - f0, + nanos, + out_rows: cs.rows.len(), + concurrent_ids, + ids, + } +} + +/// REPS DIFFs, reported as min/max for every integer (min == max is how determinism is SHOWN) and +/// the median for wall time. +struct Cell { + vmin: u64, + vmax: u64, + rmin: u64, + rmax: u64, + fmin: u64, + fmax: u64, + med_ns: u128, + out_rows: usize, + concurrent: Vec, +} + +fn cell(s: &Server, sess: &mut Session) -> Cell { + let obs: Vec = (0..REPS).map(|_| diff_once(s, sess)).collect(); + let mut ns: Vec = obs.iter().map(|o| o.nanos).collect(); + ns.sort_unstable(); + let out_rows = obs[0].out_rows; + assert!(obs.iter().all(|o| o.out_rows == out_rows), "DIFF row count varied across reps"); + Cell { + vmin: obs.iter().map(|o| o.visited).min().unwrap(), + vmax: obs.iter().map(|o| o.visited).max().unwrap(), + rmin: obs.iter().map(|o| o.rows).min().unwrap(), + rmax: obs.iter().map(|o| o.rows).max().unwrap(), + fmin: obs.iter().map(|o| o.frame).min().unwrap(), + fmax: obs.iter().map(|o| o.frame).max().unwrap(), + med_ns: ns[ns.len() / 2], + out_rows, + concurrent: obs[0].concurrent_ids.clone(), + } +} + +fn verdict(ok: bool) -> &'static str { + if ok { + "ok" + } else { + "MISMATCH" + } +} + +fn main() { + let dir = std::env::temp_dir().join(format!("d191_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + println!("D191 — AgentRuntime::diff's scan of State::applied, as a control-flow COUNT."); + println!("NROWS={NROWS} DELTA={DELTA} OTHER_ROWS={OTHER_ROWS} POOL={POOL_LO}..={NROWS} MATCH_ID={MATCH_ID} MATCH_MERGE={MATCH_MERGE} REPS={REPS}"); + println!("visited = DIFF_APPLIED_VISITED, rows = DIFF_ROWS, frame = DIFF_FRAME_VISITED, each per ONE `DIFF;`."); + println!("|applied| is summed from MergeReport::rows[*].applied of every published merge, NOT from the counter."); + println!(); + + let s = build(&dir); + // Both forked BEFORE any merge, so every later entry has seq > their fork_seq. + let mut b = open_with(&s, "B", &[1, 2, 3, 4], 1); + let mut c = open_with(&s, "C", &[1, 2, 3, MATCH_ID], 1); + + // C's RowId for id 19, read from C's own DIFF so no assumption about how ids become RowIds. + let c0 = diff_once(&s, &mut c); + let match_row: RowId = c0 + .ids + .iter() + .find(|(id, _)| *id == MATCH_ID) + .map(|(_, r)| *r) + .expect("C's DIFF does not contain id 19 — the fixture did not stage it"); + println!("C's RowId for id {MATCH_ID}: {:?}", match_row); + + let mut applied_total: u64 = 0; + let mut ops_per_merge: Vec = Vec::new(); + // Position in `State::applied` of the id-19 entry, from merge #30's report. + let mut match_pos: Option = None; + let mut cursor: usize = 0; + let pool = (NROWS - POOL_LO + 1) as usize; + let mut k_done = 0usize; + let mut merge_ns: Vec = Vec::new(); + + println!("== ARMS A / P / M, delta pinned at {DELTA}, |applied| grown by K other branches' merges =="); + println!( + "{:>5} {:>8} | {:>7} {:>7} {:>9} {:>4} {:>5} {:>10} {:>8} | {:>7} {:>7} {:>9} {:>10} {:>8} | {:>7} {:>7} {:>9} {:>6} {:>8} {:>5}", + "K", "applied", + "A_vmin", "A_vmax", "A_expect", "A_r", "A_frm", "A_med_ns", "A", + "P_vmin", "P_vmax", "P_expect", "P_med_ns", "P", + "M_vmin", "M_vmax", "M_expect", "M_conc", "M", "rows" + ); + let mut rows_a: Vec<(usize, u64, u64, u128)> = Vec::new(); + let mut all_ok = true; + for &k in CHECKPOINTS.iter() { + while k_done < k { + let m = k_done + 1; + let mut ids: Vec = (0..OTHER_ROWS) + .map(|_| { + let id = POOL_LO + (cursor % pool) as i64; + cursor += 1; + id + }) + .collect(); + if m == MATCH_MERGE { + ids[0] = MATCH_ID; + } + let mut sess = open_with(&s, &format!("o{m}"), &ids, (m % 1000) as i64 + 2); + let t0 = Instant::now(); + let rep: MergeReport = match exec(&s, "MERGE;", &mut sess) { + Ok(Outcome::Agent(AgentOutput::Merge(r))) => r, + Ok(_) => panic!("merge {m} returned a non-merge outcome"), + Err(e) => panic!("merge {m} failed: {e}"), + }; + merge_ns.push(t0.elapsed().as_nanos()); + if !rep.applied_to_target { + panic!("merge {m} did not reach the target; |applied| would be wrong"); + } + if m == MATCH_MERGE { + let mut off = applied_total; + for r in &rep.rows { + if let Some(i) = r.applied.iter().position(|op| op.row == match_row) { + match_pos = Some(off + i as u64); + break; + } + off += r.applied.len() as u64; + } + assert!(match_pos.is_some(), "merge {m} published no op on C's id-19 RowId"); + } + let n: u64 = rep.rows.iter().map(|r| r.applied.len() as u64).sum(); + ops_per_merge.push(n); + applied_total += n; + k_done += 1; + } + + let a = cell(&s, &mut b); + let mut p_sess = open_with(&s, &format!("P{k}"), &[1, 2, 3, 4], 3); + let p = cell(&s, &mut p_sess); + drop(p_sess); + let mcell = cell(&s, &mut c); + + let a_exp = DELTA as u64 * applied_total; + let m_exp = match match_pos { + Some(pos) => (DELTA as u64 - 1) * applied_total + pos + 1, + None => DELTA as u64 * applied_total, + }; + let a_ok = a.vmin == a_exp && a.vmax == a_exp && a.rmin == DELTA as u64 && a.rmax == DELTA as u64 && a.concurrent.is_empty() && a.out_rows == DELTA as usize; + let p_ok = p.vmin == a_exp && p.vmax == a_exp && p.rmin == DELTA as u64 && p.rmax == DELTA as u64 && p.concurrent.is_empty(); + let m_conc_exp: Vec = if match_pos.is_some() { vec![MATCH_ID] } else { vec![] }; + let m_ok = mcell.vmin == m_exp && mcell.vmax == m_exp && mcell.concurrent == m_conc_exp && mcell.rmin == DELTA as u64; + all_ok &= a_ok && p_ok && m_ok; + println!( + "{:>5} {:>8} | {:>7} {:>7} {:>9} {:>4} {:>5} {:>10} {:>8} | {:>7} {:>7} {:>9} {:>10} {:>8} | {:>7} {:>7} {:>9} {:>6} {:>8} {:>5}", + k, applied_total, + a.vmin, a.vmax, a_exp, a.rmax, format!("{}-{}", a.fmin, a.fmax), a.med_ns, verdict(a_ok), + p.vmin, p.vmax, a_exp, p.med_ns, verdict(p_ok), + mcell.vmin, mcell.vmax, m_exp, format!("{:?}", mcell.concurrent), verdict(m_ok), + format!("{}/{}/{}", a.out_rows, p.out_rows, mcell.out_rows) + ); + rows_a.push((k, applied_total, a.vmax, a.med_ns)); + } + println!(); + println!("ops per merge (from reports): min={} max={}", ops_per_merge.iter().min().unwrap(), ops_per_merge.iter().max().unwrap()); + println!("id-{MATCH_ID} entry position in State::applied (from merge #{MATCH_MERGE}'s report): {:?}", match_pos); + println!(); + + // ---------------- DELTA axis at the final |applied| ---------------- + println!("== ARM Δ — delta varies at K={} (|applied|={applied_total}), fresh sessions forked after every merge ==", CHECKPOINTS[CHECKPOINTS.len() - 1]); + println!("{:>5} {:>9} {:>9} {:>10} {:>5} {:>9} {:>10} {:>8}", "delta", "vmin", "vmax", "expect", "rows", "frame", "med_ns", ""); + for d in DELTAS { + let ids: Vec = (1..=d).collect(); + let mut sess = open_with(&s, &format!("D{d}"), &ids, 5); + let cl = cell(&s, &mut sess); + let exp = d as u64 * applied_total; + let ok = cl.vmin == exp && cl.vmax == exp && cl.rmax == d as u64 && cl.concurrent.is_empty(); + all_ok &= ok; + println!("{:>5} {:>9} {:>9} {:>10} {:>5} {:>9} {:>10} {:>8}", d, cl.vmin, cl.vmax, exp, cl.rmax, format!("{}-{}", cl.fmin, cl.fmax), cl.med_ns, verdict(ok)); + } + println!(); + + // ---------------- Wall time, SECONDARY ---------------- + // Least-squares slope of ARM A's median DIFF wall time against visited entries. + let n = rows_a.len() as f64; + let xs: Vec = rows_a.iter().map(|r| r.2 as f64).collect(); + let ys: Vec = rows_a.iter().map(|r| r.3 as f64).collect(); + let mx = xs.iter().sum::() / n; + let my = ys.iter().sum::() / n; + let sxy: f64 = xs.iter().zip(&ys).map(|(x, y)| (x - mx) * (y - my)).sum(); + let sxx: f64 = xs.iter().map(|x| (x - mx) * (x - mx)).sum(); + let slope = sxy / sxx; + let icept = my - slope * mx; + println!("== WALL TIME (secondary) — ARM A median DIFF ns vs visited entries =="); + println!("least-squares: {:.3} ns per visited entry, intercept {:.0} ns", slope, icept); + let first = &rows_a[0]; + let last = &rows_a[rows_a.len() - 1]; + println!("K={} median {} ns -> K={} median {} ns ({:.2}x)", first.0, first.3, last.0, last.3, last.3 as f64 / first.3.max(1) as f64); + let mut ms = merge_ns.clone(); + ms.sort_unstable(); + let dec = ms.len() / 10; + let f40: u128 = merge_ns[..dec].iter().sum::() / dec.max(1) as u128; + let l40: u128 = merge_ns[merge_ns.len() - dec..].iter().sum::() / dec.max(1) as u128; + println!("context: other-branch MERGE mean, first decile {} ns, last decile {} ns (median {} ns)", f40, l40, ms[ms.len() / 2]); + println!(); + println!("PRE-REGISTERED CHECKS (P1-P4, P6-P8): {}", if all_ok { "ALL MATCH" } else { "AT LEAST ONE MISMATCH — read the rows" }); + let _ = std::fs::remove_dir_all(&dir); + if !all_ok { + std::process::exit(2); + } +} diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 379ddfdb..41282c33 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -181,6 +181,40 @@ pub fn scan_table_seq_tuples() -> u64 { SCAN_TABLE_SEQ_TUPLES.load(AtomicOrdering::Relaxed) } +/// **D191 instrument — how many `State::applied` entries `AgentRuntime::diff` VISITED.** +/// +/// `diff` answers each changed row's `concurrent` flag with `state.applied.iter().any(..)`, once +/// PER CHANGED ROW, over a log that is global across branches and never pruned. D86's +/// `applied_by_cell` index is keyed `(tbl, row, col)` and cannot serve this `(tbl, row)` question, +/// so the read shape is O(delta x |applied|). This counter is what turns that reading into a +/// measurement (`examples/d191_diff_applied_curve.rs`). +/// +/// Taken from INSIDE the `.any` closure, for the reason `ours_ops_on_cell` gives: a count computed +/// as `applied.len()` from outside would be an assertion about the code rather than a reading of +/// it, and it would miss the short-circuit — `.any` stops at the first concurrent entry, so a row +/// the target DID move visits only up to that entry. Integers, like `OURS_SCAN_*`, because a count +/// does not move when the box is loaded. +/// +/// ⚠ Counted per ROW, not per entry: one local increment inside the closure and one relaxed add +/// per changed row, so the instrument cannot create the slope it measures. +pub static DIFF_APPLIED_VISITED: AtomicU64 = AtomicU64::new(0); +/// Changed rows `diff` examined — the delta, which a curve over `|applied|` must hold fixed. +pub static DIFF_ROWS: AtomicU64 = AtomicU64::new(0); +/// Frame ops plus frame guards the two sibling per-row `filter`s visited. They have no early +/// exit, so this is `delta x (|frame.ops| + |frame.guards|)` — the per-task term `diff` pays beside +/// the global one. Kept so a reader can see which of the two dominates, not assume it. +pub static DIFF_FRAME_VISITED: AtomicU64 = AtomicU64::new(0); + +/// `(applied_visited, rows, frame_visited)` since process start. Read twice and subtract to scope +/// it to one `DIFF`. +pub fn diff_scan_counters() -> (u64, u64, u64) { + ( + DIFF_APPLIED_VISITED.load(AtomicOrdering::Relaxed), + DIFF_ROWS.load(AtomicOrdering::Relaxed), + DIFF_FRAME_VISITED.load(AtomicOrdering::Relaxed), + ) +} + /// **The `ours` side of one cell's three-way comparison:** this branch's own recorded ops on that /// cell, in the order it wrote them, for `compose_ops` to fold. /// @@ -3169,13 +3203,20 @@ impl AgentRuntime { ws.rows .iter() .map(|((t, r), st)| { + // D191 instrument: counted inside the closures that walk, never as a + // `len()` from outside. See `DIFF_APPLIED_VISITED`. + let mut frame_visited = 0u64; + let mut applied_visited = 0u64; let table = ws.tables.get(t).cloned().unwrap_or_default(); let before = ws.base_rows.get(&(*t, *r)).cloned().flatten(); let ops: Vec = ws .frame .ops .iter() - .filter(|o| o.tbl.0 == *t && o.row.0 == *r) + .filter(|o| { + frame_visited += 1; + o.tbl.0 == *t && o.row.0 == *r + }) .cloned() .collect(); let guards: Vec = ws @@ -3183,6 +3224,7 @@ impl AgentRuntime { .guards .iter() .filter(|g| { + frame_visited += 1; g.expr .referenced_cells() .iter() @@ -3190,10 +3232,13 @@ impl AgentRuntime { }) .cloned() .collect(); - let concurrent = state - .applied - .iter() - .any(|a| a.seq > ws.fork_seq && a.tbl.0 == *t && a.row.0 == *r); + let concurrent = state.applied.iter().any(|a| { + applied_visited += 1; + a.seq > ws.fork_seq && a.tbl.0 == *t && a.row.0 == *r + }); + DIFF_APPLIED_VISITED.fetch_add(applied_visited, AtomicOrdering::Relaxed); + DIFF_FRAME_VISITED.fetch_add(frame_visited, AtomicOrdering::Relaxed); + DIFF_ROWS.fetch_add(1, AtomicOrdering::Relaxed); (*t, *r, table, before, st.clone(), ops, guards, concurrent) }) .collect(); From d0d7dbc5477b17269501ceb11c8650a8340840f0 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:45:28 -0400 Subject: [PATCH 077/315] D192: observing footprint accessor on AttestedHistory and a three-instrument B/branch harness Measure-only. AttestedHistory::footprint and AgentRuntime::attested_footprint read lengths and capacities; nothing on a production path calls them. examples/d192_attest_footprint.rs drives the production lifecycle (merge|abandon|fork arms) and checks a byte model against a tracking allocator. --- examples/d192_attest_footprint.rs | 393 ++++++++++++++++++++++++++++++ src/agent_sql/runtime.rs | 6 + src/branch/attest.rs | 54 ++++ 3 files changed, 453 insertions(+) create mode 100644 examples/d192_attest_footprint.rs diff --git a/examples/d192_attest_footprint.rs b/examples/d192_attest_footprint.rs new file mode 100644 index 00000000..83336770 --- /dev/null +++ b/examples/d192_attest_footprint.rs @@ -0,0 +1,393 @@ +//! D192: how many bytes `AgentRuntime::attested` (`AttestedHistory`) keeps resident per branch, +//! measured as a SLOPE over a branch-lifecycle curve. +//! +//! d192_attest_footprint [checkpoints,comma,separated] +//! +//! Arms, one fresh runtime each, driven through the production lifecycle (SQL +//! `BEGIN AGENT SESSION`, then `AgentRuntime::merge` / `AgentRuntime::abandon`): +//! +//! * `merge` — fork, write one row, merge. The merge publishes and reaps the worker, so each branch +//! leaves `[Fork, Reap]` on itself and `[Merge]` on trunk: 3 entries. +//! * `abandon` — fork, abandon: `[Fork, Reap]`, 2 entries. +//! * `fork` — fork only, branch left live: `[Fork]`, 1 entry. +//! +//! `abandon` against `fork` is the arm that discriminates "a reap frees the branch's history" from +//! "a reap APPENDS to it": a reap that freed anything would put abandon BELOW fork. +//! +//! # Three instruments, and what each can and cannot see +//! +//! 1. **LIVE model** — `AgentRuntime::attested_footprint()` lengths × element sizes (+1 control +//! byte per hash-table item). Phase-free, so it is the class figure; it cannot see capacity slack. +//! 2. **ALLOC model** — the same accessor's CAPACITIES × element sizes, plus a stated hash-table +//! layout model (buckets derived from `HashMap::capacity()`, `round_up(buckets*size_of(T), 8) + +//! buckets + GROUP`). That layout is a model of std's hashbrown, not a fact, which is why: +//! 3. **ALLOCATOR** — a tracking `#[global_allocator]` (the `examples/cow_scan_memory.rs` +//! instrument) brackets `AttestedHistory::load_untrusted(rt.attested_log())`: a replay through the +//! same single `push` path, whose every length and capacity is ASSERTED equal to the runtime's +//! before its bytes are believed. Its bytes are what the allocator actually handed out, so the +//! residual `allocator − alloc_model` checks instrument 2 rather than repeating it. The replay is +//! then dropped and the bytes it RETURNS must equal the bytes it took; a mismatch means another +//! thread allocated inside the window and that point is contaminated. +//! +//! Not measured here: allocator rounding and per-block metadata beyond `Layout::size()` (the +//! tracking allocator counts requested bytes), and RSS. Resident cost lies between LIVE and ALLOC: +//! untouched `Vec` capacity is address space, not resident pages. +//! +//! The runtime is built the way `src/cli/cli.rs::run_cli` builds it — durable table catalog, arena +//! page store, durable effect log, durable provenance, two-tier reaper — so the whole-process heap +//! column is this engine's configuration and not a test double's. The lease thread is NOT started: +//! a background thread allocating inside the replay bracket would contaminate instrument 3. +use std::alloc::{GlobalAlloc, Layout, System}; +use std::fs::OpenOptions; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::agent_sql::runtime::{AgentRuntime, ExecCtx}; +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::attest::{AttestFootprint, Attestation, AttestedHistory, HistoryEntry}; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::BranchId; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::PageStore; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::DurableEffectLog; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +// ---- the instrument (examples/cow_scan_memory.rs, live counter only) ------------------------ + +static LIVE: AtomicUsize = AtomicUsize::new(0); + +struct Tracking; + +unsafe impl GlobalAlloc for Tracking { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc(l) }; + if !p.is_null() { + LIVE.fetch_add(l.size(), Ordering::Relaxed); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Ordering::Relaxed); + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + let q = unsafe { System.realloc(p, l, new) }; + if !q.is_null() { + LIVE.fetch_sub(l.size(), Ordering::Relaxed); + LIVE.fetch_add(new, Ordering::Relaxed); + } + q + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(l) }; + if !p.is_null() { + LIVE.fetch_add(l.size(), Ordering::Relaxed); + } + p + } +} + +#[global_allocator] +static A: Tracking = Tracking; + +fn live() -> usize { + LIVE.load(Ordering::SeqCst) +} + +// ---- the byte models ------------------------------------------------------------------------ + +/// hashbrown's control-group width. 8 on aarch64 (NEON) and the generic fallback, 16 with SSE2. +/// A MODEL CONSTANT — instrument 3's residual is what says whether it is right. +const GROUP: usize = if cfg!(all(target_arch = "x86_64", target_feature = "sse2")) { 16 } else { 8 }; + +const E: usize = std::mem::size_of::(); +const IDX: usize = std::mem::size_of::(); +const NODE: usize = std::mem::size_of::<[u8; 32]>(); +const LEVEL_VEC: usize = std::mem::size_of::>(); +const BB: usize = std::mem::size_of::<(BranchId, Vec)>(); +const HD: usize = std::mem::size_of::<(BranchId, Attestation)>(); + +/// Bytes of one hash-table allocation, from `HashMap::capacity()`. Zero capacity is the +/// unallocated empty singleton. +fn table_bytes(cap_items: usize, t: usize) -> usize { + if cap_items == 0 { + return 0; + } + let buckets = if cap_items < 8 { cap_items + 1 } else { cap_items / 7 * 8 }; + let ctrl_align = GROUP.max(8); + (buckets * t).div_ceil(ctrl_align) * ctrl_align + buckets + GROUP +} + +fn live_model(f: &AttestFootprint) -> usize { + f.entries_len * E + + f.level_nodes_len * NODE + + f.by_branch_idx_len * IDX + + f.by_branch_keys * (BB + 1) + + f.heads_keys * (HD + 1) +} + +fn alloc_model(f: &AttestFootprint) -> usize { + f.entries_cap * E + + f.level_nodes_cap * NODE + + f.levels_outer_cap * LEVEL_VEC + + f.by_branch_idx_cap * IDX + + table_bytes(f.by_branch_table_cap, BB) + + table_bytes(f.heads_table_cap, HD) +} + +/// Instrument 3: bytes the allocator hands a replay of the runtime's log, and the bytes it gives +/// back on drop. Returns (built, returned, replay footprint). +fn replay(rt: &AgentRuntime) -> (usize, usize, AttestFootprint) { + let b0 = live(); + let h = AttestedHistory::load_untrusted(rt.attested_log()); + let b1 = live(); + let fp = h.footprint(); + let d0 = live(); + drop(h); + let d1 = live(); + (b1.wrapping_sub(b0), d0.wrapping_sub(d1), fp) +} + +// ---- the runtime, built as run_cli builds it ------------------------------------------------ + +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Result { + let dir = tempfile::tempdir().map_err(|e| FerroError::Io(e.to_string()))?; + let db_path = dir.path().join("d192.db").to_string_lossy().into_owned(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&db_path) + .map_err(|e| FerroError::Io(e.to_string()))?; + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file)?))); + let wal = Arc::new(WalManager::new(format!("{db_path}.wal").into())?); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let catalog = Catalog::create(bp.clone())?; + + // run_cli's TRUNK_ROOT_PLACEHOLDER and DEFAULT_ARENA_HEADROOM (both private there). + let branches = Arc::new(TableBranchCatalog::default_for_database(&db_path, 1)?); + let base = bp.disk_manager.high_water()?.saturating_add(32_736); + let store = Arc::new(ArenaPageStore::new(bp.clone(), branches.clone(), base)?); + store.checkpoint_to(format!("{db_path}.arena").into()); + let reaper = Arc::new(TwoTierReaper::new(branches.clone(), store.clone())); + let effects = DurableEffectLog::default_for_database(&db_path)?; + let runtime = Arc::new( + AgentRuntime::with_storage( + branches as Arc, + effects, + store as Arc, + )? + .with_durable_provenance(format!("{db_path}.provenance"))? + .with_reaper(reaper as Arc), + ); + Ok(Db { catalog, bp, txn, runtime, _dir: dir }) + } + + fn exec(&mut self, sql: &str, s: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() || stmts.len() != 1 { + return Err(FerroError::SqlParseError(format!("{sql}: {:?}", parser.errors))); + } + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + } + + fn ok(&mut self, sql: &str, s: &mut Session) { + if let Err(e) = self.exec(sql, s) { + panic!("{sql} failed: {e}"); + } + } + + /// One branch lifecycle for `arm`. Returns the number of attested entries it must add. + fn lifecycle(&mut self, arm: &str, i: usize) -> usize { + let mut s = Session::with_runtime(self.runtime.clone()); + self.ok(&format!("BEGIN AGENT SESSION AS 'agent-{i}' RUN 'r_{i}';"), &mut s); + let branch = s.agent.as_ref().expect("BEGIN AGENT SESSION opened no branch").branch; + match arm { + "merge" => { + self.ok(&format!("UPDATE inventory SET qty = {i} WHERE id = 1;"), &mut s); + let rt = self.runtime.clone(); + let mut ctx = + ExecCtx { catalog: &mut self.catalog, bp: self.bp.clone(), txn: self.txn.clone() }; + let report = rt.merge(&mut ctx, branch).expect("merge failed"); + assert!(!report.outcome.is_conflict(), "lifecycle {i} conflicted"); + assert!(report.applied_to_target, "lifecycle {i} did not publish"); + 3 + } + "abandon" => { + self.runtime.abandon(branch).expect("abandon failed"); + 2 + } + "fork" => 1, + other => panic!("unknown arm {other:?}; expected merge|abandon|fork"), + } + } +} + +fn lsq(xs: &[f64], ys: &[f64]) -> f64 { + let n = xs.len() as f64; + let (mx, my) = (xs.iter().sum::() / n, ys.iter().sum::() / n); + let num: f64 = xs.iter().zip(ys).map(|(x, y)| (x - mx) * (y - my)).sum(); + let den: f64 = xs.iter().map(|x| (x - mx) * (x - mx)).sum(); + num / den +} + +fn main() { + let mut args = std::env::args().skip(1); + let arm = args.next().unwrap_or_else(|| { + eprintln!("usage: d192_attest_footprint [checkpoints]"); + std::process::exit(2) + }); + if !matches!(arm.as_str(), "merge" | "abandon" | "fork") { + eprintln!("unknown arm {arm:?}; expected merge|abandon|fork"); + std::process::exit(2); + } + let checkpoints: Vec = args + .next() + .unwrap_or_else(|| "1000,1400,2000,2800,4000,5600,8000,11200,16000,22400,32000".into()) + .split(',') + .map(|s| s.trim().parse().expect("checkpoint must be an integer")) + .collect(); + if checkpoints.len() < 2 || checkpoints.windows(2).any(|w| w[0] >= w[1]) { + eprintln!("need >= 2 strictly increasing checkpoints; got {checkpoints:?}"); + std::process::exit(2); + } + + println!("D192 attest footprint — arm={arm} checkpoints={checkpoints:?}"); + println!( + "size_of: HistoryEntry={E} usize={IDX} node={NODE} Vec<[u8;32]>={LEVEL_VEC} \ + (BranchId,Vec)={BB} (BranchId,Attestation)={HD} GROUP(model)={GROUP}" + ); + + let mut db = Db::new().expect("runtime construction"); + { + let mut s = Session::with_runtime(db.runtime.clone()); + db.ok("CREATE TABLE inventory (id INTEGER NOT NULL, qty INTEGER);", &mut s); + db.ok("INSERT INTO inventory VALUES (1, 100);", &mut s); + } + + // Negative control: an empty log replays to zero bytes, and the counter is quiet. + let len0 = db.runtime.attested_len(); + let (built0, returned0, fp0) = replay(&db.runtime); + println!( + "control: attested_len before any fork = {len0}; empty-log replay built={built0} \ + returned={returned0} footprint={fp0:?}" + ); + assert_eq!(len0, 0, "the seed attested something; the per-branch arithmetic would be offset"); + assert_eq!((built0, returned0), (0, 0), "empty replay allocated: the bracket is not clean"); + + println!(); + println!( + "{:>7} {:>8} {:>7} {:>12} {:>12} {:>12} {:>9} {:>12} {:>13} {:>8} {:>8}", + "N", "att_len", "keys", "live_model", "alloc_model", "allocator", "resid", "returned", + "proc_heap", "alloc/N", "secs" + ); + let t0 = Instant::now(); + let heap_start = live(); + let mut done = 0usize; + let mut expect_len = 0usize; + let (mut xs, mut ys_live, mut ys_model, mut ys_alloc, mut ys_heap) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut all_ok = true; + for &n in &checkpoints { + while done < n { + expect_len += db.lifecycle(&arm, done); + done += 1; + } + let fp = db.runtime.attested_footprint(); + let len = db.runtime.attested_len(); + let (built, returned, rfp) = replay(&db.runtime); + let heap = live(); + let (lm, am) = (live_model(&fp), alloc_model(&fp)); + let resid = built as i64 - am as i64; + println!( + "{:>7} {:>8} {:>7} {:>12} {:>12} {:>12} {:>9} {:>12} {:>13} {:>8.1} {:>8.1}", + n, + len, + fp.by_branch_keys, + lm, + am, + built, + resid, + returned, + heap - heap_start.min(heap), + built as f64 / n as f64, + t0.elapsed().as_secs_f64() + ); + // P2, P6, P9: refuse rather than print a slope over a point these fail at. + let mut why = Vec::new(); + if len != expect_len { + why.push(format!("P2 attested_len {len} != expected {expect_len}")); + } + if rfp != fp { + why.push(format!("P6 replay footprint differs: runtime {fp:?} replay {rfp:?}")); + } + if built != returned { + why.push(format!("P9 built {built} != returned {returned}")); + } + if !why.is_empty() { + all_ok = false; + println!(" ⛔ N={n}: {}", why.join("; ")); + } + println!(" footprint {fp:?}"); + xs.push(n as f64); + ys_live.push(lm as f64); + ys_model.push(am as f64); + ys_alloc.push(built as f64); + ys_heap.push((heap - heap_start.min(heap)) as f64); + } + + println!(); + println!("SEGMENT SLOPES (B/branch between consecutive checkpoints):"); + for i in 1..xs.len() { + let dx = xs[i] - xs[i - 1]; + println!( + " {:>6}->{:<6} live {:>8.1} alloc_model {:>8.1} allocator {:>8.1} proc_heap {:>9.1}", + xs[i - 1], + xs[i], + (ys_live[i] - ys_live[i - 1]) / dx, + (ys_model[i] - ys_model[i - 1]) / dx, + (ys_alloc[i] - ys_alloc[i - 1]) / dx, + (ys_heap[i] - ys_heap[i - 1]) / dx + ); + } + println!( + "LSQ SLOPE over {} checkpoints (B/branch): live {:.1} alloc_model {:.1} allocator {:.1} \ + proc_heap {:.1}", + xs.len(), + lsq(&xs, &ys_live), + lsq(&xs, &ys_model), + lsq(&xs, &ys_alloc), + lsq(&xs, &ys_heap) + ); + println!("elapsed {:.1}s", t0.elapsed().as_secs_f64()); + if all_ok { + println!("rc=0 arm={arm} (P2, P6, P9 held at every checkpoint)"); + } else { + println!("rc=1 arm={arm} (a checkpoint failed P2/P6/P9 — see the ⛔ lines; slopes above are NOT usable)"); + std::process::exit(1); + } +} diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 379ddfdb..ba4404a7 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -3663,6 +3663,12 @@ impl AgentRuntime { self.attested.lock().unwrap().len() } + /// **Observing only (D192):** lengths and capacities inside the resident attested history. + /// See [`AttestedHistory::footprint`]; O(branches), for harnesses, not for request paths. + pub fn attested_footprint(&self) -> crate::branch::attest::AttestFootprint { + self.attested.lock().unwrap().footprint() + } + /// Record a fork. The child's `prev` is the **parent's** head, which is what makes a /// verification walk of a child continue into the ancestry it forked from. /// diff --git a/src/branch/attest.rs b/src/branch/attest.rs index 04f7d0a8..23f49867 100644 --- a/src/branch/attest.rs +++ b/src/branch/attest.rs @@ -743,6 +743,33 @@ pub struct AttestedHistory { levels: Vec>, } +/// Lengths and capacities of every collection inside an [`AttestedHistory`] — see +/// [`AttestedHistory::footprint`]. Element counts, never bytes. +/// +/// `*_table_cap` is `HashMap::capacity()`, the number of items the table holds before it must +/// grow — NOT its bucket count. Turning it into bytes needs the hash table's layout, which is a +/// model the D192 harness states and checks against an allocator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AttestFootprint { + pub entries_len: usize, + pub entries_cap: usize, + pub by_branch_keys: usize, + pub by_branch_table_cap: usize, + /// Sum over branches of `by_branch[b].len()`. + pub by_branch_idx_len: usize, + /// Sum over branches of `by_branch[b].capacity()`. + pub by_branch_idx_cap: usize, + pub heads_keys: usize, + pub heads_table_cap: usize, + /// `levels.len()`: Merkle levels, leaves included. + pub levels: usize, + pub levels_outer_cap: usize, + /// Sum over levels of `levels[k].len()`. + pub level_nodes_len: usize, + /// Sum over levels of `levels[k].capacity()`. + pub level_nodes_cap: usize, +} + impl Default for AttestedHistory { fn default() -> Self { Self::new() @@ -771,6 +798,33 @@ impl AttestedHistory { &self.entries } + /// **Observing only (D192):** the lengths and capacities that decide this structure's heap + /// footprint. It reads, it never changes anything, and nothing on a production path calls it. + /// + /// Raw counts rather than a byte total, on purpose: the byte arithmetic (element sizes, the + /// hash table's control bytes) is a MODEL, and a model belongs where it can be checked against + /// an allocator — `examples/d192_attest_footprint.rs` does that — not baked into an accessor + /// whose output would then look like a measurement. + /// + /// O(branches), because it sums every per-branch index `Vec`. Fine for a harness; do not put + /// it on a request path. + pub fn footprint(&self) -> AttestFootprint { + AttestFootprint { + entries_len: self.entries.len(), + entries_cap: self.entries.capacity(), + by_branch_keys: self.by_branch.len(), + by_branch_table_cap: self.by_branch.capacity(), + by_branch_idx_len: self.by_branch.values().map(Vec::len).sum(), + by_branch_idx_cap: self.by_branch.values().map(Vec::capacity).sum(), + heads_keys: self.heads.len(), + heads_table_cap: self.heads.capacity(), + levels: self.levels.len(), + levels_outer_cap: self.levels.capacity(), + level_nodes_len: self.levels.iter().map(Vec::len).sum(), + level_nodes_cap: self.levels.iter().map(Vec::capacity).sum(), + } + } + /// The latest attestation for a branch, if it has any history. /// /// Takes a whole [`BranchId`]: a reaped slot and its recycled successor are different From a89b130fb836624cc18bdecc99d4830a006bc75f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:56:19 -0400 Subject: [PATCH 078/315] D192: pin AttestedHistory::footprint against hand-worked lifecycle counts --- src/branch/attest.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/branch/attest.rs b/src/branch/attest.rs index 23f49867..8c2ec56c 100644 --- a/src/branch/attest.rs +++ b/src/branch/attest.rs @@ -1956,4 +1956,48 @@ mod tests { bad[84] = 200; assert!(HistoryEntry::from_canonical_bytes(&bad).is_none(), "accepted an unknown op code"); } + + /// D192: `footprint` counts what the production lifecycle leaves behind — fork, merge into + /// trunk, reap — and a reap ADDS to every count rather than removing anything. + /// + /// Expected values are worked by hand from the structure, not read back from the accessor: + /// after one lifecycle the log has 3 entries on 2 keys (the worker and trunk) and a Merkle tree + /// over 3 leaves (levels of 3, 2, 1 nodes); after two, 6 entries on 3 keys and levels of + /// 6, 3, 2, 1. + #[test] + fn footprint_counts_the_lifecycle_and_a_reap_only_adds() { + fn lifecycle(h: &mut AttestedHistory, worker: BranchId, n: u64) { + h.append_fork(worker, BranchId::TRUNK, Epoch(n), cid(n)); + h.append(BranchId::TRUNK, Epoch(n + 1), BranchOp::Merge, cid(n + 1)); + h.append(worker, Epoch(n + 2), BranchOp::Reap, cid(n + 2)); + } + let mut h = AttestedHistory::new(); + assert_eq!(h.footprint(), AttestFootprint::default(), "a new log is not empty"); + + lifecycle(&mut h, bid(1, 0), 10); + let one = h.footprint(); + assert_eq!( + (one.entries_len, one.by_branch_keys, one.by_branch_idx_len, one.heads_keys), + (3, 2, 3, 2) + ); + assert_eq!((one.levels, one.level_nodes_len), (3, 3 + 2 + 1)); + + lifecycle(&mut h, bid(2, 0), 20); + let two = h.footprint(); + assert_eq!( + (two.entries_len, two.by_branch_keys, two.by_branch_idx_len, two.heads_keys), + (6, 3, 6, 3) + ); + assert_eq!((two.levels, two.level_nodes_len), (4, 6 + 3 + 2 + 1)); + + // Every capacity covers its length — otherwise the accessor is reading the wrong field. + for f in [one, two] { + assert!(f.entries_cap >= f.entries_len); + assert!(f.by_branch_idx_cap >= f.by_branch_idx_len); + assert!(f.by_branch_table_cap >= f.by_branch_keys); + assert!(f.heads_table_cap >= f.heads_keys); + assert!(f.levels_outer_cap >= f.levels); + assert!(f.level_nodes_cap >= f.level_nodes_len); + } + } } From 58a458c05b8e1d74344c6a02924ce0a65ce0cf3b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:03:19 -0400 Subject: [PATCH 079/315] D192: pool agent identities under the page provenance cap; nine checkpoints; keep smoke raw --- bench/d192_attest_footprint_smoke.txt | 21 +++++++++++++++++++++ examples/d192_attest_footprint.rs | 14 ++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 bench/d192_attest_footprint_smoke.txt diff --git a/bench/d192_attest_footprint_smoke.txt b/bench/d192_attest_footprint_smoke.txt new file mode 100644 index 00000000..f352051b --- /dev/null +++ b/bench/d192_attest_footprint_smoke.txt @@ -0,0 +1,21 @@ +# D192 smoke run, harness d0d7dbc (distinct identity per lifecycle), lock held 2026-09-23T22:01:47Z. Raw, unedited. +lock acquired 2026-09-23T22:01:47Z + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d192-attest-size) + Finished `release` profile [optimized] target(s) in 10.18s +build rc=0 +D192 attest footprint — arm=merge checkpoints=[100, 200, 400] +size_of: HistoryEntry=96 usize=8 node=32 Vec<[u8;32]>=24 (BranchId,Vec)=40 (BranchId,Attestation)=48 GROUP(model)=8 +control: attested_len before any fork = 0; empty-log replay built=0 returned=0 footprint=AttestFootprint { entries_len: 0, entries_cap: 0, by_branch_keys: 0, by_branch_table_cap: 0, by_branch_idx_len: 0, by_branch_idx_cap: 0, heads_keys: 0, heads_table_cap: 0, levels: 0, levels_outer_cap: 0, level_nodes_len: 0, level_nodes_cap: 0 } + + N att_len keys live_model alloc_model allocator resid returned proc_heap alloc/N secs + 100 300 101 59586 98192 98192 0 98192 368796 981.9 5.5 + footprint AttestFootprint { entries_len: 300, entries_cap: 512, by_branch_keys: 101, by_branch_table_cap: 112, by_branch_idx_len: 300, by_branch_idx_cap: 528, heads_keys: 101, heads_table_cap: 112, levels: 10, levels_outer_cap: 16, level_nodes_len: 603, level_nodes_cap: 1028 } + 200 600 201 118986 195856 195856 0 195856 729380 979.3 10.8 + footprint AttestFootprint { entries_len: 600, entries_cap: 1024, by_branch_keys: 201, by_branch_table_cap: 224, by_branch_idx_len: 600, by_branch_idx_cap: 1056, heads_keys: 201, heads_table_cap: 224, levels: 11, levels_outer_cap: 16, level_nodes_len: 1203, level_nodes_cap: 2052 } + +thread 'main' (315390194) panicked at examples/d192_attest_footprint.rs:235:57: +merge failed: Provenance("page provenance dictionary full (255 entries); refusing to widen the per-version slot") +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace +smoke rc=101 + +[exited with code 0] diff --git a/examples/d192_attest_footprint.rs b/examples/d192_attest_footprint.rs index 83336770..8efe1605 100644 --- a/examples/d192_attest_footprint.rs +++ b/examples/d192_attest_footprint.rs @@ -161,6 +161,9 @@ fn replay(rt: &AgentRuntime) -> (usize, usize, AttestFootprint) { // ---- the runtime, built as run_cli builds it ------------------------------------------------ +/// Distinct agent identities cycled through. Below `MAX_PAGE_DICT_ENTRIES` (255) with room to spare. +const IDENTITY_POOL: usize = 64; + struct Db { catalog: Catalog, bp: Arc, @@ -224,7 +227,14 @@ impl Db { /// One branch lifecycle for `arm`. Returns the number of attested entries it must add. fn lifecycle(&mut self, arm: &str, i: usize) -> usize { let mut s = Session::with_runtime(self.runtime.clone()); - self.ok(&format!("BEGIN AGENT SESSION AS 'agent-{i}' RUN 'r_{i}';"), &mut s); + // Identities are POOLED. With a distinct (agent, run) per lifecycle, every merge stamps a + // new run into the one page holding row 1, and that page's provenance dictionary refuses + // its 256th run (`MAX_PAGE_DICT_ENTRIES`, src/provenance/store.rs) — the first run of this + // harness died there at ~255 merges. Interning a known run is a lookup, and nothing in + // `AttestedHistory` is sized by the identity: every entry is fixed-width and every fork + // still mints its own `BranchId`. + let who = i % IDENTITY_POOL; + self.ok(&format!("BEGIN AGENT SESSION AS 'agent-{who}' RUN 'r_{who}';"), &mut s); let branch = s.agent.as_ref().expect("BEGIN AGENT SESSION opened no branch").branch; match arm { "merge" => { @@ -267,7 +277,7 @@ fn main() { } let checkpoints: Vec = args .next() - .unwrap_or_else(|| "1000,1400,2000,2800,4000,5600,8000,11200,16000,22400,32000".into()) + .unwrap_or_else(|| "1000,1400,2000,2800,4000,5600,8000,11200,16000".into()) .split(',') .map(|s| s.trim().parse().expect("checkpoint must be an integer")) .collect(); From 74cff645db145a8cf97aa3300ee67ab6f7bedec1 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:04:58 -0400 Subject: [PATCH 080/315] delete-insert-lookup: failing-first tests for the reused-key old-snapshot gap (UNBUILT, not run) Pins lane_d194_fork_snapshot.md section 6.1 on main at 9aa6968: after DELETE k; INSERT k, a snapshot older than the reuse finds k by scan and not by key. Six tests are expected RED at this commit (primary, same-txn, write-by-key, secondary, relocated, index backfill), three GREEN guards (re-stamp resurrection, relocation feed, D194 agent tripwire), and two decoder unit tests RED. Pre-registered expectations: FAN-QUEUE #10. No cargo was run (quiet mode). --- src/replication/logical.rs | 80 +++++ tests/reused_key_old_snapshot.rs | 554 +++++++++++++++++++++++++++++++ 2 files changed, 634 insertions(+) create mode 100644 tests/reused_key_old_snapshot.rs diff --git a/src/replication/logical.rs b/src/replication/logical.rs index 59d60d6f..8ba8975f 100644 --- a/src/replication/logical.rs +++ b/src/replication/logical.rs @@ -1547,6 +1547,86 @@ mod tests { assert_eq!(out2.events[0].op.name(), "UPDATE", "a live update was relabelled"); } + /// **A `HeapUpdate` whose OLD image is dead and whose NEW image is live is an INSERT.** + /// + /// That is the record a reused primary key writes once the new row goes into the dead + /// version's slot, which is how an older snapshot's index lookup reaches the old version (the + /// reused-key gap, `tests/reused_key_old_snapshot.rs`). The consumer already received the + /// DELETE of the old row. Reported as an UPDATE, the feed tells it to modify a row it has + /// already deleted. `integration_cdc_key_reuse` pins the same thing end to end. + #[test] + fn a_heap_update_that_revives_a_dead_version_is_an_insert() { + let (_d, w) = wal("revive"); + w.append(1, 0, &RecKind::Begin).unwrap(); + w.append( + 1, + 0, + &RecKind::HeapUpdate { + dir_root: 7, + page_id: 1, + slot: 0, + old: dead_tuple_bytes(3, 30), + new: tuple_bytes(3, Some(31)), + }, + ) + .unwrap(); + w.append(1, 0, &RecKind::Commit).unwrap(); + + let out = decode_all(&decoder(), &w); + assert_eq!(out.events.len(), 1, "one reused key must be one event: {:?}", out.events); + assert_eq!( + out.events[0].op, + ChangeOp::Insert { new: vec![Value::Integer(3), Value::Integer(31)] }, + "a dead version brought back under its key was reported as {} rather than an insert", + out.events[0].op.name() + ); + } + + /// **Relocating a dead version is bookkeeping, not a second DELETE.** + /// + /// When a reused key's new row does not fit in the dead version's slot, + /// `HeapFileManager::update` relocates it and logs a `HeapDelete` of the slot's current bytes, + /// then a `HeapInsert` of the new row. The current bytes are the dead version, which the + /// consumer already saw deleted. + /// + /// The second half is the anti-vacuity: a `HeapDelete` of a LIVE image (an ordinary UPDATE + /// that relocated) must still be emitted. It asserts only that it is emitted and not counted + /// as bookkeeping. It does not pin the kind it is emitted as. + #[test] + fn relocating_a_dead_version_is_bookkeeping_not_a_second_delete() { + let (_d, w) = wal("relocate_dead"); + w.append(1, 0, &RecKind::Begin).unwrap(); + w.append(1, 0, &RecKind::HeapDelete { dir_root: 7, page_id: 1, slot: 0, old: dead_tuple_bytes(3, 30) }) + .unwrap(); + w.append( + 1, + 0, + &RecKind::HeapInsert { dir_root: 7, page_id: 2, slot: 0, tuple: tuple_bytes(3, Some(31)) }, + ) + .unwrap(); + w.append(1, 0, &RecKind::Commit).unwrap(); + + let out = decode_all(&decoder(), &w); + assert_eq!( + out.events.len(), + 1, + "a relocated dead version leaked into the feed beside the insert: {:?}", + out.events + ); + assert_eq!(out.events[0].op, ChangeOp::Insert { new: vec![Value::Integer(3), Value::Integer(31)] }); + assert_eq!(out.internal, 1, "the relocated dead version was not counted as bookkeeping"); + assert!(out.is_complete(), "bookkeeping made the decode look incomplete: {out:?}"); + + let (_d2, w2) = wal("relocate_live"); + w2.append(1, 0, &RecKind::Begin).unwrap(); + w2.append(1, 0, &RecKind::HeapDelete { dir_root: 7, page_id: 1, slot: 0, old: tuple_bytes(3, Some(30)) }) + .unwrap(); + w2.append(1, 0, &RecKind::Commit).unwrap(); + let out2 = decode_all(&decoder(), &w2); + assert_eq!(out2.internal, 0, "a HeapDelete of a LIVE image was swallowed as bookkeeping"); + assert_eq!(out2.events.len(), 1, "a HeapDelete of a LIVE image was not emitted: {out2:?}"); + } + /// Records against a table's time-travel heap are MVCC bookkeeping: not emitted, and not an /// error either. Counting them as unresolved would report normal operation as data loss. #[test] diff --git a/tests/reused_key_old_snapshot.rs b/tests/reused_key_old_snapshot.rs new file mode 100644 index 00000000..bdb925ee --- /dev/null +++ b/tests/reused_key_old_snapshot.rs @@ -0,0 +1,554 @@ +//! A reused primary key hides its old version from every lookup that goes through the index. +//! +//! # The gap +//! +//! E63 lets `DELETE k; INSERT k` reuse a key. It writes the new row into a NEW heap slot and +//! repoints the primary-index entry at it (`execution::insert`, the `upsert` after `heap.insert`). +//! The new version's `prev` is zero. The dead version stays in its old slot, reachable from nothing +//! but a sequential scan. +//! +//! So a reader whose snapshot predates the reuse gets two answers for one row: +//! +//! - **scan:** `SeqScan` walks every slot, finds the dead version's slot, and `resolve_visibility` +//! says it is still live for this reader (its deleter had not committed when the reader began). +//! The row is there. +//! - **by key:** `IndexScan` (and `SecondaryIndexScan`, which resolves through the primary index) +//! lands on the NEW slot. Its `begin_ts` is not committed for this reader, and `prev` is zero, so +//! `resolve_visibility` returns `None`. The row is not there. +//! +//! Found by the D194 lane (`frontier/lane_d194_fork_snapshot.md` §6.1, probes 2 and 3), which makes +//! agent branches read as of their fork and so widens it. It predates D194: a plain `BEGIN` shows +//! it with no agent anywhere. E63's own comment said nothing needs the index to reach an old +//! version because there is no temporal `AS OF`. An explicit transaction's snapshot is exactly +//! that, and it has existed the whole time. +//! +//! # What each test pins +//! +//! RED at the base of this lane (`9aa6968`), the gap itself: +//! - `an_old_snapshot_finds_a_reused_key_by_key_as_its_scan_does` +//! - `an_older_reader_still_finds_a_row_deleted_and_reinserted_in_one_transaction` +//! - `a_write_by_key_through_an_old_snapshot_conflicts_as_a_write_by_scan_does` +//! - `a_secondary_lookup_through_an_old_snapshot_finds_the_reused_row` +//! - `an_old_snapshot_finds_a_reused_key_whose_new_row_had_to_move` +//! - `building_an_index_after_a_same_value_reuse_does_not_return_the_row_twice`, a neighbour the +//! same two-slot heap causes: `create_index`'s backfill posts one entry per SLOT. +//! +//! GREEN at base, and each one guards a specific wrong fix: +//! - `a_reader_that_saw_the_delete_does_not_see_the_old_row_come_back`: a fix that re-stamps the +//! archived dead version's `end_ts` with the inserter's id (copying UPDATE's archive line +//! verbatim) resurrects the row for this reader. +//! - `a_reuse_that_relocates_emits_one_delete_not_two`: a fix whose new row does not fit the dead +//! row's page relocates it, and `HeapFileManager::update` logs that as a `HeapDelete` of the dead +//! image. The change feed must not report it as a second DELETE. +//! - `an_agent_branch_point_lookup_agrees_with_its_own_scan`: at base an agent branch reads the +//! LATEST snapshot, so this cannot fail here. It is the tripwire for D194: D194 without this fix +//! turns it red (probe 2 measured the split on the D194 lane tree). +//! +//! Every "by key" read below first proves, with `EXPLAIN`, that the plan really is an index scan. +//! Without that, an optimizer that picked a sequential scan would turn every red test green for a +//! reason that has nothing to do with the fix. + +use std::fs::OpenOptions; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::replication::logical::{ChangeOp, LogicalDecoder}; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::heap_file_manager::RecordId; +use ferrodb::storage::index::BPlusTreeManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + wal: Arc, + runtime: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("reuse.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("reuse.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal.clone()); + Db { catalog, bp, txn, wal, runtime: Arc::new(AgentRuntime::new()), _dir: dir } + } + + fn session(&self) -> Session { + Session::with_runtime(self.runtime.clone()) + } + + fn exec(&mut self, sql: &str, s: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "parse error in `{sql}`: {:?}", p.errors); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + } + + fn ok(&mut self, sql: &str, s: &mut Session) -> Outcome { + self.exec(sql, s).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + + fn rows(&mut self, sql: &str, s: &mut Session) -> Vec> { + match self.ok(sql, s) { + Outcome::Rows(r) => r, + _ => panic!("`{sql}` did not return rows"), + } + } + + /// Fails unless the plan for `select` reads the index named by `needle`. + fn assert_plan(&mut self, select: &str, needle: &str, s: &mut Session) { + let sql = format!("EXPLAIN {select}"); + match self.ok(&sql, s) { + Outcome::Explain(plan) => assert!( + plan.contains(needle), + "premise failed: `{select}` does not plan as `{needle}`, so this test would not be \ + measuring the path it names:\n{plan}" + ), + _ => panic!("`{sql}` did not explain"), + } + } + + /// Where the primary index says `key` lives, read straight from the tree. + fn primary_rid(&self, table: &str, key: i32) -> Option { + let entry = self.catalog.get_table(table).expect("table"); + BPlusTreeManager::::open(entry.primary_index_root, self.bp.clone()) + .search(&Value::Integer(key)) + .expect("primary search") + } +} + +fn row(id: i32, qty: i32) -> Vec { + vec![Value::Integer(id), Value::Integer(qty)] +} + +fn sorted(mut rows: Vec>) -> Vec> { + rows.sort(); + rows +} + +/// The D194 lane's probe fixture, unchanged: two rows, and row 3 is the one that gets reused. +fn seed(db: &mut Db, main: &mut Session) { + db.ok("CREATE TABLE inventory (id INTEGER NOT NULL, qty INTEGER);", main); + db.ok("INSERT INTO inventory VALUES (1, 20);", main); + db.ok("INSERT INTO inventory VALUES (3, 7);", main); +} + +const BY_KEY: &str = "SELECT id, qty FROM inventory WHERE id = 3;"; +const ALL: &str = "SELECT id, qty FROM inventory;"; +const PRIMARY: &str = "Index scan on inventory (col 0"; + +/// **The gap, in the lane's own shape (probe 3).** +#[test] +fn an_old_snapshot_finds_a_reused_key_by_key_as_its_scan_does() { + let mut db = Db::new(); + let mut main = db.session(); + seed(&mut db, &mut main); + db.assert_plan(BY_KEY, PRIMARY, &mut main); + + // The snapshot is taken at BEGIN (`TxnManager::begin_locked` stores it in the transaction's + // table entry), so nothing needs to be read to pin it. + let mut old = db.session(); + db.ok("BEGIN;", &mut old); + db.ok("DELETE FROM inventory WHERE id = 3;", &mut main); + db.ok("INSERT INTO inventory VALUES (3, 8);", &mut main); + + // Premise, and the guard against a fix that leaves the dead version in the heap AND chains to + // it: the scan through the old snapshot has (3, 7), exactly once. + assert_eq!( + sorted(db.rows(ALL, &mut old)), + vec![row(1, 20), row(3, 7)], + "the old snapshot's scan is not (1, 20), (3, 7) exactly once each" + ); + assert_eq!( + db.rows(BY_KEY, &mut old), + vec![row(3, 7)], + "the old snapshot's scan shows (3, 7) but its lookup by key does not: the index reaches \ + only the new version, which this snapshot cannot see, and nothing links it to the old one" + ); + db.ok("COMMIT;", &mut old); + + // Anti-vacuity: a reader that begins after the reuse sees (3, 8) both ways. A fix that made the + // lookup return the OLDEST version would pass everything above and fail here. + let mut fresh = db.session(); + assert_eq!(db.rows(BY_KEY, &mut fresh), vec![row(3, 8)], "a new reader does not see the new row by key"); + assert_eq!(sorted(db.rows(ALL, &mut fresh)), vec![row(1, 20), row(3, 8)], "a new reader's scan is wrong"); +} + +/// **The same gap when one transaction does both halves.** +/// +/// The dead version's `end_ts` is the writer's OWN id here, which `is_commited_for_me` accepts +/// (`ts == self.txn_id`), so E63 reuses the key inside the transaction. An older reader must still +/// see (3, 7) before the writer commits and after it. +#[test] +fn an_older_reader_still_finds_a_row_deleted_and_reinserted_in_one_transaction() { + let mut db = Db::new(); + let mut main = db.session(); + seed(&mut db, &mut main); + db.assert_plan(BY_KEY, PRIMARY, &mut main); + + let mut old = db.session(); + db.ok("BEGIN;", &mut old); + + let mut writer = db.session(); + db.ok("BEGIN;", &mut writer); + db.ok("DELETE FROM inventory WHERE id = 3;", &mut writer); + db.ok("INSERT INTO inventory VALUES (3, 8);", &mut writer); + // Anti-vacuity: the writer sees its own new row by key. + assert_eq!(db.rows(BY_KEY, &mut writer), vec![row(3, 8)], "the writer cannot see its own reinsert"); + + assert_eq!( + db.rows(BY_KEY, &mut old), + vec![row(3, 7)], + "while the writer is still open, an older reader lost (3, 7) by key" + ); + db.ok("COMMIT;", &mut writer); + assert_eq!( + db.rows(BY_KEY, &mut old), + vec![row(3, 7)], + "after the writer committed, an older reader lost (3, 7) by key" + ); + assert_eq!(sorted(db.rows(ALL, &mut old)), vec![row(1, 20), row(3, 7)], "the older reader's scan is wrong"); + db.ok("COMMIT;", &mut old); +} + +/// **Guard: a reader that already saw the DELETE must not see the old row come back.** +/// +/// GREEN at base. It exists for the fix. UPDATE archives the version it supersedes after stamping +/// that version's `end_ts` with its own id, because the version was live. A reused key's dead +/// version is already ended by its deleter. Re-stamping it with the INSERTER's id makes it live +/// again for exactly this reader: the deleter has committed for it, and the inserter has not. +#[test] +fn a_reader_that_saw_the_delete_does_not_see_the_old_row_come_back() { + let mut db = Db::new(); + let mut main = db.session(); + seed(&mut db, &mut main); + db.assert_plan(BY_KEY, PRIMARY, &mut main); + + db.ok("DELETE FROM inventory WHERE id = 3;", &mut main); + let mut between = db.session(); + db.ok("BEGIN;", &mut between); + db.ok("INSERT INTO inventory VALUES (3, 8);", &mut main); + + assert_eq!( + db.rows(BY_KEY, &mut between), + Vec::>::new(), + "a reader whose snapshot includes the DELETE and not the reinsert found row 3 by key" + ); + assert_eq!( + sorted(db.rows(ALL, &mut between)), + vec![row(1, 20)], + "a reader whose snapshot includes the DELETE and not the reinsert found row 3 by scan" + ); + db.ok("COMMIT;", &mut between); +} + +/// **A write by key through an old snapshot is silently a no-op; by scan it is a conflict.** +/// +/// The same row, reached two ways by the same kind of statement. The sequential path finds the +/// dead version and `check_write_conflict` refuses it: its deleter is not committed for this +/// transaction. The index path finds nothing and reports `Affected(0)`, so the statement "succeeds" +/// having done nothing to a row its own scan shows. Postgres at REPEATABLE READ raises a +/// serialization failure here, which is what the sequential path already does. +#[test] +fn a_write_by_key_through_an_old_snapshot_conflicts_as_a_write_by_scan_does() { + let mut db = Db::new(); + let mut main = db.session(); + seed(&mut db, &mut main); + db.assert_plan(BY_KEY, PRIMARY, &mut main); + // `build_scan` plans an UPDATE's or DELETE's rows with the SELECT optimizer (D178), so the + // SELECT plan is the child plan of the write. + db.assert_plan("SELECT id, qty FROM inventory WHERE qty = 7;", "Sequential scan on inventory", &mut main); + + let mut by_scan = db.session(); + db.ok("BEGIN;", &mut by_scan); + let mut by_key = db.session(); + db.ok("BEGIN;", &mut by_key); + let mut delete_by_key = db.session(); + db.ok("BEGIN;", &mut delete_by_key); + + db.ok("DELETE FROM inventory WHERE id = 3;", &mut main); + db.ok("INSERT INTO inventory VALUES (3, 8);", &mut main); + + fn conflict(res: Result, what: &str) { + match res { + Err(e @ FerroError::Txn(_)) => { + assert!(e.to_string().contains("conflict"), "{what}: refused, but not as a write conflict: {e}") + } + Err(e) => panic!("{what}: refused, but not as a transaction conflict: {e}"), + Ok(Outcome::Affected(n)) => panic!( + "{what}: succeeded with {n} row(s) affected against a row whose deleter is not \ + committed for this transaction" + ), + Ok(_) => panic!("{what}: succeeded with an unexpected outcome"), + } + } + + // Control: the sequential path conflicts today. + conflict(db.exec("UPDATE inventory SET qty = 70 WHERE qty = 7;", &mut by_scan), "UPDATE by scan"); + // THE DEFECT: the same row by key. + conflict(db.exec("UPDATE inventory SET qty = 70 WHERE id = 3;", &mut by_key), "UPDATE by key"); + conflict(db.exec("DELETE FROM inventory WHERE id = 3;", &mut delete_by_key), "DELETE by key"); +} + +/// Enough rows, and fresh statistics, that the optimizer takes the secondary index on `qty`. +/// +/// Arithmetic from `cost_model::cost`, stated so it can be checked rather than trusted: with 1000 +/// rows and 1000 distinct values, the index point lookup costs 2*4 + 1 + 4 + 4 = 17. The sequential +/// scan plus filter costs 8 pages + 10 + 10 = 28. At 2 rows the sequential scan wins, which is why +/// the lane's two-row fixture cannot reach this path. `assert_plan` checks the result either way. +fn seed_indexed(db: &mut Db, main: &mut Session) { + db.ok("CREATE TABLE inventory (id INTEGER NOT NULL, qty INTEGER);", main); + db.ok("CREATE INDEX iq ON inventory (qty);", main); + db.ok("BEGIN;", main); + for i in 1..=1000 { + db.ok(&format!("INSERT INTO inventory VALUES ({i}, {});", i * 10), main); + } + db.ok("COMMIT;", main); +} + +const SECONDARY: &str = "Index scan on inventory (col 1"; + +/// **The same gap through a secondary index.** `SecondaryIndexScan` turns each `(value, pk)` entry +/// into a heap read through `primary_index.search(&pk)`, so it lands on the same new slot. +#[test] +fn a_secondary_lookup_through_an_old_snapshot_finds_the_reused_row() { + let mut db = Db::new(); + let mut main = db.session(); + seed_indexed(&mut db, &mut main); + db.ok("ANALYZE inventory;", &mut main); + let by_value = "SELECT id, qty FROM inventory WHERE qty = 30;"; + db.assert_plan(by_value, SECONDARY, &mut main); + + let mut old = db.session(); + db.ok("BEGIN;", &mut old); + db.ok("DELETE FROM inventory WHERE id = 3;", &mut main); + db.ok("INSERT INTO inventory VALUES (3, 31);", &mut main); + + // Premise: the old snapshot's scan has (3, 30). + let scanned: Vec> = db + .rows(ALL, &mut old) + .into_iter() + .filter(|r| r[0] == Value::Integer(3)) + .collect(); + assert_eq!(scanned, vec![row(3, 30)], "the old snapshot's scan does not hold (3, 30) exactly once"); + + assert_eq!( + db.rows(by_value, &mut old), + vec![row(3, 30)], + "the old snapshot finds (3, 30) by scan but not through the secondary index" + ); + db.ok("COMMIT;", &mut old); + + // Anti-vacuity: the new value is found by a new reader. + let mut fresh = db.session(); + assert_eq!( + db.rows("SELECT id, qty FROM inventory WHERE qty = 31;", &mut fresh), + vec![row(3, 31)], + "a new reader does not find the reinserted row by its new value" + ); +} + +/// **Neighbour: an index built after a same-value reuse returns the row twice.** +/// +/// `Catalog::create_index` backfills one `(value, pk)` entry per heap SLOT and does not +/// de-duplicate. Its full-text twin's doc (`create_fulltext_index`, point 2) says it "gets away with +/// it", but that only holds when the reused row's value differs. With the same value, E63's two +/// slots post `(40, 4)` twice, and `SecondaryIndexScan` yields one row per entry. INFERRED from +/// source, never run: this is the red test that settles it. It goes away with the gap's fix, because +/// the heap then holds one slot per key. +#[test] +fn building_an_index_after_a_same_value_reuse_does_not_return_the_row_twice() { + let mut db = Db::new(); + let mut main = db.session(); + db.ok("CREATE TABLE inventory (id INTEGER NOT NULL, qty INTEGER);", &mut main); + db.ok("BEGIN;", &mut main); + for i in 1..=1000 { + db.ok(&format!("INSERT INTO inventory VALUES ({i}, {});", i * 10), &mut main); + } + db.ok("COMMIT;", &mut main); + db.ok("DELETE FROM inventory WHERE id = 4;", &mut main); + db.ok("INSERT INTO inventory VALUES (4, 40);", &mut main); + + db.ok("CREATE INDEX iq ON inventory (qty);", &mut main); + db.ok("ANALYZE inventory;", &mut main); + let by_value = "SELECT id, qty FROM inventory WHERE qty = 40;"; + db.assert_plan(by_value, SECONDARY, &mut main); + + assert_eq!( + db.rows(by_value, &mut main), + vec![row(4, 40)], + "the index built over a reused key returns its row once per heap slot" + ); +} + +/// A page with no room for the reused row: rows 1 and 2 fill it, and row 1 comes back larger. +/// +/// Arithmetic from `Tuple::serialize` and `heap_page`: an `(INTEGER, VARCHAR)` tuple is +/// 24 (version header) + 1 (null bitmap) + 3 (padding) + 4 + 2 (length) + len bytes. Row 1 is 35 +/// bytes and row 2 is 3934, plus two 4-byte slots, against 4073 usable. That leaves 96 free, and the +/// reused row 1 is 234. An in-place rewrite of row 1's slot therefore cannot fit and has to relocate. +/// The premise assertions check both halves of that. +fn packed_page(db: &mut Db, main: &mut Session) -> RecordId { + db.ok("CREATE TABLE notes (id INTEGER NOT NULL, note VARCHAR(4000));", main); + db.ok("INSERT INTO notes VALUES (1, 'a');", main); + db.ok(&format!("INSERT INTO notes VALUES (2, '{}');", "x".repeat(3900)), main); + let one = db.primary_rid("notes", 1).expect("row 1 is indexed"); + let two = db.primary_rid("notes", 2).expect("row 2 is indexed"); + assert_eq!(one.page_id, two.page_id, "premise failed: rows 1 and 2 are not on one page, so it is not full"); + one +} + +fn big_note() -> String { + "y".repeat(200) +} + +/// **The gap when the new row cannot take the dead row's place on its page.** +#[test] +fn an_old_snapshot_finds_a_reused_key_whose_new_row_had_to_move() { + let mut db = Db::new(); + let mut main = db.session(); + let before = packed_page(&mut db, &mut main); + db.assert_plan("SELECT id, note FROM notes WHERE id = 1;", "Index scan on notes (col 0", &mut main); + + let mut old = db.session(); + db.ok("BEGIN;", &mut old); + db.ok("DELETE FROM notes WHERE id = 1;", &mut main); + db.ok(&format!("INSERT INTO notes VALUES (1, '{}');", big_note()), &mut main); + let after = db.primary_rid("notes", 1).expect("row 1 is indexed"); + assert_ne!(after, before, "premise failed: the reused row did not move, so this is not the relocation case"); + + assert_eq!( + db.rows("SELECT id, note FROM notes WHERE id = 1;", &mut old), + vec![vec![Value::Integer(1), Value::Varchar("a".into())]], + "the old snapshot lost (1, 'a') by key after the reuse moved the row to another page" + ); + db.ok("COMMIT;", &mut old); + + let mut fresh = db.session(); + assert_eq!( + db.rows("SELECT id, note FROM notes WHERE id = 1;", &mut fresh), + vec![vec![Value::Integer(1), Value::Varchar(big_note())]], + "a new reader does not find the relocated reinsert by key" + ); +} + +/// **Guard: a reuse that relocates is still one DELETE and one INSERT in the change feed.** +/// +/// GREEN at base, where E63 never relocates anything: it calls `heap.insert`. It guards the fix. +/// A reuse written into the dead row's slot that does not fit goes through `HeapFileManager::update`'s +/// relocation arm, which logs `HeapDelete` of the slot's current bytes, and those bytes are the DEAD +/// version. Decoded like any other `HeapDelete`, that is a second DELETE of a row the consumer +/// already deleted. +#[test] +fn a_reuse_that_relocates_emits_one_delete_not_two() { + let mut db = Db::new(); + let mut main = db.session(); + let before = packed_page(&mut db, &mut main); + db.ok("DELETE FROM notes WHERE id = 1;", &mut main); + db.ok(&format!("INSERT INTO notes VALUES (1, '{}');", big_note()), &mut main); + assert_ne!( + db.primary_rid("notes", 1).expect("row 1 is indexed"), + before, + "premise failed: the reused row did not move, so this is not the relocation case" + ); + + db.wal.flush().unwrap(); + let decoder = LogicalDecoder::new(&db.catalog); + assert!(decoder.known_tables() > 0, "the decoder resolved no tables, so every change would decode as unresolved"); + let out = decoder + .decode(&db.wal, db.wal.base_lsn.load(Ordering::SeqCst), db.wal.next_lsn.load(Ordering::SeqCst)) + .expect("decode"); + assert!(out.is_complete(), "the feed is incomplete, so no sequence claim below is sound: {out:?}"); + + let key_one: Vec<&'static str> = out + .events + .iter() + .filter(|e| { + let image = match &e.op { + ChangeOp::Insert { new } | ChangeOp::Update { new, .. } => Some(new), + ChangeOp::Delete { old } => Some(old), + _ => None, + }; + matches!(image.and_then(|r| r.first()), Some(Value::Integer(1))) + }) + .map(|e| e.op.name()) + .collect(); + assert_eq!( + key_one, + vec!["INSERT", "DELETE", "INSERT"], + "key 1's history is not insert, delete, insert: a relocated dead version leaked into the feed" + ); + let deletes = out.events.iter().filter(|e| matches!(e.op, ChangeOp::Delete { .. })).count(); + assert_eq!(deletes, 1, "one SQL DELETE ran and the feed carries {deletes}"); + match out.events.iter().rev().find(|e| matches!(e.op, ChangeOp::Insert { .. })).map(|e| &e.op) { + Some(ChangeOp::Insert { new }) => assert_eq!( + new, + &vec![Value::Integer(1), Value::Varchar(big_note())], + "the last INSERT does not carry the reused row's new values" + ), + other => panic!("no INSERT at the end of the feed: {other:?}"), + } +} + +/// **Tripwire for D194: an agent branch's lookup by key agrees with its own scan.** +/// +/// GREEN at base, and it cannot be otherwise here: at `9aa6968` an agent branch reads shared tables +/// through the LATEST snapshot (`scan_table_where`: `read_snapshot_cached()`), so both paths see +/// (3, 8). D194 pins the branch's reads at its fork, and on the D194 lane tree the scan showed +/// (3, 7) while the lookup returned nothing (probe 2, `bench/d194_fork_snapshot/probe_run1.txt`). +/// So this is red exactly when D194 lands without the fix, and green with neither or both. +/// +/// It asserts agreement, not a value, on purpose. Pinning (3, 8) would pin read-latest, which D194 +/// removes, and pinning (3, 7) would fail here for a reason unrelated to this gap. The scan's row is +/// constrained to the two legal answers so that "both empty" cannot pass. +#[test] +fn an_agent_branch_point_lookup_agrees_with_its_own_scan() { + let mut db = Db::new(); + let mut main = db.session(); + seed(&mut db, &mut main); + db.assert_plan(BY_KEY, PRIMARY, &mut main); + + let mut agent = db.session(); + db.ok("BEGIN AGENT SESSION AS 'a' RUN 'r1';", &mut agent); + db.ok("DELETE FROM inventory WHERE id = 3;", &mut main); + db.ok("INSERT INTO inventory VALUES (3, 8);", &mut main); + + let scanned: Vec> = db + .rows(ALL, &mut agent) + .into_iter() + .filter(|r| r[0] == Value::Integer(3)) + .collect(); + assert_eq!(scanned.len(), 1, "the branch's scan does not hold row 3 exactly once: {scanned:?}"); + assert!( + scanned[0] == row(3, 7) || scanned[0] == row(3, 8), + "the branch's scan holds a row 3 that no snapshot ever had: {scanned:?}" + ); + assert_eq!( + db.rows(BY_KEY, &mut agent), + scanned, + "the branch's lookup by key disagrees with its own scan" + ); +} From 42967239643edb26d3be63387f56b5f1e09c1247 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:08:05 -0400 Subject: [PATCH 081/315] delete-insert-lookup: write a reused key into its dead version's slot, chained to it (UNBUILT) DELETE k; INSERT k now archives the dead version verbatim in the time-travel heap and writes the new version into the dead version's slot with prev pointing at the archive (UPDATE's mechanism, InnoDB's insert-by-modify). The primary-index entry stays put unless the row relocates, so a lookup through an older snapshot walks prev to the old version, as its scan already did. The change feed keeps the INSERT: the decoder reads a HeapUpdate with a dead old image and a live new image as an Insert, and a HeapDelete of a dead image (a relocated reuse) as bookkeeping. UNBUILT: no cargo was run (quiet mode). Expected to turn one existing premise assertion red: integration_fulltext_retrieval::a_rebuild_over_a_re_used_primary_key_does_not_double_the_row asserts the heap holds 2 slots for a reused key and will read 1. Pre-registered in FAN-QUEUE #10. --- src/catalog/catalog.rs | 3 ++ src/execution/insert.rs | 105 ++++++++++++++++++++++++++++--------- src/planner/plan.rs | 8 ++- src/replication/logical.rs | 26 ++++++++- src/wal/recovery.rs | 5 ++ 5 files changed, 120 insertions(+), 27 deletions(-) diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index 2ec9182f..8b21740e 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -275,6 +275,9 @@ impl Catalog { /// `INSERT INTO t VALUES (4, ...)` leaves **two** slots with pk 4 in the same heap. Building /// an index over that emits every token they share twice, `insert_entry` appends rather than /// overwrites, and the search then returns that row twice. `post_tokens` probes first. + /// ⚠ Since the reused-key fix (`execution::insert`), SQL writes the new version into the + /// dead one's slot, so a heap written after it holds one slot per key. A heap written + /// before it still holds two, and the probe is what keeps this correct over one. /// 3. **It lands in `fulltext_indexes`**, not `indexes` — see `FullTextIndexInfo`. pub fn create_fulltext_index(&mut self, table: &str, column: &str) -> Result<(), FerroError> { let (schema, first_dir_page_id, col_index) = { diff --git a/src/execution/insert.rs b/src/execution/insert.rs index 0982d73b..6b9ad0fb 100644 --- a/src/execution/insert.rs +++ b/src/execution/insert.rs @@ -37,9 +37,40 @@ //! duplicate. Measured with the removal commented out: `INSERT (1,10); DELETE id=1; INSERT (1,99); //! INSERT (1,777)` left both `1 | 99` and `1 | 777` live. //! -//! Removing the entry orphans nothing. The deleted version stays in the heap where a sequential scan -//! still finds it and `ReadView::visible` still filters it, and nothing needs the index to reach an -//! old version: there is no temporal `AS OF ` in this SQL surface, only `AS OF BRANCH`. +//! ⛔ **SUPERSEDED — this paragraph said:** *"Removing the entry orphans nothing. The deleted version +//! stays in the heap where a sequential scan still finds it and `ReadView::visible` still filters +//! it, and nothing needs the index to reach an old version: there is no temporal `AS OF +//! ` in this SQL surface, only `AS OF BRANCH`."* The last clause was false. An explicit +//! transaction's snapshot IS a temporal read. See the next section. +//! +//! # The reused key must keep its old version reachable from the index +//! +//! The E63 fix wrote the new row into a NEW slot, repointed the index at it, and gave it no `prev`. +//! A reader whose snapshot predates the reuse then disagreed with itself: its sequential scan found +//! the dead version's slot, still live for it, and its lookup by key landed on the new slot, which +//! it cannot see, with nowhere to go. `WHERE id = k` returned nothing while `SELECT *` returned +//! the row. `UPDATE`/`DELETE ... WHERE id = k` affected 0 rows where the same statement by scan +//! raised a write conflict. `SecondaryIndexScan` resolves through the primary index and lost the +//! row the same way. Found by the D194 lane (artie-research +//! `frontier/lane_d194_fork_snapshot.md` §6.1). Pinned by `tests/reused_key_old_snapshot.rs`. +//! +//! So the new version now goes INTO the dead version's slot, with `prev` pointing at a +//! time-travel copy of the dead version. That is UPDATE's mechanism, and the index entry stays +//! where it was. A lookup lands on the new head and walks `prev` to the old version exactly as it +//! does after an UPDATE. The heap holds one slot per key, so a scan cannot yield the old version +//! twice. +//! +//! One line differs from UPDATE, and it is load-bearing. UPDATE stamps the archived version's +//! `end_ts` with its own id, because that version was live. A reused key's dead version is already +//! ended by its DELETE, and it is archived **verbatim**. Re-stamping it with the inserter's id +//! would make it live again for any reader that saw the DELETE commit but not the INSERT. +//! `a_reader_that_saw_the_delete_does_not_see_the_old_row_come_back` fails if that is done. +//! +//! The WAL record changes, and the change feed must not. The table's record for a reuse is now a +//! `HeapUpdate` whose old image is dead and whose new image is live. When the new row does not fit +//! and relocates, it is a `HeapDelete` of the dead image followed by a `HeapInsert`. +//! `replication::logical` decodes the `HeapUpdate` as an INSERT and counts the dead `HeapDelete` as +//! bookkeeping. Both rules read the version headers, as the DELETE rule already does. //! //! The B+tree does not rebalance on delete (`handle_underflow` is unimplemented and never called), so //! this can leave a sparse leaf. Sparse is correct; the alternative was a key that could not be @@ -77,6 +108,9 @@ pub struct Insert { /// entry outlives the row: DELETE stamps `end_ts` on the version in place and leaves the entry /// pointing at it. So a deleted primary key could never be reused. pub view: std::sync::Arc, + /// Where a reused key's dead version is archived, so the new version's `prev` can reach it. + /// Opened and logged exactly as `Update::tt_heap` is. + pub tt_heap: HeapFileManager, } impl Modify for Insert { @@ -110,6 +144,7 @@ impl Modify for Insert { // in place and leaves the entry alone, so `search` finding a key does NOT mean the key is // taken. Asking the index alone made a deleted primary key unusable forever, and said so // with "use UPDATE to change the existing row" when there was no row to update. + let mut reused: Option<(RecordId, Tuple)> = None; if let Some(existing) = self.primary_index.search(&vals[0])? { let head = self.heap.read(existing)?; let h = head.version_header()?; @@ -127,31 +162,53 @@ impl Modify for Insert { self.schema.columns.first().map(|c| c.name.as_str()).unwrap_or("?") ))) } - // The key is free, but the stale entry must be REPLACED rather than shadowed: - // `insert_entry` appends, it does not overwrite, so leaving it would put two entries - // for one key in a unique index and `search` would return whichever binary search - // landed on. The deleted version itself stays in the heap, where a sequential scan - // still finds it and resolves it as invisible - nothing needs the index to reach it, - // because there is no temporal `AS OF` in the SQL surface, only `AS OF BRANCH`. - // - // ⛔ **D126 — the `delete` that used to sit here has MOVED INTO the `upsert` below.** - // It was `primary_index.delete(&vals[0])` here and `primary_index.insert(..)` after - // the heap write, with `delete` dropping the leaf write latch on return: the primary - // key was absent from the index for the whole of a heap insert, and `search` descends - // with no latch at all. A concurrent point lookup on that key got "no such row" for a - // row that exists. Same defect the branch catalog had, same fix: one `upsert`, one - // page write, no window. See `BPlusTreeManager::upsert`. + // The key is free. Its dead version is where the new one goes: into the same slot, + // with `prev` linking back to it, so the index entry that already points here reaches + // both (module doc, "The reused key must keep its old version reachable"). Writing a + // NEW slot instead, as this did until the reused-key fix, left the entry pointing at a + // version no older snapshot can see, with nothing behind it. + reused = Some((existing, head)); } - let tuple = Tuple::serialize(&vals, &self.schema, self.heap.txn_id)?; - let rid = self.heap.insert(tuple)?; + let mut tuple = Tuple::serialize(&vals, &self.schema, self.heap.txn_id)?; + let (rid, index_moved) = match reused { + Some((dead_rid, dead)) => { + // Archived VERBATIM: its `end_ts` is its deleter's and must stay so. UPDATE + // re-stamps the version it archives because that version was live; this one is + // not, and re-stamping it with this transaction's id would revive it for every + // reader that saw the DELETE commit and not this INSERT. + let tt_rid = self.tt_heap.insert(dead)?; + tuple.data[16..20].copy_from_slice(&tt_rid.page_id.to_be_bytes()); + tuple.data[20..22].copy_from_slice(&tt_rid.slot_num.to_be_bytes()); + // In place when the new row fits the dead row's page, which leaves the index + // untouched. Otherwise `update` relocates it and the entry has to follow, exactly + // as it does for a relocated UPDATE. + let rid = self.heap.update(dead_rid, tuple)?; + (rid, rid != dead_rid) + } + None => (self.heap.insert(tuple)?, true), + }; if let Some((prov, id)) = &self.author { prov.stamp(rid, *id)?; } - // `upsert`, not `insert`: this both writes a brand-new key and REPLACES the stale entry - // of a primary key freed by a committed DELETE (the branch above). `insert` cannot do the - // second -- it appends -- and delete-then-insert could, but only through a window in which - // the key is absent to every lockless reader. D126. - self.primary_index.upsert(vals[0].clone(), rid)?; + // `upsert`, not `insert`, and only when the row is somewhere the index does not already + // say. A brand-new key needs its entry written. A reused key whose new row relocated needs + // its entry REPLACED: `insert` cannot do that because it appends, and delete-then-insert + // could, but only through a window in which the key is absent to every lockless reader. + // + // ⛔ **D126 — the `delete` that used to sit in the reuse branch above MOVED INTO this + // `upsert`.** It was `primary_index.delete(&vals[0])` there and `primary_index.insert(..)` + // after the heap write, with `delete` dropping the leaf write latch on return: the primary + // key was absent from the index for the whole of a heap insert, and `search` descends with + // no latch at all. A concurrent point lookup on that key got "no such row" for a row that + // exists. Same defect the branch catalog had, same fix: one `upsert`, one page write, no + // window. See `BPlusTreeManager::upsert`. + // + // A reuse written in place writes no index page at all. The relocating case keeps the + // window a relocated UPDATE already has: `HeapFileManager::update` frees the old slot + // before this line repoints the entry. + if index_moved { + self.primary_index.upsert(vals[0].clone(), rid)?; + } // **E66 — the same de-duplication UPDATE needs, on the path E63 opened.** // // DELETE leaves a secondary entry behind on purpose (see `execution::update`: an older diff --git a/src/planner/plan.rs b/src/planner/plan.rs index 22823514..ca791f28 100644 --- a/src/planner/plan.rs +++ b/src/planner/plan.rs @@ -39,7 +39,7 @@ pub fn plan(stmt: Stmt, catalog: &Catalog, bp: Arc, txn_ctx: Stmt::Insert { table, values } => { let entry = catalog.require_table(&table)?; let (txn, txn_id) = txn_ctx.ok_or(FerroError::Wal("no transaction for delete".into()))?; - let (heap, tree, handles, fulltext) = open_table(entry, catalog, bp, txn, txn_id)?; + let (heap, tree, handles, fulltext) = open_table(entry, catalog, bp.clone(), txn.clone(), txn_id)?; let binder = Binder::new(catalog); let empty = Scope::new(); // Positional: value i lands in column i, so column i's declared type is what decides @@ -47,7 +47,11 @@ pub fn plan(stmt: Stmt, catalog: &Catalog, bp: Arc, txn_ctx: let column_types: Vec<&crate::catalog::column::DataType> = entry.schema.columns.iter().map(|c| &c.data_type).collect(); let bound_vals = binder.bind_row_against(values, &column_types, &empty)?; - let insert = Insert {author: None, table, values: bound_vals, heap, schema: entry.schema.clone(), primary_index: tree, secondary_indexes: handles, fulltext_indexes: fulltext, view: view.clone()}; + // A reused key archives its dead version here (see `execution::insert`), logged under + // this transaction exactly as UPDATE's archive is, so an abort takes it back. + let mut tt_heap = HeapFileManager::open(entry.time_travel_root, bp); + tt_heap.set_transaction(txn, txn_id); + let insert = Insert {author: None, table, values: bound_vals, heap, schema: entry.schema.clone(), primary_index: tree, secondary_indexes: handles, fulltext_indexes: fulltext, view: view.clone(), tt_heap}; return Ok(Plan::Write(Box::new(insert))) } Stmt::Update { table, assignments, where_clause } => { diff --git a/src/replication/logical.rs b/src/replication/logical.rs index 8ba8975f..d0c99815 100644 --- a/src/replication/logical.rs +++ b/src/replication/logical.rs @@ -22,6 +22,13 @@ //! `end_ts` decides: zero means the row is still live and this is an `Update`; non-zero means the //! version was killed and this is a `Delete`. //! +//! **1b. A reused primary key is an MVCC `HeapUpdate` too.** `DELETE k; INSERT k` writes the new +//! row into the dead version's slot so that older snapshots can still reach the old version through +//! the index (`execution::insert`). The record's OLD image is then dead and its new image is live, +//! and that is an `Insert`: the consumer applied the `Delete` when it committed. If the new row has +//! to relocate, the dead image leaves its slot as a `HeapDelete`. That is bookkeeping, counted in +//! [`Decoded::internal`] like the time-travel records below. +//! //! **2. Half the records are internal MVCC traffic.** An update also writes the superseded version //! into the table's separate `time_travel_root` heap. Those `HeapInsert`s are not user-visible //! changes — emitting them would double-count every update as an insert as well — but they are not @@ -296,7 +303,8 @@ pub struct Decoded { /// Schema changes seen, in log order: `(lsn, table, change)`. pub schema_changes: Vec<(u64, String, SchemaChange)>, /// Records that belong to a table's time-travel heap: superseded versions archived by MVCC. - /// Correctly not emitted, and correctly not an error. + /// Also a reused key's dead version leaving its slot when the new row relocates (point 1b of + /// the module doc). Correctly not emitted, and correctly not an error. pub internal: usize, /// Transactions that rolled back. Their changes are correctly absent. pub aborted: BTreeSet, @@ -854,6 +862,14 @@ impl LogicalDecoder { } } } + // A dead version being relocated: a reused key's new row did not fit the dead + // version's slot, and `HeapFileManager::update` logs the move as a delete of the + // slot's current bytes. Those bytes were already deleted, and the consumer was told + // when that DELETE committed. No other writer logs a `HeapDelete` of a dead image: + // DELETE never relocates (it rewrites the same bytes with a new `end_ts`), and an + // UPDATE never writes over a dead head (its scan yields only versions visible to it, + // and `check_write_conflict` refuses a head whose deleter it cannot see). + RecKind::HeapDelete { old, .. } if Self::is_dead(old) => out.internal += 1, RecKind::HeapDelete { dir_root, old, .. } => match Self::row_in(&tables, *dir_root, old) { RowResult::Row(table, columns, old) => staged .entry(txn) @@ -866,12 +882,20 @@ impl LogicalDecoder { // Decided BEFORE the images are turned into values, because it is a property of // the version header rather than of the columns. let killed = Self::is_dead(new); + // The mirror image: a dead version replaced by a live one under the same key. + // That is a reused primary key written into its dead version's slot + // (`execution::insert`), and the consumer has already applied the DELETE. + let revived = !killed && Self::is_dead(old); match (Self::row_in(&tables, *dir_root, old), Self::row_in(&tables, *dir_root, new)) { (RowResult::Row(table, columns, old), RowResult::Row(_, _, new)) => { let op = if killed { // A SQL DELETE. Reporting it as an update would leave a consumer // holding a row the database no longer has. ChangeOp::Delete { old } + } else if revived { + // Reporting it as an update would tell a consumer to modify a + // row it has already deleted. + ChangeOp::Insert { new } } else { ChangeOp::Update { old, new } }; diff --git a/src/wal/recovery.rs b/src/wal/recovery.rs index 95b396ab..41e78aea 100644 --- a/src/wal/recovery.rs +++ b/src/wal/recovery.rs @@ -213,6 +213,11 @@ pub fn rebuild_indexes(catalog: &mut Catalog, bp: &Arc) -> Re // returned the row. This is the same de-duplication rule E66 established for the write // paths, in the one path that never had it; it was found by B8's full-text search, which // resolves every posting through this index. + // + // ⚠ Since the reused-key fix (`execution::insert`, "The reused key must keep its old + // version reachable"), SQL no longer leaves two slots for one key: the new version is + // written into the dead version's slot. A heap written BEFORE that fix still holds them, + // and this rebuild is what reads such a heap after a crash, so the rule stays. let mut primary: BTreeMap = BTreeMap::new(); for (rid, vals, deleted) in &rows { match primary.get_mut(&vals[0]) { From 52b66d6b55c6bd4380a38d20db1168e633410a9d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:08:27 -0400 Subject: [PATCH 082/315] delete-insert-lookup: red test for a rolled-back INSERT's orphaned primary-index entry (UNBUILT, not run) INFERRED from source: abort undoes heap records only, so the index entry of a rolled-back INSERT points at a freed slot, and IndexScan and Insert both propagate SlotDeleted for that key. The reused-key fix's relocation arm inherits this and does not close it. Expected RED at this commit and at 4296723; FAN-QUEUE #10. --- tests/rolled_back_insert_index_entry.rs | 100 ++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 tests/rolled_back_insert_index_entry.rs diff --git a/tests/rolled_back_insert_index_entry.rs b/tests/rolled_back_insert_index_entry.rs new file mode 100644 index 00000000..9e5adeda --- /dev/null +++ b/tests/rolled_back_insert_index_entry.rs @@ -0,0 +1,100 @@ +//! A rolled-back INSERT leaves its primary-index entry pointing at a slot the rollback freed. +//! +//! INFERRED from source, never run; this file is the measurement. Found while designing the +//! reused-key fix (`tests/reused_key_old_snapshot.rs`), whose relocation arm inherits it. It is +//! not fixed there. +//! +//! - `TxnManager::abort` undoes heap records only (`HeapInsert` -> `undo_insert` -> +//! `Page::delete`). Index pages are not logged and nothing on the abort path touches a tree. +//! - So after `BEGIN; INSERT (5, ..); ROLLBACK;` the primary entry for 5 still points at the freed +//! slot, and `Page::read` answers `SlotDeleted` for it. +//! - `IndexScan::next` propagates a `heap.read` error, so `WHERE id = 5` fails rather than +//! returning no rows. `Insert::execute` reads the entry's slot before its uniqueness check and +//! propagates the same error, so key 5 cannot be inserted again. +//! +//! `executor::tests::test_block_rollback_discards_everything` passes regardless: it reads the table +//! with `SELECT id FROM t;`, a sequential scan, which skips the freed slot. + +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("rollback.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("rollback.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + Db { catalog, bp, txn, _dir: dir } + } + + fn exec(&mut self, sql: &str, s: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "parse error in `{sql}`: {:?}", p.errors); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + } + + fn ok(&mut self, sql: &str, s: &mut Session) -> Outcome { + self.exec(sql, s).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } +} + +#[test] +fn a_key_whose_insert_rolled_back_is_absent_by_key_and_insertable_again() { + let mut db = Db::new(); + let mut s = Session::new(); + db.ok("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut s); + db.ok("INSERT INTO t VALUES (1, 10);", &mut s); + match db.ok("EXPLAIN SELECT id, v FROM t WHERE id = 5;", &mut s) { + Outcome::Explain(plan) => assert!( + plan.contains("Index scan on t (col 0"), + "premise failed: a lookup of key 5 does not use the primary index:\n{plan}" + ), + _ => panic!("EXPLAIN did not explain"), + } + + db.ok("BEGIN;", &mut s); + db.ok("INSERT INTO t VALUES (5, 50);", &mut s); + db.ok("ROLLBACK;", &mut s); + + match db.exec("SELECT id, v FROM t WHERE id = 5;", &mut s) { + Ok(Outcome::Rows(r)) => assert!(r.is_empty(), "a rolled-back row is visible by key: {r:?}"), + Ok(_) => panic!("a SELECT did not return rows"), + Err(e) => panic!("a lookup of a key whose INSERT rolled back failed instead of finding nothing: {e}"), + } + db.exec("INSERT INTO t VALUES (5, 51);", &mut s) + .unwrap_or_else(|e| panic!("a key whose INSERT rolled back cannot be inserted again: {e}")); + match db.ok("SELECT id, v FROM t WHERE id = 5;", &mut s) { + Outcome::Rows(r) => assert_eq!(r, vec![vec![Value::Integer(5), Value::Integer(51)]]), + _ => panic!("a SELECT did not return rows"), + } +} From 97726cf8bcbc0f00652be2aecfafe04175a86291 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:15:36 -0400 Subject: [PATCH 083/315] Wall #17 (D191): failing-first COUNT test, DIFF must read one high-water entry per changed row, flat in |applied| -- UNBUILT Sits on D191's DIFF_APPLIED_VISITED instrument (merged in 41a5f3e). Pre-registered: at this commit the outcome assertions pass and the FIRST count assertion fails, C's fire-check at checkpoint 1, with visited = 3*|applied_1| + (p19+1) + (p20+1) (329 at the inferred one op per single-column UPDATE). No cargo was run (NO LOCAL COMPUTE brief). --- tests/d191_diff_does_not_scan_applied.rs | 354 +++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 tests/d191_diff_does_not_scan_applied.rs diff --git a/tests/d191_diff_does_not_scan_applied.rs b/tests/d191_diff_does_not_scan_applied.rs new file mode 100644 index 00000000..9ef555a8 --- /dev/null +++ b/tests/d191_diff_does_not_scan_applied.rs @@ -0,0 +1,354 @@ +//! D191 / branch-count wall #17: `DIFF` must not pay for every merge anyone has ever published. +//! +//! `AgentRuntime::diff` answered each changed row's `concurrent` flag with +//! `state.applied.iter().any(|a| a.seq > ws.fork_seq && a.tbl == t && a.row == r)`, once per changed +//! row, over a log that is global across branches and never pruned. That is O(delta × |applied|), +//! and |applied| grows with every merge of every branch. D86's `applied_by_cell` index could not +//! serve it: it is keyed `(tbl, row, col)` and skips whole-row ops entirely. +//! +//! # Why this file holds exactly ONE test, and must keep holding exactly one +//! +//! `DIFF_APPLIED_VISITED` and `DIFF_ROWS` (D191's instrument) are **process-global atomics**, +//! scoped to one `DIFF;` by reading them twice and subtracting. `cargo test` runs the tests of one +//! binary on a thread pool, so a second test here would run its own `DIFF` inside this one's window. +//! The reasoning is the same as `tests/d178_dml_index_counters.rs`. ⛔ **Do not add a second `#[test]` +//! here.** +//! +//! # What is asserted, and what each arm is for +//! +//! Every expected value comes from the FIXTURE, never from the subject. It is the number of changed +//! rows that have any published history at all, which the fixture decides by construction: +//! +//! | arm | session | changed rows | published history on them | concurrent | visited | +//! |---|---|---|---|---|---| +//! | A | forked before every merge | ids 1..4 | none | none | **0** | +//! | C | forked before every merge | ids 1,2,3,19,20 | 19: a cell UPDATE after C's fork; 20: a whole-row DELETE after C's fork | **19, 20** | **2** (the FIRE-CHECK) | +//! | P | forked immediately after the merge that published 19 | ids 1,2,3,19 | 19: one op whose `seq == P.fork_seq` | none | **1** | +//! +//! * **C is the fire-check.** A zero from A means nothing unless C, in the same process and the same +//! run, reads non-zero. It is also the only arm with a WHOLE-ROW op (the DELETE of 20). A fix built +//! on D86's cell index cannot see that op, so C's outcome for 20 is what kills that fix. +//! * **P is the boundary.** The one op on 19 has `seq == fork_seq` exactly, so it predates the fork +//! and is NOT concurrent. An off-by-one (`>=`) turns P's 19 into `PendingConcurrent`. The entry is +//! still examined, which is why P reads 1 and not 0. +//! * **Two checkpoints.** Between them, 40 more merges publish 160 more entries. Every integer must +//! be identical at both. That is the slope, not one point: a scan is linear in the axis, and the +//! high-water lookup is flat on it. +//! +//! # Pre-registered, before any build of this file +//! +//! * **Before the fix** (the commit that adds this file, on top of D191's instrument), the outcome +//! assertions pass and the FIRST COUNT assertion fails. That is C's fire-check at checkpoint 1, +//! with `visited = 3·|applied₁| + (p19 + 1) + (p20 + 1)`. Here `p19` and `p20` are the log positions +//! of the two ops, read from merge reports, and the failure message prints both numbers. At the +//! inferred one op per single-column UPDATE, that is 3·82 + 41 + 42 = **329**. The inference is +//! unverified; the formula stands either way. +//! * **After the fix**, every assertion passes with the integers in the table above. +//! +//! # Blind spots, stated here rather than discovered later +//! +//! * A high-water map that OVERWROTE instead of taking the max is invisible through SQL, because +//! publishes are serialised and seqs arrive in increasing order. The unit test +//! `the_row_high_water_is_the_max_over_every_push_on_that_row` in `agent_sql::runtime` plants an +//! out-of-order push to cover it. +//! * One table only. A key that dropped `tbl` is covered by that same unit test, not here. + +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::agent_sql::dispatch::AgentOutput; +use ferrodb::agent_sql::runtime::{diff_scan_counters, AgentRuntime}; +use ferrodb::agent_sql::{ChangeOutcome, ChangeSet, MergeReport}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::tel::ids::RowId; +use ferrodb::tel::op::OpKind; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +const NROWS: i32 = 80; +/// Noise merges draw their rows from `POOL_LO..=NROWS`, so they never touch ids 1..20. +const POOL_LO: i32 = 21; +/// Rows each noise branch updates before merging. +const NOISE_ROWS: usize = 4; +/// C updates it; merge X publishes one CELL op on it, after C's fork. +const HIT_CELL: i32 = 19; +/// C updates it; merge Y publishes one WHOLE-ROW delete of it, after C's fork. +const HIT_ROW: i32 = 20; +const NOISE_BEFORE_X: usize = 10; +const NOISE_AFTER_Y: usize = 10; +/// Merges between the two checkpoints: |applied| grows by 4 × this and nothing may move. +const NOISE_BETWEEN: usize = 40; + +/// The `tests/agent_sql_surface.rs` database. `AgentRuntime::new()` is map-backed, and that is +/// enough here: `diff`, `record_applied` and `push_applied` do not branch on storage. +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("d191.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("d191.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + Db { catalog, bp, txn, runtime: Arc::new(AgentRuntime::new()), _dir: dir } + } + + fn session(&self) -> Session { + Session::with_runtime(self.runtime.clone()) + } + + fn ok(&mut self, sql: &str, s: &mut Session) -> Outcome { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens().unwrap(); + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + assert!(parser.errors.is_empty(), "parse failed for {sql}: {:?}", parser.errors); + assert_eq!(stmts.len(), 1, "one statement: {sql}"); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), s) + .unwrap_or_else(|e| panic!("{sql} failed: {e}")) + } + + /// Open an agent session and stage one single-column UPDATE per id. + fn open(&mut self, name: &str, ids: &[i32], v: i32) -> Session { + let mut s = self.session(); + self.ok(&format!("BEGIN AGENT SESSION AS '{name}';"), &mut s); + for id in ids { + self.ok(&format!("UPDATE t SET v = {v} WHERE id = {id};"), &mut s); + } + s + } + + fn merge(&mut self, s: &mut Session) -> MergeReport { + let rep = match self.ok("MERGE;", s) { + Outcome::Agent(AgentOutput::Merge(r)) => r, + _ => panic!("MERGE did not return a merge report"), + }; + assert!(rep.applied_to_target, "a fixture merge did not publish, so |applied| is wrong: {rep}"); + rep + } + + fn diff(&mut self, s: &mut Session) -> Seen { + let (v0, r0, _) = diff_scan_counters(); + let cs = match self.ok("DIFF;", s) { + Outcome::Agent(AgentOutput::Diff(cs)) => cs, + _ => panic!("DIFF did not return a changeset, so the window is not a DIFF"), + }; + let (v1, r1, _) = diff_scan_counters(); + Seen::of(cs, v1 - v0, r1 - r0) + } +} + +/// One `DIFF;`: the two counters scoped to it, and what it reported. +struct Seen { + visited: u64, + rows: u64, + /// `(id, RowId, outcome)` per reported row, sorted by id. + reported: Vec<(i32, RowId, ChangeOutcome)>, +} + +impl Seen { + fn of(cs: ChangeSet, visited: u64, rows: u64) -> Seen { + let mut reported: Vec<(i32, RowId, ChangeOutcome)> = cs + .rows + .iter() + .map(|r| { + let id = match r.after.as_ref().and_then(|v| v.first()) { + Some(Value::Integer(i)) => *i, + other => panic!("every fixture change is an UPDATE with an integer key, got {other:?}"), + }; + (id, r.row, r.outcome.clone()) + }) + .collect(); + reported.sort_by_key(|(id, _, _)| *id); + Seen { visited, rows, reported } + } + + fn ids(&self) -> Vec { + self.reported.iter().map(|(id, _, _)| *id).collect() + } + + fn concurrent(&self) -> Vec { + self.reported + .iter() + .filter(|(_, _, o)| *o == ChangeOutcome::PendingConcurrent) + .map(|(id, _, _)| *id) + .collect() + } + + fn row_of(&self, id: i32) -> RowId { + self.reported + .iter() + .find(|(i, _, _)| *i == id) + .map(|(_, r, _)| *r) + .unwrap_or_else(|| panic!("id {id} is not in this DIFF, so the fixture did not stage it")) + } +} + +fn applied_len(rep: &MergeReport) -> u64 { + rep.rows.iter().map(|r| r.applied.len() as u64).sum() +} + +/// Publish one noise branch: `NOISE_ROWS` pool rows, each set to a value no earlier write used. +fn noise(db: &mut Db, cursor: &mut usize, m: &mut i32) -> u64 { + let pool = (NROWS - POOL_LO + 1) as usize; + let ids: Vec = (0..NOISE_ROWS) + .map(|_| { + let id = POOL_LO + (*cursor % pool) as i32; + *cursor += 1; + id + }) + .collect(); + *m += 1; + // Negative and distinct per merge; the seed values are positive. So every UPDATE moves its + // cell and publishes an op; an unchanged cell would publish nothing and shrink |applied|. + let mut s = db.open(&format!("n{m}"), &ids, -*m); + applied_len(&db.merge(&mut s)) +} + +#[test] +fn diff_reads_one_high_water_entry_per_changed_row_however_long_the_applied_log_grows() { + let mut db = Db::new(); + let mut seed = db.session(); + db.ok("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER);", &mut seed); + for i in 1..=NROWS { + db.ok(&format!("INSERT INTO t VALUES ({i}, {});", i * 7), &mut seed); + } + + // ---- the fixture: every published entry is counted from MERGE REPORTS ----------------------- + let mut a = db.open("A", &[1, 2, 3, 4], 1); + let mut c = db.open("C", &[1, 2, 3, HIT_CELL, HIT_ROW], 1); + // C's RowIds, from C's own DIFF, so no assumption about how a key becomes a RowId. + let c0 = db.diff(&mut c); + let (row_cell, row_row) = (c0.row_of(HIT_CELL), c0.row_of(HIT_ROW)); + + let (mut cursor, mut m, mut applied) = (0usize, 0i32, 0u64); + for _ in 0..NOISE_BEFORE_X { + applied += noise(&mut db, &mut cursor, &mut m); + } + + // X: exactly one CELL op, on C's id 19. + let p_cell = applied; + let mut x = db.open("X", &[HIT_CELL], -1000); + let rx = db.merge(&mut x); + let x_ops: Vec<_> = rx.rows.iter().flat_map(|r| r.applied.iter()).collect(); + assert_eq!(x_ops.len(), 1, "merge X must publish exactly one op: {rx}"); + assert!(x_ops[0].col.is_some(), "merge X's op must be a CELL op: {rx}"); + assert_eq!(x_ops[0].row, row_cell, "merge X's op is not on C's id-{HIT_CELL} row"); + applied += 1; + + // P forks NOW, so its `fork_seq` equals the seq of X's one op: the exact boundary. + let mut p = db.open("P", &[1, 2, 3, HIT_CELL], 2); + + // Y: exactly one WHOLE-ROW op, the delete of C's id 20. D86's cell index never sees this op. + let p_row = applied; + let mut y = db.session(); + db.ok("BEGIN AGENT SESSION AS 'Y';", &mut y); + db.ok(&format!("DELETE FROM t WHERE id = {HIT_ROW};"), &mut y); + let ry = db.merge(&mut y); + let y_ops: Vec<_> = ry.rows.iter().flat_map(|r| r.applied.iter()).collect(); + assert_eq!(y_ops.len(), 1, "merge Y must publish exactly one op: {ry}"); + assert!( + y_ops[0].col.is_none() && matches!(y_ops[0].kind, OpKind::RowDelete), + "merge Y's op must be a whole-row RowDelete: {ry}" + ); + assert_eq!(y_ops[0].row, row_row, "merge Y's op is not on C's id-{HIT_ROW} row"); + applied += 1; + + for _ in 0..NOISE_AFTER_Y { + applied += noise(&mut db, &mut cursor, &mut m); + } + let applied_1 = applied; + assert!( + applied_1 >= (NOISE_BEFORE_X + NOISE_AFTER_Y) as u64 * NOISE_ROWS as u64 + 2, + "only {applied_1} entries were published; the axis this test is about barely exists" + ); + + let mut prev: Option<(u64, u64, u64)> = None; + for checkpoint in 1..=2 { + if checkpoint == 2 { + for _ in 0..NOISE_BETWEEN { + applied += noise(&mut db, &mut cursor, &mut m); + } + assert!(applied > applied_1, "the axis did not move between the checkpoints"); + } + let (sa, sc, sp) = (db.diff(&mut a), db.diff(&mut c), db.diff(&mut p)); + eprintln!( + "d191 checkpoint {checkpoint}: |applied|={applied} | A visited={} rows={} | \ + C visited={} rows={} concurrent={:?} | P visited={} rows={}", + sa.visited, sa.rows, sc.visited, sc.rows, sc.concurrent(), sp.visited, sp.rows + ); + + // ---- 1. What DIFF reports. True before the fix and after it: this is the behaviour the + // fix must preserve, so it is asserted before any count. + assert_eq!(sa.ids(), vec![1, 2, 3, 4]); + assert_eq!(sc.ids(), vec![1, 2, 3, HIT_CELL, HIT_ROW]); + assert_eq!(sp.ids(), vec![1, 2, 3, HIT_CELL]); + assert_eq!(sa.concurrent(), Vec::::new(), "A: nothing touched ids 1..4"); + assert_eq!( + sc.concurrent(), + vec![HIT_CELL, HIT_ROW], + "C: a cell op AND a whole-row delete were published on its rows after it forked" + ); + assert_eq!( + sp.concurrent(), + Vec::::new(), + "P: the op on id {HIT_CELL} has seq == P's fork_seq, so it PREDATES the fork" + ); + + // ---- 2. The loop ran once per changed row, so a small `visited` is not an idle DIFF. + assert_eq!((sa.rows, sc.rows, sp.rows), (4, 5, 4), "DIFF_ROWS is not the pinned delta"); + + // ---- 3. The FIRE-CHECK: two of C's rows have published history, so C must read 2. Placed + // first among the counts so a zero below can mean something. + let retired_scan_c = 3 * applied + (p_cell + 1) + (p_row + 1); + assert_eq!( + sc.visited, 2, + "C visited {} entries at |applied|={applied}; the retired per-row scan of \ + State::applied reads exactly {retired_scan_c} here (3·|applied| + (p19+1) + (p20+1), \ + p19={p_cell}, p20={p_row}). Expected 2: one high-water entry for each of ids \ + {HIT_CELL} and {HIT_ROW}, the only changed rows with published history", + sc.visited + ); + assert_eq!( + sp.visited, 1, + "P visited {} entries at |applied|={applied}; expected 1, the id-{HIT_CELL} entry, \ + examined and rejected at the boundary (the retired scan reads 4·|applied| = {})", + sp.visited, + 4 * applied + ); + assert_eq!( + sa.visited, 0, + "A visited {} entries at |applied|={applied}; none of its rows was ever published, so \ + it has nothing to examine (the retired scan reads 4·|applied| = {})", + sa.visited, + 4 * applied + ); + + // ---- 4. The slope: 4·NOISE_BETWEEN more entries, and not one integer moved. + let now = (sa.visited, sc.visited, sp.visited); + if let Some(before) = prev { + assert_eq!(now, before, "a count moved with |applied|: {before:?} -> {now:?}"); + } + prev = Some(now); + } +} From 5d4b201ca4660e11159982c2d20ada653f9eeb5e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:18:38 -0400 Subject: [PATCH 084/315] Wall #17 (D191): DIFF answers 'published on this row after my fork?' from a per-row high-water seq, not a scan of State::applied -- UNBUILT State::applied_row_high: (tbl,row) -> max seq ever pushed, maintained in push_applied (the one door), whole-row ops included (D86's cell index skips them), max not overwrite (order-independent). diff: one hash probe per changed row; O(delta x |applied|) -> O(delta). DIFF_APPLIED_VISITED now counts high-water entries found (doc says so, and names the harness's BEFORE-shape expectations). Unit test plants an out-of-order push and diffs the map against the retired predicate. Doc corrections: D86's 'two other readers' list (diff was a third; cherry-pick's at_seq a fourth) and cherry_pick's false 'Nothing walks State::applied'. No cargo was run (NO LOCAL COMPUTE brief). --- src/agent_sql/runtime.rs | 199 ++++++++++++++++++++++++++++++++++----- 1 file changed, 176 insertions(+), 23 deletions(-) diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 5988fd8c..a2142468 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -181,22 +181,35 @@ pub fn scan_table_seq_tuples() -> u64 { SCAN_TABLE_SEQ_TUPLES.load(AtomicOrdering::Relaxed) } -/// **D191 instrument — how many `State::applied` entries `AgentRuntime::diff` VISITED.** +/// **D191 instrument: how many entries of the published history `AgentRuntime::diff` VISITED to +/// answer its `concurrent` flags.** /// -/// `diff` answers each changed row's `concurrent` flag with `state.applied.iter().any(..)`, once -/// PER CHANGED ROW, over a log that is global across branches and never pruned. D86's -/// `applied_by_cell` index is keyed `(tbl, row, col)` and cannot serve this `(tbl, row)` question, -/// so the read shape is O(delta x |applied|). This counter is what turns that reading into a -/// measurement (`examples/d191_diff_applied_curve.rs`). +/// ⚠ **What it counts changed with wall #17's fix. The name did not.** Quote the commit with any +/// reading. /// -/// Taken from INSIDE the `.any` closure, for the reason `ours_ops_on_cell` gives: a count computed -/// as `applied.len()` from outside would be an assertion about the code rather than a reading of -/// it, and it would miss the short-circuit — `.any` stops at the first concurrent entry, so a row -/// the target DID move visits only up to that entry. Integers, like `OURS_SCAN_*`, because a count -/// does not move when the box is loaded. +/// * **Before the fix** (D191's branch at `0df2216`, and any tree without `State::applied_row_high`), +/// `diff` answered each changed row with `state.applied.iter().any(..)`, once PER CHANGED ROW, over +/// a log that is global across branches and never pruned. D86's `applied_by_cell` index is keyed +/// `(tbl, row, col)` and cannot serve this `(tbl, row)` question, so the read shape was +/// O(delta x |applied|). This counter was taken from INSIDE the `.any` closure, which is what +/// `examples/d191_diff_applied_curve.rs` curves. ⚠ That harness's pre-registered checks +/// describe the BEFORE shape (`4·|applied|` and so on). On a tree with the fix, it prints +/// `MISMATCH` and exits 2, which is the fix working, not the fix failing. Its AFTER shape, derived from its +/// fixture, is 0 for arms A, P and Δ, and 1 for arm M at every checkpoint after merge #30. All +/// of it is flat in K. Run the BEFORE arm from D191's own +/// branch. +/// * **After the fix**, `diff` asks [`State::applied_high_on_row`], and this counts the high-water +/// entries that lookup FOUND. That is one per changed row that has any published history, and +/// none for a row that has never been published. It is bounded by delta, with no `|applied|` +/// term. `tests/d191_diff_does_not_scan_applied.rs` pins the exact integers. /// -/// ⚠ Counted per ROW, not per entry: one local increment inside the closure and one relaxed add -/// per changed row, so the instrument cannot create the slope it measures. +/// Either way it is a reading, not a `len()`. Counted from outside, it would be an assertion about +/// the code rather than a reading of it, for the reason `ours_ops_on_cell` gives. The old count saw +/// `.any`'s short-circuit, and the new one sees which rows have history. Integers, like +/// `OURS_SCAN_*`, because a count does not move when the box is loaded. +/// +/// ⚠ One local increment per visit and one relaxed add per changed row, so the instrument cannot +/// create the slope it measures. pub static DIFF_APPLIED_VISITED: AtomicU64 = AtomicU64::new(0); /// Changed rows `diff` examined — the delta, which a curve over `|applied|` must hold fixed. pub static DIFF_ROWS: AtomicU64 = AtomicU64::new(0); @@ -866,11 +879,74 @@ struct State { /// comparison a shared box cannot fake. Merge k cost O(k x delta); merging N branches was /// O(N^2). /// - /// ⚠ The Vec STAYS. Two other readers need it and neither is served by this key: the `txn` - /// filter in `REVERT` (`:4159`) and `highest_applied_seq` (`:3538`). This is an index beside - /// the log, not a replacement for it — which also means the two must be pushed together, and - /// `push_applied` is the only place that does either. + /// ⚠ The Vec STAYS. This is an index beside the log, not a replacement for it, so the two must + /// be pushed together, and `push_applied` is the only place that does either. Its readers that + /// this key does not serve, enumerated by symbol: + /// * `undo_txn`'s `txn` filter (REVERT): one full pass per reverted txn. + /// * `highest_applied_seq`: one full pass per merge, kept on purpose as an ordering guard + /// (D182; do not replace it with `last()`). + /// * `RuntimeCherryLog::project`'s `at_seq` map: one full pass per cherry-pick. + /// * `pickable_ops`: returns the whole log, so it is output-sized. + /// + /// ⚠ That list used to say "two" and name only the first two. `AgentRuntime::diff` was a third, + /// scanning the whole log once per changed row, and nothing listed it (D191, branch-count wall + /// #17). It now reads [`State::applied_row_high`] instead. A worded list of readers is evidence + /// that its author modelled the hazard, not evidence that the list is complete. Re-derive it + /// with `grep -n 'state\.applied\b\|\.applied\.iter'` before relying on it. applied_by_cell: std::collections::HashMap<(u32, u64, u32), Vec>, + /// **Wall #17 / D191.** `(tbl, row)` -> the highest `seq` of any op EVER pushed onto `applied` + /// for that row, whole-row ops included. It lets `AgentRuntime::diff` stop scanning the whole + /// log once per changed row. + /// + /// `diff`'s question for each changed row is "was anything published on this row after I + /// forked?", i.e. `applied.iter().any(|a| a.seq > fork_seq && a.tbl == t && a.row == r)`. An + /// entry satisfies that iff the row's highest seq does, so a high-water mark answers it exactly + /// in one probe. Unlike D86's key, only `seq` is a range here, and nothing about the answer needs + /// more than its maximum. + /// + /// **What it must stay consistent with:** for every `(tbl, row)`, the value is + /// `max { a.seq : a pushed through push_applied, (a.tbl, a.row) == (tbl, row) }`, and the key is + /// absent iff no such `a` exists. Three properties make that hold by construction, not by + /// discipline: + /// * **One door.** It is maintained in `push_applied` and nowhere else, the same door as `applied` + /// and `applied_by_cell`. `record_applied` is that door's only caller. + /// * **Every op shape.** Whole-row ops (`RowCreate`, `RowDelete`, which have `col == None`) are + /// included. A published delete moves the row as surely as an assign does, and it is exactly + /// the op D86's `(tbl, row, col)` key skips. That is why D86's index could not be reused. + /// * **`max`, not overwrite.** It is right for any push order, so it does not depend on the + /// increasing-`seq` order that `highest_applied_seq` exists to distrust. Publishes into + /// one database are serialised today (by the pgwire statement lock, and by the `&mut Catalog` + /// in `ExecCtx`), so overwrite would give the same answer there. But `max` costs nothing more, and it keeps this map from resting + /// on the invariant another guard in this file checks. + /// + /// **REVERT does not touch it, and does not need to.** At the time of writing, nothing removes + /// from `applied`. `undo_txn` only reads it, and its inverse writes go through + /// `PendingWrite::apply`, which has no access to `State`. The map is **monotone by design**, not + /// by accident: "something was published on this row after seq s" stays true after the log + /// entry that recorded it is gone. So if `applied` is ever pruned, or a revert ever removes the + /// entries it undoes, the retired scan would forget a move that this map still reports, and + /// this map is the one that matches `ChangeOutcome::PendingConcurrent`'s contract ("the target + /// has moved under this row"). What does NOT reach it is a revert's own inverse writes, because + /// they are not pushed. That was the retired scan's behaviour too, and it is kept, not decided, + /// here. `concurrent_op` on the merge path sees such a move only through its image fallback. + /// + /// **Why not `State::versions`,** which is also keyed `(tbl, row)` and also carries the seq: + /// * It is written LAST-writer-wins in `record_applied`, outside the door. + /// * It is the read-premise check's authority, and its own comments anticipate a purge (for + /// example on `DROP TABLE`) that would silently turn `diff`'s answer from moved to not-moved. + /// + /// The two maps hold the same key space (one entry per distinct published row, never per op), + /// so the duplication costs O(rows ever published), with no branch or merge term. + /// + /// Prior art: the per-record latest-writer stamp that OCC validation compares against the + /// transaction's start, instead of intersecting the write-sets of everything committed since + /// then. Examples are Silo's per-record TID word (Tu et al., SOSP 2013) and Hekaton's version + /// timestamps (Larson et al., VLDB 2011). The retired scan WAS that write-set intersection + /// (Kung & Robinson, TODS 1981, which Härder 1984 calls backward-oriented validation). It is also snapshot isolation's first-committer-wins + /// test (Berenson et al., SIGMOD 1995). Precision locking (Jordan et al., SIGMOD 1981) is the + /// predicate generalisation. It is not needed here, because `diff` asks about a row KEY, not a + /// predicate. + applied_row_high: std::collections::HashMap<(u32, u64), u64>, merges: BTreeMap, /// Why each quarantined branch is being held, keyed by branch id SLOT. /// @@ -948,6 +1024,10 @@ impl State { .or_default() .push(at); } + // Wall #17: every op, whole-row ones included, and `max` rather than overwrite. See + // `State::applied_row_high` for why each of those is load-bearing. + let high = self.applied_row_high.entry((op.tbl.0, op.row.0)).or_insert(op.seq); + *high = (*high).max(op.seq); self.applied.push(op); } @@ -960,6 +1040,12 @@ impl State { .unwrap_or(&[]) } + /// The highest `seq` ever published on one row, by any op shape. `None` when the row has + /// never been published, which is the common case. One hash probe, whatever `|applied|` is. + fn applied_high_on_row(&self, tbl: TableId, row: RowId) -> Option { + self.applied_row_high.get(&(tbl.0, row.0)).copied() + } + /// Insert a workspace, taking the txn references it holds. One of the two doors into /// `workspaces`; see [`State::txn_refs`] for why there are only two. fn insert_workspace(&mut self, branch: BranchId, ws: Workspace) { @@ -3243,10 +3329,17 @@ impl AgentRuntime { }) .cloned() .collect(); - let concurrent = state.applied.iter().any(|a| { + // **Wall #17: one high-water probe, not a scan of the log.** This was + // `state.applied.iter().any(|a| a.seq > ws.fork_seq && a.tbl == t && + // a.row == r)`. That is O(|applied|) per changed row, over a log every + // merge of every branch appends to and nothing prunes. The row's highest + // published seq exceeds `fork_seq` iff some entry does, so the answer is + // the same. See `State::applied_row_high` for what keeps it the same. + let high = state.applied_high_on_row(TableId(*t), RowId(*r)); + if high.is_some() { applied_visited += 1; - a.seq > ws.fork_seq && a.tbl.0 == *t && a.row.0 == *r - }); + } + let concurrent = high.map_or(false, |seq| seq > ws.fork_seq); DIFF_APPLIED_VISITED.fetch_add(applied_visited, AtomicOrdering::Relaxed); DIFF_FRAME_VISITED.fetch_add(frame_visited, AtomicOrdering::Relaxed); DIFF_ROWS.fetch_add(1, AtomicOrdering::Relaxed); @@ -3538,9 +3631,14 @@ impl AgentRuntime { /// /// # What it costs /// - /// O(picked + ops on the picked cells). The divergence question is read through D86's - /// `applied_by_cell` index — the one that already exists — rather than by scanning the log, - /// which is the contract [`CherryLog::ops_on_cell`] states. Nothing walks `State::applied`. + /// The divergence question is O(picked + ops on the picked cells). It is read through D86's + /// `applied_by_cell` index (the one that already exists) rather than by scanning the log, which + /// is the contract [`CherryLog::ops_on_cell`] states. + /// + /// ⚠ Not "nothing walks `State::applied`", which this sentence used to say. + /// `RuntimeCherryLog::project` builds its `at_seq` map with one full pass over the log per pick, + /// so a pick costs O(|applied|) on top of the bound above. The divergence question does not + /// walk the log; the projection does (wall #17's reader audit). pub fn cherry_pick( &self, ctx: &mut ExecCtx, @@ -7367,4 +7465,59 @@ mod tests { over-reports rather than corrupts, so it must not be refused" ); } + + /// **Wall #17.** `diff` asks `State::applied_row_high` instead of scanning `applied`. So the + /// map has to give the scan's answer for every push ORDER, every op SHAPE and every KEY, + /// including the ones SQL cannot reach today. Publishes are serialised, which makes an + /// out-of-order push invisible to `tests/d191_diff_does_not_scan_applied.rs`, and with it any + /// mutant that overwrites instead of taking the max. This test plants one. + /// + /// The oracle is the retired predicate itself, evaluated over the same log, so no expected value + /// here comes from the map under test. + #[test] + fn the_row_high_water_is_the_max_over_every_push_on_that_row() { + let mut state = State::default(); + assert_eq!(state.applied_high_on_row(TableId(1), RowId(1)), None, "nothing published yet"); + + // Whole-row ops (`applied_at` builds a `RowDelete`, `col: None`), out of seq order on purpose. + for seq in [1, 3, 2] { + state.push_applied(applied_at(seq)); + } + assert_eq!( + state.applied_high_on_row(TableId(1), RowId(1)), + Some(3), + "an overwrite answers 2 here: the LAST push, not the highest seq" + ); + + // A cell op on the same row, then ops on keys that share exactly one coordinate with it. + state.push_applied(AppliedOp { + col: Some(ColId(1)), + kind: OpKind::Assign(Value::Integer(0)), + ..applied_at(5) + }); + state.push_applied(AppliedOp { tbl: TableId(2), table: "other".into(), ..applied_at(9) }); + state.push_applied(AppliedOp { row: RowId(2), ..applied_at(4) }); + assert_eq!(state.applied_high_on_row(TableId(1), RowId(1)), Some(5), "a cell op moves the row"); + assert_eq!(state.applied_high_on_row(TableId(2), RowId(1)), Some(9), "same row id, other table"); + assert_eq!(state.applied_high_on_row(TableId(1), RowId(2)), Some(4), "same table, other row"); + assert_eq!(state.applied_high_on_row(TableId(2), RowId(2)), None, "never published"); + + // Differential against the predicate `diff` used to evaluate, at every fork point on both + // sides of every seq in the log, each seq itself included (the `>` boundary). + for fork_seq in 0..=10u64 { + for (t, r) in [(1u32, 1u64), (2, 1), (1, 2), (2, 2)] { + let scan = state + .applied + .iter() + .any(|a| a.seq > fork_seq && a.tbl.0 == t && a.row.0 == r); + let high = state + .applied_high_on_row(TableId(t), RowId(r)) + .map_or(false, |seq| seq > fork_seq); + assert_eq!( + high, scan, + "({t}, {r}) at fork_seq {fork_seq}: the high-water map and the retired scan disagree" + ); + } + } + } } From 36ab65ade23378a2ff7da9bfeee8b1b0e6277341 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:19:35 -0400 Subject: [PATCH 085/315] Wall #17 (D191): reflow the new docs, make the reader-audit grep catch split chains (pickable_ops), tidy the unit test -- UNBUILT --- src/agent_sql/runtime.rs | 61 ++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index a2142468..bfbb9ccb 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -187,17 +187,19 @@ pub fn scan_table_seq_tuples() -> u64 { /// ⚠ **What it counts changed with wall #17's fix. The name did not.** Quote the commit with any /// reading. /// -/// * **Before the fix** (D191's branch at `0df2216`, and any tree without `State::applied_row_high`), -/// `diff` answered each changed row with `state.applied.iter().any(..)`, once PER CHANGED ROW, over -/// a log that is global across branches and never pruned. D86's `applied_by_cell` index is keyed -/// `(tbl, row, col)` and cannot serve this `(tbl, row)` question, so the read shape was -/// O(delta x |applied|). This counter was taken from INSIDE the `.any` closure, which is what -/// `examples/d191_diff_applied_curve.rs` curves. ⚠ That harness's pre-registered checks -/// describe the BEFORE shape (`4·|applied|` and so on). On a tree with the fix, it prints -/// `MISMATCH` and exits 2, which is the fix working, not the fix failing. Its AFTER shape, derived from its -/// fixture, is 0 for arms A, P and Δ, and 1 for arm M at every checkpoint after merge #30. All -/// of it is flat in K. Run the BEFORE arm from D191's own -/// branch. +/// * **Before the fix** (D191's branch at `0df2216`, and any tree without +/// `State::applied_row_high`), `diff` answered each changed row with +/// `state.applied.iter().any(..)`, once PER CHANGED ROW, over a log that is global across +/// branches and never pruned. D86's `applied_by_cell` index is keyed `(tbl, row, col)` and +/// cannot serve this `(tbl, row)` question, so the read shape was O(delta x |applied|). This +/// counter was taken from INSIDE the `.any` closure, which is what +/// `examples/d191_diff_applied_curve.rs` curves. +/// +/// ⚠ That harness's pre-registered checks describe the BEFORE shape (`4·|applied|` and so on). +/// On a tree with the fix, it prints `MISMATCH` and exits 2, which is the fix working, not the +/// fix failing. Its AFTER shape, derived from its fixture, is 0 for arms A, P and Δ, and 1 for +/// arm M at every checkpoint after merge #30. All of it is flat in K. Run the BEFORE arm from +/// D191's own branch. /// * **After the fix**, `diff` asks [`State::applied_high_on_row`], and this counts the high-water /// entries that lookup FOUND. That is one per changed row that has any published history, and /// none for a row that has never been published. It is bounded by delta, with no `|applied|` @@ -892,7 +894,10 @@ struct State { /// scanning the whole log once per changed row, and nothing listed it (D191, branch-count wall /// #17). It now reads [`State::applied_row_high`] instead. A worded list of readers is evidence /// that its author modelled the hazard, not evidence that the list is complete. Re-derive it - /// with `grep -n 'state\.applied\b\|\.applied\.iter'` before relying on it. + /// before relying on it, with `grep -n '\.applied\([^_a-zA-Z]\|$\)' src/agent_sql/runtime.rs`. + /// The `$` arm is what catches the chains split across lines (`pickable_ops` is one); the + /// shorter `'state\.applied[^_]'` misses those. Read the hits: `r.applied` is + /// `RowMergeOutcome`'s, not this log. applied_by_cell: std::collections::HashMap<(u32, u64, u32), Vec>, /// **Wall #17 / D191.** `(tbl, row)` -> the highest `seq` of any op EVER pushed onto `applied` /// for that row, whole-row ops included. It lets `AgentRuntime::diff` stop scanning the whole @@ -915,9 +920,10 @@ struct State { /// the op D86's `(tbl, row, col)` key skips. That is why D86's index could not be reused. /// * **`max`, not overwrite.** It is right for any push order, so it does not depend on the /// increasing-`seq` order that `highest_applied_seq` exists to distrust. Publishes into - /// one database are serialised today (by the pgwire statement lock, and by the `&mut Catalog` - /// in `ExecCtx`), so overwrite would give the same answer there. But `max` costs nothing more, and it keeps this map from resting - /// on the invariant another guard in this file checks. + /// one database are serialised today (by the pgwire statement lock, and by the + /// `&mut Catalog` in `ExecCtx`), so overwrite would give the same answer there. But `max` + /// costs nothing more, and it keeps this map from resting on the invariant another guard in + /// this file checks. /// /// **REVERT does not touch it, and does not need to.** At the time of writing, nothing removes /// from `applied`. `undo_txn` only reads it, and its inverse writes go through @@ -942,10 +948,10 @@ struct State { /// transaction's start, instead of intersecting the write-sets of everything committed since /// then. Examples are Silo's per-record TID word (Tu et al., SOSP 2013) and Hekaton's version /// timestamps (Larson et al., VLDB 2011). The retired scan WAS that write-set intersection - /// (Kung & Robinson, TODS 1981, which Härder 1984 calls backward-oriented validation). It is also snapshot isolation's first-committer-wins - /// test (Berenson et al., SIGMOD 1995). Precision locking (Jordan et al., SIGMOD 1981) is the - /// predicate generalisation. It is not needed here, because `diff` asks about a row KEY, not a - /// predicate. + /// (Kung & Robinson, TODS 1981, which Härder 1984 calls backward-oriented validation). It is + /// also snapshot isolation's first-committer-wins test (Berenson et al., SIGMOD 1995). + /// Precision locking (Jordan et al., SIGMOD 1981) is the predicate generalisation. It is not + /// needed here, because `diff` asks about a row KEY, not a predicate. applied_row_high: std::collections::HashMap<(u32, u64), u64>, merges: BTreeMap, /// Why each quarantined branch is being held, keyed by branch id SLOT. @@ -7479,7 +7485,7 @@ mod tests { let mut state = State::default(); assert_eq!(state.applied_high_on_row(TableId(1), RowId(1)), None, "nothing published yet"); - // Whole-row ops (`applied_at` builds a `RowDelete`, `col: None`), out of seq order on purpose. + // Whole-row ops (`applied_at` builds a `RowDelete`), pushed out of seq order on purpose. for seq in [1, 3, 2] { state.push_applied(applied_at(seq)); } @@ -7497,10 +7503,11 @@ mod tests { }); state.push_applied(AppliedOp { tbl: TableId(2), table: "other".into(), ..applied_at(9) }); state.push_applied(AppliedOp { row: RowId(2), ..applied_at(4) }); - assert_eq!(state.applied_high_on_row(TableId(1), RowId(1)), Some(5), "a cell op moves the row"); - assert_eq!(state.applied_high_on_row(TableId(2), RowId(1)), Some(9), "same row id, other table"); - assert_eq!(state.applied_high_on_row(TableId(1), RowId(2)), Some(4), "same table, other row"); - assert_eq!(state.applied_high_on_row(TableId(2), RowId(2)), None, "never published"); + let high = |t: u32, r: u64| state.applied_high_on_row(TableId(t), RowId(r)); + assert_eq!(high(1, 1), Some(5), "a cell op moves the row"); + assert_eq!(high(2, 1), Some(9), "same row id, other table"); + assert_eq!(high(1, 2), Some(4), "same table, other row"); + assert_eq!(high(2, 2), None, "never published"); // Differential against the predicate `diff` used to evaluate, at every fork point on both // sides of every seq in the log, each seq itself included (the `>` boundary). @@ -7510,11 +7517,9 @@ mod tests { .applied .iter() .any(|a| a.seq > fork_seq && a.tbl.0 == t && a.row.0 == r); - let high = state - .applied_high_on_row(TableId(t), RowId(r)) - .map_or(false, |seq| seq > fork_seq); + let from_map = high(t, r).map_or(false, |seq| seq > fork_seq); assert_eq!( - high, scan, + from_map, scan, "({t}, {r}) at fork_seq {fork_seq}: the high-water map and the retired scan disagree" ); } From fcec288ffc6017cd91e5670b3caa8b8c805033e1 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:25:51 -0400 Subject: [PATCH 086/315] Wall #17 (D191): declare State::applied_row_high in the capability-envelope field allowlist, with the three-ground determination the guard demands -- UNBUILT Found by wall18-revert: the_envelope_reads_one_funnel_while_three_reach_branch_state asserts State's exact field list, so 36ab65a would have failed it (per-target 2580/1, not 2581/0). The determination mirrors D86's for applied_by_cell: derived from applied (unit test diffs it against the scan), keyed by row not branch, single writer push_applied on the merge-publication path. --- tests/integration_capability_envelope.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/integration_capability_envelope.rs b/tests/integration_capability_envelope.rs index b704a1a2..49af6a4c 100644 --- a/tests/integration_capability_envelope.rs +++ b/tests/integration_capability_envelope.rs @@ -1464,6 +1464,23 @@ fn the_envelope_reads_one_funnel_while_three_reach_branch_state() { // If it ever gains a field of its own, or a second writer, that reasoning lapses and // this decision has to be made again. "applied_by_cell", + // **Wall #17 / D191: DECIDED, as this guard requires.** `applied_row_high` maps + // `(tbl, row)` to the highest `seq` pushed onto `applied`. `AgentRuntime::diff` reads + // it in place of scanning the whole log once per changed row. It is NOT a second + // funnel, on the same three grounds as `applied_by_cell`: + // * it holds no information `applied` does not already hold. It is one `max` over + // that Vec per row, and the lib unit test + // `the_row_high_water_is_the_max_over_every_push_on_that_row` diffs it against + // the scan it replaced; + // * it is keyed by ROW, not by branch, so there is no per-branch state in it for an + // envelope to govern; + // * its only writer is `State::push_applied`, the same single call that appends to + // `applied`. Only `record_applied` on the merge-publication path reaches it, so + // no statement reaches it outside that. + // It is monotone by design. If `applied` is ever pruned, this map keeps facts the log + // no longer holds, and the first ground lapses. If it gains a second writer, the + // third lapses. Either way, this decision has to be made again. + "applied_row_high", "merges", "quarantine_reasons", "escrow", "versions", "captures", // `runs` and `row_author` were REMOVED by E79c (`ee01420`), and a removal gets the // same determination an addition does — the difference is which way it can be wrong. From b9754309a6b188c9c2297132af8c23c87312280f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:37:29 -0400 Subject: [PATCH 087/315] UNBUILT D16 chain: pre-register retained pages against reaped-chain depth Written before the harness. Predicts P*(D-1) retained for a chain under a live leaf, 0 after the leaf goes, 0 for a flat fanout, and an overwrite arm equal to the chain arm (the interval rule cannot see supersession). Nothing compiled or run (quiet mode). --- bench/d16_chain/PREREG.md | 186 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 bench/d16_chain/PREREG.md diff --git a/bench/d16_chain/PREREG.md b/bench/d16_chain/PREREG.md new file mode 100644 index 00000000..868c1135 --- /dev/null +++ b/bench/d16_chain/PREREG.md @@ -0,0 +1,186 @@ +# D16 chain retention — PRE-REGISTRATION + +Written and committed BEFORE the harness (`examples/d16_chain_retention.rs`) exists. Amendments +are append-only, at the bottom, each dated and committed before the run it amends. + +- worktree: `/Users/idide/wt/ferrodb-d16-chain-arm.noindex` +- branch: `d16-chain-arm` +- base: `9aa6968` (main) +- produced under QUIET MODE: nothing here has been compiled or run. + +## The claim under test + +SCALE-DESIGN D16, "The decision: option 1": a `Reaped` interior branch that still +`has_live_children()` keeps counting as a live child of its own parent, so its pages stay parked +while any descendant lives. The recorded cost, never measured: + +> an abandoned interior branch's arenas stay pinned by one live descendant — a space leak +> proportional to chain length, which is the exact shape MCTS generates. + +This measures the retention. Whether that retention is a *leak* is a separate question, and one +arm below exists to answer it (see "What `chain` cannot tell you"). + +## Fixture + +Every (arm, D) cell gets a fresh directory, a fresh `TableBranchCatalog` sidecar (the shipped +catalog), a fresh `ArenaPageStore`, and a fresh `TwoTierReaper`. The free-space map is persisted +through `checkpoint_to`, as `src/cli/cli.rs` does (`D16_PERSIST=0` turns it off; the header says +which). Single-threaded, so every count is fixed by control flow. + +"Write a page" is `PageStore::alloc_for(branch, BTreeLeaf, next_epoch())` followed by a payload +byte and `stamp_checksum`: the calls `examples/branch_curve_writes.rs` and +`examples/d31_reap_cost.rs` use. P pages per branch. Default **P = 2**, chosen so that pages, +extents and reserved pages are three different numbers (geometric extents 1 + 2 → 2 extents, +3 reserved pages, 2 allocated). + +Leases: every branch that is to be reaped first gets `LeaseDeadline(1_000)`; the survivor gets +`LeaseDeadline(2_000)`. + +**Reaping goes through `Reaper::reap_expired` and nothing else.** It is the two halves that +`lease_thread::scan_once` runs (`expired_candidates`, deepest first, then +`reap_if_still_expired` per candidate), back to back. Each reap ends in `drain_pending_seeded`. +No reap is hand-rolled, and no reap skips the drain (the D183 retraction). + +- **sweep 1:** `reap_expired(1_000)` — reaps every branch except the survivor. +- then `drain_pending()` once more and `collect_orphaned_extents()` once — the two collectors + production also runs — so the reported retention is the steady state, not a transient. +- **sweep 2 (control a):** `reap_expired(2_000)` — reaps the survivor, then the same two + collectors. + +Arms, all with the same total writes (P·D pages) and the same number of branches (D): + +| arm | topology | writes | survivor | +|---|---|---|---| +| `chain` | trunk → b1 → … → bD; each bk writes P fresh pages, then forks b(k+1) | fresh pages | the leaf bD | +| `fanout` (control b) | trunk → b1 … bD, all siblings | fresh pages | the last sibling | +| `overwrite` (added by me, not in the brief) | the `chain` topology | b1 writes P fresh pages; each b(k+1) overwrites its parent's P pages through `PageStore::cow_page` | the leaf bD | + +Control (a) is sweep 2, run in every arm. + +## Instruments (at the layer that pays: arena pages, not rows) + +1. `PageStore::live_page_count()` — the store's net counter. +2. A **membership enumeration**: for every `(arena, owner)` in `live_arenas()`, + `allocated_pages(arena).len()`, bucketed by owner id into *reaped-set*, *survivor*, *other*. +3. `reserved_page_count()`, and the same membership split over `extent_range(arena)` sizes. +4. `pending_len()` — pages parked in the pending-free log. +5. Wall-clock ms per sweep. **Secondary, load-sensitive, no prediction rests on it.** + +`retained_pages` = instrument 2's reaped-set bucket after sweep 1 and both collectors. +The file length is NOT used: freed extents return to the free-space map and the file never +shrinks, so file length is a high-water mark, not retention. + +## Guards — any failure prints `NOT A RESULT` on the row and the run exits 2 + +- G0 provenance: the build stamp is a real sha and not `+DIRTY`. +- G1 fixture fired: before sweep 1, enumerated pages = P·D exactly, and the survivor's depth is + D (`chain`, `overwrite`) or 1 (`fanout`). +- G2 instruments agree at every census: `live_page_count` = enumeration, and + `reserved_page_count` = the sum of live extent sizes. (A net count can hide offsetting errors.) +- G3 sweep 1 reaped exactly the D−1 expected ids; sweep 2 reaped exactly the survivor; + `refused_reaps()` = 0. +- G4 after sweep 1 the survivor reads `Live` and every reaped id reads `Reaped`. +- G5 the *other* bucket is 0 at every census (no page is owned by a branch the fixture did not make). +- G6 `overwrite` only: every `cow_page` returned `copied = true` and `retire_previous = false`. +- A run with no parsable depth or arm refuses and exits 2. + +## Predictions (exact integers; single-threaded, so exact equality, not a fit) + +At P = 2, D ∈ {1, 10, 100, 1000}: + +| quantity after sweep 1 | `chain` | `overwrite` | `fanout` | +|---|---|---|---| +| reaped | D−1 | D−1 | D−1 | +| retained_pages | **P·(D−1)** = 0, 18, 198, 1998 | **identical to `chain`** | **0** | +| retained reserved pages | 3·(D−1) = 0, 27, 297, 2997 | identical to `chain` | 0 | +| retained extents | 2·(D−1) | identical | 0 | +| pending | P·(D−1) | identical | 0 | +| survivor pages | P | P | P | +| extra `drain_pending` released | 0 | 0 | 0 | +| `collect_orphaned_extents` freed | 0 | 0 | 0 | + +After sweep 2 (control a), every arm: enumerated pages 0, reserved 0, pending 0, live extents 0, +orphan collector frees 0. + +**Slope of retained pages against D, per page written per level: 1 for `chain` and `overwrite`, +0 for `fanout`, 0 after the leaf goes.** + +The pin's own share is P·(D−2), not P·(D−1): b(D−1)'s pages are held by the ordinary interval rule, +because its direct child is the leaf and the leaf is `Live`. The D16 pin is what holds b1 … b(D−2). + +## What each other outcome would mean + +- **`chain` = P at every D ≥ 2 (flat).** Only the leaf's direct parent is protected; the pin does + not hold. In this fixture those pages are readable by the live leaf, so that is **D16's data loss + back**, not a saving. Stop and report it as a correctness regression. +- **`chain` = 0.** Even the direct-child rule failed (D18 class). Correctness regression. +- **`chain` between P and P·(D−1), or not a multiple of P.** The pin holds to some depth and then + breaks. `retained/P + 1` is that depth; report it. Also a correctness regression. +- **`chain` > P·(D−1)** with every guard green. Interior-owned pages exceed what the interiors + wrote, so something on the reap path allocates. A new finding; the D16 cost line does not + cover it. +- **retained reserved ≠ 3·(D−1) while retained pages match.** Extent accounting differs from the + doubling rule (`next_extent_pages`). The page claim stands; the disk cost is the reserved number. +- **pending ≠ retained_pages.** Some retained pages are not on the pending log, so no drain can + ever return them: an orphan leak with a different mechanism from the pin. +- **after-leaf > 0.** The cascade does not unwind: retention outlives the last descendant, which is + a permanent leak and worse than D16's recorded cost. = P·(D−1) means the drain never retested; + a fraction of it means the cascade stops partway up. +- **`fanout` > 0.** The fast path retains pages, so `chain`'s number cannot be attributed to the + pin alone; subtract `fanout` at the same D before reading `chain`. +- **`overwrite` ≠ `chain`.** The rule can see that a descendant superseded a page. That would + falsify my reading that it cannot (below). Lower `overwrite` = the leak is smaller than the + retention. +- **orphan collector or extra drain frees > 0 after sweep 1.** The retention after sweep 1 was a + transient that production's periodic collectors recover; the steady-state number is the one + after them, and D16's cost is smaller than sweep 1 alone suggests. + +## What `chain` cannot tell you — why `overwrite` exists + +The brief's fixture writes FRESH pages. A fresh page b(k) wrote before forking b(k+1) is one the +leaf can read: its root is b(k)'s root at fork. **Keeping it is required, not leaked.** Any correct +reclaimer keeps it, including D16's rejected option 3, which never reaps the interiors at all. So +`chain` measures what the pin holds, not what it wastes. + +In `overwrite`, every interior page has been replaced by a copy in its child before the child +forks on, and `cow_page` still stores whole pages (D102: "no chain is written yet"). No interior +page is the current version of anything the leaf reads, so everything `overwrite` retains is +garbage. The interval rule tests `[birth, free)` against a live child's fork epoch and has no input +that says a child superseded the page (READ: `retire_arenas_by_rule`, `live_child_in_epoch_range`), +hence the prediction that the two arms retain the same count. + +If that holds, the "leak proportional to chain length" is real in `overwrite`. **INFERRED, not +tested here:** D16's options 2 and 3 would retain the same set, because all three keep the interval +rule and none of them gives it that missing input. The leak would then belong to the rule, not to +option 1. + +## Secondary: time (no prediction; reported for FAN-QUEUE budgeting) + +INFERRED from reading, and independently in `frontier/lane_wall21_reap_walk.md` §1.3: under a live +leaf, sweep 1 in `chain` and `overwrite` is expected to cost Θ(P·D³) CHILD-span scans. The +deepest-first order reaps b(D−1) first; each reap's drain retests the whole pending log; and each +retest of a page owned m levels above the leaf walks m spans. At D = 1000 that is about +1.7·10⁸·P span scans. `fanout` and sweep 2 are linear. No duration is predicted. If wall21's fix +lands first, the time changes and **every retention count must not**: a changed count under that +fix means the fix broke the pin. + +## Not covered + +- `LogBranchCatalog` (only the shipped `TableBranchCatalog` is run). +- Concurrency (D15), crash and reopen, and replicas. +- Catalog-side retention: the interior id slots `release_id` refuses while pinned, which wall21 F1 + says the cascade never releases. That is records, not arena pages. +- A real B+tree. `overwrite` models supersession at page granularity. A tree would also shadow + root-to-leaf path pages at every level, adding garbage to `chain` that this fixture does not + write. + +## Run + +``` +cargo build --release --example d16_chain_retention +timeout 1800 target/release/examples/d16_chain_retention 1,10,100 2 fanout,chain,overwrite +timeout 14400 target/release/examples/d16_chain_retention 1000 2 fanout,chain,overwrite +``` + +Exit 0 = every guard held and every prediction matched. Exit 1 = guards held, and at least one +pre-registered prediction did not (a result, printed as `MISMATCH`). Exit 2 = `NOT A RESULT`. From 771bc12753a6befa21bcbe4ba57243a309d54416 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:44:04 -0400 Subject: [PATCH 088/315] UNBUILT D16 chain: harness for retained arena pages against reaped-chain depth examples/d16_chain_retention.rs, per bench/d16_chain/PREREG.md (b975430). Arms chain, fanout and overwrite; reaps only through Reaper::reap_expired, then drain_pending and collect_orphaned_extents; counts arena pages twice (net counter and per-extent enumeration). Rows print measured and predicted under the same keys, NOT A RESULT on any guard. Never compiled (quiet mode); CI builds examples with -D dead_code. --- examples/d16_chain_retention.rs | 767 ++++++++++++++++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 examples/d16_chain_retention.rs diff --git a/examples/d16_chain_retention.rs b/examples/d16_chain_retention.rs new file mode 100644 index 00000000..14bbce9a --- /dev/null +++ b/examples/d16_chain_retention.rs @@ -0,0 +1,767 @@ +//! D16: what a chain of reaped interior branches keeps ALLOCATED while its leaf lives. +//! +//! SCALE-DESIGN D16 chose option 1: a `Reaped` interior branch that still has a live descendant +//! keeps counting as a live child of its own parent, so its pages stay parked. The cost it +//! recorded — "a space leak proportional to chain length, which is the exact shape MCTS +//! generates" — was never measured. This is the instrument. The predictions, the guards and what +//! every other outcome would mean are in `bench/d16_chain/PREREG.md`, committed before this file. +//! +//! d16_chain_retention [depths,comma,separated] [pages_per_level] [arms,comma,separated] +//! +//! Defaults: `1,10,100,1000 2 fanout,chain,overwrite`. `D16_PERSIST=0` turns off persistence of the +//! free-space map, which is ON by default because `cli.rs` ships it on. +//! +//! **Arms.** `chain`: trunk -> b1 -> ... -> bD, every level writes P fresh pages and then forks the +//! next, and only the leaf survives. `fanout`: D siblings with the same writes, the last one +//! survives, so there are no interiors. `overwrite`: the chain again, but every level overwrites +//! its parent's pages through `cow_page`, so no interior page is the current version of anything +//! the leaf reads. **`overwrite` is the only arm in which retention and leak are the same number.** +//! In `chain` the leaf can still read every interior page, so keeping them is required. +//! +//! **The reap is the production one.** `Reaper::reap_expired` runs `expired_candidates` (deepest +//! first) and then `reap_if_still_expired` for each branch: the two halves +//! `lease_thread::scan_once` runs. Every reap ends in its own drain. A D183 number was retracted +//! because its test hand-rolled a reap that skipped `drain_pending`, so nothing here reaps any +//! other way. After each sweep the harness also runs `drain_pending` and `collect_orphaned_extents`, +//! the collectors production runs later, so what it reports is the steady state and not a +//! transient. +//! +//! **The instrument is the layer that pays: arena pages still allocated.** They are counted twice, +//! once by the store's net counter and once by enumerating every live extent's allocated pages, +//! because a net count can hide a leak and a release that cancel out. The file length is not used: +//! freed extents go back to the free-space map and the file never shrinks. +//! +//! Depths are the outer loop and `fanout` runs first by default, so the cheap rows for a depth are +//! printed before its expensive ones. Under a live leaf, sweep 1 of a chain arm is expected to cost +//! on the order of P·D³ CHILD-span scans (PREREG, "Secondary"). Every phase prints a flushed +//! `progress` line first, so a run stopped by `timeout` names the cell and phase it stopped in. +//! +//! Exit 0: every guard held and every prediction matched. Exit 1: the guards held but a prediction +//! did not (that is a result, printed as MISMATCH). Exit 2: NOT A RESULT. +use std::collections::BTreeSet; +use std::io::Write; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Instant; + +use ferrodb::branch::arena::ArenaPageStore; +use ferrodb::branch::reaper::TwoTierReaper; +use ferrodb::branch::table_catalog::TableBranchCatalog; +use ferrodb::branch::types::{BranchId, BranchState, LeaseDeadline, PageId}; +use ferrodb::branch::{BranchCatalog, Reaper}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::cow::page_header::PageType; +use ferrodb::cow::{stamp_checksum, PageStore, PAGE_HEADER_SIZE}; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; + +/// Lease deadline of every branch the first sweep must reap. +const DOOMED_DEADLINE: u64 = 1_000; +/// Lease deadline of the survivor. Only the second sweep (control a) reaches it. +const SURVIVOR_DEADLINE: u64 = 2_000; + +/// The extent sizes pre-registered in `bench/d16_chain/PREREG.md`: one page first, each next extent +/// double the last, capped at 256. They are written out here, not read from `ferrodb`, so the +/// reserved-page prediction does not come from the code it checks. +const MODEL_FIRST_EXTENT: u64 = 1; +const MODEL_EXTENT_CAP: u64 = 256; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Arm { + Fanout, + Chain, + Overwrite, +} + +impl Arm { + fn parse(s: &str) -> Option { + match s { + "fanout" => Some(Arm::Fanout), + "chain" => Some(Arm::Chain), + "overwrite" => Some(Arm::Overwrite), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Arm::Fanout => "fanout", + Arm::Chain => "chain", + Arm::Overwrite => "overwrite", + } + } +} + +/// One cell's directory, removed when the cell is done. It is the LAST field of [`Rig`], so the +/// store and the catalog that hold files inside it are dropped before it is removed. +struct Scratch(PathBuf); + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +struct Rig { + catalog: Arc, + store: Arc, + reaper: TwoTierReaper, + _scratch: Scratch, +} + +/// A fresh database per cell, so no cell inherits another's pages, ids or pending log. +fn rig(arm: Arm, depth: usize, persist: bool) -> Result { + let dir = std::env::temp_dir() + .join(format!("ferrodb-d16chain-{}-{}-{}", std::process::id(), arm.name(), depth)); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e:?}", dir.display()))?; + let scratch = Scratch(dir.clone()); + let file = std::fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .open(dir.join("main.db")) + .map_err(|e| format!("open main.db: {e:?}"))?; + let disk = DiskManager::new(file).map_err(|e| format!("DiskManager::new: {e:?}"))?; + let pool = Arc::new(BufferPoolManager::new(Arc::new(disk))); + // The catalog that ships, on a real file. `LogBranchCatalog` is not run: PREREG, "Not covered". + let catalog: Arc = Arc::new( + TableBranchCatalog::open_sidecar(&dir.join("branches.branchcat"), 1) + .map_err(|e| format!("open the branch catalog: {e:?}"))?, + ); + let base = pool.disk_manager.high_water().map_err(|e| format!("high_water: {e:?}"))?; + let store = Arc::new( + ArenaPageStore::new(Arc::clone(&pool), Arc::clone(&catalog), base) + .map_err(|e| format!("ArenaPageStore::new: {e:?}"))?, + ); + if persist { + store.checkpoint_to(dir.join("main.db.arena")); + } + let reaper = TwoTierReaper::new(Arc::clone(&catalog), Arc::clone(&store)); + Ok(Rig { catalog, store, reaper, _scratch: scratch }) +} + +/// Write `p` fresh pages into `branch`, one epoch each, the way `branch_curve_writes` and +/// `d31_reap_cost` do. +fn write_fresh(rig: &Rig, branch: BranchId, level: usize, p: usize) -> Result, String> { + let mut pages = Vec::with_capacity(p); + for _ in 0..p { + let epoch = rig.catalog.next_epoch(); + let id = rig + .store + .alloc_for(branch, PageType::BTreeLeaf, epoch) + .map_err(|e| format!("alloc_for {branch}: {e:?}"))?; + let h = rig.store.read_page(id).map_err(|e| format!("read_page {id}: {e:?}"))?; + let mut f = h.write(); + f.data[PAGE_HEADER_SIZE] = (level & 0xff) as u8; + stamp_checksum(&mut f.data); + pages.push(id); + } + Ok(pages) +} + +/// Overwrite each of `parents` in `branch` through the store's real copy-on-write entry point. +/// Every answer must be a fresh private copy that leaves the original to its owner (G6). One that +/// is not is counted in `bad` and never repaired, because repairing it would change what is +/// measured. +fn overwrite( + rig: &Rig, + branch: BranchId, + level: usize, + parents: &[PageId], + bad: &mut u64, +) -> Result, String> { + let mut pages = Vec::with_capacity(parents.len()); + for &prev in parents { + let epoch = rig.catalog.next_epoch(); + let cp = rig + .store + .cow_page(prev, branch, epoch) + .map_err(|e| format!("cow_page {prev} in {branch}: {e:?}"))?; + if !cp.copied || cp.retire_previous { + *bad += 1; + } + { + let mut f = cp.handle.write(); + f.data[PAGE_HEADER_SIZE] = (level & 0xff) as u8; + stamp_checksum(&mut f.data); + } + pages.push(cp.page_id); + } + Ok(pages) +} + +/// What a fixture made: the branches sweep 1 must reap and the one it must leave alone. +struct Built { + doomed: Vec, + survivor: BranchId, + /// G6: `cow_page` answers that were not a fresh private copy. Always 0 outside `overwrite`. + bad_cows: u64, +} + +fn build_chain(rig: &Rig, depth: usize, p: usize, overwrites: bool) -> Result { + let mut doomed = Vec::with_capacity(depth.saturating_sub(1)); + let mut survivor = None; + let mut parent = BranchId::TRUNK; + let mut parent_pages: Vec = Vec::new(); + let mut bad_cows = 0u64; + for level in 1..=depth { + let deadline = if level == depth { SURVIVOR_DEADLINE } else { DOOMED_DEADLINE }; + let b = rig + .catalog + .fork(parent, LeaseDeadline(deadline)) + .map_err(|e| format!("fork level {level} off {parent}: {e:?}"))? + .branch_id; + // Written BEFORE the next level forks, so every page here is born before its child's fork + // epoch and sits inside the window the interval rule protects. + parent_pages = if overwrites && level > 1 { + overwrite(rig, b, level, &parent_pages, &mut bad_cows)? + } else { + write_fresh(rig, b, level, p)? + }; + if level == depth { + survivor = Some(b); + } else { + doomed.push(b); + } + parent = b; + } + let survivor = survivor.ok_or_else(|| "depth 0 builds no chain".to_string())?; + Ok(Built { doomed, survivor, bad_cows }) +} + +fn build_fanout(rig: &Rig, depth: usize, p: usize) -> Result { + let mut doomed = Vec::with_capacity(depth.saturating_sub(1)); + let mut survivor = None; + for k in 1..=depth { + let deadline = if k == depth { SURVIVOR_DEADLINE } else { DOOMED_DEADLINE }; + let b = rig + .catalog + .fork(BranchId::TRUNK, LeaseDeadline(deadline)) + .map_err(|e| format!("fork sibling {k}: {e:?}"))? + .branch_id; + write_fresh(rig, b, k, p)?; + if k == depth { + survivor = Some(b); + } else { + doomed.push(b); + } + } + let survivor = survivor.ok_or_else(|| "depth 0 builds no fanout".to_string())?; + Ok(Built { doomed, survivor, bad_cows: 0 }) +} + +/// One reading of the store, split by owner. Both halves of every pair must agree (G2). +#[derive(Clone, Copy, Default)] +struct Census { + /// `PageStore::live_page_count`, the store's net counter. + counter_pages: u64, + /// `allocated_pages(arena).len()` summed over `live_arenas()`: membership, not a net count. + enum_pages: u64, + reaped_pages: u64, + survivor_pages: u64, + /// Owned by neither the doomed set nor the survivor. Must be 0 (G5). + other_pages: u64, + /// `reserved_page_count`, the store's net counter. + counter_reserved: u64, + /// Sizes of every live extent, from `extent_range`. + enum_reserved: u64, + reaped_reserved: u64, + reaped_extents: u64, + live_extents: u64, + /// `pending_len`: pages parked in the pending-free log. + pending: u64, +} + +/// `doomed` is the fixture's own list, not the reaper's answer, so the split cannot agree with +/// the reaper merely because it came from the reaper. +fn census(store: &ArenaPageStore, doomed: &BTreeSet, survivor: u64) -> Result { + let mut c = Census { + counter_pages: store.live_page_count().map_err(|e| format!("live_page_count: {e:?}"))? + as u64, + counter_reserved: store.reserved_page_count() as u64, + pending: store.pending_len() as u64, + ..Census::default() + }; + for (arena, owner) in store.live_arenas() { + let pages = store.allocated_pages(arena).len() as u64; + // `None` would mean the extent vanished between the two reads. Counting it as 0 makes G2 + // fire rather than hiding it. + let size = store.extent_range(arena).map(|(_, n)| n as u64).unwrap_or(0); + c.live_extents += 1; + c.enum_pages += pages; + c.enum_reserved += size; + if doomed.contains(&owner.id) { + c.reaped_pages += pages; + c.reaped_reserved += size; + c.reaped_extents += 1; + } else if owner.id == survivor { + c.survivor_pages += pages; + } else { + c.other_pages += pages; + } + } + Ok(c) +} + +fn check_census(c: &Census, when: &str, fails: &mut Vec) { + if c.counter_pages != c.enum_pages { + fails.push(format!( + "G2 {when}: live_page_count says {} pages, the live extents enumerate {}", + c.counter_pages, c.enum_pages + )); + } + if c.counter_reserved != c.enum_reserved { + fails.push(format!( + "G2 {when}: reserved_page_count says {}, the live extents' sizes sum to {}", + c.counter_reserved, c.enum_reserved + )); + } + if c.other_pages != 0 { + fails.push(format!( + "G5 {when}: {} pages are owned by a branch this fixture did not make", + c.other_pages + )); + } +} + +/// Every pre-registered quantity for one cell. Measured and predicted values use this one struct, +/// so they print under the same keys and are compared field by field. +#[derive(Clone, Copy)] +struct Outcome { + retained_pages: u64, + retained_reserved: u64, + retained_extents: u64, + pending: u64, + survivor_pages: u64, + extra_drain: u64, + orphans: u64, + after_leaf_pages: u64, + after_leaf_reserved: u64, + after_leaf_extents: u64, + after_leaf_pending: u64, + after_leaf_drain: u64, + after_leaf_orphans: u64, +} + +impl Outcome { + fn fields(&self) -> [(&'static str, u64); 13] { + [ + ("retained_pages", self.retained_pages), + ("retained_reserved", self.retained_reserved), + ("retained_extents", self.retained_extents), + ("pending", self.pending), + ("survivor_pages", self.survivor_pages), + ("extra_drain", self.extra_drain), + ("orphans", self.orphans), + ("after_leaf_pages", self.after_leaf_pages), + ("after_leaf_reserved", self.after_leaf_reserved), + ("after_leaf_extents", self.after_leaf_extents), + ("after_leaf_pending", self.after_leaf_pending), + ("after_leaf_drain", self.after_leaf_drain), + ("after_leaf_orphans", self.after_leaf_orphans), + ] + } + + /// PREREG's prediction table: every interior page is retained under a live leaf in `chain` + /// and `overwrite`, none in `fanout`, and nothing at all once the survivor goes. + fn predicted(arm: Arm, depth: usize, p: usize) -> Outcome { + let p = p as u64; + let pinned_branches = match arm { + Arm::Fanout => 0, + Arm::Chain | Arm::Overwrite => (depth - 1) as u64, + }; + let (extents, reserved) = extent_model(p); + Outcome { + retained_pages: p * pinned_branches, + retained_reserved: reserved * pinned_branches, + retained_extents: extents * pinned_branches, + pending: p * pinned_branches, + survivor_pages: p, + extra_drain: 0, + orphans: 0, + after_leaf_pages: 0, + after_leaf_reserved: 0, + after_leaf_extents: 0, + after_leaf_pending: 0, + after_leaf_drain: 0, + after_leaf_orphans: 0, + } + } +} + +/// `(extents, reserved pages)` one branch holds after writing `pages` pages, by the pre-registered +/// doubling rule. +fn extent_model(pages: u64) -> (u64, u64) { + let (mut extents, mut reserved, mut next) = (0u64, 0u64, MODEL_FIRST_EXTENT); + while reserved < pages { + extents += 1; + reserved += next; + next = (next * 2).min(MODEL_EXTENT_CAP); + } + (extents, reserved) +} + +struct Cell { + arm: Arm, + depth: usize, + measured: Outcome, + written: u64, + reaped: usize, + total_after_sweep1: u64, + sweep1_ms: f64, + sweep2_ms: f64, + guard_failures: Vec, +} + +/// One flushed line before each phase. A run killed by `timeout` then names the cell and phase it +/// stopped in, and its last finished row cannot be mistaken for the whole run. +fn progress(tag: &str, phase: &str) { + println!("progress {tag} phase={phase}"); + let _ = std::io::stdout().flush(); +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +fn run_cell(arm: Arm, depth: usize, p: usize, persist: bool, tag: &str) -> Result { + let rig = rig(arm, depth, persist)?; + let built = match arm { + Arm::Fanout => build_fanout(&rig, depth, p)?, + Arm::Chain => build_chain(&rig, depth, p, false)?, + Arm::Overwrite => build_chain(&rig, depth, p, true)?, + }; + let doomed: BTreeSet = built.doomed.iter().map(|b| b.id).collect(); + let survivor = built.survivor; + let mut fails: Vec = Vec::new(); + + // ---- G1: the fixture fired ------------------------------------------------------------------ + let before = census(&rig.store, &doomed, survivor.id)?; + check_census(&before, "before sweep 1", &mut fails); + let want_written = (p * depth) as u64; + if before.enum_pages != want_written { + fails.push(format!( + "G1: {} pages allocated before sweep 1, the fixture wrote P*D = {want_written}", + before.enum_pages + )); + } + let want_depth = match arm { + Arm::Fanout => 1, + Arm::Chain | Arm::Overwrite => depth as u32, + }; + match rig.catalog.get(survivor) { + Ok(r) if r.depth == want_depth => {} + Ok(r) => fails.push(format!("G1: survivor {survivor} has depth {}, not {want_depth}", r.depth)), + Err(e) => fails.push(format!("G1: survivor {survivor} unreadable before sweep 1: {e:?}")), + } + if built.bad_cows != 0 { + fails.push(format!( + "G6: {} cow_page calls were not a fresh private copy of an ancestor's page", + built.bad_cows + )); + } + + // ---- sweep 1: every branch but the survivor ------------------------------------------------ + progress(tag, "sweep1"); + let t = Instant::now(); + let reaped1 = rig + .reaper + .reap_expired(DOOMED_DEADLINE) + .map_err(|e| format!("sweep 1 reap_expired: {e:?}"))?; + let sweep1_ms = ms(t); + let got1: BTreeSet = reaped1.iter().map(|b| b.id).collect(); + if got1 != doomed || reaped1.len() != built.doomed.len() { + fails.push(format!( + "G3: sweep 1 reaped {} branches ({} distinct), expected exactly the {} non-survivors", + reaped1.len(), + got1.len(), + built.doomed.len() + )); + } + // The collectors production runs after a sweep. Both are predicted to free nothing here: every + // retained page is still pinned, and no extent is both ownerless and empty. + let drained1 = rig.reaper.drain_pending().map_err(|e| format!("drain_pending after sweep 1: {e:?}"))?; + let orphans1 = rig + .reaper + .collect_orphaned_extents() + .map_err(|e| format!("collect_orphaned_extents after sweep 1: {e:?}"))?; + + // ---- G4: the survivor lives and the doomed are gone ---------------------------------------- + match rig.catalog.get(survivor) { + Ok(r) if r.state == BranchState::Live => {} + Ok(r) => fails.push(format!("G4: survivor {survivor} reads {:?} after sweep 1", r.state)), + Err(e) => fails.push(format!("G4: survivor {survivor} unreadable after sweep 1: {e:?}")), + } + let not_reaped = built + .doomed + .iter() + .filter(|b| !matches!(rig.catalog.get_raw(b.id), Ok(r) if r.state == BranchState::Reaped)) + .count(); + if not_reaped != 0 { + fails.push(format!("G4: {not_reaped} doomed branches do not read Reaped after sweep 1")); + } + let after1 = census(&rig.store, &doomed, survivor.id)?; + check_census(&after1, "after sweep 1", &mut fails); + + // ---- sweep 2, control (a): the survivor goes too ------------------------------------------- + progress(tag, "sweep2"); + let t = Instant::now(); + let reaped2 = rig + .reaper + .reap_expired(SURVIVOR_DEADLINE) + .map_err(|e| format!("sweep 2 reap_expired: {e:?}"))?; + let sweep2_ms = ms(t); + if reaped2.len() != 1 || reaped2[0].id != survivor.id { + let got: Vec = reaped2.iter().map(|b| b.to_string()).collect(); + fails.push(format!("G3: sweep 2 reaped [{}], expected only {survivor}", got.join(", "))); + } + let drained2 = rig.reaper.drain_pending().map_err(|e| format!("drain_pending after sweep 2: {e:?}"))?; + let orphans2 = rig + .reaper + .collect_orphaned_extents() + .map_err(|e| format!("collect_orphaned_extents after sweep 2: {e:?}"))?; + let after2 = census(&rig.store, &doomed, survivor.id)?; + check_census(&after2, "after sweep 2", &mut fails); + + let refused = rig.reaper.refused_reaps(); + if refused != 0 { + fails.push(format!("G3: the reaper refused {refused} reaps")); + } + + Ok(Cell { + arm, + depth, + measured: Outcome { + retained_pages: after1.reaped_pages, + retained_reserved: after1.reaped_reserved, + retained_extents: after1.reaped_extents, + pending: after1.pending, + survivor_pages: after1.survivor_pages, + extra_drain: drained1 as u64, + orphans: orphans1 as u64, + after_leaf_pages: after2.enum_pages, + after_leaf_reserved: after2.enum_reserved, + after_leaf_extents: after2.live_extents, + after_leaf_pending: after2.pending, + after_leaf_drain: drained2 as u64, + after_leaf_orphans: orphans2 as u64, + }, + written: before.enum_pages, + reaped: reaped1.len(), + total_after_sweep1: after1.enum_pages, + sweep1_ms, + sweep2_ms, + guard_failures: fails, + }) +} + +fn print_fields(label: &str, tag: &str, o: &Outcome) { + let kv: Vec = o.fields().iter().map(|(k, v)| format!("{k}={v}")).collect(); + println!("{label} {tag} {}", kv.join(" ")); +} + +/// Parse a comma list, REFUSING the whole run on any token it cannot read. Silently dropping a +/// token would run a smaller experiment under the requested one's name. +fn parse_list(raw: &str, what: &str, parse: impl Fn(&str) -> Option) -> Result, String> { + let mut out = Vec::new(); + for tok in raw.split(',') { + let tok = tok.trim(); + match parse(tok) { + Some(v) => out.push(v), + None => return Err(format!("cannot parse {what} {tok:?} in {raw:?}")), + } + } + Ok(out) +} + +fn arg<'a>(args: &'a [String], i: usize, default: &'a str) -> &'a str { + args.get(i).map(String::as_str).unwrap_or(default) +} + +fn parse_args(args: &[String]) -> Result<(Vec, usize, Vec), String> { + let depths = parse_list(arg(args, 1, "1,10,100,1000"), "depth", |s| { + s.parse::().ok().filter(|&d| d >= 1) + })?; + let raw_p = arg(args, 2, "2"); + let p = raw_p.trim().parse::().ok().filter(|&p| p >= 1).ok_or_else(|| { + format!("cannot parse pages_per_level {raw_p:?}: it must be an integer >= 1, or nothing is written") + })?; + let arms = parse_list(arg(args, 3, "fanout,chain,overwrite"), "arm", Arm::parse)?; + Ok((depths, p, arms)) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let prov = ferrodb::build_provenance(); + let sha = prov.split_whitespace().nth(1).unwrap_or("unknown").to_string(); + // G0. A row that cannot name the code it ran is not attributable to anything. + let provenance_failure = if sha == "unknown" || prov.contains("+DIRTY") { + Some(format!("G0: build provenance is {prov:?}, so no row can be attributed to a commit")) + } else { + None + }; + let (depths, p, arms) = match parse_args(&args) { + Ok(v) => v, + Err(e) => { + println!("NOT A RESULT: {e}. Nothing was run."); + std::process::exit(2); + } + }; + let persist = std::env::var("D16_PERSIST").map(|v| v != "0").unwrap_or(true); + let arm_names: Vec<&str> = arms.iter().map(|a| a.name()).collect(); + let (model_extents, model_reserved) = extent_model(p as u64); + + println!("D16 chain retention: arena pages a reaped interior chain keeps allocated while its leaf lives."); + println!("build {prov}"); + println!("pre-registration: bench/d16_chain/PREREG.md, committed before this harness"); + println!( + "catalog: TableBranchCatalog on a sidecar file. Free-space map persistence: {}", + if persist { "ON (as cli.rs ships it)" } else { "OFF (D16_PERSIST=0)" } + ); + println!("reap: Reaper::reap_expired only, i.e. expired_candidates (deepest first) + reap_if_still_expired,"); + println!(" the halves lease_thread::scan_once runs. Every reap ends in its own drain; each sweep is then"); + println!(" followed by drain_pending and collect_orphaned_extents, so retention is the steady state."); + println!( + "P = {p} pages per level. depths {depths:?}. arms [{}]. Extent model {MODEL_FIRST_EXTENT} doubling to \ + {MODEL_EXTENT_CAP}: {p} pages -> {model_extents} extents, {model_reserved} reserved pages per branch", + arm_names.join(", ") + ); + println!("instrument: live_page_count AND the allocated pages enumerated over live_arenas, which must agree;"); + println!(" reserved_page_count AND the live extents' sizes, which must agree"); + println!("per cell: `measured` and `predicted` print the same keys; `verdict` is MATCH, MISMATCH , or"); + println!(" NOT A RESULT ; `context` is not pre-registered and its wall-clock ms are load-sensitive"); + if let Some(f) = &provenance_failure { + println!("NOT A RESULT: {f}"); + } + println!(); + + let mut cells: Vec = Vec::new(); + let mut not_a_result = 0usize; + let mut mismatches = 0usize; + for &depth in &depths { + for &arm in &arms { + let tag = format!("arm={} D={depth} P={p} sha={sha}", arm.name()); + progress(&tag, "build"); + let mut cell = match run_cell(arm, depth, p, persist, &tag) { + Ok(c) => c, + Err(e) => { + not_a_result += 1; + println!("verdict {tag} NOT A RESULT: harness error: {e}"); + continue; + } + }; + if let Some(f) = &provenance_failure { + cell.guard_failures.push(f.clone()); + } + let want = Outcome::predicted(arm, depth, p); + print_fields("measured ", &tag, &cell.measured); + print_fields("predicted", &tag, &want); + let got_fields = cell.measured.fields(); + let want_fields = want.fields(); + let wrong: Vec<&str> = got_fields + .iter() + .zip(want_fields.iter()) + .filter(|(g, w)| g.1 != w.1) + .map(|(g, _)| g.0) + .collect(); + let per_interior = if depth > 1 { + format!("{:.3}", cell.measured.retained_pages as f64 / (depth - 1) as f64 / p as f64) + } else { + "n/a".to_string() + }; + println!( + "context {tag} written={} reaped={} total_pages_after_sweep1={} \ + retained_pages_per_reaped_branch_per_page={per_interior} retained_reserved_bytes={} \ + sweep1_ms={:.1} sweep2_ms={:.1}", + cell.written, + cell.reaped, + cell.total_after_sweep1, + cell.measured.retained_reserved * PAGE_SIZE as u64, + cell.sweep1_ms, + cell.sweep2_ms, + ); + if !cell.guard_failures.is_empty() { + not_a_result += 1; + println!("verdict {tag} NOT A RESULT: {}", cell.guard_failures.join(" | ")); + } else if !wrong.is_empty() { + mismatches += 1; + println!("verdict {tag} MISMATCH {}", wrong.join(",")); + } else { + println!("verdict {tag} MATCH"); + } + cells.push(cell); + } + } + + println!(); + // The slope the brief asked for, over the clean cells only. It is the same quantity as the + // per-cell `retained_pages_per_reaped_branch_per_page`, taken across the depth axis, so a + // one-depth run still reports it cell by cell. + for &arm in &arms { + let mut pts: Vec<(usize, u64)> = cells + .iter() + .filter(|c| c.arm == arm && c.guard_failures.is_empty()) + .map(|c| (c.depth, c.measured.retained_pages)) + .collect(); + pts.sort_unstable(); + let pre_registered = match arm { + Arm::Fanout => "0", + Arm::Chain | Arm::Overwrite => "1", + }; + match (pts.first(), pts.last()) { + (Some(&(d0, r0)), Some(&(d1, r1))) if d1 > d0 => { + let slope = (r1 as f64 - r0 as f64) / (d1 - d0) as f64 / p as f64; + println!( + "slope arm={} P={p} sha={sha} retained pages per level per page written = {slope:.4} \ + over D={d0}..{d1} (pre-registered {pre_registered})", + arm.name() + ); + } + _ => println!( + "slope arm={} P={p} sha={sha} needs two clean depths; see each context line", + arm.name() + ), + } + } + // Pre-registered: `overwrite` retains exactly what `chain` does, because the interval rule has + // no input saying a child superseded a page. A difference means it can see that after all. + for &depth in &depths { + let clean = |a: Arm| { + cells.iter().find(|c| c.arm == a && c.depth == depth && c.guard_failures.is_empty()) + }; + if let (Some(ch), Some(ow)) = (clean(Arm::Chain), clean(Arm::Overwrite)) { + let ch_fields = ch.measured.fields(); + let ow_fields = ow.measured.fields(); + let differ: Vec<&str> = ch_fields + .iter() + .zip(ow_fields.iter()) + .filter(|(a, b)| a.1 != b.1) + .map(|(a, _)| a.0) + .collect(); + if differ.is_empty() { + println!("compare D={depth} P={p} sha={sha} overwrite == chain on every key (pre-registered: equal)"); + } else { + mismatches += 1; + println!( + "compare D={depth} P={p} sha={sha} MISMATCH overwrite != chain on {} (pre-registered: \ + equal; the rule distinguished superseded pages)", + differ.join(",") + ); + } + } + } + + let planned = depths.len() * arms.len(); + if not_a_result > 0 || cells.is_empty() { + println!("NOT A RESULT: {not_a_result} of {planned} cells failed a guard or the harness. Exit 2."); + std::process::exit(2); + } + if mismatches > 0 { + println!( + "RESULT WITH MISMATCH: all {planned} cells held their guards; {mismatches} pre-registered \ + predictions failed. Read PREREG, 'What each other outcome would mean'. Exit 1." + ); + std::process::exit(1); + } + println!("RESULT: all {planned} cells held their guards and matched the pre-registration. Exit 0."); +} From 6ceb719fbe69af8fcabe70c48c073f7bc086f152 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:59:41 -0400 Subject: [PATCH 089/315] UNBUILT D207: red tests for the leaked accept slot, the outbound byte bound and the per-frame config budget Written against main 9aa6968 before any fix, and every one compiles against that tree: - a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved (portable; drives accept_loop with a zero poll_interval so every accepted socket fails its setup) - a_peer_that_resets_before_accept_cannot_fill_the_connection_cap (macOS only; the RST trigger 207d362 recorded as measured 40/40, reproduced here as a claim, not by me) - one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound - one_frame_cannot_spend_more_config_nodes_than_its_whole_budget (from 1b3a6a6) - a_frame_whose_configurations_exceed_the_budget_is_refused_to_its_sender (new: the encoder twin) Never compiled or run: quiet mode. Predictions in bench/d207/PREREG.md. --- src/consensus/tests_transport.rs | 352 +++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index ae540583..4fce41cb 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -1951,3 +1951,355 @@ fn the_spawn_failure_teardown_actually_stops_the_threads_it_is_given() { assert!(stop.load(Ordering::SeqCst), "the teardown did not raise the stop flag"); assert!(ob.state.lock().unwrap().stopped, "the teardown did not stop the outbox"); } + + +// --------------------------------------------------------------------------------------------- +// D207 — the fixes F3-transport wrote on 2026-08-28 (207d362, 1b3a6a6) and never merged. Written +// red against main 9aa6968, BEFORE the port; every test here compiles against that tree. +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved() { + // **The cap turned into the hole it was added to close.** `accept_loop` reserves the slot + // (`map.insert(id, mine)`) and only then configures the socket, and when that failed it did + // `continue` without removing the entry. `mine` is a `try_clone`, so the entry kept a descriptor + // and a connection nobody reads; after `max_inbound_conns` of them every real peer is refused for + // ever while `live_inbound_conns()` sits at the cap and no counter moves. + // + // The failure is forced with a zero `poll_interval`: std refuses a zero read timeout with + // `InvalidInput` before any system call, on every platform, so every accepted socket fails its + // setup. `Transport::start` refuses a zero `poll_interval` for exactly that reason, so the loop is + // driven directly. The zero is only the means of failing the setup; what is under test is what + // the loop does AFTER it fails. The macOS test below reaches the same exit the way a peer does. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut opts = fast(); + opts.poll_interval = Duration::ZERO; + opts.max_inbound_conns = 4; + // Long, so a connection whose setup wrongly SUCCEEDED would sit in the registry for the whole + // wait below, rather than be refused at its handshake and leave it empty for the wrong reason. + opts.handshake_deadline = Duration::from_secs(60); + + let stop = Arc::new(AtomicBool::new(false)); + let counters = Arc::new(Counters::default()); + let conns: Arc>> = Arc::new(Mutex::new(BTreeMap::new())); + let ids = Arc::new(AtomicU64::new(0)); + let (tx, _rx) = mpsc::channel::<(Message, usize)>(); + let h = { + let (stop, counters, conns, ids) = + (Arc::clone(&stop), Arc::clone(&counters), Arc::clone(&conns), Arc::clone(&ids)); + std::thread::spawn(move || { + accept_loop(listener, NodeId(1), tx, stop, counters, conns, ids, opts, None) + }) + }; + + // Twice the cap. If a failed setup leaks its slot, the first four fill the cap and the other four + // are refused as if the node were busy. + const TRIES: u64 = 8; + let mut held = Vec::new(); + for _ in 0..TRIES { + held.push(TcpStream::connect(addr).unwrap()); + } + // An id is minted once per accepted connection, before anything else is done with it, so this + // waits for the loop to have taken every one rather than for a guessed length of time. + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline && ids.load(Ordering::SeqCst) < TRIES { + std::thread::sleep(Duration::from_millis(10)); + } + let taken = ids.load(Ordering::SeqCst); + // Read while the loop is still RUNNING. After a join the registry would also read empty for a + // connection that was wrongly served, because its own thread releases the slot on the way out; + // before the join, only a released slot reads empty. + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && !conns.lock().unwrap().is_empty() { + std::thread::sleep(Duration::from_millis(10)); + } + let leaked = conns.lock().unwrap().len(); + // Stopped and joined BEFORE any assertion: with a zero poll the loop never sleeps, and a panic + // here would leave it spinning for the rest of the test binary. + stop.store(true, Ordering::SeqCst); + h.join().expect("the accept loop panicked"); + + assert_eq!(taken, TRIES, "the accept loop took {taken} of {TRIES} connections"); + assert_eq!( + leaked, 0, + "{TRIES} connections failed their socket setup and the registry still holds {leaked} of \ + them. Each is a slot and a descriptor no thread will ever release: at the cap of 4 every \ + real peer is refused for ever, while this node's outbound meters read healthy" + ); + // Counted, never silent: every one of them was closed without being served. + assert_eq!( + counters.refused_conns.load(Ordering::SeqCst), + TRIES, + "a connection closed because its socket could not be configured was not counted" + ); + drop(held); +} + +#[cfg(target_os = "macos")] +#[test] +fn a_peer_that_resets_before_accept_cannot_fill_the_connection_cap() { + // The same exit, reached the way an unauthenticated peer reaches it. 207d362 recorded, as + // measured on this platform (40 of 40), that a connection reset while it waits in the listen + // queue is still returned by `accept`, and that setting a read timeout on it then fails with + // EINVAL. So `max_inbound_conns` connect-and-reset pairs from anything that can reach the port + // would fill the cap with slots no thread releases, and the node would be deaf to every real + // peer. macOS only because that is where the trigger was measured; the test above pins the exit + // itself on every platform. + use std::os::fd::AsRawFd; + + // An RST on close needs SO_LINGER with a zero timeout, and std's `set_linger` is still unstable + // (rust#88494), so it is set directly. Values from the macOS SDK's , where + // `struct linger` is `{ int l_onoff; int l_linger; }`: two ints, laid out as `[i32; 2]`. + unsafe extern "C" { + fn setsockopt( + fd: i32, + level: i32, + name: i32, + value: *const std::ffi::c_void, + len: u32, + ) -> i32; + } + const SOL_SOCKET: i32 = 0xffff; + const SO_LINGER: i32 = 0x0080; + + let mut opts = fast(); + opts.max_inbound_conns = 4; + // A slow accept poll, so the resets below land while the loop is asleep and each connection is + // already reset by the time `accept` hands it over, which is the case under test. + opts.poll_interval = Duration::from_millis(100); + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + let t = Transport::from_listener(NodeId(1), l, BTreeMap::new(), opts).unwrap(); + + const RESETS: usize = 16; + for i in 0..RESETS { + let s = TcpStream::connect(addr).unwrap(); + let linger: [i32; 2] = [1, 0]; // l_onoff = 1, l_linger = 0 + // SAFETY: `s` is an open socket for the duration of the call, and `linger` is a live + // `struct linger` whose size is passed with it. + let rc = unsafe { + setsockopt( + s.as_raw_fd(), + SOL_SOCKET, + SO_LINGER, + linger.as_ptr().cast::(), + std::mem::size_of_val(&linger) as u32, + ) + }; + assert_eq!(rc, 0, "SO_LINGER on reset {i}: {}", std::io::Error::last_os_error()); + drop(s); // an RST, not a FIN + } + + // A real peer, queued BEHIND every reset. The listen queue is first in, first out, so by the + // time this one is accepted the loop has already dealt with all sixteen. + let mut s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut hs = Vec::new(); + crate::replication::write_handshake(&mut hs).unwrap(); + let _ = s.write_all(&hs); + let _ = s.flush(); + let mut theirs = [0u8; 6]; + let answered = s.read_exact(&mut theirs).is_ok(); + assert!( + answered, + "after {RESETS} peers reset before being accepted, a real peer was not answered \ + (live_inbound_conns={}, refused_conns={}): the cap is full of slots no thread will release", + t.live_inbound_conns(), + t.refused_conns() + ); + // Anti-vacuity. With the slot released, nothing here can fill the cap of 4, so the only + // refusals are failed setups. None at all means `accept` never handed over a reset socket, or + // its setup succeeded, and this test exercised nothing. + assert!( + t.refused_conns() > 0, + "no reset connection failed its socket setup, so the trigger 207d362 recorded did not fire \ + and this test exercised nothing" + ); + drop(s); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && t.live_inbound_conns() > 0 { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(t.live_inbound_conns(), 0, "slots are still held after every peer has gone"); +} + +#[test] +fn one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound() { + // **The outbound half of D207.** A peer's queue was bounded in MESSAGES only, and a message is + // anything from 18 bytes to 8 MiB, so the default depth of 1024 let one peer's queue hold + // 1024 x 8 MiB = 8.6 GB. 207d362 added a byte bound defaulting to 64 MiB, and it never merged. + // + // The DEFAULT options are what is under test, because the default is what a node runs: + // `NodeOptions` takes `TransportOptions::default()`. Only the timings are shortened. + const DEFAULT_QUEUE_BYTES: usize = 64 * 1024 * 1024; + let opts = TransportOptions { + poll_interval: Duration::from_millis(5), + reconnect_delay: Duration::from_millis(5), + ..Default::default() + }; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + // Port 1 refuses every connection, so the sender never gets past its dial and never drains: + // every frame stays queued, and what the queue holds is decided by its bounds alone. + let dead: SocketAddr = "127.0.0.1:1".parse().unwrap(); + let t = Transport::from_listener( + NodeId(1), + l, + BTreeMap::from([(NodeId(2), dead)]), + opts.clone(), + ) + .unwrap(); + + let payload = vec![0x5a_u8; 7 * 1024 * 1024]; + let msg = |term: u64| Message { + from: NodeId(1), + to: NodeId(2), + term, + body: Body::Append { + prev_round: 0, + prev_term: 0, + entries: vec![Entry { + term, + round: 1, + command: Command::WalBatch { start_lsn: 0, bytes: payload.clone() }, + }], + commit: 0, + }, + }; + // Every frame is the same length: the only field that varies is the fixed-width term. + let frame_len = encode(&msg(1)).unwrap().len(); + let fit = DEFAULT_QUEUE_BYTES / frame_len; + // Twelve 7 MiB frames is 84 MiB: over the byte bound, and far under the depth, so a queue + // bounded only in messages keeps every one of them. + const SENT: u64 = 12; + assert!( + (SENT as usize) < opts.queue_depth && (SENT as usize) > fit, + "the fixture no longer separates the two bounds: {SENT} frames, depth {}, {fit} fit in bytes", + opts.queue_depth + ); + for term in 1..=SENT { + t.send(&msg(term)).unwrap(); + } + + let st = t.outboxes[&NodeId(2)].state.lock().unwrap(); + let held: usize = st.queue.iter().map(|f| f.len()).sum(); + assert!( + held <= DEFAULT_QUEUE_BYTES, + "one unreachable peer's queue holds {held} bytes in {} frames, over the \ + {DEFAULT_QUEUE_BYTES}-byte default bound. Bounded only in messages, the default depth of {} \ + lets it hold {} bytes", + st.queue.len(), + opts.queue_depth, + opts.queue_depth * MAX_FRAME_BYTES + ); + // As many as fit, and not fewer: a bound that over-drops tells the peer less than it could. + assert_eq!(st.queue.len(), fit, "the queue holds {} frames where {fit} fit", st.queue.len()); + // ...and the ones dropped are the OLDEST, for the reason the depth bound drops the oldest. + let terms: Vec = st + .queue + .iter() + .map(|f| decode(&f[5..]).expect("a queued frame must still decode").term) + .collect(); + let newest: Vec = ((SENT - fit as u64 + 1)..=SENT).collect(); + assert_eq!(terms, newest, "the byte bound kept {terms:?}; it must keep the newest"); + drop(st); + assert_eq!(t.dropped_to(NodeId(2)), SENT - fit as u64, "a byte-bound drop was not counted"); +} + +#[test] +fn one_frame_cannot_spend_more_config_nodes_than_its_whole_budget() { + // Ported from 1b3a6a6. `MAX_CONFIG_NODES` bounds ONE configuration, and `Config::with_learners` + // retains learners with `Vec::contains` (config.rs), so a configuration of n members and n + // learners costs n x n comparisons however this file validates it. A frame may carry many: an + // 8 MiB Append holds about 1018 configurations of 1024 + 1024 ids, and 1018 x 1024 x 1024 is + // 1.07e9 comparisons for one frame. So the budget is per FRAME. + // + // Five configurations of 1024 members is 5120 ids against a 4096-id budget: the first four fit + // and the fifth must be refused. + let per_cfg = MAX_CONFIG_NODES as u32; + let frame = |configs: u32| { + let mut b = Vec::new(); + b.extend_from_slice(&1u32.to_be_bytes()); // from + b.extend_from_slice(&2u32.to_be_bytes()); // to + b.extend_from_slice(&1u64.to_be_bytes()); // term + b.push(4); // Append + b.extend_from_slice(&0u64.to_be_bytes()); // prev_round + b.extend_from_slice(&0u64.to_be_bytes()); // prev_term + b.extend_from_slice(&0u64.to_be_bytes()); // commit + b.extend_from_slice(&configs.to_be_bytes()); // entries + for e in 0..u64::from(configs) { + b.extend_from_slice(&1u64.to_be_bytes()); // entry term + b.extend_from_slice(&(e + 1).to_be_bytes()); // entry round + b.push(7); // Membership + b.extend_from_slice(&1u64.to_be_bytes()); // config version + b.extend_from_slice(&1u64.to_be_bytes()); // config term + b.extend_from_slice(&per_cfg.to_be_bytes()); // members + for i in 1..=per_cfg { + b.extend_from_slice(&i.to_be_bytes()); + } + b.extend_from_slice(&0u32.to_be_bytes()); // no learners + } + b + }; + + match decode(&frame(5)) { + Ok(_) => panic!( + "five configurations of {per_cfg} members in one frame were accepted, so the cap still \ + bounds each configuration and nothing bounds the frame" + ), + Err(e) => { + let e = format!("{e}"); + assert!(e.contains("budget is left"), "refused, but not by the frame budget: {e}"); + // Named in the refusal, so a reader knows WHICH entry exhausted it. + assert!(e.contains("entry 4 of 5"), "the refusal does not say where: {e}"); + } + } + + // Anti-vacuity: four fit exactly (4 x 1024 = 4096), so the refusal is about crossing the budget + // and not about a frame carrying several configurations. + let m = decode(&frame(4)).expect("four configurations totalling exactly the budget must decode"); + let Body::Append { entries, .. } = &m.body else { panic!("shape changed") }; + assert_eq!(entries.len(), 4); +} + +#[test] +fn a_frame_whose_configurations_exceed_the_budget_is_refused_to_its_sender() { + // The frame budget has a sender-side twin for the reason the per-configuration cap has one in + // `encode_config`: without it this node frames an Append that every peer's decoder refuses, and + // never learns why. That is a cluster that silently cannot replicate. Not in 1b3a6a6, which + // bounded the decoder only. + let cfg = Config::new((1..=MAX_CONFIG_NODES as u32).map(NodeId), 1, 1); + let append = |n: u64| Message { + from: NodeId(1), + to: NodeId(2), + term: 1, + body: Body::Append { + prev_round: 0, + prev_term: 0, + entries: (1..=n) + .map(|round| Entry { + term: 1, + round, + command: Command::Membership { config: cfg.clone() }, + }) + .collect(), + commit: 0, + }, + }; + match encode(&append(5)) { + Ok(f) => panic!( + "an Append carrying five configurations of {MAX_CONFIG_NODES} members was framed ({} \ + bytes); every peer's decoder refuses it, so this node would send a frame that can \ + never land", + f.len() + ), + Err(e) => { + assert!(format!("{e}").contains("budget"), "refused, but not by the frame budget: {e}") + } + } + // Anti-vacuity: four encode and round-trip, so the sender's limit is the decoder's and no + // stricter. + let m = append(4); + assert_eq!(decode_frame(&encode(&m).unwrap()).unwrap(), m, "four configurations must round trip"); +} From 4af69c3ec2459a166dc04bf0230908bb00879018 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:00:57 -0400 Subject: [PATCH 090/315] UNBUILT D207: pre-register the red and green runs before the fix Predicted values for each of the five new tests at 6ceb719 (red) and at the fix (green), the test counts per platform, and what each outcome of the macOS reset trigger would mean. Nothing has been built or run. --- bench/d207/PREREG.md | 68 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 bench/d207/PREREG.md diff --git a/bench/d207/PREREG.md b/bench/d207/PREREG.md new file mode 100644 index 00000000..4ce51c0b --- /dev/null +++ b/bench/d207/PREREG.md @@ -0,0 +1,68 @@ +# D207 — pre-registration, written BEFORE any build or run + +Branch `d207-transport-port`, base main `9aa6968`. Red tests at `6ceb719`. The fix follows this file. +Nothing on this branch has been compiled or run (quiet mode), so every line below is a prediction. +Amendments append only. + +**Instrument**, for every count below: `timeout 1800 cargo test --lib consensus::transport::tests::` +from the worktree, at default QoS (never `taskpolicy -b`), under the shared suite lock. The filter +is a substring match on the module path `consensus::transport::tests::` (`src/lib.rs:21` +`pub mod consensus`, `src/consensus/mod.rs:62` `pub mod transport`, `transport.rs` `mod tests` at the +file's end). The five new names occur once each in `src/`, so no other module matches. + +**Counts.** `src/consensus/tests_transport.rs` has 45 `#[test]` at `9aa6968` and 50 at `6ceb719`. +The file has no test macros, and only one new `#[cfg]`: the macOS test. So the module runs **50 +tests on macOS** and **49 on Linux and Windows**. Counted from the attributes, not from a run. + +## Run R — RED, at `6ceb719` (tests added, `src/consensus/transport.rs` untouched) + +Predicted on macOS: **compiles; `45 passed; 5 failed`**, rc=101. On Linux and Windows: `45 passed; +4 failed` (no macOS test). A compile error is a failure of this pre-registration: record it, then +fix it in its own commit. + +| test | predicted failure (the assertion that fires, with its values) | +|---|---| +| `a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved` | `leaked == 0` fails with **left 4, right 0**, after its 10 s wait. The first 4 connections reserve a slot and leak it at the failed setup; connections 5–8 are refused as cap-full. `taken == 8` passes before it. | +| `a_peer_that_resets_before_accept_cannot_fill_the_connection_cap` (macOS) | the `answered` assertion: the real peer gets EOF instead of a handshake, with **`live_inbound_conns=4`** and `refused_conns` between 1 and **13** (13 if all 16 resets reach the failed-setup exit: 12 cap-full resets plus the real peer). ⚠ CONDITIONAL on 207d362's recorded trigger (below). | +| `one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound` | `held <= 67108864` fails: **`held` = 88081332 bytes in 12 frames**. The frame is 7340111 bytes (5 header + 21 envelope + 1 kind + 24 + 4 count + 16 entry + 1 tag + 8 lsn + 4 len + 7340032 payload), so `fit` = 9 and the fixture guard passes (12 < 1024, 12 > 9). | +| `one_frame_cannot_spend_more_config_nodes_than_its_whole_budget` | the `Ok(_)` arm panics: "five configurations of 1024 members in one frame were accepted". | +| `a_frame_whose_configurations_exceed_the_budget_is_refused_to_its_sender` | the `Ok(f)` arm panics: "…was framed (**20735** bytes)". 20735 = 5 + 45 + 5 × 4137. | + +All 45 tests that exist at `9aa6968` pass. + +## Run G — GREEN, at the fix commit + +Predicted: **compiles; 50 passed, 0 failed** on macOS (49 on Linux and Windows). + +Specific values the new tests read on the fixed tree: + +- setup-failure test: `taken` 8, `leaked` 0, `refused_conns` **exactly 8**. The slot is released at + each failure, so the map never holds more than 1 and nothing is refused as cap-full. +- reset test (macOS): answered; `refused_conns` ≥ 1 (only failed setups can be refused, because the cap + of 4 is never reached); `live_inbound_conns` returns to 0. +- byte-bound test: `held` = 9 × 7340111 = **66060999** ≤ 67108864; queue terms **[4..=12]**; + `dropped_to(n2)` **3**. +- frame-budget decode test: refused with "budget is left" and "entry 4 of 5"; four configurations decode. +- encoder twin: refused with a message containing "budget"; four configurations round-trip. + +## The trigger the macOS test depends on, and what each outcome means + +207d362's message records, as a reviewer's measurement on this platform, that a peer which connects +and RSTs before `accept()` leaves a socket on which `SO_RCVTIMEO` returns EINVAL, 40 of 40 trials. +**I have not reproduced it.** Two ways it could fail to hold, each visible in the runs: + +- `accept` does not hand back a reset socket (for example, std's `accept` returns an error when the + kernel gives no peer address). Then nothing is reserved, and on BOTH trees the real peer is + answered and `refused_conns` is 0. Run R fails at the anti-vacuity assertion instead of + `answered`. That falsifies the claim that an unauthenticated remote can reach this exit on macOS. + The portable test still pins the exit. D207's severity is then "any setup failure leaks" rather + than "a remote can make the node deaf". +- The setup succeeds on a reset socket. Same observable, same reading. + +## What would falsify the diagnosis + +- The setup-failure test passing at `6ceb719`, or failing there with `leaked` ≠ 4. +- The byte-bound test at `6ceb719` failing at any assertion other than `held`, or with a `held` other + than 88081332. +- Any of the 45 existing tests failing at `6ceb719`. The red commit touches no line that exists at + `9aa6968` (`git diff 9aa6968 6ceb719 --numstat` = `352 0`). From cfe9cf5151fe6f50515ae071fea8eb13600f4bc9 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:04:13 -0400 Subject: [PATCH 091/315] UNBUILT D207: release the accept slot on every exit, bound each peer's queue in bytes, budget config ids per frame Ported by meaning from F3-transport's 207d362 and 1b3a6a6, which never merged. - accept_loop: the ConnRegistration guard is now made in the critical section that reserves the slot and moved into the connection thread, so the failed socket setup (which leaked the slot and a descriptor), the failed spawn and every conn_loop return all release it. The failed setup is counted in refused_conns and the socket shut down, as 207d362 did. - TransportOptions::queue_bytes (default 64 MiB): Outbox::push drops the oldest while either the depth or the byte bound binds; a frame larger than the whole bound is still admitted alone. The sender's pop and both teardowns keep OutboxState::bytes equal to the queue's sum. - MAX_FRAME_CONFIG_NODES (4096) charged by decode_node_list across every configuration in a frame, AFTER the per-configuration cap so each keeps its own refusal. Unlike 1b3a6a6, the encoder charges the same budget, so a sender never frames what every peer refuses; a const assert keeps one maximal configuration inside the budget. tests_transport.rs: only the new fields added to fast() and the two Outbox literals; no assertion changed. Never compiled or run: quiet mode. --- src/consensus/tests_transport.rs | 17 ++- src/consensus/transport.rs | 252 ++++++++++++++++++++++++------- 2 files changed, 212 insertions(+), 57 deletions(-) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index 4fce41cb..e8842bac 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -860,6 +860,7 @@ fn fast() -> TransportOptions { inbox_bytes: 32 * 1024 * 1024, max_inbound_conns: 256, idle_deadline: Duration::from_secs(60), + queue_bytes: 64 * 1024 * 1024, } } @@ -1488,9 +1489,15 @@ fn the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives() { // up, so the peer would be told something that is permanently out of date. let ob = Outbox { addr: "127.0.0.1:1".parse().unwrap(), - state: Mutex::new(OutboxState { queue: VecDeque::new(), live: None, stopped: false }), + state: Mutex::new(OutboxState { + queue: VecDeque::new(), + bytes: 0, + live: None, + stopped: false, + }), woken: Condvar::new(), depth: 4, + max_bytes: 64 * 1024 * 1024, dropped: std::sync::atomic::AtomicU64::new(0), }; @@ -1924,9 +1931,15 @@ fn the_spawn_failure_teardown_actually_stops_the_threads_it_is_given() { let opts = fast(); let ob = Arc::new(Outbox { addr: "127.0.0.1:1".parse().unwrap(), // refuses instantly, so the loop is in its retry path - state: Mutex::new(OutboxState { queue: VecDeque::new(), live: None, stopped: false }), + state: Mutex::new(OutboxState { + queue: VecDeque::new(), + bytes: 0, + live: None, + stopped: false, + }), woken: Condvar::new(), depth: 4, + max_bytes: 64 * 1024 * 1024, dropped: std::sync::atomic::AtomicU64::new(0), }); let ob_c = Arc::clone(&ob); diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index ba2db259..45203d3a 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -137,6 +137,29 @@ const B_REAP: u8 = 3; /// 1024 the quadratic term is about a million comparisons, which is microseconds. pub const MAX_CONFIG_NODES: usize = 1024; +/// Most node ids **one frame** may spend, summed over every configuration it carries. +/// +/// [`MAX_CONFIG_NODES`] bounds one configuration, and that alone is not a bound on the frame. +/// `Config::with_learners` retains learners with `Vec::contains` (`config.rs`), so a configuration of +/// n members and n learners costs n x n comparisons however this file validates it, and an `Append` +/// may carry many: an 8 MiB frame holds about 1018 configurations of 1024 + 1024 ids, which is 1.07e9 +/// comparisons for one frame. The per-configuration cap cut the original 10^12 to that and stopped +/// there (D207, ported from F3-transport's `1b3a6a6`, which never merged). +/// +/// Charged per frame, the worst case is two maximal configurations, about 2.1e6 comparisons. Real +/// traffic is nowhere near it: membership changes are single-node by construction, so a frame carries +/// one configuration of a handful of nodes. Both halves of the codec charge it, for the reason +/// [`MAX_CONFIG_NODES`] is enforced on both: a limit only the receiver knows is a frame the sender +/// emits and every peer refuses. +pub const MAX_FRAME_CONFIG_NODES: usize = 4096; + +// One maximal configuration — a full member list and a full learner list — must fit a frame's +// budget, or a configuration the encoder accepts could never be sent at all. +const _: () = assert!( + MAX_FRAME_CONFIG_NODES >= 2 * MAX_CONFIG_NODES, + "a single maximal configuration no longer fits the per-frame node budget" +); + /// `RecKind::Ddl`'s tag in `wal::log`. Named here because [`Command::Catalog`] is carried *as* one /// of those records, and because the tag has to be checked before the record is handed to /// `RecKind::deserialize` — see [`decode_catalog`]. @@ -174,7 +197,10 @@ pub fn encode_signed(m: &Message, key: Option<&Key>) -> Result, FerroErr put_u32(&mut body, m.from.0); put_u32(&mut body, m.to.0); put_u64(&mut body, m.term); - encode_body(&mut body, &m.body)?; + // Spent by every configuration in the message, as `decode` spends it. See + // [`MAX_FRAME_CONFIG_NODES`]. + let mut budget = MAX_FRAME_CONFIG_NODES; + encode_body(&mut body, &m.body, &mut budget)?; // Belt as well as braces: every variable-length step above already refused to grow past the // limit, and this is the single statement a reader can check the claim against. @@ -232,7 +258,7 @@ fn put_bytes(b: &mut Vec, v: &[u8]) -> Result<(), FerroError> { Ok(()) } -fn encode_body(b: &mut Vec, body: &Body) -> Result<(), FerroError> { +fn encode_body(b: &mut Vec, body: &Body, budget: &mut usize) -> Result<(), FerroError> { match body { Body::PreVote { last_term, last_round } => { b.push(K_PRE_VOTE); @@ -262,7 +288,7 @@ fn encode_body(b: &mut Vec, body: &Body) -> Result<(), FerroError> { room(b, 4)?; put_u32(b, u32::try_from(entries.len()).map_err(|_| too_big(entries.len()))?); for e in entries { - encode_entry(b, e)?; + encode_entry(b, e, budget)?; } } Body::AppendResp { success, matched, hint, digest } => { @@ -274,7 +300,7 @@ fn encode_body(b: &mut Vec, body: &Body) -> Result<(), FerroError> { } Body::InstallSnapshot { meta, offset, data, done } => { b.push(K_INSTALL_SNAPSHOT); - encode_snapshot_meta(b, meta)?; + encode_snapshot_meta(b, meta, budget)?; put_u64(b, *offset); put_bool(b, *done); put_bytes(b, data)?; @@ -287,16 +313,16 @@ fn encode_body(b: &mut Vec, body: &Body) -> Result<(), FerroError> { Ok(()) } -fn encode_entry(b: &mut Vec, e: &Entry) -> Result<(), FerroError> { +fn encode_entry(b: &mut Vec, e: &Entry, budget: &mut usize) -> Result<(), FerroError> { // Checked per entry rather than only at the end, so a list of a hundred million `NoOp`s is // refused after the first few bytes instead of after 1.7 GB of them. room(b, 17)?; put_u64(b, e.term); put_u64(b, e.round); - encode_command(b, &e.command) + encode_command(b, &e.command, budget) } -fn encode_command(b: &mut Vec, c: &Command) -> Result<(), FerroError> { +fn encode_command(b: &mut Vec, c: &Command, budget: &mut usize) -> Result<(), FerroError> { match c { Command::WalBatch { start_lsn, bytes } => { b.push(C_WAL_BATCH); @@ -374,7 +400,7 @@ fn encode_command(b: &mut Vec, c: &Command) -> Result<(), FerroError> { Command::Checkpoint => b.push(C_CHECKPOINT), Command::Membership { config } => { b.push(C_MEMBERSHIP); - encode_config(b, config)?; + encode_config(b, config, budget)?; } Command::NoOp => b.push(C_NO_OP), } @@ -407,7 +433,7 @@ fn encode_branch_op(b: &mut Vec, op: &BranchOp) { } } -fn encode_config(b: &mut Vec, cfg: &Config) -> Result<(), FerroError> { +fn encode_config(b: &mut Vec, cfg: &Config, budget: &mut usize) -> Result<(), FerroError> { put_u64(b, cfg.version); put_u64(b, cfg.term); for list in [cfg.members(), cfg.learners()] { @@ -422,6 +448,19 @@ fn encode_config(b: &mut Vec, cfg: &Config) -> Result<(), FerroError> { list.len() ))); } + // The frame's budget, for the same reason and in the same order as `decode_node_list` + // charges it: after the per-configuration cap, so each refusal names its own limit. + if list.len() > *budget { + return Err(FerroError::Wal(format!( + "a configuration holds {} nodes, but only {} of this frame's \ + {MAX_FRAME_CONFIG_NODES}-node budget is left; a peer would refuse this frame, so it \ + is refused here where the cause is visible. Send fewer Membership entries per \ + Append", + list.len(), + *budget + ))); + } + *budget -= list.len(); room(b, 4 + list.len() * 4)?; put_u32(b, u32::try_from(list.len()).map_err(|_| too_big(list.len()))?); for n in list { @@ -431,14 +470,18 @@ fn encode_config(b: &mut Vec, cfg: &Config) -> Result<(), FerroError> { Ok(()) } -fn encode_snapshot_meta(b: &mut Vec, meta: &SnapshotMeta) -> Result<(), FerroError> { +fn encode_snapshot_meta( + b: &mut Vec, + meta: &SnapshotMeta, + budget: &mut usize, +) -> Result<(), FerroError> { put_u64(b, meta.last_round); put_u64(b, meta.last_term); put_u64(b, meta.total_bytes); // The configuration travels with the snapshot because a receiver that installs one without it // cannot count a majority — F6's rule, enforced here by there being no way to encode a // `SnapshotMeta` that omits it. - encode_config(b, &meta.config) + encode_config(b, &meta.config, budget) } // --------------------------------------------------------------------------------------------- @@ -461,7 +504,10 @@ pub fn decode(body: &[u8]) -> Result { let from = NodeId(take_u32(body, &mut at)?); let to = NodeId(take_u32(body, &mut at)?); let term: Term = take_u64(body, &mut at)?; - let b = decode_body(body, &mut at)?; + // Spent by every configuration in this frame, so the work a frame can buy is bounded and not + // only the work each configuration can. See [`MAX_FRAME_CONFIG_NODES`]. + let mut budget = MAX_FRAME_CONFIG_NODES; + let b = decode_body(body, &mut at, &mut budget)?; if at != body.len() { return Err(FerroError::Wal(format!( "a consensus frame has {} byte(s) left over after its last field; the sender and this \ @@ -512,7 +558,7 @@ fn take_bytes(bytes: &[u8], at: &mut usize) -> Result, FerroError> { Ok(slice.to_vec()) } -fn decode_body(bytes: &[u8], at: &mut usize) -> Result { +fn decode_body(bytes: &[u8], at: &mut usize, budget: &mut usize) -> Result { Ok(match take_u8(bytes, at)? { K_PRE_VOTE => { Body::PreVote { last_term: take_u64(bytes, at)?, last_round: take_u64(bytes, at)? } @@ -522,7 +568,7 @@ fn decode_body(bytes: &[u8], at: &mut usize) -> Result { Body::RequestVote { last_term: take_u64(bytes, at)?, last_round: take_u64(bytes, at)? } } K_REQUEST_VOTE_RESP => Body::RequestVoteResp { granted: take_bool(bytes, at)? }, - K_APPEND => decode_append(bytes, at)?, + K_APPEND => decode_append(bytes, at, budget)?, K_APPEND_RESP => Body::AppendResp { success: take_bool(bytes, at)?, matched: take_u64(bytes, at)?, @@ -530,7 +576,7 @@ fn decode_body(bytes: &[u8], at: &mut usize) -> Result { digest: take_u64(bytes, at)?, }, K_INSTALL_SNAPSHOT => { - let meta = decode_snapshot_meta(bytes, at)?; + let meta = decode_snapshot_meta(bytes, at, budget)?; let offset = take_u64(bytes, at)?; let done = take_bool(bytes, at)?; let data = take_bytes(bytes, at)?; @@ -550,7 +596,7 @@ fn decode_body(bytes: &[u8], at: &mut usize) -> Result { }) } -fn decode_append(bytes: &[u8], at: &mut usize) -> Result { +fn decode_append(bytes: &[u8], at: &mut usize, budget: &mut usize) -> Result { let prev_round: Round = take_u64(bytes, at)?; let prev_term: Term = take_u64(bytes, at)?; let commit: Round = take_u64(bytes, at)?; @@ -562,20 +608,24 @@ fn decode_append(bytes: &[u8], at: &mut usize) -> Result { // decode, and the first entry that runs off the end of the body ends it. let mut entries = Vec::new(); for i in 0..count { - entries.push(decode_entry(bytes, at).map_err(|e| { + entries.push(decode_entry(bytes, at, budget).map_err(|e| { FerroError::Wal(format!("entry {i} of {count} in an Append frame: {e}")) })?); } Ok(Body::Append { prev_round, prev_term, entries, commit }) } -fn decode_entry(bytes: &[u8], at: &mut usize) -> Result { +fn decode_entry(bytes: &[u8], at: &mut usize, budget: &mut usize) -> Result { let term: Term = take_u64(bytes, at)?; let round: Round = take_u64(bytes, at)?; - Ok(Entry { term, round, command: decode_command(bytes, at)? }) + Ok(Entry { term, round, command: decode_command(bytes, at, budget)? }) } -fn decode_command(bytes: &[u8], at: &mut usize) -> Result { +fn decode_command( + bytes: &[u8], + at: &mut usize, + budget: &mut usize, +) -> Result { Ok(match take_u8(bytes, at)? { C_WAL_BATCH => { Command::WalBatch { start_lsn: take_u64(bytes, at)?, bytes: take_bytes(bytes, at)? } @@ -594,7 +644,7 @@ fn decode_command(bytes: &[u8], at: &mut usize) -> Result { }, C_LEASE_TICK => Command::LeaseTick { unix_millis: take_u64(bytes, at)? }, C_CHECKPOINT => Command::Checkpoint, - C_MEMBERSHIP => Command::Membership { config: decode_config(bytes, at)? }, + C_MEMBERSHIP => Command::Membership { config: decode_config(bytes, at, budget)? }, C_NO_OP => Command::NoOp, other => { return Err(FerroError::Wal(format!( @@ -719,7 +769,12 @@ fn decode_branch_op(bytes: &[u8], at: &mut usize) -> Result Result, FerroError> { +fn decode_node_list( + bytes: &[u8], + at: &mut usize, + what: &str, + budget: &mut usize, +) -> Result, FerroError> { let count = take_u32(bytes, at)? as usize; // Refused before a single id is read. See [`MAX_CONFIG_NODES`]: the frame limit bounds the // bytes a peer can make this process hold, not the work it can make this process do. @@ -730,6 +785,20 @@ fn decode_node_list(bytes: &[u8], at: &mut usize, what: &str) -> Result *budget { + return Err(FerroError::Wal(format!( + "a configuration claims {count} {what}s, but only {} of this frame's \ + {MAX_FRAME_CONFIG_NODES}-node budget is left. A configuration costs work quadratic in \ + its own size, so bounding one configuration bounds nothing about a frame that carries \ + a thousand of them", + *budget + ))); + } + *budget -= count; // Not pre-allocated from `count`, for the reason given in `decode_append`. let mut out: Vec = Vec::new(); for _ in 0..count { @@ -766,11 +835,11 @@ fn first_common(a: &[NodeId], b: &[NodeId]) -> Option { None } -fn decode_config(bytes: &[u8], at: &mut usize) -> Result { +fn decode_config(bytes: &[u8], at: &mut usize, budget: &mut usize) -> Result { let version = take_u64(bytes, at)?; let term = take_u64(bytes, at)?; - let members = decode_node_list(bytes, at, "member")?; - let learners = decode_node_list(bytes, at, "learner")?; + let members = decode_node_list(bytes, at, "member", budget)?; + let learners = decode_node_list(bytes, at, "learner", budget)?; // A node cannot be both. `with_learners` drops such a learner silently, which is the right // thing for a *builder* — the voter entry is the stronger statement — and the wrong thing for a @@ -810,11 +879,20 @@ fn decode_config(bytes: &[u8], at: &mut usize) -> Result { Ok(cfg) } -fn decode_snapshot_meta(bytes: &[u8], at: &mut usize) -> Result { +fn decode_snapshot_meta( + bytes: &[u8], + at: &mut usize, + budget: &mut usize, +) -> Result { let last_round = take_u64(bytes, at)?; let last_term = take_u64(bytes, at)?; let total_bytes = take_u64(bytes, at)?; - Ok(SnapshotMeta { last_round, last_term, total_bytes, config: decode_config(bytes, at)? }) + Ok(SnapshotMeta { + last_round, + last_term, + total_bytes, + config: decode_config(bytes, at, budget)?, + }) } // --------------------------------------------------------------------------------------------- @@ -1008,14 +1086,25 @@ fn send_handshake(stream: &mut TcpStream) -> Result<(), FerroError> { /// Knobs, all of them with a defensible default. /// -/// Every one is a real parameter rather than a tuning guess: `queue_depth` is how much a peer may -/// fall behind before this node starts dropping to it, `poll_interval` is how long a thread may -/// stay blocked in `read` after a shutdown has been asked for, and `reconnect_delay` is how hard -/// this node retries a peer that is down. +/// Every one is a real parameter rather than a tuning guess: `queue_depth` and `queue_bytes` are how +/// far a peer may fall behind, in messages and in bytes, before this node starts dropping to it, +/// `poll_interval` is how long a thread may stay blocked in `read` after a shutdown has been asked +/// for, and `reconnect_delay` is how hard this node retries a peer that is down. #[derive(Debug, Clone)] pub struct TransportOptions { /// Messages held for one peer before the **oldest** is dropped. See [`Transport::send`]. pub queue_depth: usize, + /// Bytes of queued frames held for one peer before the **oldest** is dropped. + /// + /// A depth in messages is not a bound on memory: a message is anything from 18 bytes to 8 MiB, so + /// `queue_depth` alone let one peer's queue hold 1024 x 8 MiB = 8.6 GB (D207, ported from + /// F3-transport's `207d362`, which never merged). Both bounds apply and whichever binds first + /// wins: the depth stops a flood of tiny heartbeats, and this stops a run of maximal `Append`s. + /// + /// A frame larger than the whole bound is still queued, alone — the oldest are dropped until + /// the queue is empty and then it goes in — so no message is ever unsendable because of this + /// knob, however small it is set. + pub queue_bytes: usize, /// Socket read timeout, and the listener's accept poll. Bounds shutdown latency. pub poll_interval: Duration, /// How long a sender thread waits after a failed dial before trying that peer again. @@ -1054,6 +1143,7 @@ impl Default for TransportOptions { fn default() -> Self { TransportOptions { queue_depth: 1024, + queue_bytes: 64 * 1024 * 1024, poll_interval: Duration::from_millis(50), reconnect_delay: Duration::from_millis(100), handshake_deadline: Duration::from_secs(5), @@ -1082,7 +1172,8 @@ struct Counters { lost_in_flight: AtomicU64, /// Bytes of decoded messages sitting in the inbox, undelivered. inbox_bytes: AtomicUsize, - /// Connections closed immediately because `max_inbound_conns` were already established. + /// Connections closed without being served: because `max_inbound_conns` were already + /// established, or because the accepted socket could not be configured (D207). refused_conns: AtomicU64, /// Connections closed for going silent longer than `idle_deadline`. idle_closed: AtomicU64, @@ -1104,11 +1195,16 @@ struct Outbox { state: Mutex, woken: Condvar, depth: usize, + /// `TransportOptions::queue_bytes`. + max_bytes: usize, dropped: AtomicU64, } struct OutboxState { queue: VecDeque>, + /// The sum of `queue`'s frame lengths, kept beside it so the byte bound costs no walk. Every + /// push, drop and pop of a frame moves it by that frame's length, under the same lock. + bytes: usize, /// A clone of the live socket, held **only** so that [`Transport::shutdown`] can call /// `shutdown(Both)` on it. A sender thread parked in `write_all` against a peer whose receive /// window is full is otherwise unreachable, and joining it would hang for ever. @@ -1117,7 +1213,8 @@ struct OutboxState { } impl Outbox { - /// Enqueue, dropping the **oldest** if this peer is already `depth` behind. + /// Enqueue, dropping the **oldest** while this peer is already `depth` frames behind, or while + /// this frame would take it past `max_bytes`. /// /// Oldest and not newest, deliberately. Consensus messages are cumulative: a later `Append` /// carries a higher `commit` and later entries, and a later heartbeat supersedes an earlier @@ -1128,10 +1225,19 @@ impl Outbox { if st.stopped { return; } - while st.queue.len() >= self.depth { - st.queue.pop_front(); - self.dropped.fetch_add(1, Ordering::SeqCst); + // Both bounds, whichever binds first. The loop stops at an empty queue whatever the bytes + // say, so a frame larger than the whole byte bound is still admitted, alone: no message is + // made permanently unsendable by the bound that exists to keep sending possible. + while !st.queue.is_empty() + && (st.queue.len() >= self.depth + || st.bytes.saturating_add(frame.len()) > self.max_bytes) + { + if let Some(old) = st.queue.pop_front() { + st.bytes -= old.len(); + self.dropped.fetch_add(1, Ordering::SeqCst); + } } + st.bytes += frame.len(); st.queue.push_back(frame); self.woken.notify_all(); } @@ -1180,11 +1286,19 @@ pub struct Transport { key: Option>, } -/// Removes a connection from the live registry however its thread leaves — including the early -/// return on a refused handshake. +/// Removes a connection from the live registry however it leaves — including the early return on a +/// refused handshake, and a connection that never got a thread at all. /// /// A `Drop` guard rather than a call at the end of `conn_loop`, because "every exit path also /// deregisters" is exactly the invariant a later edit breaks by adding one more `return`. +/// +/// **Made in the same critical section that reserves the slot**, and moved into the connection +/// thread from there. It used to be made at the top of `conn_loop`, which left the two exits in +/// `accept_loop` between the reservation and the spawn outside it: the spawn failure released the +/// slot by hand, and the failed socket setup did not — so each failed setup leaked a slot and a +/// descriptor, and `max_inbound_conns` of them made the node refuse every real peer (D207, ported +/// from F3-transport's `207d362`, which never merged). With the guard owning the slot from the +/// moment it exists, no exit can be added that forgets it. struct ConnRegistration { id: u64, conns: Arc>>, @@ -1328,11 +1442,13 @@ impl Transport { addr, state: Mutex::new(OutboxState { queue: VecDeque::new(), + bytes: 0, live: None, stopped: false, }), woken: Condvar::new(), depth: opts.queue_depth, + max_bytes: opts.queue_bytes, dropped: AtomicU64::new(0), }); outboxes.insert(peer, Arc::clone(&ob)); @@ -1483,7 +1599,8 @@ impl Transport { pub fn sent(&self) -> u64 { self.counters.sent.load(Ordering::SeqCst) } - /// Messages dropped because a peer was `queue_depth` behind. See [`Transport::send`]. + /// Messages dropped because a peer was `queue_depth` messages or `queue_bytes` bytes behind. + /// See [`Transport::send`]. pub fn dropped(&self) -> u64 { self.outboxes.values().map(|o| o.dropped.load(Ordering::SeqCst)).sum() } @@ -1552,7 +1669,10 @@ impl Transport { pub fn signs_its_traffic(&self) -> bool { self.key.is_some() } - /// Connections closed immediately because `max_inbound_conns` were already established. + /// Inbound connections closed without being served: the cap was full, or the accepted socket + /// could not be configured. F3-transport's `207d362` recorded the second as what a peer that + /// connects and resets before `accept` leaves behind on macOS (D207), so on that platform this + /// meter climbing while `live_inbound_conns` stays low reads as resets, not as a busy node. pub fn refused_conns(&self) -> u64 { self.counters.refused_conns.load(Ordering::SeqCst) } @@ -1594,6 +1714,7 @@ impl Transport { let mut st = ob.state.lock().unwrap(); st.stopped = true; st.queue.clear(); + st.bytes = 0; if let Some(s) = st.live.take() { let _ = s.shutdown(Shutdown::Both); } @@ -1620,6 +1741,7 @@ fn stop_started(stop: &Arc, outboxes: &[Arc], threads: Vec= opts.max_inbound_conns { counters.refused_conns.fetch_add(1, Ordering::SeqCst); @@ -1842,35 +1972,48 @@ fn accept_loop( continue; } map.insert(id, mine); - } + ConnRegistration { id, conns: Arc::clone(&conns) } + }; // An accepted socket's blocking mode is not portably inherited from its listener, // so it is set explicitly rather than assumed. The read timeout is what lets the // connection thread notice a shutdown. if stream.set_nonblocking(false).is_err() || stream.set_read_timeout(Some(opts.poll_interval)).is_err() { + // Not hypothetical: F3-transport's `207d362` recorded that on macOS a peer which + // connects and resets before `accept` leaves a socket on which `SO_RCVTIMEO` + // fails with EINVAL. `registration` is dropped by this `continue`, which + // releases the slot; the refusal is counted, because an unserved connection + // with no number attached is the silent loss this module does not allow. + counters.refused_conns.fetch_add(1, Ordering::SeqCst); + let _ = stream.shutdown(Shutdown::Both); continue; } let _ = stream.set_nodelay(true); let tx_c = tx.clone(); let stop_c = Arc::clone(&stop); let counters_c = Arc::clone(&counters); - let conns_c = Arc::clone(&conns); let opts_c = opts.clone(); let key_c = key.clone(); match std::thread::Builder::new() .name(format!("consensus-in-{self_id}")) .spawn(move || { conn_loop( - stream, id, self_id, tx_c, stop_c, counters_c, conns_c, opts_c, key_c, + stream, + registration, + self_id, + tx_c, + stop_c, + counters_c, + opts_c, + key_c, ) }) { Ok(h) => conn_threads.push(h), Err(_) => { - // The slot was reserved above and no thread will release it, so it is - // released here. Without this a run of spawn failures would fill the cap - // with connections that do not exist and refuse every real peer after. - conns.lock().unwrap().remove(&id); + // A failed spawn drops the closure it was given, and `registration` with + // it, so the slot is already released: no thread will ever run for it, and + // no second release is needed here. continue; } } @@ -1894,20 +2037,19 @@ fn accept_loop( #[allow(clippy::too_many_arguments)] fn conn_loop( mut stream: TcpStream, - id: u64, + // Held, never read: dropping it at any return below is what releases the slot. + _registration: ConnRegistration, self_id: NodeId, tx: mpsc::Sender<(Message, usize)>, stop: Arc, counters: Arc, - conns: Arc>>, opts: TransportOptions, key: Option>, ) { // The accept thread already reserved this connection's slot and registered its socket, so - // `shutdown` can reach a peer that connects and then goes silent. All this thread owns is the - // guard that releases the slot again, on every exit path including the refused-handshake - // return below. - let _registration = ConnRegistration { id, conns: Arc::clone(&conns) }; + // `shutdown` can reach a peer that connects and then goes silent, and it handed over the guard + // that releases the slot. Owning it here is what makes every exit path below release it, + // including the refused-handshake return. let verdict = recv_handshake(&mut stream, &stop, opts.handshake_deadline); From 9287b3422460c2839645a69b69268e11c809eb96 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:05:46 -0400 Subject: [PATCH 092/315] =?UTF-8?q?UNBUILT=20D207:=20tests=20that=20need?= =?UTF-8?q?=20the=20ported=20API=20=E2=80=94=20byte=20refund,=20both=20bou?= =?UTF-8?q?nds,=20learners=20charged,=20budget=20premise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These read OutboxState::bytes, build an Outbox with max_bytes or name MAX_FRAME_CONFIG_NODES, so none could be written red against 9aa6968. Their red evidence is the mutants pre-registered in bench/d207/PREREG.md amendment 1. Additions only; never compiled or run. --- src/consensus/tests_transport.rs | 176 +++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index e8842bac..c3bee9d5 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -2316,3 +2316,179 @@ fn a_frame_whose_configurations_exceed_the_budget_is_refused_to_its_sender() { let m = append(4); assert_eq!(decode_frame(&encode(&m).unwrap()).unwrap(), m, "four configurations must round trip"); } + +// --------------------------------------------------------------------------------------------- +// D207 — tests that need the ported API, so they could not be written red against 9aa6968. Their +// red evidence is the mutants in bench/d207/PREREG.md (amendment 1), not a run on main. +// --------------------------------------------------------------------------------------------- + +#[test] +fn the_outbound_byte_count_returns_to_zero_as_the_sender_drains() { + // The byte bound's other half. `OutboxState::bytes` is charged at `push` and must be given back + // at the sender's pop. A counter that only grew would, once a peer had been sent `queue_bytes` + // over its whole life, make every later push drop everything queued before it — and a peer that + // is never more than one frame behind would never show it. So the counter itself is read. + let (a, b) = pair(fast()); + const SENT: u64 = 20; + for term in 1..=SENT { + a.send(&Message { + from: NodeId(1), + to: NodeId(2), + term, + body: Body::PreVoteResp { granted: true }, + }) + .unwrap(); + assert_eq!(expect_recv(&b, Duration::from_secs(10)).term, term); + } + // The sender pops a frame, under the lock, before it writes it. So once b holds the last one, + // nothing is left to pop and the counter has nothing left to give back. + let st = a.outboxes[&NodeId(2)].state.lock().unwrap(); + assert!(st.queue.is_empty(), "all {SENT} were delivered and {} are still queued", st.queue.len()); + assert_eq!( + st.bytes, 0, + "all {SENT} frames were delivered and the queue still charges {} bytes, so the sender's pop \ + does not give a frame's bytes back", + st.bytes + ); +} + +#[test] +fn whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone() { + let outbox = |depth: usize, max_bytes: usize| Outbox { + addr: "127.0.0.1:1".parse().unwrap(), + state: Mutex::new(OutboxState { + queue: VecDeque::new(), + bytes: 0, + live: None, + stopped: false, + }), + woken: Condvar::new(), + depth, + max_bytes, + dropped: std::sync::atomic::AtomicU64::new(0), + }; + // The queue as the sender would see it: each frame's first byte, in order. And the counter, + // checked against a sum recomputed from the frames themselves every time, not trusted. + let view = |ob: &Outbox| { + let st = ob.state.lock().unwrap(); + let sum: usize = st.queue.iter().map(|f| f.len()).sum(); + assert_eq!(st.bytes, sum, "the byte counter disagrees with the frames it counts"); + let firsts: Vec = st.queue.iter().map(|f| f[0]).collect(); + firsts + }; + let dropped = |ob: &Outbox| ob.dropped.load(std::sync::atomic::Ordering::SeqCst); + + // The bytes bind: ten-byte frames against a 100-byte bound and a depth of 1000. Ten fit exactly. + let ob = outbox(1000, 100); + for i in 1..=15u8 { + ob.push(vec![i; 10]); + } + assert_eq!(view(&ob), (6..=15).collect::>(), "the byte bound must keep the ten newest"); + assert_eq!(dropped(&ob), 5); + + // The depth binds: the same frames, a bound they never reach, and a depth of 3. + let ob = outbox(3, 1_000_000); + for i in 1..=15u8 { + ob.push(vec![i; 10]); + } + assert_eq!(view(&ob), vec![13, 14, 15], "the depth must still bind when the bytes do not"); + assert_eq!(dropped(&ob), 12); + + // A frame larger than the whole byte bound is queued, alone, rather than refused: the bound + // exists to keep sending possible, so it must not make any message unsendable... + let ob = outbox(1000, 100); + for i in 1..=5u8 { + ob.push(vec![i; 10]); + } + ob.push(vec![99; 500]); + assert_eq!(view(&ob), vec![99], "a frame over the whole bound must be queued, alone"); + assert_eq!(dropped(&ob), 5); + // ...and the next frame displaces it, so the bound holds again from there. + ob.push(vec![7; 10]); + assert_eq!(view(&ob), vec![7]); + assert_eq!(dropped(&ob), 6); +} + +#[test] +fn learners_spend_the_same_frame_budget_as_members() { + // The quadratic term is learners x members, so a budget that charged only members would bound + // nothing about the half that costs. Two configurations of 1024 members and 1024 disjoint + // learners are 4096 ids and fill the budget exactly; a third must be refused, by the decoder + // and by the encoder alike. + let per = MAX_CONFIG_NODES as u32; + let members = 1..=per; + let learners = (per + 1)..=(2 * per); + + // Decoder: hand-built, because the encoder now refuses the frame this half needs. + let frame = |configs: u32| { + let mut b = Vec::new(); + b.extend_from_slice(&1u32.to_be_bytes()); // from + b.extend_from_slice(&2u32.to_be_bytes()); // to + b.extend_from_slice(&1u64.to_be_bytes()); // term + b.push(4); // Append + b.extend_from_slice(&[0u8; 24]); // prev_round, prev_term, commit + b.extend_from_slice(&configs.to_be_bytes()); // entries + for e in 0..u64::from(configs) { + b.extend_from_slice(&1u64.to_be_bytes()); // entry term + b.extend_from_slice(&(e + 1).to_be_bytes()); // entry round + b.push(7); // Membership + b.extend_from_slice(&1u64.to_be_bytes()); // config version + b.extend_from_slice(&1u64.to_be_bytes()); // config term + for list in [members.clone(), learners.clone()] { + b.extend_from_slice(&per.to_be_bytes()); + for i in list { + b.extend_from_slice(&i.to_be_bytes()); + } + } + } + b + }; + match decode(&frame(3)) { + Ok(_) => panic!("three configurations of {per} + {per} ids were accepted in one frame"), + Err(e) => { + let e = format!("{e}"); + assert!(e.contains("budget is left"), "refused, but not by the frame budget: {e}"); + assert!(e.contains("entry 2 of 3"), "refused at the wrong entry: {e}"); + } + } + decode(&frame(2)).expect("two configurations totalling exactly the budget must decode"); + + // Encoder: the same line, from the sending side. + let cfg = Config::new(members.clone().map(NodeId), 1, 1).with_learners(learners.map(NodeId)); + assert_eq!(cfg.learners().len(), MAX_CONFIG_NODES, "the learners did not survive `Config`"); + let append = |n: u64| Message { + from: NodeId(1), + to: NodeId(2), + term: 1, + body: Body::Append { + prev_round: 0, + prev_term: 0, + entries: (1..=n) + .map(|round| Entry { + term: 1, + round, + command: Command::Membership { config: cfg.clone() }, + }) + .collect(), + commit: 0, + }, + }; + match encode(&append(3)) { + Ok(f) => panic!("three configurations of {per} + {per} ids were framed ({} bytes)", f.len()), + Err(e) => { + assert!(format!("{e}").contains("budget"), "refused, but not by the frame budget: {e}") + } + } + let m = append(2); + assert_eq!(decode_frame(&encode(&m).unwrap()).unwrap(), m, "two must round trip"); +} + +#[test] +fn the_frame_budget_is_four_maximal_member_lists() { + // `one_frame_cannot_spend_more_config_nodes_than_its_whole_budget` was written before + // `MAX_FRAME_CONFIG_NODES` existed, so it states the budget only as arithmetic: four + // configurations of `MAX_CONFIG_NODES` members fill it exactly and a fifth does not fit, and + // `learners_spend_the_same_frame_budget_as_members` relies on two full configurations doing the + // same. This pins that premise to the constants, so moving either limit fails here, by name. + assert_eq!(MAX_FRAME_CONFIG_NODES, 4 * MAX_CONFIG_NODES); +} From dc6bf4ec717223a7c94824bc42e53b746a361ff3 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:06:24 -0400 Subject: [PATCH 093/315] UNBUILT D207: count a connection closed by a failed spawn in refused_conns The failed-setup exit is now counted; the failed-spawn exit next to it closed a connection unserved with no number attached. Unreachable from a test (it needs pthread_create to fail), so its mutant is pre-registered as unkillable. --- src/consensus/transport.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index 45203d3a..bc69b3ef 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -1173,7 +1173,8 @@ struct Counters { /// Bytes of decoded messages sitting in the inbox, undelivered. inbox_bytes: AtomicUsize, /// Connections closed without being served: because `max_inbound_conns` were already - /// established, or because the accepted socket could not be configured (D207). + /// established, because the accepted socket could not be configured (D207), or because no + /// thread could be started for it. refused_conns: AtomicU64, /// Connections closed for going silent longer than `idle_deadline`. idle_closed: AtomicU64, @@ -1669,8 +1670,8 @@ impl Transport { pub fn signs_its_traffic(&self) -> bool { self.key.is_some() } - /// Inbound connections closed without being served: the cap was full, or the accepted socket - /// could not be configured. F3-transport's `207d362` recorded the second as what a peer that + /// Inbound connections closed without being served: the cap was full, the accepted socket + /// could not be configured, or no thread could be started for it. F3-transport's `207d362` recorded the second as what a peer that /// connects and resets before `accept` leaves behind on macOS (D207), so on that platform this /// meter climbing while `live_inbound_conns` stays low reads as resets, not as a busy node. pub fn refused_conns(&self) -> u64 { @@ -2013,7 +2014,9 @@ fn accept_loop( Err(_) => { // A failed spawn drops the closure it was given, and `registration` with // it, so the slot is already released: no thread will ever run for it, and - // no second release is needed here. + // no second release is needed here. Counted like the failed setup above, + // for the same reason — it is a connection this node closed unserved. + counters.refused_conns.fetch_add(1, Ordering::SeqCst); continue; } } From 48f0265ef620ac6fe1a7294d7c692f2321f1b531 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:09:36 -0400 Subject: [PATCH 094/315] =?UTF-8?q?UNBUILT=20D207:=20pin=20the=20registrat?= =?UTF-8?q?ion=20guard's=20lifetime=20=E2=80=94=20four=20held=20connection?= =?UTF-8?q?s=20hold=20exactly=20four=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passes on 9aa6968 as well. It exists because the older cap test asserts only "some refused" and "at most 4 live", which a guard dropped at the top of conn_loop can still satisfy by timing (mutant M3). Additions only; never compiled or run. --- src/consensus/tests_transport.rs | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index c3bee9d5..8fb2f17f 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -2492,3 +2492,56 @@ fn the_frame_budget_is_four_maximal_member_lists() { // same. This pins that premise to the constants, so moving either limit fails here, by name. assert_eq!(MAX_FRAME_CONFIG_NODES, 4 * MAX_CONFIG_NODES); } + +#[test] +fn an_open_connection_holds_its_slot_until_it_closes() { + // The guard's LIFETIME is the cap. `ConnRegistration` gives the slot back when it is dropped, and + // since D207 it is made in `accept_loop` and moved into the connection thread. Drop it any earlier + // and the cap counts nothing, while the older cap test (which asserts only "some refused" and + // "at most 4 live") can still pass on a refusal won by timing before the early drop ran. So this + // counts exactly: four slots, twenty-four connections held open, four live and twenty refused. + // + // It passes on 9aa6968 too; it is here because nothing else pins the guard's lifetime (mutant M3 + // in bench/d207/PREREG.md). + let mut opts = fast(); + opts.max_inbound_conns = 4; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + let t = Transport::from_listener(NodeId(1), l, BTreeMap::new(), opts).unwrap(); + + const OPENED: u64 = 24; + let mut held = Vec::new(); + for _ in 0..OPENED { + let mut s = TcpStream::connect(addr).unwrap(); + let mut hs = Vec::new(); + crate::replication::write_handshake(&mut hs).unwrap(); + let _ = s.write_all(&hs); + let _ = s.flush(); + held.push(s); + } + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline && t.refused_conns() < OPENED - 4 { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + t.refused_conns(), + OPENED - 4, + "{OPENED} connections held open against a cap of 4, and {} were refused", + t.refused_conns() + ); + // Settled: nothing else connects, and the four admitted connections are idle but open. + assert_eq!( + t.live_inbound_conns(), + 4, + "four connections are open and {} hold a slot, so a slot is given back before its \ + connection ends", + t.live_inbound_conns() + ); + + drop(held); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline && t.live_inbound_conns() > 0 { + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!(t.live_inbound_conns(), 0, "the slots were not given back when the connections closed"); +} From 196f9aad4328bd35690c01b4ead36a52db176e80 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:10:32 -0400 Subject: [PATCH 095/315] =?UTF-8?q?UNBUILT=20D207:=20amendment=201=20?= =?UTF-8?q?=E2=80=94=20post-fix=20tests,=20fourteen=20mutants=20and=20thei?= =?UTF-8?q?r=20predicted=20kills,=20collateral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mutant patches generated from 48f0265 by bench/d207/make_mutants.py; each passes git apply --check. Predictions before any run. --- bench/d207/PREREG.md | 82 +++++++++ bench/d207/make_mutants.py | 163 ++++++++++++++++++ .../M10_budget_checked_before_cap.patch | 30 ++++ .../d207/mutants/M11_learners_uncharged.patch | 11 ++ .../mutants/M12_encoder_budget_removed.patch | 21 +++ .../mutants/M13_fresh_budget_per_entry.patch | 12 ++ .../mutants/M14_spawn_failure_uncounted.patch | 10 ++ .../mutants/M1_setup_exit_forgets_slot.patch | 10 ++ .../mutants/M2_setup_exit_uncounted.patch | 10 ++ .../M3_slot_released_at_thread_start.patch | 10 ++ .../d207/mutants/M4_byte_bound_removed.patch | 11 ++ bench/d207/mutants/M5_drop_not_refunded.patch | 11 ++ bench/d207/mutants/M6_pop_not_refunded.patch | 15 ++ .../mutants/M7_oversized_frame_refused.patch | 13 ++ .../d207/mutants/M8_empty_guard_removed.patch | 12 ++ .../M9_decode_budget_not_charged.patch | 10 ++ 16 files changed, 431 insertions(+) create mode 100644 bench/d207/make_mutants.py create mode 100644 bench/d207/mutants/M10_budget_checked_before_cap.patch create mode 100644 bench/d207/mutants/M11_learners_uncharged.patch create mode 100644 bench/d207/mutants/M12_encoder_budget_removed.patch create mode 100644 bench/d207/mutants/M13_fresh_budget_per_entry.patch create mode 100644 bench/d207/mutants/M14_spawn_failure_uncounted.patch create mode 100644 bench/d207/mutants/M1_setup_exit_forgets_slot.patch create mode 100644 bench/d207/mutants/M2_setup_exit_uncounted.patch create mode 100644 bench/d207/mutants/M3_slot_released_at_thread_start.patch create mode 100644 bench/d207/mutants/M4_byte_bound_removed.patch create mode 100644 bench/d207/mutants/M5_drop_not_refunded.patch create mode 100644 bench/d207/mutants/M6_pop_not_refunded.patch create mode 100644 bench/d207/mutants/M7_oversized_frame_refused.patch create mode 100644 bench/d207/mutants/M8_empty_guard_removed.patch create mode 100644 bench/d207/mutants/M9_decode_budget_not_charged.patch diff --git a/bench/d207/PREREG.md b/bench/d207/PREREG.md index 4ce51c0b..7c85cf46 100644 --- a/bench/d207/PREREG.md +++ b/bench/d207/PREREG.md @@ -66,3 +66,85 @@ and RSTs before `accept()` leaves a socket on which `SO_RCVTIMEO` returns EINVAL than 88081332. - Any of the 45 existing tests failing at `6ceb719`. The red commit touches no line that exists at `9aa6968` (`git diff 9aa6968 6ceb719 --numstat` = `352 0`). + +--- + +## Amendment 1 — added after the fix commits and BEFORE any run (still nothing built) + +Commits since the red set: `cfe9cf5` the fix; `9287b34` four tests that need the ported API; +`dc6bf4e` a failed spawn is counted too; `48f0265` a test pinning the guard's lifetime. The mutants +are generated from `48f0265` by `bench/d207/make_mutants.py 48f0265` into `bench/d207/mutants/`, +and every patch passes `git apply --check` against that tree (checked; applying is not a build). + +**Counts at `48f0265`**: 55 `#[test]` in `tests_transport.rs`, one of them macOS-only, so the module +runs **55 tests on macOS** and 54 on Linux and Windows. Ten are new to D207: + +| key | test | since | +|---|---|---| +| A | `a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved` | `6ceb719` (red) | +| B | `a_peer_that_resets_before_accept_cannot_fill_the_connection_cap` (macOS) | `6ceb719` (red) | +| C | `one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound` | `6ceb719` (red) | +| D | `one_frame_cannot_spend_more_config_nodes_than_its_whole_budget` | `6ceb719` (red) | +| E | `a_frame_whose_configurations_exceed_the_budget_is_refused_to_its_sender` | `6ceb719` (red) | +| F | `the_outbound_byte_count_returns_to_zero_as_the_sender_drains` | `9287b34` | +| G | `whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone` | `9287b34` | +| H | `learners_spend_the_same_frame_budget_as_members` | `9287b34` | +| I | `the_frame_budget_is_four_maximal_member_lists` | `9287b34` | +| J | `an_open_connection_holds_its_slot_until_it_closes` | `48f0265` | + +Two pre-existing tests are named below: **CAP** = +`concurrent_inbound_connections_are_capped_and_the_refusals_are_counted`, **MIL** = +`a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read`. + +F–J cannot be red against `9aa6968`: F, G and I use fields or a constant that do not exist there, +and the tip's test file does not compile against it (`fast()` names `queue_bytes`). H and J would +compile there in isolation; H would fail there and J would pass. Their red evidence is the mutants +below, not a run on main. + +### Run G, amended: GREEN at `48f0265` (replaces "at the fix commit") + +Predicted: **compiles; 55 passed, 0 failed** on macOS (54 on Linux and Windows). A–E read the +values already registered above. F: queue empty, `bytes` 0. G: all three cases as coded. H: refused +with "budget is left" at "entry 2 of 3"; two decode; the encoder refuses three with "budget"; two +round-trip. I: passes. J: `refused_conns` exactly 20, `live_inbound_conns` exactly 4, then 0. + +### Mutants — run the whole module once per mutant, from a clean `48f0265` + +Command per mutant (M8 excepted): `timeout 900 cargo test --no-fail-fast --lib consensus::transport::tests::`. +Predicted on macOS; on Linux and Windows drop B. "Fails" lists every test predicted to fail; the other +tests of the 55 pass. + +| mutant | what it puts back | fails | the assertion that fires | +|---|---|---|---| +| M1 `setup_exit_forgets_slot` | **the D207 leak** — the slot outlives a failed setup | A, B | A: `leaked` left **4**, right 0. B: `answered` false (conditional, as in Run R) | +| M2 `setup_exit_uncounted` | a failed setup closed with no number attached | A, B | A: `refused_conns` left **0**, right 8. B: the anti-vacuity `refused_conns > 0` | +| M3 `slot_released_at_thread_start` | the guard dropped at the top of `conn_loop` | J | J: `refused_conns` left < 20, right 20. CAP may also fail, by timing; not scored either way | +| M4 `byte_bound_removed` | **the D207 outbound defect** — a depth-only bound | C, G | C: `held` **88081332**. G: "must keep the ten newest", left `[1..=15]` | +| M5 `drop_not_refunded` | bytes not given back when a frame is dropped | C, G | C: `queue.len()` left **1**, right 9. G: "the byte counter disagrees" (150 vs 10) | +| M6 `pop_not_refunded` | bytes not given back at the sender's pop | F | F: `bytes` left **460** (20 frames x 23 bytes), right 0 | +| M7 `oversized_frame_refused` | a frame over the whole bound is dropped | G | G: "must be queued, alone", left `[1, 2, 3, 4, 5]` | +| M8 `empty_guard_removed` | the drop loop does not stop at an empty queue | G never returns | run G alone: `timeout 300 cargo test --lib -- --exact consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone` → **rc=124** | +| M9 `decode_budget_not_charged` | **the D207 frame defect** — the budget is never spent | D, H | D: the `Ok` arm, "five configurations … accepted". H: "three configurations … accepted" | +| M10 `budget_checked_before_cap` | the shadowing that made `1b3a6a6` edit MIL's assertion | MIL | MIL: "was not refused by the node cap: … budget is left" | +| M11 `learners_uncharged` | only members spend the budget | H | H: the decoder half's `Ok` arm | +| M12 `encoder_budget_removed` | the sender frames what every peer refuses | E, H | E: "was framed (**20735** bytes)". H: "were framed (**24749** bytes)" | +| M13 `fresh_budget_per_entry` | a per-configuration budget, which is `1b3a6a6`'s own premise | D, H | as M9 | +| M14 `spawn_failure_uncounted` | a failed spawn not counted | **none: SURVIVES** | unkillable: it needs `pthread_create` to fail, which no test can arrange | + +Each patch must apply and leave the tree dirty, or the run is refused rather than scored. A mutant +that fails MORE tests than listed is a finding about coupling, not a pass; one that fails FEWER is a +guard that cannot fire and blocks landing. + +### Collateral, at `48f0265` + +- `timeout 1800 cargo build --examples` (the integration tests spawn `examples/consensus_node.rs`). +- `timeout 1800 cargo test --no-fail-fast --lib consensus::` — every test passes. +- `timeout 1800 cargo test --no-fail-fast --test d154_length_prefix_refusal --test integration_cluster_agents --test integration_cluster_snapshot --test integration_server_stdout` + — every test passes. These are every target outside `src/consensus/` that names the transport or + the node (`git grep -ln "consensus::transport\|consensus::node\|TransportOptions\|from_listener"`). + INFERRED: I did not read them all. A failure means the 64 MiB byte bound or the frame budget + changed behaviour on a path I did not read. The first suspect is snapshot transfer, whose 1 MiB + chunks (`SNAPSHOT_CHUNK_BYTES`) now meet a byte bound as well as the depth. +- Per-target suite: `VERIFY_MODE=per-target tools/verify-suite.sh d207`. Predicted **2589 passed, + 0 failed** on macOS: main's 2579 plus the ten tests above. The 2579 is the lead's measurement at + `9aa6968` (FAN-QUEUE #9). I have not reproduced it, so it is INFERRED here. diff --git a/bench/d207/make_mutants.py b/bench/d207/make_mutants.py new file mode 100644 index 00000000..a567cb4b --- /dev/null +++ b/bench/d207/make_mutants.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Write D207's mutants of src/consensus/transport.rs as `git apply`-able patches. + +Each mutant re-introduces one defect (or removes one guard) by an exact-string edit that must match +exactly once, or the script refuses. Reads the file from a commit, never from the working tree, and +writes only into bench/d207/mutants/. Run from the worktree root: + + python3 bench/d207/make_mutants.py + +Predictions for every mutant are in bench/d207/PREREG.md, amendment 1. +""" +import difflib +import pathlib +import subprocess +import sys + +PATH = "src/consensus/transport.rs" + +SETUP_EXIT = """ counters.refused_conns.fetch_add(1, Ordering::SeqCst); + let _ = stream.shutdown(Shutdown::Both); + continue; + } + let _ = stream.set_nodelay(true); +""" + +CAP_BLOCK = """ if count > MAX_CONFIG_NODES { + return Err(FerroError::Wal(format!( + "a configuration claims {count} {what}s, over the {MAX_CONFIG_NODES} limit. A frame is \\ + allowed to be large; the comparisons a configuration costs are quadratic in its two \\ + list lengths, so a large one is a request for this node's CPU rather than a cluster" + ))); + } +""" + +BUDGET_BLOCK = """ if count > *budget { + return Err(FerroError::Wal(format!( + "a configuration claims {count} {what}s, but only {} of this frame's \\ + {MAX_FRAME_CONFIG_NODES}-node budget is left. A configuration costs work quadratic in \\ + its own size, so bounding one configuration bounds nothing about a frame that carries \\ + a thousand of them", + *budget + ))); + } +""" + +ENCODE_BUDGET = """ if list.len() > *budget { + return Err(FerroError::Wal(format!( + "a configuration holds {} nodes, but only {} of this frame's \\ + {MAX_FRAME_CONFIG_NODES}-node budget is left; a peer would refuse this frame, so it \\ + is refused here where the cause is visible. Send fewer Membership entries per \\ + Append", + list.len(), + *budget + ))); + } + *budget -= list.len(); +""" + +# (name, [(old, new), ...]) — every `old` must occur exactly once in the file. +MUTANTS = [ + # --- the leaked accept slot ------------------------------------------------------------- + ("M1_setup_exit_forgets_slot", [( + SETUP_EXIT, + SETUP_EXIT.replace( + " continue;\n", + " std::mem::forget(registration);\n continue;\n", + ), + )]), + ("M2_setup_exit_uncounted", [( + SETUP_EXIT, + SETUP_EXIT.replace(" counters.refused_conns.fetch_add(1, Ordering::SeqCst);\n", ""), + )]), + ("M3_slot_released_at_thread_start", [( + " // including the refused-handshake return.\n\n let verdict = recv_handshake(", + " // including the refused-handshake return.\n drop(_registration);\n\n let verdict = recv_handshake(", + )]), + # --- the outbound byte bound -------------------------------------------------------------- + ("M4_byte_bound_removed", [( + " || st.bytes.saturating_add(frame.len()) > self.max_bytes)\n", + " || (false && st.bytes.saturating_add(frame.len()) > self.max_bytes))\n", + )]), + ("M5_drop_not_refunded", [( + " if let Some(old) = st.queue.pop_front() {\n st.bytes -= old.len();\n", + " if let Some(old) = st.queue.pop_front() {\n let _ = old.len();\n", + )]), + ("M6_pop_not_refunded", [( + " let taken = st.queue.pop_front();\n if let Some(f) = taken.as_ref() {\n st.bytes -= f.len();\n }\n taken\n", + " st.queue.pop_front()\n", + )]), + ("M7_oversized_frame_refused", [( + " // Both bounds, whichever binds first.", + " if frame.len() > self.max_bytes {\n self.dropped.fetch_add(1, Ordering::SeqCst);\n return;\n }\n // Both bounds, whichever binds first.", + )]), + ("M8_empty_guard_removed", [( + " while !st.queue.is_empty()\n && (st.queue.len() >= self.depth\n", + " while (st.queue.len() >= self.depth\n", + )]), + # --- the per-frame config budget ------------------------------------------------------------ + ("M9_decode_budget_not_charged", [( + " *budget -= count;\n // Not pre-allocated from `count`", + " // Not pre-allocated from `count`", + )]), + ("M10_budget_checked_before_cap", [( + CAP_BLOCK, + "", + ), ( + BUDGET_BLOCK + " *budget -= count;\n", + BUDGET_BLOCK + CAP_BLOCK + " *budget -= count;\n", + )]), + ("M11_learners_uncharged", [( + ' let learners = decode_node_list(bytes, at, "learner", budget)?;\n', + ' let learners = decode_node_list(bytes, at, "learner", &mut usize::MAX)?;\n', + )]), + ("M12_encoder_budget_removed", [( + ENCODE_BUDGET, + " let _ = &budget;\n", + )]), + ("M13_fresh_budget_per_entry", [( + " entries.push(decode_entry(bytes, at, budget).map_err(|e| {\n", + " let _ = &budget;\n entries.push(decode_entry(bytes, at, &mut { MAX_FRAME_CONFIG_NODES }).map_err(|e| {\n", + )]), + ("M14_spawn_failure_uncounted", [( + " // for the same reason — it is a connection this node closed unserved.\n counters.refused_conns.fetch_add(1, Ordering::SeqCst);\n", + " // for the same reason — it is a connection this node closed unserved.\n", + )]), +] + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: make_mutants.py ", file=sys.stderr) + return 2 + sha = sys.argv[1] + src = subprocess.run( + ["git", "show", f"{sha}:{PATH}"], check=True, capture_output=True, text=True + ).stdout + out = pathlib.Path("bench/d207/mutants") + out.mkdir(parents=True, exist_ok=True) + for name, edits in MUTANTS: + text = src + for old, new in edits: + n = text.count(old) + if n != 1: + print(f"{name}: an edit matches {n} times, not once; refusing", file=sys.stderr) + return 1 + text = text.replace(old, new) + if text == src: + print(f"{name}: the mutant is identical to the source; refusing", file=sys.stderr) + return 1 + diff = difflib.unified_diff( + src.splitlines(keepends=True), + text.splitlines(keepends=True), + fromfile=f"a/{PATH}", + tofile=f"b/{PATH}", + n=3, + ) + (out / f"{name}.patch").write_text("".join(diff)) + print(f"{name}: written") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/d207/mutants/M10_budget_checked_before_cap.patch b/bench/d207/mutants/M10_budget_checked_before_cap.patch new file mode 100644 index 00000000..fa3c65b0 --- /dev/null +++ b/bench/d207/mutants/M10_budget_checked_before_cap.patch @@ -0,0 +1,30 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -778,13 +778,6 @@ + let count = take_u32(bytes, at)? as usize; + // Refused before a single id is read. See [`MAX_CONFIG_NODES`]: the frame limit bounds the + // bytes a peer can make this process hold, not the work it can make this process do. +- if count > MAX_CONFIG_NODES { +- return Err(FerroError::Wal(format!( +- "a configuration claims {count} {what}s, over the {MAX_CONFIG_NODES} limit. A frame is \ +- allowed to be large; the comparisons a configuration costs are quadratic in its two \ +- list lengths, so a large one is a request for this node's CPU rather than a cluster" +- ))); +- } + // ...and charged against the FRAME's budget, also before a single id is read. The cap above + // bounds each configuration and nothing else bounds how many a frame carries. Checked second, + // so a count over the cap is always refused by the cap: in the other order the budget would +@@ -796,6 +789,13 @@ + its own size, so bounding one configuration bounds nothing about a frame that carries \ + a thousand of them", + *budget ++ ))); ++ } ++ if count > MAX_CONFIG_NODES { ++ return Err(FerroError::Wal(format!( ++ "a configuration claims {count} {what}s, over the {MAX_CONFIG_NODES} limit. A frame is \ ++ allowed to be large; the comparisons a configuration costs are quadratic in its two \ ++ list lengths, so a large one is a request for this node's CPU rather than a cluster" + ))); + } + *budget -= count; diff --git a/bench/d207/mutants/M11_learners_uncharged.patch b/bench/d207/mutants/M11_learners_uncharged.patch new file mode 100644 index 00000000..d9a5bc12 --- /dev/null +++ b/bench/d207/mutants/M11_learners_uncharged.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -839,7 +839,7 @@ + let version = take_u64(bytes, at)?; + let term = take_u64(bytes, at)?; + let members = decode_node_list(bytes, at, "member", budget)?; +- let learners = decode_node_list(bytes, at, "learner", budget)?; ++ let learners = decode_node_list(bytes, at, "learner", &mut usize::MAX)?; + + // A node cannot be both. `with_learners` drops such a learner silently, which is the right + // thing for a *builder* — the voter entry is the stronger statement — and the wrong thing for a diff --git a/bench/d207/mutants/M12_encoder_budget_removed.patch b/bench/d207/mutants/M12_encoder_budget_removed.patch new file mode 100644 index 00000000..2e745e1f --- /dev/null +++ b/bench/d207/mutants/M12_encoder_budget_removed.patch @@ -0,0 +1,21 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -450,17 +450,7 @@ + } + // The frame's budget, for the same reason and in the same order as `decode_node_list` + // charges it: after the per-configuration cap, so each refusal names its own limit. +- if list.len() > *budget { +- return Err(FerroError::Wal(format!( +- "a configuration holds {} nodes, but only {} of this frame's \ +- {MAX_FRAME_CONFIG_NODES}-node budget is left; a peer would refuse this frame, so it \ +- is refused here where the cause is visible. Send fewer Membership entries per \ +- Append", +- list.len(), +- *budget +- ))); +- } +- *budget -= list.len(); ++ let _ = &budget; + room(b, 4 + list.len() * 4)?; + put_u32(b, u32::try_from(list.len()).map_err(|_| too_big(list.len()))?); + for n in list { diff --git a/bench/d207/mutants/M13_fresh_budget_per_entry.patch b/bench/d207/mutants/M13_fresh_budget_per_entry.patch new file mode 100644 index 00000000..68b261cb --- /dev/null +++ b/bench/d207/mutants/M13_fresh_budget_per_entry.patch @@ -0,0 +1,12 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -608,7 +608,8 @@ + // decode, and the first entry that runs off the end of the body ends it. + let mut entries = Vec::new(); + for i in 0..count { +- entries.push(decode_entry(bytes, at, budget).map_err(|e| { ++ let _ = &budget; ++ entries.push(decode_entry(bytes, at, &mut { MAX_FRAME_CONFIG_NODES }).map_err(|e| { + FerroError::Wal(format!("entry {i} of {count} in an Append frame: {e}")) + })?); + } diff --git a/bench/d207/mutants/M14_spawn_failure_uncounted.patch b/bench/d207/mutants/M14_spawn_failure_uncounted.patch new file mode 100644 index 00000000..94ddaab8 --- /dev/null +++ b/bench/d207/mutants/M14_spawn_failure_uncounted.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -2016,7 +2016,6 @@ + // it, so the slot is already released: no thread will ever run for it, and + // no second release is needed here. Counted like the failed setup above, + // for the same reason — it is a connection this node closed unserved. +- counters.refused_conns.fetch_add(1, Ordering::SeqCst); + continue; + } + } diff --git a/bench/d207/mutants/M1_setup_exit_forgets_slot.patch b/bench/d207/mutants/M1_setup_exit_forgets_slot.patch new file mode 100644 index 00000000..72cbda3b --- /dev/null +++ b/bench/d207/mutants/M1_setup_exit_forgets_slot.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1988,6 +1988,7 @@ + // with no number attached is the silent loss this module does not allow. + counters.refused_conns.fetch_add(1, Ordering::SeqCst); + let _ = stream.shutdown(Shutdown::Both); ++ std::mem::forget(registration); + continue; + } + let _ = stream.set_nodelay(true); diff --git a/bench/d207/mutants/M2_setup_exit_uncounted.patch b/bench/d207/mutants/M2_setup_exit_uncounted.patch new file mode 100644 index 00000000..8ff03658 --- /dev/null +++ b/bench/d207/mutants/M2_setup_exit_uncounted.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1986,7 +1986,6 @@ + // fails with EINVAL. `registration` is dropped by this `continue`, which + // releases the slot; the refusal is counted, because an unserved connection + // with no number attached is the silent loss this module does not allow. +- counters.refused_conns.fetch_add(1, Ordering::SeqCst); + let _ = stream.shutdown(Shutdown::Both); + continue; + } diff --git a/bench/d207/mutants/M3_slot_released_at_thread_start.patch b/bench/d207/mutants/M3_slot_released_at_thread_start.patch new file mode 100644 index 00000000..21061a18 --- /dev/null +++ b/bench/d207/mutants/M3_slot_released_at_thread_start.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -2053,6 +2053,7 @@ + // `shutdown` can reach a peer that connects and then goes silent, and it handed over the guard + // that releases the slot. Owning it here is what makes every exit path below release it, + // including the refused-handshake return. ++ drop(_registration); + + let verdict = recv_handshake(&mut stream, &stop, opts.handshake_deadline); + diff --git a/bench/d207/mutants/M4_byte_bound_removed.patch b/bench/d207/mutants/M4_byte_bound_removed.patch new file mode 100644 index 00000000..eba4cdc0 --- /dev/null +++ b/bench/d207/mutants/M4_byte_bound_removed.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1231,7 +1231,7 @@ + // made permanently unsendable by the bound that exists to keep sending possible. + while !st.queue.is_empty() + && (st.queue.len() >= self.depth +- || st.bytes.saturating_add(frame.len()) > self.max_bytes) ++ || (false && st.bytes.saturating_add(frame.len()) > self.max_bytes)) + { + if let Some(old) = st.queue.pop_front() { + st.bytes -= old.len(); diff --git a/bench/d207/mutants/M5_drop_not_refunded.patch b/bench/d207/mutants/M5_drop_not_refunded.patch new file mode 100644 index 00000000..65141691 --- /dev/null +++ b/bench/d207/mutants/M5_drop_not_refunded.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1234,7 +1234,7 @@ + || st.bytes.saturating_add(frame.len()) > self.max_bytes) + { + if let Some(old) = st.queue.pop_front() { +- st.bytes -= old.len(); ++ let _ = old.len(); + self.dropped.fetch_add(1, Ordering::SeqCst); + } + } diff --git a/bench/d207/mutants/M6_pop_not_refunded.patch b/bench/d207/mutants/M6_pop_not_refunded.patch new file mode 100644 index 00000000..f297a1ef --- /dev/null +++ b/bench/d207/mutants/M6_pop_not_refunded.patch @@ -0,0 +1,15 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1846,11 +1846,7 @@ + if st.stopped { + break; + } +- let taken = st.queue.pop_front(); +- if let Some(f) = taken.as_ref() { +- st.bytes -= f.len(); +- } +- taken ++ st.queue.pop_front() + }; + let Some(frame) = frame else { continue }; + diff --git a/bench/d207/mutants/M7_oversized_frame_refused.patch b/bench/d207/mutants/M7_oversized_frame_refused.patch new file mode 100644 index 00000000..084952e6 --- /dev/null +++ b/bench/d207/mutants/M7_oversized_frame_refused.patch @@ -0,0 +1,13 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1224,6 +1224,10 @@ + fn push(&self, frame: Vec) { + let mut st = self.state.lock().unwrap(); + if st.stopped { ++ return; ++ } ++ if frame.len() > self.max_bytes { ++ self.dropped.fetch_add(1, Ordering::SeqCst); + return; + } + // Both bounds, whichever binds first. The loop stops at an empty queue whatever the bytes diff --git a/bench/d207/mutants/M8_empty_guard_removed.patch b/bench/d207/mutants/M8_empty_guard_removed.patch new file mode 100644 index 00000000..738122e2 --- /dev/null +++ b/bench/d207/mutants/M8_empty_guard_removed.patch @@ -0,0 +1,12 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1229,8 +1229,7 @@ + // Both bounds, whichever binds first. The loop stops at an empty queue whatever the bytes + // say, so a frame larger than the whole byte bound is still admitted, alone: no message is + // made permanently unsendable by the bound that exists to keep sending possible. +- while !st.queue.is_empty() +- && (st.queue.len() >= self.depth ++ while (st.queue.len() >= self.depth + || st.bytes.saturating_add(frame.len()) > self.max_bytes) + { + if let Some(old) = st.queue.pop_front() { diff --git a/bench/d207/mutants/M9_decode_budget_not_charged.patch b/bench/d207/mutants/M9_decode_budget_not_charged.patch new file mode 100644 index 00000000..676109f3 --- /dev/null +++ b/bench/d207/mutants/M9_decode_budget_not_charged.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -798,7 +798,6 @@ + *budget + ))); + } +- *budget -= count; + // Not pre-allocated from `count`, for the reason given in `decode_append`. + let mut out: Vec = Vec::new(); + for _ in 0..count { From 2466ffc7ff6f54bf8d604581022b825afbab1957 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:17:41 -0400 Subject: [PATCH 096/315] =?UTF-8?q?UNBUILT=20D207:=20red=20tests=20for=20t?= =?UTF-8?q?he=20lead-scoped=20rest=20of=20207d362=20=E2=80=94=20zero=20idl?= =?UTF-8?q?e=5Fdeadline,=20zero=20cap,=20cap-full=20backoff,=20lost=20wake?= =?UTF-8?q?up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Written against 196f9aa before their fix; each compiles against that tree. Additions only. - a_zero_idle_deadline_is_refused_at_bind - a_connection_cap_of_zero_is_refused_at_bind - refusals_at_a_full_cap_are_paced_by_the_poll_interval (a lower bound on elapsed time, which load can only lengthen) - a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay (deterministic: the peer never answers the handshake, so the dial fails because of the shutdown, after its notify) Never compiled or run: quiet mode. Predictions in bench/d207/PREREG.md amendment 2. --- src/consensus/tests_transport.rs | 138 +++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index 8fb2f17f..e666a4dd 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -2545,3 +2545,141 @@ fn an_open_connection_holds_its_slot_until_it_closes() { } assert_eq!(t.live_inbound_conns(), 0, "the slots were not given back when the connections closed"); } + +// --------------------------------------------------------------------------------------------- +// D207 amendment 2 — the rest of 207d362 the lead scoped in: two knobs refused at zero, the cap-full +// backoff, and the lost wakeup. Written red against 196f9aa, before their fix; every test here +// compiles against that tree. +// --------------------------------------------------------------------------------------------- + +#[test] +fn a_zero_idle_deadline_is_refused_at_bind() { + // Zero does not mean "never idle". `conn_loop` closes a connection once + // `last_heard.elapsed() > idle_deadline`, which a zero deadline makes true on the first poll + // after the handshake: a node that accepts every peer and hangs up on each at once, while its + // outbound meters read healthy. Refused where it is set, like the three timings before it. + let mut opts = fast(); + opts.idle_deadline = Duration::ZERO; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + match Transport::from_listener(NodeId(1), l, BTreeMap::new(), opts) { + Ok(_) => panic!("a zero idle_deadline was accepted at bind"), + Err(e) => assert!( + format!("{e}").contains("`idle_deadline` is zero"), + "refused, but not by name: {e}" + ), + } +} + +#[test] +fn a_connection_cap_of_zero_is_refused_at_bind() { + // `map.len() >= 0` holds for every accept, so a cap of zero refuses every inbound connection: a + // node deaf to the whole cluster, counting refusals, with nothing named as the cause. + let mut opts = fast(); + opts.max_inbound_conns = 0; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + match Transport::from_listener(NodeId(1), l, BTreeMap::new(), opts) { + Ok(_) => panic!("a max_inbound_conns of zero was accepted at bind"), + Err(e) => assert!( + format!("{e}").contains("`max_inbound_conns` is zero"), + "refused, but not by name: {e}" + ), + } +} + +#[test] +fn refusals_at_a_full_cap_are_paced_by_the_poll_interval() { + // A peer that loops `connect()` against a full cap was refused back to back — accept, refuse, + // accept — with nothing to slow the accept thread, so it bought a core of this process for free. + // Each cap-full refusal now waits one `poll_interval` before the next accept (207d362). + // + // Asserted as a LOWER bound on elapsed time, which load can only lengthen: n paced refusals are + // separated by at least n - 1 sleeps, so a paced loop can never finish them sooner. An unpaced + // one finishes them within an accept poll or two of the last connect. + let poll = Duration::from_millis(100); + let mut opts = fast(); + opts.max_inbound_conns = 1; + opts.poll_interval = poll; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = l.local_addr().unwrap(); + let t = Transport::from_listener(NodeId(1), l, BTreeMap::new(), opts).unwrap(); + + // Fill the cap with one real connection, left idle. + let mut first = TcpStream::connect(addr).unwrap(); + first.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let mut hs = Vec::new(); + crate::replication::write_handshake(&mut hs).unwrap(); + first.write_all(&hs).unwrap(); + first.flush().unwrap(); + let mut theirs = [0u8; 6]; + first.read_exact(&mut theirs).unwrap(); + // The slot is reserved before the connection thread writes its handshake, so it is held by now. + assert_eq!(t.live_inbound_conns(), 1, "the admitted connection does not hold the cap's one slot"); + + const EXTRA: u64 = 8; + // Taken before the first extra connects, so it precedes every refusal counted below. + let t0 = Instant::now(); + let c0 = t.refused_conns(); + let mut held = Vec::new(); + for _ in 0..EXTRA { + held.push(TcpStream::connect(addr).unwrap()); + } + let deadline = Instant::now() + Duration::from_secs(30); + while Instant::now() < deadline && t.refused_conns() < c0 + EXTRA { + std::thread::sleep(Duration::from_millis(10)); + } + let c1 = t.refused_conns(); + let took = t0.elapsed(); + assert_eq!(c1 - c0, EXTRA, "{EXTRA} connections against a full cap, {} refused", c1 - c0); + let floor = poll * (EXTRA as u32 - 1); + assert!( + took >= floor, + "{EXTRA} refusals at a full cap took {took:?}. Paced by a {poll:?} poll they cannot take less \ + than {floor:?}, so the accept thread is refusing back to back" + ); + drop(held); + drop(first); +} + +#[test] +fn a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay() { + // **The lost wakeup.** After a failed dial the sender waits on the outbox condvar for + // `reconnect_delay`, so that `shutdown`'s notify can cut the wait short. A notify that lands while + // the sender is still INSIDE the dial finds nobody waiting, and the sender then waited the whole + // delay before it looked at the stop flag again: a shutdown that should take a poll took a + // `reconnect_delay`. + // + // Deterministic rather than raced. The peer is a listener that accepts at the kernel and never + // answers the handshake, so the sender sits in `dial`'s handshake read — polling the stop flag, + // never the condvar — until `shutdown` is called. The dial then fails BECAUSE of the shutdown, + // after its notify has come and gone. + let silent = TcpListener::bind("127.0.0.1:0").unwrap(); + let peer = silent.local_addr().unwrap(); + silent.set_nonblocking(true).unwrap(); + let mut opts = fast(); + opts.reconnect_delay = Duration::from_secs(30); + opts.handshake_deadline = Duration::from_secs(60); + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let t = Transport::from_listener(NodeId(1), l, BTreeMap::from([(NodeId(2), peer)]), opts) + .unwrap(); + + // The sender has connected, and is waiting for a handshake that will never come. + let deadline = Instant::now() + Duration::from_secs(15); + let mut held = None; + while Instant::now() < deadline && held.is_none() { + match silent.accept() { + Ok((s, _)) => held = Some(s), + Err(_) => std::thread::sleep(Duration::from_millis(10)), + } + } + assert!(held.is_some(), "the sender never dialled the silent peer, so nothing here is tested"); + + let started = Instant::now(); + t.shutdown(); + let took = started.elapsed(); + assert!( + took < Duration::from_secs(10), + "shutdown took {took:?}: the sender's dial failed because of the shutdown, missed the \ + notify that had already been sent, and waited out its 30 s reconnect_delay" + ); + drop(held); +} From 4534838997acf6d3247826d0f616f12fbedc0d7d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:17:58 -0400 Subject: [PATCH 097/315] =?UTF-8?q?UNBUILT=20D207:=20PREREG=20amendment=20?= =?UTF-8?q?2=20=E2=80=94=20red=20and=20green=20predictions=20for=20the=20l?= =?UTF-8?q?ead-scoped=20items,=20before=20their=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d207/PREREG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bench/d207/PREREG.md b/bench/d207/PREREG.md index 7c85cf46..ae04019a 100644 --- a/bench/d207/PREREG.md +++ b/bench/d207/PREREG.md @@ -148,3 +148,40 @@ guard that cannot fire and blocks landing. - Per-target suite: `VERIFY_MODE=per-target tools/verify-suite.sh d207`. Predicted **2589 passed, 0 failed** on macOS: main's 2579 plus the ten tests above. The 2579 is the lead's measurement at `9aa6968` (FAN-QUEUE #9). I have not reproduced it, so it is INFERRED here. + +--- + +## Amendment 2 — the rest of 207d362 that the lead scoped in, written BEFORE its fix (nothing built) + +Lead decision (2026-09-24): port three more items, red first. (1) Refuse `idle_deadline = 0` and +`max_inbound_conns = 0` at bind. (2) The cap-full backoff. (3) The lost wakeup at the two redial +waits. **Not** the `inbox_bytes ≥ MAX_FRAME_BYTES` floor, which is a decision for Ryan. + +Red tests at **`2466ffc`**, added on top of `196f9aa`; additions only (`138 0`). The module has 59 +`#[test]` there, one of them macOS-only, so it runs **59 on macOS** and 58 on Linux and Windows. + +| key | test | +|---|---| +| K | `a_zero_idle_deadline_is_refused_at_bind` | +| L | `a_connection_cap_of_zero_is_refused_at_bind` | +| P | `refusals_at_a_full_cap_are_paced_by_the_poll_interval` | +| W | `a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay` | + +### Run R2 — RED at `2466ffc` (the D207 fix present, these four items not) + +Same instrument. Predicted on macOS: **compiles; 55 passed, 4 failed**, rc=101. + +| key | predicted failure | +|---|---| +| K | the `Ok` arm: "a zero idle_deadline was accepted at bind" | +| L | the `Ok` arm: "a max_inbound_conns of zero was accepted at bind" | +| P | `c1 - c0 == 8` passes. `took >= 700ms` fails, with `took` **under 300 ms**. Unpaced, the 8 refusals finish within one or two 100 ms accept polls of the last connect. INFERRED bound: a loaded box could stretch it, and past 700 ms P would pass on this tree. That would be a missed red, never a false one, and is read as "re-run on a quieter box". | +| W | shutdown takes **between 30 s and 31 s**: the whole `reconnect_delay`. The `< 10 s` assertion fails | + +### Run G2 — GREEN at the fix + +Predicted: **compiles; 59 passed, 0 failed** on macOS (58 elsewhere). K and L are refused by name. +P: all 8 are refused, and `took` ≥ 700 ms (guaranteed by the sleeps, and load can only add to it). +W: shutdown returns in **under 1 s**: the dial sees the stop flag within one 5 ms poll, and the +sender then leaves without waiting. J and CAP each take about 20 x 5 ms longer, well inside their +deadlines. From dacc14944102be40276c4f41ee5845b12bdba8af Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:18:42 -0400 Subject: [PATCH 098/315] UNBUILT D207: refuse a zero idle_deadline or connection cap, pace cap-full refusals, close the redial lost wakeup The lead-scoped rest of F3-transport's 207d362, ported by meaning: - Transport::start refuses idle_deadline == 0 and max_inbound_conns == 0, each with its own message (not folded into the zero-timeout loop, whose message blames std socket calls). - accept_loop sleeps one poll_interval after each cap-full refusal, after releasing the map lock. - Both redial waits in sender_loop go through Outbox::wait_before_redial, which reads stopped and the stop flag under the outbox lock before waiting. Plain wait_timeout is kept (not wait_timeout_while), so a push still cuts a redial wait short as before. The inbox_bytes >= MAX_FRAME_BYTES floor is NOT ported (lead: a decision for Ryan). Never compiled or run. --- src/consensus/transport.rs | 52 +++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index bc69b3ef..43ea1682 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -1242,6 +1242,24 @@ impl Outbox { st.queue.push_back(frame); self.woken.notify_all(); } + + /// Wait up to `delay` before the sender redials, returning early if woken. `false` means the + /// transport is stopping, and the sender must leave its loop rather than dial again. + /// + /// **The stop flags are read under the lock BEFORE waiting.** `shutdown` notifies once, and a + /// notify that lands while the sender is still inside `dial` finds nobody waiting; a wait begun + /// after it used to sleep out the whole delay before anything looked at the flags again — the + /// lost wakeup (D207, from F3-transport's `207d362`). `stopped` is set under this same lock, so + /// it lands either before this check or while the wait below is parked, never between them. + /// Both redial waits in `sender_loop` go through here, so there is one copy to get right. + fn wait_before_redial(&self, stop: &AtomicBool, delay: Duration) -> bool { + let st = self.state.lock().unwrap(); + if st.stopped || stop.load(Ordering::SeqCst) { + return false; + } + let _ = self.woken.wait_timeout(st, delay); + true + } } /// Thread-per-peer carriage for consensus messages, over `std::net`. @@ -1408,6 +1426,25 @@ impl Transport { ))); } } + // Two more knobs whose zero is a partition that reads as health, not a smaller setting + // (D207, ported from F3-transport's `207d362`). Not folded into the loop above: its message + // blames std's socket calls, and neither of these ever reaches one. + if opts.idle_deadline.is_zero() { + return Err(FerroError::Internal( + "`idle_deadline` is zero. It does not mean \"never idle\": a connection is closed \ + once it has been silent longer than this, which a zero makes true on the first \ + poll after the handshake — a node that accepts every peer and at once hangs up on \ + each" + .to_string(), + )); + } + if opts.max_inbound_conns == 0 { + return Err(FerroError::Internal( + "`max_inbound_conns` is zero, so every inbound connection would be refused at the \ + cap: a node deaf to the whole cluster while its outbound meters read healthy" + .to_string(), + )); + } if opts.queue_depth == 0 { return Err(FerroError::Internal( "a queue depth of 0 would drop every message on the way out, which is a \ @@ -1815,8 +1852,9 @@ fn sender_loop( // immediately is a 100%-CPU loop and a connection storm against a peer // that has done nothing wrong. counters.connect_failures.fetch_add(1, Ordering::SeqCst); - let st = ob.state.lock().unwrap(); - let _ = ob.woken.wait_timeout(st, opts.reconnect_delay); + if !ob.wait_before_redial(&stop, opts.reconnect_delay) { + break; + } continue; } } @@ -1826,8 +1864,9 @@ fn sender_loop( counters.connect_failures.fetch_add(1, Ordering::SeqCst); // Wait on the condvar rather than sleeping, so a shutdown does not have to // wait out a reconnect delay it has already made pointless. - let st = ob.state.lock().unwrap(); - let _ = ob.woken.wait_timeout(st, opts.reconnect_delay); + if !ob.wait_before_redial(&stop, opts.reconnect_delay) { + break; + } continue; } } @@ -1970,6 +2009,11 @@ fn accept_loop( counters.refused_conns.fetch_add(1, Ordering::SeqCst); drop(map); let _ = stream.shutdown(Shutdown::Both); + // **Paced.** Refused back to back, a peer looping `connect()` against a + // full cap had this thread spin accept-refuse-accept, a core of this process + // for free. One poll per refusal bounds that, and delays a stop by at most + // the poll the stop flag is already allowed (D207, from `207d362`). + std::thread::sleep(opts.poll_interval); continue; } map.insert(id, mine); From cb053290bfc19f2823d1ddb2c547255bb9667e24 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:19:10 -0400 Subject: [PATCH 099/315] UNBUILT D207: the redial wait returns nothing; the loop head already leaves on stop stopped is only ever set after the stop flag (shutdown, stop_started), so a bool that told the caller to break was a second guard on an exit the loop head already takes, and no test could tell if it broke. The pre-check under the lock, which is what closes the lost wakeup, stays. --- src/consensus/transport.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index 43ea1682..fc9b823a 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -1243,8 +1243,10 @@ impl Outbox { self.woken.notify_all(); } - /// Wait up to `delay` before the sender redials, returning early if woken. `false` means the - /// transport is stopping, and the sender must leave its loop rather than dial again. + /// Wait up to `delay` before the sender redials, returning early if woken — or at once if the + /// transport is already stopping, in which case the sender's loop head sees the stop flag and + /// leaves. (`stopped` is only ever set after the stop flag, so that check suffices; a returned + /// flag here would be a second guard on the same exit, and nothing could tell if it broke.) /// /// **The stop flags are read under the lock BEFORE waiting.** `shutdown` notifies once, and a /// notify that lands while the sender is still inside `dial` finds nobody waiting; a wait begun @@ -1252,13 +1254,12 @@ impl Outbox { /// lost wakeup (D207, from F3-transport's `207d362`). `stopped` is set under this same lock, so /// it lands either before this check or while the wait below is parked, never between them. /// Both redial waits in `sender_loop` go through here, so there is one copy to get right. - fn wait_before_redial(&self, stop: &AtomicBool, delay: Duration) -> bool { + fn wait_before_redial(&self, stop: &AtomicBool, delay: Duration) { let st = self.state.lock().unwrap(); if st.stopped || stop.load(Ordering::SeqCst) { - return false; + return; } let _ = self.woken.wait_timeout(st, delay); - true } } @@ -1852,9 +1853,7 @@ fn sender_loop( // immediately is a 100%-CPU loop and a connection storm against a peer // that has done nothing wrong. counters.connect_failures.fetch_add(1, Ordering::SeqCst); - if !ob.wait_before_redial(&stop, opts.reconnect_delay) { - break; - } + ob.wait_before_redial(&stop, opts.reconnect_delay); continue; } } @@ -1864,9 +1863,7 @@ fn sender_loop( counters.connect_failures.fetch_add(1, Ordering::SeqCst); // Wait on the condvar rather than sleeping, so a shutdown does not have to // wait out a reconnect delay it has already made pointless. - if !ob.wait_before_redial(&stop, opts.reconnect_delay) { - break; - } + ob.wait_before_redial(&stop, opts.reconnect_delay); continue; } } From dd9d1e16814ad857ab184629b447315e02a91b0f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:20:07 -0400 Subject: [PATCH 100/315] =?UTF-8?q?UNBUILT=20D207:=20PREREG=20amendment=20?= =?UTF-8?q?3=20=E2=80=94=20mutants=20re-cut=20from=20cb05329=20(M15-M18=20?= =?UTF-8?q?added),=20table=20for=2059=20tests,=20the=20unported=20floor=20?= =?UTF-8?q?stated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every patch passes git apply --check against cb05329. Predictions before any run. --- bench/d207/PREREG.md | 55 +++++++++++++++++++ bench/d207/make_mutants.py | 20 ++++++- .../mutants/M14_spawn_failure_uncounted.patch | 2 +- .../M15_zero_idle_deadline_accepted.patch | 11 ++++ .../d207/mutants/M16_zero_cap_accepted.patch | 11 ++++ bench/d207/mutants/M17_cap_full_unpaced.patch | 10 ++++ bench/d207/mutants/M18_lost_wakeup.patch | 13 +++++ .../mutants/M1_setup_exit_forgets_slot.patch | 2 +- .../mutants/M2_setup_exit_uncounted.patch | 2 +- .../M3_slot_released_at_thread_start.patch | 2 +- bench/d207/mutants/M6_pop_not_refunded.patch | 2 +- 11 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 bench/d207/mutants/M15_zero_idle_deadline_accepted.patch create mode 100644 bench/d207/mutants/M16_zero_cap_accepted.patch create mode 100644 bench/d207/mutants/M17_cap_full_unpaced.patch create mode 100644 bench/d207/mutants/M18_lost_wakeup.patch diff --git a/bench/d207/PREREG.md b/bench/d207/PREREG.md index ae04019a..bfd2f65b 100644 --- a/bench/d207/PREREG.md +++ b/bench/d207/PREREG.md @@ -185,3 +185,58 @@ P: all 8 are refused, and `took` ≥ 700 ms (guaranteed by the sleeps, and load W: shutdown returns in **under 1 s**: the dial sees the stop flag within one 5 ms poll, and the sender then leaves without waiting. J and CAP each take about 20 x 5 ms longer, well inside their deadlines. + +--- + +## Amendment 3 — the fix for amendment 2, and the mutants re-cut (still nothing built) + +The fix is `dacc149`, followed by `cb05329`. `cb05329` drops the redial helper's bool: `stopped` is only +ever set after the stop flag (`shutdown` and `stop_started`, READ), so the loop head already takes that exit. +All 18 mutants are re-generated from **`cb05329`** by `bench/d207/make_mutants.py cb05329`, and each passes +`git apply --check`. M1–M14 have the same edits as in amendment 1; only their hunk line numbers moved. + +**Run G2, restated at `cb05329` and at the tip:** **59 passed, 0 failed** on macOS (58 elsewhere), with +the values in amendment 2. + +### The mutant table, re-cut for 59 tests (it supersedes amendment 1's table) + +Same command per mutant; M8 is still run alone. Every test not listed passes. + +| mutant | fails | changed from amendment 1 | +|---|---|---| +| M1 | A, B | — | +| M2 | A, B | — | +| M3 | **J, P** (CAP may also fail, by timing; not scored) | **P added.** Its precondition `live_inbound_conns == 1` reads 0, because `drop(_registration)` runs before the connection thread reads the handshake, so it has already run by the time the client has the reply. Deterministic. | +| M4 | C, G | — | +| M5 | C, G | — | +| M6 | F | — | +| M7 | G | — | +| M8 | G never returns, rc=124 | — | +| M9 | D, H | — | +| M10 | MIL | — | +| M11 | H | — | +| M12 | E, H | — | +| M13 | D, H | — | +| M14 | **none: SURVIVES** by design | — | +| M15 `zero_idle_deadline_accepted` | K | new. The `Ok` arm | +| M16 `zero_cap_accepted` | L | new. The `Ok` arm | +| M17 `cap_full_unpaced` | P | new. `took` under 300 ms against the 700 ms floor. On a box loaded enough to stretch it past 700 ms, P passes and M17 survives. That reads as "re-run quieter", not as the backoff being untestable. | +| M18 `lost_wakeup` | W | new. Shutdown takes 30 to 31 s | + +**Per-target, restated:** **2593 passed, 0 failed** on macOS = main's 2579 (INFERRED, the lead's number) +plus the 14 D207 tests. + +### What is still not ported, stated so it can be decided + +The `inbox_bytes ≥ MAX_FRAME_BYTES` floor. It would refuse the existing +`an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted` at its own setup. At `9aa6968`: + +- the fixture at `tests_transport.rs:1528` is `opts.inbox_bytes = 4096`; +- `:1531` is `Transport::from_listener(…).unwrap()`, which would panic on the new refusal; +- the premises built on 4096 would all need changing too: the 1 KiB payload, the `for _ in 0..40` writes at + `:1560`, the assertion `t.inbox_bytes() <= 4096` at `:1575`, and `const SENT: u64 = 40` at `:1594`, + against which `drained + inbound_dropped() == SENT` is checked. + +`207d362` made exactly that edit (fixture → `MAX_FRAME_BYTES`, 1 MiB payloads, 16 frames written from a +thread, the bound assertion → `<= MAX_FRAME_BYTES`). It is a test edit, and the lead has filed it as +Ryan's decision. diff --git a/bench/d207/make_mutants.py b/bench/d207/make_mutants.py index a567cb4b..b6a241ea 100644 --- a/bench/d207/make_mutants.py +++ b/bench/d207/make_mutants.py @@ -7,7 +7,8 @@ python3 bench/d207/make_mutants.py -Predictions for every mutant are in bench/d207/PREREG.md, amendment 1. +Predictions for every mutant are in bench/d207/PREREG.md: M1-M14 in amendment 1, all of them as +re-cut in amendment 3. """ import difflib import pathlib @@ -123,6 +124,23 @@ " // for the same reason — it is a connection this node closed unserved.\n counters.refused_conns.fetch_add(1, Ordering::SeqCst);\n", " // for the same reason — it is a connection this node closed unserved.\n", )]), + # --- amendment 2: the lead-scoped rest of 207d362 -------------------------------------------- + ("M15_zero_idle_deadline_accepted", [( + " if opts.idle_deadline.is_zero() {\n", + " if false && opts.idle_deadline.is_zero() {\n", + )]), + ("M16_zero_cap_accepted", [( + " if opts.max_inbound_conns == 0 {\n", + " if false && opts.max_inbound_conns == 0 {\n", + )]), + ("M17_cap_full_unpaced", [( + " // the poll the stop flag is already allowed (D207, from `207d362`).\n std::thread::sleep(opts.poll_interval);\n", + " // the poll the stop flag is already allowed (D207, from `207d362`).\n", + )]), + ("M18_lost_wakeup", [( + " if st.stopped || stop.load(Ordering::SeqCst) {\n return;\n }\n let _ = self.woken.wait_timeout(st, delay);\n", + " let _ = stop;\n let _ = self.woken.wait_timeout(st, delay);\n", + )]), ] diff --git a/bench/d207/mutants/M14_spawn_failure_uncounted.patch b/bench/d207/mutants/M14_spawn_failure_uncounted.patch index 94ddaab8..58b2e8b2 100644 --- a/bench/d207/mutants/M14_spawn_failure_uncounted.patch +++ b/bench/d207/mutants/M14_spawn_failure_uncounted.patch @@ -1,6 +1,6 @@ --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs -@@ -2016,7 +2016,6 @@ +@@ -2057,7 +2057,6 @@ // it, so the slot is already released: no thread will ever run for it, and // no second release is needed here. Counted like the failed setup above, // for the same reason — it is a connection this node closed unserved. diff --git a/bench/d207/mutants/M15_zero_idle_deadline_accepted.patch b/bench/d207/mutants/M15_zero_idle_deadline_accepted.patch new file mode 100644 index 00000000..37f6af86 --- /dev/null +++ b/bench/d207/mutants/M15_zero_idle_deadline_accepted.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1430,7 +1430,7 @@ + // Two more knobs whose zero is a partition that reads as health, not a smaller setting + // (D207, ported from F3-transport's `207d362`). Not folded into the loop above: its message + // blames std's socket calls, and neither of these ever reaches one. +- if opts.idle_deadline.is_zero() { ++ if false && opts.idle_deadline.is_zero() { + return Err(FerroError::Internal( + "`idle_deadline` is zero. It does not mean \"never idle\": a connection is closed \ + once it has been silent longer than this, which a zero makes true on the first \ diff --git a/bench/d207/mutants/M16_zero_cap_accepted.patch b/bench/d207/mutants/M16_zero_cap_accepted.patch new file mode 100644 index 00000000..7ceea0c1 --- /dev/null +++ b/bench/d207/mutants/M16_zero_cap_accepted.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1439,7 +1439,7 @@ + .to_string(), + )); + } +- if opts.max_inbound_conns == 0 { ++ if false && opts.max_inbound_conns == 0 { + return Err(FerroError::Internal( + "`max_inbound_conns` is zero, so every inbound connection would be refused at the \ + cap: a node deaf to the whole cluster while its outbound meters read healthy" diff --git a/bench/d207/mutants/M17_cap_full_unpaced.patch b/bench/d207/mutants/M17_cap_full_unpaced.patch new file mode 100644 index 00000000..31d3183c --- /dev/null +++ b/bench/d207/mutants/M17_cap_full_unpaced.patch @@ -0,0 +1,10 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -2010,7 +2010,6 @@ + // full cap had this thread spin accept-refuse-accept, a core of this process + // for free. One poll per refusal bounds that, and delays a stop by at most + // the poll the stop flag is already allowed (D207, from `207d362`). +- std::thread::sleep(opts.poll_interval); + continue; + } + map.insert(id, mine); diff --git a/bench/d207/mutants/M18_lost_wakeup.patch b/bench/d207/mutants/M18_lost_wakeup.patch new file mode 100644 index 00000000..675b4d16 --- /dev/null +++ b/bench/d207/mutants/M18_lost_wakeup.patch @@ -0,0 +1,13 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1256,9 +1256,7 @@ + /// Both redial waits in `sender_loop` go through here, so there is one copy to get right. + fn wait_before_redial(&self, stop: &AtomicBool, delay: Duration) { + let st = self.state.lock().unwrap(); +- if st.stopped || stop.load(Ordering::SeqCst) { +- return; +- } ++ let _ = stop; + let _ = self.woken.wait_timeout(st, delay); + } + } diff --git a/bench/d207/mutants/M1_setup_exit_forgets_slot.patch b/bench/d207/mutants/M1_setup_exit_forgets_slot.patch index 72cbda3b..03ab74b9 100644 --- a/bench/d207/mutants/M1_setup_exit_forgets_slot.patch +++ b/bench/d207/mutants/M1_setup_exit_forgets_slot.patch @@ -1,6 +1,6 @@ --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs -@@ -1988,6 +1988,7 @@ +@@ -2029,6 +2029,7 @@ // with no number attached is the silent loss this module does not allow. counters.refused_conns.fetch_add(1, Ordering::SeqCst); let _ = stream.shutdown(Shutdown::Both); diff --git a/bench/d207/mutants/M2_setup_exit_uncounted.patch b/bench/d207/mutants/M2_setup_exit_uncounted.patch index 8ff03658..74c22c7c 100644 --- a/bench/d207/mutants/M2_setup_exit_uncounted.patch +++ b/bench/d207/mutants/M2_setup_exit_uncounted.patch @@ -1,6 +1,6 @@ --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs -@@ -1986,7 +1986,6 @@ +@@ -2027,7 +2027,6 @@ // fails with EINVAL. `registration` is dropped by this `continue`, which // releases the slot; the refusal is counted, because an unserved connection // with no number attached is the silent loss this module does not allow. diff --git a/bench/d207/mutants/M3_slot_released_at_thread_start.patch b/bench/d207/mutants/M3_slot_released_at_thread_start.patch index 21061a18..1f0c3f9f 100644 --- a/bench/d207/mutants/M3_slot_released_at_thread_start.patch +++ b/bench/d207/mutants/M3_slot_released_at_thread_start.patch @@ -1,6 +1,6 @@ --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs -@@ -2053,6 +2053,7 @@ +@@ -2094,6 +2094,7 @@ // `shutdown` can reach a peer that connects and then goes silent, and it handed over the guard // that releases the slot. Owning it here is what makes every exit path below release it, // including the refused-handshake return. diff --git a/bench/d207/mutants/M6_pop_not_refunded.patch b/bench/d207/mutants/M6_pop_not_refunded.patch index f297a1ef..19724507 100644 --- a/bench/d207/mutants/M6_pop_not_refunded.patch +++ b/bench/d207/mutants/M6_pop_not_refunded.patch @@ -1,6 +1,6 @@ --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs -@@ -1846,11 +1846,7 @@ +@@ -1882,11 +1882,7 @@ if st.stopped { break; } From 96718a606da6252cafe9baf1f98d1e599bc6678b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:35:54 -0400 Subject: [PATCH 101/315] UNBUILT D16 chain: PREREG amendment 1 -- the extra drain is a probe, not a production collector Corrects the claim that drain_pending() is a collector production runs (only tests call it outside reap). Census now precedes the drain probe so a reap that skips its drain cannot be hidden. Adds G7 (deferred residue), G8, a run-level G0, an id-slot probe predicted per tree (D200), the reconciled cost note, and seven source mutants with expected per-cell verdicts. Nothing compiled or run (quiet mode). --- bench/d16_chain/PREREG.md | 105 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/bench/d16_chain/PREREG.md b/bench/d16_chain/PREREG.md index 868c1135..1bf96f62 100644 --- a/bench/d16_chain/PREREG.md +++ b/bench/d16_chain/PREREG.md @@ -184,3 +184,108 @@ timeout 14400 target/release/examples/d16_chain_retention 1000 2 fanout,chain,ov Exit 0 = every guard held and every prediction matched. Exit 1 = guards held, and at least one pre-registered prediction did not (a result, printed as `MISMATCH`). Exit 2 = `NOT A RESULT`. + +--- + +## Amendment 1 — 2026-09-24, committed before the harness change it describes + +Still quiet mode: nothing has been compiled or run, so no number has been seen. + +### A1.1 Correction: `drain_pending()` is NOT a collector production runs + +"Fixture" above says the harness runs "`drain_pending()` once more and `collect_orphaned_extents()` +once — the two collectors production also runs". **That is false for the drain.** +`git grep -n "\.drain_pending(" -- src/` at `9aa6968` finds callers only inside `reaper.rs`'s +`#[cfg(test)]` module. Production drains only inside each `reap` (`drain_pending_seeded`). Outside a +reap it runs just one collector, `collect_orphans_if_due`, from `lease_thread.rs` after the reap +lock is released. That is the `deferred` residue plus the full `collect_orphaned_extents` scan. + +It matters for more than wording. An extra drain run BEFORE the census would clean up after a reap +that skipped its own drain (the D183 retraction's class), and the retention columns would then read +0 while the defect was live. + +**New order, after each sweep:** `reap_expired` → `collect_orphaned_extents` (production's periodic +collector; equal to `collect_orphans_if_due`'s work when `deferred` is empty, which new guard G7 +asserts) → G4 → **census (the reported numbers)** → `drain_pending()` as a **probe**. The probe is +predicted to release 0. A non-zero probe means a reap left reclaimable pages parked. The probe runs +after the census, so it cannot hide what it finds. The predicted integers do not change: in the +predicted world the probe releases nothing. + +### A1.2 New guards + +- **G7:** `TwoTierReaper::deferred_len() == 0` after each sweep's collector: no drain returned early + with touched extents left unswept. +- **G8:** the slot probe (A1.4) could fork. +- **G0 is now run-level.** A dirty or unknown build stamp makes the whole run NOT A RESULT (exit 2), + and every line's tag says `build=DIRTY` or `build=unknown`. The per-cell verdict from G1–G8 and the + predictions is still printed. That is what lets a fire-check, which always builds dirty, be read. + +### A1.3 D200 does not move any retained-page prediction + +D200 (the cascade never releases a detached ancestor's id slot) is fixed on `wall21-reaped-subtree` +@ `17cbd4c`. It is not on main. The page predictions do not depend on it (READ, `git show 206af08 +1107752`): +- `release_id` writes a FREE_ID key. Retention is decided by `live_child_in_epoch_range` over CHILD + entries, which release does not touch, and a released slot keeps its record, so the drain's + `get_raw(owner)` still answers. +- `extent_is_collectable` treats an owner as gone once its generation moved or it reads `Reaped`. + Both held before any release. +- The fixture never forks after a reap until the slot probe (A1.4), which runs after every census. + So no slot is recycled underneath a page count. + +### A1.4 Secondary: an id-slot probe (D200 on the depth axis). This binary does not judge it + +After sweep 2's census, the harness forks D branches off trunk and counts how many were handed a +slot id the fixture had used. `fork` takes a free slot before it mints a new id, so the count is the +number of slots released. It prints as a `slots` line. It is not in the MATCH/MISMATCH verdict, +because its expected value depends on the tree: + +| tree | `chain`, `overwrite` | `fanout` | +|---|---|---| +| main lineage without D200 (`9aa6968`) | **1** at every D (only the leaf's own reap releases; D−1 interior slots leak) | **D** | +| `wall21-reaped-subtree` @ `17cbd4c` (D200) | **D** | **D** | + +### A1.5 Cost note, reconciled with wall21 + +wall21's pre-registered counter covers the three per-reap liveness questions: Θ(D²) span scans for +the interior sweep, `3·D(D−1)/2`. That is READ from `17cbd4c:tests/wall21_reaped_chain_liveness.rs`. +The Θ(P·D³) figure above adds the drain's retests. Each reap retests the whole pending log, and +`lane_wall21_reap_walk.md` §1.3 gives the same Θ(P·D³). It is INFERRED in both lanes. No duration is +predicted. + +### A1.6 Fire-checks: mutants injected INSIDE the measured path + +Each mutant is a one-anchor source replacement in the engine, never in the harness. It is applied +by `bench/d16_chain/firecheck.py`, which refuses a dirty `src/` and any anchor that does not occur +exactly once, and it is restored from git after every build. Each anchor was checked with +`git grep -nF` at `9aa6968`: 1 hit each. Harness arguments: `1,3,10 2 fanout,chain,overwrite`. +D = 3 is the first depth at which the D16 pin, rather than the ordinary rule, holds a page. + +Expected per-cell verdict (`M` = MISMATCH with exactly these keys; `N` = NOT A RESULT with exactly +these guard ids; `=` = MATCH): + +| # | mutant (file, anchor) | what it removes | `fanout` | `chain` and `overwrite` | +|---|---|---|---|---| +| M0 | none, clean build | — | = | = (and exit 0) | +| M1 | `table_catalog.rs` `live_child_at`: the D16 arm's guard → `if false` | **the pin** (red arm) | = | D=1 `=`; D=3,10 **M** retained_pages, retained_reserved, retained_extents, pending (b(D−1)'s P pages only) | +| M2 | `reaper.rs` `reap`: `drain_pending_seeded(own_arenas)` dropped | **a reap's own drain** (control a; D183's class) | = | D=1 `=`; D=3,10 **M** after_leaf_pages, after_leaf_reserved, after_leaf_extents, after_leaf_pending, after_leaf_drain | +| M3 | `reaper.rs` `reap` fast path: `free_arena` dropped | **the fast path's free** (control b) | D=1 **M** after_leaf_pages, after_leaf_reserved, after_leaf_extents; D=3,10 **M** retained_pages, retained_reserved, retained_extents, after_leaf_pages, after_leaf_reserved, after_leaf_extents | every D **M** after_leaf_pages, after_leaf_reserved, after_leaf_extents | +| M4 | `arena.rs` `release_page`: `live_pages.fetch_sub(1)` dropped | the net counter's decrement (G2) | = | D=1 `=`; D=3,10 **N** G2 | +| M5 | `arena.rs` `cow_page`: the in-place test → `if true` | the copy (G6, G1) | = | `chain` = ; `overwrite` D=1 `=`, D=3,10 **N** G1, G6 | +| M6 | `reaper.rs` `reap_expired`: `candidates` → `.skip(1)` | one reap per sweep (G3, G4) | D=1 **N** G3; D=3,10 **N** G3, G4 | D=1 **N** G3; D=3,10 **N** G3, G4 | +| M7 | `reaper.rs` `drain_pending_seeded`: `guard.swept = true` dropped | the drain's disarm (G7) | **N** G7 | **N** G7 | + +Plus A0 on the clean build: an unparsable argument (`1,x`) must print `NOT A RESULT` and exit 2. + +A mutant whose cells differ from its row has not fired as registered. Report the difference; do not +re-register it. Guards not fire-checked here, stated as blind spots: G5 (a page owned by a branch the +fixture never made) and G8. + +### A1.7 Run commands, superseding "Run" above + +``` +cargo build --release --example d16_chain_retention +timeout 1800 target/release/examples/d16_chain_retention 1,10,100 2 fanout,chain,overwrite +timeout 14400 target/release/examples/d16_chain_retention 1000 2 fanout,chain,overwrite +timeout 7200 python3 bench/d16_chain/firecheck.py +``` From e7acf391b2d9770d165d333dde39e48fc2bfa934 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:40:07 -0400 Subject: [PATCH 102/315] UNBUILT D16 chain: census before the drain probe, G7/G8, slot probe, and in-path fire-checks Harness per PREREG amendment 1 (96718a6): collect_orphaned_extents then the census, drain_pending only afterwards as a probe; G7 (deferred residue), G8 (slot probe), run-level G0 with per-cell verdicts kept; D forks after both sweeps count released id slots (D200 on the depth axis, not judged). bench/d16_chain/firecheck.py applies seven one-anchor mutants to src/ (pin, reap drain, fast-path free, net counter, copy, candidate loop, drain disarm), refuses a dirty tree, restores only the file it mutated and checks the blob, and compares every cell's verdict to A1.6. Nothing compiled or run. --- bench/d16_chain/firecheck.py | 337 ++++++++++++++++++++++++++++++++ examples/d16_chain_retention.rs | 127 ++++++++++-- 2 files changed, 445 insertions(+), 19 deletions(-) create mode 100644 bench/d16_chain/firecheck.py diff --git a/bench/d16_chain/firecheck.py b/bench/d16_chain/firecheck.py new file mode 100644 index 00000000..843306e0 --- /dev/null +++ b/bench/d16_chain/firecheck.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +"""D16 chain fire-check: every mutant removes ONE mechanism inside the measured path. + +Every mutant edits the engine (`src/`). None edits `examples/d16_chain_retention.rs`: a forced result +at the call site would exercise nothing inside the reaper, the catalog or the store. The pattern is +`bench/d154_mutate.py`'s, plus three changes: + + * it REFUSES to start on a dirty `src/` or `examples/`. Restoring with `git checkout -- src/` would + otherwise destroy uncommitted work, which is how `git checkout --` has eaten a fix here before. + * after every restore, each mutated file's blob hash must equal HEAD's. "Restored" is checked, not + assumed. + * a mutant counts only if EVERY cell's verdict equals the one pre-registered in + `bench/d16_chain/PREREG.md`, amendment 1, A1.6. "Something went red" is not enough: the red has + to come from the detector the mutant was aimed at. + +Run from the worktree root, on a committed tree: + + timeout 7200 python3 bench/d16_chain/firecheck.py + +Raw harness output for every run goes to `bench/d16_chain/firecheck/

#ferrodb@`, +# `path+file:///<...>/ferrodb#` when the directory is named after the package, and the old +# `ferrodb (path+file:///...)`. +OURS = re.compile(r"(#ferrodb@|/ferrodb#[0-9]|^ferrodb )") for line in sys.stdin: try: m = json.loads(line) except ValueError: continue - pid = m.get("package_id", "") - if "#ferrodb@" not in pid and not pid.startswith("ferrodb "): + if not OURS.search(m.get("package_id", "")): continue if m.get("reason") == "build-script-executed": out_dir = m.get("out_dir") or "-" @@ -184,11 +199,43 @@ mt() { local v v=$(python3 -c 'import os,sys; p=sys.argv[1]; print(os.stat(p).st_mtime_ns if os.path.exists(p) else 0)' "$2") case "$v" in - ''|*[!0-9]*) echo "D231 HARNESS: could not read the mtime of $2"; exit 2 ;; + ''|*[!0-9]*) harness "could not read the mtime of $2" ;; esac printf -v "$1" '%s' "$v" } +# sha12 : the 12-or-more-hex abbreviation, or failure (callers exit 2 on it). +sha12() { + local s + s=$(git -C "$1" rev-parse --verify -q --short=12 "$2^{commit}") || return 1 + case "$s" in ''|*[!0-9a-f]*) return 1 ;; esac + [ ${#s} -ge 12 ] || return 1 + echo "$s" +} + +# The harness's own edits, each checked to have taken before anything is built on it: an edit +# that silently failed would otherwise be scored, and at the base a no-op edit reproduces exactly +# the FAILs the PREREG predicts. +porcelain() { git -C "$1" --no-optional-locks status --porcelain --untracked-files=no -- "$2"; } +append() { # append + printf '%s' "$3" >> "$1/$2" || harness "could not append to $1/$2" + [ -n "$(porcelain "$1" "$2")" ] || harness "an append to $2 is not visible to git status" +} +restore() { # restore + g "$1" checkout -q -- "$2" || harness "git checkout -- $2 failed" + [ -z "$(porcelain "$1" "$2")" ] || harness "$2 still differs after git checkout --" +} +touch_moved() { # touch_moved ...: every file must exist and its mtime must move + local f b a + for f in "$@"; do + [ -f "$f" ] || harness "cannot touch $f: it does not exist" + mt b "$f" + touch "$f" || harness "touch $f failed" + mt a "$f" + [ "$a" != "$b" ] || harness "touch did not move the mtime of $f" + done +} + script_stamp() { # the env lines the build script handed cargo: / local f=$1 if [ ! -f "$f" ]; then echo "?/?"; return; fi @@ -209,33 +256,33 @@ check() { local of before json_line got after rerun fresh out_dir stamp_ok verdict wnote of=$(outfile_for "$arm") before=0 - if [ "$of" != "-" ]; then mt before "$of"; fi + if [ "$of" != "-" ]; then + mt before "$of" + [ "$before" != 0 ] || harness "the build script's output $of vanished before arm=$arm step=$step" + fi # `${1+"$@"}`, not `"$@"`: bash 3.2 (macOS /bin/bash) calls an empty "$@" unbound under set -u. if ! ( cd "$ck" && env CARGO_TARGET_DIR="$tgt" ${1+"$@"} timeout 3600 cargo build --offline --quiet \ --example d231_stamp_probe --message-format=json > "$WORK/steps/$arm.$step.json" \ 2> "$WORK/steps/$arm.$step.err" ); then - echo "D231 HARNESS: build failed, arm=$arm step=$step; see $WORK/steps/$arm.$step.err" tail -5 "$WORK/steps/$arm.$step.err" - exit 2 + harness "build failed, arm=$arm step=$step; see $WORK/steps/$arm.$step.err" fi - json_line=$(python3 "$PARSE" < "$WORK/steps/$arm.$step.json") || exit 2 + json_line=$(python3 "$PARSE" < "$WORK/steps/$arm.$step.json") || harness "could not parse cargo's JSON, arm=$arm step=$step" out_dir=${json_line% *} fresh=${json_line##* } - if [ "$out_dir" != "-" ]; then - of=$(dirname "$out_dir")/output - echo "$of" > "$WORK/outfile.$arm" - fi + # Emitted on every build, fresh or not (Cargo book, external-tools), so its absence is a fault + # in this harness's reading, never a reason to reuse the last step's path. + [ "$out_dir" != "-" ] || harness "cargo emitted no build-script-executed message for ferrodb, arm=$arm step=$step" + of=$(dirname "$out_dir")/output + echo "$of" > "$WORK/outfile.$arm" # stdout only: an exec error or a panic goes to stderr, and must not be read as a stamp. got=$("$tgt/debug/examples/d231_stamp_probe" | head -1) # Only a stamp is a measurement. Anything else would score as a FAIL, and a FAIL is what the # base run is expected to produce. - if ! [[ $got =~ $STAMP_RE ]]; then - echo "D231 HARNESS: the probe printed something that is not a stamp, arm=$arm step=$step: '$got'"; exit 2 - fi - if [ "$of" = "-" ]; then echo "D231 HARNESS: cargo never said where the build script's output is, arm=$arm step=$step"; exit 2; fi + [[ $got =~ $STAMP_RE ]] || harness "the probe printed something that is not a stamp, arm=$arm step=$step: '$got'" mt after "$of" - if [ "$after" = 0 ]; then echo "D231 HARNESS: the build script's output $of does not exist after a build, arm=$arm step=$step"; exit 2; fi + [ "$after" != 0 ] || harness "the build script's output $of does not exist after a build, arm=$arm step=$step" cp "$of" "$WORK/steps/$arm.$step.output" || exit 2 if [ "$after" != "$before" ]; then rerun=yes; else rerun=no; fi @@ -253,19 +300,32 @@ check() { say "D231 commit=$SHA12 arm=$arm step=$step expect=\"$expect\" got=\"$got\" stamp_ok=$stamp_ok expect_rerun=$expect_rerun rerun=$rerun$wnote lib_fresh=$fresh script_stamp=$(script_stamp "$of") verdict=$verdict" } -# witness -# +# snap ...: record the mtimes of the files a git operation must or must not touch. Each +# must exist BEFORE the operation: `mt` reads a missing file as 0, and a file that appears reads +# as "touched" (a gc between steps can remove a loose ref that the next operation recreates). +snap() { + local f m + SNAP_F=(); SNAP_M=() + for f in "$@"; do + [ -f "$f" ] || harness "witnessed file $f does not exist before the operation" + mt m "$f" + SNAP_F+=("$f"); SNAP_M+=("$m") + done +} + +# witness # Folded into the NEXT check's verdict: that step tests one watch only if exactly the expected -# files moved. A witnessed file that does not exist is a harness failure, never a "no". +# files moved. A witnessed file missing after the operation is a harness failure, never a "no". witness() { - local f i h r seen - for f in "$5" "$6" "$7"; do - [ -f "$f" ] || { echo "D231 HARNESS: witnessed file $f does not exist"; exit 2; } + local i m seen="" + for i in "${!SNAP_F[@]}"; do + [ -f "${SNAP_F[$i]}" ] || harness "witnessed file ${SNAP_F[$i]} does not exist after the operation" + mt m "${SNAP_F[$i]}" + if [ "$m" = "${SNAP_M[$i]}" ]; then seen="$seen no"; else seen="$seen yes"; fi done - mt i "$5"; mt h "$6"; mt r "$7" - seen="$([ "$2" = "$i" ] && echo no || echo yes) $([ "$3" = "$h" ] && echo no || echo yes) $([ "$4" = "$r" ] && echo no || echo yes)" + seen=${seen# } if [ "$seen" = "$1" ]; then WITNESS_OK=y; else WITNESS_OK=n; fi - WITNESS_NOTE="witness_index/head/ref_touched=\"$seen\"(expect \"$1\")" + WITNESS_NOTE="witness_touched=\"$seen\"(expect \"$1\")" } probe_source() { @@ -288,111 +348,136 @@ gpath() { # an absolute git path in a checkout: gpath } # --- the checkouts --------------------------------------------------------------------------- -git clone -q --shared --no-checkout "$REPO" "$CLONE" || exit 2 -g "$CLONE" checkout -q -b d231-probe "$SHA" || exit 2 -g "$CLONE" worktree add -q -b d231-probe-linked "$LINKED" "$SHA" || exit 2 -git clone -q --bare --shared "$REPO" "$FOREIGN" || exit 2 -git --git-dir="$FOREIGN" update-ref --no-deref HEAD "$SHA^" || exit 2 -# Q0: a second commit with EXACTLY S0's tree. Steps 7-21 sit on it, so the stamp moves while every +git clone -q --shared --no-checkout "$REPO" "$CLONE" || harness "git clone failed" +g "$CLONE" checkout -q -b d231-probe "$SHA" || harness "checkout of $SHA failed" +g "$CLONE" worktree add -q -b d231-probe-linked "$LINKED" "$SHA" || harness "git worktree add failed" +git clone -q --bare --shared "$REPO" "$FOREIGN" || harness "the bare clone failed" +git --git-dir="$FOREIGN" update-ref --no-deref HEAD "$SHA^" || harness "could not detach foreign.git at $SHA^" +# Q0: a second commit with EXACTLY S0's tree. Steps 7-25 sit on it, so the stamp moves while every # file, build.rs included, stays the one under test. (^ would not do: its build.rs is not # necessarily the one being tested.) -Q0FULL=$(g "$CLONE" commit-tree "$SHA^{tree}" -p "$SHA" -m "d231 probe: S0's tree, another commit") || exit 2 -S0=$(git -C "$CLONE" rev-parse --short=12 HEAD) -Q0=$(git -C "$CLONE" rev-parse --short=12 "$Q0FULL") -FOREIGN12=$(git --git-dir="$FOREIGN" rev-parse --short=12 HEAD) +Q0FULL=$(g "$CLONE" commit-tree "$SHA^{tree}" -p "$SHA" -m "d231 probe: S0's tree, another commit") || harness "commit-tree for Q0 failed" +S0=$(sha12 "$CLONE" HEAD) || harness "S0 did not resolve" +Q0=$(sha12 "$CLONE" "$Q0FULL") || harness "Q0 did not resolve" +FOREIGN12=$(git --git-dir="$FOREIGN" rev-parse --verify -q --short=12 HEAD) || harness "foreign.git's HEAD did not resolve" if [ "$S0" = "$Q0" ] || [ "$S0" = "$FOREIGN12" ]; then - echo "D231 HARNESS: S0=$S0 Q0=$Q0 foreign=$FOREIGN12; steps 6 and 7 need three different commits"; exit 2 + harness "S0=$S0 Q0=$Q0 foreign=$FOREIGN12; steps 6 and 7 need three different commits" fi say "D231 SHAS S0=$S0 Q0=$Q0 foreign=$FOREIGN12" for arm in clone linked; do - if [ "$arm" = clone ]; then ck=$CLONE; else ck=$LINKED; fi + if [ "$arm" = clone ]; then ck=$CLONE; first="refs/heads/d231-probe"; else ck=$LINKED; first="refs/heads/d231-probe-linked"; fi tgt=$WORK/target-$arm probe_source "$ck" # The checkout kind this arm claims to be, asserted rather than assumed. - if [ "$arm" = clone ] && [ ! -d "$ck/.git" ]; then echo "D231 HARNESS: $ck/.git is not a directory"; exit 2; fi - if [ "$arm" = linked ] && [ ! -f "$ck/.git" ]; then echo "D231 HARNESS: $ck/.git is not a file"; exit 2; fi + if [ "$arm" = clone ] && [ ! -d "$ck/.git" ]; then harness "$ck/.git is not a directory"; fi + if [ "$arm" = linked ] && [ ! -f "$ck/.git" ]; then harness "$ck/.git is not a file"; fi ex=$(git -C "$ck" ls-files 'examples/*.rs' | head -1) ts=$(git -C "$ck" ls-files 'tests/*.rs' | head -1) - if [ -z "$ex" ] || [ -z "$ts" ]; then echo "D231 HARNESS: no tracked example or test file to edit"; exit 2; fi + if [ -z "$ex" ] || [ -z "$ts" ]; then harness "no tracked example or test file to edit"; fi + idx=$(gpath "$ck" index) || harness "no index path" + hf=$(gpath "$ck" HEAD) || harness "no HEAD path" + ff=$(gpath "$ck" "$first") || harness "no path for $first" sleep 2 # after the checkout: see WHY SOME STEPS SLEEP FIRST check "$arm" "$ck" "$tgt" 1-fresh-build "at $S0" yes check "$arm" "$ck" "$tgt" 2-no-change "at $S0" no - printf '\n// d231 probe edit\n' >> "$ck/src/lib.rs" + append "$ck" src/lib.rs $'\n// d231 probe edit\n' check "$arm" "$ck" "$tgt" 3-src-edit "at $S0 +DIRTY" yes - g "$ck" add src/lib.rs && g "$ck" commit -q -m "d231 probe commit" || exit 2 - S1=$(git -C "$ck" rev-parse --short=12 HEAD) + g "$ck" add src/lib.rs || harness "git add src/lib.rs failed" + g "$ck" commit -q -m "d231 probe commit" || harness "git commit failed" + S1=$(sha12 "$ck" HEAD) || harness "S1 did not resolve" + [ "$S1" != "$S0" ] || harness "the probe commit did not move HEAD" check "$arm" "$ck" "$tgt" 4-commit "at $S1" yes - idx=$(gpath "$ck" index); hf=$(gpath "$ck" HEAD); rf=$(gpath "$ck" "$(git -C "$ck" symbolic-ref -q HEAD)") - mt i0 "$idx"; mt h0 "$hf"; mt r0 "$rf" - g "$ck" reset -q --soft HEAD~1 || exit 2 - witness "no no yes" "$i0" "$h0" "$r0" "$idx" "$hf" "$rf" + snap "$idx" "$hf" "$ff" + g "$ck" reset -q --soft HEAD~1 || harness "git reset --soft failed" + witness "no no yes" check "$arm" "$ck" "$tgt" 5-reset-soft "at $S0 +DIRTY" yes - touch "$idx" + touch_moved "$idx" check "$arm" "$ck" "$tgt" 6-foreign-git-dir "at $S0 +DIRTY" yes GIT_DIR="$FOREIGN" - g "$ck" branch -q "d231-probe-$arm-q0" "$Q0FULL" || exit 2 - mt i0 "$idx"; mt h0 "$hf"; mt r0 "$rf" - g "$ck" symbolic-ref HEAD "refs/heads/d231-probe-$arm-q0" || exit 2 - witness "no yes no" "$i0" "$h0" "$r0" "$idx" "$hf" "$rf" + g "$ck" branch -q "d231-probe-$arm-q0" "$Q0FULL" || harness "git branch for Q0 failed" + snap "$idx" "$hf" "$ff" + g "$ck" symbolic-ref HEAD "refs/heads/d231-probe-$arm-q0" || harness "git symbolic-ref HEAD failed" + witness "no yes no" check "$arm" "$ck" "$tgt" 7-head-only "at $Q0 +DIRTY" yes - g "$ck" reset -q --hard || exit 2 + g "$ck" reset -q --hard || harness "git reset --hard failed" sleep 2 check "$arm" "$ck" "$tgt" 8-reset-hard "at $Q0" yes check "$arm" "$ck" "$tgt" 9-settle "at $Q0" no - printf '\n// d231 probe edit\n' >> "$ck/$ex" + append "$ck" "$ex" $'\n// d231 probe edit\n' check "$arm" "$ck" "$tgt" 10-example-edit "at $Q0 +DIRTY" yes - g "$ck" checkout -q -- "$ex" || exit 2 + restore "$ck" "$ex" sleep 2 check "$arm" "$ck" "$tgt" 11-example-restore "at $Q0" yes check "$arm" "$ck" "$tgt" 12-settle "at $Q0" no - printf '\n// d231 probe edit\n' >> "$ck/$ts" + append "$ck" "$ts" $'\n// d231 probe edit\n' check "$arm" "$ck" "$tgt" 13-test-edit "at $Q0 +DIRTY" yes - g "$ck" checkout -q -- "$ts" || exit 2 + restore "$ck" "$ts" sleep 2 check "$arm" "$ck" "$tgt" 14-test-restore "at $Q0" yes check "$arm" "$ck" "$tgt" 15-settle "at $Q0" no - printf '\n# d231 probe edit\n' >> "$ck/Cargo.toml" + append "$ck" Cargo.toml $'\n# d231 probe edit\n' check "$arm" "$ck" "$tgt" 16-manifest-edit "at $Q0 +DIRTY" yes - g "$ck" checkout -q -- Cargo.toml || exit 2 + restore "$ck" Cargo.toml sleep 2 check "$arm" "$ck" "$tgt" 17-manifest-restore "at $Q0" yes check "$arm" "$ck" "$tgt" 18-settle "at $Q0" no - printf '\nd231 probe\n' >> "$ck/README.md" - g "$ck" add README.md || exit 2 + append "$ck" README.md $'\nd231 probe\n' + g "$ck" add README.md || harness "git add README.md failed" check "$arm" "$ck" "$tgt" 19-index-only "at $Q0 +DIRTY" yes - touch "$ck/README.md" "$ck/src/lib.rs" "$idx" + touch_moved "$ck/README.md" "$ck/src/lib.rs" "$idx" check "$arm" "$ck" "$tgt" 20-stale-stat "at $Q0 +DIRTY" yes check "$arm" "$ck" "$tgt" 21-no-self-rerun "at $Q0 +DIRTY" no - # A one-link symbolic-ref chain: HEAD -> link -> the Q0 branch. Retargeting the link changes - # what HEAD resolves to and touches neither HEAD nor the index. + # A one-link symbolic-ref chain: HEAD -> link -> the Q0 branch. link="refs/heads/d231-link-$arm" - if [ "$arm" = clone ]; then first="refs/heads/d231-probe"; else first="refs/heads/d231-probe-linked"; fi - g "$ck" symbolic-ref "$link" "refs/heads/d231-probe-$arm-q0" || exit 2 - g "$ck" symbolic-ref HEAD "$link" || exit 2 + g "$ck" symbolic-ref "$link" "refs/heads/d231-probe-$arm-q0" || harness "could not create $link" + g "$ck" symbolic-ref HEAD "$link" || harness "could not point HEAD at $link" check "$arm" "$ck" "$tgt" 22-chain-link "at $Q0 +DIRTY" yes - lf=$(gpath "$ck" "$link") - mt i0 "$idx"; mt h0 "$hf"; mt r0 "$lf" - g "$ck" symbolic-ref "$link" "$first" || exit 2 - witness "no no yes" "$i0" "$h0" "$r0" "$idx" "$hf" "$lf" + lf=$(gpath "$ck" "$link") || harness "no path for $link" + + # Retarget the FIRST link: HEAD -> link -> $first (at S0). Neither HEAD nor the index moves. + snap "$idx" "$hf" "$lf" + g "$ck" symbolic-ref "$link" "$first" || harness "could not retarget $link" + witness "no no yes" check "$arm" "$ck" "$tgt" 23-chain-retarget "at $S0 +DIRTY" yes + + # Move the SECOND link: $first itself, from S0 to Q0. Only $first's file moves. + snap "$idx" "$hf" "$lf" "$ff" + g "$ck" update-ref "$first" "$Q0FULL" || harness "git update-ref $first failed" + witness "no no no yes" + check "$arm" "$ck" "$tgt" 24-chain-second-link "at $Q0 +DIRTY" yes + + # A replacement for Q0 whose tree is the current index tree (Q0 plus the staged README), so a + # git that honours replacements reads the tree clean. Its parent is S0, not Q0, so the + # replacement cannot form a cycle. Both premises are asserted before the build: honoured it + # reads clean, and with --no-replace-objects it does not. + xtree=$(git -C "$ck" write-tree) || harness "git write-tree failed" + xcommit=$(g "$ck" commit-tree "$xtree" -p "$SHA" -m "d231 probe: replacement for Q0") || harness "commit-tree for the replacement failed" + g "$ck" replace "$Q0FULL" "$xcommit" || harness "git replace failed" + [ -z "$(git -C "$ck" --no-optional-locks status --porcelain --untracked-files=no)" ] \ + || harness "with the replacement honoured the tree does not read clean; step 25 would not discriminate" + [ -n "$(git -C "$ck" --no-optional-locks --no-replace-objects status --porcelain --untracked-files=no)" ] \ + || harness "without the replacement the tree reads clean; step 25 would not discriminate" + touch_moved "$idx" + check "$arm" "$ck" "$tgt" 25-replace "at $Q0 +DIRTY" yes + g "$ck" replace -d "$Q0FULL" > /dev/null || harness "git replace -d failed" done say "D231 SUMMARY commit=$SHA12 pass=$PASS fail=$FAIL verdicts=$((PASS + FAIL)) expected_verdicts=$EXPECTED_VERDICTS result=$RESULT" if [ $((PASS + FAIL)) -ne "$EXPECTED_VERDICTS" ]; then - echo "D231 HARNESS: $((PASS + FAIL)) verdicts reached, not $EXPECTED_VERDICTS"; exit 2 + harness "$((PASS + FAIL)) verdicts reached, not $EXPECTED_VERDICTS" fi if [ "${D231_KEEP:-0}" = 1 ]; then diff --git a/build.rs b/build.rs index 20cf5eaa..b62d8285 100644 --- a/build.rs +++ b/build.rs @@ -224,8 +224,9 @@ fn git_state_paths(git: &dyn Fn(&[&str]) -> Option) -> Option = Vec::new(); if !reftable { let mut at = "HEAD".to_string(); From 6c3f8749ee72a4f3f8841c923bc38de47fef9f13 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:15:57 -0400 Subject: [PATCH 218/315] UNBUILT D244 tests: A1 never steers a deadline or child key into the record-tail or state-tail leaf (the record-tail leaf also holds the first deadline keys); comment on phase 1's per-fork separator bound corrected (review 2 F1-F3; not built, not run) --- tests/d244_publish_root.rs | 51 ++++++++++++++++++++++++-------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/tests/d244_publish_root.rs b/tests/d244_publish_root.rs index dc1db827..19d58131 100644 --- a/tests/d244_publish_root.rs +++ b/tests/d244_publish_root.rs @@ -34,7 +34,7 @@ use std::io; use std::sync::{Arc, Mutex}; use ferrodb::branch::table_catalog::SIDECAR_HEADER_PAGE; -use ferrodb::branch::{BranchCatalog, BranchId, LeaseDeadline, TableBranchCatalog}; +use ferrodb::branch::{BranchCatalog, BranchId, BranchState, LeaseDeadline, TableBranchCatalog}; use ferrodb::buffer::buffer_pool::BufferPoolManager; use ferrodb::error::FerroError; use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; @@ -246,11 +246,11 @@ fn a_failed_read_of_page_one_at_a_root_splits_publish_is_retried_by_the_next_mut assert_reopens_with(&storage, &ids); } -/// Free bytes in the leaf an insert of `key` would land in, found the way the tree finds it: down -/// from the root through `BPlusTreeInternalPage::find_child`. A leaf splits on an insert of `E` bytes -/// once `27 + keys + values + E >= PAGE_SIZE` (`index_page.rs`, `BPlusTreeLeafPage::is_full`), and -/// every key and value here is a `Vec` serialized behind a 4-byte length. -fn leaf_room(cat: &TableBranchCatalog, key: &[u8]) -> usize { +/// The leaf an insert of `key` would land in, and its free bytes, found the way the tree finds it: +/// down from the root through `BPlusTreeInternalPage::find_child`. A leaf splits on an insert of `E` +/// bytes once `27 + keys + values + E >= PAGE_SIZE` (`index_page.rs`, `BPlusTreeLeafPage::is_full`), +/// and every key and value here is a `Vec` serialized behind a 4-byte length. +fn leaf_of(cat: &TableBranchCatalog, key: &[u8]) -> (u32, usize) { let tree = BPlusTreeManager::, Vec>::open(cat.root_page_id(), cat.pool_handle().clone()); let key = key.to_vec(); let mut page = cat.root_page_id(); @@ -261,7 +261,7 @@ fn leaf_room(cat: &TableBranchCatalog, key: &[u8]) -> usize { let used = 27 + l.key_arr.iter().map(|k| 4 + k.len()).sum::() + l.vals.iter().map(|v| 4 + v.len()).sum::(); - return PAGE_SIZE.saturating_sub(used); + return (page, PAGE_SIZE.saturating_sub(used)); } } } @@ -279,12 +279,15 @@ const DEADLINE_ENTRY: usize = 4 + 17 + 4; /// A fork writes, in order: its record key, its Live state key, its deadline key, its child key in /// the parent's live set, and the header key. The first two always append to the tails of their /// groups (ids only grow). The next two are steered, by the parent and the lease each fork is -/// given, into leaves with room, so they never split. The header rewrite is the same size. So a root -/// split in a fork can only come from the record or the state append, and each is followed by a -/// write this fork has not yet touched: the state tail after the record, the steered deadline leaf -/// after the state. On a cold pool that write must read from storage, and the injector fails that -/// read. Its first reads (the parent's record, the parent's envelope, the free-id span) are made -/// before anything is appended, so the rule cannot fire on them. +/// given, into leaves with room, so they never split. Those leaves must also not BE the record-tail +/// or state-tail leaf: the record group (tag 0) and the deadline group (tag 1) are neighbours, so the +/// record-tail leaf also holds deadline keys. A deadline key steered into it would land in a leaf the +/// fork has just read and grown. The header rewrite is the same size. So a root split in a fork can +/// only come from the record or the state append, and each is followed by a write this fork has not +/// yet touched: the state tail after the record, the steered deadline leaf after the state. On a cold +/// pool that write must read from storage, and the injector fails that read. Its first reads (the +/// parent's record, the parent's envelope, the free-id span) are made before anything is appended, +/// so the rule cannot fire on them. /// /// Phases: /// 1. **Grow.** 200 parents forked from trunk, then forks round-robin across them, every fork with @@ -338,8 +341,9 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { "premise failed: the internal root split while growing, so the tree is three levels \ deep and a leaf split can no longer reach its root" ); - // Fewer than ~6 separators of up to 25 bytes left, and one fork adds at most two (its - // two tails), so the root is not full yet and a few tail splits will fill it. + // Under 150 bytes left. One fork adds at most four separators of up to 25 bytes (its + // record, deadline, state and child inserts can each split a leaf, while the leases + // still rise), so the root is not full yet, and a few tail splits will fill it. if room < 150 { vehicle = seq; break; @@ -355,14 +359,23 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { for attempt in 0..5_000usize { let epoch = cat.current_epoch().0 + 1; let id = ids.last().copied().expect("ids") + 1; + // The two leaves this fork appends to, which a steered write must not share. + let tails = [ + leaf_of(&cat, &ferrodb::branch::tree_keys::record(id)).0, + leaf_of(&cat, &ferrodb::branch::tree_keys::state(BranchState::Live.as_u8(), id)).0, + ]; + let steered = |key: Vec, entry: usize| { + let (page, room) = leaf_of(&cat, &key); + room > entry && !tails.contains(&page) + }; let parent = (0..parents.len()) .map(|k| parents[(attempt + k) % parents.len()]) - .find(|p| leaf_room(&cat, &ferrodb::branch::tree_keys::child(p.id, epoch)) > CHILD_ENTRY) - .expect("premise failed: no parent's live set has a leaf with room for one more child"); + .find(|p| steered(ferrodb::branch::tree_keys::child(p.id, epoch), CHILD_ENTRY)) + .expect("premise failed: no parent's live set has a leaf with room, apart from the tails"); let deadline = (0..leases.len()) .map(|k| leases[(attempt * 97 + k) % leases.len()] + 1) - .find(|l| leaf_room(&cat, &ferrodb::branch::tree_keys::deadline(*l, id)) > DEADLINE_ENTRY) - .expect("premise failed: no deadline leaf has room for one more key"); + .find(|l| steered(ferrodb::branch::tree_keys::deadline(*l, id), DEADLINE_ENTRY)) + .expect("premise failed: no deadline leaf has room, apart from the tails"); cold(&cat); storage.arm(Rule::ReadAfterAppends { appends_needed: 3, appended: HashSet::new() }); From b5cbdec83e00b91a723bbfe8c2356d07f7415cf6 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:22:03 -0400 Subject: [PATCH 219/315] UNBUILT D235: PREREG amendment 4 -- review 3's W1-W3 and N1 recorded; T3 cells named by panic text Nothing compiled or run. --- bench/d235/PREREG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/bench/d235/PREREG.md b/bench/d235/PREREG.md index 072a6082..a84d9e9e 100644 --- a/bench/d235/PREREG.md +++ b/bench/d235/PREREG.md @@ -204,3 +204,53 @@ U = `record::d235_parent_entry_holders` (7 tests), R = `branch::catalog::tests:: | M7 walk ignores incarnations | green | green | RED, log arm, `Q reads as having a live child` | 1 red (recycled slot) | green | Counts are unchanged: `--test d235_rebuild_keeps_d16_pins` 3 passed; per-target 2589. + +--- + +## Amendment 4 — 2026-09-24, the lead's decisions on review 3 (`frontier/d235_review3.md` @ `0b8ddf8`) + +Nothing compiled or run. Review 3 is SOUND-WITH-CAVEATS at `faf45e5`: no behaviour change, no type +error. The test, the rule and the matrix hold as registered. The record changes: + +- **W1: the D201 claim was overstated.** Corrected in the code comments in the next commit: + `tests/d235_rebuild_keeps_d16_pins.rs` above `a_rebuild_does_not_pin_a_recycled_parent_slot_s_new_occupant`, + and `record::parent_entry_holders`' doc. The reaper-less `seal` fallback (`runtime.rs`, + SCALE-LEDGER D201) supplies ONLY the unconditional detach: it never calls `release_id`. + - READ here at `faf45e5`: outside test modules the only callers are `reaper.rs:715` (`reap`) and + `table_catalog.rs:337` (`migrate_from`). `catalog.rs:697` is the trait forwarding to the + inherent method. + - Recycling P's slot additionally needs a PRE-D235 rebuild freeing P (the legacy-log route), or a + reaper releasing P. + - At the tip, a reopen after a fallback detach RE-PINS rather than frees (INFERRED, review 3). + `mark_reaped` bumps only `generation`, so P's `branch_id` still matches X's handle. The walk + re-derives X's entry under P, and P's slot stays unfree. +- **W2: the kept-pin assertion is named by its message, not as "over-pin".** In amendment 3's matrix + "over-pin" meant the assertion whose message begins `X's entry must stay under P's old slot`. The + M5 and M7 failure is ALSO an over-pin (N pins Q), so the label cannot tell the two apart. A runner + checking cells against panic text should read the T3 cells as: + + | # | T3 first failure (the panic message after the arm prefix `log reopen: ` or `migration: `) | + |---|---| + | M1 | `log reopen: X's entry must stay under P's old slot` | + | M2 | `log reopen: X's entry must stay under P's old slot` | + | M3 | `migration: X's entry must stay under P's old slot` | + | M4 | green | + | M5 | `migration: Q reads as having a live child` | + | M6 | green | + | M7 | `log reopen: Q reads as having a live child` | + | `9aa6968` src (step a2) | `log reopen: X's entry must stay under P's old slot` | + | `6589552` src (step a2) | `log reopen: Q reads as having a live child` | + +- **W3: the suite count is only partly derived.** The +10 is READ: `git diff 9aa6968 faf45e5` adds + exactly 10 `#[test]` lines, with no `#[ignore]`, no `should_panic` and no macro. That is 3 in the + d235 target and 7 in the unit module. **2579 is NOT re-derived**: it is read from FAN-QUEUE rows + 12–15. So 2589 is conditional on it, and the count is left to the fan run. Quote its result in + **per-target** mode, as measured. +- **N1, recorded so nobody re-derives it (no action; INFERRED, review 3).** The kept pin rarely + protects anything in practice. + - It never protects N's pages: they are born after X's fork. + - It protects old P's pages only if some were still pending at rebuild time. That needs a drain + that refused and put them back (the D83/D124 path), because `release_id` needs P's array empty + and `reap` drains right after releasing. + - Otherwise it is a slot leak until C is reaped. The docstring's "dropping it COULD release a page" + stands, and keeping the entry is the leak direction. From cf7085b146cff1d464772d639934d94bb0837468 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:22:14 -0400 Subject: [PATCH 220/315] UNBUILT D235: the D201 route supplies only the detach, not the recycling (review 3 W1, comments only) The reaper-less seal fallback never calls release_id (non-test callers: reap and migrate_from), so recycling a pinned node's parent slot also needs a pre-D235 rebuild or a reaper releasing it; at the tip a reopen after a fallback detach re-pins. Nothing compiled or run. --- src/branch/record.rs | 5 ++++- tests/d235_rebuild_keeps_d16_pins.rs | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/branch/record.rs b/src/branch/record.rs index 5a06e5d2..e2f8dbea 100644 --- a/src/branch/record.rs +++ b/src/branch/record.rs @@ -1316,7 +1316,10 @@ pub fn reclaimable(live_children: &[Epoch], birth: Epoch, freed: Epoch) -> bool /// same way. /// /// The running reaper cannot build this shape: `release_id` refuses a slot with live children. A -/// pre-D16 legacy log migrated by `default_for_database`, or D201's seal fallback, can. +/// pre-D16 legacy log can, once a pre-D235 rebuild has freed the parent's slot. The reaper-less +/// `seal` fallback (SCALE-LEDGER D201) supplies only the unconditional detach, and never releases a +/// slot. At the tip, a reopen after such a detach re-pins rather than frees: `mark_reaped` leaves +/// the parent's `branch_id` matching the handle the child recorded. (D235 review 3, W1.) pub(crate) fn parent_entry_holders( nodes: &HashMap, BranchState)>, ) -> HashSet { diff --git a/tests/d235_rebuild_keeps_d16_pins.rs b/tests/d235_rebuild_keeps_d16_pins.rs index 296a5374..c34e1e51 100644 --- a/tests/d235_rebuild_keeps_d16_pins.rs +++ b/tests/d235_rebuild_keeps_d16_pins.rs @@ -244,8 +244,12 @@ fn a_table_catalog_reopen_keeps_the_pin_of_a_reaped_interior() { /// **D235 review F3: a rebuild must not pin a recycled parent slot's new occupant.** /// /// The running D16 reaper cannot build this shape: `release_id` refuses a slot with live children. -/// A PRE-D16 reaper could, because it detached unconditionally. So can the reaper-less `seal` -/// fallback in `runtime.rs` (SCALE-LEDGER D201), which detaches without asking about live children. +/// A PRE-D16 reaper could, because it detached unconditionally and a pre-D235 rebuild then freed +/// P's slot. The reaper-less `seal` fallback in `runtime.rs` (SCALE-LEDGER D201) supplies only the +/// unconditional DETACH: it never calls `release_id`, whose only non-test callers are `reap` and +/// `migrate_from`. Recycling P's slot additionally needs a pre-D235 rebuild or a reaper releasing +/// P. At the tip, a reopen after a fallback detach re-pins X under P rather than freeing P, because +/// `mark_reaped` leaves P's `branch_id` matching the handle X recorded. (D235 review 3, W1.) /// The shape: P -> X -> C, with X reaped and DETACHED from P although C lives. P, now childless, is /// reaped and its slot released and recycled into N, a child of Q, and N is reaped too. X still /// names P's OLD incarnation as its parent. From 4a9dd6f78a5dd9b8189b07f51bf786fd9b933a4e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:24:14 -0400 Subject: [PATCH 221/315] =?UTF-8?q?UNBUILT=20D249=20review:=20PREREG=20ame?= =?UTF-8?q?ndment=203=20=E2=80=94=20A9's=20premise=20corrected,=20A33=20an?= =?UTF-8?q?d=20E2,=20AI=20and=20E4,=20the=20two=20stale=20comments,=20Cons?= =?UTF-8?q?traint=20recorded,=20the=20staleness=20check=20with=20ST=20and?= =?UTF-8?q?=20E5;=20before=20the=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d249/PREREG.md | 118 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/bench/d249/PREREG.md b/bench/d249/PREREG.md index 98d39718..b93fee26 100644 --- a/bench/d249/PREREG.md +++ b/bench/d249/PREREG.md @@ -178,3 +178,121 @@ AA's row assertion. That is wrong (READ `storage/tuple.rs`): `--test adv_f4_false_refusal`. - These are the targets that drive ALTER, and the merge path through `plan_alters`. - The pre-check refuses only an entry `persist` would itself have refused, so none of them should move. + +--- + +## Amendment 3 — the review's F1, F2 and F4–F7, written BEFORE their code (nothing built) + +Source: `artie-research frontier/d249_review.md` @ `13577b1`, verdict SOUND-WITH-CAVEATS: the fix is sound and the +evidence is weak. These are the lead's decisions. The review's F3 is a new row, D254, on the child branch +`d254-catalog-persist-bound` with its own PREREG. + +### F1 and F6: amendment 1's premise was wrong, and E2 is re-registered + +- **Retracted** (append-only): amendment 1's "a ninth column makes the bitmap two bytes; a row rewritten under + nine columns is then misread under eight, or refused by `deserialize`". Both halves are false (READ + `storage/tuple.rs`): + - **Alignment padding hides the extra bitmap byte.** Every INTEGER, value or NULL, is padded to a 4-byte boundary + measured from the start of the tuple (`get_padding(4, …)`, `:72`, `:124`, `:180`), after the 24-byte version + header (`:9`). Eight columns give 24 + 1 = 25, padded to 28. Nine columns give 24 + 2 = 26, also padded to 28. + Every INTEGER sits at the same offset in both layouts, so the eight-column reader reads the nine-column row + correctly. + - **`deserialize` has no refusal.** It checks no length, and an overrun is a slice panic (`:186`, `:217-218`). +- **So E2 survives A9, as well as AR and AA.** Before this amendment, no registered test pinned where the refusal + happens. +- **A33** = `a_thirty_third_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten`, additions only. + - The setup: `t` with 32 INTEGER columns (`id NOT NULL`, `c1..c31`), holding `(1..=32)`, then + `ADD COLUMN <300 bytes> INTEGER`. + - It must be refused by name, the catalog must keep 32 columns, `SELECT *` must return exactly `(1..=32)`, and a + later CREATE TABLE must succeed. + - **The layout arithmetic.** 32 columns give 24 + 4 = 28, with no padding. 33 columns give 24 + 5 = 29, padded to + 32. So every value moves. + - **The premise, asserted without becoming the expected value:** `Tuple::serialize((1..=32) + NULL, 33-column + schema)` followed by `.deserialize(32-column schema)` must NOT equal `(1..=32)`. If a future layout change + makes the fixture non-discriminating, this fails loudly. +- **A9 stays**, because it still pins the wedge. Its doc comment repeats the false premise and is corrected + (comment only; no assertion moves). + +### F2: the index-rename half of the pre-check is unpinned. E4, and test AI + +- **E4 `index_renames_not_checked`** deletes `rename_indexed_columns(&mut installed, actions);` from `plan_alters`. + It survives AR, AA, A9, A33, U1 and U2. + - Wherever a renamed index name exceeds 255 bytes, the same name is already in the schema. The column-name guard + (4/8) fires before the index-name guards (6/8, 8/8). + - So index renames change the answer only through the size arm. +- **AI** = `a_rename_that_grows_an_indexed_column_past_a_page_is_refused_by_size`, additions only. The numbers are + READ lengths and INFERRED sums, per the review: + - **Setup:** `t` with `id`, `v`, and 14 filler INTEGER columns with distinct 255-byte names; `CREATE INDEX` on + `v`; then `RENAME COLUMN v TO <255 bytes>`. + - **Before the rename** the entry is 23 + 3645 = 3668 bytes. **After it** the entry is 3668 + 254 (schema) + 254 + (index) = 4176, which is over 4096, so the size arm refuses it as a `Constraint` naming `"t"` and "catalog page". + - **The premises**, asserted through `CatalogPage::has_space` on an empty page: + - the fully renamed entry does NOT fit; + - the entry with only the schema renamed DOES fit (3922). That second premise is what makes the test see E4. + - **Then:** the table keeps `v`, its index still covers `v`, and a later CREATE TABLE succeeds. + - **Predicted kills:** + - E4: the check passes at 3922. The rename-only chain skips the rewrite, `finish` installs, and `persist` + cannot place the 4176-byte entry. AI fails at its schema assertion. + - E1: the same failure. + - E3: `serialize` panics on the oversized entry. +- **The hazard, bounded.** Under E1 or E4, AI reaches `persist`'s unbounded page loop (D254). So AI's harness + calls `dm.reserve_region("test floor", 256, u32::MAX)` before `Catalog::create`, which makes allocation refuse + past page 255 instead of filling the disk. AR, AA, A9 and A33 get the same bound, because E1 sends them into the + same `persist`. + - **Correction.** Under E1 those four do not loop: a 300-byte name refuses in the length guard, which `persist` + reaches only after placing the entry. Only an oversized entry loops. They are bounded anyway, as a guard. + +### F4: two in-tree comments the fix made false + +`plan_alters`' "Every refusal lives in `resulting_schema` … an agent is told at the moment it types the statement +exactly what it would be told at merge" (`alter.rs:556-559`), and `stage_schema_edit`'s "Validated now … with the +same rules `Catalog::alter_table` applies" (`agent_sql/runtime.rs:2545-2547`), are both amended to name the +exception: + +- the encoder's check is made at `plan_alters`, which a branch reaches only at MERGE; +- so a staged rename or column that the catalog cannot encode is refused at merge, before anything is written, not + when typed. + +The check is NOT moved into staging (the lead's decision). Comments only. + +### F5: the size arm's error variant (a record of a decision already in the code) + +The fix plan registered U1 as `Err(Unrepresentable { limit: PAGE_SIZE, .. })`. The code (`24c0ead`) and U1 use +`FerroError::Constraint`. That switch was made without an amendment, and this records it and its reasons: + +- `Unrepresentable` is documented as arithmetic on a length field (`error.rs:73-86`), and it explicitly says a + page-size limit is not its case. +- `NotEnoughSpace` is the page-size variant, but it carries no text, so it cannot name the table the operator has + to change. +- `Constraint` carries a message and is what this module already uses for a refusal the user can act on. + +### F7: a plan held across a change to its own table + +- **The latent state.** `plan_alters` checks a clone of the entry. If a caller held the plan while the same table's + entry changed, for example by a CREATE INDEX, `apply_plan` would install renames on an entry nobody checked. No + caller does that today, but `apply_plan`'s own comment says "a plan can be held across a statement". +- **The fix.** `AlterPlan` stores the entry it checked. `apply_plan`, before anything it writes (before + `reserve_free_space`), rebuilds that entry from the LIVE one, with the plan's final shape and renames applied. It + refuses with a `Constraint` if the result differs from what was checked. This is a staleness check, not a second + encoder guard. +- **ST** = `a_plan_held_across_a_change_to_its_own_table_is_refused`, additions only, red first. + - The steps, through the public `Catalog` API: `plan_alters(RENAME COLUMN v TO w)`, then `create_index(t, v)`, + then `apply_plan(plan)`. + - It must be refused, and the table must keep `v`, with its index on `v`. + - **Red at the tip before F7:** `apply_plan` installs the rename, so the refusal assertion fails. +- **E5 `staleness_check_removed`** deletes the comparison. It fails ST. + +### Counts and runs, amended (predicted) + +- **New tests:** A33, AI and ST are integration tests in `d249_alter_encodable`, so that target has **6** tests. + - ST is red against the tip before F7's code; A33 and AI pass there. + - The target's red run R at `d42549f` is unchanged: 3 tests, all red. +- **Per-target: 2579 + 8 = 2587** (AR, AA, A9, A33, AI, ST, U1, U2). The 2579 was measured on main's tree. + +| mutant | fails | +|---|---| +| E1 | AR, AA, A9, A33, AI | +| E2 | **A33 only**, at its row assertion, whose first value reads `16777216` (the review's MODEL) | +| E3 | U1, and AI (a panic in `serialize`) | +| E4 | AI | +| E5 | ST | From 0369d88b4df1bafe7aeb893074bdafc7d67855c9 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:25:38 -0400 Subject: [PATCH 222/315] UNBUILT D249 review: tests A33 (a 33rd column pins the refusal before the rewrite), AI (an indexed rename past a page), ST (a stale plan is refused; red first); A9's false premise corrected; the harness bounds the allocator --- tests/d249_alter_encodable.rs | 161 +++++++++++++++++++++++++++++++--- 1 file changed, 151 insertions(+), 10 deletions(-) diff --git a/tests/d249_alter_encodable.rs b/tests/d249_alter_encodable.rs index f2b82014..73535461 100644 --- a/tests/d249_alter_encodable.rs +++ b/tests/d249_alter_encodable.rs @@ -23,13 +23,16 @@ use std::sync::Arc; use ferrodb::buffer::buffer_pool::BufferPoolManager; use ferrodb::catalog::catalog::Catalog; -use ferrodb::catalog::column::Value; +use ferrodb::catalog::catalog_page::CatalogPage; +use ferrodb::catalog::column::{Column, DataType, Value}; +use ferrodb::catalog::schema::Schema; use ferrodb::error::FerroError; use ferrodb::execution::executor::{run, Outcome}; use ferrodb::execution::session::Session; -use ferrodb::parser::parser::Parser; +use ferrodb::parser::parser::{AlterAction, Parser}; use ferrodb::parser::scanner::Scanner; use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::storage::tuple::Tuple; use ferrodb::wal::log::WalManager; use ferrodb::wal::txn::TxnManager; @@ -50,7 +53,12 @@ impl Db { .truncate(true) .open(dir.path().join("d249.db")) .unwrap(); - let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let dm = Arc::new(DiskManager::new(file).unwrap()); + // A floor for the page allocator, so that a mutant reaching `persist`'s loop over an entry no + // page can hold (D254) refuses at page 256 instead of writing zero pages until the disk is + // full. No test here needs more than a few dozen pages. + dm.reserve_region("test floor", 256, u32::MAX).unwrap(); + let bp = Arc::new(BufferPoolManager::new(dm)); let catalog = Catalog::create(bp.clone()).unwrap(); let wal = Arc::new(WalManager::new(dir.path().join("d249.wal")).unwrap()); let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); @@ -174,14 +182,15 @@ fn an_added_column_whose_name_the_catalog_cannot_hold_is_refused_and_wedges_noth assert_not_wedged(&mut db, "ADD COLUMN", "after_add"); } -/// The ADD case again, on a table whose row layout an added column WOULD change. +/// The ADD case again, on an eight-column table: it pins the wedge, and NOT where the refusal +/// happens. /// -/// A trailing NULL column occupies its type's width after the existing ones and, below nine -/// columns, leaves the null bitmap at one byte. So a two-column row rewritten to three still reads -/// back as its first two values, and the test above cannot tell "refused before the rewrite" from -/// "refused after it". At the ninth column the bitmap grows to two bytes, and every value after it -/// moves. A row rewritten under nine columns then does NOT read back under the old eight. This is -/// the test that pins where the refusal happens, and not only that it happens (I19). +/// It was written to pin the placement too, on the premise that the ninth column's wider null +/// bitmap moves every value. That premise is false (D249 review, F1): every INTEGER is padded to a +/// 4-byte boundary after the 24-byte version header, so eight columns (24 + 1 bytes) and nine +/// (24 + 2) both put the first value at 28, and a row rewritten under nine reads back unchanged +/// under eight. `deserialize` checks no length either. The test that pins the placement is the +/// 33-column one below. #[test] fn an_added_ninth_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten() { let mut db = Db::new(); @@ -220,3 +229,135 @@ fn an_added_ninth_column_the_catalog_cannot_hold_is_refused_before_any_row_is_re } assert_not_wedged(&mut db, "ADD COLUMN (ninth)", "after_add_ninth"); } + +/// Where the refusal happens: before any row is rewritten (I19), on a table where a rewrite would +/// show. +/// +/// With 32 INTEGER columns the null bitmap is 4 bytes, so the first value sits at 24 + 4 = 28 with no +/// padding. With 33 it is 5 bytes, padded to 32, so every value moves. A row rewritten under 33 +/// columns does not read back under the old 32. The premise is asserted through the tuple layer +/// itself, and is not used as the expected value. +#[test] +fn a_thirty_third_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten() { + let names: Vec = (0..32).map(|i| if i == 0 { "id".to_string() } else { format!("c{i}") }).collect(); + let want: Vec = (1..=32).map(Value::Integer).collect(); + + // Premise: a 33-column row does NOT read back as its first 32 values under the 32-column schema. + let cols32: Vec = + names.iter().enumerate().map(|(i, n)| Column::new(n.clone(), DataType::Integer, i != 0)).collect(); + let mut cols33 = cols32.clone(); + cols33.push(Column::new("added".to_string(), DataType::Integer, true)); + let mut values33 = want.clone(); + values33.push(Value::Null); + let rewritten = Tuple::serialize(&values33, &Schema::new(cols33), 1).unwrap(); + assert!( + !matches!(rewritten.deserialize(&Schema::new(cols32)), Ok(ref v) if *v == want), + "a 33-column row reads back unchanged under 32 columns, so this fixture cannot tell a refusal \ + before the rewrite from one after it" + ); + + let mut db = Db::new(); + let decl: Vec = names + .iter() + .enumerate() + .map(|(i, n)| if i == 0 { format!("{n} INTEGER NOT NULL") } else { format!("{n} INTEGER") }) + .collect(); + db.exec(&format!("CREATE TABLE t ({});", decl.join(", "))).unwrap(); + let row: Vec = (1..=32).map(|i| i.to_string()).collect(); + db.exec(&format!("INSERT INTO t VALUES ({});", row.join(", "))).unwrap(); + let long = over_long(); + + let err = db + .exec(&format!("ALTER TABLE t ADD COLUMN {long} INTEGER;")) + .err() + .expect("a 33rd column with a 300-byte name must be refused, not written truncated"); + assert_refused_by_name(&err, "ADD COLUMN (33rd)"); + + let columns = db.catalog.get_table("t").expect("the table is still there").schema.columns.len(); + assert_eq!(columns, 32, "ADD COLUMN (33rd) was refused, but the catalog in memory holds {columns} columns"); + match db.exec("SELECT * FROM t;") { + Ok(Outcome::Rows(rows)) => assert_eq!( + rows, + vec![want], + "ADD COLUMN (33rd) was refused, but the row no longer reads back under the old 32 columns: it \ + was rewritten under 33 before the refusal (I19)" + ), + Ok(_) => panic!("SELECT after the refusal returned something other than rows"), + Err(e) => panic!("SELECT after the refused ADD COLUMN (33rd) failed: {e}"), + } + assert_not_wedged(&mut db, "ADD COLUMN (33rd)", "after_add_33rd"); +} + +/// The size arm, reached through an INDEX rename. +/// +/// A rename grows the entry twice when the renamed column is indexed: once in the schema, once in the +/// index record, which also names the column. Here the schema-only growth still fits a page and the +/// full growth does not, so a pre-check that forgot the index renames would pass it. Both halves are +/// premises, asserted through the page's own placement question. +#[test] +fn a_rename_that_grows_an_indexed_column_past_a_page_is_refused_by_size() { + let mut db = Db::new(); + let fillers: Vec = (1..=14).map(|i| format!("f{i:02}{}", "x".repeat(252))).collect(); + assert!(fillers.iter().all(|f| f.len() == 255), "each filler name must be exactly 255 bytes"); + let decl: Vec = fillers.iter().map(|f| format!("{f} INTEGER")).collect(); + db.exec(&format!("CREATE TABLE t (id INTEGER NOT NULL, v INTEGER, {});", decl.join(", "))).unwrap(); + db.exec("CREATE INDEX ix ON t (v);").unwrap(); + let to = "w".repeat(255); + + // Premises: renaming the schema alone would fit an empty catalog page; renaming the index too does + // not. + let before = db.catalog.get_table("t").expect("the table is there").clone(); + let mut schema_only = before.clone(); + for c in schema_only.schema.columns.iter_mut() { + if c.name == "v" { + c.name = to.clone(); + } + } + let mut full = schema_only.clone(); + for ix in full.indexes.iter_mut() { + if ix.column_name == "v" { + ix.column_name = to.clone(); + } + } + assert!( + CatalogPage::new(0).has_space(&schema_only), + "the schema-only rename already overflows a page, so this cannot see the index half" + ); + assert!(!CatalogPage::new(0).has_space(&full), "the full rename fits a page, so this tests nothing"); + + let err = db + .exec(&format!("ALTER TABLE t RENAME COLUMN v TO {to};")) + .err() + .expect("a rename that grows the entry past a catalog page must be refused"); + let msg = err.to_string(); + assert!( + matches!(err, FerroError::Constraint(_)) && msg.contains("\"t\"") && msg.contains("catalog page"), + "expected the size refusal naming the table, got: {err:?}" + ); + let after = db.catalog.get_table("t").expect("the table is still there"); + assert!( + after.schema.columns.iter().any(|c| c.name == "v") && after.indexes.iter().any(|ix| ix.column_name == "v"), + "the rename was refused, but the catalog in memory holds the renamed column or index" + ); + assert_not_wedged(&mut db, "RENAME COLUMN (indexed, past a page)", "after_index_rename"); +} + +/// A plan decides against the entry it read. Held across a change to the same table, it must be +/// refused, not installed over an entry nobody checked. +#[test] +fn a_plan_held_across_a_change_to_its_own_table_is_refused() { + let mut db = Db::with_one_row(); + let rename = AlterAction::RenameColumn { from: "v".to_string(), to: "w".to_string() }; + let plan = db.catalog.plan_alters("t", std::slice::from_ref(&rename), &db.txn, None).unwrap(); + + // The table changes while the plan is held: an index on the column the plan renames. + db.catalog.create_index("t", "v").unwrap(); + + let applied = db.catalog.apply_plan(plan, &db.txn); + assert!(applied.is_err(), "a plan made before the table changed was applied anyway"); + let entry = db.catalog.get_table("t").expect("the table is still there"); + assert!( + entry.schema.columns.iter().any(|c| c.name == "v") && entry.indexes.iter().any(|ix| ix.column_name == "v"), + "the stale plan was refused, but the table no longer has its column v with its index" + ); +} From fa2e9a3e39b87c565037bd094e6de0fffd0c583e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:26:36 -0400 Subject: [PATCH 223/315] UNBUILT D249 review: apply_plan refuses a plan whose table changed since it was checked (F7); the two comments the pre-check made false name the exception (F4) --- src/agent_sql/runtime.rs | 5 ++++ src/catalog/alter.rs | 53 +++++++++++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 6ed533ac..b5abf01b 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -2546,6 +2546,11 @@ impl AgentRuntime { /// [`Catalog::alter_table`] applies (`catalog::alter::resulting_schema`). An agent that types /// an `ADD COLUMN ... NOT NULL` is told so immediately rather than at merge, after everything /// that depended on it. + /// + /// **One exception** (D249): whether the catalog can ENCODE the resulting table entry — a + /// column name longer than its 255-byte length prefix, or an entry grown past one catalog page — + /// is asked by `Catalog::plan_alters`, which a branch reaches only at merge. Such an edit is + /// accepted here and refused at merge, before anything is written. pub fn stage_schema_edit( &self, catalog: &Catalog, diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index a0951b4d..f65f0b83 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -370,6 +370,10 @@ pub struct AlterPlan { prov: Option>, dir_root: u32, primary_root: u32, + /// The entry the catalog encoder was asked about: the table's entry as the plan read it, with the + /// final shape and the renamed indexes. [`Catalog::apply_plan`] refuses the plan if the live + /// entry, with the same changes applied, is not this one (D249 review, F7). + checked: TableEntry, } impl AlterPlan { @@ -553,10 +557,15 @@ impl Catalog { let primary_root = entry.primary_index_root; let row_count = self.stats.get(table).map(|s| s.row_count).unwrap_or(0); - // Every refusal lives in `resulting_schema`, shared with the branch path, so an agent is - // told at the moment it types the statement exactly what it would be told at merge — and - // it runs for EVERY action, against the shape the action before it produces, rather than - // for the first one only. + // Every refusal about the SHAPE lives in `resulting_schema`, shared with the branch path, so + // an agent is told at the moment it types the statement exactly what it would be told at + // merge — and it runs for EVERY action, against the shape the action before it produces, + // rather than for the first one only. + // + // **One refusal is not in it** (D249): whether the catalog can encode the resulting entry, + // asked below. That needs the whole `TableEntry` — the table's own name and its indexes — and + // not just a shape, so it is asked only here. A branch reaches here at MERGE, so a staged + // edit the catalog cannot hold is refused then, before anything is written, not when typed. let mut shapes = vec![old_schema.clone()]; for action in actions { let next = resulting_schema(table, shapes.last().unwrap(), action, row_count)?; @@ -603,6 +612,7 @@ impl Catalog { prov: prov.cloned(), dir_root, primary_root, + checked: installed, }) } @@ -624,8 +634,39 @@ impl Catalog { // about what is in flight while tuples move, and a plan can be held across a statement. quiesce_guard(&plan.table, txn)?; - let AlterPlan { table, shapes, actions, prepared, carried, prov, dir_root, primary_root } = - plan; + let AlterPlan { + table, + shapes, + actions, + prepared, + carried, + prov, + dir_root, + primary_root, + checked, + } = plan; + + // **The plan still describes this table** (D249 review, F7). `plan_alters` asked the catalog + // encoder about one entry: the table's as it read it, with the final shape and the renamed + // indexes. A plan can be held across a statement, and if the same table changed in between + // (an index created, say), installing the renames now would persist an entry nobody asked + // about. So the live entry, with the same changes applied, must BE the entry that was + // checked. A staleness check, not a second encoder guard: it compares, and asks the encoder + // nothing. + { + let mut now = self.require_table(&table)?.clone(); + now.schema = shapes + .last() + .cloned() + .ok_or_else(|| FerroError::Internal(format!("a plan for '{table}' held no shape")))?; + rename_indexed_columns(&mut now, &actions); + if now != checked { + return Err(FerroError::Constraint(format!( + "the alteration of '{table}' was planned against the table as it was, and the \ + table has changed since; nothing was written. Plan it again." + ))); + } + } // Reserve the space the relocations will need, before the first one happens. // From 27dd0a1da6b28f2aa7727fd3d282dc0b7f2e7d41 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:26:44 -0400 Subject: [PATCH 224/315] UNBUILT D249 review: mutants E4 (index renames not checked) and E5 (staleness check removed); E1-E5 cut from fa2e9a3 --- bench/d249/make_mutants.py | 10 ++++++++++ bench/d249/mutants/E1_precheck_removed.patch | 2 +- bench/d249/mutants/E2_precheck_in_finish.patch | 4 ++-- bench/d249/mutants/E4_index_renames_not_checked.patch | 10 ++++++++++ bench/d249/mutants/E5_staleness_check_removed.patch | 11 +++++++++++ 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 bench/d249/mutants/E4_index_renames_not_checked.patch create mode 100644 bench/d249/mutants/E5_staleness_check_removed.patch diff --git a/bench/d249/make_mutants.py b/bench/d249/make_mutants.py index 8f94704e..a8c3189e 100644 --- a/bench/d249/make_mutants.py +++ b/bench/d249/make_mutants.py @@ -37,6 +37,16 @@ " if !page.has_space(entry) {\n", " if false && !page.has_space(entry) {\n", )]), + # PREREG amendment 3 (the D249 review). E4: the checked entry omits the index renames (F2). + # E5: the staleness check in `apply_plan` never refuses (F7). + ("E4_index_renames_not_checked", ALTER, [( + " rename_indexed_columns(&mut installed, actions);\n", + "", + )]), + ("E5_staleness_check_removed", ALTER, [( + " if now != checked {\n", + " if false && now != checked {\n", + )]), ] diff --git a/bench/d249/mutants/E1_precheck_removed.patch b/bench/d249/mutants/E1_precheck_removed.patch index 58b6a896..9ccf7a4c 100644 --- a/bench/d249/mutants/E1_precheck_removed.patch +++ b/bench/d249/mutants/E1_precheck_removed.patch @@ -1,6 +1,6 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -577,7 +577,7 @@ +@@ -586,7 +586,7 @@ let mut installed = entry.clone(); installed.schema = new_schema.clone(); rename_indexed_columns(&mut installed, actions); diff --git a/bench/d249/mutants/E2_precheck_in_finish.patch b/bench/d249/mutants/E2_precheck_in_finish.patch index 6a288b41..373dadf3 100644 --- a/bench/d249/mutants/E2_precheck_in_finish.patch +++ b/bench/d249/mutants/E2_precheck_in_finish.patch @@ -1,6 +1,6 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -577,7 +577,7 @@ +@@ -586,7 +586,7 @@ let mut installed = entry.clone(); installed.schema = new_schema.clone(); rename_indexed_columns(&mut installed, actions); @@ -9,7 +9,7 @@ // The statistics are carried across the alteration by [`carried_stats`], and they are // computed HERE, before the rewrite, rather than inside `finish` where they used to be. -@@ -713,6 +713,9 @@ +@@ -754,6 +754,9 @@ stats.columns = columns; } let entry = self.tables.get_mut(table).ok_or(FerroError::KeyNotFound)?; diff --git a/bench/d249/mutants/E4_index_renames_not_checked.patch b/bench/d249/mutants/E4_index_renames_not_checked.patch new file mode 100644 index 00000000..15ae8e0f --- /dev/null +++ b/bench/d249/mutants/E4_index_renames_not_checked.patch @@ -0,0 +1,10 @@ +--- a/src/catalog/alter.rs ++++ b/src/catalog/alter.rs +@@ -585,7 +585,6 @@ + // is a fixed-width field, so it cannot change the answer. + let mut installed = entry.clone(); + installed.schema = new_schema.clone(); +- rename_indexed_columns(&mut installed, actions); + refuse_unless_encodable(&installed)?; + + // The statistics are carried across the alteration by [`carried_stats`], and they are diff --git a/bench/d249/mutants/E5_staleness_check_removed.patch b/bench/d249/mutants/E5_staleness_check_removed.patch new file mode 100644 index 00000000..d7a83214 --- /dev/null +++ b/bench/d249/mutants/E5_staleness_check_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/alter.rs ++++ b/src/catalog/alter.rs +@@ -660,7 +660,7 @@ + .cloned() + .ok_or_else(|| FerroError::Internal(format!("a plan for '{table}' held no shape")))?; + rename_indexed_columns(&mut now, &actions); +- if now != checked { ++ if false && now != checked { + return Err(FerroError::Constraint(format!( + "the alteration of '{table}' was planned against the table as it was, and the \ + table has changed since; nothing was written. Plan it again." From 17604f51457be86b1083f51ad3f4e8a09a4ebe3e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:28:14 -0400 Subject: [PATCH 225/315] UNBUILT D244 tests: A1 never steers into a leaf the fork reads before a split can move the root (each tail's right neighbour, the free-id scan's leaf and its neighbour, the parent's record and envelope leaves), removing review void mode v2; phase 1's per-fork bound stated as 85 bytes (review F2/F3; not built, not run) --- tests/d244_publish_root.rs | 78 +++++++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/tests/d244_publish_root.rs b/tests/d244_publish_root.rs index 19d58131..df3c08e2 100644 --- a/tests/d244_publish_root.rs +++ b/tests/d244_publish_root.rs @@ -267,6 +267,16 @@ fn leaf_of(cat: &TableBranchCatalog, key: &[u8]) -> (u32, usize) { } } +/// The leaf to the right of leaf `page`: the one a split of `page` re-links, and the one a range +/// scan steps into when `page` holds no key at or above its start. +fn right_of(cat: &TableBranchCatalog, page: u32) -> Option { + let tree = BPlusTreeManager::, Vec>::open(cat.root_page_id(), cat.pool_handle().clone()); + match tree.read_node(page).expect("read a tree page") { + BPlusTreePage::Leaf(l) => l.next, + BPlusTreePage::Internal(_) => None, + } +} + /// Bytes a fork's child entry and deadline entry take in a leaf: key and value, each behind a /// 4-byte length (`tree_keys.rs`: a child key is 17 bytes with an 8-byte id value, a deadline key /// 17 bytes with an empty value). @@ -279,15 +289,21 @@ const DEADLINE_ENTRY: usize = 4 + 17 + 4; /// A fork writes, in order: its record key, its Live state key, its deadline key, its child key in /// the parent's live set, and the header key. The first two always append to the tails of their /// groups (ids only grow). The next two are steered, by the parent and the lease each fork is -/// given, into leaves with room, so they never split. Those leaves must also not BE the record-tail -/// or state-tail leaf: the record group (tag 0) and the deadline group (tag 1) are neighbours, so the -/// record-tail leaf also holds deadline keys. A deadline key steered into it would land in a leaf the -/// fork has just read and grown. The header rewrite is the same size. So a root split in a fork can -/// only come from the record or the state append, and each is followed by a write this fork has not -/// yet touched: the state tail after the record, the steered deadline leaf after the state. On a cold -/// pool that write must read from storage, and the injector fails that read. Its first reads (the -/// parent's record, the parent's envelope, the free-id span) are made before anything is appended, -/// so the rule cannot fire on them. +/// given, into leaves with room, so they never split. Those leaves must also be leaves the fork has +/// not read before a split can move the root: +/// - not the record-tail or state-tail leaf. The record group (tag 0) and the deadline group (tag 1) +/// are neighbours, so the record-tail leaf also holds deadline keys, and a deadline key steered +/// there would land in a leaf the fork has just read and grown (review void mode v1); +/// - not the leaf right of either tail, which a tail split re-links; +/// - not the free-id scan's leaf or the one right of it, and not the parent's record or envelope +/// leaf (review void mode v2). +/// +/// The header rewrite is the same size. So a root split in a fork can only come from the record or +/// the state append, and each is followed by a write to a leaf this fork has not yet read: the state +/// tail after the record, the steered deadline leaf after the state. On a cold pool that write must +/// read from storage, and the injector fails that read. Its first reads (the parent's record, the +/// parent's envelope, the free-id span) are made before anything is appended, so the rule cannot +/// fire on them. /// /// Phases: /// 1. **Grow.** 200 parents forked from trunk, then forks round-robin across them, every fork with @@ -341,9 +357,9 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { "premise failed: the internal root split while growing, so the tree is three levels \ deep and a leaf split can no longer reach its root" ); - // Under 150 bytes left. One fork adds at most four separators of up to 25 bytes (its - // record, deadline, state and child inserts can each split a leaf, while the leases - // still rise), so the root is not full yet, and a few tail splits will fill it. + // Under 150 bytes left. One fork adds at most four separators, 17 + 18 + 25 + 25 = 85 + // bytes (its record, state, deadline and child inserts can each split a leaf, while + // the leases still rise), so the root is not full yet, and a few tail splits will fill it. if room < 150 { vehicle = seq; break; @@ -359,23 +375,39 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { for attempt in 0..5_000usize { let epoch = cat.current_epoch().0 + 1; let id = ids.last().copied().expect("ids") + 1; - // The two leaves this fork appends to, which a steered write must not share. - let tails = [ - leaf_of(&cat, &ferrodb::branch::tree_keys::record(id)).0, - leaf_of(&cat, &ferrodb::branch::tree_keys::state(BranchState::Live.as_u8(), id)).0, - ]; - let steered = |key: Vec, entry: usize| { + // Every leaf this fork reads before a split can move the root. A steered write must land + // in none of them, so that it is still cold when it comes (review F2, void modes v1/v2): + // - the two tails it appends to, and the leaf right of each, which a tail split re-links; + // - the free-id scan's leaf, and the leaf right of it, where the scan finds its first key; + // - the parent's record and envelope leaves, added once the parent is chosen. + let record_tail = leaf_of(&cat, &ferrodb::branch::tree_keys::record(id)).0; + let live_tail = leaf_of(&cat, &ferrodb::branch::tree_keys::state(BranchState::Live.as_u8(), id)).0; + let free_ids = leaf_of(&cat, &ferrodb::branch::tree_keys::whole_group(ferrodb::branch::tree_keys::tag::FREE_ID).0).0; + let mut warm = vec![record_tail, live_tail, free_ids]; + warm.extend([record_tail, live_tail, free_ids].iter().filter_map(|p| right_of(&cat, *p))); + let parent_leaves = |p: BranchId| { + [ + leaf_of(&cat, &ferrodb::branch::tree_keys::record(p.id)).0, + leaf_of(&cat, &ferrodb::branch::tree_keys::envelope(p.id)).0, + ] + }; + let steered = |key: Vec, entry: usize, warm: &[u32]| { let (page, room) = leaf_of(&cat, &key); - room > entry && !tails.contains(&page) + room > entry && !warm.contains(&page) }; let parent = (0..parents.len()) .map(|k| parents[(attempt + k) % parents.len()]) - .find(|p| steered(ferrodb::branch::tree_keys::child(p.id, epoch), CHILD_ENTRY)) - .expect("premise failed: no parent's live set has a leaf with room, apart from the tails"); + .find(|p| { + let mut w = warm.clone(); + w.extend(parent_leaves(*p)); + steered(ferrodb::branch::tree_keys::child(p.id, epoch), CHILD_ENTRY, w.as_slice()) + }) + .expect("premise failed: no parent's live set has a leaf with room that this fork does not read first"); + warm.extend(parent_leaves(parent)); let deadline = (0..leases.len()) .map(|k| leases[(attempt * 97 + k) % leases.len()] + 1) - .find(|l| steered(ferrodb::branch::tree_keys::deadline(*l, id), DEADLINE_ENTRY)) - .expect("premise failed: no deadline leaf has room, apart from the tails"); + .find(|l| steered(ferrodb::branch::tree_keys::deadline(*l, id), DEADLINE_ENTRY, warm.as_slice())) + .expect("premise failed: no deadline leaf has room that this fork does not read first"); cold(&cat); storage.arm(Rule::ReadAfterAppends { appends_needed: 3, appended: HashSet::new() }); From ef64ffda0b707ae78a95625a811c1bcfbe47d80c Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:29:41 -0400 Subject: [PATCH 226/315] UNBUILT D225: review 5 decisions R1, R2, R4, R5, R6 Pre-registered in lane_d225_byte_split.md amendment 2 (artie-research 202ae7f) before this commit. - R1: one remedy text, index_page::LEGACY_ENTRY_REMEDY, for the ALTER, UPDATE (primary key) and crash-recovery refusals of a legacy over-bound entry: with the build that wrote it, copy the table's live rows into a new table and DROP the old one. It states that this build has no in-place remedy (a deleted row's tuple is never purged); the UPDATE refusal no longer carries only "shorten the indexed value" and never suggests DELETE + INSERT. - R2 (M26b): a same-length changed envelope is refused, and the lease and envelope are left untouched. - R4: allocator probes (high_water unchanged) in the S2, M16 and M17 tests, INFERRED killers that D202's undo does not mask. - R5: MAX_ENTRY_BYTES lists the fork, mid-record branch-catalog and LogBranchCatalog-migration legacy cases. - R6: envelope_write's canonicality claim states its exception. Not built or run: quiet mode. --- src/branch/table_catalog.rs | 37 +++++++++++++++++++++++++++++++++---- src/catalog/alter.rs | 5 ++--- src/execution/update.rs | 16 +++++++++++++--- src/storage/index_page.rs | 33 ++++++++++++++++++++++++++++++--- src/wal/recovery.rs | 6 +++--- tests/d225_byte_split.rs | 25 +++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 16 deletions(-) diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 39ebf852..07b292d3 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -633,9 +633,15 @@ impl TableBranchCatalog { /// refused, and that includes `charge_row_writes` and `restrict_envelope`, which write through /// `upsert` directly. /// - /// The comparison is of the stored bytes against this record's encoding, which is canonical - /// (`CapabilityEnvelope::serialize`), so a hydrated envelope handed back re-encodes to exactly - /// what was read. Callers hold `logical`, and every writer of the envelope key does too. + /// The comparison is of the stored bytes against this record's encoding. A hydrated envelope + /// handed back re-encodes to exactly what was read **when the stored bytes are this encoder's + /// own output**. They need not be: `CapabilityEnvelope::deserialize` normalises. `allow` sorts + /// and deduplicates tables, `TableCapability::new` sorts and deduplicates columns, a tag-0 + /// floor's payload is discarded, and trailing bytes are not checked. So a non-canonical stored + /// envelope reads as CHANGED, is admitted, and is refused if it is over the bound. That fails + /// closed, but this relief does not reach it. Byte equality is still the right test: anything + /// weaker (a length, a field subset) would let a genuinely changed envelope pass unwritten. + /// Callers hold `logical`, and every writer of the envelope key does too. fn envelope_write(&self, rec: &BranchRecord) -> Result { let key = keys::envelope(rec.branch_id.id); Ok(match &rec.envelope { @@ -1824,7 +1830,12 @@ mod d225_envelope_bound { /// An envelope granting `columns` open columns on one table: 21 + 8 + 13 per column bytes. fn envelope_of(columns: u32) -> CapabilityEnvelope { - CapabilityEnvelope::new(Verb::ALL, 10).allow(1, (0..columns).map(ColumnCapability::open).collect()) + envelope_with_budget(columns, 10) + } + + /// The same shape with a different row-write budget: a fixed-width field, so the same length. + fn envelope_with_budget(columns: u32, max_row_writes: u64) -> CapabilityEnvelope { + CapabilityEnvelope::new(Verb::ALL, max_row_writes).allow(1, (0..columns).map(ColumnCapability::open).collect()) } /// **An envelope too large for a B+tree entry refuses the whole record, before any key of it @@ -1916,6 +1927,24 @@ mod d225_envelope_bound { ); assert_eq!(cat.tree.search(&keys::envelope(id)).unwrap(), Some(legacy.clone()), "the refused write changed the envelope"); + // M26b (review 5): a change of the SAME length is still a change. `fork_staged`'s + // recycled-id path hands `write_record` exactly this: the child's envelope is `inherited()`, + // which keeps the tables and changes only fixed-width counters. Only the budget differs. + let same_length = envelope_with_budget(153, 11).serialize(); + assert_eq!(same_length.len(), legacy.len(), "premise: the same encoded length"); + assert_ne!(same_length, legacy, "premise: different bytes"); + let mut rebudgeted = cat.get_raw(id).unwrap(); + rebudgeted.envelope = Some(envelope_with_budget(153, 11)); + rebudgeted.lease_deadline = LeaseDeadline(8); + let e = cat.write_record(&rebudgeted, Some(&old)).expect_err("a same-length changed over-bound envelope must be refused"); + assert!(e.to_string().contains("index entry too large: 2035 bytes"), "not the named refusal: {e}"); + assert_eq!( + cat.core(id).unwrap().unwrap().lease_deadline(), + LeaseDeadline(1_000_000), + "the refused same-length write stored its record" + ); + assert_eq!(cat.tree.search(&keys::envelope(id)).unwrap(), Some(legacy.clone()), "the refused same-length write changed the envelope"); + cat.set_state(child.branch_id, BranchState::Live, BranchState::Reaping).expect("Live -> Reaping"); cat.set_state(child.branch_id, BranchState::Reaping, BranchState::Reaped).expect("Reaping -> Reaped"); assert_eq!(cat.core(id).unwrap().unwrap().state(), BranchState::Reaped, "the branch was not retired"); diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index 40013c60..378484f7 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -77,7 +77,7 @@ use crate::provenance::ProvenanceStore; use crate::storage::heap_file_manager::{HeapFileManager, RecordId}; use crate::storage::heap_page::{MAX_TUPLE_SIZE, SLOT_ENTRY_SIZE}; use crate::storage::index::BPlusTreeManager; -use crate::storage::index_page::admit_entry; +use crate::storage::index_page::{admit_entry, LEGACY_ENTRY_REMEDY}; use crate::storage::tuple::{Tuple, VERSION_HEADER_SIZE}; use crate::wal::txn::TxnManager; @@ -974,8 +974,7 @@ fn prepare_rewrite( "this ALTER would re-point the primary-index entry of the row whose first \ column is {shown}, and that entry is over the B+tree entry bound ({e}). A \ build before D225 could store such a key; this one cannot re-point it. \ - Nothing has been written; rewrite that row under a shorter key with the build \ - that wrote it, then run the ALTER again." + Nothing has been written. {LEGACY_ENTRY_REMEDY} Then run the ALTER again." )) })?; } diff --git a/src/execution/update.rs b/src/execution/update.rs index 2a4f40b5..a4d367a2 100644 --- a/src/execution/update.rs +++ b/src/execution/update.rs @@ -13,7 +13,7 @@ use crate::storage::heap_file_manager::RecordId; use crate::catalog::column::Value; use crate::execution::index_handle::{FullTextHandle, IndexHandle}; use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; -use crate::storage::index_page::admit_entry; +use crate::storage::index_page::{admit_entry, LEGACY_ENTRY_REMEDY}; use crate::provenance::{ProvId, ProvenanceStore}; pub struct Update { @@ -99,8 +99,18 @@ impl Modify for Update { let pk = &old_values[0]; // The primary re-point, `upsert(pk, new_rid)`, happens only if the heap moves the row, // which is not known until it is written, so it is asked for every row. This build - // cannot write a key over the bound; an earlier build could. - admit_entry(pk, &RecordId::new(0, 0))?; + // cannot write a key over the bound; an earlier build could. The refusal names the one + // remedy that works for a key (review 5): a primary key cannot be UPDATEd, and DELETE + // then INSERT would leave the deleted tuple under the key for good. + admit_entry(pk, &RecordId::new(0, 0)).map_err(|e| { + let shown: String = format!("{pk:?}").chars().take(60).collect(); + FerroError::Constraint(format!( + "this UPDATE would re-point the primary-index entry of the row whose key is \ + {shown}, and that entry is over the B+tree entry bound ({e}). A build before \ + D225 could store such a key; this one cannot re-point it. Nothing has been \ + written. {LEGACY_ENTRY_REMEDY}" + )) + })?; for handle in &self.secondary_indexes { let new_v = &new_values[handle.col_index]; if &old_values[handle.col_index] != new_v { diff --git a/src/storage/index_page.rs b/src/storage/index_page.rs index 343cfc40..4ab5fe0b 100644 --- a/src/storage/index_page.rs +++ b/src/storage/index_page.rs @@ -88,17 +88,44 @@ const LEAF_HEADER_SIZE: usize = 27; /// **D225 therefore lands after D202**; `a_no_cut_refusal_after_the_heap_write_leaves_no_dangling_primary_entry` /// is red without it; /// - a heap row whose entry is over the bound, met by the crash-recovery rebuild: refused before -/// any tree is freed (`wal::recovery`), and the database does not open until that row is -/// shortened or deleted with the build that wrote it; +/// any tree is freed (`wal::recovery`), and the database does not open until the remedy below +/// has been applied; /// - a primary key over the bound, met by an ALTER that rewrites rows or an UPDATE of that row: /// refused before the first row is written (`catalog::alter::prepare_rewrite`, the UPDATE /// pre-pass), because either may have to re-point the key's entry; /// - a branch whose stored capability envelope is over the bound: rewrites of its record that /// leave the envelope unchanged go through (`TableBranchCatalog::envelope_write`), so its lease /// can be renewed and it can be reaped; a CHANGED envelope is refused, which includes -/// `charge_row_writes` and `restrict_envelope`. +/// `charge_row_writes` and `restrict_envelope`; +/// - such a branch cannot be **forked**: the child inherits the envelope at the same encoded size +/// (`CapabilityEnvelope::inherited` changes only fixed-width counters), so `write_record_new` +/// refuses it, and on a recycled id `envelope_write` does; +/// - a branch-catalog leaf left exactly full around an over-bound entry refuses **mid-record**: +/// `write_record` removes the branch's old state and deadline keys before it upserts the new +/// ones, so a no-cut refusal there leaves the branch in neither span. D202's undo does not cover +/// the branch catalog; +/// - a `LogBranchCatalog` holding an over-bound envelope **fails to open**: `open` migrates it +/// through `migrate_from`, whose `write_record` puts the envelope into a fresh tree and is +/// refused. +/// +/// **The remedy, for the rows of a table: [`LEGACY_ENTRY_REMEDY`].** It is the only one that +/// works. A deleted row cannot take any other: DELETE stamps the tuple and leaves it, nothing in +/// this build purges it, and a deleted row can be neither updated nor deleted again, so it is +/// scanned, and refused, until its table is dropped. That makes DELETE followed by INSERT a +/// remedy that CREATES the problem for a key. The branch-catalog cases above have no remedy in +/// this build. pub const MAX_ENTRY_BYTES: usize = (PAGE_SIZE - LEAF_HEADER_SIZE) / 2; +/// What a refusal of a legacy entry over [`MAX_ENTRY_BYTES`] tells the user to do. D225, review 5. +/// +/// One text, so the ALTER, UPDATE and crash-recovery refusals cannot drift apart, and none of them +/// can suggest the DELETE + INSERT that leaves a permanent tuple under the key. +pub const LEGACY_ENTRY_REMEDY: &str = "The one remedy is to use the build that wrote it: copy the \ + table's live rows into a new table, shortening any key or indexed value over the bound as they \ + are copied, and DROP the old table. This build has no in-place remedy: a deleted row's tuple is \ + never purged, so deleting the row leaves its entry behind for good, and a primary key cannot be \ + updated."; + /// Bytes `t` occupies on a page. One definition, so the split, the fullness test and the entry /// bound cannot disagree about what an entry costs. pub fn serialized_len(t: &T) -> usize { diff --git a/src/wal/recovery.rs b/src/wal/recovery.rs index 4305999a..9be7d2f4 100644 --- a/src/wal/recovery.rs +++ b/src/wal/recovery.rs @@ -1,6 +1,6 @@ use std::{collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, atomic::Ordering}}; -use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}, index_page::admit_entry, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; +use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}, index_page::{admit_entry, LEGACY_ENTRY_REMEDY}, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; pub fn recover(txn: &TxnManager) -> Result { let wal = &txn.wal; @@ -206,8 +206,8 @@ fn refuse_rows_no_rebuilt_tree_admits(catalog: &Catalog, bp: &Arc::open(root, d.bp.clone()); assert!(primary.search(&Value::Varchar("a".into())).unwrap().is_some(), "the old primary tree lost key 'a'"); From ff12cfe60894f18388467b3cccfd1b8f5a479633 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:29:46 -0400 Subject: [PATCH 227/315] UNBUILT D244 docs: a no-op set_state can now return a failed heal-publish's error, kept and stated (review F6); fork_staged's closure comments re-indented, and d33's assertion MESSAGE names mutate() (review F8; message text only, the asserted condition is unchanged; not built, not run) --- src/branch/table_catalog.rs | 15 +++++++++++---- tests/d33_add_arena_must_be_durable.rs | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index e9aa90dd..3840db08 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -945,7 +945,6 @@ impl BranchCatalog for TableBranchCatalog { // concurrent forkers share one disk round-trip instead of queueing for private ones. See // `group_commit` for why the ticket is taken last. let (child, seq) = self.mutate(|| { - // HYDRATED, and this is a security property, not an optimisation. `fork_child` does // `parent.envelope.as_ref().map(CapabilityEnvelope::inherited)`, so a parent read WITHOUT // its envelope hands the child `None` - which is the UNGOVERNED default. The child of a @@ -999,9 +998,9 @@ impl BranchCatalog for TableBranchCatalog { self.write_record_new(&child)?; } // The child's entry in its parent's live set. A child that exists but is not listed in its - // parent is a GC correctness hole, which is why both happen under one logical lock. - // The VALUE is the child's branch id, so a reader can resolve the child and check - // whether it is still live. See `live_child_at` for why the entry is only a hint. + // parent is a GC correctness hole, which is why both happen under one logical lock. + // The VALUE is the child's branch id, so a reader can resolve the child and check + // whether it is still live. See `live_child_at` for why the entry is only a hint. self.tree .insert(keys::child(parent.id, fork_epoch.0), child_num.to_be_bytes().to_vec())?; self.write_header()?; @@ -1112,6 +1111,14 @@ impl BranchCatalog for TableBranchCatalog { if expect == to { // Nothing to write, and it keeps a second `set_state(.., Reaped)` from bumping the // generation twice. + // + // D244 review F6: the no-op still goes through `mutate`'s publish, so a publish an + // earlier mutation failed is retried here. If the retry fails too, this call returns + // that error, where before D244 it could not fail once past the checks above. Kept + // on purpose: the header write it reports is still owed, and swallowing it would + // hide a catalog the next open refuses. It is reached when a caller passes the state + // the branch already has, e.g. `seal`'s reaper-less fallback passing `record.state` + // with `to = Reaped` (`agent_sql/runtime.rs`). return Ok(false); } let old = core.clone(); diff --git a/tests/d33_add_arena_must_be_durable.rs b/tests/d33_add_arena_must_be_durable.rs index c844d924..a2c2e64c 100644 --- a/tests/d33_add_arena_must_be_durable.rs +++ b/tests/d33_add_arena_must_be_durable.rs @@ -51,7 +51,8 @@ fn table_catalog_add_arena_survives_a_reopen() { "add_arena wrote {} of 64 arenas durably. The reaper frees exactly `record.arenas`, so \ every arena missing here is an extent reserved in the free-space map that no branch \ owns and nothing will ever free -- a permanent leak across a restart. `add_arena` is \ - the only mutating method in TableBranchCatalog that never calls stage()/durable().", + the only mutating method in TableBranchCatalog that never went through \ + mutate()/durable() (stage()/durable() before D244).", back.arenas.len() ); } From 815ba5a71d251627a4a11e9f179c70082d9cf5e1 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:29:46 -0400 Subject: [PATCH 228/315] UNBUILT D244 F7: the clean exit publishes the branch catalog's root and syncs it before the checkpoints (TableBranchCatalog::publish_root_durably; cli::exit_sequence, which run_cli calls so a test runs the binary's code; the same call in pgserver's exit) (review F7; not built, not run) --- examples/pgserver.rs | 4 ++++ src/branch/table_catalog.rs | 13 +++++++++++++ src/cli/cli.rs | 28 ++++++++++++++++++++++++---- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/examples/pgserver.rs b/examples/pgserver.rs index f67cf255..b6a26e46 100644 --- a/examples/pgserver.rs +++ b/examples/pgserver.rs @@ -155,5 +155,9 @@ fn main() { // written would leave a durable map that still charges pages nothing owns. let stats = lease.stop(); let _ = writeln!(std::io::stderr(), "pgserver: lease scan stopped after {stats:?}"); + // D244 review F7: the branch catalog's root, published and synced before the arena's map, for + // the reason `ferrodb::cli::cli::exit_sequence` gives. This binary takes no database checkpoint + // at exit, so it makes the one call rather than calling that function. + branches.publish_root_durably().expect("publish the branch catalog's root"); store.checkpoint(Path::new(&arena_path)).expect("checkpoint the arena"); } diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 3840db08..77d59644 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -394,6 +394,19 @@ impl TableBranchCatalog { }) } + /// **D244 review F7: the publish a clean exit owes.** Writes the tree's root into the header + /// page if the last publish did not, then syncs this catalog's file. + /// + /// A publish that fails on a mutation's exit records nothing, so the NEXT mutation retries it. + /// A process that makes no further mutation never gets that retry. Meanwhile another writer's + /// `durable()` or an eviction may already have put the split pages on disk, and the next open + /// then reads a root the tree has left and refuses. So both production exits call this before + /// their checkpoints: `cli::exit_sequence` and `examples/pgserver.rs`. + pub fn publish_root_durably(&self) -> Result<(), FerroError> { + let ((), seq) = self.mutate(|| Ok(()))?; + self.durable(seq) + } + // `commit()` (stage + durable in one call) was DELETED once every caller had been migrated. // It was the only remaining way to fsync while still holding `logical`, which is exactly the // serialization group commit exists to remove -- so leaving it would have left a second, worse diff --git a/src/cli/cli.rs b/src/cli/cli.rs index 5f76d2ea..ad37d4cc 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -217,14 +217,34 @@ pub fn run_cli(db_path: &str) -> Result<(), FerroError> { { println!("ferrodb: lease scan {stats:?}"); } - txn.checkpoint()?; - // Persist where the arena starts and what it has allocated. Without this the next open finds - // no checkpoint, refuses to reattach, and the branch tree written this session is unreachable. - store.checkpoint(Path::new(&arena_path))?; + exit_sequence(&branches, &txn, &store, Path::new(&arena_path))?; println!("bye bye"); Ok(()) } +/// The clean exit's durable steps, in order, once the REPL has ended and the lease scan has +/// stopped. `run_cli` calls this, and so do the tests, so a test runs the code the binary runs. +/// +/// 1. **D244 review F7.** The branch catalog publishes its root and syncs. A publish that failed on +/// a mutation's exit is otherwise retried only by the next mutation, and an exit makes none. If +/// split pages have already reached the disk through another writer's sync or an eviction, +/// the header would still name the old root, and the next open would refuse. +/// 2. The database's checkpoint. +/// 3. Persist where the arena starts and what it has allocated. Without this the next open finds no +/// checkpoint, refuses to reattach, and the branch tree written this session is unreachable. It +/// runs after the branch catalog's sync, so the map it writes is taken after the catalog's last +/// durable write. +pub fn exit_sequence( + branches: &TableBranchCatalog, + txn: &TxnManager, + store: &ArenaPageStore, + arena_path: &Path, +) -> Result<(), FerroError> { + branches.publish_root_durably()?; + txn.checkpoint()?; + store.checkpoint(arena_path) +} + fn execute_sql(sql: &str, catalog: &CatalogLock, bp: Arc, txn: Arc, session: &mut Session) { let tokens = match Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens() { Ok(t) => t, From 8d95d282d3dc4ccdcffabd78240b4f9612b93201 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:31:02 -0400 Subject: [PATCH 229/315] UNBUILT D244 F7 test: a failed publish, no further mutation, split pages flushed (premise: a trial open of the file refuses), then the clean exit through cli::exit_sequence, and the reopen must open with every branch; own file, since it calls new API (red by mutants MC/MD; not built, not run) --- tests/d244_clean_exit_publishes_the_root.rs | 202 ++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 tests/d244_clean_exit_publishes_the_root.rs diff --git a/tests/d244_clean_exit_publishes_the_root.rs b/tests/d244_clean_exit_publishes_the_root.rs new file mode 100644 index 00000000..6098dc54 --- /dev/null +++ b/tests/d244_clean_exit_publishes_the_root.rs @@ -0,0 +1,202 @@ +//! D244 review F7 — **a clean exit publishes the branch catalog's root, so a publish that failed +//! and was never retried cannot leave a file the next open refuses.** +//! +//! Since D244 a publish that fails on a mutation's exit records nothing, and the NEXT mutation +//! retries it (`tests/d244_publish_root.rs`, A2). A process that makes no further mutation never +//! gets that retry. Meanwhile another writer's `durable()` or an eviction may write the split pages +//! to disk. Page 1 was never even dirty, so it keeps naming the old root. The next open searches for +//! the header key `[0x07]` under that root, which never holds it after a split, and refuses. +//! +//! `cli::exit_sequence` is the function `run_cli` calls once its REPL ends. It now publishes the +//! branch catalog's root and syncs it before the database and arena checkpoints. +//! +//! # Red evidence +//! +//! This file calls `exit_sequence` and `TableBranchCatalog::publish_root_durably`, and neither +//! exists at `9aa6968`, so it cannot compile there. It lives apart from `d244_publish_root.rs` so +//! that file's red tree still builds. Its red is shown by two mutants: MC removes the publish from +//! `exit_sequence`, and MD publishes without syncing. The lane report lists both. +//! +//! # The fault injector +//! +//! The same shape as `d244_publish_root.rs`'s, cut down to the one rule used here: fail the next +//! read of page 1, once. It is an in-memory file behind `DiskManager::with_storage`, and it can be +//! copied, so a trial open can read the file as it stands without touching it. + +use std::fs::OpenOptions; +use std::io; +use std::sync::{Arc, Mutex}; + +use ferrodb::branch::table_catalog::SIDECAR_HEADER_PAGE; +use ferrodb::branch::{ArenaPageStore, BranchCatalog, BranchId, LeaseDeadline, TableBranchCatalog}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cli::cli::exit_sequence; +use ferrodb::error::FerroError; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; +use ferrodb::storage::storage::Storage; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +/// Where page 1 starts in the file. +const PAGE_ONE: u64 = SIDECAR_HEADER_PAGE as u64 * PAGE_SIZE as u64; +/// Every child's lease: one value, so deadline keys arrive in id order. +const LEASE: LeaseDeadline = LeaseDeadline(4_000_000_000_000); +/// Trunk's data root lives in another file; any page id will do. +const TRUNK_ROOT: u32 = 7; +/// The text the injected failure carries, so the test can tell it from any other error. +const INJECTED: &str = "d244 F7 injected read failure"; +/// The arena's floor above the database's high-water mark. Any headroom will do: nothing here +/// allocates from the arena. +const ARENA_HEADROOM: u32 = 64; + +/// An in-memory file that, once armed, fails the next read of page 1 and then carries on. +struct PageOneFault { + image: Mutex>, + armed: Mutex, + fired: Mutex, +} + +impl PageOneFault { + fn new(image: Vec) -> Arc { + Arc::new(PageOneFault { image: Mutex::new(image), armed: Mutex::new(false), fired: Mutex::new(0) }) + } + + fn arm(&self) { + *self.armed.lock().unwrap() = true; + } + + fn fired(&self) -> u32 { + *self.fired.lock().unwrap() + } + + /// A second file holding the same bytes, unarmed. + fn copy(&self) -> Arc { + PageOneFault::new(self.image.lock().unwrap().clone()) + } +} + +impl Storage for PageOneFault { + fn pwrite(&self, buf: &[u8], offset: u64) -> io::Result { + let mut image = self.image.lock().unwrap(); + let end = offset as usize + buf.len(); + if image.len() < end { + image.resize(end, 0); + } + image[offset as usize..end].copy_from_slice(buf); + Ok(buf.len()) + } + + fn pread(&self, buf: &mut [u8], offset: u64) -> io::Result { + { + let mut armed = self.armed.lock().unwrap(); + if *armed && offset == PAGE_ONE { + *armed = false; + *self.fired.lock().unwrap() += 1; + return Err(io::Error::new(io::ErrorKind::Other, format!("{INJECTED}: the read of page 1"))); + } + } + let image = self.image.lock().unwrap(); + let start = (offset as usize).min(image.len()); + let n = buf.len().min(image.len() - start); + buf[..n].copy_from_slice(&image[start..start + n]); + Ok(n) + } + + fn sync_all(&self) -> io::Result<()> { + Ok(()) + } + + fn sync_data(&self) -> io::Result<()> { + Ok(()) + } + + fn set_len(&self, len: u64) -> io::Result<()> { + self.image.lock().unwrap().resize(len as usize, 0); + Ok(()) + } + + fn len(&self) -> io::Result { + Ok(self.image.lock().unwrap().len() as u64) + } +} + +/// Open what `storage` holds with a fresh pool, the way `open_sidecar` reopens a file. +fn open_branches(storage: &Arc) -> Result { + let dm = DiskManager::with_storage(storage.clone() as Arc)?; + let pool = Arc::new(BufferPoolManager::new(Arc::new(dm))); + TableBranchCatalog::open_from_header(pool, SIDECAR_HEADER_PAGE) +} + +/// Write every dirty page back, then drop every cached page, so the next access reads storage. +fn cold(cat: &TableBranchCatalog) { + cat.pool_handle().flush_all().expect("flush the pool"); + cat.pool_handle().invalidate_all().expect("nothing is pinned between mutations"); +} + +#[test] +fn a_clean_exit_after_a_failed_publish_leaves_a_catalog_that_opens() { + let dir = tempfile::tempdir().unwrap(); + + // The database, its log and its catalog, in `run_cli`'s order: the arena is built after the + // catalog, above the pages the catalog has allocated. + let file = OpenOptions::new().read(true).write(true).create(true).open(dir.path().join("f7.db")).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(dir.path().join("f7.wal")).unwrap()); + let txn = TxnManager::new(wal.clone(), bp.clone()); + bp.attach_wal(wal); + let _catalog = Catalog::create(bp.clone()).expect("create the database catalog"); + + // The branch catalog, in a file whose page-1 reads can be failed. + let storage = PageOneFault::new(Vec::new()); + let dm = DiskManager::with_storage(storage.clone() as Arc).expect("disk manager"); + let (branches, header) = TableBranchCatalog::create_with_header(Arc::new(BufferPoolManager::new(Arc::new(dm))), TRUNK_ROOT) + .expect("create the branch catalog"); + assert_eq!(header, SIDECAR_HEADER_PAGE, "premise failed: the header must be page 1, where the injector aims"); + let branches = Arc::new(branches); + let base = bp.disk_manager.high_water().expect("high water") + ARENA_HEADROOM; + let store = ArenaPageStore::new(bp.clone(), branches.clone() as Arc, base).expect("the arena"); + + // 1. A publish that fails. Page 1 is read from storage only when a publish has a moved root to + // write, so the first fork whose publish reads it is the first fork that split the root. + let root_before = branches.root_page_id(); + let mut ids = Vec::new(); + storage.arm(); + let refused = loop { + assert!(ids.len() < 5_000, "premise failed: {} forks never moved the root", ids.len()); + cold(&branches); + match branches.fork(BranchId::TRUNK, LEASE) { + Ok(rec) => ids.push(rec.branch_id.id), + Err(e) => break e.to_string(), + } + }; + assert_eq!(storage.fired(), 1, "premise failed: exactly one read of page 1 must have been failed"); + assert!(refused.contains(INJECTED), "premise failed: the fork failed for another reason: {refused}"); + assert_ne!(branches.root_page_id(), root_before, "premise failed: the root did not split before the failed publish"); + + // 2. No further mutation. What another writer's `durable()` or an eviction would do: the split + // pages reach the file. Page 1 does not, because the failed publish never wrote it. + branches.pool_handle().flush_all().expect("flush"); + match open_branches(&storage.copy()) { + Ok(_) => panic!( + "premise failed: the file opened BEFORE the exit, so there was no stale header for the exit to repair" + ), + Err(e) => assert!( + e.to_string().contains("header key is missing"), + "premise failed: the file refused before the exit, but for another reason: {e}" + ), + } + + // 3. The clean exit, through the function `run_cli` calls, then every handle dropped. + exit_sequence(&branches, &txn, &store, &dir.path().join("f7.arena")).expect("the clean exit"); + drop(store); + drop(branches); + + let reopened = open_branches(&storage) + .unwrap_or_else(|e| panic!("the branch catalog must open after a clean exit, and it refused: {e}")); + for id in &ids { + reopened + .get_raw(*id) + .unwrap_or_else(|e| panic!("branch {id} must still be in the reopened catalog: {e}")); + } +} From c09d99f54ed48e10ce8f068ad196a484587cc484 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:43:51 -0400 Subject: [PATCH 230/315] D255 red: ON conjuncts reading one relation or none are dropped by the INNER join search (UNBUILT) 14 tests against 9aa6968's API only. Pre-registered in artie-research frontier/lane_d255_join.md @ b0847f5: 12 fail at 9aa6968, the two POSITIVE CONTROLs (13-relation cross-joined chain, 33-relation two-row chain) pass. --- tests/d255_join_on_conjuncts.rs | 404 ++++++++++++++++++++++++++++++++ 1 file changed, 404 insertions(+) create mode 100644 tests/d255_join_on_conjuncts.rs diff --git a/tests/d255_join_on_conjuncts.rs b/tests/d255_join_on_conjuncts.rs new file mode 100644 index 00000000..9784fdfe --- /dev/null +++ b/tests/d255_join_on_conjuncts.rs @@ -0,0 +1,404 @@ +//! D255 — an INNER JOIN applied an ON conjunct only when it linked two relations. +//! +//! `optimizer::search_algorithm::reorder_inner_joins` splits every ON into conjuncts and tags each +//! with the bitmask of relations it reads. A conjunct was then used in exactly one way: as a +//! `bridge` on a split `(l, r)`, which needs its mask to meet BOTH sides. A one-bit mask cannot meet +//! two disjoint sides and a zero mask meets nothing, so: +//! +//! * `a JOIN b ON a.id = b.id AND b.v = 5` ignored `b.v = 5` — at every size. A WRONG ANSWER. +//! * `ON a.id = b.id AND 1 = 0` ignored `1 = 0` and returned every match. +//! * At 12 relations or fewer, a query no bridged split could cover was refused ("disconnected +//! join graph"): `ON 1 = 1`, `ON TRUE`, `ON b.v = 5`, and a join linked only by a conjunct over +//! three relations. Past 12 the left-deep fallback planned the same queries as cross products, +//! so whether a query ran depended on how many tables it joined. +//! +//! The parser has no `CROSS JOIN` and no comma FROM list, so `ON TRUE` is the only way to write a +//! cross product here; refusing it made a cross product unwritable below 13 relations. +//! +//! Every assertion is on the ANSWER, compared as a sorted row set, except where a test says it is +//! pinning a plan and why. `tests/d64_expression_depth.rs` also writes `ON 1` and `ON 1 = 1`, but it +//! only parses — it never reaches the planner — so it is not a control for any of this. The +//! planner-level positive controls are the two tests marked POSITIVE CONTROL, which pass at +//! `9aa6968` and must keep passing. +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::column::Value; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::optimizer::search_algorithm::MAX_DP_RELATIONS; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +/// d66's fixture, copied rather than shared: each integration test file is its own crate. +struct Db { + catalog: Catalog, + bp: Arc, + txn: Arc, + s: Session, + _dir: tempfile::TempDir, +} + +type Rows = Vec>>; + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("p.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + Db { catalog, bp, txn, s: Session::new(), _dir: dir } + } + + fn exec(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(FerroError::SqlParseError(format!("{:?}", parser.errors))); + } + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut self.s) + } + + /// `Outcome` has no `Debug`, so nothing here can `unwrap` or `expect` a `Result`. + fn ok(&mut self, sql: &str) -> Outcome { + self.exec(sql).unwrap_or_else(|e| panic!("`{sql}` failed: {e}")) + } + + /// The answer as a SORTED row set, so the assertion is on which rows came back and not on the + /// order a plan happened to produce them in. `None` is SQL NULL. + fn rows(&mut self, sql: &str) -> Rows { + let mut rows: Rows = match self.ok(sql) { + Outcome::Rows(rows) => rows + .into_iter() + .map(|row| { + row.into_iter() + .map(|v| match v { + Value::Integer(i) => Some(i), + Value::Null => None, + other => panic!("`{sql}`: this file only stores INTEGERs, got {other:?}"), + }) + .collect() + }) + .collect(), + _ => panic!("`{sql}`: expected Outcome::Rows"), + }; + rows.sort(); + rows + } + + fn explain(&mut self, sql: &str) -> String { + match self.ok(sql) { + Outcome::Explain(text) => text, + _ => panic!("`{sql}`: expected Outcome::Explain"), + } + } + + fn table(&mut self, name: &str, rows: &[(i32, i32)]) { + self.ok(&format!("CREATE TABLE {name} (id INTEGER NOT NULL, v INTEGER);")); + for (id, v) in rows { + self.ok(&format!("INSERT INTO {name} VALUES ({id}, {v});")); + } + } +} + +fn r(rows: &[&[i32]]) -> Rows { + let mut out: Rows = rows.iter().map(|row| row.iter().map(|&v| Some(v)).collect()).collect(); + out.sort(); + out +} + +/// a = (1,10) (2,20) (3,30); b = (1,5) (2,6) (3,5). Every id matches exactly one row across. +fn ab() -> Db { + let mut db = Db::new(); + db.table("a", &[(1, 10), (2, 20), (3, 30)]); + db.table("b", &[(1, 5), (2, 6), (3, 5)]); + db +} + +/// Every (a.id, b.id) pair: the cross product of `ab()`. +fn all_ab_pairs() -> Rows { + let mut out = Rows::new(); + for x in 1..=3 { + for y in 1..=3 { + out.push(vec![Some(x), Some(y)]); + } + } + out.sort(); + out +} + +/// `n` tables `r0..r{n-1}`, each holding (1,10) and (2,20). +/// +/// TWO rows each, on purpose: with one row per table (d66's fixture) a cross product is one row and +/// a dropped predicate is invisible. Here a single dropped link doubles the answer. +fn chain_db(n: usize) -> Db { + let mut db = Db::new(); + for i in 0..n { + db.table(&format!("r{i}"), &[(1, 10), (2, 20)]); + } + db +} + +/// `SELECT FROM r0 JOIN r1 ON ... JOIN r{n-1} ON ;` +fn chain_sql(n: usize, cols: &str, on: impl Fn(usize) -> String) -> String { + let mut sql = format!("SELECT {cols} FROM r0"); + for i in 1..n { + sql.push_str(&format!(" JOIN r{i} ON {}", on(i))); + } + sql.push(';'); + sql +} + +// ---- one-relation conjuncts -------------------------------------------------------------------- + +#[test] +fn a_one_relation_conjunct_on_the_joined_side_filters_the_join() { + let mut db = ab(); + assert_eq!( + db.rows("SELECT a.id, b.id FROM a JOIN b ON a.id = b.id AND b.v = 5;"), + r(&[&[1, 1], &[3, 3]]), + "`b.v = 5` reads only b; it was dropped, so b's row with v = 6 joined too" + ); +} + +#[test] +fn a_one_relation_conjunct_on_the_accumulated_side_filters_the_join() { + let mut db = ab(); + assert_eq!( + db.rows("SELECT a.id, b.id FROM a JOIN b ON a.id = b.id AND a.v = 20;"), + r(&[&[2, 2]]), + "`a.v = 20` reads only a; it was dropped" + ); + // The same conjuncts in the other order: placement is by the relations a conjunct reads, not + // by where it was typed. + assert_eq!( + db.rows("SELECT a.id, b.id FROM a JOIN b ON a.v = 20 AND a.id = b.id;"), + r(&[&[2, 2]]), + ); +} + +// ---- zero-relation conjuncts ------------------------------------------------------------------- + +#[test] +fn a_zero_relation_conjunct_filters_the_whole_join() { + let mut db = ab(); + // Premise, and it passes at 9aa6968: the join itself is reached and answers. So a failure + // below is the `1 = 0`, not a broken fixture. + assert_eq!( + db.rows("SELECT a.id, b.id FROM a JOIN b ON a.id = b.id AND 1 = 1;"), + r(&[&[1, 1], &[2, 2], &[3, 3]]), + ); + assert_eq!( + db.rows("SELECT a.id, b.id FROM a JOIN b ON a.id = b.id AND 1 = 0;"), + Rows::new(), + "`1 = 0` reads no relation; it was dropped and every match came back" + ); +} + +#[test] +fn on_one_equals_zero_alone_is_an_empty_join_not_a_refusal() { + let mut db = ab(); + assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON 1 = 0;"), Rows::new()); +} + +// ---- no linking conjunct: a cross product, not a refusal ------------------------------------- + +#[test] +fn a_join_with_no_linking_conjunct_is_a_cross_product() { + let mut db = ab(); + assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON 1 = 1;"), all_ab_pairs()); + assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON TRUE;"), all_ab_pairs()); + // A one-relation conjunct and nothing linking: b filtered, then crossed with a. + let filtered: Rows = all_ab_pairs().into_iter().filter(|p| p[1] != Some(2)).collect(); + assert_eq!(filtered.len(), 6, "fixture: b holds v = 5 at ids 1 and 3"); + assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON b.v = 5;"), filtered); +} + +/// `Filter`, `NestedLoopJoin` and `HashJoin` all keep a row only when its predicate is +/// `Boolean(true)`, so a bare `1` keeps nothing, in WHERE and in ON alike. The law pinned is that +/// `a JOIN b ON p` answers exactly as `a JOIN b ON TRUE WHERE p` does. +/// +/// ⚠ Past 12 relations this CHANGES an answer that planned at 9aa6968: the `1` was dropped there and +/// the cross product came back. +#[test] +fn a_non_boolean_on_answers_like_the_same_where() { + let mut db = ab(); + // Premise: the cross product itself answers, so the equality below is not two refusals, or two + // empty results for an unrelated reason. + assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON TRUE;"), all_ab_pairs()); + let on = db.rows("SELECT a.id, b.id FROM a JOIN b ON 1;"); + let where_ = db.rows("SELECT a.id, b.id FROM a JOIN b ON TRUE WHERE 1;"); + assert_eq!(on, where_, "ON 1 and WHERE 1 must be the same predicate"); + assert_eq!(on, Rows::new()); +} + +// ---- the same shapes on both sides of MAX_DP_RELATIONS -------------------------------------------- + +/// The exhaustive DP runs at `MAX_DP_RELATIONS` relations and the left-deep fallback one past it. +/// Each conjunct goes on the LAST join, so on the left-deep path the one-relation conjunct is on the +/// relation being joined (`r{n-1}`) in one arm and on the accumulated side (`r0`) in the other. +#[test] +fn the_same_conjuncts_filter_at_and_past_the_dp_limit() { + for n in [MAX_DP_RELATIONS, MAX_DP_RELATIONS + 1] { + let mut db = chain_db(n); + let last = n - 1; + let with = |extra: &str| { + chain_sql(n, "r0.id", |i| { + if i == last { + format!("r0.id = r{i}.id {extra}") + } else { + format!("r0.id = r{i}.id") + } + }) + }; + // Premise, passing at 9aa6968: the whole chain joins, two rows. + assert_eq!(db.rows(&with("")), r(&[&[1], &[2]]), "n = {n}: premise"); + assert_eq!( + db.rows(&with(&format!("AND r{last}.v = 20"))), + r(&[&[2]]), + "n = {n}: a conjunct on the joined relation alone was dropped" + ); + assert_eq!( + db.rows(&with("AND r0.v = 10")), + r(&[&[1]]), + "n = {n}: a conjunct on the accumulated side alone was dropped" + ); + assert_eq!(db.rows(&with("AND 1 = 0")), Rows::new(), "n = {n}: `1 = 0` was dropped"); + } +} + +/// r1 is joined `ON 1 = 1` and linked to nothing; every other relation links to r0. +fn crossed_chain(n: usize) -> String { + chain_sql(n, "r0.id, r1.id", |i| if i == 1 { "1 = 1".into() } else { format!("r0.id = r{i}.id") }) +} + +/// POSITIVE CONTROL — passes at 9aa6968 and must keep passing. The left-deep path always planned a +/// cross product, with `true`, where no conjunct linked the next relation. +#[test] +fn past_the_dp_limit_a_cross_joined_relation_still_joins() { + let n = MAX_DP_RELATIONS + 1; + let mut db = chain_db(n); + assert_eq!(db.rows(&crossed_chain(n)), r(&[&[1, 1], &[1, 2], &[2, 1], &[2, 2]])); +} + +/// The same query one relation smaller, on the DP path, where 9aa6968 refused it. +#[test] +fn at_the_dp_limit_a_cross_joined_relation_joins_the_same_way() { + let n = MAX_DP_RELATIONS; + let mut db = chain_db(n); + assert_eq!(db.rows(&crossed_chain(n)), r(&[&[1, 1], &[1, 2], &[2, 1], &[2, 2]])); +} + +/// Connected, but only by a conjunct over THREE relations: no pair is ever linked, so no split of +/// two relations has a bridge. The DP must cross two of them first and apply the conjunct at the +/// third. +#[test] +fn a_conjunct_over_three_relations_is_applied_when_no_pair_is_linked() { + let mut db = ab(); + db.table("c", &[(1, 15), (2, 26)]); + // a.v + b.v over all nine pairs: 15 16 15 / 25 26 25 / 35 36 35. c.v = 15 matches (a1,b1) and + // (a1,b3); c.v = 26 matches (a2,b2). + assert_eq!( + db.rows("SELECT a.id, b.id, c.id FROM a JOIN b ON 1 = 1 JOIN c ON a.v + b.v = c.v;"), + r(&[&[1, 1, 1], &[1, 3, 1], &[2, 2, 2]]), + ); +} + +/// Two components, each linked inside and not to the other. The cheapest plan joins each component +/// and crosses the two results last; this pins that the search can SEE that plan. +/// +/// This is the one assertion here on a plan rather than an answer, and it rests on the cost model, +/// worked by hand from `cost_model.rs` (50 rows a table, ids unique, ANALYZEd): the bushy root +/// `(w⋈x)×(y⋈z)` costs 4.5 + 4.5 + 25 = 34, and every root that is a join on a predicate costs 83 +/// (`((w⋈x)×y)⋈z` = 31 + 1.5 + 50.5). A search that only crosses when a whole level is empty never +/// builds the bushy root. +#[test] +fn two_unlinked_components_are_joined_first_and_crossed_last() { + let mut db = Db::new(); + let rows: Vec<(i32, i32)> = (0..50).map(|i| (i, i)).collect(); + for t in ["w", "x", "y", "z"] { + db.table(t, &rows); + db.ok(&format!("ANALYZE {t};")); + } + let q = "SELECT w.id, y.id FROM w JOIN x ON w.id = x.id JOIN y ON TRUE JOIN z ON y.id = z.id;"; + let mut every_pair = Rows::new(); + for i in 0..50 { + for j in 0..50 { + every_pair.push(vec![Some(i), Some(j)]); + } + } + every_pair.sort(); + assert_eq!(db.rows(q), every_pair); + + let plan = db.explain(&format!("EXPLAIN {q}")); + let root_join = plan + .lines() + .find(|l| l.contains(" join ")) + .unwrap_or_else(|| panic!("no join in the plan:\n{plan}")); + assert!( + root_join.contains("(on true)"), + "the root join should be the cross product of the two components:\n{plan}" + ); +} + +/// A one-relation ON conjunct is a filter on that relation BEFORE its access path is chosen, so a +/// primary-key equality in ON reaches the index exactly as it does in WHERE. D56: a unique-key +/// equality wins over a sequential scan at every table width, statistics or not. +#[test] +fn a_one_relation_conjunct_reaches_index_selection() { + let mut db = ab(); + let q = "SELECT a.id FROM a JOIN b ON a.id = b.id AND b.id = 3;"; + assert_eq!(db.rows(q), r(&[&[3]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(plan.contains("Index scan on b"), "`b.id = 3` did not reach b's access path:\n{plan}"); + + // With a WHERE conjunct on b as well, pushed down onto b's scan first: the two must become ONE + // filter, or the planner sees a filter over a filter and never considers the index. + let q = "SELECT a.id FROM a JOIN b ON a.id = b.id AND b.id = 3 WHERE b.v > 0;"; + assert_eq!(db.rows(q), r(&[&[3]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(plan.contains("Index scan on b"), "the ON and WHERE filters on b were not merged:\n{plan}"); +} + +/// The leaf a one-relation conjunct reads can be a LEFT JOIN. The filter goes OVER it: pushed into +/// the nullable side it would stop excluding the NULL-extended row, which is (2, NULL, 2) here. +#[test] +fn a_one_relation_conjunct_over_a_left_join_filters_after_the_left_join() { + let mut db = Db::new(); + db.table("la", &[(1, 0), (2, 0)]); + db.table("lb", &[(1, 5), (2, 6)]); + db.table("lc", &[(1, 0), (2, 0)]); + assert_eq!( + db.rows( + "SELECT la.id, lb.id, lc.id FROM la LEFT JOIN lb ON la.id = lb.id \ + JOIN lc ON la.id = lc.id AND lb.v = 5;" + ), + r(&[&[1, 1, 1]]), + ); +} + +/// POSITIVE CONTROL — passes at 9aa6968 and must keep passing. The left-deep path at d66's width +/// (relation 32 is past `u32`), with two rows a table, so that a dropped or aliased link would double +/// the answer instead of hiding in a one-row cross product. +#[test] +fn thirty_three_two_row_relations_chain_to_exactly_two_rows() { + let n = 33; + let mut db = chain_db(n); + assert_eq!(db.rows(&chain_sql(n, "r0.id", |i| format!("r0.id = r{i}.id"))), r(&[&[1], &[2]])); +} From 14b445335a175e2494314ef68cf9c393623dda56 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:45:34 -0400 Subject: [PATCH 231/315] D255 fix: place every INNER-join ON conjunct by the relations it reads; plan a join no predicate links as a cross product at every size (UNBUILT) One relation: a filter on that relation's logical leaf, before access-path choice, merged with a pushed-down WHERE filter, wrapped over (never pushed into) a LEFT JOIN leaf. No relation: a filter over the whole join. Two or more: a bridge, unchanged. The DP admits a split with no bridge under PostgreSQL's two rules: R1 when one side is closed (no multi-relation conjunct crosses it), R2 when a whole subset size produced no plan. The 'disconnected join graph' refusal is gone; a missing full plan is now FerroError::Internal. PREREG: artie-research frontier/lane_d255_join.md @ b0847f5. --- src/optimizer/search_algorithm.rs | 200 ++++++++++++++++++++++++------ 1 file changed, 164 insertions(+), 36 deletions(-) diff --git a/src/optimizer/search_algorithm.rs b/src/optimizer/search_algorithm.rs index 1b6db944..4ee9df0f 100644 --- a/src/optimizer/search_algorithm.rs +++ b/src/optimizer/search_algorithm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, split_and}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; +use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, split_and, wrap_filter}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; pub const MAX_DP_RELATIONS: usize = 12; @@ -24,6 +24,25 @@ pub struct Sub { pub cost: f64, } +/// Plan a tree of INNER joins: flatten it, place every ON conjunct, and search for a join order. +/// +/// # D255 — every ON conjunct is placed by the relations it reads +/// +/// A conjunct used to be applied only as a `bridge`: on a split `(l, r)` whose two sides it both +/// reads. A conjunct reading ONE relation has a one-bit mask, which cannot meet two disjoint sides, +/// and one reading NONE meets nothing — so both were silently dropped, at every size. +/// `a JOIN b ON a.id = b.id AND b.v = 5` ignored `b.v = 5`, and `ON a.id = b.id AND 1 = 0` +/// returned every match. Now, as in any planner that does predicate placement: +/// +/// * **one relation** — a filter on that relation's LOGICAL leaf, before `optimize` chooses its +/// access path, so `ON b.id = 3` reaches the index exactly as `WHERE b.id = 3` does. See +/// [`filter_leaf`] for why it merges with, and never pushes into, the leaf. +/// * **no relation** — a filter over the whole join. +/// * **two or more** — a bridge, unchanged. +/// +/// The other half of D255 is that a join no predicate links now plans as a cross product at every +/// size instead of being refused at 12 relations or fewer — see [`dynamic_program`]. +/// `tests/d255_join_on_conjuncts.rs` pins both halves on both sides of `MAX_DP_RELATIONS`. pub fn reorder_inner_joins(plan: LogicalPlan, catalog: &Catalog) -> Result { let mut leaves = Vec::new(); let mut preds = Vec::new(); @@ -42,72 +61,175 @@ pub fn reorder_inner_joins(plan: LogicalPlan, catalog: &Catalog) -> Result = Vec::new(); + let mut local: Vec> = vec![Vec::new(); n]; + let mut constant: Vec = Vec::new(); for pred in preds { let mut parts = Vec::new(); split_and(pred, &mut parts); for part in parts { let mut rel = 0u64; relations_of(&part, &orig_offset, &mut rel, &widths); - conjuncts.push((rel, part)); + match rel.count_ones() { + 0 => constant.push(part), + 1 => { + // Global column numbers → the leaf's own, which start at 0. + let r = rel.trailing_zeros() as usize; + local[r].push(remap(&part, &build_remap(&[r], &orig_offset, &widths))); + } + _ => conjuncts.push((rel, part)), + } } } - if n > MAX_DP_RELATIONS { - return Ok(left_deep(base_plans, &conjuncts, &orig_offset, &widths, catalog)) + let mut base_plans = Vec::with_capacity(n); + for (leaf, filters) in leaves.into_iter().zip(local) { + base_plans.push(optimize(filter_leaf(leaf, filters), catalog)?) + } + + let joined = if n > MAX_DP_RELATIONS { + left_deep(base_plans, &conjuncts, &orig_offset, &widths, catalog) + } else { + dynamic_program(base_plans, &conjuncts, &orig_offset, &widths, catalog)? + }; + // It reads no column, so it means the same over any column order and goes over whichever plan + // came back, including the DP's reordering projection. + Ok(if constant.is_empty() { + joined + } else { + PhysicalPlan::Filter { input: Box::new(joined), predicate: combine_and(constant) } + }) +} + +/// Put `filters` — conjuncts that read only this leaf, in its own column numbers — on the leaf. +/// +/// * A leaf with nothing to add is returned UNTOUCHED, so no plan that had no such conjunct has its +/// predicate tree rebuilt: re-splitting a WHERE filter and recombining it reassociates its ANDs. +/// * A leaf that is already a `Filter` — a WHERE conjunct `pushdown` put there — gets ONE filter +/// with both predicates. Two stacked filters are the same rows, but `optimize` only considers an +/// index for a `Filter` directly over a `Scan`, so the ON conjunct would never reach the index. +/// * Anything else is wrapped, never pushed into. A leaf can be a LEFT JOIN subtree, and pushing a +/// conjunct on its nullable side below the left join stops it from excluding the NULL-extended +/// rows the INNER join's ON must exclude. That is why this is not `optimizer::push`. +fn filter_leaf(leaf: LogicalPlan, mut filters: Vec) -> LogicalPlan { + if filters.is_empty() { + return leaf; } + match leaf { + LogicalPlan::Filter { input, predicate } => { + let mut all = Vec::new(); + split_and(predicate, &mut all); + all.append(&mut filters); + wrap_filter(*input, all) + } + other => wrap_filter(other, filters), + } +} +/// The exhaustive join-order search, for at most `MAX_DP_RELATIONS` relations. +/// +/// Subsets are built by increasing size. For every split `(l, r)` of a subset whose two sides both +/// have a plan, `bridge` is the conjuncts the split's join applies: those inside the subset that +/// read both sides. +/// +/// # D255 — when a split with no bridge is a candidate +/// +/// Before D255 it never was, so a query no chain of bridged splits could cover was refused with +/// "disconnected join graph": `a JOIN b ON TRUE`, and `a JOIN b ON TRUE JOIN c ON a.v + b.v = c.v`, +/// which is connected but only by a conjunct over three relations. Past `MAX_DP_RELATIONS`, +/// `left_deep` planned the same queries as cross products. And the parser has no `CROSS JOIN` and +/// no comma FROM list, so `ON TRUE` is the only way to write a cross product at all. +/// +/// A split with no bridge is now a cross-product candidate, joined `ON true`, under PostgreSQL's +/// two rules (`join_search_one_level`): +/// +/// * **R1, a closed side.** One side is CLOSED: no multi-relation conjunct reads both a relation in +/// it and one outside it. It has no predicate left to wait for, so crossing it now loses none. +/// PostgreSQL's `make_rels_by_clauseless_joins`, for a rel with no join clauses. +/// * **R2, the last ditch.** If no subset of this size got a plan, the size is searched again with +/// every split a candidate. PostgreSQL forces cartesian products when a level produced nothing. +/// +/// What those two rules guarantee: +/// +/// * **The full set always gets a plan.** Level 1 is the base relations; if bridges and R1 give a +/// level nothing, R2 joins any planned subset one size down with any relation outside it. The +/// `Internal` error below is that argument failing, not a refusal of a query. +/// * **Every multi-relation conjunct is applied exactly once, in any tree.** At the lowest node +/// whose subset contains it, it is inside neither child but inside their union, so it reads both +/// sides; above that node it is inside one child. +/// * **Queries that planned before D255 see the same candidates**, when their conjuncts each read at +/// most two relations. R1 cannot fire once every relation is bridge-reachable: take the lowest +/// node with relations both inside and outside a closed set — its children split the set from +/// the rest, and its bridge reads both. R2 cannot fire on a connected graph, which has a +/// connected, bridge-plannable subset of every size. +/// +/// The cost: with no join predicate at all every subset is closed and every split is built — +/// 523,265 at 12 relations, the same count a 12-relation clique already built before D255, which is +/// the load `MAX_DP_RELATIONS` bounds. +fn dynamic_program(base: Vec, conjuncts: &[(u64, BoundExpr)], orig_offset: &[usize], widths: &[usize], catalog: &Catalog) -> Result { + let n = widths.len(); let mut best = HashMap::new(); - for (r, plan) in base_plans.into_iter().enumerate() { + for (r, plan) in base.into_iter().enumerate() { let cost = cost(&plan, catalog).cost; best.insert(1u64 << r, Sub { plan, order: vec![r], cost}); } + let closed = |side: u64| conjuncts.iter().all(|(rel, _)| rel & side == 0 || rel & side == *rel); + // build up subsets by increasing size for size in 2..=n { let masks: Vec = (1u64..(1u64 << n)).filter(|m| m.count_ones() as usize == size).collect(); - for mask in masks { - let mut sub = (mask - 1) & mask; - while sub > 0 { - let l_mask = sub; - let r_mask = mask & !sub; - sub = (sub.wrapping_sub(1)) & mask; - let (Some(l), Some(r)) = (best.get(&l_mask), best.get(&r_mask)) else {continue;}; - - let bridge: Vec<&BoundExpr> = conjuncts.iter() - .filter(|(rel, _)| rel & mask == *rel && rel & l_mask != 0 && rel & r_mask != 0).map(|(_, e)| e).collect(); - if bridge.is_empty() { - continue; - } + for forced in [false, true] { + // R2: only when this size has no plan at all. + if forced && masks.iter().any(|m| best.contains_key(m)) { + break; + } + for &mask in &masks { + let mut sub = (mask - 1) & mask; + while sub > 0 { + let l_mask = sub; + let r_mask = mask & !sub; + sub = (sub.wrapping_sub(1)) & mask; + let (Some(l), Some(r)) = (best.get(&l_mask), best.get(&r_mask)) else {continue;}; + + let bridge: Vec<&BoundExpr> = conjuncts.iter() + .filter(|(rel, _)| rel & mask == *rel && rel & l_mask != 0 && rel & r_mask != 0).map(|(_, e)| e).collect(); + if bridge.is_empty() && !(forced || closed(l_mask) || closed(r_mask)) { + continue; + } - let mut order = l.order.clone(); - order.extend(&r.order); - let map = build_remap(&order, &orig_offset, &widths); - let on = combine_and(bridge.iter().map(|e| remap(e, &map)).collect()); - let left_width: usize = l.order.iter().map(|&x| widths[x]).sum(); - let right_width: usize = r.order.iter().map(|&x| widths[x]).sum(); - let candidate = build_join(l.plan.clone(), r.plan.clone(), on, JoinType::Inner, left_width, right_width, catalog); - let cost = cost(&candidate, catalog).cost; - if best.get(&mask).map_or(true, |s| cost < s.cost) { - best.insert(mask, Sub { plan: candidate, order, cost }); + let mut order = l.order.clone(); + order.extend(&r.order); + let map = build_remap(&order, orig_offset, widths); + let on = if bridge.is_empty() { + BoundExpr::Literal(Value::Boolean(true)) + } else { + combine_and(bridge.iter().map(|e| remap(e, &map)).collect()) + }; + let left_width: usize = l.order.iter().map(|&x| widths[x]).sum(); + let right_width: usize = r.order.iter().map(|&x| widths[x]).sum(); + let candidate = build_join(l.plan.clone(), r.plan.clone(), on, JoinType::Inner, left_width, right_width, catalog); + let cost = cost(&candidate, catalog).cost; + if best.get(&mask).map_or(true, |s| cost < s.cost) { + best.insert(mask, Sub { plan: candidate, order, cost }); + } } } } } let full = (1u64 << n) - 1; - let best_full = best.remove(&full).ok_or_else(|| FerroError::Bind("disconnected join graph".into()))?; - + let best_full = best.remove(&full).ok_or_else(|| FerroError::Internal(format!( + "the join search planned no subset covering all {n} relations; R2 in `dynamic_program` is meant to make that impossible" + )))?; + let idendity: Vec = (0..n).collect(); if best_full.order == idendity { return Ok(best_full.plan) } else { - let map = build_remap(&best_full.order, &orig_offset, &widths); + let map = build_remap(&best_full.order, orig_offset, widths); let total: usize = widths.iter().sum(); let exprs: Vec = (0..total).map(|k| BoundExpr::Column(*map.get(&k).unwrap())).collect(); Ok(PhysicalPlan::Projection { input: Box::new(best_full.plan), exprs }) @@ -141,6 +263,12 @@ pub fn relations_of(expr: &BoundExpr, orig_offset: &[usize], out: &mut u64, widt } } +/// Past `MAX_DP_RELATIONS`: join in the order written, crossing `ON true` where no bridge applies. +/// +/// `conjuncts` holds only conjuncts over two or more relations (D255 — the others are already on +/// the base plans or over the whole join). Each one, with highest relation `m`, becomes a bridge at +/// exactly one step, the one that joins relation `m`: before it the conjunct is not yet inside +/// `mask`, and after it the conjunct no longer reads the relation being added. fn left_deep(base: Vec, conjuncts: &[(u64, BoundExpr)], orig_offset: &[usize], widths: &[usize], catalog: &Catalog) -> PhysicalPlan { let mut iter = base.into_iter(); let mut acc = iter.next().unwrap(); From feed5f94682596e9d11d176174380490766129dc Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:53:48 -0400 Subject: [PATCH 232/315] UNBUILT D244: every exit step runs whatever the earlier ones returned, first error returned in step order (review 3 R3-F2, the shape rollback-index-orphan gives its checkpoints); the no-op set_state comment names the caller that reaches it, the reaper's resume path (R3-F4; not built, not run) --- examples/pgserver.rs | 13 ++++++++++--- src/branch/table_catalog.rs | 4 ++-- src/cli/cli.rs | 20 +++++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/examples/pgserver.rs b/examples/pgserver.rs index b6a26e46..979c0682 100644 --- a/examples/pgserver.rs +++ b/examples/pgserver.rs @@ -157,7 +157,14 @@ fn main() { let _ = writeln!(std::io::stderr(), "pgserver: lease scan stopped after {stats:?}"); // D244 review F7: the branch catalog's root, published and synced before the arena's map, for // the reason `ferrodb::cli::cli::exit_sequence` gives. This binary takes no database checkpoint - // at exit, so it makes the one call rather than calling that function. - branches.publish_root_durably().expect("publish the branch catalog's root"); - store.checkpoint(Path::new(&arena_path)).expect("checkpoint the arena"); + // at exit, so it makes the one call rather than calling that function. Both run whatever the + // other returns, so a failed publish does not cost the arena its checkpoint (D244 review 3, + // R3-F2); the publish's error is reported first. + let published = branches.publish_root_durably(); + let arena_checkpoint = store.checkpoint(Path::new(&arena_path)); + if let (Err(_), Err(a)) = (&published, &arena_checkpoint) { + let _ = writeln!(std::io::stderr(), "pgserver: the arena checkpoint failed as well ({a})"); + } + published.expect("publish the branch catalog's root"); + arena_checkpoint.expect("checkpoint the arena"); } diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 77d59644..0aede216 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -1130,8 +1130,8 @@ impl BranchCatalog for TableBranchCatalog { // that error, where before D244 it could not fail once past the checks above. Kept // on purpose: the header write it reports is still owed, and swallowing it would // hide a catalog the next open refuses. It is reached when a caller passes the state - // the branch already has, e.g. `seal`'s reaper-less fallback passing `record.state` - // with `to = Reaped` (`agent_sql/runtime.rs`). + // the branch already has: the reaper's resume path re-enters an interrupted reap with + // `set_state(branch, Reaping, Reaping)` (`reaper.rs`, `resume_interrupted_reaps`). return Ok(false); } let old = core.clone(); diff --git a/src/cli/cli.rs b/src/cli/cli.rs index ad37d4cc..76fdc008 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -234,15 +234,29 @@ pub fn run_cli(db_path: &str) -> Result<(), FerroError> { /// checkpoint, refuses to reattach, and the branch tree written this session is unreachable. It /// runs after the branch catalog's sync, so the map it writes is taken after the catalog's last /// durable write. +/// +/// Every step runs whatever the ones before it returned, and the errors come back in step order, +/// the first returned and any later one printed. A failed publish must not also cost the database +/// and the arena their checkpoints (D244 review 3, R3-F2), which is the shape +/// `rollback-index-orphan` already gives its two checkpoints (its review 2, C2). pub fn exit_sequence( branches: &TableBranchCatalog, txn: &TxnManager, store: &ArenaPageStore, arena_path: &Path, ) -> Result<(), FerroError> { - branches.publish_root_durably()?; - txn.checkpoint()?; - store.checkpoint(arena_path) + let published = branches.publish_root_durably(); + let wal_checkpoint = txn.checkpoint(); + let arena_checkpoint = store.checkpoint(arena_path); + let mut errors = [published, wal_checkpoint, arena_checkpoint].into_iter().filter_map(Result::err); + let first = errors.next(); + for later in errors { + eprintln!("ferrodb: a later exit step failed as well ({later})"); + } + match first { + Some(e) => Err(e), + None => Ok(()), + } } fn execute_sql(sql: &str, catalog: &CatalogLock, bp: Arc, txn: Arc, session: &mut Session) { From ea2b58f7bb5ea89067db287525080221f0b785a8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:53:48 -0400 Subject: [PATCH 233/315] UNBUILT D244 test: a source-shape check that run_cli ends through exit_sequence and pgserver publishes before its arena checkpoint (review 3 R3-F3; wording, not outcome, stated with its blind spots; not built, not run) --- tests/d244_exits_run_the_publish.rs | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tests/d244_exits_run_the_publish.rs diff --git a/tests/d244_exits_run_the_publish.rs b/tests/d244_exits_run_the_publish.rs new file mode 100644 index 00000000..954dd7b9 --- /dev/null +++ b/tests/d244_exits_run_the_publish.rs @@ -0,0 +1,79 @@ +//! D244 review 3, R3-F3 — **both production exits run the branch catalog's publish.** This checks +//! WORDING, not an outcome, and says so here. +//! +//! `tests/d244_clean_exit_publishes_the_root.rs` runs `cli::exit_sequence` and shows that the +//! publish in it is load-bearing (mutants MC and MD). What no test can run is the binaries' own +//! exits: `run_cli` reads a REPL from stdin, `examples/pgserver.rs` serves a socket, and neither can +//! be handed a storage that fails a read of page 1. So two edits would pass every outcome test: +//! - ME: `run_cli` goes back to checkpointing inline instead of calling `exit_sequence`; +//! - MF: pgserver drops its `publish_root_durably()` call. +//! +//! This test reads the two files, with `//` comments stripped, and fails on either. +//! +//! # Blind spots +//! +//! It matches text. A call under another name (an alias, a wrapper), a call in dead code, or a +//! string holding the pattern would all satisfy it. It is the same kind of guard as +//! `d53_private_root_allowlist.rs`, for the same reason: the outcome is not reachable from a test. + +/// `path` under the crate root, with every `//` comment removed. +fn code_only(path: &str) -> String { + let full = format!("{}/{path}", env!("CARGO_MANIFEST_DIR")); + let src = std::fs::read_to_string(&full).unwrap_or_else(|e| panic!("read {full}: {e}")); + src.lines() + .map(|l| match l.find("//") { + Some(i) => &l[..i], + None => l, + }) + .collect::>() + .join("\n") +} + +/// The text of a top-level function in `src`: from `signature` to the first line that is `}` alone. +fn body_of<'a>(src: &'a str, signature: &str) -> &'a str { + let start = src + .find(signature) + .unwrap_or_else(|| panic!("`{signature}` not found: this guard is aimed at code that has moved")); + let len = src[start..] + .find("\n}\n") + .unwrap_or_else(|| panic!("`{signature}` has no closing line: this guard is aimed at code that has moved")); + &src[start..start + len] +} + +#[test] +fn run_cli_ends_through_exit_sequence_which_publishes_before_the_arena_checkpoint() { + let cli = code_only("src/cli/cli.rs"); + let run_cli = body_of(&cli, "pub fn run_cli("); + assert_eq!( + run_cli.matches("exit_sequence(").count(), + 1, + "`run_cli` must end through `exit_sequence`, exactly once: it is the only exit code a test \ + runs (ME)" + ); + assert_eq!( + run_cli.matches("store.checkpoint(").count(), + 0, + "`run_cli` checkpoints the arena itself instead of through `exit_sequence`, so the exit the \ + binary runs is not the one the tests run (ME)" + ); + let exit = body_of(&cli, "pub fn exit_sequence("); + let publish = exit + .find("publish_root_durably()") + .unwrap_or_else(|| panic!("`exit_sequence` must publish the branch catalog's root (MC)")); + let arena = exit + .find("store.checkpoint(") + .unwrap_or_else(|| panic!("`exit_sequence` must checkpoint the arena")); + assert!(publish < arena, "`exit_sequence` must publish the branch catalog's root before the arena checkpoint"); +} + +#[test] +fn pgserver_publishes_the_branch_root_before_its_arena_checkpoint() { + let pg = code_only("examples/pgserver.rs"); + let publish = pg + .find("publish_root_durably()") + .unwrap_or_else(|| panic!("pgserver's exit must publish the branch catalog's root (MF)")); + let arena = pg + .rfind("store.checkpoint(") + .unwrap_or_else(|| panic!("pgserver must checkpoint its arena at exit")); + assert!(publish < arena, "pgserver must publish the branch catalog's root before its arena checkpoint (MF)"); +} From 547de64c48cdb99ffab2011f2c229d845fbe2dbb Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:54:07 -0400 Subject: [PATCH 234/315] =?UTF-8?q?UNBUILT=20D249=20review=202:=20PREREG?= =?UTF-8?q?=20amendment=204=20=E2=80=94=20the=20staleness=20check=20compar?= =?UTF-8?q?es=20the=20entry=20as=20read=20(G2/G3),=20apply=5Fplan's=20doc?= =?UTF-8?q?=20(G4),=20A9=20renamed=20(G5),=20E1/E2/E4=20re-registered=20on?= =?UTF-8?q?=20the=20D249=20tip=20(G1);=20before=20the=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d249/PREREG.md | 92 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/bench/d249/PREREG.md b/bench/d249/PREREG.md index b93fee26..96be6b96 100644 --- a/bench/d249/PREREG.md +++ b/bench/d249/PREREG.md @@ -296,3 +296,95 @@ The fix plan registered U1 as `Err(Unrepresentable { limit: PAGE_SIZE, .. })`. T | E3 | U1, and AI (a panic in `serialize`) | | E4 | AI | | E5 | ST | + +--- + +## Amendment 4 — review 2's G1–G5, written BEFORE their code (nothing built) + +Source: `artie-research frontier/d249_review2.md` @ `4c2bf91`, a review of `98432bc..27dd0a1`, verdict +SOUND-WITH-CAVEATS. These are the lead's decisions. An earlier amendment 4, recording `0369d88..27dd0a1`, was +refused by the handoff gate before it was written. This is the first amendment 4 on record, and it covers both. + +### What `0369d88..27dd0a1` did (the record the refused amendment would have made) + +| commit | what | +|---|---| +| `0369d88` | tests A33, AI and ST (additions); A9's doc comment corrected; `Db::new` bounds the allocator (`reserve_region("test floor", 256, u32::MAX)`) | +| `fa2e9a3` | F7: `AlterPlan.checked` and `apply_plan`'s staleness check. F4: two comments name the exception | +| `27dd0a1` | E4 and E5; E1–E5 cut from `fa2e9a3` | + +### G2 and G3: the staleness check compares the entry AS READ, and does not overwrite the live schema + +- **What is wrong at `fa2e9a3`.** `checked` is the entry after the plan's shape and renames. The check rebuilt the + live entry by overwriting its schema with the plan's final shape, then compared. + - **It is blind to a schema change** (G3). Another ALTER between plan and apply would vanish from the catalog, + and rows converted from the pre-change heap would be installed. + - **It turned E4 into a test of the staleness check** (G2). E4 removes the renames from `installed`, which was + also `checked`, so E4 died by comparison and never reached the encoder. +- **The fix.** `AlterPlan` stores `read: TableEntry`, the entry exactly as `plan_alters` read it, and `checked` is + removed. `apply_plan` refuses, before anything it writes, unless the LIVE entry equals `read`, with nothing + overwritten. + - `checked` was a deterministic function of `read`, the plan's shapes and its actions, so "live == read" implies + what the old comparison implied. + - It also covers the schema. +- **Its blind spot, stated in the code.** A committed INSERT, UPDATE or DELETE that moves no root changes the heap + and not the entry, so the check does not see it. The plan's `prepared` rows would then not describe the heap. + That stays with the caller's discipline, as the merge's comment already says ("a plan's decision is only valid for + the heap it read", `agent_sql/runtime.rs:5003-5006`). The heading and message are narrowed to "the table's + catalog entry has changed since". +- **ST is strengthened.** It now asserts the refusal is a `Constraint` whose message contains "changed since", not + any `Err`, so an unrelated refusal (a future quiesce change, say) cannot pass it. This is the review's note, and + it applies to this lane's own unrun test. +- **E6 is not registered.** With `live == read`, E4 no longer passes through the staleness check (the live entry and + `read` are both untouched by the plan). So E4 measures what F2 asked for: whether the encoder is asked about the + entry with the renamed index. + +### G4: `apply_plan`'s doc + +"The failures it can still meet are environmental" is false. `apply_plan` has two non-environmental refusals, and +the doc will name both, and why neither fires inside `AgentRuntime::merge`: + +- the quiesce re-check: a merge holds no transaction open; +- the staleness check: a merge applies each table's plan with no statement in between that touches that table's + entry. `apply_plan(Y)` changes only Y's entry (review 2, §4). + +### G5: A9's name and message carry the retracted reason + +- **Renamed.** `an_added_ninth_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten` becomes + **`an_added_ninth_column_the_catalog_cannot_hold_is_refused_and_wedges_nothing`**. It pins the wedge, and not the + placement. +- **Its row-assertion message** loses "it was rewritten under nine, whose null bitmap is a byte wider (I19)". +- **No assertion moves.** The key stays A9. + +### G1: the mutant table, re-registered against the tree it runs on + +**The tree.** The D249 mutant run applies each patch to **the D249 branch tip**, the commit amendment 5 will name. +That tree does NOT contain D254's fix. On D254's child branch, `persist` refuses an oversized entry before placing +anything, which moves E1's and E4's AI failure from the error assertion to the schema assertion. D254's own PREREG +registers the mutants for that tree. + +| mutant | fails (on the D249 tip, no D254) | +|---|---| +| E1 `precheck_removed` | AR, AA, A9 and A33 (a wedge: the refused name is installed, then `persist` refuses on its length); **AI at its error assertion**. The staleness check passes, `finish` installs, and `persist` loops until the test floor refuses with `Io("no free page below … 256 …")`, which is not a `Constraint` | +| E2 `precheck_in_finish` | **A33** at its row assertion (the review's MODEL: `16777216` first), **and AI** at its "holds the renamed column or index" assertion. `apply_plan` renames the live index to `w…` before `finish`, then E2's check refuses with the correct size error, which leaves an index naming a column that does not exist | +| E3 `oversize_arm_removed` | U1 (run on `--lib catalog::catalog_page::tests::`). AI would also panic in `serialize`, but that command does not run AI | +| E4 `index_renames_not_checked` | **AI only, at its error assertion.** The pre-check sees 3922 and passes. The staleness check passes too, because `live == read`. `finish` installs, and `persist` loops until the floor's `Io` refusal | +| E5 `staleness_check_removed` | ST | + +**Under the check at `fa2e9a3`, E4 also failed three existing tests**, each by the staleness refusal on a rename of an +indexed column (review 2, G1): + +- `integration_alter_column::renaming_an_indexed_column_leaves_the_table_queryable`; +- `integration_merge_ddl_atomicity::an_index_follows_its_column_across_the_renames_a_merge_applies`; +- `integration_merge_ddl_atomicity::a_rename_follows_its_column_into_the_fulltext_index_too`. + +**Under the `live == read` check, E4 no longer fails them** (INFERRED). Their entries are far below a page, so +leaving the index renames out of the checked entry changes nothing the encoder answers. The staleness check no +longer involves the renames either. These three are named so that a collateral run under E4, if one is ever made, +has a registered prediction: **they pass**. + +### Counts (unchanged by this amendment) + +- `d249_alter_encodable` has **6** tests, and the lib gains U1 and U2. **Per-target: 2579 (measured) + 8 = 2587.** +- **R2 at `0369d88`** (predicted): `d249_alter_encodable` **5 passed, 1 failed (ST)**. A33 and AI pass there, + because the pre-check is present. From c5e57b81ea81a41267f919ff4951a0893e195964 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:54:46 -0400 Subject: [PATCH 235/315] UNBUILT D249 review 2: the staleness check compares the live entry with the entry as the plan read it, nothing overwritten (G2/G3); apply_plan's doc names its two refusals (G4) --- src/catalog/alter.rs | 73 +++++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 31 deletions(-) diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index f65f0b83..13e1d772 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -370,10 +370,11 @@ pub struct AlterPlan { prov: Option>, dir_root: u32, primary_root: u32, - /// The entry the catalog encoder was asked about: the table's entry as the plan read it, with the - /// final shape and the renamed indexes. [`Catalog::apply_plan`] refuses the plan if the live - /// entry, with the same changes applied, is not this one (D249 review, F7). - checked: TableEntry, + /// The table's catalog entry exactly as the plan read it, before any change. Everything the plan + /// decided, the encoder's answer included, was decided against this entry, so + /// [`Catalog::apply_plan`] refuses the plan unless the live entry is still this one (D249 reviews, + /// F7 and G2-G3). + read: TableEntry, } impl AlterPlan { @@ -612,7 +613,7 @@ impl Catalog { prov: prov.cloned(), dir_root, primary_root, - checked: installed, + read: entry.clone(), }) } @@ -620,11 +621,23 @@ impl Catalog { /// a caller can log one DDL record per action carrying the shape that action produced. /// /// Everything the data could refuse was refused by [`Catalog::plan_alters`] while the heap was - /// still untouched. What is left here is the writing pass and the catalog install, and the - /// failures it can still meet are environmental — a buffer pool with no evictable frame, a - /// disk write that fails, a B+tree page that cannot be read. See [`prepare_rewrite`] for what - /// that boundary is and why the answer to crossing it is to log the rewrite rather than to - /// pretend it cannot happen. + /// still untouched. What is left here is the writing pass and the catalog install. Two refusals + /// remain, and both come before anything is written: + /// + /// - **the quiesce re-check**: a transaction in flight while tuples move; + /// - **the staleness check**: the table's catalog entry is no longer the one the plan read + /// (D249). A plan can be held across a statement, and the plan's decisions, the encoder's + /// answer included, hold only for the entry they were made against. + /// + /// Neither fires inside `AgentRuntime::merge`. A merge holds no transaction open, and it applies + /// each table's plan with no statement in between that touches that table's entry: + /// `apply_plan` for one table changes only that table's entry. That is what keeps a merge atomic + /// in its refusals. + /// + /// Past those two, the failures it can still meet are environmental — a buffer pool with no + /// evictable frame, a disk write that fails, a B+tree page that cannot be read. See + /// [`prepare_rewrite`] for what that boundary is and why the answer to crossing it is to log the + /// rewrite rather than to pretend it cannot happen. pub fn apply_plan( &mut self, plan: AlterPlan, @@ -643,29 +656,27 @@ impl Catalog { prov, dir_root, primary_root, - checked, + read, } = plan; - // **The plan still describes this table** (D249 review, F7). `plan_alters` asked the catalog - // encoder about one entry: the table's as it read it, with the final shape and the renamed - // indexes. A plan can be held across a statement, and if the same table changed in between - // (an index created, say), installing the renames now would persist an entry nobody asked - // about. So the live entry, with the same changes applied, must BE the entry that was - // checked. A staleness check, not a second encoder guard: it compares, and asks the encoder - // nothing. - { - let mut now = self.require_table(&table)?.clone(); - now.schema = shapes - .last() - .cloned() - .ok_or_else(|| FerroError::Internal(format!("a plan for '{table}' held no shape")))?; - rename_indexed_columns(&mut now, &actions); - if now != checked { - return Err(FerroError::Constraint(format!( - "the alteration of '{table}' was planned against the table as it was, and the \ - table has changed since; nothing was written. Plan it again." - ))); - } + // **The table's catalog entry is still the one the plan read** (D249 reviews, F7 and + // G2-G3). Every decision `plan_alters` made was made against that entry: its shapes, its + // renames, and the catalog encoder's answer about the entry `finish` will install. A plan can + // be held across a statement, and if the entry changed in between (an index created, another + // ALTER, a root moved), installing now would persist an entry nobody asked about, over a + // shape the plan did not start from. So the live entry must equal it, compared as it is, with + // nothing overwritten first. A staleness check, not a second encoder guard: it compares, and + // asks the encoder nothing. + // + // **Its blind spot, stated.** A committed INSERT, UPDATE or DELETE that moves no root changes + // the heap and not the entry, and this does not see it. The plan's `prepared` rows would then + // not describe the heap. That stays with the caller's discipline, as `AgentRuntime::merge`'s + // ordering comment already says: a plan's decision is only valid for the heap it read. + if self.require_table(&table)? != &read { + return Err(FerroError::Constraint(format!( + "the alteration of '{table}' was planned against its catalog entry as it was then, \ + and that entry has changed since; nothing was written. Plan it again." + ))); } // Reserve the space the relocations will need, before the first one happens. From e6af6394aa94ff51ddbd994055be019bc7ea2dfb Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:54:55 -0400 Subject: [PATCH 236/315] UNBUILT D249 review 2: A9 renamed and its message cut to what it pins (G5); ST asserts the staleness refusal itself --- tests/d249_alter_encodable.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/d249_alter_encodable.rs b/tests/d249_alter_encodable.rs index 73535461..67b100de 100644 --- a/tests/d249_alter_encodable.rs +++ b/tests/d249_alter_encodable.rs @@ -192,7 +192,7 @@ fn an_added_column_whose_name_the_catalog_cannot_hold_is_refused_and_wedges_noth /// under eight. `deserialize` checks no length either. The test that pins the placement is the /// 33-column one below. #[test] -fn an_added_ninth_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten() { +fn an_added_ninth_column_the_catalog_cannot_hold_is_refused_and_wedges_nothing() { let mut db = Db::new(); db.exec( "CREATE TABLE t (id INTEGER NOT NULL, c1 INTEGER, c2 INTEGER, c3 INTEGER, c4 INTEGER, \ @@ -219,7 +219,7 @@ fn an_added_ninth_column_the_catalog_cannot_hold_is_refused_before_any_row_is_re rows, vec![want], "ADD COLUMN (ninth) was refused, but the row no longer reads back under the old eight \ - columns: it was rewritten under nine, whose null bitmap is a byte wider (I19)" + columns" ), Ok(_) => panic!("SELECT after the refusal returned something other than rows"), Err(e) => panic!( @@ -353,8 +353,14 @@ fn a_plan_held_across_a_change_to_its_own_table_is_refused() { // The table changes while the plan is held: an index on the column the plan renames. db.catalog.create_index("t", "v").unwrap(); - let applied = db.catalog.apply_plan(plan, &db.txn); - assert!(applied.is_err(), "a plan made before the table changed was applied anyway"); + match db.catalog.apply_plan(plan, &db.txn) { + Err(FerroError::Constraint(msg)) => assert!( + msg.contains("changed since"), + "the stale plan was refused, but not by the staleness check: {msg}" + ), + Err(e) => panic!("the stale plan was refused for some other reason: {e:?}"), + Ok(_) => panic!("a plan made before the table changed was applied anyway"), + } let entry = db.catalog.get_table("t").expect("the table is still there"); assert!( entry.schema.columns.iter().any(|c| c.name == "v") && entry.indexes.iter().any(|ix| ix.column_name == "v"), From bed42db4de14364af870b03a57305c6f49fec685 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:55:02 -0400 Subject: [PATCH 237/315] UNBUILT D249 review 2: E5 re-anchored on the new comparison; E1-E5 cut from e6af639 --- bench/d249/make_mutants.py | 4 ++-- bench/d249/mutants/E1_precheck_removed.patch | 2 +- bench/d249/mutants/E2_precheck_in_finish.patch | 4 ++-- .../mutants/E4_index_renames_not_checked.patch | 2 +- .../mutants/E5_staleness_check_removed.patch | 18 +++++++++--------- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/bench/d249/make_mutants.py b/bench/d249/make_mutants.py index a8c3189e..a68242f0 100644 --- a/bench/d249/make_mutants.py +++ b/bench/d249/make_mutants.py @@ -44,8 +44,8 @@ "", )]), ("E5_staleness_check_removed", ALTER, [( - " if now != checked {\n", - " if false && now != checked {\n", + " if self.require_table(&table)? != &read {\n", + " if false && self.require_table(&table)? != &read {\n", )]), ] diff --git a/bench/d249/mutants/E1_precheck_removed.patch b/bench/d249/mutants/E1_precheck_removed.patch index 9ccf7a4c..58304f46 100644 --- a/bench/d249/mutants/E1_precheck_removed.patch +++ b/bench/d249/mutants/E1_precheck_removed.patch @@ -1,6 +1,6 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -586,7 +586,7 @@ +@@ -587,7 +587,7 @@ let mut installed = entry.clone(); installed.schema = new_schema.clone(); rename_indexed_columns(&mut installed, actions); diff --git a/bench/d249/mutants/E2_precheck_in_finish.patch b/bench/d249/mutants/E2_precheck_in_finish.patch index 373dadf3..8cbba106 100644 --- a/bench/d249/mutants/E2_precheck_in_finish.patch +++ b/bench/d249/mutants/E2_precheck_in_finish.patch @@ -1,6 +1,6 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -586,7 +586,7 @@ +@@ -587,7 +587,7 @@ let mut installed = entry.clone(); installed.schema = new_schema.clone(); rename_indexed_columns(&mut installed, actions); @@ -9,7 +9,7 @@ // The statistics are carried across the alteration by [`carried_stats`], and they are // computed HERE, before the rewrite, rather than inside `finish` where they used to be. -@@ -754,6 +754,9 @@ +@@ -765,6 +765,9 @@ stats.columns = columns; } let entry = self.tables.get_mut(table).ok_or(FerroError::KeyNotFound)?; diff --git a/bench/d249/mutants/E4_index_renames_not_checked.patch b/bench/d249/mutants/E4_index_renames_not_checked.patch index 15ae8e0f..0b210a79 100644 --- a/bench/d249/mutants/E4_index_renames_not_checked.patch +++ b/bench/d249/mutants/E4_index_renames_not_checked.patch @@ -1,6 +1,6 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -585,7 +585,6 @@ +@@ -586,7 +586,6 @@ // is a fixed-width field, so it cannot change the answer. let mut installed = entry.clone(); installed.schema = new_schema.clone(); diff --git a/bench/d249/mutants/E5_staleness_check_removed.patch b/bench/d249/mutants/E5_staleness_check_removed.patch index d7a83214..5113da68 100644 --- a/bench/d249/mutants/E5_staleness_check_removed.patch +++ b/bench/d249/mutants/E5_staleness_check_removed.patch @@ -1,11 +1,11 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -660,7 +660,7 @@ - .cloned() - .ok_or_else(|| FerroError::Internal(format!("a plan for '{table}' held no shape")))?; - rename_indexed_columns(&mut now, &actions); -- if now != checked { -+ if false && now != checked { - return Err(FerroError::Constraint(format!( - "the alteration of '{table}' was planned against the table as it was, and the \ - table has changed since; nothing was written. Plan it again." +@@ -672,7 +672,7 @@ + // the heap and not the entry, and this does not see it. The plan's `prepared` rows would then + // not describe the heap. That stays with the caller's discipline, as `AgentRuntime::merge`'s + // ordering comment already says: a plan's decision is only valid for the heap it read. +- if self.require_table(&table)? != &read { ++ if false && self.require_table(&table)? != &read { + return Err(FerroError::Constraint(format!( + "the alteration of '{table}' was planned against its catalog entry as it was then, \ + and that entry has changed since; nothing was written. Plan it again." From 9b291ac9d6d25f199cac26303225ce2a0bf479d3 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:55:12 -0400 Subject: [PATCH 238/315] =?UTF-8?q?UNBUILT=20D249=20review=202:=20PREREG?= =?UTF-8?q?=20amendment=205=20=E2=80=94=20the=20as-read=20check,=20A9=20an?= =?UTF-8?q?d=20ST=20changes,=20and=20E1-E5=20recorded;=20the=20mutant=20tr?= =?UTF-8?q?ee=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d249/PREREG.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/bench/d249/PREREG.md b/bench/d249/PREREG.md index 96be6b96..31e1485a 100644 --- a/bench/d249/PREREG.md +++ b/bench/d249/PREREG.md @@ -388,3 +388,26 @@ has a registered prediction: **they pass**. - `d249_alter_encodable` has **6** tests, and the lib gains U1 and U2. **Per-target: 2579 (measured) + 8 = 2587.** - **R2 at `0369d88`** (predicted): `d249_alter_encodable` **5 passed, 1 failed (ST)**. A33 and AI pass there, because the pre-check is present. + +--- + +## Amendment 5 — amendment 4's changes recorded; the D249 mutant tree named (nothing built) + +| commit | what | +|---|---| +| `547de64` | amendment 4 | +| `c5e57b8` | `AlterPlan.read`, the entry as read, replaces `checked`. `apply_plan` refuses unless `live == read`, with nothing overwritten, and its blind spot is stated. The doc names both refusals (G4) | +| `e6af639` | A9 renamed to `an_added_ninth_column_the_catalog_cannot_hold_is_refused_and_wedges_nothing`, and its row message cut (G5). ST asserts `Constraint` containing "changed since" | +| `bed42db` | E5 re-anchored on `if self.require_table(&table)? != &read`; E1–E5 cut from `e6af639`, each passing `git apply --check`. E1, E2 and E4 have the same edits as before | + +- **Code at `c5e57b8`** (comment lines filtered from `git diff 27dd0a1 c5e57b8 -- src/catalog/alter.rs`): + - the field and its initialiser: `read: entry.clone()`; + - the destructured name; + - the old 13-line rebuild-and-compare block, replaced by a 6-line `if … != &read { return Err(Constraint) }`. +- **Test lines changed at `e6af639`:** A9's fn name and one message line; ST's `assert!(applied.is_err(), …)`, + replaced by a `match` that requires the staleness `Constraint`. The ST change strengthens an assertion and does + not weaken one. +- **Counts at `bed42db`:** `d249_alter_encodable` has 6 tests, and `git diff 9aa6968 bed42db` adds 8 `#[test]` and + removes 0 (READ). **Per-target: 2587.** +- **The D249 mutant tree is `bed42db`** (this commit's parent carries no code change after it; amendment 4's G1 + table applies to it). It does not contain D254's fix. From cb3787eb020360c31d2f10a49011ad6ff486cda5 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:06:45 -0400 Subject: [PATCH 239/315] UNBUILT D231: PREREG amendment 2 (review 4) -- 64 verdicts, steps 26-32, base range 32-41 PASS Appended before steps 26-32 or the build.rs changes they pin exist, and before any run. Tip 64/0; base 9aa6968 nominally 32/32, exit 1, with clone 28 added to the allowed FAIL->PASS(rerun=yes) flips, so PASS 32-41. Kill map for the new mutants (packed-refs watched, two links only, status failure keeping the sha, no toplevel check, no index-bit check); the unexercised list grows by skip-worktree/ignoreStat, the N6 re-read and preferSymlinkRefs (N7). --- bench/d231_PREREG.md | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/bench/d231_PREREG.md b/bench/d231_PREREG.md index a6f25458..896b2839 100644 --- a/bench/d231_PREREG.md +++ b/bench/d231_PREREG.md @@ -139,3 +139,92 @@ The harness exits 2, and does not score the step, when: * the run reaches other than 50 verdicts. Exit 0 requires FAIL = 0 and PASS = 50. + +## Amendment 2 (D231 review 4, N1-N7 and H1-H2; lead decisions 2026-09-24) + +Written before steps 26-32 exist and before any run of them, stub model included. It supersedes amendment 1's totals, +for the script at the commit that follows this amendment. Steps 1-25 and their predictions are unchanged. The +`build.rs` behaviour these steps pin is new at that same commit: + +* **N1.** The stamp is `unknown` unless `git rev-parse --show-toplevel` is the package root. +* **N2.** The stamp is `unknown` when any index entry carries assume-unchanged (a lowercase `ls-files -v` tag) or + skip-worktree (`S`), or when `core.ignoreStat` is true. +* **N4.** A failed `git status` stamps `unknown`. It used to keep the sha and mark it dirty. +* **N6.** The sha is read, then `status` runs, then the sha is read again, and the stamp is `unknown` if it moved. + +### New steps, per arm (32 in all), and what each must show at the tip + +The first link of the chain is `refs/heads/d231-link-` ("link"). Step 27 adds `refs/heads/d231-link2-` +("link2") in front of it. + +| n | change before the build | tip stamp | tip rerun | +|---|---|---|---| +| 26 | an UNRELATED packed ref deleted (`update-ref -d` of a packed remote-tracking ref). This rewrites `packed-refs`, and nothing HEAD resolves through moves. Witness: only `packed-refs` moved; index, HEAD, link and `$first` did not (N3) | at Q0 +DIRTY | **no** | +| 27 | symref link2 → link; HEAD → link2. HEAD now resolves through three links: link2 → link → `$first` | at Q0 +DIRTY | yes | +| 28 | `git update-ref $first S0` moves the chain's THIRD link. Witness: only `$first`'s file moved; index, HEAD, link2 and link did not (N5) | at S0 +DIRTY | yes | +| 29 | index touched, then made unreadable (`chmod 000`), so `git status` fails. The mode is restored after the build (N4) | at unknown +DIRTY | yes | +| 30 | index touched, now readable again: the `unknown` does not stick | at S0 +DIRTY | yes | +| 31 | a comment appended to `src/lib.rs`, then `update-index --assume-unchanged src/lib.rs` (premise asserted: `ls-files -v` tags it `h`). `git status` no longer lists the edit. The bit is cleared and the file restored after the build (N2) | at unknown +DIRTY | yes | +| 32 | `git archive S0` extracted into the checkout's gitignored `target/d231-export/`, probe written, built there with the arm's target dir. git discovers the ENCLOSING checkout (N1) | at unknown +DIRTY | yes | + +### Predictions + +**At the D231 tip: 64 PASS / 0 FAIL, exit 0.** No step depends on timing. + +**At `9aa6968`: nominally 32 PASS / 32 FAIL, exit 1.** + +| arm | PASS | FAIL | +|---|---|---| +| clone | 1 4 7 8 11 14 17 19 20 22 27 30 | 2 3 5 6 9 10 12 13 15 16 18 21 23 24 25 26 28 29 31 32 | +| linked | 1 3 4 5 7 8 10 11 13 14 16 17 19 20 22 23 24 27 28 30 | 2 6 9 12 15 18 21 25 26 29 31 32 | + +Why each NEW base step comes out as it does: + +* **clone 26:** FAIL. There is no re-run, so the stamp is still step 25's clean `at Q0`, because the base honoured the + replacement. If a re-run happened it would still FAIL, on `rerun=yes`. +* **linked 26:** FAIL on `rerun=yes`. The base re-runs on every build. +* **27:** PASS in both arms. HEAD moved, and the base watches HEAD. +* **clone 28:** FAIL. There is no re-run (`$first`'s file is not watched), so the stamp is still `at Q0`. +* **linked 28:** PASS. +* **29:** FAIL in both arms. The base keeps the sha and marks it dirty: `at S0 +DIRTY`. +* **30:** PASS in both arms. The index moved, and the base watches it. +* **31:** FAIL in both arms. The base's status skips the assume-unchanged entry; the staged README still makes it + `at S0 +DIRTY`, not `unknown`. +* **32:** FAIL in both arms. The export's base build script stamps the enclosing checkout's HEAD, `at S0 +DIRTY`. + +### Base steps that depend on timing, and the only flips allowed + +Amendment 1's list stands: +* clone 2, 9, 12, 15 and 18 may go FAIL→PASS with `rerun=no`; +* clone 5, 23 and 24 may go FAIL→PASS with `rerun=yes`. + +Amendment 2 adds one: +* **clone 28 may go FAIL→PASS with `rerun=yes`**, only if a racy write-back is still pending from sub-second + preceding builds. That is the same mechanism as clone 5, 23 and 24. +* Steps 26, 27 and 29-32 come out the same either way at the base. + +So a base run gives **PASS between 32 and 41, FAIL between 23 and 32, and exit 1**. Only the named steps may differ, and +only in the named direction. Any other deviation is a MISMATCH. + +### Mutants these steps must kill (at the tip) + +| mutant | killed by step | +|---|---| +| `packed-refs` watched as well | 26 | +| only the first two links watched (`links.len() < 2`) | 28 | +| a failed `git status` keeps the sha (the old behaviour), or reads clean | 29 | +| no `--show-toplevel` check (N1) | 32 | +| no index-bit check (N2) | 31 | + +Amendment 1's kill map stands for steps 1-25. + +### Added to "not exercised by any step" + +* the skip-worktree (`S`) tag and `core.ignoreStat`. The code refuses both; step 31 exercises only assume-unchanged. +* the N6 re-read of the sha. No step can move HEAD inside one build script run. +* `core.preferSymlinkRefs` (N7, a known limit). With it, HEAD is a symlink and `--git-path HEAD` names its target's + realpath, so a HEAD retarget moves no watched file. The option is deprecated and not set on this machine (review 4, + MEASURED). + +The exit condition becomes: exit 0 requires FAIL = 0 and PASS = 64, and a run that reaches anything other than 64 +verdicts exits 2. From 35f42a37abf19c3d6958d2a1beb938c4badddcae Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:08:31 -0400 Subject: [PATCH 240/315] UNBUILT D231: PREREG amendment 2a -- step 29 fails git status through config, not an unreadable index An unreadable index also fails the N2 ls-files check, which would answer 'unknown' for the N4 mutant too (two guards, one untestable). status.aheadBehind = bogus fails git status (128) while ls-files, rev-parse and config --bool keep working (measured, git 2.50.1). Predictions unchanged; no run has happened. --- bench/d231_PREREG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bench/d231_PREREG.md b/bench/d231_PREREG.md index 896b2839..c2d48d1e 100644 --- a/bench/d231_PREREG.md +++ b/bench/d231_PREREG.md @@ -228,3 +228,25 @@ Amendment 1's kill map stands for steps 1-25. The exit condition becomes: exit 0 requires FAIL = 0 and PASS = 64, and a run that reaches anything other than 64 verdicts exits 2. + +## Amendment 2a (before any run): how step 29 makes `git status` fail + +Amendment 2 said step 29 makes the index unreadable. That would not isolate the N4 rule. An unreadable index also +fails the `ls-files -v` of the N2 check, and N2 answers `unknown` too, so the mutant "a failed status keeps the sha" +would pass step 29 through N2. That is two guards, one of which cannot be tested. + +Step 29 instead sets `status.aheadBehind = bogus` in the clone's repository config, then touches the index to force a +re-run. That config value makes `git status` fail and leaves every other call build.rs makes working. MEASURED on git +2.50.1, with the value set: + +| call | exit code | +|---|---| +| `git status --porcelain --untracked-files=no` | 128 | +| `git ls-files -v` | 0 | +| `git rev-parse --short=12 HEAD` | 0 | +| `git config --bool core.ignoreStat` | 1, the normal answer for an unset key | + +The value is unset right after the build. Step 29's predictions are unchanged: +* the tip stamps `at unknown +DIRTY`, with rerun yes; +* the base FAILs with `at S0 +DIRTY`; +* the N4 mutant FAILs the same way. From 3f9f41c834c5ffba877b2b4e52e2769c9ea8caf3 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:08:57 -0400 Subject: [PATCH 241/315] UNBUILT D225: review 6 decisions H1 and F1-F8 Pre-registered in lane_d225_byte_split.md amendment 3 (artie-research 784669b) before this commit. - H1: an ALTER that retypes an indexed column asks the entry bound of every widened secondary entry, for every tuple (deleted ones too), before anything is written; before, it wrote a database whose own crash recovery refused to open. Red test and its 2034-byte control (which also runs the rebuild). - F1: index_page::entry_over_bound / entry_too_large; the ALTER, UPDATE, recovery and backfill refusals no longer carry "shorten the indexed value" (asserted, case-insensitively). - F2: the CREATE [FULLTEXT] INDEX backfill names the row, whether it is deleted, and the table copy; dead tuples are not skipped. A test runs the copy remedy in this build. - F3: two remedy texts, OPEN_TABLE_REMEDY (this build; no INSERT ... SELECT, no RENAME TABLE) and RECOVERY_REMEDY (a build before D225). - F4: M26c (same length and header, different columns) is refused. - F5/F7/F8: probe comments, envelope remedy (restrict_envelope below the bound), recovery and E65 docs, the DELETE guard's limit stated. Not built or run: quiet mode. --- src/branch/table_catalog.rs | 35 ++++++++- src/catalog/alter.rs | 56 +++++++++++--- src/catalog/catalog.rs | 47 +++++++++++- src/execution/update.rs | 23 +++--- src/storage/index_page.rs | 75 +++++++++++++----- src/wal/recovery.rs | 39 ++++++---- tests/d225_byte_split.rs | 149 +++++++++++++++++++++++++++++++++--- 7 files changed, 358 insertions(+), 66 deletions(-) diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 07b292d3..188c7831 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -1838,6 +1838,12 @@ mod d225_envelope_bound { CapabilityEnvelope::new(Verb::ALL, max_row_writes).allow(1, (0..columns).map(ColumnCapability::open).collect()) } + /// 153 open columns numbered from 1 instead of 0: the same count, so the same length and the + /// same 21-byte header as `envelope_of(153)`, but different column ids. + fn envelope_shifted_153() -> CapabilityEnvelope { + CapabilityEnvelope::new(Verb::ALL, 10).allow(1, (1..=153).map(ColumnCapability::open).collect()) + } + /// **An envelope too large for a B+tree entry refuses the whole record, before any key of it /// is written.** 153 columns is 21 + 8 + 153·13 = 2018 bytes of envelope. Under the 9-byte /// envelope key the entry is 4 + 9 + 4 + 2018 = 2035 bytes, one over the bound; 152 columns @@ -1927,9 +1933,11 @@ mod d225_envelope_bound { ); assert_eq!(cat.tree.search(&keys::envelope(id)).unwrap(), Some(legacy.clone()), "the refused write changed the envelope"); - // M26b (review 5): a change of the SAME length is still a change. `fork_staged`'s - // recycled-id path hands `write_record` exactly this: the child's envelope is `inherited()`, - // which keeps the tables and changes only fixed-width counters. Only the budget differs. + // M26b (review 5): a change of the SAME length is still a change. Only the budget differs + // here. The production shape this guards is `fork_staged`'s recycled-id path (review 6 + // F4): the stored bytes there are the REAPED occupant's envelope and the child's are + // `parent.inherited()`, so they can differ in their counters, or in their tables at the + // same length (M26c below). let same_length = envelope_with_budget(153, 11).serialize(); assert_eq!(same_length.len(), legacy.len(), "premise: the same encoded length"); assert_ne!(same_length, legacy, "premise: different bytes"); @@ -1945,6 +1953,27 @@ mod d225_envelope_bound { ); assert_eq!(cat.tree.search(&keys::envelope(id)).unwrap(), Some(legacy.clone()), "the refused same-length write changed the envelope"); + // M26c (review 6): the same length AND the same 21-byte header (verbs, budget, row-writes, + // table count), with different column ids. A comparison of length plus header would call + // this unchanged and leave the stored grants in place. + let shifted = envelope_shifted_153().serialize(); + assert_eq!(shifted.len(), legacy.len(), "premise: the same encoded length"); + assert_eq!(shifted[..21], legacy[..21], "premise: the same 21-byte header"); + assert_ne!(shifted, legacy, "premise: different bytes"); + let mut regranted = cat.get_raw(id).unwrap(); + regranted.envelope = Some(envelope_shifted_153()); + regranted.lease_deadline = LeaseDeadline(9); + let e = cat + .write_record(®ranted, Some(&old)) + .expect_err("a same-length, same-header changed over-bound envelope must be refused"); + assert!(e.to_string().contains("index entry too large: 2035 bytes"), "not the named refusal: {e}"); + assert_eq!( + cat.core(id).unwrap().unwrap().lease_deadline(), + LeaseDeadline(1_000_000), + "the refused same-header write stored its record" + ); + assert_eq!(cat.tree.search(&keys::envelope(id)).unwrap(), Some(legacy.clone()), "the refused same-header write changed the envelope"); + cat.set_state(child.branch_id, BranchState::Live, BranchState::Reaping).expect("Live -> Reaping"); cat.set_state(child.branch_id, BranchState::Reaping, BranchState::Reaped).expect("Reaping -> Reaped"); assert_eq!(cat.core(id).unwrap().unwrap().state(), BranchState::Reaped, "the branch was not retired"); diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index 378484f7..f71229da 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -77,7 +77,7 @@ use crate::provenance::ProvenanceStore; use crate::storage::heap_file_manager::{HeapFileManager, RecordId}; use crate::storage::heap_page::{MAX_TUPLE_SIZE, SLOT_ENTRY_SIZE}; use crate::storage::index::BPlusTreeManager; -use crate::storage::index_page::{admit_entry, LEGACY_ENTRY_REMEDY}; +use crate::storage::index_page::{entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; use crate::storage::tuple::{Tuple, VERSION_HEADER_SIZE}; use crate::wal::txn::TxnManager; @@ -577,7 +577,23 @@ impl Catalog { // `Integer(5)` to `Decimal("5")`, through the same [`Widening`] table. let carried = carried_stats(&old_schema, &new_schema, self.stats.get(table))?; - let prepared = prepare_rewrite(&self.buffer_pool, table, dir_root, &shapes, actions, prov)?; + // **D225, review 6 H1: the secondary indexes whose entries the chain widens.** A retype + // changes the bytes of every entry in an index on that column, and nothing else rewrites + // those entries until crash recovery rebuilds them from the heap, which asks the entry + // bound. So `prepare_rewrite` asks it first. Positions are stable through a chain (ADD + // COLUMN appends and there is no DROP COLUMN), so a column is retyped exactly when its type + // differs between the first shape and the last. + let widened: Vec<(String, usize)> = entry + .indexes + .iter() + .filter_map(|i| { + let p = old_schema.columns.iter().position(|c| c.name == i.column_name)?; + (old_schema.columns[p].data_type != new_schema.columns[p].data_type) + .then(|| (i.column_name.clone(), p)) + }) + .collect(); + + let prepared = prepare_rewrite(&self.buffer_pool, table, dir_root, &shapes, actions, prov, &widened)?; Ok(AlterPlan { table: table.to_string(), @@ -923,6 +939,7 @@ fn prepare_rewrite( shapes: &[Schema], actions: &[AlterAction], prov: Option<&Arc>, + widened: &[(String, usize)], ) -> Result, FerroError> { // **A chain that cannot change a single byte of a single row does no heap work at all.** // @@ -960,6 +977,7 @@ fn prepare_rewrite( let mut header = [0u8; VERSION_HEADER_SIZE]; header.copy_from_slice(&tuple.data[..VERSION_HEADER_SIZE]); let was = tuple.data.len(); + let deleted = tuple.version_header()?.end_ts != 0; let mut values = tuple.deserialize(&shapes[0])?; let key = values.first().cloned(); // **D225 — every key's primary entry is asked the entry bound here, while nothing has been @@ -968,15 +986,16 @@ fn prepare_rewrite( // known until the heap is written, so every key is asked, and a refusal leaves the table // exactly as it was. This build cannot write such a key; an earlier build could. if let Some(k) = &key { - admit_entry(k, &RecordId::new(0, 0)).map_err(|e| { + if let Some(len) = entry_over_bound(k, &RecordId::new(0, 0)) { let shown: String = format!("{k:?}").chars().take(60).collect(); - FerroError::Constraint(format!( + return Err(FerroError::Constraint(format!( "this ALTER would re-point the primary-index entry of the row whose first \ - column is {shown}, and that entry is over the B+tree entry bound ({e}). A \ - build before D225 could store such a key; this one cannot re-point it. \ - Nothing has been written. {LEGACY_ENTRY_REMEDY} Then run the ALTER again." - )) - })?; + column is {shown}: {}. A build before D225 could store such a key; this one \ + cannot re-point it. Nothing has been written. {OPEN_TABLE_REMEDY} Then run \ + the ALTER again.", + entry_too_large(len) + ))); + } } let mut converted: Option = None; @@ -999,6 +1018,25 @@ fn prepare_rewrite( if over { continue; } + // **D225, review 6 H1 — every entry the chain widens is asked the bound here, while + // nothing has been written.** Crash recovery rebuilds each secondary index from the heap in + // the new shape and refuses an entry over `MAX_ENTRY_BYTES`, so a widening that pushed one + // over would leave a database this build wrote and its own recovery will not open. Every + // tuple is asked, deleted ones too, because the rebuild indexes every tuple the heap holds. + if let Some(k) = &key { + for (column, p) in widened { + if let Some(len) = entry_over_bound(&(values[*p].clone(), k.clone()), &()) { + let shown: String = format!("{k:?}").chars().take(60).collect(); + let state = if deleted { "a deleted row, whose tuple stays" } else { "a live row" }; + return Err(FerroError::Constraint(format!( + "this ALTER would widen the entry of the row whose first column is {shown} \ + ({state}) in the index on '{table}.{column}': {}. Nothing has been \ + written. {OPEN_TABLE_REMEDY} Then run the ALTER again.", + entry_too_large(len) + ))); + } + } + } let mut converted = converted.ok_or_else(|| { FerroError::Internal(format!("a rewrite of '{table}' converted a row through no shape")) })?; diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index d600a0e2..5744ba18 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -8,7 +8,8 @@ use crate::storage::heap_file_manager::HeapFileManager; use crate::storage::index::BPlusTreeManager; use crate::storage::heap_file_manager::RecordId; use crate::catalog::column::{DataType, Value}; -use crate::storage::index_fulltext::{indexed_text, post_tokens}; +use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; +use crate::storage::index_page::{entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; use std::sync::atomic::{AtomicU32, Ordering}; use crate::catalog::schema::Schema; @@ -208,6 +209,33 @@ impl Catalog { self.get_table(name).ok_or_else(|| self.unknown_table(name)) } + /// The refusal of an index whose backfill meets an entry over `MAX_ENTRY_BYTES`. D225, review 6 F2. + /// + /// It names the row and says whether its tuple is deleted, because that decides the remedy. A + /// live row whose value is the cause can be UPDATEd to fit. A deleted one cannot be touched: its + /// tuple is never purged (there is no VACUUM), so the refusal repeats until the table is copied + /// and dropped, `index_page::OPEN_TABLE_REMEDY`. That residual is the no-purge root cause, and + /// no index-side change removes it. + fn backfill_refusal(table: &str, column: &str, key: &Value, deleted: bool, len: usize) -> FerroError { + let shown: String = format!("{key:?}").chars().take(60).collect(); + let advice = if deleted { + format!( + "The row is deleted and its tuple stays: nothing purges a deleted row, so this \ + refusal repeats. {OPEN_TABLE_REMEDY}" + ) + } else { + format!( + "The row is live. If its indexed value is the cause, UPDATE it to fit; if its key \ + is: {OPEN_TABLE_REMEDY}" + ) + }; + FerroError::Constraint(format!( + "cannot build the index on '{table}.{column}': the row whose first column is {shown} \ + makes {}. {advice}", + entry_too_large(len) + )) + } + /// Free an index tree whose backfill failed, and return the failure. D225. /// /// The tree is not in the catalog yet, so nothing else can hold a handle on it. If freeing it @@ -250,10 +278,18 @@ impl Catalog { let backfill = (|| -> Result<(), FerroError> { for item in hfm.scan() { let (_, tuple) = item?; + let deleted = tuple.version_header()?.end_ts != 0; let values = tuple.deserialize(&schema)?; let sec_value = values[col_index].clone(); let primary_key = values[0].clone(); // first column = primary key - sec_tree.insert((sec_value, primary_key), ())?; + // D225, review 6 F2: asked here, so the refusal can say which row and whether it + // is deleted, which the tree's own refusal cannot. Dead tuples are NOT skipped: an + // older snapshot or AS OF may still need their entries. + let entry = (sec_value, primary_key); + if let Some(len) = entry_over_bound(&entry, &()) { + return Err(Self::backfill_refusal(table, column, &entry.1, deleted, len)); + } + sec_tree.insert(entry, ())?; } Ok(()) })(); @@ -329,9 +365,16 @@ impl Catalog { let backfill = (|| -> Result<(), FerroError> { for item in hfm.scan() { let (_, tuple) = item?; + let deleted = tuple.version_header()?.end_ts != 0; let values = tuple.deserialize(&schema)?; let primary_key = values[0].clone(); // first column = primary key if let Some(text) = indexed_text(&values[col_index])? { + // D225, review 6 F2: every posting asked first, as in `create_index`. + for token in distinct_tokens(text) { + if let Some(len) = entry_over_bound(&posting_key(&token, &primary_key), &()) { + return Err(Self::backfill_refusal(table, column, &primary_key, deleted, len)); + } + } post_tokens(&ft_tree, text, &primary_key)?; } } diff --git a/src/execution/update.rs b/src/execution/update.rs index a4d367a2..a7b21b38 100644 --- a/src/execution/update.rs +++ b/src/execution/update.rs @@ -13,7 +13,7 @@ use crate::storage::heap_file_manager::RecordId; use crate::catalog::column::Value; use crate::execution::index_handle::{FullTextHandle, IndexHandle}; use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; -use crate::storage::index_page::{admit_entry, LEGACY_ENTRY_REMEDY}; +use crate::storage::index_page::{admit_entry, entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; use crate::provenance::{ProvId, ProvenanceStore}; pub struct Update { @@ -50,7 +50,12 @@ impl Modify for Update { // vacated and the new one being claimed - and this executor does neither. Refusing is the // correct trade. Refusing without saying what to do instead is not, especially now that // there IS something to do: as of E63 a deleted key can be used again, so DELETE-then-INSERT - // is a real remedy rather than advice that would have failed. + // works rather than being advice that would have failed. + // + // **With one caveat (D225, review 6):** the deleted tuple is never purged. Every entry an + // index over its values would need, including a CREATE INDEX added later, is still asked of + // it, so a row whose value is too long for an index entry leaves a tuple that refuses that + // index for good. The remedy then is the table copy, `index_page::OPEN_TABLE_REMEDY`. if let Some((col, _)) = self.assignments.iter().find(|(col, _)| *col == 0) { return Err(FerroError::Constraint(format!( "column '{}' of '{}' is the primary key and cannot be updated: moving a key means \ @@ -102,15 +107,15 @@ impl Modify for Update { // cannot write a key over the bound; an earlier build could. The refusal names the one // remedy that works for a key (review 5): a primary key cannot be UPDATEd, and DELETE // then INSERT would leave the deleted tuple under the key for good. - admit_entry(pk, &RecordId::new(0, 0)).map_err(|e| { + if let Some(len) = entry_over_bound(pk, &RecordId::new(0, 0)) { let shown: String = format!("{pk:?}").chars().take(60).collect(); - FerroError::Constraint(format!( + return Err(FerroError::Constraint(format!( "this UPDATE would re-point the primary-index entry of the row whose key is \ - {shown}, and that entry is over the B+tree entry bound ({e}). A build before \ - D225 could store such a key; this one cannot re-point it. Nothing has been \ - written. {LEGACY_ENTRY_REMEDY}" - )) - })?; + {shown}: {}. A build before D225 could store such a key; this one cannot \ + re-point it. Nothing has been written. {OPEN_TABLE_REMEDY}", + entry_too_large(len) + ))); + } for handle in &self.secondary_indexes { let new_v = &new_values[handle.col_index]; if &old_values[handle.col_index] != new_v { diff --git a/src/storage/index_page.rs b/src/storage/index_page.rs index 4ab5fe0b..2c37ca4a 100644 --- a/src/storage/index_page.rs +++ b/src/storage/index_page.rs @@ -95,11 +95,12 @@ const LEAF_HEADER_SIZE: usize = 27; /// pre-pass), because either may have to re-point the key's entry; /// - a branch whose stored capability envelope is over the bound: rewrites of its record that /// leave the envelope unchanged go through (`TableBranchCatalog::envelope_write`), so its lease -/// can be renewed and it can be reaped; a CHANGED envelope is refused, which includes -/// `charge_row_writes` and `restrict_envelope`; +/// can be renewed and it can be reaped; a CHANGED envelope over the bound is refused, which +/// includes `charge_row_writes` and a `restrict_envelope` that leaves it over the bound; /// - such a branch cannot be **forked**: the child inherits the envelope at the same encoded size /// (`CapabilityEnvelope::inherited` changes only fixed-width counters), so `write_record_new` -/// refuses it, and on a recycled id `envelope_write` does; +/// refuses it, and on a recycled id `envelope_write` does, unless the reaped occupant's bytes +/// happen to equal the child's, when the fork succeeds harmlessly; /// - a branch-catalog leaf left exactly full around an over-bound entry refuses **mid-record**: /// `write_record` removes the branch's old state and deadline keys before it upserts the new /// ones, so a no-cut refusal there leaves the branch in neither span. D202's undo does not cover @@ -108,22 +109,44 @@ const LEAF_HEADER_SIZE: usize = 27; /// through `migrate_from`, whose `write_record` puts the envelope into a fresh tree and is /// refused. /// -/// **The remedy, for the rows of a table: [`LEGACY_ENTRY_REMEDY`].** It is the only one that -/// works. A deleted row cannot take any other: DELETE stamps the tuple and leaves it, nothing in -/// this build purges it, and a deleted row can be neither updated nor deleted again, so it is -/// scanned, and refused, until its table is dropped. That makes DELETE followed by INSERT a -/// remedy that CREATES the problem for a key. The branch-catalog cases above have no remedy in -/// this build. +/// **The remedies.** For the rows of a table on a database this build has open, [`OPEN_TABLE_REMEDY`]; +/// for one crash recovery refuses to open, [`RECOVERY_REMEDY`]. There is no other, because a +/// deleted row can take nothing in place: DELETE stamps the tuple and leaves it, nothing in this +/// build purges it (there is no VACUUM), and a deleted row can be neither updated nor deleted +/// again, so it is scanned, and refused, until its table is dropped. DELETE followed by INSERT +/// therefore CREATES the problem for a key. For the envelope cases the remedy is +/// `restrict_envelope` to an envelope below the bound, which is admitted; after it, charges and +/// forks work again. The mid-record and migration cases have none in this build. +/// +/// **This build writes nothing over the bound itself**, and that includes an ALTER that widens an +/// indexed column: `catalog::alter::prepare_rewrite` asks the bound of every widened secondary +/// entry (review 6 H1). **One residual is not a bound failure but its root cause:** a deleted +/// tuple is never purged, so a deleted row whose value is too long for an index entry refuses +/// every CREATE INDEX on that column, and every retype of an indexed column it sits in, until the +/// table is copied and dropped. The refusals name that; they cannot cure it. pub const MAX_ENTRY_BYTES: usize = (PAGE_SIZE - LEAF_HEADER_SIZE) / 2; -/// What a refusal of a legacy entry over [`MAX_ENTRY_BYTES`] tells the user to do. D225, review 5. +/// What a refusal of an entry over [`MAX_ENTRY_BYTES`] tells the user to do on a database THIS +/// build has open: the ALTER, UPDATE and CREATE INDEX refusals. D225, reviews 5 and 6. /// -/// One text, so the ALTER, UPDATE and crash-recovery refusals cannot drift apart, and none of them -/// can suggest the DELETE + INSERT that leaves a permanent tuple under the key. -pub const LEGACY_ENTRY_REMEDY: &str = "The one remedy is to use the build that wrote it: copy the \ - table's live rows into a new table, shortening any key or indexed value over the bound as they \ - are copied, and DROP the old table. This build has no in-place remedy: a deleted row's tuple is \ - never purged, so deleting the row leaves its entry behind for good, and a primary key cannot be \ +/// Every step is one this build can take. SELECT reads the rows, an INSERT under a key and values +/// that fit is admitted, and `drop_table` frees the trees without asking the bound. It spells out +/// the two steps this SQL surface lacks (INSERT … SELECT and RENAME TABLE). It never suggests +/// DELETE followed by INSERT, which leaves the deleted tuple behind for good. +pub const OPEN_TABLE_REMEDY: &str = "With this build: copy the table's live rows into a new table, \ + each under a key and values whose index entries fit the bound (there is no INSERT ... SELECT, \ + so SELECT the rows and INSERT each one), DROP the old table, then CREATE it again under its old \ + name and copy the rows back (there is no RENAME TABLE). There is no in-place remedy: a deleted \ + row's tuple is never purged (there is no VACUUM), so it keeps its entries until its table is \ + dropped, and a primary key cannot be updated."; + +/// What the crash-recovery refusal tells the user to do: the same steps, with a build that can +/// still open the database, because this one refuses to. D225, reviews 5 and 6. +pub const RECOVERY_REMEDY: &str = "This build cannot open the database until that is repaired. \ + With a build before D225: copy the table's live rows into a new table, each under a key and \ + values whose index entries fit the bound, DROP the old table, then CREATE it again under its \ + old name and copy the rows back. There is no in-place remedy: a deleted row's tuple is never \ + purged, so it keeps its entries until its table is dropped, and a primary key cannot be \ updated."; /// Bytes `t` occupies on a page. One definition, so the split, the fullness test and the entry @@ -134,6 +157,23 @@ pub fn serialized_len(t: &T) -> usize { buf.len() } +/// The size of an entry that is over [`MAX_ENTRY_BYTES`], or `None` when it is admitted. The +/// measurement behind [`admit_entry`], for refusals that give their own remedy. D225, review 6. +pub fn entry_over_bound(key: &K, value: &V) -> Option { + let len = serialized_len(key) + serialized_len(value); + (len > MAX_ENTRY_BYTES).then_some(len) +} + +/// The named part of every entry-bound refusal, and no remedy: the wrappers that carry +/// [`OPEN_TABLE_REMEDY`] or [`RECOVERY_REMEDY`] must not also say "shorten the indexed value", +/// which [`admit_entry`]'s own message does. D225, review 6. +pub fn entry_too_large(len: usize) -> String { + format!( + "index entry too large: {len} bytes (key and value as stored), over the B+tree entry \ + limit MAX_ENTRY_BYTES = {MAX_ENTRY_BYTES}" + ) +} + /// Refuse an entry larger than [`MAX_ENTRY_BYTES`], naming the limit. D225. /// /// `BPlusTreeManager::insert` and `upsert` call this before they latch or write anything. The DML @@ -145,8 +185,7 @@ pub fn serialized_len(t: &T) -> usize { /// exceeds maximum". It reaches a client as a statement error (`23000` in `pgwire::sqlstate_of`), /// not as an internal one, because the fix is to shorten the value. pub fn admit_entry(key: &K, value: &V) -> Result<(), FerroError> { - let len = serialized_len(key) + serialized_len(value); - if len > MAX_ENTRY_BYTES { + if let Some(len) = entry_over_bound(key, value) { return Err(FerroError::Constraint(format!( "index entry too large: {len} bytes (key and value as stored) does not fit under the \ B+tree entry limit MAX_ENTRY_BYTES = {MAX_ENTRY_BYTES}, the largest size for which \ diff --git a/src/wal/recovery.rs b/src/wal/recovery.rs index 9be7d2f4..252062de 100644 --- a/src/wal/recovery.rs +++ b/src/wal/recovery.rs @@ -1,6 +1,6 @@ use std::{collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, atomic::Ordering}}; -use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}, index_page::{admit_entry, LEGACY_ENTRY_REMEDY}, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; +use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}, index_page::{entry_over_bound, entry_too_large, RECOVERY_REMEDY}, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; pub fn recover(txn: &TxnManager) -> Result { let wal = &txn.wal; @@ -179,6 +179,10 @@ fn redo_one(bp: &Arc, lsn: u64, kind: &RecKind) -> Result<(), /// /// Every write path refuses an entry over `MAX_ENTRY_BYTES`, so this build cannot commit such a /// row, but a build before D225 could: its count split admitted any entry that happened to fit. +/// "Every write path" includes an ALTER that widens an indexed column, whose rewrite writes no +/// index entry at all and would otherwise leave the heap holding values the rebuild widens: its +/// `prepare_rewrite` asks the bound of every widened secondary entry first (review 6 H1; before +/// that, this premise was false). /// The rebuild below frees each old tree before refilling a fresh one, table by table, and the /// catalog is persisted only at the end. A refusal part-way would leave the in-memory catalog /// naming new trees, the persisted one naming freed pages, and the next open freeing them again. @@ -186,9 +190,11 @@ fn redo_one(bp: &Arc, lsn: u64, kind: &RecKind) -> Result<(), /// refusal leaves the database exactly as recovery found it. The cost is a second heap scan, at /// recovery only. /// -/// The database then does not open until the row is shortened or deleted, which has to happen -/// through the build that wrote it. That is deliberate: the alternative is admitting an entry for -/// which a leaf split is not guaranteed to exist. +/// The database then does not open in this build until the table is repaired with a build that +/// can still open it, `index_page::RECOVERY_REMEDY`. Deleting the row is not a repair: a deleted +/// tuple is never purged, and this check reads every tuple the heap holds, as the rebuild does. +/// That is deliberate: the alternative is admitting an entry for which a leaf split is not +/// guaranteed to exist. fn refuse_rows_no_rebuilt_tree_admits(catalog: &Catalog, bp: &Arc, names: &[String]) -> Result<(), FerroError> { for name in names { let entry = catalog.tables.get(name).expect("name came from this map"); @@ -201,25 +207,32 @@ fn refuse_rows_no_rebuilt_tree_admits(catalog: &Catalog, bp: &Arc Vec> { + match self.sql(sql) { + Outcome::Rows(r) => r, + other => panic!("expected rows from `{}`, got {:?}", abbreviate(sql), std::mem::discriminant(&other)), + } + } + /// The plan text `EXPLAIN` gives for `sql`. fn plan(&mut self, sql: &str) -> String { match self.sql(&format!("EXPLAIN {sql}")) { @@ -479,10 +487,13 @@ fn a_multi_row_update_is_refused_before_its_first_row_is_written() { let plan = d.plan("SELECT n FROM u WHERE id = 'a';"); assert!(plan.contains("Index scan on u (col 0"), "premise failed: the key lookup is not a primary index scan: {plan}"); - // Review 4 R4, allocator probe: the refused statement must write nothing. Under a late - // refusal the first row is relocated to a new heap page and its old version goes to the - // time-travel heap before the refusal, and the abort gives neither page back, so the file's - // high-water mark rises. D202's undo does not change that, so this holds on main (INFERRED). + // Review 5 R4, allocator probe: the refused statement must write nothing. Under a late + // refusal the first row's write begins with its old version going to the time-travel heap, + // whose first data page is allocated then, before and independently of the relocation, which + // allocates a second page. The abort gives neither page back, and D202's undo frees none, so + // the file's high-water mark rises on d225 alone and on main alike (INFERRED; review 6 §3). + // Blind spot: allocation is first-fit, so a free hole below the mark would take one allocation + // unseen. These fixtures free no page before the probe, so they have none. let pages_before = d.bp.disk_manager.high_water().unwrap(); let e = d.err(&format!("UPDATE u SET v = '{}' WHERE n < 3;", "z".repeat(2020))); assert!(e.contains("index entry too large: 2046 bytes"), "not the named refusal: {}", abbreviate(&e)); @@ -514,10 +525,13 @@ fn a_multi_row_update_refused_for_not_null_writes_no_row_first() { let plan = d.plan("SELECT n FROM u WHERE id = 'a';"); assert!(plan.contains("Index scan on u (col 0"), "premise failed: the key lookup is not a primary index scan: {plan}"); - // Review 4 R4, allocator probe: the refused statement must write nothing. Under a late - // refusal the first row is relocated to a new heap page and its old version goes to the - // time-travel heap before the refusal, and the abort gives neither page back, so the file's - // high-water mark rises. D202's undo does not change that, so this holds on main (INFERRED). + // Review 5 R4, allocator probe: the refused statement must write nothing. Under a late + // refusal the first row's write begins with its old version going to the time-travel heap, + // whose first data page is allocated then, before and independently of the relocation, which + // allocates a second page. The abort gives neither page back, and D202's undo frees none, so + // the file's high-water mark rises on d225 alone and on main alike (INFERRED; review 6 §3). + // Blind spot: allocation is first-fit, so a free hole below the mark would take one allocation + // unseen. These fixtures free no page before the probe, so they have none. let pages_before = d.bp.disk_manager.high_water().unwrap(); let e = d.err("UPDATE u SET a = b WHERE n < 3;"); assert!(e.contains("is declared NOT NULL"), "not the NOT NULL refusal: {}", abbreviate(&e)); @@ -647,6 +661,8 @@ fn an_alter_that_would_repoint_a_legacy_oversized_key_is_refused_before_any_row_ ); // Review 5: the one remedy that works, also for a deleted row. assert!(e.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {e}"); + // Review 6 F1: and no in-place advice, which a legacy key cannot take. + assert!(!e.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {e}"); assert_eq!( d.catalog.get_table("k").unwrap().schema.columns[1].data_type, DataType::Integer, @@ -672,7 +688,11 @@ fn an_update_of_a_row_under_a_legacy_oversized_key_is_refused_before_it_is_writt // Review 5: a key cannot be UPDATEd, and DELETE then INSERT would leave the deleted tuple // under the key for good, so the message names the one remedy and never suggests that. assert!(e.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {e}"); + // Case-sensitive on purpose (review 6 F8): the remedy itself says a "deleted" row's tuple is + // never purged. What must not appear is the statement DELETE offered as advice. A lowercase + // "delete the row" suggestion would pass this guard; the text is one constant, reviewed. assert!(!e.contains("DELETE"), "the refusal suggests DELETE, which creates the problem for a key: {e}"); + assert!(!e.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {e}"); assert_eq!(d.ids("SELECT n FROM k;"), vec![1, 2], "the refused UPDATE changed a row"); } @@ -761,10 +781,13 @@ fn an_update_whose_new_posting_is_over_the_bound_is_refused_before_the_row_moves let plan = d.plan(&format!("SELECT n FROM g WHERE id = '{pk}';")); assert!(plan.contains("Index scan on g (col 0"), "premise failed: the key lookup is not a primary index scan: {}", abbreviate(&plan)); - // Review 4 R4, allocator probe: the refused statement must write nothing. Under a late - // refusal the first row is relocated to a new heap page and its old version goes to the - // time-travel heap before the refusal, and the abort gives neither page back, so the file's - // high-water mark rises. D202's undo does not change that, so this holds on main (INFERRED). + // Review 5 R4, allocator probe: the refused statement must write nothing. Under a late + // refusal the first row's write begins with its old version going to the time-travel heap, + // whose first data page is allocated then, before and independently of the relocation, which + // allocates a second page. The abort gives neither page back, and D202's undo frees none, so + // the file's high-water mark rises on d225 alone and on main alike (INFERRED; review 6 §3). + // Blind spot: allocation is first-fit, so a free hole below the mark would take one allocation + // unseen. These fixtures free no page before the probe, so they have none. let pages_before = d.bp.disk_manager.high_water().unwrap(); let e = d.err(&format!("UPDATE g SET body = '{}' WHERE n = 1;", "y".repeat(255))); assert!(e.contains("index entry too large: 2061 bytes"), "not the named refusal: {}", abbreviate(&e)); @@ -796,6 +819,7 @@ fn a_rebuild_over_a_legacy_oversized_primary_key_is_refused_before_anything_is_f abbreviate(&msg) ); assert!(msg.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {msg}"); + assert!(!msg.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {msg}"); assert_eq!(d.catalog.get_table("k").unwrap().primary_index_root, root, "the primary tree was rebuilt"); let primary = BPlusTreeManager::::open(root, d.bp.clone()); assert!(primary.search(&Value::Varchar("a".into())).unwrap().is_some(), "the old primary tree lost key 'a'"); @@ -839,3 +863,104 @@ fn a_rebuild_over_a_legacy_oversized_posting_is_refused_before_anything_is_freed let next = d.bp.new_page().unwrap(); assert!(next != primary_root && next != posting_root, "page {next}, an old root, came back from the allocator"); } + +// ---- Review 6 (PREREG amendment 3): an ALTER that widens an indexed column; a backfill over a +// ---- deleted row ---------------------------------------------------------------------------------- + +/// **H1 — an ALTER that widens an indexed column past the bound is refused before any row moves.** +/// +/// `n INTEGER` is indexed and the key is 2023 characters. INSERT admits every entry: the primary +/// is (3 + 2023) + 6 = 2032 and the secondary 5 + 2026 = 2031. `TYPE BIGINT` turns the secondary +/// entry into 9 + 2026 = 2035. The rewrite itself writes no index entry, so before the fix it was +/// accepted, and the next crash recovery rebuilt the index from the heap, refused 2035, and did not +/// open: a database this build wrote. Red at `ef64ffd`: "was accepted; it must be refused". +#[test] +fn an_alter_that_widens_an_indexed_column_past_the_bound_is_refused_before_any_row_moves() { + let mut d = db(); + d.sql("CREATE TABLE t (id VARCHAR(3000) NOT NULL, n INTEGER);"); + d.sql("CREATE INDEX i ON t (n);"); + d.sql(&format!("INSERT INTO t VALUES ('{}', 7);", "p".repeat(2023))); + + let e = d.err("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"); + assert!( + e.contains("this ALTER would widen") && e.contains("index entry too large: 2035 bytes"), + "not the named refusal: {}", + abbreviate(&e) + ); + assert!(e.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {e}"); + assert!(!e.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {e}"); + assert_eq!(d.catalog.get_table("t").unwrap().schema.columns[1].data_type, DataType::Integer, "the refused ALTER installed the new shape"); + assert_eq!(d.ids("SELECT n FROM t;"), vec![7], "the refused ALTER changed the row"); +} + +/// **H1's control — widening to exactly the bound is accepted, and the database it writes reopens.** +/// The key is 2022 characters: 9 + 2025 = 2034. The ALTER goes through, and the crash-recovery +/// rebuild, which widens the secondary entry from the heap, admits it. +#[test] +fn an_alter_that_widens_an_indexed_column_to_exactly_the_bound_is_accepted_and_recovers() { + let mut d = db(); + d.sql("CREATE TABLE t (id VARCHAR(3000) NOT NULL, n INTEGER);"); + d.sql("CREATE INDEX i ON t (n);"); + d.sql(&format!("INSERT INTO t VALUES ('{}', 7);", "p".repeat(2022))); + + d.sql("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"); + assert_eq!(d.catalog.get_table("t").unwrap().schema.columns[1].data_type, DataType::BigInt); + assert_eq!(d.rows("SELECT n FROM t;"), vec![vec![Value::BigInt(7)]]); + rebuild_indexes(&mut d.catalog, &d.bp).expect("the database this build wrote must reopen"); +} + +/// **F2 — CREATE INDEX over a deleted row whose value is too long names the table copy, and the +/// copy works in this build.** +/// +/// `v` is unindexed, so INSERT admits no entry for it. `(1, 'x' x 2100)` is inserted and deleted; +/// DELETE stamps the tuple and leaves it, and nothing purges it. The backfill must index every +/// tuple the heap holds, because an older snapshot may need a deleted one, so `('x' x 2100, 1)`, +/// (3 + 2100) + 5 = 2108 bytes, refuses the index, on every retry. The refusal says the row is +/// deleted and names the copy. Then the copy is carried out, as the refusal words it, with this +/// build: SELECT the live rows, INSERT each into a new table, DROP the old one, CREATE it again, +/// copy back. After that the index builds. Red at `ef64ffd` on the message (the tree's own text). +#[test] +fn a_create_index_over_a_deleted_long_row_names_the_table_copy_and_the_copy_works() { + let mut d = db(); + let shape = "(id INTEGER NOT NULL, v VARCHAR(3000))"; + d.sql(&format!("CREATE TABLE t {shape};")); + d.sql(&format!("INSERT INTO t VALUES (1, '{}');", "x".repeat(2100))); + d.sql("INSERT INTO t VALUES (2, 'kept');"); + d.sql("DELETE FROM t WHERE id = 1;"); + + let e = d.err("CREATE INDEX iv ON t (v);"); + assert!( + e.contains("index entry too large: 2108 bytes") + && e.contains("deleted") + && e.contains("copy the table's live rows into a new table") + && !e.to_lowercase().contains("shorten"), + "not the named backfill refusal: {}", + abbreviate(&e) + ); + let again = d.err("CREATE INDEX iv ON t (v);"); + assert!(again.contains("index entry too large: 2108 bytes"), "the tombstone stopped refusing: {}", abbreviate(&again)); + + // The remedy, step by step, in this build. + let live = d.rows("SELECT id, v FROM t;"); + assert_eq!(live, vec![vec![Value::Integer(2), Value::Varchar("kept".into())]], "premise: one live row"); + let insert_all = |d: &mut Db, table: &str, rows: &[Vec]| { + for r in rows { + match (&r[0], &r[1]) { + (Value::Integer(id), Value::Varchar(v)) => { + d.sql(&format!("INSERT INTO {table} VALUES ({id}, '{v}');")); + } + other => panic!("unexpected row shape {other:?}"), + } + } + }; + d.sql(&format!("CREATE TABLE t_copy {shape};")); + insert_all(&mut d, "t_copy", &live); + d.sql("DROP TABLE t;"); + d.sql(&format!("CREATE TABLE t {shape};")); + let copied = d.rows("SELECT id, v FROM t_copy;"); + insert_all(&mut d, "t", &copied); + d.sql("DROP TABLE t_copy;"); + + d.sql("CREATE INDEX iv ON t (v);"); + assert_eq!(d.ids("SELECT id FROM t WHERE v = 'kept';"), vec![2], "the rebuilt table lost its row"); +} From 47df5f817975d4e84a3e0ea7edaa384d612ec69d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:10:00 -0400 Subject: [PATCH 242/315] UNBUILT D231: review 4 (N1-N7, H1-H2) -- unknown for a foreign repo, hidden index bits, failed status; steps 26-32 Implements PREREG amendments 2 and 2a (cb3787e, 35f42a3), committed first. build.rs - N1: the stamp is `unknown` unless `git rev-parse --show-toplevel` is the package root (both canonicalised). A `git archive` copy inside another checkout was stamped with that checkout's HEAD, and could read clean. - N2: `unknown` when `core.ignoreStat` is true or any `ls-files -v` tag is lowercase (assume-unchanged) or `S` (skip-worktree); an index that cannot be listed counts as hiding. - N4: a failed `git status` stamps `unknown` (it used to keep the sha, dirty). - N6: the sha is read before and after `status`; a HEAD that moved in between stamps `unknown`. - N7: `core.preferSymlinkRefs` recorded as a known limit; the compile window (an edit after status, before rustc) stated. - The doc gains a section listing every case that stamps `unknown`. bench/d231_stamp_check.sh - Steps 26-32: an unrelated packed ref deleted (must NOT re-run), a three-link chain and its third link moved, `git status` failing via status.aheadBehind=bogus (then recovering), an assume-unchanged edit, and a `git archive` export built inside the checkout's ignored target/. Each step asserts its premise or exits 2. 64 verdicts. - H1: every harness `git status` goes through `st`/`st_path`, which exit 2 on a git error instead of reading its empty output as clean. - H2: step 25's write-tree runs on a copy of the index; the header's claim is scoped to what the harness asks versus what a step changes. - The build script's output is tracked per package (step 32 is another one). UNBUILT: quiet mode, no cargo was run. --- bench/d231_stamp_check.sh | 169 +++++++++++++++++++++++++++++++------- build.rs | 101 +++++++++++++++++++---- 2 files changed, 227 insertions(+), 43 deletions(-) diff --git a/bench/d231_stamp_check.sh b/bench/d231_stamp_check.sh index 399e2b37..cb4bfb69 100755 --- a/bench/d231_stamp_check.sh +++ b/bench/d231_stamp_check.sh @@ -4,8 +4,8 @@ # FAN WORK: this builds ferrodb (twice from scratch, then incrementally). Queue it; never run it # under quiet mode. Run it once per commit under test, the D231 base and the D231 tip: # -# bench/d231_stamp_check.sh 9aa6968 # expected RED: 27-35 PASS, exit 1 -# bench/d231_stamp_check.sh # expected GREEN: 50 PASS, exit 0 +# bench/d231_stamp_check.sh 9aa6968 # expected RED: 32-41 PASS, exit 1 +# bench/d231_stamp_check.sh # expected GREEN: 64 PASS, exit 0 # # THE PRE-REGISTRATION IS bench/d231_PREREG.md (its latest amendment): the per-step outcome at both # commits, which base steps may flip with timing and in which direction, the mutant kill map, and @@ -25,7 +25,8 @@ # rerun=yes|no whether this build rewrote the build script's `output` file, i.e. re-stamped; # lib_fresh cargo's own `fresh` flag for the ferrodb lib, from --message-format=json. # A step PASSES only if the printed stamp AND `rerun` are both what the step expects, and, for -# steps 5, 7, 23 and 24, the files git touched are exactly the ones the step exists to isolate. +# steps 5, 7, 23, 24, 26 and 28, the files git touched are exactly the ones the step exists to +# isolate. # Every build gets one line: # D231 commit= arm= step= expect="" got="" # stamp_ok= expect_rerun= rerun= [witness...] lib_fresh= @@ -39,8 +40,10 @@ # checkout, `reset --hard`, `checkout --`) makes that write-back produce an index with nothing # racy, so exactly ONE later build shows it, the SETTLE step, and the step after that starts # clean at both commits. The tip never writes the index, so the sleeps change nothing there. -# The harness's own `git status` calls all pass `--no-optional-locks`, so it never writes the -# index either. +# The harness's own read-only questions never write the index either: its `git status` calls pass +# `--no-optional-locks`, and step 25's `write-tree`, which rewrites the index it reads even with +# that flag (review 4, MEASURED), runs on a COPY of the index. The index is written only by the +# git operations that ARE a step's change (add, commit, reset, checkout, update-index). # # STEPS, per arm. Q0 is a second commit with S0's exact tree; foreign.git's HEAD is ^; # $first is the arm's first branch (d231-probe, or d231-probe-linked). @@ -77,12 +80,26 @@ # 25 replace `git replace Q0 X`, X = a commit of the current # index tree (so honouring the replacement reads # clean), index touched; `replace -d` after at Q0 +DIRTY yes +# 26 unrelated-packed-ref an UNRELATED packed ref deleted (packed-refs +# rewritten); witness: only packed-refs moved at Q0 +DIRTY NO +# 27 chain-three-links symref link2 -> link; HEAD -> link2 at Q0 +DIRTY yes +# 28 third-link `git update-ref $first S0`: the chain's THIRD +# link; witness: only $first's file moved at S0 +DIRTY yes +# 29 status-fails status.aheadBehind=bogus (fails `git status` +# only), index touched; unset after at unknown +DIRTY yes +# 30 status-recovers index touched at S0 +DIRTY yes +# 31 assume-unchanged src/lib.rs edited, then marked assume-unchanged +# (status hides it); cleared and restored after at unknown +DIRTY yes +# 32 export-inside- `git archive S0` into the checkout's ignored +# checkout target/d231-export/, built there at unknown +DIRTY yes # # WHICH STEP KILLS WHICH MUTANT OF build.rs (each removal fails at least the steps named): # HEAD watch 7, 22 · index watch 6, 19, 25 · every link 5, 23, 24 · links past the first # (`links.len() < 1`) 24 · a recursive `symbolic-ref -q HEAD` 23 · src/ 3 · examples/ 10 · # tests/ 13 · Cargo.toml 16 · INPUTS = ["src","benches","build.rs"] 10, 13, 16 · env clearing 6 -# · --no-optional-locks 2, 9, 12, 15, 18, 21 · --no-replace-objects 25. +# · --no-optional-locks 2, 9, 12, 15, 18, 21 · --no-replace-objects 25 · packed-refs watched 26 +# · links.len() < 2 28 · a failed status keeping the sha 29 · no index-bit check 31 · no +# --show-toplevel check 32. # ⚠ SHARED INFERRED PREMISE: the Cargo.toml kill (step 16 at the tip) and base clone step 16's # FAIL both rest on cargo NOT re-running a rerun-if-changed script when only a comment is # appended to Cargo.toml. Base clone step 16 is its only measurement: if it comes out PASS with @@ -90,27 +107,33 @@ # NOT EXERCISED by any step: Cargo.lock (cargo may rewrite a lock it did not write); benches/ # (absent); the reftable directories (ferrodb uses the files store); the fallback for a git # without `--no-recurse`; the refusal when git does not answer one absolute path per question; -# the depth cap (git resolves 4 links and refuses 5; the walk collects at most 5). +# the depth cap (git resolves 4 links and refuses 5; the walk collects at most 5); the +# skip-worktree tag and core.ignoreStat (step 31 exercises assume-unchanged only); the N6 +# re-read of the sha (no step can move HEAD inside one script run); core.preferSymlinkRefs, a +# known limit (review 4, N7). # -# PRE-REGISTERED (bench/d231_PREREG.md, amendment 1): 2 arms x 25 steps = 50 verdicts. -# AT THE D231 TIP: 50 PASS, exit 0. No step depends on timing. -# AT 9aa6968: nominally 27 PASS / 23 FAIL, exit 1. -# clone PASS 1 4 7 8 11 14 17 19 20 22; FAIL 2 3 5 6 9 10 12 13 15 16 18 21 23 24 25 -# linked PASS 1 3 4 5 7 8 10 11 13 14 16 17 19 20 22 23 24; FAIL 2 6 9 12 15 18 21 25 +# PRE-REGISTERED (bench/d231_PREREG.md, amendments 1, 2 and 2a): 2 arms x 32 steps = 64 verdicts. +# AT THE D231 TIP: 64 PASS, exit 0. No step depends on timing. +# AT 9aa6968: nominally 32 PASS / 32 FAIL, exit 1. +# clone PASS 1 4 7 8 11 14 17 19 20 22 27 30 +# FAIL 2 3 5 6 9 10 12 13 15 16 18 21 23 24 25 26 28 29 31 32 +# linked PASS 1 3 4 5 7 8 10 11 13 14 16 17 19 20 22 23 24 27 28 30 +# FAIL 2 6 9 12 15 18 21 25 26 29 31 32 # TIMING-DEPENDENT base steps, and the only flips allowed: # clone 2, 9, 12, 15, 18 FAIL->PASS with rerun=no, only if the preceding git operation's file # writes and index write straddled a second boundary; -# clone 5, 23, 24 FAIL->PASS with rerun=yes, only if the preceding builds took under +# clone 5, 23, 24, 28 FAIL->PASS with rerun=yes, only if the preceding builds took under # about 1 s (each recompiles the lib, so INFERRED improbable). -# So a base run gives PASS 27..35, FAIL 15..23, exit 1; any other deviation is a MISMATCH. +# So a base run gives PASS 32..41, FAIL 23..32, exit 1; any other deviation is a MISMATCH. # -# EXIT: 0 only if FAIL is 0 AND PASS is exactly 50; 1 any FAIL; 2 the harness could not measure: +# EXIT: 0 only if FAIL is 0 AND PASS is exactly 64; 1 any FAIL; 2 the harness could not measure: # a build failed; the probe printed something that is not a stamp; cargo emitted no # build-script-executed message for ferrodb (the Cargo book says it is emitted even when the # script does not run); the script's `output` was missing before or after a build; a witnessed # file was missing before or after its git operation; one of the harness's own edits did not take # (an append `git status` cannot see, a restore it still sees, a `touch` that did not move the -# mtime); a git operation or sha lookup failed; or other than 50 verdicts were reached. A run that +# mtime); a git operation, a `git status` or a sha lookup failed; a step's premise did not hold +# (steps 25, 26, 29, 31 and 32 assert theirs); or other than 64 verdicts were reached. A run that # collected nothing has not passed, so 2 is never folded into 0 or 1. # # EVIDENCE: every D231 line is also written to $WORK/d231-result-.txt, and each step's @@ -156,12 +179,13 @@ PARSE=$WORK/d231_parse.py RESULT=$WORK/d231-result-$SHA12.txt PASS=0 FAIL=0 -EXPECTED_VERDICTS=50 +EXPECTED_VERDICTS=64 # What build_provenance() prints: `at `, then ` +DIRTY (...)`. STAMP_RE='^at ([0-9a-f]{12,}|unknown)( \+DIRTY .*)?$' # Set by `witness`, consumed and cleared by the next `check`. WITNESS_NOTE="" WITNESS_OK=y +OUTKEY="" SNAP_F=() SNAP_M=() @@ -216,14 +240,34 @@ sha12() { # The harness's own edits, each checked to have taken before anything is built on it: an edit # that silently failed would otherwise be scored, and at the base a no-op edit reproduces exactly # the FAILs the PREREG predicts. -porcelain() { git -C "$1" --no-optional-locks status --porcelain --untracked-files=no -- "$2"; } +# st [git flag...] [-- ]: `git status --porcelain` into . A git error +# exits 2: a failing status prints nothing on stdout, and nothing reads exactly like "clean". +st() { + local var=$1 ck=$2 out + shift 2 + # `${1+"$@"}`: bash 3.2 calls an empty "$@" unbound under set -u. + out=$(git -C "$ck" --no-optional-locks ${1+"$@"} status --porcelain --untracked-files=no 2>/dev/null) \ + || harness "git status failed in $ck" + printf -v "$var" '%s' "$out" +} +# The same, for one path. +st_path() { + local var=$1 ck=$2 path=$3 out + out=$(git -C "$ck" --no-optional-locks status --porcelain --untracked-files=no -- "$path" 2>/dev/null) \ + || harness "git status -- $path failed in $ck" + printf -v "$var" '%s' "$out" +} append() { # append + local seen printf '%s' "$3" >> "$1/$2" || harness "could not append to $1/$2" - [ -n "$(porcelain "$1" "$2")" ] || harness "an append to $2 is not visible to git status" + st_path seen "$1" "$2" + [ -n "$seen" ] || harness "an append to $2 is not visible to git status" } restore() { # restore + local seen g "$1" checkout -q -- "$2" || harness "git checkout -- $2 failed" - [ -z "$(porcelain "$1" "$2")" ] || harness "$2 still differs after git checkout --" + st_path seen "$1" "$2" + [ -z "$seen" ] || harness "$2 still differs after git checkout --" } touch_moved() { # touch_moved ...: every file must exist and its mtime must move local f b a @@ -253,8 +297,11 @@ outfile_for() { cat "$WORK/outfile.$1" 2>/dev/null || echo "-"; } check() { local arm=$1 ck=$2 tgt=$3 step=$4 expect=$5 expect_rerun=$6 shift 6 - local of before json_line got after rerun fresh out_dir stamp_ok verdict wnote - of=$(outfile_for "$arm") + local of before json_line got after rerun fresh out_dir stamp_ok verdict wnote key + # The build script's output is tracked per package: step 32 builds a different one. + key=${OUTKEY:-$arm} + OUTKEY="" + of=$(outfile_for "$key") before=0 if [ "$of" != "-" ]; then mt before "$of" @@ -275,7 +322,7 @@ check() { # in this harness's reading, never a reason to reuse the last step's path. [ "$out_dir" != "-" ] || harness "cargo emitted no build-script-executed message for ferrodb, arm=$arm step=$step" of=$(dirname "$out_dir")/output - echo "$of" > "$WORK/outfile.$arm" + echo "$of" > "$WORK/outfile.$key" # stdout only: an exec error or a panic goes to stderr, and must not be read as a stamp. got=$("$tgt/debug/examples/d231_stamp_probe" | head -1) # Only a stamp is a measurement. Anything else would score as a FAIL, and a FAIL is what the @@ -463,16 +510,82 @@ for arm in clone linked; do # git that honours replacements reads the tree clean. Its parent is S0, not Q0, so the # replacement cannot form a cycle. Both premises are asserted before the build: honoured it # reads clean, and with --no-replace-objects it does not. - xtree=$(git -C "$ck" write-tree) || harness "git write-tree failed" + cp "$idx" "$WORK/index.copy" || harness "could not copy the index" + xtree=$(GIT_INDEX_FILE="$WORK/index.copy" git -C "$ck" write-tree) || harness "git write-tree failed" xcommit=$(g "$ck" commit-tree "$xtree" -p "$SHA" -m "d231 probe: replacement for Q0") || harness "commit-tree for the replacement failed" g "$ck" replace "$Q0FULL" "$xcommit" || harness "git replace failed" - [ -z "$(git -C "$ck" --no-optional-locks status --porcelain --untracked-files=no)" ] \ - || harness "with the replacement honoured the tree does not read clean; step 25 would not discriminate" - [ -n "$(git -C "$ck" --no-optional-locks --no-replace-objects status --porcelain --untracked-files=no)" ] \ - || harness "without the replacement the tree reads clean; step 25 would not discriminate" + st honoured "$ck" + [ -z "$honoured" ] || harness "with the replacement honoured the tree does not read clean; step 25 would not discriminate" + st unhonoured "$ck" --no-replace-objects + [ -n "$unhonoured" ] || harness "without the replacement the tree reads clean; step 25 would not discriminate" touch_moved "$idx" check "$arm" "$ck" "$tgt" 25-replace "at $Q0 +DIRTY" yes g "$ck" replace -d "$Q0FULL" > /dev/null || harness "git replace -d failed" + + # 26 (N3): an UNRELATED packed ref deleted. packed-refs is rewritten and nothing HEAD resolves + # through moves, so the stamp must NOT re-run: the reason build.rs does not watch packed-refs. + pr="$(git -C "$ck" rev-parse --path-format=absolute --git-common-dir)/packed-refs" || harness "no common dir" + victim=$(git -C "$ck" for-each-ref --format='%(refname)' refs/remotes/ | grep -v '/HEAD$' | head -1) + [ -n "$victim" ] || harness "no remote-tracking ref to delete" + grep -q " $victim\$" "$pr" || harness "$victim is not in packed-refs, so deleting it would not rewrite packed-refs" + snap "$idx" "$hf" "$lf" "$ff" "$pr" + g "$ck" update-ref -d "$victim" || harness "git update-ref -d $victim failed" + witness "no no no no yes" + check "$arm" "$ck" "$tgt" 26-unrelated-packed-ref "at $Q0 +DIRTY" no + + # 27: a three-link chain, HEAD -> link2 -> link -> $first. + link2="refs/heads/d231-link2-$arm" + g "$ck" symbolic-ref "$link2" "$link" || harness "could not create $link2" + g "$ck" symbolic-ref HEAD "$link2" || harness "could not point HEAD at $link2" + check "$arm" "$ck" "$tgt" 27-chain-three-links "at $Q0 +DIRTY" yes + l2f=$(gpath "$ck" "$link2") || harness "no path for $link2" + + # 28 (N5): move the THIRD link, $first itself, back to S0. Only $first's file moves. + snap "$idx" "$hf" "$l2f" "$lf" "$ff" + g "$ck" update-ref "$first" "$SHA" || harness "git update-ref $first failed" + witness "no no no no yes" + check "$arm" "$ck" "$tgt" 28-third-link "at $S0 +DIRTY" yes + + # 29 (N4): `git status` fails while every other question build.rs asks still works + # (PREREG amendment 2a: status.aheadBehind=bogus fails status, not ls-files or rev-parse). + g "$ck" config status.aheadBehind bogus || harness "could not set status.aheadBehind" + if git -C "$ck" --no-optional-locks status --porcelain --untracked-files=no > /dev/null 2>&1; then + harness "git status still succeeds with status.aheadBehind=bogus; step 29 would not discriminate" + fi + git -C "$ck" --no-optional-locks ls-files -v > /dev/null 2>&1 \ + || harness "ls-files fails too with status.aheadBehind=bogus; step 29 would not isolate the status rule" + touch_moved "$idx" + check "$arm" "$ck" "$tgt" 29-status-fails "at unknown +DIRTY" yes + g "$ck" config --unset status.aheadBehind || harness "could not unset status.aheadBehind" + + # 30: status works again, and the `unknown` does not stick. + touch_moved "$idx" + check "$arm" "$ck" "$tgt" 30-status-recovers "at $S0 +DIRTY" yes + + # 31 (N2): an edit hidden from `git status` by the assume-unchanged bit. + append "$ck" src/lib.rs $'\n// d231 probe edit (assume-unchanged)\n' + g "$ck" update-index --assume-unchanged src/lib.rs || harness "git update-index --assume-unchanged failed" + tag=$(git -C "$ck" --no-optional-locks ls-files -v -- src/lib.rs) || harness "git ls-files -v failed" + case "$tag" in "h "*) ;; *) harness "src/lib.rs is not tagged assume-unchanged ('$tag')" ;; esac + st_path hidden "$ck" src/lib.rs + [ -z "$hidden" ] || harness "git status still lists src/lib.rs; step 31 would not discriminate" + check "$arm" "$ck" "$tgt" 31-assume-unchanged "at unknown +DIRTY" yes + g "$ck" update-index --no-assume-unchanged src/lib.rs || harness "git update-index --no-assume-unchanged failed" + restore "$ck" src/lib.rs + + # 32 (N1): a `git archive` copy of S0 inside this checkout's gitignored target/. git discovery + # walks up from the copy and finds THIS checkout, whose HEAD the copy was not built from. + ex_dir="$ck/target/d231-export" + mkdir -p "$ex_dir" || harness "could not create $ex_dir" + git -C "$ck" archive "$SHA" | tar -x -C "$ex_dir" || harness "git archive into $ex_dir failed" + [ -f "$ex_dir/build.rs" ] && [ ! -e "$ex_dir/.git" ] || harness "$ex_dir is not a bare copy of the sources" + top=$(git -C "$ex_dir" rev-parse --show-toplevel) || harness "git finds no repository above $ex_dir" + [ "$top" != "$ex_dir" ] || harness "$ex_dir is its own repository; step 32 would not discriminate" + git -C "$ck" check-ignore -q "target/d231-export/build.rs" \ + || harness "the export is not in an ignored directory of $ck" + probe_source "$ex_dir" + OUTKEY="$arm-export" + check "$arm" "$ex_dir" "$tgt" 32-export-inside-checkout "at unknown +DIRTY" yes done say "D231 SUMMARY commit=$SHA12 pass=$PASS fail=$FAIL verdicts=$((PASS + FAIL)) expected_verdicts=$EXPECTED_VERDICTS result=$RESULT" diff --git a/build.rs b/build.rs index b62d8285..1d8d0279 100644 --- a/build.rs +++ b/build.rs @@ -93,10 +93,38 @@ //! * In a reftable repository the shared table changes on every ref update in every worktree, so //! any commit anywhere re-stamps, and rebuilds, every worktree. ferrodb's repository uses the //! files store, so this is latent here. -//! * If `git` is absent, or cannot say where this package's repository is (which includes a git -//! older than 2.31, too old to answer the question the way it is asked), it emits `unknown`, -//! which reads as loudly as it should. It does NOT fail the build: refusing to compile ferrodb -//! because git is missing would be a worse failure than an honest `unknown`. +//! * With the deprecated `core.preferSymlinkRefs`, `HEAD` is a symbolic LINK, and `--git-path +//! HEAD` names its target's real path, so retargeting `HEAD` moves no watched file and the stamp +//! keeps the old branch's sha (measured by D231's review 4). A known limit: the option is not +//! set on this machine, and git has deprecated it. +//! * The sha and the dirty bit are read before rustc compiles anything. An edit that lands after +//! this script's `git status` and before rustc reads the file is compiled and not stamped; the +//! next build re-stamps. +//! +//! # When it says `unknown`, which is never a guess in the direction that invites trust +//! +//! The stamp is `unknown`, marked dirty, whenever the two facts cannot be read as a description +//! of the tree cargo is compiling: +//! +//! * `git` is absent, or cannot say where this package's repository is. That includes a git older +//! than 2.31, too old to answer the path question the way it is asked. +//! * **The repository git finds is not this package's own** (D231 review 4, N1). Git discovery +//! walks UP from the package root, so a `git archive` copy extracted inside another checkout, +//! under its `target/` for example, would otherwise be stamped with THAT checkout's sha. It can +//! read clean, too, because the copy is untracked there. `git rev-parse --show-toplevel` must be +//! the package root. ferrodb is a single-package repository, so a package below the repository +//! root is refused in the same way. +//! * **An index entry is told to hide changes** (N2): assume-unchanged (a lowercase `ls-files -v` +//! tag), skip-worktree (`S`), or `core.ignoreStat`. `git status` skips such entries by design, +//! so an edited, compiled file would read clean. +//! * **`git status` fails** (N4). "Dirty" with the old sha would still name a commit, and nothing +//! here was able to compare the tree with it. +//! * **HEAD moved while the two facts were being read** (N6). The sha is read, then `status` runs, +//! then the sha is read again. A commit landing between them would otherwise pair the parent's +//! sha with the child's status. +//! +//! None of these fails the build: refusing to compile ferrodb because git is missing or odd would +//! be a worse failure than an honest `unknown`. //! //! It describes the repository at the package root and nothing else: every variable that //! `git rev-parse --local-env-vars` lists (`GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, and the @@ -151,7 +179,12 @@ fn main() { Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) }; - // Fail loud. Without a repository there is nothing to watch and nothing to believe. + // Fail loud. A repository that is not this package's own describes some other tree, and + // without a repository there is nothing to watch and nothing to believe. + if !repository_is_the_package(&git, &root) { + stamp("unknown", true); + return; + } let Some(git_paths) = git_state_paths(&git) else { stamp("unknown", true); return; @@ -159,8 +192,11 @@ fn main() { for p in &git_paths { watch(p); } - - let commit = git(&["rev-parse", "--short=12", "HEAD"]).unwrap_or_else(|| "unknown".into()); + // After the watch list is emitted, so that whatever changes the answer re-stamps. + if index_hides_changes(&git) { + stamp("unknown", true); + return; + } // `--porcelain` over TRACKED files only, and that is a deliberate trade with a real hole in it. // @@ -174,14 +210,18 @@ fn main() { // The hole that remains is narrow and worth naming precisely: a NEW file that is on the build // path and has never been committed. An edit to any file the crate already tracks IS caught, // and that is the case the mechanism exists for. - let dirty = match git(&["status", "--porcelain", "--untracked-files=no"]) { - Some(s) => !s.is_empty(), - // Could not ask. "clean" would be a guess in the direction that invites trust, so it is - // reported as unknown-and-therefore-suspect instead. - None => true, - }; - - stamp(&commit, dirty); + // + // The sha is read on both sides of `status`: two git processes are two moments, and a commit + // landing between them would pair one commit's sha with another's status. + let before = git(&["rev-parse", "--short=12", "HEAD"]); + let status = git(&["status", "--porcelain", "--untracked-files=no"]); + let after = git(&["rev-parse", "--short=12", "HEAD"]); + match (before, status, after) { + (Some(sha), Some(changes), Some(again)) if sha == again => stamp(&sha, !changes.is_empty()), + // No sha, a status that failed, or a HEAD that moved in between. "Dirty" beside a sha + // would still name a commit nothing here compared the tree with. + _ => stamp("unknown", true), + } } fn watch(p: &Path) { @@ -207,6 +247,37 @@ fn local_git_env() -> Vec { .unwrap_or_default() } +/// Whether the repository git finds from the package root is the package's own (module doc, N1). +/// +/// Discovery walks UP, so a copy of the sources extracted inside another checkout finds that +/// checkout. Both sides are canonicalised, because the manifest directory and git's answer can +/// spell one directory differently (a symlinked prefix, `/` against `\` on Windows). +fn repository_is_the_package(git: &dyn Fn(&[&str]) -> Option, root: &Path) -> bool { + let Some(top) = git(&["rev-parse", "--show-toplevel"]) else { + return false; + }; + match (std::fs::canonicalize(&top), std::fs::canonicalize(root)) { + (Ok(top), Ok(root)) => top == root, + _ => false, + } +} + +/// Whether the index is told to hide changes from `git status` (module doc, N2): an entry marked +/// assume-unchanged (`ls-files -v` prints its tag in lower case) or skip-worktree (`S`), or +/// `core.ignoreStat`, which marks entries assume-unchanged as they are added. An index that cannot +/// be listed counts as hiding: "clean" is not the answer to a question that could not be asked. +fn index_hides_changes(git: &dyn Fn(&[&str]) -> Option) -> bool { + if git(&["config", "--bool", "core.ignoreStat"]).as_deref() == Some("true") { + return true; + } + match git(&["ls-files", "-v"]) { + Some(entries) => { + entries.lines().any(|l| l.starts_with(|c: char| c.is_ascii_lowercase() || c == 'S')) + } + None => true, + } +} + /// Every git file whose change can change the stamp, as absolute paths, or `None` when git cannot /// say where this package's repository is. /// From 0bd9802d6c7fe0cb5bdc96ec27cafa227ed51743 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:14:42 -0400 Subject: [PATCH 243/315] =?UTF-8?q?UNBUILT=20D249=20review=203:=20PREREG?= =?UTF-8?q?=20amendment=206=20=E2=80=94=20ST2=20and=20E7=20(H1),=20the=20b?= =?UTF-8?q?lind=20spot=20by=20its=20condition=20(H2),=20H3=20verified=20(t?= =?UTF-8?q?he=20exclusive=20catalog=20borrow=20every=20begin=20needs;=20no?= =?UTF-8?q?t=20a=20defect),=20counts=20derived;=20before=20the=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d249/PREREG.md | 105 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/bench/d249/PREREG.md b/bench/d249/PREREG.md index 31e1485a..de6ac4ab 100644 --- a/bench/d249/PREREG.md +++ b/bench/d249/PREREG.md @@ -411,3 +411,108 @@ has a registered prediction: **they pass**. removes 0 (READ). **Per-target: 2587.** - **The D249 mutant tree is `bed42db`** (this commit's parent carries no code change after it; amendment 4's G1 table applies to it). It does not contain D254's fix. + +--- + +## Amendment 6 — review 3's H1–H3, written BEFORE their code (nothing built) + +Source: `artie-research frontier/d249_review3.md` @ `5094f19`, with its erratum @ `4233eb0`. It reviews this tip, +`9b291ac`. Verdict: SOUND-WITH-CAVEATS, with G1–G5 and E1–E5 closed. These are the lead's decisions. + +### H1: the as-read check's schema half has no killer. ST2 and E7 + +- **What is missing.** Amendment 4 says the `live == read` form "also covers the schema" (`PREREG.md:329`). No test + holds a plan across a SCHEMA change. ST's intervening change is `create_index`, which moves `indexes` and not + `schema`. So a comparison that ignores the schema passes every test. +- **ST2** = `a_plan_held_across_another_alter_of_its_own_table_is_refused`, additions only. It uses only API that ST + already uses. The steps: + 1. `Db::with_one_row()`: `t(id, v)` holding `(1, 7)`. + 2. `plan_alters(t, [ADD COLUMN y INTEGER])`. This plan rewrites rows, so it exercises G3's stated harm. + 3. `db.exec("ALTER TABLE t ADD COLUMN x INTEGER;")`. + 4. `apply_plan(plan)`. + + It must return `Err(Constraint)` containing "changed since". The schema must be `[id, v, x]`, and `SELECT *` must + return exactly `[(1, 7, NULL)]`. +- **Where it is red.** At this tip, ST2 is green (the `live == read` check refuses it). Its red is: + - the `fa2e9a3`/`27dd0a1` form of the check, which overwrote the live schema before comparing (INFERRED: after the + overwrite `live` equals `read` for this table, because `x`'s ALTER moves no root and no index); + - mutant **E7**, which restores exactly that shape. +- **E7 `schema_overwritten_before_compare`** replaces ` if self.require_table(&table)? != &read {` with three + lines: + - `let mut live = self.require_table(&table)?.clone();` + - `live.schema = read.schema.clone();` + - `if live != read {` + + The id is new. "E6" appears in amendment 4 only as "not registered", for review 2's declined swap. +- **Predicted kills:** + - **E7 → ST2 only.** ST2 fails at its `Ok(_)` arm: the stale plan applies and installs `[id, v, y]`. ST still dies + of `indexes`. + - **E5 → ST and ST2.** + - E1–E4 are unchanged. ST2's entries are small, and the staleness check refuses before any of their edits matter. + +### H2: the blind spot is stated by its condition, with both known routes + +`apply_plan`'s comment will say that the check does not see **the heap changing while the catalog entry compares +equal**, and will name the two known routes: + +- committed DML that moves no root; +- `DROP TABLE` followed by a `CREATE TABLE` of the same declaration, between plan and apply. `drop_table` frees every + page of the table, and `DiskManager::allocate` walks the bitmap from bit 0, so the new table can receive the same + page ids, and so an equal entry. That is INFERRED; it depends on nothing lower being freed in between. + +Words only. Closing it would need a heap-side stamp taken at plan time, which is not done here. + +### H3: what keeps another session's `BEGIN` out between a merge's plan loop and its apply (READ at `9b291ac`) + +**The claim to verify.** `quiesce_guard` reads every session's active set (`alter.rs`, `fn quiesce_guard`). So "a +merge holds no transaction open" rules out only the merge's own, and another session's `BEGIN` in the window +between `plan_alters` (`agent_sql/runtime.rs:5035`) and a later table's `apply_plan` (`:5103`) would refuse table 2 +after table 1 was applied. + +**What excludes it: an exclusive borrow of the one `Catalog`, which every transaction start requires.** + +- **Every production `TxnManager::begin` holds `&mut Catalog`** (`git grep` for `.begin()` over `src/`, tests + excluded): + - `executor.rs:196` (`Stmt::Begin`) and `:445` (the implicit transaction) are both inside `run_staged(stmt, catalog: + &mut Catalog, …)`, which `run` wraps; + - `agent_sql/runtime.rs:6732` (`apply_dml`) takes `ctx: &mut ExecCtx`, whose `catalog` is `&'a mut Catalog` + (`runtime.rs:235-239`); + - `agent_sql/runtime.rs:5184` is the merge's own publish transaction, begun AFTER its apply loop (`:5103`). +- **The shared read path begins nothing.** `try_run_read` (`executor.rs:81`), which pgwire's extended protocol calls + without the outer lock (`pgwire/extended.rs:373`), either snapshots an already-open transaction or reads the + cached snapshot with `txn_id: 0` (`executor.rs:116-121`). +- **Where that exclusive borrow comes from:** + - **pgwire:** `ServerContext::catalog()`, the `Mutex` (`pgwire/mod.rs:64`, `:143`), held for one whole + statement. A MERGE is one statement. + - **The CLI:** its catalog mutex, taken once per statement (`cli.rs:246`, and `branch/lease_thread.rs`'s CLI + `RuntimeLock` doc). + - **An embedded caller:** its own `&mut Catalog`. Rust makes that exclusive, so no second statement on the same + catalog can begin a transaction while `merge` holds it. +- **The premise under all three: one `Catalog` per `TxnManager`.** Two `Catalog` values over one `TxnManager` would + let a second one begin a transaction during a merge. + - No production path builds two: `git grep 'Catalog::open(\|Catalog::create('` over `src/` and `examples/` finds + one per process. + - That is READ for the grep, and INFERRED for "no embedder does". + +**Result: no caller reaches `merge` without the exclusion, so this is not a defect.** The G4 doc is rewritten to name +the real premise (the exclusive catalog borrow that every `begin` needs, and its three sources) instead of the +merge's own transactions. + +### Counts, derived (not copied from the review) + +- **Source counts** (READ): + - `tests/d249_alter_encodable.rs`: 6 `#[test]` at `9b291ac`, so **7** with ST2. + - The file has no `#[cfg`, no `#[ignore]` and no `macro_rules!`. + - The lib's U1 and U2 are two plain `#[test]` in `catalog_page.rs`'s test module. + - `git diff 9aa6968 | grep -cE '^\+[[:space:]]*#\[test\]'` will be 9, with 0 removed. +- **Per-target: 2579 + 9 = 2588.** The 2579 is measured on main's tree. +- **The authority at run time is the harness's own list, not these.** The run script gains two steps: + - `cargo test --test d249_alter_encodable -- --list` + - `cargo test --lib catalog::catalog_page::tests:: -- --list` + + Each writes its output to `bench/d249/raw/`. +- **Predicted list counts:** + - the target: **7 tests**; + - the unit filter: the catalog-page test module's count at `9aa6968` plus 2. + + The run is scored against the list, and a disagreement is reported as a registration miss. From 005e1b2b8453f364552b90e0b2145847504c22e8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:15:10 -0400 Subject: [PATCH 244/315] UNBUILT D249 review 3: the staleness check's blind spot stated by its condition with both routes (H2); apply_plan's doc names what excludes a mid-merge BEGIN (H3) (comments only) --- src/catalog/alter.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index 13e1d772..6c71c237 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -629,10 +629,21 @@ impl Catalog { /// (D249). A plan can be held across a statement, and the plan's decisions, the encoder's /// answer included, hold only for the entry they were made against. /// - /// Neither fires inside `AgentRuntime::merge`. A merge holds no transaction open, and it applies - /// each table's plan with no statement in between that touches that table's entry: - /// `apply_plan` for one table changes only that table's entry. That is what keeps a merge atomic - /// in its refusals. + /// Neither fires inside `AgentRuntime::merge`, and that is what keeps a merge atomic in its + /// refusals. The reasons (D249 review 3, H3): + /// + /// - **The quiesce re-check reads every session's transactions, not only the merge's.** What + /// keeps another session from beginning one between the merge's plan loop and a later table's + /// `apply_plan` is that every production `TxnManager::begin` runs with an exclusive borrow of + /// the one `Catalog` (`executor::run_staged`, `apply_dml`'s `ExecCtx`), which the merge holds + /// for its whole statement. The shared read path, `executor::try_run_read`, begins none. That + /// borrow comes from the pgwire server's per-statement `Mutex` + /// (`ServerContext::catalog`), from the CLI's per-statement catalog mutex, or, for an embedded + /// caller, from its own `&mut Catalog`. The premise under all three is ONE `Catalog` per + /// `TxnManager`. A second catalog over the same transactions could begin one mid-merge. + /// - **The staleness check:** a merge applies each table's plan with no statement in between + /// that touches that table's entry, and `apply_plan` for one table changes only that table's + /// entry. /// /// Past those two, the failures it can still meet are environmental — a buffer pool with no /// evictable frame, a disk write that fails, a B+tree page that cannot be read. See @@ -668,10 +679,18 @@ impl Catalog { // nothing overwritten first. A staleness check, not a second encoder guard: it compares, and // asks the encoder nothing. // - // **Its blind spot, stated.** A committed INSERT, UPDATE or DELETE that moves no root changes - // the heap and not the entry, and this does not see it. The plan's `prepared` rows would then - // not describe the heap. That stays with the caller's discipline, as `AgentRuntime::merge`'s - // ordering comment already says: a plan's decision is only valid for the heap it read. + // **Its blind spot, stated by its condition: the heap changed while the entry compares + // equal.** Then the plan's `prepared` rows no longer describe the heap, and this cannot tell. + // Two known routes (D249 review 3, H2): + // + // - committed DML (an INSERT, UPDATE or DELETE) that moves no root; + // - `DROP TABLE` followed by a `CREATE TABLE` of the same declaration. `drop_table` frees every + // page of the table, and the allocator walks its bitmap from bit 0, so the new table can + // receive the same page ids and so an equal entry. + // + // Closing it would take a heap-side stamp taken at plan time. It stays with the caller's + // discipline, as `AgentRuntime::merge`'s ordering comment says: a plan's decision is only + // valid for the heap it read. if self.require_table(&table)? != &read { return Err(FerroError::Constraint(format!( "the alteration of '{table}' was planned against its catalog entry as it was then, \ From e6f87754a84175c85eaa8455ea78fd32eb02763b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:15:24 -0400 Subject: [PATCH 245/315] =?UTF-8?q?UNBUILT=20D249=20review=203:=20test=20S?= =?UTF-8?q?T2=20=E2=80=94=20a=20plan=20held=20across=20another=20ALTER=20o?= =?UTF-8?q?f=20its=20own=20table=20is=20refused,=20and=20the=20table=20kee?= =?UTF-8?q?ps=20[id,=20v,=20x]=20with=20(1,=207,=20NULL)=20(H1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/d249_alter_encodable.rs | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/d249_alter_encodable.rs b/tests/d249_alter_encodable.rs index 67b100de..f668dcba 100644 --- a/tests/d249_alter_encodable.rs +++ b/tests/d249_alter_encodable.rs @@ -367,3 +367,41 @@ fn a_plan_held_across_a_change_to_its_own_table_is_refused() { "the stale plan was refused, but the table no longer has its column v with its index" ); } + +/// The as-read check covers the SCHEMA: a plan held across another ALTER of the same table must be +/// refused, not laid over it. +/// +/// ST's intervening change is an index, which leaves the schema alone, so ST cannot tell a +/// comparison that includes the schema from one that overwrites it first. Here the intervening +/// change is a schema change that moves no root and no index, and a plan that rewrites rows, so a +/// stale apply would install `[id, v, y]` over the `[id, v, x]` the table now has. +#[test] +fn a_plan_held_across_another_alter_of_its_own_table_is_refused() { + let mut db = Db::with_one_row(); + let add_y = AlterAction::AddColumn(Column::new("y".to_string(), DataType::Integer, true)); + let plan = db.catalog.plan_alters("t", std::slice::from_ref(&add_y), &db.txn, None).unwrap(); + + // The table changes while the plan is held: another column, by another statement. + db.exec("ALTER TABLE t ADD COLUMN x INTEGER;").unwrap(); + + match db.catalog.apply_plan(plan, &db.txn) { + Err(FerroError::Constraint(msg)) => assert!( + msg.contains("changed since"), + "the stale plan was refused, but not by the staleness check: {msg}" + ), + Err(e) => panic!("the stale plan was refused for some other reason: {e:?}"), + Ok(_) => panic!("a plan made before another ALTER of the same table was applied over it"), + } + let names: Vec = + db.catalog.get_table("t").expect("the table is still there").schema.columns.iter().map(|c| c.name.clone()).collect(); + assert_eq!(names, vec!["id".to_string(), "v".to_string(), "x".to_string()], "the table's shape moved"); + match db.exec("SELECT * FROM t;") { + Ok(Outcome::Rows(rows)) => assert_eq!( + rows, + vec![vec![Value::Integer(1), Value::Integer(7), Value::Null]], + "the row no longer reads back as (1, 7, NULL) under [id, v, x]" + ), + Ok(_) => panic!("SELECT after the refusal returned something other than rows"), + Err(e) => panic!("SELECT after the refused stale plan failed: {e}"), + } +} From d9e42d1533ed533b182768dae6f660311623c5dd Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:15:31 -0400 Subject: [PATCH 246/315] UNBUILT D249 review 3: mutant E7 (the live schema overwritten before the compare); E1-E7 cut from e6f8775 --- bench/d249/make_mutants.py | 8 ++++++++ bench/d249/mutants/E2_precheck_in_finish.patch | 2 +- bench/d249/mutants/E5_staleness_check_removed.patch | 8 ++++---- .../E7_schema_overwritten_before_compare.patch | 13 +++++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) create mode 100644 bench/d249/mutants/E7_schema_overwritten_before_compare.patch diff --git a/bench/d249/make_mutants.py b/bench/d249/make_mutants.py index a68242f0..fbfdd429 100644 --- a/bench/d249/make_mutants.py +++ b/bench/d249/make_mutants.py @@ -47,6 +47,14 @@ " if self.require_table(&table)? != &read {\n", " if false && self.require_table(&table)? != &read {\n", )]), + # PREREG amendment 6 (the D249 review 3, H1): the live schema overwritten before the compare, + # which is the form the check had at fa2e9a3. + ("E7_schema_overwritten_before_compare", ALTER, [( + " if self.require_table(&table)? != &read {\n", + " let mut live = self.require_table(&table)?.clone();\n" + " live.schema = read.schema.clone();\n" + " if live != read {\n", + )]), ] diff --git a/bench/d249/mutants/E2_precheck_in_finish.patch b/bench/d249/mutants/E2_precheck_in_finish.patch index 8cbba106..b7a3a1b8 100644 --- a/bench/d249/mutants/E2_precheck_in_finish.patch +++ b/bench/d249/mutants/E2_precheck_in_finish.patch @@ -9,7 +9,7 @@ // The statistics are carried across the alteration by [`carried_stats`], and they are // computed HERE, before the rewrite, rather than inside `finish` where they used to be. -@@ -765,6 +765,9 @@ +@@ -784,6 +784,9 @@ stats.columns = columns; } let entry = self.tables.get_mut(table).ok_or(FerroError::KeyNotFound)?; diff --git a/bench/d249/mutants/E5_staleness_check_removed.patch b/bench/d249/mutants/E5_staleness_check_removed.patch index 5113da68..8a8bc973 100644 --- a/bench/d249/mutants/E5_staleness_check_removed.patch +++ b/bench/d249/mutants/E5_staleness_check_removed.patch @@ -1,9 +1,9 @@ --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs -@@ -672,7 +672,7 @@ - // the heap and not the entry, and this does not see it. The plan's `prepared` rows would then - // not describe the heap. That stays with the caller's discipline, as `AgentRuntime::merge`'s - // ordering comment already says: a plan's decision is only valid for the heap it read. +@@ -691,7 +691,7 @@ + // Closing it would take a heap-side stamp taken at plan time. It stays with the caller's + // discipline, as `AgentRuntime::merge`'s ordering comment says: a plan's decision is only + // valid for the heap it read. - if self.require_table(&table)? != &read { + if false && self.require_table(&table)? != &read { return Err(FerroError::Constraint(format!( diff --git a/bench/d249/mutants/E7_schema_overwritten_before_compare.patch b/bench/d249/mutants/E7_schema_overwritten_before_compare.patch new file mode 100644 index 00000000..e66adf32 --- /dev/null +++ b/bench/d249/mutants/E7_schema_overwritten_before_compare.patch @@ -0,0 +1,13 @@ +--- a/src/catalog/alter.rs ++++ b/src/catalog/alter.rs +@@ -691,7 +691,9 @@ + // Closing it would take a heap-side stamp taken at plan time. It stays with the caller's + // discipline, as `AgentRuntime::merge`'s ordering comment says: a plan's decision is only + // valid for the heap it read. +- if self.require_table(&table)? != &read { ++ let mut live = self.require_table(&table)?.clone(); ++ live.schema = read.schema.clone(); ++ if live != read { + return Err(FerroError::Constraint(format!( + "the alteration of '{table}' was planned against its catalog entry as it was then, \ + and that entry has changed since; nothing was written. Plan it again." From 06b0188cecc0f5f5b3654306b5c00db9df2bd1ba Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:15:41 -0400 Subject: [PATCH 247/315] =?UTF-8?q?UNBUILT=20D249=20review=203:=20PREREG?= =?UTF-8?q?=20amendment=207=20=E2=80=94=20ST2,=20E7=20and=20the=20H2/H3=20?= =?UTF-8?q?comments=20recorded;=20counts=207=20/=2013=20/=202588=20from=20?= =?UTF-8?q?source,=20the=20list=20steps=20authoritative?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d249/PREREG.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bench/d249/PREREG.md b/bench/d249/PREREG.md index de6ac4ab..48a306e1 100644 --- a/bench/d249/PREREG.md +++ b/bench/d249/PREREG.md @@ -516,3 +516,34 @@ merge's own transactions. - the unit filter: the catalog-page test module's count at `9aa6968` plus 2. The run is scored against the list, and a disagreement is reported as a registration miss. + +--- + +## Amendment 7 — amendment 6's changes recorded, before any run (nothing built) + +| commit | what | +|---|---| +| `0bd9802` | amendment 6 | +| `005e1b2` | H2 and H3: the blind spot stated by its condition, with both routes; `apply_plan`'s doc names the exclusive catalog borrow every `begin` needs, and its three sources. **Comments only** | +| `e6f8775` | ST2 (additions only, `38 0`) | +| `d9e42d1` | E7 added; E1–E7 cut from `e6f8775`, each passing `git apply --check`. E1, E3 and E4 are byte-identical re-cuts; E2 and E5 moved context only | + +- **No code line in `src/` changed since `9b291ac`.** Instrument: `git diff 9b291ac d9e42d1 -- src/`, keeping `^[-+]` + lines and dropping comment lines, leaves 0. +- **Counts:** + - `d249_alter_encodable` has **7** `#[test]`; + - `catalog_page.rs` has **13**: 11 at `9aa6968` plus U1 and U2; + - `git diff 9aa6968 d9e42d1` adds 9 `#[test]` and removes 0. +- **Per-target: 2579 + 9 = 2588.** +- **The run's authority is the `-- --list` of each named target** (amendment 6). Predicted: the target lists 7, and + `catalog::catalog_page::tests::` lists 13. +- **The mutant tree is this branch's tip.** Its `src/` and `tests/` equal `e6f8775`'s, and it has no D254 fix. + +| mutant | fails | +|---|---| +| E1 | AR, AA, A9, A33; and AI at its error assertion | +| E2 | A33 (row); AI ("holds the renamed column or index") | +| E3 | U1 (a panic), on the unit filter | +| E4 | AI, at its error assertion | +| E5 | **ST and ST2** | +| E7 | **ST2 only**, at its `Ok(_)` arm | From 0e89af6af32b2cc637e3a8cd74dee1d32a83ef12 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:20 -0400 Subject: [PATCH 248/315] UNBUILT D244: durable() publishes an owed root before it flushes, under logical inside the group-commit leader's sync, and a failed publish fails the sync before anything is written (review 2 R2-2/R2-5/R2-6, the lead's decision); exit_sequence's doc says what the exit call still buys (an eviction); the no-op set_state comment names its one production caller and the startup consequence (R2-4) (not built, not run) --- examples/pgserver.rs | 13 +------------ src/branch/table_catalog.rs | 37 +++++++++++++++++++++++++++++++++++-- src/cli/cli.rs | 8 +++++--- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/examples/pgserver.rs b/examples/pgserver.rs index 979c0682..f67cf255 100644 --- a/examples/pgserver.rs +++ b/examples/pgserver.rs @@ -155,16 +155,5 @@ fn main() { // written would leave a durable map that still charges pages nothing owns. let stats = lease.stop(); let _ = writeln!(std::io::stderr(), "pgserver: lease scan stopped after {stats:?}"); - // D244 review F7: the branch catalog's root, published and synced before the arena's map, for - // the reason `ferrodb::cli::cli::exit_sequence` gives. This binary takes no database checkpoint - // at exit, so it makes the one call rather than calling that function. Both run whatever the - // other returns, so a failed publish does not cost the arena its checkpoint (D244 review 3, - // R3-F2); the publish's error is reported first. - let published = branches.publish_root_durably(); - let arena_checkpoint = store.checkpoint(Path::new(&arena_path)); - if let (Err(_), Err(a)) = (&published, &arena_checkpoint) { - let _ = writeln!(std::io::stderr(), "pgserver: the arena checkpoint failed as well ({a})"); - } - published.expect("publish the branch catalog's root"); - arena_checkpoint.expect("checkpoint the arena"); + store.checkpoint(Path::new(&arena_path)).expect("checkpoint the arena"); } diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 0aede216..9a541fd8 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -389,11 +389,40 @@ impl TableBranchCatalog { /// merely described here. fn durable(&self, seq: u64) -> Result<(), FerroError> { self.commit_group.wait_durable(seq, || { + self.publish_if_owed()?; self.pool.flush_all()?; self.pool.disk_manager.sync() }) } + /// **D244 review 2, R2-2 (the lead's decision): a sync writes the header before the pages it + /// names.** A publish that failed on a mutation's exit leaves split pages dirty in this pool + /// under a header naming the old root. Before this, the next `durable()` from ANY writer flushed + /// them without page 1, and the file refused to open after any exit that came before the next + /// successful publish: a kill, a panic, an error return, or pgserver, whose `serve` never returns + /// `Ok`. So the leader publishes first, and a failed publish fails the sync before anything is + /// flushed. + /// + /// It runs inside `wait_durable`'s sync closure, which runs after the group-commit mutex is + /// dropped (`group_commit.rs`). No `durable()` caller holds `logical`, so taking it here is + /// `mutate`'s lock order. When nothing is owed it is two atomic loads: `mutate` publishes on + /// every exit, so this path runs only after a failed publish. A catalog with no header page + /// (`create`) never owes one. + /// + /// `mutate`'s own publish on a failed exit stays, and it is not redundant with this one. It puts + /// the current page 1 in the pool at the failure itself, so a write-back that does not go + /// through `durable()` carries it too. `tests/d244_publish_root.rs` pins each separately: A1 for + /// the exit publish, A3 for this one. + fn publish_if_owed(&self) -> Result<(), FerroError> { + let header_page = self.header_page.load(Ordering::SeqCst); + let root = self.tree.root_page_id.load(Ordering::SeqCst); + if header_page == 0 || self.published_root.load(Ordering::SeqCst) == root { + return Ok(()); + } + let _g = self.logical.lock().unwrap(); + self.publish_root() + } + /// **D244 review F7: the publish a clean exit owes.** Writes the tree's root into the header /// page if the last publish did not, then syncs this catalog's file. /// @@ -1130,8 +1159,12 @@ impl BranchCatalog for TableBranchCatalog { // that error, where before D244 it could not fail once past the checks above. Kept // on purpose: the header write it reports is still owed, and swallowing it would // hide a catalog the next open refuses. It is reached when a caller passes the state - // the branch already has: the reaper's resume path re-enters an interrupted reap with - // `set_state(branch, Reaping, Reaping)` (`reaper.rs`, `resume_interrupted_reaps`). + // the branch already has, and the one production caller that does is the reaper's + // resume path, re-entering an interrupted reap with `set_state(branch, Reaping, + // Reaping)` (`reaper.rs`, `resume_interrupted_reaps`). That runs inside + // `LeaseThread::start`, so a heal-publish that fails there fails the start. + // (`quarantine` returns early for a branch already `Quarantined`, so it never gets + // here.) return Ok(false); } let old = core.clone(); diff --git a/src/cli/cli.rs b/src/cli/cli.rs index 76fdc008..739026f7 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -226,9 +226,11 @@ pub fn run_cli(db_path: &str) -> Result<(), FerroError> { /// stopped. `run_cli` calls this, and so do the tests, so a test runs the code the binary runs. /// /// 1. **D244 review F7.** The branch catalog publishes its root and syncs. A publish that failed on -/// a mutation's exit is otherwise retried only by the next mutation, and an exit makes none. If -/// split pages have already reached the disk through another writer's sync or an eviction, -/// the header would still name the old root, and the next open would refuse. +/// a mutation's exit is otherwise retried only by the next mutation or the next sync +/// (`durable()` publishes an owed root first, D244 review 2), and a quiet exit may have neither. +/// If split pages have already reached the disk through an eviction, the header would still name +/// the old root, and the next open would refuse. That eviction case is what this call still +/// buys over `durable()`'s own publish. /// 2. The database's checkpoint. /// 3. Persist where the arena starts and what it has allocated. Without this the next open finds no /// checkpoint, refuses to reattach, and the branch tree written this session is unreachable. It From a3a4bd7cb1bee7e1dab94f51873ade7d1bdf5ede Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:20 -0400 Subject: [PATCH 249/315] UNBUILT D244 tests: A3, a sync after a failed publish with no clean exit (base API; killer of MG); A1 flushes directly instead of through a staged fork's durable(), which now heals an owed root and would hide MB; the maximum-key argument scoped to the base (R2-8); F7's stand-in flush is an eviction (not built, not run) --- tests/d244_clean_exit_publishes_the_root.rs | 15 ++-- tests/d244_publish_root.rs | 93 ++++++++++++++++----- 2 files changed, 83 insertions(+), 25 deletions(-) diff --git a/tests/d244_clean_exit_publishes_the_root.rs b/tests/d244_clean_exit_publishes_the_root.rs index 6098dc54..480e9b95 100644 --- a/tests/d244_clean_exit_publishes_the_root.rs +++ b/tests/d244_clean_exit_publishes_the_root.rs @@ -3,9 +3,10 @@ //! //! Since D244 a publish that fails on a mutation's exit records nothing, and the NEXT mutation //! retries it (`tests/d244_publish_root.rs`, A2). A process that makes no further mutation never -//! gets that retry. Meanwhile another writer's `durable()` or an eviction may write the split pages -//! to disk. Page 1 was never even dirty, so it keeps naming the old root. The next open searches for -//! the header key `[0x07]` under that root, which never holds it after a split, and refuses. +//! gets that retry. Meanwhile an eviction may write the split pages to disk. (A sync would not: since +//! D244 review 2, R2-2, `durable()` publishes an owed root before it flushes; `d244_publish_root.rs`, +//! A3.) Page 1 was never even dirty, so it keeps naming the old root. The next open searches for the +//! header key `[0x07]` under that root, which never holds it after a split, and refuses. //! //! `cli::exit_sequence` is the function `run_cli` calls once its REPL ends. It now publishes the //! branch catalog's root and syncs it before the database and arena checkpoints. @@ -174,8 +175,9 @@ fn a_clean_exit_after_a_failed_publish_leaves_a_catalog_that_opens() { assert!(refused.contains(INJECTED), "premise failed: the fork failed for another reason: {refused}"); assert_ne!(branches.root_page_id(), root_before, "premise failed: the root did not split before the failed publish"); - // 2. No further mutation. What another writer's `durable()` or an eviction would do: the split - // pages reach the file. Page 1 does not, because the failed publish never wrote it. + // 2. No further mutation. What an eviction would do: the split pages reach the file. Page 1 + // does not, because the failed publish never wrote it. (A `durable()` here would publish + // first, which is A3's case, not this one.) branches.pool_handle().flush_all().expect("flush"); match open_branches(&storage.copy()) { Ok(_) => panic!( @@ -187,7 +189,8 @@ fn a_clean_exit_after_a_failed_publish_leaves_a_catalog_that_opens() { ), } - // 3. The clean exit, through the function `run_cli` calls, then every handle dropped. + // 3. The clean exit, through the function `run_cli` calls, then the arena and the branch + // catalog dropped. Nothing else holds the branch catalog's pool. exit_sequence(&branches, &txn, &store, &dir.path().join("f7.arena")).expect("the clean exit"); drop(store); drop(branches); diff --git a/tests/d244_publish_root.rs b/tests/d244_publish_root.rs index df3c08e2..b67d12d2 100644 --- a/tests/d244_publish_root.rs +++ b/tests/d244_publish_root.rs @@ -11,9 +11,14 @@ //! - **A1:** a mutator published only through `stage()`, after its last `?`. A fault after a root //! split returned before publishing, and the next flush wrote the split pages without page 1. //! -//! Either way, the next open reads the old root and searches for the header key `[0x07]`. That is -//! the tree's maximum key, and after a split it never sits under the old root, so the open REFUSES: -//! "branch catalog header key is missing". Every branch becomes unreachable. +//! Either way, the next open reads the old root and searches for the header key `[0x07]`. At +//! `9aa6968` that is the tree's maximum key, so after a split it sits in the new right part, never +//! under the old root, and the open REFUSES: "branch catalog header key is missing". Every branch +//! becomes unreachable. (Branches that add keys above `[0x07]` keep this only while fewer keys lie +//! above the header than below it: D244 review 2, R2-8.) +//! +//! A3 covers a third route, found by D244 review 2 (R2-2): after a failed publish, ANY later sync +//! flushed the split pages without page 1, whatever exit followed. `durable()` now publishes first. //! //! # The fault injector, and why it is not `sim::SimStorage` //! @@ -246,6 +251,54 @@ fn a_failed_read_of_page_one_at_a_root_splits_publish_is_retried_by_the_next_mut assert_reopens_with(&storage, &ids); } +/// A3 (D244 review 2, R2-2): a failed publish, no further mutation, then a sync, then no clean exit. +/// +/// Forks are staged, not awaited, so no sync runs until the end. The first fork that splits the +/// root is the first whose publish reads page 1, and that read fails. Then the last GOOD fork's +/// ticket is awaited: its `durable()` is the next sync, and it flushes the split pages. The catalog +/// is then dropped with no exit path at all, which is what a kill, a panic or pgserver amounts to. +/// +/// At `9aa6968` the failed publish had already swapped the new root in, so nothing was owed. Up to +/// the D244 exit fix, the failed publish left the root owed but `durable()` never paid it. Either +/// way the sync writes the split pages under the old header, and the reopen refuses. With +/// `durable()` publishing first, page 1 is written in that same flush. +#[test] +fn a_sync_after_a_failed_publish_writes_the_root_before_the_split_pages() { + let storage = FlakyStorage::new(); + let cat = create(&storage); + let root_before = cat.root_page_id(); + let mut ids = Vec::new(); + let mut last_ticket = None; + + storage.arm(Rule::ReadAt { offset: PAGE_ONE }); + let refused = loop { + assert!(ids.len() < 5_000, "premise failed: {} forks never moved the root", ids.len()); + cold(&cat); + match cat.fork_staged(BranchId::TRUNK, LEASE) { + Ok((rec, seq)) => { + ids.push(rec.branch_id.id); + last_ticket = seq; + } + Err(e) => break e.to_string(), + } + }; + storage.disarm(); + let fired = storage.fired(); + assert!( + fired.len() == 1 && fired[0].contains("the read of page 1 "), + "premise failed: exactly one read of page 1 must have been failed; fired: {fired:?}" + ); + assert!(refused.contains(INJECTED), "premise failed: the fork failed for another reason: {refused}"); + assert_ne!(cat.root_page_id(), root_before, "premise failed: the root did not split before the failed publish"); + let ticket = last_ticket.expect("premise failed: no fork was staged before the failed publish"); + + // No further mutation. The earlier fork's sync: no sync has run in this test, so it flushes. + cat.await_fork_durable(Some(ticket)).expect("the earlier fork's sync"); + drop(cat); + + assert_reopens_with(&storage, &ids); +} + /// The leaf an insert of `key` would land in, and its free bytes, found the way the tree finds it: /// down from the root through `BPlusTreeInternalPage::find_child`. A leaf splits on an insert of `E` /// bytes once `27 + keys + values + E >= PAGE_SIZE` (`index_page.rs`, `BPlusTreeLeafPage::is_full`), @@ -283,8 +336,13 @@ fn right_of(cat: &TableBranchCatalog, page: u32) -> Option { const CHILD_ENTRY: usize = 4 + 17 + 4 + 8; const DEADLINE_ENTRY: usize = 4 + 17 + 4; -/// A1: a fork that faults AFTER it split the root, flushed by an earlier staged fork's `durable()`, -/// then a reopen with no further mutation. +/// A1: a fork that faults AFTER it split the root, then a whole-pool flush that does NOT go through +/// `durable()`, then a reopen with no further mutation. +/// +/// Why not a sync: since D244 review 2 (R2-2) `durable()` publishes an owed root before it flushes, +/// so a sync would heal a missing exit publish and this test could not see one. What the error +/// exit's publish still buys is page 1 current in the pool from the failure on, so a write-back +/// outside `durable()` carries it. The direct `flush_all` is that write-back. /// /// A fork writes, in order: its record key, its Live state key, its deadline key, its child key in /// the parent's live set, and the header key. The first two always append to the tails of their @@ -307,15 +365,14 @@ const DEADLINE_ENTRY: usize = 4 + 17 + 4; /// /// Phases: /// 1. **Grow.** 200 parents forked from trunk, then forks round-robin across them, every fork with -/// its own lease, until the root is an internal node with under 150 bytes of room. The last of -/// these is staged and never awaited: its ticket is the vehicle. +/// its own lease, until the root is an internal node with under 150 bytes of room. /// 2. **Steer.** Staged forks on a cold pool with the rule armed, until one faults. None is -/// awaited, so the previous one's ticket is still unsynced when the failure comes. +/// awaited, so no sync runs in this phase. /// -/// Then that earlier ticket is awaited: its `durable()` flushes every dirty page, the split ones -/// included. At `9aa6968` the failed fork returned before `stage()`, page 1 still names the old -/// root, and the reopen refuses. With the fix the error exit published the new root, and the reopen -/// finds every branch. +/// Then one `flush_all` writes every dirty page, the split ones included. At `9aa6968` the failed +/// fork returned before `stage()`, page 1 still names the old root, and the reopen refuses. With +/// the fix the error exit published the new root into the pool, the flush carries it, and the +/// reopen finds every branch. #[test] fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { const PARENTS: u64 = 200; @@ -340,7 +397,6 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { parents.push(rec.branch_id); } let mut first_internal_root = None; - let mut vehicle = None; for i in 0.. { assert!(i < 30_000, "premise failed: {i} forks never filled the root"); let l = lease(); @@ -361,13 +417,12 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { // bytes (its record, state, deadline and child inserts can each split a leaf, while // the leases still rise), so the root is not full yet, and a few tail splits will fill it. if room < 150 { - vehicle = seq; + cat.await_fork_durable(seq).expect("sync the last growing fork"); break; } } cat.await_fork_durable(seq).expect("sync a growing fork"); } - let mut vehicle = vehicle.expect("premise failed: the last growing fork handed back no ticket"); // ---- 2. Staged forks, steered, on a cold pool, until one faults --------------------------- let root_before = cat.root_page_id(); @@ -415,7 +470,7 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { let fired = storage.fired(); storage.disarm(); match (result, fired.is_empty()) { - (Ok((rec, seq)), true) => { + (Ok((rec, _)), true) => { assert_eq!(rec.branch_id.id, id, "premise failed: the fork did not take the id its deadline key was steered for"); assert_eq!( cat.root_page_id(), @@ -423,7 +478,6 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { "premise failed: a fork split the root and read nothing from storage after the split" ); ids.push(id); - vehicle = seq.expect("a staged fork hands back its ticket"); } (Err(e), false) => { failure = Some(e.to_string()); @@ -437,8 +491,9 @@ fn a_fork_that_faults_after_splitting_the_root_still_publishes_it() { assert!(failure.contains(INJECTED), "premise failed: the fork failed for another reason: {failure}"); assert_ne!(cat.root_page_id(), root_before, "premise failed: the fault fired before the root moved"); - // The earlier staged fork's durable(): no sync has run since it was staged, so this one flushes. - cat.await_fork_durable(Some(vehicle)).expect("the earlier fork's sync"); + // A write-back that does not go through `durable()`: it carries page 1 only if the failed + // fork's own exit published it. + cat.pool_handle().flush_all().expect("flush the pool"); drop(cat); assert_reopens_with(&storage, &ids); From b310768a3e36777d5580b200c65e5ea3669c9967 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:20 -0400 Subject: [PATCH 250/315] UNBUILT D244: pgserver's exit publish reverted, since its code after serve(..).unwrap() is unreachable (serve returns only Err) and durable() now covers the server; the shape test checks run_cli only, and MF is withdrawn (review 2 R2-2, review 5; not built, not run) --- tests/d244_exits_run_the_publish.rs | 36 +++++++++++------------------ 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/tests/d244_exits_run_the_publish.rs b/tests/d244_exits_run_the_publish.rs index 954dd7b9..ef26e099 100644 --- a/tests/d244_exits_run_the_publish.rs +++ b/tests/d244_exits_run_the_publish.rs @@ -1,20 +1,24 @@ -//! D244 review 3, R3-F3 — **both production exits run the branch catalog's publish.** This checks +//! D244 review 3, R3-F3 — **the CLI's clean exit runs the branch catalog's publish.** This checks //! WORDING, not an outcome, and says so here. //! //! `tests/d244_clean_exit_publishes_the_root.rs` runs `cli::exit_sequence` and shows that the -//! publish in it is load-bearing (mutants MC and MD). What no test can run is the binaries' own -//! exits: `run_cli` reads a REPL from stdin, `examples/pgserver.rs` serves a socket, and neither can -//! be handed a storage that fails a read of page 1. So two edits would pass every outcome test: -//! - ME: `run_cli` goes back to checkpointing inline instead of calling `exit_sequence`; -//! - MF: pgserver drops its `publish_root_durably()` call. +//! publish in it is load-bearing (mutants MC and MD). What no test can run is the binary's own +//! exit: `run_cli` reads a REPL from stdin and cannot be handed a storage that fails a read of page +//! 1. So one edit would pass every outcome test: +//! - ME: `run_cli` goes back to checkpointing inline instead of calling `exit_sequence`. //! -//! This test reads the two files, with `//` comments stripped, and fails on either. +//! This test reads `src/cli/cli.rs`, with `//` comments stripped, and fails on it. +//! +//! pgserver is not checked here: its code after `serve(..).unwrap()` is unreachable, because +//! `serve` returns only `Err` (D244 review 2, R2-2). A check on it would guard dead code. The +//! server is covered by `durable()`'s own publish instead (`d244_publish_root.rs`, A3). //! //! # Blind spots //! -//! It matches text. A call under another name (an alias, a wrapper), a call in dead code, or a -//! string holding the pattern would all satisfy it. It is the same kind of guard as -//! `d53_private_root_allowlist.rs`, for the same reason: the outcome is not reachable from a test. +//! It matches text. A call under another name (an alias, a wrapper), a call in dead code, a string +//! holding the pattern, or a pattern inside a `/* */` comment (only `//` is stripped) would all +//! satisfy it. It is the same kind of guard as `d53_private_root_allowlist.rs`, for the same reason: +//! the outcome is not reachable from a test. /// `path` under the crate root, with every `//` comment removed. fn code_only(path: &str) -> String { @@ -65,15 +69,3 @@ fn run_cli_ends_through_exit_sequence_which_publishes_before_the_arena_checkpoin .unwrap_or_else(|| panic!("`exit_sequence` must checkpoint the arena")); assert!(publish < arena, "`exit_sequence` must publish the branch catalog's root before the arena checkpoint"); } - -#[test] -fn pgserver_publishes_the_branch_root_before_its_arena_checkpoint() { - let pg = code_only("examples/pgserver.rs"); - let publish = pg - .find("publish_root_durably()") - .unwrap_or_else(|| panic!("pgserver's exit must publish the branch catalog's root (MF)")); - let arena = pg - .rfind("store.checkpoint(") - .unwrap_or_else(|| panic!("pgserver must checkpoint its arena at exit")); - assert!(publish < arena, "pgserver must publish the branch catalog's root before its arena checkpoint (MF)"); -} From caf6ba48e6d492dfcb6a4c38fe65798fb2000883 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:21 -0400 Subject: [PATCH 251/315] D255 red, amendment 2: review F3/F4/F7/F8/F9 tests and the LEFT JOIN WHERE-pushdown test (UNBUILT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 tests, all against 9aa6968's API. Test 12 now proves the ON/WHERE merge keeps the WHERE conjunct (b.v = 6 -> []); test 13 adds a relation after the 4-column left-join leaf and a preserved-side conjunct that must reach la's index; test 15 pins that a linked query never plans a cross product; test 16 is the WHERE-on-the-nullable-side wrong answer. PREREG: artie-research frontier/lane_d255_join.md §2.2 @ cd05afc. --- tests/d255_join_on_conjuncts.rs | 106 +++++++++++++++++++++++++++----- 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/tests/d255_join_on_conjuncts.rs b/tests/d255_join_on_conjuncts.rs index 9784fdfe..db89cd76 100644 --- a/tests/d255_join_on_conjuncts.rs +++ b/tests/d255_join_on_conjuncts.rs @@ -15,6 +15,12 @@ //! The parser has no `CROSS JOIN` and no comma FROM list, so `ON TRUE` is the only way to write a //! cross product here; refusing it made a cross product unwritable below 13 relations. //! +//! Placing a one-relation conjunct on a leaf that is a LEFT JOIN needs the rule for which side of +//! an outer join a filter may go below, and `optimizer::push` — the WHERE pushdown — did not have +//! it: `a LEFT JOIN b ON a.id = b.id WHERE b.v = 5` filtered b below the left join and returned +//! the NULL-extended rows the WHERE must exclude. D255 fixes that too, and both placements now +//! share it. +//! //! Every assertion is on the ANSWER, compared as a sorted row set, except where a test says it is //! pinning a plan and why. `tests/d64_expression_depth.rs` also writes `ON 1` and `ON 1 = 1`, but it //! only parses — it never reaches the planner — so it is not a control for any of this. The @@ -224,6 +230,10 @@ fn a_join_with_no_linking_conjunct_is_a_cross_product() { let mut db = ab(); assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON 1 = 1;"), all_ab_pairs()); assert_eq!(db.rows("SELECT a.id, b.id FROM a JOIN b ON TRUE;"), all_ab_pairs()); + // A TRUE that reads no column is dropped, not kept as a no-op filter over every output row: + // `ON TRUE` plans exactly as the left-deep path always planned it. + let plan = db.explain("EXPLAIN SELECT a.id, b.id FROM a JOIN b ON TRUE;"); + assert!(!plan.contains("Filter"), "`ON TRUE` left a filter in the plan:\n{plan}"); // A one-relation conjunct and nothing linking: b filtered, then crossed with a. let filtered: Rows = all_ab_pairs().into_iter().filter(|p| p[1] != Some(2)).collect(); assert_eq!(filtered.len(), 6, "fixture: b holds v = 5 at ids 1 and 3"); @@ -231,8 +241,13 @@ fn a_join_with_no_linking_conjunct_is_a_cross_product() { } /// `Filter`, `NestedLoopJoin` and `HashJoin` all keep a row only when its predicate is -/// `Boolean(true)`, so a bare `1` keeps nothing, in WHERE and in ON alike. The law pinned is that -/// `a JOIN b ON p` answers exactly as `a JOIN b ON TRUE WHERE p` does. +/// `Boolean(true)`, so a bare `1` keeps nothing, in WHERE and in ON alike. What is pinned is that +/// `a JOIN b ON p` answers as `a JOIN b ON TRUE WHERE p` does for a SINGLE-conjunct `p`. +/// +/// ⚠ Not for a compound `p` with a non-boolean conjunct. `AND` refuses a non-boolean operand +/// (`logical`), while a filter holding the conjunct alone just drops the row, and which of the two +/// a conjunct meets depends on where placement puts it — in WHERE as in ON, since pushdown splits +/// conjuncts too. Only a binder type check settles that, and it is not D255's. /// /// ⚠ Past 12 relations this CHANGES an answer that planned at 9aa6968: the `1` was dropped there and /// the cross product came back. @@ -323,8 +338,9 @@ fn a_conjunct_over_three_relations_is_applied_when_no_pair_is_linked() { /// Two components, each linked inside and not to the other. The cheapest plan joins each component /// and crosses the two results last; this pins that the search can SEE that plan. /// -/// This is the one assertion here on a plan rather than an answer, and it rests on the cost model, -/// worked by hand from `cost_model.rs` (50 rows a table, ids unique, ANALYZEd): the bushy root +/// The plan assertion rests on the cost model — as do the plan assertions in the TRUE-folding, +/// index-selection and connected-chain tests — worked by hand from `cost_model.rs` (50 rows a +/// table, ids unique, ANALYZEd): the bushy root /// `(w⋈x)×(y⋈z)` costs 4.5 + 4.5 + 25 = 34, and every root that is a join on a predicate costs 83 /// (`((w⋈x)×y)⋈z` = 31 + 1.5 + 50.5). A search that only crosses when a whole level is empty never /// builds the bushy root. @@ -369,21 +385,31 @@ fn a_one_relation_conjunct_reaches_index_selection() { assert!(plan.contains("Index scan on b"), "`b.id = 3` did not reach b's access path:\n{plan}"); // With a WHERE conjunct on b as well, pushed down onto b's scan first: the two must become ONE - // filter, or the planner sees a filter over a filter and never considers the index. - let q = "SELECT a.id FROM a JOIN b ON a.id = b.id AND b.id = 3 WHERE b.v > 0;"; - assert_eq!(db.rows(q), r(&[&[3]])); - let plan = db.explain(&format!("EXPLAIN {q}")); - assert!(plan.contains("Index scan on b"), "the ON and WHERE filters on b were not merged:\n{plan}"); + // filter, or the planner sees a filter over a filter and never considers the index. And the + // merge must KEEP the WHERE conjunct: b's row 3 has v = 5, so `b.v = 6` must empty the answer. + for (where_, want) in [("b.v = 5", r(&[&[3]])), ("b.v = 6", Rows::new())] { + let q = format!("SELECT a.id FROM a JOIN b ON a.id = b.id AND b.id = 3 WHERE {where_};"); + assert_eq!(db.rows(&q), want, "WHERE {where_}: the merge lost a conjunct"); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(plan.contains("Index scan on b"), "WHERE {where_}: the ON and WHERE filters on b were not merged:\n{plan}"); + } } -/// The leaf a one-relation conjunct reads can be a LEFT JOIN. The filter goes OVER it: pushed into -/// the nullable side it would stop excluding the NULL-extended row, which is (2, NULL, 2) here. -#[test] -fn a_one_relation_conjunct_over_a_left_join_filters_after_the_left_join() { +/// la = (1,0) (2,0); lb = (1,5) (2,6); lc = (1,0) (2,7). +fn left_join_db() -> Db { let mut db = Db::new(); db.table("la", &[(1, 0), (2, 0)]); db.table("lb", &[(1, 5), (2, 6)]); - db.table("lc", &[(1, 0), (2, 0)]); + db.table("lc", &[(1, 0), (2, 7)]); + db +} + +/// The leaf a one-relation conjunct reads can be a LEFT JOIN, here `la LEFT JOIN lb`, relation 0. +#[test] +fn a_one_relation_conjunct_over_a_left_join_filters_after_the_left_join() { + let mut db = left_join_db(); + // On the NULLABLE side the filter must stay above the left join. Pushed into lb it would only + // thin lb, and la's row 2 would come back NULL-extended as (2, NULL, 2). assert_eq!( db.rows( "SELECT la.id, lb.id, lc.id FROM la LEFT JOIN lb ON la.id = lb.id \ @@ -391,6 +417,58 @@ fn a_one_relation_conjunct_over_a_left_join_filters_after_the_left_join() { ), r(&[&[1, 1, 1]]), ); + // A relation AFTER the 4-column leaf: lc starts at column 4, not at 2 × its position, so this + // is the arm that sees a leaf-local remap computed from the wrong offset. + assert_eq!( + db.rows( + "SELECT la.id, lc.id FROM la LEFT JOIN lb ON la.id = lb.id \ + JOIN lc ON la.id = lc.id AND lc.v = 7;" + ), + r(&[&[2, 2]]), + ); + // On the PRESERVED side it may go below the left join, σp(la ⟕ lb) = σp(la) ⟕ lb, and it + // should: that is what reaches la's primary-key index. + let q = "SELECT la.id FROM la LEFT JOIN lb ON la.id = lb.id JOIN lc ON la.id = lc.id AND la.id = 2;"; + assert_eq!(db.rows(q), r(&[&[2]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(plan.contains("Index scan on la"), "`la.id = 2` did not reach la through the left join:\n{plan}"); +} + +/// The same rule for WHERE, which `optimizer::push` used to break: it routed a conjunct by its +/// columns alone, so a WHERE on the nullable side of a LEFT JOIN went below it and let the +/// NULL-extended rows through — `[1, 2]` here instead of `[1]`. +#[test] +fn a_where_conjunct_on_the_nullable_side_of_a_left_join_filters_after_it() { + let mut db = left_join_db(); + assert_eq!( + db.rows("SELECT la.id FROM la LEFT JOIN lb ON la.id = lb.id WHERE lb.v = 5;"), + r(&[&[1]]), + "la's row 2 has no lb row with v = 5; NULL = 5 is not true, so it must not come back" + ); + // The preserved side still goes down, to the index. + let q = "SELECT la.id FROM la LEFT JOIN lb ON la.id = lb.id WHERE la.id = 2;"; + assert_eq!(db.rows(q), r(&[&[2]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(plan.contains("Index scan on la"), "a preserved-side WHERE stopped reaching la's index:\n{plan}"); +} + +/// §1.3's third property: a query every relation of which is linked is searched exactly as before +/// D255 — no cross-product candidate at all. Here one would WIN on cost, so a search that admitted +/// it would show it. Worked by hand from `cost_model.rs`, no statistics (cb is costed at 1,000 rows): +/// ca and cc are 1-row primary-key index scans (13 each); every plan through the links costs +/// 41 + 13 + 0.1 = 54.1, while `ca × cc` first and then cb on both keys costs 26.01 + 18 + 10 = +/// 54.01, because the two-key join is estimated at one row. +#[test] +fn a_connected_join_is_never_planned_with_a_cross_product() { + let mut db = Db::new(); + db.table("ca", &[(1, 0), (2, 0)]); + db.table("cb", &[(1, 1), (2, 1), (3, 2)]); + db.table("cc", &[(1, 0), (2, 0)]); + let q = "SELECT ca.id, cb.id, cc.id FROM ca JOIN cb ON ca.id = cb.v AND ca.id = 1 \ + JOIN cc ON cb.v = cc.id AND cc.id = 1;"; + assert_eq!(db.rows(q), r(&[&[1, 1, 1], &[1, 2, 1]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(!plan.contains("(on true)"), "a linked query was planned through a cross product:\n{plan}"); } /// POSITIVE CONTROL — passes at 9aa6968 and must keep passing. The left-deep path at d66's width From 986eef517e22236e7ad9ad0232c597263da558e3 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:21 -0400 Subject: [PATCH 252/315] D255 fix, amendment 2: join-type-aware push, ON placement through it, TRUE constants folded (UNBUILT) optimizer::push sends a conjunct below a join only into a side that join never NULL-extends (left: Inner/Left; right: Inner/Right), fixing WHERE on the nullable side of a LEFT JOIN. filter_leaf delegates to push, so a preserved- side ON conjunct on a LEFT JOIN leaf reaches its table's index and a nullable- side one stays above. A column-free conjunct that evaluates TRUE is dropped, so ON TRUE plans as the left-deep path always did. Doc count 523,250. --- src/optimizer/optimizer.rs | 18 +++++++++++-- src/optimizer/search_algorithm.rs | 45 +++++++++++++++---------------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/optimizer/optimizer.rs b/src/optimizer/optimizer.rs index 01477038..9357d801 100644 --- a/src/optimizer/optimizer.rs +++ b/src/optimizer/optimizer.rs @@ -363,6 +363,18 @@ pub fn wrap_filter(plan: LogicalPlan, conjuncts: Vec) -> LogicalPlan } } +/// Carry `carried` — conjuncts over `plan`'s output columns — down to the lowest node that can +/// evaluate each one, merging them into any `Filter` on the way. +/// +/// # D255 — a conjunct only goes below a join into a side that join never NULL-extends +/// +/// This used to route a conjunct by the columns it reads and nothing else, so under +/// `a LEFT JOIN b ON a.id = b.id WHERE b.v = 5` the `b.v = 5` went into `b`, BELOW the left join. +/// There it only removes b's rows before the join, and every `a` row they would have matched comes +/// back NULL-extended — rows the WHERE exists to exclude, since `NULL = 5` is not true. A wrong +/// answer. The preserved side is safe, σp(L ⟕ R) = σp(L) ⟕ R when p reads only L, so a LEFT join +/// still takes left-side conjuncts; anything it cannot take stays in a `Filter` above it. +/// `reorder_inner_joins` places one-relation ON conjuncts through here for the same reason. pub fn push(plan: LogicalPlan, carried: Vec) -> LogicalPlan { match plan { LogicalPlan::Filter { input, predicate } => { @@ -372,15 +384,17 @@ pub fn push(plan: LogicalPlan, carried: Vec) -> LogicalPlan { } LogicalPlan::Join { left, right, join_type, on } => { let left_width = left.output_schema().len(); + let into_left = matches!(join_type, JoinType::Inner | JoinType::Left); + let into_right = matches!(join_type, JoinType::Inner | JoinType::Right); let (mut go_left, mut go_right, mut stay) = (Vec::new(), Vec::new(), Vec::new()); for expr in carried { let mut cols = HashSet::new(); collect_columns(&expr, &mut cols); if cols.is_empty() { stay.push(expr); - } else if cols.iter().all(|&c| c < left_width) { + } else if into_left && cols.iter().all(|&c| c < left_width) { go_left.push(expr); - } else if cols.iter().all(|&c| c >= left_width) { + } else if into_right && cols.iter().all(|&c| c >= left_width) { go_right.push(remap(expr, left_width)); } else { stay.push(expr); diff --git a/src/optimizer/search_algorithm.rs b/src/optimizer/search_algorithm.rs index 4ee9df0f..5bee1017 100644 --- a/src/optimizer/search_algorithm.rs +++ b/src/optimizer/search_algorithm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, split_and, wrap_filter}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; +use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, execution::executor::evaluate, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, push, split_and}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; pub const MAX_DP_RELATIONS: usize = 12; @@ -34,10 +34,11 @@ pub struct Sub { /// `a JOIN b ON a.id = b.id AND b.v = 5` ignored `b.v = 5`, and `ON a.id = b.id AND 1 = 0` /// returned every match. Now, as in any planner that does predicate placement: /// -/// * **one relation** — a filter on that relation's LOGICAL leaf, before `optimize` chooses its -/// access path, so `ON b.id = 3` reaches the index exactly as `WHERE b.id = 3` does. See -/// [`filter_leaf`] for why it merges with, and never pushes into, the leaf. -/// * **no relation** — a filter over the whole join. +/// * **one relation** — a filter on that relation's LOGICAL leaf, placed by the same +/// [`push`] WHERE uses and before `optimize` chooses an access path, so `ON b.id = 3` reaches the +/// index as `WHERE b.id = 3` does. See [`filter_leaf`]. +/// * **no relation** — a filter over the whole join, unless it is TRUE, when it is dropped: it +/// keeps every row, and `ON TRUE` then plans exactly as it did past 12 relations before D255. /// * **two or more** — a bridge, unchanged. /// /// The other half of D255 is that a join no predicate links now plans as a cross product at every @@ -73,7 +74,13 @@ pub fn reorder_inner_joins(plan: LogicalPlan, catalog: &Catalog) -> Result constant.push(part), + // A `BoundExpr` is literals and operators over columns, so one that reads no column + // has one value for every row. Only TRUE is folded; anything else — FALSE, NULL, a + // non-boolean, an evaluation error — stays a per-row filter, where WHERE keeps its + // own column-free conjuncts too, so it fails or empties the join the same way. + 0 => if !matches!(evaluate(&part, &[]), Ok(Value::Boolean(true))) { + constant.push(part) + }, 1 => { // Global column numbers → the leaf's own, which start at 0. let r = rel.trailing_zeros() as usize; @@ -107,25 +114,17 @@ pub fn reorder_inner_joins(plan: LogicalPlan, catalog: &Catalog) -> Result) -> LogicalPlan { +/// * Otherwise [`push`] places them, exactly as it placed the WHERE conjuncts already on the leaf: +/// - into ONE filter with a WHERE filter already there. Two stacked filters are the same rows, +/// but `optimize` only considers an index for a `Filter` directly over a `Scan`; +/// - through a LEFT JOIN leaf into its preserved side, which is sound and reaches that table's +/// index — and never into its nullable side, where a conjunct stops excluding the +/// NULL-extended rows this INNER join's ON must exclude. It stays above the left join. +fn filter_leaf(leaf: LogicalPlan, filters: Vec) -> LogicalPlan { if filters.is_empty() { return leaf; } - match leaf { - LogicalPlan::Filter { input, predicate } => { - let mut all = Vec::new(); - split_and(predicate, &mut all); - all.append(&mut filters); - wrap_filter(*input, all) - } - other => wrap_filter(other, filters), - } + push(leaf, filters) } /// The exhaustive join-order search, for at most `MAX_DP_RELATIONS` relations. @@ -166,7 +165,7 @@ fn filter_leaf(leaf: LogicalPlan, mut filters: Vec) -> LogicalPlan { /// connected, bridge-plannable subset of every size. /// /// The cost: with no join predicate at all every subset is closed and every split is built — -/// 523,265 at 12 relations, the same count a 12-relation clique already built before D255, which is +/// 523,250 at 12 relations, the same count a 12-relation clique already built before D255, which is /// the load `MAX_DP_RELATIONS` bounds. fn dynamic_program(base: Vec, conjuncts: &[(u64, BoundExpr)], orig_offset: &[usize], widths: &[usize], catalog: &Catalog) -> Result { let n = widths.len(); From 1c20cbe04b03ec1b953c0c59ed139873d0400807 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:17:39 -0400 Subject: [PATCH 253/315] =?UTF-8?q?UNBUILT=20D254:=20red=20tests=20?= =?UTF-8?q?=E2=80=94=20CREATE=20TABLE,=20CREATE=20INDEX=20and=20CREATE=20F?= =?UTF-8?q?ULLTEXT=20INDEX=20over=20a=20page,=20and=20persist=20itself,=20?= =?UTF-8?q?are=20refused=20by=20size=20with=20the=20catalog=20intact=20on?= =?UTF-8?q?=20its=20pages=20and=20no=20page=20allocated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/d254_catalog_persist_bound.rs | 279 ++++++++++++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 tests/d254_catalog_persist_bound.rs diff --git a/tests/d254_catalog_persist_bound.rs b/tests/d254_catalog_persist_bound.rs new file mode 100644 index 00000000..694f469d --- /dev/null +++ b/tests/d254_catalog_persist_bound.rs @@ -0,0 +1,279 @@ +//! D254 — a catalog entry larger than one catalog page, driven from ordinary DDL. +//! +//! `Catalog::persist` places each table's entry on a page with `has_space`, and `break`s at the first +//! entry that does not fit. An entry larger than an EMPTY page fits nowhere, so the loop links a new, +//! empty catalog page on every turn and never advances past it, until page allocation refuses. What +//! it leaves: +//! +//! - every free page below the region floor used up; +//! - a catalog image that has lost every table sorting after the refused one; +//! - and, from CREATE TABLE and CREATE INDEX, the pages each allocated before persisting, never +//! freed. +//! +//! Ordinary identifiers reach it: 150 columns with 25-byte names are enough. +//! +//! Each test holds a table `zz`, which sorts after the one refused, and asserts three things: +//! +//! - the statement is refused by the size check, naming the table; +//! - the catalog is intact, in memory AND as its pages read back (`Catalog::open` over the same +//! pool), with `zz` still there; +//! - no page was allocated. The fixture has freed nothing, so the bitmap has no holes, and ANY +//! allocation during the statement raises `bitmap_high_water`. That is the blind spot: an +//! allocation into a hole below the mark would not show, and there is no hole here to use. +//! +//! **Every harness bounds the allocator** (`reserve_region` at page 256) BEFORE the catalog exists, +//! so the unfixed loop, or a mutant of the fix, refuses at page 256 instead of filling the disk. + +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::catalog_page::{CatalogPage, FullTextIndexInfo, IndexInfo, TableEntry}; +use ferrodb::catalog::column::{Column, DataType}; +use ferrodb::catalog::schema::Schema; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::DiskManager; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + bp: Arc, + dm: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("d254.db")) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + dm.reserve_region("test floor", 256, u32::MAX).unwrap(); + let bp = Arc::new(BufferPoolManager::new(dm.clone())); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("d254.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let mut db = Db { catalog, bp, dm, txn, _dir: dir }; + // The table that sorts after every refused one, so a truncated catalog image loses it. + db.exec("CREATE TABLE zz (id INTEGER NOT NULL);").unwrap(); + db + } + + fn exec(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(FerroError::SqlParseError( + parser.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut s = Session::new(); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut s) + } + + fn high_water(&self) -> u32 { + self.dm.bitmap_high_water().unwrap() + } + + /// The tables the catalog's pages hold, read back through the same pool. + fn tables_on_the_pages(&self) -> Vec { + let reread = Catalog::open(self.bp.clone(), self.catalog.first_catalog_page_id) + .expect("the catalog pages must still read back"); + let mut names: Vec = reread.tables.keys().cloned().collect(); + names.sort(); + names + } +} + +/// `n` distinct names of exactly `len` bytes. +fn names(prefix: char, n: usize, len: usize) -> Vec { + (0..n) + .map(|i| { + let head = format!("{prefix}{i:03}"); + format!("{head}{}", "x".repeat(len - head.len())) + }) + .collect() +} + +fn entry_of(name: &str, columns: Vec) -> TableEntry { + TableEntry { + name: name.to_string(), + first_directory_page_id: 0, + primary_index_root: 0, + time_travel_root: 0, + schema: Schema::new(columns), + indexes: Vec::new(), + fulltext_indexes: Vec::new(), + } +} + +fn assert_refused_by_size(err: &FerroError, table: &str, statement: &str) { + let msg = err.to_string(); + assert!( + matches!(err, FerroError::Constraint(_)) + && msg.contains(&format!("\"{table}\"")) + && msg.contains("catalog page"), + "{statement}: expected the size refusal naming table {table}, got: {err:?}" + ); +} + +fn assert_catalog_intact(db: &Db, present: &[&str], absent: &str, statement: &str) { + for t in present { + assert!(db.catalog.get_table(t).is_some(), "{statement}: table {t} is gone from the catalog in memory"); + } + let on_pages = db.tables_on_the_pages(); + for t in present { + assert!( + on_pages.iter().any(|n| n == t), + "{statement}: table {t} is gone from the catalog's pages, which list only {on_pages:?}. A \ + restart would lose it" + ); + } + assert!( + !on_pages.iter().any(|n| n == absent) && db.catalog.get_table(absent).is_none(), + "{statement}: the refused table {absent} is in the catalog" + ); +} + +#[test] +fn a_create_table_whose_entry_exceeds_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone() { + let mut db = Db::new(); + let cols = names('c', 150, 25); + // Premise: the entry, with its ordinary 25-byte names, is larger than an empty catalog page. + let columns: Vec = + cols.iter().enumerate().map(|(i, n)| Column::new(n.clone(), DataType::Integer, i != 0)).collect(); + assert!( + !CatalogPage::new(0).has_space(&entry_of("m", columns)), + "150 columns of 25 bytes fit an empty catalog page, so this tests nothing" + ); + + let before = db.high_water(); + let decl: Vec = cols + .iter() + .enumerate() + .map(|(i, n)| if i == 0 { format!("{n} INTEGER NOT NULL") } else { format!("{n} INTEGER") }) + .collect(); + let err = db + .exec(&format!("CREATE TABLE m ({});", decl.join(", "))) + .err() + .expect("a table whose catalog entry no page can hold must be refused"); + assert_refused_by_size(&err, "m", "CREATE TABLE"); + assert_eq!(db.high_water(), before, "the refused CREATE TABLE allocated pages it never freed"); + assert_catalog_intact(&db, &["zz"], "m", "CREATE TABLE"); + db.exec("CREATE TABLE after_table (id INTEGER NOT NULL);") + .expect("an ordinary CREATE TABLE after the refusal must still work"); +} + +#[test] +fn a_create_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone() { + let mut db = Db::new(); + let cols = names('c', 15, 255); + let decl: Vec = cols + .iter() + .enumerate() + .map(|(i, n)| if i == 0 { format!("{n} INTEGER NOT NULL") } else { format!("{n} INTEGER") }) + .collect(); + db.exec(&format!("CREATE TABLE t ({});", decl.join(", "))).unwrap(); + + // Premise: the table's entry fits a page, and the same entry with the index does not. + let mut with_index = db.catalog.get_table("t").expect("t exists").clone(); + assert!(CatalogPage::new(0).has_space(&with_index), "the table itself does not fit, so this is not CREATE INDEX"); + with_index.indexes.push(IndexInfo { column_name: cols[1].clone(), root_page_id: 0 }); + assert!(!CatalogPage::new(0).has_space(&with_index), "the entry with its index still fits, so this tests nothing"); + + let before = db.high_water(); + let err = db + .exec(&format!("CREATE INDEX ix ON t ({});", cols[1])) + .err() + .expect("an index that grows the entry past a catalog page must be refused"); + assert_refused_by_size(&err, "t", "CREATE INDEX"); + assert_eq!(db.high_water(), before, "the refused CREATE INDEX allocated pages it never freed"); + assert_catalog_intact(&db, &["t", "zz"], "m", "CREATE INDEX"); + assert!(db.catalog.get_table("t").unwrap().indexes.is_empty(), "the refused index is on the table in memory"); + db.exec("CREATE TABLE after_index (id INTEGER NOT NULL);") + .expect("an ordinary CREATE TABLE after the refusal must still work"); +} + +#[test] +fn a_create_fulltext_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone() { + let mut db = Db::new(); + let cols = names('c', 15, 255); + let decl: Vec = cols + .iter() + .enumerate() + .map(|(i, n)| match i { + 0 => format!("{n} INTEGER NOT NULL"), + 1 => format!("{n} VARCHAR(20)"), + _ => format!("{n} INTEGER"), + }) + .collect(); + db.exec(&format!("CREATE TABLE t ({});", decl.join(", "))).unwrap(); + + let mut with_index = db.catalog.get_table("t").expect("t exists").clone(); + assert!( + CatalogPage::new(0).has_space(&with_index), + "the table itself does not fit, so this is not CREATE FULLTEXT INDEX" + ); + with_index.fulltext_indexes.push(FullTextIndexInfo { column_name: cols[1].clone(), root_page_id: 0 }); + assert!( + !CatalogPage::new(0).has_space(&with_index), + "the entry with its full-text index still fits, so this tests nothing" + ); + + let before = db.high_water(); + let err = db + .exec(&format!("CREATE FULLTEXT INDEX ix ON t ({});", cols[1])) + .err() + .expect("a full-text index that grows the entry past a catalog page must be refused"); + assert_refused_by_size(&err, "t", "CREATE FULLTEXT INDEX"); + assert_eq!(db.high_water(), before, "the refused CREATE FULLTEXT INDEX allocated pages it never freed"); + assert_catalog_intact(&db, &["t", "zz"], "m", "CREATE FULLTEXT INDEX"); + assert!( + db.catalog.get_table("t").unwrap().fulltext_indexes.is_empty(), + "the refused full-text index is on the table in memory" + ); + db.exec("CREATE TABLE after_fulltext (id INTEGER NOT NULL);") + .expect("an ordinary CREATE TABLE after the refusal must still work"); +} + +/// `persist` itself, for an entry that reached the catalog map by a path with no check of its own. +/// +/// The DDL paths are checked before they allocate. That makes `persist`'s own check unreachable from +/// them, so it is driven here directly: an oversized entry is put into the map, and `persist` must +/// refuse it before it writes any page. Otherwise it truncates the image to the tables sorting +/// before it, and links empty pages until the floor. +#[test] +fn persist_refuses_an_entry_no_page_can_hold_before_writing_any_page() { + let mut db = Db::new(); + let columns: Vec = names('c', 150, 25) + .into_iter() + .enumerate() + .map(|(i, n)| Column::new(n, DataType::Integer, i != 0)) + .collect(); + let oversized = entry_of("m", columns); + assert!(!CatalogPage::new(0).has_space(&oversized), "the entry fits an empty page, so this tests nothing"); + + let before = db.high_water(); + db.catalog.tables.insert("m".to_string(), oversized); + let err = db.catalog.persist().err().expect("persist must refuse an entry no catalog page can hold"); + db.catalog.tables.remove("m"); + + assert_refused_by_size(&err, "m", "persist"); + assert_eq!(db.high_water(), before, "persist allocated pages for an entry it then refused"); + assert_catalog_intact(&db, &["zz"], "m", "persist"); +} From d8bf380851327aa429f3420a4ce42be3ba5e0d9b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:18:27 -0400 Subject: [PATCH 254/315] =?UTF-8?q?UNBUILT=20D254:=20PREREG=20=E2=80=94=20?= =?UTF-8?q?the=20persist=20loop,=20Run=20R=20at=201c20cbe=20(0/4,=20Io),?= =?UTF-8?q?=20the=20four=20checks,=20P1-P4,=20and=20D249's=20E1/E4=20on=20?= =?UTF-8?q?this=20tree;=20before=20the=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d254/PREREG.md | 118 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 bench/d254/PREREG.md diff --git a/bench/d254/PREREG.md b/bench/d254/PREREG.md new file mode 100644 index 00000000..640aa750 --- /dev/null +++ b/bench/d254/PREREG.md @@ -0,0 +1,118 @@ +# D254 — pre-registration, written BEFORE the fix (nothing built) + +Branch `d254-catalog-persist-bound`, a CHILD of `d249-alter-encodable` @ `06b0188`, D249's final tip. It lives in +`/Users/idide/wt/ferrodb-d254.noindex`. Quiet mode: nothing on this branch has been compiled or run, so every line +below is a prediction. Amendments append only. + +Source: the D249 lane's §5 candidate, confirmed and widened by `d249_review.md` @ `13577b1` (F3), and made ledger D254 +by the lead, lead-verified at `9aa6968` `catalog.rs:474-525`. + +**Instrument:** `timeout 1800 cargo test --no-fail-fast --test d254_catalog_persist_bound`, at default QoS, under the +shared suite lock. Its `-- --list` is the authority for the count. + +## The defect (READ at `9aa6968`) + +- **`Catalog::persist` never places an entry that cannot fit an empty page.** + - It sorts the entries by name (`catalog.rs:482-483`) and walks them with a shared `peek` iterator (`:484`). + - It adds each entry only while `page.has_space(entry)` (`:498`), and `break`s at the first that does not fit. + - An entry larger than an EMPTY page (`HEADER_SIZE + entry.length() > PAGE_SIZE`) fits no page. The iterator + never advances past it, and `has_more` stays true (`:505`). + - When `next_catalog_page == 0`, it calls `new_page()?` (`:508-509`), which allocates and writes a zero page. It + stamps and links that page, serializes the current page (which holds only the entries before the oversized one), + and moves on (`:553`). This repeats until `new_page` fails. +- **`allocate` refuses only at an unbounded region** (`disk_manager.rs:399-431`). + - The CLI and `examples/pgserver.rs` set that region at `DEFAULT_ARENA_HEADROOM` = 32,736 pages. + - A `DiskManager` with no region never refuses (INFERRED). +- **Ordinary DDL reaches it** (the review's F3, READ-derived): + - CREATE TABLE with 150 INTEGER columns of 25-byte names (4,200 bytes of columns); + - CREATE INDEX on a table near the limit, which adds `5 + len(column)` bytes; + - CREATE FULLTEXT INDEX, the same. + - Nothing upstream bounds column count times name bytes. +- **What it leaves:** + - the table region used up; + - a catalog image holding only the entries sorted before the refused one, so a restart would lose the tables + after it (INFERRED); + - the pages that `create_table` (`:160-162`), `create_index` (`:233`) and `create_fulltext_index` allocated + before persisting, which are never freed. `persist_or_undo` undoes the map, not the pages. + - The empty chain is reclaimed by the next successful `persist` (the review, READ). + +## Red tests at `1c20cbe` (additions only: `tests/d254_catalog_persist_bound.rs`, `279 0`) + +**The shared setup.** Every harness calls `reserve_region("test floor", 256, u32::MAX)` before `Catalog::create`, so +the unfixed loop refuses at page 256 instead of filling the disk. Every harness also holds a table `zz`, which sorts +after every refused name. + +**Each test asserts three things:** + +- **the refusal**: the size refusal, a `Constraint` whose message names the table and "catalog page"; +- **no page allocated**: `DiskManager::bitmap_high_water` is unchanged. The fixture frees nothing, so there is no + hole below the mark for an allocation to hide in; +- **the catalog is intact**: in memory, AND on its pages as `Catalog::open` over the same pool reads them back, with + `zz` still present and the refused object absent. + +Each premise is asserted through `CatalogPage::has_space`. + +| key | test | +|---|---| +| **CT** | `a_create_table_whose_entry_exceeds_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone`: 150 columns of 25 bytes. A later CREATE TABLE must still work | +| **CI** | `a_create_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone`: 15 columns of 255 bytes (3,911 with the header, which fits), then an index on the second (4,171, which does not) | +| **CF** | `a_create_fulltext_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone`: the same shape, with the second column `VARCHAR(20)`, and a full-text index on it | +| **CP** | `persist_refuses_an_entry_no_page_can_hold_before_writing_any_page`: an oversized entry is inserted into `catalog.tables` directly, then `persist()` is called. This is the only test that reaches `persist`'s own check, because the DDL paths refuse before it | + +**Run R at `1c20cbe` (predicted): 0 passed, 4 failed.** Each fails at its refusal assertion. At this tree, `persist` +loops until the test floor refuses, so the error is `Io("no free page below …")` and not a `Constraint`. + +## The fix, as it will be made + +1. **`persist` asks the encoder about every entry before it writes any page.** After sorting, and before the first + `fetch_page`, it runs `for e in &sorted { refuse_unless_encodable(e)?; }`. This is D249's function, which asks + `has_space` on an empty page and then `serialize`s a one-entry page. + - It closes the loop and the truncated image for every caller, including future ones. + - It also closes D141's partial-image window: an encoder refusal on catalog page k used to leave pages before k + rewritten. +2. **`create_table` checks the entry before its three allocations.** It builds the `TableEntry` with placeholder + roots (0), checks it, and only then allocates and fills in the real roots. Roots are fixed-width `u32`, so the + placeholder cannot change the answer. +3. **`create_index` and `create_fulltext_index` check the entry with the new index record** (root 0), before + `BPlusTreeManager::create`. +4. **`refuse_unless_encodable`'s doc names its callers.** + +**Why four checks and not one.** The pre-pass in `persist` is the correctness fix: no loop, and no truncated image. +The three DDL checks stop the pages those statements allocate before persisting from leaking. Each has its own +killer: + +- CP drives `persist` directly, so the DDL checks cannot mask the pre-pass. +- CT, CI and CF each assert no allocation. With only the pre-pass, each would pass its refusal and catalog + assertions and fail its high-water assertion. + +**Cost.** Every `persist` now serializes every entry twice: once in the pre-pass, and once for real. `persist` was +already O(tables) per DDL, so this is a constant factor of 2 on DDL, and nothing grows with rows or branches. + +## Run G at the fix (predicted) + +- `--test d254_catalog_persist_bound`: its list shows **4**, and all **4 pass**. +- **Per-target:** D249's 2588 + 4 = **2592** on macOS. The 2579 at its base is measured on main's tree. + +## Mutants (cut from the fix; predicted) + +| mutant | what it does | fails | +|---|---|---| +| **P1 `persist_prepass_removed`** | the `persist` pre-pass deleted | **CP**: the error is `Io`, `zz` is lost from the pages, and the high water rises. CT, CI and CF survive it, because the DDL checks refuse first | +| **P2 `create_table_check_removed`** | `create_table`'s early check deleted | **CT**, at its high-water assertion. The 3 pages are allocated before `persist`'s pre-pass refuses with the same `Constraint` | +| **P3 `create_index_check_removed`** | `create_index`'s early check deleted | **CI**, at high water (the index's root page) | +| **P4 `create_fulltext_check_removed`** | `create_fulltext_index`'s early check deleted | **CF**, at high water | + +### D249's mutants on THIS tree (D249's amendment 4, G1, named the tree difference) + +With the pre-pass in `persist`, a D249 mutant that sends an oversized entry to `persist` gets a `Constraint`, not the +loop: + +- **E1 → AI at its SCHEMA assertion**, not at its error assertion. The error is the size `Constraint` naming `"t"` + and "catalog page", so that assertion passes; then the in-memory rename is found installed. AR, AA, A9 and A33 are + unchanged. +- **E4 → AI, the same way.** +- E2, E5 and E7 are unchanged. +- **E3 now panics on more tests.** `refuse_unless_encodable`'s `has_space` arm guards every caller, so removing it + makes U1, AI, CT, CI, CF and CP all panic in `serialize`. + +D254's run script runs E1 and E4 on `d249_alter_encodable` to confirm the moved assertion. From 64d6d90aacf70b6ec4b655f61bd81fc141cf4dc4 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:19:03 -0400 Subject: [PATCH 255/315] UNBUILT D254: persist asks the encoder about every entry before writing any page; create_table, create_index and create_fulltext_index ask before they allocate --- src/catalog/catalog.rs | 48 ++++++++++++++++++++++++++++++------- src/catalog/catalog_page.rs | 17 +++++++++---- 2 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index 2ec9182f..df98229b 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -1,7 +1,9 @@ use std::collections::HashMap; use std::sync::Arc; use crate::buffer::buffer_pool::BufferPoolManager; -use crate::catalog::catalog_page::{CatalogPage, FullTextIndexInfo, IndexInfo, TableEntry}; +use crate::catalog::catalog_page::{ + refuse_unless_encodable, CatalogPage, FullTextIndexInfo, IndexInfo, TableEntry, +}; use crate::catalog::stats::{ColumnStats, TableStats}; use crate::error::FerroError; use crate::storage::heap_file_manager::HeapFileManager; @@ -157,18 +159,26 @@ impl Catalog { // both halves are false, because `system_views::view_for` yields to the real table. The // honest answer there is the one above: the table already exists. crate::catalog::system_views::reject_view_name_collision(&name)?; - let hfm = HeapFileManager::new(self.buffer_pool.clone())?; - let primary = BPlusTreeManager::::create(self.buffer_pool.clone())?; - let tt_heap = HeapFileManager::new(self.buffer_pool.clone())?; - let entry = TableEntry { + // **Asked of the encoder BEFORE the three allocations below** (D254). An entry the catalog + // cannot write is refused by `persist` too, but only after these pages exist, and nothing + // frees them: the undo below removes the entry, not its pages. The roots are placeholders + // here; they are fixed-width, so the real ones cannot change the answer. + let mut entry = TableEntry { name: name.clone(), - first_directory_page_id: hfm.first_directory_page_id, + first_directory_page_id: 0, schema, - primary_index_root: primary.root_page_id.load(Ordering::Relaxed), - time_travel_root: tt_heap.first_directory_page_id, + primary_index_root: 0, + time_travel_root: 0, indexes: Vec::new(), fulltext_indexes: Vec::new() }; + refuse_unless_encodable(&entry)?; + let hfm = HeapFileManager::new(self.buffer_pool.clone())?; + let primary = BPlusTreeManager::::create(self.buffer_pool.clone())?; + let tt_heap = HeapFileManager::new(self.buffer_pool.clone())?; + entry.first_directory_page_id = hfm.first_directory_page_id; + entry.primary_index_root = primary.root_page_id.load(Ordering::Relaxed); + entry.time_travel_root = tt_heap.first_directory_page_id; self.tables.insert(name.clone(), entry); // Undo: the table never existed if it could not be written down. self.persist_or_undo(|c| { @@ -228,6 +238,13 @@ impl Catalog { .map(|c| c.name.as_str()).collect::>().join(", ") )))?; + // Asked of the encoder BEFORE the tree is allocated (D254): the entry with this index, + // under a placeholder root. `persist` would refuse it too, but after the tree's pages + // exist, and the undo below pops the record, not the pages. + let mut with_index = entry.clone(); + with_index.indexes.push(IndexInfo { column_name: column.to_string(), root_page_id: 0 }); + refuse_unless_encodable(&with_index)?; + (entry.schema.clone(), entry.first_directory_page_id, col_index) }; let sec_tree = BPlusTreeManager::<(Value, Value), ()>::create(self.buffer_pool.clone())?; @@ -298,6 +315,11 @@ impl Catalog { ))); } + // Asked of the encoder BEFORE the tree is allocated (D254), as in `create_index`. + let mut with_index = entry.clone(); + with_index.fulltext_indexes.push(FullTextIndexInfo { column_name: column.to_string(), root_page_id: 0 }); + refuse_unless_encodable(&with_index)?; + (entry.schema.clone(), entry.first_directory_page_id, col_index) }; let ft_tree = BPlusTreeManager::<(Value, Value), ()>::create(self.buffer_pool.clone())?; @@ -481,6 +503,16 @@ impl Catalog { // reproducible at all. let mut sorted: Vec<&TableEntry> = self.tables.values().collect(); sorted.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + // **Every entry is asked of the encoder before any page is written** (D254). The loop below + // places entries with `has_space` and `break`s at the first that does not fit. An entry + // larger than an EMPTY page fits none, so the loop never advances past it: it linked a fresh + // page per turn until allocation refused, and each turn rewrote the current page holding + // only the entries sorted before it, which truncated the catalog's image. An encoder refusal + // on a later page likewise left the earlier pages rewritten. Asking first means `persist` + // refuses with every page as it was, and `persist_or_undo` undoes cleanly. + for entry in &sorted { + refuse_unless_encodable(entry)?; + } let mut iter = sorted.into_iter().peekable(); loop { diff --git a/src/catalog/catalog_page.rs b/src/catalog/catalog_page.rs index 2c492f9d..bbd93590 100644 --- a/src/catalog/catalog_page.rs +++ b/src/catalog/catalog_page.rs @@ -385,12 +385,19 @@ impl CatalogPage { } -/// Refuse an entry the catalog page format cannot hold, **by asking the encoder itself** (D249). +/// Refuse an entry the catalog page format cannot hold, **by asking the encoder itself** (D249, +/// D254). /// -/// For a caller that has to know BEFORE it changes anything whether an entry will persist. `ALTER` -/// decides everything in `Catalog::plan_alters` and only then rewrites the heap, and a refusal that -/// first arrives at `persist`, after the rewrite, can neither be undone (the rows are already in the -/// new shape) nor left in memory (every later `persist` would re-serialize it and refuse too). +/// For a caller that has to know BEFORE it changes anything whether an entry will persist: +/// +/// - `ALTER` decides everything in `Catalog::plan_alters` and only then rewrites the heap. A refusal +/// that first arrives at `persist`, after the rewrite, can neither be undone (the rows are already +/// in the new shape) nor left in memory (every later `persist` would re-serialize it and refuse +/// too) (D249). +/// - `create_table`, `create_index` and `create_fulltext_index` ask it before they allocate their +/// pages, which nothing would free after a refusal (D254). +/// - `Catalog::persist` asks it of every entry before it writes any page, because an entry no page +/// can hold used to send its placement loop round for ever, truncating the catalog's image (D254). /// /// **It is not a second length check.** It runs `serialize`, the single authority the header above /// describes, so a change to any guard in there changes this answer with it, and nothing here can From 1654b9b24dd835678bc7b91316ce99c1efc8e81b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:19:27 -0400 Subject: [PATCH 256/315] UNBUILT D254: mutants P1-P4 (the persist pre-pass and each DDL pre-check removed), cut from 64d6d90 --- bench/d254/make_mutants.py | 76 +++++++++++++++++++ .../mutants/P1_persist_prepass_removed.patch | 11 +++ .../P2_create_table_check_removed.patch | 11 +++ .../P3_create_index_check_removed.patch | 11 +++ .../P4_create_fulltext_check_removed.patch | 11 +++ 5 files changed, 120 insertions(+) create mode 100644 bench/d254/make_mutants.py create mode 100644 bench/d254/mutants/P1_persist_prepass_removed.patch create mode 100644 bench/d254/mutants/P2_create_table_check_removed.patch create mode 100644 bench/d254/mutants/P3_create_index_check_removed.patch create mode 100644 bench/d254/mutants/P4_create_fulltext_check_removed.patch diff --git a/bench/d254/make_mutants.py b/bench/d254/make_mutants.py new file mode 100644 index 00000000..4665e9ed --- /dev/null +++ b/bench/d254/make_mutants.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Write D254's mutants as `git apply`-able patches. + +The same shape as bench/d249/make_mutants.py. Each edit must match exactly once, or the script +refuses. It reads each file from a commit, never from the working tree, and writes only into +bench/d254/mutants/. Predictions are in bench/d254/PREREG.md. + + python3 bench/d254/make_mutants.py +""" +import difflib +import pathlib +import subprocess +import sys + +CATALOG = "src/catalog/catalog.rs" + +# Each is written as `if false { … }`, so `refuse_unless_encodable` stays referenced and the mutant +# builds under CI's `-D dead_code` as well. +MUTANTS = [ + ("P1_persist_prepass_removed", CATALOG, [( + " refuse_unless_encodable(entry)?;\n", + " if false { refuse_unless_encodable(entry)?; }\n", + )]), + ("P2_create_table_check_removed", CATALOG, [( + " refuse_unless_encodable(&entry)?;\n", + " if false { refuse_unless_encodable(&entry)?; }\n", + )]), + ("P3_create_index_check_removed", CATALOG, [( + " with_index.indexes.push(IndexInfo { column_name: column.to_string(), root_page_id: 0 });\n" + " refuse_unless_encodable(&with_index)?;\n", + " with_index.indexes.push(IndexInfo { column_name: column.to_string(), root_page_id: 0 });\n" + " if false { refuse_unless_encodable(&with_index)?; }\n", + )]), + ("P4_create_fulltext_check_removed", CATALOG, [( + " with_index.fulltext_indexes.push(FullTextIndexInfo { column_name: column.to_string(), root_page_id: 0 });\n" + " refuse_unless_encodable(&with_index)?;\n", + " with_index.fulltext_indexes.push(FullTextIndexInfo { column_name: column.to_string(), root_page_id: 0 });\n" + " if false { refuse_unless_encodable(&with_index)?; }\n", + )]), +] + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: make_mutants.py ", file=sys.stderr) + return 2 + out = pathlib.Path("bench/d254/mutants") + out.mkdir(parents=True, exist_ok=True) + for name, path, edits in MUTANTS: + src = subprocess.run( + ["git", "show", f"{sys.argv[1]}:{path}"], check=True, capture_output=True, text=True + ).stdout + text = src + for old, new in edits: + n = text.count(old) + if n != 1: + print(f"{name}: an edit matches {n} times, not once; refusing", file=sys.stderr) + return 1 + text = text.replace(old, new) + if text == src: + print(f"{name}: the mutant is identical to the source; refusing", file=sys.stderr) + return 1 + diff = difflib.unified_diff( + src.splitlines(keepends=True), + text.splitlines(keepends=True), + fromfile=f"a/{path}", + tofile=f"b/{path}", + n=3, + ) + (out / f"{name}.patch").write_text("".join(diff)) + print(f"{name}: written") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/d254/mutants/P1_persist_prepass_removed.patch b/bench/d254/mutants/P1_persist_prepass_removed.patch new file mode 100644 index 00000000..2fe4ea54 --- /dev/null +++ b/bench/d254/mutants/P1_persist_prepass_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -511,7 +511,7 @@ + // on a later page likewise left the earlier pages rewritten. Asking first means `persist` + // refuses with every page as it was, and `persist_or_undo` undoes cleanly. + for entry in &sorted { +- refuse_unless_encodable(entry)?; ++ if false { refuse_unless_encodable(entry)?; } + } + let mut iter = sorted.into_iter().peekable(); + diff --git a/bench/d254/mutants/P2_create_table_check_removed.patch b/bench/d254/mutants/P2_create_table_check_removed.patch new file mode 100644 index 00000000..18ef56f8 --- /dev/null +++ b/bench/d254/mutants/P2_create_table_check_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -172,7 +172,7 @@ + indexes: Vec::new(), + fulltext_indexes: Vec::new() + }; +- refuse_unless_encodable(&entry)?; ++ if false { refuse_unless_encodable(&entry)?; } + let hfm = HeapFileManager::new(self.buffer_pool.clone())?; + let primary = BPlusTreeManager::::create(self.buffer_pool.clone())?; + let tt_heap = HeapFileManager::new(self.buffer_pool.clone())?; diff --git a/bench/d254/mutants/P3_create_index_check_removed.patch b/bench/d254/mutants/P3_create_index_check_removed.patch new file mode 100644 index 00000000..48cd814f --- /dev/null +++ b/bench/d254/mutants/P3_create_index_check_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -243,7 +243,7 @@ + // exist, and the undo below pops the record, not the pages. + let mut with_index = entry.clone(); + with_index.indexes.push(IndexInfo { column_name: column.to_string(), root_page_id: 0 }); +- refuse_unless_encodable(&with_index)?; ++ if false { refuse_unless_encodable(&with_index)?; } + + (entry.schema.clone(), entry.first_directory_page_id, col_index) + }; diff --git a/bench/d254/mutants/P4_create_fulltext_check_removed.patch b/bench/d254/mutants/P4_create_fulltext_check_removed.patch new file mode 100644 index 00000000..9e3bca63 --- /dev/null +++ b/bench/d254/mutants/P4_create_fulltext_check_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -318,7 +318,7 @@ + // Asked of the encoder BEFORE the tree is allocated (D254), as in `create_index`. + let mut with_index = entry.clone(); + with_index.fulltext_indexes.push(FullTextIndexInfo { column_name: column.to_string(), root_page_id: 0 }); +- refuse_unless_encodable(&with_index)?; ++ if false { refuse_unless_encodable(&with_index)?; } + + (entry.schema.clone(), entry.first_directory_page_id, col_index) + }; From 1eb4da30d6f33a710acbd028f1a0a82fb9764c58 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 07:19:36 -0400 Subject: [PATCH 257/315] =?UTF-8?q?UNBUILT=20D254:=20PREREG=20amendment=20?= =?UTF-8?q?1=20=E2=80=94=20the=20fix=20and=20P1-P4=20recorded;=20counts=20?= =?UTF-8?q?4=20/=202592?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d254/PREREG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/bench/d254/PREREG.md b/bench/d254/PREREG.md index 640aa750..afd0a32b 100644 --- a/bench/d254/PREREG.md +++ b/bench/d254/PREREG.md @@ -116,3 +116,25 @@ loop: makes U1, AI, CT, CI, CF and CP all panic in `serialize`. D254's run script runs E1 and E4 on `d249_alter_encodable` to confirm the moved assertion. + +--- + +## Amendment 1 — the fix and mutants recorded, before any run (nothing built) + +| commit | what | +|---|---| +| `1c20cbe` | red tests CT, CI, CF, CP (additions only, `279 0`) | +| `d8bf380` | this PREREG | +| `64d6d90` | **the fix**: `persist`'s pre-pass; the checks in `create_table` (before its three allocations), `create_index` and `create_fulltext_index` (before the tree); `refuse_unless_encodable`'s doc | +| `1654b9b` | mutants P1–P4, cut from `64d6d90` by `bench/d254/make_mutants.py`, each passing `git apply --check`. D249's E1 and E4 (`bench/d249/mutants/`) also apply to this tree, since `alter.rs` is unchanged here | + +- **Code removed at `64d6d90`** (`git diff 06b0188 64d6d90 -- src/`): one import line, widened to add + `refuse_unless_encodable`, and `create_table`'s three allocations and entry literal. + - They are re-ordered, not deleted: the entry is built first with placeholder roots and checked, then the pages + are allocated and the roots filled in. The fields and values are unchanged. + - `catalog_page.rs` changes only a doc comment. +- **Counts:** + - `d254_catalog_persist_bound` has **4** `#[test]`, with no `cfg`, `ignore` or macro; + - `git diff 06b0188 1654b9b` adds 4, and from `9aa6968` it adds 13, with 0 removed. +- **Per-target: 2579 + 13 = 2592** (2579 measured on main's tree). +- **The list step is the authority:** `cargo test --test d254_catalog_persist_bound -- --list` is predicted to show 4. From f87e08c1b45977259c309ea924009e4646ff1031 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:13:56 -0400 Subject: [PATCH 258/315] D255/D266 red, amendment 4: WHERE-as-join-predicate, no plan-time evaluation, filtered-not-linked, outer-join controls, and push's rule for every outer join type (UNBUILT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests 17-20 in tests/d255_join_on_conjuncts.rs (20 total) and lib optimizer::tests::push_moves_a_conjunct_only_into_a_side_its_join_never_null_extends (D266: INNER/LEFT/RIGHT/FULL, a conjunct over both sides, carried order). All against 9aa6968's API. PREREG: artie-research frontier/lane_d255_join.md §2.4 @ a5f0482. --- src/optimizer/optimizer.rs | 51 +++++++++++++++++++ tests/d255_join_on_conjuncts.rs | 88 ++++++++++++++++++++++++++++++++- 2 files changed, 137 insertions(+), 2 deletions(-) diff --git a/src/optimizer/optimizer.rs b/src/optimizer/optimizer.rs index 9357d801..356328fa 100644 --- a/src/optimizer/optimizer.rs +++ b/src/optimizer/optimizer.rs @@ -635,4 +635,55 @@ mod tests { _ => panic!() } } + + /// D266 — `push` moves a conjunct below a join only into a side that join never NULL-extends: + /// the left side under INNER and LEFT, the right side under INNER and RIGHT, neither under FULL. + /// A conjunct over both sides always stays above. What stays is a `Filter` over the join, in the + /// order it was carried; what goes down is a `Filter` over that side's scan, in its own numbering. + /// + /// RIGHT and FULL are pinned here and not end to end, because `optimize` refuses both — after + /// `pushdown` has already run — so no query of theirs returns rows to be wrong. + #[test] + fn push_moves_a_conjunct_only_into_a_side_its_join_never_null_extends() { + let c = BoundColumn { qualifier: "t".into(), name: "x".into(), data_type: DataType::Integer, nullable: true}; + let eq = |col: usize, v: i32| BoundExpr::BinaryOp { left: Box::new(BoundExpr::Column(col)), operator: TokenType::Equal, right: Box::new(BoundExpr::Literal(Value::Integer(v))) }; + let on_left = eq(0, 5); + let on_right = eq(3, 7); + let both = BoundExpr::BinaryOp { left: Box::new(BoundExpr::Column(0)), operator: TokenType::Greater, right: Box::new(BoundExpr::Column(3)) }; + for (join_type, left_down, right_down) in [ + (JoinType::Inner, true, true), + (JoinType::Left, true, false), + (JoinType::Right, false, true), + (JoinType::Full, false, false), + ] { + let label = format!("{join_type:?}"); + let plan = LogicalPlan::Join { + left: Box::new(LogicalPlan::Scan { table: "users".into(), alias: None, output: vec![c.clone(), c.clone()] }), + right: Box::new(LogicalPlan::Scan { table: "posts".into(), alias: None, output: vec![c.clone(), c.clone(), c.clone()] }), + join_type, + on: BoundExpr::Literal(Value::Boolean(true)), + }; + let LogicalPlan::Filter { input, predicate } = push(plan, vec![on_left.clone(), on_right.clone(), both.clone()]) else { + panic!("{label}: the conjunct over both sides must stay in a Filter above the join") + }; + let LogicalPlan::Join { left, right, .. } = *input else { + panic!("{label}: expected the join directly under the filter") + }; + let pushed = |side: LogicalPlan| match side { + LogicalPlan::Filter { input, predicate } if matches!(*input, LogicalPlan::Scan { .. }) => Some(predicate), + LogicalPlan::Scan { .. } => None, + other => panic!("{label}: a side should be a scan, or a filter over one: {other:?}"), + }; + assert_eq!(pushed(*left), left_down.then(|| eq(0, 5)), "{label}: the left-side conjunct"); + assert_eq!(pushed(*right), right_down.then(|| eq(1, 7)), "{label}: the right-side conjunct"); + let mut above = Vec::new(); + split_and(predicate, &mut above); + let want: Vec = [(on_left.clone(), left_down), (on_right.clone(), right_down), (both.clone(), false)] + .into_iter() + .filter(|(_, down)| !down) + .map(|(e, _)| e) + .collect(); + assert_eq!(above, want, "{label}: what stays above the join"); + } + } } \ No newline at end of file diff --git a/tests/d255_join_on_conjuncts.rs b/tests/d255_join_on_conjuncts.rs index db89cd76..1e7c4206 100644 --- a/tests/d255_join_on_conjuncts.rs +++ b/tests/d255_join_on_conjuncts.rs @@ -18,8 +18,11 @@ //! Placing a one-relation conjunct on a leaf that is a LEFT JOIN needs the rule for which side of //! an outer join a filter may go below, and `optimizer::push` — the WHERE pushdown — did not have //! it: `a LEFT JOIN b ON a.id = b.id WHERE b.v = 5` filtered b below the left join and returned -//! the NULL-extended rows the WHERE must exclude. D255 fixes that too, and both placements now -//! share it. +//! the NULL-extended rows the WHERE must exclude. That is ledger D266, fixed here because both +//! placements now share the rule. +//! +//! And over an INNER join a WHERE conjunct is a join predicate: `a JOIN b ON TRUE WHERE a.id = b.id` +//! was refused like `ON TRUE` alone, because the reorderer never saw the WHERE (the C4 caveat). //! //! Every assertion is on the ANSWER, compared as a sorted row set, except where a test says it is //! pinning a plan and why. `tests/d64_expression_depth.rs` also writes `ON 1` and `ON 1 = 1`, but it @@ -480,3 +483,84 @@ fn thirty_three_two_row_relations_chain_to_exactly_two_rows() { let mut db = chain_db(n); assert_eq!(db.rows(&chain_sql(n, "r0.id", |i| format!("r0.id = r{i}.id"))), r(&[&[1], &[2]])); } + +// ---- WHERE over an INNER join is a join predicate (C4) --------------------------------------- + +/// Over an INNER join a WHERE conjunct and an ON conjunct are the same predicate, so a WHERE that +/// links two relations must link them in the search. `pushdown` leaves such a conjunct in a filter +/// over the join, and the reorderer used to see that filter as an opaque leaf or never see it at all: +/// with `ON TRUE` the join below it was a cross product, refused at 12 relations or fewer. +#[test] +fn a_where_conjunct_linking_two_relations_is_their_join_predicate() { + // The filter directly over the join. + let mut db = ab(); + let q = "SELECT a.id, b.id FROM a JOIN b ON TRUE WHERE a.id = b.id;"; + assert_eq!(db.rows(q), r(&[&[1, 1], &[2, 2], &[3, 3]])); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(!plan.contains("(on true)"), "the WHERE conjunct did not become the join predicate:\n{plan}"); + + // Deep in the tree, where `pushdown` stops it at the join of r0 and r1, past and at the limit. + for n in [MAX_DP_RELATIONS + 1, MAX_DP_RELATIONS] { + let mut db = chain_db(n); + let mut q = chain_sql(n, "r0.id, r1.id", |i| if i == 1 { "TRUE".into() } else { format!("r0.id = r{i}.id") }); + q.pop(); + q.push_str(" WHERE r0.id = r1.id;"); + assert_eq!(db.rows(&q), r(&[&[1, 1], &[2, 2]]), "n = {n}"); + let plan = db.explain(&format!("EXPLAIN {q}")); + assert!(!plan.contains("(on true)"), "n = {n}: the WHERE conjunct did not link r0 and r1:\n{plan}"); + } +} + +/// CONTROL — passes at 9aa6968 and must keep passing. Planning must never evaluate a user's +/// expression: `i32::MIN / -1` has no `i32` result and `evaluate`'s arithmetic panics on it in every +/// build profile, so a planner that folded constants by evaluating them would panic on EXPLAIN, +/// which reads no row at all. At 9aa6968 the conjunct was dropped, so this planned then too. +#[test] +fn planning_never_evaluates_a_constant_conjunct() { + let mut db = ab(); + let plan = db.explain("EXPLAIN SELECT a.id FROM a JOIN b ON a.id = b.id AND (0 - 2147483647 - 1) / (0 - 1) = 0;"); + assert!(plan.contains(" join "), "expected a join in the plan:\n{plan}"); +} + +/// A relation joined by a conjunct on itself alone: filtered, then crossed. Past the limit +/// 9aa6968 dropped the filter (a cross product of both of r1's rows); at the limit it refused. +#[test] +fn a_relation_filtered_but_not_linked_is_a_filtered_cross_product_at_and_past_the_dp_limit() { + for n in [MAX_DP_RELATIONS + 1, MAX_DP_RELATIONS] { + let mut db = chain_db(n); + let q = chain_sql(n, "r0.id, r1.id", |i| if i == 1 { "r1.v = 20".into() } else { format!("r0.id = r{i}.id") }); + assert_eq!(db.rows(&q), r(&[&[1, 2], &[2, 2]]), "n = {n}"); + } +} + +// ---- outer joins: what must NOT move (D266) ---------------------------------------------------- + +/// CONTROL — passes at 9aa6968 and must keep passing. Three things an outer join must keep, next to +/// the placement changes above, plus the join types the planner does not run. +#[test] +fn outer_join_controls_that_held_before_d255_and_must_keep_holding() { + let mut db = left_join_db(); + // The LEFT join's OWN ON decides matches only: a conjunct on its preserved side filters nothing, + // it NULL-extends. (An INNER join's ON over a left-join leaf is the other case — test 13.) + assert_eq!( + db.rows("SELECT la.id, lb.id FROM la LEFT JOIN lb ON la.id = lb.id AND la.v = 1;"), + vec![vec![Some(1), None], vec![Some(2), None]], + ); + // A WHERE over both sides of a LEFT join stays above it. + assert_eq!( + db.rows("SELECT la.id, lb.id FROM la LEFT JOIN lb ON la.id = lb.id WHERE la.v + 5 = lb.v;"), + r(&[&[1, 1]]), + ); + // RIGHT and FULL parse and bind, then the planner refuses them by name — after `pushdown` has + // run, so a WHERE on their nullable side can never come back as a wrong answer end to end. + // `push`'s rule for them is pinned at its own level, in `optimizer::tests`. + for q in [ + "SELECT la.id FROM la RIGHT JOIN lb ON la.id = lb.id WHERE la.v = 0;", + "SELECT la.id FROM la FULL JOIN lb ON la.id = lb.id WHERE lb.v = 5;", + ] { + match db.exec(q) { + Err(e) => assert!(e.to_string().contains("right/full not implemented"), "`{q}`: {e}"), + Ok(_) => panic!("`{q}` answered; the planner does not implement RIGHT or FULL"), + } + } +} From f337998114e820cee6f7387f7f1c08ea348cbc70 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:13:56 -0400 Subject: [PATCH 259/315] D255/D266 fix, amendment 4: WHERE over an INNER join feeds the join graph; R3 (PostgreSQL's relevant-clause rule) replaces the forced pass; TRUE folded by shape, never evaluated (UNBUILT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit optimize hands a Filter over an INNER join to reorder_inner_joins and flatten collects its predicate with the ONs, so a WHERE conjunct linking two relations is a bridge. A split with no bridge is admitted when a conjunct reads both sides (R3) or a side is closed (R1); every size then has a plan, so the forced pass (R2) is gone. The constant fold no longer calls evaluate: planning must not run a user expression (review 2 G1: i32::MIN / -1 panics). PREREG §2.4 @ a5f0482. --- src/optimizer/optimizer.rs | 6 ++ src/optimizer/search_algorithm.rs | 134 +++++++++++++++++------------- 2 files changed, 84 insertions(+), 56 deletions(-) diff --git a/src/optimizer/optimizer.rs b/src/optimizer/optimizer.rs index 356328fa..c5673e70 100644 --- a/src/optimizer/optimizer.rs +++ b/src/optimizer/optimizer.rs @@ -10,6 +10,12 @@ pub fn optimize(lp: LogicalPlan, catalog: &Catalog) -> Result match join_type { diff --git a/src/optimizer/search_algorithm.rs b/src/optimizer/search_algorithm.rs index 5bee1017..69dc7728 100644 --- a/src/optimizer/search_algorithm.rs +++ b/src/optimizer/search_algorithm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, execution::executor::evaluate, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, push, split_and}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; +use crate::{binder::binder::BoundExpr, catalog::{catalog::Catalog, column::Value}, error::FerroError, optimizer::{cost_model::cost, optimizer::{build_join, combine_and, optimize, push, split_and}}, parser::parser::JoinType, planner::{logical_plan::LogicalPlan, physical_plan::PhysicalPlan}}; pub const MAX_DP_RELATIONS: usize = 12; @@ -24,7 +24,8 @@ pub struct Sub { pub cost: f64, } -/// Plan a tree of INNER joins: flatten it, place every ON conjunct, and search for a join order. +/// Plan a tree of INNER joins, with the WHERE filters over it: flatten it, place every conjunct, +/// and search for a join order. /// /// # D255 — every ON conjunct is placed by the relations it reads /// @@ -37,10 +38,14 @@ pub struct Sub { /// * **one relation** — a filter on that relation's LOGICAL leaf, placed by the same /// [`push`] WHERE uses and before `optimize` chooses an access path, so `ON b.id = 3` reaches the /// index as `WHERE b.id = 3` does. See [`filter_leaf`]. -/// * **no relation** — a filter over the whole join, unless it is TRUE, when it is dropped: it -/// keeps every row, and `ON TRUE` then plans exactly as it did past 12 relations before D255. +/// * **no relation** — a filter over the whole join, unless it is the literal TRUE, when it is +/// dropped: it keeps every row, and `ON TRUE` then plans exactly as it did past 12 relations +/// before D255. /// * **two or more** — a bridge, unchanged. /// +/// A WHERE conjunct that reads two or more of these relations is placed the same way: over an +/// INNER join it is the same predicate as an ON conjunct, and [`flatten`] collects it with them. +/// /// The other half of D255 is that a join no predicate links now plans as a cross product at every /// size instead of being refused at 12 relations or fewer — see [`dynamic_program`]. /// `tests/d255_join_on_conjuncts.rs` pins both halves on both sides of `MAX_DP_RELATIONS`. @@ -74,11 +79,12 @@ pub fn reorder_inner_joins(plan: LogicalPlan, catalog: &Catalog) -> Result if !matches!(evaluate(&part, &[]), Ok(Value::Boolean(true))) { + // A conjunct that reads no column has one value for every row. Only the literal + // TRUE is dropped, and it is recognised by its shape, never evaluated: planning must + // not run a user's expression (`i32::MIN / -1` panics in `evaluate`, and EXPLAIN + // would then panic too). Everything else stays a per-row filter over the join, as a + // column-free WHERE conjunct always has. + 0 => if part != BoundExpr::Literal(Value::Boolean(true)) { constant.push(part) }, 1 => { @@ -141,32 +147,41 @@ fn filter_leaf(leaf: LogicalPlan, filters: Vec) -> LogicalPlan { /// `left_deep` planned the same queries as cross products. And the parser has no `CROSS JOIN` and /// no comma FROM list, so `ON TRUE` is the only way to write a cross product at all. /// -/// A split with no bridge is now a cross-product candidate, joined `ON true`, under PostgreSQL's -/// two rules (`join_search_one_level`): +/// A split with no bridge is now a cross-product candidate, joined `ON true`, under two rules from +/// PostgreSQL's `join_search_one_level`: /// /// * **R1, a closed side.** One side is CLOSED: no multi-relation conjunct reads both a relation in /// it and one outside it. It has no predicate left to wait for, so crossing it now loses none. /// PostgreSQL's `make_rels_by_clauseless_joins`, for a rel with no join clauses. -/// * **R2, the last ditch.** If no subset of this size got a plan, the size is searched again with -/// every split a candidate. PostgreSQL forces cartesian products when a level produced nothing. +/// * **R3, a linked split.** Some conjunct reads both sides without yet lying inside them — it +/// reads a third relation too — so joining them is progress towards applying it. PostgreSQL's +/// `have_relevant_joinclause`. A conjunct over at most two relations that reads both sides is a +/// bridge, so R3 is only ever about conjuncts over three or more. /// /// What those two rules guarantee: /// -/// * **The full set always gets a plan.** Level 1 is the base relations; if bridges and R1 give a -/// level nothing, R2 joins any planned subset one size down with any relation outside it. The -/// `Internal` error below is that argument failing, not a refusal of a query. +/// * **The full set always gets a plan.** Take any planned proper subset S: if it is closed, R1 +/// joins it with any relation outside it; if not, some conjunct reads S and a relation x outside +/// it, and `(S, {x})` is a bridge or R3. So every size has a plan. The `Internal` error below is +/// that argument failing, not a refusal of a query. PostgreSQL's third rule, the one-sided "last +/// ditch", is for a sub-problem whose rels have clauses to rels OUTSIDE it, and none exists here: +/// every conjunct reads only relations of its own flattened unit. /// * **Every multi-relation conjunct is applied exactly once, in any tree.** At the lowest node /// whose subset contains it, it is inside neither child but inside their union, so it reads both /// sides; above that node it is inside one child. /// * **Queries that planned before D255 see the same candidates**, when their conjuncts each read at -/// most two relations. R1 cannot fire once every relation is bridge-reachable: take the lowest -/// node with relations both inside and outside a closed set — its children split the set from -/// the rest, and its bridge reads both. R2 cannot fire on a connected graph, which has a -/// connected, bridge-plannable subset of every size. +/// most two relations. R3 cannot fire for them (above). R1 cannot fire once every relation is +/// bridge-reachable: take the lowest node with relations both inside and outside a closed set — +/// its children split the set from the rest, and its bridge reads both. /// -/// The cost: with no join predicate at all every subset is closed and every split is built — -/// 523,250 at 12 relations, the same count a 12-relation clique already built before D255, which is -/// the load `MAX_DP_RELATIONS` bounds. +/// **Where this differs from PostgreSQL, on purpose:** its clauseless joins are one-sided — a rel +/// against a single base relation — "to avoid unreasonable growth of planning time". R1 here also +/// admits a split whose sides are both larger, which is how two linked components are each joined +/// and then crossed ONCE: `(w⋈x)×(y⋈z)` instead of `((w⋈x)×y)⋈z`, whose intermediate is a full +/// cross product. This search visits every split anyway, so an admitted one costs its `build_join`. +/// With no join predicate at all every subset is closed and all 523,250 splits of 12 relations are +/// built — the count a 12-relation clique already built before D255, the load `MAX_DP_RELATIONS` +/// bounds. fn dynamic_program(base: Vec, conjuncts: &[(u64, BoundExpr)], orig_offset: &[usize], widths: &[usize], catalog: &Catalog) -> Result { let n = widths.len(); let mut best = HashMap::new(); @@ -180,40 +195,36 @@ fn dynamic_program(base: Vec, conjuncts: &[(u64, BoundExpr)], orig // build up subsets by increasing size for size in 2..=n { let masks: Vec = (1u64..(1u64 << n)).filter(|m| m.count_ones() as usize == size).collect(); - for forced in [false, true] { - // R2: only when this size has no plan at all. - if forced && masks.iter().any(|m| best.contains_key(m)) { - break; - } - for &mask in &masks { - let mut sub = (mask - 1) & mask; - while sub > 0 { - let l_mask = sub; - let r_mask = mask & !sub; - sub = (sub.wrapping_sub(1)) & mask; - let (Some(l), Some(r)) = (best.get(&l_mask), best.get(&r_mask)) else {continue;}; + for mask in masks { + let mut sub = (mask - 1) & mask; + while sub > 0 { + let l_mask = sub; + let r_mask = mask & !sub; + sub = (sub.wrapping_sub(1)) & mask; + let (Some(l), Some(r)) = (best.get(&l_mask), best.get(&r_mask)) else {continue;}; - let bridge: Vec<&BoundExpr> = conjuncts.iter() - .filter(|(rel, _)| rel & mask == *rel && rel & l_mask != 0 && rel & r_mask != 0).map(|(_, e)| e).collect(); - if bridge.is_empty() && !(forced || closed(l_mask) || closed(r_mask)) { - continue; - } + // A bridge, or R3: some conjunct reads both sides. Otherwise only R1. + let linked = conjuncts.iter().any(|(rel, _)| rel & l_mask != 0 && rel & r_mask != 0); + if !(linked || closed(l_mask) || closed(r_mask)) { + continue; + } + let bridge: Vec<&BoundExpr> = conjuncts.iter() + .filter(|(rel, _)| rel & mask == *rel && rel & l_mask != 0 && rel & r_mask != 0).map(|(_, e)| e).collect(); - let mut order = l.order.clone(); - order.extend(&r.order); - let map = build_remap(&order, orig_offset, widths); - let on = if bridge.is_empty() { - BoundExpr::Literal(Value::Boolean(true)) - } else { - combine_and(bridge.iter().map(|e| remap(e, &map)).collect()) - }; - let left_width: usize = l.order.iter().map(|&x| widths[x]).sum(); - let right_width: usize = r.order.iter().map(|&x| widths[x]).sum(); - let candidate = build_join(l.plan.clone(), r.plan.clone(), on, JoinType::Inner, left_width, right_width, catalog); - let cost = cost(&candidate, catalog).cost; - if best.get(&mask).map_or(true, |s| cost < s.cost) { - best.insert(mask, Sub { plan: candidate, order, cost }); - } + let mut order = l.order.clone(); + order.extend(&r.order); + let map = build_remap(&order, orig_offset, widths); + let on = if bridge.is_empty() { + BoundExpr::Literal(Value::Boolean(true)) + } else { + combine_and(bridge.iter().map(|e| remap(e, &map)).collect()) + }; + let left_width: usize = l.order.iter().map(|&x| widths[x]).sum(); + let right_width: usize = r.order.iter().map(|&x| widths[x]).sum(); + let candidate = build_join(l.plan.clone(), r.plan.clone(), on, JoinType::Inner, left_width, right_width, catalog); + let cost = cost(&candidate, catalog).cost; + if best.get(&mask).map_or(true, |s| cost < s.cost) { + best.insert(mask, Sub { plan: candidate, order, cost }); } } } @@ -221,7 +232,7 @@ fn dynamic_program(base: Vec, conjuncts: &[(u64, BoundExpr)], orig let full = (1u64 << n) - 1; let best_full = best.remove(&full).ok_or_else(|| FerroError::Internal(format!( - "the join search planned no subset covering all {n} relations; R2 in `dynamic_program` is meant to make that impossible" + "the join search planned no subset covering all {n} relations; R1 and R3 in `dynamic_program` are meant to make that impossible" )))?; let idendity: Vec = (0..n).collect(); @@ -242,10 +253,21 @@ pub fn flatten(plan: LogicalPlan, leaves: &mut Vec, preds: &mut Vec flatten(*right, leaves, preds); preds.push(on); } + // D255 — a WHERE conjunct `push` left over an INNER join (it reads both inputs) is the same + // predicate as an ON conjunct, so it joins the ONs. A filter over any other join stays a + // leaf: `push` kept it above a join that NULL-extends, and it must stay there. + LogicalPlan::Filter { input, predicate } if is_inner_join(&input) => { + flatten(*input, leaves, preds); + preds.push(predicate); + } other => leaves.push(other), } } +fn is_inner_join(plan: &LogicalPlan) -> bool { + matches!(plan, LogicalPlan::Join { join_type: JoinType::Inner, .. }) +} + pub fn relation_of(idx: usize, orig_offset: &[usize], widths: &[usize]) -> usize { (0..widths.len()).find(|&r| idx >= orig_offset[r] && idx < orig_offset[r] + widths[r]).unwrap_or(0) } From d8956c14e140177f0dfd2d0eb63bb99b297d12c8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:15:30 -0400 Subject: [PATCH 260/315] =?UTF-8?q?UNBUILT=20D254=20review=201:=20PREREG?= =?UTF-8?q?=20amendment=202=20=E2=80=94=20the=20cost=20restated=20(R7),=20?= =?UTF-8?q?the=20five=20non-encoder=20partial-image=20routes=20(R3),=20the?= =?UTF-8?q?=20v1->v2=20wedge,=20the=20refusal's=20wording=20and=20CI/CF's?= =?UTF-8?q?=20page=20assertion=20registered=20(R6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d254/PREREG.md | 71 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/bench/d254/PREREG.md b/bench/d254/PREREG.md index afd0a32b..62018782 100644 --- a/bench/d254/PREREG.md +++ b/bench/d254/PREREG.md @@ -138,3 +138,74 @@ D254's run script runs E1 and E4 on `d249_alter_encodable` to confirm the moved - `git diff 06b0188 1654b9b` adds 4, and from `9aa6968` it adds 13, with 0 removed. - **Per-target: 2579 + 13 = 2592** (2579 measured on main's tree). - **The list step is the authority:** `cargo test --test d254_catalog_persist_bound -- --list` is predicted to show 4. + +--- + +## Amendment 2 — review 1's R3, R6 and R7: text, one message string, and CI/CF's page assertion (nothing built) + +Source: `artie-research frontier/d254_review1.md` @ `8ecc169`, which reviews `1eb4da3`. Verdict: SOUND for the +scoped claim. These are the lead's decisions. **No logic changes.** One error-message string is reworded, and CI and +CF gain an assertion. + +### R7: the cost, restated + +The PREREG's "every `persist` serializes each entry twice … a constant factor of 2 on DDL" is wrong on two counts. + +- **The pre-pass costs more per entry than a second serialize.** It builds a fresh `CatalogPage`, makes a deep + `entry.clone()` (the name, every column name, both index vectors), and calls a `serialize` that zero-fills a whole + 4096-byte page for that one entry. For clones and entry bytes the factor is 2. For buffer traffic it is about + 4073 / mean entry bytes: about 115× for a 35-byte entry (review R7). +- **It is paid on DML too, not only on DDL.** `persist` also runs whenever a DML statement moves a root: `sync_roots` + or `sync_fulltext_roots` (`executor.rs:516-552`, from `insert`, `update` and `delete`) call + `update_{primary,index,fulltext}_root`, which call `persist` (`catalog.rs:406`, `:419`, `:433`). +- **Still O(tables).** Nothing grows with rows or branches, and T (tables) is not one of this project's scale axes. +- **D270 removes it:** the page images serialized once ARE the encoder's answer. + +### R3: every route by which `persist` can still fail after its first page write + +D254 closes the ENCODER routes. The non-encoder routes remain, and there are five (review 1, READ at `1eb4da3`, +`catalog.rs:518-586`): + +1. `new_page` refuses (the table region is full) at the old chain's end (`:541`). **This is reachable, not merely + environmental.** With the catalog on k ≥ 2 pages and the region holding exactly what the DDL itself allocates, + a statement that makes the catalog need page k+1 rewrites pages 1..k-1 and then fails. +2. `fetch_page(curr)` fails at turn 2 or later (`:519`). +3. `CatalogPage::deserialize` of an existing chain page fails at turn 2 or later (`:523`). +4. `fetch_page(new_id)` fails after `new_page` succeeded (`:549`). That leaves a partial image, and a page + allocated but never linked. +5. The orphan tail (`fetch_page`, `deserialize` or `delete_page`, `:571-582`) fails AFTER the whole new image was + written. This is the reverse divergence: the pages hold the new image, the refused object included, while + `persist_or_undo` rolls memory back. + +**What each leaves.** Routes 1–4 rewrite pages 1..k-1 and leave page k and the old tail. Entries can be lost from the +image, and the refused object can be recorded on the pages. D254's claim is scoped to SIZE refusals, and holds. +**D270** (the child branch) closes routes 1–4 and moves route 5's fetches ahead of every write. + +### R6: edges, recorded + +- **The v1 → v2 exactly-full-entry wedge** (INFERRED, pre-existing in effect). + - A v1 catalog page carries no full-text count byte. So an entry written in v1 at exactly 4,073 bytes is 4,074 in + v2, and 23 + 4,074 = 4,097. + - On such a database, `persist`'s pre-pass refuses EVERY persist, including `rebuild_indexes` at open, so the + database will not open. + - Before D254 the same persist looped and truncated the image. D254 turns damage into a clean refusal, but the + result is still a hard wedge. + - It is also the one case where a DDL's own check passes and the pre-pass refuses on ANOTHER entry, which leaks + the DDL's pages. No test is registered, because it needs a hand-built v1 page at the exact size. +- **The refusal's wording.** `refuse_unless_encodable`'s message ends "refused before anything was written", which + is false when `persist`'s pre-pass is what fires: an ALTER's heap, or a DDL's pages, may already be written. It + becomes **"refused before any catalog page was written"**, which is true at every call site. The one string + changes. The tests match "catalog page" and the table's quoted name, both of which the new text still carries. +- **CI and CF passed `absent = "m"` to `assert_catalog_intact`**, a table that exists in neither test, so that half + was vacuous. + - The helper's `absent` becomes `Option<&str>`: CT and CP pass `Some("m")`, and CI and CF pass `None`. + - CI and CF gain the assertion that was missing: the refused index is absent ON THE PAGES. They check + `Catalog::open(..)`'s entry for `t`: `indexes` is empty for CI, and `fulltext_indexes` is empty for CF. + - These strengthen assertions and weaken none. Predictions are unchanged: they pass at the tip, and no registered + mutant changes outcome, because every mutant's refusal still comes before any page write. +- **D249's E3 patch no longer applies to this tree** after the wording change. Its context includes the message's + second line. It is registered as not run here (amendment 1), and it still applies to D249's own tree. + +### Counts + +No test is added or removed. The target has 4 tests, and per-target stays 2592. From 8ef65728e06b4e50aa36dd498b70478ff8c22544 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:15:47 -0400 Subject: [PATCH 261/315] UNBUILT D254 review 1: the size refusal says 'before any catalog page was written' (message text only) --- src/catalog/catalog_page.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/catalog/catalog_page.rs b/src/catalog/catalog_page.rs index bbd93590..e164c68e 100644 --- a/src/catalog/catalog_page.rs +++ b/src/catalog/catalog_page.rs @@ -413,7 +413,7 @@ pub(crate) fn refuse_unless_encodable(entry: &TableEntry) -> Result<(), FerroErr if !page.has_space(entry) { return Err(FerroError::Constraint(format!( "the catalog entry of table {} would be {} bytes, more than one {}-byte catalog page \ - holds; refused before anything was written", + holds; refused before any catalog page was written", elide(&entry.name), HEADER_SIZE + entry.length(), PAGE_SIZE From e5e2332382f110c0ce066170503676eea10462c9 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:15:47 -0400 Subject: [PATCH 262/315] UNBUILT D254 review 1: CI and CF assert the refused index is absent from the catalog's pages; the helper's vacuous absent="m" becomes an Option (R6) --- tests/d254_catalog_persist_bound.rs | 40 +++++++++++++++++++---------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/tests/d254_catalog_persist_bound.rs b/tests/d254_catalog_persist_bound.rs index 694f469d..9fc2d43f 100644 --- a/tests/d254_catalog_persist_bound.rs +++ b/tests/d254_catalog_persist_bound.rs @@ -90,11 +90,15 @@ impl Db { self.dm.bitmap_high_water().unwrap() } - /// The tables the catalog's pages hold, read back through the same pool. + /// The catalog as its pages hold it, read back through the same pool. + fn catalog_on_the_pages(&self) -> Catalog { + Catalog::open(self.bp.clone(), self.catalog.first_catalog_page_id) + .expect("the catalog pages must still read back") + } + + /// The tables the catalog's pages hold. fn tables_on_the_pages(&self) -> Vec { - let reread = Catalog::open(self.bp.clone(), self.catalog.first_catalog_page_id) - .expect("the catalog pages must still read back"); - let mut names: Vec = reread.tables.keys().cloned().collect(); + let mut names: Vec = self.catalog_on_the_pages().tables.keys().cloned().collect(); names.sort(); names } @@ -132,7 +136,7 @@ fn assert_refused_by_size(err: &FerroError, table: &str, statement: &str) { ); } -fn assert_catalog_intact(db: &Db, present: &[&str], absent: &str, statement: &str) { +fn assert_catalog_intact(db: &Db, present: &[&str], absent: Option<&str>, statement: &str) { for t in present { assert!(db.catalog.get_table(t).is_some(), "{statement}: table {t} is gone from the catalog in memory"); } @@ -144,10 +148,12 @@ fn assert_catalog_intact(db: &Db, present: &[&str], absent: &str, statement: &st restart would lose it" ); } - assert!( - !on_pages.iter().any(|n| n == absent) && db.catalog.get_table(absent).is_none(), - "{statement}: the refused table {absent} is in the catalog" - ); + if let Some(absent) = absent { + assert!( + !on_pages.iter().any(|n| n == absent) && db.catalog.get_table(absent).is_none(), + "{statement}: the refused table {absent} is in the catalog" + ); + } } #[test] @@ -174,7 +180,7 @@ fn a_create_table_whose_entry_exceeds_a_page_is_refused_and_leaves_the_catalog_a .expect("a table whose catalog entry no page can hold must be refused"); assert_refused_by_size(&err, "m", "CREATE TABLE"); assert_eq!(db.high_water(), before, "the refused CREATE TABLE allocated pages it never freed"); - assert_catalog_intact(&db, &["zz"], "m", "CREATE TABLE"); + assert_catalog_intact(&db, &["zz"], Some("m"), "CREATE TABLE"); db.exec("CREATE TABLE after_table (id INTEGER NOT NULL);") .expect("an ordinary CREATE TABLE after the refusal must still work"); } @@ -203,8 +209,12 @@ fn a_create_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_cata .expect("an index that grows the entry past a catalog page must be refused"); assert_refused_by_size(&err, "t", "CREATE INDEX"); assert_eq!(db.high_water(), before, "the refused CREATE INDEX allocated pages it never freed"); - assert_catalog_intact(&db, &["t", "zz"], "m", "CREATE INDEX"); + assert_catalog_intact(&db, &["t", "zz"], None, "CREATE INDEX"); assert!(db.catalog.get_table("t").unwrap().indexes.is_empty(), "the refused index is on the table in memory"); + assert!( + db.catalog_on_the_pages().tables["t"].indexes.is_empty(), + "the refused index is on the table as the catalog's pages record it" + ); db.exec("CREATE TABLE after_index (id INTEGER NOT NULL);") .expect("an ordinary CREATE TABLE after the refusal must still work"); } @@ -242,11 +252,15 @@ fn a_create_fulltext_index_that_grows_an_entry_past_a_page_is_refused_and_leaves .expect("a full-text index that grows the entry past a catalog page must be refused"); assert_refused_by_size(&err, "t", "CREATE FULLTEXT INDEX"); assert_eq!(db.high_water(), before, "the refused CREATE FULLTEXT INDEX allocated pages it never freed"); - assert_catalog_intact(&db, &["t", "zz"], "m", "CREATE FULLTEXT INDEX"); + assert_catalog_intact(&db, &["t", "zz"], None, "CREATE FULLTEXT INDEX"); assert!( db.catalog.get_table("t").unwrap().fulltext_indexes.is_empty(), "the refused full-text index is on the table in memory" ); + assert!( + db.catalog_on_the_pages().tables["t"].fulltext_indexes.is_empty(), + "the refused full-text index is on the table as the catalog's pages record it" + ); db.exec("CREATE TABLE after_fulltext (id INTEGER NOT NULL);") .expect("an ordinary CREATE TABLE after the refusal must still work"); } @@ -275,5 +289,5 @@ fn persist_refuses_an_entry_no_page_can_hold_before_writing_any_page() { assert_refused_by_size(&err, "m", "persist"); assert_eq!(db.high_water(), before, "persist allocated pages for an entry it then refused"); - assert_catalog_intact(&db, &["zz"], "m", "persist"); + assert_catalog_intact(&db, &["zz"], Some("m"), "persist"); } From 6ead01bdcb220217440bb091e339a8f38dc6b1f0 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:15:56 -0400 Subject: [PATCH 263/315] =?UTF-8?q?UNBUILT=20D254=20review=201:=20PREREG?= =?UTF-8?q?=20amendment=203=20=E2=80=94=20the=20text,=20message=20and=20CI?= =?UTF-8?q?/CF=20changes=20recorded;=20mutant=20base=20e5e2332?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d254/PREREG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/bench/d254/PREREG.md b/bench/d254/PREREG.md index 62018782..6fc4e6d2 100644 --- a/bench/d254/PREREG.md +++ b/bench/d254/PREREG.md @@ -209,3 +209,19 @@ image, and the refused object can be recorded on the pages. D254's claim is scop ### Counts No test is added or removed. The target has 4 tests, and per-target stays 2592. + +--- + +## Amendment 3 — amendment 2's changes recorded (nothing built) + +| commit | what | +|---|---| +| `d8956c1` | amendment 2 | +| `8ef6572` | the size refusal's text: "refused before any catalog page was written" (one string) | +| `e5e2332` | CI and CF assert the refused index is absent from the catalog's pages; `assert_catalog_intact`'s `absent` becomes `Option<&str>` | + +- **`catalog.rs` is unchanged since `64d6d90`,** so P1–P4 re-cut from `e5e2332` are byte-identical to the committed + patches. The re-cut left the tree clean (`git status --porcelain`: 0). +- **D249's E1 and E4 still apply** (`git apply --check`, rc 0). +- **The run's mutant base moves to `e5e2332`,** where `src/` and `tests/` equal the tip's. +- **The counts are unchanged:** the target has 4 tests, and per-target is 2592. From 179329803f85373490c83fa4a768b763c636151e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:18:57 -0400 Subject: [PATCH 264/315] =?UTF-8?q?UNBUILT=20D270:=20red=20tests=20?= =?UTF-8?q?=E2=80=94=20on=20a=20two-page=20catalog,=20a=20refused=20alloca?= =?UTF-8?q?tion=20(region=20full)=20and=20an=20unreadable=20second=20page?= =?UTF-8?q?=20each=20leave=20the=20catalog's=20pages=20exactly=20as=20they?= =?UTF-8?q?=20were?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/d270_catalog_persist_allocate_first.rs | 228 +++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 tests/d270_catalog_persist_allocate_first.rs diff --git a/tests/d270_catalog_persist_allocate_first.rs b/tests/d270_catalog_persist_allocate_first.rs new file mode 100644 index 00000000..77b7dfbf --- /dev/null +++ b/tests/d270_catalog_persist_allocate_first.rs @@ -0,0 +1,228 @@ +//! D270 — `Catalog::persist` fails part way through writing its chain, and leaves a mixed image. +//! +//! D254 made `persist` refuse an entry the ENCODER cannot write before any page is written. The other +//! failures still come mid-write, because `persist` reads, allocates and writes page by page: turn k +//! reads page k, allocates page k+1 only when page k is the old chain's end, then writes page k. So +//! an error at turn k ≥ 2 leaves pages 1..k-1 rewritten and the old chain after them (review 1 of +//! D254, R3). The image can then lose tables, and it can record the one whose statement was refused. +//! +//! Two of those routes, each driven from an ordinary CREATE TABLE on a catalog of two pages: +//! +//! - **allocation**: the table region has exactly the pages the statement itself needs, and none for +//! the catalog's third page; +//! - **reading**: the second chain page does not read back. It is corrupted in the pool, which stands +//! in for any failed fetch or decode of a later chain page. +//! +//! Each test asserts that the refusal leaves the catalog's pages exactly as they were. +//! +//! **The harness bounds the allocator** (`reserve_region` at page 256) before the catalog exists. + +use std::collections::BTreeSet; +use std::fs::OpenOptions; +use std::sync::Arc; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::catalog::catalog_page::{CatalogPage, TableEntry}; +use ferrodb::catalog::column::{Column, DataType}; +use ferrodb::catalog::schema::Schema; +use ferrodb::error::FerroError; +use ferrodb::execution::executor::{run, Outcome}; +use ferrodb::execution::session::Session; +use ferrodb::parser::parser::Parser; +use ferrodb::parser::scanner::Scanner; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; +use ferrodb::wal::log::WalManager; +use ferrodb::wal::txn::TxnManager; + +struct Db { + catalog: Catalog, + bp: Arc, + dm: Arc, + txn: Arc, + _dir: tempfile::TempDir, +} + +impl Db { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("d270.db")) + .unwrap(); + let dm = Arc::new(DiskManager::new(file).unwrap()); + dm.reserve_region("test floor", 256, u32::MAX).unwrap(); + let bp = Arc::new(BufferPoolManager::new(dm.clone())); + let catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("d270.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + Db { catalog, bp, dm, txn, _dir: dir } + } + + fn exec(&mut self, sql: &str) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut parser = Parser::new(tokens); + let mut stmts = parser.parse(); + if !parser.errors.is_empty() { + return Err(FerroError::SqlParseError( + parser.errors.iter().map(|e| e.to_string()).collect::>().join("; "), + )); + } + assert_eq!(stmts.len(), 1, "expected one statement: {sql}"); + let mut s = Session::new(); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), &mut s) + } + + /// A page's bytes as the pool holds them. + fn page_bytes(&self, id: u32) -> [u8; PAGE_SIZE] { + let i = self.bp.fetch_page(id).unwrap(); + let data = self.bp.frames[i].read().unwrap().data; + self.bp.unpin_page(id, false); + data + } + + /// The catalog's page chain, walked from its first page. + fn chain(&self) -> Vec { + let mut ids = Vec::new(); + let mut id = self.catalog.first_catalog_page_id; + while id != 0 { + ids.push(id); + id = CatalogPage::deserialize(self.page_bytes(id)).unwrap().next_catalog_page; + } + ids + } + + /// The tables the catalog's pages hold, read back through the same pool. + fn tables_on_the_pages(&self) -> BTreeSet { + Catalog::open(self.bp.clone(), self.catalog.first_catalog_page_id) + .expect("the catalog pages must still read back") + .tables + .keys() + .cloned() + .collect() + } +} + +/// Four INTEGER columns with 240-byte names: 991 bytes of catalog entry for a two-byte table name. +/// Four such entries fit one catalog page and five do not. +fn wide_columns() -> Vec { + (0..4) + .map(|i| Column::new(format!("k{i}{}", "x".repeat(238)), DataType::Integer, i != 0)) + .collect() +} + +fn wide_table(name: &str) -> String { + let decl: Vec = wide_columns() + .iter() + .map(|c| if c.nullable { format!("{} INTEGER", c.name) } else { format!("{} INTEGER NOT NULL", c.name) }) + .collect(); + format!("CREATE TABLE {name} ({});", decl.join(", ")) +} + +fn entry_of(name: &str, columns: Vec) -> TableEntry { + TableEntry { + name: name.to_string(), + first_directory_page_id: 0, + primary_index_root: 0, + time_travel_root: 0, + schema: Schema::new(columns), + indexes: Vec::new(), + fulltext_indexes: Vec::new(), + } +} + +/// How many pages a greedy placement of `entries`, in name order, takes: the placement `persist` +/// makes. A premise, computed through the page's own `has_space`. +fn pages_for(mut entries: Vec) -> usize { + entries.sort_by(|a, b| a.name.cmp(&b.name)); + let mut pages = 1; + let mut page = CatalogPage::new(0); + for e in entries { + if !page.has_space(&e) { + pages += 1; + page = CatalogPage::new(0); + } + page.add_entry(e).expect("each entry fits an empty page"); + } + pages +} + +/// Eight wide tables `b1`..`b8`: a catalog of exactly two pages. +fn two_page_catalog() -> Db { + let mut db = Db::new(); + for i in 1..=8 { + db.exec(&wide_table(&format!("b{i}"))).unwrap(); + } + assert_eq!(db.chain().len(), 2, "the fixture's catalog is not two pages, so this tests nothing"); + db +} + +#[test] +fn a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were() { + let mut db = two_page_catalog(); + let mut with_new: Vec = + db.catalog.tables.values().cloned().collect(); + with_new.push(entry_of("aaa", wide_columns())); + assert_eq!(pages_for(with_new), 3, "adding the table does not need a third catalog page, so this tests nothing"); + + // Leave exactly the three pages CREATE TABLE allocates for itself (heap, primary tree, + // time-travel heap), and none for the catalog's third page. + let mut taken = Vec::new(); + while let Ok(id) = db.dm.allocate() { + taken.push(id); + } + assert!(taken.len() >= 3, "the region had fewer than three free pages"); + for id in taken.split_off(taken.len() - 3) { + db.dm.deallocate(id).unwrap(); + } + + let before = db.tables_on_the_pages(); + let err = db + .exec(&wide_table("aaa")) + .err() + .expect("with no page for the catalog, the CREATE TABLE must be refused"); + assert!( + matches!(&err, FerroError::Io(m) if m.contains("no free page below")), + "expected the region's refusal, got: {err:?}" + ); + assert!(db.catalog.get_table("aaa").is_none(), "the refused table is in the catalog in memory"); + assert_eq!( + db.tables_on_the_pages(), + before, + "the refused statement changed the catalog's pages: a restart would read this image" + ); +} + +#[test] +fn a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written() { + let mut db = two_page_catalog(); + let chain = db.chain(); + let first_before = db.page_bytes(chain[0]); + + // The second chain page stops reading back as a catalog page. + let i = db.bp.fetch_page(chain[1]).unwrap(); + { + let mut frame = db.bp.frame_write(i); + frame.data[0] = 0xEE; + } + db.bp.unpin_page(chain[1], true); + assert!( + CatalogPage::deserialize(db.page_bytes(chain[1])).is_err(), + "the damaged page still reads back, so this tests nothing" + ); + + let err = db + .exec("CREATE TABLE aaa (id INTEGER NOT NULL);") + .err() + .expect("a catalog whose second page does not read back cannot be rewritten"); + assert!(matches!(err, FerroError::Corruption(_)), "expected the page's refusal, got: {err:?}"); + assert!(db.catalog.get_table("aaa").is_none(), "the refused table is in the catalog in memory"); + assert!( + db.page_bytes(chain[0]) == first_before, + "the first catalog page was rewritten before the second was read, so the pages now mix two images" + ); +} From ce06adc69362ae1e4a2b0b43dd4253ee9de9928e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:19:50 -0400 Subject: [PATCH 265/315] =?UTF-8?q?UNBUILT=20D270:=20PREREG=20=E2=80=94=20?= =?UTF-8?q?the=20mixed-image=20routes,=20Run=20R=20at=201793298=20(0/2),?= =?UTF-8?q?=20the=20phased=20persist,=20the=20residual,=20Q1-Q3;=20before?= =?UTF-8?q?=20the=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d270/PREREG.md | 137 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 bench/d270/PREREG.md diff --git a/bench/d270/PREREG.md b/bench/d270/PREREG.md new file mode 100644 index 00000000..86f5bfed --- /dev/null +++ b/bench/d270/PREREG.md @@ -0,0 +1,137 @@ +# D270 — pre-registration, written BEFORE the fix (nothing built) + +Branch `d270-catalog-persist-allocate-first`, a CHILD of `d254-catalog-persist-bound` @ `6ead01b`, D254's tip with +review 1's text amendments applied, in `/Users/idide/wt/ferrodb-d270.noindex`. + +- The lead named `1eb4da3` as the parent. This branches from `6ead01b`, which contains `1eb4da3` plus review 1's + amendments (text, one message string, and CI/CF's page assertion), so that D270 lands after D254 with no merge + in between. + +Quiet mode: nothing on this branch has been compiled or run, so every line below is a prediction. Amendments append +only. Source: `d254_review1.md` @ `8ecc169`, R3 and R7, made ledger D270 by the lead. + +**Instrument:** `timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first`. Its +`-- --list` is the authority for the count. + +## The defect (READ at `6ead01b`, `catalog.rs`, `fn persist`) + +`persist` reads, allocates and writes the chain in one loop. Turn k: + +1. fetches and deserializes page k; +2. places entries; +3. allocates a new page only when page k is the old chain's end; +4. THEN writes page k. + +**So any `Err` at turn k ≥ 2 leaves pages 1..k-1 rewritten**, with page k and the old tail unchanged. The image can +lose tables that the new layout pushed forward, and it can record the table whose statement was refused while +`persist_or_undo` removes it from memory. Review 1's R3 lists the routes: + +- `new_page` refuses at the old chain's end. This is reachable: the table region is full, and the catalog is on 2 or + more pages. +- `fetch_page` or `deserialize` fails at turn 2 or later. +- `fetch_page(new_id)` fails after an allocation. +- The orphan tail fails after the whole new image is written. + +**The cost (R7).** D254's pre-pass adds a deep clone and a one-entry page `serialize` per entry to every `persist`, +DML root moves included. + +## Red tests at `1793298` (additions only: `tests/d270_catalog_persist_allocate_first.rs`, `228 0`) + +**The fixture.** Every harness calls `reserve_region("test floor", 256, u32::MAX)` before `Catalog::create`. It +builds eight tables `b1`..`b8`, each with four INTEGER columns of 240-byte names: 991 bytes of entry, so four fit +a page and five do not. That makes a catalog of **exactly two pages**, a premise asserted by walking the chain. + +| key | test | the route | +|---|---|---| +| **NP** | `a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were` | allocation | +| **FR** | `a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written` | reading | + +**NP.** + +- **The premise:** adding a same-shaped `aaa` needs a THIRD page. That is `pages_for`, a greedy placement through + `CatalogPage::has_space` in name order, which is the placement `persist` makes. +- **The setup:** the test exhausts the region with `DiskManager::allocate` until it refuses, then frees exactly three + pages: the ones CREATE TABLE allocates for itself. The catalog's third page then has nowhere to go. +- **The assertions:** + - the refusal is the region's `Io("no free page below …")`; + - `aaa` is absent in memory; + - **the set of tables on the pages** (`Catalog::open` over the same pool) **is exactly what it was before**. + +**FR.** + +- **The setup:** the second chain page's format byte is set to `0xEE` in the pool, so it no longer deserializes. That + premise is asserted. It stands in for any failed fetch or decode of a later chain page, injected inside the real + `deserialize` on real bytes. +- **The statement:** `CREATE TABLE aaa (id INTEGER NOT NULL)`, a small table that sorts first, so a rewritten page 1 + would differ. +- **The assertions:** + - the refusal is the page's `Corruption`; + - `aaa` is absent in memory; + - **page 1's bytes are identical to before.** + +**Run R at `1793298` (predicted): 0 passed, 2 failed.** + +- **NP fails at its pages assertion.** Turn 1 rewrites page 1 as `[aaa, b1, b2, b3]`, then turn 2's `new_page` + refuses. The pages then read `{aaa, b1, b2, b3, b5, b6, b7, b8}`: `b4` is lost, and the refused `aaa` is + recorded. +- **FR fails at its page-1 assertion.** Turn 1 rewrites page 1 as `[aaa, b1, b2, b3, b4]`, then turn 2's + `deserialize` refuses. +- Both refusal assertions pass at the base. + +## The fix, as it will be made + +`persist` becomes phases. Each phase finishes before the next begins: + +1. **Lay out** (memory only). Place the sorted entries greedily into page images, as the loop did. An entry that + fits no EMPTY page is refused there, by `refuse_unless_encodable`, whose `has_space` arm returns the size + `Constraint`. The D254 per-entry pre-pass is removed. +2. **Serialize every page image ONCE**, with placeholder links. Any length-prefix refusal happens here. **The page + images are the encoder's answer**, so there is no separate per-entry `serialize`. That is R7's cost removed, back + to the pre-D254 P page serializes and one clone per entry. +3. **Walk the existing chain, reads only,** for the page ids it owns. It checks every page's format and stops on a + cycle (a `Corruption`, where the loop used to spin). +4. **Allocate every missing page, before any catalog write.** Each is stamped as an empty catalog page, as the loop + did. If one refuses, the pages this call allocated are freed and the error is returned. The old chain is + untouched. +5. **Write** each target page in order: fetch, stamp its id and its successor's into its image (a new + `CatalogPage::stamp_links`, which `serialize` also uses), copy, unpin. +6. **Free the surplus:** the old chain's pages past the new end, which are already unlinked. + +**What this closes.** + +- The `new_page` route: all allocation happens in step 4, before any catalog write. +- The `deserialize` route at turn 2 or later, and the first fetch of every existing chain page: step 3. +- The `fetch_page(new_id)` route: step 4 stamps each new page before anything links to it. + +**The residual, stated in the code.** + +- **An I/O failure in step 5.** A `fetch_page` of a target page can fail mid-write, for example when evicting a dirty + frame fails. Pages already written then hold the new image and the rest the old one. Each link still points at a + readable page, because every fresh page was stamped in step 4, but the image is mixed. +- **Step 6** can fail after the image is complete (`fetch_page` or `delete_page` of a surplus page). The error is + returned, which is the reverse divergence: the pages hold the new image while `persist_or_undo` rolls memory back. +- Step 5 is not pinned ahead. Pinning every target page first would make the writes infallible, but a catalog of + more pages than the pool has frames (1,024) could then never persist. That would be a new wall, and it is not + taken. +- **Correction to D254's amendment 2** (append-only there): it says D270 "moves route 5's fetches ahead of every + write". That is wrong. Step 3 moves the surplus pages' DESERIALIZE ahead. Their fetch and delete stay after the + write, in step 6. + +**Also not closed:** a CREATE TABLE refused by a `persist` failure that is not an encoder refusal (NP's region-full +case) still leaks the three pages it allocated first. `persist_or_undo` undoes the map, not the pages. That is +pre-existing, and NP asserts nothing about it. + +## Run G at the fix (predicted) + +- `--test d270_catalog_persist_allocate_first`: its list shows **2**, and both pass. +- D254's `d254_catalog_persist_bound`: all 4 still pass. CP now refuses in step 1's layout, with the same size + `Constraint` and before any page write. +- **Per-target: 2592 + 2 = 2594** (2579 measured on main's tree). + +## Mutants (cut from the fix; predicted) + +| mutant | what it does | fails | +|---|---|---| +| **Q1 `first_page_written_before_allocating`** | writes page 1's new image between step 3 and step 4 | **NP** (its pages assertion). FR survives, because step 3 refuses before Q1's write | +| **Q2 `first_page_written_before_the_walk`** | writes page 1's new image before step 3 | **FR and NP** | +| **Q3 `layout_size_refusal_removed`** | step 1 no longer refuses an entry that fits no empty page | **CP** (D254's test): `add_entry` returns `NotEnoughSpace`, not the size `Constraint`. CT, CI and CF survive, because their DDL checks refuse first | From 34c9f413ddfc769bdde356e879d6c0b0b4141782 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:20:50 -0400 Subject: [PATCH 266/315] UNBUILT D244: publish_if_owed turns a poisoned logical lock into an error, since a panic inside the group-commit sync would leave syncing set and hang every later sync (review 6 R6-1); docs corrected for review 6 (publish_root_durably's routes, publish_if_owed's lock cases and the widened error effect, group_commit's shareable-sync and panic notes, A3's window, the shape test's coverage wording) (not built, not run) --- src/branch/group_commit.rs | 10 ++++--- src/branch/table_catalog.rs | 42 +++++++++++++++++++++-------- tests/d244_exits_run_the_publish.rs | 5 ++-- tests/d244_publish_root.rs | 6 ++--- 4 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/branch/group_commit.rs b/src/branch/group_commit.rs index 81b8b5df..9d13f3ee 100644 --- a/src/branch/group_commit.rs +++ b/src/branch/group_commit.rs @@ -21,7 +21,9 @@ //! //! What must NOT move out of the lock: every B+tree mutation. `BPlusTreeManager` is not safe for //! concurrent compound mutations, and a child that exists but is not listed in its parent is a GC -//! correctness hole. Only `flush_all + sync` is shareable. +//! correctness hole. Only the sync is shareable: `flush_all + sync`, preceded since D244 by the +//! publish of a root a failed mutation left owed, which takes the logical lock itself +//! (`TableBranchCatalog::publish_if_owed`) and so is never run with it held. use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Condvar, Mutex}; @@ -201,8 +203,10 @@ mod tests { /// the natural test HANGS -- and a guard that detects a bug by deadlocking is a bad guard, /// because in CI it burns the job timeout and reports nothing. The obvious production fix, /// letting a waiter steal leadership after a while, was REJECTED: the only way `syncing` stays - /// set is a code bug (a panicking leader poisons the mutex instead), so that would be - /// production complexity added to make a mutant convenient to test, which is the wrong trade. + /// set is a code bug, a sync closure that panics (the state mutex is not held during `sync()`, + /// so the panic unwinds past the reset and poisons nothing; closures must return errors, as + /// D244's `publish_if_owed` does for a poisoned lock). Stealing would be production complexity + /// added to make a mutant convenient to test, which is the wrong trade. /// Bounding it here gets the fast failure without paying for it in the write path. #[test] fn a_failed_sync_leaves_the_group_usable() { diff --git a/src/branch/table_catalog.rs b/src/branch/table_catalog.rs index 9a541fd8..56f52436 100644 --- a/src/branch/table_catalog.rs +++ b/src/branch/table_catalog.rs @@ -400,14 +400,27 @@ impl TableBranchCatalog { /// under a header naming the old root. Before this, the next `durable()` from ANY writer flushed /// them without page 1, and the file refused to open after any exit that came before the next /// successful publish: a kill, a panic, an error return, or pgserver, whose `serve` never returns - /// `Ok`. So the leader publishes first, and a failed publish fails the sync before anything is - /// flushed. + /// `Ok`. So the leader publishes first, and a failed publish fails the sync before `flush_all` + /// runs. (The publish's own fetch of page 1 may first evict one dirty page to make room; that is + /// the eviction case, which a sync cannot close.) /// /// It runs inside `wait_durable`'s sync closure, which runs after the group-commit mutex is /// dropped (`group_commit.rs`). No `durable()` caller holds `logical`, so taking it here is - /// `mutate`'s lock order. When nothing is owed it is two atomic loads: `mutate` publishes on - /// every exit, so this path runs only after a failed publish. A catalog with no header page - /// (`create`) never owes one. + /// `mutate`'s lock order, and no code may call `durable()` while holding `logical`: the mutex + /// is not re-entrant. When nothing is owed it is three atomic loads and no lock. The lock is + /// taken after a failed publish, after a mutation body that panicked, after writes made outside + /// `mutate` (`migrate_from`), and briefly when the check lands between an in-flight split and + /// that mutation's own publish, where it only waits for the mutation to finish. A catalog with + /// no header page (`create`) never owes one. + /// + /// A poisoned `logical` is an ERROR here, never a panic. A panic in this closure would unwind + /// past `wait_durable`'s `syncing = false`, and since nothing is poisoned by it, every later sync + /// on this catalog would wait for ever (D244 review 6, R6-1). An `Err` clears `syncing` and + /// wakes the followers, who each retry and fail the same way. + /// + /// While a publish is owed and keeps failing, every sync fails, so unrelated mutations return + /// `Err` for changes that are already in the pool and that a later successful sync makes + /// durable: A2's second effect, widened to every caller. /// /// `mutate`'s own publish on a failed exit stays, and it is not redundant with this one. It puts /// the current page 1 in the pool at the failure itself, so a write-back that does not go @@ -419,18 +432,25 @@ impl TableBranchCatalog { if header_page == 0 || self.published_root.load(Ordering::SeqCst) == root { return Ok(()); } - let _g = self.logical.lock().unwrap(); + let _g = self.logical.lock().map_err(|_| { + FerroError::Branch( + "branch catalog: the logical lock is poisoned by a panicked mutation, so the root it \ + owes cannot be published and nothing is flushed" + .to_string(), + ) + })?; self.publish_root() } /// **D244 review F7: the publish a clean exit owes.** Writes the tree's root into the header /// page if the last publish did not, then syncs this catalog's file. /// - /// A publish that fails on a mutation's exit records nothing, so the NEXT mutation retries it. - /// A process that makes no further mutation never gets that retry. Meanwhile another writer's - /// `durable()` or an eviction may already have put the split pages on disk, and the next open - /// then reads a root the tree has left and refuses. So both production exits call this before - /// their checkpoints: `cli::exit_sequence` and `examples/pgserver.rs`. + /// A publish that fails on a mutation's exit records nothing, so the NEXT mutation retries it, + /// or the next sync (`durable()` publishes an owed root first). A process that makes neither + /// may still have had the split pages put on disk by an eviction, and the next open then reads + /// a root the tree has left and refuses. So the CLI's clean exit calls this before its + /// checkpoints (`cli::exit_sequence`). pgserver has no reachable exit: `serve` returns only on + /// an error, which its `unwrap()` turns into a panic. pub fn publish_root_durably(&self) -> Result<(), FerroError> { let ((), seq) = self.mutate(|| Ok(()))?; self.durable(seq) diff --git a/tests/d244_exits_run_the_publish.rs b/tests/d244_exits_run_the_publish.rs index ef26e099..16b1ebb8 100644 --- a/tests/d244_exits_run_the_publish.rs +++ b/tests/d244_exits_run_the_publish.rs @@ -10,8 +10,9 @@ //! This test reads `src/cli/cli.rs`, with `//` comments stripped, and fails on it. //! //! pgserver is not checked here: its code after `serve(..).unwrap()` is unreachable, because -//! `serve` returns only `Err` (D244 review 2, R2-2). A check on it would guard dead code. The -//! server is covered by `durable()`'s own publish instead (`d244_publish_root.rs`, A3). +//! `serve` returns only on an error (inferred from `TcpListener::incoming` never ending; D244 +//! review 2, R2-2). A check on it would guard dead code. On the sync route the server is covered by +//! `durable()`'s own publish (`d244_publish_root.rs`, A3); its eviction route stays open. //! //! # Blind spots //! diff --git a/tests/d244_publish_root.rs b/tests/d244_publish_root.rs index b67d12d2..0ec7974d 100644 --- a/tests/d244_publish_root.rs +++ b/tests/d244_publish_root.rs @@ -258,9 +258,9 @@ fn a_failed_read_of_page_one_at_a_root_splits_publish_is_retried_by_the_next_mut /// ticket is awaited: its `durable()` is the next sync, and it flushes the split pages. The catalog /// is then dropped with no exit path at all, which is what a kill, a panic or pgserver amounts to. /// -/// At `9aa6968` the failed publish had already swapped the new root in, so nothing was owed. Up to -/// the D244 exit fix, the failed publish left the root owed but `durable()` never paid it. Either -/// way the sync writes the split pages under the old header, and the reopen refuses. With +/// At `9aa6968` the failed publish had already swapped the new root in, so nothing was owed. From +/// `dd021cd` until `0e89af6`, the failed publish left the root owed but `durable()` never paid it. +/// Either way the sync writes the split pages under the old header, and the reopen refuses. With /// `durable()` publishing first, page 1 is written in that same flush. #[test] fn a_sync_after_a_failed_publish_writes_the_root_before_the_split_pages() { From 05878dc038054cf732f01eb662bbb183ed5dc093 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:20:55 -0400 Subject: [PATCH 267/315] UNBUILT D270: persist lays out, serializes once, walks the chain and allocates every page before its first catalog write; the links are stamped by one CatalogPage::stamp_links --- src/catalog/catalog.rs | 190 +++++++++++++++++++++++------------- src/catalog/catalog_page.rs | 18 +++- 2 files changed, 135 insertions(+), 73 deletions(-) diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index df98229b..2c9c0cf4 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use crate::storage::disk_manager::PAGE_SIZE; use std::sync::Arc; use crate::buffer::buffer_pool::BufferPoolManager; use crate::catalog::catalog_page::{ @@ -494,7 +495,6 @@ impl Catalog { } pub fn persist(&self) -> Result<(), FerroError> { - let mut curr_page_id = self.first_catalog_page_id; // **By name, not by `HashMap` order.** Which table lands on which catalog page, and therefore // which bytes are written where, used to depend on a per-process hash seed: `persist()` on the // same two tables produced a different on-disk layout on every run. Nothing can rely on the @@ -503,87 +503,139 @@ impl Catalog { // reproducible at all. let mut sorted: Vec<&TableEntry> = self.tables.values().collect(); sorted.sort_unstable_by(|a, b| a.name.cmp(&b.name)); - // **Every entry is asked of the encoder before any page is written** (D254). The loop below - // places entries with `has_space` and `break`s at the first that does not fit. An entry - // larger than an EMPTY page fits none, so the loop never advances past it: it linked a fresh - // page per turn until allocation refused, and each turn rewrote the current page holding - // only the entries sorted before it, which truncated the catalog's image. An encoder refusal - // on a later page likewise left the earlier pages rewritten. Asking first means `persist` - // refuses with every page as it was, and `persist_or_undo` undoes cleanly. - for entry in &sorted { - refuse_unless_encodable(entry)?; + + // **Everything that can refuse happens before the first catalog write** (D270). This used to + // be one loop that read page k, allocated page k+1 when page k ended the old chain, and THEN + // wrote page k, so an error at turn k >= 2 left pages 1..k-1 rewritten over the old tail: a + // mixed image that could lose tables and record the one whose statement was refused. Now the + // phases run in order, and only the last two touch a catalog page. + + // 1. Lay the pages out in memory. An entry larger than an EMPTY page fits nowhere; it is + // refused here, by the encoder's own size question (D254). + let mut layout: Vec = vec![CatalogPage::new(0)]; + for entry in sorted { + if !layout[layout.len() - 1].has_space(entry) { + let fresh = CatalogPage::new(0); + if !fresh.has_space(entry) { + refuse_unless_encodable(entry)?; + return Err(FerroError::Internal(format!( + "the catalog entry of table '{}' fits no empty page, yet the encoder accepted it", + entry.name + ))); + } + layout.push(fresh); + } + let last = layout.len() - 1; + layout[last].add_entry(entry.clone())?; } - let mut iter = sorted.into_iter().peekable(); - loop { - let frame_i = self.buffer_pool.fetch_page(curr_page_id)?; + // 2. Serialize every page ONCE, with placeholder links. Every length-prefix refusal comes + // from here, so these images are the encoder's whole answer: no entry is serialized twice. + let mut images: Vec<[u8; PAGE_SIZE]> = Vec::with_capacity(layout.len()); + for page in &layout { + images.push(page.serialize()?); + } - let mut page = { + // 3. Walk the chain the catalog already owns: reads only. A page that does not read back as a + // catalog page is found here, not after its predecessors were rewritten. A chain that loops + // back on itself is corruption; the old loop would have followed it for ever. + let mut chain: Vec = Vec::new(); + let mut id = self.first_catalog_page_id; + while id != 0 { + if chain.contains(&id) { + return Err(FerroError::Corruption(format!( + "the catalog's page chain returns to page {id}; refusing to rewrite a chain that \ + loops" + ))); + } + chain.push(id); + let frame_i = self.buffer_pool.fetch_page(id)?; + let page = { let frame = self.buffer_pool.frames[frame_i].read().unwrap(); - CatalogPage::deserialize(frame.data)? + CatalogPage::deserialize(frame.data) }; + self.buffer_pool.unpin_page(id, false); + id = page?.next_catalog_page; + } - page.entries.clear(); - page.num_entries = 0; - - while let Some(entry) = iter.peek() { - if page.has_space(entry) { - page.add_entry(iter.next().unwrap().clone())?; - } else { - break; - } - } - - let has_more = iter.peek().is_some(); - let mut orphan_head = 0; - if has_more { - if page.next_catalog_page == 0 { - let new_id = self.buffer_pool.new_page()?; - // Stamp it as an empty catalog page before linking it. `new_page` hands back a - // zero-filled page, and the next turn of this loop deserializes whatever is at - // `next_catalog_page` — so an unstamped page arrives at `CatalogPage::deserialize` - // with format byte 0. That used to parse as an accidentally-empty page because - // byte 0 was never read; now that the byte is the format stamp (B8), the choice - // is between initialising the page here and teaching the format allowlist to - // accept all-zeroes, which would let a genuinely corrupt page through. - let frame_i = self.buffer_pool.fetch_page(new_id)?; - { - let mut frame = self.buffer_pool.frame_write(frame_i); - frame.data = CatalogPage::new(new_id).serialize()?; - } - self.buffer_pool.unpin_page(new_id, true); - page.next_catalog_page = new_id; + // 4. Allocate every page the layout needs beyond the chain, before any catalog write. Each + // new page is stamped as an empty catalog page as soon as it exists, so nothing that later + // links to it can read back a zero page. If an allocation refuses, the pages this call + // allocated are freed and nothing of the catalog was touched. + let mut fresh: Vec = Vec::new(); + while chain.len() + fresh.len() < layout.len() { + match self.stamped_catalog_page() { + Ok(new_id) => fresh.push(new_id), + Err(e) => { + self.release_catalog_pages(&fresh); + return Err(e); } - } else { - orphan_head = page.next_catalog_page; - page.next_catalog_page = 0; } + } + let targets: Vec = chain.iter().chain(fresh.iter()).copied().take(layout.len()).collect(); + + // 5. Write. **The residual, stated:** each page is fetched as it is written, so a fetch that + // fails here (a dirty frame that cannot be evicted, say) leaves the pages before it with the + // new image and the rest with the old one. Every link still reaches a readable page, since + // every new page was stamped in step 4. Pinning every target first would make these writes + // infallible, but a catalog longer than the pool has frames could then never persist. + for (k, &page_id) in targets.iter().enumerate() { + let next = targets.get(k + 1).copied().unwrap_or(0); + CatalogPage::stamp_links(&mut images[k], page_id, next); + self.write_catalog_page(page_id, &images[k])?; + } - let next = page.next_catalog_page; + // 6. Free the old chain's pages past the new end, already unlinked by step 5's last write. A + // failure here comes after the new image is complete: the error is returned, and the pages + // hold the image while the caller's undo rolls memory back. + for &surplus in chain.iter().skip(layout.len()) { + self.buffer_pool.fetch_page(surplus)?; + self.buffer_pool.unpin_page(surplus, false); + self.buffer_pool.delete_page(surplus)?; + } + Ok(()) + } - { - let mut frame = self.buffer_pool.frame_write(frame_i); - frame.data = page.serialize()?; + /// A new page, stamped as an empty catalog page before anything can link to it. + /// + /// `new_page` hands back a zero-filled page, and a later read of the chain deserializes whatever + /// sits at `next_catalog_page`, so an unstamped page arrives at `CatalogPage::deserialize` with + /// format byte 0. The choice is between initialising the page here and teaching the format + /// allowlist to accept all-zeroes, which would let a genuinely corrupt page through (B8). + fn stamped_catalog_page(&self) -> Result { + let new_id = self.buffer_pool.new_page()?; + let stamped = self.buffer_pool.fetch_page(new_id).and_then(|frame_i| { + let image = CatalogPage::new(new_id).serialize(); + if let Ok(bytes) = &image { + self.buffer_pool.frame_write(frame_i).data = *bytes; } - self.buffer_pool.unpin_page(curr_page_id, true); - - if !has_more { - let mut free_id = orphan_head; - while free_id != 0 { - let frame_i = self.buffer_pool.fetch_page(free_id)?; - let next_orphan = { - let frame = self.buffer_pool.frames[frame_i].read().unwrap(); - CatalogPage::deserialize(frame.data)?.next_catalog_page - }; - - self.buffer_pool.unpin_page(free_id, false); - self.buffer_pool.delete_page(free_id)?; - free_id = next_orphan; - } - break; + self.buffer_pool.unpin_page(new_id, true); + image.map(|_| ()) + }); + match stamped { + Ok(()) => Ok(new_id), + Err(e) => { + self.release_catalog_pages(&[new_id]); + Err(e) } - curr_page_id = next; } + } + + /// Free catalog pages this `persist` allocated and never linked, on its way out of a refusal. + /// + /// Best effort: the refusal being returned is the error the caller needs, and a page that cannot + /// be freed here is unlinked, so it is leaked and not corrupting. + fn release_catalog_pages(&self, ids: &[u32]) { + for &id in ids { + let _ = self.buffer_pool.delete_page(id); + } + } + + /// Copy one serialized image into its page. + fn write_catalog_page(&self, page_id: u32, image: &[u8; PAGE_SIZE]) -> Result<(), FerroError> { + let frame_i = self.buffer_pool.fetch_page(page_id)?; + self.buffer_pool.frame_write(frame_i).data = *image; + self.buffer_pool.unpin_page(page_id, true); Ok(()) } diff --git a/src/catalog/catalog_page.rs b/src/catalog/catalog_page.rs index e164c68e..14f01a6a 100644 --- a/src/catalog/catalog_page.rs +++ b/src/catalog/catalog_page.rs @@ -126,8 +126,7 @@ impl CatalogPage { // field, and writing 4 back out alongside a v2 body is exactly the misparse the stamp // exists to prevent. bytes[0] = CATALOG_PAGE_TYPE; - bytes[1..5].copy_from_slice(&self.page_id.to_be_bytes()); - bytes[5..9].copy_from_slice(&self.next_catalog_page.to_be_bytes()); + Self::stamp_links(&mut bytes, self.page_id, self.next_catalog_page); // Guard 1/8 — the page's table count. Checked here, before the body loop writes anything, // so an over-count refuses rather than reaching a slice index. let num_entries = fits_u16(|| "the catalog page's table count".to_string(), self.entries.len())?; @@ -257,6 +256,15 @@ impl CatalogPage { } // header -> entries (table name -> page id -> schema (column name -> datatype tag & null tag) -> index) + /// Write a page's own id and its successor's into a serialized image, where `serialize` puts them. + /// + /// One definition of where the two links live, used by `serialize` and by `Catalog::persist`, which + /// serializes every page once before it knows the page ids it will write them to (D270). + pub(crate) fn stamp_links(image: &mut [u8; PAGE_SIZE], page_id: u32, next: u32) { + image[1..5].copy_from_slice(&page_id.to_be_bytes()); + image[5..9].copy_from_slice(&next.to_be_bytes()); + } + pub fn deserialize(bytes: [u8; PAGE_SIZE]) -> Result { // An allowlist, and it has to be: byte 0 was previously written and never read, so a page // that is neither format is a page this build cannot parse. Falling through to "assume the @@ -396,8 +404,10 @@ impl CatalogPage { /// too) (D249). /// - `create_table`, `create_index` and `create_fulltext_index` ask it before they allocate their /// pages, which nothing would free after a refusal (D254). -/// - `Catalog::persist` asks it of every entry before it writes any page, because an entry no page -/// can hold used to send its placement loop round for ever, truncating the catalog's image (D254). +/// - `Catalog::persist` lays every page out in memory before it writes any (D270), and asks it of an +/// entry that fits no empty page: such an entry used to send its placement loop round for ever, +/// truncating the catalog's image (D254). Every other refusal comes from `persist` serializing its +/// page images, once, before the first write. /// /// **It is not a second length check.** It runs `serialize`, the single authority the header above /// describes, so a change to any guard in there changes this answer with it, and nothing here can From 3b37071b094f09c5a0e420408a7435190cb4a434 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:21:25 -0400 Subject: [PATCH 268/315] UNBUILT D270: mutants Q1-Q3 (page 1 written before allocating, before the walk; the layout's size refusal removed), cut from 05878dc --- bench/d270/make_mutants.py | 84 +++++++++++++++++++ ...first_page_written_before_allocating.patch | 14 ++++ ...2_first_page_written_before_the_walk.patch | 14 ++++ .../Q3_layout_size_refusal_removed.patch | 11 +++ 4 files changed, 123 insertions(+) create mode 100644 bench/d270/make_mutants.py create mode 100644 bench/d270/mutants/Q1_first_page_written_before_allocating.patch create mode 100644 bench/d270/mutants/Q2_first_page_written_before_the_walk.patch create mode 100644 bench/d270/mutants/Q3_layout_size_refusal_removed.patch diff --git a/bench/d270/make_mutants.py b/bench/d270/make_mutants.py new file mode 100644 index 00000000..205dd9ae --- /dev/null +++ b/bench/d270/make_mutants.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Write D270's mutants as `git apply`-able patches. + +The same shape as bench/d254/make_mutants.py. Each edit must match exactly once, or the script +refuses. It reads each file from a commit, never from the working tree, and writes only into +bench/d270/mutants/. Predictions are in bench/d270/PREREG.md. + + python3 bench/d270/make_mutants.py +""" +import difflib +import pathlib +import subprocess +import sys + +CATALOG = "src/catalog/catalog.rs" + +EARLY_WRITE_AFTER_WALK = ( + " {\n" + " let mut early = images[0];\n" + " CatalogPage::stamp_links(&mut early, chain[0], chain.get(1).copied().unwrap_or(0));\n" + " self.write_catalog_page(chain[0], &early)?;\n" + " }\n" +) +EARLY_WRITE_BEFORE_WALK = ( + " {\n" + " let mut early = images[0];\n" + " CatalogPage::stamp_links(&mut early, self.first_catalog_page_id, 0);\n" + " self.write_catalog_page(self.first_catalog_page_id, &early)?;\n" + " }\n" +) + +MUTANTS = [ + # The old order for the allocation route: page 1 is written before the chain's pages are allocated. + ("Q1_first_page_written_before_allocating", CATALOG, [( + " let mut fresh: Vec = Vec::new();\n", + EARLY_WRITE_AFTER_WALK + " let mut fresh: Vec = Vec::new();\n", + )]), + # The old order for the reading route: page 1 is written before the rest of the chain is read. + ("Q2_first_page_written_before_the_walk", CATALOG, [( + " let mut chain: Vec = Vec::new();\n", + EARLY_WRITE_BEFORE_WALK + " let mut chain: Vec = Vec::new();\n", + )]), + # The layout no longer refuses an entry that fits no empty page. + ("Q3_layout_size_refusal_removed", CATALOG, [( + " if !fresh.has_space(entry) {\n", + " if false && !fresh.has_space(entry) {\n", + )]), +] + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: make_mutants.py ", file=sys.stderr) + return 2 + out = pathlib.Path("bench/d270/mutants") + out.mkdir(parents=True, exist_ok=True) + for name, path, edits in MUTANTS: + src = subprocess.run( + ["git", "show", f"{sys.argv[1]}:{path}"], check=True, capture_output=True, text=True + ).stdout + text = src + for old, new in edits: + n = text.count(old) + if n != 1: + print(f"{name}: an edit matches {n} times, not once; refusing", file=sys.stderr) + return 1 + text = text.replace(old, new) + if text == src: + print(f"{name}: the mutant is identical to the source; refusing", file=sys.stderr) + return 1 + diff = difflib.unified_diff( + src.splitlines(keepends=True), + text.splitlines(keepends=True), + fromfile=f"a/{path}", + tofile=f"b/{path}", + n=3, + ) + (out / f"{name}.patch").write_text("".join(diff)) + print(f"{name}: written") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/d270/mutants/Q1_first_page_written_before_allocating.patch b/bench/d270/mutants/Q1_first_page_written_before_allocating.patch new file mode 100644 index 00000000..c9e5480f --- /dev/null +++ b/bench/d270/mutants/Q1_first_page_written_before_allocating.patch @@ -0,0 +1,14 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -562,6 +562,11 @@ + // new page is stamped as an empty catalog page as soon as it exists, so nothing that later + // links to it can read back a zero page. If an allocation refuses, the pages this call + // allocated are freed and nothing of the catalog was touched. ++ { ++ let mut early = images[0]; ++ CatalogPage::stamp_links(&mut early, chain[0], chain.get(1).copied().unwrap_or(0)); ++ self.write_catalog_page(chain[0], &early)?; ++ } + let mut fresh: Vec = Vec::new(); + while chain.len() + fresh.len() < layout.len() { + match self.stamped_catalog_page() { diff --git a/bench/d270/mutants/Q2_first_page_written_before_the_walk.patch b/bench/d270/mutants/Q2_first_page_written_before_the_walk.patch new file mode 100644 index 00000000..8f778fdf --- /dev/null +++ b/bench/d270/mutants/Q2_first_page_written_before_the_walk.patch @@ -0,0 +1,14 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -539,6 +539,11 @@ + // 3. Walk the chain the catalog already owns: reads only. A page that does not read back as a + // catalog page is found here, not after its predecessors were rewritten. A chain that loops + // back on itself is corruption; the old loop would have followed it for ever. ++ { ++ let mut early = images[0]; ++ CatalogPage::stamp_links(&mut early, self.first_catalog_page_id, 0); ++ self.write_catalog_page(self.first_catalog_page_id, &early)?; ++ } + let mut chain: Vec = Vec::new(); + let mut id = self.first_catalog_page_id; + while id != 0 { diff --git a/bench/d270/mutants/Q3_layout_size_refusal_removed.patch b/bench/d270/mutants/Q3_layout_size_refusal_removed.patch new file mode 100644 index 00000000..fcda2459 --- /dev/null +++ b/bench/d270/mutants/Q3_layout_size_refusal_removed.patch @@ -0,0 +1,11 @@ +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -516,7 +516,7 @@ + for entry in sorted { + if !layout[layout.len() - 1].has_space(entry) { + let fresh = CatalogPage::new(0); +- if !fresh.has_space(entry) { ++ if false && !fresh.has_space(entry) { + refuse_unless_encodable(entry)?; + return Err(FerroError::Internal(format!( + "the catalog entry of table '{}' fits no empty page, yet the encoder accepted it", From 56ba536ca0e3c527ac5832b3fe39cf77b2bedd01 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:21:36 -0400 Subject: [PATCH 269/315] =?UTF-8?q?UNBUILT=20D270:=20PREREG=20amendment=20?= =?UTF-8?q?1=20=E2=80=94=20the=20fix=20and=20Q1-Q3=20recorded,=20Q2's=20FR?= =?UTF-8?q?=20route=20corrected,=20counts=202=20/=202594?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bench/d270/PREREG.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/bench/d270/PREREG.md b/bench/d270/PREREG.md index 86f5bfed..6bce652c 100644 --- a/bench/d270/PREREG.md +++ b/bench/d270/PREREG.md @@ -135,3 +135,42 @@ pre-existing, and NP asserts nothing about it. | **Q1 `first_page_written_before_allocating`** | writes page 1's new image between step 3 and step 4 | **NP** (its pages assertion). FR survives, because step 3 refuses before Q1's write | | **Q2 `first_page_written_before_the_walk`** | writes page 1's new image before step 3 | **FR and NP** | | **Q3 `layout_size_refusal_removed`** | step 1 no longer refuses an entry that fits no empty page | **CP** (D254's test): `add_entry` returns `NotEnoughSpace`, not the size `Constraint`. CT, CI and CF survive, because their DDL checks refuse first | + +--- + +## Amendment 1 — the fix and mutants recorded; Q2's FR prediction corrected (nothing built) + +| commit | what | +|---|---| +| `1793298` | red tests NP, FR (additions only, `228 0`) | +| `ce06adc` | this PREREG | +| `05878dc` | **the fix**: `persist` in six phases; `stamped_catalog_page`, `release_catalog_pages`, `write_catalog_page`; `CatalogPage::stamp_links`, which `serialize` now uses; `refuse_unless_encodable`'s doc | +| `3b37071` | mutants Q1–Q3, cut from `05878dc` by `bench/d270/make_mutants.py`, each passing `git apply --check` | + +- **What was removed at `05878dc`:** the old one-loop body of `persist`, including D254's per-entry pre-pass, which + the layout and the single serialize replace; and `serialize`'s two link lines, now one `stamp_links` call. + `git diff 6ead01b 05878dc -- src/` is `catalog.rs` `121 69` and `catalog_page.rs` `14 4`. +- **Which D254 mutants still apply here.** + - P2, P3 and P4 apply, because the DDL checks are unchanged. + - P1 (the `persist` pre-pass) does not apply: the pre-pass no longer exists. Its role, refusing an oversized + entry inside `persist`, is Q3's here. + - Both facts are from `git apply --check`. +- **Q2's FR prediction, corrected.** Q2 writes page 1's new image before the walk, with a `next` of 0, because the + walk has not yet read page 1's successor. That cuts the chain. + - The walk then never reaches the damaged page 2, the statement SUCCEEDS, and FR fails at its + `.expect("… cannot be rewritten")`, not at the page-1 assertion. + - NP fails at its pages assertion: page 1 holds `[aaa, b1, b2, b3]`, and allocating the two missing pages + refuses. + - **Q2 → FR (at the refusal expectation) and NP.** +- **Counts:** + - `d270_catalog_persist_allocate_first` has **2** `#[test]`, with no `cfg`, `ignore` or macro; + - `git diff 6ead01b 3b37071` adds 2, and from `9aa6968` it adds 15, with 0 removed. +- **Per-target: 2579 + 15 = 2594.** +- **The list step is the authority:** `cargo test --test d270_catalog_persist_allocate_first -- --list` is predicted + to show 2. + +| mutant | fails | +|---|---| +| Q1 | NP (pages assertion) | +| Q2 | FR (the statement succeeds) and NP (pages assertion) | +| Q3 | CP, in D254's target: `NotEnoughSpace` instead of the size `Constraint` | From 996c598a2709d8d9165863863e8de0ca25f33c38 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:30:25 -0400 Subject: [PATCH 270/315] UNBUILT D231: PREREG amendment 3 (review 5) -- 104 verdicts, steps 33-52, base range 51-61 PASS Appended before the U1/U2/R1 code, the probe-crate instrument and steps 33-52 exist, and before any run. Tip 104/0; base 9aa6968 nominally 51/53, exit 1, with clone 34 added to the allowed FAIL->PASS(rerun=no) flips, so PASS 51-61. Records why the probe must leave examples/ (U1 would count it), the M1 blob pin, and the kill for each of review 5's surviving mutants. --- bench/d231_PREREG.md | 106 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/bench/d231_PREREG.md b/bench/d231_PREREG.md index c2d48d1e..3be68b01 100644 --- a/bench/d231_PREREG.md +++ b/bench/d231_PREREG.md @@ -250,3 +250,109 @@ The value is unset right after the build. Step 29's predictions are unchanged: * the tip stamps `at unknown +DIRTY`, with rerun yes; * the base FAILs with `at S0 +DIRTY`; * the N4 mutant FAILs the same way. + +## Amendment 3 (D231 review 5, U1-U3, M1, M2, R1; lead decisions 2026-09-24) + +Written before any of the code or steps it predicts exists, and before any run of them, stub model included. It +supersedes amendment 2's totals, for the script at the commit that follows this amendment. + +### What changes in `build.rs` (the steps below pin each part) + +* **U1, untracked inputs.** An untracked file under the build inputs makes the stamp `+DIRTY`. That means an + untracked, not-ignored file, or an ignored file ending in `.rs`, which cargo's target auto-discovery would compile. + Review 5 measured the old premise false: 1 of the 72 worktrees has such a file, and it is a true positive. Ignored + files that are not `.rs` stay uncounted, for example `tests/pg/__pycache__`. +* **U2, a byte comparison.** Every tracked input's bytes on disk (`git hash-object --no-filters`) are compared with + HEAD's blob ids (`git ls-tree -r HEAD`). Any difference, or a tracked input missing on disk, makes the stamp + `+DIRTY`. This is independent of the index, its stat cache, its bits, fsmonitor, clean filters and + `GIT_CONFIG_GLOBAL`. Review 5 measured the cost: 386 files, 9.8 MB, 0.162 s. `git status` is still asked, and its + answer is OR-ed in, so that tracked files that are not build inputs still count. +* **Failures.** A byte-check question that fails, or a gitlink among the inputs (it has no bytes to compare), makes the + stamp `unknown`. N6's two sha reads now bracket the status AND the byte check. +* **R1.** `.cargo`, `rust-toolchain` and `rust-toolchain.toml` at the package root join the inputs: watched when + present, and counted by U1 and U2. Such files in parent directories, and one that appears where none existed, are + seen only at the next re-stamp: the same limit as a new `benches/`. +* **U3.** The doc's submodule sentence is corrected (review 5 measured it wrong both ways). + +### The instrument changes (M1, and one change U1 forces) + +* **The probe is no longer an example inside the package.** An untracked `examples/d231_stamp_probe.rs` is exactly + what U1 now counts, so every stamp would read `+DIRTY`. + * The probe becomes a separate crate outside the checkout (`$WORK/probe-`, and `$WORK/probe--export` for + step 32). + * Its `Cargo.toml` depends on the package by path and has an empty `[workspace]`. Its `main` prints + `ferrodb::build_provenance()`. + * cargo builds ferrodb's lib as a path dependency. A path dependency is a local package, with mtime fingerprints and + its build script's cwd at its own root (INFERRED, from the Cargo book's build-script and path-dependency text). + The probe binary is `$tgt/debug/d231_probe`. + * ferrodb's dev-dependencies are no longer built. +* **M1.** The stub cargo is pinned to the `build.rs` BLOB it transcribes. It refuses (exit 101, which the harness turns + into exit 2) unless the dependency's `build.rs` hashes to the tip blob or to `9aa6968`'s blob, and each `.out` + records the blobs. The tip blob is recorded, once committed, in the lane report and in `d231_model/`. + +### New steps, per arm (52 in all), and what each must show at the tip + +In these steps, "touch" means the index is touched with `touch_moved`, to force a re-run. The shim is a `git` placed +first on `PATH` for that one build only, which passes every other call through to the real git. + +| n | change before the build | tip stamp | tip rerun | kills | +|---|---|---|---|---| +| 33 | `git reset --hard` (HEAD → link2 → link → `$first` at S0), sleep 2 | at S0 | yes | | +| 34 | nothing (settle) | at S0 | no | `--no-optional-locks` | +| 35 | an untracked, not-ignored `examples/d231_untracked.rs` created (premise: listed by `ls-files --others --exclude-standard`) | at S0 +DIRTY | yes | no untracked-file check (U1) | +| 36 | it is removed; touch | at S0 | yes | | +| 37 | `tests/d231_ignored.rs` created and excluded in `.git/info/exclude` (premise: `check-ignore` says ignored) | at S0 +DIRTY | yes | no ignored-`.rs` check | +| 38 | it and the exclude line are removed; touch | at S0 | yes | | +| 39 | a clean filter `sed '/d231 filter-hidden line/d'` on `src/lib.rs` (from `.git/info/attributes` and repo config), then that line appended. Premises: `git status` hides it, and the bytes differ from HEAD's blob | at S0 +DIRTY | yes | no byte comparison (U2) | +| 40 | the filter and attribute removed, `src/lib.rs` restored; touch | at S0 | yes | | +| 41 | `src/lib.rs` edited, then `update-index --skip-worktree` (premise: tag `S`, status hides it) | at unknown +DIRTY | yes | `noS` | +| 42 | the bit cleared, the file restored; touch | at S0 | yes | | +| 43 | `core.ignoreStat = true`; touch | at unknown +DIRTY | yes | `noignorestat` | +| 44 | the key unset; touch | at S0 | yes | | +| 45 | shim: `git ls-files` exits 1; touch | at unknown +DIRTY | yes | `lsfailopen` | +| 46 | touch | at S0 | yes | | +| 47 | shim: the first `git status` runs `update-ref $first Q0` before answering, so HEAD moves between build.rs's two sha reads (premise, checked after the build: `$first` resolves to Q0); touch | at unknown +DIRTY | yes | `non6` | +| 48 | nothing. The ref moved during the last script run, so it is newer than `output` | at Q0 | yes | | +| 49 | `git pack-refs --all`. Premises: `$first`'s loose file is gone, and `$first` still resolves Q0. A watched path went missing | at Q0 | yes | | +| 50 | `update-ref $first S0`, which recreates the loose file | at S0 | yes | `existsonly` | +| 51 | an untracked `.cargo/config.toml` created (outside the probe's config search path, so the build itself is unchanged); touch | at S0 +DIRTY | yes | `.cargo` not an input (R1) | +| 52 | `.cargo` removed; touch | at S0 | yes | | + +### Predictions + +**At the D231 tip: 104 PASS / 0 FAIL, exit 0.** No step depends on timing. + +**At `9aa6968`: nominally 51 PASS / 53 FAIL, exit 1.** Steps 1-32 are as in amendment 2: +* clone: 12 PASS / 20 FAIL; +* linked: 20 PASS / 12 FAIL. + +Steps 33-52 at the base: + +| arm | PASS | FAIL | why each FAIL | +|---|---|---|---| +| clone | 33 36 38 40 42 44 46 52 | 34 35 37 39 41 43 45 47 48 49 50 51 | 34 is a settle, after the self-re-run from `reset --hard`'s racy index. 35, 37 and 39: nothing re-runs (examples/, tests/ and src/ are not watched), so the stamp stays clean. 41, 43, 45 and 47: the base stamps `at S0` clean where the tip says `unknown`. 48, 49 and 50: the ref is not watched, so there is no re-run. 51: plain status ignores untracked files, so the stamp is clean | +| linked | 33 36 38 40 42 44 46 48 49 50 52 | 34 35 37 39 41 43 45 47 51 | 34: it re-runs on every build. The others are as in the clone arm, except that 48-50 re-run and PASS | + +So the base totals are clone 20 PASS / 32 FAIL and linked 31 PASS / 21 FAIL. + +**Timing-dependent base steps.** Amendment 2's allowed flips stand: +* clone 2, 9, 12, 15 and 18, FAIL→PASS with `rerun=no`; +* clone 5, 23, 24 and 28, FAIL→PASS with `rerun=yes`. + +Amendment 3 adds **clone 34, FAIL→PASS with `rerun=no`**. Every other new step is timing-independent: each restore or +checkout is followed by a touch of the index, which leaves no racy entry. So a base run gives **PASS 51-61, FAIL +43-53, exit 1**. Any other deviation is a MISMATCH. + +### Still not exercised + +* `rust-toolchain*`. A real file would make rustup switch or install toolchains. +* `.cargo` or toolchain files in parent directories. +* A gitlink among the inputs. ferrodb has none; it is refused as `unknown`. +* An fsmonitor hook. Step 39's clean filter pins the byte comparison through the same mechanism: git's opinion versus + the bytes. + +The earlier lists stand: Cargo.lock, benches/, reftable, the `--no-recurse` fallback, the answer-shape refusal, the +depth cap, and preferSymlinkRefs (N7). + +The exit condition becomes: exit 0 requires FAIL = 0 and PASS = 104, and a run that reaches anything other than 104 +verdicts exits 2. From c96d166a7c1486981f433364787c13fca4083bc6 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:36:00 -0400 Subject: [PATCH 271/315] UNBUILT D225: review 7 decisions H2 and K1-K9 (PREREG amendment 4 2cb324c, supplement A4.3b 7f8b229) H2: a MERGE's landing check asks the entry bound under the landing shape before apply_plan, through the one builder of entry shapes; an Update is asked exactly the entries its UPDATE writes (index_page::entries_an_update_writes, shared with the UPDATE pre-pass). K5/K6: the ALTER's widened-entry check runs at every step and refuses only growth past the bound. K9: index_page::row_entry_sizes / first_entry_over_bound / is_over_bound, called by every pre-check. K1-K4, K7: tests per A4.2 and A4.3b (41 new against 9aa6968; 2 red by design on d225 alone). K2/K3: remedy and comment texts made true on the landing tree (#16). --- src/agent_sql/runtime.rs | 97 ++++++++++++++-- src/catalog/alter.rs | 101 +++++++++++++---- src/catalog/catalog.rs | 49 +++++---- src/execution/insert.rs | 23 ++-- src/execution/update.rs | 65 +++++------ src/storage/index_page.rs | 217 +++++++++++++++++++++++++++++------- src/wal/recovery.rs | 37 +++---- tests/d225_byte_split.rs | 226 +++++++++++++++++++++++++++++++++++++- 8 files changed, 656 insertions(+), 159 deletions(-) diff --git a/src/agent_sql/runtime.rs b/src/agent_sql/runtime.rs index 6ed533ac..8ea974df 100644 --- a/src/agent_sql/runtime.rs +++ b/src/agent_sql/runtime.rs @@ -82,6 +82,7 @@ use crate::provenance::store::MemProvenanceStore; use crate::provenance::sha256::prompt_digest; use crate::provenance::{ProvId, ProvenanceStore, RunEntity}; use crate::storage::heap_file_manager::RecordId; +use crate::storage::index_page::entries_an_update_writes; use crate::tel::frame::TxnFrame; use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use crate::tel::guard::{ArithOp, CmpOp, Guard, GuardExpr}; @@ -5004,7 +5005,13 @@ impl AgentRuntime { // **What is still fallible after step 3, stated exactly rather than generously.** It is what // `catalog::alter` already calls environmental — a disk write that fails, a buffer pool with // no evictable frame, the arena floor exhausted by a relocation that `reserve_free_space` - // could not see coming. Two shapes of residue, and the second is not the first: + // could not see coming. Every refusal the DATA can earn is asked before step 3, by + // `conform_to`: NOT NULL, page fit, and (D225, review 7 H2) the index entry bound, which + // the publishing executors would otherwise raise only after the schema was durable. One + // exception is not decidable here: a no-cut refusal on an index leaf an earlier build left + // exactly full around an entry over the bound (`index_page::MAX_ENTRY_BYTES`'s list), + // which depends on the page, not the row. Two shapes of residue, and the second is not the + // first: // // - **one table**: its shape is applied and no row is published. The merge returns `Err` // and the target is a table with the edit and none of the branch's rows. @@ -5039,30 +5046,51 @@ impl AgentRuntime { // a table nobody altered would otherwise be refused by `Tuple::serialize` from inside the // publish transaction, after a DIFFERENT table's edits had already landed. One merge, one // decision, over every table it touches. - let mut landing: BTreeMap = BTreeMap::new(); + // + // With each table's index column positions (D225, review 7 H2), resolved by name against + // the table as it stands. ADD appends, RENAME renames in place and RETYPE retypes in place, + // so a position read here is the same column's position in the landing shape. A name that + // does not resolve is refused, not skipped: skipping it would leave that index's entries + // unasked. + let mut landing: BTreeMap, Vec)> = BTreeMap::new(); for w in &pending { let name = w.table(); if landing.contains_key(name) { continue; } - let from = ctx.catalog.require_table(name)?.schema.clone(); + let entry = ctx.catalog.require_table(name)?; + let from = entry.schema.clone(); + let position = |column: &str| { + from.columns.iter().position(|c| c.name == column).ok_or_else(|| { + FerroError::Internal(format!( + "an index on '{name}' names column '{column}', which '{name}' does not have" + )) + }) + }; + let secondary = + entry.indexes.iter().map(|i| position(&i.column_name)).collect::, _>>()?; + let fulltext = entry + .fulltext_indexes + .iter() + .map(|i| position(&i.column_name)) + .collect::, _>>()?; let to = plans .iter() .find(|(i, _)| schema_reports[*i].table == name) .map(|(_, plan)| plan.final_shape().clone()) .unwrap_or_else(|| from.clone()); - landing.insert(name.to_string(), (from, to)); + landing.insert(name.to_string(), (from, to, secondary, fulltext)); } let mut ready: Vec = Vec::with_capacity(pending.len()); for w in pending { - let (from, to) = landing.get(w.table()).ok_or_else(|| { + let (from, to, secondary, fulltext) = landing.get(w.table()).ok_or_else(|| { FerroError::Internal(format!( "no landing shape for '{}', which this merge is about to write to", w.table() )) })?; - ready.push(w.conform_to(from, to)?); + ready.push(w.conform_to(from, to, secondary, fulltext)?); } // ---- the schema, applied while no row of this merge has been written ------------------- @@ -5173,7 +5201,8 @@ impl AgentRuntime { // avoid: the report says the merge landed or it says it did not. // // The rows are `ready` rather than `pending`: already carried into the shape the schema - // edits above left the table in, and already measured against it. `ALTER` is refused + // edits above left the table in, and already measured against it — its width, its NOT + // NULL columns, and every index entry it makes (D225, review 7 H2). `ALTER` is refused // inside a transaction, so the edits could never have run inside this one; they ran // before it opened. let publish_txn = ctx.txn.begin()?; @@ -6605,19 +6634,30 @@ impl PendingWrite { /// altered (`catalog::alter::resulting_schema` refuses it), so the value it names means the /// same thing under both shapes. The key's column NAME can change, and `into_stmt` reads that /// from the catalog at the moment it builds the statement — which is after the rename landed. - fn conform_to(self, from: &Schema, to: &Schema) -> Result { + fn conform_to( + self, + from: &Schema, + to: &Schema, + secondary: &[usize], + fulltext: &[usize], + ) -> Result { match self { PendingWrite::Insert { table, row } => { let row = conform_row(&row, from, to)?; let which = format!("the row this merge inserts with key {:?}", row.first()); - refuse_if_the_row_cannot_land(&table, to, &row, &which)?; + refuse_if_the_row_cannot_land(&table, to, &row, &which, secondary, fulltext)?; Ok(PendingWrite::Insert { table, row }) } PendingWrite::Update { table, schema: _, key, row, before } => { let row = conform_row(&row, from, to)?; let before = conform_row(&before, from, to)?; let which = format!("the row this merge updates with key {key:?}"); - refuse_if_the_row_cannot_land(&table, to, &row, &which)?; + // Asked exactly the entries the UPDATE that publishes it writes (review 7 H2): an + // entry it leaves alone is not re-inserted, so refusing it would refuse a merge + // whose publish succeeds. A column this merge retypes is asked on the trunk row by + // `prepare_rewrite`. + let (secondary, fulltext) = entries_an_update_writes(&before, &row, secondary, fulltext)?; + refuse_if_the_row_cannot_land(&table, to, &row, &which, &secondary, &fulltext)?; // The landing shape, not the scored one: `into_stmt` builds the `UPDATE`'s // assignments from these column names, and after a rename the scored shape names a // column the table no longer has. @@ -7034,6 +7074,43 @@ fn access_shape(where_clause: Option<&Expr>, schema: &Schema) -> AccessShape { #[cfg(test)] mod tests { use super::*; + use crate::catalog::column::Column; + + /// **D225, review 7 H2 (A4.3b): a merged UPDATE is asked the index entries its UPDATE writes, + /// and no others.** `v` is indexed at position 1, and `'x' x 2100` makes the entry + /// (3 + 2100) + 5 = 2108 bytes, over `MAX_ENTRY_BYTES` = 2034, which only an earlier build could + /// have stored. An Update that leaves `v` alone writes no entry of `v`, so the merge must not + /// refuse it. An Update that changes `v` to that value writes it, so the merge must refuse it + /// before anything is written. + #[test] + fn a_merged_update_is_asked_only_the_index_entries_its_update_writes() { + let shape = Schema::new(vec![ + Column::new("id".to_string(), DataType::Integer, false), + Column::new("v".to_string(), DataType::Varchar(3000), true), + Column::new("n".to_string(), DataType::Integer, true), + ]); + let long = Value::Varchar("x".repeat(2100)); + let update = |before_v: Value, after_v: Value| PendingWrite::Update { + table: "t".to_string(), + schema: shape.clone(), + key: Value::Integer(2), + row: vec![Value::Integer(2), after_v, Value::Integer(5)], + before: vec![Value::Integer(2), before_v, Value::Integer(0)], + }; + + let kept = update(long.clone(), long.clone()).conform_to(&shape, &shape, &[1], &[]); + assert!( + kept.is_ok(), + "a merged UPDATE was refused for an index entry it does not write: {:?}", + kept.err() + ); + + let e = match update(Value::Varchar("short".to_string()), long).conform_to(&shape, &shape, &[1], &[]) { + Ok(_) => panic!("a merged UPDATE whose new entry is over the bound was accepted"), + Err(e) => e.to_string(), + }; + assert!(e.contains("index entry too large: 2108 bytes"), "not the landing check's refusal: {e}"); + } /// A workspace with nothing in it but the two fields `txn_refs` indexes. fn ws(name: &str, txn: u64, inherited: &[u64]) -> Workspace { diff --git a/src/catalog/alter.rs b/src/catalog/alter.rs index f71229da..31e1de44 100644 --- a/src/catalog/alter.rs +++ b/src/catalog/alter.rs @@ -77,7 +77,7 @@ use crate::provenance::ProvenanceStore; use crate::storage::heap_file_manager::{HeapFileManager, RecordId}; use crate::storage::heap_page::{MAX_TUPLE_SIZE, SLOT_ENTRY_SIZE}; use crate::storage::index::BPlusTreeManager; -use crate::storage::index_page::{entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; +use crate::storage::index_page::{entry_too_large, first_entry_over_bound, is_over_bound, row_entry_sizes, EntryOf, MAX_ENTRY_BYTES, OPEN_TABLE_REMEDY}; use crate::storage::tuple::{Tuple, VERSION_HEADER_SIZE}; use crate::wal::txn::TxnManager; @@ -399,7 +399,7 @@ impl AlterPlan { /// Refuse a row that cannot be written under `schema` — **every reason the write itself would /// refuse it for, asked while nothing has been written.** /// -/// Three questions, one function, because a caller that asks only two of them has a merge that +/// Four questions, one function, because a caller that asks only some of them has a merge that /// refuses halfway: /// /// - **arity and type**, from [`Tuple::serialize`] inside [`serialize_and_measure`], which is the @@ -410,7 +410,17 @@ impl AlterPlan { /// without ever refusing on it, so a `NULL` in a NOT NULL column is accepted when the agent /// types it and refused by `InsertOp::execute` at publication — which, now that the schema is /// applied first, is after the merge has already changed the table. -/// - **page fit**, which is the question [`prepare_rewrite`] asks of the rows already there. +/// - **page fit**, which is the question [`prepare_rewrite`] asks of the rows already there; +/// - **the index entry bound** (D225, review 7 H2): every entry the published row makes in the +/// target's indexes under the landing shape, the primary entry, each secondary entry and each +/// posting, through the same builder and the same comparison the INSERT and UPDATE executors use +/// (`index_page::first_entry_over_bound`). Those executors publish the merge's rows, and they +/// refuse an entry over `MAX_ENTRY_BYTES`, but only after `apply_plan` has made the schema edits +/// durable. So a row the bound refuses has to be refused here, or a staged `TYPE BIGINT` under a +/// long key retypes the table and then fails to publish: E82's half-merge again. `secondary` and +/// `fulltext` are the index column positions to ask, which ADD, RENAME and RETYPE leave where +/// they are: every index of the target for a row the merge inserts, and for a row it updates the +/// ones that UPDATE writes (`index_page::entries_an_update_writes`, the executor's own rule). /// /// **The second caller is why [`serialize_and_measure`] exists.** `AgentRuntime::merge` publishes /// a branch's rows into a table the same merge is altering, and "can this row be written in the @@ -423,6 +433,8 @@ pub fn refuse_if_the_row_cannot_land( schema: &Schema, values: &[Value], which: &str, + secondary: &[usize], + fulltext: &[usize], ) -> Result<(), FerroError> { for (i, c) in schema.columns.iter().enumerate() { if c.nullable || !matches!(values.get(i), None | Some(Value::Null)) { @@ -438,6 +450,23 @@ pub fn refuse_if_the_row_cannot_land( } let (tuple, fits) = serialize_and_measure(values, schema)?; if fits { + if let Some((of, len)) = first_entry_over_bound(values, true, secondary, fulltext)? { + let column = |p: usize| schema.columns.get(p).map(|c| c.name.as_str()).unwrap_or("?"); + let entry = match of { + EntryOf::Primary => "primary-index entry".to_string(), + EntryOf::Secondary(p) => format!("entry in the index on '{}'", column(p)), + EntryOf::Posting(p) => format!("posting in the full-text index on '{}'", column(p)), + }; + return Err(FerroError::Constraint(format!( + "this MERGE would publish a row into '{table}' whose {entry}, under the shape this \ + merge leaves '{table}' in, is {}: {which}. Nothing has been written — the schema \ + edits and the rows are decided together and refused together, because a merge \ + that applied one without the other is exactly the half-applied state a merge \ + exists to avoid. Change that row on the branch so its index entries fit, then \ + MERGE again.", + entry_too_large(len) + ))); + } return Ok(()); } let size = tuple.data.len(); @@ -985,9 +1014,11 @@ fn prepare_rewrite( // through `upsert`, which refuses an entry over `MAX_ENTRY_BYTES`. Which rows move is not // known until the heap is written, so every key is asked, and a refusal leaves the table // exactly as it was. This build cannot write such a key; an earlier build could. - if let Some(k) = &key { - if let Some(len) = entry_over_bound(k, &RecordId::new(0, 0)) { - let shown: String = format!("{k:?}").chars().take(60).collect(); + let shown: String = key.as_ref().map(|k| format!("{k:?}")).unwrap_or_default().chars().take(60).collect(); + if key.is_some() { + // Measured through the one builder of entry shapes (`index_page::row_entry_sizes`, + // review 7 K9). + if let Some((_, len)) = first_entry_over_bound(&values, true, &[], &[])? { return Err(FerroError::Constraint(format!( "this ALTER would re-point the primary-index entry of the row whose first \ column is {shown}: {}. A build before D225 could store such a key; this one \ @@ -998,6 +1029,25 @@ fn prepare_rewrite( } } + // **D225, review 6 H1 — every entry the chain widens is asked the bound, while nothing has + // been written.** Crash recovery rebuilds each secondary index from the heap in the new + // shape and refuses an entry over `MAX_ENTRY_BYTES`, so a widening that pushed one over + // would leave a database this build wrote and its own recovery will not open. Every tuple + // is asked, deleted ones too, because the rebuild indexes every tuple the heap holds. + // + // **At every step, and only for growth** (review 7 K6, K5). Each step's entry is compared + // with the same entry one step earlier, the way the statements typed one at a time would be + // measured, so a chain is refused where its first refusable edit is, even if a later edit + // shrinks the entry again. Only an entry that GREW past the bound is refused: an edit that + // moves no byte (`VarcharWider`) over an entry an earlier build stored over the bound + // leaves it as it was, and refusing that would refuse a change that writes nothing new. + let positions: Vec = widened.iter().map(|(_, p)| *p).collect(); + let mut before = if key.is_some() && !positions.is_empty() { + row_entry_sizes(&values, false, &positions, &[])? + } else { + Vec::new() + }; + let mut converted: Option = None; let mut over = false; for step in 1..shapes.len() { @@ -1013,29 +1063,34 @@ fn prepare_rewrite( over = true; break; } - converted = Some(bytes); - } - if over { - continue; - } - // **D225, review 6 H1 — every entry the chain widens is asked the bound here, while - // nothing has been written.** Crash recovery rebuilds each secondary index from the heap in - // the new shape and refuses an entry over `MAX_ENTRY_BYTES`, so a widening that pushed one - // over would leave a database this build wrote and its own recovery will not open. Every - // tuple is asked, deleted ones too, because the rebuild indexes every tuple the heap holds. - if let Some(k) = &key { - for (column, p) in widened { - if let Some(len) = entry_over_bound(&(values[*p].clone(), k.clone()), &()) { - let shown: String = format!("{k:?}").chars().take(60).collect(); - let state = if deleted { "a deleted row, whose tuple stays" } else { "a live row" }; + if !before.is_empty() { + let after = row_entry_sizes(&values, false, &positions, &[])?; + for ((column, _), (&(_, old_len), &(_, len))) in widened.iter().zip(before.iter().zip(after.iter())) { + if !(is_over_bound(len) && len > old_len) { + continue; + } + let state = if deleted { "a deleted row" } else { "a live row" }; + let at = if actions.len() > 1 { + let edit = actions.get(step - 1).map(|a| format!("{a:?}")).unwrap_or_default(); + format!(" at edit {step} of {}, {edit},", actions.len()) + } else { + String::new() + }; + let excess = len - MAX_ENTRY_BYTES; return Err(FerroError::Constraint(format!( "this ALTER would widen the entry of the row whose first column is {shown} \ - ({state}) in the index on '{table}.{column}': {}. Nothing has been \ - written. {OPEN_TABLE_REMEDY} Then run the ALTER again.", + ({state}) in the index on '{table}.{column}'{at} to {}: the row's key must \ + be shorter by at least {excess} bytes before the column can be retyped. \ + Nothing has been written. {OPEN_TABLE_REMEDY} Then run the ALTER again.", entry_too_large(len) ))); } + before = after; } + converted = Some(bytes); + } + if over { + continue; } let mut converted = converted.ok_or_else(|| { FerroError::Internal(format!("a rewrite of '{table}' converted a row through no shape")) diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs index 5744ba18..8b2c1ea8 100644 --- a/src/catalog/catalog.rs +++ b/src/catalog/catalog.rs @@ -8,8 +8,8 @@ use crate::storage::heap_file_manager::HeapFileManager; use crate::storage::index::BPlusTreeManager; use crate::storage::heap_file_manager::RecordId; use crate::catalog::column::{DataType, Value}; -use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; -use crate::storage::index_page::{entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; +use crate::storage::index_fulltext::{indexed_text, post_tokens}; +use crate::storage::index_page::{entry_too_large, first_entry_over_bound, OPEN_TABLE_REMEDY}; use std::sync::atomic::{AtomicU32, Ordering}; use crate::catalog::schema::Schema; @@ -209,19 +209,29 @@ impl Catalog { self.get_table(name).ok_or_else(|| self.unknown_table(name)) } - /// The refusal of an index whose backfill meets an entry over `MAX_ENTRY_BYTES`. D225, review 6 F2. + /// The refusal of an index whose backfill meets an entry over `MAX_ENTRY_BYTES`. D225, reviews 6 + /// and 7. /// - /// It names the row and says whether its tuple is deleted, because that decides the remedy. A - /// live row whose value is the cause can be UPDATEd to fit. A deleted one cannot be touched: its - /// tuple is never purged (there is no VACUUM), so the refusal repeats until the table is copied - /// and dropped, `index_page::OPEN_TABLE_REMEDY`. That residual is the no-purge root cause, and - /// no index-side change removes it. + /// It names the row and says whether its tuple is deleted, because that decides the remedy, + /// and each arm opens with its own sentence ("The row is live." / "The row is deleted.") so a + /// test can tell them apart (review 7 K1). + /// + /// - A **live** row: UPDATE the value to fit, or, if the key is the cause, the table copy. + /// - A **deleted** row: its tuple stays in the table until its key is inserted again. On the + /// tree D225 lands on, #16 (D202, `4296723`) then writes the new row into the dead version's + /// slot and moves the dead version to the table's history. Nothing else removes it; there is + /// no VACUUM. So when the key fits, inserting it again with values that fit (and deleting it + /// again if unwanted) is the in-place remedy; when the key is the cause, only the table copy. + /// On `d225-byte-split` alone, without #16, the in-place route does not work (review 7 K2). fn backfill_refusal(table: &str, column: &str, key: &Value, deleted: bool, len: usize) -> FerroError { let shown: String = format!("{key:?}").chars().take(60).collect(); let advice = if deleted { format!( - "The row is deleted and its tuple stays: nothing purges a deleted row, so this \ - refusal repeats. {OPEN_TABLE_REMEDY}" + "The row is deleted. Its tuple stays in the table until its key is inserted again, \ + which moves the dead version to the table's history; nothing else removes it \ + (there is no VACUUM), so until then this refusal repeats. If the key fits, INSERT \ + that key again with values whose index entries fit, remove the row again if it is \ + not wanted, and CREATE the index again. If the key is the cause: {OPEN_TABLE_REMEDY}" ) } else { format!( @@ -285,11 +295,11 @@ impl Catalog { // D225, review 6 F2: asked here, so the refusal can say which row and whether it // is deleted, which the tree's own refusal cannot. Dead tuples are NOT skipped: an // older snapshot or AS OF may still need their entries. - let entry = (sec_value, primary_key); - if let Some(len) = entry_over_bound(&entry, &()) { - return Err(Self::backfill_refusal(table, column, &entry.1, deleted, len)); + // Measured through the one builder of entry shapes (review 7 K9). + if let Some((_, len)) = first_entry_over_bound(&values, false, &[col_index], &[])? { + return Err(Self::backfill_refusal(table, column, &primary_key, deleted, len)); } - sec_tree.insert(entry, ())?; + sec_tree.insert((sec_value, primary_key), ())?; } Ok(()) })(); @@ -368,13 +378,12 @@ impl Catalog { let deleted = tuple.version_header()?.end_ts != 0; let values = tuple.deserialize(&schema)?; let primary_key = values[0].clone(); // first column = primary key + // D225, review 6 F2: every posting asked first, as in `create_index`, through the + // one builder of entry shapes (review 7 K9). + if let Some((_, len)) = first_entry_over_bound(&values, false, &[], &[col_index])? { + return Err(Self::backfill_refusal(table, column, &primary_key, deleted, len)); + } if let Some(text) = indexed_text(&values[col_index])? { - // D225, review 6 F2: every posting asked first, as in `create_index`. - for token in distinct_tokens(text) { - if let Some(len) = entry_over_bound(&posting_key(&token, &primary_key), &()) { - return Err(Self::backfill_refusal(table, column, &primary_key, deleted, len)); - } - } post_tokens(&ft_tree, text, &primary_key)?; } } diff --git a/src/execution/insert.rs b/src/execution/insert.rs index b06e2b8f..2ab30d61 100644 --- a/src/execution/insert.rs +++ b/src/execution/insert.rs @@ -56,8 +56,8 @@ use crate::catalog::column::Value; use crate::storage::index::BPlusTreeManager; use crate::storage::heap_file_manager::RecordId; use crate::execution::index_handle::{FullTextHandle, IndexHandle}; -use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; -use crate::storage::index_page::admit_entry; +use crate::storage::index_fulltext::{indexed_text, post_tokens}; +use crate::storage::index_page::{entry_refusal, first_entry_over_bound}; use crate::provenance::{ProvId, ProvenanceStore}; use std::sync::Arc; @@ -115,18 +115,13 @@ impl Modify for Insert { // primary entry are already written. The abort undoes only the heap, so the primary entry // would be left pointing at a deleted slot, and every later INSERT of that key would fail // reading it (`SlotDeleted`) — a key refused once could never be used again. So the same - // bound is asked here, for the entries the writes below will make, in the shapes they make - // them: `(pk, rid)`, `(value, pk)` per index, `(token, pk)` per distinct token. - admit_entry(&vals[0], &RecordId::new(0, 0))?; - for sec_idx in &self.secondary_indexes { - admit_entry(&(vals[sec_idx.col_index].clone(), vals[0].clone()), &())?; - } - for ft in &self.fulltext_indexes { - if let Some(text) = indexed_text(&vals[ft.col_index])? { - for token in distinct_tokens(text) { - admit_entry(&posting_key(&token, &vals[0]), &())?; - } - } + // bound is asked here, for every entry the writes below will make, through the one builder + // of entry shapes (`index_page::row_entry_sizes`, review 7 K9): the primary entry, each + // secondary entry, and each posting of each distinct token. + let secondary: Vec = self.secondary_indexes.iter().map(|h| h.col_index).collect(); + let fulltext: Vec = self.fulltext_indexes.iter().map(|h| h.col_index).collect(); + if let Some((_, len)) = first_entry_over_bound(&vals, true, &secondary, &fulltext)? { + return Err(entry_refusal(len)); } // **An index entry outlives the row it points at.** DELETE stamps `end_ts` on the version // in place and leaves the entry alone, so `search` finding a key does NOT mean the key is diff --git a/src/execution/update.rs b/src/execution/update.rs index a7b21b38..53c50cc8 100644 --- a/src/execution/update.rs +++ b/src/execution/update.rs @@ -12,8 +12,10 @@ use crate::storage::index::BPlusTreeManager; use crate::storage::heap_file_manager::RecordId; use crate::catalog::column::Value; use crate::execution::index_handle::{FullTextHandle, IndexHandle}; -use crate::storage::index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}; -use crate::storage::index_page::{admit_entry, entry_over_bound, entry_too_large, OPEN_TABLE_REMEDY}; +use crate::storage::index_fulltext::{indexed_text, post_tokens}; +use crate::storage::index_page::{ + entries_an_update_writes, entry_refusal, entry_too_large, first_entry_over_bound, EntryOf, OPEN_TABLE_REMEDY, +}; use crate::provenance::{ProvId, ProvenanceStore}; pub struct Update { @@ -52,10 +54,12 @@ impl Modify for Update { // there IS something to do: as of E63 a deleted key can be used again, so DELETE-then-INSERT // works rather than being advice that would have failed. // - // **With one caveat (D225, review 6):** the deleted tuple is never purged. Every entry an - // index over its values would need, including a CREATE INDEX added later, is still asked of - // it, so a row whose value is too long for an index entry leaves a tuple that refuses that - // index for good. The remedy then is the table copy, `index_page::OPEN_TABLE_REMEDY`. + // **With one caveat (D225, reviews 6 and 7):** the old key's deleted tuple stays in the heap + // until THAT key is inserted again (then its dead version moves to the table's history; + // D202, #16), and nothing else removes it (there is no VACUUM). Every entry an index over + // its values would need, including a CREATE INDEX added later, is still asked of it, so a + // row whose value is too long for an index entry leaves a tuple that refuses that index + // until then. The backfill's refusal names both remedies (`Catalog::backfill_refusal`). if let Some((col, _)) = self.assignments.iter().find(|(col, _)| *col == 0) { return Err(FerroError::Constraint(format!( "column '{}' of '{}' is the primary key and cannot be updated: moving a key means \ @@ -87,6 +91,8 @@ impl Modify for Update { // and the tokens of changed text. The assignments are evaluated here too, still once per row, and the NOT // NULL check moves with them: it reads only the new values, and inside the loop it had // the same late-refusal shape. + let secondary: Vec = self.secondary_indexes.iter().map(|h| h.col_index).collect(); + let fulltext: Vec = self.fulltext_indexes.iter().map(|ft| ft.col_index).collect(); let mut planned = Vec::with_capacity(res.len()); for (rid, old_values) in res { let mut new_values = old_values.clone(); @@ -105,32 +111,29 @@ impl Modify for Update { // The primary re-point, `upsert(pk, new_rid)`, happens only if the heap moves the row, // which is not known until it is written, so it is asked for every row. This build // cannot write a key over the bound; an earlier build could. The refusal names the one - // remedy that works for a key (review 5): a primary key cannot be UPDATEd, and DELETE - // then INSERT would leave the deleted tuple under the key for good. - if let Some(len) = entry_over_bound(pk, &RecordId::new(0, 0)) { - let shown: String = format!("{pk:?}").chars().take(60).collect(); - return Err(FerroError::Constraint(format!( - "this UPDATE would re-point the primary-index entry of the row whose key is \ - {shown}: {}. A build before D225 could store such a key; this one cannot \ - re-point it. Nothing has been written. {OPEN_TABLE_REMEDY}", - entry_too_large(len) - ))); - } - for handle in &self.secondary_indexes { - let new_v = &new_values[handle.col_index]; - if &old_values[handle.col_index] != new_v { - admit_entry(&(new_v.clone(), pk.clone()), &())?; - } - } - for ft in &self.fulltext_indexes { - let new_text = indexed_text(&new_values[ft.col_index])?; - if indexed_text(&old_values[ft.col_index])? != new_text { - if let Some(text) = new_text { - for token in distinct_tokens(text) { - admit_entry(&posting_key(&token, pk), &())?; - } - } + // remedy that works for a key (review 5): a primary key cannot be UPDATEd, and the + // INSERT half of DELETE-then-INSERT is refused for a key over the bound, so the deleted + // tuple would stay under that key for good. + // + // Measured through the one builder of entry shapes (`index_page::row_entry_sizes`, + // review 7 K9), for the entries the loop below may write: the primary re-point, the + // secondary entry of each changed value, and the postings of each changed text. + // Which of those change is decided by `entries_an_update_writes`, which a MERGE's + // landing check also calls for the Update it publishes through this executor. + let (changed, retokenized) = + entries_an_update_writes(&old_values, &new_values, &secondary, &fulltext)?; + match first_entry_over_bound(&new_values, true, &changed, &retokenized)? { + Some((EntryOf::Primary, len)) => { + let shown: String = format!("{pk:?}").chars().take(60).collect(); + return Err(FerroError::Constraint(format!( + "this UPDATE would re-point the primary-index entry of the row whose key \ + is {shown}: {}. A build before D225 could store such a key; this one \ + cannot re-point it. Nothing has been written. {OPEN_TABLE_REMEDY}", + entry_too_large(len) + ))); } + Some((_, len)) => return Err(entry_refusal(len)), + None => {} } planned.push((rid, old_values, new_values)); } diff --git a/src/storage/index_page.rs b/src/storage/index_page.rs index 2c37ca4a..242aeb20 100644 --- a/src/storage/index_page.rs +++ b/src/storage/index_page.rs @@ -1,4 +1,5 @@ use crate::{catalog::column::Value, error::FerroError, storage::{disk_manager::PAGE_SIZE, heap_file_manager::RecordId}}; +use crate::storage::index_fulltext::{distinct_tokens, indexed_text, posting_key}; #[derive(PartialEq, Debug)] pub struct BPlusTreeInternalPage { pub page_type: u8, @@ -110,44 +111,67 @@ const LEAF_HEADER_SIZE: usize = 27; /// refused. /// /// **The remedies.** For the rows of a table on a database this build has open, [`OPEN_TABLE_REMEDY`]; -/// for one crash recovery refuses to open, [`RECOVERY_REMEDY`]. There is no other, because a -/// deleted row can take nothing in place: DELETE stamps the tuple and leaves it, nothing in this -/// build purges it (there is no VACUUM), and a deleted row can be neither updated nor deleted -/// again, so it is scanned, and refused, until its table is dropped. DELETE followed by INSERT -/// therefore CREATES the problem for a key. For the envelope cases the remedy is -/// `restrict_envelope` to an envelope below the bound, which is admitted; after it, charges and -/// forks work again. The mid-record and migration cases have none in this build. +/// for one crash recovery refuses to open, [`RECOVERY_REMEDY`]. Written for the tree D225 lands on, +/// which has #16 (D202): there, INSERTing a deleted row's key again writes the new row into the +/// dead version's slot and moves the dead version to the table's history (#16's `4296723`). So a +/// deleted row whose KEY fits has an in-place remedy (insert the key again with values that fit, +/// then delete it again if unwanted), and nothing else removes a deleted tuple (there is no +/// VACUUM). A key OVER the bound has none: it cannot be updated, and inserting it again is +/// refused, so only the table copy reaches it. On `d225-byte-split` alone, before #16 is merged, +/// the in-place route does not work: the re-inserted row takes a new slot and the dead tuple stays. +/// For the envelope cases the remedy is `restrict_envelope` to an envelope below the bound, which +/// is admitted; after it, charges and forks work again. The mid-record and migration cases have +/// none in this build. /// /// **This build writes nothing over the bound itself**, and that includes an ALTER that widens an -/// indexed column: `catalog::alter::prepare_rewrite` asks the bound of every widened secondary -/// entry (review 6 H1). **One residual is not a bound failure but its root cause:** a deleted -/// tuple is never purged, so a deleted row whose value is too long for an index entry refuses -/// every CREATE INDEX on that column, and every retype of an indexed column it sits in, until the -/// table is copied and dropped. The refusals name that; they cannot cure it. +/// indexed column (`catalog::alter::prepare_rewrite` asks every step, review 6 H1 and review 7 K6) +/// and a MERGE that publishes rows into a shape it changes (`refuse_if_the_row_cannot_land`, +/// review 7 H2). **One residual is not a bound failure but its root cause:** a deleted tuple stays +/// until its key is inserted again, so a deleted row whose value is too long for an index entry +/// refuses every CREATE INDEX on that column until then, or until the table is copied. The +/// refusals name that; they cannot cure it. pub const MAX_ENTRY_BYTES: usize = (PAGE_SIZE - LEAF_HEADER_SIZE) / 2; /// What a refusal of an entry over [`MAX_ENTRY_BYTES`] tells the user to do on a database THIS -/// build has open: the ALTER, UPDATE and CREATE INDEX refusals. D225, reviews 5 and 6. +/// build has open, when the row's KEY is the cause or nothing in place reaches it: the ALTER, +/// UPDATE and CREATE INDEX refusals. D225, reviews 5 to 7. /// /// Every step is one this build can take. SELECT reads the rows, an INSERT under a key and values /// that fit is admitted, and `drop_table` frees the trees without asking the bound. It spells out -/// the two steps this SQL surface lacks (INSERT … SELECT and RENAME TABLE). It never suggests -/// DELETE followed by INSERT, which leaves the deleted tuple behind for good. +/// the steps this SQL surface lacks (INSERT … SELECT, RENAME TABLE) and the two the copy needs +/// that are easy to miss: the indexes are created again, and the table's history is lost. It never +/// suggests a delete, which for a key over the bound leaves the tuple behind for good. pub const OPEN_TABLE_REMEDY: &str = "With this build: copy the table's live rows into a new table, \ each under a key and values whose index entries fit the bound (there is no INSERT ... SELECT, \ - so SELECT the rows and INSERT each one), DROP the old table, then CREATE it again under its old \ - name and copy the rows back (there is no RENAME TABLE). There is no in-place remedy: a deleted \ - row's tuple is never purged (there is no VACUUM), so it keeps its entries until its table is \ - dropped, and a primary key cannot be updated."; - -/// What the crash-recovery refusal tells the user to do: the same steps, with a build that can -/// still open the database, because this one refuses to. D225, reviews 5 and 6. + so SELECT the rows and INSERT each one), DROP the old table, CREATE it again under its old \ + name, copy the rows back and CREATE its indexes again (there is no RENAME TABLE). The copy \ + discards the table's history: DROP frees its time-travel heap and its dead versions, so no \ + older snapshot can read them any more. No in-place change repairs a key over the bound: a \ + primary key cannot be updated and one over the bound cannot be inserted again, so a deleted \ + row under it stays in the table (there is no VACUUM)."; + +/// What the crash-recovery refusal tells the user to do, with a build that can still open the +/// database, because this one refuses to. D225, reviews 5 to 7. pub const RECOVERY_REMEDY: &str = "This build cannot open the database until that is repaired. \ - With a build before D225: copy the table's live rows into a new table, each under a key and \ - values whose index entries fit the bound, DROP the old table, then CREATE it again under its \ - old name and copy the rows back. There is no in-place remedy: a deleted row's tuple is never \ - purged, so it keeps its entries until its table is dropped, and a primary key cannot be \ - updated."; + With a build before D225: for a live row, UPDATE its value to fit; for a deleted row whose key \ + fits, INSERT that key again with values that fit, if that build writes a re-inserted key into \ + its dead version's slot (#16's D202 build does), then remove it again if it is not wanted; \ + otherwise copy the table's live rows into a new table, each under a key and values whose index \ + entries fit the bound, DROP the old table, CREATE it again under its old name, copy the rows \ + back and CREATE its indexes again. The copy discards the table's history: DROP frees its \ + time-travel heap and its dead versions. A primary key over the bound can be neither updated \ + nor inserted again, so for it only the copy works."; + +/// Which index an entry belongs to, so a refusal can name it. D225, review 7 K9. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntryOf { + /// The primary index: `(key, RecordId)`. + Primary, + /// The secondary index on the column at this position: `((value, key), ())`. + Secondary(usize), + /// A posting in the full-text index on the column at this position: `((token, key), ())`. + Posting(usize), +} /// Bytes `t` occupies on a page. One definition, so the split, the fullness test and the entry /// bound cannot disagree about what an entry costs. @@ -157,11 +181,105 @@ pub fn serialized_len(t: &T) -> usize { buf.len() } +/// Whether an entry of `len` bytes is over [`MAX_ENTRY_BYTES`]. **The one comparison with the +/// bound**: every other check, [`entry_over_bound`] and [`first_entry_over_bound`] included, asks +/// this. D225, review 7. +pub fn is_over_bound(len: usize) -> bool { + len > MAX_ENTRY_BYTES +} + /// The size of an entry that is over [`MAX_ENTRY_BYTES`], or `None` when it is admitted. The /// measurement behind [`admit_entry`], for refusals that give their own remedy. D225, review 6. pub fn entry_over_bound(key: &K, value: &V) -> Option { let len = serialized_len(key) + serialized_len(value); - (len > MAX_ENTRY_BYTES).then_some(len) + is_over_bound(len).then_some(len) +} + +/// The size of every index entry `row` makes, **measured on the trees' own entry types**, in this +/// order: the primary entry when `primary` is set, then the secondary index on each position in +/// `secondary`, then every posting of each position in `fulltext`. `row[0]` is the key. D225, +/// review 7 K9. +/// +/// **The one builder of entry shapes.** Every pre-check asks this instead of building +/// `(value, key)` or a posting itself: INSERT, UPDATE's pre-pass, the ALTER, both backfills, +/// recovery and the MERGE's landing check. So none of them can drift from what the trees store. +/// Two of those guard paths with no backstop before a free (the ALTER, which writes no secondary +/// entry, and recovery), where a drift would be review 6's H1 again. +pub fn row_entry_sizes( + row: &[Value], + primary: bool, + secondary: &[usize], + fulltext: &[usize], +) -> Result, FerroError> { + let key = row + .first() + .ok_or_else(|| FerroError::Internal("a row with no columns has no key to index".into()))?; + let column = |p: usize| { + row.get(p).ok_or_else(|| { + FerroError::Internal(format!("an index names column {p} of a {}-column row", row.len())) + }) + }; + let mut out = Vec::new(); + if primary { + out.push((EntryOf::Primary, serialized_len(key) + serialized_len(&RecordId::new(0, 0)))); + } + for &p in secondary { + let entry = (column(p)?.clone(), key.clone()); + out.push((EntryOf::Secondary(p), serialized_len(&entry) + serialized_len(&()))); + } + for &p in fulltext { + if let Some(text) = indexed_text(column(p)?)? { + for token in distinct_tokens(text) { + out.push((EntryOf::Posting(p), serialized_len(&posting_key(&token, key)) + serialized_len(&()))); + } + } + } + Ok(out) +} + +/// The first of `row`'s index entries that is over [`MAX_ENTRY_BYTES`], with its size, in +/// [`row_entry_sizes`]'s order. D225, review 7. +pub fn first_entry_over_bound( + row: &[Value], + primary: bool, + secondary: &[usize], + fulltext: &[usize], +) -> Result, FerroError> { + Ok(row_entry_sizes(row, primary, secondary, fulltext)? + .into_iter() + .find(|&(_, len)| is_over_bound(len))) +} + +/// The index entries an UPDATE from `old` to `new` writes besides the primary re-point: the +/// `secondary` positions whose value changed, and the `fulltext` positions whose indexed text +/// changed. The UPDATE executor's pre-pass asks these, and so does a MERGE's landing check for +/// the Update it will publish through that executor, so the two ask the same set (review 7 H2). +/// Asking more would refuse an UPDATE that writes nothing new. +pub fn entries_an_update_writes( + old: &[Value], + new: &[Value], + secondary: &[usize], + fulltext: &[usize], +) -> Result<(Vec, Vec), FerroError> { + // Borrowed, not cloned: the UPDATE pre-pass calls this for every row it plans. + fn at(row: &[Value], p: usize) -> Result<&Value, FerroError> { + row.get(p).ok_or_else(|| { + FerroError::Internal(format!("an index names position {p} of a {}-column row", row.len())) + }) + } + let mut changed = Vec::new(); + for &p in secondary { + if at(old, p)? != at(new, p)? { + changed.push(p); + } + } + let mut retokenized = Vec::new(); + for &p in fulltext { + if indexed_text(at(old, p)?)? != indexed_text(at(new, p)?)? { + retokenized.push(p); + } + } + Ok((changed, retokenized)) } /// The named part of every entry-bound refusal, and no remedy: the wrappers that carry @@ -185,15 +303,21 @@ pub fn entry_too_large(len: usize) -> String { /// exceeds maximum". It reaches a client as a statement error (`23000` in `pgwire::sqlstate_of`), /// not as an internal one, because the fix is to shorten the value. pub fn admit_entry(key: &K, value: &V) -> Result<(), FerroError> { - if let Some(len) = entry_over_bound(key, value) { - return Err(FerroError::Constraint(format!( - "index entry too large: {len} bytes (key and value as stored) does not fit under the \ - B+tree entry limit MAX_ENTRY_BYTES = {MAX_ENTRY_BYTES}, the largest size for which \ - every leaf split can leave both halves under a {PAGE_SIZE}-byte page; shorten the \ - indexed value" - ))); - } - Ok(()) + match entry_over_bound(key, value) { + Some(len) => Err(entry_refusal(len)), + None => Ok(()), + } +} + +/// [`admit_entry`]'s refusal, for a pre-check that measured with [`first_entry_over_bound`] and +/// refuses NEW data (INSERT, UPDATE's changed values), where shortening the value is the remedy. +pub fn entry_refusal(len: usize) -> FerroError { + FerroError::Constraint(format!( + "index entry too large: {len} bytes (key and value as stored) does not fit under the \ + B+tree entry limit MAX_ENTRY_BYTES = {MAX_ENTRY_BYTES}, the largest size for which \ + every leaf split can leave both halves under a {PAGE_SIZE}-byte page; shorten the \ + indexed value" + )) } // const CHILD_POINTER_SIZE: usize = 4; // HEADER: |page_type (1)|page_id (4)|lsn (8)|checksum (4)|num_keys (2)| @@ -1231,6 +1355,25 @@ mod tests { } } + /// **D225, review 7 K4 (M4b): a leaf's tie goes to the count midpoint.** Entries of 30, 10, + /// 10, 10 and 10 bytes (`(Varchar, Integer)`, 3 + len + 5, sorted by the string). Cut 1 leaves + /// 30 | 40 and cut 2 leaves 40 | 30, both 10 bytes apart; `n / 2` = 2 breaks the tie. Without + /// the tie-break the first of the two, cut 1, wins. The fixed-width test cannot see this: for + /// one width the tied cuts sit either side of `n / 2` only when `n` is odd, and there the + /// first of them IS `n / 2`. + #[test] + fn a_leaf_tie_goes_to_the_count_midpoint() { + let mut leaf = BPlusTreeLeafPage::<(Value, Value), ()>::new(1); + leaf.insert_entry((Value::Varchar("a".repeat(22)), Value::Integer(1)), ()); + for (pk, c) in [(2, "b"), (3, "c"), (4, "d"), (5, "e")] { + leaf.insert_entry((Value::Varchar(c.repeat(2)), Value::Integer(pk)), ()); + } + let sizes: Vec = + leaf.key_arr.iter().zip(&leaf.vals).map(|(k, v)| serialized_len(k) + serialized_len(v)).collect(); + assert_eq!(sizes, vec![30, 10, 10, 10, 10], "premise: the entry widths"); + assert_eq!(leaf.split_point(), Some(2), "a tie must go to the count midpoint"); + } + /// **D225's guard, forced to fire: a cut that leaves a half full is refused, and the page is /// left exactly as it was.** The cut is the count split's own, on both reproducer pages. #[test] diff --git a/src/wal/recovery.rs b/src/wal/recovery.rs index 252062de..44e903b7 100644 --- a/src/wal/recovery.rs +++ b/src/wal/recovery.rs @@ -1,6 +1,6 @@ use std::{collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, atomic::Ordering}}; -use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{distinct_tokens, indexed_text, post_tokens, posting_key}, index_page::{entry_over_bound, entry_too_large, RECOVERY_REMEDY}, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; +use crate::{buffer::buffer_pool::BufferPoolManager, catalog::{catalog::Catalog, column::Value}, error::FerroError, storage::{heap_file_manager::{HeapFileManager, RecordId}, heap_page::Page, index::BPlusTreeManager, index_fulltext::{indexed_text, post_tokens}, index_page::{entry_too_large, first_entry_over_bound, EntryOf, RECOVERY_REMEDY}, tuple::Tuple}, wal::{log::RecKind, txn::{TxnEntry, TxnManager, TxnStatus}}}; pub fn recover(txn: &TxnManager) -> Result { let wal = &txn.wal; @@ -192,7 +192,9 @@ fn redo_one(bp: &Arc, lsn: u64, kind: &RecKind) -> Result<(), /// /// The database then does not open in this build until the table is repaired with a build that /// can still open it, `index_page::RECOVERY_REMEDY`. Deleting the row is not a repair: a deleted -/// tuple is never purged, and this check reads every tuple the heap holds, as the rebuild does. +/// tuple stays in the heap until its key is inserted again (then its dead version moves to the +/// table's history; D202, #16), nothing else removes it (there is no VACUUM), and this check reads +/// every tuple the heap holds, as the rebuild does. /// That is deliberate: the alternative is admitting an entry for which a leaf split is not /// guaranteed to exist. fn refuse_rows_no_rebuilt_tree_admits(catalog: &Catalog, bp: &Arc, names: &[String]) -> Result<(), FerroError> { @@ -216,25 +218,20 @@ fn refuse_rows_no_rebuilt_tree_admits(catalog: &Catalog, bp: &Arc "its primary entry".to_string(), + EntryOf::Secondary(col) => { + format!("its entry in the index on '{}'", entry.schema.columns[col].name) } - } + EntryOf::Posting(col) => { + format!("a posting in the full-text index on '{}'", entry.schema.columns[col].name) + } + }; + return Err(refuse(&what, len)); } } } diff --git a/tests/d225_byte_split.rs b/tests/d225_byte_split.rs index f36b0e74..21381455 100644 --- a/tests/d225_byte_split.rs +++ b/tests/d225_byte_split.rs @@ -31,11 +31,13 @@ use std::ops::Bound; use std::path::Path; use std::sync::Arc; +use ferrodb::agent_sql::runtime::AgentRuntime; use ferrodb::buffer::buffer_pool::BufferPoolManager; use ferrodb::catalog::catalog::Catalog; use ferrodb::catalog::column::{DataType, Value}; use ferrodb::execution::executor::{run, Outcome}; use ferrodb::execution::session::Session; +use ferrodb::error::FerroError; use ferrodb::parser::parser::Parser; use ferrodb::parser::scanner::Scanner; use ferrodb::storage::disk_manager::DiskManager; @@ -688,8 +690,8 @@ fn an_update_of_a_row_under_a_legacy_oversized_key_is_refused_before_it_is_writt // Review 5: a key cannot be UPDATEd, and DELETE then INSERT would leave the deleted tuple // under the key for good, so the message names the one remedy and never suggests that. assert!(e.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {e}"); - // Case-sensitive on purpose (review 6 F8): the remedy itself says a "deleted" row's tuple is - // never purged. What must not appear is the statement DELETE offered as advice. A lowercase + // Case-sensitive on purpose (review 6 F8): the remedy itself says a "deleted" row under such + // a key stays in the table. What must not appear is the statement DELETE offered as advice. A lowercase // "delete the row" suggestion would pass this guard; the text is one constant, reviewed. assert!(!e.contains("DELETE"), "the refusal suggests DELETE, which creates the problem for a key: {e}"); assert!(!e.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {e}"); @@ -889,6 +891,8 @@ fn an_alter_that_widens_an_indexed_column_past_the_bound_is_refused_before_any_r ); assert!(e.contains("copy the table's live rows into a new table"), "the refusal does not name the remedy: {e}"); assert!(!e.to_lowercase().contains("shorten"), "the refusal suggests shortening in place: {e}"); + // Review 7 K3: the refusal says how far the key is from fitting under the new type (2035 - 2034). + assert!(e.contains("shorter by at least 1 bytes"), "the refusal does not say how much shorter the key must be: {e}"); assert_eq!(d.catalog.get_table("t").unwrap().schema.columns[1].data_type, DataType::Integer, "the refused ALTER installed the new shape"); assert_eq!(d.ids("SELECT n FROM t;"), vec![7], "the refused ALTER changed the row"); } @@ -905,7 +909,13 @@ fn an_alter_that_widens_an_indexed_column_to_exactly_the_bound_is_accepted_and_r d.sql("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;"); assert_eq!(d.catalog.get_table("t").unwrap().schema.columns[1].data_type, DataType::BigInt); - assert_eq!(d.rows("SELECT n FROM t;"), vec![vec![Value::BigInt(7)]]); + // `matches!`, not `assert_eq!`: `Value::eq` compares numbers by value, so it cannot tell + // `BigInt(7)` from `Integer(7)` (review 7 K7). + let rows = d.rows("SELECT n FROM t;"); + assert!( + matches!(rows.as_slice(), [r] if matches!(r.as_slice(), [Value::BigInt(7)])), + "the retyped row did not read back as BIGINT 7: {rows:?}" + ); rebuild_indexes(&mut d.catalog, &d.bp).expect("the database this build wrote must reopen"); } @@ -931,7 +941,7 @@ fn a_create_index_over_a_deleted_long_row_names_the_table_copy_and_the_copy_work let e = d.err("CREATE INDEX iv ON t (v);"); assert!( e.contains("index entry too large: 2108 bytes") - && e.contains("deleted") + && e.contains("The row is deleted") && e.contains("copy the table's live rows into a new table") && !e.to_lowercase().contains("shorten"), "not the named backfill refusal: {}", @@ -964,3 +974,211 @@ fn a_create_index_over_a_deleted_long_row_names_the_table_copy_and_the_copy_work d.sql("CREATE INDEX iv ON t (v);"); assert_eq!(d.ids("SELECT id FROM t WHERE v = 'kept';"), vec![2], "the rebuilt table lost its row"); } + +// ---- Review 7 (PREREG amendment 4) --------------------------------------------------------------- + +/// A database with an agent runtime, for the tests that MERGE a branch. Its shape is copied from +/// `integration_merge_ddl_atomicity.rs`. +struct MergeDb { + _dir: tempfile::TempDir, + catalog: Catalog, + bp: Arc, + txn: Arc, + runtime: Arc, + session: Session, +} + +fn merge_db() -> MergeDb { + let dir = tempfile::tempdir().unwrap(); + let (bp, txn) = stack(dir.path()); + let catalog = Catalog::create(bp.clone()).unwrap(); + let runtime = Arc::new(AgentRuntime::new()); + let session = Session::with_runtime(runtime.clone()); + MergeDb { _dir: dir, catalog, bp, txn, runtime, session } +} + +impl MergeDb { + fn exec(&mut self, sql: &str, session: &mut Session) -> Result { + let tokens = Scanner::new(sql.chars().collect(), Vec::new()).scan_tokens()?; + let mut p = Parser::new(tokens); + let mut stmts = p.parse(); + assert!(p.errors.is_empty(), "parse error in `{}`: {:?}", abbreviate(sql), p.errors); + run(stmts.remove(0), &mut self.catalog, self.bp.clone(), self.txn.clone(), session) + } + + fn sql(&mut self, sql: &str) -> Outcome { + let mut session = std::mem::replace(&mut self.session, Session::with_runtime(self.runtime.clone())); + let out = self.exec(sql, &mut session); + self.session = session; + out.unwrap_or_else(|e| panic!("`{}` failed: {e}", abbreviate(sql))) + } + + fn branch(&mut self, name: &str) -> Session { + let mut s = Session::with_runtime(self.runtime.clone()); + self.exec(&format!("BEGIN AGENT SESSION AS '{name}';"), &mut s).expect("open the branch"); + s + } + + fn shape(&self, table: &str) -> Vec<(String, DataType)> { + self.catalog + .get_table(table) + .unwrap_or_else(|| panic!("no table {table}")) + .schema + .columns + .iter() + .map(|c| (c.name.clone(), c.data_type.clone())) + .collect() + } + + fn heap(&self, table: &str) -> Vec<(RecordId, Vec)> { + let root = self.catalog.get_table(table).unwrap().first_directory_page_id; + HeapFileManager::open(root, self.bp.clone()) + .scan() + .map(|r| { + let (rid, tuple) = r.unwrap(); + (rid, tuple.data) + }) + .collect() + } +} + +/// **H2: a MERGE whose row would land over the bound is refused before the schema moves.** +/// +/// The trunk has `t (id VARCHAR(3000) NOT NULL, n INTEGER)`, indexed on `n`, and no rows, so the +/// plan's own check (review 6 H1, trunk rows only) has nothing to ask. The branch inserts +/// `('p' x 2023, 7)`, whose entry under INTEGER is 5 + 2026 = 2031, and stages `n TYPE BIGINT`. +/// At landing that entry is 9 + 2026 = 2035. The INSERT executor that publishes the row refuses +/// it, but only after `apply_plan` has retyped `t` durably: E82's half-merge. The landing check +/// must refuse first. State is asserted before wording. Red at `3f9f41c`: "the refused MERGE +/// retyped the table anyway". +#[test] +fn a_merge_whose_row_would_land_over_the_bound_is_refused_before_the_schema_moves() { + let mut d = merge_db(); + d.sql("CREATE TABLE t (id VARCHAR(3000) NOT NULL, n INTEGER);"); + d.sql("CREATE INDEX i ON t (n);"); + let shape_before = d.shape("t"); + let heap_before = d.heap("t"); + + let mut agent = d.branch("agent-h2"); + d.exec(&format!("INSERT INTO t VALUES ('{}', 7);", "p".repeat(2023)), &mut agent) + .expect("the branch takes the row: its entry is 2031 bytes under INTEGER"); + d.exec("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;", &mut agent).expect("the branch stages the retype"); + + let e = d.exec("MERGE;", &mut agent).err().expect("MERGE must be refused"); + let msg = e.to_string(); + assert_eq!(shape_before, d.shape("t"), "the refused MERGE retyped the table anyway: {}", abbreviate(&msg)); + assert_eq!(heap_before, d.heap("t"), "the refused MERGE wrote to the heap"); + assert!( + msg.contains("this MERGE would publish a row into 't'") && msg.contains("index entry too large: 2035 bytes"), + "not the landing check's refusal: {}", + abbreviate(&msg) + ); +} + +/// **K6: a chain is refused at the step that widens an entry past the bound**, as the statements +/// typed one at a time would be. The trunk holds `('p' x 2023, 7)`, indexed on `n`. A branch +/// stages `n TYPE BIGINT` (entry 9 + 2026 = 2035) and then `n TYPE DECIMAL`. The final entry, +/// `Decimal("7")`, is 4 + 2026 = 2030, which fits. Measuring only the final shape accepts the +/// chain; the first edit alone would be refused. Red at `3f9f41c`: the MERGE is accepted. +#[test] +fn a_chain_is_refused_at_the_step_that_widens_an_entry_past_the_bound() { + let mut d = merge_db(); + d.sql("CREATE TABLE t (id VARCHAR(3000) NOT NULL, n INTEGER);"); + d.sql("CREATE INDEX i ON t (n);"); + d.sql(&format!("INSERT INTO t VALUES ('{}', 7);", "p".repeat(2023))); + let shape_before = d.shape("t"); + + let mut agent = d.branch("agent-k6"); + d.exec("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;", &mut agent).expect("stage edit 1"); + d.exec("ALTER TABLE t ALTER COLUMN n TYPE DECIMAL;", &mut agent).expect("stage edit 2"); + + let msg = match d.exec("MERGE;", &mut agent) { + Ok(_) => panic!("`MERGE;` was accepted; it must be refused"), + Err(e) => e.to_string(), + }; + assert_eq!(shape_before, d.shape("t"), "the refused MERGE retyped the table anyway: {}", abbreviate(&msg)); + assert!( + msg.contains("index entry too large: 2035 bytes") && msg.contains("at edit 1 of 2"), + "not refused at the edit that widened the entry: {}", + abbreviate(&msg) + ); +} + +/// **K5: a VARCHAR widening over a legacy entry it does not grow is accepted.** `v VARCHAR(2500)` +/// is indexed, and a legacy row puts `(2, 'x' x 2100)` in the heap: an entry of +/// (3 + 2100) + 5 = 2108 bytes, which only an earlier build could store. `TYPE VARCHAR(3000)` +/// moves no byte of it, so it writes nothing that was not already there, and it must not be +/// refused. Red at `3f9f41c`: refused with "this ALTER would widen". +#[test] +fn a_varchar_widening_over_a_legacy_entry_it_does_not_grow_is_accepted() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(2500));"); + d.sql("CREATE INDEX iv ON t (v);"); + d.sql("INSERT INTO t VALUES (1, 'short');"); + d.legacy_row("t", &[Value::Integer(2), Value::Varchar("x".repeat(2100))]); + + d.sql("ALTER TABLE t ALTER COLUMN v TYPE VARCHAR(3000);"); + assert_eq!(d.catalog.get_table("t").unwrap().schema.columns[1].data_type, DataType::Varchar(3000)); +} + +/// **K1's control: the backfill says a LIVE row is live.** Without it, the deleted arm's own +/// sentence would be the only one ever asserted, and a refusal that got the arm wrong would pass. +/// This passes at `3f9f41c` too: it is a control, not a red test. +#[test] +fn a_create_index_over_a_live_long_row_says_the_row_is_live() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(3000));"); + d.sql(&format!("INSERT INTO t VALUES (2, '{}');", "x".repeat(2100))); + let e = d.err("CREATE INDEX iv ON t (v);"); + assert!( + e.contains("index entry too large: 2108 bytes") && e.contains("The row is live") && !e.contains("The row is deleted"), + "not the live arm of the backfill refusal: {}", + abbreviate(&e) + ); +} + +/// **K4: the full-text backfill asks the bound of a deleted row's postings.** A deleted +/// `('p' x 1800, 'y' x 255)`: primary entry 1809, posting (3 + 255) + (3 + 1800) = 2061. Without +/// the backfill's own check, the tree refuses with its generic text and no row or arm is named. +#[test] +fn a_create_fulltext_index_over_a_deleted_row_with_an_over_bound_posting_is_refused_by_the_backfill() { + let mut d = db(); + d.sql("CREATE TABLE f (id VARCHAR(2000) NOT NULL, body VARCHAR(300));"); + let pk = "p".repeat(1800); + d.sql(&format!("INSERT INTO f VALUES ('{pk}', '{}');", "y".repeat(255))); + d.sql(&format!("DELETE FROM f WHERE id = '{pk}';")); + let e = d.err("CREATE FULLTEXT INDEX ff ON f (body);"); + assert!( + e.contains("cannot build the index on 'f.body'") + && e.contains("index entry too large: 2061 bytes") + && e.contains("The row is deleted"), + "not the backfill's refusal: {}", + abbreviate(&e) + ); +} + +/// **K2, a merged-tree test: a deleted long row is replaced by inserting its key again, and the +/// index then builds.** RED BY DESIGN on `d225-byte-split` alone; green on the merge with #16. +/// +/// F2's fixture: `(1, 'x' x 2100)` inserted and deleted, so CREATE INDEX is refused. The remedy +/// the refusal names for a deleted row whose key fits: INSERT the key again with a value that fits, +/// remove it again, and create the index. On #16 (`4296723`) the re-inserted row takes the dead +/// version's slot and the long dead version moves to the table's history, so the backfill no +/// longer meets it. Without #16 the re-insert takes a new slot, the long tuple stays, and the +/// last CREATE INDEX fails with "cannot build the index". +#[test] +fn a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_builds() { + let mut d = db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(3000));"); + d.sql(&format!("INSERT INTO t VALUES (1, '{}');", "x".repeat(2100))); + d.sql("INSERT INTO t VALUES (2, 'kept');"); + d.sql("DELETE FROM t WHERE id = 1;"); + let e = d.err("CREATE INDEX iv ON t (v);"); + assert!(e.contains("The row is deleted"), "premise: the backfill refuses the deleted row: {}", abbreviate(&e)); + + d.sql("INSERT INTO t VALUES (1, 'x');"); + d.sql("DELETE FROM t WHERE id = 1;"); + // Red here on d225 alone: "cannot build the index". + d.sql("CREATE INDEX iv ON t (v);"); + assert_eq!(d.ids("SELECT id FROM t WHERE v = 'kept';"), vec![2]); +} From 8aba419dd072c4962811d554a8b8d86627e8bd21 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:50:54 -0400 Subject: [PATCH 272/315] UNBUILT D225 red (PREREG amendment 5 5fbdb89): a branch INSERT or UPDATE over the entry bound is refused when typed --- tests/d225_byte_split.rs | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/d225_byte_split.rs b/tests/d225_byte_split.rs index 21381455..180352e7 100644 --- a/tests/d225_byte_split.rs +++ b/tests/d225_byte_split.rs @@ -1040,6 +1040,14 @@ impl MergeDb { }) .collect() } + + /// Every row a trunk SELECT returns. + fn rows(&mut self, sql: &str) -> Vec> { + match self.sql(sql) { + Outcome::Rows(r) => r, + _ => panic!("`{}` did not return rows", abbreviate(sql)), + } + } } /// **H2: a MERGE whose row would land over the bound is refused before the schema moves.** @@ -1182,3 +1190,76 @@ fn a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_ d.sql("CREATE INDEX iv ON t (v);"); assert_eq!(d.ids("SELECT id FROM t WHERE v = 'kept';"), vec![2]); } + +// ---- PREREG amendment 5: the bound asked when a row is typed on a branch -------------------------- + +/// **A branch INSERT over the bound in the branch's own shape is refused when it is typed.** The +/// branch stages `n TYPE BIGINT` FIRST, so `('p' x 2023, 7)` would land with a secondary entry of +/// 9 + 2026 = 2035 bytes, one over the bound, although it is 5 + 2026 = 2031 in the table's shape +/// as it stands. The agent is told when it types the row, and nothing is staged. The control, +/// `('p' x 2022, 8)`, is 2034 and is taken. Red at `c96d166`: the INSERT is accepted. +/// +/// The MERGE's landing check is the second guard, for a row typed BEFORE the edit that pushes it +/// over, which this check cannot see; its own test is +/// `a_merge_whose_row_would_land_over_the_bound_is_refused_before_the_schema_moves`. +#[test] +fn a_branch_insert_over_the_bound_in_the_branch_shape_is_refused_when_typed() { + let mut d = merge_db(); + d.sql("CREATE TABLE t (id VARCHAR(3000) NOT NULL, n INTEGER);"); + d.sql("CREATE INDEX i ON t (n);"); + + let mut agent = d.branch("agent-typed-insert"); + d.exec("ALTER TABLE t ALTER COLUMN n TYPE BIGINT;", &mut agent).expect("the branch stages the retype"); + let msg = match d.exec(&format!("INSERT INTO t VALUES ('{}', 7);", "p".repeat(2023)), &mut agent) { + Ok(_) => panic!("the branch took a row whose index entry is over the bound in its own shape"), + Err(e) => e.to_string(), + }; + assert!( + msg.contains("index entry too large: 2035 bytes") + && msg.contains("on this branch") + && msg.contains("Nothing has been staged"), + "not the typing-time refusal: {}", + abbreviate(&msg) + ); + d.exec(&format!("INSERT INTO t VALUES ('{}', 8);", "p".repeat(2022)), &mut agent) + .expect("the control, 2034 bytes in the branch's shape, is taken"); + + d.exec("MERGE;", &mut agent).expect("the merge lands the retype and the control row"); + assert_eq!(d.shape("t")[1].1, DataType::BigInt, "the merge did not land the retype"); + let rows = d.rows("SELECT n FROM t;"); + assert!( + matches!(rows.as_slice(), [r] if matches!(r.as_slice(), [Value::BigInt(8)])), + "the trunk must hold exactly the control row: {rows:?}" + ); +} + +/// **A branch UPDATE to a value over the bound is refused when it is typed.** `v` is indexed and +/// `'x' x 2027` makes the entry (3 + 2027) + 5 = 2035 bytes. The UPDATE writes that entry, so it is +/// asked, as the UPDATE executor and the landing check ask it. The control, `'x' x 2026`, is 2034 +/// and is taken. Red at `c96d166`: the UPDATE is accepted. +#[test] +fn a_branch_update_over_the_bound_is_refused_when_typed() { + let mut d = merge_db(); + d.sql("CREATE TABLE t (id INTEGER NOT NULL, v VARCHAR(3000));"); + d.sql("CREATE INDEX iv ON t (v);"); + d.sql("INSERT INTO t VALUES (1, 'short');"); + + let mut agent = d.branch("agent-typed-update"); + let msg = match d.exec(&format!("UPDATE t SET v = '{}' WHERE id = 1;", "x".repeat(2027)), &mut agent) { + Ok(_) => panic!("the branch took an UPDATE whose index entry is over the bound"), + Err(e) => e.to_string(), + }; + assert!( + msg.contains("index entry too large: 2035 bytes") + && msg.contains("on this branch") + && msg.contains("Nothing has been staged"), + "not the typing-time refusal: {}", + abbreviate(&msg) + ); + let at = "x".repeat(2026); + d.exec(&format!("UPDATE t SET v = '{at}' WHERE id = 1;"), &mut agent) + .expect("the control, 2034 bytes, is taken"); + + d.exec("MERGE;", &mut agent).expect("the merge lands the control"); + assert_eq!(d.rows("SELECT v FROM t WHERE id = 1;"), vec![vec![Value::Varchar(at)]], "the trunk row is not the control"); +} From da8e10cdc00d88043492eea2fde1b565a97d9613 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:29 -0400 Subject: [PATCH 273/315] D270: raw outputs of the registered run at 56ba536 (compile, R, L, G, G_d254, collateral, Q1-Q3, Z), uninterpreted --- bench/d270/raw/B_compile_tip.txt | 60 +++++ bench/d270/raw/C_integration.txt | 93 ++++++++ bench/d270/raw/C_lib_catalog.txt | 213 ++++++++++++++++++ bench/d270/raw/G_d254.txt | 15 ++ bench/d270/raw/G_target.txt | 13 ++ bench/d270/raw/L_target.txt | 10 + ...1_first_page_written_before_allocating.txt | 30 +++ ...st_page_written_before_allocating_diff.txt | 16 ++ .../Q2_first_page_written_before_the_walk.txt | 36 +++ ...irst_page_written_before_the_walk_diff.txt | 16 ++ .../raw/Q3_layout_size_refusal_removed.txt | 30 +++ .../Q3_layout_size_refusal_removed_diff.txt | 13 ++ bench/d270/raw/RUN_log.txt | 15 ++ bench/d270/raw/R_target.txt | 36 +++ bench/d270/raw/Z_target.txt | 13 ++ 15 files changed, 609 insertions(+) create mode 100644 bench/d270/raw/B_compile_tip.txt create mode 100644 bench/d270/raw/C_integration.txt create mode 100644 bench/d270/raw/C_lib_catalog.txt create mode 100644 bench/d270/raw/G_d254.txt create mode 100644 bench/d270/raw/G_target.txt create mode 100644 bench/d270/raw/L_target.txt create mode 100644 bench/d270/raw/Q1_first_page_written_before_allocating.txt create mode 100644 bench/d270/raw/Q1_first_page_written_before_allocating_diff.txt create mode 100644 bench/d270/raw/Q2_first_page_written_before_the_walk.txt create mode 100644 bench/d270/raw/Q2_first_page_written_before_the_walk_diff.txt create mode 100644 bench/d270/raw/Q3_layout_size_refusal_removed.txt create mode 100644 bench/d270/raw/Q3_layout_size_refusal_removed_diff.txt create mode 100644 bench/d270/raw/RUN_log.txt create mode 100644 bench/d270/raw/R_target.txt create mode 100644 bench/d270/raw/Z_target.txt diff --git a/bench/d270/raw/B_compile_tip.txt b/bench/d270/raw/B_compile_tip.txt new file mode 100644 index 00000000..87597ef6 --- /dev/null +++ b/bench/d270/raw/B_compile_tip.txt @@ -0,0 +1,60 @@ +2026-09-24T21:47:49Z d207 acquiring (5s poll), pid 35698 +2026-09-24T21:48:04Z d207 ACQUIRED: 35698 d207 2026-09-24T21:48:04Z + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:850:13 + | +850 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 16.91s + Executable unittests src/lib.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/ferrodb-d929e200b07daf14) + Executable tests/d249_alter_encodable.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d249_alter_encodable-78f073dd67732cc9) + Executable tests/d254_catalog_persist_bound.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d254_catalog_persist_bound-e16469b54ac53f5f) + Executable tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) +2026-09-24T21:48:21Z d207 rc=0 +2026-09-24T21:48:21Z d207 released (owner matched) diff --git a/bench/d270/raw/C_integration.txt b/bench/d270/raw/C_integration.txt new file mode 100644 index 00000000..54a5579f --- /dev/null +++ b/bench/d270/raw/C_integration.txt @@ -0,0 +1,93 @@ +# 2026-09-24T21:59:56Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --test d141_long_identifier --test d249_alter_encodable --test integration_alter_column --test integration_merge_ddl_atomicity --test integration_alter_refusal_paths + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 6.00s + Running tests/d141_long_identifier.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d141_long_identifier-aa2222abb63538df) + +running 4 tests +test a_three_hundred_byte_table_name_does_not_corrupt_the_catalog ... ok +test the_longest_expressible_table_name_still_works_from_sql ... ok +test a_refused_create_table_does_not_wedge_the_next_one ... ok +test many_refused_statements_leave_the_database_working ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.24s + + Running tests/d249_alter_encodable.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d249_alter_encodable-78f073dd67732cc9) + +running 7 tests +test a_plan_held_across_a_change_to_its_own_table_is_refused ... ok +test a_plan_held_across_another_alter_of_its_own_table_is_refused ... ok +test an_added_ninth_column_the_catalog_cannot_hold_is_refused_and_wedges_nothing ... ok +test a_rename_to_a_column_name_the_catalog_cannot_hold_is_refused_and_wedges_nothing ... ok +test an_added_column_whose_name_the_catalog_cannot_hold_is_refused_and_wedges_nothing ... ok +test a_rename_that_grows_an_indexed_column_past_a_page_is_refused_by_size ... ok +test a_thirty_third_column_the_catalog_cannot_hold_is_refused_before_any_row_is_rewritten ... ok + +test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.71s + + Running tests/integration_alter_column.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_alter_column-19d5bafdeeed6834) + +running 29 tests +test a_branch_whose_only_statement_is_an_alter_still_merges_against_its_fork_point ... ok +test a_merge_report_names_the_shape_it_produced ... ok +test a_branchs_schema_change_is_invisible_until_it_merges ... ok +test a_column_an_agent_added_reaches_the_feed ... ok +test an_alter_is_refused_while_any_transaction_is_open ... ok +test a_column_added_mid_stream_survives_a_pump_boundary ... ok +test a_row_written_before_a_sibling_widened_the_table_still_publishes ... ok +test decoding_a_range_twice_gives_the_same_answer_across_an_alter ... ok +test a_pump_after_an_alter_does_not_silently_truncate_the_row ... ok +test deleted_rows_stay_deleted_across_a_rewrite ... ok +test crossing_the_null_bitmap_boundary_keeps_every_value ... ok +test renaming_an_indexed_column_leaves_the_table_queryable ... ok +test retyping_a_middle_column_does_not_disturb_its_neighbours ... ok +test rows_written_before_the_column_are_still_readable_after_it ... ok +test the_altered_shape_survives_a_truncation_and_a_reopen ... ok +test the_feed_carries_the_column_change_in_log_order ... ok +test two_branches_adding_different_columns_compose ... ok +test the_refusals_refuse_and_the_permitted_cases_are_permitted ... ok +test two_branches_retyping_one_column_conflict_with_the_predicate ... ok +test three_branches_forked_at_three_different_shapes_all_compose ... ok +test two_branches_making_the_same_change_do_not_conflict ... ok +test history_learned_from_one_log_is_not_applied_to_another ... ok +test a_lookup_by_key_still_finds_a_row_the_rewrite_moved ... ok +test a_secondary_index_over_a_retyped_column_still_answers ... ok +test statistics_survive_an_alteration_exactly ... ok +test the_decoders_history_does_not_grow_with_every_checkpoint ... ok +test a_column_name_needing_escaping_survives_the_whole_pipeline ... ok +test a_rename_and_a_retype_reach_the_destination_without_losing_data ... ok +test a_column_added_mid_stream_reaches_the_destination ... ok + +test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 10.97s + + Running tests/integration_alter_refusal_paths.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_alter_refusal_paths-58a897f4b4bc3fb2) + +running 9 tests +test a_refused_merge_leaves_a_branch_that_does_not_double_apply_on_retry ... ok +test the_retry_after_a_refused_merge_does_not_report_success_while_dropping_the_edit ... ok +test a_retry_that_reports_ok_must_have_applied_the_staged_retype ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_first_applied ... ok +test the_half_applied_rename_is_on_disk_not_just_in_memory ... ok +test a_merge_refused_for_size_still_publishes_the_branch_rows ... ok +test control_the_same_merge_lands_when_the_rows_leave_room ... ok +test a_second_branch_refused_leaves_the_first_branchs_merge_intact ... ok +test a_merge_refused_on_the_second_edit_does_not_leave_the_heap_rewritten ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.41s + + Running tests/integration_merge_ddl_atomicity.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_merge_ddl_atomicity-81b4e1b7467e1784) + +running 9 tests +test a_row_the_merge_would_publish_with_a_null_in_a_not_null_column_refuses_the_whole_merge ... ok +test a_merge_publishes_its_rows_into_the_shape_its_own_edits_produced ... ok +test an_index_follows_its_column_across_the_renames_a_merge_applies ... ok +test a_rename_follows_its_column_into_the_fulltext_index_too ... ok +test a_chain_refused_at_its_first_edit_names_that_edit_and_not_the_last ... ok +test a_row_the_merge_would_publish_too_wide_for_the_shape_it_lands_in_refuses_the_whole_merge ... ok +test narrowing_a_row_and_retyping_behind_it_takes_two_merges_and_the_refusal_says_so ... ok +test planning_a_refused_merge_does_not_even_append_an_empty_page ... ok +test a_merge_refused_at_its_third_edit_leaves_neither_of_the_first_two_applied ... ok + +test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.00s + +# rc=0 end=2026-09-24T22:00:27Z diff --git a/bench/d270/raw/C_lib_catalog.txt b/bench/d270/raw/C_lib_catalog.txt new file mode 100644 index 00000000..86c33cf9 --- /dev/null +++ b/bench/d270/raw/C_lib_catalog.txt @@ -0,0 +1,213 @@ +# 2026-09-24T21:58:19Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --lib catalog:: + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:850:13 + | +850 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 20.31s + Running unittests src/lib.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/ferrodb-d929e200b07daf14) + +running 151 tests +test branch::catalog::tests::epochs_are_strictly_monotonic_across_forks ... ok +test branch::catalog::tests::the_live_child_queries_answer_the_rule_the_array_used_to ... ok +test branch::catalog::tests::reparent_refuses_a_branch_that_is_no_longer_readable ... ok +test branch::catalog::tests::reparent_moves_the_four_position_fields_and_nothing_else ... ok +test branch::catalog::tests::a_slot_still_pinning_children_is_not_recycled ... ok +test agent_sql::mem_catalog::tests::reading_a_reaped_branch_is_a_hard_error_not_stale_data ... ok +test agent_sql::mem_catalog::tests::fork_records_the_child_in_the_parent_and_shares_the_root ... ok +test branch::catalog::tests::reading_a_reaped_branch_is_a_hard_error ... ok +test branch::catalog::tests::fork_records_the_child_in_the_parent_atomically ... ok +test branch::catalog::tests::expired_before_excludes_trunk_the_unexpired_and_the_not_live ... ok +test branch::catalog::tests::a_recycled_id_slot_never_answers_to_the_old_handle ... ok +test branch::catalog::tests::a_chain_far_past_the_old_cap_forks_and_reports_its_depth ... ok +test branch::catalog::tests::every_record_sweep_comes_back_in_branch_id_order ... ok +test branch::reaper::tests::log_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::catalog::tests::a_torn_tail_keeps_every_intact_record ... ok +test branch::reaper::tests::log_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::reaper::tests::log_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +test branch::catalog::tests::a_parent_listing_only_reaped_children_comes_back_with_an_empty_array ... ok +test branch::reaper::tests::log_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::reaper::tests::log_catalog::a_refused_reap_is_not_the_same_answer_as_a_lease_that_moved ... ok +test branch::reaper::tests::log_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::catalog::tests::durable_catalog_survives_a_reopen ... ok +test branch::reaper::tests::log_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::catalog::tests::a_runtime_release_and_a_reload_agree_on_which_slot_is_next ... ok +test branch::reaper::tests::log_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::log_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::reaper::tests::log_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::reaper::tests::log_catalog::a_failing_residue_sweep_puts_the_deferred_arenas_back ... ok +test branch::reaper::tests::log_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::log_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::log_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::log_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::log_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::log_catalog::trunk_is_never_reaped ... ok +test branch::reaper::tests::log_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::reaper::tests::log_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test branch::reaper::tests::log_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +test branch::reaper::tests::log_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test branch::reaper::tests::table_catalog::a_branch_that_dies_before_flushing_allocates_zero_pages ... ok +test branch::reaper::tests::table_catalog::a_crash_orphaned_extent_is_collected_when_the_store_is_reopened ... ok +test branch::catalog::tests::live_children_is_derived_at_replay_and_excludes_reaped_children ... ok +test branch::reaper::tests::log_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test branch::reaper::tests::table_catalog::a_drain_whose_sweep_never_runs_records_its_touched_arenas ... ok +test branch::reaper::tests::table_catalog::a_drain_that_fails_partway_records_the_arenas_it_already_touched ... ok +test branch::reaper::tests::log_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::catalog::tests::two_opens_of_one_log_recycle_branch_ids_in_the_same_order ... ok +test branch::reaper::tests::table_catalog::a_pinned_page_is_not_released_while_the_child_lives ... ok +test branch::reaper::tests::table_catalog::a_failing_residue_sweep_puts_the_deferred_arenas_back ... ok +test branch::reaper::tests::table_catalog::a_reap_interrupted_by_a_crash_is_resumed_not_leaked ... ok +test branch::reaper::tests::log_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test branch::reaper::tests::log_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::reaper::tests::log_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test branch::reaper::tests::log_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::reaper::tests::table_catalog::a_refused_reap_is_not_the_same_answer_as_a_lease_that_moved ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_that_parks_nothing_still_gives_its_extents_back ... ok +test branch::reaper::tests::table_catalog::reap_is_idempotent_and_a_stale_handle_is_refused ... ok +test branch::reaper::tests::table_catalog::reap_marks_a_branch_reaped_before_detaching_it_from_its_parent ... ok +test branch::reaper::tests::table_catalog::an_interrupted_reap_resumes_deepest_first_so_no_id_slot_is_stranded ... ok +test branch::reaper::tests::table_catalog::fast_path_frees_extents_wholesale_without_touching_a_page_header ... ok +test branch::reaper::tests::table_catalog::d85_a_live_childs_pages_survive_a_reap_after_a_crash_understated_next_free ... ok +test branch::reaper::tests::table_catalog::a_fast_path_reap_reaches_the_durable_map_not_just_memory ... ok +test branch::reaper::tests::table_catalog::the_crash_orphan_collector_fires_on_state_the_narrowed_sweep_cannot_see ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_refuses_a_live_owner_and_refuses_a_non_empty_extent ... ok +test branch::reaper::tests::table_catalog::trunk_is_never_reaped ... ok +test branch::reaper::tests::table_catalog::the_orphan_collector_runs_on_the_first_tick_then_only_once_per_cadence ... ok +test branch::reaper::tests::table_catalog::renewing_a_lease_keeps_a_branch_out_of_the_scan ... ok +test branch::reaper::tests::table_catalog::slow_path_pins_pages_a_live_child_can_still_see_then_releases_them ... ok +test branch::table_catalog::serial_section_profile::where_the_serial_section_goes ... ignored, profiling, not a correctness test; run with --ignored --nocapture +test branch::table_catalog::d10_guard::write_record_new_accepts_a_freshly_forked_child ... ok +test branch::table_catalog::d10_guard::write_record_new_refuses_a_record_carrying_arenas - should panic ... ok +test branch::table_catalog::tests::a_child_entry_naming_a_branch_with_no_record_is_refused_not_resolved_away ... ok +test branch::table_catalog::tests::a_fresh_catalog_holds_trunk_and_nothing_else ... ok +test branch::table_catalog::tests::a_child_inherits_its_parents_capability_envelope ... ok +test branch::reaper::tests::table_catalog::a_slow_path_reap_and_the_drain_that_follows_both_reach_the_durable_map ... ok +test branch::table_catalog::tests::a_stale_child_entry_against_a_reaped_child_is_resolved_and_ignored ... ok +test branch::table_catalog::tests::a_migrated_catalog_answers_every_query_the_way_its_source_does ... ok +test branch::reaper::tests::table_catalog::expired_scan_reaps_deepest_first_so_a_whole_chain_goes_back ... ok +test branch::table_catalog::tests::attach_child_puts_a_branch_into_a_parents_live_set_and_detach_takes_it_out ... ok +test branch::table_catalog::tests::an_arena_appended_the_way_the_page_store_does_it_survives_a_round_trip ... ok +test branch::reaper::tests::table_catalog::the_reaper_does_not_fire_before_the_lease_expires ... ok +test branch::table_catalog::tests::expired_before_selects_only_expired_live_non_trunk_branches ... ok +test branch::table_catalog::tests::fork_records_the_child_in_its_parents_live_set ... ok +test branch::table_catalog::tests::an_interrupted_switchover_prefers_the_tree_and_retires_the_stale_log ... ok +test branch::table_catalog::tests::opening_an_existing_catalog_never_creates_a_fresh_one_over_it ... ok +test branch::reaper::tests::log_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test branch::table_catalog::tests::reopening_recovers_the_counters_without_replaying_anything ... ok +test branch::reaper::tests::table_catalog::a_freed_extent_is_reused_so_a_second_wave_costs_no_new_space ... ok +test catalog::catalog::tests::dropping_a_table_forgets_its_statistics ... ok +test catalog::catalog::tests::dropping_a_table_returns_its_pages_including_the_time_travel_heap ... ok +test catalog::catalog::tests::test_catalog_create_open ... ok +test branch::table_catalog::tests::the_two_catalogs_agree_about_what_current_epoch_means ... ok +test branch::table_catalog::tests::writing_a_record_twice_leaves_exactly_one_of_it ... ok +test branch::table_catalog::tests::scan_returns_every_record_in_branch_id_order ... ok +test branch::table_catalog::tests::a_legacy_log_is_migrated_once_and_retired_not_deleted ... ok +test catalog::catalog::tests::test_create_table_adds_time_travel_root ... ok +test catalog::catalog::tests::test_drop_table ... ok +test catalog::catalog_page::tests::a_long_name_s_own_bytes_are_not_parsed_as_catalog_entries ... ok +test catalog::catalog::tests::test_create_index ... ok +test catalog::catalog_page::tests::a_middle_entry_s_fulltext_block_does_not_shift_the_next_entry ... ok +test catalog::catalog::tests::test_persist_orphan_removal ... ok +test catalog::catalog::tests::test_get_table ... ok +test catalog::catalog_page::tests::a_name_too_long_for_its_length_field_never_yields_a_page_that_misparses_the_next_entry ... ok +test catalog::catalog_page::tests::a_pre_b8_catalog_page_still_parses_with_no_fulltext_indexes ... ok +test catalog::catalog::tests::test_update_roots ... ok +test catalog::catalog_page::tests::an_entry_larger_than_an_empty_page_is_refused_rather_than_serialized ... ok +test catalog::catalog_page::tests::an_unknown_catalog_page_format_is_refused ... ok +test catalog::catalog_page::tests::every_length_prefix_refuses_the_first_value_it_cannot_express ... ok +test catalog::catalog_page::tests::fulltext_indexes_round_trip_separately_from_btree_indexes ... ok +test catalog::catalog_page::tests::test_add_space_management ... ok +test catalog::catalog_page::tests::test_basic_roundtrip ... ok +test catalog::catalog_page::tests::test_remove_entry ... ok +test catalog::catalog_page::tests::the_largest_value_each_prefix_can_express_still_round_trips ... ok +test branch::reaper::tests::table_catalog::branches_abandoned_before_a_restart_are_still_reaped_after_it ... ok +test catalog::catalog_page::tests::the_longest_expressible_column_name_is_encodable ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_decimal ... ok +test catalog::column::tests::a_floor_guard_rejects_a_negative_float ... ok +test catalog::column::tests::bigint_orders_by_value_at_the_i64_extremes ... ok +test catalog::column::tests::bigint_versus_float_does_not_widen_past_2_53 ... ok +test catalog::column::tests::bigint_versus_non_finite_floats_matches_total_cmp_placement ... ok +test catalog::column::tests::decimal_beyond_i128_still_compares_exactly ... ok +test catalog::column::tests::decimal_orders_numerically_not_lexicographically ... ok +test catalog::column::tests::decimal_versus_float_uses_the_floats_exact_value ... ok +test catalog::column::tests::decimal_versus_non_finite_floats_is_fixed_and_cycle_free ... ok +test catalog::column::tests::f64_exact_decimal_is_exact_not_shortest ... ok +test catalog::column::tests::float_does_not_outrank_a_larger_integer ... ok +test branch::reaper::tests::log_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok +test catalog::column::tests::integer_to_float_widening_is_exact_at_i32_bounds ... ok +test catalog::column::tests::numerically_equal_int_and_float_compare_equal ... ok +test catalog::column::tests::ordering_is_transitive_across_the_numeric_band ... ok +test catalog::column::tests::parse_decimal_accepts_what_sql_writes_and_refuses_what_it_does_not ... ok +test catalog::column::tests::rank_fallback_still_orders_incomparable_variants ... ok +test catalog::column::tests::the_numeric_band_is_a_total_order ... ok +test catalog::column::tests::the_whole_value_domain_is_a_total_order ... ok +test catalog::column::tests::timestamp_compares_by_value_and_sits_outside_the_numeric_band ... ok +test catalog::system_views::tests::a_table_may_not_take_a_view_name_but_an_ordinary_name_is_fine ... ok +test catalog::system_views::tests::every_view_name_resolves_and_nothing_else_does ... ok +test catalog::system_views::tests::hex_of_a_prompt_hash_is_64_characters ... ok +test catalog::system_views::tests::synthetic_row_ids_stay_distinct_past_the_slot_number_width ... ok +test catalog::system_views::tests::u64_values_past_i64_max_are_not_reported_as_negative ... ok +test catalog::system_views::tests::view_names_and_declared_columns_agree_with_the_enum ... ok +test branch::reaper::tests::table_catalog::reclaimed_space_is_reclaimed_on_disk_not_merely_in_a_counter ... ok +test branch::reaper::tests::table_catalog::the_narrowed_sweep_leaves_the_global_scan_nothing_to_find ... ok +test branch::table_catalog::d126_record_key_probe::renew_lease_never_un_reads_the_record_and_the_neighbour_never_moves ... ok +test branch::table_catalog::d126_record_key_probe::the_record_key_is_never_absent_while_it_is_rewritten ... ok +test branch::reaper::tests::table_catalog::abandoned_branches_are_reaped_with_no_client_cooperation_and_pages_return_to_baseline ... ok +test branch::reaper::tests::table_catalog::reaping_n_branches_costs_no_catalog_descent_per_branch ... ok +test branch::table_catalog::tests::a_reopen_through_the_header_page_follows_a_root_that_moved ... ok +test branch::table_catalog::tests::a_sidecar_catalog_survives_being_closed_and_reopened_from_the_file_alone ... ok +test branch::reaper::tests::table_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent has been running for over 60 seconds +test branch::reaper::tests::table_catalog::a_thousand_branches_forked_and_abandoned_concurrently_stay_consistent ... ok + +test result: ok. 150 passed; 0 failed; 1 ignored; 0 measured; 1518 filtered out; finished in 76.37s + +# rc=0 end=2026-09-24T21:59:56Z diff --git a/bench/d270/raw/G_d254.txt b/bench/d270/raw/G_d254.txt new file mode 100644 index 00000000..4a9d5bae --- /dev/null +++ b/bench/d270/raw/G_d254.txt @@ -0,0 +1,15 @@ +# 2026-09-24T21:58:17Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --test d254_catalog_persist_bound + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1.84s + Running tests/d254_catalog_persist_bound.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d254_catalog_persist_bound-e16469b54ac53f5f) + +running 4 tests +test persist_refuses_an_entry_no_page_can_hold_before_writing_any_page ... ok +test a_create_table_whose_entry_exceeds_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok +test a_create_fulltext_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok +test a_create_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok + +test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.30s + +# rc=0 end=2026-09-24T21:58:19Z diff --git a/bench/d270/raw/G_target.txt b/bench/d270/raw/G_target.txt new file mode 100644 index 00000000..2660a00f --- /dev/null +++ b/bench/d270/raw/G_target.txt @@ -0,0 +1,13 @@ +# 2026-09-24T21:58:14Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.01s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) + +running 2 tests +test a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written ... ok +test a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.49s + +# rc=0 end=2026-09-24T21:58:17Z diff --git a/bench/d270/raw/L_target.txt b/bench/d270/raw/L_target.txt new file mode 100644 index 00000000..318631fe --- /dev/null +++ b/bench/d270/raw/L_target.txt @@ -0,0 +1,10 @@ +# 2026-09-24T21:58:10Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --test d270_catalog_persist_allocate_first -- --list + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.44s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) +a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written: test +a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were: test + +2 tests, 0 benchmarks +# rc=0 end=2026-09-24T21:58:14Z diff --git a/bench/d270/raw/Q1_first_page_written_before_allocating.txt b/bench/d270/raw/Q1_first_page_written_before_allocating.txt new file mode 100644 index 00000000..452c0887 --- /dev/null +++ b/bench/d270/raw/Q1_first_page_written_before_allocating.txt @@ -0,0 +1,30 @@ +# 2026-09-24T22:00:27Z HEAD=56ba536 dirty=1 +# cmd: timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 4.18s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) + +running 2 tests +test a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written ... ok +test a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were ... FAILED + +failures: + +---- a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were stdout ---- + +thread 'a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were' (356832426) panicked at tests/d270_catalog_persist_allocate_first.rs:193:5: +assertion `left == right` failed: the refused statement changed the catalog's pages: a restart would read this image + left: {"aaa", "b1", "b2", "b3", "b5", "b6", "b7", "b8"} + right: {"b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8"} +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were + +test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.70s + +error: test failed, to rerun pass `--test d270_catalog_persist_allocate_first` +error: 1 target failed: + `--test d270_catalog_persist_allocate_first` +# rc=101 end=2026-09-24T22:00:33Z diff --git a/bench/d270/raw/Q1_first_page_written_before_allocating_diff.txt b/bench/d270/raw/Q1_first_page_written_before_allocating_diff.txt new file mode 100644 index 00000000..4134f358 --- /dev/null +++ b/bench/d270/raw/Q1_first_page_written_before_allocating_diff.txt @@ -0,0 +1,16 @@ +diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs +index 2c9c0cf..f7e58b0 100644 +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -562,6 +562,11 @@ impl Catalog { + // new page is stamped as an empty catalog page as soon as it exists, so nothing that later + // links to it can read back a zero page. If an allocation refuses, the pages this call + // allocated are freed and nothing of the catalog was touched. ++ { ++ let mut early = images[0]; ++ CatalogPage::stamp_links(&mut early, chain[0], chain.get(1).copied().unwrap_or(0)); ++ self.write_catalog_page(chain[0], &early)?; ++ } + let mut fresh: Vec = Vec::new(); + while chain.len() + fresh.len() < layout.len() { + match self.stamped_catalog_page() { diff --git a/bench/d270/raw/Q2_first_page_written_before_the_walk.txt b/bench/d270/raw/Q2_first_page_written_before_the_walk.txt new file mode 100644 index 00000000..e22eff67 --- /dev/null +++ b/bench/d270/raw/Q2_first_page_written_before_the_walk.txt @@ -0,0 +1,36 @@ +# 2026-09-24T22:00:33Z HEAD=56ba536 dirty=1 +# cmd: timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.46s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) + +running 2 tests +test a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were ... FAILED +test a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written ... FAILED + +failures: + +---- a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were stdout ---- + +thread 'a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were' (356840731) panicked at tests/d270_catalog_persist_allocate_first.rs:193:5: +assertion `left == right` failed: the refused statement changed the catalog's pages: a restart would read this image + left: {"aaa", "b1", "b2", "b3"} + right: {"b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8"} +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written stdout ---- + +thread 'a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written' (356840729) panicked at tests/d270_catalog_persist_allocate_first.rs:221:10: +a catalog whose second page does not read back cannot be rewritten + + +failures: + a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written + a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were + +test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.57s + +error: test failed, to rerun pass `--test d270_catalog_persist_allocate_first` +error: 1 target failed: + `--test d270_catalog_persist_allocate_first` +# rc=101 end=2026-09-24T22:00:38Z diff --git a/bench/d270/raw/Q2_first_page_written_before_the_walk_diff.txt b/bench/d270/raw/Q2_first_page_written_before_the_walk_diff.txt new file mode 100644 index 00000000..3b4aaf7d --- /dev/null +++ b/bench/d270/raw/Q2_first_page_written_before_the_walk_diff.txt @@ -0,0 +1,16 @@ +diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs +index 2c9c0cf..1fa2261 100644 +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -539,6 +539,11 @@ impl Catalog { + // 3. Walk the chain the catalog already owns: reads only. A page that does not read back as a + // catalog page is found here, not after its predecessors were rewritten. A chain that loops + // back on itself is corruption; the old loop would have followed it for ever. ++ { ++ let mut early = images[0]; ++ CatalogPage::stamp_links(&mut early, self.first_catalog_page_id, 0); ++ self.write_catalog_page(self.first_catalog_page_id, &early)?; ++ } + let mut chain: Vec = Vec::new(); + let mut id = self.first_catalog_page_id; + while id != 0 { diff --git a/bench/d270/raw/Q3_layout_size_refusal_removed.txt b/bench/d270/raw/Q3_layout_size_refusal_removed.txt new file mode 100644 index 00000000..5761d8a7 --- /dev/null +++ b/bench/d270/raw/Q3_layout_size_refusal_removed.txt @@ -0,0 +1,30 @@ +# 2026-09-24T22:00:39Z HEAD=56ba536 dirty=1 +# cmd: timeout 1800 cargo test --no-fail-fast --test d254_catalog_persist_bound + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 3.07s + Running tests/d254_catalog_persist_bound.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d254_catalog_persist_bound-e16469b54ac53f5f) + +running 4 tests +test persist_refuses_an_entry_no_page_can_hold_before_writing_any_page ... FAILED +test a_create_table_whose_entry_exceeds_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok +test a_create_fulltext_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok +test a_create_index_that_grows_an_entry_past_a_page_is_refused_and_leaves_the_catalog_and_the_pages_alone ... ok + +failures: + +---- persist_refuses_an_entry_no_page_can_hold_before_writing_any_page stdout ---- + +thread 'persist_refuses_an_entry_no_page_can_hold_before_writing_any_page' (356849021) panicked at tests/d254_catalog_persist_bound.rs:131:5: +persist: expected the size refusal naming table m, got: NotEnoughSpace +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + + +failures: + persist_refuses_an_entry_no_page_can_hold_before_writing_any_page + +test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.23s + +error: test failed, to rerun pass `--test d254_catalog_persist_bound` +error: 1 target failed: + `--test d254_catalog_persist_bound` +# rc=101 end=2026-09-24T22:00:43Z diff --git a/bench/d270/raw/Q3_layout_size_refusal_removed_diff.txt b/bench/d270/raw/Q3_layout_size_refusal_removed_diff.txt new file mode 100644 index 00000000..2511a431 --- /dev/null +++ b/bench/d270/raw/Q3_layout_size_refusal_removed_diff.txt @@ -0,0 +1,13 @@ +diff --git a/src/catalog/catalog.rs b/src/catalog/catalog.rs +index 2c9c0cf..2328aa2 100644 +--- a/src/catalog/catalog.rs ++++ b/src/catalog/catalog.rs +@@ -516,7 +516,7 @@ impl Catalog { + for entry in sorted { + if !layout[layout.len() - 1].has_space(entry) { + let fresh = CatalogPage::new(0); +- if !fresh.has_space(entry) { ++ if false && !fresh.has_space(entry) { + refuse_unless_encodable(entry)?; + return Err(FerroError::Internal(format!( + "the catalog entry of table '{}' fits no empty page, yet the encoder accepted it", diff --git a/bench/d270/raw/RUN_log.txt b/bench/d270/raw/RUN_log.txt new file mode 100644 index 00000000..e79a8e3c --- /dev/null +++ b/bench/d270/raw/RUN_log.txt @@ -0,0 +1,15 @@ +2026-09-24T21:48:43Z d207 acquiring (5s poll), pid 65562 +2026-09-24T21:57:56Z d207 ACQUIRED: 65562 d207 2026-09-24T21:57:56Z +R_target.txt rc=101 +L_target.txt rc=0 +G_target.txt rc=0 +G_d254.txt rc=0 +C_lib_catalog.txt rc=0 +C_integration.txt rc=0 +Q1_first_page_written_before_allocating.txt rc=101 +Q2_first_page_written_before_the_walk.txt rc=101 +Q3_layout_size_refusal_removed.txt rc=101 +Z_target.txt rc=0 +done. Commit /Users/idide/wt/ferrodb-d270.noindex/bench/d270/raw (by explicit path) BEFORE reading it, then run the per-target suite. +2026-09-24T22:00:47Z d207 rc=0 +2026-09-24T22:00:47Z d207 released (owner matched) diff --git a/bench/d270/raw/R_target.txt b/bench/d270/raw/R_target.txt new file mode 100644 index 00000000..97bcb29d --- /dev/null +++ b/bench/d270/raw/R_target.txt @@ -0,0 +1,36 @@ +# 2026-09-24T21:57:57Z HEAD=1793298 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 12.00s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) + +running 2 tests +test a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were ... FAILED +test a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written ... FAILED + +failures: + +---- a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were stdout ---- + +thread 'a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were' (356593682) panicked at tests/d270_catalog_persist_allocate_first.rs:193:5: +assertion `left == right` failed: the refused statement changed the catalog's pages: a restart would read this image + left: {"aaa", "b1", "b2", "b3", "b5", "b6", "b7", "b8"} + right: {"b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8"} +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written stdout ---- + +thread 'a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written' (356593681) panicked at tests/d270_catalog_persist_allocate_first.rs:224:5: +the first catalog page was rewritten before the second was read, so the pages now mix two images + + +failures: + a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written + a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were + +test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.48s + +error: test failed, to rerun pass `--test d270_catalog_persist_allocate_first` +error: 1 target failed: + `--test d270_catalog_persist_allocate_first` +# rc=101 end=2026-09-24T21:58:10Z diff --git a/bench/d270/raw/Z_target.txt b/bench/d270/raw/Z_target.txt new file mode 100644 index 00000000..b77285bf --- /dev/null +++ b/bench/d270/raw/Z_target.txt @@ -0,0 +1,13 @@ +# 2026-09-24T22:00:43Z HEAD=56ba536 dirty=0 +# cmd: timeout 1800 cargo test --no-fail-fast --test d270_catalog_persist_allocate_first + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d270.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2.80s + Running tests/d270_catalog_persist_allocate_first.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d270_catalog_persist_allocate_first-6a040453678cad1b) + +running 2 tests +test a_refused_allocation_for_the_catalog_leaves_its_pages_as_they_were ... ok +test a_catalog_page_that_does_not_read_back_is_found_before_any_page_is_written ... ok + +test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.46s + +# rc=0 end=2026-09-24T22:00:47Z From 4097a1eca4be78c9ac544f11901a6edfac11e00c Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:42 -0400 Subject: [PATCH 274/315] D224: raw tip compile output at d5ff299, uninterpreted --- bench/d224/raw/B_compile_tip.txt | 62 ++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 bench/d224/raw/B_compile_tip.txt diff --git a/bench/d224/raw/B_compile_tip.txt b/bench/d224/raw/B_compile_tip.txt new file mode 100644 index 00000000..e8554bf7 --- /dev/null +++ b/bench/d224/raw/B_compile_tip.txt @@ -0,0 +1,62 @@ +2026-09-24T21:48:58Z d207 acquiring (5s poll), pid 71114 +2026-09-24T22:02:06Z d207 ACQUIRED: 71114 d207 2026-09-24T22:02:06Z + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d224-idle-failover.noindex) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:800:13 + | +800 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 25.99s + Executable unittests src/lib.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/ferrodb-d929e200b07daf14) + Executable tests/d154_length_prefix_refusal.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d154_length_prefix_refusal-20e5687817a52249) + Executable tests/integration_cluster_agents.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_cluster_agents-3b12af37878b8d8b) + Executable tests/integration_cluster_snapshot.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_cluster_snapshot-350a4148b9c71550) + Executable tests/integration_consensus_failover.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_consensus_failover-a060b5a25175e6bb) + Executable tests/integration_server_stdout.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_server_stdout-9259fddcd1538fdb) +2026-09-24T22:02:33Z d207 rc=0 +2026-09-24T22:02:33Z d207 released (owner matched) From bc45500f3cf2963a0550ea3c671a25324ab4c8e8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:14:42 -0400 Subject: [PATCH 275/315] D207: raw tip compile output at 1eaf1a7, uninterpreted --- bench/d207/raw/B_compile_tip.txt | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 bench/d207/raw/B_compile_tip.txt diff --git a/bench/d207/raw/B_compile_tip.txt b/bench/d207/raw/B_compile_tip.txt new file mode 100644 index 00000000..c22798c5 --- /dev/null +++ b/bench/d207/raw/B_compile_tip.txt @@ -0,0 +1,61 @@ +2026-09-24T21:48:58Z d207 acquiring (5s poll), pid 71112 +2026-09-24T22:03:28Z d207 ACQUIRED: 71112 d207 2026-09-24T22:03:28Z + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d207-transport.noindex) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:800:13 + | +800 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 24.59s + Executable unittests src/lib.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/ferrodb-d929e200b07daf14) + Executable tests/d154_length_prefix_refusal.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d154_length_prefix_refusal-20e5687817a52249) + Executable tests/integration_cluster_agents.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_cluster_agents-3b12af37878b8d8b) + Executable tests/integration_cluster_snapshot.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_cluster_snapshot-350a4148b9c71550) + Executable tests/integration_server_stdout.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/integration_server_stdout-9259fddcd1538fdb) +2026-09-24T22:03:52Z d207 rc=0 +2026-09-24T22:03:53Z d207 released (owner matched) From fcf3cdbf5aff51065e2e4aed6ca42d216ab57c6e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:20:24 -0400 Subject: [PATCH 276/315] UNBUILT D231: review 5 (U1-U3, R1, M2) -- untracked inputs and bytes against HEAD, .cargo/rust-toolchain watched; probe crate outside the checkout; steps 33-52, 104 verdicts; PREREG amendment 3a --- bench/d231_PREREG.md | 34 ++++ bench/d231_stamp_check.sh | 396 ++++++++++++++++++++++++++++++-------- build.rs | 214 +++++++++++++++----- 3 files changed, 521 insertions(+), 123 deletions(-) diff --git a/bench/d231_PREREG.md b/bench/d231_PREREG.md index 3be68b01..ad3fee9c 100644 --- a/bench/d231_PREREG.md +++ b/bench/d231_PREREG.md @@ -356,3 +356,37 @@ depth cap, and preferSymlinkRefs (N7). The exit condition becomes: exit 0 requires FAIL = 0 and PASS = 104, and a run that reaches anything other than 104 verdicts exits 2. + +## Amendment 3a (before any run of steps 33-52, stub model included): two instrument corrections + +Found while writing the steps. Neither changes a predicted outcome; the totals above stand (tip 104 PASS / 0 FAIL, +exit 0; base PASS 51-61 / FAIL 43-53, exit 1). + +* **Step 45's shim refuses `git ls-files` only when the call carries `-v`**, not every `ls-files` as amendment 3's + table says. A blanket refusal also fails the U1 question (`ls-files --others`), which stamps `unknown` by itself. + Then `lsfailopen` (an `ls-files -v` that fails, read as "nothing hidden") stamps `unknown` too and survives: two + guards, and the step could test neither (review 5 said the same: "a state where `ls-files -v` fails but `status` + succeeds is needed"). Premises, asserted before the build through the shim: `ls-files -v` fails, and both + `ls-files --others --exclude-standard` and `status` succeed. A `D231 NOTE` line records whether the shim refused a + call during the build. It is an observation, not a premise, because the base never asks `ls-files -v`. +* **Step 49 packs with `git pack-refs --include $first`, not `--all`.** In the clone arm, `--all` packs every loose + ref in the shared common directory, including the linked arm's branch `d231-probe-linked`. The linked arm's + first build would then watch a missing path and re-run on every build, and its step 2 would fail at the tip. That + would be an artefact of the arms sharing refs, not a finding. MEASURED on a toy repository with this machine's + git (2.50.1, Apple Git-155): `pack-refs --include refs/heads/main` packed only `main`, the checked-out branch. It + left a second branch, a linked worktree's branch and a symbolic ref loose. The step asserts that premise: `$first`'s + loose file is gone, and in the clone arm the linked arm's branch file exists before and after. + +Premises added beyond amendment 3's table, each an exit 2 if false: +* 33 and 47: HEAD resolves to S0 before the build. +* 37: no untracked, not-ignored file exists anywhere in the checkout, so the step isolates the ignored-`.rs` rule + from U1's untracked rule. +* 43: no index entry carries a lower-case or `S` tag, so only the `core.ignoreStat` rule can see the step. +* 47: after the build, the shim's `update-ref` returned 0 and HEAD resolves to Q0. +* 51: the probe crate is not inside the checkout. + +**Real runs.** Ryan lifted quiet mode on 2026-09-24 ("ok you can resume literally everything, including using +fans"). Both commits are now run for real, under the fleet lock (`~/wt/logs/lead-lanes-0924/lockrun.sh`). Each arm +builds in its own target directory under `$WORK`, never a shared one. That is the lead's instruction, after the +lease-grace lane measured, on toy crates, a shared target running one tree's code with another tree's stamp. The +result files are committed before they are read. diff --git a/bench/d231_stamp_check.sh b/bench/d231_stamp_check.sh index cb4bfb69..ac56cb73 100755 --- a/bench/d231_stamp_check.sh +++ b/bench/d231_stamp_check.sh @@ -4,8 +4,8 @@ # FAN WORK: this builds ferrodb (twice from scratch, then incrementally). Queue it; never run it # under quiet mode. Run it once per commit under test, the D231 base and the D231 tip: # -# bench/d231_stamp_check.sh 9aa6968 # expected RED: 32-41 PASS, exit 1 -# bench/d231_stamp_check.sh # expected GREEN: 64 PASS, exit 0 +# bench/d231_stamp_check.sh 9aa6968 # expected RED: 51-61 PASS, exit 1 +# bench/d231_stamp_check.sh # expected GREEN: 104 PASS, exit 0 # # THE PRE-REGISTRATION IS bench/d231_PREREG.md (its latest amendment): the per-step outcome at both # commits, which base steps may flip with timing and in which direction, the mutant kill map, and @@ -17,12 +17,18 @@ # linked.noindex/ `git worktree add` of that clone, on its own branch at . Its `.git` is # a FILE, like every lane worktree's. # foreign.git/ a bare clone with HEAD detached at ^: a GIT_DIR that points elsewhere. -# target-clone/, target-linked/ one cargo target dir per checkout, so neither sees the other. +# probe-clone/, probe-linked/, probe--export/ the probe crates (below). +# target-clone/, target-linked/ one cargo target dir per checkout, so neither sees the other, +# and neither is shared with any other tree (a shared target can run one tree's +# code carrying another tree's stamp). +# shim45-/, shim47-/ the one-build `git` wrappers of steps 45 and 47. # -# THE INSTRUMENT is the binary itself: an untracked example, examples/d231_stamp_probe.rs, written -# into each checkout before its first build, prints `ferrodb::build_provenance()`, the same call -# every bench harness prints. Two witnesses are read off cargo beside it: -# rerun=yes|no whether this build rewrote the build script's `output` file, i.e. re-stamped; +# THE INSTRUMENT is a binary built against the checkout: the probe crate $WORK/probe-, OUTSIDE +# the checkout, depends on it by path and prints `ferrodb::build_provenance()`, the same call every +# bench harness prints. (It used to be an untracked example inside the checkout, which the tip now +# counts as an untracked build input: PREREG amendment 3.) Each build is `cargo build` in the probe +# crate; the binary is $tgt/debug/d231_probe. Two witnesses are read off cargo beside it: +# rerun=yes|no whether this build rewrote ferrodb's build-script `output` file, i.e. re-stamped; # lib_fresh cargo's own `fresh` flag for the ferrodb lib, from --message-format=json. # A step PASSES only if the printed stamp AND `rerun` are both what the step expects, and, for # steps 5, 7, 23, 24, 26 and 28, the files git touched are exactly the ones the step exists to @@ -31,6 +37,8 @@ # D231 commit= arm= step= expect="" got="" # stamp_ok= expect_rerun= rerun= [witness...] lib_fresh= # script_stamp=/ verdict= +# and step 45 adds one `D231 NOTE` line: whether its shim refused an `ls-files -v` (the base never +# asks one). # # WHY SOME STEPS SLEEP FIRST. A git operation that writes working-tree files and then the index # in the same second leaves those entries racy, and a plain `git status` (the base's) rewrites such @@ -40,13 +48,15 @@ # checkout, `reset --hard`, `checkout --`) makes that write-back produce an index with nothing # racy, so exactly ONE later build shows it, the SETTLE step, and the step after that starts # clean at both commits. The tip never writes the index, so the sleeps change nothing there. +# Steps 35-52 expect a re-run at every step, so a self-re-run cannot change their verdicts. # The harness's own read-only questions never write the index either: its `git status` calls pass # `--no-optional-locks`, and step 25's `write-tree`, which rewrites the index it reads even with # that flag (review 4, MEASURED), runs on a COPY of the index. The index is written only by the # git operations that ARE a step's change (add, commit, reset, checkout, update-index). # # STEPS, per arm. Q0 is a second commit with S0's exact tree; foreign.git's HEAD is ^; -# $first is the arm's first branch (d231-probe, or d231-probe-linked). +# $first is the arm's first branch (d231-probe, or d231-probe-linked). "touch" is `touch_moved` +# of the index, which forces a re-run. # n name change before the build expect rerun # 1 fresh-build (checkout, then sleep) at S0 yes # 2 no-change nothing at S0 no @@ -91,50 +101,86 @@ # 31 assume-unchanged src/lib.rs edited, then marked assume-unchanged # (status hides it); cleared and restored after at unknown +DIRTY yes # 32 export-inside- `git archive S0` into the checkout's ignored -# checkout target/d231-export/, built there at unknown +DIRTY yes +# checkout target/d231-export/; a probe crate built on it at unknown +DIRTY yes +# 33 reset-hard-2 `git reset --hard` (HEAD -> link2 -> link -> +# $first at S0), sleep at S0 yes +# 34 settle nothing at S0 no +# 35 untracked-input untracked examples/d231_untracked.rs at S0 +DIRTY yes +# 36 untracked-removed it removed; touch at S0 yes +# 37 ignored-rs-input tests/d231_ignored.rs, excluded in info/exclude at S0 +DIRTY yes +# 38 ignored-rs-removed it and the exclude line removed; touch at S0 yes +# 39 clean-filter a clean filter deletes a line appended to +# src/lib.rs, so `git status` reads it clean at S0 +DIRTY yes +# 40 filter-removed filter and attribute removed, file restored; +# touch at S0 yes +# 41 skip-worktree src/lib.rs edited, marked skip-worktree at unknown +DIRTY yes +# 42 skip-cleared bit cleared, file restored; touch at S0 yes +# 43 ignorestat core.ignoreStat=true, no entry marked; touch at unknown +DIRTY yes +# 44 ignorestat-cleared unset; touch at S0 yes +# 45 ls-files-fails shim: `git ls-files -v` exits 1, every other +# call passes; touch at unknown +DIRTY yes +# 46 ls-files-recovers touch at S0 yes +# 47 head-moves-during- shim: the first `git status` moves $first to +# status Q0 before answering; touch at unknown +DIRTY yes +# 48 head-moved-restamps nothing ($first moved during the last run) at Q0 yes +# 49 packed-away `pack-refs --include $first`: its loose file, +# a watched path, is gone at Q0 yes +# 50 packed-branch-moves `update-ref $first S0` creates the file again at S0 yes +# 51 cargo-config untracked .cargo/config.toml at the package +# root (not on the probe's config path); touch at S0 +DIRTY yes +# 52 cargo-config-removed .cargo removed; touch at S0 yes # # WHICH STEP KILLS WHICH MUTANT OF build.rs (each removal fails at least the steps named): # HEAD watch 7, 22 · index watch 6, 19, 25 · every link 5, 23, 24 · links past the first # (`links.len() < 1`) 24 · a recursive `symbolic-ref -q HEAD` 23 · src/ 3 · examples/ 10 · # tests/ 13 · Cargo.toml 16 · INPUTS = ["src","benches","build.rs"] 10, 13, 16 · env clearing 6 -# · --no-optional-locks 2, 9, 12, 15, 18, 21 · --no-replace-objects 25 · packed-refs watched 26 -# · links.len() < 2 28 · a failed status keeping the sha 29 · no index-bit check 31 · no -# --show-toplevel check 32. +# · --no-optional-locks 2, 9, 12, 15, 18, 21, 34 · --no-replace-objects 25 · packed-refs +# watched 26 · links.len() < 2 28 · a failed status keeping the sha 29 · no index-bit check 31 +# · no --show-toplevel check 32 · no untracked-file check 35 · no ignored-.rs check 37 · no +# byte comparison 39 · the skip-worktree tag not refused (noS) 41 · core.ignoreStat not +# refused (noignorestat) 43 · an `ls-files -v` that fails read as "nothing hidden" +# (lsfailopen) 45 · the sha not re-read after the tree questions (non6) 47 · git paths watched +# only if they exist (existsonly) 50 · .cargo not an input 51. # ⚠ SHARED INFERRED PREMISE: the Cargo.toml kill (step 16 at the tip) and base clone step 16's # FAIL both rest on cargo NOT re-running a rerun-if-changed script when only a comment is # appended to Cargo.toml. Base clone step 16 is its only measurement: if it comes out PASS with # rerun=yes, the Cargo.toml row is void at the tip too, not only the base cell. # NOT EXERCISED by any step: Cargo.lock (cargo may rewrite a lock it did not write); benches/ -# (absent); the reftable directories (ferrodb uses the files store); the fallback for a git -# without `--no-recurse`; the refusal when git does not answer one absolute path per question; -# the depth cap (git resolves 4 links and refuses 5; the walk collects at most 5); the -# skip-worktree tag and core.ignoreStat (step 31 exercises assume-unchanged only); the N6 -# re-read of the sha (no step can move HEAD inside one script run); core.preferSymlinkRefs, a -# known limit (review 4, N7). +# (absent); rust-toolchain* (a real one makes rustup switch or install toolchains); .cargo or +# toolchain files in parent directories; a gitlink among the inputs (ferrodb has none; refused +# as unknown); an fsmonitor hook (step 39 pins the byte comparison through the same gap, git's +# opinion against the bytes); the reftable directories (ferrodb uses the files store); the +# fallback for a git without `--no-recurse`; the refusal when git does not answer one absolute +# path per question; the depth cap (git resolves 4 links and refuses 5; the walk collects at +# most 5); core.preferSymlinkRefs, a known limit (review 4, N7). # -# PRE-REGISTERED (bench/d231_PREREG.md, amendments 1, 2 and 2a): 2 arms x 32 steps = 64 verdicts. -# AT THE D231 TIP: 64 PASS, exit 0. No step depends on timing. -# AT 9aa6968: nominally 32 PASS / 32 FAIL, exit 1. -# clone PASS 1 4 7 8 11 14 17 19 20 22 27 30 -# FAIL 2 3 5 6 9 10 12 13 15 16 18 21 23 24 25 26 28 29 31 32 -# linked PASS 1 3 4 5 7 8 10 11 13 14 16 17 19 20 22 23 24 27 28 30 -# FAIL 2 6 9 12 15 18 21 25 26 29 31 32 +# PRE-REGISTERED (bench/d231_PREREG.md, amendments 1, 2, 2a, 3 and 3a): 2 arms x 52 steps = 104 +# verdicts. +# AT THE D231 TIP: 104 PASS, exit 0. No step depends on timing. +# AT 9aa6968: nominally 51 PASS / 53 FAIL, exit 1. +# clone PASS 1 4 7 8 11 14 17 19 20 22 27 30 33 36 38 40 42 44 46 52 +# FAIL 2 3 5 6 9 10 12 13 15 16 18 21 23 24 25 26 28 29 31 32 34 35 37 39 41 43 45 47 +# 48 49 50 51 +# linked PASS 1 3 4 5 7 8 10 11 13 14 16 17 19 20 22 23 24 27 28 30 33 36 38 40 42 44 46 48 +# 49 50 52 +# FAIL 2 6 9 12 15 18 21 25 26 29 31 32 34 35 37 39 41 43 45 47 51 # TIMING-DEPENDENT base steps, and the only flips allowed: -# clone 2, 9, 12, 15, 18 FAIL->PASS with rerun=no, only if the preceding git operation's file -# writes and index write straddled a second boundary; +# clone 2, 9, 12, 15, 18, 34 FAIL->PASS with rerun=no, only if the preceding git operation's +# file writes and index write straddled a second boundary; # clone 5, 23, 24, 28 FAIL->PASS with rerun=yes, only if the preceding builds took under # about 1 s (each recompiles the lib, so INFERRED improbable). -# So a base run gives PASS 32..41, FAIL 23..32, exit 1; any other deviation is a MISMATCH. +# So a base run gives PASS 51..61, FAIL 43..53, exit 1; any other deviation is a MISMATCH. # -# EXIT: 0 only if FAIL is 0 AND PASS is exactly 64; 1 any FAIL; 2 the harness could not measure: +# EXIT: 0 only if FAIL is 0 AND PASS is exactly 104; 1 any FAIL; 2 the harness could not measure: # a build failed; the probe printed something that is not a stamp; cargo emitted no # build-script-executed message for ferrodb (the Cargo book says it is emitted even when the # script does not run); the script's `output` was missing before or after a build; a witnessed # file was missing before or after its git operation; one of the harness's own edits did not take # (an append `git status` cannot see, a restore it still sees, a `touch` that did not move the # mtime); a git operation, a `git status` or a sha lookup failed; a step's premise did not hold -# (steps 25, 26, 29, 31 and 32 assert theirs); or other than 64 verdicts were reached. A run that -# collected nothing has not passed, so 2 is never folded into 0 or 1. +# (steps 25, 26, 29, 31-33, 35, 37-39, 41, 43, 45, 47, 49-51 assert theirs); or other than 104 +# verdicts were reached. A run that collected nothing has not passed, so 2 is never folded into +# 0 or 1. # # EVIDENCE: every D231 line is also written to $WORK/d231-result-.txt, and each step's # cargo JSON, cargo stderr and build-script `output` are copied to $WORK/steps/. On exit 0 or 1 @@ -179,7 +225,7 @@ PARSE=$WORK/d231_parse.py RESULT=$WORK/d231-result-$SHA12.txt PASS=0 FAIL=0 -EXPECTED_VERDICTS=64 +EXPECTED_VERDICTS=104 # What build_provenance() prints: `at `, then ` +DIRTY (...)`. STAMP_RE='^at ([0-9a-f]{12,}|unknown)( \+DIRTY .*)?$' # Set by `witness`, consumed and cleared by the next `check`. @@ -293,9 +339,9 @@ script_stamp() { # the env lines the build script handed cargo: //dev/null || echo "-"; } -# check [VAR=value ...] +# check [VAR=value ...] check() { - local arm=$1 ck=$2 tgt=$3 step=$4 expect=$5 expect_rerun=$6 + local arm=$1 probe_dir=$2 tgt=$3 step=$4 expect=$5 expect_rerun=$6 shift 6 local of before json_line got after rerun fresh out_dir stamp_ok verdict wnote key # The build script's output is tracked per package: step 32 builds a different one. @@ -309,8 +355,8 @@ check() { fi # `${1+"$@"}`, not `"$@"`: bash 3.2 (macOS /bin/bash) calls an empty "$@" unbound under set -u. - if ! ( cd "$ck" && env CARGO_TARGET_DIR="$tgt" ${1+"$@"} timeout 3600 cargo build --offline --quiet \ - --example d231_stamp_probe --message-format=json > "$WORK/steps/$arm.$step.json" \ + if ! ( cd "$probe_dir" && env CARGO_TARGET_DIR="$tgt" ${1+"$@"} timeout 3600 cargo build --offline --quiet \ + --message-format=json > "$WORK/steps/$arm.$step.json" \ 2> "$WORK/steps/$arm.$step.err" ); then tail -5 "$WORK/steps/$arm.$step.err" harness "build failed, arm=$arm step=$step; see $WORK/steps/$arm.$step.err" @@ -324,7 +370,7 @@ check() { of=$(dirname "$out_dir")/output echo "$of" > "$WORK/outfile.$key" # stdout only: an exec error or a panic goes to stderr, and must not be read as a stamp. - got=$("$tgt/debug/examples/d231_stamp_probe" | head -1) + got=$("$tgt/debug/d231_probe" | head -1) # Only a stamp is a measurement. Anything else would score as a FAIL, and a FAIL is what the # base run is expected to produce. [[ $got =~ $STAMP_RE ]] || harness "the probe printed something that is not a stamp, arm=$arm step=$step: '$got'" @@ -375,16 +421,49 @@ witness() { WITNESS_NOTE="witness_touched=\"$seen\"(expect \"$1\")" } -probe_source() { - cat > "$1/examples/d231_stamp_probe.rs" <<'RS' -//! D231 probe: print the build stamp this binary was compiled with. Written into a throwaway -//! checkout by bench/d231_stamp_check.sh and never committed. +# probe_crate : the probe, a crate OUTSIDE the checkout that depends on the +# package by path and prints its stamp. It used to be an untracked example inside the package, +# which is now itself an untracked build input and would stamp every build dirty (PREREG +# amendment 3). The empty [workspace] keeps it out of any workspace above it. +probe_crate() { + mkdir "$1" "$1/src" || harness "could not create the probe crate at $1" + cat > "$1/Cargo.toml" < "$1/src/main.rs" <<'RS' || harness "could not write $1/src/main.rs" +//! D231 probe: print the build stamp of the ferrodb this binary was compiled against. fn main() { println!("{}", ferrodb::build_provenance()); } RS } +# shim [NAME=value ...]: a `git` put first on PATH for one build (PREREG amendments 3 +# and 3a). It runs (bash, with git's arguments as "$@", the real git as $REALGIT, its own +# directory as $SHIMDIR, and each NAME set), then hands the call to the real git. +REALGIT=$(command -v git) || harness "no git on PATH" +shim() { + local dir=$1 body=$2 kv + shift 2 + mkdir "$dir" || harness "could not create the shim directory $dir" + { + printf '#!/bin/bash\nREALGIT=%q\nSHIMDIR=%q\n' "$REALGIT" "$dir" + for kv in ${1+"$@"}; do printf '%s=%q\n' "${kv%%=*}" "${kv#*=}"; done + printf '%s\n' "$body" + printf 'exec "$REALGIT" "$@"\n' + } > "$dir/git" || harness "could not write the shim $dir/git" + chmod +x "$dir/git" || harness "could not make $dir/git executable" +} + g() { # git in a probe checkout: no hooks, no signing, a fixed identity git -C "$1" -c core.hooksPath=/dev/null -c commit.gpgsign=false \ -c user.name=d231-probe -c user.email=d231@probe.invalid "${@:2}" @@ -415,7 +494,8 @@ say "D231 SHAS S0=$S0 Q0=$Q0 foreign=$FOREIGN12" for arm in clone linked; do if [ "$arm" = clone ]; then ck=$CLONE; first="refs/heads/d231-probe"; else ck=$LINKED; first="refs/heads/d231-probe-linked"; fi tgt=$WORK/target-$arm - probe_source "$ck" + probe=$WORK/probe-$arm + probe_crate "$probe" "$ck" # The checkout kind this arm claims to be, asserted rather than assumed. if [ "$arm" = clone ] && [ ! -d "$ck/.git" ]; then harness "$ck/.git is not a directory"; fi if [ "$arm" = linked ] && [ ! -f "$ck/.git" ]; then harness "$ck/.git is not a file"; fi @@ -427,84 +507,84 @@ for arm in clone linked; do ff=$(gpath "$ck" "$first") || harness "no path for $first" sleep 2 # after the checkout: see WHY SOME STEPS SLEEP FIRST - check "$arm" "$ck" "$tgt" 1-fresh-build "at $S0" yes - check "$arm" "$ck" "$tgt" 2-no-change "at $S0" no + check "$arm" "$probe" "$tgt" 1-fresh-build "at $S0" yes + check "$arm" "$probe" "$tgt" 2-no-change "at $S0" no append "$ck" src/lib.rs $'\n// d231 probe edit\n' - check "$arm" "$ck" "$tgt" 3-src-edit "at $S0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 3-src-edit "at $S0 +DIRTY" yes g "$ck" add src/lib.rs || harness "git add src/lib.rs failed" g "$ck" commit -q -m "d231 probe commit" || harness "git commit failed" S1=$(sha12 "$ck" HEAD) || harness "S1 did not resolve" [ "$S1" != "$S0" ] || harness "the probe commit did not move HEAD" - check "$arm" "$ck" "$tgt" 4-commit "at $S1" yes + check "$arm" "$probe" "$tgt" 4-commit "at $S1" yes snap "$idx" "$hf" "$ff" g "$ck" reset -q --soft HEAD~1 || harness "git reset --soft failed" witness "no no yes" - check "$arm" "$ck" "$tgt" 5-reset-soft "at $S0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 5-reset-soft "at $S0 +DIRTY" yes touch_moved "$idx" - check "$arm" "$ck" "$tgt" 6-foreign-git-dir "at $S0 +DIRTY" yes GIT_DIR="$FOREIGN" + check "$arm" "$probe" "$tgt" 6-foreign-git-dir "at $S0 +DIRTY" yes GIT_DIR="$FOREIGN" g "$ck" branch -q "d231-probe-$arm-q0" "$Q0FULL" || harness "git branch for Q0 failed" snap "$idx" "$hf" "$ff" g "$ck" symbolic-ref HEAD "refs/heads/d231-probe-$arm-q0" || harness "git symbolic-ref HEAD failed" witness "no yes no" - check "$arm" "$ck" "$tgt" 7-head-only "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 7-head-only "at $Q0 +DIRTY" yes g "$ck" reset -q --hard || harness "git reset --hard failed" sleep 2 - check "$arm" "$ck" "$tgt" 8-reset-hard "at $Q0" yes - check "$arm" "$ck" "$tgt" 9-settle "at $Q0" no + check "$arm" "$probe" "$tgt" 8-reset-hard "at $Q0" yes + check "$arm" "$probe" "$tgt" 9-settle "at $Q0" no append "$ck" "$ex" $'\n// d231 probe edit\n' - check "$arm" "$ck" "$tgt" 10-example-edit "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 10-example-edit "at $Q0 +DIRTY" yes restore "$ck" "$ex" sleep 2 - check "$arm" "$ck" "$tgt" 11-example-restore "at $Q0" yes - check "$arm" "$ck" "$tgt" 12-settle "at $Q0" no + check "$arm" "$probe" "$tgt" 11-example-restore "at $Q0" yes + check "$arm" "$probe" "$tgt" 12-settle "at $Q0" no append "$ck" "$ts" $'\n// d231 probe edit\n' - check "$arm" "$ck" "$tgt" 13-test-edit "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 13-test-edit "at $Q0 +DIRTY" yes restore "$ck" "$ts" sleep 2 - check "$arm" "$ck" "$tgt" 14-test-restore "at $Q0" yes - check "$arm" "$ck" "$tgt" 15-settle "at $Q0" no + check "$arm" "$probe" "$tgt" 14-test-restore "at $Q0" yes + check "$arm" "$probe" "$tgt" 15-settle "at $Q0" no append "$ck" Cargo.toml $'\n# d231 probe edit\n' - check "$arm" "$ck" "$tgt" 16-manifest-edit "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 16-manifest-edit "at $Q0 +DIRTY" yes restore "$ck" Cargo.toml sleep 2 - check "$arm" "$ck" "$tgt" 17-manifest-restore "at $Q0" yes - check "$arm" "$ck" "$tgt" 18-settle "at $Q0" no + check "$arm" "$probe" "$tgt" 17-manifest-restore "at $Q0" yes + check "$arm" "$probe" "$tgt" 18-settle "at $Q0" no append "$ck" README.md $'\nd231 probe\n' g "$ck" add README.md || harness "git add README.md failed" - check "$arm" "$ck" "$tgt" 19-index-only "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 19-index-only "at $Q0 +DIRTY" yes touch_moved "$ck/README.md" "$ck/src/lib.rs" "$idx" - check "$arm" "$ck" "$tgt" 20-stale-stat "at $Q0 +DIRTY" yes - check "$arm" "$ck" "$tgt" 21-no-self-rerun "at $Q0 +DIRTY" no + check "$arm" "$probe" "$tgt" 20-stale-stat "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 21-no-self-rerun "at $Q0 +DIRTY" no # A one-link symbolic-ref chain: HEAD -> link -> the Q0 branch. link="refs/heads/d231-link-$arm" g "$ck" symbolic-ref "$link" "refs/heads/d231-probe-$arm-q0" || harness "could not create $link" g "$ck" symbolic-ref HEAD "$link" || harness "could not point HEAD at $link" - check "$arm" "$ck" "$tgt" 22-chain-link "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 22-chain-link "at $Q0 +DIRTY" yes lf=$(gpath "$ck" "$link") || harness "no path for $link" # Retarget the FIRST link: HEAD -> link -> $first (at S0). Neither HEAD nor the index moves. snap "$idx" "$hf" "$lf" g "$ck" symbolic-ref "$link" "$first" || harness "could not retarget $link" witness "no no yes" - check "$arm" "$ck" "$tgt" 23-chain-retarget "at $S0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 23-chain-retarget "at $S0 +DIRTY" yes # Move the SECOND link: $first itself, from S0 to Q0. Only $first's file moves. snap "$idx" "$hf" "$lf" "$ff" g "$ck" update-ref "$first" "$Q0FULL" || harness "git update-ref $first failed" witness "no no no yes" - check "$arm" "$ck" "$tgt" 24-chain-second-link "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 24-chain-second-link "at $Q0 +DIRTY" yes # A replacement for Q0 whose tree is the current index tree (Q0 plus the staged README), so a # git that honours replacements reads the tree clean. Its parent is S0, not Q0, so the @@ -519,7 +599,7 @@ for arm in clone linked; do st unhonoured "$ck" --no-replace-objects [ -n "$unhonoured" ] || harness "without the replacement the tree reads clean; step 25 would not discriminate" touch_moved "$idx" - check "$arm" "$ck" "$tgt" 25-replace "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 25-replace "at $Q0 +DIRTY" yes g "$ck" replace -d "$Q0FULL" > /dev/null || harness "git replace -d failed" # 26 (N3): an UNRELATED packed ref deleted. packed-refs is rewritten and nothing HEAD resolves @@ -531,20 +611,20 @@ for arm in clone linked; do snap "$idx" "$hf" "$lf" "$ff" "$pr" g "$ck" update-ref -d "$victim" || harness "git update-ref -d $victim failed" witness "no no no no yes" - check "$arm" "$ck" "$tgt" 26-unrelated-packed-ref "at $Q0 +DIRTY" no + check "$arm" "$probe" "$tgt" 26-unrelated-packed-ref "at $Q0 +DIRTY" no # 27: a three-link chain, HEAD -> link2 -> link -> $first. link2="refs/heads/d231-link2-$arm" g "$ck" symbolic-ref "$link2" "$link" || harness "could not create $link2" g "$ck" symbolic-ref HEAD "$link2" || harness "could not point HEAD at $link2" - check "$arm" "$ck" "$tgt" 27-chain-three-links "at $Q0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 27-chain-three-links "at $Q0 +DIRTY" yes l2f=$(gpath "$ck" "$link2") || harness "no path for $link2" # 28 (N5): move the THIRD link, $first itself, back to S0. Only $first's file moves. snap "$idx" "$hf" "$l2f" "$lf" "$ff" g "$ck" update-ref "$first" "$SHA" || harness "git update-ref $first failed" witness "no no no no yes" - check "$arm" "$ck" "$tgt" 28-third-link "at $S0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 28-third-link "at $S0 +DIRTY" yes # 29 (N4): `git status` fails while every other question build.rs asks still works # (PREREG amendment 2a: status.aheadBehind=bogus fails status, not ls-files or rev-parse). @@ -555,12 +635,12 @@ for arm in clone linked; do git -C "$ck" --no-optional-locks ls-files -v > /dev/null 2>&1 \ || harness "ls-files fails too with status.aheadBehind=bogus; step 29 would not isolate the status rule" touch_moved "$idx" - check "$arm" "$ck" "$tgt" 29-status-fails "at unknown +DIRTY" yes + check "$arm" "$probe" "$tgt" 29-status-fails "at unknown +DIRTY" yes g "$ck" config --unset status.aheadBehind || harness "could not unset status.aheadBehind" # 30: status works again, and the `unknown` does not stick. touch_moved "$idx" - check "$arm" "$ck" "$tgt" 30-status-recovers "at $S0 +DIRTY" yes + check "$arm" "$probe" "$tgt" 30-status-recovers "at $S0 +DIRTY" yes # 31 (N2): an edit hidden from `git status` by the assume-unchanged bit. append "$ck" src/lib.rs $'\n// d231 probe edit (assume-unchanged)\n' @@ -569,7 +649,7 @@ for arm in clone linked; do case "$tag" in "h "*) ;; *) harness "src/lib.rs is not tagged assume-unchanged ('$tag')" ;; esac st_path hidden "$ck" src/lib.rs [ -z "$hidden" ] || harness "git status still lists src/lib.rs; step 31 would not discriminate" - check "$arm" "$ck" "$tgt" 31-assume-unchanged "at unknown +DIRTY" yes + check "$arm" "$probe" "$tgt" 31-assume-unchanged "at unknown +DIRTY" yes g "$ck" update-index --no-assume-unchanged src/lib.rs || harness "git update-index --no-assume-unchanged failed" restore "$ck" src/lib.rs @@ -580,12 +660,176 @@ for arm in clone linked; do git -C "$ck" archive "$SHA" | tar -x -C "$ex_dir" || harness "git archive into $ex_dir failed" [ -f "$ex_dir/build.rs" ] && [ ! -e "$ex_dir/.git" ] || harness "$ex_dir is not a bare copy of the sources" top=$(git -C "$ex_dir" rev-parse --show-toplevel) || harness "git finds no repository above $ex_dir" - [ "$top" != "$ex_dir" ] || harness "$ex_dir is its own repository; step 32 would not discriminate" + # Both spelled physically: git answers with the real path, and $WORK may sit under a symlink. + [ "$(cd "$top" && pwd -P)" != "$(cd "$ex_dir" && pwd -P)" ] \ + || harness "$ex_dir is its own repository; step 32 would not discriminate" git -C "$ck" check-ignore -q "target/d231-export/build.rs" \ || harness "the export is not in an ignored directory of $ck" - probe_source "$ex_dir" + probe_crate "$WORK/probe-$arm-export" "$ex_dir" OUTKEY="$arm-export" - check "$arm" "$ex_dir" "$tgt" 32-export-inside-checkout "at unknown +DIRTY" yes + check "$arm" "$WORK/probe-$arm-export" "$tgt" 32-export-inside-checkout "at unknown +DIRTY" yes + + # 33-34: a clean tree at S0 again (HEAD -> link2 -> link -> $first), then a settle. + g "$ck" reset -q --hard || harness "git reset --hard failed" + st now "$ck" + [ -z "$now" ] || harness "the tree is not clean after reset --hard" + [ "$(sha12 "$ck" HEAD)" = "$S0" ] || harness "HEAD does not resolve to S0 before step 33" + sleep 2 + check "$arm" "$probe" "$tgt" 33-reset-hard-2 "at $S0" yes + check "$arm" "$probe" "$tgt" 34-settle "at $S0" no + + # 35-36 (U1): an untracked, not-ignored file among the inputs. + printf 'fn main() {}\n' > "$ck/examples/d231_untracked.rs" || harness "could not create an untracked example" + others=$(git -C "$ck" --no-optional-locks ls-files --others --exclude-standard -- examples) \ + || harness "git ls-files --others failed" + [ "$others" = "examples/d231_untracked.rs" ] || harness "the untracked examples are not exactly the one planted ('$others')" + check "$arm" "$probe" "$tgt" 35-untracked-input "at $S0 +DIRTY" yes + rm "$ck/examples/d231_untracked.rs" || harness "could not remove the untracked example" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 36-untracked-removed "at $S0" yes + + # 37-38: an IGNORED .rs among the inputs, which cargo's target discovery compiles whatever git + # says. The exclude file is copied first and put back after. + excl=$(gpath "$ck" info/exclude) || harness "no info/exclude path" + excl_had=no + if [ -f "$excl" ]; then cp "$excl" "$WORK/exclude.$arm" || harness "could not copy $excl"; excl_had=yes; fi + mkdir -p "$(dirname "$excl")" || exit 2 + printf '%s\n' "/tests/d231_ignored.rs" >> "$excl" || harness "could not write $excl" + printf '#[test]\nfn d231_ignored() {}\n' > "$ck/tests/d231_ignored.rs" || harness "could not create the ignored test" + git -C "$ck" check-ignore -q tests/d231_ignored.rs || harness "tests/d231_ignored.rs is not ignored" + others=$(git -C "$ck" --no-optional-locks ls-files --others --exclude-standard) || harness "git ls-files --others failed" + [ -z "$others" ] || harness "untracked, not-ignored files exist ('$others'); step 37 would not isolate the ignored-.rs rule" + check "$arm" "$probe" "$tgt" 37-ignored-rs-input "at $S0 +DIRTY" yes + rm "$ck/tests/d231_ignored.rs" || harness "could not remove the ignored test" + if [ "$excl_had" = yes ]; then + cp "$WORK/exclude.$arm" "$excl" || harness "could not put $excl back" + else + rm "$excl" || harness "could not remove $excl" + fi + git -C "$ck" check-ignore -q tests/d231_ignored.rs && harness "tests/d231_ignored.rs is still ignored" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 38-ignored-rs-removed "at $S0" yes + + # 39-40 (U2): a clean filter hides an edit from `git status`; the bytes still differ from HEAD. + attrs=$(gpath "$ck" info/attributes) || harness "no info/attributes path" + [ ! -e "$attrs" ] || harness "$attrs exists already" + mkdir -p "$(dirname "$attrs")" || exit 2 + printf '%s\n' "src/lib.rs filter=d231" > "$attrs" || harness "could not write $attrs" + g "$ck" config filter.d231.clean "sed '/d231 filter-hidden line/d'" || harness "could not configure the clean filter" + printf '%s\n' "// d231 filter-hidden line" >> "$ck/src/lib.rs" || harness "could not append to src/lib.rs" + st_path hidden "$ck" src/lib.rs + [ -z "$hidden" ] || harness "git status sees the filtered edit; step 39 would not discriminate" + head_blob=$(git -C "$ck" rev-parse HEAD:src/lib.rs) || harness "no HEAD blob for src/lib.rs" + disk_blob=$(git -C "$ck" hash-object --no-filters src/lib.rs) || harness "git hash-object failed" + [ "$head_blob" != "$disk_blob" ] || harness "src/lib.rs's bytes equal HEAD's; step 39 would not discriminate" + check "$arm" "$probe" "$tgt" 39-clean-filter "at $S0 +DIRTY" yes + rm "$attrs" || harness "could not remove $attrs" + g "$ck" config --unset filter.d231.clean || harness "could not unset the clean filter" + restore "$ck" src/lib.rs + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 40-filter-removed "at $S0" yes + + # 41-42 (noS): an edit hidden from `git status` by the skip-worktree bit. + append "$ck" src/lib.rs $'\n// d231 probe edit (skip-worktree)\n' + g "$ck" update-index --skip-worktree src/lib.rs || harness "git update-index --skip-worktree failed" + tag=$(git -C "$ck" --no-optional-locks ls-files -v -- src/lib.rs) || harness "git ls-files -v failed" + case "$tag" in "S "*) ;; *) harness "src/lib.rs is not tagged skip-worktree ('$tag')" ;; esac + st_path hidden "$ck" src/lib.rs + [ -z "$hidden" ] || harness "git status still lists src/lib.rs; step 41 would not discriminate" + check "$arm" "$probe" "$tgt" 41-skip-worktree "at unknown +DIRTY" yes + g "$ck" update-index --no-skip-worktree src/lib.rs || harness "git update-index --no-skip-worktree failed" + restore "$ck" src/lib.rs + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 42-skip-cleared "at $S0" yes + + # 43-44 (noignorestat): core.ignoreStat, with no entry marked, so only the config rule can see it. + g "$ck" config core.ignoreStat true || harness "could not set core.ignoreStat" + lv=$(git -C "$ck" --no-optional-locks ls-files -v) || harness "git ls-files -v failed" + # A here-string, not a pipe: under pipefail an early `grep -q` exit can SIGPIPE the writer and + # turn a match into a failed pipeline. + grep -q '^[a-zS]' <<< "$lv" && harness "an index entry is already marked; step 43 would not isolate core.ignoreStat" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 43-ignorestat "at unknown +DIRTY" yes + g "$ck" config --unset core.ignoreStat || harness "could not unset core.ignoreStat" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 44-ignorestat-cleared "at $S0" yes + + # 45-46 (lsfailopen): `git ls-files -v` fails while every other question works, the byte + # comparison's own `ls-files --others` included (PREREG amendment 3a). + sh45=$WORK/shim45-$arm + read -r -d '' body45 <<'SH' || true +l=; v= +for a in "$@"; do case $a in ls-files) l=1 ;; -v) v=1 ;; esac; done +if [ "$l$v" = 11 ]; then : > "$SHIMDIR/fired"; echo "d231 shim: ls-files -v refused" >&2; exit 1; fi +SH + shim "$sh45" "$body45" + env PATH="$sh45:$PATH" git -C "$ck" --no-optional-locks ls-files -v > /dev/null 2>&1 \ + && harness "the step-45 shim does not refuse ls-files -v" + env PATH="$sh45:$PATH" git -C "$ck" --no-optional-locks ls-files --others --exclude-standard > /dev/null 2>&1 \ + || harness "the step-45 shim refuses ls-files --others too; step 45 would not isolate the N2 rule" + env PATH="$sh45:$PATH" git -C "$ck" --no-optional-locks status --porcelain --untracked-files=no > /dev/null 2>&1 \ + || harness "the step-45 shim breaks git status" + rm "$sh45/fired" || harness "the step-45 shim left no mark when it refused" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 45-ls-files-fails "at unknown +DIRTY" yes PATH="$sh45:$PATH" + # An observation, not a premise: the base never asks `ls-files -v`. + say "D231 NOTE commit=$SHA12 arm=$arm step=45 shim_refused_ls_files_v=$([ -e "$sh45/fired" ] && echo yes || echo no)" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 46-ls-files-recovers "at $S0" yes + + # 47-48 (non6): the build script's first `git status` moves $first to Q0 before it answers, so + # HEAD moves between the script's two reads of the sha. + sh47=$WORK/shim47-$arm + read -r -d '' body47 <<'SH' || true +for a in "$@"; do + if [ "$a" = status ] && [ ! -e "$SHIMDIR/fired" ]; then + "$REALGIT" -C "$D231_CK" update-ref "$D231_FIRST" "$D231_TO" 2> "$SHIMDIR/fired.err" + echo "rc=$?" > "$SHIMDIR/fired" + break + fi +done +SH + shim "$sh47" "$body47" D231_CK="$ck" D231_FIRST="$first" D231_TO="$Q0FULL" + [ "$(sha12 "$ck" HEAD)" = "$S0" ] || harness "HEAD does not resolve to S0 before step 47" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 47-head-moves-during-status "at unknown +DIRTY" yes PATH="$sh47:$PATH" + [ "$(cat "$sh47/fired" 2>/dev/null)" = "rc=0" ] || harness "the step-47 shim did not move $first during the build" + [ "$(sha12 "$ck" HEAD)" = "$Q0" ] || harness "HEAD does not resolve to Q0 after step 47" + check "$arm" "$probe" "$tgt" 48-head-moved-restamps "at $Q0" yes + + # 49-50 (existsonly): packing $first removes its loose ref file, a watched path; the next write + # to $first creates it again. `--include $first`, not `--all` (PREREG amendment 3a): `--all` in + # the clone arm would also pack the linked arm's branch, whose missing file would then re-run + # the linked arm's script on every build and fail its settle steps at the tip. + other_ff=$(gpath "$ck" refs/heads/d231-probe-linked) || harness "no path for the linked arm's branch" + [ -f "$ff" ] || harness "$first has no loose ref file before pack-refs" + if [ "$arm" = clone ]; then [ -f "$other_ff" ] || harness "the linked arm's branch has no loose ref file"; fi + g "$ck" pack-refs --include "$first" || harness "git pack-refs --include $first failed" + [ ! -e "$ff" ] || harness "$first's loose ref file survived pack-refs" + if [ "$arm" = clone ]; then [ -f "$other_ff" ] || harness "pack-refs packed the linked arm's branch too"; fi + [ "$(sha12 "$ck" HEAD)" = "$Q0" ] || harness "HEAD does not resolve to Q0 after pack-refs" + check "$arm" "$probe" "$tgt" 49-packed-away "at $Q0" yes + g "$ck" update-ref "$first" "$SHA" || harness "git update-ref $first failed" + [ -f "$ff" ] || harness "update-ref did not create $first's loose ref file again" + check "$arm" "$probe" "$tgt" 50-packed-branch-moves "at $S0" yes + + # 51-52 (R1): an untracked .cargo/config.toml at the package root. The probe builds from its own + # directory, whose config search path does not include the checkout, so the build is the same; + # only the stamp can tell. + case "$probe/" in "$ck"/*) harness "the probe crate is inside the checkout" ;; esac + [ ! -e "$ck/.cargo" ] || harness "$ck already has a .cargo" + mkdir "$ck/.cargo" || harness "could not create $ck/.cargo" + printf '# d231 probe: an untracked cargo config at the package root\n' > "$ck/.cargo/config.toml" \ + || harness "could not write $ck/.cargo/config.toml" + others=$(git -C "$ck" --no-optional-locks ls-files --others --exclude-standard -- .cargo) \ + || harness "git ls-files --others failed" + [ "$others" = ".cargo/config.toml" ] || harness ".cargo/config.toml is not the one untracked file under .cargo ('$others')" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 51-cargo-config "at $S0 +DIRTY" yes + rm "$ck/.cargo/config.toml" || harness "could not remove $ck/.cargo/config.toml" + rmdir "$ck/.cargo" || harness "could not remove $ck/.cargo" + touch_moved "$idx" + check "$arm" "$probe" "$tgt" 52-cargo-config-removed "at $S0" yes done say "D231 SUMMARY commit=$SHA12 pass=$PASS fail=$FAIL verdicts=$((PASS + FAIL)) expected_verdicts=$EXPECTED_VERDICTS result=$RESULT" diff --git a/build.rs b/build.rs index 1d8d0279..13e1938e 100644 --- a/build.rs +++ b/build.rs @@ -23,14 +23,17 @@ //! # When the stamp is taken, and therefore what it can see //! //! The stamp is two facts read from git **when this script runs**: the commit `HEAD` resolves to, -//! and whether any tracked file differs from it, staged or not. Cargo re-runs the script only when -//! something it was told to watch has changed, so the stamp is exactly as fresh as the watch list, -//! and the list is the whole of the mechanism: +//! and whether the tree differs from it. "Differs" is three questions, OR-ed: `git status` over +//! the tracked files; the BYTES of every tracked build input against HEAD's blobs; and any +//! untracked file among the build inputs that cargo could compile. Cargo re-runs the script only +//! when something it was told to watch has changed, so the stamp is exactly as fresh as the watch +//! list, and the list is the whole of the mechanism: //! //! * **The build's inputs**: `src/`, `examples/`, `tests/` and `benches/` (each scanned whole, and -//! each only if it exists), `build.rs`, `Cargo.toml` and `Cargo.lock`. An edit to any of them is -//! an edit to what is being compiled, so it re-stamps. This is what makes an unstaged edit say -//! `+DIRTY`. +//! each only if it exists), `build.rs`, `Cargo.toml` and `Cargo.lock`, and the package root's +//! `.cargo/`, `rust-toolchain` and `rust-toolchain.toml`, which change the build without changing +//! a line of source. An edit to any of them is an edit to what is being compiled, so it +//! re-stamps. This is what makes an unstaged edit say `+DIRTY`. //! * **The git state**: this worktree's `HEAD` and index, and the ref file of every link from //! `HEAD` to the branch it resolves to, normally just the checked-out branch's (or, on a //! repository that keeps its refs in reftable, its `reftable/` directories). Each is @@ -70,7 +73,15 @@ //! * Cargo detects a change by mtime alone, against the time this script last ran. A change that //! leaves a file's mtime no newer than that is invisible: an edit that preserves the mtime, or //! an older copy put back with `cp -p` or `rsync -a`. -//! * It cannot see a dirty SUBMODULE or an untracked file that is not on the build path. +//! * Submodules: a submodule that is modified, or checked out at another commit, shows in +//! `git status` and stamps `+DIRTY`. What hides one is `submodule..ignore = all` in the +//! tracked `.gitmodules`, or an untracked file inside it (measured by D231's review 5, which +//! corrected this sentence). A submodule among the build INPUTS stamps `unknown`, because it has +//! no bytes to compare. ferrodb has no submodules. +//! * Untracked files that are NOT build inputs, and ignored files among the inputs that do not end +//! in `.rs`, are not counted: nothing compiles them. A `.cargo/` or `rust-toolchain*` in a parent +//! directory is not seen at all, and one that appears at the package root where none existed is +//! seen only at the next re-stamp for another reason, like a new `benches/`. //! * **Every re-stamp rebuilds the crate, and so everything that links it.** The Cargo FAQ lists a //! re-running build script as a cause of rebuilds; that the dependents follow is inferred. So an //! edit to one test or example rebuilds the library and every target, where cargo alone would @@ -98,8 +109,9 @@ //! keeps the old branch's sha (measured by D231's review 4). A known limit: the option is not //! set on this machine, and git has deprecated it. //! * The sha and the dirty bit are read before rustc compiles anything. An edit that lands after -//! this script's `git status` and before rustc reads the file is compiled and not stamped; the -//! next build re-stamps. +//! this script reads the tree and before rustc reads the file is compiled and not stamped; the +//! next build re-stamps, on a filesystem with sub-second mtimes. On one with 1 s or 2 s mtimes an +//! edit in the same tick as the script's start is not newer than its `output`, and is not seen. //! //! # When it says `unknown`, which is never a guess in the direction that invites trust //! @@ -117,11 +129,12 @@ //! * **An index entry is told to hide changes** (N2): assume-unchanged (a lowercase `ls-files -v` //! tag), skip-worktree (`S`), or `core.ignoreStat`. `git status` skips such entries by design, //! so an edited, compiled file would read clean. -//! * **`git status` fails** (N4). "Dirty" with the old sha would still name a commit, and nothing -//! here was able to compare the tree with it. -//! * **HEAD moved while the two facts were being read** (N6). The sha is read, then `status` runs, -//! then the sha is read again. A commit landing between them would otherwise pair the parent's -//! sha with the child's status. +//! * **`git status` fails** (N4), or one of the byte comparison's questions does (U2), or a build +//! input is a submodule, which has no bytes to compare. "Dirty" with the old sha would still +//! name a commit, and nothing here was able to compare the tree with it. +//! * **HEAD moved while the two facts were being read** (N6). The sha is read, then `status` +//! and the byte comparison run, then the sha is read again. A commit landing between them would +//! otherwise pair the parent's sha with the child's tree. //! //! None of these fails the build: refusing to compile ferrodb because git is missing or odd would //! be a worse failure than an honest `unknown`. @@ -131,8 +144,9 @@ //! rest) is cleared before git is asked. A git hook exports those for the repository the hook is //! about, and `tools/prepush.sh` builds the checkout it lives in, which need not be that one. +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; /// What the crate is compiled from, relative to the package root. Anything the crate compiles in /// from elsewhere must be added here, or an edit to it never re-stamps. @@ -141,9 +155,20 @@ use std::process::Command; /// on every build (the Cargo FAQ, "Why is Cargo rebuilding my code?"), and this package has no /// `benches/`. One that appears later is picked up at the next re-stamp, and adding a tracked file /// to it rewrites the index, which is watched. `build.rs` is listed for completeness: cargo -/// already re-runs a script whose source changed. -const INPUTS: &[&str] = - &["src", "examples", "tests", "benches", "build.rs", "Cargo.toml", "Cargo.lock"]; +/// already re-runs a script whose source changed. `.cargo/` and the `rust-toolchain` files change +/// the build without changing a line of source (D231 review 5, R1). +const INPUTS: &[&str] = &[ + "src", + "examples", + "tests", + "benches", + "build.rs", + "Cargo.toml", + "Cargo.lock", + ".cargo", + "rust-toolchain", + "rust-toolchain.toml", +]; fn main() { let root = PathBuf::from( @@ -159,20 +184,27 @@ fn main() { let local_env = local_git_env(); let git = |args: &[&str]| -> Option { - let mut cmd = Command::new("git"); - // `--no-optional-locks`: a plain `git status` refreshes the index and writes it back - // (git-status(1), BACKGROUND REFRESH). That takes the index lock out from under whoever - // is committing in this worktree, and it rewrites a file this script watches, so the - // script would schedule its own re-run. - // - // `--no-replace-objects`: the stamped sha names the real commit, so `dirty` must compare - // against the real commit's tree. With `git replace` honoured, a replacement object - // changes the comparison without changing any file this script watches (D231 review). - cmd.args(["--no-optional-locks", "--no-replace-objects"]).args(args).current_dir(&root); - for var in &local_env { - cmd.env_remove(var); + let out = git_command(&root, &local_env, args).output().ok()?; + if !out.status.success() { + return None; } - let out = cmd.output().ok()?; + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + }; + // The same, with `input` on stdin. Written from a thread: git writes its answers while it + // reads, and a caller that wrote everything before reading would deadlock once the answers + // filled the pipe. + let git_in = |args: &[&str], input: &str| -> Option { + let mut child = git_command(&root, &local_env, args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + let mut stdin = child.stdin.take()?; + let input = input.to_string(); + let writer = std::thread::spawn(move || stdin.write_all(input.as_bytes())); + let out = child.wait_with_output().ok()?; + writer.join().ok()?.ok()?; if !out.status.success() { return None; } @@ -198,32 +230,120 @@ fn main() { return; } - // `--porcelain` over TRACKED files only, and that is a deliberate trade with a real hole in it. + // "Dirty" is three questions, OR-ed. `git status` covers every TRACKED file, build input or + // not. The byte comparison covers the build inputs without trusting git's opinion of them. + // The untracked check covers what `status --untracked-files=no` leaves out. // - // ⚠ **AN UNTRACKED FILE ON THE BUILD PATH STAMPS CLEAN, AND THIS WAS DEMONSTRATED, NOT - // GUESSED.** Proving the flag fires in both directions needed a throwaway `examples/*.rs`; it - // was untracked, it compiled and ran, and the stamp read clean. So the honest statement is not - // "untracked files cannot change compiled behaviour" — they plainly can — it is that counting - // them would mark the tree dirty for every stray scratch file and the flag would mean nothing - // within a day. A flag nobody believes catches nothing at all. + // The untracked check replaces a trade this comment used to defend: that counting untracked + // files "would mark the tree dirty for every stray scratch file". D231's review 5 measured it + // across all 72 ferrodb worktrees on this machine. One had an untracked file among the inputs, + // and it was a true positive: three adversarial test files that cargo compiled, in a tree whose + // tracked files were clean, so its stamp read clean over code no commit holds. // - // The hole that remains is narrow and worth naming precisely: a NEW file that is on the build - // path and has never been committed. An edit to any file the crate already tracks IS caught, - // and that is the case the mechanism exists for. - // - // The sha is read on both sides of `status`: two git processes are two moments, and a commit - // landing between them would pair one commit's sha with another's status. + // The sha is read on both sides: separate git processes are separate moments, and a commit + // landing between them would pair one commit's sha with another tree's answer. let before = git(&["rev-parse", "--short=12", "HEAD"]); let status = git(&["status", "--porcelain", "--untracked-files=no"]); + let inputs = inputs_differ_from_head(&git, &git_in, &root); let after = git(&["rev-parse", "--short=12", "HEAD"]); - match (before, status, after) { - (Some(sha), Some(changes), Some(again)) if sha == again => stamp(&sha, !changes.is_empty()), - // No sha, a status that failed, or a HEAD that moved in between. "Dirty" beside a sha + match (before, status, inputs, after) { + (Some(sha), Some(changes), Some(differ), Some(again)) if sha == again => { + stamp(&sha, !changes.is_empty() || differ) + } + // No sha, a question that failed, or a HEAD that moved in between. "Dirty" beside a sha // would still name a commit nothing here compared the tree with. _ => stamp("unknown", true), } } +/// `git`, run at the package root, with the flags and the cleared environment every question +/// here needs. +/// +/// `--no-optional-locks`: a plain `git status` refreshes the index and writes it back +/// (git-status(1), BACKGROUND REFRESH). That takes the index lock out from under whoever is +/// committing in this worktree, and it rewrites a file this script watches, so the script would +/// schedule its own re-run. +/// +/// `--no-replace-objects`: the stamped sha names the real commit, so `dirty` must compare against +/// the real commit's tree. With `git replace` honoured, a replacement object changes the +/// comparison without changing any file this script watches (D231 review). +fn git_command(root: &Path, local_env: &[String], args: &[&str]) -> Command { + let mut cmd = Command::new("git"); + cmd.args(["--no-optional-locks", "--no-replace-objects"]).args(args).current_dir(root); + for var in local_env { + cmd.env_remove(var); + } + cmd +} + +/// Whether the build inputs on disk differ from HEAD, by their BYTES (D231 review 5, U1 and U2). +/// `Some(true)` differs, `Some(false)` is identical, `None` could not tell. +/// +/// `git status` answers from the index: its stat cache, its assume-unchanged and skip-worktree +/// bits, an fsmonitor's report, clean filters, and whatever configuration supplies them, +/// `GIT_CONFIG_GLOBAL` included, which the environment clearing above does not reach. Review 5 +/// made each of those hide an edit that was compiled. This asks none of them. It hashes each +/// tracked input as it is on disk (`hash-object --no-filters`) and compares that with HEAD's blob +/// id (`ls-tree`). Measured by review 5 on this package: 386 files, 9.8 MB, 0.162 s. +/// +/// Then the inputs HEAD does not have. An untracked file that is not ignored counts, whatever it +/// is. An ignored one counts only if it ends in `.rs`, because cargo's target discovery compiles +/// `examples/*.rs` and `tests/*.rs` whether git ignores them or not, and ignored noise such as +/// `tests/pg/__pycache__` must not make every stamp dirty. +fn inputs_differ_from_head( + git: &dyn Fn(&[&str]) -> Option, + git_in: &dyn Fn(&[&str], &str) -> Option, + root: &Path, +) -> Option { + // HEAD's entries under the inputs, NUL-separated: ` \t`. + let mut args = vec!["ls-tree", "-r", "-z", "--full-tree", "HEAD", "--"]; + args.extend(INPUTS.iter().copied()); + let tree = git(&args)?; + let mut paths: Vec<&str> = Vec::new(); + let mut ids: Vec<&str> = Vec::new(); + for entry in tree.split('\0').filter(|e| !e.is_empty()) { + let (meta, path) = entry.split_once('\t')?; + let mut fields = meta.split(' '); + let (_mode, kind, id) = (fields.next()?, fields.next()?, fields.next()?); + // A submodule has no bytes to compare, and a path with a newline cannot be handed to + // `--stdin-paths`. Neither exists in ferrodb; either is refused rather than skipped. + if kind != "blob" || path.contains('\n') { + return None; + } + paths.push(path); + ids.push(id); + } + // A tracked input missing from the disk differs. + if paths.iter().any(|p| !root.join(p).is_file()) { + return Some(true); + } + if !paths.is_empty() { + let mut list = paths.join("\n"); + list.push('\n'); + let disk = git_in(&["hash-object", "--no-filters", "--stdin-paths"], &list)?; + let disk: Vec<&str> = disk.lines().collect(); + if disk.len() != ids.len() { + return None; + } + if disk.iter().zip(&ids).any(|(on_disk, in_head)| on_disk != in_head) { + return Some(true); + } + } + + let present: Vec<&str> = INPUTS.iter().copied().filter(|i| root.join(i).exists()).collect(); + if present.is_empty() { + return Some(false); + } + let mut args = vec!["ls-files", "--others", "--exclude-standard", "-z", "--"]; + args.extend(present.iter().copied()); + if !git(&args)?.is_empty() { + return Some(true); + } + let mut args = vec!["ls-files", "--others", "--ignored", "--exclude-standard", "-z", "--"]; + args.extend(present.iter().copied()); + Some(git(&args)?.split('\0').any(|p| p.ends_with(".rs"))) +} + fn watch(p: &Path) { println!("cargo:rerun-if-changed={}", p.display()); } From 0d56eeab3554cdaa9cf33c06f77d3f5ebd019dad Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:34:43 -0400 Subject: [PATCH 277/315] UNBUILT D231: step 39 edits without changing the file's size (a size change bypasses the clean filter); build.rs states the checkout-conversion assumption; PREREG amendment 3b --- bench/d231_PREREG.md | 28 ++++++++++++++++++++++++++++ bench/d231_stamp_check.sh | 24 +++++++++++++++++++----- build.rs | 6 ++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/bench/d231_PREREG.md b/bench/d231_PREREG.md index ad3fee9c..63bc21e7 100644 --- a/bench/d231_PREREG.md +++ b/bench/d231_PREREG.md @@ -390,3 +390,31 @@ fans"). Both commits are now run for real, under the fleet lock (`~/wt/logs/lead builds in its own target directory under `$WORK`, never a shared one. That is the lead's instruction, after the lease-grace lane measured, on toy crates, a shared target running one tree's code with another tree's stamp. The result files are committed before they are read. + +## Amendment 3b (before any real run; after an interrupted model batch): step 39's edit keeps the file's size + +**What happened.** The first model batch at `fcf3cdb` (stub cargo, not a real build) reached step 39 in both base +runs, and exited 2 there with "git status sees the filtered edit; step 39 would not discriminate". The premise refused +before any verdict was scored. The other runs in that batch were stopped by hand once the cause was known. No real run +had started: the queued one was still waiting for the fleet lock, and was stopped too. The raw `.out` files of that +batch, including the partial ones, are committed with the model (`d231_model/batch3/runs-interrupted/`). + +**Why.** git reads a change in a file's size as a modification without asking the clean filter. MEASURED on a toy +repository with this machine's git (2.50.1, Apple Git-155): +* an appended line, under a filter that deletes that line, still reads ` M` in `status --porcelain`; +* a same-size edit that the filter reverses reads clean, and `hash-object --no-filters` gives a different blob from + HEAD's. + +**The change.** Step 39 now changes the case of one word in a comment in `src/lib.rs`: `Every harness in` becomes +`EVERY harness in`. That string occurs exactly once at both commits, and the edit asserts it. The filter is +`sed 's/EVERY harness in/Every harness in/'`. The premises are unchanged and still asserted: `git status` hides the +edit, and the bytes differ from HEAD's blob. The predictions are unchanged: at the tip `at S0 +DIRTY` with a re-run, +and at the base a FAIL. + +**Also in this commit, documentation only:** `build.rs` states that the byte comparison assumes a checkout writes each +blob's bytes unchanged. A smudge filter, or line endings converted on checkout, make a clean tree stamp `+DIRTY`. That +is the safe direction, but the flag then means nothing on that machine. `.gitattributes` pins `* text=auto eol=lf`. +This changes the tip's `build.rs` blob, so M1's pin moves to the new blob. + +**A dry run before this commit**, under the model only: the uncommitted harness with this fix, against `fcf3cdb`'s +tree, gave 104 PASS / 0 FAIL, rc 0. Both arms' step-45 NOTE lines read `shim_refused_ls_files_v=yes`. diff --git a/bench/d231_stamp_check.sh b/bench/d231_stamp_check.sh index ac56cb73..e6669ef5 100755 --- a/bench/d231_stamp_check.sh +++ b/bench/d231_stamp_check.sh @@ -109,8 +109,9 @@ # 36 untracked-removed it removed; touch at S0 yes # 37 ignored-rs-input tests/d231_ignored.rs, excluded in info/exclude at S0 +DIRTY yes # 38 ignored-rs-removed it and the exclude line removed; touch at S0 yes -# 39 clean-filter a clean filter deletes a line appended to -# src/lib.rs, so `git status` reads it clean at S0 +DIRTY yes +# 39 clean-filter a same-size edit to a comment in src/lib.rs +# that a clean filter reverses, so `git status` +# reads it clean at S0 +DIRTY yes # 40 filter-removed filter and attribute removed, file restored; # touch at S0 yes # 41 skip-worktree src/lib.rs edited, marked skip-worktree at unknown +DIRTY yes @@ -154,7 +155,7 @@ # path per question; the depth cap (git resolves 4 links and refuses 5; the walk collects at # most 5); core.preferSymlinkRefs, a known limit (review 4, N7). # -# PRE-REGISTERED (bench/d231_PREREG.md, amendments 1, 2, 2a, 3 and 3a): 2 arms x 52 steps = 104 +# PRE-REGISTERED (bench/d231_PREREG.md, amendments 1, 2, 2a, 3, 3a and 3b): 2 arms x 52 steps = 104 # verdicts. # AT THE D231 TIP: 104 PASS, exit 0. No step depends on timing. # AT 9aa6968: nominally 51 PASS / 53 FAIL, exit 1. @@ -711,12 +712,25 @@ for arm in clone linked; do check "$arm" "$probe" "$tgt" 38-ignored-rs-removed "at $S0" yes # 39-40 (U2): a clean filter hides an edit from `git status`; the bytes still differ from HEAD. + # The edit keeps the file's size, because git reads a size change as a modification without + # asking the filter (PREREG amendment 3b, MEASURED on a toy repository with git 2.50.1: an + # appended line stayed visible through a filter that deletes it, and a same-size edit that the + # filter reverses read clean). So one word of a comment in src/lib.rs changes case, and the + # filter changes it back. attrs=$(gpath "$ck" info/attributes) || harness "no info/attributes path" [ ! -e "$attrs" ] || harness "$attrs exists already" mkdir -p "$(dirname "$attrs")" || exit 2 printf '%s\n' "src/lib.rs filter=d231" > "$attrs" || harness "could not write $attrs" - g "$ck" config filter.d231.clean "sed '/d231 filter-hidden line/d'" || harness "could not configure the clean filter" - printf '%s\n' "// d231 filter-hidden line" >> "$ck/src/lib.rs" || harness "could not append to src/lib.rs" + g "$ck" config filter.d231.clean "sed 's/EVERY harness in/Every harness in/'" \ + || harness "could not configure the clean filter" + python3 - "$ck/src/lib.rs" <<'PY' || harness "could not make the same-size edit to src/lib.rs" +import sys +p = sys.argv[1] +b = open(p, "rb").read() +if b.count(b"Every harness in") != 1 or b"EVERY harness in" in b: + sys.exit(1) +open(p, "wb").write(b.replace(b"Every harness in", b"EVERY harness in")) +PY st_path hidden "$ck" src/lib.rs [ -z "$hidden" ] || harness "git status sees the filtered edit; step 39 would not discriminate" head_blob=$(git -C "$ck" rev-parse HEAD:src/lib.rs) || harness "no HEAD blob for src/lib.rs" diff --git a/build.rs b/build.rs index 13e1938e..efb705a2 100644 --- a/build.rs +++ b/build.rs @@ -78,6 +78,12 @@ //! tracked `.gitmodules`, or an untracked file inside it (measured by D231's review 5, which //! corrected this sentence). A submodule among the build INPUTS stamps `unknown`, because it has //! no bytes to compare. ferrodb has no submodules. +//! * The byte comparison assumes a checkout writes each blob's bytes unchanged. A smudge filter, +//! or line endings converted on checkout (`core.autocrlf`, `eol=crlf`), makes every converted +//! file differ from its blob, so a clean tree stamps `+DIRTY` on that machine: the safe +//! direction, but a flag that then means nothing there. ferrodb's `.gitattributes` pins +//! `* text=auto eol=lf`, which checks out LF on every platform, CI's Windows runner included +//! (from gitattributes(5); not run on Windows here). //! * Untracked files that are NOT build inputs, and ignored files among the inputs that do not end //! in `.rs`, are not counted: nothing compiles them. A `.cargo/` or `rust-toolchain*` in a parent //! directory is not seen at all, and one that appears at the package root where none existed is From b98d356cb0b0a28c7db82de8f5b5de4c11538e98 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:40:30 -0400 Subject: [PATCH 278/315] D280 RED: a restored base backup opened by the CLI loses a committed write after a crash; pgserver serves it Two tests on the base API only (the ferrodb binary, the pgserver example, backup::take/restore). Pre-registered in artie-research frontier/lane_d280_orphan_wal.md (b54231e): red at the base with 'COMMITTED ROW LOST' and 'pgserver ACCEPTED'. --- tests/d280_orphaned_wal_binaries.rs | 368 ++++++++++++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 tests/d280_orphaned_wal_binaries.rs diff --git a/tests/d280_orphaned_wal_binaries.rs b/tests/d280_orphaned_wal_binaries.rs new file mode 100644 index 00000000..a95cfab1 --- /dev/null +++ b/tests/d280_orphaned_wal_binaries.rs @@ -0,0 +1,368 @@ +//! D280 — a data file whose write-ahead log never issued its pages' LSNs, opened as a primary by +//! the two shipped entry points. +//! +//! `backup::restore` is a file copy (`replication/backup.rs`), and a replica never builds a +//! `WalManager` (`examples/repl_replica.rs`). Either file therefore arrives with pages stamped at the +//! primary's LSNs and no `.wal`. Opened with `ferrodb` or `pgserver`, the log was created at +//! LSN 1, far below those pages. A committed write to one of them that crashed before the page was +//! flushed was then skipped by redo (`page.lsn >= lsn`), and the transaction still had its Commit, +//! so nothing undid or reported it. +//! +//! These tests drive the real binaries, so they state the outcome and nothing about the API: +//! either the open is refused and leaves the file exactly as it found it, or every committed write +//! survives a crash. They use only API that existed before the fix, so the same file is the red +//! run at the base and the green run at the tip. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::replication::backup; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; +use ferrodb::storage::heap_page::Page; +use ferrodb::wal::log::WalManager; + +/// Ordinary-table pages below the arena floor. Small for the reason +/// `integration_cli_effect_log_durability.rs` gives: the default puts the arena ~128 MB in, which is +/// 128 MB of real zeroes on a filesystem without sparse files. +const HEADROOM: u32 = 256; + +/// Rows the primary writes before the backup. Enough that the page they share carries an LSN far +/// above the first record a fresh log issues, and few enough that they share one page with room left, +/// so the restored database's next INSERT lands on that same page. +const ROWS: i64 = 20; + +/// A generous deadline costs nothing when the binary answers, and a tight one fails on a loaded box. +const DEADLINE: Duration = Duration::from_secs(120); + +/// Kills the child when dropped, so a failing assertion never leaves a server or a CLI behind. +struct Reaped(Child); + +impl Drop for Reaped { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn wal_of(db: &Path) -> PathBuf { + let mut p = db.as_os_str().to_os_string(); + p.push(".wal"); + PathBuf::from(p) +} + +fn lock_of(db: &Path) -> PathBuf { + let mut p = db.as_os_str().to_os_string(); + p.push(".lock"); + PathBuf::from(p) +} + +fn cli(db: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_ferrodb")); + c.arg(db) + .env("FERRODB_ARENA_HEADROOM", HEADROOM.to_string()) + // Neither the lease scan nor the automatic checkpoint may run between the commit and the + // kill: either one could flush the page and close the window this test aims at. + .env("FERRODB_LEASE_SCAN_MILLIS", "3600000") + .env("FERRODB_CHECKPOINT_INTERVAL", "100000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + c +} + +/// One whole CLI session that exits cleanly: its exit status and everything it printed. +fn session(db: &Path, sql: &str) -> (ExitStatus, String) { + let mut child = cli(db).spawn().expect("spawn ferrodb"); + child.stdin.take().unwrap().write_all(sql.as_bytes()).expect("write sql"); + let out = child.wait_with_output().expect("wait for ferrodb"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + (out.status, text) +} + +/// The `a | b` rows a SELECT printed, with the REPL's prompts stripped off the front of each line. +fn rows(out: &str) -> Vec<(i64, i64)> { + let mut found = Vec::new(); + for line in out.lines() { + let mut l = line; + while let Some(rest) = l.strip_prefix("ferrodb=> ").or_else(|| l.strip_prefix(" ...? ")) { + l = rest; + } + let cells: Vec<&str> = l.split(" | ").collect(); + if let [a, b] = cells[..] { + if let (Ok(a), Ok(b)) = (a.trim().parse(), b.trim().parse()) { + found.push((a, b)); + } + } + } + found +} + +/// A primary written by the shipped CLI and closed cleanly, so its pages are flushed with the LSNs +/// its own log issued. Also the control: a second, ordinary open of the same file must work, or a +/// guard that refuses everything would pass every test below. +fn cli_primary(dir: &Path) -> PathBuf { + let db = dir.join("p.db"); + let mut sql = String::from("CREATE TABLE inv (id INTEGER NOT NULL, qty INTEGER);\n"); + for i in 1..=ROWS { + sql.push_str(&format!("INSERT INTO inv VALUES ({i}, {i});\n")); + } + let (status, out) = session(&db, &sql); + assert!(status.success() && !out.contains("error:"), "building the primary failed:\n{out}"); + + let (status, out) = session(&db, "SELECT * FROM inv;\n"); + assert!( + status.success() && !out.contains("error:"), + "CONTROL: an ordinary reopen of a database the CLI itself wrote failed:\n{out}" + ); + assert_eq!(rows(&out).len(), ROWS as usize, "CONTROL: the primary does not hold its rows:\n{out}"); + db +} + +/// `backup::take` on the primary, then `backup::restore` to `dest`: the file a replica starts from, +/// and the file an operator restoring a backup gets. No `.wal` exists afterwards. +fn restored_copy(primary: &Path, dir: &Path) -> PathBuf { + let file = std::fs::OpenOptions::new().read(true).write(true).open(primary).unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let wal = Arc::new(WalManager::new(wal_of(primary)).unwrap()); + bp.attach_wal(wal.clone()); + let backup_dir = dir.join("backup"); + drop(backup::take(&bp, &wal, &backup_dir).expect("take a base backup")); + drop(bp); + drop(wal); + + let dest = dir.join("r.db"); + backup::restore(&backup_dir, &dest).expect("restore the backup"); + assert!(!wal_of(&dest).exists(), "fixture: restore wrote a WAL, so this is not the case under test"); + + // The premise, computed from the bytes rather than asked of anything under test: the copy + // carries heap pages stamped with LSNs, which a log starting at 1 has never issued. + let bytes = std::fs::read(&dest).unwrap(); + let highest = bytes + .chunks_exact(PAGE_SIZE) + .enumerate() + .filter(|(id, raw)| raw[0] == 0 && raw[1..5] == (*id as u32).to_be_bytes()) + .map(|(_, raw)| Page::deserialize(raw.try_into().unwrap()).unwrap().lsn) + .max() + .unwrap_or(0); + assert!(highest > 0, "fixture: the restored copy carries no page LSN, so nothing can be lost"); + dest +} + +/// Lines from a child's pipe, delivered on a channel so the test can wait with a deadline. +fn lines_of(pipe: impl Read + Send + 'static) -> Receiver { + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(pipe).lines() { + match line { + Ok(l) => { + if tx.send(l).is_err() { + return; + } + } + Err(_) => return, + } + } + }); + rx +} + +/// Wait for a line containing `needle`. `Ok(Some(line))` when it arrives, `Ok(None)` when the pipe +/// closes first (the process is exiting), and a panic naming `what` at the deadline. +fn wait_for(rx: &Receiver, needle: &str, what: &str, seen: &mut Vec) -> Option { + let until = Instant::now() + DEADLINE; + loop { + let left = until.saturating_duration_since(Instant::now()); + match rx.recv_timeout(left) { + Ok(line) => { + seen.push(line.clone()); + if line.contains(needle) { + return Some(line); + } + } + Err(RecvTimeoutError::Disconnected) => return None, + Err(RecvTimeoutError::Timeout) => { + panic!("no {what} within {DEADLINE:?}; stdout so far:\n{}", seen.join("\n")) + } + } + } +} + +/// The refusal, checked by its effects: a non-zero exit, a message naming the missing log, and a +/// directory left exactly as it was — no log created, no lock left behind, the data file untouched. +fn assert_refused_without_trace(db: &Path, before: &[u8], status: ExitStatus, stderr: &str) { + assert!(!status.success(), "the process exited successfully without opening: {stderr}"); + let wal_name = wal_of(db).file_name().unwrap().to_string_lossy().into_owned(); + assert!( + stderr.contains(&wal_name), + "the refusal does not name the missing log {wal_name}: {stderr}" + ); + assert!(!wal_of(db).exists(), "the refused open created {}", wal_of(db).display()); + assert!(!lock_of(db).exists(), "the refused open left its lock {} behind", lock_of(db).display()); + assert!( + std::fs::read(db).unwrap() == before, + "the refused open changed the data file it refused" + ); +} + +/// **The red test.** Open the restored copy with the CLI, commit one row on a page that came from +/// the primary, crash, reopen. Before the fix the row is gone: redo compared the new record's LSN, +/// tens of bytes into a fresh log, with the primary's LSN on the page, and skipped it. +/// +/// After the fix, the first open is refused. There is no third acceptable outcome: an open that is +/// not refused and happens to keep the row (because something flushed the page first) still fails, +/// under a different message, since the hazard is the open and not this particular crash. +#[test] +fn a_restored_backup_opened_by_the_cli_keeps_every_committed_write_or_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let primary = cli_primary(dir.path()); + let db = restored_copy(&primary, dir.path()); + let before = std::fs::read(&db).unwrap(); + + let mut child = Reaped(cli(&db).spawn().expect("spawn ferrodb on the restored copy")); + let stdout = lines_of(child.0.stdout.take().unwrap()); + let stderr = lines_of(child.0.stderr.take().unwrap()); + let mut seen = Vec::new(); + + if wait_for(&stdout, "type .exit", "banner or exit", &mut seen).is_none() { + let status = child.0.wait().expect("wait for the refused CLI"); + let err: Vec = stderr.iter().collect(); + assert_refused_without_trace(&db, &before, status, &err.join("\n")); + return; + } + + // It opened. Commit one row, and die the moment the commit is acknowledged. + let mut stdin = child.0.stdin.take().unwrap(); + stdin.write_all(b"INSERT INTO inv VALUES (999, 999);\n").expect("send the insert"); + stdin.flush().unwrap(); + if wait_for(&stdout, "row affected", "commit acknowledgement", &mut seen).is_none() { + let err: Vec = stderr.try_iter().collect(); + panic!("the CLI exited before acknowledging the insert:\n{}\n{}", seen.join("\n"), err.join("\n")); + } + child.0.kill().expect("SIGKILL the CLI"); + child.0.wait().unwrap(); + drop(stdin); + // A SIGKILLed process leaves its lock, and the next open refuses a stale one by design. + std::fs::remove_file(lock_of(&db)).expect("remove the dead process's lock"); + + let (status, out) = session(&db, "SELECT * FROM inv;\n"); + assert!(status.success() && !out.contains("error:"), "the reopen after the crash failed:\n{out}"); + let after = rows(&out); + for i in 1..=ROWS { + assert!( + after.contains(&(i, i)), + "premise: the primary's row {i} is missing after the reopen, so the table itself is not \ + being read and the check below would mean nothing:\n{out}" + ); + } + assert!( + after.contains(&(999, 999)), + "COMMITTED ROW LOST: a restored base backup (pages at the primary's LSNs, no WAL) was opened \ + by the CLI as a primary; row (999, 999) was committed and acknowledged, the process was \ + killed, and after recovery the row is gone. Rows after the reopen: {after:?}" + ); + panic!( + "the restored copy was opened as a primary and not refused. The committed row survived this \ + crash, but only because its page was flushed first; the open itself is the hazard." + ); +} + +/// The same file offered to `pgserver`. Before the fix it printed `LISTENING` and served it; after, +/// it refuses the way the CLI does, and does not leave its lock behind on the way out. +#[test] +fn pgserver_refuses_a_restored_backup_and_leaves_no_trace() { + let dir = tempfile::tempdir().unwrap(); + let primary = cli_primary(dir.path()); + let db = restored_copy(&primary, dir.path()); + let before = std::fs::read(&db).unwrap(); + + let mut child = Reaped( + Command::new(example_bin("pgserver")) + .arg(&db) + .arg("127.0.0.1:0") + .env("FERRODB_ARENA_HEADROOM", HEADROOM.to_string()) + .env("FERRODB_LEASE_SCAN_MILLIS", "3600000") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn pgserver"), + ); + let stdout = lines_of(child.0.stdout.take().unwrap()); + let stderr = lines_of(child.0.stderr.take().unwrap()); + let mut seen = Vec::new(); + + if let Some(line) = wait_for(&stdout, "LISTENING", "readiness line or exit", &mut seen) { + panic!( + "pgserver ACCEPTED a restored base backup (pages at the primary's LSNs, no WAL) as a \ + primary and is serving it: {line}" + ); + } + let status = child.0.wait().expect("wait for the refused pgserver"); + let err: Vec = stderr.iter().collect(); + assert_refused_without_trace(&db, &before, status, &err.join("\n")); +} + +/// Refuse to run against a stale example binary: `cargo test` does not rebuild examples. The same +/// guard `integration_base_backup.rs` carries, for the same reason. +fn assert_example_is_fresh(bin: &Path) { + let bin_time = std::fs::metadata(bin) + .unwrap_or_else(|e| panic!("{} is missing ({e}); run: cargo build --examples", bin.display())) + .modified() + .expect("mtime"); + let own_src = bin + .file_stem() + .map(|s| Path::new("examples").join(format!("{}.rs", s.to_string_lossy()))) + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| m.modified().ok()); + let newest_source = [walk_newest(Path::new("src")), own_src].into_iter().flatten().max(); + if let Some(src_time) = newest_source { + assert!( + bin_time >= src_time, + "{} is older than src/ or examples/ — cargo test does not rebuild examples, so \ + this would test a stale binary. Run: cargo build --examples", + bin.display() + ); + } +} + +fn walk_newest(dir: &Path) -> Option { + let mut newest = None; + for e in std::fs::read_dir(dir).ok()?.flatten() { + let p = e.path(); + // A `src/**/tests_*.rs` file is `#[cfg(test)]` and does not link into an example binary. + if p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("tests_")) { + continue; + } + let t = if p.is_dir() { + walk_newest(&p) + } else { + std::fs::metadata(&p).ok().and_then(|m| m.modified().ok()) + }; + if let Some(t) = t { + newest = Some(match newest { + Some(cur) if cur > t => cur, + _ => t, + }); + } + } + newest +} + +fn example_bin(name: &str) -> PathBuf { + let mut p = std::env::current_exe().expect("test exe"); + p.pop(); + if p.ends_with("deps") { + p.pop(); + } + let out = p.join("examples").join(format!("{name}{}", std::env::consts::EXE_SUFFIX)); + assert_example_is_fresh(&out); + out +} From f3c790092d9fb8cb14419933ffb00df0184ffa10 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:45:26 -0400 Subject: [PATCH 279/315] D280: refuse opening a data file whose pages carry LSNs its WAL never issued WalManager::open_for_database is the one open path cli.rs and examples/pgserver.rs share. When .wal is missing (checked before it is created) or has never issued an LSN, it reads every page the file holds and refuses with FerroError::LogBehindPages if any self-named page carries an LSN >= INITIAL_LSN, naming .replstate (a replica's file) or the missing/empty log. A log that has issued anything is trusted without reading a page. pgserver drops its DbLock before process::exit on the refusal. The consensus snapshot install path gets a note: it is D280's latent twin. Tests: tests/d280_open_for_database.rs (T3-T10, lane PREREG b54231e). --- examples/pgserver.rs | 11 +- src/buffer/buffer_pool.rs | 9 +- src/cli/cli.rs | 4 +- src/consensus/snapshot.rs | 8 + src/error.rs | 54 +++- src/wal/log.rs | 122 +++++++++- tests/d280_open_for_database.rs | 419 ++++++++++++++++++++++++++++++++ 7 files changed, 620 insertions(+), 7 deletions(-) create mode 100644 tests/d280_open_for_database.rs diff --git a/examples/pgserver.rs b/examples/pgserver.rs index f67cf255..41b3a7c2 100644 --- a/examples/pgserver.rs +++ b/examples/pgserver.rs @@ -60,7 +60,16 @@ fn main() { .expect("open db"); let dm = Arc::new(DiskManager::new(file).unwrap()); let bp = Arc::new(BufferPoolManager::new(dm)); - let wal = Arc::new(WalManager::new(format!("{db}.wal").into()).unwrap()); + // D280: the same refusal as the CLI's, through the same function. Released by hand because + // `process::exit` runs no destructors, and a lock left behind refuses the next open as stale. + let wal = match WalManager::open_for_database(Path::new(&db), &bp.disk_manager) { + Ok(w) => Arc::new(w), + Err(e) => { + eprintln!("pgserver: {e}"); + drop(_lock); + std::process::exit(1); + } + }; let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); bp.attach_wal(wal); recover(&txn).unwrap(); diff --git a/src/buffer/buffer_pool.rs b/src/buffer/buffer_pool.rs index fd4f60e1..ca515c52 100644 --- a/src/buffer/buffer_pool.rs +++ b/src/buffer/buffer_pool.rs @@ -1433,7 +1433,14 @@ impl BufferPoolManager { } } -fn page_lsn_of(data: &[u8; PAGE_SIZE]) -> u64 { +/// The LSN field of a page, found by its type byte; 0 for a type that carries none. +/// +/// Classifies by `data[0]` alone, and that byte is not a type on every page: on the bitmap page it +/// is the low byte of the next-bitmap pointer, and on a COW arena page it is the top byte of +/// `birth_epoch`. Both read as a heap page here. The WAL gate can live with that, since it only +/// flushes the log further than it needed to. `WalManager::open_for_database` cannot, so it also +/// requires the page to name itself in bytes 1..5, as redo does. +pub(crate) fn page_lsn_of(data: &[u8; PAGE_SIZE]) -> u64 { match data[0] { 0 => u64::from_be_bytes(data[11..19].try_into().unwrap()), 2 | 3 => u64::from_be_bytes(data[5..13].try_into().unwrap()), diff --git a/src/cli/cli.rs b/src/cli/cli.rs index 5f76d2ea..12f78596 100644 --- a/src/cli/cli.rs +++ b/src/cli/cli.rs @@ -59,7 +59,9 @@ pub fn run_cli(db_path: &str) -> Result<(), FerroError> { let file = OpenOptions::new().read(true).write(true).create(true).open(db_path).map_err(|e|FerroError::Io(e.to_string()))?; let dm = Arc::new(DiskManager::new(file)?); let bp = Arc::new(BufferPoolManager::new(dm)); - let wal = Arc::new(WalManager::new(format!("{}.wal", db_path).into())?); + // D280: refuses a data file whose pages carry LSNs this log never issued (a restored backup, a + // replica's file, a lost `.wal`), before anything else is created beside it. + let wal = Arc::new(WalManager::open_for_database(Path::new(db_path), &bp.disk_manager)?); let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); bp.attach_wal(wal.clone()); let recovered = recover(&txn)?; diff --git a/src/consensus/snapshot.rs b/src/consensus/snapshot.rs index 9d819e35..5b3980f1 100644 --- a/src/consensus/snapshot.rs +++ b/src/consensus/snapshot.rs @@ -1520,6 +1520,14 @@ impl SnapshotStore for PageStoreSnapshots { // applied onto a page from a different database with nothing to notice it. `truncate` // throws the log away whole and restarts it at the current end, which is exactly what a // node whose pages are now a checkpoint needs. + // + // ⚠ D280's twin, latent while snapshots are unwired in production (`agent_sql/cluster.rs` + // installs none). "The current end" is THIS node's end, and nothing places it above the + // installed pages' LSNs, which are the SENDER's. If it is below them, the next local write + // to an installed page is skipped by redo after a crash, which is the loss + // `WalManager::open_for_database` refuses on open. Before this path is wired, it needs the + // same predicate: no installed page may carry an LSN at or above the re-based log's end. + // It must either re-base above the highest one or refuse. let before = self.wal.base_lsn.load(std::sync::atomic::Ordering::SeqCst); self.wal.truncate(0)?; let after = self.wal.base_lsn.load(std::sync::atomic::Ordering::SeqCst); diff --git a/src/error.rs b/src/error.rs index 3d2f6052..0d89c73e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -96,6 +96,14 @@ pub enum FerroError { /// an egress refusal from a bad insert. Publication(String), + /// **D280.** The database's pages carry an LSN its write-ahead log never issued, so the database + /// was refused at open. See `WalManager::open_for_database`. + /// + /// Structured rather than a `Wal(String)` because the cause decides what the operator does + /// next, and a test that has to match rendered text pins wording, not the fact. `page_id` and + /// `page_lsn` name the page with the highest such LSN. + LogBehindPages { db: String, cause: LogBehindPagesCause, page_id: u32, page_lsn: u64 }, + /// This node is not the leader, so it refused rather than serving a write or a stale read. /// /// `leader` is an **address** and not a `NodeId`, because the only useful thing a refused @@ -119,6 +127,38 @@ impl Display for FerroError { {limit}); refused rather than written truncated" ), FerroError::Publication(e) => write!(f, "publication refused: {}", e), + // Every arm says what the file is, why opening it would lose data, and what to do. The + // shared tail is the mechanism, because an operator told only "refused" will look for a + // flag to override it, and there is none. + FerroError::LogBehindPages { db, cause, page_id, page_lsn } => { + let why = format!( + "page {page_id} carries LSN {page_lsn}, which this database's own write-ahead \ + log never issued. Opened as a primary, the log would start at LSN 1, below \ + the pages' LSNs, and after a crash recovery would silently skip committed \ + writes to those pages" + ); + match cause { + LogBehindPagesCause::ReplicaFile => write!( + f, + "refusing to open {db}: it is a replica's file ({db}.replstate is \ + present), and promoting a replica is not supported. {why}. Keep serving \ + it with repl_replica." + ), + LogBehindPagesCause::MissingLog => write!( + f, + "refusing to open {db}: its write-ahead log {db}.wal is missing. {why}. \ + This is a restored base backup or a database whose log was lost: put \ + back the .wal that belongs to this file. A base-backup image can only \ + be served as a replica." + ), + LogBehindPagesCause::EmptyLog => write!( + f, + "refusing to open {db}: its write-ahead log {db}.wal has never issued an \ + LSN. {why}. The log does not belong to these pages: put back the one \ + that does." + ), + } + } FerroError::Io(e) => write!(f, "io error: {}", e), FerroError::NotEnoughSpace => write!(f, "not enough space in page"), FerroError::SlotDeleted => write!(f, "the slot is delted"), @@ -156,4 +196,16 @@ impl Display for FerroError { } } -impl error::Error for FerroError {} \ No newline at end of file +impl error::Error for FerroError {} + +/// What [`FerroError::LogBehindPages`] found beside the data file, which is what the refusal names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LogBehindPagesCause { + /// `.replstate` is present: a replica's file. It takes precedence over the other two, since + /// a replica never has a log of its own. + ReplicaFile, + /// `.wal` does not exist: a `backup::restore` output, or a database whose log was lost. + MissingLog, + /// `.wal` exists but has never issued an LSN (its base and its end are both the first LSN). + EmptyLog, +} \ No newline at end of file diff --git a/src/wal/log.rs b/src/wal/log.rs index 23f1e530..4f2899a3 100644 --- a/src/wal/log.rs +++ b/src/wal/log.rs @@ -1,11 +1,13 @@ -use std::{fs::OpenOptions, mem::take, path::PathBuf, sync::{Arc, Mutex, OnceLock, atomic::{AtomicU64, Ordering}}}; +use std::{fs::OpenOptions, mem::take, path::{Path, PathBuf}, sync::{Arc, Mutex, OnceLock, atomic::{AtomicU64, Ordering}}}; use crate::{ branch::types::BranchId, + buffer::buffer_pool::page_lsn_of, catalog::column::DataType, - error::FerroError, + error::{FerroError, LogBehindPagesCause}, provenance::{ProvId, RunEntity}, - storage::storage::Storage, + replication::ReplicaState, + storage::{disk_manager::{DiskManager, PAGE_SIZE}, storage::Storage}, }; const HEADER_SIZE: usize = 24; @@ -588,6 +590,69 @@ impl WalManager { fail_next_append: std::sync::atomic::AtomicBool::new(false)}) } + /// Open `.wal` for the database at `db_path`. **It refuses when the data file's pages carry an + /// LSN this log never issued** (D280). `cli.rs` and `examples/pgserver.rs` both open through + /// here, so they cannot disagree about it. + /// + /// `WalManager::new` on a missing file starts the log at `INITIAL_LSN`, and it looks at no page. + /// Three files arrive in exactly that state with pages stamped by some other log: + /// - a `backup::restore` output (a file copy); + /// - a replica's file (`repl_replica` builds no `WalManager`, and every record it applies stamps + /// the primary's LSN); + /// - any database whose `.wal` was lost. + /// + /// Opened as a primary, the first write to such a page takes an LSN tens of bytes into the new + /// log, far below the page's. Commit forces only the log. After a crash, redo reads the page + /// back with its old, higher LSN and skips the record (`page.lsn >= lsn`, `recovery.rs`). The + /// transaction has its Commit, so nothing undoes it or reports it: the write is gone. So this + /// refuses, and it never re-bases. Re-basing (starting the log above the highest page LSN) + /// would be an explicit operator decision, and no such option exists yet. + /// + /// **A normal open reads no page.** The pages are scanned only when the log cannot have issued + /// anything: + /// - `.wal` does not exist. Checked BEFORE it is created, so a refused open leaves the + /// directory as it found it. + /// - `.wal` has never issued an LSN: base and end are both `INITIAL_LSN`. `truncate` sets the + /// base to the end and never lowers it, so a log that has issued anything has a base above 1. + /// + /// **What it reads.** Every page the file physically holds, not `0..high_water`. A replica + /// applies records to pages the primary allocated after the backup, and its copy of the bitmap + /// never learns of them. A page only counts if it names itself in bytes 1..5, which is the rule + /// redo uses. Its LSN is then read by `buffer_pool::page_lsn_of`, the WAL gate's classifier. + /// The self-naming rule is what stops that classifier reading the bitmap page and COW arena + /// pages as heap pages with large LSNs. + /// + /// **Blind spots, stated so nobody takes this for more than it is:** + /// - A log that has issued anything is trusted without reading a page. A restored file opened + /// by a binary from before this guard, which logged a write and checkpointed, now has a log + /// whose base is above 1. It is not rescanned, and its untouched pages stay exposed. + /// - The existence check and the creation are two steps, so the caller must hold the + /// database's `DbLock`, as both entry points do. + /// - A database that has never logged anything (no DDL, no DML) is rescanned on every open. In + /// the CLI that includes the sparse gap below the arena floor. The first `CREATE TABLE` logs a + /// `Ddl` record and ends that for good. + pub fn open_for_database(db_path: &Path, dm: &DiskManager) -> Result { + let mut name = db_path.as_os_str().to_os_string(); + name.push(".wal"); + let path = PathBuf::from(name); + // `try_exists`, not `exists`: an error here must refuse, not read as "missing" and create a + // second log beside one this process cannot see. + let exists = path + .try_exists() + .map_err(|e| FerroError::Wal(format!("cannot tell whether {} exists: {e}", path.display())))?; + if !exists { + refuse_pages_the_log_never_issued(db_path, dm, LogBehindPagesCause::MissingLog)?; + return Self::new(path); + } + let wal = Self::new(path)?; + if wal.base_lsn.load(Ordering::SeqCst) == INITIAL_LSN + && wal.next_lsn.load(Ordering::SeqCst) == INITIAL_LSN + { + refuse_pages_the_log_never_issued(db_path, dm, LogBehindPagesCause::EmptyLog)?; + } + Ok(wal) + } + /// Pin the log at its current durable frontier, and return where that turned out to be. /// /// **Reading the LSN and registering the claim happen under one lock, and that is the whole @@ -857,6 +922,57 @@ impl WalManager { } } +/// The refusal half of [`WalManager::open_for_database`]. It is called only when the log has +/// issued nothing, so any page LSN at or above `INITIAL_LSN` came from some other log. +/// +/// It scans every page and reports the one with the HIGHEST such LSN, rather than stopping at the +/// first: that number is how far the log would have to start to be above every page, and it is the +/// figure an operator needs. `.replstate` is looked for only once the scan has found something. +fn refuse_pages_the_log_never_issued( + db_path: &Path, + dm: &DiskManager, + cause_unless_replica: LogBehindPagesCause, +) -> Result<(), FerroError> { + let len = dm.storage.len().map_err(|e| FerroError::Io(e.to_string()))?; + // Whole pages only. A torn last page cannot be read back, and recovery replaces such a page + // with `Page::empty` (lsn 0) before redo, so its bytes are not an LSN redo will compare with. + let pages = u32::try_from(len / PAGE_SIZE as u64) + .map_err(|_| FerroError::Io(format!("{} is too long to hold u32 page ids", db_path.display())))?; + let mut highest: Option<(u32, u64)> = None; + for page_id in 0..pages { + let lsn = self_named_page_lsn(page_id, &dm.read(page_id)?); + if lsn >= INITIAL_LSN && highest.is_none_or(|(_, h)| lsn > h) { + highest = Some((page_id, lsn)); + } + } + let Some((page_id, page_lsn)) = highest else { + return Ok(()); + }; + let replica = ReplicaState::at(db_path); + let is_replica = replica.path().try_exists().map_err(|e| { + FerroError::Wal(format!("cannot tell whether {} exists: {e}", replica.path().display())) + })?; + Err(FerroError::LogBehindPages { + db: db_path.display().to_string(), + cause: if is_replica { LogBehindPagesCause::ReplicaFile } else { cause_unless_replica }, + page_id, + page_lsn, + }) +} + +/// The page's LSN, or 0 unless the page names itself in bytes 1..5. +/// +/// The identity rule is redo's (`recovery.rs`, `redo_one`): a page that does not name itself is +/// treated there as `Page::empty`, with LSN 0. Every page type this engine stamps puts its id at +/// bytes 1..5, and `Page::serialize` always writes it. The bitmap page and COW arena pages do not, +/// and `page_lsn_of` alone would read both as heap pages (see its doc). +fn self_named_page_lsn(page_id: u32, data: &[u8; PAGE_SIZE]) -> u64 { + if data[1..5] != page_id.to_be_bytes() { + return 0; + } + page_lsn_of(data) +} + pub fn scan_valid_end(file: &dyn Storage, base_lsn: u64, file_len: u64) -> Result{ let mut offset = HEADER_SIZE as u64; loop { diff --git a/tests/d280_open_for_database.rs b/tests/d280_open_for_database.rs new file mode 100644 index 00000000..8a7f61a6 --- /dev/null +++ b/tests/d280_open_for_database.rs @@ -0,0 +1,419 @@ +//! D280 — `WalManager::open_for_database`, the one open path the CLI and `pgserver` share, asked +//! directly. `d280_orphaned_wal_binaries.rs` proves the binaries refuse and lose nothing. This file +//! pins each part of the rule, so that a mutant of any one part fails a test named for it: +//! - which cause is named; +//! - when the pages are read at all; +//! - which pages count; +//! - which page is reported. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ferrodb::branch::types::{ArenaId, Epoch}; +use ferrodb::buffer::buffer_pool::BufferPoolManager; +use ferrodb::catalog::catalog::Catalog; +use ferrodb::cow::page_header::{stamp_checksum, PageHeader, PageType}; +use ferrodb::error::{FerroError, LogBehindPagesCause}; +use ferrodb::replication::{backup, ReplicaState}; +use ferrodb::storage::disk_manager::{DiskManager, PAGE_SIZE}; +use ferrodb::storage::heap_file_manager::HeapFileManager; +use ferrodb::storage::heap_page::Page; +use ferrodb::storage::storage::Storage; +use ferrodb::wal::log::WalManager; + +const HEADROOM: u32 = 256; +const ROWS: i64 = 20; +const DEADLINE: Duration = Duration::from_secs(120); + +fn wal_of(db: &Path) -> PathBuf { + let mut p = db.as_os_str().to_os_string(); + p.push(".wal"); + PathBuf::from(p) +} + +fn lock_of(db: &Path) -> PathBuf { + let mut p = db.as_os_str().to_os_string(); + p.push(".lock"); + PathBuf::from(p) +} + +fn open_rw(db: &Path) -> std::fs::File { + std::fs::OpenOptions::new().read(true).write(true).create(true).open(db).unwrap() +} + +/// Counts every `pread`, so "a normal open reads no page" is a number and not a claim. +struct CountingStorage { + inner: std::fs::File, + reads: Arc, +} + +impl Storage for CountingStorage { + fn pwrite(&self, buf: &[u8], offset: u64) -> std::io::Result { + Storage::pwrite(&self.inner, buf, offset) + } + fn pread(&self, buf: &mut [u8], offset: u64) -> std::io::Result { + self.reads.fetch_add(1, Ordering::SeqCst); + Storage::pread(&self.inner, buf, offset) + } + fn sync_all(&self) -> std::io::Result<()> { + Storage::sync_all(&self.inner) + } + fn sync_data(&self) -> std::io::Result<()> { + Storage::sync_data(&self.inner) + } + fn set_len(&self, len: u64) -> std::io::Result<()> { + Storage::set_len(&self.inner, len) + } + fn len(&self) -> std::io::Result { + Storage::len(&self.inner) + } +} + +/// `open_for_database` over a `DiskManager` whose reads are counted: the result, and how many +/// `pread`s the open itself issued. +fn counted_open(db: &Path) -> (Result, u64) { + let reads = Arc::new(AtomicU64::new(0)); + let dm = DiskManager::with_storage(Arc::new(CountingStorage { inner: open_rw(db), reads: reads.clone() })) + .unwrap(); + let before = reads.load(Ordering::SeqCst); + let out = WalManager::open_for_database(db, &dm); + (out, reads.load(Ordering::SeqCst) - before) +} + +/// The refusal's fields, or a panic saying what came back instead. +fn refusal(r: Result) -> (String, LogBehindPagesCause, u32, u64) { + match r { + Err(FerroError::LogBehindPages { db, cause, page_id, page_lsn }) => (db, cause, page_id, page_lsn), + Err(e) => panic!("refused, but not as LogBehindPages: {e:?}"), + Ok(_) => panic!("opened a data file whose pages carry LSNs the log never issued"), + } +} + +// ---- the CLI-built primary and its restored copy ----------------------------------------------- + +fn cli(db: &Path) -> Command { + let mut c = Command::new(env!("CARGO_BIN_EXE_ferrodb")); + c.arg(db) + .env("FERRODB_ARENA_HEADROOM", HEADROOM.to_string()) + .env("FERRODB_LEASE_SCAN_MILLIS", "3600000") + .env("FERRODB_CHECKPOINT_INTERVAL", "100000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + c +} + +fn session(db: &Path, sql: &str) -> (ExitStatus, String) { + let mut child = cli(db).spawn().expect("spawn ferrodb"); + child.stdin.take().unwrap().write_all(sql.as_bytes()).expect("write sql"); + let out = child.wait_with_output().expect("wait for ferrodb"); + let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + (out.status, text) +} + +fn cli_primary(dir: &Path) -> PathBuf { + let db = dir.join("p.db"); + let mut sql = String::from("CREATE TABLE inv (id INTEGER NOT NULL, qty INTEGER);\n"); + for i in 1..=ROWS { + sql.push_str(&format!("INSERT INTO inv VALUES ({i}, {i});\n")); + } + let (status, out) = session(&db, &sql); + assert!(status.success() && !out.contains("error:"), "building the primary failed:\n{out}"); + db +} + +/// The page holding the primary's rows, and the LSN on it. Found through the catalog and the heap, +/// which is a different route from the page scan under test, so the expectation does not come from +/// the subject. +fn rows_page(db: &Path) -> (u32, u64) { + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(open_rw(db)).unwrap()))); + let catalog = Catalog::open(bp.clone(), 1).expect("open the primary's catalog"); + let root = catalog.get_table("inv").expect("the table").first_directory_page_id; + let mut pages: Vec = HeapFileManager::open(root, bp.clone()) + .scan() + .map(|r| r.expect("scan the primary's heap").0.page_id) + .collect(); + pages.dedup(); + assert_eq!(pages.len(), 1, "fixture: the primary's rows span {pages:?}, not one page"); + let lsn = Page::deserialize(bp.disk_manager.read(pages[0]).unwrap()).unwrap().lsn; + assert!(lsn > 0, "fixture: the primary's rows page carries no LSN"); + (pages[0], lsn) +} + +/// `backup::take` then `backup::restore` to `/r.db`, with the label the replica starts from. +fn restored_copy(primary: &Path, dir: &Path) -> (PathBuf, backup::BackupLabel) { + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(open_rw(primary)).unwrap()))); + let wal = Arc::new(WalManager::new(wal_of(primary)).unwrap()); + bp.attach_wal(wal.clone()); + let backup_dir = dir.join("backup"); + drop(backup::take(&bp, &wal, &backup_dir).expect("take a base backup")); + let dest = dir.join("r.db"); + let label = backup::restore(&backup_dir, &dest).expect("restore the backup"); + assert!(!wal_of(&dest).exists(), "fixture: restore wrote a WAL"); + (dest, label) +} + +// ---- a database written page by page, with no log at all ---------------------------------------- + +/// A data file built with the `DiskManager` alone: `n` heap pages at LSN 0, allocated through the +/// bitmap. No log has ever existed for it, which is a legitimate database that has logged nothing. +fn unlogged(dir: &Path, n: u32) -> PathBuf { + let db = dir.join("n.db"); + let dm = DiskManager::new(open_rw(&db)).unwrap(); + for _ in 0..n { + let id = dm.allocate().unwrap(); + dm.write(id, &Page::empty(id).serialize().unwrap()).unwrap(); + } + dm.sync().unwrap(); + db +} + +fn write_page(db: &Path, id: u32, bytes: &[u8; PAGE_SIZE]) { + let dm = DiskManager::new(open_rw(db)).unwrap(); + dm.write(id, bytes).unwrap(); + dm.sync().unwrap(); +} + +fn heap_page_at(id: u32, lsn: u64) -> [u8; PAGE_SIZE] { + let mut p = Page::empty(id); + p.lsn = lsn; + p.serialize().unwrap() +} + +fn file_pages(db: &Path) -> u32 { + (std::fs::metadata(db).unwrap().len() / PAGE_SIZE as u64) as u32 +} + +// ---- tests --------------------------------------------------------------------------------------- + +/// T3. A restored base backup: no `.wal`, pages at the primary's LSNs. Refused as a missing log, +/// naming the page the primary's rows live on and its LSN. The refused open creates no log. +#[test] +fn a_restored_backup_is_refused_as_a_missing_log() { + let dir = tempfile::tempdir().unwrap(); + let primary = cli_primary(dir.path()); + let (rows_id, rows_lsn) = rows_page(&primary); + let (db, _) = restored_copy(&primary, dir.path()); + let copied = DiskManager::new(open_rw(&db)).unwrap().read(rows_id).unwrap(); + let original = DiskManager::new(open_rw(&primary)).unwrap().read(rows_id).unwrap(); + assert!(copied == original, "fixture: restore did not copy the rows page verbatim"); + + let (named, cause, page_id, page_lsn) = refusal(counted_open(&db).0); + assert_eq!(cause, LogBehindPagesCause::MissingLog); + assert_eq!((page_id, page_lsn), (rows_id, rows_lsn), "the refusal names the wrong page"); + assert_eq!(named, db.display().to_string()); + assert!(!wal_of(&db).exists(), "the refused open created {}", wal_of(&db).display()); +} + +/// T4. The same copy, after `repl_replica`'s own record of progress. A replica has no log of its +/// own, and the refusal says it is a replica's file rather than a lost log. +#[test] +fn a_replicas_file_is_refused_as_a_replica() { + let dir = tempfile::tempdir().unwrap(); + let primary = cli_primary(dir.path()); + let (db, label) = restored_copy(&primary, dir.path()); + ReplicaState::at(&db).record_applied(label.end_lsn).expect("record the replica's position"); + assert!(ReplicaState::at(&db).path().exists(), "fixture: no .replstate was written"); + + let (_, cause, _, page_lsn) = refusal(counted_open(&db).0); + assert_eq!(cause, LogBehindPagesCause::ReplicaFile); + assert!(page_lsn > 0); + assert!(!wal_of(&db).exists(), "the refused open created {}", wal_of(&db).display()); +} + +/// T5. The same copy with an empty log beside it — one some earlier open created and never wrote +/// to. It exists, so "missing" is not the test; it has never issued an LSN, which is. +#[test] +fn a_log_that_never_issued_an_lsn_is_refused_as_empty() { + let dir = tempfile::tempdir().unwrap(); + let primary = cli_primary(dir.path()); + let (db, _) = restored_copy(&primary, dir.path()); + drop(WalManager::new(wal_of(&db)).unwrap()); + let len = std::fs::metadata(wal_of(&db)).unwrap().len(); + + let (_, cause, _, page_lsn) = refusal(counted_open(&db).0); + assert_eq!(cause, LogBehindPagesCause::EmptyLog); + assert!(page_lsn > 0); + assert_eq!(std::fs::metadata(wal_of(&db)).unwrap().len(), len, "the refused open wrote to the log"); +} + +/// T6. An ordinary database reads no page at open, both after a clean exit and after a crash that +/// left committed records in the log. The positive control is the same instrument on the restored +/// copy, which does read, so a counter that never counts cannot pass this. +#[test] +fn a_normal_reopen_reads_no_page() { + let dir = tempfile::tempdir().unwrap(); + + // Cleanly closed by the CLI. + let clean = cli_primary(dir.path()); + let (opened, reads) = counted_open(&clean); + let wal = opened.expect("an ordinary database was refused"); + assert!(wal.base_lsn.load(Ordering::SeqCst) > 1, "fixture: the clean database's log never issued anything"); + assert_eq!(reads, 0, "opening a cleanly closed database read {reads} times"); + drop(wal); + + // Killed right after a commit, so its log still holds that transaction. + let crashed = dir.path().join("c.db"); + let (status, out) = session(&crashed, "CREATE TABLE t (id INTEGER NOT NULL);\n"); + assert!(status.success() && !out.contains("error:"), "{out}"); + let mut child = Reaped(cli(&crashed).spawn().unwrap()); + let stdout = lines_of(child.0.stdout.take().unwrap()); + let mut seen = Vec::new(); + wait_for(&stdout, "type .exit", "banner", &mut seen).expect("the CLI did not open c.db"); + let mut stdin = child.0.stdin.take().unwrap(); + stdin.write_all(b"INSERT INTO t VALUES (7);\n").unwrap(); + stdin.flush().unwrap(); + wait_for(&stdout, "row affected", "commit acknowledgement", &mut seen).expect("no commit"); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + std::fs::remove_file(lock_of(&crashed)).unwrap(); + let (opened, reads) = counted_open(&crashed); + let wal = opened.expect("a crashed ordinary database was refused"); + assert!( + wal.next_lsn.load(Ordering::SeqCst) > wal.base_lsn.load(Ordering::SeqCst), + "fixture: the crashed database's log holds no records" + ); + assert_eq!(reads, 0, "opening a crashed database read {reads} times"); + drop(wal); + + // Positive control: the instrument does count when the scan runs. + let sub = dir.path().join("control"); + std::fs::create_dir(&sub).unwrap(); + let (db, _) = restored_copy(&clean, &sub); + let (opened, reads) = counted_open(&db); + refusal(opened); + assert!(reads >= file_pages(&db) as u64, "CONTROL: the scan of {} pages counted {reads} reads", file_pages(&db)); +} + +/// T7. A database that never logged anything, holding the two kinds of page the WAL gate's +/// classifier misreads: the bitmap page (enough pages allocated that its bytes 11..19 are set) and +/// a COW arena page (`data[0]` is the top byte of a small epoch). Neither is a page LSN, and +/// the database opens. +#[test] +fn a_never_logged_database_with_bitmap_and_cow_pages_opens() { + let dir = tempfile::tempdir().unwrap(); + let db = unlogged(dir.path(), 70); + let cow_id = file_pages(&db); + let mut cow = [0u8; PAGE_SIZE]; + PageHeader::new(Epoch(3), ArenaId(0x0102_0304), PageType::BTreeLeaf).write_to(&mut cow); + stamp_checksum(&mut cow); + write_page(&db, cow_id, &cow); + + // Premises: without the self-naming rule, both pages would read as heap pages with an LSN. + let bitmap = DiskManager::new(open_rw(&db)).unwrap().read(0).unwrap(); + assert_eq!(bitmap[0], 0, "fixture: the bitmap page has a chained pointer; its type byte is not 0"); + assert_ne!(bitmap[11..19], [0u8; 8], "fixture: 70 pages did not set the bitmap's bytes 11..19"); + assert_eq!(cow[0], 0, "fixture: the COW page's first byte is not 0"); + assert_ne!(cow[11..19], [0u8; 8], "fixture: the COW page's bytes 11..19 are zero"); + + let (opened, _) = counted_open(&db); + opened.expect("a database that never logged anything was refused"); + assert!(wal_of(&db).exists(), "the accepted open did not create the log"); +} + +/// T8. One heap page at exactly the first LSN a log issues, planted as the file's last page, above +/// anything the bitmap ever allocated — where a replica's applier puts a page the primary allocated +/// after the backup. It is found, and named. +#[test] +fn a_page_above_the_bitmap_at_the_first_lsn_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let db = unlogged(dir.path(), 70); + let planted = file_pages(&db); + write_page(&db, planted, &heap_page_at(planted, 1)); + + let dm = DiskManager::new(open_rw(&db)).unwrap(); + assert!(dm.bitmap_high_water().unwrap() <= planted, "fixture: the bitmap covers the planted page"); + assert_eq!(file_pages(&db) - 1, planted, "fixture: the planted page is not the last page"); + drop(dm); + + let (_, cause, page_id, page_lsn) = refusal(counted_open(&db).0); + assert_eq!(cause, LogBehindPagesCause::MissingLog); + assert_eq!((page_id, page_lsn), (planted, 1)); + assert!(!wal_of(&db).exists(), "the refused open created {}", wal_of(&db).display()); +} + +/// T9. Several stamped pages: the refusal names the highest LSN, which is how far a log would have +/// to start to be above every page. It sits between a lower first page and a lower last one, so +/// neither "first found" nor "last found" gets it. +#[test] +fn the_refusal_names_the_page_with_the_highest_lsn() { + let dir = tempfile::tempdir().unwrap(); + let db = unlogged(dir.path(), 14); + for (id, lsn) in [(5u32, 500u64), (9, 900), (12, 700)] { + write_page(&db, id, &heap_page_at(id, lsn)); + } + let (_, _, page_id, page_lsn) = refusal(counted_open(&db).0); + assert_eq!((page_id, page_lsn), (9, 900)); +} + +/// T10. A brand-new database opens and gets a log at the first LSN. Reopened before anything is +/// logged, the log exists and has issued nothing, so the scan runs, finds no stamped page, and the +/// open succeeds. +#[test] +fn a_fresh_database_opens_and_gets_a_log_at_the_first_lsn() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("f.db"); + let dm = DiskManager::new(open_rw(&db)).unwrap(); + let wal = WalManager::open_for_database(&db, &dm).expect("a brand-new database was refused"); + assert_eq!(wal.base_lsn.load(Ordering::SeqCst), 1); + assert_eq!(wal.next_lsn.load(Ordering::SeqCst), 1); + assert!(wal_of(&db).exists()); + drop(wal); + drop(dm); + + let (opened, reads) = counted_open(&db); + opened.expect("a brand-new database was refused on its second open"); + assert!(reads > 0, "the second open of a never-logged database did not scan"); +} + +// ---- child-process plumbing ---------------------------------------------------------------------- + +struct Reaped(Child); + +impl Drop for Reaped { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn lines_of(pipe: impl Read + Send + 'static) -> Receiver { + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in BufReader::new(pipe).lines() { + match line { + Ok(l) => { + if tx.send(l).is_err() { + return; + } + } + Err(_) => return, + } + } + }); + rx +} + +fn wait_for(rx: &Receiver, needle: &str, what: &str, seen: &mut Vec) -> Option { + let until = Instant::now() + DEADLINE; + loop { + let left = until.saturating_duration_since(Instant::now()); + match rx.recv_timeout(left) { + Ok(line) => { + seen.push(line.clone()); + if line.contains(needle) { + return Some(line); + } + } + Err(RecvTimeoutError::Disconnected) => return None, + Err(RecvTimeoutError::Timeout) => { + panic!("no {what} within {DEADLINE:?}; stdout so far:\n{}", seen.join("\n")) + } + } + } +} From 72fa4b854369a5ec15ccd8dbb237c783922ed065 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:50:34 -0400 Subject: [PATCH 280/315] r11-dist: part A harness (E-O3/E-O2 ledger arms: HEAD, ZK, M1, OFL, M2), PREREG 079f108 --- examples/r11_dist_ledger.rs | 1486 +++++++++++++++++++++++++++++++++++ 1 file changed, 1486 insertions(+) create mode 100644 examples/r11_dist_ledger.rs diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs new file mode 100644 index 00000000..2c637430 --- /dev/null +++ b/examples/r11_dist_ledger.rs @@ -0,0 +1,1486 @@ +//! r11-dist part A: E-O3 and E-O2 against the real `BranchLedger`, beside the published-fix arms, in +//! one process with no disk and no network. +//! +//! PREREG: artie-research `frontier/round11/r11-dist/PREREG.md` (registered at 079f108, before this +//! file existed). Every expected value below is computed from the fixture's constants (T, N, K, S), +//! never from the subject. +//! +//! Arms (PREREG §2): HEAD (the real ledger and the real `ClusterAgents::abandon_orphans_of`), ZK +//! (apply-time orphan set, ZooKeeper `closeSession`), M1 (an owner fence at a log position, +//! CockroachDB's liveness epoch), OFL (M1 plus the lazy record: only merges reach the log), and for +//! E-O2 the M2 snapshot arm. M1 and M2 run on `Copy`, this file's copy of HEAD's rules, whose +//! agreement with the real ledger is itself measured (`conform`). +//! +//! Modes: +//! counts P2 P3 P4 A-W A-R A-L, every arm, workload W (integers) +//! p6 log entries per merged result, k = 16, HEAD through the real ClusterAgents +//! p8 safety equivalence on a seeded trace, negative controls, and `conform` +//! o2 E-O2 part A (C0..C4), mutants F and S, anti-vacuity, negative control +//! timed P1, P1-fire, A-S, A-Z (run it under lockrun) + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Instant; + +use ferrodb::agent_sql::cluster::{ + moves_base, BranchEffect, BranchLedger, ClusterAgents, ClusterBranchId, MergeVerdict, + Replicated, +}; +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::consensus::{BranchOp, Command, Entry, NodeId, Round}; +use ferrodb::error::FerroError; + +// --------------------------------------------------------------------------------------------- +// Heap instrument (P3): live bytes held by the global allocator. +// --------------------------------------------------------------------------------------------- + +struct Counting; +static LIVE: AtomicI64 = AtomicI64::new(0); + +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc(l) }; + if !p.is_null() { + LIVE.fetch_add(l.size() as i64, Ordering::Relaxed); + } + p + } + unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { + let p = unsafe { System.alloc_zeroed(l) }; + if !p.is_null() { + LIVE.fetch_add(l.size() as i64, Ordering::Relaxed); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + unsafe { System.dealloc(p, l) }; + LIVE.fetch_sub(l.size() as i64, Ordering::Relaxed); + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + let q = unsafe { System.realloc(p, l, new) }; + if !q.is_null() { + LIVE.fetch_add(new as i64 - l.size() as i64, Ordering::Relaxed); + } + q + } +} + +#[global_allocator] +static GA: Counting = Counting; + +fn heap() -> i64 { + LIVE.load(Ordering::Relaxed) +} + +// --------------------------------------------------------------------------------------------- +// Fixture vocabulary +// --------------------------------------------------------------------------------------------- + +/// `cluster.rs` `LOCAL_BITS` / `LOCAL_MASK` (private there; the packing is `ClusterBranchId::of`). +const LOCAL_BITS: u32 = 40; +const LOCAL_MASK: u64 = (1u64 << LOCAL_BITS) - 1; + +fn cid(node: u32, local: u64) -> u64 { + ((node as u64) << LOCAL_BITS) | local +} +fn owner_of(id: u64) -> u32 { + (id >> LOCAL_BITS) as u32 +} +/// The fence / close-owner sentinel (O2 M1): an `Abandon` of a local id no node will ever mint. +fn sentinel(node: u32) -> u64 { + cid(node, LOCAL_MASK) +} +fn is_sentinel(id: u64) -> bool { + id != 0 && id & LOCAL_MASK == LOCAL_MASK +} +fn fork_c(child: u64) -> Command { + Command::Branch { op: BranchOp::Fork { child, parent: 0, fork_epoch: 1, lease_millis: 900_000 } } +} +fn merge_c(branch: u64, base_round: Round) -> Command { + Command::Branch { op: BranchOp::Merge { branch, base_round } } +} +fn abandon_c(branch: u64) -> Command { + Command::Branch { op: BranchOp::Abandon { branch } } +} +fn wal_c(n: u64) -> Command { + Command::WalBatch { start_lsn: n, bytes: Vec::new() } +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum V { + Applied, + ReEval, + Refused, +} + +fn class(v: &MergeVerdict) -> V { + match v { + MergeVerdict::Applied { .. } => V::Applied, + MergeVerdict::ReEvaluate { .. } => V::ReEval, + MergeVerdict::Refused { .. } => V::Refused, + } +} + +/// splitmix64: the fixture's only randomness, seeded from argv. +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +// --------------------------------------------------------------------------------------------- +// The arms +// --------------------------------------------------------------------------------------------- + +trait Ledger { + /// Apply one committed entry; `Some` for a merge's verdict class. + fn apply(&mut self, e: &Entry) -> Option; + fn is_live(&self, id: u64) -> bool; + /// Branch records held, the trunk counted as one where the arm keeps it. + fn records(&self) -> usize; + fn live_ids_of(&self, node: u32) -> Vec; + /// Records touched by the single largest apply so far (HEAD: not instrumented, see `Head`). + fn touched_max(&self) -> u64; +} + +/// HEAD: the real ledger. +struct Head(Arc>); + +impl Ledger for Head { + fn apply(&mut self, e: &Entry) -> Option { + match lock(&self.0).apply(e) { + BranchEffect::Merged(v) => Some(class(&v)), + _ => None, + } + } + fn is_live(&self, id: u64) -> bool { + lock(&self.0).get(ClusterBranchId(id)).map(|b| b.state.is_live()).unwrap_or(false) + } + fn records(&self) -> usize { + lock(&self.0).all().count() + } + fn live_ids_of(&self, node: u32) -> Vec { + lock(&self.0).live_owned_by(NodeId(node)).into_iter().map(|c| c.0).collect() + } + /// Not instrumented: HEAD's `apply_branch` does one map get/insert per entry (READ at 7dc428f). + /// Printed as 0 and labelled READ in the output, never as a measurement. + fn touched_max(&self) -> u64 { + 0 + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum St { + Live, + Merged(Round), + Abandoned(Round), + Reaped(Round, u32), +} + +/// Same five fields and state width as `ReplicatedBranch`, so M1's heap is comparable with HEAD's. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +struct Rec { + parent: u64, + fork_epoch: u64, + lease_millis: u64, + forked_at: Round, + st: St, +} + +/// HEAD's rules copied (`cluster.rs` `apply` / `apply_branch` / `merge_verdict` at 7dc428f), plus +/// the M1 fence when `m1`, and two planted mutants for the controls. +#[derive(Clone)] +struct HeadCopy { + br: BTreeMap, + last_applied: Round, + lbm: Round, + fence: BTreeMap, + m1: bool, + /// Mutant F (O2): the fence ignores position, so it kills the owner for ever. + mut_f: bool, + /// Negative control for P8: the fence is recorded but never checked. + mut_nofence: bool, + /// Copy mutant for `conform`: `>=` for `>` in the verdict. + mut_ge: bool, + touched: u64, +} + +impl HeadCopy { + fn new(m1: bool) -> HeadCopy { + let mut br = BTreeMap::new(); + br.insert( + 0, + Rec { parent: 0, fork_epoch: 0, lease_millis: u64::MAX, forked_at: 0, st: St::Live }, + ); + HeadCopy { + br, + last_applied: 0, + lbm: 0, + fence: BTreeMap::new(), + m1, + mut_f: false, + mut_nofence: false, + mut_ge: false, + touched: 0, + } + } + + fn rec_live(&self, id: u64, r: &Rec) -> bool { + if r.st != St::Live { + return false; + } + if !self.m1 || id == 0 || self.mut_nofence { + return true; + } + let o = owner_of(id); + if self.mut_f { + return !self.fence.contains_key(&o); + } + r.forked_at > self.fence.get(&o).copied().unwrap_or(0) + } +} + +impl Ledger for HeadCopy { + fn apply(&mut self, e: &Entry) -> Option { + if e.round <= self.last_applied { + return None; + } + self.last_applied = e.round; + let round = e.round; + let op = match &e.command { + Command::Branch { op } => op, + other => { + if moves_base(other) { + self.lbm = round; + } + return None; + } + }; + self.touched = self.touched.max(1); + match op { + BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { + if *child == 0 || self.br.contains_key(child) { + return None; + } + match self.br.get(parent) { + Some(p) if self.rec_live(*parent, p) => {} + _ => return None, + } + self.br.insert( + *child, + Rec { + parent: *parent, + fork_epoch: *fork_epoch, + lease_millis: *lease_millis, + forked_at: round, + st: St::Live, + }, + ); + None + } + BranchOp::Merge { branch, base_round } => { + let v = match self.br.get(branch) { + None => V::Refused, + Some(r) if !self.rec_live(*branch, r) => V::Refused, + Some(_) => { + let moved = if self.mut_ge { self.lbm >= *base_round } else { self.lbm > *base_round }; + if moved { V::ReEval } else { V::Applied } + } + }; + if v == V::Applied { + if let Some(r) = self.br.get_mut(branch) { + r.st = St::Merged(round); + } + self.lbm = round; + } + Some(v) + } + BranchOp::Abandon { branch } => { + if self.m1 && is_sentinel(*branch) { + let f = self.fence.entry(owner_of(*branch)).or_insert(0); + *f = (*f).max(round); + return None; + } + if let Some(r) = self.br.get(branch).copied() { + // HEAD checks `state.is_live()` only (not the fence): kept identical. + if r.st == St::Live { + self.br.get_mut(branch).unwrap().st = St::Abandoned(round); + } + } + None + } + BranchOp::Reap { branch, generation } => { + if let Some(r) = self.br.get_mut(branch) { + if !matches!(r.st, St::Reaped(..)) { + r.st = St::Reaped(round, *generation); + } + } + None + } + } + } + fn is_live(&self, id: u64) -> bool { + self.br.get(&id).map(|r| self.rec_live(id, r)).unwrap_or(false) + } + fn records(&self) -> usize { + self.br.len() + } + fn live_ids_of(&self, node: u32) -> Vec { + self.br + .iter() + .filter(|(id, r)| **id != 0 && owner_of(**id) == node && self.rec_live(**id, r)) + .map(|(id, _)| *id) + .collect() + } + fn touched_max(&self) -> u64 { + self.touched + } +} + +/// ZK: records exist only while live; a close-owner command deletes the owner's set AT APPLY. +struct Zk { + recs: BTreeMap, + by_owner: BTreeMap>, + last_applied: Round, + lbm: Round, + touched: u64, +} + +impl Zk { + fn new() -> Zk { + Zk { recs: BTreeMap::new(), by_owner: BTreeMap::new(), last_applied: 0, lbm: 0, touched: 0 } + } + fn delete(&mut self, id: u64) { + if self.recs.remove(&id).is_some() { + if let Some(s) = self.by_owner.get_mut(&owner_of(id)) { + s.remove(&id); + } + } + } +} + +impl Ledger for Zk { + fn apply(&mut self, e: &Entry) -> Option { + if e.round <= self.last_applied { + return None; + } + self.last_applied = e.round; + let round = e.round; + let op = match &e.command { + Command::Branch { op } => op, + other => { + if moves_base(other) { + self.lbm = round; + } + return None; + } + }; + self.touched = self.touched.max(1); + match op { + BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { + if *child == 0 || self.recs.contains_key(child) { + return None; + } + if *parent != 0 && !self.recs.contains_key(parent) { + return None; + } + self.recs.insert( + *child, + Rec { + parent: *parent, + fork_epoch: *fork_epoch, + lease_millis: *lease_millis, + forked_at: round, + st: St::Live, + }, + ); + self.by_owner.entry(owner_of(*child)).or_default().insert(*child); + None + } + BranchOp::Merge { branch, base_round } => { + let v = if !self.recs.contains_key(branch) { + V::Refused + } else if self.lbm > *base_round { + V::ReEval + } else { + V::Applied + }; + if v == V::Applied { + self.delete(*branch); + self.lbm = round; + } + Some(v) + } + BranchOp::Abandon { branch } => { + if is_sentinel(*branch) { + // `ephemerals.remove(session)`, then delete every member: the whole set, here. + let set = self.by_owner.remove(&owner_of(*branch)).unwrap_or_default(); + self.touched = self.touched.max(set.len() as u64); + for id in set { + self.recs.remove(&id); + } + } else { + self.delete(*branch); + } + None + } + BranchOp::Reap { branch, .. } => { + self.delete(*branch); + None + } + } + } + fn is_live(&self, id: u64) -> bool { + id == 0 || self.recs.contains_key(&id) + } + fn records(&self) -> usize { + self.recs.len() + 1 + } + fn live_ids_of(&self, node: u32) -> Vec { + self.by_owner.get(&node).map(|s| s.iter().copied().collect()).unwrap_or_default() + } + fn touched_max(&self) -> u64 { + self.touched + } +} + +/// OFL's own log vocabulary: only merges, fences and base-moving others reach it. +#[derive(Clone, Debug)] +enum OCmd { + Merge { branch: u64, base_round: Round, inc: u32 }, + Fence { node: u32, dead_inc: u32 }, + Other { moves: bool }, +} + +#[derive(Clone, Default)] +struct Ofl { + cur: BTreeMap, + lbm: Round, + last_applied: Round, + /// Negative control: the incarnation check deleted. + mut_noinc: bool, +} + +impl Ofl { + fn apply(&mut self, round: Round, c: &OCmd) -> Option { + if round <= self.last_applied { + return None; + } + self.last_applied = round; + match c { + OCmd::Merge { branch, base_round, inc } => { + let cur = self.cur.get(&owner_of(*branch)).copied().unwrap_or(0); + let v = if !self.mut_noinc && *inc != cur { + V::Refused + } else if self.lbm > *base_round { + V::ReEval + } else { + V::Applied + }; + if v == V::Applied { + self.lbm = round; + } + Some(v) + } + OCmd::Fence { node, dead_inc } => { + let c = self.cur.entry(*node).or_insert(0); + *c = (*c).max(dead_inc + 1); + None + } + OCmd::Other { moves } => { + if *moves { + self.lbm = round; + } + None + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// The counting seam: the real `ClusterAgents` proposes through it, and nothing is fsynced. +// --------------------------------------------------------------------------------------------- + +struct CountSeam { + leader: NodeId, + term: u64, + next: AtomicU64, + proposals: AtomicU64, + sink: Mutex>, + /// Ledgers `pump` applies the sink to (the coordinator's own, for `merge`'s waits). + apply_to: Vec>>, + applied: Mutex, +} + +impl CountSeam { + fn new(leader: NodeId, term: u64, next_round: Round, apply_to: Vec>>) -> Arc { + Arc::new(CountSeam { + leader, + term, + next: AtomicU64::new(next_round), + proposals: AtomicU64::new(0), + sink: Mutex::new(Vec::new()), + apply_to, + applied: Mutex::new(0), + }) + } + fn take(&self) -> Vec { + std::mem::take(&mut *lock(&self.sink)) + } +} + +impl Replicated for CountSeam { + fn propose(&self, c: Command) -> Result { + let round = self.next.fetch_add(1, Ordering::SeqCst); + lock(&self.sink).push(Entry { term: self.term, round, command: c }); + self.proposals.fetch_add(1, Ordering::SeqCst); + Ok(round) + } + fn committed_head(&self) -> Round { + self.next.load(Ordering::SeqCst) - 1 + } + fn pump(&self) -> Result<(), FerroError> { + let sink = lock(&self.sink); + let mut applied = lock(&self.applied); + for e in &sink[*applied..] { + for l in &self.apply_to { + lock(l).apply(e); + } + } + *applied = sink.len(); + Ok(()) + } + fn leader(&self) -> Option { + Some(self.leader) + } +} + +/// HEAD's disposal, through the real code: `abandon_orphans_of` on a coordinator bound to +/// `sweeper`, whose ledger is `ledger`. Returns the proposed entries (not yet applied anywhere). +fn head_sweep(ledger: &Arc>, sweeper: u32, dead: u32, term: u64, next_round: Round) -> Vec { + let seam = CountSeam::new(NodeId(sweeper), term, next_round, Vec::new()); + let agents = ClusterAgents::new(NodeId(sweeper), Arc::new(AgentRuntime::new()), seam.clone(), ledger.clone()); + let out = agents.abandon_orphans_of(NodeId(dead)).expect("abandon_orphans_of refused"); + let entries = seam.take(); + assert_eq!(out.len(), entries.len(), "the sweep's report and its proposals disagree"); + assert_eq!(agents.cost().proposals, entries.len() as u64); + entries +} + +fn check(id: &str, arm: &str, pred: &str, ok: bool, got: String) { + println!("CHECK {id} arm={arm} pred={pred} got={got} {}", if ok { "PASS" } else { "FAIL" }); +} + +// --------------------------------------------------------------------------------------------- +// Workload W (E-O3): n1 forks T; the first T-N are disposed (i % 16 == 0 merges, else abandons); +// the last N stay live. Streamed, so nothing but the ledger holds T. +// --------------------------------------------------------------------------------------------- + +fn run_w(t: u64, n: u64, mut sink: impl FnMut(Entry)) -> Round { + let mut round = 0; + for i in 1..=t { + round += 1; + sink(Entry { term: 1, round, command: fork_c(cid(1, i)) }); + } + for i in 1..=(t - n) { + let head = round; + round += 1; + let c = if (i - 1) % 16 == 0 { merge_c(cid(1, i), head) } else { abandon_c(cid(1, i)) }; + sink(Entry { term: 1, round, command: c }); + } + round +} + +fn merges_in_w(t: u64, n: u64) -> u64 { + (t - n).div_ceil(16) +} + +fn mode_counts(t: u64, n: u64) { + assert!(n <= t && n >= 1); + println!("# counts T={t} N={n}"); + // ---------------- HEAD + { + let h0 = heap(); + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let mut head = Head(ledger.clone()); + let (mut forks, mut verdicts, mut applied_v) = (0u64, 0u64, 0u64); + let last = run_w(t, n, |e| { + if matches!(e.command, Command::Branch { op: BranchOp::Fork { .. } }) { + forks += 1; + } + if let Some(v) = head.apply(&e) { + verdicts += 1; + if v == V::Applied { + applied_v += 1; + } + } + }); + let h1 = heap(); + let recs = head.records(); + let t0 = Instant::now(); + let props = head_sweep(&ledger, 2, 1, 2, last + 1); + let sweep_ms = t0.elapsed().as_secs_f64() * 1e3; + let p4 = props.len() as u64; + for e in &props { + head.apply(e); + } + let live_after = head.live_ids_of(1).len(); + let recs_after = head.records(); + let bpr = (h1 - h0) as f64 / recs as f64; + println!( + "ARM=HEAD T={t} N={n} forks_applied={forks} verdicts={verdicts} applied={applied_v} P2_records={recs} P3_heap_bytes={} P3_bytes_per_record={bpr:.1} P4_proposals={p4} A-W=READ:1 A-R_records_after={recs_after} A-L_n1_live_after={live_after} sweep_ms_inmem={sweep_ms:.1}", + h1 - h0 + ); + check("anti-vacuity", "HEAD", &format!("forks={t},verdicts={}", merges_in_w(t, n)), forks == t && verdicts == merges_in_w(t, n) && applied_v == verdicts, format!("{forks},{verdicts},applied={applied_v}")); + check("P2", "HEAD", &format!("{}", t + 1), recs as u64 == t + 1, recs.to_string()); + check("P3", "HEAD", "90..150", (90.0..=150.0).contains(&bpr), format!("{bpr:.1}")); + check("P4", "HEAD", &n.to_string(), p4 == n, p4.to_string()); + check("A-R", "HEAD", &format!("{}", t + 1), recs_after as u64 == t + 1, recs_after.to_string()); + check("A-L", "HEAD", "0", live_after == 0, live_after.to_string()); + } + // ---------------- ZK + { + let h0 = heap(); + let mut zk = Zk::new(); + let last = run_w(t, n, |e| { + zk.apply(&e); + }); + let h1 = heap(); + let recs = zk.records(); + let before_touched = zk.touched_max(); + zk.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + let aw = zk.touched_max(); + let live_after = zk.live_ids_of(1).len(); + let bpr = (h1 - h0) as f64 / (n + 1) as f64; + println!( + "ARM=ZK T={t} N={n} P2_records={recs} P3_heap_bytes={} P3_bytes_per_live_record={bpr:.1} P4_proposals=1 A-W_touched_max={aw} (before_disposal={before_touched}) A-R_records_after={} A-L_n1_live_after={live_after}", + h1 - h0, + zk.records() + ); + check("P2", "ZK", &format!("{}", n + 1), recs as u64 == n + 1, recs.to_string()); + check("P3", "ZK", "110..200 per live", (110.0..=200.0).contains(&bpr), format!("{bpr:.1}")); + check("A-W", "ZK", &n.to_string(), aw == n, aw.to_string()); + check("A-R", "ZK", "1", zk.records() == 1, zk.records().to_string()); + check("A-L", "ZK", "0", live_after == 0, live_after.to_string()); + } + // ---------------- M1 + { + let h0 = heap(); + let mut m1 = HeadCopy::new(true); + let last = run_w(t, n, |e| { + m1.apply(&e); + }); + let h1 = heap(); + let recs = m1.records(); + m1.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + let live_after = m1.live_ids_of(1).len(); + let bpr = (h1 - h0) as f64 / recs as f64; + println!( + "ARM=M1 T={t} N={n} P2_records={recs} P3_heap_bytes={} P3_bytes_per_record={bpr:.1} P4_proposals=1 A-W_touched_max={} A-R_records_after={} A-L_n1_live_after={live_after}", + h1 - h0, + m1.touched_max(), + m1.records() + ); + check("P2", "M1", &format!("{}", t + 1), recs as u64 == t + 1, recs.to_string()); + check("P3", "M1", "90..150", (90.0..=150.0).contains(&bpr), format!("{bpr:.1}")); + check("A-W", "M1", "1", m1.touched_max() == 1, m1.touched_max().to_string()); + check("A-R", "M1", &format!("{}", t + 1), m1.records() as u64 == t + 1, m1.records().to_string()); + check("A-L", "M1", "0", live_after == 0, live_after.to_string()); + } + // ---------------- OFL: only merges reach the log + { + let h0 = heap(); + let mut o = Ofl::default(); + let mut round = 0; + let mut entries = 0u64; + for i in 1..=(t - n) { + if (i - 1) % 16 == 0 { + let head = round; + round += 1; + entries += 1; + o.apply(round, &OCmd::Merge { branch: cid(1, i), base_round: head, inc: 0 }); + } + } + round += 1; + o.apply(round, &OCmd::Fence { node: 1, dead_inc: 0 }); + let h1 = heap(); + let bytes = h1 - h0; + println!("ARM=OFL T={t} N={n} P2_branch_records=0 state_nodes={} P3_heap_bytes_total={bytes} P4_proposals=1 log_entries_lifecycle={entries}", o.cur.len()); + check("P3", "OFL", "<65536 total", bytes < 65536, bytes.to_string()); + check("OFL-entries", "OFL", &merges_in_w(t, n).to_string(), entries == merges_in_w(t, n), entries.to_string()); + } +} + +// --------------------------------------------------------------------------------------------- +// P6: entries per merged result, k = 16, HEAD through the real ClusterAgents +// --------------------------------------------------------------------------------------------- + +fn mode_p6() { + use ferrodb::agent_sql::runtime::{ExecCtx, RunIdentity}; + use ferrodb::branch::types::BranchId; + use ferrodb::buffer::buffer_pool::BufferPoolManager; + use ferrodb::catalog::catalog::Catalog; + use ferrodb::storage::disk_manager::DiskManager; + use ferrodb::wal::log::WalManager; + use ferrodb::wal::txn::TxnManager; + + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(dir.path().join("p6.db")) + .unwrap(); + let bp = Arc::new(BufferPoolManager::new(Arc::new(DiskManager::new(file).unwrap()))); + let mut catalog = Catalog::create(bp.clone()).unwrap(); + let wal = Arc::new(WalManager::new(dir.path().join("p6.wal")).unwrap()); + let txn = Arc::new(TxnManager::new(wal.clone(), bp.clone())); + bp.attach_wal(wal); + let runtime = Arc::new(AgentRuntime::new()); + + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let seam = CountSeam::new(NodeId(1), 1, 1, vec![ledger.clone()]); + let agents = ClusterAgents::new(NodeId(1), runtime.clone(), seam.clone(), ledger.clone()); + let mut sessions = Vec::new(); + for _ in 0..16 { + sessions.push( + agents + .fork(RunIdentity { agent_id: "p6", run_id: Some("r"), ..RunIdentity::default() }, BranchId::TRUNK) + .unwrap(), + ); + } + seam.pump().unwrap(); + for s in &sessions[1..] { + agents.abandon(s).unwrap(); + } + let mut ctx = ExecCtx { catalog: &mut catalog, bp: bp.clone(), txn: txn.clone() }; + let r = agents.merge(&mut ctx, sessions[0].branch()); + let merged = match &r { + Ok(rep) => rep.merge_round.is_some(), + Err(e) => { + println!("P6 merge error: {e}"); + false + } + }; + seam.pump().unwrap(); + let log = lock(&seam.sink).clone(); + let mut kinds: BTreeMap<&str, u64> = BTreeMap::new(); + for e in &log { + let k = match &e.command { + Command::Branch { op: BranchOp::Fork { .. } } => "Fork", + Command::Branch { op: BranchOp::Merge { .. } } => "Merge", + Command::Branch { op: BranchOp::Abandon { .. } } => "Abandon", + Command::Branch { op: BranchOp::Reap { .. } } => "Reap", + Command::LeaseTick { .. } => "LeaseTick", + _ => "Other", + }; + *kinds.entry(k).or_default() += 1; + } + println!("ARM=HEAD P6 merged={merged} entries={} by_kind={kinds:?} proposals_counter={}", log.len(), agents.cost().proposals); + check("P6", "HEAD", "32 (16F+1M+15A, 0 Reap, 0 LeaseTick)", merged && log.len() == 32 && kinds.get("Fork") == Some(&16) && kinds.get("Merge") == Some(&1) && kinds.get("Abandon") == Some(&15) && !kinds.contains_key("Reap") && !kinds.contains_key("LeaseTick"), log.len().to_string()); + println!("ARM=ZK P6 entries=32 (BY ARM DEFINITION: ZooKeeper logs every create and delete)"); + println!("ARM=M1 P6 entries=32 (BY ARM DEFINITION: M1 changes only the disposal)"); + println!("ARM=OFL P6 entries=1 (BY ARM DEFINITION: only the merge reaches the log)"); +} + +// --------------------------------------------------------------------------------------------- +// P8: seeded trace, safety equivalence, negative controls; plus `conform` on the same log +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +enum Ev { + Fork { id: u64, inc: u32 }, + Merge { id: u64, base_pos: usize, inc: u32, planted: bool, dup: bool }, + Abandon { id: u64 }, + Write, + Fail { owner: u32, dead_inc: u32 }, +} + +fn gen_trace(seed: u64, len: usize) -> Vec { + let mut rng = Rng(seed); + let mut evs: Vec = Vec::with_capacity(len + 64); + let mut next_local = [0u64; 4]; + let mut inc = [0u32; 4]; + // generator's view: live (id, fork_pos, inc) per owner + let mut live: Vec> = vec![Vec::new(); 4]; + let fail_at = [len * 3 / 10, len * 6 / 10]; + let fail_owner = [1u32, 2u32]; + while evs.len() < len { + let pos = evs.len(); + if let Some(k) = fail_at.iter().position(|&p| p == pos) { + let o = fail_owner[k]; + let fenced: Vec<(u64, usize, u32)> = std::mem::take(&mut live[o as usize]); + evs.push(Ev::Fail { owner: o, dead_inc: inc[o as usize] }); + // planted: a zombie merge from the fenced incarnation, with a FRESH base + if let Some(&(id, _, i)) = fenced.first() { + let p = evs.len(); + evs.push(Ev::Merge { id, base_pos: p - 1, inc: i, planted: true, dup: false }); + } + inc[o as usize] += 1; + continue; + } + let r = rng.below(100); + let o = 1 + rng.below(3) as u32; + if r < 40 || live[o as usize].is_empty() { + next_local[o as usize] += 1; + let id = cid(o, next_local[o as usize]); + evs.push(Ev::Fork { id, inc: inc[o as usize] }); + live[o as usize].push((id, pos, inc[o as usize])); + } else if r < 60 { + let k = rng.below(live[o as usize].len() as u64) as usize; + let (id, fpos, i) = live[o as usize].swap_remove(k); + // The gate read a committed head at or after the fork (HEAD's merge pumps until the + // fork is applied before reading it), up to 50 events stale. + let lo = fpos.max(pos.saturating_sub(50)); + let hi = pos - 1; + let base_pos = lo + rng.below((hi - lo + 1) as u64) as usize; + evs.push(Ev::Merge { id, base_pos, inc: i, planted: false, dup: false }); + if rng.below(100) == 0 { + evs.push(Ev::Merge { id, base_pos, inc: i, planted: false, dup: true }); + } + } else if r < 80 { + let k = rng.below(live[o as usize].len() as u64) as usize; + let (id, _, _) = live[o as usize].swap_remove(k); + evs.push(Ev::Abandon { id }); + } else { + evs.push(Ev::Write); + } + } + evs +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum P8Arm { + Head, + Zk, + M1, + M1NoFence, +} + +/// Run the trace through one log-carrying arm. Returns (verdict per event index, the log). +fn p8_run(evs: &[Ev], arm: P8Arm) -> (BTreeMap, Vec) { + let head_ledger = Arc::new(Mutex::new(BranchLedger::new())); + let mut l: Box = match arm { + P8Arm::Head => Box::new(Head(head_ledger.clone())), + P8Arm::Zk => Box::new(Zk::new()), + P8Arm::M1 => Box::new(HeadCopy::new(true)), + P8Arm::M1NoFence => { + let mut c = HeadCopy::new(true); + c.mut_nofence = true; + Box::new(c) + } + }; + let mut log: Vec = Vec::new(); + let mut pos_round: Vec = Vec::with_capacity(evs.len()); + let mut out = BTreeMap::new(); + let mut wal = 0u64; + for (p, ev) in evs.iter().enumerate() { + let push = |c: Command, log: &mut Vec, l: &mut Box| -> Option { + let e = Entry { term: 1, round: log.len() as Round + 1, command: c }; + let v = l.apply(&e); + log.push(e); + v + }; + match ev { + Ev::Fork { id, .. } => { + push(fork_c(*id), &mut log, &mut l); + } + Ev::Merge { id, base_pos, .. } => { + let base = pos_round[*base_pos]; + if let Some(v) = push(merge_c(*id, base), &mut log, &mut l) { + out.insert(p, v); + } + } + Ev::Abandon { id } => { + push(abandon_c(*id), &mut log, &mut l); + } + Ev::Write => { + wal += 1; + push(wal_c(wal), &mut log, &mut l); + } + Ev::Fail { owner, .. } => match arm { + P8Arm::Head => { + let sweeper = owner % 3 + 1; + let props = head_sweep(&head_ledger, sweeper, *owner, 1, log.len() as Round + 1); + for e in props { + l.apply(&e); + log.push(e); + } + } + _ => { + push(abandon_c(sentinel(*owner)), &mut log, &mut l); + } + }, + } + pos_round.push(log.len() as Round); + } + (out, log) +} + +fn p8_ofl(evs: &[Ev], mut_noinc: bool) -> BTreeMap { + let mut o = Ofl { mut_noinc, ..Ofl::default() }; + let mut round = 0; + let mut pos_round: Vec = Vec::with_capacity(evs.len()); + let mut out = BTreeMap::new(); + for (p, ev) in evs.iter().enumerate() { + match ev { + Ev::Merge { id, base_pos, inc, .. } => { + round += 1; + if let Some(v) = o.apply(round, &OCmd::Merge { branch: *id, base_round: pos_round[*base_pos], inc: *inc }) { + out.insert(p, v); + } + } + Ev::Write => { + round += 1; + o.apply(round, &OCmd::Other { moves: true }); + } + Ev::Fail { owner, dead_inc } => { + round += 1; + o.apply(round, &OCmd::Fence { node: *owner, dead_inc: *dead_inc }); + } + Ev::Fork { .. } | Ev::Abandon { .. } => {} + } + pos_round.push(round); + } + out +} + +fn mode_p8(seed: u64) { + let evs = gen_trace(seed, 100_000); + let merges: Vec = evs.iter().enumerate().filter(|(_, e)| matches!(e, Ev::Merge { .. })).map(|(i, _)| i).collect(); + let planted: BTreeSet = evs.iter().enumerate().filter(|(_, e)| matches!(e, Ev::Merge { planted: true, .. })).map(|(i, _)| i).collect(); + let dups: BTreeSet = evs.iter().enumerate().filter(|(_, e)| matches!(e, Ev::Merge { dup: true, .. })).map(|(i, _)| i).collect(); + let fails = evs.iter().filter(|e| matches!(e, Ev::Fail { .. })).count(); + println!("# p8 seed={seed} events={} merges={} planted={} dups={} failovers={fails}", evs.len(), merges.len(), planted.len(), dups.len()); + + let (vh, head_log) = p8_run(&evs, P8Arm::Head); + let (vz, _) = p8_run(&evs, P8Arm::Zk); + let (vm, _) = p8_run(&evs, P8Arm::M1); + let (vmn, _) = p8_run(&evs, P8Arm::M1NoFence); + let vo = p8_ofl(&evs, false); + let von = p8_ofl(&evs, true); + + let applied_count = vh.values().filter(|v| **v == V::Applied).count(); + let reeval_count = vh.values().filter(|v| **v == V::ReEval).count(); + println!("HEAD verdicts={} applied={applied_count} reeval={reeval_count} log_entries={}", vh.len(), head_log.len()); + check("anti-vacuity", "HEAD", "every merge has a verdict; >=1 Applied; >=1 ReEval", vh.len() == merges.len() && applied_count > 0 && reeval_count > 0, format!("{}/{}", vh.len(), merges.len())); + + let class_div = |other: &BTreeMap| merges.iter().filter(|p| !planted.contains(p) && vh.get(p) != other.get(p)).count(); + let appl_div = |other: &BTreeMap| { + merges + .iter() + .filter(|p| !planted.contains(p) && (vh.get(p) == Some(&V::Applied)) != (other.get(p) == Some(&V::Applied))) + .count() + }; + let planted_applied = |vv: &BTreeMap| planted.iter().filter(|p| vv.get(p) == Some(&V::Applied)).count(); + let dup_applied = |vv: &BTreeMap| dups.iter().filter(|p| vv.get(p) == Some(&V::Applied)).count(); + + for (name, vv) in [("HEAD", &vh), ("ZK", &vz), ("M1", &vm), ("OFL", &vo)] { + println!("ARM={name} planted_applied={} dup_applied={}", planted_applied(vv), dup_applied(vv)); + check("P8-planted", name, "0", planted_applied(vv) == 0, planted_applied(vv).to_string()); + check("P8-dup", name, "0", dup_applied(vv) == 0, dup_applied(vv).to_string()); + } + check("P8-class", "ZK", "0", class_div(&vz) == 0, class_div(&vz).to_string()); + check("P8-class", "M1", "0", class_div(&vm) == 0, class_div(&vm).to_string()); + check("P8-applied", "OFL", "0", appl_div(&vo) == 0, appl_div(&vo).to_string()); + let nc_m1 = planted_applied(&vmn) + class_div(&vmn); + let nc_ofl = planted_applied(&von) + appl_div(&von); + check("P8-negctl", "M1-nofence", ">=1", nc_m1 >= 1, nc_m1.to_string()); + check("P8-negctl", "OFL-noinc", ">=1", nc_ofl >= 1, nc_ofl.to_string()); + + // conform: the identical HEAD log through the copy, and through the copy mutant + let conform = |mut_ge: bool| -> usize { + let real = Arc::new(Mutex::new(BranchLedger::new())); + let mut r = Head(real); + let mut c = HeadCopy::new(false); + c.mut_ge = mut_ge; + let mut div = 0; + for e in &head_log { + if r.apply(e) != c.apply(e) { + div += 1; + } + } + let ids: BTreeSet = evs + .iter() + .filter_map(|e| match e { + Ev::Fork { id, .. } => Some(*id), + _ => None, + }) + .collect(); + div + ids.iter().filter(|id| r.is_live(**id) != c.is_live(**id)).count() + }; + let cd = conform(false); + let cdm = conform(true); + check("conform", "Copy", "0", cd == 0, cd.to_string()); + check("conform-negctl", "Copy->=", ">=1", cdm >= 1, cdm.to_string()); +} + +// --------------------------------------------------------------------------------------------- +// E-O2 part A +// --------------------------------------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum O2Kind { + Head, + Copy { m1: bool, m2: bool }, +} + +#[derive(Clone, Copy, Debug)] +struct O2Arm { + name: &'static str, + kind: O2Kind, + barrier: bool, + sweeper: u32, + restart_n3: bool, + failover: bool, + mut_f: bool, + mut_s: bool, +} + +struct O2Fixture { + log: Vec, + f: Round, + r_c: Round, + t1_ids: Vec, + tail_ids: Vec, + merged_ids: Vec, +} + +const O2_K: u64 = 64; +const O2_S: u64 = 1000; + +fn o2_term1(n: u64) -> O2Fixture { + let mut log: Vec = Vec::new(); + let push = |log: &mut Vec, term: u64, c: Command| -> Round { + let round = log.len() as Round + 1; + log.push(Entry { term, round, command: c }); + round + }; + let mut t1_ids = Vec::new(); + let mut merged_ids = Vec::new(); + let mut local = 0u64; + let mut next = || { + local += 1; + cid(1, local) + }; + push(&mut log, 1, Command::NoOp); + for _ in 0..n / 2 { + let id = next(); + t1_ids.push(id); + push(&mut log, 1, fork_c(id)); + } + push(&mut log, 1, wal_c(1)); + let pm = next(); + t1_ids.push(pm); + let f = push(&mut log, 1, fork_c(pm)); + push(&mut log, 1, merge_c(pm, f - 2)); + for _ in 0..n / 2 { + let id = next(); + t1_ids.push(id); + push(&mut log, 1, fork_c(id)); + } + for _ in 0..n / 10 { + let id = next(); + merged_ids.push(id); + let fr = push(&mut log, 1, fork_c(id)); + push(&mut log, 1, merge_c(id, fr)); + } + let r_c = log.len() as Round; + let mut tail_ids = Vec::new(); + for _ in 0..O2_K { + let id = next(); + tail_ids.push(id); + push(&mut log, 1, fork_c(id)); + } + O2Fixture { log, f, r_c, t1_ids, tail_ids, merged_ids } +} + +/// One node's ledger in an O2 arm: HEAD's real ledger, or the copy. +enum O2L { + Head(Arc>), + Copy(HeadCopy), +} + +impl O2L { + fn fresh(kind: O2Kind, mut_f: bool) -> O2L { + match kind { + O2Kind::Head => O2L::Head(Arc::new(Mutex::new(BranchLedger::new()))), + O2Kind::Copy { m1, .. } => { + let mut c = HeadCopy::new(m1); + c.mut_f = mut_f; + O2L::Copy(c) + } + } + } + fn apply(&mut self, e: &Entry) -> Option { + match self { + O2L::Head(l) => Head(l.clone()).apply(e), + O2L::Copy(c) => c.apply(e), + } + } + fn is_live(&self, id: u64) -> bool { + match self { + O2L::Head(l) => Head(l.clone()).is_live(id), + O2L::Copy(c) => c.is_live(id), + } + } + fn has(&self, id: u64) -> bool { + match self { + O2L::Head(l) => lock(l).get(ClusterBranchId(id)).is_some(), + O2L::Copy(c) => c.br.contains_key(&id), + } + } +} + +struct O2Out { + c0: u64, + c1: [u64; 3], + c2: u64, + c3: u64, + c4: u64, + anti_vacuity: bool, +} + +fn o2_run(n: u64, arm: O2Arm) -> O2Out { + let fx = o2_term1(n); + let mut log = fx.log.clone(); + let mut_s = arm.mut_s; + + // The snapshot at floor F (M2): the copy's whole state, applied through F. + let snapshot = |fx: &O2Fixture| -> HeadCopy { + let (m1, _) = match arm.kind { + O2Kind::Copy { m1, m2 } => (m1, m2), + O2Kind::Head => (false, false), + }; + let mut c = HeadCopy::new(m1); + c.mut_f = arm.mut_f; + for e in &fx.log[..fx.f as usize] { + c.apply(e); + } + if mut_s { + c.lbm = 0; + } + c + }; + let m2 = matches!(arm.kind, O2Kind::Copy { m2: true, .. }); + let restarted = |fx: &O2Fixture| -> O2L { + if m2 { + O2L::Copy(snapshot(fx)) + } else { + let mut l = O2L::fresh(arm.kind, arm.mut_f); + // HEAD's restart: a new ledger, and `applied` seeded from the floor (node.rs :370-373). + if let O2L::Copy(c) = &mut l { + c.last_applied = fx.f; + } + if let O2L::Head(h) = &l { + // A real BranchLedger cannot be seeded; it sees only rounds above F because only + // those are handed to it below. + let _ = h; + } + l + } + }; + + let mut anti_vacuity = true; + let mut c0 = 0u64; + if arm.failover { + // Term 2: NoOp(2) after the inherited tail. + let noop2 = log.len() as Round + 1; + log.push(Entry { term: 2, round: noop2, command: Command::NoOp }); + // The sweeper's view at the sweep. + let sweeper_restarted = arm.sweeper == 3 && arm.restart_n3; + let mut sw = if sweeper_restarted { restarted(&fx) } else { O2L::fresh(arm.kind, arm.mut_f) }; + let upto = if arm.barrier { noop2 } else { fx.r_c }; + let from = if sweeper_restarted { fx.f + 1 } else { 1 }; + for e in &log[(from - 1) as usize..upto as usize] { + sw.apply(e); + } + if !arm.barrier { + anti_vacuity = fx.tail_ids.iter().all(|id| { + let r = log.iter().position(|e| matches!(&e.command, Command::Branch { op: BranchOp::Fork { child, .. } } if child == id)); + r.is_some() && !sw.has(*id) + }); + } + let next_round = log.len() as Round + 1; + let sweep: Vec = match (&sw, arm.kind) { + (O2L::Head(h), _) => head_sweep(h, arm.sweeper, 1, 2, next_round), + (O2L::Copy(_), O2Kind::Copy { m1: true, .. }) => { + vec![Entry { term: 2, round: next_round, command: abandon_c(sentinel(1)) }] + } + (O2L::Copy(c), _) => c + .live_ids_of(1) + .into_iter() + .enumerate() + .map(|(i, id)| Entry { term: 2, round: next_round + i as Round, command: abandon_c(id) }) + .collect(), + }; + c0 = sweep.len() as u64; + log.extend(sweep); + for i in 1..=O2_S { + let r = log.len() as Round + 1; + log.push(Entry { term: 2, round: r, command: fork_c(cid(2, i)) }); + } + let r = log.len() as Round + 1; + log.push(Entry { term: 3, round: r, command: Command::NoOp }); + let r = log.len() as Round + 1; + log.push(Entry { term: 3, round: r, command: fork_c(cid(1, 1_000_000_000)) }); + } + + // Final ledgers: n1, n2 fresh over the whole log; n3 restarted or fresh. + let mut nodes: Vec = Vec::new(); + let mut verdicts: Vec> = Vec::new(); + for k in 1..=3u32 { + let restart = k == 3 && arm.restart_n3; + let mut l = if restart { restarted(&fx) } else { O2L::fresh(arm.kind, arm.mut_f) }; + let from = if restart { fx.f + 1 } else { 1 }; + let mut vm = BTreeMap::new(); + for e in &log[(from - 1) as usize..] { + if let Some(v) = l.apply(e) { + vm.insert(e.round, v); + } + } + nodes.push(l); + verdicts.push(vm); + } + + let c1 = [0, 1, 2].map(|k| { + fx.t1_ids.iter().chain(fx.tail_ids.iter()).filter(|id| nodes[k].is_live(**id)).count() as u64 + }); + let c2 = if arm.failover { + (0..3) + .map(|k| { + (1..=O2_S).filter(|i| !nodes[k].is_live(cid(2, *i))).count() as u64 + + u64::from(!nodes[k].is_live(cid(1, 1_000_000_000))) + }) + .max() + .unwrap() + } else { + 0 + }; + let rounds: BTreeSet = verdicts.iter().flat_map(|m| m.keys().copied()).collect(); + let c3 = rounds + .iter() + .filter(|r| { + let a = verdicts[0].get(r); + a != verdicts[1].get(r) || a != verdicts[2].get(r) + }) + .count() as u64; + let mut all_ids: Vec = fx.t1_ids.iter().chain(fx.tail_ids.iter()).chain(fx.merged_ids.iter()).copied().collect(); + if arm.failover { + all_ids.extend((1..=O2_S).map(|i| cid(2, i))); + all_ids.push(cid(1, 1_000_000_000)); + } + let c4 = all_ids + .iter() + .filter(|id| { + let a = nodes[0].is_live(**id); + a != nodes[1].is_live(**id) || a != nodes[2].is_live(**id) + }) + .count() as u64; + O2Out { c0, c1, c2, c3, c4, anti_vacuity } +} + +fn mode_o2(n: u64) { + assert!(n % 10 == 0 && n >= 1000); + let head = O2Kind::Head; + let m1 = O2Kind::Copy { m1: true, m2: false }; + let m2 = O2Kind::Copy { m1: false, m2: true }; + let m12 = O2Kind::Copy { m1: true, m2: true }; + let a = |name, kind, barrier, sweeper, restart_n3| O2Arm { name, kind, barrier, sweeper, restart_n3, failover: true, mut_f: false, mut_s: false }; + let h = n / 2; + // (arm, predicted C0, C1 [n1,n2,n3], C2, C3, C4) -- O2 §6.3, from the constants N, K = 64 + let rows: Vec<(O2Arm, u64, [u64; 3], u64, u64, u64)> = vec![ + (a("HEAD n2/none", head, false, 2, false), n + 1, [64, 64, 64], 0, 0, 0), + (a("HEAD n2/n3-restarted", head, false, 2, true), n + 1, [64, 64, 64], 0, 1, 0), + (a("HEAD n3-restarted-sweeps", head, false, 3, true), h, [h + 65, h + 65, 64], 0, 1, h + 1), + (a("HEAD+barrier n2/none", head, true, 2, false), n + 65, [0, 0, 0], 0, 0, 0), + (a("HEAD+barrier n3-sweeps", head, true, 3, true), h + 64, [h + 1, h + 1, 0], 0, 1, h + 1), + (a("M1 n3-sweeps", m1, false, 3, true), 1, [0, 0, 0], 0, 1, 0), + (a("M2 n3-sweeps", m2, false, 3, true), n + 1, [64, 64, 64], 0, 0, 0), + (a("M1+M2 n2-sweeps", m12, false, 2, true), 1, [0, 0, 0], 0, 0, 0), + (a("M1+M2 n3-sweeps", m12, false, 3, true), 1, [0, 0, 0], 0, 0, 0), + ]; + for (arm, c0, c1, c2, c3, c4) in rows { + let t0 = Instant::now(); + let o = o2_run(n, arm); + let ok = o.c0 == c0 && o.c1 == c1 && o.c2 == c2 && o.c3 == c3 && o.c4 == c4; + println!( + "O2 N={n} arm=\"{}\" C0={} C1={:?} C2={} C3={} C4={} anti_vacuity={} ms={:.0}", + arm.name, o.c0, o.c1, o.c2, o.c3, o.c4, o.anti_vacuity, t0.elapsed().as_secs_f64() * 1e3 + ); + check("O2-row", arm.name, &format!("C0={c0} C1={c1:?} C2={c2} C3={c3} C4={c4}"), ok, format!("C0={} C1={:?} C2={} C3={} C4={}", o.c0, o.c1, o.c2, o.c3, o.c4)); + if !arm.barrier { + check("O2-anti-vacuity", arm.name, "tail in log, absent from sweeper ledger", o.anti_vacuity, o.anti_vacuity.to_string()); + } + } + let mut f = a("mutant-F (M1+M2, fence ignores position)", m12, false, 3, true); + f.mut_f = true; + let o = o2_run(n, f); + check("O2-mutF", f.name, "C2=1", o.c2 == 1, o.c2.to_string()); + let mut s = a("mutant-S (M1+M2, snapshot without last_base_move)", m12, false, 3, true); + s.mut_s = true; + let o = o2_run(n, s); + check("O2-mutS", s.name, "C3=1", o.c3 == 1, o.c3.to_string()); + let mut nc = a("negative control (HEAD, no failover, no restart)", head, false, 2, false); + nc.failover = false; + let o = o2_run(n, nc); + check("O2-negctl", nc.name, "C0=0 C2=0 C3=0 C4=0", o.c0 == 0 && o.c2 == 0 && o.c3 == 0 && o.c4 == 0, format!("C0={} C2={} C3={} C4={}", o.c0, o.c2, o.c3, o.c4)); +} + +// --------------------------------------------------------------------------------------------- +// Timed: P1, P1-fire, A-S, A-Z (under lockrun) +// --------------------------------------------------------------------------------------------- + +const B: u64 = 256; +const REPS: usize = 5; + +/// Fill for P1: T forks, the first 9T/10 disposed as in W, then a pool of 2B live forks. +fn p1_fill(l: &mut dyn Ledger, t: u64) -> (Round, u64) { + let mut round = run_w(t, t - t * 9 / 10, |e| { + l.apply(&e); + }); + let pool_start = t + 1; + for i in 0..2 * B { + round += 1; + l.apply(&Entry { term: 1, round, command: fork_c(cid(1, pool_start + i)) }); + } + (round, pool_start) +} + +fn time_batch(l: &mut dyn Ledger, round: &mut Round, cmds: impl Iterator, fire: Option<&Arc>>) -> f64 { + let batch: Vec = cmds.collect(); + let n = batch.len(); + let t0 = Instant::now(); + for c in batch { + *round += 1; + l.apply(&Entry { term: 1, round: *round, command: c }); + if let Some(h) = fire { + std::hint::black_box(lock(h).live_owned_by(NodeId(1)).len()); + } + } + t0.elapsed().as_nanos() as f64 / n as f64 +} + +fn mode_timed() { + // P1: arms interleaved ABBA across reps. + let arms = ["HEAD", "ZK", "M1"]; + for &t in &[1_000u64, 10_000_000] { + for rep in 0..REPS { + let order: Vec<&str> = if rep % 2 == 0 { arms.to_vec() } else { arms.iter().rev().copied().collect() }; + for arm in order { + let hl = Arc::new(Mutex::new(BranchLedger::new())); + let mut l: Box = match arm { + "HEAD" => Box::new(Head(hl.clone())), + "ZK" => Box::new(Zk::new()), + _ => Box::new(HeadCopy::new(true)), + }; + let (mut round, pool) = p1_fill(l.as_mut(), t); + let fork_base = pool + 2 * B; + let ns_fork = time_batch(l.as_mut(), &mut round, (0..B).map(|i| fork_c(cid(1, fork_base + i))), None); + let ns_merge = { + let mut r = round; + let batch: Vec<(u64, Round)> = (0..B).map(|i| (cid(1, pool + i), 0)).collect(); + let t0 = Instant::now(); + for (id, _) in batch { + let base = r; + r += 1; + l.apply(&Entry { term: 1, round: r, command: merge_c(id, base) }); + } + let ns = t0.elapsed().as_nanos() as f64 / B as f64; + round = r; + ns + }; + let ns_abandon = time_batch(l.as_mut(), &mut round, (0..B).map(|i| abandon_c(cid(1, pool + B + i))), None); + let ns_tick = time_batch(l.as_mut(), &mut round, (0..B).map(|i| Command::LeaseTick { unix_millis: i }), None); + println!("TIMED id=P1 arm={arm} T={t} rep={rep} batch={B} records={} ns_fork={ns_fork:.1} ns_merge={ns_merge:.1} ns_abandon={ns_abandon:.1} ns_leasetick={ns_tick:.1}", l.records()); + } + // OFL: merges and a base-moving other; no T. + let mut o = Ofl::default(); + let t0 = Instant::now(); + for i in 0..B { + o.apply(i + 1, &OCmd::Merge { branch: cid(1, i + 1), base_round: i, inc: 0 }); + } + let ns_m = t0.elapsed().as_nanos() as f64 / B as f64; + let t0 = Instant::now(); + for i in 0..B { + o.apply(B + i + 1, &OCmd::Other { moves: false }); + } + let ns_o = t0.elapsed().as_nanos() as f64 / B as f64; + println!("TIMED id=P1 arm=OFL T={t} rep={rep} batch={B} ns_merge={ns_m:.1} ns_leasetick={ns_o:.1}"); + } + } + // P1-fire: a planted linear term inside the timed loop (HEAD), T = 10^3 vs 10^6. + for &t in &[1_000u64, 1_000_000] { + for rep in 0..REPS { + let hl = Arc::new(Mutex::new(BranchLedger::new())); + let mut l = Head(hl.clone()); + let (mut round, pool) = p1_fill(&mut l, t); + let ns = time_batch(&mut l, &mut round, (0..B).map(|i| abandon_c(cid(1, pool + i))), Some(&hl)); + println!("TIMED id=P1-fire arm=HEAD+planted-scan T={t} rep={rep} batch={B} ns_abandon={ns:.1}"); + } + } + // A-S: orphans_of at fixed N = 10^3, T = 10^3 vs 10^6. + for &t in &[1_000u64, 1_000_000] { + for rep in 0..REPS { + let hl = Arc::new(Mutex::new(BranchLedger::new())); + let mut l = Head(hl.clone()); + run_w(t, 1_000, |e| { + l.apply(&e); + }); + let t0 = Instant::now(); + let o = lock(&hl).orphans_of(NodeId(1)); + let ns = t0.elapsed().as_nanos(); + println!("TIMED id=A-S arm=HEAD N=1000 T={t} rep={rep} orphans={} ns={ns}", o.len()); + } + } + // A-Z: ZK's close-owner apply, and M1's fence apply, at N = 10^3 vs 10^6. + for &n in &[1_000u64, 1_000_000] { + for rep in 0..REPS { + for arm in ["ZK", "M1"] { + let mut l: Box = if arm == "ZK" { Box::new(Zk::new()) } else { Box::new(HeadCopy::new(true)) }; + let last = run_w(n, n, |e| { + l.apply(&e); + }); + let t0 = Instant::now(); + l.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + let ns = t0.elapsed().as_nanos(); + println!("TIMED id=A-Z arm={arm} N={n} rep={rep} ns_single_apply={ns} touched={} live_after={}", l.touched_max(), l.live_ids_of(1).len()); + } + } + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let arg = |i: usize| -> u64 { args.get(i).and_then(|s| s.parse().ok()).expect("numeric argument") }; + println!("# r11_dist_ledger {:?} size_of::={} size_of::={}", &args[1..], std::mem::size_of::(), std::mem::size_of::()); + let t0 = Instant::now(); + match args.get(1).map(|s| s.as_str()) { + Some("counts") => mode_counts(arg(2), arg(3)), + Some("p6") => mode_p6(), + Some("p8") => mode_p8(arg(2)), + Some("o2") => mode_o2(arg(2)), + Some("timed") => mode_timed(), + _ => { + eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed"); + std::process::exit(2); + } + } + println!("# done wall_ms={:.0} heap_live_end={}", t0.elapsed().as_secs_f64() * 1e3, heap()); +} From f67dc3bfa15a714e15eb06a3e4e777bb2eb9719d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:25:08 -0400 Subject: [PATCH 281/315] r11-dist: part B harness (3 real nodes) and the GC arm (opt-in group commit in Node, default off) --- examples/r11_dist/arms.rs | 342 +++++++++++++++++++ examples/r11_dist_fleet.rs | 654 +++++++++++++++++++++++++++++++++++++ src/consensus/node.rs | 62 ++++ 3 files changed, 1058 insertions(+) create mode 100644 examples/r11_dist/arms.rs create mode 100644 examples/r11_dist_fleet.rs diff --git a/examples/r11_dist/arms.rs b/examples/r11_dist/arms.rs new file mode 100644 index 00000000..6d08d5a8 --- /dev/null +++ b/examples/r11_dist/arms.rs @@ -0,0 +1,342 @@ +//! r11-dist shared arms for part B, copied VERBATIM from `examples/r11_dist_ledger.rs` at 72fa4b8 +//! (where they were conformance-checked against the real `BranchLedger`: `conform` = 0), with `pub` +//! added so the fleet harness can reach them. Not an example itself (a subdirectory without main.rs). +#![allow(dead_code)] + +use std::collections::{BTreeMap, BTreeSet}; + +use ferrodb::agent_sql::cluster::{moves_base, MergeVerdict}; +use ferrodb::consensus::{BranchOp, Command, Entry, Round}; + +/// `cluster.rs` `LOCAL_BITS` / `LOCAL_MASK` (private there; the packing is `ClusterBranchId::of`). +pub const LOCAL_BITS: u32 = 40; +pub const LOCAL_MASK: u64 = (1u64 << LOCAL_BITS) - 1; + +pub fn cid(node: u32, local: u64) -> u64 { + ((node as u64) << LOCAL_BITS) | local +} +pub fn owner_of(id: u64) -> u32 { + (id >> LOCAL_BITS) as u32 +} +/// The fence / close-owner sentinel (O2 M1): an `Abandon` of a local id no node will ever mint. +pub fn sentinel(node: u32) -> u64 { + cid(node, LOCAL_MASK) +} +pub fn is_sentinel(id: u64) -> bool { + id != 0 && id & LOCAL_MASK == LOCAL_MASK +} +pub fn fork_c(child: u64) -> Command { + Command::Branch { op: BranchOp::Fork { child, parent: 0, fork_epoch: 1, lease_millis: 900_000 } } +} +pub fn merge_c(branch: u64, base_round: Round) -> Command { + Command::Branch { op: BranchOp::Merge { branch, base_round } } +} +pub fn abandon_c(branch: u64) -> Command { + Command::Branch { op: BranchOp::Abandon { branch } } +} +pub fn wal_c(n: u64) -> Command { + Command::WalBatch { start_lsn: n, bytes: Vec::new() } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum V { + Applied, + ReEval, + Refused, +} + +pub fn class(v: &MergeVerdict) -> V { + match v { + MergeVerdict::Applied { .. } => V::Applied, + MergeVerdict::ReEvaluate { .. } => V::ReEval, + MergeVerdict::Refused { .. } => V::Refused, + } +} + +pub trait Ledger { + /// Apply one committed entry; `Some` for a merge's verdict class. + fn apply(&mut self, e: &Entry) -> Option; + fn is_live(&self, id: u64) -> bool; + /// Branch records held, the trunk counted as one where the arm keeps it. + fn records(&self) -> usize; + fn live_ids_of(&self, node: u32) -> Vec; + /// Records touched by the single largest apply so far (HEAD: not instrumented, see `Head`). + fn touched_max(&self) -> u64; +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum St { + Live, + Merged(Round), + Abandoned(Round), + Reaped(Round, u32), +} + +/// Same five fields and state width as `ReplicatedBranch`, so M1's heap is comparable with HEAD's. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] +pub struct Rec { + parent: u64, + fork_epoch: u64, + lease_millis: u64, + forked_at: Round, + st: St, +} + +/// HEAD's rules copied (`cluster.rs` `apply` / `apply_branch` / `merge_verdict` at 7dc428f), plus +/// the M1 fence when `m1`, and two planted mutants for the controls. +#[derive(Clone)] +pub struct HeadCopy { + br: BTreeMap, + last_applied: Round, + lbm: Round, + fence: BTreeMap, + m1: bool, + /// Mutant F (O2): the fence ignores position, so it kills the owner for ever. + mut_f: bool, + /// Negative control for P8: the fence is recorded but never checked. + mut_nofence: bool, + /// Copy mutant for `conform`: `>=` for `>` in the verdict. + mut_ge: bool, + touched: u64, +} + +impl HeadCopy { + pub fn new(m1: bool) -> HeadCopy { + let mut br = BTreeMap::new(); + br.insert( + 0, + Rec { parent: 0, fork_epoch: 0, lease_millis: u64::MAX, forked_at: 0, st: St::Live }, + ); + HeadCopy { + br, + last_applied: 0, + lbm: 0, + fence: BTreeMap::new(), + m1, + mut_f: false, + mut_nofence: false, + mut_ge: false, + touched: 0, + } + } + + fn rec_live(&self, id: u64, r: &Rec) -> bool { + if r.st != St::Live { + return false; + } + if !self.m1 || id == 0 || self.mut_nofence { + return true; + } + let o = owner_of(id); + if self.mut_f { + return !self.fence.contains_key(&o); + } + r.forked_at > self.fence.get(&o).copied().unwrap_or(0) + } +} + +impl Ledger for HeadCopy { + fn apply(&mut self, e: &Entry) -> Option { + if e.round <= self.last_applied { + return None; + } + self.last_applied = e.round; + let round = e.round; + let op = match &e.command { + Command::Branch { op } => op, + other => { + if moves_base(other) { + self.lbm = round; + } + return None; + } + }; + self.touched = self.touched.max(1); + match op { + BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { + if *child == 0 || self.br.contains_key(child) { + return None; + } + match self.br.get(parent) { + Some(p) if self.rec_live(*parent, p) => {} + _ => return None, + } + self.br.insert( + *child, + Rec { + parent: *parent, + fork_epoch: *fork_epoch, + lease_millis: *lease_millis, + forked_at: round, + st: St::Live, + }, + ); + None + } + BranchOp::Merge { branch, base_round } => { + let v = match self.br.get(branch) { + None => V::Refused, + Some(r) if !self.rec_live(*branch, r) => V::Refused, + Some(_) => { + let moved = if self.mut_ge { self.lbm >= *base_round } else { self.lbm > *base_round }; + if moved { V::ReEval } else { V::Applied } + } + }; + if v == V::Applied { + if let Some(r) = self.br.get_mut(branch) { + r.st = St::Merged(round); + } + self.lbm = round; + } + Some(v) + } + BranchOp::Abandon { branch } => { + if self.m1 && is_sentinel(*branch) { + let f = self.fence.entry(owner_of(*branch)).or_insert(0); + *f = (*f).max(round); + return None; + } + if let Some(r) = self.br.get(branch).copied() { + // HEAD checks `state.is_live()` only (not the fence): kept identical. + if r.st == St::Live { + self.br.get_mut(branch).unwrap().st = St::Abandoned(round); + } + } + None + } + BranchOp::Reap { branch, generation } => { + if let Some(r) = self.br.get_mut(branch) { + if !matches!(r.st, St::Reaped(..)) { + r.st = St::Reaped(round, *generation); + } + } + None + } + } + } + fn is_live(&self, id: u64) -> bool { + self.br.get(&id).map(|r| self.rec_live(id, r)).unwrap_or(false) + } + fn records(&self) -> usize { + self.br.len() + } + fn live_ids_of(&self, node: u32) -> Vec { + self.br + .iter() + .filter(|(id, r)| **id != 0 && owner_of(**id) == node && self.rec_live(**id, r)) + .map(|(id, _)| *id) + .collect() + } + fn touched_max(&self) -> u64 { + self.touched + } +} + +/// ZK: records exist only while live; a close-owner command deletes the owner's set AT APPLY. +pub struct Zk { + recs: BTreeMap, + by_owner: BTreeMap>, + last_applied: Round, + lbm: Round, + touched: u64, +} + +impl Zk { + pub fn new() -> Zk { + Zk { recs: BTreeMap::new(), by_owner: BTreeMap::new(), last_applied: 0, lbm: 0, touched: 0 } + } + fn delete(&mut self, id: u64) { + if self.recs.remove(&id).is_some() { + if let Some(s) = self.by_owner.get_mut(&owner_of(id)) { + s.remove(&id); + } + } + } +} + +impl Ledger for Zk { + fn apply(&mut self, e: &Entry) -> Option { + if e.round <= self.last_applied { + return None; + } + self.last_applied = e.round; + let round = e.round; + let op = match &e.command { + Command::Branch { op } => op, + other => { + if moves_base(other) { + self.lbm = round; + } + return None; + } + }; + self.touched = self.touched.max(1); + match op { + BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { + if *child == 0 || self.recs.contains_key(child) { + return None; + } + if *parent != 0 && !self.recs.contains_key(parent) { + return None; + } + self.recs.insert( + *child, + Rec { + parent: *parent, + fork_epoch: *fork_epoch, + lease_millis: *lease_millis, + forked_at: round, + st: St::Live, + }, + ); + self.by_owner.entry(owner_of(*child)).or_default().insert(*child); + None + } + BranchOp::Merge { branch, base_round } => { + let v = if !self.recs.contains_key(branch) { + V::Refused + } else if self.lbm > *base_round { + V::ReEval + } else { + V::Applied + }; + if v == V::Applied { + self.delete(*branch); + self.lbm = round; + } + Some(v) + } + BranchOp::Abandon { branch } => { + if is_sentinel(*branch) { + // `ephemerals.remove(session)`, then delete every member: the whole set, here. + let set = self.by_owner.remove(&owner_of(*branch)).unwrap_or_default(); + self.touched = self.touched.max(set.len() as u64); + for id in set { + self.recs.remove(&id); + } + } else { + self.delete(*branch); + } + None + } + BranchOp::Reap { branch, .. } => { + self.delete(*branch); + None + } + } + } + fn is_live(&self, id: u64) -> bool { + id == 0 || self.recs.contains_key(&id) + } + fn records(&self) -> usize { + self.recs.len() + 1 + } + fn live_ids_of(&self, node: u32) -> Vec { + self.by_owner.get(&node).map(|s| s.iter().copied().collect()).unwrap_or_default() + } + fn touched_max(&self) -> u64 { + self.touched + } +} + diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs new file mode 100644 index 00000000..bfa6c242 --- /dev/null +++ b/examples/r11_dist_fleet.rs @@ -0,0 +1,654 @@ +//! r11-dist part B: failover and orphan disposal on three real `Node`s (loopback TCP, real fsync), +//! against the number of live branches N, for HEAD and three published-fix arms. +//! +//! PREREG: artie-research `frontier/round11/r11-dist/PREREG.md` §5 (079f108). One invocation is one +//! N with all four arms; it runs under the fleet lock. Every number printed is labelled with its +//! instrument: `Instant` wall time (ms), or an integer counter. +//! +//! Per arm: start 3 nodes, each driven by its own thread (as a server's driver would be); elect a +//! stable leader L; grow N forks owned by L (fixture: batched proposals with group commit on, then +//! group commit off for HEAD/ZK/M1); [P7 at N in {10^3, 10^6}, HEAD cluster only, forks owned by a +//! phantom node 9 so L's orphan set is untouched]; stop L; time the election (B1); the new leader L' +//! disposes of L's orphans (B2); a fresh fork + merge on L' to Applied (B3); integers C0 C1 C2 and +//! leader/term changes (B4); restart the other survivor from its directory and time the replay (B5). +//! +//! Usage: r11_dist_fleet [arm order, e.g. HEAD,GC,ZK,M1] + +#[path = "r11_dist/arms.rs"] +mod arms; + +use std::collections::BTreeMap; +use std::io::Write; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use arms::{abandon_c, cid, fork_c, merge_c, sentinel, HeadCopy, Ledger, Zk, V}; +use ferrodb::agent_sql::cluster::{BranchApplier, BranchLedger, ClusterAgents, ClusterBranchId, MergeVerdict, Replicated}; +use ferrodb::agent_sql::runtime::AgentRuntime; +use ferrodb::consensus::config::Config; +use ferrodb::consensus::node::{Applier, Node, NodeOptions}; +use ferrodb::consensus::{Command, Entry, NodeId, Round}; +use ferrodb::error::FerroError; + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn ms(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1e3 +} + +/// Growth batch (fixture): proposals queued per drain with group commit on. +const GROW_BATCH: usize = 1024; +/// HEAD at N = 10^6: the sweep's first K proposals are timed; the whole sweep is extrapolated. +const PREFIX_K: usize = 10_000; +/// Phantom owner of P7's fork stream, so the dead leader's orphan set is untouched by it. +const PHANTOM: u32 = 9; +const P7_SECS: u64 = 20; +const POLL: Duration = Duration::from_millis(1); + +// --------------------------------------------------------------------------------------------- +// Appliers +// --------------------------------------------------------------------------------------------- + +#[derive(Default)] +struct Obs { + applied: AtomicU64, + max_apply_ns: AtomicU64, + applies: AtomicU64, +} + +/// Times every apply and publishes the applied round; wraps each arm's real applier. +struct Instr { + inner: Box, + obs: Arc, +} + +impl Applier for Instr { + fn apply(&mut self, e: &Entry) -> Result<(), FerroError> { + let t0 = Instant::now(); + let r = self.inner.apply(e); + let ns = t0.elapsed().as_nanos() as u64; + self.obs.max_apply_ns.fetch_max(ns, Ordering::Relaxed); + self.obs.applies.fetch_add(1, Ordering::Relaxed); + self.obs.applied.store(e.round, Ordering::Release); + r + } +} + +/// ZK or M1 as an `Applier`: the part-A ledger, with each merge verdict kept. +struct LA { + l: Arc>, + verdicts: Arc>>, +} + +impl Applier for LA { + fn apply(&mut self, e: &Entry) -> Result<(), FerroError> { + if let Some(v) = lock(&self.l).apply(e) { + lock(&self.verdicts).insert(e.round, v); + } + Ok(()) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Arm { + Head, + Gc, + Zk, + M1, +} + +impl Arm { + fn name(self) -> &'static str { + match self { + Arm::Head => "HEAD", + Arm::Gc => "GC", + Arm::Zk => "ZK", + Arm::M1 => "M1", + } + } +} + +/// One node's ledger, whichever arm. +#[derive(Clone)] +enum ArmL { + Head(Arc>), + Zk(Arc>, Arc>>), + M1(Arc>, Arc>>), +} + +impl ArmL { + fn new(arm: Arm) -> ArmL { + match arm { + Arm::Head | Arm::Gc => ArmL::Head(Arc::new(Mutex::new(BranchLedger::new()))), + Arm::Zk => ArmL::Zk(Arc::new(Mutex::new(Zk::new())), Arc::default()), + Arm::M1 => ArmL::M1(Arc::new(Mutex::new(HeadCopy::new(true))), Arc::default()), + } + } + fn applier(&self, obs: Arc) -> Instr { + let inner: Box = match self { + ArmL::Head(l) => Box::new(BranchApplier::new(l.clone())), + ArmL::Zk(l, v) => Box::new(LA { l: l.clone(), verdicts: v.clone() }), + ArmL::M1(l, v) => Box::new(LA { l: l.clone(), verdicts: v.clone() }), + }; + Instr { inner, obs } + } + fn is_live(&self, id: u64) -> bool { + match self { + ArmL::Head(l) => lock(l).get(ClusterBranchId(id)).map(|b| b.state.is_live()).unwrap_or(false), + ArmL::Zk(l, _) => lock(l).is_live(id), + ArmL::M1(l, _) => lock(l).is_live(id), + } + } + fn live_count_of(&self, node: u32) -> usize { + match self { + ArmL::Head(l) => lock(l).live_owned_by(NodeId(node)).len(), + ArmL::Zk(l, _) => lock(l).live_ids_of(node).len(), + ArmL::M1(l, _) => lock(l).live_ids_of(node).len(), + } + } + fn verdict(&self, round: Round) -> Option { + match self { + ArmL::Head(l) => lock(l).verdict_at(round).map(|v| match v { + MergeVerdict::Applied { .. } => V::Applied, + MergeVerdict::ReEvaluate { .. } => V::ReEval, + MergeVerdict::Refused { .. } => V::Refused, + }), + ArmL::Zk(_, v) | ArmL::M1(_, v) => lock(v).get(&round).copied(), + } + } +} + +// --------------------------------------------------------------------------------------------- +// The seam: NodeReplicator's `propose` logic (cluster.rs :746-767 at 7dc428f), over `Node` +// --------------------------------------------------------------------------------------------- + +struct Rep { + id: NodeId, + node: Mutex>>, +} + +impl Rep { + fn with(&self, f: impl FnOnce(&mut Node) -> R) -> Option { + lock(&self.node).as_mut().map(f) + } +} + +impl Replicated for Rep { + fn propose(&self, c: Command) -> Result { + let mut g = lock(&self.node); + let n = g.as_mut().ok_or_else(|| FerroError::Internal("node stopped".into()))?; + let _ = n.take_refusals(); + let before = n.last_round(); + let round = n.propose(c)?; + if let Some(why) = n.take_refusals().into_iter().next() { + return Err(why); + } + if round <= before { + return Err(FerroError::Internal("no round assigned and no refusal".into())); + } + Ok(round) + } + fn committed_head(&self) -> Round { + self.with(|n| n.commit_round()).unwrap_or(0) + } + fn pump(&self) -> Result<(), FerroError> { + match lock(&self.node).as_mut() { + Some(n) => n.poll(POLL), + None => Ok(()), + } + } + fn leader(&self) -> Option { + self.with(|n| n.leader()).flatten() + } +} + +// --------------------------------------------------------------------------------------------- +// The cluster +// --------------------------------------------------------------------------------------------- + +struct Cluster { + arm: Arm, + reps: Vec>, + ledgers: Vec, + obs: Vec>, + stops: Vec>, + threads: Vec>>, + addrs: BTreeMap, + dirs: Vec, + cfg: Config, + errs: Arc>>, +} + +fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) -> NodeOptions { + let peers: BTreeMap = addrs.iter().filter(|(k, _)| **k != id).map(|(k, v)| (*k, *v)).collect(); + // NodeOptions::new's own default tick (50 ms) is kept: the production timing. + NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9)) +} + +fn spawn_driver(rep: Arc, stop: Arc, errs: Arc>>) -> JoinHandle { + std::thread::spawn(move || { + let mut polls = 0u64; + while !stop.load(Ordering::Relaxed) { + if let Err(e) = rep.pump() { + lock(&errs).push(format!("{} driver: {e}", rep.id)); + break; + } + polls += 1; + std::thread::yield_now(); + } + polls + }) +} + +impl Cluster { + fn start(arm: Arm, root: &Path) -> Cluster { + let listeners: Vec = (0..3).map(|_| TcpListener::bind("127.0.0.1:0").unwrap()).collect(); + let addrs: BTreeMap = + listeners.iter().enumerate().map(|(i, l)| (NodeId(i as u32 + 1), l.local_addr().unwrap())).collect(); + let cfg = Config::new((1..=3).map(NodeId), 1, 0); + let errs = Arc::new(Mutex::new(Vec::new())); + let mut c = Cluster { + arm, + reps: Vec::new(), + ledgers: Vec::new(), + obs: Vec::new(), + stops: Vec::new(), + threads: Vec::new(), + addrs: addrs.clone(), + dirs: Vec::new(), + cfg: cfg.clone(), + errs: errs.clone(), + }; + for (i, l) in listeners.into_iter().enumerate() { + let id = NodeId(i as u32 + 1); + let dir = root.join(format!("n{}", i + 1)); + let ledger = ArmL::new(arm); + let obs = Arc::new(Obs::default()); + let node = Node::start(id, cfg.clone(), l, opts(&dir, &addrs, id, i), ledger.applier(obs.clone())).unwrap(); + let rep = Arc::new(Rep { id, node: Mutex::new(Some(node)) }); + let stop = Arc::new(AtomicBool::new(false)); + c.threads.push(Some(spawn_driver(rep.clone(), stop.clone(), errs.clone()))); + c.reps.push(rep); + c.ledgers.push(ledger); + c.obs.push(obs); + c.stops.push(stop); + c.dirs.push(dir); + } + c + } + + fn set_gc(&self, on: bool) { + for r in &self.reps { + r.with(|n| n.set_group_commit(on)); + } + } + + /// One node claims office, every live node agrees, and its own round is committed; held 50 ms. + fn elect(&self, alive: &[usize], bound: Duration) -> Option<(usize, Round)> { + let t0 = Instant::now(); + let mut who: Option = None; + let mut since = Instant::now(); + while t0.elapsed() < bound { + let claiming: Vec = alive.iter().copied().filter(|i| self.reps[*i].leader() == Some(NodeId(*i as u32 + 1))).collect(); + let settled = claiming.len() == 1 && { + let l = claiming[0]; + let last = self.reps[l].with(|n| n.last_round()).unwrap_or(0); + alive.iter().all(|i| self.reps[*i].leader() == Some(NodeId(l as u32 + 1))) && self.reps[l].committed_head() >= last + }; + if settled { + if who != Some(claiming[0]) { + who = Some(claiming[0]); + since = Instant::now(); + } else if since.elapsed() >= Duration::from_millis(50) { + let l = claiming[0]; + return Some((l, self.reps[l].committed_head())); + } + } else { + who = None; + } + std::thread::sleep(Duration::from_micros(200)); + } + None + } + + fn wait_applied(&self, nodes: &[usize], round: Round, bound: Duration) -> bool { + let t0 = Instant::now(); + while t0.elapsed() < bound { + if nodes.iter().all(|i| self.obs[*i].applied.load(Ordering::Acquire) >= round) { + return true; + } + std::thread::sleep(Duration::from_micros(100)); + } + false + } + + /// Stop a node's driver, shut its transport and drop it (so its files and port are released). + fn stop_node(&mut self, i: usize) -> u64 { + self.stops[i].store(true, Ordering::Relaxed); + let polls = self.threads[i].take().map(|t| t.join().unwrap_or(0)).unwrap_or(0); + if let Some(n) = lock(&self.reps[i].node).take() { + n.shutdown(); + drop(n); + } + polls + } + + /// B5: start node `i` again on its own directory and address, with a fresh ledger. + fn restart(&mut self, i: usize) -> Result<(), String> { + let id = NodeId(i as u32 + 1); + let l = TcpListener::bind(self.addrs[&id]).map_err(|e| format!("rebind {}: {e}", self.addrs[&id]))?; + let ledger = ArmL::new(self.arm); + let obs = Arc::new(Obs::default()); + let node = Node::start(id, self.cfg.clone(), l, opts(&self.dirs[i], &self.addrs, id, i), ledger.applier(obs.clone())) + .map_err(|e| format!("Node::start: {e}"))?; + *lock(&self.reps[i].node) = Some(node); + self.ledgers[i] = ledger; + self.obs[i] = obs; + let stop = Arc::new(AtomicBool::new(false)); + self.threads[i] = Some(spawn_driver(self.reps[i].clone(), stop.clone(), self.errs.clone())); + self.stops[i] = stop; + Ok(()) + } + + fn shutdown_all(&mut self) { + for i in 0..3 { + self.stop_node(i); + } + } +} + +/// Propose `cmds` on node `l` in batches through `propose_many`; returns the last round. +fn propose_batched(c: &Cluster, l: usize, cmds: Vec) -> Result { + let mut last = 0; + let mut it = cmds.into_iter().peekable(); + while it.peek().is_some() { + let batch: Vec = it.by_ref().take(GROW_BATCH).collect(); + let r = c.reps[l] + .with(|n| { + let _ = n.take_refusals(); + let r = n.propose_many(batch); + (r, n.take_refusals()) + }) + .ok_or("node stopped")?; + match r { + (Ok(round), refusals) if refusals.is_empty() => last = round, + (Ok(_), refusals) => return Err(format!("refused: {}", refusals[0])), + (Err(e), _) => return Err(format!("{e}")), + } + } + Ok(last) +} + +// --------------------------------------------------------------------------------------------- +// B0: fsync on this volume +// --------------------------------------------------------------------------------------------- + +fn b0(root: &Path) -> (f64, f64) { + let p = root.join("b0.sync"); + let mut f = std::fs::OpenOptions::new().create(true).truncate(true).write(true).open(&p).unwrap(); + let mut v = Vec::new(); + for _ in 0..2000 { + f.write_all(&[7u8; 64]).unwrap(); + let t0 = Instant::now(); + f.sync_data().unwrap(); + v.push(t0.elapsed().as_secs_f64() * 1e3); + } + let _ = std::fs::remove_file(&p); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + (v[v.len() / 2], v[v.len() * 99 / 100]) +} + +// --------------------------------------------------------------------------------------------- +// One arm at one N +// --------------------------------------------------------------------------------------------- + +fn run_arm(arm: Arm, n: u64, root: &Path) { + let tag = format!("arm={} N={n}", arm.name()); + let dir = root.join(format!("{}_{n}", arm.name())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut c = Cluster::start(arm, &dir); + let all = [0usize, 1, 2]; + let Some((l, _)) = c.elect(&all, Duration::from_secs(60)) else { + println!("B {tag} VOID: no stable leader in 60 s; errs={:?}", lock(&c.errs)); + c.shutdown_all(); + let _ = std::fs::remove_dir_all(&dir); + return; + }; + let lid = l as u32 + 1; + + // GROW (fixture): N forks owned by L, group-committed. + c.set_gc(true); + let t0 = Instant::now(); + let grow_last = match propose_batched(&c, l, (1..=n).map(|i| fork_c(cid(lid, i))).collect()) { + Ok(r) => r, + Err(e) => { + println!("B {tag} VOID: growth refused: {e}"); + c.shutdown_all(); + let _ = std::fs::remove_dir_all(&dir); + return; + } + }; + let grown = c.wait_applied(&all, grow_last, Duration::from_secs(900)); + let grow_ms = ms(t0); + if arm != Arm::Gc { + c.set_gc(false); + } + let live_before: Vec = c.ledgers.iter().map(|x| x.live_count_of(lid)).collect(); + println!("B {tag} grow_ms={grow_ms:.0} grown={grown} grow_last_round={grow_last} live_of_L_per_node={live_before:?} (fixture phase, group-committed)"); + if !grown || live_before.iter().any(|x| *x as u64 != n) { + println!("B {tag} VOID: growth did not reach every node"); + c.shutdown_all(); + let _ = std::fs::remove_dir_all(&dir); + return; + } + + // P7 (HEAD cluster only, N in {10^3, 10^6}): a single-proposal fork stream, owner PHANTOM. + if arm == Arm::Head && (n == 1_000 || n == 1_000_000) { + let f1 = (l + 1) % 3; + let t0 = Instant::now(); + let start_commit = c.reps[l].committed_head(); + let mut lags: Vec = Vec::new(); + let mut i = 0u64; + let mut refused = 0u64; + while t0.elapsed() < Duration::from_secs(P7_SECS) { + i += 1; + if c.reps[l].propose(fork_c(cid(PHANTOM, i))).is_err() { + refused += 1; + } + if i % 10 == 0 { + let head = c.reps[l].committed_head(); + let fa = c.obs[f1].applied.load(Ordering::Acquire); + lags.push(head.saturating_sub(fa)); + } + } + let committed = c.reps[l].committed_head() - start_commit; + lags.sort_unstable(); + let p99 = lags.get(lags.len() * 99 / 100).copied().unwrap_or(0); + println!( + "B {tag} P7 proposed={i} refused={refused} committed_in_{P7_SECS}s={committed} R_per_s={:.1} follower_apply_lag_p99_rounds={p99} lag_samples={}", + committed as f64 / P7_SECS as f64, + lags.len() + ); + let last = c.reps[l].with(|x| x.last_round()).unwrap_or(0); + c.wait_applied(&all, last, Duration::from_secs(120)); + } + + // FAILOVER: stop L; time the election among the survivors (B1). + let survivors: Vec = all.iter().copied().filter(|i| *i != l).collect(); + let syncs_before: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.persist_syncs()).unwrap_or(0)).collect(); + let t_fail = Instant::now(); + c.stop_node(l); + let Some((l2, _)) = c.elect(&survivors, Duration::from_secs(120)) else { + println!("B {tag} VOID: no leader among survivors in 120 s; errs={:?}", lock(&c.errs)); + c.shutdown_all(); + let _ = std::fs::remove_dir_all(&dir); + return; + }; + let t_elect_ms = ms(t_fail); + let l2id = l2 as u32 + 1; + let other = *survivors.iter().find(|i| **i != l2).unwrap(); + let term0 = c.reps[l2].with(|x| x.term()).unwrap_or(0); + + // DISPOSE (B2). + let t_disp = Instant::now(); + let (props, last_round, prefix) = match arm { + Arm::Head => { + if n <= 100_000 { + let ledger = match &c.ledgers[l2] { + ArmL::Head(x) => x.clone(), + _ => unreachable!(), + }; + let agents = ClusterAgents::new(NodeId(l2id), Arc::new(AgentRuntime::new()), c.reps[l2].clone(), ledger); + match agents.abandon_orphans_of(NodeId(lid)) { + Ok(out) => (out.len() as u64, out.last().map(|x| x.1).unwrap_or(0), false), + Err(e) => { + println!("B {tag} abandon_orphans_of REFUSED after {} proposals: {e}", agents.cost().proposals); + (agents.cost().proposals, 0, false) + } + } + } else { + // The sweep's first K proposals: the same loop body as abandon_orphans_of + // (cluster.rs :1239-1245), over the real orphans_of. + let orphans = match &c.ledgers[l2] { + ArmL::Head(x) => lock(x).orphans_of(NodeId(lid)), + _ => unreachable!(), + }; + let mut last = 0; + let mut k = 0u64; + for id in orphans.into_iter().take(PREFIX_K) { + match c.reps[l2].propose(abandon_c(id.0)) { + Ok(r) => { + last = r; + k += 1; + } + Err(e) => { + println!("B {tag} prefix proposal refused after {k}: {e}"); + break; + } + } + } + (k, last, true) + } + } + Arm::Gc => { + let orphans = match &c.ledgers[l2] { + ArmL::Head(x) => lock(x).orphans_of(NodeId(lid)), + _ => unreachable!(), + }; + let k = orphans.len() as u64; + match propose_batched(&c, l2, orphans.into_iter().map(|id| abandon_c(id.0)).collect()) { + Ok(r) => (k, r, false), + Err(e) => { + println!("B {tag} GC sweep refused: {e}"); + (k, 0, false) + } + } + } + Arm::Zk | Arm::M1 => match c.reps[l2].propose(abandon_c(sentinel(lid))) { + Ok(r) => (1, r, false), + Err(e) => { + println!("B {tag} fence refused: {e}"); + (0, 0, false) + } + }, + }; + let propose_ms = ms(t_disp); + let applied = last_round > 0 && c.wait_applied(&[l2, other], last_round, Duration::from_secs(1200)); + let dispose_ms = ms(t_disp); + let term1 = c.reps[l2].with(|x| x.term()).unwrap_or(0); + let leader_after = c.reps[other].leader(); + let max_apply_ns: Vec = [l2, other].iter().map(|i| c.obs[*i].max_apply_ns.load(Ordering::Relaxed)).collect(); + let syncs_after: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.persist_syncs()).unwrap_or(0)).collect(); + let sync_delta: Vec = syncs_after.iter().zip(&syncs_before).map(|(a, b)| a - b).collect(); + println!( + "B {tag} B1_t_elect_ms={t_elect_ms:.1} B2_dispose_ms={dispose_ms:.1} propose_ms={propose_ms:.1} C0_props={props} prefix={prefix} per_prop_ms={:.3} applied_all={applied} B4_term_before={term0} term_after={term1} leader_seen_by_other={leader_after:?} max_apply_ns_survivors={max_apply_ns:?} persist_syncs_since_failover_survivors={sync_delta:?}", + if props > 0 { dispose_ms / props as f64 } else { f64::NAN } + ); + + // SERVE (B3): a fresh fork and merge on L' until the verdict is Applied. + let t_serve = Instant::now(); + let fresh = cid(l2id, 1); + let serve = (|| -> Result<(Round, Option), String> { + let fr = c.reps[l2].propose(fork_c(fresh)).map_err(|e| e.to_string())?; + if !c.wait_applied(&[l2], fr, Duration::from_secs(60)) { + return Err("fork not applied".into()); + } + let base = c.reps[l2].committed_head(); + let mr = c.reps[l2].propose(merge_c(fresh, base)).map_err(|e| e.to_string())?; + if !c.wait_applied(&[l2, other], mr, Duration::from_secs(60)) { + return Err("merge not applied".into()); + } + Ok((mr, c.ledgers[l2].verdict(mr))) + })(); + let serve_ms = ms(t_serve); + let b3 = t_elect_ms + dispose_ms + serve_ms; + match &serve { + Ok((mr, v)) => println!("B {tag} B3_serve_tail_ms={serve_ms:.1} B3_t_serve_ms={b3:.1} merge_round={mr} verdict={v:?}"), + Err(e) => println!("B {tag} B3 FAILED: {e}"), + } + + // Integers C1 (L's branches still live) and C2 (fresh fork wrongly not live before its merge: + // checked through the merge verdict above; here the survivors' liveness of L's branches). + let c1: Vec = [l2, other].iter().map(|i| c.ledgers[*i].live_count_of(lid)).collect(); + println!("B {tag} C1_L_live_on_survivors={c1:?} (prefix run: expected N-K on HEAD) errs={:?}", lock(&c.errs)); + + // B5: restart the non-leader survivor and time the replay to its last log round. + let last_log = c.reps[other].with(|x| x.last_round()).unwrap_or(0); + c.stop_node(other); + let t_rs = Instant::now(); + match c.restart(other) { + Ok(()) => { + let start_ms = ms(t_rs); + let ok = c.wait_applied(&[other], last_log, Duration::from_secs(600)); + println!( + "B {tag} B5_restart_ms={:.1} node_start_ms={start_ms:.1} replayed_to={} last_log_round={last_log} ok={ok} applies={}", + ms(t_rs), + c.obs[other].applied.load(Ordering::Acquire), + c.obs[other].applies.load(Ordering::Relaxed) + ); + let c1r = c.ledgers[other].live_count_of(lid); + println!("B {tag} C1_after_restart={c1r}"); + } + Err(e) => println!("B {tag} B5 SKIPPED: {e}"), + } + c.shutdown_all(); + drop(c); + let _ = std::fs::remove_dir_all(&dir); +} + +fn main() { + let args: Vec = std::env::args().collect(); + let n: u64 = args.get(1).and_then(|s| s.parse().ok()).expect("usage: r11_dist_fleet [HEAD,GC,ZK,M1]"); + let root = PathBuf::from(args.get(2).expect("scratch root")); + std::fs::create_dir_all(&root).unwrap(); + let order: Vec = args + .get(3) + .map(|s| s.as_str()) + .unwrap_or("HEAD,GC,ZK,M1") + .split(',') + .map(|a| match a { + "HEAD" => Arm::Head, + "GC" => Arm::Gc, + "ZK" => Arm::Zk, + "M1" => Arm::M1, + other => panic!("unknown arm {other}"), + }) + .collect(); + println!("# r11_dist_fleet N={n} root={} order={:?} size_of::={}", root.display(), order, std::mem::size_of::()); + let (med, p99) = b0(&root); + println!("B0 sync_data_ms median={med:.3} p99={p99:.3} samples=2000"); + let t0 = Instant::now(); + for arm in order { + let ta = Instant::now(); + run_arm(arm, n, &root); + println!("# arm {} wall_ms={:.0}", arm.name(), ms(ta)); + } + println!("# done wall_ms={:.0}", ms(t0)); +} diff --git a/src/consensus/node.rs b/src/consensus/node.rs index b71109de..6b1f70fb 100644 --- a/src/consensus/node.rs +++ b/src/consensus/node.rs @@ -243,6 +243,15 @@ pub struct Node { /// `snapshots_installed` on that node is where it is counted. snapshots_armed: u64, snapshots_installed: u64, + /// r11-dist GC arm (group commit; NOT landed). Off by default, and off means every path below + /// is HEAD's: one `sync` per `Action::Persist`, fed back at once. + group_commit: bool, + /// With `group_commit`: appended-but-unsynced rounds, as (this node's term at the FIRST deferred + /// append, last round appended). Synced and fed back as one `Persisted` when the drain's queue + /// empties, and before any action whose order against the log's durability HEAD fixes. + deferred: Option<(Term, Round)>, + /// Instrument: `RoundLog::sync` calls made for `Action::Persist`, in either mode. + persist_syncs: u64, } /// The hard-state record: `term`, then a tagged `voted_for`, then a checksum over both. @@ -381,6 +390,9 @@ impl Node { installed_round: None, snapshots_armed: 0, snapshots_installed: 0, + group_commit: false, + deferred: None, + persist_syncs: 0, }; if let Some(why) = digest_note { // Degraded, not wrong: a zero base digest is the one value `AppendResp` reads as "not @@ -410,6 +422,23 @@ impl Node { /// Transfers this node has installed, as a follower. **The anti-vacuity counter**: a /// convergence test that never sees this move proved only that ordinary replication works. pub fn snapshots_installed(&self) -> u64 { self.snapshots_installed } + /// r11-dist GC arm: batch the fsyncs of every `Persist` performed in one drain. + pub fn set_group_commit(&mut self, on: bool) { + self.group_commit = on; + } + /// `RoundLog::sync` calls made for `Action::Persist` so far. + pub fn persist_syncs(&self) -> u64 { + self.persist_syncs + } + /// r11-dist GC arm: queue every command, then drain once. Returns the log's last round. + pub fn propose_many(&mut self, cs: Vec) -> Result { + let before = self.log.last_round(); + for c in cs { + self.pending.push_back(Event::Propose(c)); + } + self.drain()?; + Ok(self.log.last_round().max(before)) + } pub fn take_transitions(&mut self) -> Vec<(Role, Term, Option)> { std::mem::take(&mut self.transitions) } @@ -463,6 +492,7 @@ impl Node { fn drain(&mut self) -> Result<(), FerroError> { let mut steps = 0usize; + loop { while let Some(ev) = self.pending.pop_front() { steps += 1; if steps > MAX_DRAIN_STEPS { @@ -493,6 +523,11 @@ impl Node { self.release_installed_spool(); self.install_pending_snapshot()?; } + // r11-dist GC arm: the queue is empty; make the batch durable, then let its `Persisted` run. + if !self.flush_deferred()? { + break; + } + } // **Outside the loop**, because neither is about any one event and both are expensive. // A checkpoint rewrites the log and fsyncs three times; a capture copies the page file. // Running either once per event rather than once per drain multiplies that by the number @@ -731,9 +766,28 @@ impl Node { Ok(()) } + /// r11-dist GC arm: sync the deferred batch and queue its `Persisted`. `false` if none. + fn flush_deferred(&mut self) -> Result { + match self.deferred.take() { + None => Ok(false), + Some((term, round)) => { + self.log.sync().map_err(LogError::into_ferro)?; + self.persist_syncs += 1; + self.pending.push_back(Event::Persisted { term, round }); + Ok(true) + } + } + } + /// Perform one action, completely, before returning. See the module header for why the order /// matters and why none of this may be deferred. fn perform(&mut self, a: Action) -> Result<(), FerroError> { + // r11-dist GC arm: only Send, Persist and Apply may run ahead of a deferred sync. A Send + // never carries this node's ack (acks are produced by `Persisted`), and Apply hands on + // committed rounds, which a quorum made durable; reading them back needs no sync here. + if self.deferred.is_some() && !matches!(a, Action::Send(_) | Action::Persist { .. } | Action::Apply { .. }) { + self.flush_deferred()?; + } match a { Action::Send(m) => { // A send that cannot be delivered is dropped and counted by the transport, not an @@ -758,7 +812,15 @@ impl Node { let term = self.sm.term(); let round = entries.last().expect("non-empty above").round; self.log.append(&entries).map_err(LogError::into_ferro)?; + if self.group_commit { + self.deferred = Some(match self.deferred { + Some((first_term, _)) => (first_term, round), + None => (term, round), + }); + return Ok(()); + } self.log.sync().map_err(LogError::into_ferro)?; + self.persist_syncs += 1; self.pending.push_back(Event::Persisted { term, round }); } From 694d7857ca3d0a648538477152bd6b08c7157a29 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:50:05 -0400 Subject: [PATCH 282/315] r11-dist: part B proposals through each node's driver queue (HEADQ, GC, growth, P7); driver-gap instrument; serve on the current leader --- examples/r11_dist_fleet.rs | 227 +++++++++++++++++++++++++------------ 1 file changed, 152 insertions(+), 75 deletions(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index bfa6c242..7ca73be0 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -12,7 +12,13 @@ //! disposes of L's orphans (B2); a fresh fork + merge on L' to Applied (B3); integers C0 C1 C2 and //! leader/term changes (B4); restart the other survivor from its directory and time the replay (B5). //! -//! Usage: r11_dist_fleet [arm order, e.g. HEAD,GC,ZK,M1] +//! Proposal paths (PREREG A6): HEAD's sweep calls the seam's `propose` from the caller's thread, +//! exactly as `NodeReplicator` does, sharing the node mutex with the driver. HEADQ, GC, growth and +//! P7 instead hand commands to the node's OWN driver thread through a queue, which it proposes +//! between polls (the single-event-loop shape of etcd's `Propose` channel and hashicorp/raft's +//! `applyCh`): HEADQ one command per driver turn, GC up to 1,024 per turn with group commit on. +//! +//! Usage: r11_dist_fleet [arm order, e.g. HEAD,HEADQ,GC,ZK,M1] #[path = "r11_dist/arms.rs"] mod arms; @@ -98,6 +104,7 @@ impl Applier for LA { #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Arm { Head, + HeadQ, Gc, Zk, M1, @@ -107,6 +114,7 @@ impl Arm { fn name(self) -> &'static str { match self { Arm::Head => "HEAD", + Arm::HeadQ => "HEADQ", Arm::Gc => "GC", Arm::Zk => "ZK", Arm::M1 => "M1", @@ -125,7 +133,7 @@ enum ArmL { impl ArmL { fn new(arm: Arm) -> ArmL { match arm { - Arm::Head | Arm::Gc => ArmL::Head(Arc::new(Mutex::new(BranchLedger::new()))), + Arm::Head | Arm::HeadQ | Arm::Gc => ArmL::Head(Arc::new(Mutex::new(BranchLedger::new()))), Arm::Zk => ArmL::Zk(Arc::new(Mutex::new(Zk::new())), Arc::default()), Arm::M1 => ArmL::M1(Arc::new(Mutex::new(HeadCopy::new(true))), Arc::default()), } @@ -171,6 +179,64 @@ impl ArmL { struct Rep { id: NodeId, node: Mutex>>, + /// Commands handed to this node's driver thread, proposed between its polls. + queue: Mutex>, + /// Commands the driver takes per turn (HEADQ: 1; GC and growth: GROW_BATCH). + batch: std::sync::atomic::AtomicUsize, + /// Last round the driver's queued proposals reached, and how many it saw refused. + q_last_round: AtomicU64, + q_refused: AtomicU64, + /// Instrument: the longest interval between two driver turns (us), and turns taken. + max_gap_us: AtomicU64, + turns: AtomicU64, +} + +impl Rep { + fn new(id: NodeId, node: Node) -> Rep { + Rep { + id, + node: Mutex::new(Some(node)), + queue: Mutex::new(std::collections::VecDeque::new()), + batch: std::sync::atomic::AtomicUsize::new(GROW_BATCH), + q_last_round: AtomicU64::new(0), + q_refused: AtomicU64::new(0), + max_gap_us: AtomicU64::new(0), + turns: AtomicU64::new(0), + } + } + fn enqueue(&self, cmds: impl IntoIterator) { + lock(&self.queue).extend(cmds); + } + fn queue_len(&self) -> usize { + lock(&self.queue).len() + } + /// One driver turn's proposals: take up to `batch` queued commands and propose them in one drain. + fn drive_queue(&self) { + let k = self.batch.load(Ordering::Relaxed).max(1); + let cmds: Vec = { + let mut q = lock(&self.queue); + let k = k.min(q.len()); + q.drain(..k).collect() + }; + if cmds.is_empty() { + return; + } + let n = cmds.len() as u64; + let r = self.with(|node| { + let _ = node.take_refusals(); + let r = node.propose_many(cmds); + (r, node.take_refusals().len() as u64) + }); + match r { + Some((Ok(round), 0)) => self.q_last_round.store(round, Ordering::Release), + Some((Ok(_), refused)) => { + self.q_refused.fetch_add(refused.min(n), Ordering::Relaxed); + } + _ => { + self.q_refused.fetch_add(n, Ordering::Relaxed); + } + } + } } impl Rep { @@ -234,12 +300,18 @@ fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) fn spawn_driver(rep: Arc, stop: Arc, errs: Arc>>) -> JoinHandle { std::thread::spawn(move || { let mut polls = 0u64; + let mut last = Instant::now(); while !stop.load(Ordering::Relaxed) { + let now = Instant::now(); + rep.max_gap_us.fetch_max(now.duration_since(last).as_micros() as u64, Ordering::Relaxed); + last = now; + rep.drive_queue(); if let Err(e) = rep.pump() { lock(&errs).push(format!("{} driver: {e}", rep.id)); break; } polls += 1; + rep.turns.fetch_add(1, Ordering::Relaxed); std::thread::yield_now(); } polls @@ -271,7 +343,7 @@ impl Cluster { let ledger = ArmL::new(arm); let obs = Arc::new(Obs::default()); let node = Node::start(id, cfg.clone(), l, opts(&dir, &addrs, id, i), ledger.applier(obs.clone())).unwrap(); - let rep = Arc::new(Rep { id, node: Mutex::new(Some(node)) }); + let rep = Arc::new(Rep::new(id, node)); let stop = Arc::new(AtomicBool::new(false)); c.threads.push(Some(spawn_driver(rep.clone(), stop.clone(), errs.clone()))); c.reps.push(rep); @@ -363,26 +435,30 @@ impl Cluster { } } -/// Propose `cmds` on node `l` in batches through `propose_many`; returns the last round. -fn propose_batched(c: &Cluster, l: usize, cmds: Vec) -> Result { - let mut last = 0; - let mut it = cmds.into_iter().peekable(); - while it.peek().is_some() { - let batch: Vec = it.by_ref().take(GROW_BATCH).collect(); - let r = c.reps[l] - .with(|n| { - let _ = n.take_refusals(); - let r = n.propose_many(batch); - (r, n.take_refusals()) - }) - .ok_or("node stopped")?; - match r { - (Ok(round), refusals) if refusals.is_empty() => last = round, - (Ok(_), refusals) => return Err(format!("refused: {}", refusals[0])), - (Err(e), _) => return Err(format!("{e}")), +/// Hand `cmds` to node `l`'s driver, `batch` per turn; wait until the queue is drained. Returns +/// the last round its proposals reached, or why it could not. +fn propose_queued(c: &Cluster, l: usize, cmds: Vec, batch: usize, bound: Duration) -> Result { + let r = &c.reps[l]; + r.batch.store(batch, Ordering::Relaxed); + let refused0 = r.q_refused.load(Ordering::Relaxed); + r.enqueue(cmds); + let t0 = Instant::now(); + while r.queue_len() > 0 { + if t0.elapsed() > bound { + return Err(format!("queue not drained in {bound:?}: {} left", r.queue_len())); } + std::thread::sleep(Duration::from_micros(200)); + } + // The last batch may still be inside its drain; wait for the driver to finish the turn. + let turns = r.turns.load(Ordering::Relaxed); + while r.turns.load(Ordering::Relaxed) < turns + 2 && t0.elapsed() < bound { + std::thread::sleep(Duration::from_micros(200)); } - Ok(last) + let refused = r.q_refused.load(Ordering::Relaxed) - refused0; + if refused > 0 { + return Err(format!("{refused} queued proposals refused")); + } + Ok(r.q_last_round.load(Ordering::Acquire)) } // --------------------------------------------------------------------------------------------- @@ -426,7 +502,7 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { // GROW (fixture): N forks owned by L, group-committed. c.set_gc(true); let t0 = Instant::now(); - let grow_last = match propose_batched(&c, l, (1..=n).map(|i| fork_c(cid(lid, i))).collect()) { + let grow_last = match propose_queued(&c, l, (1..=n).map(|i| fork_c(cid(lid, i))).collect(), GROW_BATCH, Duration::from_secs(900)) { Ok(r) => r, Err(e) => { println!("B {tag} VOID: growth refused: {e}"); @@ -456,26 +532,32 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { let start_commit = c.reps[l].committed_head(); let mut lags: Vec = Vec::new(); let mut i = 0u64; - let mut refused = 0u64; + let refused0 = c.reps[l].q_refused.load(Ordering::Relaxed); + c.reps[l].batch.store(1, Ordering::Relaxed); + // Keep the driver's queue non-empty (saturation) without ever holding the node mutex here. while t0.elapsed() < Duration::from_secs(P7_SECS) { - i += 1; - if c.reps[l].propose(fork_c(cid(PHANTOM, i))).is_err() { - refused += 1; - } - if i % 10 == 0 { - let head = c.reps[l].committed_head(); - let fa = c.obs[f1].applied.load(Ordering::Acquire); - lags.push(head.saturating_sub(fa)); + if c.reps[l].queue_len() < 64 { + c.reps[l].enqueue((0..64).map(|k| fork_c(cid(PHANTOM, i + k + 1)))); + i += 64; } + let head = c.reps[l].committed_head(); + let fa = c.obs[f1].applied.load(Ordering::Acquire); + lags.push(head.saturating_sub(fa)); + std::thread::sleep(Duration::from_millis(5)); } let committed = c.reps[l].committed_head() - start_commit; + let left = { let mut q = lock(&c.reps[l].queue); let k = q.len() as u64; q.clear(); k }; + i -= left; + let refused = c.reps[l].q_refused.load(Ordering::Relaxed) - refused0; + c.reps[l].batch.store(GROW_BATCH, Ordering::Relaxed); lags.sort_unstable(); let p99 = lags.get(lags.len() * 99 / 100).copied().unwrap_or(0); println!( - "B {tag} P7 proposed={i} refused={refused} committed_in_{P7_SECS}s={committed} R_per_s={:.1} follower_apply_lag_p99_rounds={p99} lag_samples={}", + "B {tag} P7 path=driver-queue,1-per-turn,GC-off proposed={i} refused={refused} committed_in_{P7_SECS}s={committed} R_per_s={:.1} follower_apply_lag_p99_rounds={p99} lag_samples={}", committed as f64 / P7_SECS as f64, lags.len() ); + std::thread::sleep(Duration::from_millis(50)); let last = c.reps[l].with(|x| x.last_round()).unwrap_or(0); c.wait_applied(&all, last, Duration::from_secs(120)); } @@ -495,59 +577,45 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { let l2id = l2 as u32 + 1; let other = *survivors.iter().find(|i| **i != l2).unwrap(); let term0 = c.reps[l2].with(|x| x.term()).unwrap_or(0); + let leader_before_serve = l2id; // DISPOSE (B2). + for i in [l2, other] { + c.reps[i].max_gap_us.store(0, Ordering::Relaxed); + } let t_disp = Instant::now(); let (props, last_round, prefix) = match arm { Arm::Head => { - if n <= 100_000 { - let ledger = match &c.ledgers[l2] { - ArmL::Head(x) => x.clone(), - _ => unreachable!(), - }; - let agents = ClusterAgents::new(NodeId(l2id), Arc::new(AgentRuntime::new()), c.reps[l2].clone(), ledger); - match agents.abandon_orphans_of(NodeId(lid)) { - Ok(out) => (out.len() as u64, out.last().map(|x| x.1).unwrap_or(0), false), - Err(e) => { - println!("B {tag} abandon_orphans_of REFUSED after {} proposals: {e}", agents.cost().proposals); - (agents.cost().proposals, 0, false) - } - } - } else { - // The sweep's first K proposals: the same loop body as abandon_orphans_of - // (cluster.rs :1239-1245), over the real orphans_of. - let orphans = match &c.ledgers[l2] { - ArmL::Head(x) => lock(x).orphans_of(NodeId(lid)), - _ => unreachable!(), - }; - let mut last = 0; - let mut k = 0u64; - for id in orphans.into_iter().take(PREFIX_K) { - match c.reps[l2].propose(abandon_c(id.0)) { - Ok(r) => { - last = r; - k += 1; - } - Err(e) => { - println!("B {tag} prefix proposal refused after {k}: {e}"); - break; - } - } + // The real sweep, on the seam NodeReplicator's shape gives it: proposals from this + // (the caller's) thread, sharing the node mutex with the driver. + let ledger = match &c.ledgers[l2] { + ArmL::Head(x) => x.clone(), + _ => unreachable!(), + }; + let agents = ClusterAgents::new(NodeId(l2id), Arc::new(AgentRuntime::new()), c.reps[l2].clone(), ledger); + match agents.abandon_orphans_of(NodeId(lid)) { + Ok(out) => (out.len() as u64, out.last().map(|x| x.1).unwrap_or(0), false), + Err(e) => { + println!("B {tag} abandon_orphans_of REFUSED after {} proposals: {e}", agents.cost().proposals); + (agents.cost().proposals, 0, false) } - (k, last, true) } } - Arm::Gc => { + Arm::HeadQ | Arm::Gc => { + // HEAD's per-branch sweep (the real orphans_of, one Abandon per orphan), proposed by the + // driver: HEADQ one per turn with one fsync each; GC up to GROW_BATCH per turn, one fsync. let orphans = match &c.ledgers[l2] { ArmL::Head(x) => lock(x).orphans_of(NodeId(lid)), _ => unreachable!(), }; - let k = orphans.len() as u64; - match propose_batched(&c, l2, orphans.into_iter().map(|id| abandon_c(id.0)).collect()) { - Ok(r) => (k, r, false), + let (batch, take) = if arm == Arm::HeadQ { (1, if n > 100_000 { PREFIX_K } else { orphans.len() }) } else { (GROW_BATCH, orphans.len()) }; + let cmds: Vec = orphans.into_iter().take(take).map(|id| abandon_c(id.0)).collect(); + let k = cmds.len() as u64; + match propose_queued(&c, l2, cmds, batch, Duration::from_secs(1100)) { + Ok(r) => (k, r, take < n as usize), Err(e) => { - println!("B {tag} GC sweep refused: {e}"); - (k, 0, false) + println!("B {tag} queued sweep refused: {e}"); + (k, 0, take < n as usize) } } } @@ -565,15 +633,23 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { let term1 = c.reps[l2].with(|x| x.term()).unwrap_or(0); let leader_after = c.reps[other].leader(); let max_apply_ns: Vec = [l2, other].iter().map(|i| c.obs[*i].max_apply_ns.load(Ordering::Relaxed)).collect(); + let max_gap_ms: Vec = [l2, other].iter().map(|i| c.reps[*i].max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); let syncs_after: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.persist_syncs()).unwrap_or(0)).collect(); let sync_delta: Vec = syncs_after.iter().zip(&syncs_before).map(|(a, b)| a - b).collect(); println!( - "B {tag} B1_t_elect_ms={t_elect_ms:.1} B2_dispose_ms={dispose_ms:.1} propose_ms={propose_ms:.1} C0_props={props} prefix={prefix} per_prop_ms={:.3} applied_all={applied} B4_term_before={term0} term_after={term1} leader_seen_by_other={leader_after:?} max_apply_ns_survivors={max_apply_ns:?} persist_syncs_since_failover_survivors={sync_delta:?}", + "B {tag} B1_t_elect_ms={t_elect_ms:.1} B2_dispose_ms={dispose_ms:.1} propose_ms={propose_ms:.1} C0_props={props} prefix={prefix} per_prop_ms={:.3} applied_all={applied} B4_term_before={term0} term_after={term1} leader_seen_by_other={leader_after:?} max_apply_ns_survivors={max_apply_ns:?} driver_max_gap_ms_survivors={max_gap_ms:?} persist_syncs_since_failover_survivors={sync_delta:?}", if props > 0 { dispose_ms / props as f64 } else { f64::NAN } ); - // SERVE (B3): a fresh fork and merge on L' until the verdict is Applied. + // SERVE (B3): a fresh fork and merge on whoever leads now (L' unless the sweep deposed it). let t_serve = Instant::now(); + let l2 = match c.elect(&survivors, Duration::from_secs(60)) { + Some((x, _)) => x, + None => l2, + }; + let other = *survivors.iter().find(|i| **i != l2).unwrap(); + let l2id = l2 as u32 + 1; + println!("B {tag} B3_leader_after_dispose=n{l2id} (changed={})", l2id != leader_before_serve); let fresh = cid(l2id, 1); let serve = (|| -> Result<(Round, Option), String> { let fr = c.reps[l2].propose(fork_c(fresh)).map_err(|e| e.to_string())?; @@ -597,7 +673,7 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { // Integers C1 (L's branches still live) and C2 (fresh fork wrongly not live before its merge: // checked through the merge verdict above; here the survivors' liveness of L's branches). let c1: Vec = [l2, other].iter().map(|i| c.ledgers[*i].live_count_of(lid)).collect(); - println!("B {tag} C1_L_live_on_survivors={c1:?} (prefix run: expected N-K on HEAD) errs={:?}", lock(&c.errs)); + println!("B {tag} C1_L_live_on_survivors={c1:?} (HEADQ prefix run at N>1e5: expected N-K) errs={:?}", lock(&c.errs)); // B5: restart the non-leader survivor and time the replay to its last log round. let last_log = c.reps[other].with(|x| x.last_round()).unwrap_or(0); @@ -635,6 +711,7 @@ fn main() { .split(',') .map(|a| match a { "HEAD" => Arm::Head, + "HEADQ" => Arm::HeadQ, "GC" => Arm::Gc, "ZK" => Arm::Zk, "M1" => Arm::M1, From 5905408be4af2d4242085f0d3c8fef267ddd0b47 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:08:36 -0400 Subject: [PATCH 283/315] r11-dist: part A adm mode (admitted merges per batch: HEAD scalar rule vs per-key certification) --- examples/r11_dist_ledger.rs | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index 2c637430..a614358b 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -17,6 +17,7 @@ //! p8 safety equivalence on a seeded trace, negative controls, and `conform` //! o2 E-O2 part A (C0..C4), mutants F and S, anti-vacuity, negative control //! timed P1, P1-fire, A-S, A-Z (run it under lockrun) +//! adm admitted merges per commit batch: HEAD's scalar rule vs per-key certification use std::alloc::{GlobalAlloc, Layout, System}; use std::collections::{BTreeMap, BTreeSet}; @@ -1466,6 +1467,67 @@ fn mode_timed() { } } +// --------------------------------------------------------------------------------------------- +// adm: what a batch of concurrent merges admits. After OFL and group commit the log carries only +// merges, so this is O3's ceiling. HEAD: the real ledger's scalar `last_base_move` rule. PK: the +// published per-key certification (Database State Machine, Pedone et al. 1999; Hyder), in which a +// merge is Applied iff no key it read was written after its base. Each merge reads and writes one +// key drawn from K keys; every merge in a batch carries the head before the batch as its base. +// --------------------------------------------------------------------------------------------- + +fn mode_adm() { + let rounds = 64u64; + for &k in &[1u64, 16, 256, 1024] { + // HEAD, the real ledger + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let mut head = Head(ledger.clone()); + let mut round = 0; + for i in 1..=k * rounds { + round += 1; + head.apply(&Entry { term: 1, round, command: fork_c(cid(1, i)) }); + } + let mut applied = Vec::new(); + for b in 0..rounds { + let base = round; + let mut a = 0u64; + for j in 0..k { + round += 1; + if head.apply(&Entry { term: 1, round, command: merge_c(cid(1, b * k + j + 1), base) }) == Some(V::Applied) { + a += 1; + } + } + applied.push(a); + } + let (mn, mx) = (applied.iter().min().unwrap(), applied.iter().max().unwrap()); + println!("ADM arm=HEAD k={k} batches={rounds} applied_per_batch_min={mn} max={mx}"); + check("ADM-HEAD", "HEAD", "exactly 1 per batch", *mn == 1 && *mx == 1, format!("{mn}..{mx}")); + // PK, per-key certification + for &keys in &[10u64, 1_000, 1_000_000] { + let mut rng = Rng(0xAD0 ^ k ^ keys); + let mut last_write: BTreeMap = BTreeMap::new(); + let mut round = 0; + let mut total = 0u64; + let mut expect = 0.0f64; + for _ in 0..rounds { + let base = round; + for _ in 0..k { + round += 1; + let key = rng.below(keys); + if last_write.get(&key).copied().unwrap_or(0) <= base { + last_write.insert(key, round); + total += 1; + } + } + expect += keys as f64 * (1.0 - (1.0 - 1.0 / keys as f64).powf(k as f64)); + } + let per = total as f64 / rounds as f64; + let exp = expect / rounds as f64; + println!("ADM arm=PK k={k} keys={keys} batches={rounds} applied_per_batch_mean={per:.2} birthday_expectation={exp:.2}"); + check("ADM-PK", "PK", &format!("within 10% of {exp:.2}"), (per - exp).abs() <= 0.1 * exp.max(1.0), format!("{per:.2}")); + } + } +} + fn main() { let args: Vec = std::env::args().collect(); let arg = |i: usize| -> u64 { args.get(i).and_then(|s| s.parse().ok()).expect("numeric argument") }; @@ -1477,6 +1539,7 @@ fn main() { Some("p8") => mode_p8(arg(2)), Some("o2") => mode_o2(arg(2)), Some("timed") => mode_timed(), + Some("adm") => mode_adm(), _ => { eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed"); std::process::exit(2); From 9b955989d6844447e9c2d8fbc0df19991091bec2 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:07:16 -0400 Subject: [PATCH 284/315] r11-dist: P7 prints driver gaps, terms and leaders after the stream, and voids B1 when the stream moved leadership --- examples/r11_dist_fleet.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 7ca73be0..b48e4c9d 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -528,6 +528,10 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { // P7 (HEAD cluster only, N in {10^3, 10^6}): a single-proposal fork stream, owner PHANTOM. if arm == Arm::Head && (n == 1_000 || n == 1_000_000) { let f1 = (l + 1) % 3; + for r in &c.reps { + r.max_gap_us.store(0, Ordering::Relaxed); + } + let term_p7 = c.reps[l].with(|x| x.term()).unwrap_or(0); let t0 = Instant::now(); let start_commit = c.reps[l].committed_head(); let mut lags: Vec = Vec::new(); @@ -560,6 +564,14 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { std::thread::sleep(Duration::from_millis(50)); let last = c.reps[l].with(|x| x.last_round()).unwrap_or(0); c.wait_applied(&all, last, Duration::from_secs(120)); + let gaps: Vec = c.reps.iter().map(|r| r.max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); + let leaders: Vec> = c.reps.iter().map(|r| r.leader()).collect(); + let terms: Vec = c.reps.iter().map(|r| r.with(|x| x.term()).unwrap_or(0)).collect(); + let moved = c.reps[l].leader() != Some(NodeId(lid)); + println!( + "B {tag} P7_after term_at_start={term_p7} terms={terms:?} leaders={leaders:?} driver_max_gap_ms_per_node={gaps:?} leadership_moved={moved}{}", + if moved { " => B1 for this arm is VOID (the node stopped below is no longer the leader)" } else { "" } + ); } // FAILOVER: stop L; time the election among the survivors (B1). From f77779eaadb6c2c4a38a218345f9fd80edb7d674 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:22:31 -0400 Subject: [PATCH 285/315] r11-dist: B1 decomposed (stop join/drop, claim, NoOp commit) and repeated failovers per N (R11_B1_REPS) --- examples/r11_dist_fleet.rs | 85 +++++++++++++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index b48e4c9d..2b0c7600 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -411,6 +411,42 @@ impl Cluster { polls } + /// `stop_node`, timed in its two halves: joining the driver, then shutting and dropping the node. + fn stop_node_timed(&mut self, i: usize) -> (f64, f64) { + let t0 = Instant::now(); + self.stops[i].store(true, Ordering::Relaxed); + let _ = self.threads[i].take().map(|t| t.join().unwrap_or(0)); + let join_ms = ms(t0); + let t1 = Instant::now(); + if let Some(n) = lock(&self.reps[i].node).take() { + n.shutdown(); + drop(n); + } + (join_ms, ms(t1)) + } + + /// B1 decomposed (PREREG A9): after `i` is stopped, when does a survivor first claim office, + /// and when has that claimant committed the round it held at its claim (its NoOp)? + fn election_phases(&self, survivors: &[usize], bound: Duration) -> (Option, Option) { + let t0 = Instant::now(); + let mut claim: Option<(usize, Round, f64)> = None; + while t0.elapsed() < bound { + if claim.is_none() { + if let Some(x) = survivors.iter().copied().find(|x| self.reps[*x].leader() == Some(NodeId(*x as u32 + 1))) { + let last = self.reps[x].with(|n| n.last_round()).unwrap_or(0); + claim = Some((x, last, ms(t0))); + } + } + if let Some((x, last, c_ms)) = claim { + if self.reps[x].committed_head() >= last { + return (Some(c_ms), Some(ms(t0))); + } + } + std::thread::sleep(Duration::from_micros(100)); + } + (claim.map(|c| c.2), None) + } + /// B5: start node `i` again on its own directory and address, with a fresh ledger. fn restart(&mut self, i: usize) -> Result<(), String> { let id = NodeId(i as u32 + 1); @@ -577,8 +613,13 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { // FAILOVER: stop L; time the election among the survivors (B1). let survivors: Vec = all.iter().copied().filter(|i| *i != l).collect(); let syncs_before: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.persist_syncs()).unwrap_or(0)).collect(); + for r in &c.reps { + r.max_gap_us.store(0, Ordering::Relaxed); + } let t_fail = Instant::now(); - c.stop_node(l); + let (join_ms, drop_ms) = c.stop_node_timed(l); + let t_after_stop = Instant::now(); + let (claim_ms, noop_ms) = c.election_phases(&survivors, Duration::from_secs(120)); let Some((l2, _)) = c.elect(&survivors, Duration::from_secs(120)) else { println!("B {tag} VOID: no leader among survivors in 120 s; errs={:?}", lock(&c.errs)); c.shutdown_all(); @@ -586,6 +627,11 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { return; }; let t_elect_ms = ms(t_fail); + let elect_after_stop_ms = ms(t_after_stop); + let egaps: Vec = survivors.iter().map(|i| c.reps[*i].max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); + println!( + "B {tag} B1_parts stop_join_ms={join_ms:.1} stop_drop_ms={drop_ms:.1} claim_ms_after_stop={claim_ms:?} noop_committed_ms_after_stop={noop_ms:?} elect_ms_after_stop={elect_after_stop_ms:.1} survivor_driver_max_gap_ms={egaps:?}" + ); let l2id = l2 as u32 + 1; let other = *survivors.iter().find(|i| **i != l2).unwrap(); let term0 = c.reps[l2].with(|x| x.term()).unwrap_or(0); @@ -706,6 +752,43 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { } Err(e) => println!("B {tag} B5 SKIPPED: {e}"), } + // A9: repeated failovers at this N, without re-growing: restart every stopped node, let it + // catch up, then stop whoever leads and time the phases. R11_B1_REPS (default 0). + let reps: usize = std::env::var("R11_B1_REPS").ok().and_then(|x| x.parse().ok()).unwrap_or(0); + for rep in 0..reps { + for i in 0..3 { + if lock(&c.reps[i].node).is_none() { + if let Err(e) = c.restart(i) { + println!("B {tag} B1R rep={rep} VOID: restart n{}: {e}", i + 1); + } + } + } + let Some((x, _)) = c.elect(&all, Duration::from_secs(120)) else { + println!("B {tag} B1R rep={rep} VOID: no stable leader"); + break; + }; + let last = c.reps[x].with(|n| n.last_round()).unwrap_or(0); + if !c.wait_applied(&all, last, Duration::from_secs(600)) { + println!("B {tag} B1R rep={rep} VOID: a restarted node did not catch up to {last}"); + break; + } + let rest: Vec = all.iter().copied().filter(|i| *i != x).collect(); + for r in &c.reps { + r.max_gap_us.store(0, Ordering::Relaxed); + } + let t0 = Instant::now(); + let (j, d) = c.stop_node_timed(x); + let ta = Instant::now(); + let (cl, nc) = c.election_phases(&rest, Duration::from_secs(120)); + let ok = c.elect(&rest, Duration::from_secs(120)).is_some(); + let gaps: Vec = rest.iter().map(|i| c.reps[*i].max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); + println!( + "B {tag} B1R rep={rep} stopped=n{} last_round={last} stop_join_ms={j:.1} stop_drop_ms={d:.1} claim_ms_after_stop={cl:?} noop_committed_ms_after_stop={nc:?} elect_ms_after_stop={:.1} B1_total_ms={:.1} elected={ok} survivor_driver_max_gap_ms={gaps:?}", + x + 1, + ms(ta), + ms(t0) + ); + } c.shutdown_all(); drop(c); let _ = std::fs::remove_dir_all(&dir); From ce95dd9398e1b0df389e680022110614bc495011 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:50:22 -0400 Subject: [PATCH 286/315] r11-dist A10: seed salt per run, election_timer instrument, firstfail mode (transitions during growth and election, timers, stop/claim/noop, gaps) --- examples/r11_dist_fleet.rs | 153 ++++++++++++++++++++++++++++++++++++- src/consensus/node.rs | 5 ++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 2b0c7600..679e5af7 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -291,10 +291,14 @@ struct Cluster { errs: Arc>>, } +/// A10: a per-run seed salt. 0 (the default) gives every earlier run's seeds exactly. +static SEED_SALT: AtomicU64 = AtomicU64::new(0); + fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) -> NodeOptions { let peers: BTreeMap = addrs.iter().filter(|(k, _)| **k != id).map(|(k, v)| (*k, *v)).collect(); // NodeOptions::new's own default tick (50 ms) is kept: the production timing. - NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9)) + let salt = SEED_SALT.load(Ordering::Relaxed).wrapping_mul(0xD6E8_FEB8_6659_FD93); + NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt) } fn spawn_driver(rep: Arc, stop: Arc, errs: Arc>>) -> JoinHandle { @@ -794,8 +798,155 @@ fn run_arm(arm: Arm, n: u64, root: &Path) { let _ = std::fs::remove_dir_all(&dir); } +// --------------------------------------------------------------------------------------------- +// A10: first failovers after growth, one per seed, M1 arm. Records each node's role transitions +// during growth and during the election, the election timers, the stop/claim/noop split and the +// survivors' driver gaps. +// --------------------------------------------------------------------------------------------- + +fn role_counts(t: &[(ferrodb::consensus::Role, u64, Option)]) -> String { + use ferrodb::consensus::Role; + let c = |r: Role| t.iter().filter(|x| x.0 == r).count(); + format!("F{}/P{}/C{}/L{}", c(Role::Follower), c(Role::PreCandidate), c(Role::Candidate), c(Role::Leader)) +} + +fn take_all(c: &Cluster) -> Vec)>> { + c.reps.iter().map(|r| r.with(|n| n.take_transitions()).unwrap_or_default()).collect() +} + +fn run_firstfail(n: u64, seed: u64, root: &Path) { + SEED_SALT.store(seed, Ordering::Relaxed); + let tag = format!("N={n} seed={seed}"); + let dir = root.join(format!("ff_{n}_{seed}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut c = Cluster::start(Arm::M1, &dir); + let all = [0usize, 1, 2]; + let Some((l, _)) = c.elect(&all, Duration::from_secs(60)) else { + println!("FF {tag} VOID: no stable leader"); + c.shutdown_all(); + let _ = std::fs::remove_dir_all(&dir); + return; + }; + let lid = l as u32 + 1; + let init = take_all(&c); + let timers_init: Vec<(u32, u32)> = c.reps.iter().map(|r| r.with(|n| n.election_timer()).unwrap_or((0, 0))).collect(); + c.set_gc(true); + for r in &c.reps { + r.max_gap_us.store(0, Ordering::Relaxed); + } + let t0 = Instant::now(); + let grow = propose_queued(&c, l, (1..=n).map(|i| fork_c(cid(lid, i))).collect(), GROW_BATCH, Duration::from_secs(600)); + let grown = match &grow { + Ok(last) => c.wait_applied(&all, *last, Duration::from_secs(600)), + Err(_) => false, + }; + let grow_ms = ms(t0); + c.set_gc(false); + let growth = take_all(&c); + let grow_gaps: Vec = c.reps.iter().map(|r| r.max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); + let terms: Vec = c.reps.iter().map(|r| r.with(|x| x.term()).unwrap_or(0)).collect(); + let leaders: Vec> = c.reps.iter().map(|r| r.leader()).collect(); + println!( + "FF {tag} growth grow_ms={grow_ms:.0} grown={grown} grow_err={:?} leader=n{lid} init_transitions={:?} growth_transitions={:?} growth_driver_max_gap_ms={grow_gaps:?} terms_after_growth={terms:?} leaders_after_growth={leaders:?} timers_after_initial_election={timers_init:?}", + grow.as_ref().err(), + init.iter().map(|t| role_counts(t)).collect::>(), + growth.iter().map(|t| role_counts(t)).collect::>() + ); + if !grown || c.reps[l].leader() != Some(NodeId(lid)) { + println!("FF {tag} VOID: growth did not complete under the same leader"); + c.shutdown_all(); + drop(c); + let _ = std::fs::remove_dir_all(&dir); + return; + } + let survivors: Vec = all.iter().copied().filter(|i| *i != l).collect(); + let timers_at_stop: Vec<(u32, u32)> = survivors.iter().map(|i| c.reps[*i].with(|n| n.election_timer()).unwrap_or((0, 0))).collect(); + for r in &c.reps { + r.max_gap_us.store(0, Ordering::Relaxed); + } + let t_fail = Instant::now(); + let (join_ms, drop_ms) = c.stop_node_timed(l); + // Sample each survivor's timer every ms until one of them leads with its round committed. + let ta = Instant::now(); + let mut draws: Vec> = vec![Vec::new(); survivors.len()]; + let mut claim: Option<(usize, Round, f64)> = None; + let mut noop: Option = None; + while ta.elapsed() < Duration::from_secs(60) { + for (k, i) in survivors.iter().enumerate() { + if let Some((to, _)) = c.reps[*i].with(|n| n.election_timer()) { + if draws[k].last() != Some(&to) { + draws[k].push(to); + } + } + } + if claim.is_none() { + if let Some(x) = survivors.iter().copied().find(|x| c.reps[*x].leader() == Some(NodeId(*x as u32 + 1))) { + let last = c.reps[x].with(|n| n.last_round()).unwrap_or(0); + claim = Some((x, last, ms(ta))); + } + } + if let Some((x, last, _)) = claim { + if c.reps[x].committed_head() >= last { + noop = Some(ms(ta)); + break; + } + } + std::thread::sleep(Duration::from_millis(1)); + } + let elected = c.elect(&survivors, Duration::from_secs(60)); + let elect_ms = ms(ta); + let b1 = ms(t_fail); + let el = take_all(&c); + let egaps: Vec = survivors.iter().map(|i| c.reps[*i].max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); + let term_after: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.term()).unwrap_or(0)).collect(); + println!( + "FF {tag} election winner={:?} B1_ms={b1:.1} stop_join_ms={join_ms:.1} stop_drop_ms={drop_ms:.1} claim_ms={:?} noop_ms={:?} elect_ms={elect_ms:.1} survivors={:?} timers_at_stop(timeout,since_heard)={timers_at_stop:?} timeouts_seen_during_election={draws:?} election_transitions={:?} terms_after={term_after:?} survivor_driver_max_gap_ms={egaps:?}", + elected.map(|(x, _)| format!("n{}", x + 1)), + claim.map(|c| c.2), + noop, + survivors.iter().map(|i| format!("n{}", i + 1)).collect::>(), + survivors.iter().map(|i| role_counts(&el[*i])).collect::>() + ); + c.shutdown_all(); + drop(c); + let _ = std::fs::remove_dir_all(&dir); +} + fn main() { let args: Vec = std::env::args().collect(); + if args.get(1).map(|s| s.as_str()) == Some("firstfail") { + // r11_dist_fleet firstfail + let root = PathBuf::from(args.get(2).expect("scratch root")); + std::fs::create_dir_all(&root).unwrap(); + let samples: Vec<(u64, u64)> = args + .get(3) + .expect("samples") + .split(',') + .map(|x| { + let (a, b) = x.split_once(':').expect("N:seed"); + (a.parse().unwrap(), b.parse().unwrap()) + }) + .collect(); + let budget = Duration::from_secs(args.get(4).and_then(|x| x.parse().ok()).unwrap_or(1000)); + println!("# r11_dist_fleet firstfail samples={samples:?} budget_s={}", budget.as_secs()); + let (med, p99) = b0(&root); + println!("B0 sync_data_ms median={med:.3} p99={p99:.3} samples=2000"); + let t0 = Instant::now(); + for (n, seed) in samples { + // A 10^6 sample needs about 3 min; do not start one that could overrun the hold. + let need = if n >= 1_000_000 { Duration::from_secs(240) } else { Duration::from_secs(20) }; + if t0.elapsed() + need > budget { + println!("FF N={n} seed={seed} SKIPPED: hold budget ({} s used)", t0.elapsed().as_secs()); + continue; + } + let ts = Instant::now(); + run_firstfail(n, seed, &root); + println!("# sample N={n} seed={seed} wall_ms={:.0}", ms(ts)); + } + println!("# done wall_ms={:.0}", ms(t0)); + return; + } let n: u64 = args.get(1).and_then(|s| s.parse().ok()).expect("usage: r11_dist_fleet [HEAD,GC,ZK,M1]"); let root = PathBuf::from(args.get(2).expect("scratch root")); std::fs::create_dir_all(&root).unwrap(); diff --git a/src/consensus/node.rs b/src/consensus/node.rs index 6b1f70fb..9b945bec 100644 --- a/src/consensus/node.rs +++ b/src/consensus/node.rs @@ -430,6 +430,11 @@ impl Node { pub fn persist_syncs(&self) -> u64 { self.persist_syncs } + /// Instrument (r11-dist A10): this node's current election timeout and ticks since it last + /// heard a leader, both in ticks. Read-only. + pub fn election_timer(&self) -> (u32, u32) { + (self.sm.election_timeout, self.sm.since_heard) + } /// r11-dist GC arm: queue every command, then drain once. Returns the log's last round. pub fn propose_many(&mut self, cs: Vec) -> Result { let before = self.log.last_round(); From 9bd055cbcb87b529a9d4f32b2cbb1fabfaa42970 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:54:54 -0400 Subject: [PATCH 287/315] r11-dist A11: growmon mode (per-second counters during a group-committed growth; stall dump of peer progress and transport counters) --- examples/r11_dist_fleet.rs | 113 +++++++++++++++++++++++++++++++++++++ src/consensus/node.rs | 25 ++++++++ 2 files changed, 138 insertions(+) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 679e5af7..803fc735 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -913,8 +913,121 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { let _ = std::fs::remove_dir_all(&dir); } +/// A11: one group-committed growth to N (the fixture every part-B arm used), with a sample of +/// every node each second, and a full dump if the leader's commit index stops while its log is ahead. +fn run_growmon(n: u64, seed: u64, root: &Path) { + SEED_SALT.store(seed, Ordering::Relaxed); + let dir = root.join(format!("gm_{n}_{seed}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let c = Arc::new(std::sync::Mutex::new(Cluster::start(Arm::Gc, &dir))); + let all = [0usize, 1, 2]; + let l = { + let g = lock(&c); + match g.elect(&all, Duration::from_secs(60)) { + Some((l, _)) => l, + None => { + println!("GM VOID: no stable leader"); + return; + } + } + }; + let lid = l as u32 + 1; + let reps: Vec> = lock(&c).reps.clone(); + let obs: Vec> = lock(&c).obs.clone(); + for r in &reps { + r.with(|n| n.set_group_commit(true)); + r.max_gap_us.store(0, Ordering::Relaxed); + let _ = r.with(|n| n.take_transitions()); + } + let stop = Arc::new(AtomicBool::new(false)); + let t0 = Instant::now(); + // The sampler: one line per second; resets each driver's gap so each line is that second's max. + let sampler = { + let reps = reps.clone(); + let obs = obs.clone(); + let stop = stop.clone(); + std::thread::spawn(move || { + let mut last_commit = 0u64; + let mut still = 0u32; + let mut dumped = false; + while !stop.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_secs(1)); + let mut cols = Vec::new(); + for (i, r) in reps.iter().enumerate() { + let gap = r.max_gap_us.swap(0, Ordering::Relaxed) as f64 / 1e3; + let v = r.with(|x| { + (x.commit_round(), x.last_round(), x.term(), x.role(), x.leader(), x.election_timer(), x.persist_syncs(), x.take_transitions().len()) + }); + if let Some((cm, lr, term, role, ld, (to, sh), syncs, tr)) = v { + cols.push(format!( + "n{}:{role:?}/t{term}/ld{:?} commit={cm} last={lr} applied={} timer={to}/{sh} syncs={syncs} transitions={tr} gap_ms={gap:.0} q={}", + i + 1, + ld.map(|x| x.0), + obs[i].applied.load(Ordering::Acquire), + r.queue_len() + )); + } + } + let lc = reps[l].committed_head(); + let ll = reps[l].with(|x| x.last_round()).unwrap_or(0); + println!("GM t={:.0}s {}", t0.elapsed().as_secs_f64(), cols.join(" | ")); + if lc == last_commit && ll > lc { + still += 1; + } else { + still = 0; + } + last_commit = lc; + if still >= 30 && !dumped { + dumped = true; + println!("GM STALL: leader n{} commit {lc} unchanged for {still} s with last {ll}", l + 1); + for (i, r) in reps.iter().enumerate() { + let d = r.with(|x| (x.peer_progress(), x.transport_counters())); + println!("GM STALL-DUMP n{} progress(peer,next,matched,silent,needs_snapshot,diverged)={:?} transport(sent,dropped,received,inbound_dropped,lost_in_flight,inbox_bytes,idle_closed,connect_failures)={:?}", i + 1, d.as_ref().map(|x| &x.0), d.as_ref().map(|x| x.1)); + } + } + } + }) + }; + let res = { + let g = lock(&c); + let r = propose_queued(&g, l, (1..=n).map(|i| fork_c(cid(lid, i))).collect(), GROW_BATCH, Duration::from_secs(600)); + let ok = match &r { + Ok(last) => g.wait_applied(&all, *last, Duration::from_secs(420)), + Err(_) => false, + }; + (r, ok) + }; + stop.store(true, Ordering::Relaxed); + let _ = sampler.join(); + let g = lock(&c); + for (i, r) in g.reps.iter().enumerate() { + let d = r.with(|x| (x.peer_progress(), x.transport_counters(), x.commit_round(), x.last_round())); + println!("GM END n{} applied={} (progress, transport, commit, last)={:?}", i + 1, g.obs[i].applied.load(Ordering::Acquire), d); + } + println!("GM RESULT N={n} seed={seed} leader=n{lid} proposed={:?} grown={} grow_ms={:.0}", res.0, res.1, ms(t0)); + drop(g); + if let Ok(m) = Arc::try_unwrap(c) { + let mut cl = m.into_inner().unwrap_or_else(PoisonError::into_inner); + cl.shutdown_all(); + } + let _ = std::fs::remove_dir_all(&dir); +} + fn main() { let args: Vec = std::env::args().collect(); + if args.get(1).map(|s| s.as_str()) == Some("growmon") { + // r11_dist_fleet growmon : one group-committed growth, sampled each second. + let root = PathBuf::from(args.get(2).expect("scratch root")); + let n: u64 = args.get(3).and_then(|x| x.parse().ok()).expect("N"); + let seed: u64 = args.get(4).and_then(|x| x.parse().ok()).unwrap_or(0); + std::fs::create_dir_all(&root).unwrap(); + println!("# r11_dist_fleet growmon N={n} seed={seed}"); + let (med, p99) = b0(&root); + println!("B0 sync_data_ms median={med:.3} p99={p99:.3} samples=2000"); + run_growmon(n, seed, &root); + return; + } if args.get(1).map(|s| s.as_str()) == Some("firstfail") { // r11_dist_fleet firstfail let root = PathBuf::from(args.get(2).expect("scratch root")); diff --git a/src/consensus/node.rs b/src/consensus/node.rs index 9b945bec..c20c3ac8 100644 --- a/src/consensus/node.rs +++ b/src/consensus/node.rs @@ -435,6 +435,31 @@ impl Node { pub fn election_timer(&self) -> (u32, u32) { (self.sm.election_timeout, self.sm.since_heard) } + /// Instrument (r11-dist A11): the leader's view of each peer, as (peer, next, matched, silent + /// ticks, needs_snapshot, diverged). Empty on a node that has never led. Read-only. + pub fn peer_progress(&self) -> Vec<(NodeId, Round, Round, u32, bool, Option)> { + let me = self.sm.id(); + self.sm + .progress + .iter() + .filter(|(n, _)| **n != me) + .map(|(n, p)| (*n, p.next, p.matched, p.silent, p.needs_snapshot, p.diverged.clone())) + .collect() + } + /// Instrument (r11-dist A11): transport counters, as (sent, dropped, received, inbound_dropped, + /// lost_in_flight, inbox_bytes, idle_closed, connect_failures). Read-only. + pub fn transport_counters(&self) -> (u64, u64, u64, u64, u64, usize, u64, u64) { + ( + self.net.sent(), + self.net.dropped(), + self.net.received(), + self.net.inbound_dropped(), + self.net.lost_in_flight(), + self.net.inbox_bytes(), + self.net.idle_closed(), + self.net.connect_failures(), + ) + } /// r11-dist GC arm: queue every command, then drain once. Returns the log's last round. pub fn propose_many(&mut self, cs: Vec) -> Result { let before = self.log.last_round(); From 24f8dd024dab07cd3c36f718485b38afa73c39c7 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:10:11 -0400 Subject: [PATCH 288/315] r11-dist A12: idle-deadline and pre-failover idle controls; transport counter deltas over the election --- examples/r11_dist_fleet.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 803fc735..d531d067 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -298,7 +298,12 @@ fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) let peers: BTreeMap = addrs.iter().filter(|(k, _)| **k != id).map(|(k, v)| (*k, *v)).collect(); // NodeOptions::new's own default tick (50 ms) is kept: the production timing. let salt = SEED_SALT.load(Ordering::Relaxed).wrapping_mul(0xD6E8_FEB8_6659_FD93); - NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt) + let mut o = NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt); + // A12 control: the transport's idle close (default 60 s), overridable per run. + if let Some(s) = std::env::var("R11_IDLE_DEADLINE_S").ok().and_then(|x| x.parse::().ok()) { + o.transport.idle_deadline = Duration::from_secs(s); + } + o } fn spawn_driver(rep: Arc, stop: Arc, errs: Arc>>) -> JoinHandle { @@ -861,6 +866,11 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { return; } let survivors: Vec = all.iter().copied().filter(|i| *i != l).collect(); + // A12 control: idle the cluster (no proposals) before the failover. + if let Some(s) = std::env::var("R11_PRE_FAIL_SLEEP_S").ok().and_then(|x| x.parse::().ok()) { + std::thread::sleep(Duration::from_secs(s)); + } + let tc_before: Vec> = survivors.iter().map(|i| c.reps[*i].with(|n| n.transport_counters())).collect(); let timers_at_stop: Vec<(u32, u32)> = survivors.iter().map(|i| c.reps[*i].with(|n| n.election_timer()).unwrap_or((0, 0))).collect(); for r in &c.reps { r.max_gap_us.store(0, Ordering::Relaxed); @@ -900,6 +910,18 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { let el = take_all(&c); let egaps: Vec = survivors.iter().map(|i| c.reps[*i].max_gap_us.load(Ordering::Relaxed) as f64 / 1e3).collect(); let term_after: Vec = survivors.iter().map(|i| c.reps[*i].with(|x| x.term()).unwrap_or(0)).collect(); + let tc_after: Vec> = survivors.iter().map(|i| c.reps[*i].with(|n| n.transport_counters())).collect(); + // (idle_closed, lost_in_flight, connect_failures, dropped, inbound_dropped) deltas over the election. + let tdelta: Vec> = tc_before + .iter() + .zip(&tc_after) + .map(|(b, a)| match (b, a) { + (Some(b), Some(a)) => Some((a.6 - b.6, a.4 - b.4, a.7 - b.7, a.1 - b.1, a.3 - b.3)), + _ => None, + }) + .collect(); + let idle_closed_total: Vec> = tc_after.iter().map(|a| a.map(|a| a.6)).collect(); + println!("FF {tag} transport election_delta(idle_closed,lost_in_flight,connect_failures,dropped,inbound_dropped)={tdelta:?} idle_closed_total_at_end={idle_closed_total:?} idle_deadline_s={:?} pre_fail_sleep_s={:?}", std::env::var("R11_IDLE_DEADLINE_S").ok(), std::env::var("R11_PRE_FAIL_SLEEP_S").ok()); println!( "FF {tag} election winner={:?} B1_ms={b1:.1} stop_join_ms={join_ms:.1} stop_drop_ms={drop_ms:.1} claim_ms={:?} noop_ms={:?} elect_ms={elect_ms:.1} survivors={:?} timers_at_stop(timeout,since_heard)={timers_at_stop:?} timeouts_seen_during_election={draws:?} election_transitions={:?} terms_after={term_after:?} survivor_driver_max_gap_ms={egaps:?}", elected.map(|(x, _)| format!("n{}", x + 1)), From 0d2cbc5527f6df84ac700b73a7128fd95147d1d4 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sat, 26 Sep 2026 00:11:37 -0400 Subject: [PATCH 289/315] r11-dist A12: per-sample pre-failover idle and idle deadline in the sample spec (N:seed:sleep:deadline) --- examples/r11_dist_fleet.rs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index d531d067..3ec6537d 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -293,6 +293,10 @@ struct Cluster { /// A10: a per-run seed salt. 0 (the default) gives every earlier run's seeds exactly. static SEED_SALT: AtomicU64 = AtomicU64::new(0); +/// A12: per-sample transport idle deadline in seconds (0 = the transport's default). +static IDLE_DEADLINE_S: AtomicU64 = AtomicU64::new(0); +/// A12: per-sample idle wait before the failover, in seconds. +static PRE_FAIL_SLEEP_S: AtomicU64 = AtomicU64::new(0); fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) -> NodeOptions { let peers: BTreeMap = addrs.iter().filter(|(k, _)| **k != id).map(|(k, v)| (*k, *v)).collect(); @@ -300,8 +304,9 @@ fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) let salt = SEED_SALT.load(Ordering::Relaxed).wrapping_mul(0xD6E8_FEB8_6659_FD93); let mut o = NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt); // A12 control: the transport's idle close (default 60 s), overridable per run. - if let Some(s) = std::env::var("R11_IDLE_DEADLINE_S").ok().and_then(|x| x.parse::().ok()) { - o.transport.idle_deadline = Duration::from_secs(s); + let d = std::env::var("R11_IDLE_DEADLINE_S").ok().and_then(|x| x.parse::().ok()).unwrap_or(IDLE_DEADLINE_S.load(Ordering::Relaxed)); + if d > 0 { + o.transport.idle_deadline = Duration::from_secs(d); } o } @@ -867,8 +872,9 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { } let survivors: Vec = all.iter().copied().filter(|i| *i != l).collect(); // A12 control: idle the cluster (no proposals) before the failover. - if let Some(s) = std::env::var("R11_PRE_FAIL_SLEEP_S").ok().and_then(|x| x.parse::().ok()) { - std::thread::sleep(Duration::from_secs(s)); + let sl = std::env::var("R11_PRE_FAIL_SLEEP_S").ok().and_then(|x| x.parse::().ok()).unwrap_or(PRE_FAIL_SLEEP_S.load(Ordering::Relaxed)); + if sl > 0 { + std::thread::sleep(Duration::from_secs(sl)); } let tc_before: Vec> = survivors.iter().map(|i| c.reps[*i].with(|n| n.transport_counters())).collect(); let timers_at_stop: Vec<(u32, u32)> = survivors.iter().map(|i| c.reps[*i].with(|n| n.election_timer()).unwrap_or((0, 0))).collect(); @@ -921,7 +927,7 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { }) .collect(); let idle_closed_total: Vec> = tc_after.iter().map(|a| a.map(|a| a.6)).collect(); - println!("FF {tag} transport election_delta(idle_closed,lost_in_flight,connect_failures,dropped,inbound_dropped)={tdelta:?} idle_closed_total_at_end={idle_closed_total:?} idle_deadline_s={:?} pre_fail_sleep_s={:?}", std::env::var("R11_IDLE_DEADLINE_S").ok(), std::env::var("R11_PRE_FAIL_SLEEP_S").ok()); + println!("FF {tag} transport election_delta(idle_closed,lost_in_flight,connect_failures,dropped,inbound_dropped)={tdelta:?} idle_closed_total_at_end={idle_closed_total:?} idle_deadline_s={} pre_fail_sleep_s={}", IDLE_DEADLINE_S.load(Ordering::Relaxed), PRE_FAIL_SLEEP_S.load(Ordering::Relaxed)); println!( "FF {tag} election winner={:?} B1_ms={b1:.1} stop_join_ms={join_ms:.1} stop_drop_ms={drop_ms:.1} claim_ms={:?} noop_ms={:?} elect_ms={elect_ms:.1} survivors={:?} timers_at_stop(timeout,since_heard)={timers_at_stop:?} timeouts_seen_during_election={draws:?} election_transitions={:?} terms_after={term_after:?} survivor_driver_max_gap_ms={egaps:?}", elected.map(|(x, _)| format!("n{}", x + 1)), @@ -1054,13 +1060,14 @@ fn main() { // r11_dist_fleet firstfail let root = PathBuf::from(args.get(2).expect("scratch root")); std::fs::create_dir_all(&root).unwrap(); - let samples: Vec<(u64, u64)> = args + // N:seed[:pre-fail-sleep-s[:idle-deadline-s]] + let samples: Vec<(u64, u64, u64, u64)> = args .get(3) .expect("samples") .split(',') .map(|x| { - let (a, b) = x.split_once(':').expect("N:seed"); - (a.parse().unwrap(), b.parse().unwrap()) + let f: Vec = x.split(':').map(|v| v.parse().unwrap()).collect(); + (f[0], f[1], f.get(2).copied().unwrap_or(0), f.get(3).copied().unwrap_or(0)) }) .collect(); let budget = Duration::from_secs(args.get(4).and_then(|x| x.parse().ok()).unwrap_or(1000)); @@ -1068,9 +1075,11 @@ fn main() { let (med, p99) = b0(&root); println!("B0 sync_data_ms median={med:.3} p99={p99:.3} samples=2000"); let t0 = Instant::now(); - for (n, seed) in samples { + for (n, seed, sleep_s, deadline_s) in samples { + PRE_FAIL_SLEEP_S.store(sleep_s, Ordering::Relaxed); + IDLE_DEADLINE_S.store(deadline_s, Ordering::Relaxed); // A 10^6 sample needs about 3 min; do not start one that could overrun the hold. - let need = if n >= 1_000_000 { Duration::from_secs(240) } else { Duration::from_secs(20) }; + let need = if n >= 1_000_000 { Duration::from_secs(240) } else { Duration::from_secs(20 + sleep_s) }; if t0.elapsed() + need > budget { println!("FF N={n} seed={seed} SKIPPED: hold budget ({} s used)", t0.elapsed().as_secs()); continue; From 1e6aae9639641a4c7cc4e51269641ff8090d9bd8 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:05:35 -0400 Subject: [PATCH 290/315] UNBUILT r11-dist item 3: M1 retire-at-snapshot with the fence map persisted (HeadCopy::snapshot_retire, per-owner retired high-water marks); modes retire, s6 (drop-fence mutant at a floor above the fence), conformwide (18 HEAD apply categories; M1 with and without snapshots) --- examples/r11_dist_ledger.rs | 323 +++++++++++++++++++++++++++++++++++- 1 file changed, 318 insertions(+), 5 deletions(-) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index a614358b..9b1d8ab2 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -18,6 +18,10 @@ //! o2 E-O2 part A (C0..C4), mutants F and S, anti-vacuity, negative control //! timed P1, P1-fire, A-S, A-Z (run it under lockrun) //! adm admitted merges per commit batch: HEAD's scalar rule vs per-key certification +//! retire item 3: M1 retire-at-snapshot, records and heap before/after, then a tail +//! s6 item 3: a snapshot that drops the fence map must be caught +//! conformwide HEAD vs the copy on a trace that reaches every apply path; M1 with and +//! without snapshots on the same trace use std::alloc::{GlobalAlloc, Layout, System}; use std::collections::{BTreeMap, BTreeSet}; @@ -219,6 +223,12 @@ struct HeadCopy { /// Copy mutant for `conform`: `>=` for `>` in the verdict. mut_ge: bool, touched: u64, + /// Item 3 (retire-at-snapshot): per owner, the highest local id a snapshot has retired. A later + /// fork of an id at or below it is refused, as HEAD refuses a fork of any id it ever recorded. + /// Assumes per-owner ids are minted in increasing order (the owner's catalog `next_id`). + retired_hwm: BTreeMap, + /// Branch ops this copy refused, counted where HEAD returns `BranchEffect::Rejected`. + rejected: u64, } impl HeadCopy { @@ -238,9 +248,45 @@ impl HeadCopy { mut_nofence: false, mut_ge: false, touched: 0, + retired_hwm: BTreeMap::new(), + rejected: 0, } } + /// Item 3: a snapshot of this ledger at its `last_applied`, retiring at most `budget` records + /// (in id order) that can never be live again: Merged, Abandoned, Reaped, or fenced. Keeps the + /// fence map, `lbm`, `last_applied` and the retired high-water marks. A snapshot writer walks the + /// records once (O(records)) and the result holds O(live + unretired + nodes). + /// `drop_fence` is S6's mutant (r11-dist-refute-proof): a snapshot that loses the fence map. + fn snapshot_retire(&self, budget: u64, drop_fence: bool) -> (HeadCopy, u64) { + let mut br = BTreeMap::new(); + let mut hwm = self.retired_hwm.clone(); + let mut retired = 0u64; + for (id, r) in &self.br { + if *id != 0 && retired < budget && !self.rec_live(*id, r) { + retired += 1; + let h = hwm.entry(owner_of(*id)).or_insert(0); + *h = (*h).max(*id & LOCAL_MASK); + continue; + } + br.insert(*id, *r); + } + let snap = HeadCopy { + br, + last_applied: self.last_applied, + lbm: self.lbm, + fence: if drop_fence { BTreeMap::new() } else { self.fence.clone() }, + m1: self.m1, + mut_f: self.mut_f, + mut_nofence: self.mut_nofence, + mut_ge: self.mut_ge, + touched: 0, + retired_hwm: hwm, + rejected: self.rejected, + }; + (snap, retired) + } + fn rec_live(&self, id: u64, r: &Rec) -> bool { if r.st != St::Live { return false; @@ -276,11 +322,20 @@ impl Ledger for HeadCopy { match op { BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { if *child == 0 || self.br.contains_key(child) { + self.rejected += 1; + return None; + } + if self.retired_hwm.get(&owner_of(*child)).is_some_and(|h| *child & LOCAL_MASK <= *h) { + // A retired id: HEAD still holds its record and refuses the fork as a collision. + self.rejected += 1; return None; } match self.br.get(parent) { Some(p) if self.rec_live(*parent, p) => {} - _ => return None, + _ => { + self.rejected += 1; + return None; + } } self.br.insert( *child, @@ -317,19 +372,21 @@ impl Ledger for HeadCopy { *f = (*f).max(round); return None; } - if let Some(r) = self.br.get(branch).copied() { + match self.br.get(branch).copied() { // HEAD checks `state.is_live()` only (not the fence): kept identical. - if r.st == St::Live { + Some(r) if r.st == St::Live => { self.br.get_mut(branch).unwrap().st = St::Abandoned(round); } + _ => self.rejected += 1, } None } BranchOp::Reap { branch, generation } => { - if let Some(r) = self.br.get_mut(branch) { - if !matches!(r.st, St::Reaped(..)) { + match self.br.get_mut(branch) { + Some(r) if !matches!(r.st, St::Reaped(..)) => { r.st = St::Reaped(round, *generation); } + _ => self.rejected += 1, } None } @@ -1528,6 +1585,259 @@ fn mode_adm() { } } +// --------------------------------------------------------------------------------------------- +// Item 3: retire-at-snapshot for M1 (Raft ch. 5 compaction plus retirement of dead records), +// with the fence map persisted. PREREG A13. +// --------------------------------------------------------------------------------------------- + +fn mode_retire(t: u64, n: u64, n2: u64) { + assert!(n <= t); + println!("# retire T={t} N={n} N2={n2}"); + let h0 = heap(); + let mut m1 = HeadCopy::new(true); + let mut round = run_w(t, n, |e| { + m1.apply(&e); + }); + for i in 1..=n2 { + round += 1; + m1.apply(&Entry { term: 1, round, command: fork_c(cid(2, i)) }); + } + round += 1; + m1.apply(&Entry { term: 2, round, command: abandon_c(sentinel(1)) }); + let h1 = heap(); + let before = m1.records(); + let live1_before = m1.live_ids_of(1).len(); + let live2_before = m1.live_ids_of(2).len(); + let (snap, retired) = m1.snapshot_retire(u64::MAX, false); + let h2 = heap(); + println!( + "RETIRE T={t} N={n} N2={n2} records_before={before} records_after={} retired={retired} heap_ledger_bytes={} heap_snapshot_bytes={} live_n1={live1_before}->{} live_n2={live2_before}->{}", + snap.records(), + h1 - h0, + h2 - h1, + snap.live_ids_of(1).len(), + snap.live_ids_of(2).len() + ); + check("R-records-before", "M1", &format!("{}", t + n2 + 1), before as u64 == t + n2 + 1, before.to_string()); + check("R-records-after", "M1+retire", &format!("{}", n2 + 1), snap.records() as u64 == n2 + 1, snap.records().to_string()); + check("R-retired", "M1+retire", &t.to_string(), retired == t, retired.to_string()); + check("R-live", "M1+retire", &format!("n1 0, n2 {n2}"), snap.live_ids_of(1).is_empty() && snap.live_ids_of(2).len() as u64 == n2, format!("{} {}", snap.live_ids_of(1).len(), snap.live_ids_of(2).len())); + let ratio = (h2 - h1) as f64 / (h1 - h0) as f64; + let pred = (n2 + 1) as f64 / (t + n2 + 1) as f64; + check("R-heap", "M1+retire", &format!("snapshot/ledger within 20% of {pred:.4}"), (ratio / pred - 1.0).abs() <= 0.2, format!("{ratio:.4}")); + // The same tail through the unretired ledger and the restored snapshot must agree exactly. + let mut a = m1; + let mut b = snap; + let mut tail = Vec::new(); + let base = round; + for i in 1..=100u64 { + tail.push(merge_c(cid(2, i), base)); // the first applies, the rest re-evaluate + } + tail.push(merge_c(cid(1, t), base)); // a zombie merge of a fenced n1 branch + tail.push(merge_c(cid(1, 1), base)); // a merge of a merged or abandoned n1 branch + tail.push(fork_c(cid(1, 2))); // a fork of a retired id: HEAD refuses it as a collision + tail.push(fork_c(cid(1, t + 1))); // n1's new work after the fence: live + tail.push(merge_c(cid(1, t + 1), base + 104)); // and its merge + tail.push(abandon_c(cid(1, 3))); // an abandon of a retired id: refused + let mut div = 0u64; + for c in tail { + round += 1; + let e = Entry { term: 3, round, command: c }; + let (ra, rb) = (a.rejected, b.rejected); + let (va, vb) = (a.apply(&e), b.apply(&e)); + if va != vb || (a.rejected - ra) != (b.rejected - rb) { + div += 1; + } + } + let ids: Vec = (1..=100).map(|i| cid(2, i)).chain([cid(1, 1), cid(1, 2), cid(1, 3), cid(1, t), cid(1, t + 1)]).collect(); + div += ids.iter().filter(|id| a.is_live(**id) != b.is_live(**id)).count() as u64; + check("R-tail", "M1 vs M1+retire", "0 divergences", div == 0, div.to_string()); +} + +fn mode_s6(n: u64, budget: u64) { + println!("# s6 N={n} budget={budget}"); + let mut m1 = HeadCopy::new(true); + let mut round = 0; + for i in 1..=n { + round += 1; + m1.apply(&Entry { term: 1, round, command: fork_c(cid(1, i)) }); + } + round += 1; + m1.apply(&Entry { term: 2, round, command: abandon_c(sentinel(1)) }); + // The floor is ABOVE the fence (the case O2's fixture never reached). + for arm in ["correct", "mutant-drop-fence"] { + for b in [budget, u64::MAX] { + let (snap, retired) = m1.snapshot_retire(b, arm != "correct"); + let live = snap.live_ids_of(1).len() as u64; + let bs = if b == u64::MAX { "all".to_string() } else { b.to_string() }; + println!("S6 arm={arm} budget={bs} retired={retired} n1_live_after_restore={live}"); + match (arm, b == u64::MAX) { + ("correct", _) => check("S6-correct", arm, "0", live == 0, live.to_string()), + (_, false) => check("S6-mutant-caught", arm, &format!("{}", n - budget.min(n)), live == n - budget.min(n), live.to_string()), + (_, true) => println!("S6 note: after a FULL retire the mutant leaves {live} live; the fence map is redundant only when every fenced record was retired"), + } + } + } +} + +/// A HEAD-protocol log that reaches every apply path, including the refusals. +fn gen_wide(seed: u64, len: usize) -> Vec { + let mut rng = Rng(seed ^ 0x51DE); + let mut next = [0u64; 4]; + let mut known: Vec = Vec::new(); + let mut out = Vec::with_capacity(len); + let mut wal = 0u64; + while out.len() < len { + let r = rng.below(1000); + let o = 1 + rng.below(3) as u32; + let pick = |rng: &mut Rng, known: &Vec| -> u64 { if known.is_empty() { 0 } else { known[rng.below(known.len() as u64) as usize] } }; + let c = if r < 350 || known.is_empty() { + next[o as usize] += 1; + let id = cid(o, next[o as usize]); + let parent = if rng.below(5) == 0 { pick(&mut rng, &known) } else { 0 }; + known.push(id); + Command::Branch { op: BranchOp::Fork { child: id, parent, fork_epoch: 1, lease_millis: 900_000 } } + } else if r < 500 { + let head = out.len() as Round; + merge_c(pick(&mut rng, &known), head.saturating_sub(rng.below(20))) + } else if r < 620 { + abandon_c(pick(&mut rng, &known)) + } else if r < 660 { + Command::Branch { op: BranchOp::Reap { branch: pick(&mut rng, &known), generation: 1 } } + } else if r < 800 { + wal += 1; + wal_c(wal) + } else if r < 815 { + abandon_c(sentinel(o)) + } else if r < 825 { + fork_c(0) // a fork whose child is the trunk + } else if r < 840 { + fork_c(pick(&mut rng, &known)) // a collision with a recorded id + } else if r < 850 { + Command::Branch { op: BranchOp::Fork { child: cid(o, 900_000_000 + rng.below(1 << 20)), parent: cid(3, 800_000_000), fork_epoch: 1, lease_millis: 1 } } // unknown parent + } else if r < 860 { + abandon_c(cid(o, 700_000_000 + rng.below(1 << 20))) // unknown + } else if r < 870 { + Command::Branch { op: BranchOp::Reap { branch: cid(o, 600_000_000 + rng.below(1 << 20)), generation: 1 } } // unknown + } else if r < 885 { + merge_c(cid(o, 500_000_000 + rng.below(1 << 20)), out.len() as Round) // unknown + } else if r < 930 { + Command::LeaseTick { unix_millis: out.len() as u64 } + } else if r < 960 { + Command::NoOp + } else { + Command::Checkpoint + }; + out.push(c); + } + out +} + +fn head_category(e: &BranchEffect) -> String { + match e { + BranchEffect::Other { base_moved } => format!("Other(base_moved={base_moved})"), + BranchEffect::AlreadyApplied => "AlreadyApplied".into(), + BranchEffect::Forked(_) => "Forked".into(), + BranchEffect::Merged(MergeVerdict::Applied { .. }) => "Merged(Applied)".into(), + BranchEffect::Merged(MergeVerdict::ReEvaluate { .. }) => "Merged(ReEvaluate)".into(), + BranchEffect::Merged(MergeVerdict::Refused { why, .. }) => { + if why.contains("no committed round created") { "Merged(Refused:unknown)".into() } else { "Merged(Refused:not-live)".into() } + } + BranchEffect::Abandoned(_) => "Abandoned".into(), + BranchEffect::Reaped { .. } => "Reaped".into(), + BranchEffect::Rejected { why } => { + let k = if why.contains("child is the trunk") { + "fork-trunk" + } else if why.contains("already created") { + "fork-collision" + } else if why.contains("forks") && why.contains("which is") { + "fork-parent-not-live" + } else if why.contains("forks") { + "fork-parent-unknown" + } else if why.contains("abandons") && why.contains("which is") { + "abandon-not-live" + } else if why.contains("abandons") { + "abandon-unknown" + } else if why.contains("again") { + "reap-twice" + } else { + "reap-unknown" + }; + format!("Rejected({k})") + } + } +} + +fn mode_conformwide(seed: u64) { + let log: Vec = gen_wide(seed, 200_000) + .into_iter() + .enumerate() + .map(|(i, command)| Entry { term: 1, round: i as Round + 1, command }) + .collect(); + // CW1: the real ledger against the copy, entry by entry. + let real = Arc::new(Mutex::new(BranchLedger::new())); + let mut copy = HeadCopy::new(false); + let mut cats: BTreeMap = BTreeMap::new(); + let mut div = 0u64; + for (i, e) in log.iter().enumerate() { + // Every 9,973rd entry is delivered twice, so the AlreadyApplied path is reached too. + let times = if i % 9973 == 0 { 2 } else { 1 }; + for _ in 0..times { + let eff = lock(&real).apply(e); + *cats.entry(head_category(&eff)).or_default() += 1; + let hv = match &eff { + BranchEffect::Merged(v) => Some(class(v)), + _ => None, + }; + let hr = matches!(eff, BranchEffect::Rejected { .. }); + let r0 = copy.rejected; + let cv = copy.apply(e); + if hv != cv || hr != (copy.rejected > r0) { + div += 1; + } + } + } + let ids: BTreeSet = lock(&real).all().map(|b| b.id.0).collect(); + let head = Head(real); + div += ids.iter().filter(|id| head.is_live(**id) != copy.is_live(**id)).count() as u64; + println!("CW1 seed={seed} entries={} HEAD_categories={} {:?}", log.len(), cats.len(), cats); + check("CW1", "Copy", "0 divergences", div == 0, div.to_string()); + check("CW1-coverage", "trace", "18 of HEAD's 18 effect categories", cats.len() == 18, cats.len().to_string()); + // CW2: M1 with no snapshot against M1 restored from snapshots every 2,000 rounds (budget all, + // and budget 50), on the same log. The drop-fence mutant (budget 50) must diverge. + for (label, budget, drop) in [("all", u64::MAX, false), ("b50", 50, false), ("b50-drop-fence", 50, true)] { + let mut plain = HeadCopy::new(true); + let mut snapped = HeadCopy::new(true); + // Divergences that matter: a merge verdict, a fork accepted by one and refused by the other, + // or liveness. Refusals of an abandon or reap of a DEAD id may differ and are counted apart: + // retirement discards the terminal state (Merged vs Reaped) that decides them. + let mut d = 0u64; + let mut refusal_only = 0u64; + for (i, e) in log.iter().enumerate() { + let (pr, sr) = (plain.rejected, snapped.rejected); + let (pv, sv) = (plain.apply(e), snapped.apply(e)); + let (pd, sd) = (plain.rejected - pr, snapped.rejected - sr); + let is_fork = matches!(&e.command, Command::Branch { op: BranchOp::Fork { .. } }); + if pv != sv || (is_fork && pd != sd) { + d += 1; + } else if pd != sd { + refusal_only += 1; + } + if (i + 1) % 2000 == 0 { + snapped = snapped.snapshot_retire(budget, drop).0; + } + } + let all: BTreeSet = plain.br.keys().copied().collect(); + d += all.iter().filter(|id| plain.is_live(**id) != snapped.is_live(**id)).count() as u64; + println!("CW2 seed={seed} arm={label} divergences={d} refusal_only_differences={refusal_only} records_plain={} records_snapped={}", plain.records(), snapped.records()); + if drop { + check("CW2-negctl", label, ">=1", d >= 1, d.to_string()); + } else { + check("CW2", label, "0 divergences", d == 0, d.to_string()); + } + } +} + fn main() { let args: Vec = std::env::args().collect(); let arg = |i: usize| -> u64 { args.get(i).and_then(|s| s.parse().ok()).expect("numeric argument") }; @@ -1540,6 +1850,9 @@ fn main() { Some("o2") => mode_o2(arg(2)), Some("timed") => mode_timed(), Some("adm") => mode_adm(), + Some("retire") => mode_retire(arg(2), arg(3), arg(4)), + Some("s6") => mode_s6(arg(2), arg(3)), + Some("conformwide") => mode_conformwide(arg(2)), _ => { eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed"); std::process::exit(2); From 53ee3dd54e07ed7f1d07c616705cab8ce63587bc Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:06:37 -0400 Subject: [PATCH 291/315] UNBUILT r11-dist A11b: one-shot driver stall injection and an uncommitted-backlog cap (etcd MaxUncommittedEntriesSize, harness form) in growmon --- examples/r11_dist_fleet.rs | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index ffa6b901..861772b7 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -189,6 +189,13 @@ struct Rep { /// Instrument: the longest interval between two driver turns (us), and turns taken. max_gap_us: AtomicU64, turns: AtomicU64, + /// A11b: a one-shot stall of this node's driver, in ms (0 = none). The driver sleeps without the + /// node mutex, so the node takes no tick and reads no message for that long. + stall_ms: AtomicU64, + /// A11b flow-control arm: the driver proposes nothing while this node's uncommitted backlog + /// (last_round - commit_round) exceeds this (0 = no cap). The harness form of etcd raft's + /// MaxUncommittedEntriesSize. + max_uncommitted: AtomicU64, } impl Rep { @@ -202,6 +209,8 @@ impl Rep { q_refused: AtomicU64::new(0), max_gap_us: AtomicU64::new(0), turns: AtomicU64::new(0), + stall_ms: AtomicU64::new(0), + max_uncommitted: AtomicU64::new(0), } } fn enqueue(&self, cmds: impl IntoIterator) { @@ -212,6 +221,13 @@ impl Rep { } /// One driver turn's proposals: take up to `batch` queued commands and propose them in one drain. fn drive_queue(&self) { + let cap = self.max_uncommitted.load(Ordering::Relaxed); + if cap > 0 { + let backlog = self.with(|n| n.last_round().saturating_sub(n.commit_round())).unwrap_or(0); + if backlog > cap { + return; + } + } let k = self.batch.load(Ordering::Relaxed).max(1); let cmds: Vec = { let mut q = lock(&self.queue); @@ -319,6 +335,10 @@ fn spawn_driver(rep: Arc, stop: Arc, errs: Arc 0 { + std::thread::sleep(Duration::from_millis(st)); + } rep.drive_queue(); if let Err(e) = rep.pump() { lock(&errs).push(format!("{} driver: {e}", rep.id)); @@ -977,6 +997,16 @@ fn run_growmon(n: u64, seed: u64, root: &Path) { r.max_gap_us.store(0, Ordering::Relaxed); let _ = r.with(|n| n.take_transitions()); } + // A11b: R11_MAX_UNCOMMITTED caps the leader's uncommitted backlog; R11_STALL=: + // : stalls one driver once, the first second the leader's queue is empty and its + // uncommitted backlog is at least . + let cap: u64 = std::env::var("R11_MAX_UNCOMMITTED").ok().and_then(|x| x.parse().ok()).unwrap_or(0); + reps[l].max_uncommitted.store(cap, Ordering::Relaxed); + let stall: Option<(bool, u64, u64)> = std::env::var("R11_STALL").ok().and_then(|x| { + let f: Vec<&str> = x.split(':').collect(); + Some((f.first()? == &"leader", f.get(1)?.parse().ok()?, f.get(2)?.parse().ok()?)) + }); + println!("GM config max_uncommitted={cap} stall={stall:?}"); let stop = Arc::new(AtomicBool::new(false)); let t0 = Instant::now(); // The sampler: one line per second; resets each driver's gap so each line is that second's max. @@ -988,6 +1018,7 @@ fn run_growmon(n: u64, seed: u64, root: &Path) { let mut last_commit = 0u64; let mut still = 0u32; let mut dumped = false; + let mut stalled = false; while !stop.load(Ordering::Relaxed) { std::thread::sleep(Duration::from_secs(1)); let mut cols = Vec::new(); @@ -1008,6 +1039,14 @@ fn run_growmon(n: u64, seed: u64, root: &Path) { } let lc = reps[l].committed_head(); let ll = reps[l].with(|x| x.last_round()).unwrap_or(0); + if let Some((on_leader, backlog, ms)) = stall { + if !stalled && reps[l].queue_len() == 0 && ll.saturating_sub(lc) >= backlog { + stalled = true; + let target = if on_leader { l } else { (l + 1) % 3 }; + reps[target].stall_ms.store(ms, Ordering::Relaxed); + println!("GM STALL-INJECTED t={:.0}s node=n{} ms={ms} leader_last={ll} commit={lc} backlog={}", t0.elapsed().as_secs_f64(), target + 1, ll - lc); + } + } println!("GM t={:.0}s {}", t0.elapsed().as_secs_f64(), cols.join(" | ")); if lc == last_commit && ll > lc { still += 1; From c2bba62d1079a3e13829397e2f46bd9b65448ded Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:10:27 -0400 Subject: [PATCH 292/315] r11-dist A14: CW2 divergence diagnostic; F-wm apply-time per-owner fork watermark (M1) with its snapshot and a drop-wm negative control (CW3) --- examples/r11_dist_ledger.rs | 96 ++++++++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index 9b1d8ab2..161beddb 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -229,6 +229,11 @@ struct HeadCopy { retired_hwm: BTreeMap, /// Branch ops this copy refused, counted where HEAD returns `BranchEffect::Rejected`. rejected: u64, + /// A14 F-wm: when set (M1 only), a fork is refused unless its local id exceeds the highest local + /// id its owner ever had ACCEPTED. Maintained at apply time, so fork acceptance depends on the log + /// alone, never on when a node took a snapshot. Replaces the snapshot-time `retired_hwm` rule. + use_wm: bool, + fork_wm: BTreeMap, } impl HeadCopy { @@ -250,6 +255,8 @@ impl HeadCopy { touched: 0, retired_hwm: BTreeMap::new(), rejected: 0, + use_wm: false, + fork_wm: BTreeMap::new(), } } @@ -259,6 +266,11 @@ impl HeadCopy { /// records once (O(records)) and the result holds O(live + unretired + nodes). /// `drop_fence` is S6's mutant (r11-dist-refute-proof): a snapshot that loses the fence map. fn snapshot_retire(&self, budget: u64, drop_fence: bool) -> (HeadCopy, u64) { + self.snapshot_retire2(budget, drop_fence, false) + } + + /// `drop_wm` is F-wm's own negative control: a snapshot that loses the watermark map. + fn snapshot_retire2(&self, budget: u64, drop_fence: bool, drop_wm: bool) -> (HeadCopy, u64) { let mut br = BTreeMap::new(); let mut hwm = self.retired_hwm.clone(); let mut retired = 0u64; @@ -283,6 +295,8 @@ impl HeadCopy { touched: 0, retired_hwm: hwm, rejected: self.rejected, + use_wm: self.use_wm, + fork_wm: if drop_wm { BTreeMap::new() } else { self.fork_wm.clone() }, }; (snap, retired) } @@ -325,7 +339,12 @@ impl Ledger for HeadCopy { self.rejected += 1; return None; } - if self.retired_hwm.get(&owner_of(*child)).is_some_and(|h| *child & LOCAL_MASK <= *h) { + if self.use_wm { + if self.fork_wm.get(&owner_of(*child)).is_some_and(|w| *child & LOCAL_MASK <= *w) { + self.rejected += 1; + return None; + } + } else if self.retired_hwm.get(&owner_of(*child)).is_some_and(|h| *child & LOCAL_MASK <= *h) { // A retired id: HEAD still holds its record and refuses the fork as a collision. self.rejected += 1; return None; @@ -347,6 +366,10 @@ impl Ledger for HeadCopy { st: St::Live, }, ); + if self.use_wm { + let w = self.fork_wm.entry(owner_of(*child)).or_insert(0); + *w = (*w).max(*child & LOCAL_MASK); + } None } BranchOp::Merge { branch, base_round } => { @@ -1808,18 +1831,33 @@ fn mode_conformwide(seed: u64) { for (label, budget, drop) in [("all", u64::MAX, false), ("b50", 50, false), ("b50-drop-fence", 50, true)] { let mut plain = HeadCopy::new(true); let mut snapped = HeadCopy::new(true); + // A14 diagnostic: the kind of every divergence, and for a fork, whether the id existed in the + // plain ledger before it and whether its local id was at or below the snapped hwm. + let mut kinds: BTreeMap = BTreeMap::new(); // Divergences that matter: a merge verdict, a fork accepted by one and refused by the other, // or liveness. Refusals of an abandon or reap of a DEAD id may differ and are counted apart: // retirement discards the terminal state (Merged vs Reaped) that decides them. let mut d = 0u64; let mut refusal_only = 0u64; for (i, e) in log.iter().enumerate() { + let fork_child = match &e.command { + Command::Branch { op: BranchOp::Fork { child, .. } } => Some(*child), + _ => None, + }; + let existed = fork_child.map(|c| plain.br.contains_key(&c)); + let under_hwm = fork_child.map(|c| snapped.retired_hwm.get(&owner_of(c)).is_some_and(|h| c & LOCAL_MASK <= *h)); let (pr, sr) = (plain.rejected, snapped.rejected); let (pv, sv) = (plain.apply(e), snapped.apply(e)); let (pd, sd) = (plain.rejected - pr, snapped.rejected - sr); - let is_fork = matches!(&e.command, Command::Branch { op: BranchOp::Fork { .. } }); + let is_fork = fork_child.is_some(); if pv != sv || (is_fork && pd != sd) { d += 1; + let k = if is_fork { + format!("fork(existed_in_plain={:?},local_le_snapped_hwm={:?},plain_refused={})", existed, under_hwm, pd > 0) + } else { + format!("verdict(plain={pv:?},snapped={sv:?})") + }; + *kinds.entry(k).or_default() += 1; } else if pd != sd { refusal_only += 1; } @@ -1827,6 +1865,7 @@ fn mode_conformwide(seed: u64) { snapped = snapped.snapshot_retire(budget, drop).0; } } + println!("CW2-diag seed={seed} arm={label} divergence_kinds={kinds:?}"); let all: BTreeSet = plain.br.keys().copied().collect(); d += all.iter().filter(|id| plain.is_live(**id) != snapped.is_live(**id)).count() as u64; println!("CW2 seed={seed} arm={label} divergences={d} refusal_only_differences={refusal_only} records_plain={} records_snapped={}", plain.records(), snapped.records()); @@ -1836,6 +1875,59 @@ fn mode_conformwide(seed: u64) { check("CW2", label, "0 divergences", d == 0, d.to_string()); } } + // CW3 (A14 F-wm): the same comparison with the apply-time fork watermark in both ledgers. + for (label, budget, drop_fence, drop_wm) in [ + ("wm-all", u64::MAX, false, false), + ("wm-b50", 50, false, false), + ("wm-b50-drop-fence", 50, true, false), + ("wm-all-drop-wm", u64::MAX, false, true), + ] { + let mut plain = HeadCopy::new(true); + plain.use_wm = true; + let mut snapped = HeadCopy::new(true); + snapped.use_wm = true; + let mut d = 0u64; + let mut refusal_only = 0u64; + for (i, e) in log.iter().enumerate() { + let (pr, sr) = (plain.rejected, snapped.rejected); + let (pv, sv) = (plain.apply(e), snapped.apply(e)); + let (pd, sd) = (plain.rejected - pr, snapped.rejected - sr); + let is_fork = matches!(&e.command, Command::Branch { op: BranchOp::Fork { .. } }); + if pv != sv || (is_fork && pd != sd) { + d += 1; + } else if pd != sd { + refusal_only += 1; + } + if (i + 1) % 2000 == 0 { + snapped = snapped.snapshot_retire2(budget, drop_fence, drop_wm).0; + } + } + let all: BTreeSet = plain.br.keys().copied().collect(); + d += all.iter().filter(|id| plain.is_live(**id) != snapped.is_live(**id)).count() as u64; + println!("CW3 seed={seed} arm={label} divergences={d} refusal_only_differences={refusal_only} records_plain={} records_snapped={}", plain.records(), snapped.records()); + if drop_fence || drop_wm { + check("CW3-negctl", label, ">=1", d >= 1, d.to_string()); + } else { + check("CW3", label, "0 divergences", d == 0, d.to_string()); + } + } + // The protocol change F-wm makes, stated: forks HEAD accepts that M1+wm refuses. + let real = Arc::new(Mutex::new(BranchLedger::new())); + let mut wm = HeadCopy::new(true); + wm.use_wm = true; + let mut changed = 0u64; + for e in &log { + let is_fork = matches!(&e.command, Command::Branch { op: BranchOp::Fork { .. } }); + let head_ok = matches!(lock(&real).apply(e), BranchEffect::Forked(_)); + let r0 = wm.rejected; + let before = wm.br.len(); + wm.apply(e); + let wm_ok = is_fork && wm.rejected == r0 && wm.br.len() > before; + if is_fork && head_ok && !wm_ok { + changed += 1; + } + } + println!("CW3 seed={seed} forks_HEAD_accepts_that_M1+wm_refuses={changed} (the stated protocol change; includes forks M1's fence refuses)"); } fn main() { From 1a80a904389a7f4ee9501ce831738084ee7fa67f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 04:50:10 -0400 Subject: [PATCH 293/315] r11-dist A16a: DC1 draw census and DC2 virtual-time split counter on the real Consensus state machine; node_seed_for shared by opts() and both modes --- examples/r11_dist/splitsim.rs | 243 ++++++++++++++++++++++++++++++++++ examples/r11_dist_fleet.rs | 119 ++++++++++++++++- 2 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 examples/r11_dist/splitsim.rs diff --git a/examples/r11_dist/splitsim.rs b/examples/r11_dist/splitsim.rs new file mode 100644 index 00000000..41821802 --- /dev/null +++ b/examples/r11_dist/splitsim.rs @@ -0,0 +1,243 @@ +//! r11-dist DC1 and DC2 (PREREG A16, A16a): the election timer's first draws, and split votes in +//! virtual time, both on the REAL `Consensus` state machine. No socket, thread or wall clock is +//! involved: every number is an integer count or a virtual microsecond, so no timing window is +//! needed and machine load cannot move a result. +//! +//! The transport is MODELLED from its source, not run: +//! - A frame on an open link takes `WIRE_US`. +//! - Links are open from start, because the sender dials eagerly (`transport.rs:1985-1986`). +//! - The first frame on a link the peer closed for idleness waits for a redial. `dial` blocks for +//! the acceptor's handshake reply (`transport.rs:2193`). The acceptor answers only after its +//! nonblocking accept loop's next wake (`transport.rs:2338-2339`, `poll_interval` = 50 ms), so +//! the wait is `Accept`'s, plus `HANDSHAKE_US`. +//! - Later frames on that link queue behind it (FIFO per link, as one TCP connection is). +//! +//! A node performs its actions in order on one thread, as `Node::drain` does: +//! - a `Persist` or a `PersistHardState` costs `FSYNC_US` before any later `Send`; +//! - a `Persist` feeds back `Persisted` with the node's term at perform time (`node.rs:799`). +//! +//! Ticks keep a fixed phase from creation (`node.rs:508-510`). The phases come from a per-salt +//! PRNG that is separate from the node seeds, as independent machines' phases would be. + +use std::collections::{BTreeMap, BTreeSet}; + +use ferrodb::consensus::config::Config; +use ferrodb::consensus::{Action, Consensus, Event, Message, NodeId, Rng, Role, Round, Term}; + +pub const TICK_US: u64 = 50_000; +const WIRE_US: u64 = 100; +const HANDSHAKE_US: u64 = 300; +const FSYNC_US: u64 = 1_000; + +fn cfg() -> Config { + Config::new((1..=3).map(NodeId), 1, 0) +} + +/// DC1: tick one fresh node alone until it pre-campaigns. The tick count is its first draw. +pub fn first_draw(id: NodeId, seed: u64) -> u32 { + let mut c = Consensus::new(id, cfg(), seed); + for t in 1..=100u32 { + for a in c.step(Event::Tick) { + if let Action::RoleChanged { role: Role::PreCandidate, .. } = a { + return t; + } + } + } + panic!("node {id:?} did not pre-campaign within 100 ticks"); +} + +/// How long the first frame on an idle-closed link waits for the peer's accept. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Accept { + /// HEAD with D224's fix: the accept loop's next wake, uniform over one `poll_interval`. + Poll, + /// F1: an accept that answers at once. + Zero, + /// The links are never closed (A12a's 3600-s arm). + Open, +} + +enum Ev { + Tick(usize), + Deliver(usize, Message), + Persisted(usize, Term, Round), +} + +pub struct Outcome { + pub init_leader: Option, + /// Neither survivor pre-campaigned before the stop, so each still holds its FIRST draw. + pub survivors_first_draw: bool, + pub new_leader: Option, + pub split: bool, + pub precands: [u32; 3], + pub cands: [u32; 3], + pub elect_us: u64, +} + +struct Des { + seq: u64, + q: BTreeMap<(u64, u64), Ev>, + n: Vec, + up: [bool; 3], + busy: [u64; 3], + link_free: BTreeMap<(usize, usize), u64>, + closed: BTreeSet<(usize, usize)>, + rng: Rng, + accept: Accept, + /// (node, role, term, virtual time) for every `RoleChanged`. + log: Vec<(usize, Role, Term, u64)>, +} + +impl Des { + fn push(&mut self, at: u64, ev: Ev) { + self.seq += 1; + self.q.insert((at, self.seq), ev); + } + + fn step_node(&mut self, k: usize, at: u64, ev: Event) { + let mut s = at; + for a in self.n[k].step(ev) { + match a { + Action::Send(m) => { + let j = (m.to.0 - 1) as usize; + let mut arrive = s + WIRE_US; + if self.closed.remove(&(k, j)) { + let wait = match self.accept { + Accept::Poll => self.rng.next_u64() % TICK_US, + Accept::Zero | Accept::Open => 0, + }; + arrive += wait + HANDSHAKE_US; + } + let f = self.link_free.entry((k, j)).or_insert(0); + arrive = arrive.max(*f); + *f = arrive; + self.push(arrive, Ev::Deliver(j, m)); + } + Action::Persist { entries } => { + if let Some(last) = entries.last() { + s += FSYNC_US; + let term = self.n[k].term(); + self.push(s, Ev::Persisted(k, term, last.round)); + } + } + Action::PersistHardState { .. } => s += FSYNC_US, + Action::RoleChanged { role, term, .. } => self.log.push((k, role, term, s)), + _ => {} + } + } + self.busy[k] = s; + } + + fn run_until(&mut self, end: u64, stop: impl Fn(&Des) -> bool) -> u64 { + let mut now = 0; + while let Some((&(at, sq), _)) = self.q.iter().next() { + if at > end || stop(self) { + break; + } + let ev = self.q.remove(&(at, sq)).unwrap(); + now = at; + match ev { + Ev::Tick(k) => { + if !self.up[k] { + continue; + } + self.push(at + TICK_US, Ev::Tick(k)); + let t = at.max(self.busy[k]); + self.step_node(k, t, Event::Tick); + } + Ev::Deliver(j, m) => { + if !self.up[j] { + continue; + } + let t = at.max(self.busy[j]); + self.step_node(j, t, Event::Recv(m)); + } + Ev::Persisted(k, term, round) => { + if !self.up[k] { + continue; + } + let t = at.max(self.busy[k]); + self.step_node(k, t, Event::Persisted { term, round }); + } + } + } + now + } + + fn leader_among(&self, who: &[usize]) -> Option { + who.iter().copied().find(|&k| self.up[k] && self.n[k].role() == Role::Leader) + } +} + +/// DC2: elect, hold 3 s, stop the leader at a random phase, close the survivors' mutual links +/// (unless `Open`), and run to the first survivor leader. +pub fn failover(seeds: [u64; 3], phase_seed: u64, accept: Accept) -> Outcome { + let c = cfg(); + let mut phases = Rng::new(phase_seed); + let mut d = Des { + seq: 0, + q: BTreeMap::new(), + n: (0..3).map(|i| Consensus::new(NodeId(i as u32 + 1), c.clone(), seeds[i])).collect(), + up: [true; 3], + busy: [0; 3], + link_free: BTreeMap::new(), + closed: BTreeSet::new(), + rng: Rng::new(phase_seed ^ 0xACCE_97AC_CE97_ACCE), + accept, + log: Vec::new(), + }; + for k in 0..3 { + let ph = phases.next_u64() % TICK_US; + d.push(ph, Ev::Tick(k)); + } + let all = [0usize, 1, 2]; + let t_elect = d.run_until(60_000_000, |d| d.leader_among(&all).is_some()); + let Some(l) = d.leader_among(&all) else { + return Outcome { + init_leader: None, + survivors_first_draw: false, + new_leader: None, + split: false, + precands: [0; 3], + cands: [0; 3], + elect_us: 0, + }; + }; + d.run_until(t_elect + 3_000_000, |_| false); + let surv: Vec = all.iter().copied().filter(|&k| k != l).collect(); + let survivors_first_draw = + !d.log.iter().any(|&(k, r, _, _)| surv.contains(&k) && matches!(r, Role::PreCandidate | Role::Candidate)); + let t_stop = t_elect + 3_000_000 + phases.next_u64() % TICK_US; + d.run_until(t_stop, |_| false); + d.up[l] = false; + if accept != Accept::Open { + d.closed.insert((surv[0], surv[1])); + d.closed.insert((surv[1], surv[0])); + } + let from = d.log.len(); + let t_new = d.run_until(t_stop + 60_000_000, |d| d.leader_among(&surv).is_some()); + let new_leader = d.leader_among(&surv); + let mut precands = [0u32; 3]; + let mut cands = [0u32; 3]; + let mut cand_terms: [BTreeSet; 3] = Default::default(); + for &(k, r, t, _) in &d.log[from..] { + match r { + Role::PreCandidate => precands[k] += 1, + Role::Candidate => { + cands[k] += 1; + cand_terms[k].insert(t); + } + _ => {} + } + } + let split = cand_terms[surv[0]].intersection(&cand_terms[surv[1]]).next().is_some(); + Outcome { + init_leader: Some(l), + survivors_first_draw, + new_leader, + split, + precands, + cands, + elect_us: t_new.saturating_sub(t_stop), + } +} diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 861772b7..626fc26f 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -22,6 +22,8 @@ #[path = "r11_dist/arms.rs"] mod arms; +#[path = "r11_dist/splitsim.rs"] +mod splitsim; use std::collections::BTreeMap; use std::io::Write; @@ -314,11 +316,15 @@ static IDLE_DEADLINE_S: AtomicU64 = AtomicU64::new(0); /// A12: per-sample idle wait before the failover, in seconds. static PRE_FAIL_SLEEP_S: AtomicU64 = AtomicU64::new(0); +/// Node `i`'s seed under a salt: the one expression `opts()` and the DC1/DC2 modes share (A16a). +fn node_seed_for(salt: u64, i: usize) -> u64 { + 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt.wrapping_mul(0xD6E8_FEB8_6659_FD93) +} + fn opts(dir: &Path, addrs: &BTreeMap, id: NodeId, i: usize) -> NodeOptions { let peers: BTreeMap = addrs.iter().filter(|(k, _)| **k != id).map(|(k, v)| (*k, *v)).collect(); // NodeOptions::new's own default tick (50 ms) is kept: the production timing. - let salt = SEED_SALT.load(Ordering::Relaxed).wrapping_mul(0xD6E8_FEB8_6659_FD93); - let mut o = NodeOptions::new(dir, peers, 0x5eed ^ (i as u64 + 1).wrapping_mul(0x9E37_79B9) ^ salt); + let mut o = NodeOptions::new(dir, peers, node_seed_for(SEED_SALT.load(Ordering::Relaxed), i)); // A12 control: the transport's idle close (default 60 s), overridable per run. let d = std::env::var("R11_IDLE_DEADLINE_S").ok().and_then(|x| x.parse::().ok()).unwrap_or(IDLE_DEADLINE_S.load(Ordering::Relaxed)); if d > 0 { @@ -1092,6 +1098,115 @@ fn run_growmon(n: u64, seed: u64, root: &Path) { fn main() { let args: Vec = std::env::args().collect(); + if args.get(1).map(|s| s.as_str()) == Some("drawcensus") { + // r11_dist_fleet drawcensus [shared]: DC1 (PREREG A16, A16a). + let lo: u64 = args.get(2).and_then(|x| x.parse().ok()).expect("salt-lo"); + let hi: u64 = args.get(3).and_then(|x| x.parse().ok()).expect("salt-hi"); + let shared = args.get(4).map(|s| s == "shared").unwrap_or(false); + println!("# r11_dist_fleet drawcensus salts={lo}..={hi} shared={shared}"); + let (mut ties, mut all_equal) = (0u64, 0u64); + let mut gaps: BTreeMap = BTreeMap::new(); + for salt in lo..=hi { + let d: Vec = + (0..3).map(|i| splitsim::first_draw(NodeId(i as u32 + 1), node_seed_for(salt, if shared { 0 } else { i }))).collect(); + if salt <= 6 { + println!("DC1 salt={salt} first_draws={d:?}"); + } + let m = *d.iter().min().unwrap(); + if d.iter().all(|&x| x == d[0]) { + all_equal += 1; + } + if d.iter().filter(|&&x| x == m).count() > 1 { + ties += 1; + continue; + } + let s: Vec = d.iter().copied().filter(|&x| x != m).collect(); + *gaps.entry(s[0].abs_diff(s[1])).or_insert(0) += 1; + } + println!("DC1 RESULT salts={} min_ties={ties} all_three_equal={all_equal} survivor_gap_counts={gaps:?}", hi - lo + 1); + return; + } + if args.get(1).map(|s| s.as_str()) == Some("splitsim") { + // r11_dist_fleet splitsim [shared]: DC2 (PREREG A16a). + let lo: u64 = args.get(2).and_then(|x| x.parse().ok()).expect("salt-lo"); + let hi: u64 = args.get(3).and_then(|x| x.parse().ok()).expect("salt-hi"); + let accept = match args.get(4).map(|s| s.as_str()) { + Some("poll") => splitsim::Accept::Poll, + Some("zero") => splitsim::Accept::Zero, + Some("open") => splitsim::Accept::Open, + other => panic!("arm must be poll, zero or open, got {other:?}"), + }; + let shared = args.get(5).map(|s| s == "shared").unwrap_or(false); + println!("# r11_dist_fleet splitsim salts={lo}..={hi} arm={accept:?} shared={shared}"); + // class -> [salts, splits, precands, cands, elect_us] + let mut tally: BTreeMap = BTreeMap::new(); + let (mut no_init, mut no_new, mut winner_not_min, mut dc1_tie_but_first) = (0u64, 0u64, 0u64, 0u64); + for salt in lo..=hi { + let seeds: [u64; 3] = [0usize, 1, 2].map(|i| node_seed_for(salt, if shared { 0 } else { i })); + let fd: [u32; 3] = [0usize, 1, 2].map(|i| splitsim::first_draw(NodeId(i as u32 + 1), seeds[i])); + let o = splitsim::failover(seeds, salt.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x7068_6173_6573, accept); + let Some(l) = o.init_leader else { + no_init += 1; + println!("DC2 salt={salt} NO INITIAL LEADER in 60 s"); + continue; + }; + if o.new_leader.is_none() { + no_new += 1; + println!("DC2 salt={salt} NO NEW LEADER in 60 s after the stop"); + continue; + } + let m = *fd.iter().min().unwrap(); + let tie = fd.iter().filter(|&&x| x == m).count() > 1; + if !tie && fd[l] != m { + winner_not_min += 1; + } + if tie && o.survivors_first_draw { + dc1_tie_but_first += 1; + } + let surv: Vec = (0..3).filter(|&k| k != l).collect(); + let class = if tie || !o.survivors_first_draw { + "tie".to_string() + } else { + match fd[surv[0]].abs_diff(fd[surv[1]]) { + 0 => "gap0".to_string(), + 1 => "gap1".to_string(), + _ => "gap2+".to_string(), + } + }; + let t = tally.entry(class.clone()).or_insert([0; 5]); + t[0] += 1; + t[1] += o.split as u64; + t[2] += o.precands.iter().map(|&x| x as u64).sum::(); + t[3] += o.cands.iter().map(|&x| x as u64).sum::(); + t[4] += o.elect_us; + if salt <= 20 || (o.split && class == "gap2+") { + println!( + "DC2 salt={salt} first_draws={fd:?} init_leader=n{} class={class} split={} precands={:?} cands={:?} elect_us={}", + l + 1, + o.split, + o.precands, + o.cands, + o.elect_us + ); + } + } + let (mut nt_salts, mut nt_splits) = (0u64, 0u64); + for (class, t) in &tally { + println!( + "DC2 RESULT arm={accept:?} shared={shared} class={class} salts={} splits={} precands={} cands={} elect_us_sum={}", + t[0], t[1], t[2], t[3], t[4] + ); + if class != "tie" { + nt_salts += t[0]; + nt_splits += t[1]; + } + } + println!( + "DC2 RESULT arm={accept:?} shared={shared} non_tie_salts={nt_salts} non_tie_splits={nt_splits} splits_per_10000={} no_init={no_init} no_new={no_new} winner_not_min={winner_not_min} dc1_tie_but_first_draw={dc1_tie_but_first}", + if nt_salts > 0 { nt_splits * 10_000 / nt_salts } else { 0 } + ); + return; + } if args.get(1).map(|s| s.as_str()) == Some("growmon") { // r11_dist_fleet growmon : one group-committed growth, sampled each second. let root = PathBuf::from(args.get(2).expect("scratch root")); From 323c965cfc82e45ea47194f7ac02b9d00ec7aa79 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:00:37 -0400 Subject: [PATCH 294/315] F1 red: accepts_after_sleep meter (observation only) and the test that a redial after an idle close is accepted without waiting out an accept poll --- src/consensus/tests_transport.rs | 56 ++++++++++++++++++++++++++++++++ src/consensus/transport.rs | 25 +++++++++++++- 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/consensus/tests_transport.rs b/src/consensus/tests_transport.rs index 35161c64..22e80f37 100644 --- a/src/consensus/tests_transport.rs +++ b/src/consensus/tests_transport.rs @@ -3108,3 +3108,59 @@ fn a_link_the_peer_reset_is_redialled_after_an_idle_gap() { assert_eq!(a.idle_redials() - redials, 1, "the frame arrived, but not through the probe's redial"); assert_eq!(a.lost_in_flight(), 0, "a frame was lost on the way"); } + +#[test] +fn a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll() { + // **F1.** After an idle close, D224 redials before it writes, and `dial` blocks until the peer's + // accept thread answers its handshake. That thread polled a nonblocking listener and slept + // `poll_interval` whenever nothing was pending: 50 ms by default, one whole election tick. So + // the redial, and the campaign frame it carries, waited up to one poll to be accepted. + // + // Election timeouts are drawn in whole ticks. Two survivors whose timers expire within a tick + // of each other therefore both pre-campaign before either one's PreVote arrives, and the vote + // splits. r11-dist DC2, on the real `Consensus` in virtual time: 14.96 splits per 100 first + // failovers at the 50 ms poll, against 0.21 when the accept answers at once. + // + // `accepts_after_sleep` counts every connection taken on the first `accept` after the accept + // thread slept, which is every connection that waited out a sleep. With no accept failing and + // the cap not full, nothing may wait there. Both sides are read: A accepts B's dial at startup, + // and B accepts A's redial. + let mut opts = fast(); + opts.poll_interval = TransportOptions::default().poll_interval; + opts.idle_deadline = Duration::from_millis(300); + let (a, b) = pair(opts); + let msg = |term: u64| Message { + from: NodeId(1), + to: NodeId(2), + term, + body: Body::PreVote { last_term: 1, last_round: 1 }, + }; + a.send(&msg(1)).unwrap(); + assert_eq!(expect_recv(&b, Duration::from_secs(10)).term, 1, "the link never carried its first frame"); + + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline && (b.idle_closed() == 0 || b.live_inbound_conns() > 0) { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + b.idle_closed() >= 1 && b.live_inbound_conns() == 0, + "B never closed A's idle link, so this test exercises nothing" + ); + // B has sent its FIN; let A's kernel process it (the D224 review's F2). + std::thread::sleep(Duration::from_millis(200)); + + let redials = a.idle_redials(); + a.send(&msg(2)).unwrap(); + assert_eq!(expect_recv(&b, Duration::from_secs(10)).term, 2, "the campaign frame never arrived"); + // The premise: the frame needed a NEW connection, so it went through B's accept. + assert_eq!(a.idle_redials() - redials, 1, "the frame did not go through a redial, so no accept was on its path"); + assert_eq!(a.lost_in_flight(), 0, "a frame was lost on the way"); + + assert_eq!( + (a.accepts_after_sleep(), b.accepts_after_sleep()), + (0, 0), + "connections were accepted only after the accept thread had slept (A, B), so each one \ + waited up to a {:?} poll for its handshake answer: a redial's campaign frame waits a tick", + TransportOptions::default().poll_interval + ); +} diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index 910c513c..52b586c1 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -1226,6 +1226,11 @@ struct Counters { /// Probes that found the peer had closed the link, so the sender redialled before writing /// instead of losing the frame to the closed connection (D224). idle_redials: AtomicU64, + /// Connections this listener took on its first `accept` after the accept thread SLEPT (F1). Each + /// one became pending while the thread slept and waited for the sleep to end before its handshake + /// was answered, and `dial` blocks on that answer. With no accept failing and the cap not full, + /// nothing should ever wait there: a redial after an idle close carries a campaign frame. + accepts_after_sleep: AtomicU64, /// Inbound frames that did not authenticate against this node's signing key, and were refused /// **before** [`decode`] saw them. /// @@ -1796,6 +1801,10 @@ impl Transport { pub fn idle_redials(&self) -> u64 { self.counters.idle_redials.load(Ordering::SeqCst) } + /// Connections accepted only after the accept thread had slept. See `Counters::accepts_after_sleep`. + pub fn accepts_after_sleep(&self) -> u64 { + self.counters.accepts_after_sleep.load(Ordering::SeqCst) + } /// Failed dials. A peer that is down makes this climb steadily; it is the meter that says /// "unreachable" rather than "quiet". pub fn connect_failures(&self) -> u64 { @@ -1894,6 +1903,7 @@ pub struct TransportCounters { pub unaddressable: u64, pub idle_probes: u64, pub idle_redials: u64, + pub accepts_after_sleep: u64, pub live_inbound_conns: usize, pub inbox_bytes: usize, } @@ -1918,6 +1928,7 @@ impl Transport { unaddressable: self.unaddressable(), idle_probes: self.idle_probes(), idle_redials: self.idle_redials(), + accepts_after_sleep: self.accepts_after_sleep(), live_inbound_conns: self.live_inbound_conns(), inbox_bytes: self.inbox_bytes(), } @@ -1947,6 +1958,7 @@ impl std::fmt::Debug for Transport { .field("unaddressable", &self.unaddressable()) .field("idle_probes", &self.idle_probes()) .field("idle_redials", &self.idle_redials()) + .field("accepts_after_sleep", &self.accepts_after_sleep()) // The key itself is never printed: `signing::Key`'s own `Debug` redacts it, and this // reports only whether there is one. .field("signed", &self.signs_its_traffic()) @@ -2244,9 +2256,15 @@ fn accept_loop( key: Option>, ) { let mut conn_threads: Vec> = Vec::new(); + // Set by every sleep below and taken by the next accepted connection, which is the one that may + // have waited out that sleep (F1, `Counters::accepts_after_sleep`). + let mut slept = false; while !stop.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, _)) => { + if std::mem::take(&mut slept) { + counters.accepts_after_sleep.fetch_add(1, Ordering::SeqCst); + } // **Refused before a thread is spawned for it.** This transport does not // authenticate, so an unbounded accept loop is an unauthenticated peer choosing how // many threads this process runs. @@ -2281,6 +2299,7 @@ fn accept_loop( // for free. One poll per refusal bounds that, and delays a stop by at most // the poll the stop flag is already allowed (D207, from `207d362`). std::thread::sleep(opts.poll_interval); + slept = true; continue; } map.insert(id, mine); @@ -2337,8 +2356,12 @@ fn accept_loop( } Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(opts.poll_interval); + slept = true; + } + Err(_) => { + std::thread::sleep(opts.poll_interval); + slept = true; } - Err(_) => std::thread::sleep(opts.poll_interval), } } // Joined, not detached: `Transport::shutdown` joins this thread, and it must not return while From 45a9b77642a499f69ba543f6d966c7d50af12e98 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:03:43 -0400 Subject: [PATCH 295/315] F1 PREREG: red/green/mutant predictions and the 381-test consensus count, before any run --- bench/f1_accept/PREREG.md | 89 +++++++++++++++++++++++ bench/f1_accept/raw/build_red_323c965.txt | 82 +++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 bench/f1_accept/PREREG.md create mode 100644 bench/f1_accept/raw/build_red_323c965.txt diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md new file mode 100644 index 00000000..a80b586b --- /dev/null +++ b/bench/f1_accept/PREREG.md @@ -0,0 +1,89 @@ +# F1: the listener answers a pending connection at once, not after a 50-ms accept poll + +FAN-QUEUE row; engineering, not an invention lane. Lead-authorised 2026-09-27. Lane r11-dist. + +- Branch `d224-f1-accept.noindex`, cut from `d224-idle-failover` @ `4097a1e` (the branch tip). The lead's brief cited + `7d9567f`, which is an ancestor of the tip. The tip adds D224 reviews 2-3 and their tests and mutants N1-N12. +- Never landed from here. The full suite runs at landing, through lockrun. + +## The defect (source, at 4097a1e) +- The listener is set nonblocking (`transport.rs` `Transport::start`, `set_nonblocking(true)`). `accept_loop` sleeps + `poll_interval` (default 50 ms, one election tick) on every WouldBlock. +- So a connection that arrives while the accept thread sleeps waits out the sleep before its handshake is answered, and + `dial` blocks on that answer. +- D224 redials a follower-to-follower link after the peer's idle close, and that redial carries a survivor's campaign + frame. Election timeouts are drawn in whole ticks (`election.rs` `draw_timeout`, [10, 20) ticks). Two survivors whose + timers expire within a tick of each other both pre-campaign before either one's PreVote lands, and the vote splits. +- r11-dist DC2 (artie-research `frontier/round11/r11-dist`, raw 74ab3673) ran the real `Consensus` in virtual time over + salts 1..10,000: + - 14.96 splits per 100 first failovers at the 50-ms poll; + - 0.21 per 100 when the accept answers at once; + - 0.06 per 100 with links never idle-closed. + - DC2 is a MODEL of the transport. Its assumptions are under an adversary (r11-dist-refute-measure), and quoting 14.96 + is gated on that report. + +## Deflation +- Raft section 9.3 (Ongaro and Ousterhout, 2014) already requires broadcast time well below the election timeout spread: + randomized timeouts make split votes rare only when one server's campaign reaches the others before theirs start. + - Here the spread is quantized to 50-ms ticks, and the first frame after an idle close could itself cost up to a tick. +- etcd's rafthttp keeps long-lived streaming connections per peer, so its campaign frames never wait on an accept. +- Waking a blocking `accept` at shutdown by connecting to the listener is the ordinary zero-dependency pattern. + (Recalled from tiny_http's `Server` drop; not re-read, unverified.) +- Nothing here is new. This row is ordinary engineering. + +## The change +1. RED commit 323c965 (observation only; behaviour unchanged): + - meter `accepts_after_sleep`: a connection taken on the first `accept` after the accept thread slept, whatever the + sleep's reason, is one that waited out the sleep; + - accessor, `TransportCounters` field and `Debug` field; + - test `a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll`. + - The test uses no API the fix adds. The meter is the red commit's own instrument, as D224's counters were D224's. +2. FIX: + - The listener is set BLOCKING in `start`. + - The WouldBlock arm is merged into the failed-accept arm (one poll of pacing, as before). + - A connection taken after the stop flag is closed unserved and the loop leaves. + - `shutdown` wakes the accept thread by connecting to `wake_addr(local_addr)`, the listener's own address with an + unspecified IP mapped to loopback, then joins it. + - `wake_accept` retries a failed connect every `poll_interval` for at most `handshake_deadline`. Past that it DETACHES + the thread rather than join for ever. That is a stated blind spot: a node that cannot connect to its own listening + address. + - The accept handle is kept apart from the sender threads. + - `poll_interval`'s doc no longer claims the accept poll. + +## Registered predictions (before any run; lib test binary, filter `consensus::`, `taskpolicy -b`) +- R (red, at 323c965): + - The new test FAILS on its final assertion only, with (A, B) = (>= 1, >= 1). A counts B's startup dial; B counts A's + redial. Every premise assertion before it passes: the idle close, exactly one redial, the frame arriving, no loss. + - Every other `consensus::` test passes, as at the base: the meter changes no behaviour. + - The consensus test count is registered below from `--list`, before any run. +- G (green, at the fix commit): every `consensus::` test passes, the new one with (0, 0). Named risks: + - `a_peer_that_resets_before_accept_cannot_fill_the_connection_cap` (macOS) says "a slow accept poll, so the resets + below land while the loop is asleep". Under F1 the accept does not poll. Its trigger (EINVAL on `SO_RCVTIMEO`) still + needs only the RST to land before the SERVER sets its read timeout, which it does after a thread wake, `accept`, a + `dup`, a lock and a `fcntl`. The client closes two syscalls after `connect` returns. + - PREDICTED: it still passes. + - If its anti-vacuity assertion (`refused_conns() > 0`) fails, F1 has removed the test's trigger. That is a fixture + decision (a test edit) for the lead, and it is NOT made on this branch. + - `shutdown_joins_every_thread_and_closes_the_listener`: passes. The wake joins the accept thread, so the listener is + closed when `shutdown` returns. + - `refusals_at_a_full_cap_are_paced_by_the_poll_interval`: passes. Pacing is unchanged. + - `a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved`: passes. It hands `accept_loop` its own + nonblocking listener; the merged arm sleeps its ZERO poll exactly as the WouldBlock arm did. +- Mutants, each applied to the fix commit alone; killed = the named test fails: + - M1 `listener_polled_again` (`set_nonblocking(false)` becomes `true` in `start`): the new test fails with + (>= 1, >= 1). + - M2 `shutdown_never_connects` (the connect in `wake_accept` never attempted): `shutdown` detaches after + `handshake_deadline` (5 s in `fast()`), and `shutdown_joins_every_thread_and_closes_the_listener` fails because + the listener is still accepting. + - M3 `stop_not_checked_after_accept`: predicted to SURVIVE. The wake connection is served, reads EOF at its + handshake, and the loop leaves at its head, so the check saves a thread spawn and is not load-bearing. Registered + as a stated survivor. +- Counter band for the protocol consequence: DC2's Zero arm, < 1 split per 100 first failovers (measured 0.21 on the + model). + +## Counts, from `--list` of the red binary (target/debug/deps/ferrodb-d929e200b07daf14 at 323c965), before any run +- `consensus::`: **381 tests**, 0 ignored. By module: election 35, log 58, membership 25, node 14, replicate 60, signing 52, + sim 24, snapshot 38, contract 10, transport 65. The macOS-only reset test is included on this host. +- R: **380 passed, 1 failed** (the new test). G: **381 passed, 0 failed**. +- A failure of any OTHER test at R belongs to the base, not to F1. It is re-run alone, and reported with its panic line. +- Registered 2026-09-27T09:03:43Z. diff --git a/bench/f1_accept/raw/build_red_323c965.txt b/bench/f1_accept/raw/build_red_323c965.txt new file mode 100644 index 00000000..71fc25c8 --- /dev/null +++ b/bench/f1_accept/raw/build_red_323c965.txt @@ -0,0 +1,82 @@ +# build red 323c965 start 2026-09-27T09:00:43Z load={ 19.60 17.75 18.49 } + Compiling libc v0.2.186 + Compiling cfg-if v1.0.4 + Compiling getrandom v0.3.4 + Compiling getrandom v0.4.2 + Compiling zerocopy v0.8.56 + Compiling rustix v1.1.4 + Compiling bitflags v2.11.1 + Compiling autocfg v1.5.1 + Compiling fastrand v2.4.1 + Compiling once_cell v1.21.4 + Compiling quick-error v1.2.3 + Compiling fnv v1.0.7 + Compiling bit-vec v0.8.0 + Compiling regex-syntax v0.8.11 + Compiling unarray v0.1.4 + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d224-f1-accept.noindex) + Compiling bit-set v0.8.0 + Compiling num-traits v0.2.19 + Compiling errno v0.3.14 + Compiling wait-timeout v0.2.1 + Compiling rand_core v0.9.5 + Compiling rand v0.9.5 + Compiling rand_xorshift v0.4.0 + Compiling tempfile v3.27.0 + Compiling rusty-fork v0.3.1 + Compiling ppv-lite86 v0.2.21 + Compiling rand_chacha v0.9.0 + Compiling proptest v1.11.0 +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:800:13 + | +800 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 2m 26s + Executable unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) +# rc=0 end 2026-09-27T09:03:10Z From 4eac4f2bdb1ca4064bd6b7c2588457691e1ab688 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:04:35 -0400 Subject: [PATCH 296/315] F1 raw: R red run at 323c965, unread --- bench/f1_accept/raw/R_red_323c965.txt | 402 ++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 bench/f1_accept/raw/R_red_323c965.txt diff --git a/bench/f1_accept/raw/R_red_323c965.txt b/bench/f1_accept/raw/R_red_323c965.txt new file mode 100644 index 00000000..2071d484 --- /dev/null +++ b/bench/f1_accept/raw/R_red_323c965.txt @@ -0,0 +1,402 @@ +# R: red run at 323c965, binary /private/tmp/claude-501/-Users-idide-projects-ferrodb/b2b44149-483d-42d1-b512-89bf5de5a135/scratchpad/r11-dist/f1/ferrodb_test_red_323c965 (sha256 7d44338316f52d6c), filter consensus::, taskpolicy -b +# start 2026-09-27T09:03:48Z load={ 25.72 20.60 19.47 } + +running 381 tests +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_of_ordinary_frames_stays_readable_by_an_older_build ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::node::tests_node::the_transport_meters_are_readable_from_a_running_node ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_membership_the_wire_cannot_carry_is_refused_at_proposal_and_never_reaches_the_log ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_entry_that_fits_no_frame_is_still_offered_alone ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::replicate::tests_replicate::a_wal_batch_one_byte_over_a_frame_is_refused_at_proposal_and_one_at_the_limit_is_admitted ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::node::tests_node::the_idle_probe_meters_are_readable_from_a_running_node ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::log::tests_log::the_largest_entry_one_frame_can_carry_is_storable_and_reads_back ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::log::tests_log::a_frame_over_the_pre_d223_read_bound_makes_an_older_build_refuse_the_log ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::log::tests_log::a_version_raise_on_a_checkpointed_log_keeps_the_floor_and_every_round ... ok +test consensus::log::tests_log::the_largest_entry_an_unsigned_frame_carries_is_delivered_and_stored ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::log::tests_log::a_mixed_batch_raises_the_version_whichever_entry_is_large ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_mark_while_a_large_frame_survives ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::log::tests_log::the_version_is_raised_exactly_above_the_pre_d223_read_bound ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_connection_cap_of_zero_is_refused_at_bind ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::transport::tests::a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::transport::tests::a_leaders_full_catch_up_append_of_membership_entries_is_delivered ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::a_frame_sent_after_the_peer_idle_closed_the_link_arrives_on_the_first_attempt ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::a_peer_that_resets_before_accept_cannot_fill_the_connection_cap ... ok +test consensus::transport::tests::a_link_left_holding_a_refusal_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_link_the_peer_reset_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay ... ok +test consensus::transport::tests::a_zero_idle_deadline_is_refused_at_bind ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::a_link_closed_by_a_peer_with_half_the_idle_deadline_is_still_probed ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll ... FAILED +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::every_send_the_transport_refuses_is_counted ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::an_idle_closed_link_is_probed_once_and_redialled ... ok +test consensus::transport::tests::removing_voters_from_a_learner_list_is_a_binary_search_per_learner ... ok +test consensus::transport::tests::one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone ... ok +test consensus::transport::tests::the_outbound_byte_count_returns_to_zero_as_the_sender_drains ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::replicate::tests_replicate::an_append_is_cut_where_a_signed_frame_would_overflow_and_not_a_byte_before ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::refusals_at_a_full_cap_are_paced_by_the_poll_interval ... ok +test consensus::transport::tests::an_open_connection_holds_its_slot_until_it_closes ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test consensus::transport::tests::a_busy_link_is_never_probed ... ok +test consensus::log::tests_log::no_crash_can_leave_a_large_frame_under_a_header_an_older_build_accepts ... ok +test consensus::log::tests_log::no_crash_during_a_version_raise_loses_a_round_this_build_reads ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire ... ok + +failures: + +---- consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll stdout ---- + +thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435342490) panicked at src/consensus/tests_transport.rs:3159:5: +assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + + +failures: + consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll + +test result: FAILED. 380 passed; 1 failed; 0 ignored; 0 measured; 1323 filtered out; finished in 46.86s + +# rc=101 end 2026-09-27T09:04:35Z load={ 24.02 21.03 19.70 } From ca771fb9cce5137ad62287ef5f2883705f58ab7b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:04:47 -0400 Subject: [PATCH 297/315] F1: the listener blocks in accept, so a pending connection is answered at once; shutdown wakes it by connecting to its own address (bounded, detaches rather than hangs) --- src/consensus/transport.rs | 107 +++++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index 52b586c1..9a16e5d1 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -93,7 +93,7 @@ use std::collections::{BTreeMap, VecDeque}; use std::io::{Cursor, Read, Write}; -use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{mpsc, Arc, Condvar, Mutex}; use std::thread::JoinHandle; @@ -1125,7 +1125,9 @@ pub struct TransportOptions { /// the queue is empty and then it goes in — so no message is ever unsendable because of this /// knob, however small it is set. pub queue_bytes: usize, - /// Socket read timeout, and the listener's accept poll. Bounds shutdown latency. + /// Socket read timeout, and the pause after a failed `accept` or a refusal at a full cap. Bounds + /// shutdown latency. The listener itself is NOT polled (F1): `accept` blocks, and + /// [`Transport::shutdown`] wakes it. pub poll_interval: Duration, /// How long a sender thread waits after a failed dial before trying that peer again. pub reconnect_delay: Duration, @@ -1336,6 +1338,14 @@ pub struct Transport { counters: Arc, stop: Arc, threads: Mutex>>, + /// The accept thread, kept apart from `threads` because it alone blocks in a call the stop flag + /// cannot interrupt, so `shutdown` must wake it before it can join it (F1, `wake_accept`). + accept: Mutex>>, + /// Where `shutdown` connects to wake the accept thread. See `wake_addr`. + wake: SocketAddr, + /// `TransportOptions::handshake_deadline` and `poll_interval`, which bound and pace that wake. + wake_deadline: Duration, + wake_retry: Duration, /// Held for the whole of [`Transport::shutdown`], so a concurrent second call waits for the /// first to finish joining rather than taking an empty thread list and returning early. Without /// it "a second call is a barrier" was true only for a sequential caller — and a `Drop` racing @@ -1506,8 +1516,18 @@ impl Transport { } let local_addr = listener.local_addr().map_err(|e| FerroError::Io(e.to_string()))?; + // **Blocking, so a connection is answered the moment it is pending (F1).** The listener used + // to be nonblocking and polled: the accept thread slept `poll_interval` whenever nothing was + // pending, so a connection that arrived during the sleep waited it out before its handshake + // was answered, and `dial` blocks on that answer. For a link dialled once at startup that is + // noise. For D224's redial after an idle close it is not: that connection carries a + // survivor's campaign frame, and with election timeouts drawn in whole ticks, a wait of up to + // one tick (50 ms by default) splits the vote between two survivors whose timers expire within + // a tick of each other (r11-dist DC2: 14.96 splits per 100 first failovers, 0.21 without the + // wait). Set explicitly, because a caller's listener may arrive nonblocking. A blocking + // `accept` does not see the stop flag, so `shutdown` wakes it: see `wake_accept`. listener - .set_nonblocking(true) + .set_nonblocking(false) .map_err(|e| FerroError::Io(e.to_string()))?; let stop = Arc::new(AtomicBool::new(false)); @@ -1573,12 +1593,12 @@ impl Transport { let ids_c = next_conn_id; let opts_c = opts.clone(); let key_c = key.clone(); - match std::thread::Builder::new() + let accept = match std::thread::Builder::new() .name(format!("consensus-accept-{self_id}")) .spawn(move || { accept_loop(listener, self_id, tx, stop_c, counters_c, conns_c, ids_c, opts_c, key_c) }) { - Ok(h) => threads.push(h), + Ok(h) => h, Err(e) => { stop_started(&stop, &outbox_list, threads); return Err(FerroError::Io(format!( @@ -1586,7 +1606,7 @@ impl Transport { already started was stopped and joined rather than detached" ))); } - } + }; Ok(Transport { self_id, @@ -1596,6 +1616,10 @@ impl Transport { counters, stop, threads: Mutex::new(threads), + accept: Mutex::new(Some(accept)), + wake: wake_addr(local_addr), + wake_deadline: opts.handshake_deadline, + wake_retry: opts.poll_interval, shutdown_lock: Mutex::new(()), inbound_conns, key, @@ -1825,6 +1849,11 @@ impl Transport { /// `handshake_deadline` is a knob rather than a constant. It is not shortened here, because the /// alternative is failing a legitimately slow connect, and a slow shutdown is the cheaper /// failure. + /// + /// **A second, of the same size (F1):** the accept thread blocks in `accept`, and is woken by a + /// connection to this listener. If this node cannot connect to its own listening address for a + /// whole `handshake_deadline`, the accept thread is left detached, holding the listener, rather + /// than joined for ever. See `wake_accept`. pub fn shutdown(&self) { // Taken first and held throughout: a second caller blocks here until the first has finished // joining, which is what makes the barrier claim true for a concurrent caller and not just @@ -1848,6 +1877,13 @@ impl Transport { for h in handles { let _ = h.join(); } + // Last, as before F1, and woken first: it is parked in a blocking `accept`. + let accept = self.accept.lock().unwrap().take(); + if let Some(h) = accept { + if wake_accept(&h, self.wake, self.wake_deadline, self.wake_retry) { + let _ = h.join(); + } + } } } @@ -2242,6 +2278,50 @@ fn dial( Ok(s) } +/// Where [`Transport::shutdown`] connects to wake its own accept thread (F1): the listener's address, +/// with an unspecified IP (a listener bound to every interface) replaced by loopback of the same +/// family, because `0.0.0.0` and `::` are not addresses every platform will connect to. +fn wake_addr(listening: SocketAddr) -> SocketAddr { + let mut a = listening; + if a.ip().is_unspecified() { + a.set_ip(match a { + SocketAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + SocketAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST), + }); + } + a +} + +/// Wake an accept thread parked in a blocking `accept` (F1). True once it will leave by itself; false +/// if it could not be reached within `deadline`, and then the caller must not join it. +/// +/// The stop flag is set before this runs, so the thread leaves at its loop head or on the first +/// connection `accept` returns, this one or any peer's: one connect that SUCCEEDS is enough, whether +/// or not it is the connection the thread takes. A failed connect is retried every `retry`, because +/// `shutdown` has just closed every socket this transport held, and a process that was out of +/// descriptors may have room again. +/// +/// **Its blind spot, stated:** a node that cannot connect to its own listening address for a whole +/// `deadline` (an interface address removed while it listened on it) leaves the thread in `accept`, +/// holding the listener. It is detached, not joined, because that join would never return. That is +/// the one way a shut-down transport still holds its port. +fn wake_accept(h: &JoinHandle<()>, wake: SocketAddr, deadline: Duration, retry: Duration) -> bool { + let until = Instant::now() + deadline; + loop { + if h.is_finished() { + return true; + } + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return false; + } + if TcpStream::connect_timeout(&wake, left).is_ok() { + return true; + } + std::thread::sleep(retry.min(until.saturating_duration_since(Instant::now()))); + } +} + #[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)] fn accept_loop( @@ -2262,6 +2342,12 @@ fn accept_loop( while !stop.load(Ordering::SeqCst) { match listener.accept() { Ok((stream, _)) => { + // A connection taken after a stop is closed unserved, and the loop leaves: the + // transport is stopping, and this may be `shutdown`'s own wake (F1). + if stop.load(Ordering::SeqCst) { + let _ = stream.shutdown(Shutdown::Both); + break; + } if std::mem::take(&mut slept) { counters.accepts_after_sleep.fetch_add(1, Ordering::SeqCst); } @@ -2354,10 +2440,11 @@ fn accept_loop( // handles for every connection it has ever accepted. conn_threads.retain(|h| !h.is_finished()); } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(opts.poll_interval); - slept = true; - } + // A failed accept is retried after one poll, so a failure that persists (descriptors + // exhausted) cannot spin this thread. On the blocking listener `start` sets up, this and + // the refusal pacing above are the only sleeps on a pending connection's path, and the + // next accept counts both. A nonblocking listener, which only a test hands this loop + // directly, reaches here on every empty poll. Err(_) => { std::thread::sleep(opts.poll_interval); slept = true; From 2cd07b463d275e9a90bc20598fa64d431e5b93f9 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:07:09 -0400 Subject: [PATCH 298/315] F1 raw: G green run at ca771fb, unread --- bench/f1_accept/raw/G_green_ca771fb.txt | 390 ++++++++++++++++++++ bench/f1_accept/raw/build_green_ca771fb.txt | 55 +++ 2 files changed, 445 insertions(+) create mode 100644 bench/f1_accept/raw/G_green_ca771fb.txt create mode 100644 bench/f1_accept/raw/build_green_ca771fb.txt diff --git a/bench/f1_accept/raw/G_green_ca771fb.txt b/bench/f1_accept/raw/G_green_ca771fb.txt new file mode 100644 index 00000000..8628b7a3 --- /dev/null +++ b/bench/f1_accept/raw/G_green_ca771fb.txt @@ -0,0 +1,390 @@ +# G: green run at ca771fb, binary /private/tmp/claude-501/-Users-idide-projects-ferrodb/b2b44149-483d-42d1-b512-89bf5de5a135/scratchpad/r11-dist/f1/ferrodb_test_green_ca771fb (sha256 6d23ef23ece0dfd9), filter consensus::, taskpolicy -b +# start 2026-09-27T09:06:07Z load={ 18.90 20.07 19.46 } + +running 381 tests +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_of_ordinary_frames_stays_readable_by_an_older_build ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::node::tests_node::the_transport_meters_are_readable_from_a_running_node ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_membership_the_wire_cannot_carry_is_refused_at_proposal_and_never_reaches_the_log ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_entry_that_fits_no_frame_is_still_offered_alone ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::replicate::tests_replicate::a_wal_batch_one_byte_over_a_frame_is_refused_at_proposal_and_one_at_the_limit_is_admitted ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::node::tests_node::the_idle_probe_meters_are_readable_from_a_running_node ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::log::tests_log::the_largest_entry_one_frame_can_carry_is_storable_and_reads_back ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::log::tests_log::the_largest_entry_an_unsigned_frame_carries_is_delivered_and_stored ... ok +test consensus::log::tests_log::a_version_raise_on_a_checkpointed_log_keeps_the_floor_and_every_round ... ok +test consensus::log::tests_log::a_frame_over_the_pre_d223_read_bound_makes_an_older_build_refuse_the_log ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::log::tests_log::the_version_is_raised_exactly_above_the_pre_d223_read_bound ... ok +test consensus::log::tests_log::a_mixed_batch_raises_the_version_whichever_entry_is_large ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_mark_while_a_large_frame_survives ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_connection_cap_of_zero_is_refused_at_bind ... ok +test consensus::transport::tests::a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::transport::tests::a_leaders_full_catch_up_append_of_membership_entries_is_delivered ... ok +test consensus::transport::tests::a_frame_sent_after_the_peer_idle_closed_the_link_arrives_on_the_first_attempt ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_peer_that_resets_before_accept_cannot_fill_the_connection_cap ... ok +test consensus::transport::tests::a_link_left_holding_a_refusal_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::a_zero_idle_deadline_is_refused_at_bind ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::a_link_the_peer_reset_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll ... ok +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::a_link_closed_by_a_peer_with_half_the_idle_deadline_is_still_probed ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::every_send_the_transport_refuses_is_counted ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound ... ok +test consensus::transport::tests::removing_voters_from_a_learner_list_is_a_binary_search_per_learner ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::the_outbound_byte_count_returns_to_zero_as_the_sender_drains ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::an_idle_closed_link_is_probed_once_and_redialled ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::refusals_at_a_full_cap_are_paced_by_the_poll_interval ... ok +test consensus::transport::tests::an_open_connection_holds_its_slot_until_it_closes ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::replicate::tests_replicate::an_append_is_cut_where_a_signed_frame_would_overflow_and_not_a_byte_before ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test consensus::transport::tests::a_busy_link_is_never_probed ... ok +test consensus::log::tests_log::no_crash_can_leave_a_large_frame_under_a_header_an_older_build_accepts ... ok +test consensus::log::tests_log::no_crash_during_a_version_raise_loses_a_round_this_build_reads ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire has been running for over 60 seconds +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok + +test result: ok. 381 passed; 0 failed; 0 ignored; 0 measured; 1323 filtered out; finished in 61.05s + +# rc=0 end 2026-09-27T09:07:09Z load={ 22.41 20.89 19.81 } diff --git a/bench/f1_accept/raw/build_green_ca771fb.txt b/bench/f1_accept/raw/build_green_ca771fb.txt new file mode 100644 index 00000000..c2036b54 --- /dev/null +++ b/bench/f1_accept/raw/build_green_ca771fb.txt @@ -0,0 +1,55 @@ +# build green ca771fb start 2026-09-27T09:04:51Z load={ 21.51 20.62 19.58 } + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d224-f1-accept.noindex) +warning: variable does not need to be mutable + --> src/catalog/catalog_page.rs:800:13 + | +800 | let mut write_entry = |bytes: &mut [u8; PAGE_SIZE], o: &mut usize, name: &[u8], roots: [u32; 3], index_root: u32| { + | ----^^^^^^^^^^^ + | | + | help: remove this `mut` + | + = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2435 | reaper_suite!(log_catalog, false); + | --------------------------------- in this macro invocation + = note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused variable: `reaper` + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + | +help: `reaper` is captured in macro and introduced a unused variable + --> src/branch/reaper.rs:2263:17 + | +2263 | let (h, reaper) = setup(); + | ^^^^^^ +... +2436 | reaper_suite!(table_catalog, true); + | ---------------------------------- in this macro invocation + = note: this warning originates in the macro `reaper_suite` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: `ferrodb` (lib test) generated 3 warnings (run `cargo fix --lib -p ferrodb --tests` to apply 1 suggestion) + Finished `test` profile [unoptimized + debuginfo] target(s) in 1m 05s + Executable unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) +# rc=0 end 2026-09-27T09:05:57Z From a52ff407d4458ff0b227fa605c7a97f777a2ec9d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:07:39 -0400 Subject: [PATCH 299/315] F1 PREREG amendment 1: R and G held; mutants M1-M3 and 20x repeats registered before any run --- bench/f1_accept/PREREG.md | 15 +++++++++++ bench/f1_accept/fire.sh | 27 +++++++++++++++++++ .../mutants/M1_listener_polled_again.patch | 11 ++++++++ .../mutants/M2_shutdown_never_connects.patch | 11 ++++++++ .../M3_stop_not_checked_after_accept.patch | 13 +++++++++ 5 files changed, 77 insertions(+) create mode 100644 bench/f1_accept/fire.sh create mode 100644 bench/f1_accept/mutants/M1_listener_polled_again.patch create mode 100644 bench/f1_accept/mutants/M2_shutdown_never_connects.patch create mode 100644 bench/f1_accept/mutants/M3_stop_not_checked_after_accept.patch diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index a80b586b..3c8656b1 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -87,3 +87,18 @@ FAN-QUEUE row; engineering, not an invention lane. Lead-authorised 2026-09-27. L - R: **380 passed, 1 failed** (the new test). G: **381 passed, 0 failed**. - A failure of any OTHER test at R belongs to the base, not to F1. It is re-run alone, and reported with its panic line. - Registered 2026-09-27T09:03:43Z. + +## Amendment 1 (2026-09-27T09:07:39Z; after R and G, before any mutant or repeat run) +- R held: 380 passed, 1 failed (the new test, final assertion, (A, B) = (1, 2)). Raw 4eac4f2. +- G held: 381 passed, 0 failed, the reset test included. Raw 2cd07b4. +- Mutants: the three patches in `mutants/`, each against the fix commit ca771fb, run by `fire.sh` over all 381 + `consensus::` tests. transport.rs is restored from git before each run. + - M1: killed by the new test only. + - M2: killed by `shutdown_joins_every_thread_and_closes_the_listener`. Other tests may also fail, since every shutdown + now detaches its accept thread after 5 s; the run is slower. + - M3: survives (381 passed). +- Repeats, to separate a race from a result (one binary each, the single test, 20 runs): + - the new test on the RED binary: 0 of 20 pass; + - the new test on the GREEN binary: 20 of 20 pass; + - `a_peer_that_resets_before_accept_cannot_fill_the_connection_cap` on the GREEN binary: 20 of 20 pass (the named + F1 risk). Any failure there is reported with its panic line, as a fixture question for the lead, and not edited. diff --git a/bench/f1_accept/fire.sh b/bench/f1_accept/fire.sh new file mode 100644 index 00000000..ecc03b3c --- /dev/null +++ b/bench/f1_accept/fire.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# F1 mutant fire-check: apply one patch to the fix commit, build the lib test binary, restore transport.rs FROM GIT +# before running anything, then run every consensus:: test against the mutant's copied binary. +# Usage: bash bench/f1_accept/fire.sh ... (refuses unless HEAD is and the tree is clean) +set -u +export PATH="$HOME/.cargo/bin:$PATH" +WT=/Users/idide/wt/ferrodb-d224-f1-accept.noindex +S=/private/tmp/claude-501/-Users-idide-projects-ferrodb/b2b44149-483d-42d1-b512-89bf5de5a135/scratchpad/r11-dist/f1 +FIX=${1:?fix sha}; shift +cd "$WT" || exit 2 +[ "$(git rev-parse --short HEAD)" = "$FIX" ] || { echo "REFUSED: HEAD is not $FIX"; exit 2; } +trap 'git checkout -- src/consensus/transport.rs' EXIT +for m in "$@"; do + out=bench/f1_accept/raw/${m}_$FIX.txt + [ -z "$(git status --porcelain -- src)" ] || { echo "REFUSED: src is dirty before $m"; exit 2; } + git apply "bench/f1_accept/mutants/$m.patch" || { echo "REFUSED: $m does not apply"; exit 2; } + { echo "# mutant $m on $FIX, build start $(date -u +%FT%TZ) load=$(sysctl -n vm.loadavg)" + taskpolicy -b timeout 5400 cargo test --lib --no-run 2>&1 | grep -E '^error|Executable|Finished' + echo "# build rc=${PIPESTATUS[0]}"; } > "$out" 2>&1 + cp target/debug/deps/ferrodb-d929e200b07daf14 "$S/ferrodb_test_$m" + git checkout -- src/consensus/transport.rs + [ -z "$(git status --porcelain -- src)" ] || { echo "REFUSED: restore failed after $m"; exit 2; } + { echo "# run start $(date -u +%FT%TZ) load=$(sysctl -n vm.loadavg) binary sha256 $(shasum -a 256 "$S/ferrodb_test_$m" | cut -c1-16)" + taskpolicy -b timeout 1800 "$S/ferrodb_test_$m" consensus:: 2>&1 + echo "# rc=$? end $(date -u +%FT%TZ)"; } >> "$out" 2>&1 + echo "$m: $(grep '^test result' "$out")" +done diff --git a/bench/f1_accept/mutants/M1_listener_polled_again.patch b/bench/f1_accept/mutants/M1_listener_polled_again.patch new file mode 100644 index 00000000..355e6bd9 --- /dev/null +++ b/bench/f1_accept/mutants/M1_listener_polled_again.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -1527,7 +1527,7 @@ + // wait). Set explicitly, because a caller's listener may arrive nonblocking. A blocking + // `accept` does not see the stop flag, so `shutdown` wakes it: see `wake_accept`. + listener +- .set_nonblocking(false) ++ .set_nonblocking(true) + .map_err(|e| FerroError::Io(e.to_string()))?; + + let stop = Arc::new(AtomicBool::new(false)); diff --git a/bench/f1_accept/mutants/M2_shutdown_never_connects.patch b/bench/f1_accept/mutants/M2_shutdown_never_connects.patch new file mode 100644 index 00000000..a6231ab4 --- /dev/null +++ b/bench/f1_accept/mutants/M2_shutdown_never_connects.patch @@ -0,0 +1,11 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -2315,7 +2315,7 @@ + if left.is_zero() { + return false; + } +- if TcpStream::connect_timeout(&wake, left).is_ok() { ++ if false && TcpStream::connect_timeout(&wake, left).is_ok() { + return true; + } + std::thread::sleep(retry.min(until.saturating_duration_since(Instant::now()))); diff --git a/bench/f1_accept/mutants/M3_stop_not_checked_after_accept.patch b/bench/f1_accept/mutants/M3_stop_not_checked_after_accept.patch new file mode 100644 index 00000000..aa666d6f --- /dev/null +++ b/bench/f1_accept/mutants/M3_stop_not_checked_after_accept.patch @@ -0,0 +1,13 @@ +--- a/src/consensus/transport.rs ++++ b/src/consensus/transport.rs +@@ -2344,10 +2344,6 @@ + Ok((stream, _)) => { + // A connection taken after a stop is closed unserved, and the loop leaves: the + // transport is stopping, and this may be `shutdown`'s own wake (F1). +- if stop.load(Ordering::SeqCst) { +- let _ = stream.shutdown(Shutdown::Both); +- break; +- } + if std::mem::take(&mut slept) { + counters.accepts_after_sleep.fetch_add(1, Ordering::SeqCst); + } From a72ca497500e865ed199bf0336623495bb389983 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:07:50 -0400 Subject: [PATCH 300/315] F1 fire.sh: gate on src equal to the fix commit, not on HEAD --- bench/f1_accept/fire.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bench/f1_accept/fire.sh b/bench/f1_accept/fire.sh index ecc03b3c..82bf63d5 100644 --- a/bench/f1_accept/fire.sh +++ b/bench/f1_accept/fire.sh @@ -1,14 +1,14 @@ #!/usr/bin/env bash # F1 mutant fire-check: apply one patch to the fix commit, build the lib test binary, restore transport.rs FROM GIT # before running anything, then run every consensus:: test against the mutant's copied binary. -# Usage: bash bench/f1_accept/fire.sh ... (refuses unless HEAD is and the tree is clean) +# Usage: bash bench/f1_accept/fire.sh ... (refuses unless src at HEAD equals 's and is clean) set -u export PATH="$HOME/.cargo/bin:$PATH" WT=/Users/idide/wt/ferrodb-d224-f1-accept.noindex S=/private/tmp/claude-501/-Users-idide-projects-ferrodb/b2b44149-483d-42d1-b512-89bf5de5a135/scratchpad/r11-dist/f1 FIX=${1:?fix sha}; shift cd "$WT" || exit 2 -[ "$(git rev-parse --short HEAD)" = "$FIX" ] || { echo "REFUSED: HEAD is not $FIX"; exit 2; } +git diff --quiet "$FIX" HEAD -- src || { echo "REFUSED: src at HEAD differs from $FIX"; exit 2; } trap 'git checkout -- src/consensus/transport.rs' EXIT for m in "$@"; do out=bench/f1_accept/raw/${m}_$FIX.txt From 2722963b5e65fb9c8c7c9d733da2296fb0302d0d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:08:44 -0400 Subject: [PATCH 301/315] F1 raw: 20x repeats, unread --- bench/f1_accept/raw/repeats_20x.txt | 84 +++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 bench/f1_accept/raw/repeats_20x.txt diff --git a/bench/f1_accept/raw/repeats_20x.txt b/bench/f1_accept/raw/repeats_20x.txt new file mode 100644 index 00000000..f0b3433e --- /dev/null +++ b/bench/f1_accept/raw/repeats_20x.txt @@ -0,0 +1,84 @@ + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435490540) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435491483) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435492417) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435493026) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435493977) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435495162) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435495951) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435496683) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435497433) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435498263) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435498843) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435499598) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435500246) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435501139) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435502576) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435503202) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435503754) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435504284) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435504816) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435505356) panicked at src/consensus/tests_transport.rs:3159:5: + assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 2) + right: (0, 0) +REPEAT binary=red_323c965 test=a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll pass=0 fail=20 +REPEAT binary=green_ca771fb test=a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll pass=20 fail=0 +REPEAT binary=green_ca771fb test=a_peer_that_resets_before_accept_cannot_fill_the_connection_cap pass=20 fail=0 +# end 2026-09-27T09:08:44Z From 36d5c0486314dabf8d1f95050594e212f027c26d Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:12:48 -0400 Subject: [PATCH 302/315] F1 raw: mutants M1-M3 on ca771fb, unread --- .../raw/M1_listener_polled_again_ca771fb.txt | 405 +++++++++++++++++ .../M2_shutdown_never_connects_ca771fb.txt | 410 ++++++++++++++++++ ..._stop_not_checked_after_accept_ca771fb.txt | 392 +++++++++++++++++ 3 files changed, 1207 insertions(+) create mode 100644 bench/f1_accept/raw/M1_listener_polled_again_ca771fb.txt create mode 100644 bench/f1_accept/raw/M2_shutdown_never_connects_ca771fb.txt create mode 100644 bench/f1_accept/raw/M3_stop_not_checked_after_accept_ca771fb.txt diff --git a/bench/f1_accept/raw/M1_listener_polled_again_ca771fb.txt b/bench/f1_accept/raw/M1_listener_polled_again_ca771fb.txt new file mode 100644 index 00000000..9e08fe4c --- /dev/null +++ b/bench/f1_accept/raw/M1_listener_polled_again_ca771fb.txt @@ -0,0 +1,405 @@ +# mutant M1_listener_polled_again on ca771fb, build start 2026-09-27T09:08:52Z load={ 19.47 20.30 19.70 } + Finished `test` profile [unoptimized + debuginfo] target(s) in 47.27s + Executable unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) +# build rc=0 +# run start 2026-09-27T09:09:42Z load={ 17.61 19.69 19.51 } binary sha256 6a50cc40a0fe674b + +running 381 tests +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_of_ordinary_frames_stays_readable_by_an_older_build ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_membership_the_wire_cannot_carry_is_refused_at_proposal_and_never_reaches_the_log ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_entry_that_fits_no_frame_is_still_offered_alone ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::node::tests_node::the_transport_meters_are_readable_from_a_running_node ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::replicate::tests_replicate::a_wal_batch_one_byte_over_a_frame_is_refused_at_proposal_and_one_at_the_limit_is_admitted ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::log::tests_log::the_largest_entry_one_frame_can_carry_is_storable_and_reads_back ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::log::tests_log::a_version_raise_on_a_checkpointed_log_keeps_the_floor_and_every_round ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::log::tests_log::a_frame_over_the_pre_d223_read_bound_makes_an_older_build_refuse_the_log ... ok +test consensus::log::tests_log::the_largest_entry_an_unsigned_frame_carries_is_delivered_and_stored ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_mark_while_a_large_frame_survives ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::log::tests_log::the_version_is_raised_exactly_above_the_pre_d223_read_bound ... ok +test consensus::log::tests_log::a_mixed_batch_raises_the_version_whichever_entry_is_large ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::node::tests_node::the_idle_probe_meters_are_readable_from_a_running_node ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_connection_cap_of_zero_is_refused_at_bind ... ok +test consensus::transport::tests::a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::transport::tests::a_leaders_full_catch_up_append_of_membership_entries_is_delivered ... ok +test consensus::transport::tests::a_frame_sent_after_the_peer_idle_closed_the_link_arrives_on_the_first_attempt ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::a_peer_that_resets_before_accept_cannot_fill_the_connection_cap ... ok +test consensus::transport::tests::a_link_left_holding_a_refusal_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_link_the_peer_reset_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_link_closed_by_a_peer_with_half_the_idle_deadline_is_still_probed ... ok +test consensus::transport::tests::a_zero_idle_deadline_is_refused_at_bind ... ok +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll ... FAILED +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::every_send_the_transport_refuses_is_counted ... ok +test consensus::transport::tests::one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::removing_voters_from_a_learner_list_is_a_binary_search_per_learner ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::an_idle_closed_link_is_probed_once_and_redialled ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone ... ok +test consensus::replicate::tests_replicate::an_append_is_cut_where_a_signed_frame_would_overflow_and_not_a_byte_before ... ok +test consensus::transport::tests::the_outbound_byte_count_returns_to_zero_as_the_sender_drains ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::refusals_at_a_full_cap_are_paced_by_the_poll_interval ... ok +test consensus::transport::tests::an_open_connection_holds_its_slot_until_it_closes ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::log::tests_log::no_crash_can_leave_a_large_frame_under_a_header_an_older_build_accepts ... ok +test consensus::log::tests_log::no_crash_during_a_version_raise_loses_a_round_this_build_reads ... ok +test consensus::transport::tests::a_busy_link_is_never_probed ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire ... ok + +failures: + +---- consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll stdout ---- + +thread 'consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll' (435551474) panicked at src/consensus/tests_transport.rs:3159:5: +assertion `left == right` failed: connections were accepted only after the accept thread had slept (A, B), so each one waited up to a 50ms poll for its handshake answer: a redial's campaign frame waits a tick + left: (1, 1) + right: (0, 0) + + +failures: + consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll + +test result: FAILED. 380 passed; 1 failed; 0 ignored; 0 measured; 1323 filtered out; finished in 33.63s + +# rc=101 end 2026-09-27T09:10:16Z diff --git a/bench/f1_accept/raw/M2_shutdown_never_connects_ca771fb.txt b/bench/f1_accept/raw/M2_shutdown_never_connects_ca771fb.txt new file mode 100644 index 00000000..a1b1b55e --- /dev/null +++ b/bench/f1_accept/raw/M2_shutdown_never_connects_ca771fb.txt @@ -0,0 +1,410 @@ +# mutant M2_shutdown_never_connects on ca771fb, build start 2026-09-27T09:11:00Z load={ 19.49 19.74 19.54 } + Finished `test` profile [unoptimized + debuginfo] target(s) in 12.14s + Executable unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) +# build rc=0 +# run start 2026-09-27T09:11:13Z load={ 18.65 19.55 19.47 } binary sha256 12c1c5490d062a04 + +running 381 tests +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_log_of_ordinary_frames_stays_readable_by_an_older_build ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::node::tests_node::the_transport_meters_are_readable_from_a_running_node ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_membership_the_wire_cannot_carry_is_refused_at_proposal_and_never_reaches_the_log ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::a_wal_batch_one_byte_over_a_frame_is_refused_at_proposal_and_one_at_the_limit_is_admitted ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::log::tests_log::the_largest_entry_one_frame_can_carry_is_storable_and_reads_back ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_entry_that_fits_no_frame_is_still_offered_alone ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::log::tests_log::a_frame_over_the_pre_d223_read_bound_makes_an_older_build_refuse_the_log ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::log::tests_log::a_version_raise_on_a_checkpointed_log_keeps_the_floor_and_every_round ... ok +test consensus::log::tests_log::the_largest_entry_an_unsigned_frame_carries_is_delivered_and_stored ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_mark_while_a_large_frame_survives ... ok +test consensus::log::tests_log::a_mixed_batch_raises_the_version_whichever_entry_is_large ... ok +test consensus::log::tests_log::the_version_is_raised_exactly_above_the_pre_d223_read_bound ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::node::tests_node::the_idle_probe_meters_are_readable_from_a_running_node ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::replicate::tests_replicate::an_append_is_cut_where_a_signed_frame_would_overflow_and_not_a_byte_before ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_connection_cap_of_zero_is_refused_at_bind ... ok +test consensus::transport::tests::a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test consensus::transport::tests::a_link_the_peer_reset_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_link_left_holding_a_refusal_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_resets_before_accept_cannot_fill_the_connection_cap ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_frame_sent_after_the_peer_idle_closed_the_link_arrives_on_the_first_attempt ... ok +test consensus::transport::tests::a_zero_idle_deadline_is_refused_at_bind ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_leaders_full_catch_up_append_of_membership_entries_is_delivered ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::log::tests_log::no_crash_can_leave_a_large_frame_under_a_header_an_older_build_accepts ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::a_link_closed_by_a_peer_with_half_the_idle_deadline_is_still_probed ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::log::tests_log::no_crash_during_a_version_raise_loses_a_round_this_build_reads ... ok +test consensus::transport::tests::removing_voters_from_a_learner_list_is_a_binary_search_per_learner ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::refusals_at_a_full_cap_are_paced_by_the_poll_interval ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::every_send_the_transport_refuses_is_counted ... ok +test consensus::transport::tests::one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::an_open_connection_holds_its_slot_until_it_closes ... ok +test consensus::transport::tests::an_idle_closed_link_is_probed_once_and_redialled ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... FAILED +test consensus::transport::tests::a_busy_link_is_never_probed ... ok +test consensus::transport::tests::the_outbound_byte_count_returns_to_zero_as_the_sender_drains ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay has been running for over 60 seconds +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay ... FAILED + +failures: + +---- consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener stdout ---- + +thread 'consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener' (435609391) panicked at src/consensus/tests_transport.rs:1095:5: +the listener was still accepting after shutdown, so a thread outlived the transport + +---- consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay stdout ---- + +thread 'consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay' (435605169) panicked at src/consensus/tests_transport.rs:2515:5: +shutdown took 60.208895s: the sender's dial failed because of the shutdown, missed the notify that had already been sent, and waited out its 30 s reconnect_delay + + +failures: + consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay + consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener + +test result: FAILED. 379 passed; 2 failed; 0 ignored; 0 measured; 1323 filtered out; finished in 79.78s + +# rc=101 end 2026-09-27T09:12:33Z diff --git a/bench/f1_accept/raw/M3_stop_not_checked_after_accept_ca771fb.txt b/bench/f1_accept/raw/M3_stop_not_checked_after_accept_ca771fb.txt new file mode 100644 index 00000000..686f111b --- /dev/null +++ b/bench/f1_accept/raw/M3_stop_not_checked_after_accept_ca771fb.txt @@ -0,0 +1,392 @@ +# mutant M3_stop_not_checked_after_accept on ca771fb, build start 2026-09-27T09:10:16Z load={ 18.19 19.58 19.47 } + Finished `test` profile [unoptimized + debuginfo] target(s) in 8.99s + Executable unittests src/lib.rs (target/debug/deps/ferrodb-d929e200b07daf14) +# build rc=0 +# run start 2026-09-27T09:10:26Z load={ 17.52 19.40 19.41 } binary sha256 ab6bd70bd6f06195 + +running 381 tests +test consensus::election::tests_election::a_new_term_is_a_new_vote ... ok +test consensus::election::tests_election::a_node_absent_from_its_own_configuration_never_campaigns ... ok +test consensus::election::tests_election::a_node_does_not_vote_twice_in_one_term ... ok +test consensus::election::tests_election::a_granted_vote_is_made_durable_before_it_is_sent ... ok +test consensus::election::tests_election::a_new_leader_starts_every_peer_with_no_evidence_and_optimistic_next ... ok +test consensus::election::tests_election::a_leader_removed_from_its_own_configuration_steps_down ... ok +test consensus::election::tests_election::a_leader_that_hears_only_from_a_minority_still_demotes ... ok +test consensus::election::tests_election::a_new_leader_appends_a_no_op_of_its_own_term_before_it_can_commit_anything ... ok +test consensus::election::tests_election::a_node_that_knows_its_configuration_is_stale_does_not_campaign ... ok +test consensus::election::tests_election::a_pre_campaign_does_not_raise_this_nodes_term_and_writes_nothing_durable ... ok +test consensus::election::tests_election::a_campaign_that_wins_nothing_is_retried_from_the_pre_vote_and_not_from_a_raised_term ... ok +test consensus::election::tests_election::a_campaign_in_flight_is_abandoned_when_this_node_learns_it_is_stale ... ok +test consensus::election::tests_election::a_membership_change_mid_campaign_does_not_move_the_denominator ... ok +test consensus::election::tests_election::a_node_campaigning_into_a_wall_never_raises_the_clusters_term ... ok +test consensus::election::tests_election::a_leader_that_stops_hearing_from_a_majority_demotes_itself_before_any_peer_can_win ... ok +test consensus::election::tests_election::a_new_leader_asserts_its_office_at_once_and_then_on_its_heartbeat_interval ... ok +test consensus::election::tests_election::a_pre_candidate_does_not_count_real_votes_it_never_asked_for ... ok +test consensus::election::tests_election::a_pre_vote_answer_about_another_term_is_not_counted ... ok +test consensus::election::tests_election::a_pre_vote_is_refused_by_a_node_that_is_still_being_served_by_a_leader ... ok +test consensus::election::tests_election::a_single_voter_configuration_elects_itself_without_asking_anybody ... ok +test consensus::election::tests_election::a_vote_from_a_node_outside_the_campaign_configuration_is_not_counted ... ok +test consensus::election::tests_election::a_vote_in_the_current_term_is_refused_by_a_node_that_still_has_a_live_leader ... ok +test consensus::election::tests_election::an_older_configuration_report_does_not_mark_a_current_node_stale ... ok +test consensus::election::tests_election::an_unjoined_node_is_not_cleared_by_any_number_of_ticks ... ok +test consensus::election::tests_election::a_campaign_replays_identically_from_a_seed_and_its_windows_are_not_all_equal ... ok +test consensus::election::tests_election::an_unjoined_node_stands_only_once_it_holds_what_a_quorum_holds ... ok +test consensus::election::tests_election::answering_a_pre_vote_neither_raises_the_voters_term_nor_writes_hard_state ... ok +test consensus::election::tests_election::behind_is_cleared_by_applying_a_configuration_and_by_nothing_else ... ok +test consensus::election::tests_election::joining_an_empty_cluster_still_lets_a_new_member_stand ... ok +test consensus::election::tests_election::every_campaign_redraws_its_timeout_inside_the_documented_window ... ok +test consensus::election::tests_election::the_campaign_configuration_is_captured_in_the_same_step_that_raises_the_term ... ok +test consensus::election::tests_election::the_election_restriction_compares_last_term_before_last_round ... ok +test consensus::election::tests_election::the_election_restriction_is_applied_to_pre_votes_too ... ok +test consensus::log::tests_log::a_checkpoint_that_cannot_be_made_durable_poisons_rather_than_returning_an_ordinary_error ... ok +test consensus::log::tests_log::a_checkpoint_can_be_appended_to_and_the_new_rounds_continue_the_numbering ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_suffix_across_a_restart ... ok +test consensus::election::tests_election::a_leader_that_keeps_hearing_from_a_majority_keeps_its_office ... ok +test consensus::log::tests_log::a_checkpoint_syncs_its_data_before_it_writes_the_header_that_makes_it_live ... ok +test consensus::log::tests_log::a_checkpoint_above_the_end_of_the_log_empties_it_and_moves_the_floor ... ok +test consensus::log::tests_log::a_checkpoint_whose_term_disagrees_with_the_log_is_refused ... ok +test consensus::log::tests_log::a_device_error_reading_the_log_is_reported_as_io_and_not_as_corruption ... ok +test consensus::log::tests_log::a_frame_is_cross_checked_against_the_position_the_index_says_it_holds ... ok +test consensus::log::tests_log::a_frame_length_that_runs_past_the_file_ends_the_scan_without_allocating_for_it ... ok +test consensus::log::tests_log::a_failed_fsync_poisons_rather_than_letting_the_next_one_report_success ... ok +test consensus::log::tests_log::a_frame_whose_round_does_not_match_its_position_ends_the_scan ... ok +test consensus::log::tests_log::a_frame_whose_term_goes_backwards_ends_the_scan ... ok +test consensus::log::tests_log::a_header_with_the_wrong_magic_is_not_a_header ... ok +test consensus::log::tests_log::a_live_header_that_cannot_be_read_is_refused_rather_than_reinitialized_over ... ok +test consensus::log::tests_log::a_log_of_ordinary_frames_stays_readable_by_an_older_build ... ok +test consensus::log::tests_log::a_log_written_at_a_newer_format_version_is_refused_rather_than_reinitialized ... ok +test consensus::log::tests_log::a_membership_command_carries_its_learners ... ok +test consensus::log::tests_log::a_node_list_claiming_more_entries_than_the_record_holds_is_refused_rather_than_allocated_for ... ok +test consensus::log::tests_log::a_range_is_bounded_by_both_limits_and_still_returns_one_entry_when_it_must ... ok +test consensus::log::tests_log::a_read_below_the_snapshot_floor_refuses_with_compacted_and_names_the_floor ... ok +test consensus::log::tests_log::a_refused_batch_leaves_the_log_exactly_as_it_was ... ok +test consensus::log::tests_log::a_long_life_of_appends_truncations_and_checkpoints_stays_consistent ... ok +test consensus::log::tests_log::a_sync_makes_everything_appended_so_far_durable ... ok +test consensus::log::tests_log::a_superseded_file_is_retired_so_a_damaged_header_cannot_rewind_the_log ... ok +test consensus::log::tests_log::a_crash_at_any_point_during_a_checkpoint_loses_nothing ... ok +test consensus::log::tests_log::a_torn_first_header_write_does_not_brick_a_log_that_has_nothing_in_it ... ok +test consensus::log::tests_log::a_torn_tail_is_trimmed_and_everything_before_it_survives ... ok +test consensus::log::tests_log::a_truncation_that_cannot_be_made_durable_poisons_the_log ... ok +test consensus::log::tests_log::an_append_leaves_a_zero_terminator_on_the_disk ... ok +test consensus::log::tests_log::a_zero_terminator_stops_the_scan_before_a_frame_left_over_from_an_earlier_life ... ok +test consensus::log::tests_log::an_unknown_tag_is_refused_at_every_level ... ok +test consensus::log::tests_log::crossing_into_ferro_error_keeps_a_corruption_a_corruption ... ok +test consensus::log::tests_log::every_command_variant_survives_a_round_trip_through_the_log_and_a_restart ... ok +test consensus::log::tests_log::rounds_are_contiguous_from_one_and_an_append_that_would_leave_a_hole_is_refused ... ok +test consensus::log::tests_log::terms_never_decrease_along_the_log ... ok +test consensus::log::tests_log::the_column_type_tags_agree_with_the_wals ... ok +test consensus::log::tests_log::the_command_decoder_refuses_trailing_bytes ... ok +test consensus::log::tests_log::the_entry_encoding_transport_will_reuse_round_trips ... ok +test consensus::log::tests_log::the_generation_rises_by_one_at_every_checkpoint_and_survives_a_restart ... ok +test consensus::log::tests_log::the_live_file_is_the_one_at_the_greater_generation ... ok +test consensus::log::tests_log::the_command_decoder_refuses_a_truncated_record_rather_than_panicking ... ok +test consensus::log::tests_log::the_scan_refuses_a_frame_longer_than_the_maximum_before_allocating_for_it ... ok +test consensus::log::tests_log::truncating_a_conflicting_suffix_removes_it_durably ... ok +test consensus::log::tests_log::two_files_claiming_one_generation_are_refused ... ok +test consensus::log::tests_log::writes_that_were_never_synced_do_not_survive_a_crash ... ok +test consensus::log::tests_log::a_value_the_length_fields_cannot_express_is_refused_before_it_becomes_durable ... ok +test consensus::membership::tests_membership::a_change_is_refused_unless_it_is_one_version_and_this_term ... ok +test consensus::membership::tests_membership::a_change_is_refused_until_the_configuration_in_force_is_durable_on_a_majority ... ok +test consensus::membership::tests_membership::a_change_may_not_leave_a_cluster_with_no_voters ... ok +test consensus::membership::tests_membership::a_change_moves_exactly_one_node ... ok +test consensus::membership::tests_membership::a_change_that_grows_the_voter_set_waits_for_a_majority_of_the_larger_set ... ok +test consensus::membership::tests_membership::a_configuration_report_may_move_down_after_a_truncation_and_a_repeat_is_idempotent ... ok +test consensus::membership::tests_membership::a_configuration_that_is_damage_latches_this_node_out_of_office ... ok +test consensus::membership::tests_membership::a_configuration_with_no_recorded_provenance_refuses_rather_than_permits ... ok +test consensus::membership::tests_membership::a_demoted_node_can_never_campaign_again ... ok +test consensus::membership::tests_membership::a_departed_members_acknowledgement_is_dropped_when_the_change_that_dropped_it_is_installed ... ok +test consensus::membership::tests_membership::a_leader_may_not_demote_itself ... ok +test consensus::membership::tests_membership::a_learner_is_promoted_only_once_it_holds_the_leaders_committed_round ... ok +test consensus::membership::tests_membership::a_membership_change_is_refused_until_this_leader_has_committed_an_entry_of_its_own_term ... ok +test consensus::membership::tests_membership::a_learners_acknowledgement_is_not_counted_toward_a_majority ... ok +test consensus::membership::tests_membership::a_peers_acknowledgement_never_moves_backwards_and_a_peer_cannot_state_this_nodes ... ok +test consensus::membership::tests_membership::a_node_being_added_joins_as_a_learner_and_never_straight_as_a_voter ... ok +test consensus::membership::tests_membership::a_promotion_is_refused_for_a_node_with_no_replication_progress_at_all ... ok +test consensus::membership::tests_membership::a_voter_is_demoted_before_it_can_be_removed ... ok +test consensus::membership::tests_membership::an_acknowledgement_of_another_terms_configuration_of_the_same_version_does_not_count ... ok +test consensus::membership::tests_membership::installing_a_configuration_clears_behind_and_leaves_unjoined_alone ... ok +test consensus::membership::tests_membership::a_removed_node_that_keeps_running_cannot_disrupt_the_cluster ... ok +test consensus::membership::tests_membership::only_a_leader_may_begin_a_membership_change ... ok +test consensus::membership::tests_membership::the_configuration_a_node_counts_against_is_the_newest_in_its_log ... ok +test consensus::membership::tests_membership::three_grows_to_five_one_node_at_a_time_and_each_change_waits_for_the_last ... ok +test consensus::node::tests_node::a_missing_hard_state_record_is_a_fresh_node_not_an_error ... ok +test consensus::node::tests_node::a_corrupt_hard_state_record_is_refused_rather_than_guessed ... ok +test consensus::log::tests_log::the_log_opens_on_real_files_and_recovers_from_them ... ok +test consensus::election::tests_election::one_term_never_elects_two_leaders_even_when_every_node_campaigns ... ok +test consensus::membership::tests_membership::no_term_elects_two_leaders_while_the_configuration_is_changing_underneath ... ok +test consensus::node::tests_node::a_truncated_hard_state_record_is_refused ... ok +test consensus::node::tests_node::a_restarted_node_remembers_its_term_and_its_vote ... ok +test consensus::node::tests_node::a_spool_short_of_what_was_digested_is_refused_rather_than_installed ... ok +test consensus::node::tests_node::apply_hands_every_committed_round_to_the_applier_in_order_and_never_skips ... ok +test consensus::node::tests_node::persist_makes_the_entries_durable_before_it_reports_them ... ok +test consensus::node::tests_node::the_transport_meters_are_readable_from_a_running_node ... ok +test consensus::replicate::tests_replicate::a_conflicting_suffix_is_refused_with_a_hint_at_the_first_round_of_the_conflicting_term_run ... ok +test consensus::replicate::tests_replicate::a_divergence_deeper_than_one_batch_converges_without_a_livelock ... ok +test consensus::replicate::tests_replicate::a_duplicate_append_changes_nothing_and_does_not_truncate ... ok +test consensus::replicate::tests_replicate::a_follower_acknowledges_on_persistence_and_never_on_receipt ... ok +test consensus::replicate::tests_replicate::a_follower_clamps_the_leaders_commit_to_the_rounds_it_actually_holds ... ok +test consensus::replicate::tests_replicate::a_follower_does_not_commit_a_suffix_this_leader_never_sent ... ok +test consensus::node::tests_node::a_proposal_to_a_follower_is_refused_rather_than_dropped ... ok +test consensus::replicate::tests_replicate::a_follower_refuses_to_truncate_a_committed_round ... ok +test consensus::replicate::tests_replicate::a_follower_that_refused_an_append_does_not_acknowledge_it_afterwards ... ok +test consensus::replicate::tests_replicate::a_follower_truncates_its_conflicting_suffix_and_the_leader_recovers_in_one_extra_message ... ok +test consensus::replicate::tests_replicate::a_gap_is_refused_with_a_hint_at_the_followers_own_tail ... ok +test consensus::replicate::tests_replicate::a_heartbeat_that_carries_nothing_is_answered_immediately ... ok +test consensus::replicate::tests_replicate::a_leader_counts_its_own_replica_only_once_it_is_durable ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_commit_an_inherited_round_by_counting_replicas ... ok +test consensus::replicate::tests_replicate::a_leader_does_not_count_itself_twice_when_counting_a_quorum ... ok +test consensus::replicate::tests_replicate::a_leader_ignores_an_acknowledgement_of_a_round_it_does_not_hold ... ok +test consensus::replicate::tests_replicate::a_learner_is_replicated_to_but_never_counted_toward_a_quorum ... ok +test consensus::replicate::tests_replicate::a_malformed_entry_list_is_refused_rather_than_aborting_the_process ... ok +test consensus::replicate::tests_replicate::a_membership_entry_in_the_log_tells_this_node_its_configuration_is_stale ... ok +test consensus::replicate::tests_replicate::a_membership_the_wire_cannot_carry_is_refused_at_proposal_and_never_reaches_the_log ... ok +test consensus::replicate::tests_replicate::a_new_leader_appends_a_term_establishing_noop_of_its_own_term ... ok +test consensus::replicate::tests_replicate::a_peer_whose_next_is_below_the_snapshot_floor_is_marked_for_a_snapshot_not_sent_entries ... ok +test consensus::replicate::tests_replicate::a_persisted_from_before_a_truncation_does_not_make_the_replacement_durable ... ok +test consensus::node::tests_node::a_spool_that_kept_up_installs_rather_than_being_refused ... ok +test consensus::replicate::tests_replicate::a_prev_round_below_the_snapshot_floor_moves_the_leader_forward_not_backwards ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_follower_is_refused_and_names_the_leader_it_knows ... ok +test consensus::replicate::tests_replicate::a_proposal_on_a_leader_becomes_a_round_of_its_own_term_and_reaches_every_peer ... ok +test consensus::replicate::tests_replicate::a_real_digest_is_never_the_value_reserved_for_no_claim ... ok +test consensus::replicate::tests_replicate::a_refusal_carries_no_claim_about_the_followers_log ... ok +test consensus::replicate::tests_replicate::a_removed_peers_acknowledgement_is_not_counted ... ok +test consensus::replicate::tests_replicate::a_replayed_refusal_does_not_demand_a_snapshot_from_a_healthy_peer ... ok +test consensus::replicate::tests_replicate::a_stale_append_does_not_move_the_commit_watermark_backwards ... ok +test consensus::replicate::tests_replicate::a_steady_heartbeat_does_not_announce_a_role_change_on_every_beat ... ok +test consensus::replicate::tests_replicate::a_success_answer_never_pulls_next_backwards ... ok +test consensus::replicate::tests_replicate::a_truncation_retracts_the_durability_it_was_about ... ok +test consensus::replicate::tests_replicate::a_variable_length_piece_is_length_prefixed_so_two_framings_cannot_agree ... ok +test consensus::replicate::tests_replicate::adopting_a_new_leader_retracts_everything_established_with_the_last_one ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_durability_and_not_log_length ... ok +test consensus::node::tests_node::the_hard_state_record_round_trips_through_a_file ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_claims_only_the_prefix_the_append_established ... ok +test consensus::replicate::tests_replicate::an_acknowledgement_never_goes_backwards ... ok +test consensus::replicate::tests_replicate::an_append_of_the_current_term_makes_a_candidate_follow_its_leader ... ok +test consensus::replicate::tests_replicate::an_entry_that_fits_no_frame_is_still_offered_alone ... ok +test consensus::replicate::tests_replicate::an_inherited_round_commits_as_a_side_effect_of_a_round_of_the_leaders_own_term ... ok +test consensus::replicate::tests_replicate::an_install_snapshot_is_answered_rather_than_dropped_or_fatal ... ok +test consensus::replicate::tests_replicate::clearing_the_progress_map_destroys_this_nodes_log_and_says_so - should panic ... ok +test consensus::replicate::tests_replicate::initialising_leader_progress_preserves_the_log_and_the_divergence_latches ... ok +test consensus::replicate::tests_replicate::moving_last_round_outside_the_append_path_is_refused_loudly - should panic ... ok +test consensus::replicate::tests_replicate::one_append_carries_a_bounded_number_of_entries ... ok +test consensus::replicate::tests_replicate::quorum_is_counted_over_matched_and_never_over_next ... ok +test consensus::replicate::tests_replicate::the_commit_term_check_reads_the_round_being_committed_not_the_log_tail ... ok +test consensus::replicate::tests_replicate::the_digest_is_chained_so_a_change_at_an_early_round_moves_every_later_one ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_fires_on_a_flipped_byte_in_a_followers_redo_stream ... ok +test consensus::replicate::tests_replicate::the_divergence_detector_stays_quiet_on_a_healthy_cluster ... ok +test consensus::replicate::tests_replicate::the_entry_digest_separates_a_command_from_the_round_and_term_that_carry_it ... ok +test consensus::replicate::tests_replicate::the_hash_matches_the_published_fnv1a_64_vectors ... ok +test consensus::replicate::tests_replicate::the_reserved_no_claim_value_is_never_produced_by_a_real_digest ... ok +test consensus::replicate::tests_replicate::the_uncommitted_inherited_round_is_still_overwritable_which_is_why_it_must_not_commit ... ok +test consensus::replicate::tests_replicate::unjoined_clears_on_an_observable_watermark_and_never_on_a_timer ... ok +test consensus::replicate::tests_replicate::work_is_applied_only_through_the_round_that_is_both_committed_and_durable_here ... ok +test consensus::signing::tests_signing::a_connection_that_sends_one_unverifiable_frame_is_closed_rather_than_left_open ... ok +test consensus::node::tests_node::a_restarted_node_gets_its_round_log_back ... ok +test consensus::signing::tests_signing::a_directory_component_symlink_cannot_redirect_the_key ... ok +test consensus::signing::tests_signing::a_directory_named_where_a_key_was_expected_is_refused ... ok +test consensus::signing::tests_signing::a_frame_signed_with_another_key_is_refused ... ok +test consensus::signing::tests_signing::a_frame_too_short_to_hold_a_tag_is_refused_rather_than_indexed_into ... ok +test consensus::node::tests_node::a_tick_is_delivered_on_the_clock_and_missed_ticks_are_caught_up ... ok +test consensus::signing::tests_signing::a_key_file_of_zeros_is_refused_because_that_is_what_a_failed_generator_leaves ... ok +test consensus::signing::tests_signing::a_fifo_named_as_a_key_is_refused_rather_than_hanging_the_node ... ok +test consensus::signing::tests_signing::a_key_file_readable_by_group_or_other_is_refused ... ok +test consensus::signing::tests_signing::a_key_longer_than_the_block_is_replaced_by_its_own_digest ... ok +test consensus::signing::tests_signing::a_key_in_a_group_or_world_writable_directory_is_refused_unless_it_is_sticky ... ok +test consensus::signing::tests_signing::a_key_file_under_thirty_two_bytes_is_refused ... ok +test consensus::signing::tests_signing::a_key_whose_directory_is_gone_is_refused ... ok +test consensus::signing::tests_signing::a_missing_key_file_is_refused_rather_than_leaving_the_node_unsigned ... ok +test consensus::log::tests_log::a_checkpoint_streams_survivors_larger_than_one_copy_batch ... ok +test consensus::replicate::tests_replicate::a_wal_batch_one_byte_over_a_frame_is_refused_at_proposal_and_one_at_the_limit_is_admitted ... ok +test consensus::signing::tests_signing::a_relative_link_target_resolves_against_the_links_own_directory ... ok +test consensus::signing::tests_signing::a_signed_frame_round_trips_and_carries_exactly_one_tag_of_overhead ... ok +test consensus::signing::tests_signing::a_peer_that_does_not_sign_is_refused_by_a_node_that_does ... ok +test consensus::signing::tests_signing::a_symlink_cannot_launder_a_key_out_of_a_world_writable_directory ... ok +test consensus::signing::tests_signing::a_symlink_cycle_is_refused_by_the_shape_check_before_the_walk_begins ... ok +test consensus::signing::tests_signing::a_symlink_whose_own_directory_is_open_is_also_refused ... ok +test consensus::signing::tests_signing::a_symlinked_directory_component_is_judged_by_what_it_points_at ... ok +test consensus::signing::tests_signing::a_tag_is_domain_separated_from_a_bare_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::accepting_the_unverifiable_does_not_weaken_the_platform_that_can_verify ... ok +test consensus::signing::tests_signing::an_ancestor_refusal_does_not_tell_the_operator_to_chmod_somebody_elses_directory ... ok +test consensus::signing::tests_signing::a_node_holding_a_different_key_is_refused_and_counted ... ok +test consensus::signing::tests_signing::constant_time_eq_answers_correctly_at_every_difference_position ... ok +test consensus::log::tests_log::an_entry_too_large_for_the_transport_is_refused_at_append ... ok +test consensus::signing::tests_signing::constant_time_eq_refuses_different_lengths ... ok +test consensus::signing::tests_signing::decode_verified_authenticates_before_it_parses ... ok +test consensus::signing::tests_signing::every_byte_of_the_message_is_inside_the_mac ... ok +test consensus::signing::tests_signing::every_directory_above_the_key_is_inspected_not_only_its_parent ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_key_changes_the_tag ... ok +test consensus::signing::tests_signing::flipping_one_bit_of_the_message_changes_the_tag ... ok +test consensus::signing::tests_signing::hashing_the_pieces_is_hashing_their_join ... ok +test consensus::signing::tests_signing::hmac_sha256_agrees_with_rfc_4231 ... ok +test consensus::signing::tests_signing::key_tag_agrees_with_an_independent_hmac_over_the_same_bytes ... ok +test consensus::signing::tests_signing::no_hop_of_the_symlink_chain_escapes_the_directory_rule ... ok +test consensus::signing::tests_signing::protection_is_checkable_exactly_where_std_exposes_the_mode_bits ... ok +test consensus::signing::tests_signing::replay_of_a_valid_frame_is_accepted_because_there_is_no_freshness_check ... ok +test consensus::signing::tests_signing::signs_its_traffic_reports_the_configuration ... ok +test consensus::signing::tests_signing::the_domain_names_this_protocol_its_frame_tag_and_its_wire_version ... ok +test consensus::signing::tests_signing::the_key_never_appears_in_a_debug_rendering ... ok +test consensus::signing::tests_signing::the_platform_that_cannot_read_its_own_file_protection_refuses_rather_than_allowing ... ok +test consensus::signing::tests_signing::the_spelling_of_the_path_does_not_decide_whether_the_directory_is_checked ... ok +test consensus::signing::tests_signing::the_tag_is_not_sha256_of_the_key_followed_by_the_message ... ok +test consensus::signing::tests_signing::the_term_is_inside_the_mac ... ok +test consensus::signing::tests_signing::truncating_or_extending_a_signed_frame_is_refused ... ok +test consensus::signing::tests_signing::two_nodes_holding_the_same_key_exchange_messages ... ok +test consensus::signing::tests_signing::verify_is_the_constant_time_comparison_and_not_a_slice_equality ... ok +test consensus::sim::tests_sim::a_cluster_cut_into_two_minorities_elects_nobody ... ok +test consensus::sim::tests_sim::a_cold_cluster_elects_exactly_one_leader_within_an_election_window ... ok +test consensus::log::tests_log::the_largest_entry_one_frame_can_carry_is_storable_and_reads_back ... ok +test consensus::sim::tests_sim::a_failing_seed_replays_to_the_same_failure_with_a_trace ... ok +test consensus::sim::tests_sim::a_healthy_five_node_cluster_elects_one_leader_and_commits_what_clients_propose ... ok +test consensus::sim::tests_sim::a_leader_that_never_gives_up_its_lease_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::a_leader_whose_acknowledgements_are_cut_off_one_way_still_gives_up_the_office ... ok +test consensus::sim::tests_sim::a_partitioned_leader_demotes_itself_before_anybody_tells_it ... ok +test consensus::sim::tests_sim::a_pre_vote_must_not_raise_anybodys_term ... ok +test consensus::node::tests_node::the_idle_probe_meters_are_readable_from_a_running_node ... ok +test consensus::signing::tests_signing::a_signed_peer_is_refused_by_a_node_with_no_key ... ok +test consensus::log::tests_log::a_frame_over_the_pre_d223_read_bound_makes_an_older_build_refuse_the_log ... ok +test consensus::log::tests_log::the_largest_entry_an_unsigned_frame_carries_is_delivered_and_stored ... ok +test consensus::sim::tests_sim::committing_an_inherited_round_by_counting_replicas_loses_it_in_the_figure_8_scenario ... ok +test consensus::log::tests_log::a_version_raise_on_a_checkpointed_log_keeps_the_floor_and_every_round ... ok +test consensus::signing::tests_signing::an_attacker_raising_the_term_on_the_wire_never_reaches_the_state_machine ... ok +test consensus::sim::tests_sim::acknowledging_an_append_before_the_fsync_is_caught_and_the_seed_is_named ... ok +test consensus::log::tests_log::a_checkpoint_keeps_the_mark_while_a_large_frame_survives ... ok +test consensus::log::tests_log::a_mixed_batch_raises_the_version_whichever_entry_is_large ... ok +test consensus::sim::tests_sim::the_real_consensus_state_machine_runs_under_this_simulator_the_moment_f1_and_f2_land ... ok +test consensus::log::tests_log::the_version_is_raised_exactly_above_the_pre_d223_read_bound ... ok +test consensus::sim::tests_sim::killing_the_leader_under_load_costs_no_committed_round ... ok +test consensus::sim::tests_sim::the_same_seed_replays_the_same_run ... ok +test consensus::sim::tests_sim::sending_a_vote_before_its_hard_state_is_durable_is_caught_and_the_seed_is_named ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_above_what_the_engine_has_applied_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_at_or_below_the_floor_is_a_no_op ... ok +test consensus::snapshot::tests_snapshot::a_checkpoint_keeps_the_digests_of_the_rounds_it_keeps ... ok +test consensus::signing::tests_signing::constant_time_eq_does_not_short_circuit ... ok +test consensus::snapshot::tests_snapshot::a_chunk_fits_a_frame_and_the_first_one_carries_the_whole_header ... ok +test consensus::snapshot::tests_snapshot::a_chunk_that_would_run_past_the_declared_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_is_judged_on_its_length_and_then_on_its_bytes ... ok +test consensus::snapshot::tests_snapshot::a_completed_transfer_moves_next_and_never_matched ... ok +test consensus::snapshot::tests_snapshot::a_header_claiming_an_unbounded_sidecar_section_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_header_whose_page_count_contradicts_its_image_length_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_base_digest_is_not_its_own ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_a_snapshot_whose_term_disagrees_with_its_own_log ... ok +test consensus::snapshot::tests_snapshot::a_chunk_at_the_wrong_offset_is_answered_with_the_resume_point_and_never_buffered ... ok +test consensus::snapshot::tests_snapshot::a_leader_refuses_to_serve_a_snapshot_above_what_its_engine_has_applied ... ok +test consensus::snapshot::tests_snapshot::a_payload_header_that_is_not_one_is_refused_rather_than_guessed_at ... ok +test consensus::snapshot::tests_snapshot::a_payload_round_trips_and_its_header_describes_its_own_body ... ok +test consensus::snapshot::tests_snapshot::a_payload_whose_body_digest_does_not_match_is_refused_before_the_driver_is_told ... ok +test consensus::snapshot::tests_snapshot::a_peer_below_the_floor_is_sent_state_and_never_entries ... ok +test consensus::snapshot::tests_snapshot::a_refused_snapshot_does_not_change_this_nodes_office ... ok +test consensus::snapshot::tests_snapshot::a_restart_above_a_snapshot_floor_comes_back_at_the_floor_and_not_at_zero ... ok +test consensus::snapshot::tests_snapshot::a_receiver_that_lost_a_transfer_rewinds_it_rather_than_deadlocking_it ... ok +test consensus::sim::tests_sim::two_different_seeds_do_not_produce_the_same_run ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_at_round_zero_is_refused ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_claiming_an_implausible_size_is_refused_before_anything_is_allocated_for_it ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_of_a_round_this_node_already_holds_is_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_older_than_this_nodes_floor_is_acknowledged_whole_and_not_installed ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_whose_configuration_is_damage_is_refused_before_anything_is_spent ... ok +test consensus::snapshot::tests_snapshot::a_snapshot_with_no_configuration_is_refused_because_its_receiver_could_not_count_a_majority ... ok +test consensus::snapshot::tests_snapshot::a_short_done_keeps_the_bytes_already_accepted_and_a_bad_digest_does_not ... ok +test consensus::snapshot::tests_snapshot::a_transfer_ack_resets_the_peers_silence_so_a_slow_install_does_not_cost_the_leader_its_lease ... ok +test consensus::snapshot::tests_snapshot::a_zero_page_image_is_refused_because_installing_it_would_look_like_success ... ok +test consensus::snapshot::tests_snapshot::an_answer_from_a_removed_node_does_not_resurrect_its_progress ... ok +test consensus::snapshot::tests_snapshot::an_envelope_and_a_payload_that_disagree_are_refused ... ok +test consensus::snapshot::tests_snapshot::an_install_discards_the_whole_log_and_the_floor_moves_with_it ... ok +test consensus::snapshot::tests_snapshot::an_installed_snapshot_anchors_the_digest_chain_where_the_leader_is ... ok +test consensus::snapshot::tests_snapshot::completing_an_install_sets_the_watermark_and_leaves_unjoined_to_the_next_append ... ok +test consensus::snapshot::tests_snapshot::nothing_moves_until_the_install_is_reported_durable ... ok +test consensus::snapshot::tests_snapshot::a_transfer_that_claims_to_be_done_at_the_wrong_length_is_refused ... ok +test consensus::tests_contract::a_joining_node_may_not_campaign_and_each_flag_is_cleared_by_its_own_evidence ... ok +test consensus::tests_contract::a_leaders_lease_expires_strictly_before_any_peer_can_win ... ok +test consensus::tests_contract::a_learner_is_sent_to_but_never_counted ... ok +test consensus::tests_contract::a_node_outside_its_own_configuration_may_not_campaign ... ok +test consensus::tests_contract::a_repeated_member_cannot_inflate_the_quorum ... ok +test consensus::tests_contract::a_voter_cannot_be_demoted_to_learner_by_a_careless_builder ... ok +test consensus::tests_contract::an_empty_configuration_never_has_a_quorum ... ok +test consensus::tests_contract::quorum_is_a_strict_majority_at_every_size ... ok +test consensus::tests_contract::the_rng_is_reproducible_from_a_seed_and_a_zero_seed_is_not_a_fixed_point ... ok +test consensus::tests_contract::timeouts_are_drawn_inside_the_documented_window ... ok +test consensus::snapshot::tests_snapshot::receiving_state_adopts_the_sender_as_leader_so_a_long_transfer_is_not_an_election ... ok +test consensus::snapshot::tests_snapshot::a_second_snapshot_supersedes_the_one_in_flight ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_non_ddl_record_is_refused_before_it_can_panic ... ok +test consensus::transport::tests::a_catalog_command_carrying_a_page_id_is_refused ... ok +test consensus::transport::tests::a_catalog_command_with_bytes_after_its_ddl_record_is_refused ... ok +test consensus::transport::tests::a_catalog_name_too_long_for_the_wire_is_refused_by_the_sender ... ok +test consensus::transport::tests::a_configuration_naming_more_nodes_than_the_wire_allows_is_refused_before_any_id_is_read ... ok +test consensus::transport::tests::a_configuration_that_repeats_a_member_is_refused_and_cannot_inflate_the_quorum ... ok +test consensus::transport::tests::a_connection_cap_of_zero_is_refused_at_bind ... ok +test consensus::transport::tests::a_connection_whose_socket_setup_fails_releases_the_slot_it_reserved ... ok +test consensus::transport::tests::a_flag_byte_that_is_not_zero_or_one_is_refused ... ok +test consensus::transport::tests::a_frame_claiming_four_billion_entries_is_refused_rather_than_reserved_for ... ok +test consensus::signing::tests_signing::a_keyless_peer_cannot_raise_the_term_of_a_node_built_through_the_driver ... ok +test consensus::transport::tests::a_frame_split_across_read_timeouts_is_reassembled_and_not_desynchronised ... ok +test consensus::transport::tests::a_broken_connection_is_reconnected_and_the_frame_lost_to_it_is_counted ... ok +test consensus::sim::tests_sim::a_restarted_node_comes_back_with_only_what_it_had_fsynced ... ok +test consensus::signing::tests_signing::a_message_that_fits_unsigned_is_refused_signed_rather_than_framed_over_the_limit ... ok +test consensus::transport::tests::a_leaders_full_catch_up_append_of_membership_entries_is_delivered ... ok +test consensus::transport::tests::a_frame_with_an_unknown_tag_closes_the_connection_rather_than_being_skipped ... ok +test consensus::sim::tests_sim::the_fault_model_injects_every_fault_it_claims_to ... ok +test consensus::transport::tests::a_maximal_frame_round_trips_and_one_byte_more_is_refused_at_the_encoder ... ok +test consensus::transport::tests::a_frame_sent_after_the_peer_idle_closed_the_link_arrives_on_the_first_attempt ... ok +test consensus::transport::tests::a_node_listed_as_both_voter_and_learner_is_refused ... ok +test consensus::transport::tests::a_peer_map_naming_this_node_is_refused_at_bind ... ok +test consensus::transport::tests::a_message_addressed_to_another_node_is_refused_rather_than_delivered ... ok +test consensus::transport::tests::a_peer_that_cannot_be_reached_drops_the_oldest_and_counts_every_drop ... ok +test consensus::transport::tests::a_peer_that_closes_inside_a_frame_is_refused_rather_than_completed ... ok +test consensus::transport::tests::a_huge_entry_list_is_refused_before_it_is_built_and_not_after ... ok +test consensus::transport::tests::a_queue_depth_of_zero_is_refused_rather_than_silently_partitioning_the_node ... ok +test consensus::transport::tests::a_peer_that_resets_before_accept_cannot_fill_the_connection_cap ... ok +test consensus::transport::tests::a_send_after_shutdown_is_refused_rather_than_silently_discarded ... ok +test consensus::transport::tests::a_peer_that_connects_and_says_nothing_is_closed_on_the_handshake_deadline ... ok +test consensus::transport::tests::a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay ... ok +test consensus::transport::tests::a_link_the_peer_reset_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_truncated_frame_is_refused_rather_than_misread ... ok +test consensus::transport::tests::a_v1_peer_is_refused_at_the_handshake_rather_than_misparsed_later ... ok +test consensus::transport::tests::a_v1_peer_is_told_which_version_this_node_speaks ... ok +test consensus::transport::tests::a_zero_idle_deadline_is_refused_at_bind ... ok +test consensus::transport::tests::a_zero_timeout_is_refused_at_bind_rather_than_failing_every_socket_call ... ok +test consensus::transport::tests::a_link_left_holding_a_refusal_is_redialled_after_an_idle_gap ... ok +test consensus::transport::tests::a_silent_peer_is_closed_on_the_idle_deadline_rather_than_pinning_a_thread_for_ever ... ok +test consensus::transport::tests::an_inbound_connection_frees_its_descriptor_when_it_closes ... ok +test consensus::transport::tests::an_oversized_length_is_refused_before_a_single_body_byte_is_read ... ok +test consensus::transport::tests::a_stopped_transport_is_distinguishable_from_a_quiet_one ... ok +test consensus::transport::tests::an_unknown_kind_command_or_branch_tag_is_refused_by_name ... ok +test consensus::transport::tests::an_unsorted_member_list_is_refused ... ok +test consensus::transport::tests::concurrent_inbound_connections_are_capped_and_the_refusals_are_counted ... ok +test consensus::transport::tests::every_body_variant_round_trips_including_an_empty_entry_list ... ok +test consensus::transport::tests::every_body_variant_survives_a_real_socket_in_both_directions ... ok +test consensus::transport::tests::every_command_variant_round_trips_inside_an_entry ... ok +test consensus::transport::tests::every_send_the_transport_refuses_is_counted ... ok +test consensus::transport::tests::one_unreachable_peer_cannot_queue_more_than_the_default_byte_bound ... ok +test consensus::transport::tests::read_exact_loses_what_it_consumed_which_is_why_the_frame_reader_is_resumable ... ok +test consensus::transport::tests::a_redial_after_an_idle_close_is_accepted_without_waiting_for_an_accept_poll ... ok +test consensus::transport::tests::removing_voters_from_a_learner_list_is_a_binary_search_per_learner ... ok +test consensus::transport::tests::sending_to_an_unconfigured_peer_or_to_self_is_refused_rather_than_dropped ... ok +test consensus::transport::tests::shutdown_joins_every_thread_and_closes_the_listener ... ok +test consensus::transport::tests::the_consensus_tag_is_disjoint_from_every_replication_tag ... ok +test consensus::transport::tests::the_encoding_is_canonical_so_a_signature_over_a_re_encoding_still_matches ... ok +test consensus::transport::tests::the_length_covers_the_body_only_and_nothing_else ... ok +test consensus::transport::tests::the_oldest_queued_frame_is_the_one_dropped_and_the_newest_always_survives ... ok +test consensus::transport::tests::the_outbound_byte_count_returns_to_zero_as_the_sender_drains ... ok +test consensus::transport::tests::a_link_closed_by_a_peer_with_half_the_idle_deadline_is_still_probed ... ok +test consensus::transport::tests::trailing_bytes_after_the_last_field_are_refused ... ok +test consensus::transport::tests::whichever_bound_binds_first_and_a_frame_over_the_whole_byte_bound_is_still_queued_alone ... ok +test consensus::transport::tests::the_spawn_failure_teardown_actually_stops_the_threads_it_is_given ... ok +test consensus::transport::tests::an_idle_closed_link_is_probed_once_and_redialled ... ok +test consensus::transport::tests::an_undrained_inbox_is_bounded_in_bytes_and_every_refusal_is_counted ... ok +test consensus::sim::tests_sim::counting_quorum_over_next_instead_of_matched_is_caught_and_the_seed_is_named ... ok +test consensus::sim::tests_sim::removing_the_election_restriction_is_caught_and_the_seed_is_named ... ok +test consensus::replicate::tests_replicate::an_append_is_cut_where_a_signed_frame_would_overflow_and_not_a_byte_before ... ok +test consensus::transport::tests::refusals_at_a_full_cap_are_paced_by_the_poll_interval ... ok +test consensus::sim::tests_sim::voting_twice_in_one_term_is_caught_and_the_seed_is_named ... ok +test consensus::transport::tests::an_open_connection_holds_its_slot_until_it_closes ... ok +test consensus::sim::tests_sim::a_send_still_waiting_on_its_fsync_does_not_survive_the_crash ... ok +test consensus::log::tests_log::no_crash_can_leave_a_large_frame_under_a_header_an_older_build_accepts ... ok +test consensus::log::tests_log::no_crash_during_a_version_raise_loses_a_round_this_build_reads ... ok +test consensus::transport::tests::a_busy_link_is_never_probed ... ok +test consensus::sim::tests_sim::the_safety_properties_hold_under_partitions_alone ... ok +test consensus::sim::tests_sim::a_committed_round_is_never_lost_across_a_chaos_sweep ... ok +test consensus::sim::tests_sim::at_most_one_leader_per_term_across_a_chaos_sweep ... ok +test consensus::replicate::tests_replicate::a_follower_far_behind_on_large_entries_catches_up_over_the_real_wire ... ok + +test result: ok. 381 passed; 0 failed; 0 ignored; 0 measured; 1323 filtered out; finished in 33.74s + +# rc=0 end 2026-09-27T09:11:00Z From 00ce1f7b3af8dbae6a8381f91701127624aa1f48 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:13:07 -0400 Subject: [PATCH 303/315] F1 RESULT: R 380/1, G 381/0, repeats 0/20 red 20/20 green 20/20 reset test, M1 and M2 killed, M3 the registered survivor --- bench/f1_accept/PREREG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 3c8656b1..555452dc 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -102,3 +102,32 @@ FAN-QUEUE row; engineering, not an invention lane. Lead-authorised 2026-09-27. L - the new test on the GREEN binary: 20 of 20 pass; - `a_peer_that_resets_before_accept_cannot_fill_the_connection_cap` on the GREEN binary: 20 of 20 pass (the named F1 risk). Any failure there is reported with its panic line, as a fixture question for the lead, and not edited. + +## RESULT (2026-09-27T09:13:07Z) +- R (raw 4eac4f2): 380 passed, 1 failed. The new test failed on its final assertion with (1, 2). **As registered.** +- G (raw 2cd07b4): 381 passed, 0 failed, every named risk included. **As registered.** +- Repeats (raw 2722963): + + | Test | Binary | Result | + |---|---|---| + | new test | red | 0/20 pass; all 20 fail at :3159, (1,1) x6 and (1,2) x14 | + | new test | green | 20/20 pass | + | reset test (macOS) | green | 20/20 pass | + + **As registered.** +- Mutants (raw 36d5c04; five distinct binaries by sha256; transport.rs restored from git before each run, tree clean after): + + | Mutant | Tests | Result | Registered | + |---|---|---|---| + | M1 `listener_polled_again` | 380 passed, 1 failed | **KILLED** by the new test alone, at :3159 with (1, 1) | as registered | + | M2 `shutdown_never_connects` | 379 passed, 2 failed | **KILLED** | as registered | + | M3 `stop_not_checked_after_accept` | 381 passed | **SURVIVED** | the stated survivor | + + - M2 was killed by `shutdown_joins_every_thread_and_closes_the_listener` (:1095, "the listener was still accepting + after shutdown"). + - M2 also failed `a_shutdown_during_a_dial_does_not_wait_out_the_reconnect_delay`: shutdown took 60.2 s. That test + sets `handshake_deadline` = 60 s, which is the wake's bound, so the fix's bounded detach is measured. (That test's + own message blames the reconnect delay; under M2 the cause is the wake.) + - M3 survived because the check saves a thread spawn and is not load-bearing. +- Not run here, by the lead's scope: the full suite, including `tests/integration_consensus_failover.rs` and the cluster + integration targets that start and stop transports. It runs at landing, through lockrun. From 666d185c99e9f7e7f2a4e348765120a834883d92 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:18:02 -0400 Subject: [PATCH 304/315] F1 PREREG amendment 2: real-transport instruments (3), (1), (6) and their predictions, registered before any build or run --- bench/f1_accept/PREREG.md | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 555452dc..4de32fba 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -131,3 +131,57 @@ FAN-QUEUE row; engineering, not an invention lane. Lead-authorised 2026-09-27. L - M3 survived because the check saves a thread spawn and is not load-bearing. - Not run here, by the lead's scope: the full suite, including `tests/integration_consensus_failover.rs` and the cluster integration targets that start and stop transports. It runs at landing, through lockrun. + +## Amendment 2 (2026-09-27T09:18:02Z): F1 is judged on the REAL transport, not DC2. Registered before any instrument is built or run. +The lead's ruling after the DC2 adversary (r11-dist-refute-measure, artie-research 32604b84; REPORT c12bf4e1): +- DC2 confirms the real `Consensus` split condition: a split happens iff the first-frame latency L exceeds the survivors' + expiry gap delta. +- DC2's 14.96 and 0.21 per 100 are production-MODEL estimates. +- F1's "< 1 per 100" holds only if the first-frame latency after an immediate accept, thread spawn included, is under + about 1.65 ms at the box's load. +- The red test and mutants M1-M3 above stand as they are. + +**Where it runs.** +- The r11-dist harness worktree (`r11-dist.noindex`) with this branch merged, plus ONE measurement-only commit that is never + landed. + - A runtime arm switch: the lib static `R11_ACCEPT_POLL` makes `start` leave the listener nonblocking. That is exactly + M1, which is the base accept path: the failed-accept arm sleeps `poll_interval` on WouldBlock, as base's WouldBlock arm + did. + - A bounded per-transport event trace, with instants on one process clock: + - accept-loop wakes from a sleep; + - accepted connections; + - each connection's first frame delivered to the inbox, with its sender's id; + - the sender side: redial found, dial start, dial done, first write; + - a Node log of role transitions with instants, and the tick grid (`next_tick`). +- So base and F1 are the SAME binary. They are interleaved per salt: even salt indexes run base first, odd ones F1 first. + +**Failover design (instrument 6).** +- `firstfail`, N = 10, the production idle deadline (60 s), a 62-s idle before the stop, and the 50-ms tick. +- Random per-node phases: the three `Node::start`s run in a salted random order, each at T0 + U(0, 50 ms). Each node's accept + grid and tick grid keep their production relation: the transport starts inside `Node::start`, just before `next_tick` is + set. +- 30 salts from DC1, each with a winner at least 2 ticks below both survivors, so that start offsets cannot change the winner: + - gap 0: 2, 3, 5, 10, 21, 37, 66, 91, 98, 106, 123, 129; + - gap 1: 7, 27, 28, 42, 46, 48, 52, 64, 65, 70, 82, 84; + - gap >= 2: 1, 4, 6, 8, 9, 14. +- 60 failovers in 4 lockrun holds of 15, each under run.sh's 1200-s timeout, with the 2-s process monitor. +- No load gate: the question is latency AT this box's load. Every hold is scored under A16's refined rule, and its load is + printed beside every number. Pairing makes the base/F1 contrast robust to load drift. +- Gap classes are taken post hoc from `timers_at_stop`. + +**Predictions** (INFERRED unless stated). +- (3) L3 = first frame delivered at the receiver minus dial start at the sender, per redial, 2 per failover: + - base: spread over about 0-55 ms, median 15-35 ms, at most 10% under 1.65 ms; + - F1: median AND p90 under 1.65 ms. + - If F1's median is 1.65 ms or more, F1's "< 1 per 100" is REFUTED at this load. + - Reported beside it: dial to first write (sender side) and the accept wait (accepted minus dial start). +- (1) base arm: the accept phase at each redial, psi = (accepted_at_receiver - receiver's tick grid) mod 50 ms, pooled + (about 60 values). Predicted: a KS test against U(0, 50) is not rejected at 0.05. + - The adversary's correlated-start concern predicts the opposite: clustering. Either way this settles item (1). +- (6) splits, where a split means both survivors became Candidate in one term: + - base: gap 0 about 8/12 (registered range 5-11); gap 1 about 2/12 (0-5); gap >= 2 0/6. + - F1: gap 0 at most 2/12; gap 1 0/12; gap >= 2 0/6. +- The mechanism, per failover: take E = the survivor that pre-campaigned first, delta = t_pc(other) - t_pc(E), and + L_E = E's PreVote first frame at the other minus t_pc(E). Predicted: split iff L_E > delta, in at least 95% of the + failovers where both are measured. +- Phases: over gap-0 failovers, delta is spread over [0, 50) ms, with no 10-ms bin holding more than half the values. From 85d4401fc1e317946a1d3061f1594056a96ab250 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:20:02 -0400 Subject: [PATCH 305/315] r11-dist MEASUREMENT ONLY (never lands): F1 amendment 2 instruments -- accept-arm switch (R11_ACCEPT_POLL = F1's M1 = the pre-F1 poll), transport event trace, role log with instants, tick grid; firstfail phase jitter and the FFX line --- examples/r11_dist_fleet.rs | 136 +++++++++++++++++++++++++++++++++++-- src/consensus/node.rs | 16 +++++ src/consensus/transport.rs | 42 +++++++++++- 3 files changed, 187 insertions(+), 7 deletions(-) diff --git a/examples/r11_dist_fleet.rs b/examples/r11_dist_fleet.rs index 626fc26f..9f43aa43 100644 --- a/examples/r11_dist_fleet.rs +++ b/examples/r11_dist_fleet.rs @@ -377,7 +377,31 @@ impl Cluster { cfg: cfg.clone(), errs: errs.clone(), }; - for (i, l) in listeners.into_iter().enumerate() { + // F1 amendment 2 (instrument 6): with R11_PHASE_JITTER=1 the three nodes start in a salted random order, + // each at T0 + U(0, 50 ms), so their tick grids (fixed from `Node::start`, node.rs:378) have random + // phases, as independent machines would. Unset, nodes start back to back in index order, as before. + let jitter = std::env::var("R11_PHASE_JITTER").map(|v| v == "1").unwrap_or(false); + let mut order: Vec = (0..3).collect(); + let mut offs_us = [0u64; 3]; + if jitter { + let mut g = ferrodb::consensus::Rng::new(SEED_SALT.load(Ordering::Relaxed).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0x0FF5_E7); + for o in offs_us.iter_mut() { + *o = g.next_u64() % 50_000; + } + order.sort_by_key(|&i| offs_us[i]); + } + let t0 = Instant::now() + Duration::from_millis(20); + let mut slots: Vec> = listeners.into_iter().map(Some).collect(); + let mut built: Vec, ArmL, Arc, Arc, JoinHandle, PathBuf)>> = (0..3).map(|_| None).collect(); + for &i in &order { + if jitter { + let at = t0 + Duration::from_micros(offs_us[i]); + let now = Instant::now(); + if at > now { + std::thread::sleep(at - now); + } + } + let l = slots[i].take().unwrap(); let id = NodeId(i as u32 + 1); let dir = root.join(format!("n{}", i + 1)); let ledger = ArmL::new(arm); @@ -385,7 +409,15 @@ impl Cluster { let node = Node::start(id, cfg.clone(), l, opts(&dir, &addrs, id, i), ledger.applier(obs.clone())).unwrap(); let rep = Arc::new(Rep::new(id, node)); let stop = Arc::new(AtomicBool::new(false)); - c.threads.push(Some(spawn_driver(rep.clone(), stop.clone(), errs.clone()))); + let h = spawn_driver(rep.clone(), stop.clone(), errs.clone()); + built[i] = Some((rep, ledger, obs, stop, h, dir)); + } + if jitter { + println!("# phase jitter: start order {:?} offsets_us {:?}", order.iter().map(|i| i + 1).collect::>(), offs_us); + } + for b in built { + let (rep, ledger, obs, stop, h, dir) = b.unwrap(); + c.threads.push(Some(h)); c.reps.push(rep); c.ledgers.push(ledger); c.obs.push(obs); @@ -850,6 +882,90 @@ fn take_all(c: &Cluster) -> Vec], t_fail: Instant, timers: &[(u32, u32)]) { + use ferrodb::consensus::Role; + let us = |t: Instant| -> i64 { + if t >= t_fail { (t - t_fail).as_micros() as i64 } else { -((t_fail - t).as_micros() as i64) } + }; + let logs: Vec> = surv.iter().map(|i| c.reps[*i].with(|n| n.take_r11_role_log()).unwrap_or_default()).collect(); + let traces: Vec> = surv.iter().map(|i| c.reps[*i].with(|n| n.r11_transport_trace()).unwrap_or_default()).collect(); + let port = |i: usize| c.addrs[&NodeId(i as u32 + 1)].port() as u64; + let first_pc: Vec> = logs.iter().map(|l| l.iter().find(|e| e.1 == Role::PreCandidate).map(|e| e.0)).collect(); + let cand_terms: Vec> = logs.iter().map(|l| l.iter().filter(|e| e.1 == Role::Candidate).map(|e| e.2).collect()).collect(); + let split = cand_terms[0].iter().any(|t| cand_terms[1].contains(t)); + // Per direction x -> y (k = x's index in surv, j = y's). + let mut dirs = Vec::new(); + let mut l_from: [Option; 2] = [None, None]; // first frame from surv[k] delivered at the other + for k in 0..2 { + let j = 1 - k; + let (x, y) = (surv[k], surv[j]); + let tx = &traces[k]; + let ty = &traces[j]; + let after = |v: &Vec<(Instant, u8, u64)>, from: Instant, kind: u8, arg: Option| { + v.iter().find(|e| e.0 >= from && e.1 == kind && arg.map_or(true, |a| e.2 == a)).map(|e| e.0) + }; + let redial = after(tx, t_fail, 7, Some(port(y))); + let ds = redial.and_then(|r| after(tx, r, 4, Some(port(y)))); + let dd = ds.and_then(|d| after(tx, d, 5, Some(port(y)))); + let fw = dd.and_then(|d| after(tx, d, 6, Some(port(y)))); + let acc = ds.and_then(|d| after(ty, d, 2, None)); + let ff = ds.and_then(|d| after(ty, d, 3, Some(x as u64 + 1))); + l_from[k] = ff; + // (1): the receiver's accept phase against its own tick grid, and its last accept-loop wake before the accept. + let psi = match (acc, grids[j]) { + (Some(a), Some(g)) => { + let d = if a >= g { (a - g).as_micros() as i64 } else { -((g - a).as_micros() as i64) }; + Some(d.rem_euclid(50_000)) + } + _ => None, + }; + let wakes = ty.iter().filter(|e| e.1 == 1 && e.0 >= t_fail).count(); + dirs.push(format!( + "n{}->n{}: redial={:?} dial_start={:?} dial_done={:?} first_write={:?} accepted={:?} first_frame={:?} L3_us={:?} accept_wait_us={:?} dial_to_write_us={:?} psi_us={:?} receiver_wakes_after_stop={}", + x + 1, + y + 1, + redial.map(us), + ds.map(us), + dd.map(us), + fw.map(us), + acc.map(us), + ff.map(us), + ds.zip(ff).map(|(d, f)| us(f) - us(d)), + ds.zip(acc).map(|(d, a)| us(a) - us(d)), + ds.zip(fw).map(|(d, w)| us(w) - us(d)), + psi, + wakes + )); + } + // (6): E = the survivor that pre-campaigned first; delta = t_pc(other) - t_pc(E); L_E = E's first frame at the other - t_pc(E). + let (e, o) = match (first_pc[0], first_pc[1]) { + (Some(a), Some(b)) => if a <= b { (0, 1) } else { (1, 0) }, + (Some(_), None) => (0, 1), + (None, Some(_)) => (1, 0), + (None, None) => (0, 1), + }; + let delta = first_pc[e].zip(first_pc[o]).map(|(a, b)| us(b) - us(a)); + let l_e = first_pc[e].zip(l_from[e]).map(|(p, f)| us(f) - us(p)); + let predicted = match (l_e, delta) { + (Some(l), Some(d)) => Some(l > d), + (Some(_), None) => Some(false), + _ => None, + }; + let grid_phase: Vec> = grids.iter().map(|g| g.map(|g| us(g).rem_euclid(50_000))).collect(); + println!( + "FFX {tag} arm={} survivors={:?} timers_at_stop={timers:?} gap={} grid_phase_us={grid_phase:?} t_pc_us={:?} cand_terms={cand_terms:?} split={split} E=n{} delta_us={delta:?} L_E_us={l_e:?} predicted_split={predicted:?} | {} | {}", + if ferrodb::consensus::transport::R11_ACCEPT_POLL.load(Ordering::SeqCst) { "poll(base)" } else { "F1" }, + surv.iter().map(|i| format!("n{}", i + 1)).collect::>(), + timers[0].0.abs_diff(timers[1].0), + first_pc.iter().map(|p| p.map(us)).collect::>(), + surv[e] + 1, + dirs[0], + dirs[1] + ); +} + fn run_firstfail(n: u64, seed: u64, root: &Path) { SEED_SALT.store(seed, Ordering::Relaxed); let tag = format!("N={n} seed={seed}"); @@ -907,6 +1023,11 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { for r in &c.reps { r.max_gap_us.store(0, Ordering::Relaxed); } + // F1 amendment 2: each survivor's tick grid and a clean role log from here on. + let grids: Vec> = survivors.iter().map(|i| c.reps[*i].with(|n| n.r11_next_tick())).collect(); + for i in &survivors { + let _ = c.reps[*i].with(|n| n.take_r11_role_log()); + } let t_fail = Instant::now(); let (join_ms, drop_ms) = c.stop_node_timed(l); // Sample each survivor's timer every ms until one of them leads with its round committed. @@ -962,6 +1083,7 @@ fn run_firstfail(n: u64, seed: u64, root: &Path) { }) .collect(); let idle_closed_total: Vec> = tc_after.iter().map(|a| a.as_ref().map(|a| a.idle_closed)).collect(); + f1_instruments(&c, &tag, &survivors, &grids, t_fail, &timers_at_stop); println!("FF {tag} transport election_delta(idle_closed,lost_in_flight,connect_failures,dropped,inbound_dropped,idle_probes,idle_redials)={tdelta:?} idle_closed_total_at_end={idle_closed_total:?} idle_deadline_s={} pre_fail_sleep_s={}", IDLE_DEADLINE_S.load(Ordering::Relaxed), PRE_FAIL_SLEEP_S.load(Ordering::Relaxed)); println!( "FF {tag} election winner={:?} B1_ms={b1:.1} stop_join_ms={join_ms:.1} stop_drop_ms={drop_ms:.1} claim_ms={:?} noop_ms={:?} elect_ms={elect_ms:.1} survivors={:?} timers_at_stop(timeout,since_heard)={timers_at_stop:?} timeouts_seen_during_election={draws:?} election_transitions={:?} terms_after={term_after:?} survivor_driver_max_gap_ms={egaps:?}", @@ -1223,14 +1345,14 @@ fn main() { // r11_dist_fleet firstfail let root = PathBuf::from(args.get(2).expect("scratch root")); std::fs::create_dir_all(&root).unwrap(); - // N:seed[:pre-fail-sleep-s[:idle-deadline-s]] - let samples: Vec<(u64, u64, u64, u64)> = args + // N:seed[:pre-fail-sleep-s[:idle-deadline-s[:accept-arm]]]; accept-arm 2 = the pre-F1 poll, else F1 + let samples: Vec<(u64, u64, u64, u64, u64)> = args .get(3) .expect("samples") .split(',') .map(|x| { let f: Vec = x.split(':').map(|v| v.parse().unwrap()).collect(); - (f[0], f[1], f.get(2).copied().unwrap_or(0), f.get(3).copied().unwrap_or(0)) + (f[0], f[1], f.get(2).copied().unwrap_or(0), f.get(3).copied().unwrap_or(0), f.get(4).copied().unwrap_or(1)) }) .collect(); let budget = Duration::from_secs(args.get(4).and_then(|x| x.parse().ok()).unwrap_or(1000)); @@ -1238,7 +1360,9 @@ fn main() { let (med, p99) = b0(&root); println!("B0 sync_data_ms median={med:.3} p99={p99:.3} samples=2000"); let t0 = Instant::now(); - for (n, seed, sleep_s, deadline_s) in samples { + for (n, seed, sleep_s, deadline_s, accept_arm) in samples { + ferrodb::consensus::transport::R11_ACCEPT_POLL.store(accept_arm == 2, Ordering::SeqCst); + println!("# sample N={n} seed={seed} accept_arm={}", if accept_arm == 2 { "poll(base)" } else { "F1" }); PRE_FAIL_SLEEP_S.store(sleep_s, Ordering::Relaxed); IDLE_DEADLINE_S.store(deadline_s, Ordering::Relaxed); // A 10^6 sample needs about 3 min; do not start one that could overrun the hold. diff --git a/src/consensus/node.rs b/src/consensus/node.rs index f673cf1c..6ba812b2 100644 --- a/src/consensus/node.rs +++ b/src/consensus/node.rs @@ -216,6 +216,8 @@ pub struct Node { refusals: Vec, /// Role transitions since the last drain, for a caller that wants them without polling. transitions: Vec<(Role, Term, Option)>, + /// r11-dist MEASUREMENT ONLY: every role change with its instant. + r11_role_log: Vec<(Instant, Role, Term)>, /// F6. See the module header. snapshots: Option>, retain_rounds: Option, @@ -383,6 +385,7 @@ impl Node { pending: VecDeque::new(), refusals: Vec::new(), transitions: Vec::new(), + r11_role_log: Vec::new(), snapshots: opts.snapshots, retain_rounds: opts.retain_rounds, spooled: 0, @@ -462,6 +465,18 @@ impl Node { pub fn take_transitions(&mut self) -> Vec<(Role, Term, Option)> { std::mem::take(&mut self.transitions) } + /// r11-dist MEASUREMENT ONLY: role changes with their instants since the last take. + pub fn take_r11_role_log(&mut self) -> Vec<(Instant, Role, Term)> { + std::mem::take(&mut self.r11_role_log) + } + /// r11-dist MEASUREMENT ONLY: the next tick's due instant; the tick grid never moves (see `poll`). + pub fn r11_next_tick(&self) -> Instant { + self.next_tick + } + /// r11-dist MEASUREMENT ONLY: the transport's event trace. + pub fn r11_transport_trace(&self) -> Vec<(Instant, u8, u64)> { + self.net.r11_trace() + } /// Ask for a command to be committed. Only meaningful on a leader; anywhere else the state /// machine produces a `NotLeader` refusal, which lands in [`Node::take_refusals`]. A leader also @@ -888,6 +903,7 @@ impl Node { Action::RoleChanged { role, term, leader } => { self.transitions.push((role, term, leader)); + self.r11_role_log.push((Instant::now(), role, term)); } Action::Refuse { why } => self.refusals.push(why), diff --git a/src/consensus/transport.rs b/src/consensus/transport.rs index 9a16e5d1..c4b35fe8 100644 --- a/src/consensus/transport.rs +++ b/src/consensus/transport.rs @@ -1187,6 +1187,11 @@ impl Default for TransportOptions { } } +/// r11-dist MEASUREMENT ONLY, never landed: when set, `start` leaves the listener nonblocking, which is +/// F1's mutant M1 and exactly the pre-F1 accept path (the failed-accept arm sleeps `poll_interval` on +/// WouldBlock). One binary then carries both arms of the F1 instrument runs. +pub static R11_ACCEPT_POLL: AtomicBool = AtomicBool::new(false); + /// Everything this transport has counted. **Every drop and refusal it decides on is counted, never /// silent** — a message that vanished without a number attached is indistinguishable from a protocol /// bug, and this transport is allowed to drop. What TCP loses after a frame left this process is @@ -1233,6 +1238,11 @@ struct Counters { /// was answered, and `dial` blocks on that answer. With no accept failing and the cap not full, /// nothing should ever wait there: a redial after an idle close carries a campaign frame. accepts_after_sleep: AtomicU64, + /// r11-dist MEASUREMENT ONLY: a bounded trace of transport events on the process clock. + /// Kinds: 1 accept-loop wake from a sleep, 2 connection accepted, 3 a connection's first frame + /// delivered to the inbox (arg = sender id), 4 dial start, 5 dial done, 6 first frame written on a + /// new connection, 7 probe found the link closed (4-7: arg = the peer's port). + r11_events: Mutex>, /// Inbound frames that did not authenticate against this node's signing key, and were refused /// **before** [`decode`] saw them. /// @@ -1243,6 +1253,16 @@ struct Counters { unauthenticated: AtomicU64, } +impl Counters { + fn r11_trace(&self, kind: u8, arg: u64) { + let mut t = self.r11_events.lock().unwrap_or_else(|e| e.into_inner()); + if t.len() >= 16384 { + t.pop_front(); + } + t.push_back((Instant::now(), kind, arg)); + } +} + /// One peer's queue and its current connection. struct Outbox { addr: SocketAddr, @@ -1527,7 +1547,7 @@ impl Transport { // wait). Set explicitly, because a caller's listener may arrive nonblocking. A blocking // `accept` does not see the stop flag, so `shutdown` wakes it: see `wake_accept`. listener - .set_nonblocking(false) + .set_nonblocking(R11_ACCEPT_POLL.load(Ordering::SeqCst)) .map_err(|e| FerroError::Io(e.to_string()))?; let stop = Arc::new(AtomicBool::new(false)); @@ -1829,6 +1849,10 @@ impl Transport { pub fn accepts_after_sleep(&self) -> u64 { self.counters.accepts_after_sleep.load(Ordering::SeqCst) } + /// r11-dist MEASUREMENT ONLY: the event trace. See `Counters::r11_events`. + pub fn r11_trace(&self) -> Vec<(Instant, u8, u64)> { + self.counters.r11_events.lock().unwrap_or_else(|e| e.into_inner()).iter().copied().collect() + } /// Failed dials. A peer that is down makes this climb steadily; it is the meter that says /// "unreachable" rather than "quiet". pub fn connect_failures(&self) -> u64 { @@ -2023,6 +2047,7 @@ fn sender_loop( // stalest frame goes first, against `push`'s policy. Consensus refuses a stale term, so one such // frame is harmless. let mut carried: Option> = None; + let mut r11_fresh = false; // Half the idle deadline; the reasons for both bounds are at `idle_probe_gap`. let probe_gap = idle_probe_gap(&opts); loop { @@ -2031,6 +2056,7 @@ fn sender_loop( } if conn.is_none() { + counters.r11_trace(4, ob.addr.port() as u64); match dial(ob.addr, &stop, &opts) { Ok(s) => { match s.try_clone() { @@ -2052,6 +2078,8 @@ fn sender_loop( } conn = Some(s); last_used = Instant::now(); + counters.r11_trace(5, ob.addr.port() as u64); + r11_fresh = true; } Err(_) => { counters.connect_failures.fetch_add(1, Ordering::SeqCst); @@ -2106,6 +2134,7 @@ fn sender_loop( break; } counters.idle_redials.fetch_add(1, Ordering::SeqCst); + counters.r11_trace(7, ob.addr.port() as u64); let mut st = ob.state.lock().unwrap(); if let Some(old) = st.live.take() { let _ = old.shutdown(Shutdown::Both); @@ -2135,6 +2164,9 @@ fn sender_loop( conn = None; } else { last_used = Instant::now(); + if std::mem::take(&mut r11_fresh) { + counters.r11_trace(6, ob.addr.port() as u64); + } } } @@ -2348,6 +2380,7 @@ fn accept_loop( let _ = stream.shutdown(Shutdown::Both); break; } + counters.r11_trace(2, 0); if std::mem::take(&mut slept) { counters.accepts_after_sleep.fetch_add(1, Ordering::SeqCst); } @@ -2386,6 +2419,7 @@ fn accept_loop( // the poll the stop flag is already allowed (D207, from `207d362`). std::thread::sleep(opts.poll_interval); slept = true; + counters.r11_trace(1, 0); continue; } map.insert(id, mine); @@ -2448,6 +2482,7 @@ fn accept_loop( Err(_) => { std::thread::sleep(opts.poll_interval); slept = true; + counters.r11_trace(1, 0); } } } @@ -2476,6 +2511,7 @@ fn conn_loop( // including the refused-handshake return. let verdict = recv_handshake(&mut stream, &stop, opts.handshake_deadline); + let mut r11_first = true; // **Our handshake is written whatever the verdict, and that is the point of the version bump.** // A v1 peer is mid-`read_handshake` right now; giving it our six bytes is what makes its own @@ -2574,10 +2610,14 @@ fn conn_loop( continue; } counters.received.fetch_add(1, Ordering::SeqCst); + let r11_from = m.from.0 as u64; if tx.send((m, charge)).is_err() { counters.inbox_bytes.fetch_sub(charge, Ordering::SeqCst); break; } + if std::mem::take(&mut r11_first) { + counters.r11_trace(3, r11_from); + } } Err(_) => break, } From edf5d7028a679af3cae3515381c47d46e5fc448e Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:09:36 -0400 Subject: [PATCH 306/315] F1 RESULT 2: real-transport instruments over holds 1-4 (F1 L3 max 0.345 ms at load 8-72; 0/28 splits vs base 11/28; mechanism 56/56; one registered spread prediction failed, cause stated) --- bench/f1_accept/PREREG.md | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 4de32fba..9965140d 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -185,3 +185,50 @@ The lead's ruling after the DC2 adversary (r11-dist-refute-measure, artie-resear L_E = E's PreVote first frame at the other minus t_pc(E). Predicted: split iff L_E > delta, in at least 95% of the failovers where both are measured. - Phases: over gap-0 failovers, delta is spread over [0, 50) ms, with no 10-ms bin holding more than half the values. + +## RESULT 2 (2026-09-27T11:09:36Z): amendment 2 on the real transport, holds 1-4 (56 of 60 failovers; hold 5 is queued in lockrun) +- Raw is in artie-research `frontier/round11/r11-dist/raw/`, banked unread: f1i_h1 98e752df, h2 2cd46583, h3 b6652c42, + h4 a728319f, each with its .procs.txt. +- Analysis at 7056c947, by `f1i_analyze.py` (c4d04c32), which was committed before the data. +- Binary: r11-dist.noindex 85d4401 (this branch merged at 8db6416, plus measurement-only instruments). + +**(3) L3, from dial start to the first frame delivered at the receiver, spawn included (56 redials per arm):** + +| | Base | F1 | +|---|---|---| +| median | 29.4 ms | 0.215 ms | +| p90 | 49.3 ms | 0.315 ms | +| p99 | 59.3 ms | 0.342 ms | +| max | 60.0 ms | 0.345 ms | +| redials under 1.65 ms | 1/56 | 56/56 | + +- Base HELD. F1 median and p90 under 1.65 ms HELD. +- Load: 8.2-72.0 across the holds. + +**(1) Base accept phase against the receiver's tick grid:** KS D = 0.134, p = 0.246 (n = 56); 10-ms bins +[10, 11, 6, 13, 16]. Not rejected. HELD. + +**(6) Splits:** + +| gap | Base | F1 | +|---|---|---| +| 0 | 9/11 | 0/11 | +| 1 | 2/11 | 0/11 | +| >= 2 | 0/6 | 0/6 | + +- All six bands HELD. +- Mechanism: split == (L_E > delta) in 28/28 failovers per arm. HELD. + +**FAILED: "no 10-ms bin of gap-0 delta holds more than half" on the base arm.** +- Bins [7, 1, 1, 1, 1] (F1: [5, 2, 2, 0, 2]). +- The salted start offsets fell close together for most gap-0 salts: survivor offset differences 2.2-34.3 ms, 7 of 11 below + 13 ms. The arms share offsets per salt. +- The base gap-0 rate 9/11 sits above 2/3 because delta skews small, which is consistent with the 28/28 mechanism result. + +**Load verdict:** every hold is VOID under A16's refined rule, and under the strict rule, from other lanes' rustc, dolt and node +processes. There was no load gate by registration. The arms are paired within each hold. Load can only lengthen F1's latency, +so its measured maximum is an upper bound for this box. + +**Standing:** F1's "< 1 per 100" is now backed by measured real-transport latency (at most 0.345 ms, against the 1.65-ms bound) +and by 0 splits in 28 real failovers, 11 of them equal-draw pairs, where the paired base split 9. The production base rate +(DC2's 14.96 per 100) is still model-only. From 0dd663e8686b547417799b1115ccfad7840d7ffc Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:21:40 -0400 Subject: [PATCH 307/315] r11-dist A17 (UNBUILT): fz mode (fence-apply attribution: allocs, minflt, PRE and REPEAT arms) and hb mode (CockroachDB node liveness in the model: L1-L4, mutants HB-m1..m3) --- examples/r11_dist_ledger.rs | 291 ++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index 161beddb..080fc029 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -43,10 +43,13 @@ use ferrodb::error::FerroError; struct Counting; static LIVE: AtomicI64 = AtomicI64::new(0); +/// A17 (1): allocator calls (alloc, alloc_zeroed, realloc), for the fence-apply attribution. +static ALLOCS: AtomicU64 = AtomicU64::new(0); unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, l: Layout) -> *mut u8 { let p = unsafe { System.alloc(l) }; + ALLOCS.fetch_add(1, Ordering::Relaxed); if !p.is_null() { LIVE.fetch_add(l.size() as i64, Ordering::Relaxed); } @@ -54,6 +57,7 @@ unsafe impl GlobalAlloc for Counting { } unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 { let p = unsafe { System.alloc_zeroed(l) }; + ALLOCS.fetch_add(1, Ordering::Relaxed); if !p.is_null() { LIVE.fetch_add(l.size() as i64, Ordering::Relaxed); } @@ -65,6 +69,7 @@ unsafe impl GlobalAlloc for Counting { } unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { let q = unsafe { System.realloc(p, l, new) }; + ALLOCS.fetch_add(1, Ordering::Relaxed); if !q.is_null() { LIVE.fetch_add(new as i64 - l.size() as i64, Ordering::Relaxed); } @@ -234,6 +239,27 @@ struct HeadCopy { /// alone, never on when a node took a snapshot. Replaces the snapshot-time `retired_hwm` rule. use_wm: bool, fork_wm: BTreeMap, + /// A17 (2): CockroachDB node liveness in the model. `None` is every earlier arm, unchanged. + hb: Option, +} + +/// A17 (2): per-node liveness records and what the rules refused. Encodings are model-internal sentinels, +/// as the M1 fence already is: HEARTBEAT(n, e, x) is a Fork of `cid(n, LOCAL_MASK - 1)` with +/// `fork_epoch = e` and `lease_millis = x`; FENCE(n, e, t) is a Merge of `sentinel(n)` with +/// `base_round = t << 16 | e` (t is the proposer's clock). +#[derive(Clone, Default)] +struct Hb { + /// node -> (epoch, expiration) + live: BTreeMap, + max_offset: u64, + fence_applied: u64, + fence_refused: u64, + hb_refused: u64, + fork_epoch_refused: u64, + /// HB-m1: the fence ignores liveness. HB-m2: the heartbeat ignores the epoch. HB-m3: the fork ignores the epoch. + mut_fence_ignores_liveness: bool, + mut_hb_ignores_epoch: bool, + mut_fork_ignores_epoch: bool, } impl HeadCopy { @@ -257,6 +283,7 @@ impl HeadCopy { rejected: 0, use_wm: false, fork_wm: BTreeMap::new(), + hb: None, } } @@ -297,6 +324,7 @@ impl HeadCopy { rejected: self.rejected, use_wm: self.use_wm, fork_wm: if drop_wm { BTreeMap::new() } else { self.fork_wm.clone() }, + hb: self.hb.clone(), }; (snap, retired) } @@ -335,6 +363,25 @@ impl Ledger for HeadCopy { self.touched = self.touched.max(1); match op { BranchOp::Fork { child, parent, fork_epoch, lease_millis } => { + if let Some(hb) = self.hb.as_mut() { + if *child != 0 && *child & LOCAL_MASK == LOCAL_MASK - 1 { + // HEARTBEAT(n, e, x): applies only at the node's current epoch. + let rec = hb.live.entry(owner_of(*child)).or_insert((*fork_epoch, 0)); + if rec.0 == *fork_epoch || hb.mut_hb_ignores_epoch { + rec.1 = rec.1.max(*lease_millis); + } else { + hb.hb_refused += 1; + } + return None; + } + // A fork carries its owner's epoch (CockroachDB's lease-epoch check). + let cur = hb.live.get(&owner_of(*child)).map(|r| r.0); + if cur != Some(*fork_epoch) && !hb.mut_fork_ignores_epoch { + hb.fork_epoch_refused += 1; + self.rejected += 1; + return None; + } + } if *child == 0 || self.br.contains_key(child) { self.rejected += 1; return None; @@ -373,6 +420,24 @@ impl Ledger for HeadCopy { None } BranchOp::Merge { branch, base_round } => { + if let Some(hb) = self.hb.as_mut() { + if is_sentinel(*branch) { + // FENCE(n, e, t): IncrementEpoch, only if the epoch matches and the record expired. + let n = owner_of(*branch); + let (e, t) = (*base_round & 0xFFFF, *base_round >> 16); + let rec = hb.live.get(&n).copied().unwrap_or((e, 0)); + let expired = rec.1 + hb.max_offset < t; + if rec.0 == e && (expired || hb.mut_fence_ignores_liveness) { + hb.live.insert(n, (e + 1, rec.1)); + hb.fence_applied += 1; + let f = self.fence.entry(n).or_insert(0); + *f = (*f).max(round); + } else { + hb.fence_refused += 1; + } + return None; + } + } let v = match self.br.get(branch) { None => V::Refused, Some(r) if !self.rec_live(*branch, r) => V::Refused, @@ -1930,6 +1995,230 @@ fn mode_conformwide(seed: u64) { println!("CW3 seed={seed} forks_HEAD_accepts_that_M1+wm_refuses={changed} (the stated protocol change; includes forks M1's fence refuses)"); } +// --------------------------------------------------------------------------------------------- +// A17 (1): what makes one M1 fence apply cost more at larger N? Counters (allocator calls, minor +// faults) and two arms that remove the first-leaf allocation: PRE (the fence map already holds a +// no-op entry for node 9) and REPEAT (a second fence for the same owner). +// --------------------------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Default)] +struct RUsage { + utime: [i64; 2], + stime: [i64; 2], + maxrss: i64, + ixrss: i64, + idrss: i64, + isrss: i64, + minflt: i64, + majflt: i64, + nswap: i64, + inblock: i64, + oublock: i64, + msgsnd: i64, + msgrcv: i64, + nsignals: i64, + nvcsw: i64, + nivcsw: i64, +} + +unsafe extern "C" { + fn getrusage(who: i32, usage: *mut RUsage) -> i32; +} + +fn minflt() -> i64 { + let mut u = RUsage::default(); + // SAFETY: `u` is a live, correctly sized `struct rusage` for the duration of the call; RUSAGE_SELF = 0. + let rc = unsafe { getrusage(0, &mut u) }; + assert_eq!(rc, 0, "getrusage failed"); + u.minflt +} + +fn mode_fz(reps: u64) { + println!("# fz reps={reps} (A17 (1)); arms BASE, PRE, REPEAT; N in 1e3..1e6"); + for &n in &[1_000u64, 10_000, 100_000, 1_000_000] { + for rep in 0..reps { + for arm in ["BASE", "PRE", "REPEAT"] { + let mut l = HeadCopy::new(true); + if arm == "PRE" { + l.fence.insert(9, 0); + } + let mut last = run_w(n, n, |e| { + l.apply(&e); + }); + if arm == "REPEAT" { + last += 1; + l.apply(&Entry { term: 2, round: last, command: abandon_c(sentinel(1)) }); + } + let e = Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }; + let f0 = minflt(); + let a0 = ALLOCS.load(Ordering::Relaxed); + let t0 = Instant::now(); + l.apply(&e); + let ns = t0.elapsed().as_nanos(); + let a1 = ALLOCS.load(Ordering::Relaxed); + let f1 = minflt(); + println!( + "FZ arm={arm} N={n} rep={rep} ns_single_apply={ns} allocs={} minflt={} fence_entries={} live_after={}", + a1 - a0, + f1 - f0, + l.fence.len(), + l.live_ids_of(1).len() + ); + } + } + } +} + +// --------------------------------------------------------------------------------------------- +// A17 (2): the liveness heartbeat in the model (CockroachDB node liveness), against plain M1. +// --------------------------------------------------------------------------------------------- + +const HB_K: u64 = 10_000; +const HB_H: u64 = 1_000; +const HB_TTL: u64 = 4_500; +const HB_MO: u64 = 500; + +fn hb_c(n: u32, e: u64, exp: u64) -> Command { + Command::Branch { op: BranchOp::Fork { child: cid(n, LOCAL_MASK - 1), parent: 0, fork_epoch: e, lease_millis: exp } } +} +fn fence_ie_c(n: u32, e: u64, t: u64) -> Command { + Command::Branch { op: BranchOp::Merge { branch: sentinel(n), base_round: (t << 16) | e } } +} +fn fork_e(child: u64, e: u64) -> Command { + Command::Branch { op: BranchOp::Fork { child, parent: 0, fork_epoch: e, lease_millis: 900_000 } } +} + +struct HbOut { + live_n1: u64, + fence_applied: u64, + fence_refused: u64, + hb_refused: u64, + fork_refused: u64, + zombie_live: bool, + rejoin_live: bool, +} + +/// One scenario on one arm. `hb` false = plain M1 (its fence is the Abandon sentinel, and it has no +/// heartbeats). Times are model units; the heartbeat interval is HB_H and a record lives HB_TTL. +fn hb_scenario(scen: &str, hb: bool, skew: u64, muts: (bool, bool, bool)) -> HbOut { + let mut l = HeadCopy::new(true); + if hb { + l.hb = Some(Hb { + max_offset: HB_MO, + mut_fence_ignores_liveness: muts.0, + mut_hb_ignores_epoch: muts.1, + mut_fork_ignores_epoch: muts.2, + ..Hb::default() + }); + } + let mut cmds: Vec = Vec::new(); + if hb { + cmds.push(hb_c(1, 1, HB_TTL)); + } + for i in 1..=HB_K { + cmds.push(fork_e(cid(1, i), 1)); + } + // n1 heartbeats at t = 1000, 2000, 3000 (then, in L2/L3, it stops). + if hb { + for t in [HB_H, 2 * HB_H, 3 * HB_H] { + cmds.push(hb_c(1, 1, t + HB_TTL)); + } + } + let fence = |t: u64| if hb { fence_ie_c(1, 1, t) } else { abandon_c(sentinel(1)) }; + match scen { + // Live: its record (expiration 7500) is far ahead of the fence's clock. + "L1" => cmds.push(fence(3_500 + skew)), + // Dead since t = 3000: expiration 7500 + max_offset 500 < 8001. + "L2" => cmds.push(fence(8_001 + skew)), + "L3" => { + cmds.push(fence(8_001 + skew)); + // The zombie resumes at t = 9000 with its OLD epoch: a heartbeat and a fork. + if hb { + cmds.push(hb_c(1, 1, 9_000 + HB_TTL)); + } + cmds.push(fork_e(cid(1, HB_K + 1), 1)); + // It reads its record, learns epoch 2, heartbeats and forks again. + if hb { + cmds.push(hb_c(1, 2, 9_500 + HB_TTL)); + } + cmds.push(fork_e(cid(1, HB_K + 2), 2)); + } + // Worst moment: the fence at true time 3999, one unit before the next heartbeat was due. + "L4" => cmds.push(fence(3_999 + skew)), + other => panic!("unknown scenario {other}"), + } + for (i, c) in cmds.into_iter().enumerate() { + l.apply(&Entry { term: 1, round: i as u64 + 1, command: c }); + } + let h = l.hb.clone().unwrap_or_default(); + HbOut { + live_n1: l.live_ids_of(1).iter().filter(|id| **id & LOCAL_MASK <= HB_K).count() as u64, + fence_applied: h.fence_applied, + fence_refused: h.fence_refused, + hb_refused: h.hb_refused, + fork_refused: h.fork_epoch_refused, + zombie_live: l.is_live(cid(1, HB_K + 1)), + rejoin_live: l.is_live(cid(1, HB_K + 2)), + } +} + +fn hb_line(scen: &str, arm: &str, skew: u64, o: &HbOut) { + println!( + "HB scen={scen} arm={arm} skew={skew} pre_fence_live_n1={} fence_applied={} fence_refused={} hb_refused={} fork_epoch_refused={} zombie_fork_live={} rejoin_fork_live={}", + o.live_n1, o.fence_applied, o.fence_refused, o.hb_refused, o.fork_refused, o.zombie_live, o.rejoin_live + ); +} + +fn mode_hb() { + println!("# hb (A17 (2), A17a): K={HB_K} H={HB_H} TTL={HB_TTL} max_offset={HB_MO}"); + let none = (false, false, false); + // L1: a live node loses office. + let h = hb_scenario("L1", true, 0, none); + hb_line("L1", "HB", 0, &h); + check("HB-L1", "HB", "fence refused, 10000 live", h.fence_refused == 1 && h.fence_applied == 0 && h.live_n1 == HB_K, format!("refused={} live={}", h.fence_refused, h.live_n1)); + let m = hb_scenario("L1", false, 0, none); + hb_line("L1", "M1", 0, &m); + check("HB-L1-M1-harm", "M1", "0 live (10000 live branches discarded)", m.live_n1 == 0, m.live_n1.to_string()); + // L2: a dead node. + let h = hb_scenario("L2", true, 0, none); + hb_line("L2", "HB", 0, &h); + check("HB-L2", "HB", "fence applied, 0 live", h.fence_applied == 1 && h.live_n1 == 0, format!("applied={} live={}", h.fence_applied, h.live_n1)); + let m = hb_scenario("L2", false, 0, none); + hb_line("L2", "M1", 0, &m); + check("HB-L2-M1", "M1", "0 live", m.live_n1 == 0, m.live_n1.to_string()); + // L3: the zombie. + let h = hb_scenario("L3", true, 0, none); + hb_line("L3", "HB", 0, &h); + check( + "HB-L3", + "HB", + "stale hb refused 1, old-epoch fork refused 1, zombie dead, rejoin live, pre-fence 0 live", + h.hb_refused == 1 && h.fork_refused == 1 && !h.zombie_live && h.rejoin_live && h.live_n1 == 0, + format!("hb_refused={} fork_refused={} zombie={} rejoin={} pre={}", h.hb_refused, h.fork_refused, h.zombie_live, h.rejoin_live, h.live_n1), + ); + let m = hb_scenario("L3", false, 0, none); + hb_line("L3", "M1", 0, &m); + check("HB-L3-M1", "M1", "zombie fork live (M1 accepts it after the fence)", m.zombie_live && m.rejoin_live && m.live_n1 == 0, format!("zombie={} rejoin={} pre={}", m.zombie_live, m.rejoin_live, m.live_n1)); + // L4: skew. + for s in [0u64, 500, 1_000, 4_001, 4_002, 5_000] { + let h = hb_scenario("L4", true, s, none); + hb_line("L4", "HB", s, &h); + let want = u64::from(s >= 4_002); + check(&format!("HB-L4-s{s}"), "HB", &format!("live nodes fenced = {want}"), h.fence_applied == want, h.fence_applied.to_string()); + } + // Mutants: each must be caught. + let x = hb_scenario("L1", true, 0, (true, false, false)); + hb_line("L1", "HB-m1", 0, &x); + check("HB-m1-caught", "HB-m1 fence ignores liveness", "0 live (the live node is fenced)", x.live_n1 == 0, x.live_n1.to_string()); + let x = hb_scenario("L3", true, 0, (false, true, false)); + hb_line("L3", "HB-m2", 0, &x); + check("HB-m2-caught", "HB-m2 heartbeat ignores epoch", "stale hb refused 0", x.hb_refused == 0, x.hb_refused.to_string()); + let x = hb_scenario("L3", true, 0, (false, false, true)); + hb_line("L3", "HB-m3", 0, &x); + check("HB-m3-caught", "HB-m3 fork ignores epoch", "zombie fork live", x.zombie_live, x.zombie_live.to_string()); +} + fn main() { let args: Vec = std::env::args().collect(); let arg = |i: usize| -> u64 { args.get(i).and_then(|s| s.parse().ok()).expect("numeric argument") }; @@ -1945,6 +2234,8 @@ fn main() { Some("retire") => mode_retire(arg(2), arg(3), arg(4)), Some("s6") => mode_s6(arg(2), arg(3)), Some("conformwide") => mode_conformwide(arg(2)), + Some("fz") => mode_fz(arg(2)), + Some("hb") => mode_hb(), _ => { eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed"); std::process::exit(2); From 76e6928c3cde8ee9ca68d36162ade49d156c58ea Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:56:42 -0400 Subject: [PATCH 308/315] F1 RESULT 2 addendum: hold 5 landed; all 60 failovers (F1 L3 max 0.345 ms; splits base 13/30 vs F1 0/30; mechanism 60/60) --- bench/f1_accept/PREREG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 9965140d..545af677 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -232,3 +232,30 @@ so its measured maximum is an upper bound for this box. **Standing:** F1's "< 1 per 100" is now backed by measured real-transport latency (at most 0.345 ms, against the 1.65-ms bound) and by 0 splits in 28 real failovers, 11 of them equal-draw pairs, where the paired base split 9. The production base rate (DC2's 14.96 per 100) is still model-only. + +## RESULT 2 addendum (2026-09-27T14:56:42Z): hold 5 landed, all 60 failovers +- Hold 5: raw artie-research b876c043 (monitor 5b5a06e9). Its first attempt was killed in the lockrun queue by my own + 3000-s timeout; that attempt never ran and is in raw/aborted. +- Rerun in the lead's quiet window #4: start load 8.81. +- Analysis over all five holds: 78e9ed69. + +**Totals, 30 failovers per arm:** + +| | Base | F1 | +|---|---|---| +| L3 median | 30.2 ms | 0.213 ms | +| L3 p90 | 52.4 ms | 0.314 ms | +| L3 max | 60.0 ms | 0.345 ms | +| redials under 1.65 ms | 1/60 | 60/60 | +| splits, gap 0 | 10/12 | 0/12 | +| splits, gap 1 | 3/12 | 0/12 | +| splits, gap >= 2 | 0/6 | 0/6 | +| mechanism split == (L_E > delta) | 30/30 | 30/30 | + +- (1) Base accept phase: KS D = 0.120, p = 0.328 (n = 60); 10-ms bins [11, 11, 9, 13, 16]. +- Every registered band HOLDS, as it did for holds 1-4. +- The base gap-0 delta-spread prediction stays FAILED: bins [8, 1, 1, 1, 1]. The cause is as stated in RESULT 2, and it + is not re-scored. + +**Hold 5 load:** VOID under (ii), from other lanes' rustc (14:51:50-14:53:11 and 14:55:16-14:55:49) and a node process for 8 s. +Holds 1-4 are VOID as before. The paired contrast stands as an upper bound on F1's latency. From 687e63342775e280b9d1bb65e0d36bad6dcb9c1f Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:02:37 -0400 Subject: [PATCH 309/315] F1 PREREG amendment 3: Rule One hold-5 corrections recorded; hold 5 VOID and re-queued as f1i_h5b under the polled start bar --- bench/f1_accept/PREREG.md | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 545af677..073fa338 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -259,3 +259,43 @@ and by 0 splits in 28 real failovers, 11 of them equal-draw pairs, where the pai **Hold 5 load:** VOID under (ii), from other lanes' rustc (14:51:50-14:53:11 and 14:55:16-14:55:49) and a node process for 8 s. Holds 1-4 are VOID as before. The paired contrast stands as an upper bound on F1's latency. + +## Amendment 3 (2026-09-27T16:02:37Z; append-only; Rule One BATCH3 dist-f1h5 @ 398ce949, both lenses STANDS-WITH-CORRECTIONS) +Every number reproduces from 78e9ed69/b876c043. Corrections: +1. "Every registered band HOLDS" is FALSE as written. The registered phases prediction FAILED: + - base [8,1,1,1,1,0]; + - F1 passes only at exactly 6/12; + - pooled over both arms, 14/24 fall in the 0-10 ms bin, a FAIL. +2. Base L3 exceeds the registered "about 0-55 ms" spread in 3/60 redials (55.2, 58.7, 60.0 ms). That band did not hold + either. The median band and the under-1.65-ms band held. +3. The F1 split and mechanism rows cannot discriminate. + - Every F1 delta is >= 3.55 ms and every F1 L_E <= 0.378 ms, so the F1 mechanism check has no positive case. + - The 0/30 F1 splits bound the F1 rate only at about 9.5 per 100 (95%). + - ONLY the latency result carries F1. + - The analysis's "mechanism" line counts predicted == split, which equals (L_E > delta) here. +4. The arms are not matched on delta, because since_heard at the stop differs between arms. At most 11 of the 13 base splits + are attributable to the accept path: salts 106 and 123 would not have split at their F1 partner's delta. +5. The gap class is the timeout draw only; since_heard is ignored. 2 of the 3 base gap-1 splits (salts 52, 84) had equal + remaining ticks at the stop, so they are equal-expiry splits. +6. The cause given for the delta-spread failure is incomplete. + - The offsets do not determine delta: salt 21 has a 9.8-ms offset gap but delta 42.1/40.6 ms; salt 106 has 15.1 ms but + delta 9.1/41.0; salt 10 has 11.9 ms but delta 3.6/6.5. + - Under independent U(0,50) offsets, P(>= 7 of 11 below 13 ms) = 0.18, so the uniform-spread premise was wrong by + construction, not unlucky. + - A since_heard flip (phi versus 50 - phi) plus driver lateness is not excluded. +7. "Upper bound on F1" is UNTESTED: load's direction on F1's latency was never measured (per-hold F1 medians are flat, + 176-225 us). The scope is loopback (127.0.0.1) at N = 10. On real hosts pays a connect plus a handshake round trip + before the first write, so L3 >= about 2.5 RTT. For multi-host deployments the measured value is a LOWER bound. +8. The change in hold structure was not recorded, and is recorded now. + - Registered: "60 failovers in 4 lockrun holds of 15". Run: 4 holds of 14, plus 1 of 4. + - The fifth hold ran about 4 h later (the driver stopped on its disk gate), at load 4.7-15 against 8-72. +9. KS: the exact one-sample p is 0.322 (0.328 was Stephens' asymptotic value). One per failover, n = 30: p = 0.109. Not + rejected either way. +10. The load account missed node pid 35936 (14:54:50-58). The in-window load was min 8.81 / median 11.91 / max 15.06. The + analysis's 4.7/8.2/15.1 included 5.5 min of queue wait. + +**Hold 5 is VOID and re-queued** (lead ruling: builds compiled inside the 14:51:42Z hold). +- It re-runs as f1i_h5b: the same 4 samples (salts 129, 84; both arms, order as registered), binary 85d4401. +- It runs under the polled start bar (r11-dist PREREG A18 (P)): load + 5 <= 18, polled every 10 s for up to 300 s inside + the lock, refusing if the bar is not met. +- The earlier hold-5 rows stay in raw and are labelled VOID. The 60-failover totals are re-reported with h5b in place of h5. From 5bc200c4efe7a75f79ab112d268dee40288a7f6a Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:02:45 -0400 Subject: [PATCH 310/315] F1 PREREG amendment 3 erratum (a backticked word lost to the shell) --- bench/f1_accept/PREREG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 073fa338..062caead 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -299,3 +299,5 @@ Every number reproduces from 78e9ed69/b876c043. Corrections: - It runs under the polled start bar (r11-dist PREREG A18 (P)): load + 5 <= 18, polled every 10 s for up to 300 s inside the lock, refusing if the bar is not met. - The earlier hold-5 rows stay in raw and are labelled VOID. The 60-failover totals are re-reported with h5b in place of h5. +- Erratum to item 7 above: the unquoted heredoc that wrote it swallowed a backticked word. The sentence reads "On real + hosts `dial` pays a connect plus a handshake round trip before the first write" (transport.rs `dial`). From 6d9198922b27d47a119c680a06a5f4a9611d4dc0 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:05:40 -0400 Subject: [PATCH 311/315] r11-dist A18 (UNBUILT): fz redesigned (AZ exact A-Z history, PRE, ALLOC; counters mode separate), retire/s6 on F-wm with a measured HEAD baseline, HB L5 stalls and the outcome-based HB-m2 check (L3m) --- examples/r11_dist_ledger.rs | 167 ++++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 35 deletions(-) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index 080fc029..048522c4 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -1678,11 +1678,31 @@ fn mode_adm() { // with the fence map persisted. PREREG A13. // --------------------------------------------------------------------------------------------- -fn mode_retire(t: u64, n: u64, n2: u64) { +fn mode_retire(t: u64, n: u64, n2: u64, wm: bool) { assert!(n <= t); - println!("# retire T={t} N={n} N2={n2}"); + println!("# retire T={t} N={n} N2={n2} wm={wm}"); + if wm { + // A18 (R): the real HEAD ledger over the same trace, measured, so its figure is not inferred. + let h0 = heap(); + let ledger = Arc::new(Mutex::new(BranchLedger::new())); + let mut head = Head(ledger); + let mut round = run_w(t, n, |e| { + head.apply(&e); + }); + for i in 1..=n2 { + round += 1; + head.apply(&Entry { term: 1, round, command: fork_c(cid(2, i)) }); + } + round += 1; + head.apply(&Entry { term: 2, round, command: abandon_c(sentinel(1)) }); + let h1 = heap(); + let recs = head.records(); + println!("RETIRE-HEAD T={t} N={n} N2={n2} records={recs} heap_bytes={} bytes_per_record={:.1}", h1 - h0, (h1 - h0) as f64 / recs as f64); + check("R-HEAD-heap", "HEAD", "1.25e9..1.45e9 bytes", (1.25e9..=1.45e9).contains(&((h1 - h0) as f64)), (h1 - h0).to_string()); + } let h0 = heap(); let mut m1 = HeadCopy::new(true); + m1.use_wm = wm; let mut round = run_w(t, n, |e| { m1.apply(&e); }); @@ -1742,9 +1762,10 @@ fn mode_retire(t: u64, n: u64, n2: u64) { check("R-tail", "M1 vs M1+retire", "0 divergences", div == 0, div.to_string()); } -fn mode_s6(n: u64, budget: u64) { - println!("# s6 N={n} budget={budget}"); +fn mode_s6(n: u64, budget: u64, wm: bool) { + println!("# s6 N={n} budget={budget} wm={wm}"); let mut m1 = HeadCopy::new(true); + m1.use_wm = wm; let mut round = 0; for i in 1..=n { round += 1; @@ -2034,37 +2055,80 @@ fn minflt() -> i64 { u.minflt } -fn mode_fz(reps: u64) { - println!("# fz reps={reps} (A17 (1)); arms BASE, PRE, REPEAT; N in 1e3..1e6"); +/// A18 (F): the A-Z heap history, exactly as 72fa4b8:1452-1465 ran it: ZK over run_w(N, N), ZK's close applied, ZK dropped; +/// then M1 over run_w(N, N), boxed as `dyn Ledger`. `pre` puts a no-op fence (owner 9, round 0) in before the build. +fn az_build(n: u64, pre: bool) -> (Box, Round) { + { + let mut z: Box = Box::new(Zk::new()); + let last = run_w(n, n, |e| { + z.apply(&e); + }); + z.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + std::hint::black_box(z.live_ids_of(1).len()); + } + let mut m = HeadCopy::new(true); + if pre { + m.fence.insert(9, 0); + } + let mut l: Box = Box::new(m); + let last = run_w(n, n, |e| { + l.apply(&e); + }); + (l, last) +} + +/// The size of a `BTreeMap` leaf: parent pointer 8, parent index 2, length 2, 11 keys of 4 (padded to 56), 11 +/// values of 8. ALLOC allocates and first-writes exactly this, with no fence apply. +const FENCE_LEAF_BYTES: usize = 144; + +/// A18 (F). `timed` prints ns per arm (run in one lockrun hold); otherwise the same sequences print allocator-call and +/// minor-fault deltas around the operation, with no timer (unlocked). +fn mode_fz(reps: u64, timed: bool) { + println!("# fz reps={reps} timed={timed} (A18 (F)); arms AZ, PRE, ALLOC rotated per rep; N in 1e3..1e6"); + let arms = ["AZ", "PRE", "ALLOC"]; for &n in &[1_000u64, 10_000, 100_000, 1_000_000] { for rep in 0..reps { - for arm in ["BASE", "PRE", "REPEAT"] { - let mut l = HeadCopy::new(true); - if arm == "PRE" { - l.fence.insert(9, 0); - } - let mut last = run_w(n, n, |e| { - l.apply(&e); - }); - if arm == "REPEAT" { - last += 1; - l.apply(&Entry { term: 2, round: last, command: abandon_c(sentinel(1)) }); + for k in 0..3 { + let arm = arms[(k + rep as usize) % 3]; + let (mut l, last) = az_build(n, arm == "PRE"); + if timed { + let ns = if arm == "ALLOC" { + let lay = Layout::from_size_align(FENCE_LEAF_BYTES, 8).unwrap(); + let t0 = Instant::now(); + // SAFETY: a non-zero-size layout; the block is written in full and freed below. + let p = unsafe { std::alloc::alloc(lay) }; + unsafe { std::ptr::write_bytes(p, 0, FENCE_LEAF_BYTES) }; + let ns = t0.elapsed().as_nanos(); + std::hint::black_box(p); + unsafe { std::alloc::dealloc(p, lay) }; + ns + } else { + let t0 = Instant::now(); + l.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + t0.elapsed().as_nanos() + }; + println!("FZ arm={arm} N={n} rep={rep} ns={ns} live_after={}", l.live_ids_of(1).len()); + } else { + let e = Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }; + let lay = Layout::from_size_align(FENCE_LEAF_BYTES, 8).unwrap(); + let f0 = minflt(); + let a0 = ALLOCS.load(Ordering::Relaxed); + if arm == "ALLOC" { + // SAFETY: as above. + let p = unsafe { std::alloc::alloc(lay) }; + unsafe { std::ptr::write_bytes(p, 0, FENCE_LEAF_BYTES) }; + std::hint::black_box(p); + let a1 = ALLOCS.load(Ordering::Relaxed); + let f1 = minflt(); + unsafe { std::alloc::dealloc(p, lay) }; + println!("FZC arm={arm} N={n} rep={rep} allocs={} minflt={}", a1 - a0, f1 - f0); + } else { + l.apply(&e); + let a1 = ALLOCS.load(Ordering::Relaxed); + let f1 = minflt(); + println!("FZC arm={arm} N={n} rep={rep} allocs={} minflt={} live_after={}", a1 - a0, f1 - f0, l.live_ids_of(1).len()); + } } - let e = Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }; - let f0 = minflt(); - let a0 = ALLOCS.load(Ordering::Relaxed); - let t0 = Instant::now(); - l.apply(&e); - let ns = t0.elapsed().as_nanos(); - let a1 = ALLOCS.load(Ordering::Relaxed); - let f1 = minflt(); - println!( - "FZ arm={arm} N={n} rep={rep} ns_single_apply={ns} allocs={} minflt={} fence_entries={} live_after={}", - a1 - a0, - f1 - f0, - l.fence.len(), - l.live_ids_of(1).len() - ); } } } @@ -2146,6 +2210,17 @@ fn hb_scenario(scen: &str, hb: bool, skew: u64, muts: (bool, bool, bool)) -> HbO } // Worst moment: the fence at true time 3999, one unit before the next heartbeat was due. "L4" => cmds.push(fence(3_999 + skew)), + // A18 (H) L5: a driver stall. n1 is silent from its last heartbeat (t = 3000) for S = `skew` units, and the fence + // comes at the end of the stall. + "L5" => cmds.push(fence(3_000 + skew)), + // A18 (H) L3m: L3 with a second fence right after the zombie's stale heartbeat. Correct: it applies (n1 never + // heartbeated in epoch 2, expiration 7500 + 500 < 9600). Under HB-m2 the stale heartbeat extended the expiration to + // 13500, so it is refused: caught by the fence OUTCOME, not by the counter the mutant disables. + "L3m" => { + cmds.push(fence(8_001)); + cmds.push(hb_c(1, 1, 9_000 + HB_TTL)); + cmds.push(fence_ie_c(1, 2, 9_600)); + } other => panic!("unknown scenario {other}"), } for (i, c) in cmds.into_iter().enumerate() { @@ -2217,6 +2292,28 @@ fn mode_hb() { let x = hb_scenario("L3", true, 0, (false, false, true)); hb_line("L3", "HB-m3", 0, &x); check("HB-m3-caught", "HB-m3 fork ignores epoch", "zombie fork live", x.zombie_live, x.zombie_live.to_string()); + // A18 (H): L5 stalls, and the outcome-based HB-m2 check. + for s in [2_000u64, 4_000, 6_000] { + let h = hb_scenario("L5", true, s, none); + hb_line("L5", "HB", s, &h); + let fenced = s > HB_TTL + HB_MO; + check( + &format!("HB-L5-s{s}"), + "HB", + if fenced { "fence applied, 0 live" } else { "fence refused, 10000 live" }, + if fenced { h.fence_applied == 1 && h.live_n1 == 0 } else { h.fence_refused == 1 && h.live_n1 == HB_K }, + format!("applied={} refused={} live={}", h.fence_applied, h.fence_refused, h.live_n1), + ); + } + let m = hb_scenario("L5", false, 2_000, none); + hb_line("L5", "M1", 2_000, &m); + check("HB-L5-M1-harm", "M1", "0 live (a 2000-unit stall costs the live node its branches)", m.live_n1 == 0, m.live_n1.to_string()); + let h = hb_scenario("L3m", true, 0, none); + hb_line("L3m", "HB", 0, &h); + check("HB-L3m", "HB", "second fence applied (2 applied)", h.fence_applied == 2, h.fence_applied.to_string()); + let x = hb_scenario("L3m", true, 0, (false, true, false)); + hb_line("L3m", "HB-m2", 0, &x); + check("HB-m2-caught-by-outcome", "HB-m2 heartbeat ignores epoch", "second fence refused (1 applied)", x.fence_applied == 1, x.fence_applied.to_string()); } fn main() { @@ -2231,10 +2328,10 @@ fn main() { Some("o2") => mode_o2(arg(2)), Some("timed") => mode_timed(), Some("adm") => mode_adm(), - Some("retire") => mode_retire(arg(2), arg(3), arg(4)), - Some("s6") => mode_s6(arg(2), arg(3)), + Some("retire") => mode_retire(arg(2), arg(3), arg(4), args.get(5).map(|x| x == "wm").unwrap_or(false)), + Some("s6") => mode_s6(arg(2), arg(3), args.get(4).map(|x| x == "wm").unwrap_or(false)), Some("conformwide") => mode_conformwide(arg(2)), - Some("fz") => mode_fz(arg(2)), + Some("fz") => mode_fz(arg(2), args.get(3).map(|x| x == "timed").unwrap_or(false)), Some("hb") => mode_hb(), _ => { eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed"); From e347342e220e3718b0bf4213da97859536b4da09 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:51:56 -0400 Subject: [PATCH 312/315] F1 RESULT 3: hold 5 re-run f1i_h5b VALID under the refined rule; totals with h5b replacing the VOID h5 --- bench/f1_accept/PREREG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/bench/f1_accept/PREREG.md b/bench/f1_accept/PREREG.md index 062caead..ccf1148a 100644 --- a/bench/f1_accept/PREREG.md +++ b/bench/f1_accept/PREREG.md @@ -301,3 +301,28 @@ Every number reproduces from 78e9ed69/b876c043. Corrections: - The earlier hold-5 rows stay in raw and are labelled VOID. The 60-failover totals are re-reported with h5b in place of h5. - Erratum to item 7 above: the unquoted heredoc that wrote it swallowed a backticked word. The sentence reads "On real hosts `dial` pays a connect plus a handshake round trip before the first write" (transport.rs `dial`). + +## RESULT 3 (2026-09-27T16:51:56Z): hold 5 re-run (f1i_h5b), and the totals with it in place of the VOID hold 5 +- Raw: artie-research 8a17fce1 (monitor e346dd77). Scored by score_a18.py; window 16:06:31-16:10:55Z from ACQUIRED. + - Start rule: 5.86 + 5 = 10.86, OK. + - (ii): VALID (no foreign process above 1 core for >= 10 s). + - (ii-b): VALID (5.20 + 5 = 10.20). + - Strict letter: VOID (230 foreign starts). + - This is the first F1 instrument hold that is VALID under the refined rule. +- Rows: salt 129 (gap 0) base split (L_E 46.5 ms > delta 2.9 ms), F1 did not (L_E 0.363 ms). Salt 84 (gap 1) base split + (L_E 34.7 > delta 25.8 ms), F1 did not (L_E 0.380 ms). The outcomes are the same as the VOID hold 5. +- Totals with h5b in place of h5 (analysis 67a0f6f2): + + | | Base | F1 | + |---|---|---| + | L3 median | 30.2 ms | 0.216 ms | + | L3 max | 60.0 ms | 0.345 ms | + | redials under 1.65 ms | 2/60 | 60/60 | + | splits, gap 0 | 10/12 | 0/12 | + | splits, gap 1 | 3/12 | 0/12 | + | splits, gap >= 2 | 0/6 | 0/6 | + + - The new base under-1.65 ms redial is h5b salt 129 n2->n1 at 0.445 ms, an accept that landed on the loop's wake. + - KS p = 0.322 (asymptotic formula, n = 60). +- Amendment 3's corrections all still apply (the phases prediction failed; the F1 split rows cannot discriminate; loopback at + N = 10 only). From 863d755327739b9a80c4c8dacb80216a5610045b Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:49:33 -0400 Subject: [PATCH 313/315] WIP snapshot before worktree removal (Ryan 2026-09-28: 'clear up your trees ... we will resume later'). Uncommitted work of a stopped lane, UNREVIEWED: re-derive before use. --- bench/d207/raw/RUN_log.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bench/d207/raw/RUN_log.txt diff --git a/bench/d207/raw/RUN_log.txt b/bench/d207/raw/RUN_log.txt new file mode 100644 index 00000000..3336b7f0 --- /dev/null +++ b/bench/d207/raw/RUN_log.txt @@ -0,0 +1,3 @@ +2026-09-24T22:17:39Z d207 acquiring (5s poll), pid 65286 +2026-09-24T23:08:42Z d207 ABORTED by signal (every descendant ran its own exit) +d207 exit=143 From 422be66324de8ec1f2b4ab93789a513b31eb7fbd Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:49:35 -0400 Subject: [PATCH 314/315] WIP snapshot before worktree removal (Ryan 2026-09-28: 'clear up your trees ... we will resume later'). Uncommitted work of a stopped lane, UNREVIEWED: re-derive before use. --- bench/d225/red_a5.txt | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 bench/d225/red_a5.txt diff --git a/bench/d225/red_a5.txt b/bench/d225/red_a5.txt new file mode 100644 index 00000000..0c9da1f2 --- /dev/null +++ b/bench/d225/red_a5.txt @@ -0,0 +1,73 @@ +head=8aba419dd072c4962811d554a8b8d86627e8bd21 dirty=0 cmd=cargo test --test d225_byte_split start=2026-09-24T21:51:03Z +2026-09-24T21:51:03Z d225 acquiring (5s poll), pid 40568 +2026-09-24T22:04:34Z d225 ACQUIRED: 40568 d225 2026-09-24T22:04:34Z + Compiling ferrodb v0.1.0 (/Users/idide/wt/ferrodb-d225-byte-split.noindex) + Finished `test` profile [unoptimized + debuginfo] target(s) in 13.62s + Running tests/d225_byte_split.rs (/Users/idide/wt/ferrodb-lane-target.noindex/debug/deps/d225_byte_split-ab03c813b9e4bd22) + +running 28 tests +test a_varchar_primary_key_over_the_entry_bound_is_refused_by_name ... ok +test a_create_index_over_a_live_long_row_says_the_row_is_live ... ok +test a_rebuild_over_a_legacy_oversized_primary_key_is_refused_before_anything_is_freed ... ok +test a_create_fulltext_index_over_a_deleted_row_with_an_over_bound_posting_is_refused_by_the_backfill ... ok +test a_chain_is_refused_at_the_step_that_widens_an_entry_past_the_bound ... ok +test a_merge_whose_row_would_land_over_the_bound_is_refused_before_the_schema_moves ... ok +test a_multi_row_update_refused_for_not_null_writes_no_row_first ... ok +test a_branch_insert_over_the_bound_in_the_branch_shape_is_refused_when_typed ... FAILED +test a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_builds ... FAILED +test a_rebuild_over_a_row_an_earlier_build_indexed_is_refused_before_anything_is_freed ... ok +test a_branch_update_over_the_bound_is_refused_when_typed ... FAILED +test a_secondary_value_over_the_entry_bound_is_refused_by_name_and_writes_nothing ... ok +test a_no_cut_refusal_after_the_heap_write_leaves_no_dangling_primary_entry ... FAILED +test a_rebuild_over_a_legacy_oversized_posting_is_refused_before_anything_is_freed ... ok +test a_varchar_widening_over_a_legacy_entry_it_does_not_grow_is_accepted ... ok +test a_multi_row_update_is_refused_before_its_first_row_is_written ... ok +test a_create_index_the_entry_bound_refuses_registers_nothing_and_leaks_nothing ... ok +test an_update_of_a_row_under_a_legacy_oversized_key_is_refused_before_it_is_written ... ok +test an_alter_that_would_repoint_a_legacy_oversized_key_is_refused_before_any_row_moves ... ok +test an_alter_that_widens_an_indexed_column_past_the_bound_is_refused_before_any_row_moves ... ok +test an_insert_whose_posting_is_over_the_bound_into_an_existing_full_text_index_writes_nothing ... ok +test an_alter_that_widens_an_indexed_column_to_exactly_the_bound_is_accepted_and_recovers ... ok +test an_update_to_a_value_over_the_entry_bound_is_refused_and_the_row_keeps_its_value ... ok +test an_update_whose_new_posting_is_over_the_bound_is_refused_before_the_row_moves ... ok +test a_create_index_over_a_deleted_long_row_names_the_table_copy_and_the_copy_works ... ok +test the_32nd_insert_into_a_mixed_width_posting_tree_splits_by_bytes ... ok +test the_32nd_insert_into_a_mixed_width_varchar_index_splits_by_bytes ... ok +test random_widths_never_leave_a_page_at_or_over_the_threshold ... ok + +failures: + +---- a_branch_insert_over_the_bound_in_the_branch_shape_is_refused_when_typed stdout ---- + +thread 'a_branch_insert_over_the_bound_in_the_branch_shape_is_refused_when_typed' (357359747) panicked at tests/d225_byte_split.rs:1214:18: +the branch took a row whose index entry is over the bound in its own shape +note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace + +---- a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_builds stdout ---- + +thread 'a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_builds' (357359754) panicked at tests/d225_byte_split.rs:101:33: +`CREATE INDEX iv ON t (v);` failed: constraint error: cannot build the index on 't.v': the row whose first column is Integer(1) makes index entry too large: 2108 bytes (key and value as stored), over the B+tree entry limit MAX_ENTRY_BYTES = 2034. The row is deleted. Its tuple stays in the table until its key is inserted again, which moves the dead version to the table's history; nothing else removes it (there is no VACUUM), so until then this refusal repeats. If the key fits, INSERT that key again with values whose index entries fit, remove the row again if it is not wanted, and CREATE the index again. If the key is the cause: With this build: copy the table's live rows into a new table, each under a key and values whose index entries fit the bound (there is no INSERT ... SELECT, so SELECT the rows and INSERT each one), DROP the old table, CREATE it again under its old name, copy the rows back and CREATE its indexes again (there is no RENAME TABLE). The copy discards the table's history: DROP frees its time-travel heap and its dead versions, so no older snapshot can read them any more. No in-place change repairs a key over the bound: a primary key cannot be updated and one over the bound cannot be inserted again, so a deleted row under it stays in the table (there is no VACUUM). + +---- a_branch_update_over_the_bound_is_refused_when_typed stdout ---- + +thread 'a_branch_update_over_the_bound_is_refused_when_typed' (357359748) panicked at tests/d225_byte_split.rs:1249:18: +the branch took an UPDATE whose index entry is over the bound + +---- a_no_cut_refusal_after_the_heap_write_leaves_no_dangling_primary_entry stdout ---- + +thread 'a_no_cut_refusal_after_the_heap_write_leaves_no_dangling_primary_entry' (357359760) panicked at tests/d225_byte_split.rs:101:33: +`INSERT INTO t VALUES (1, 'short');` failed: the slot is delted + + +failures: + a_branch_insert_over_the_bound_in_the_branch_shape_is_refused_when_typed + a_branch_update_over_the_bound_is_refused_when_typed + a_deleted_long_row_is_replaced_by_inserting_its_key_again_and_the_index_then_builds + a_no_cut_refusal_after_the_heap_write_leaves_no_dangling_primary_entry + +test result: FAILED. 24 passed; 4 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.46s + +error: test failed, to rerun pass `--test d225_byte_split` +2026-09-24T22:04:49Z d225 rc=101 +2026-09-24T22:04:50Z d225 released (owner matched) +exit=101 end=2026-09-24T22:04:50Z From 4dbf846d2a9fab45ee1412c0a5945838919f92a1 Mon Sep 17 00:00:00 2001 From: RyanLin5967 <187064462+RyanLin5967@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:10:42 -0400 Subject: [PATCH 315/315] r11-dist A19 (X) (UNBUILT): mode x -- AZ, OWN (owner 1's own fence record, CockroachDB layout), CACHE-COLD (512 MiB, every 128-B line), TLB-COLD (16 KiB stride), SQRT/LIN plants at 1e6/1e7; N 1e4..1e7; timed and counters --- examples/r11_dist_ledger.rs | 91 ++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/examples/r11_dist_ledger.rs b/examples/r11_dist_ledger.rs index 048522c4..a095580d 100644 --- a/examples/r11_dist_ledger.rs +++ b/examples/r11_dist_ledger.rs @@ -2058,6 +2058,13 @@ fn minflt() -> i64 { /// A18 (F): the A-Z heap history, exactly as 72fa4b8:1452-1465 ran it: ZK over run_w(N, N), ZK's close applied, ZK dropped; /// then M1 over run_w(N, N), boxed as `dyn Ledger`. `pre` puts a no-op fence (owner 9, round 0) in before the build. fn az_build(n: u64, pre: bool) -> (Box, Round) { + az_build_owner(n, if pre { Some(9) } else { None }) +} + +/// The A-Z history with an optional fence record pre-inserted at round 0 before the build: owner 9 is A18's PRE (an +/// allocation-free proxy); owner 1 is A19's OWN (CockroachDB's layout: the dead owner's own record exists and the fence +/// updates it in place). A fence at round 0 fences nothing, since every fork is at round >= 1. +fn az_build_owner(n: u64, pre_owner: Option) -> (Box, Round) { { let mut z: Box = Box::new(Zk::new()); let last = run_w(n, n, |e| { @@ -2067,8 +2074,8 @@ fn az_build(n: u64, pre: bool) -> (Box, Round) { std::hint::black_box(z.live_ids_of(1).len()); } let mut m = HeadCopy::new(true); - if pre { - m.fence.insert(9, 0); + if let Some(o) = pre_owner { + m.fence.insert(o, 0); } let mut l: Box = Box::new(m); let last = run_w(n, n, |e| { @@ -2134,6 +2141,85 @@ fn mode_fz(reps: u64, timed: bool) { } } +// --------------------------------------------------------------------------------------------- +// A19 (X), PREREG.md (registered 2026-10-01T05:22:22Z, before any A18 (F) data): the fence apply on the SOTA layout (OWN) +// from 10^4 to 10^7, with a forced-cold bound, a TLB-cold arm, and two planted controls. A closing arm: it cannot yield SURV. +// --------------------------------------------------------------------------------------------- + +/// 512 MiB, above the 16 MiB L2 and the SLC (sysctl hw.perflevel0.l2cachesize); written outside the timer. +const X_BUF: usize = 512 << 20; + +fn mode_x(reps: u64, timed: bool) { + println!("# x reps={reps} timed={timed} (A19 (X)); arms AZ, OWN, CACHE-COLD, TLB-COLD, SQRT-PLANT, LIN-PLANT rotated per rep"); + // Allocated and pre-faulted once, so the cold passes write resident memory and fault nothing. + let mut buf = vec![0u8; X_BUF]; + for i in (0..X_BUF).step_by(4096) { + buf[i] = 1; + } + let arms = ["AZ", "OWN", "CACHE-COLD", "TLB-COLD", "SQRT-PLANT", "LIN-PLANT"]; + for &n in &[10_000u64, 100_000, 1_000_000, 3_000_000, 10_000_000] { + for rep in 0..reps { + for k in 0..arms.len() { + let arm = arms[(k + rep as usize) % arms.len()]; + // The plants are measured where the falsifier reads: 10^6 and 10^7. + if arm.ends_with("PLANT") && n != 1_000_000 && n != 10_000_000 { + continue; + } + let (mut l, last) = az_build_owner(n, if arm == "AZ" { None } else { Some(1) }); + match arm { + // Every 128-B line: evicts the caches. + "CACHE-COLD" => { + for i in (0..X_BUF).step_by(128) { + buf[i] = buf[i].wrapping_add(1); + } + } + // One write per 16 KiB page: flushes the TLB, touches at most 1/128 of the sets. + "TLB-COLD" => { + for i in (0..X_BUF).step_by(16384) { + buf[i] = buf[i].wrapping_add(1); + } + } + _ => {} + } + std::hint::black_box(&buf); + let walk: u64 = match arm { + "SQRT-PLANT" => (n as f64).sqrt().ceil() as u64, + "LIN-PLANT" => n, + _ => 0, + }; + if timed { + let t0 = Instant::now(); + let mut hits = 0u64; + for i in 1..=walk { + if l.is_live(cid(1, i)) { + hits += 1; + } + } + l.apply(&Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }); + let ns = t0.elapsed().as_nanos(); + std::hint::black_box(hits); + println!("X arm={arm} N={n} rep={rep} ns={ns} walk={walk} live_after={}", l.live_ids_of(1).len()); + } else { + let e = Entry { term: 2, round: last + 1, command: abandon_c(sentinel(1)) }; + let f0 = minflt(); + let a0 = ALLOCS.load(Ordering::Relaxed); + let mut hits = 0u64; + for i in 1..=walk { + if l.is_live(cid(1, i)) { + hits += 1; + } + } + l.apply(&e); + let a1 = ALLOCS.load(Ordering::Relaxed); + let f1 = minflt(); + std::hint::black_box(hits); + println!("XC arm={arm} N={n} rep={rep} allocs={} minflt={} walk={walk} live_after={}", a1 - a0, f1 - f0, l.live_ids_of(1).len()); + } + } + } + } +} + // --------------------------------------------------------------------------------------------- // A17 (2): the liveness heartbeat in the model (CockroachDB node liveness), against plain M1. // --------------------------------------------------------------------------------------------- @@ -2332,6 +2418,7 @@ fn main() { Some("s6") => mode_s6(arg(2), arg(3), args.get(4).map(|x| x == "wm").unwrap_or(false)), Some("conformwide") => mode_conformwide(arg(2)), Some("fz") => mode_fz(arg(2), args.get(3).map(|x| x == "timed").unwrap_or(false)), + Some("x") => mode_x(arg(2), args.get(3).map(|x| x == "timed").unwrap_or(false)), Some("hb") => mode_hb(), _ => { eprintln!("usage: r11_dist_ledger counts | p6 | p8 | o2 | timed");