//! Where the time goes when an added ignore pattern scrubs the index. //! //! Adding one pattern sets `IndexWork { prune_scope: true, reindex: false }` //! (`config::diff_actions`), and `scope::advance` then reads **every** stored //! row under every root, decides each one, and deletes the losers by id. On a //! large index that has been measured slower than building the index was. This //! attributes that time to a phase. //! //! ```text //! cargo build -p quicksearch-core --example pruneprobe --release //! ./target/release/examples/pruneprobe //! ``` //! //! `QSB_FILES` / `QSB_DIRS` / `QSB_CONTENT_EVERY` size the corpus; //! `QSB_ARMS=plain|keyed|both` picks the key states. A keyed arm is not //! optional dressing — every page the delete touches is decrypted and //! re-encrypted, and the two arms have disagreed before (see //! `db::schema::PAGE_SIZE`). //! //! The stages are cumulative, so consecutive rows subtract to a phase cost: //! //! ```text //! page read the rows, decide nothing //! +cover ...and run the scope test per row //! +files ...and delete the doomed `files` rows //! +fts(all) ...and tombstone every doomed id <- ships today //! +fts(done) ...tombstoning only ids that can have an FTS row //! +subtree ...range-deleting a doomed directory instead of paging it //! ``` //! //! **Read the `commit` column, not `fts`.** FTS5 buffers a contentless delete //! in memory and writes the tombstone pages when the transaction is flushed, so //! the `DELETE` statement itself times as nearly free and the cost lands in the //! commit. That is also why `-tx` sweeps the number of pages per transaction: //! if the flush is priced per commit rather than per tombstone, transaction //! size is the variable that matters and the row count is not. //! //! Every stage runs against a byte-identical copy of one seeded index rather //! than a fresh seed: FTS5 segment layout is most of what decides delete cost, //! and reseeding would let it drift between the rows of the table. mod common; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use rusqlite::Connection; use quicksearch_core::config::Config; use quicksearch_core::db::{self, repo}; use quicksearch_core::extract::Registry; use quicksearch_core::file_handling::ExtractCursor; use quicksearch_core::scope::{self, Scope, WorkCursor}; use quicksearch_core::testutil::{self, Arm, SeedSpec}; use common::Io; /// The root every `seed_index` row hangs under; it need not exist on disk, /// because nothing in the prune path stats a stored path on Unix (see /// `platform::entry_hidden_reason`, which short-circuits on the dot prefix). const ROOT: &str = "/seed"; /// The ignore patterns the probe can add, each excluding a fifth of the index. /// /// `seed_index` names its second path segment `WORDS[(d * 7 + 13) % 35]`, and /// because 7 and 35 share a factor only these five of the thirty-five words are /// ever reachable — one per residue of `d mod 5`. So each excludes a fifth of /// the directories, spread across the keyspace rather than gathered at one end, /// which is the shape a real `node_modules` or `.git` pattern has. Taking a /// prefix of this list is how the probe sweeps the excluded fraction. `main` /// asserts the fractions rather than trusting this comment. const PRUNE_PATTERNS: [&str; 5] = ["jumps", "content", "revenue", "figure", "eta"]; /// The single-pattern case, and the one the stage table runs. const PRUNE_PATTERN: &str = PRUNE_PATTERNS[0]; /// Rows per page, matching `processing.batch_size`'s default — the figure /// `scope::advance` runs at. const PAGE: i64 = 500; /// Ids per `IN (...)` list, matching `repo::DELETE_IDS_CHUNK`. const CHUNK: usize = 512; /// Output leaf pages per `'merge'` call, matching `FINALIZE_MERGE_PAGES`. const MERGE_PAGES: i64 = 1000; // --------------------------------------------------------------------------- // Stages // --------------------------------------------------------------------------- /// Cumulative slices of the prune. Each does everything the one before it does. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] enum Stage { Page, Cover, Files, FtsAll, FtsDone, Subtree, } impl Stage { const ALL: [Stage; 6] = [ Stage::Page, Stage::Cover, Stage::Files, Stage::FtsAll, Stage::FtsDone, Stage::Subtree, ]; fn label(self) -> &'static str { match self { Stage::Page => "page", Stage::Cover => "+cover", Stage::Files => "+files", Stage::FtsAll => "+fts(all)", Stage::FtsDone => "+fts(done)", Stage::Subtree => "+subtree", } } fn tag(self) -> &'static str { match self { Stage::Page => "page", Stage::Cover => "cover", Stage::Files => "files", Stage::FtsAll => "fts-all", Stage::FtsDone => "fts-done", Stage::Subtree => "subtree", } } /// Whether the doomed rows leave `files`. fn deletes_rows(self) -> bool { self >= Stage::Files } /// Whether the doomed ids are tombstoned in FTS. fn deletes_fts(self) -> bool { self >= Stage::FtsAll } /// Whether the FTS delete is narrowed to ids that can hold a posting. fn fts_filtered(self) -> bool { self >= Stage::FtsDone } } /// What one stage cost, split by phase. #[derive(Default)] struct Timing { page: Duration, cover: Duration, files: Duration, fts: Duration, commit: Duration, total: Duration, examined: usize, deleted: usize, /// Rows skipped by a range delete rather than paged through. skipped: usize, io: Io, misses: i64, } // --------------------------------------------------------------------------- // The measured loop // --------------------------------------------------------------------------- fn placeholders(n: usize) -> String { let mut s = String::with_capacity(n * 2); for i in 0..n { if i > 0 { s.push(','); } s.push('?'); } s } /// Whether the walker would still descend into the stored parent `parent`. /// /// `Scope::covers` judges a path component by component, which for a directory /// is exactly "would the walk enter it" — the same question the walker asks /// before recursing. The trailing separator every stored parent carries has to /// go first, or the leaf component is empty. fn dir_covered(scope: &Scope, root: &Path, parent: &str) -> bool { let trimmed = parent.trim_end_matches(['/', '\\']); scope.covers(root, Path::new(trimmed)) } /// When the pass commits. #[derive(Clone, Copy)] enum Commit { /// One transaction per page — what ships. PerPage, /// One per `n` pages; the sweep's variable. Pages(usize), /// One per elapsed slice, which is what `scope::advance` already uses as /// its unit of interruptible work. Slice(Duration), } impl Commit { fn due(self, pages_open: usize, since: Instant) -> bool { match self { Commit::PerPage => true, Commit::Pages(n) => pages_open >= n, Commit::Slice(d) => since.elapsed() >= d, } } } /// Page the index the way `scope::advance` does, doing `stage`'s share of the /// work and timing each phase separately. /// /// The deletes are spelled out here rather than routed through `repo` so that /// the filtered and unfiltered FTS variants can be compared in one run — and /// so this keeps measuring the same thing after `repo` is reshaped around /// whichever variant wins. fn run_stage(conn: &Connection, config: &Config, stage: Stage, commit: Commit) -> Timing { let scope = Scope::from_config(config).expect("compile the scope"); let root = PathBuf::from(ROOT); let range = ExtractCursor::for_root(ROOT); let mut t = Timing::default(); let io_before = Io::read(); let (_, misses_before) = testutil::cache_stats(conn); let started = Instant::now(); let mut after = (range.lo.clone(), String::new()); let mut covered = scope::CoverCache::default(); // `unchecked_transaction` borrows shared, which is what lets one end and // the next begin inside the loop — the same trick `testutil::seed_index` // uses for `commit_every`. let mut tx = conn.unchecked_transaction().expect("begin"); let mut pages_open = 0usize; let mut tx_since = Instant::now(); loop { let at = Instant::now(); let rows = repo::rows_in_range_page(&tx, &after.0, &after.1, &range.hi, PAGE).expect("read a page"); t.page += at.elapsed(); let Some(last) = rows.last() else { break }; after = (last.parent.clone(), last.name.clone()); pages_open += 1; if stage == Stage::Page { t.examined += rows.len(); continue; } // The subtree short-circuit. A page spans several directories, so the // verdict is taken per distinct parent *as it appears* — stopping at // the first doomed one, range-deleting it, and re-seeking past it. // Testing only `rows[0]` would fire only on the pages that happen to // begin inside an excluded directory. let mut rows: &[repo::ScopeRow] = &rows; if stage == Stage::Subtree { let at = Instant::now(); let mut doomed_at = None; let mut last_dir: Option<(&str, bool)> = None; for (i, row) in rows.iter().enumerate() { let ok = match last_dir { Some((dir, ok)) if dir == row.parent => ok, _ => { let ok = dir_covered(&scope, &root, &row.parent); last_dir = Some((&row.parent, ok)); ok } }; if !ok { doomed_at = Some(i); break; } } t.cover += at.elapsed(); if let Some(i) = doomed_at { let dir = rows[i].parent.trim_end_matches(['/', '\\']).to_string(); let sub = ExtractCursor::for_root(&dir); let (removed, fts, files) = delete_range(&tx, &sub.lo, &sub.hi); t.fts += fts; t.files += files; t.deleted += removed; t.skipped += removed; // The rows before the doomed directory still need deciding; // everything from it on is gone or will be re-read after the // seek. Both happen inside the open transaction, so the seek // sees the range delete without needing a commit first. after = (sub.hi.clone(), String::new()); rows = &rows[..i]; } } let at = Instant::now(); let mut doomed: Vec = Vec::new(); let mut doomed_fts: Vec = Vec::new(); for row in rows { if scope.covers_cached(&root, Path::new(&row.path), &mut covered) { continue; } doomed.push(row.id); if row.content_state == repo::STATE_DONE { doomed_fts.push(row.id); } } t.cover += at.elapsed(); t.examined += rows.len(); if stage.deletes_rows() && !doomed.is_empty() { let fts_ids: &[i64] = if stage.fts_filtered() { &doomed_fts } else { &doomed }; if stage.deletes_fts() { for chunk in fts_ids.chunks(CHUNK) { let at = Instant::now(); tx.execute( &format!( "DELETE FROM searchabletext WHERE rowid IN ({})", placeholders(chunk.len()) ), rusqlite::params_from_iter(chunk.iter()), ) .expect("tombstone"); t.fts += at.elapsed(); } } for chunk in doomed.chunks(CHUNK) { let at = Instant::now(); t.deleted += tx .execute( &format!( "DELETE FROM files WHERE id IN ({})", placeholders(chunk.len()) ), rusqlite::params_from_iter(chunk.iter()), ) .expect("delete rows"); t.files += at.elapsed(); } } if commit.due(pages_open, tx_since) { let at = Instant::now(); tx.commit().expect("commit"); t.commit += at.elapsed(); tx = conn.unchecked_transaction().expect("begin"); pages_open = 0; tx_since = Instant::now(); } } let at = Instant::now(); tx.commit().expect("commit"); t.commit += at.elapsed(); t.total = started.elapsed(); t.io = Io::read().since(&io_before); let (_, misses_after) = testutil::cache_stats(conn); t.misses = misses_after - misses_before; t } /// Delete a whole parent range, tombstoning only the ids that can hold a /// posting. Returns `(rows, fts time, files time)`. fn delete_range(tx: &Connection, lo: &str, hi: &str) -> (usize, Duration, Duration) { let at = Instant::now(); tx.execute( "DELETE FROM searchabletext WHERE rowid IN \ (SELECT id FROM files WHERE parent >= ?1 AND parent < ?2 AND content_state = ?3)", rusqlite::params![lo, hi, repo::STATE_DONE], ) .expect("tombstone range"); let fts = at.elapsed(); let at = Instant::now(); let removed = tx .execute( "DELETE FROM files WHERE parent >= ?1 AND parent < ?2", rusqlite::params![lo, hi], ) .expect("delete range"); (removed, fts, at.elapsed()) } /// How much data FTS5 is holding — the only quiescence signal that works. /// /// `sqlite3_changes()` after a `'merge'` does **not** report whether the merge /// did any work: measured here, it reports non-zero forever, so a /// `while changes() != 0` loop never terminates. Watching `%_data` shrink and /// stop is what actually detects a consolidated index. fn fts_data_rows(conn: &Connection) -> i64 { conn.query_row("SELECT COUNT(*) FROM searchabletext_data", [], |r| r.get(0)) .unwrap_or(-1) } /// Merge until `%_data` stops moving, and report what that took. /// /// The **positive** argument is deliberate: negative picks /// `fts5IndexOptimizeStruct` instead, which is a different algorithm and a /// documented trap (`file_handling::records::fts_finalize_after_text_indexing`). fn merge_to_quiescence(conn: &Connection) -> (Duration, u32, i64, i64) { let before = fts_data_rows(conn); let started = Instant::now(); let mut rounds = 0; let mut last = before; loop { conn.execute( "INSERT INTO searchabletext(searchabletext, rank) VALUES('merge', ?1)", [MERGE_PAGES], ) .expect("merge"); rounds += 1; let now = fts_data_rows(conn); if now == last || rounds > 500 { break; } last = now; } (started.elapsed(), rounds, before, last) } // --------------------------------------------------------------------------- // Corpus and arms // --------------------------------------------------------------------------- fn env_usize(key: &str, default: usize) -> usize { std::env::var(key) .ok() .and_then(|v| v.trim().parse().ok()) .unwrap_or(default) } fn spec() -> SeedSpec { SeedSpec { files: env_usize("QSB_FILES", 200_000), dirs: env_usize("QSB_DIRS", 2_000), // **Coprime with 5, and that is the whole point.** `seed_index` places // content every `content_every` files and directories every `dirs`, // and [`PRUNE_PATTERN`] excludes the directories where `d mod 5 == 0`. // The shipped default of 10 shares the factor 5 with that, so *every* // extracted document lands in an excluded directory: the prune deletes // 100% of the postings, the FTS index goes empty, and every question // about tombstone cost is answered by a degenerate corpus. 7 puts a // fifth of the documents in the doomed set, which is the real shape. // `main` asserts the fraction. content_every: env_usize("QSB_CONTENT_EVERY", 7), // At least two segments, so the second can carry the excluded name. // Depth is most of what decides stored row width, and therefore how // many pages the scan reads — see `SeedSpec::dir_depth`. dir_depth: env_usize("QSB_DIR_DEPTH", 6).max(2), // A real run commits in slices, and each commit flushes FTS5's hash to // its own segment. Seeding in one transaction would leave a single // segment and understate every tombstone cost below. commit_every: 500, ..SeedSpec::default() } } fn config_with(patterns: &[&str]) -> Config { let mut config = Config::default(); config.paths.indexing_paths = vec![ROOT.to_string()]; config.indexing.ignore_patterns = patterns.iter().map(|p| p.to_string()).collect(); config.processing.batch_size = PAGE as usize; config } /// A byte-identical copy of `master`, under its own scratch directory. /// /// `seed_index` ends with a TRUNCATE checkpoint, so the one file holds the /// whole index and there is no `-wal` to carry across. fn clone_arm(master: &Arm, tag: &str) -> Arm { let path = testutil::scratch_db(tag); std::fs::copy(&master.path, &path).expect("copy the seeded index"); Arm { what: master.what.clone(), keyed: master.keyed, pgsz: master.pgsz, page_size: master.page_size, hmac: master.hmac, path, seeded_in: Duration::ZERO, } } /// The coordinator's own writer profile, which is what the reconcile actually /// runs on — `PRAGMAS_INCREMENTAL`, a 4 MiB page cache. Opening this /// `open_existing(.., true)` instead would measure `PRAGMAS_FAST`'s 8 MiB and /// quietly flatter every figure below. fn open(arm: &Arm) -> Connection { arm.with_key(|| { db::open::open_incremental_writer(&arm.path.to_string_lossy()).expect("open the copy") }) } /// A writer knob worth sweeping, applied over [`open`]'s profile. #[derive(Clone, Copy)] struct Knobs { label: &'static str, /// `None` leaves SQLite's 1000-page default: the committing thread /// checkpoints — copying WAL pages back into the main file, re-encrypting /// every one on a keyed index — every 8 MiB of log. autocheckpoint: Option, /// MiB of page cache; `None` keeps `PRAGMAS_INCREMENTAL`'s 4. cache_mib: Option, } const SHIPPED: Knobs = Knobs { label: "as shipped", autocheckpoint: None, cache_mib: None, }; /// What `PRAGMAS_INCREMENTAL` gives the reconcile today, in MiB. Named so the /// sweep's first row is labelled with the figure it is arguing against rather /// than repeating it as a literal. const SHIPPED_MIB: i64 = 4; fn apply(conn: &Connection, knobs: Knobs) { if let Some(n) = knobs.autocheckpoint { conn.execute_batch(&format!("PRAGMA wal_autocheckpoint = {};", n)) .expect("autocheckpoint"); } if let Some(mib) = knobs.cache_mib { conn.execute_batch(&format!("PRAGMA cache_size = -{};", mib * 1024)) .expect("cache_size"); } } fn count(conn: &Connection, sql: &str) -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap_or(-1) } // --------------------------------------------------------------------------- fn main() { let spec = spec(); let arms: Vec = match std::env::var("QSB_ARMS").as_deref() { Ok("plain") => vec![false], Ok("keyed") => vec![true], _ => vec![false, true], }; println!( "corpus: {} files across {} dirs, 1 in {} with content, pattern {:?}", spec.files, spec.dirs, spec.content_every, PRUNE_PATTERN ); for keyed in arms { let label = if keyed { "keyed" } else { "plain" }; let master = Arm::seed(label, &format!("prune-{}", label), keyed, &spec); println!( "\n=== {} === seeded in {:.1}s, {}", label, master.seeded_in.as_secs_f64(), common::mib(master.size_bytes()) ); // What the pattern is worth, read off the index rather than assumed. { let conn = open(&master); let total = count(&conn, "SELECT COUNT(*) FROM files"); let doomed = count( &conn, &format!( "SELECT COUNT(*) FROM files WHERE parent LIKE '%/{}/%'", PRUNE_PATTERN ), ); let doomed_fts = count( &conn, &format!( "SELECT COUNT(*) FROM files WHERE parent LIKE '%/{}/%' AND content_state = 1", PRUNE_PATTERN ), ); let fts = count(&conn, "SELECT COUNT(*) FROM searchabletext"); let done = count(&conn, "SELECT COUNT(*) FROM files WHERE content_state = 1"); assert!( doomed > 0, "the pattern excludes nothing — seed_index's directory naming moved" ); assert_eq!(fts, done, "a posting exists exactly for content_state = 1"); assert!( doomed_fts * 2 < done, "the prune takes {} of {} postings — the content and directory \ strides have collided again; see `spec`'s content_every", doomed_fts, done ); println!( " {} rows, {} doomed ({:.0}%), of which {} hold a posting ({:.0}%) — \ the rest are tombstoned today for nothing", total, doomed, 100.0 * doomed as f64 / total as f64, doomed_fts, 100.0 * doomed_fts as f64 / doomed as f64, ); println!( " plan: {}", conn.query_row( "EXPLAIN QUERY PLAN DELETE FROM searchabletext WHERE rowid IN (1,2,3)", [], |r| r.get::<_, String>(3), ) .unwrap_or_else(|e| format!("unavailable: {}", e)) ); // What the tombstone actually has to find its way into. `%_data` // holds the segment leaves, `%_idx` one row per segment b-tree // node, `%_docsize` the origin a contentless delete looks up. println!( " fts: {} %_data rows ({}), {} %_idx, {} %_docsize", count(&conn, "SELECT COUNT(*) FROM searchabletext_data"), common::mib( count( &conn, "SELECT COALESCE(SUM(pgsize), 0) FROM dbstat \ WHERE name LIKE 'searchabletext%'" ) .max(0) as u64 ), count(&conn, "SELECT COUNT(*) FROM searchabletext_idx"), count(&conn, "SELECT COUNT(*) FROM searchabletext_docsize"), ); } let config = config_with(&[PRUNE_PATTERN]); let header = || { println!( "\n {:<11} {:>7} {:>7} {:>7} {:>7} {:>8} {:>8} {:>9} {:>8}", "stage", "page", "cover", "files", "fts", "commit", "total", "misses", "deleted" ); }; let row = |name: &str, t: &Timing| { let ms = |d: Duration| d.as_secs_f64() * 1000.0; println!( " {:<11} {:>7.0} {:>7.0} {:>7.0} {:>7.0} {:>8.0} {:>8.0} {:>9} {:>8}", name, ms(t.page), ms(t.cover), ms(t.files), ms(t.fts), ms(t.commit), ms(t.total), t.misses, t.deleted, ); }; header(); for stage in Stage::ALL { let arm = clone_arm(&master, &format!("prune-{}-{}", label, stage.tag())); let conn = open(&arm); let t = run_stage(&conn, &config, stage, Commit::PerPage); row(stage.label(), &t); if stage == Stage::Subtree { println!( " {:<11} {} of those rows never reached a page", "", t.skipped ); } // Consolidation, priced apart: today one 1000-page `'merge'` runs // and the scrub stops, whatever it left behind. if stage.deletes_fts() { let (took, rounds, before, after) = merge_to_quiescence(&conn); println!( " {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows", "", took.as_secs_f64() * 1000.0, rounds, before, after ); } drop(conn); arm.discard(); } // Is the cost priced per commit or per row? Today's // one-transaction-per-page is the first line; if it collapses as pages // are folded together, commit cadence is the lever and none of the // stages above are. println!("\n transaction size, at +fts(all):"); header(); for pages in [1usize, 8, 64, 512] { let arm = clone_arm(&master, &format!("prune-{}-tx{}", label, pages)); let conn = open(&arm); let t = run_stage(&conn, &config, Stage::FtsAll, Commit::Pages(pages)); row(&format!("{} page/tx", pages), &t); drop(conn); arm.discard(); } // The projected end state: commit on the slice `advance` already runs // to, skip doomed directories wholesale, tombstone only what can hold // a posting. Nothing here needs a longer uninterruptible window than // ships today — the slice boundary is already the cancellation point. println!("\n combined, committing once per {:?} slice:", scope::SLICE); header(); { let arm = clone_arm(&master, &format!("prune-{}-combined", label)); let conn = open(&arm); let t = run_stage(&conn, &config, Stage::Subtree, Commit::Slice(scope::SLICE)); row("combined", &t); let (took, rounds, before, after) = merge_to_quiescence(&conn); println!( " {:<11} merge: {:.0} ms over {} rounds, %_data {} -> {} rows", "", took.as_secs_f64() * 1000.0, rounds, before, after ); drop(conn); arm.discard(); } // The page cache. `PRAGMAS_INCREMENTAL` gives the reconcile a flat // 4 MiB while it scans and deletes across the *whole* index — and a // delete does not only touch the index the scan reads in order. It // maintains `idx_files_mtime`, `idx_files_mime` and `idx_files_hash` // too, three b-trees in orders uncorrelated with `(parent, name)`, so // every doomed row scatters ~3 page touches that a 512-page cache // cannot hold. `misses` is the column to read. // // Both commit policies are swept, because a cache that holds the // working set may make the commit cadence stop mattering. println!("\n page cache, at +fts(all) [{} = shipped]:", SHIPPED_MIB); header(); for mib in [SHIPPED_MIB, 16, 32, 64, 128, 256] { for (name, commit) in [ ("per page", Commit::PerPage), ("per slice", Commit::Slice(scope::SLICE)), ] { let arm = clone_arm(&master, &format!("prune-{}-c{}-{}", label, mib, name.len())); let conn = open(&arm); apply( &conn, Knobs { cache_mib: Some(mib), ..SHIPPED }, ); let t = run_stage(&conn, &config, Stage::FtsAll, commit); row(&format!("{} MiB, {}", mib, name), &t); drop(conn); arm.discard(); } } // Measured and rejected, so the table records it: with autocheckpoint // at its 1000-page default the committing thread copies the log back // into the main file every 8 MiB and re-encrypts every page it moves, // which looked like an obvious suspect. Deferring it to one checkpoint // at the end is a wash — the pages have to move either way. { let arm = clone_arm(&master, &format!("prune-{}-nockpt", label)); let conn = open(&arm); apply( &conn, Knobs { autocheckpoint: Some(0), ..SHIPPED }, ); let t = run_stage(&conn, &config, Stage::FtsAll, Commit::Slice(scope::SLICE)); let at = Instant::now(); conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok(); let ckpt = at.elapsed(); row( "no autockpt", &Timing { commit: t.commit + ckpt, total: t.total + ckpt, ..t }, ); drop(conn); arm.discard(); } // How much of the index the pattern takes, swept. A prune that removes // most of the postings is a different problem from one that removes a // few: past some fraction FTS5 stops tombstoning and starts rewriting // segments, and rewriting the full-text index is what building it was. // `today` is what ships; `fixed` is the projection above. println!("\n excluded fraction (today -> fixed, both + merge to quiescence):"); println!( " {:<11} {:>8} {:>9} {:>9} {:>9} {:>11} {:>11}", "patterns", "deleted", "today", "fixed", "merge", "%_data", "postings" ); for n in 1..=PRUNE_PATTERNS.len() { let patterns = &PRUNE_PATTERNS[..n]; let config = config_with(patterns); let mut timings = Vec::new(); for (tag, stage, commit) in [ ("today", Stage::FtsAll, Commit::PerPage), ("fixed", Stage::Subtree, Commit::Slice(scope::SLICE)), ] { let arm = clone_arm(&master, &format!("prune-{}-f{}-{}", label, n, tag)); let conn = open(&arm); let before = fts_data_rows(&conn); let t = run_stage(&conn, &config, stage, commit); let (merge, _, _, after) = merge_to_quiescence(&conn); let left = count(&conn, "SELECT COUNT(*) FROM searchabletext"); timings.push((t, merge, before, after, left)); drop(conn); arm.discard(); } let ms = |d: Duration| d.as_secs_f64() * 1000.0; println!( " {:<11} {:>8} {:>8.0} {:>8.0} {:>8.0} {:>11} {:>11}", format!("{} of 5", n), timings[0].0.deleted, ms(timings[0].0.total), ms(timings[1].0.total), ms(timings[1].1), format!("{}->{}", timings[1].2, timings[1].3), timings[1].4, ); } // The reference number every stage above is decomposing: the real // `scope::advance`, driven to completion the way the coordinator drives // it. It must land on `+fts(all)`. { let arm = clone_arm(&master, &format!("prune-{}-live", label)); let mut conn = open(&arm); let old = config_with(&[]); let actions = quicksearch_core::config::diff_actions(&old, &config); let mut cursor = WorkCursor::new(actions.work, &config).expect("plan"); let registry = Registry::default_set(); let run = std::sync::atomic::AtomicBool::new(false); let started = Instant::now(); while !cursor.done() { scope::advance( &mut conn, &config, ®istry, &mut cursor, Instant::now() + scope::SLICE, &run, ) .expect("advance"); } println!( " {:<11} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8.0} {:>10} {:>9} <- scope::advance", "live", "", "", "", "", "", started.elapsed().as_secs_f64() * 1000.0, "", cursor.deleted, ); drop(conn); arm.discard(); } master.discard(); } }