OXYGENHUB

Coverage / crates/hub/src/live.rs 100.0%

89 of 89 lines run, 20 of 23 functions · run #545 on main · run missed

1
//! Live collaboration on the conflict resolver (hub spec §6.10): everyone
2
//! looking at the same conflict sees who else is there, the choices each
3
//! makes region by region (the last choice for a region wins), and the
4
//! resolution when one of them commits it. Rooms live in memory: they are
5
//! for people working together now, not a record.
6
//!
7
//! With several web processes (docs/roles.md), choices and landings travel
8
//! on the event bus ([`crate::bus`]) to every process's room for the same
9
//! conflict; who is present, and a newcomer's snapshot, are this process's
10
//! own (a room's viewers on other processes are not counted).
11
12
use std::collections::{BTreeMap, HashMap};
13
use std::sync::{Arc, Mutex};
14
use tokio::sync::broadcast;
15
16
/// One conflict being resolved: repository, bookmark, path.
17
#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18
pub struct RoomKey {
19
    pub repo_id: i64,
20
    pub bookmark: String,
21
    pub path: String,
22
}
23
24
/// A region's current choice: `side1`…, `all`, `none` or `text`, with the
25
/// text for `text`, and who made it.
26
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27
pub struct Choice {
28
    pub choice: String,
29
    pub text: String,
30
    pub by: String,
31
}
32
33
/// What a room tells its viewers.
34
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
35
#[serde(tag = "kind", rename_all = "lowercase")]
36
pub enum LiveEvent {
37
    /// Everything so far, sent to a viewer who joins.
38
    Snapshot { present: Vec<String>, choices: BTreeMap<String, Choice>, keep: Option<Choice> },
39
    /// Who is looking now.
40
    Presence { present: Vec<String> },
41
    /// A viewer chose for region `region` (`keep` for a candidate page).
42
    Choice { region: String, choice: Choice, client: String },
43
    /// A resolution was committed; `url` is its change.
44
    Landed { url: String, by: String },
45
}
46
47
/// What one viewer does that every viewer of the room hears, in this
48
/// process and (over the bus) in the others.
49
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
50
#[serde(tag = "kind", rename_all = "lowercase")]
51
pub enum RoomEvent {
52
    /// `client` chose for `region`.
53
    Choice { region: String, choice: Choice, client: String },
54
    /// The conflict was resolved; `url` is the change.
55
    Landed { url: String, by: String },
56
}
57
58
struct Room {
59
    tx: broadcast::Sender<LiveEvent>,
60
    /// Connected viewers by client id: their user names.
61
    present: HashMap<String, String>,
62
    choices: BTreeMap<String, Choice>,
63
}
64
65
impl Room {
664×
    fn names(&self) -> Vec<String> {
674×
        let mut v: Vec<String> = self.present.values().cloned().collect();
684×
        v.sort();
694×
        v.dedup();
704×
        v
714×
    }
72
}
73
74
#[derive(Default)]
75
pub struct Rooms {
76
    rooms: Mutex<HashMap<RoomKey, Room>>,
77
}
78
79
/// A viewer's place in a room; leaving happens when it is dropped (the
80
/// viewer's event stream closed).
81
pub struct Seat {
82
    rooms: Arc<Rooms>,
83
    key: RoomKey,
84
    client: String,
85
}
86
87
impl Drop for Seat {
883×
    fn drop(&mut self) {
893×
        self.rooms.leave(&self.key, &self.client);
903×
    }
91
}
92
93
impl Rooms {
94
    /// Join `key` as `client` (one browser tab) of `user`: the events to
95
    /// follow, starting with a snapshot, and the seat that leaves on drop.
963×
    pub fn join(&self, key: RoomKey, client: &str, user: &str) -> (broadcast::Receiver<LiveEvent>, LiveEvent) {
973×
        let mut rooms = self.rooms.lock().unwrap();
983×
        let room =
993×
            rooms.entry(key).or_insert_with(|| Room { tx: broadcast::channel(256).0, present: HashMap::new(), choices: BTreeMap::new() });
1003×
        room.present.insert(client.to_string(), user.to_string());
1013×
        let rx = room.tx.subscribe();
1023×
        let present = room.names();
1033×
        let _ = room.tx.send(LiveEvent::Presence { present: present.clone() });
1043×
        let mut choices = room.choices.clone();
1053×
        let keep = choices.remove("keep");
1063×
        (rx, LiveEvent::Snapshot { present, choices, keep })
1073×
    }
108
109
    /// A seat for `client` in `key`, to hold while its stream is open.
1103×
    pub fn seat(self: &Arc<Self>, key: RoomKey, client: &str) -> Seat {
1113×
        Seat { rooms: Arc::clone(self), key, client: client.to_string() }
1123×
    }
113
1143×
    fn leave(&self, key: &RoomKey, client: &str) {
1153×
        let mut rooms = self.rooms.lock().unwrap();
1163×
        let Some(room) = rooms.get_mut(key) else { return };
1173×
        room.present.remove(client);
1183×
        if room.present.is_empty() {
1192×
            // Nobody is looking: forget the room and its choices.
1202×
            rooms.remove(key);
1212×
        } else {
1221×
            let _ = room.tx.send(LiveEvent::Presence { present: room.names() });
1231×
        }
1243×
    }
125
126
    /// Record and share a choice; `false` when nobody is in the room here.
1274×
    pub fn choose(&self, key: &RoomKey, client: &str, region: &str, choice: Choice) -> bool {
1284×
        self.apply(key, &RoomEvent::Choice { region: region.to_string(), choice, client: client.to_string() })
1294×
    }
130
131
    /// Tell a room its conflict was resolved.
1321×
    pub fn landed(&self, key: &RoomKey, url: &str, by: &str) {
1331×
        self.apply(key, &RoomEvent::Landed { url: url.to_string(), by: by.to_string() });
1341×
    }
135
136
    /// Apply a viewer's event to the room for `key` in this process, if
137
    /// anyone here is in it: record a choice, and tell the viewers. `false`
138
    /// when nobody here is.
13910×
    pub fn apply(&self, key: &RoomKey, event: &RoomEvent) -> bool {
14010×
        let mut rooms = self.rooms.lock().unwrap();
14110×
        let Some(room) = rooms.get_mut(key) else { return false };
1426×
        let told = match event {
1434×
            RoomEvent::Choice { region, choice, client } => {
1444×
                room.choices.insert(region.clone(), choice.clone());
1454×
                LiveEvent::Choice { region: region.clone(), choice: choice.clone(), client: client.clone() }
146
            }
1472×
            RoomEvent::Landed { url, by } => LiveEvent::Landed { url: url.clone(), by: by.clone() },
148
        };
1496×
        let _ = room.tx.send(told);
1506×
        true
15110×
    }
152
153
    /// How many rooms are open (for tests and the admin page).
1541×
    pub fn open(&self) -> usize {
1551×
        self.rooms.lock().unwrap().len()
1561×
    }
157
}
158
159
#[cfg(test)]
160
mod tests {
161
    use super::*;
162
1639×
    fn key() -> RoomKey {
1649×
        RoomKey { repo_id: 1, bookmark: "main".into(), path: "a.txt".into() }
1659×
    }
166
1675×
    fn pick(choice: &str, by: &str) -> Choice {
1685×
        Choice { choice: choice.into(), text: String::new(), by: by.into() }
1695×
    }
170
171
    #[test]
1721×
    fn viewers_see_each_other_and_each_others_choices() {
1731×
        let rooms = Arc::new(Rooms::default());
1741×
        let (mut a_rx, a_snap) = rooms.join(key(), "tab-a", "andy");
1751×
        assert_eq!(a_snap, LiveEvent::Snapshot { present: vec!["andy".into()], choices: BTreeMap::new(), keep: None });
1761×
        let seat_a = rooms.seat(key(), "tab-a");
1771×
        assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Presence { .. })), "a's own arrival");
