Skip to main content

layover_core/
barrier.rs

1//! Rendezvous joins.
2//!
3//! A joined agent does not wake until its declared upstreams have arrived. The Tower parks
4//! flights rather than parking processes, which is what makes fan-in possible without a blocking
5//! request/response mode.
6//!
7//! Three behaviours here are subtle:
8//!
9//! - **Scope.** A barrier constrains the upstreams it names and nobody else. The same agent may
10//!   also sit behind ordinary unjoined edges — including a human entry point — and a flight
11//!   arriving on one of those wakes it immediately without touching the barrier. A join declares
12//!   *which inputs an agent needs together*, not *when an agent is allowed to run*.
13//! - **Reset.** A second delivery from the same upstream discards partial state, so a stale
14//!   result from a sibling branch cannot satisfy the barrier alongside a fresh one.
15//! - **Abandonment.** A barrier whose missing upstreams can no longer be reached by any live run
16//!   is dead, and must be abandoned rather than parked forever.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use crate::agent::AgentName;
21use crate::flight::{Flight, ItineraryId};
22use crate::graph::JoinSpec;
23use crate::graph::RouteGraph;
24use crate::route::Join;
25
26/// Identifies a barrier within the Tower.
27#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct BarrierKey {
29    /// The chain the barrier belongs to.
30    pub itinerary: ItineraryId,
31    /// The agent being guarded.
32    pub to: AgentName,
33}
34
35impl BarrierKey {
36    /// Builds a key.
37    #[must_use]
38    pub fn new(itinerary: ItineraryId, to: AgentName) -> Self {
39        Self { itinerary, to }
40    }
41}
42
43/// The outcome of delivering a flight to a joined agent.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Delivery {
46    /// The flight was parked; the agent stays asleep.
47    Parked {
48        /// Upstreams still outstanding.
49        waiting_for: Vec<AgentName>,
50    },
51    /// The condition is met. The agent should be spawned exactly once with these flights.
52    Ready(Vec<Flight>),
53    /// The sender is not a declared upstream, so the barrier does not apply.
54    ///
55    /// The flight bypasses the barrier and wakes the agent on its own. It is not an error: the
56    /// route check has already established that the edge exists, and a barrier only speaks for
57    /// the upstreams it names. This is what lets a joined agent also be an entry point.
58    Direct(Box<Flight>),
59    /// The barrier already released for this dispatch wave, and this upstream arrived after.
60    ///
61    /// Only `join = "any"` produces this: it releases on the first arrival, so every other
62    /// upstream in the same wave is necessarily late. Dropping the flight is the whole point of
63    /// `any` — the alternative is waking the agent once per upstream, which for a publisher means
64    /// one pull request per straggler.
65    ///
66    /// Returned rather than silently discarded so the Tower can record that work was superseded.
67    Late(Box<Flight>),
68}
69
70/// A rendezvous barrier holding flights until its condition is met.
71#[derive(Debug, Clone)]
72pub struct Barrier {
73    required: BTreeSet<AgentName>,
74    join: Join,
75    parked: BTreeMap<AgentName, Flight>,
76    /// Upstreams that have arrived in the current dispatch wave, parked or already consumed.
77    ///
78    /// Distinct from `parked`, which is emptied when the barrier releases. Without this an `any`
79    /// barrier forgets it ever fired.
80    seen: BTreeSet<AgentName>,
81    /// Whether this wave has already woken the agent.
82    released: bool,
83}
84
85impl Barrier {
86    /// Creates a barrier from a route's rendezvous condition.
87    #[must_use]
88    pub fn from_spec(spec: &JoinSpec) -> Self {
89        Self {
90            required: spec.upstreams.clone(),
91            join: spec.join,
92            parked: BTreeMap::new(),
93            seen: BTreeSet::new(),
94            released: false,
95        }
96    }
97
98    /// Delivers a flight to the barrier.
99    ///
100    /// A flight from a sender the barrier does not name is returned as [`Delivery::Direct`] and
101    /// leaves parked state untouched, so an agent behind a join can still be triggered by a human
102    /// or by an unjoined peer.
103    ///
104    /// A second delivery from an upstream that has already reported starts a **new dispatch
105    /// wave**: partial state is discarded and every upstream must deliver again. This is what
106    /// stops a stale verdict from before a failure loop-back being combined with a fresh one, and
107    /// it is also what lets a loop re-run: the barrier is reusable, but only deliberately.
108    ///
109    /// Within one wave the agent is woken at most once. An upstream arriving after an `any`
110    /// barrier has fired is [`Delivery::Late`].
111    pub fn deliver(&mut self, flight: Flight) -> Delivery {
112        let Some(sender) = flight.from.agent().cloned() else {
113            return Delivery::Direct(Box::new(flight));
114        };
115
116        if !self.required.contains(&sender) {
117            return Delivery::Direct(Box::new(flight));
118        }
119
120        // An upstream reporting twice is the signal that a new wave has begun — under `all`
121        // because the fan-out was re-dispatched, and under `any` because the loop came round.
122        if self.seen.contains(&sender) {
123            self.parked.clear();
124            self.seen.clear();
125            self.released = false;
126        }
127        self.seen.insert(sender.clone());
128
129        if self.released {
130            return Delivery::Late(Box::new(flight));
131        }
132
133        self.parked.insert(sender, flight);
134
135        let complete = match self.join {
136            Join::Any => true,
137            Join::All => self.parked.len() == self.required.len(),
138        };
139
140        if complete {
141            self.released = true;
142            Delivery::Ready(std::mem::take(&mut self.parked).into_values().collect())
143        } else {
144            Delivery::Parked {
145                waiting_for: self.waiting_for(),
146            }
147        }
148    }
149
150    /// Returns `true` when this wave has already woken the agent.
151    #[must_use]
152    pub fn has_released(&self) -> bool {
153        self.released
154    }
155
156    /// Upstreams that have not yet delivered.
157    #[must_use]
158    pub fn waiting_for(&self) -> Vec<AgentName> {
159        self.required
160            .iter()
161            .filter(|name| !self.parked.contains_key(*name))
162            .cloned()
163            .collect()
164    }
165
166    /// Returns `true` if any live run could still satisfy this barrier.
167    ///
168    /// When this is `false` the barrier is dead: no process remains that could deliver the
169    /// missing upstreams, so the itinerary should be marked stalled rather than left parked.
170    #[must_use]
171    pub fn is_reachable(&self, graph: &RouteGraph, live: &BTreeSet<AgentName>) -> bool {
172        let missing = self.waiting_for();
173        if missing.is_empty() {
174            return true;
175        }
176
177        let reachable = graph.reachable_from(live);
178        match self.join {
179            Join::All => missing.iter().all(|name| reachable.contains(name)),
180            Join::Any => missing.iter().any(|name| reachable.contains(name)),
181        }
182    }
183
184    /// Number of upstreams currently parked.
185    #[must_use]
186    pub fn parked_count(&self) -> usize {
187        self.parked.len()
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::config::Config;
195    use crate::flight::Origin;
196
197    #[test]
198    fn an_any_barrier_wakes_the_agent_once_however_many_upstreams_arrive() {
199        // `Delivery::Ready` promises the agent is spawned exactly once. Before this, an `any`
200        // barrier released on the first arrival, cleared its parked state, and then released
201        // again on the second -- so a two-upstream `any` into a publisher opened two pull
202        // requests, with Hops, Fuel and the run cap all satisfied because both were ordinary
203        // first runs. Manual recovery could not help: neither was a recovery.
204        let mut barrier = barrier_any();
205
206        assert!(matches!(
207            barrier.deliver(flight_from("probe_a", "first")),
208            Delivery::Ready(_)
209        ));
210        assert!(barrier.has_released());
211
212        match barrier.deliver(flight_from("probe_b", "second")) {
213            Delivery::Late(flight) => assert_eq!(flight.body, "second"),
214            other => panic!("the straggler must not wake the agent again: {other:?}"),
215        }
216    }
217
218    #[test]
219    fn an_any_barrier_can_still_fire_again_on_the_next_time_round_the_loop() {
220        // Late must not mean dead. A repeat from an upstream that already reported is the signal
221        // that a new dispatch wave has begun, which is what makes a barrier inside a loop usable.
222        let mut barrier = barrier_any();
223        barrier.deliver(flight_from("probe_a", "first"));
224        barrier.deliver(flight_from("probe_b", "late"));
225
226        match barrier.deliver(flight_from("probe_a", "next time round")) {
227            Delivery::Ready(flights) => {
228                assert_eq!(flights.len(), 1);
229                assert_eq!(flights[0].body, "next time round");
230            }
231            other => panic!("a new wave should release: {other:?}"),
232        }
233    }
234
235    #[test]
236    fn an_all_barrier_also_wakes_the_agent_only_once_per_wave() {
237        let mut barrier = barrier_all();
238        barrier.deliver(flight_from("probe_a", "a"));
239
240        assert!(matches!(
241            barrier.deliver(flight_from("probe_b", "b")),
242            Delivery::Ready(_)
243        ));
244        assert!(barrier.has_released());
245    }
246
247    fn flight_from(sender: &str, body: &str) -> Flight {
248        Flight::new(
249            ItineraryId::generate(),
250            Origin::Agent(sender.into()),
251            "collector".into(),
252            body,
253            5,
254        )
255    }
256
257    fn barrier_any() -> Barrier {
258        Barrier {
259            required: ["probe_a".into(), "probe_b".into()].into_iter().collect(),
260            join: Join::Any,
261            parked: BTreeMap::new(),
262            seen: BTreeSet::new(),
263            released: false,
264        }
265    }
266
267    fn barrier_all() -> Barrier {
268        Barrier {
269            required: ["probe_a".into(), "probe_b".into()].into_iter().collect(),
270            join: Join::All,
271            parked: BTreeMap::new(),
272            seen: BTreeSet::new(),
273            released: false,
274        }
275    }
276
277    #[test]
278    fn parks_until_every_upstream_arrives() {
279        let mut barrier = barrier_all();
280
281        let first = barrier.deliver(flight_from("probe_a", "a"));
282
283        assert_eq!(
284            first,
285            Delivery::Parked {
286                waiting_for: vec!["probe_b".into()]
287            }
288        );
289    }
290
291    #[test]
292    fn releases_once_with_every_parked_flight() {
293        let mut barrier = barrier_all();
294        let _ = barrier.deliver(flight_from("probe_a", "a"));
295
296        let Delivery::Ready(flights) = barrier.deliver(flight_from("probe_b", "b")) else {
297            panic!("barrier should release once both upstreams arrive");
298        };
299
300        assert_eq!(flights.len(), 2);
301        assert_eq!(barrier.parked_count(), 0, "barrier drains on release");
302    }
303
304    #[test]
305    fn join_any_releases_on_the_first_arrival() {
306        let mut barrier = Barrier {
307            required: ["probe_a".into(), "probe_b".into()].into_iter().collect(),
308            join: Join::Any,
309            parked: BTreeMap::new(),
310            seen: BTreeSet::new(),
311            released: false,
312        };
313
314        let Delivery::Ready(flights) = barrier.deliver(flight_from("probe_a", "a")) else {
315            panic!("join = any should release immediately");
316        };
317
318        assert_eq!(flights.len(), 1);
319    }
320
321    #[test]
322    fn a_repeat_delivery_discards_stale_siblings() {
323        let mut barrier = barrier_all();
324        let _ = barrier.deliver(flight_from("probe_b", "stale verdict"));
325        let _ = barrier.deliver(flight_from("probe_a", "first attempt"));
326        // Both have now delivered, so the barrier already released; re-park to model a re-run.
327        let _ = barrier.deliver(flight_from("probe_a", "first attempt"));
328
329        let outcome = barrier.deliver(flight_from("probe_a", "second attempt"));
330
331        assert_eq!(
332            outcome,
333            Delivery::Parked {
334                waiting_for: vec!["probe_b".into()]
335            },
336            "a repeat delivery must discard partial state, not complete the barrier"
337        );
338    }
339
340    #[test]
341    fn a_sender_outside_the_join_is_delivered_directly() {
342        let mut barrier = barrier_all();
343
344        let outcome = barrier.deliver(flight_from("stranger", "hello"));
345
346        assert!(matches!(outcome, Delivery::Direct(_)));
347        assert_eq!(barrier.parked_count(), 0);
348    }
349
350    #[test]
351    fn a_human_flight_is_delivered_directly() {
352        let mut barrier = barrier_all();
353        let flight = Flight::new(
354            ItineraryId::generate(),
355            Origin::Human,
356            "collector".into(),
357            "go",
358            5,
359        );
360
361        assert!(matches!(barrier.deliver(flight), Delivery::Direct(_)));
362    }
363
364    #[test]
365    fn a_direct_flight_leaves_parked_state_intact() {
366        // A joined agent may also be an entry point. Waking it by the front door must not discard
367        // the half-collected rendezvous, or the upstream that already reported would be lost.
368        let mut barrier = barrier_all();
369        let _ = barrier.deliver(flight_from("probe_a", "a"));
370
371        let _ = barrier.deliver(flight_from("stranger", "unrelated work"));
372
373        assert_eq!(barrier.parked_count(), 1);
374        assert_eq!(barrier.waiting_for(), vec![AgentName::from("probe_b")]);
375
376        let Delivery::Ready(flights) = barrier.deliver(flight_from("probe_b", "b")) else {
377            panic!("the barrier should still complete normally");
378        };
379        assert_eq!(flights.len(), 2);
380    }
381
382    fn pipeline_graph() -> RouteGraph {
383        let config = Config::from_toml(
384            r#"
385            [[routes]]
386            from = "planner"
387            to = ["probe_a", "probe_b"]
388
389            [[routes]]
390            from = ["probe_a", "probe_b"]
391            to = "collector"
392            join = "all"
393
394            [[routes]]
395            from = "probe_a"
396            to = "planner"
397            "#,
398            "test.toml",
399        )
400        .expect("config parses");
401        RouteGraph::from_config(&config)
402    }
403
404    #[test]
405    fn a_live_upstream_keeps_the_barrier_alive() {
406        let mut barrier = barrier_all();
407        let _ = barrier.deliver(flight_from("probe_a", "a"));
408        let live = ["probe_b".into()].into_iter().collect();
409
410        assert!(barrier.is_reachable(&pipeline_graph(), &live));
411    }
412
413    #[test]
414    fn an_upstream_reachable_from_a_live_run_keeps_it_alive() {
415        let mut barrier = barrier_all();
416        let _ = barrier.deliver(flight_from("probe_a", "a"));
417        // planner is live and can still reach probe_b.
418        let live = ["planner".into()].into_iter().collect();
419
420        assert!(barrier.is_reachable(&pipeline_graph(), &live));
421    }
422
423    #[test]
424    fn a_barrier_no_live_run_can_reach_is_abandoned() {
425        // The loop-back hazard: probe_a diverted back to the planner instead of the collector,
426        // and nothing left running can deliver probe_b.
427        let mut barrier = barrier_all();
428        let _ = barrier.deliver(flight_from("probe_a", "a"));
429        let live = ["collector".into()].into_iter().collect();
430
431        assert!(
432            !barrier.is_reachable(&pipeline_graph(), &live),
433            "a barrier that nothing can satisfy must be abandoned, not parked forever"
434        );
435    }
436
437    #[test]
438    fn a_barrier_with_nothing_live_is_abandoned() {
439        let mut barrier = barrier_all();
440        let _ = barrier.deliver(flight_from("probe_a", "a"));
441
442        assert!(!barrier.is_reachable(&pipeline_graph(), &BTreeSet::new()));
443    }
444}