beck_rt/awareness.rs
1//! What everybody is doing now — the roster `awareness(f)` reads.
2//!
3//! [`crate::presence`] holds who is connected; this holds what each of them contributes. The two
4//! are separate registries because they move at different rates and for different reasons: a
5//! connection joins and leaves, and between those two moments a client may change its
6//! contribution any number of times by navigating.
7//!
8//! The compiler's half is a source in the signal graph ([`beck_core::signal::Op::Awareness`]) and a
9//! role beside the view ([`beck_core::split::Roles::awareness`]). The role is a function
10//! `Session -> T`; **this** is what applies it, once per connection per change, because the
11//! subscribers are the runtime's fact and not the graph's — the signal graph of one program has no
12//! way to name another connection's session.
13//!
14//! # Everything here follows from presence's one sentence
15//!
16//! Nothing is appended, snapshotted or replayed; a process that restarts comes back empty; the
17//! checker keeps `awareness` away from the chokepoint (`B0515`) so no event's existence depended on
18//! it. The bound is [`crate::presence`]'s bound for [`crate::presence`]'s reason — the table is
19//! keyed by a string the client may choose ([`docs/82`](../../../../../docs/82-the-edge-report.md)
20//! §82.5) — and past [`Config::capacity`] distinct actors a new one is **not recorded**, so the
21//! roster under-reports rather than growing.
22//!
23//! # What is different: a value, and therefore a size
24//!
25//! A roster of counts is bounded by its capacity alone. A roster of *values* is bounded by the
26//! capacity times whatever `f` returns, and `f` is the program's — a session's path is a few
27//! dozen bytes, and nothing in the type system says it has to be. [`Config::each`] is the second
28//! bound this needs and presence does not: a contribution whose rendered size exceeds it is
29//! **refused**, the actor keeps whatever it contributed before, and [`Registry::oversized`] counts
30//! it. Refusing one client's update is the failure this direction should have, and holding an
31//! unbounded value per connection is not.
32
33use std::collections::BTreeMap;
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::sync::{Arc, Mutex};
36
37use beck_core::Value;
38use tokio::sync::watch;
39
40/// How large a roster this process will hold, and how large one contribution may be.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct Config {
43 /// How many distinct actors may be in the roster at once.
44 pub capacity: usize,
45 /// How many bytes one actor's contribution may render to.
46 pub each: usize,
47}
48
49impl Default for Config {
50 /// 4,096 actors of 4 KiB each — sixteen megabytes at worst, and that is the number to read this
51 /// as, because the product is what the process pays.
52 ///
53 /// The capacity is [`crate::presence::Config::default`]'s and for its reason. The per-actor
54 /// bound is a cursor, a selection or a route with room to spare, and far short of a document.
55 fn default() -> Config {
56 Config {
57 capacity: 4096,
58 each: 4096,
59 }
60 }
61}
62
63/// The awareness roster of one application.
64pub struct Registry {
65 /// Each actor's contribution, and how many connections that actor has.
66 ///
67 /// The count is here for the same reason presence keeps one: an actor with two tabs leaves the
68 /// roster when the second closes, not the first. The *value* is whichever of that actor's
69 /// connections published last, which is the only answer available when the roster is keyed by
70 /// actor and a person may open two tabs.
71 inner: Mutex<BTreeMap<Arc<str>, Entry>>,
72 value: watch::Sender<Value>,
73 config: Config,
74 refused: AtomicU64,
75 oversized: AtomicU64,
76}
77
78struct Entry {
79 connections: u32,
80 contribution: Value,
81}
82
83impl Registry {
84 pub fn new(config: Config) -> Arc<Registry> {
85 Arc::new(Registry {
86 inner: Mutex::new(BTreeMap::new()),
87 value: watch::channel(beck_core::edge::no_awareness()).0,
88 config,
89 refused: AtomicU64::new(0),
90 oversized: AtomicU64::new(0),
91 })
92 }
93
94 /// The roster as a Beck value: `Map[Str, T]`, actor to contribution.
95 pub fn value(&self) -> Value {
96 self.value.borrow().clone()
97 }
98
99 /// Wake on every change to it. A subscription watches this **only** when the program's page
100 /// reads `awareness` — a program that never asks must not re-render when somebody navigates.
101 pub fn watch(&self) -> watch::Receiver<Value> {
102 self.value.subscribe()
103 }
104
105 /// How many actors are in the roster.
106 pub fn here(&self) -> usize {
107 self.inner.lock().expect("awareness").len()
108 }
109
110 /// How many joins the capacity refused, for the life of this process.
111 pub fn refused(&self) -> u64 {
112 self.refused.load(Ordering::Relaxed)
113 }
114
115 /// How many contributions the per-actor bound refused, for the life of this process.
116 pub fn oversized(&self) -> u64 {
117 self.oversized.load(Ordering::Relaxed)
118 }
119
120 /// Record a connection's first contribution. The roster holds it until the guard is dropped.
121 ///
122 /// A guard for [`crate::presence::Guard`]'s reason: a subscription ends by returning, by
123 /// erroring or by its socket dying, and a roster that only removed an actor on the happy path
124 /// would fill up with people who left hours ago.
125 pub fn join(self: &Arc<Self>, actor: &str, contribution: Value) -> Guard {
126 let held = {
127 let mut rows = self.inner.lock().expect("awareness");
128 let room = rows.len() < self.config.capacity;
129 match rows.get_mut(actor) {
130 Some(entry) => {
131 entry.connections += 1;
132 true
133 }
134 None if room => {
135 rows.insert(
136 Arc::from(actor),
137 Entry {
138 connections: 1,
139 contribution: Value::Unit,
140 },
141 );
142 true
143 }
144 None => {
145 self.refused.fetch_add(1, Ordering::Relaxed);
146 false
147 }
148 }
149 };
150 let guard = Guard {
151 registry: held.then(|| self.clone()),
152 actor: Arc::from(actor),
153 };
154 if held {
155 // Through `update` rather than written above, so the size bound applies to the first
156 // contribution as it does to every later one. A joining connection whose contribution
157 // is too large is in the roster with nothing in it, and says so.
158 self.update(actor, contribution);
159 }
160 guard
161 }
162
163 /// Publish a new contribution for an actor already in the roster.
164 ///
165 /// Silently does nothing for an actor that is not — the capacity refused them, and a client
166 /// navigating should not be a way to get in through a different door.
167 ///
168 /// Returns whether the roster changed, which is what saves a re-render: the common navigation
169 /// republishes the same route.
170 pub fn update(&self, actor: &str, contribution: Value) -> bool {
171 let changed = {
172 let mut rows = self.inner.lock().expect("awareness");
173 let Some(entry) = rows.get_mut(actor) else {
174 return false;
175 };
176 // The bound, measured on what the value renders to rather than on a shallow field
177 // count: a list of a million empty strings is one field and eight megabytes.
178 if contribution.display().len() > self.config.each {
179 self.oversized.fetch_add(1, Ordering::Relaxed);
180 return false;
181 }
182 // Structural equality, not the engine's conservative pointer test: a client that
183 // navigates to the route it is already on rebuilds an equal value, and answering
184 // "changed" there would wake every subscriber for nothing.
185 let changed = entry.contribution != contribution;
186 entry.contribution = contribution;
187 changed
188 };
189 if changed {
190 self.publish();
191 }
192 changed
193 }
194
195 fn leave(&self, actor: &str) {
196 {
197 let mut rows = self.inner.lock().expect("awareness");
198 match rows.get_mut(actor) {
199 Some(entry) if entry.connections > 1 => {
200 entry.connections -= 1;
201 // The value stays: the actor is still here through another tab. Which of that
202 // actor's tabs it came from is not something this roster distinguishes.
203 return;
204 }
205 Some(_) => {
206 rows.remove(actor);
207 }
208 None => return,
209 }
210 }
211 self.publish();
212 }
213
214 /// Rebuild the published value from the rows.
215 ///
216 /// `O(actors)` per change, and unlike presence's the change here is per *navigation* rather
217 /// than per connection. That is the cost this trades knowingly: a roster is read on every
218 /// render of every subscriber, so rebuilding it once per change beats rebuilding it once per
219 /// reader. If a program ever moves a cursor through here, this is the line that becomes a
220 /// delta stream rather than a rebuild.
221 fn publish(&self) {
222 let rows = self.inner.lock().expect("awareness");
223 let value = beck_core::edge::awareness(
224 rows.iter()
225 .map(|(actor, entry)| (actor.as_ref(), entry.contribution.clone())),
226 );
227 // `send_replace` for [`crate::presence::Registry::publish`]'s reason: `send` fails when
228 // there is no receiver and leaves the value unpublished, so every join before the first
229 // subscription would be lost — including the first client's own.
230 self.value.send_replace(value);
231 }
232}
233
234/// One connection's membership of the awareness roster.
235pub struct Guard {
236 /// `None` when the capacity refused this join.
237 registry: Option<Arc<Registry>>,
238 actor: Arc<str>,
239}
240
241impl Guard {
242 /// Whether this connection is in the roster. False only when the capacity refused it.
243 pub fn recorded(&self) -> bool {
244 self.registry.is_some()
245 }
246
247 /// Publish this connection's new contribution. Returns whether the roster changed.
248 pub fn publish(&self, contribution: Value) -> bool {
249 match &self.registry {
250 Some(registry) => registry.update(&self.actor, contribution),
251 None => false,
252 }
253 }
254}
255
256impl Drop for Guard {
257 fn drop(&mut self) {
258 if let Some(registry) = &self.registry {
259 registry.leave(&self.actor);
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn text(s: &str) -> Value {
269 Value::str_(s)
270 }
271
272 fn roster(r: &Registry) -> Vec<(String, String)> {
273 let value = r.value();
274 let map = value.as_map().expect("a map");
275 map.iter()
276 .map(|(k, v)| {
277 (
278 k.as_str().expect("actor").to_string(),
279 v.as_str().expect("contribution").to_string(),
280 )
281 })
282 .collect()
283 }
284
285 #[test]
286 fn a_connection_contributes_and_its_guard_removes_it() {
287 let r = Registry::new(Config::default());
288 assert_eq!(roster(&r), Vec::new());
289 {
290 let _ana = r.join("ana", text("/todos"));
291 assert_eq!(roster(&r), vec![("ana".into(), "/todos".to_string())]);
292 let _bo = r.join("bo", text("/done"));
293 assert_eq!(
294 roster(&r),
295 vec![
296 ("ana".into(), "/todos".to_string()),
297 ("bo".into(), "/done".to_string())
298 ]
299 );
300 }
301 assert_eq!(roster(&r), Vec::new());
302 }
303
304 #[test]
305 fn navigating_republishes_and_says_whether_anything_moved() {
306 let r = Registry::new(Config::default());
307 let ana = r.join("ana", text("/todos"));
308 assert!(!ana.publish(text("/todos")), "the same route is no change");
309 assert!(ana.publish(text("/done")));
310 assert_eq!(roster(&r), vec![("ana".into(), "/done".to_string())]);
311 }
312
313 #[test]
314 fn one_actor_with_two_tabs_leaves_when_the_second_closes() {
315 let r = Registry::new(Config::default());
316 let first = r.join("ana", text("/todos"));
317 let second = r.join("ana", text("/done"));
318 assert_eq!(roster(&r), vec![("ana".into(), "/done".to_string())]);
319 drop(second);
320 assert_eq!(
321 roster(&r),
322 vec![("ana".into(), "/done".to_string())],
323 "still here through the other tab"
324 );
325 drop(first);
326 assert_eq!(roster(&r), Vec::new());
327 }
328
329 /// §82.5's finding, keyed by a string the client chooses.
330 #[test]
331 fn the_capacity_refuses_rather_than_growing() {
332 let r = Registry::new(Config {
333 capacity: 2,
334 each: 4096,
335 });
336 let _a = r.join("a", text("/a"));
337 let _b = r.join("b", text("/b"));
338 let c = r.join("c", text("/c"));
339 assert!(!c.recorded());
340 assert_eq!(r.here(), 2);
341 assert_eq!(r.refused(), 1);
342 // And a refused actor cannot get in by publishing.
343 assert!(!c.publish(text("/c2")));
344 assert_eq!(r.here(), 2);
345 drop(c);
346 assert_eq!(r.here(), 2);
347 }
348
349 /// The bound presence does not need: a roster of values is the capacity times the value.
350 #[test]
351 fn a_contribution_past_the_size_bound_is_refused_and_the_last_one_stands() {
352 let r = Registry::new(Config {
353 capacity: 8,
354 each: 16,
355 });
356 let ana = r.join("ana", text("/todos"));
357 assert_eq!(roster(&r), vec![("ana".into(), "/todos".to_string())]);
358 assert!(!ana.publish(text(&"x".repeat(64))));
359 assert_eq!(
360 roster(&r),
361 vec![("ana".into(), "/todos".to_string())],
362 "the actor keeps what it last contributed"
363 );
364 assert_eq!(r.oversized(), 1);
365 // A joining connection whose first contribution is too large is in the roster, empty.
366 let _bo = r.join("bo", text(&"y".repeat(64)));
367 assert_eq!(r.oversized(), 2);
368 assert_eq!(r.here(), 2);
369 }
370
371 #[test]
372 fn a_watcher_wakes_on_a_join_a_change_and_a_leave() {
373 let r = Registry::new(Config::default());
374 let mut w = r.watch();
375 assert!(!w.has_changed().expect("live"));
376 let ana = r.join("ana", text("/todos"));
377 assert!(w.has_changed().expect("live"));
378 w.mark_unchanged();
379 ana.publish(text("/done"));
380 assert!(w.has_changed().expect("live"));
381 w.mark_unchanged();
382 drop(ana);
383 assert!(w.has_changed().expect("live"));
384 }
385}