178
1791×
        assert!(rooms.choose(&key(), "tab-a", "1", pick("side2", "andy")));
1801×
        assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Choice { region, .. }) if region == "1"));
181
182
        // A second viewer gets the choices so far and is announced.
1831×
        let (mut b_rx, b_snap) = rooms.join(key(), "tab-b", "bea");
1841×
        let seat_b = rooms.seat(key(), "tab-b");
1851×
        let LiveEvent::Snapshot { present, choices, .. } = b_snap else { panic!() };
1861×
        assert_eq!(present, vec!["andy", "bea"]);
1871×
        assert_eq!(choices["1"].choice, "side2");
1881×
        assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into(), "bea".into()] });
189
190
        // The last choice for a region wins; keep is apart for candidate pages.
1911×
        rooms.choose(&key(), "tab-b", "1", pick("all", "bea"));
1921×
        rooms.choose(&key(), "tab-b", "keep", pick("1", "bea"));
1931×
        let _ = b_rx.try_recv();
1941×
        drop(seat_b);
1951×
        assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Choice { region: "1".into(), choice: pick("all", "bea"), client: "tab-b".into() });
1961×
        let _ = a_rx.try_recv();
1971×
        assert_eq!(a_rx.try_recv().unwrap(), LiveEvent::Presence { present: vec!["andy".into()] }, "bea left");
198
1991×
        rooms.landed(&key(), "/andy/demo/change/abc", "andy");
2001×
        assert!(matches!(a_rx.try_recv(), Ok(LiveEvent::Landed { .. })));
201
202
        // The last viewer leaving closes the room.
2031×
        drop(seat_a);
2041×
        assert_eq!(rooms.open(), 0);
2051×
        assert!(!rooms.choose(&key(), "tab-a", "1", pick("none", "andy")));
2061×
    }
207
}