diff --git a/src/app/collection.rs b/src/app/collection.rs index fb11f09..702725a 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. @@ -252,10 +275,12 @@ impl Collection { &reading, trackers, &|project| wanted.names(project), + &|project| cfg.reads(project), &panes, now, ); loop { + self.reach_across(cfg); match self.draw(cfg, &panes, &out_of_reach, &provider, filter, now) { Ok(snapshot) => return snapshot, Err(needed) => { @@ -265,6 +290,7 @@ impl Collection { &reading, trackers, &|project| needed.contains(project), + &|project| cfg.reads(project), &panes, now, ); @@ -346,24 +372,27 @@ 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) => { + 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 { @@ -378,6 +407,7 @@ impl Collection { .or_default() .read(journal); } + self.reads_taken += 1; self.read.insert( project.name.clone(), Read { @@ -392,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 { @@ -419,6 +450,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 +468,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, @@ -461,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. @@ -479,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); @@ -500,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() @@ -508,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, }), @@ -688,10 +743,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; -/// 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. + fn deref(&self) -> &Assembled { + match self { + Assembly::Own(assembled) | Assembly::Across(assembled) => assembled, + } + } +} + +/// 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 @@ -699,41 +795,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() } @@ -1249,7 +1360,7 @@ mod tests { self.inner.fingerprint() } - fn all(&self) -> Result, RunFailure> { + fn all(&self) -> Result>, RunFailure> { self.inner.all() } @@ -1676,6 +1787,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 @@ -2289,6 +2432,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 @@ -2825,6 +3011,67 @@ 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 24fb925..d8bc986 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) } } @@ -124,8 +127,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 +136,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. @@ -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) { - return Ok(Refresh::Unchanged); + 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,13 +229,14 @@ fn read_project( project: &Project, cfg: &Config, panes: &[Pane], + draws_trees: bool, now: DateTime, ) -> Result { let (beads, (ready, blocked_by)) = together( || 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. @@ -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(); @@ -1383,9 +1408,18 @@ dunwich = ["dun-c3"] ); let cfg = one_project(); - let refreshed = - refresh_project(&trackers, &cfg.projects[0], &cfg, &[], 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") @@ -1895,7 +1929,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 ead012a..f24acca 100644 --- a/src/collect/bd.rs +++ b/src/collect/bd.rs @@ -9,7 +9,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use anyhow::Context; use serde::Deserialize; @@ -723,12 +723,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 b4ade30..fbbe266 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).ok()?; + let bead = Arc::new(bead_of(row).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() } @@ -1532,4 +1546,88 @@ 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)); + } } 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 ac64049..56c12e7 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, @@ -799,23 +799,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