From af9b8bbd602fc1df6d1890b47b5206b6ca99b30a Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:17:22 +0100 Subject: [PATCH 1/5] perf(collect): skip a board's read of an answer that moved nothing The watcher sends only the beads that changed, so an answer of a freshness line alone leaves a board's read where it stood. Each answer is numbered by the change that last moved its beads, which a board compares as a fingerprint, and the beads are shared rather than copied between answers and into the board's read. --- src/app/collection.rs | 38 ++++++++++++-- src/app/tracker.rs | 12 ++--- src/collect/bd.rs | 7 +-- src/collect/tracker.rs | 24 +++++++-- src/collect/watched.rs | 115 +++++++++++++++++++++++++++++++++++------ 5 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/app/collection.rs b/src/app/collection.rs index 9dfff37..960d298 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -357,13 +357,13 @@ impl Collection { ) { for (project, answer) in self.refresh_together(cfg, trackers, named, panes, now) { match answer { - Ok(Refresh::Unchanged) => { + Ok(Refresh::Unchanged { as_of }) => { // A skipped read is a successful read: `bdi` knows the // tracker has not moved, so the project is as fresh as if // the cascade had run and the foot must not draw it as // stale. if let Some(standing) = self.read.get_mut(&project.name) { - standing.at = now; + standing.at = as_of; } } Ok(Refresh::Read { @@ -1230,7 +1230,7 @@ mod tests { self.inner.fingerprint() } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { self.inner.all() } @@ -1657,6 +1657,38 @@ mod tests { ); } + /// A tracker answering from what another process read is as fresh as + /// that process last vouched for, whether the read was skipped or not. + #[test] + fn a_skipped_read_of_an_answer_read_elsewhere_is_as_fresh_as_that_answer() { + let cfg = one_project(); + let earlier = now(); + let vouched = earlier + chrono::Duration::seconds(20); + let later = earlier + chrono::Duration::seconds(30); + let mut standing = Collection::default(); + standing.collect( + &cfg, + &panes(), + &dunwich_with(dunwich_tracker().vouched_for(earlier)), + &Wanted::Everything, + Filter::All, + earlier, + ); + + let trackers = dunwich_with(dunwich_tracker().vouched_for(vouched)); + let after = standing.collect( + &cfg, + &panes(), + &trackers, + &dunwich_alone(), + Filter::All, + later, + ); + + assert_eq!(trackers.tracker("dunwich").asked(), [Asked::Fingerprint]); + assert_eq!(after.read_at["dunwich"], vouched); + } + /// Skipping the read must not skip the drawing. What a node says about /// its own age is derived from the clock at each collection rather than /// from what the tracker said, so a claim goes stale on the screen while diff --git a/src/app/tracker.rs b/src/app/tracker.rs index 24fb925..f492eba 100644 --- a/src/app/tracker.rs +++ b/src/app/tracker.rs @@ -124,8 +124,8 @@ fn speaks_until(beads: &[Arc], read_at: DateTime) -> Option }, /// What the tracker says now, and what it was read against. `None` where /// the probe could not answer, which has every later refresh read in full /// rather than compare against a state nobody established. `as_of` is @@ -133,7 +133,7 @@ pub(super) enum Refresh { /// tracker answered from what another process read. /// /// `at` and `work` are behind a box because `Unchanged` is the usual - /// answer and carries nothing: a project that has not moved would + /// answer and carries only its date: a project that has not moved would /// otherwise be handed back on the stack as the size of one that had. /// `ReadAt` holds a whole `Project`, so it grows whenever a project entry /// gains a field. @@ -187,7 +187,7 @@ pub(super) fn refresh_project( if let (Some(fingerprint), Some(standing)) = (probed.as_deref(), standing) { if standing.still_speaks_for(project, fingerprint, &named, &roots, now) { - return Ok(Refresh::Unchanged); + return Ok(Refresh::Unchanged { as_of }); } } @@ -227,7 +227,7 @@ fn read_project( || tracker.all(), || together(|| tracker.ready(), || tracker.blocked()), ); - let beads: Vec> = beads?.into_iter().map(Arc::new).collect(); + let beads = beads?; // An empty readiness set reads as "nothing here is ready", so a tracker // that cannot answer must not leave one behind. @@ -1895,7 +1895,7 @@ dunwich = ["bdi-404"] Tracker::fingerprint(&self.inner) } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { self.at.arrive("all"); Tracker::all(&self.inner) } diff --git a/src/collect/bd.rs b/src/collect/bd.rs index cb2b9d9..a2a706f 100644 --- a/src/collect/bd.rs +++ b/src/collect/bd.rs @@ -746,12 +746,13 @@ impl Tracker for Reader<'_> { /// `--all` is load-bearing: without it bd answers about open beads only, /// and a smaller correct-looking answer about a different population is /// the kind of wrong that reads as right. - fn all(&self) -> Result, RunFailure> { - if self.unfinished_work { + fn all(&self) -> Result>, RunFailure> { + let beads = if self.unfinished_work { self.every_bead_with_unfinished_text() } else { self.every_bead() - } + }; + beads.map(|beads| beads.into_iter().map(Arc::new).collect()) } /// bd computes readiness itself and treats it as a state of its own, so diff --git a/src/collect/tracker.rs b/src/collect/tracker.rs index 1092def..f794a7f 100644 --- a/src/collect/tracker.rs +++ b/src/collect/tracker.rs @@ -6,6 +6,7 @@ //! is the line it stays behind. use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use chrono::{DateTime, Utc}; use serde_json::Value; @@ -28,7 +29,7 @@ pub trait Tracker: Sync { /// it depends on and the bead it hangs under. A tracker opened for a run /// that shows unfinished work alone may leave out a finished bead's free /// text. - fn all(&self) -> Result, RunFailure>; + fn all(&self) -> Result>, RunFailure>; /// The ids the tracker itself considers ready to start. fn ready(&self) -> Result, RunFailure>; @@ -120,7 +121,7 @@ impl Tracker for &T { (**self).fingerprint() } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { (**self).all() } @@ -176,6 +177,7 @@ pub mod testing { /// Every record the journal holds, where the tracker was opened with /// one. journal: Mutex, RunFailure>>>, + as_of: Option>, asked: Mutex>, } @@ -192,6 +194,7 @@ pub mod testing { ready: Ok(BTreeSet::new()), blocked: Ok(BTreeMap::new()), journal: Mutex::new(None), + as_of: None, asked: Mutex::new(Vec::new()), } } @@ -248,6 +251,13 @@ pub mod testing { self } + /// The same tracker answering from what another process read, which + /// last vouched for it at `at`. + pub fn vouched_for(mut self, at: DateTime) -> Self { + self.as_of = Some(at); + self + } + /// A tracker with nothing to fingerprint by, which is read in full /// every time. pub fn without_a_fingerprint(mut self) -> Self { @@ -284,9 +294,11 @@ pub mod testing { self.fingerprint.clone() } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { self.note(Asked::All); - self.all.clone() + self.all + .clone() + .map(|beads| beads.into_iter().map(Arc::new).collect()) } fn ready(&self) -> Result, RunFailure> { @@ -299,6 +311,10 @@ pub mod testing { self.blocked.clone() } + fn as_of(&self) -> Option> { + self.as_of + } + fn events(&self, since: u64) -> Option, RunFailure>> { let journal = self.journal.lock().unwrap().clone()?; self.note(Asked::Events(since)); diff --git a/src/collect/watched.rs b/src/collect/watched.rs index 146f5ec..8dbb412 100644 --- a/src/collect/watched.rs +++ b/src/collect/watched.rs @@ -205,12 +205,19 @@ struct Watcher { current: BTreeMap>, /// The answers that have begun and not yet closed. arriving: BTreeMap, + /// How many answers have changed what a project holds, over every + /// connection, which numbers each project's beads. + changes: u64, } /// One project as the watcher last said it stood. #[derive(Debug, Clone, Default)] struct Told { - beads: BTreeMap, + /// Shared with the answer before, and copied only by a line that + /// changes it. + beads: Arc>, + /// The change that left the beads as they stand. + change: u64, as_of: Option>, unreachable: Option, /// How the watcher's config reaches the tracker, where it said. @@ -220,7 +227,7 @@ struct Told { /// One bead as the watcher sent it, with the readiness bd gives it. #[derive(Debug, Clone)] struct Listed { - bead: Bead, + bead: Arc, bd: BeadReadiness, } @@ -359,14 +366,13 @@ impl Watcher { fn take(&mut self, line: Line) -> Option> { match line { Line::Bead { project, bd, row } => { - let bead = bead_of(row, false).ok()?; + let bead = Arc::new(bead_of(row, false).ok()?); let listed = Listed { bead, bd }; - self.arriving(&project) - .beads + Arc::make_mut(&mut self.arriving(&project).beads) .insert(listed.bead.id.clone(), listed); } Line::Gone { project, id } => { - self.arriving(&project).beads.remove(&id); + Arc::make_mut(&mut self.arriving(&project).beads).remove(&id); } Line::Freshness { project, @@ -378,11 +384,17 @@ impl Watcher { if protocol != Some(PROTOCOL) { return None; } - let mut told = self - .arriving - .remove(&project) - .or_else(|| self.current.get(&project).map(|told| (**told).clone())) - .unwrap_or_default(); + let standing = self.current.get(&project).map(|told| (**told).clone()); + let mut told = match (self.arriving.remove(&project), standing) { + (None, Some(standing)) => standing, + (arrived, _) => { + self.changes += 1; + Told { + change: self.changes, + ..arrived.unwrap_or_default() + } + } + }; told.as_of = as_of; told.reach = reach; told.unreachable = match tracker { @@ -540,17 +552,19 @@ fn next(from: &mut BufReader, giving_up: Instant) -> Option { struct Answered(Arc); impl Tracker for Answered { - /// Nothing to compare against: the watcher has done the comparing. + /// The change that left the beads as they stand. The watcher sends only + /// the beads that changed, so an answer that sent none has moved nothing + /// a read takes. fn fingerprint(&self) -> Option> { - None + Some(Ok(format!("watcher change {}", self.0.change))) } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { Ok(self .0 .beads .values() - .map(|listed| listed.bead.clone()) + .map(|listed| Arc::clone(&listed.bead)) .collect()) } @@ -723,7 +737,7 @@ path = "/srv/work/ferry" .all() .expect("the tracker answers") .into_iter() - .map(|bead| bead.id) + .map(|bead| bead.id.clone()) .collect() } @@ -1533,4 +1547,73 @@ path = "/srv/work/dunwich" assert!(!own.tracker("dunwich").asked().is_empty()); } + + /// What `watcher` holds of dunwich once it has taken each of `lines`, as + /// a read of it would find it. + fn taking(watcher: &mut Watcher, lines: &[String]) -> Answered { + for line in lines { + let line = serde_json::from_str(line).expect("a line about a watch"); + watcher.take(line).expect("a line this run reads"); + } + Answered(Arc::clone(&watcher.current["dunwich"])) + } + + fn fingerprint(answered: &Answered) -> String { + answered + .fingerprint() + .expect("an answer offers a fingerprint") + .expect("and gives it") + } + + fn gone(id: &str) -> String { + json!({ "line": "gone", "project": "dunwich", "id": id }).to_string() + } + + /// The watcher sends a bead only where it changed, so an answer of a + /// freshness line alone is one in which nothing a read takes has moved. + #[test] + fn an_answer_bringing_no_bead_has_the_fingerprint_of_the_one_before() { + let mut watcher = Watcher::default(); + let first = taking( + &mut watcher, + &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + ); + + let then = taking(&mut watcher, &[fresh("dunwich", json!("ok"))]); + + assert_eq!(fingerprint(&then), fingerprint(&first)); + } + + #[test] + fn an_answer_bringing_a_bead_or_its_going_moves_the_fingerprint() { + let mut watcher = Watcher::default(); + let first = taking( + &mut watcher, + &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + ); + let changed = taking( + &mut watcher, + &[bead("dunwich", "dun-1", false, &[]), fresh("dunwich", json!("ok"))], + ); + let went = taking(&mut watcher, &[gone("dun-1"), fresh("dunwich", json!("ok"))]); + + assert_ne!(fingerprint(&changed), fingerprint(&first)); + assert_ne!(fingerprint(&went), fingerprint(&changed)); + } + + /// A watcher found again tells each project from nothing, so its first + /// answer may hold less than the last one before it went. + #[test] + fn a_watcher_found_again_answers_under_a_fingerprint_never_used_before() { + let mut watcher = Watcher::default(); + let before = taking( + &mut watcher, + &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + ); + watcher.gone(true); + + let again = taking(&mut watcher, &[fresh("dunwich", json!("ok"))]); + + assert_ne!(fingerprint(&again), fingerprint(&before)); + } } From 0aae57fcd963558ad5c9e39bfa06c01ff274bcbb Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:24:37 +0100 Subject: [PATCH 2/5] perf(app): assemble no trees for a project read only on demand A project read only for what another project's trees reach in it draws no trees of its own, so its read assembles none. The standing read records whether it assembled them, so a project the run later reads for itself is read again. --- src/app/collection.rs | 71 +++++++++++++++++++++++++++++++++++++++++-- src/app/tracker.rs | 55 ++++++++++++++++++++++++--------- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/src/app/collection.rs b/src/app/collection.rs index 960d298..fae43b7 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -252,6 +252,7 @@ impl Collection { &reading, trackers, &|project| wanted.names(project), + &|project| cfg.reads(project), &panes, now, ); @@ -265,6 +266,7 @@ impl Collection { &reading, trackers, &|project| needed.contains(project), + &|project| cfg.reads(project), &panes, now, ); @@ -346,16 +348,19 @@ impl Collection { } /// Refresh every project `cfg` reads that `named` names, and keep what - /// each said. + /// each said. Only a project that `draws_trees` has its trees assembled. fn read( &mut self, cfg: &Config, trackers: &dyn Trackers, named: &(dyn Fn(&str) -> bool + Sync), + draws_trees: &(dyn Fn(&str) -> bool + Sync), panes: &[Pane], now: DateTime, ) { - for (project, answer) in self.refresh_together(cfg, trackers, named, panes, now) { + for (project, answer) in + self.refresh_together(cfg, trackers, named, draws_trees, panes, now) + { match answer { Ok(Refresh::Unchanged { as_of }) => { // A skipped read is a successful read: `bdi` knows the @@ -419,6 +424,7 @@ impl Collection { cfg: &'a Config, trackers: &dyn Trackers, named: &(dyn Fn(&str) -> bool + Sync), + draws_trees: &(dyn Fn(&str) -> bool + Sync), panes: &[Pane], now: DateTime, ) -> Vec<(&'a Project, Result)> { @@ -436,12 +442,14 @@ impl Collection { .get(&project.name) .and_then(|journal| journal.seq) .unwrap_or(0); + let draws_trees = draws_trees(&project.name); reads.spawn(move || { let answer = refresh_project( trackers, project, cfg, panes, + draws_trees, standing.as_ref(), !self.once, since, @@ -2838,6 +2846,65 @@ path = "{FERRY}" assert_eq!(snap.projects, ["dunwich", "ferry"]); } + /// How many trees each project's standing read assembled. + fn trees_assembled(standing: &Collection) -> BTreeMap<&str, usize> { + standing + .read + .iter() + .filter_map(|(project, read)| Some((project.as_str(), read.work.as_ref().ok()?.roots.len()))) + .collect() + } + + /// Only a project the run reads draws trees of its own, so one read for + /// what those trees reach in it has none assembled. + #[test] + fn a_project_read_only_for_what_another_reaches_in_it_assembles_no_trees() { + let mut standing = Collection::default(); + standing.collect( + &reading_ferry_where_dunwich(STATES_ITS_PREFIX), + &no_panes(), + &ferry_waiting_on_dunwich(), + &Wanted::Everything, + Filter::All, + now(), + ); + + assert_eq!( + trees_assembled(&standing), + BTreeMap::from([("dunwich", 0), ("ferry", 1)]) + ); + } + + /// A project the run comes to read for itself has its trees assembled, + /// though its tracker has not moved since it was read on demand. + #[test] + fn a_project_read_on_demand_and_then_for_itself_has_its_trees_assembled() { + let trackers = ferry_waiting_on_dunwich(); + let mut standing = Collection::default(); + standing.collect( + &reading_ferry_where_dunwich(STATES_ITS_PREFIX), + &no_panes(), + &trackers, + &Wanted::Everything, + Filter::All, + now(), + ); + + let both = reading_ferry_where_dunwich(STATES_ITS_PREFIX) + .scoped_to(&["dunwich".to_string(), "ferry".to_string()]) + .expect("both are configured"); + standing.collect( + &both, + &no_panes(), + &trackers, + &Wanted::Everything, + Filter::All, + now(), + ); + + assert_ne!(trees_assembled(&standing)["dunwich"], 0); + } + /// dunwich's bead waits on kadath's, so reading dunwich for ferry's /// blocker is what says kadath is needed too. #[test] diff --git a/src/app/tracker.rs b/src/app/tracker.rs index f492eba..161d74a 100644 --- a/src/app/tracker.rs +++ b/src/app/tracker.rs @@ -89,6 +89,7 @@ pub(super) struct ReadAt { fingerprint: String, named: BTreeSet, roots: BTreeSet, + draws_trees: bool, speaks_until: Option>, } @@ -101,12 +102,14 @@ impl ReadAt { fingerprint: &str, named: &BTreeSet, roots: &BTreeSet, + draws_trees: bool, now: DateTime, ) -> bool { self.project == *project && self.fingerprint == fingerprint && self.named == *named && self.roots == *roots + && self.draws_trees == draws_trees && self.speaks_until.is_none_or(|until| now < until) } } @@ -163,12 +166,16 @@ pub(super) enum Refresh { /// /// The journal is read after `since` before the beads are, so every record /// read has a bead read at least as new as it. +/// +/// A project that `draws_trees` has its trees assembled. One read only for +/// what other projects' trees reach in it has none. #[allow(clippy::too_many_arguments)] pub(super) fn refresh_project( trackers: &dyn Trackers, project: &Project, cfg: &Config, panes: &[Pane], + draws_trees: bool, standing: Option<&ReadAt>, probing: bool, since: u64, @@ -186,13 +193,13 @@ pub(super) fn refresh_project( let roots = roots_named(cfg, project); if let (Some(fingerprint), Some(standing)) = (probed.as_deref(), standing) { - if standing.still_speaks_for(project, fingerprint, &named, &roots, now) { + if standing.still_speaks_for(project, fingerprint, &named, &roots, draws_trees, now) { return Ok(Refresh::Unchanged { as_of }); } } let journal = tracker.events(since); - let work = read_project(tracker.as_ref(), project, cfg, panes, now)?; + let work = read_project(tracker.as_ref(), project, cfg, panes, draws_trees, now)?; let journal_answered = !matches!(journal, Some(Err(_))); let at = probed.filter(|_| journal_answered).map(|fingerprint| { Box::new(ReadAt { @@ -200,6 +207,7 @@ pub(super) fn refresh_project( fingerprint, named, roots, + draws_trees, speaks_until: work.speaks_until, }) }); @@ -221,6 +229,7 @@ fn read_project( project: &Project, cfg: &Config, panes: &[Pane], + draws_trees: bool, now: DateTime, ) -> Result { let (beads, (ready, blocked_by)) = together( @@ -236,6 +245,27 @@ fn read_project( blocked_by: blocked_by?, }; + Ok(ProjectWork { + readiness, + relations: edges::relations(&beads), + roots: if draws_trees { + trees(&beads, project, cfg, panes) + } else { + Vec::new() + }, + speaks_until: speaks_until(&beads, now), + beads, + }) +} + +/// Each root's tree in one project's answer, in id order, or why it has +/// none. +fn trees( + beads: &[Arc], + project: &Project, + cfg: &Config, + panes: &[Pane], +) -> Vec<(String, Result)> { // Every bead this read of the tracker turned up, and the bead each one // hangs under. A parent chain that leaves it has run off the end of what // `bdi` read, and there is no tree to draw from where it went — so the @@ -248,12 +278,12 @@ fn read_project( // The edges are read before discovery rather than after it, because a // bead with no parent is a root only where nothing nests it, and nothing // outside the edges can say whether anything does. - let nesting = Nesting::of(&beads); + let nesting = Nesting::of(beads); let mut roots = roots_named(cfg, project); let mut ancestors: BTreeMap = BTreeMap::new(); let mut climbed: BTreeSet = BTreeSet::new(); - for bead in unfinished(&beads) { + for bead in unfinished(beads) { climbed.extend(root_of(bead, &parents, &mut ancestors)); } for named in beads_named_here(panes, project, cfg) { @@ -285,14 +315,7 @@ fn read_project( .collect(); read.extend(what_no_root_reached(&nesting, &read)); read.sort_by(|(one, _), (two, _)| one.cmp(two)); - - Ok(ProjectWork { - readiness, - relations: edges::relations(&beads), - roots: read, - speaks_until: speaks_until(&beads, now), - beads, - }) + read } /// The beads the discovered roots left off the screen, each drawn from the @@ -669,7 +692,7 @@ dunwich = ["dun-4"] let tracker = dunwich_tracker().also(beads(MAST_TREE)).also(beads(lost)); let before = nestings_on_this_thread(); - let work = read_project(&tracker, &cfg.projects[0], &cfg, &[], now()) + let work = read_project(&tracker, &cfg.projects[0], &cfg, &[], true, now()) .expect("the tracker answers every call"); let roots: Vec<&str> = work.roots.iter().map(|(root, _)| root.as_str()).collect(); @@ -987,6 +1010,7 @@ dunwich = ["dun-4"] &before.projects[0], &before, &[], + true, None, true, 0, @@ -1002,6 +1026,7 @@ dunwich = ["dun-4"] &after.projects[0], &after, &[], + true, at.as_deref(), true, 0, @@ -1037,7 +1062,7 @@ dunwich = ["dun-c3"] .expect("the config parses"); let tracker = dunwich_holding(CHAIN_OF_PARENTLESS); - let work = read_project(&tracker, &cfg.projects[0], &cfg, &[], now()) + let work = read_project(&tracker, &cfg.projects[0], &cfg, &[], true, now()) .expect("the tracker answers every call"); let roots: Vec<&str> = work.roots.iter().map(|(root, _)| root.as_str()).collect(); @@ -1384,7 +1409,7 @@ dunwich = ["dun-c3"] let cfg = one_project(); let refreshed = - refresh_project(&trackers, &cfg.projects[0], &cfg, &[], None, true, 0, now()) + refresh_project(&trackers, &cfg.projects[0], &cfg, &[], true, None, true, 0, now()) .expect("the beads are read"); let Refresh::Read { at, journal, .. } = refreshed else { From 556bbe6a3727993393693e865421a8983414205f Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:27:50 +0100 Subject: [PATCH 3/5] perf(app): keep the trees reaching across projects until a read changes A collection that read nothing new assembled every tree waiting on another project's bead again, reading the edges of every project's answer to do it. It now keeps those trees until a read they were assembled from is replaced. --- src/app/collection.rs | 234 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 190 insertions(+), 44 deletions(-) diff --git a/src/app/collection.rs b/src/app/collection.rs index 22a1f4e..c99bc6e 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -11,6 +11,7 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Deref; use std::sync::Arc; use chrono::{DateTime, TimeDelta, Utc}; @@ -173,6 +174,28 @@ pub struct Collection { /// in the journal it has read to, and what the journal said since the /// answers were last handed over. journals: BTreeMap, + /// How many reads `read` has taken in, so trees assembled from the + /// standing set can tell when one of them has been replaced. + reads_taken: u64, + reached: Reached, +} + +/// The trees that reach into another project's answer, kept from one +/// collection to the next until what they were assembled from changes. +#[derive(Default)] +struct Reached { + from: Option, + /// Each tree by its project and then its root. + trees: BTreeMap>, +} + +/// What a set of trees reaching across projects was assembled from. +#[derive(PartialEq)] +struct ReachedFrom { + reads_taken: u64, + /// Every project that answered, and whether it draws trees of its own. + answered: Vec<(String, bool)>, + not_read: Vec<(String, Option)>, } /// One project's events journal as a collection has read it. @@ -257,6 +280,7 @@ impl Collection { now, ); loop { + self.reach_across(cfg); match self.draw(cfg, &panes, &out_of_reach, &provider, filter, now) { Ok(snapshot) => return snapshot, Err(needed) => { @@ -383,6 +407,7 @@ impl Collection { .or_default() .read(journal); } + self.reads_taken += 1; self.read.insert( project.name.clone(), Read { @@ -397,6 +422,7 @@ impl Collection { // failure sticky: the next probe would match it, the // cascade that would have recovered is skipped, and the // project keeps whatever partial state the failure left. + self.reads_taken += 1; self.read.insert( project.name.clone(), Read { @@ -469,6 +495,33 @@ impl Collection { }) } + /// Assemble again the trees reaching into another project's answer, + /// where what they were assembled from has changed since. + fn reach_across(&mut self, rooted: &Config) { + let cfg = &self.widened(rooted); + let answered: Vec<(&str, &ProjectWork)> = self.that_answered(cfg).collect(); + let not_read = not_read(cfg, &answered); + let from = ReachedFrom { + reads_taken: self.reads_taken, + answered: answered + .iter() + .map(|(project, _)| (project.to_string(), rooted.reads(project))) + .collect(), + not_read: not_read + .iter() + .map(|(project, prefix)| (project.to_string(), prefix.map(str::to_string))) + .collect(), + }; + if self.reached.from.as_ref() == Some(&from) { + return; + } + let trees = assembled_across(&answered, ¬_read, &rooted.scope); + self.reached = Reached { + from: Some(from), + trees, + }; + } + /// Everything standing, in config order, however much of it this /// collection just read. Where a project `cfg`'s scope would take in holds /// a drawn bead's blocker, it hands back those projects instead. @@ -487,13 +540,7 @@ impl Collection { let rooted = cfg; let cfg = &self.widened(rooted); let answered: Vec<(&str, &ProjectWork)> = self.that_answered(cfg).collect(); - let not_read: Vec<(&str, Option<&str>)> = cfg - .projects - .iter() - .map(|project| (project.name.as_str(), project.prefix.as_deref())) - .filter(|(project, _)| !answered.iter().any(|(answering, _)| answering == project)) - .collect(); - let drawn = reaching_across(&answered, ¬_read, &rooted.scope); + let drawn = reaching_across(&answered, &rooted.scope, &self.reached.trees); let needed = held_by_unread(&drawn, &cfg.scope); if !needed.is_empty() { return Err(needed); @@ -508,7 +555,7 @@ impl Collection { let reached: Vec<(&str, Vec>)> = drawn .iter() .filter_map(|(project, _, read)| match read { - Ok(Cow::Owned(assembled)) => Some(by_project(project, assembled)), + Ok(Assembly::Across(assembled)) => Some(by_project(project, assembled)), _ => None, }) .flatten() @@ -516,7 +563,7 @@ impl Collection { let rows: Vec> = drawn .iter() .filter_map(|(project, _, read)| match read { - Ok(Cow::Borrowed(assembled)) => Some(ProjectRows { + Ok(Assembly::Own(assembled)) => Some(ProjectRows { project, rows: &assembled.beads, }), @@ -698,10 +745,51 @@ impl Collection { /// One root's tree as a collection draws it: its project, its root, and the /// tree, or why there is none. -type Drawn<'a> = (&'a str, &'a str, Result, &'a RootUnread>); +type Drawn<'a> = (&'a str, &'a str, Result, &'a RootUnread>); + +/// A tree assembled from its own project's answer, or across every answer. +#[derive(Clone, Copy)] +enum Assembly<'a> { + Own(&'a Assembled), + Across(&'a Assembled), +} + +impl Deref for Assembly<'_> { + type Target = Assembled; + + fn deref(&self) -> &Assembled { + match self { + Assembly::Own(assembled) | Assembly::Across(assembled) => assembled, + } + } +} -/// Every root's tree in a project `rooted` reads, reaching into another -/// project's answer where a bead in it waits on a bead that project holds. +/// Every root's tree in a project `rooted` reads, taking the one `reached` +/// assembled across every answer where there is one. +fn reaching_across<'a>( + answered: &[(&'a str, &'a ProjectWork)], + rooted: &Scope, + reached: &'a BTreeMap>, +) -> Vec> { + answered + .iter() + .filter(|(project, _)| rooted.reads(project)) + .flat_map(|&(project, work)| { + work.roots.iter().map(move |(root, read)| { + let drawn = read.as_ref().map(|assembled| { + reached + .get(project) + .and_then(|trees| trees.get(root)) + .map_or(Assembly::Own(assembled), Assembly::Across) + }); + (project, root.as_str(), drawn) + }) + }) + .collect() +} + +/// The trees in a project `rooted` reads that reach into another project's +/// answer, each by its project and its root. /// /// A project's read assembled its trees from its own answer alone, and a /// bead waiting on work that answer does not hold is one each tree already @@ -709,41 +797,56 @@ type Drawn<'a> = (&'a str, &'a str, Result, &'a RootUnread>); /// every answer, and a run with none reads nothing twice. `not_read` is the /// configured projects that gave no answer, each with the prefix its config /// states. -fn reaching_across<'a>( - answered: &[(&'a str, &'a ProjectWork)], - not_read: &[(&'a str, Option<&'a str>)], +fn assembled_across( + answered: &[(&str, &ProjectWork)], + not_read: &[(&str, Option<&str>)], rooted: &Scope, -) -> Vec> { - let roots = || answered.iter().filter(|(project, _)| rooted.reads(project)); - let waits_elsewhere = |read: &Result| { - read.as_ref() - .is_ok_and(|assembled| !assembled.orphaned_dependencies.is_empty()) - }; - let across = roots() - .any(|(_, work)| work.roots.iter().any(|(_, read)| waits_elsewhere(read))) - .then(|| { - Across::of( - answered - .iter() - .map(|(project, work)| (*project, Nesting::of(&work.beads))), - not_read.iter().copied(), - ) - }); - - roots() +) -> BTreeMap> { + let waiting: Vec<(&str, &str)> = answered + .iter() + .filter(|(project, _)| rooted.reads(project)) .flat_map(|&(project, work)| { - let across = across.as_ref(); - work.roots.iter().map(move |(root, read)| { - let drawn = match read { - Ok(assembled) => Ok(across - .filter(|_| waits_elsewhere(read)) - .and_then(|across| across.assemble(project, root).ok()) - .map_or(Cow::Borrowed(assembled), Cow::Owned)), - Err(why) => Err(why), - }; - (project, root.as_str(), drawn) - }) + work.roots + .iter() + .filter(|(_, read)| { + read.as_ref() + .is_ok_and(|assembled| !assembled.orphaned_dependencies.is_empty()) + }) + .map(move |(root, _)| (project, root.as_str())) }) + .collect(); + if waiting.is_empty() { + return BTreeMap::new(); + } + + let across = Across::of( + answered + .iter() + .map(|(project, work)| (*project, Nesting::of(&work.beads))), + not_read.iter().copied(), + ); + let mut trees: BTreeMap> = BTreeMap::new(); + for (project, root) in waiting { + if let Ok(assembled) = across.assemble(project, root) { + trees + .entry(project.to_string()) + .or_default() + .insert(root.to_string(), assembled); + } + } + trees +} + +/// The projects `cfg` configures that gave no answer, each with the prefix +/// its config states. +fn not_read<'a>( + cfg: &'a Config, + answered: &[(&str, &ProjectWork)], +) -> Vec<(&'a str, Option<&'a str>)> { + cfg.projects + .iter() + .map(|project| (project.name.as_str(), project.prefix.as_deref())) + .filter(|(project, _)| !answered.iter().any(|(answering, _)| answering == project)) .collect() } @@ -2310,6 +2413,49 @@ path = "{}" assert_eq!(nested_to_draw(&trackers), 2); } + /// A collection that reads nothing new draws the trees reaching across + /// projects as the last one assembled them. + #[test] + fn a_collection_reading_nothing_new_reads_no_answer_again_to_reach_across() { + let trackers = ferry_waiting_on_dunwich(); + let mut standing = Collection::default(); + collect(&mut standing, &no_panes(), &trackers, &Wanted::Everything); + + let before = crate::model::tree::nestings_on_this_thread(); + collect(&mut standing, &no_panes(), &trackers, &Wanted::Everything); + + assert_eq!(crate::model::tree::nestings_on_this_thread() - before, 0); + } + + /// Dunwich's epic under a new title. + const RETITLED_IN_DUNWICH: &str = r#"[ + {"id":"dun-7","title":"lift the relay station","status":"in_progress", + "priority":1,"issue_type":"epic"} + ]"#; + + /// A tree reaching across projects is assembled again once an answer it + /// reaches into has moved. + #[test] + fn a_tree_reaching_across_draws_the_blocker_as_its_project_last_said_it() { + let mut standing = Collection::default(); + collect( + &mut standing, + &no_panes(), + &ferry_waiting_on_dunwich(), + &Wanted::Everything, + ); + + let retitled = Fakes::default() + .with("dunwich", Fake::holding(beads(RETITLED_IN_DUNWICH)).moved()) + .with("ferry", Fake::holding(beads(WAITING_ON_DUNWICH))); + let snap = collect(&mut standing, &no_panes(), &retitled, &dunwich_alone()); + + assert_eq!( + node(tree_of(&snap, "ferry"), "dun-7").title, + "lift the relay station" + ); + } + /// The refresh gate reaches the trackers and stops there. A provider /// reports on the sessions on the machine rather than on a project, so a /// collection asks it which sessions there are once and each session for From 4922467a45704d794dd87f14080e961e69e34b1c Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:28:18 +0100 Subject: [PATCH 4/5] perf(view): keep a tree's facts while a collection leaves it unchanged A collection that moves nothing used to work out every tree's facts again. The forest now carries a tree's facts over when the tree arrives equal to the one it answered before. --- src/view/forest/facts.rs | 31 ++++++++++++++++++++---- src/view/forest/mod.rs | 28 ++++++++++++++++------ src/view/forest/tests/progress.rs | 39 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/view/forest/facts.rs b/src/view/forest/facts.rs index 42575b7..2e7b62d 100644 --- a/src/view/forest/facts.rs +++ b/src/view/forest/facts.rs @@ -9,6 +9,7 @@ //! get what the last one got. use std::collections::BTreeMap; +use std::sync::Arc; use crate::model::join::BeadKey; use crate::model::snapshot::{Counts, Snapshot, Tree}; @@ -27,7 +28,7 @@ use super::spine::{Chosen, Spine, Stand}; /// the group holding it. #[derive(Debug, PartialEq, Eq)] pub(super) struct Facts { - trees: BTreeMap, + trees: BTreeMap>, projects: BTreeMap, begun: BTreeMap, chosen: Vec, @@ -36,12 +37,31 @@ pub(super) struct Facts { impl Facts { /// `spine` is the rule in force over the forest, and `spines` the rule /// the reader has put in force under each line they set one on. - pub(super) fn of(snapshot: &Snapshot, spine: Spine, spines: &BTreeMap) -> Self { + /// `earlier` is a snapshot answered before and its answers, which a tree + /// it held unchanged keeps. + pub(super) fn of( + snapshot: &Snapshot, + spine: Spine, + spines: &BTreeMap, + earlier: Option<(&Snapshot, &Facts)>, + ) -> Self { + let mut answered = BTreeMap::new(); + if let Some((snapshot, facts)) = earlier { + for tree in snapshot.trees.iter().chain(&snapshot.collected) { + let key = root_key(tree); + if let Some(facts) = facts.trees.get(&key) { + answered.entry(key).or_insert((tree, facts)); + } + } + } let mut trees = BTreeMap::new(); for tree in snapshot.trees.iter().chain(&snapshot.collected) { - trees - .entry(root_key(tree)) - .or_insert_with(|| TreeFacts::of(tree)); + let key = root_key(tree); + let earlier = answered.get(&key); + trees.entry(key).or_insert_with(|| match earlier { + Some((was, facts)) if *was == tree => Arc::clone(facts), + _ => Arc::new(TreeFacts::of(tree)), + }); } // The rule over the forest begins on every tree's root, and a rule // set on a line begins there instead of the one it stands under. @@ -94,6 +114,7 @@ impl Facts { self.trees .get(root) .expect("every tree the snapshot holds was answered when it was taken") + .as_ref() } /// Every bead in the project's trees, shown or hidden, counted once. diff --git a/src/view/forest/mod.rs b/src/view/forest/mod.rs index eedd834..50658d0 100644 --- a/src/view/forest/mod.rs +++ b/src/view/forest/mod.rs @@ -121,7 +121,7 @@ pub fn flatten(snapshot: Snapshot) -> Forest { let spine = Spine::default(); let spines = BTreeMap::new(); let mut forest = Forest { - facts: Arc::new(Facts::of(&snapshot, spine, &spines)), + facts: Arc::new(Facts::of(&snapshot, spine, &spines, None)), snapshot, folds: Folds::default(), spine, @@ -798,23 +798,37 @@ impl Forest { /// Take a snapshot as the one drawn. fn take(&mut self, snapshot: Snapshot) { - self.snapshot = snapshot; - self.answer(); + let earlier = std::mem::replace(&mut self.snapshot, snapshot); + self.facts = Arc::new(Facts::of( + &self.snapshot, + self.spine, + &self.spines_in_force(), + Some((&earlier, &self.facts)), + )); } /// Answer what layout reads of the snapshot in hand, here and not per /// keystroke. - /// - /// Each bead the forest is rooted at is where the rule in force over it - /// begins, as a rule set on its line would. fn answer(&mut self) { + self.facts = Arc::new(Facts::of( + &self.snapshot, + self.spine, + &self.spines_in_force(), + Some((&self.snapshot, &self.facts)), + )); + } + + /// The rules the reader set, and on each bead the forest is rooted at + /// the rule in force over it, which begins there as a rule set on its + /// line would. + fn spines_in_force(&self) -> BTreeMap { let mut spines = self.spines.clone(); for place in &self.focused { spines .entry(Handle::Bead(place.clone())) .or_insert_with(|| self.spine_on(place)); } - self.facts = Arc::new(Facts::of(&self.snapshot, self.spine, &spines)); + spines } /// `e` and `c`: point every fold in the selected node's subtree, at every diff --git a/src/view/forest/tests/progress.rs b/src/view/forest/tests/progress.rs index cdae5b0..a346623 100644 --- a/src/view/forest/tests/progress.rs +++ b/src/view/forest/tests/progress.rs @@ -154,6 +154,45 @@ fn a_keystroke_walks_no_subtree() { assert_eq!(walks_on_this_thread() - before, 0); } +/// A collection lands every time the watcher speaks, and most of them find +/// every tree as it was. A tree that did not move keeps what was answered +/// of it, so such a collection asks nothing of any tree. +#[test] +fn a_collection_that_moved_no_tree_walks_no_subtree() { + let mut forest = flatten(built(Filter::All)); + let before = walks_on_this_thread(); + + forest.refresh(built(Filter::All)); + + assert_eq!(walks_on_this_thread() - before, 0); +} + +/// A tree that did move is answered again: a child closing under +/// `dun-7.1` is a bead more of its subtree done. +#[test] +fn a_collection_that_moved_a_tree_answers_it_again() { + let mut forest = flatten(snapshot()); + + let finished = edited( + DUNWICH, + r#""id":"dun-7.1.1","title":"true the mount","status":"open""#, + r#""id":"dun-7.1.1","title":"true the mount","status":"closed""#, + ); + forest.refresh(gather( + vec![tree_of("dunwich", &finished)], + Vec::new(), + Filter::LiveAgents, + )); + + assert_eq!( + row_of(&forest, "dun-7.1").progress, + Some(Progress { + finished: 1, + total: 3 + }) + ); +} + /// A subtree the folds name nothing at or beneath is counted from its /// tree rather than drawn, and drawn from it only where a reader reaches /// in. So a key that lays the forest out again costs the count and the From d593e45843096b535a10e9382b7cf108196c5329 Mon Sep 17 00:00:00 2001 From: Graeme Foster <80714+GraemeF@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:34:25 +0100 Subject: [PATCH 5/5] style: format the tests this branch added --- src/app/collection.rs | 4 +++- src/app/tracker.rs | 15 ++++++++++++--- src/collect/watched.rs | 25 ++++++++++++++++++++----- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/app/collection.rs b/src/app/collection.rs index 9cdc5ce..702725a 100644 --- a/src/app/collection.rs +++ b/src/app/collection.rs @@ -3016,7 +3016,9 @@ path = "{FERRY}" standing .read .iter() - .filter_map(|(project, read)| Some((project.as_str(), read.work.as_ref().ok()?.roots.len()))) + .filter_map(|(project, read)| { + Some((project.as_str(), read.work.as_ref().ok()?.roots.len())) + }) .collect() } diff --git a/src/app/tracker.rs b/src/app/tracker.rs index 161d74a..d8bc986 100644 --- a/src/app/tracker.rs +++ b/src/app/tracker.rs @@ -1408,9 +1408,18 @@ dunwich = ["dun-c3"] ); let cfg = one_project(); - let refreshed = - refresh_project(&trackers, &cfg.projects[0], &cfg, &[], true, None, true, 0, now()) - .expect("the beads are read"); + let refreshed = refresh_project( + &trackers, + &cfg.projects[0], + &cfg, + &[], + true, + None, + true, + 0, + now(), + ) + .expect("the beads are read"); let Refresh::Read { at, journal, .. } = refreshed else { panic!("a project nothing has read is read in full") diff --git a/src/collect/watched.rs b/src/collect/watched.rs index 018963f..fbbe266 100644 --- a/src/collect/watched.rs +++ b/src/collect/watched.rs @@ -1575,7 +1575,10 @@ path = "/srv/work/dunwich" let mut watcher = Watcher::default(); let first = taking( &mut watcher, - &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + &[ + bead("dunwich", "dun-1", true, &[]), + fresh("dunwich", json!("ok")), + ], ); let then = taking(&mut watcher, &[fresh("dunwich", json!("ok"))]); @@ -1588,13 +1591,22 @@ path = "/srv/work/dunwich" let mut watcher = Watcher::default(); let first = taking( &mut watcher, - &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + &[ + bead("dunwich", "dun-1", true, &[]), + fresh("dunwich", json!("ok")), + ], ); let changed = taking( &mut watcher, - &[bead("dunwich", "dun-1", false, &[]), fresh("dunwich", json!("ok"))], + &[ + bead("dunwich", "dun-1", false, &[]), + fresh("dunwich", json!("ok")), + ], + ); + let went = taking( + &mut watcher, + &[gone("dun-1"), fresh("dunwich", json!("ok"))], ); - let went = taking(&mut watcher, &[gone("dun-1"), fresh("dunwich", json!("ok"))]); assert_ne!(fingerprint(&changed), fingerprint(&first)); assert_ne!(fingerprint(&went), fingerprint(&changed)); @@ -1607,7 +1619,10 @@ path = "/srv/work/dunwich" let mut watcher = Watcher::default(); let before = taking( &mut watcher, - &[bead("dunwich", "dun-1", true, &[]), fresh("dunwich", json!("ok"))], + &[ + bead("dunwich", "dun-1", true, &[]), + fresh("dunwich", json!("ok")), + ], ); watcher.gone(true);