beck_rt/presence.rs
1//! Who is connected now — the roster `presence()` reads.
2//!
3//! [`docs/10-decisions.md`](../../../../../docs/10-decisions.md) D6: "**Presence** (who is connected
4//! now) ships v1 as a first-class non-durable `Signal` — it is both the natural demo of per-session
5//! fanout and its permanent stress test."
6//!
7//! The compiler's half is a source in the signal graph
8//! ([`beck_core::signal::Op::Presence`]); this is the fact that source reads. It is deliberately
9//! small, and everything interesting about it is a consequence of one sentence: **this is the only
10//! input to a view that moves without an event**. A session is not in the log either, and it is
11//! fixed for the life of a subscription; the accumulator moves only when the log does.
12//!
13//! # What that sentence forbids
14//!
15//! Nothing here is appended, snapshotted or replayed. A process that restarts comes back with an
16//! empty roster and fills it as clients reconnect, which is correct rather than lossy: who is
17//! connected to a process that no longer exists is nobody. The checker keeps this from mattering
18//! anywhere it would — `presence` cannot reach the chokepoint (`B0515`), so no event's existence
19//! ever depended on it.
20//!
21//! # The bound, and why it is here rather than in a later hardening pass
22//!
23//! The obvious implementation is a map from actor to a count, and that map is **unbounded memory
24//! keyed by a string the client chooses** — which is
25//! [`docs/82`](../../../../../docs/82-the-edge-report.md) §82.5's finding
26//! exactly, one subsystem over. Under [`crate::identity::DevIdentity`] the actor is whatever the
27//! connection said it was, so a client opening sockets under fresh names would grow this table
28//! until the process died.
29//!
30//! [`crate::quota`] answers the same problem by sharding into a fixed table, and that answer is not
31//! available here: a quota needs a *number* per actor and may share buckets, while a roster needs
32//! the actor's **name** and would be nonsense if two names collided. So the bound is a capacity:
33//! past [`Config::capacity`] distinct actors, a new one is **not recorded** and
34//! [`Registry::refused`] counts it. Presence then under-reports rather than growing, which is the
35//! failure this direction should have — a page that says "127 here" when 200 are connected is
36//! wrong in a way that costs nothing, and the opposite is a process that dies.
37//!
38//! An actor already in the roster is never refused, whatever the capacity: the bound is on how many
39//! *names* are held, not on how many connections one of them may open.
40
41use std::collections::BTreeMap;
42use std::sync::atomic::{AtomicU64, Ordering};
43use std::sync::{Arc, Mutex};
44
45use beck_core::Value;
46use tokio::sync::watch;
47
48/// How large a roster this process will hold.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub struct Config {
51 /// How many distinct actors may be in the roster at once.
52 pub capacity: usize,
53}
54
55impl Default for Config {
56 /// 4,096 actors.
57 ///
58 /// The number is chosen the way [`crate::quota::Quota::default`]'s is: large enough that no
59 /// legitimate deployment of a single process meets it — a Beck process serving more than four
60 /// thousand *distinct* identities at one instant has a fanout problem before it has a roster
61 /// problem — and small enough that the table is a few hundred kilobytes at worst.
62 fn default() -> Config {
63 Config { capacity: 4096 }
64 }
65}
66
67/// The connection set of one application.
68pub struct Registry {
69 /// The counts, and the published value derived from them.
70 ///
71 /// One mutex rather than a lock-free map: it is taken twice per *connection* — once on join and
72 /// once on leave — and never on a render or an event. The published value is rebuilt under it
73 /// so that what a subscriber reads is always a roster that existed.
74 inner: Mutex<BTreeMap<Arc<str>, u32>>,
75 value: watch::Sender<Value>,
76 config: Config,
77 refused: AtomicU64,
78}
79
80impl Registry {
81 pub fn new(config: Config) -> Arc<Registry> {
82 Arc::new(Registry {
83 inner: Mutex::new(BTreeMap::new()),
84 value: watch::channel(beck_core::edge::presence([])).0,
85 config,
86 refused: AtomicU64::new(0),
87 })
88 }
89
90 /// The roster as a Beck value: `Map[Str, Int]`, actor to connections.
91 pub fn value(&self) -> Value {
92 self.value.borrow().clone()
93 }
94
95 /// Wake on every change to it. A subscription watches this **only** when the program's page
96 /// reads `presence` — a program that never asks who is connected must not re-render when
97 /// somebody connects.
98 pub fn watch(&self) -> watch::Receiver<Value> {
99 self.value.subscribe()
100 }
101
102 /// How many actors are in the roster.
103 pub fn here(&self) -> usize {
104 self.inner.lock().expect("presence").len()
105 }
106
107 /// How many joins the capacity refused, for the life of this process.
108 pub fn refused(&self) -> u64 {
109 self.refused.load(Ordering::Relaxed)
110 }
111
112 /// Record a connection. The roster holds it until the returned guard is dropped.
113 ///
114 /// A guard rather than a pair of calls, for the reason every other guard in this crate is one:
115 /// a subscription ends by returning, by erroring or by its socket dying, and a roster that only
116 /// removed an actor on the happy path would fill up with people who left hours ago.
117 pub fn join(self: &Arc<Self>, actor: &str) -> Guard {
118 let held = {
119 let mut counts = self.inner.lock().expect("presence");
120 let room = counts.len() < self.config.capacity;
121 match counts.get_mut(actor) {
122 Some(n) => {
123 *n += 1;
124 true
125 }
126 None if room => {
127 counts.insert(Arc::from(actor), 1);
128 true
129 }
130 // The bound. Counted rather than logged per occurrence: whoever is doing this is
131 // doing it at a rate that would make a log line the denial of service.
132 None => {
133 self.refused.fetch_add(1, Ordering::Relaxed);
134 false
135 }
136 }
137 };
138 if held {
139 self.publish();
140 }
141 Guard {
142 registry: held.then(|| self.clone()),
143 actor: Arc::from(actor),
144 }
145 }
146
147 fn leave(&self, actor: &str) {
148 {
149 let mut counts = self.inner.lock().expect("presence");
150 match counts.get_mut(actor) {
151 Some(n) if *n > 1 => *n -= 1,
152 Some(_) => {
153 counts.remove(actor);
154 }
155 None => return,
156 }
157 }
158 self.publish();
159 }
160
161 /// Rebuild the published value from the counts.
162 ///
163 /// `O(actors)` per connection change, which is the shape this trades for: a roster is read on
164 /// every render of every subscriber and written once per connection, so the cost belongs on the
165 /// write. What a reader gets is one `Arc` bump.
166 fn publish(&self) {
167 let counts = self.inner.lock().expect("presence");
168 let value = beck_core::edge::presence(
169 counts
170 .iter()
171 .map(|(actor, n)| (actor.as_ref(), i64::from(*n))),
172 );
173 // `send_replace` and not `send`: `send` fails when there is no receiver, and — the part
174 // that matters — leaves the value it was given *unpublished*. Nothing may subscribe to a
175 // roster until a program that reads one has a connection, so every join before the first
176 // subscription would have been lost, including the first client's own.
177 self.value.send_replace(value);
178 }
179}
180
181/// One connection's membership of the roster.
182pub struct Guard {
183 /// `None` when the capacity refused this join: the guard still exists, so the caller has one
184 /// code path, and dropping it removes nothing because nothing was added.
185 registry: Option<Arc<Registry>>,
186 actor: Arc<str>,
187}
188
189impl Guard {
190 /// Whether this connection is in the roster. False only when the capacity refused it.
191 pub fn recorded(&self) -> bool {
192 self.registry.is_some()
193 }
194}
195
196impl Drop for Guard {
197 fn drop(&mut self) {
198 if let Some(registry) = &self.registry {
199 registry.leave(&self.actor);
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 fn roster(r: &Registry) -> Vec<(String, i64)> {
209 let value = r.value();
210 let map = value.as_map().expect("a map");
211 map.iter()
212 .map(|(k, v)| {
213 (
214 k.as_str().expect("actor").to_string(),
215 v.as_int().expect("count"),
216 )
217 })
218 .collect()
219 }
220
221 #[test]
222 fn a_connection_joins_and_its_guard_removes_it() {
223 let r = Registry::new(Config::default());
224 assert_eq!(roster(&r), Vec::new());
225 {
226 let _ana = r.join("ana");
227 assert_eq!(roster(&r), vec![("ana".to_string(), 1)]);
228 let _bo = r.join("bo");
229 assert_eq!(
230 roster(&r),
231 vec![("ana".to_string(), 1), ("bo".to_string(), 1)]
232 );
233 }
234 assert_eq!(roster(&r), Vec::new());
235 }
236
237 #[test]
238 fn one_actor_with_two_tabs_is_one_row_counted_twice() {
239 let r = Registry::new(Config::default());
240 let first = r.join("ana");
241 let second = r.join("ana");
242 assert_eq!(roster(&r), vec![("ana".to_string(), 2)]);
243 drop(second);
244 assert_eq!(roster(&r), vec![("ana".to_string(), 1)]);
245 drop(first);
246 assert_eq!(roster(&r), Vec::new());
247 }
248
249 /// §82.5's finding, one subsystem over: the table is keyed by a string the client chooses, so
250 /// what stops it is a capacity rather than a hope.
251 #[test]
252 fn the_capacity_refuses_rather_than_growing() {
253 let r = Registry::new(Config { capacity: 2 });
254 let _a = r.join("a");
255 let _b = r.join("b");
256 let c = r.join("c");
257 assert!(!c.recorded(), "the third actor is not in the roster");
258 assert_eq!(r.here(), 2);
259 assert_eq!(r.refused(), 1);
260 // An actor already held is never refused, however full the table is.
261 let again = r.join("a");
262 assert!(again.recorded());
263 assert_eq!(roster(&r), vec![("a".to_string(), 2), ("b".to_string(), 1)]);
264 // And dropping the refused guard removes nothing.
265 drop(c);
266 assert_eq!(r.here(), 2);
267 }
268
269 #[test]
270 fn a_watcher_wakes_on_a_join_and_on_a_leave() {
271 let r = Registry::new(Config::default());
272 let mut w = r.watch();
273 assert!(!w.has_changed().expect("live"));
274 let ana = r.join("ana");
275 assert!(w.has_changed().expect("live"));
276 w.mark_unchanged();
277 drop(ana);
278 assert!(w.has_changed().expect("live"));
279 }
280}