Skip to main content

wire/
aggregate.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Watching several cities from one interface.
7//!
8//! One machine, one City, one Ledger. Two cities never
9//! share a history, because a shared history would need distributed
10//! ordering - and that trades a single writer's simple fact for a new class
11//! of failure. What the interface may do is *look* at several at once.
12//!
13//! The hard constraint is one sentence, and it has a shape here rather
14//! than a rule somewhere: **the aggregation layer forwards Queries and Events and never
15//! forwards a Command.** Changing anything in another city means connecting
16//! to that city directly, authenticating with its token, and writing to its
17//! Ledger. An aggregator that could relay commands would be a cross-city
18//! authority appearing on nobody's books - the target city could not tie
19//! the command back to a person, and `PutSecret` refuses the trip anyway.
20//!
21//! That rule is not a check in this file. There is no method here that
22//! accepts a Command, so relaying one has no spelling.
23
24use std::collections::BTreeMap;
25
26use kernel::{AxCode, AxError, B3Hash, EventRecord, Seq, TimeMs};
27
28use crate::frames::Query;
29
30/// Names one upstream city as the interface labels it. Local to this
31/// viewer: the upstream does not know or care what we call it.
32#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct CityLabel(String);
34
35impl CityLabel {
36    /// Sole constructor.
37    ///
38    /// # Errors
39    /// Refuses empty labels and control characters; a label is shown in the
40    /// top bar and read by a person choosing which city to look at.
41    pub fn parse(raw: &str) -> Result<Self, AxError> {
42        if raw.is_empty() || raw.chars().any(char::is_control) {
43            return Err(AxError::failure(
44                AxCode::ConfigInvalid,
45                "label an upstream city",
46                "the label is empty or holds control characters",
47            )
48            .with_recovery("give the city a short single-line name"));
49        }
50        Ok(Self(raw.to_owned()))
51    }
52
53    #[must_use]
54    pub fn as_str(&self) -> &str {
55        &self.0
56    }
57}
58
59/// One upstream connection's settings. No plaintext token: same discipline
60/// as `ServeConfig`, for the same reason.
61#[derive(Debug, Clone)]
62pub struct Upstream {
63    pub label: CityLabel,
64    /// Where to reach it, as the operator typed it. Reachability is the
65    /// transport's problem, not this module's.
66    pub address: String,
67    /// `None` when the upstream binds loopback and needs no pairing.
68    pub token_digest: Option<B3Hash>,
69}
70
71/// One event as the merged view carries it: which city it came from, and
72/// the event itself. The pair is what makes a merged stream readable - an
73/// event with no city is a fact with no address.
74#[derive(Debug, Clone, PartialEq)]
75pub struct Sighting {
76    pub city: CityLabel,
77    pub event: EventRecord,
78}
79
80/// The read-only multi-city view.
81///
82/// Deliberately not generic and deliberately without a transport: the merge
83/// order is the part that must be deterministic and testable, and it needs
84/// no socket to be either.
85#[derive(Debug, Default)]
86pub struct Aggregate {
87    upstreams: BTreeMap<CityLabel, Upstream>,
88}
89
90impl Aggregate {
91    #[must_use]
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Registers an upstream. Re-registering the same label replaces its
97    /// settings rather than adding a second entry, so a corrected address
98    /// does not silently leave the old one attached.
99    pub fn attach(&mut self, upstream: Upstream) {
100        self.upstreams.insert(upstream.label.clone(), upstream);
101    }
102
103    pub fn detach(&mut self, label: &CityLabel) -> bool {
104        self.upstreams.remove(label).is_some()
105    }
106
107    /// Every attached city, in label order. `BTreeMap` rather than a hash
108    /// map because iteration order reaching an interface must not depend on
109    /// hashing.
110    pub fn cities(&self) -> impl Iterator<Item = &Upstream> {
111        self.upstreams.values()
112    }
113
114    /// Forwards a read to one upstream.
115    ///
116    /// This is the only method that sends anything, and it accepts a
117    /// [`Query`]. There is no sibling that accepts a Command, which is how
118    /// "never forwards a Command" is held.
119    ///
120    /// # Errors
121    /// Refuses a label that was never attached, rather than guessing.
122    pub fn ask(&self, label: &CityLabel, query: Query) -> Result<Forwarded, AxError> {
123        let upstream = self.upstreams.get(label).ok_or_else(|| {
124            AxError::failure(
125                AxCode::ConfigInvalid,
126                "forward a query to another city",
127                format!("no city is attached under the label `{}`", label.as_str()),
128            )
129            .with_recovery("attach the city with its address and pairing code first")
130        })?;
131        Ok(Forwarded {
132            address: upstream.address.clone(),
133            query,
134        })
135    }
136
137    /// Interleaves per-city event streams into one readable sequence.
138    ///
139    /// Ordering is `(t, city, seq)`. Sequence numbers are meaningless across
140    /// cities - each Ledger numbers its own history - so time leads, and the
141    /// label breaks ties. The label is in the key rather than a tiebreak of
142    /// last resort because two cities can genuinely stamp the same
143    /// millisecond, and the merged view must still be the same twice.
144    #[must_use]
145    pub fn merge(streams: Vec<(CityLabel, Vec<EventRecord>)>) -> Vec<Sighting> {
146        let mut merged: Vec<Sighting> = streams
147            .into_iter()
148            .flat_map(|(city, events)| {
149                events.into_iter().map(move |event| Sighting {
150                    city: city.clone(),
151                    event,
152                })
153            })
154            .collect();
155        merged.sort_by_key(sort_key);
156        merged
157    }
158}
159
160fn sort_key(sighting: &Sighting) -> (TimeMs, CityLabel, Seq) {
161    (
162        sighting.event.t(),
163        sighting.city.clone(),
164        sighting.event.seq(),
165    )
166}
167
168/// What `ask` produced: an address and a read. It carries no Command field,
169/// so nothing downstream can promote it into one.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub struct Forwarded {
172    pub address: String,
173    pub query: Query,
174}
175
176#[cfg(test)]
177#[allow(
178    clippy::unwrap_used,
179    clippy::expect_used,
180    clippy::panic,
181    clippy::indexing_slicing,
182    clippy::arithmetic_side_effects,
183    reason = "test code"
184)]
185mod tests {
186    use super::*;
187
188    fn city(name: &str) -> CityLabel {
189        CityLabel::parse(name).unwrap()
190    }
191
192    fn upstream(name: &str) -> Upstream {
193        Upstream {
194            label: city(name),
195            address: format!("{name}.local:8787"),
196            token_digest: None,
197        }
198    }
199
200    #[test]
201    fn attaching_the_same_label_twice_replaces_rather_than_duplicates() {
202        let mut aggregate = Aggregate::new();
203        aggregate.attach(upstream("studio"));
204        let mut corrected = upstream("studio");
205        corrected.address = "192.168.1.9:8787".to_owned();
206        aggregate.attach(corrected);
207        assert_eq!(aggregate.cities().count(), 1);
208        let only = aggregate.cities().next().unwrap();
209        assert_eq!(only.address, "192.168.1.9:8787");
210    }
211
212    #[test]
213    fn asking_an_unattached_city_is_refused_not_guessed() {
214        let aggregate = Aggregate::new();
215        let err = aggregate
216            .ask(&city("nowhere"), Query::CityView)
217            .expect_err("an unknown label has no address to guess");
218        assert_eq!(*err.code(), AxCode::ConfigInvalid);
219        assert!(!err.recovery().is_empty());
220    }
221
222    fn event(at: u64, seq: u64) -> EventRecord {
223        EventRecord::from_draft(
224            kernel::EventDraft {
225                run: kernel::RunId::from_bytes([4u8; 16]),
226                t: TimeMs::new(at),
227                who: "test".to_owned(),
228                addr: None,
229                kind: kernel::EventKind::RunStarted,
230                data: kernel::Payload::empty(),
231                ig: false,
232            },
233            Seq::new(seq),
234            B3Hash::digest(b"prev"),
235        )
236    }
237
238    #[test]
239    fn merging_is_the_same_sequence_however_the_streams_arrive() {
240        // Sequence numbers mean nothing across cities - each Ledger numbers
241        // its own history - so time leads and the label breaks ties. Two
242        // cities stamping the same millisecond is ordinary, and the merged
243        // view must still be identical on a second pass.
244        let left = (city("attic"), vec![event(10, 1), event(30, 2)]);
245        let right = (city("studio"), vec![event(10, 7), event(20, 8)]);
246
247        let one = Aggregate::merge(vec![left.clone(), right.clone()]);
248        let other = Aggregate::merge(vec![right, left]);
249        assert_eq!(one, other, "merge order cannot depend on arrival order");
250
251        let trail: Vec<(&str, u64)> = one
252            .iter()
253            .map(|s| (s.city.as_str(), s.event.t().value()))
254            .collect();
255        assert_eq!(
256            trail,
257            [("attic", 10), ("studio", 10), ("studio", 20), ("attic", 30)]
258        );
259    }
260
261    #[test]
262    fn cities_iterate_in_label_order_regardless_of_attachment_order() {
263        let mut aggregate = Aggregate::new();
264        for name in ["studio", "attic", "shed"] {
265            aggregate.attach(upstream(name));
266        }
267        let order: Vec<&str> = aggregate.cities().map(|u| u.label.as_str()).collect();
268        assert_eq!(order, ["attic", "shed", "studio"]);
269    }
270}