Skip to main content

sofie_db/
live.rs

1use std::cell::RefCell;
2use std::collections::BTreeSet;
3use std::fmt;
4use std::hash::{BuildHasher, RandomState};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex, OnceLock, Weak};
7use std::time::Duration;
8
9use sofie_core::{SqlValue, Uuid, unpoisoned};
10use tokio::sync::{broadcast, mpsc};
11
12use crate::arrivals::coalesced;
13use crate::{Aggregate, Db, Driver, Notifications, Reach, query_values};
14
15const BACKLOG: usize = 256;
16/// Postgres refuses a payload of 8000 bytes or more.
17const PAYLOAD_LIMIT: usize = 7900;
18const LISTEN_RETRY: Duration = Duration::from_secs(5);
19const ALIVE_CHECK: Duration = Duration::from_secs(30);
20/// No table is named so: a schema's name holds no dot.
21#[cfg(feature = "remote-contracts")]
22const REMOTE_APPS: &str = "sofie.remote";
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct Topic(u64);
26
27impl Topic {
28    /// FNV-1a, stable across builds, so instances running different versions agree.
29    #[must_use]
30    pub fn named(schema: &str, table: &str) -> Self {
31        let hash = sofie_core::Fnv1a64::new()
32            .write(schema.as_bytes())
33            .write(b".")
34            .write(table.as_bytes());
35        Self(hash.finish())
36    }
37
38    #[must_use]
39    pub fn of<A: Aggregate>() -> Self {
40        Self::named(A::MODULE, A::NAME)
41    }
42
43    /// Coarse: every event pulled from that app refreshes each screen that read through it.
44    #[cfg(feature = "remote-contracts")]
45    #[must_use]
46    pub fn remote(app: &str) -> Self {
47        Self::named(REMOTE_APPS, app)
48    }
49
50    #[must_use]
51    pub fn parse(hex: &str) -> Option<Self> {
52        if hex.len() != 16 {
53            return None;
54        }
55        u64::from_str_radix(hex, 16).ok().map(Self)
56    }
57}
58
59impl fmt::Display for Topic {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(formatter, "{:016x}", self.0)
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
66pub enum Readers {
67    Anyone,
68    Owner(Uuid),
69    /// An owned record whose owner the write did not name: only an unrestricted reach hears it.
70    Unrestricted,
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
74pub struct TableChange {
75    pub topic: Topic,
76    pub readers: Readers,
77}
78
79impl TableChange {
80    pub(crate) fn of<A: Aggregate>(owner: Option<Uuid>, reach: Reach) -> Self {
81        let readers = match (A::OWNER, owner, reach) {
82            (None, _, _) => Readers::Anyone,
83            (Some(_), Some(owner), _) | (Some(_), None, Reach::OwnedBy(owner)) => {
84                Readers::Owner(owner)
85            }
86            (Some(_), None, Reach::All | Reach::AllAs(_)) => Readers::Unrestricted,
87        };
88        Self {
89            topic: Topic::of::<A>(),
90            readers,
91        }
92    }
93
94    #[must_use]
95    pub fn reaches(&self, reach: Reach) -> bool {
96        match (self.readers, reach) {
97            (_, Reach::All | Reach::AllAs(_)) | (Readers::Anyone, _) => true,
98            (Readers::Owner(owner), Reach::OwnedBy(principal)) => owner == principal,
99            (Readers::Unrestricted, Reach::OwnedBy(_)) => false,
100        }
101    }
102}
103
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum Topics {
106    Every,
107    Of(BTreeSet<Topic>),
108}
109
110impl Topics {
111    #[must_use]
112    pub fn covers(&self, topic: Topic) -> bool {
113        match self {
114            Self::Every => true,
115            Self::Of(topics) => topics.contains(&topic),
116        }
117    }
118}
119
120tokio::task_local! {
121    static HEARD: RefCell<Topics>;
122}
123
124pub async fn track_topics<F: Future>(future: F) -> (F::Output, Topics) {
125    HEARD
126        .scope(RefCell::new(Topics::Of(BTreeSet::new())), async {
127            let output = future.await;
128            let topics = HEARD.with(|heard| heard.borrow().clone());
129            (output, topics)
130        })
131        .await
132}
133
134pub(crate) fn tracking() -> bool {
135    HEARD.try_with(|_| ()).is_ok()
136}
137
138pub(crate) fn read_tables<'t>(tables: impl IntoIterator<Item = (&'t str, &'t str)>) {
139    let _ = HEARD.try_with(|heard| {
140        if let Topics::Of(heard) = &mut *heard.borrow_mut() {
141            heard.extend(
142                tables
143                    .into_iter()
144                    .map(|(schema, table)| Topic::named(schema, table)),
145            );
146        }
147    });
148}
149
150#[cfg(feature = "remote-contracts")]
151pub(crate) fn read_remote(app: &str) {
152    read_tables([(REMOTE_APPS, app)]);
153}
154
155#[cfg(any(feature = "raw-sql", test))]
156pub(crate) fn read_anything() {
157    let _ = HEARD.try_with(|heard| *heard.borrow_mut() = Topics::Every);
158}
159
160#[derive(Clone, Debug, PartialEq, Eq)]
161pub enum Announced {
162    Changed(Arc<[TableChange]>),
163    /// Listening to other instances was down, so any change of theirs may have been missed.
164    MaybeMissed,
165}
166
167impl Announced {
168    #[must_use]
169    pub fn concerns(&self, topics: &Topics, reach: Reach) -> bool {
170        match self {
171            Self::Changed(changes) => changes
172                .iter()
173                .any(|change| topics.covers(change.topic) && change.reaches(reach)),
174            Self::MaybeMissed => true,
175        }
176    }
177}
178
179pub(crate) struct Changes {
180    driver: Weak<dyn Driver>,
181    here: broadcast::Sender<Announced>,
182    instance: u64,
183    outgoing: Arc<Mutex<Vec<TableChange>>>,
184    sending: OnceLock<mpsc::Sender<()>>,
185    listening: OnceLock<()>,
186    /// Always set where the database tells no other instance; elsewhere only while subscribed.
187    hearing: Arc<AtomicBool>,
188}
189
190impl Changes {
191    pub(crate) fn new(driver: &Arc<dyn Driver>) -> Self {
192        Self {
193            driver: Arc::downgrade(driver),
194            here: broadcast::channel(BACKLOG).0,
195            instance: RandomState::new().hash_one(std::process::id()),
196            outgoing: Arc::default(),
197            sending: OnceLock::new(),
198            listening: OnceLock::new(),
199            hearing: Arc::new(AtomicBool::new(driver.change_notifications().is_none())),
200        }
201    }
202
203    pub(crate) fn announce(&self, driver: &Arc<dyn Driver>, changes: Vec<TableChange>) {
204        if changes.is_empty() {
205            return;
206        }
207        let changes = distinct(changes);
208        // Fails only while no screen listens, which is no failure.
209        let _ = self.here.send(Announced::Changed(changes.clone().into()));
210        let Some(notify) = driver.dialect().live_notify() else {
211            return;
212        };
213        unpoisoned::lock(&self.outgoing).extend(changes);
214        let sender = self.sending.get_or_init(|| {
215            let (outgoing, instance) = (Arc::clone(&self.outgoing), self.instance);
216            coalesced(Arc::downgrade(driver), move |driver| {
217                send(driver, Arc::clone(&outgoing), notify, instance)
218            })
219        });
220        if sender.try_send(()).is_err() {
221            tracing::trace!("a notification of changes is already pending");
222        }
223    }
224
225    fn subscribe(&self) -> broadcast::Receiver<Announced> {
226        self.listening.get_or_init(|| {
227            tokio::spawn(listen_elsewhere(
228                Weak::clone(&self.driver),
229                self.here.clone(),
230                Arc::clone(&self.hearing),
231                self.instance,
232            ));
233        });
234        self.here.subscribe()
235    }
236}
237
238/// Every change committed from now on, here or by another instance.
239#[must_use]
240pub fn committed_changes(db: &Db) -> broadcast::Receiver<Announced> {
241    db.changes.subscribe()
242}
243
244/// Whether another instance's commits reach `committed_changes` now: always where the
245/// database tells no other instance (SQLite), elsewhere only while the listener the first
246/// `committed_changes` started is subscribed. A cache trusts what it holds only while true.
247#[must_use]
248pub fn hears_other_instances(db: &Db) -> bool {
249    db.changes.hearing.load(Ordering::Acquire)
250}
251
252async fn send(
253    driver: Arc<dyn Driver>,
254    outgoing: Arc<Mutex<Vec<TableChange>>>,
255    notify: &'static str,
256    instance: u64,
257) {
258    let changes = distinct(std::mem::take(&mut *unpoisoned::lock(&outgoing)));
259    for payload in payloads(instance, &changes) {
260        let params = [SqlValue::Text(payload)];
261        if let Err(error) = query_values(driver.as_ref(), notify, &params).await {
262            tracing::warn!(
263                error.kind = "notify",
264                error = %error,
265                "telling other instances of changes failed; their open screens miss them"
266            );
267        }
268    }
269}
270
271/// `<instance> <topic>:<readers> …`, readers `*` for anyone, `!` for an unrestricted reach.
272fn distinct(mut changes: Vec<TableChange>) -> Vec<TableChange> {
273    changes.sort_unstable();
274    changes.dedup();
275    changes
276}
277
278fn payloads(instance: u64, changes: &[TableChange]) -> Vec<String> {
279    let start = format!("{instance:016x}");
280    let mut payloads = Vec::new();
281    let mut payload = start.clone();
282    for change in changes {
283        let entry = match change.readers {
284            Readers::Anyone => format!(" {}:*", change.topic),
285            Readers::Unrestricted => format!(" {}:!", change.topic),
286            Readers::Owner(owner) => format!(" {}:{owner}", change.topic),
287        };
288        if payload.len() + entry.len() > PAYLOAD_LIMIT {
289            payloads.push(std::mem::replace(&mut payload, start.clone()));
290        }
291        payload.push_str(&entry);
292    }
293    payloads.push(payload);
294    payloads
295}
296
297fn parsed(payload: &str) -> Option<(u64, Vec<TableChange>)> {
298    let mut parts = payload.split(' ');
299    let instance = u64::from_str_radix(parts.next()?, 16).ok()?;
300    let changes = parts
301        .map(|entry| {
302            let (topic, readers) = entry.split_once(':')?;
303            let readers = match readers {
304                "*" => Readers::Anyone,
305                "!" => Readers::Unrestricted,
306                owner => Readers::Owner(owner.parse().ok()?),
307            };
308            Some(TableChange {
309                topic: Topic::parse(topic)?,
310                readers,
311            })
312        })
313        .collect::<Option<Vec<TableChange>>>()?;
314    Some((instance, changes))
315}
316
317async fn listen_elsewhere(
318    driver: Weak<dyn Driver>,
319    here: broadcast::Sender<Announced>,
320    hearing: Arc<AtomicBool>,
321    instance: u64,
322) {
323    let mut listened_before = false;
324    loop {
325        let subscribed = {
326            let Some(driver) = driver.upgrade() else {
327                return;
328            };
329            let Some(subscribing) = driver.change_notifications() else {
330                return;
331            };
332            subscribing.await
333        };
334        match subscribed {
335            Ok(notices) => {
336                // Nothing was missed before the first subscription: no screen was listening.
337                if std::mem::replace(&mut listened_before, true) {
338                    let _ = here.send(Announced::MaybeMissed);
339                }
340                // After `MaybeMissed`, so a reader seeing it set finds that queued first.
341                hearing.store(true, Ordering::Release);
342                let error = follow(notices, &driver, &here, instance).await;
343                hearing.store(false, Ordering::Release);
344                let Some(error) = error else {
345                    return;
346                };
347                tracing::warn!(error = %error, "listening for other instances' changes failed; listening again");
348            }
349            Err(error) => {
350                tracing::warn!(error = %error, "listening for other instances' changes failed; trying again");
351            }
352        }
353        tokio::time::sleep(LISTEN_RETRY).await;
354    }
355}
356
357async fn follow(
358    mut notices: Box<dyn Notifications>,
359    driver: &Weak<dyn Driver>,
360    here: &broadcast::Sender<Announced>,
361    instance: u64,
362) -> Option<sofie_core::Error> {
363    loop {
364        if driver.strong_count() == 0 {
365            return None;
366        }
367        let payloads = match tokio::time::timeout(ALIVE_CHECK, notices.next()).await {
368            Err(_) => continue,
369            Ok(Err(error)) => return Some(error),
370            Ok(Ok(payloads)) => payloads,
371        };
372        for payload in payloads {
373            match parsed(&payload) {
374                Some((sender, _)) if sender == instance => {}
375                Some((_, changes)) => drop(here.send(Announced::Changed(changes.into()))),
376                None => tracing::warn!(
377                    live.payload_bytes = payload.len(),
378                    "a change notification this version cannot read was skipped"
379                ),
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    const OWNER: Uuid = Uuid::from_u128(0x0190_a7d2_0000_7000_8000_0000_0000_0001);
390    const OTHER: Uuid = Uuid::from_u128(0x0190_a7d2_0000_7000_8000_0000_0000_0002);
391
392    #[test]
393    fn a_topic_is_fnv_1a_of_the_qualified_table_as_every_version_names_it() {
394        assert_eq!(
395            Topic::named("shop", "order").to_string(),
396            "814cce8dc5ccd897"
397        );
398    }
399
400    fn change(readers: Readers) -> TableChange {
401        TableChange {
402            topic: Topic::named("shop", "order"),
403            readers,
404        }
405    }
406
407    #[test]
408    fn changes_of_interleaved_owners_are_announced_once_each() {
409        let changes = [OWNER, OTHER, OWNER, OTHER]
410            .map(|owner| change(Readers::Owner(owner)))
411            .to_vec();
412
413        assert_eq!(
414            distinct(changes),
415            [change(Readers::Owner(OWNER)), change(Readers::Owner(OTHER))]
416        );
417    }
418
419    #[test]
420    fn a_change_reaches_only_those_who_may_read_the_record() {
421        let owned = change(Readers::Owner(OWNER));
422
423        assert!(owned.reaches(Reach::All));
424        assert!(owned.reaches(Reach::OwnedBy(OWNER)));
425        assert!(!owned.reaches(Reach::OwnedBy(OTHER)));
426        assert!(change(Readers::Anyone).reaches(Reach::OwnedBy(OTHER)));
427        assert!(change(Readers::Unrestricted).reaches(Reach::All));
428        assert!(change(Readers::Unrestricted).reaches(Reach::AllAs(OTHER)));
429        assert!(!change(Readers::Unrestricted).reaches(Reach::OwnedBy(OWNER)));
430    }
431
432    #[test]
433    fn payloads_read_back_as_written_and_stay_under_the_limit() {
434        let changes: Vec<TableChange> = (0..400_u32)
435            .map(|index| TableChange {
436                topic: Topic::named("shop", &index.to_string()),
437                readers: match index % 3 {
438                    0 => Readers::Anyone,
439                    1 => Readers::Unrestricted,
440                    _ => Readers::Owner(OWNER),
441                },
442            })
443            .collect();
444
445        let written = payloads(7, &changes);
446
447        assert!(written.len() > 1);
448        assert!(written.iter().all(|payload| payload.len() <= PAYLOAD_LIMIT));
449        let read: Vec<TableChange> = written
450            .iter()
451            .flat_map(|payload| {
452                let (instance, changes) = parsed(payload).unwrap();
453                assert_eq!(instance, 7);
454                changes
455            })
456            .collect();
457        assert_eq!(read, changes);
458        assert_eq!(parsed("7 nonsense"), None);
459    }
460
461    #[test]
462    fn a_topic_is_stable_and_round_trips_as_hex() {
463        let topic = Topic::named("shop", "order");
464
465        assert_eq!(topic, Topic::named("shop", "order"));
466        assert_ne!(topic, Topic::named("shop", "orders"));
467        assert_eq!(Topic::parse(&topic.to_string()), Some(topic));
468        assert_eq!(Topic::parse("xyz"), None);
469    }
470
471    #[tokio::test]
472    async fn reads_are_heard_until_one_cannot_name_its_tables() {
473        let order = Topic::named("shop", "order");
474        let ((), named) = track_topics(async { read_tables([("shop", "order")]) }).await;
475        let ((), unnamed) = track_topics(async {
476            read_tables([("shop", "order")]);
477            read_anything();
478        })
479        .await;
480
481        assert_eq!(named, Topics::Of(BTreeSet::from([order])));
482        assert_eq!(unnamed, Topics::Every);
483        assert!(unnamed.covers(Topic::named("other", "table")));
484        read_anything();
485    }
486}