Skip to main content

liminal_server/tap/
subscription.rs

1//! The tap and its subscriptions.
2
3use liminal::channel::{PublishRecord, Recipient};
4use std::collections::HashMap;
5use std::num::NonZeroU32;
6use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
7use std::sync::{Arc, Mutex, OnceLock};
8use std::time::Instant;
9
10use super::error::{TapError, lock};
11use super::message::{
12    Hop, PayloadGrant, PublishFact, Reached, TapBatch, TapFilter, TapIdent, TapMessage, TapSource,
13};
14use super::ring::BoundedRing;
15mod call;
16mod publish;
17mod reader;
18
19/// Default per-subscriber ring capacity, in messages.
20pub const DEFAULT_TAP_CAPACITY: usize = 4096;
21
22/// The largest sampling divisor the server will choose.
23pub const MAX_SAMPLE_ONE_IN: NonZeroU32 = match NonZeroU32::new(1 << 16) {
24    Some(most) => most,
25    None => NonZeroU32::MIN,
26};
27
28const TWO: NonZeroU32 = NonZeroU32::MIN.saturating_add(1);
29
30/// Wakes whoever drains the tap; called at most once per publish, after every
31/// tap lock is released.
32pub type TapWaker = Arc<dyn Fn() + Send + Sync>;
33
34/// One kept message and the divisor it was admitted under, so a drain can
35/// close a batch wherever the divisor changed without a second ring.
36#[derive(Debug)]
37struct Admitted {
38    one_in: NonZeroU32,
39    message: TapMessage,
40}
41
42#[derive(Debug)]
43struct SubscriberState {
44    ring: BoundedRing<Admitted>,
45    one_in: NonZeroU32,
46    seen: u64,
47    next_seq: u64,
48}
49
50impl SubscriberState {
51    /// The sampling decision for one matching publish, taken before anything
52    /// is allocated for it.
53    fn admits(&mut self) -> bool {
54        let take = self.seen % u64::from(self.one_in.get()) == 0;
55        self.seen = self.seen.wrapping_add(1);
56        take
57    }
58
59    /// Doubles the divisor once the ring is more than half full.
60    fn after_push(&mut self) {
61        if self.ring.len().saturating_mul(2) > self.ring.capacity() {
62            self.one_in = self.one_in.saturating_mul(TWO).min(MAX_SAMPLE_ONE_IN);
63        }
64    }
65}
66
67struct Subscriber {
68    id: u64,
69    filter: TapFilter,
70    grant: Option<PayloadGrant>,
71    state: Mutex<SubscriberState>,
72}
73
74impl std::fmt::Debug for Subscriber {
75    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        formatter
77            .debug_struct("Subscriber")
78            .field("id", &self.id)
79            .field("filter", &self.filter)
80            .finish_non_exhaustive()
81    }
82}
83
84#[derive(Default)]
85struct TapShared {
86    active: AtomicUsize,
87    observed: AtomicU64,
88    /// Of `observed`, the server's own publishes (`liminal.<device>`).
89    /// Test-only.
90    #[cfg(test)]
91    observed_server: AtomicU64,
92    dropped_total: AtomicU64,
93    unsubscribe_failures: AtomicU64,
94    next_id: AtomicU64,
95    subscribers: Mutex<Vec<Arc<Subscriber>>>,
96    /// Each registered worker's connection and its station id, so a kept
97    /// message names its publishing station without the topology lock.
98    stations: Mutex<HashMap<u64, Arc<str>>>,
99    /// Each live subscription's subscriber pid and its connection, so a
100    /// recipient the fan-out recorded names its connection's station.
101    receivers: Mutex<HashMap<u64, u64>>,
102    /// Recipient buffers between publishes, so a recording publish reuses
103    /// an allocation rather than making one.
104    buffers: Mutex<Vec<PublishRecord>>,
105    /// Recipient buffers lent: publishes that recorded their recipients.
106    lent: AtomicU64,
107    /// The one empty station list, shared by every kept message whose
108    /// recorded fan-out reached no registered worker, so naming none
109    /// allocates nothing.
110    no_stations: Arc<[Reached]>,
111    waker: OnceLock<TapWaker>,
112    /// The ring each subscription taken from now on holds, never zero.
113    capacity: AtomicUsize,
114}
115
116impl std::fmt::Debug for TapShared {
117    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        formatter
119            .debug_struct("TapShared")
120            .field("active", &self.active)
121            .field("observed", &self.observed)
122            .field("dropped_total", &self.dropped_total)
123            .finish_non_exhaustive()
124    }
125}
126
127/// The server's tap. Cheap to clone; every clone is the same tap.
128#[derive(Clone, Debug)]
129pub struct Tap {
130    shared: Arc<TapShared>,
131    node: Arc<str>,
132    /// This server's depot's station id, when it keeps a persistent store:
133    /// a durable message's hop between its publisher and the exchange.
134    depot: Option<Arc<str>>,
135}
136
137impl Tap {
138    /// A tap whose hops name `node`, with per-subscriber rings of `capacity`,
139    /// that wakes nobody: for a reader that drains when it chooses, as a test
140    /// does.
141    #[must_use]
142    pub fn new(node: &str, capacity: usize) -> Self {
143        Self::build(node, capacity, OnceLock::new())
144    }
145
146    /// A tap that calls `waker` once per publish any subscription kept.
147    /// Fixed at construction, so the hot path reads it without a lock and
148    /// nothing can replace it.
149    #[must_use]
150    pub fn with_waker(node: &str, capacity: usize, waker: TapWaker) -> Self {
151        Self::build(node, capacity, OnceLock::from(waker))
152    }
153
154    /// Sets the ring capacity subscriptions taken from now on hold.
155    #[cfg(any(test, feature = "test-support"))]
156    pub fn set_capacity(&self, capacity: usize) {
157        self.shared
158            .capacity
159            .store(capacity.max(1), Ordering::Relaxed);
160    }
161
162    fn build(node: &str, capacity: usize, waker: OnceLock<TapWaker>) -> Self {
163        Self {
164            shared: Arc::new(TapShared {
165                waker,
166                capacity: AtomicUsize::new(capacity.max(1)),
167                ..TapShared::default()
168            }),
169            node: Arc::from(node),
170            depot: None,
171        }
172    }
173
174    /// This tap, on a server that keeps a persistent store when `depot`:
175    /// its kept durable messages then pass `<node>.depot` (lane brief
176    /// ruling 8).
177    #[must_use]
178    pub fn with_depot(mut self, depot: bool) -> Self {
179        self.depot = depot.then(|| Arc::from(format!("{}.depot", self.node)));
180        self
181    }
182
183    /// The station `id` would collide with: this tap's exchange (its node)
184    /// or its depot. A worker with such an identity names no station here.
185    #[must_use]
186    pub fn reserved(&self, id: &str) -> Option<&'static str> {
187        if id == &*self.node {
188            Some("exchange")
189        } else if self.depot.as_deref() == Some(id) {
190            Some("depot")
191        } else {
192            None
193        }
194    }
195
196    /// Subscribes without payloads.
197    ///
198    /// # Errors
199    /// Returns [`TapError::Poisoned`] when the subscriber list is poisoned.
200    pub fn subscribe(&self, filter: TapFilter) -> Result<TapSubscription, TapError> {
201        self.subscribe_with(filter, None)
202    }
203
204    /// Subscribes, with payloads when `grant` is present.
205    ///
206    /// # Errors
207    /// Returns [`TapError::Poisoned`] when the subscriber list is poisoned.
208    pub fn subscribe_with(
209        &self,
210        filter: TapFilter,
211        grant: Option<PayloadGrant>,
212    ) -> Result<TapSubscription, TapError> {
213        let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed);
214        let subscriber = Arc::new(Subscriber {
215            id,
216            filter,
217            grant,
218            state: Mutex::new(SubscriberState {
219                ring: BoundedRing::new(self.shared.capacity.load(Ordering::Relaxed)),
220                one_in: NonZeroU32::MIN,
221                seen: 0,
222                next_seq: 1,
223            }),
224        });
225        let mut subscribers = lock(&self.shared.subscribers, "tap subscribers")?;
226        subscribers.push(Arc::clone(&subscriber));
227        self.shared
228            .active
229            .store(subscribers.len(), Ordering::Release);
230        drop(subscribers);
231        Ok(TapSubscription {
232            shared: Arc::clone(&self.shared),
233            subscriber,
234        })
235    }
236
237    /// Publishes the hook has shown the tap while a subscription was live (a
238    /// count pin: N ordinary publishes give N, the tap's own records give
239    /// zero).
240    #[must_use]
241    pub fn observations(&self) -> u64 {
242        self.shared.observed.load(Ordering::Relaxed)
243    }
244
245    /// Of [`Self::observations`], those of the server's own publishes (its
246    /// `liminal.events` and `liminal.cost` rows), which its publisher owner
247    /// writes on its own schedule. Test-only.
248    #[cfg(test)]
249    #[must_use]
250    pub(crate) fn server_observations(&self) -> u64 {
251        self.shared.observed_server.load(Ordering::Relaxed)
252    }
253
254    /// Live subscriptions.
255    #[must_use]
256    pub fn subscriber_count(&self) -> usize {
257        self.shared.active.load(Ordering::Acquire)
258    }
259
260    /// Messages evicted from every subscription's ring since the tap was made.
261    #[must_use]
262    pub fn dropped_total(&self) -> u64 {
263        self.shared.dropped_total.load(Ordering::Relaxed)
264    }
265
266    /// Poisons the subscriber list, as a panic on a thread holding it would,
267    /// and answers whether it is now poisoned. Test-only.
268    #[cfg(test)]
269    pub(crate) fn poison_subscribers(&self) -> bool {
270        std::thread::scope(|scope| {
271            scope
272                .spawn(|| {
273                    let _held = self.shared.subscribers.lock();
274                    std::panic::resume_unwind(Box::new("poisoned for a test"));
275                })
276                .join()
277                .is_err()
278        }) && self.shared.subscribers.is_poisoned()
279    }
280
281    /// Names `station` as the station `connection` publishes as, from its
282    /// worker registration until [`Self::forget_station`].
283    ///
284    /// # Errors
285    /// Returns [`TapError::Poisoned`] when the station table is poisoned.
286    pub fn name_station(&self, connection: u64, station: &str) -> Result<(), TapError> {
287        lock(&self.shared.stations, "tap stations")?.insert(connection, Arc::from(station));
288        Ok(())
289    }
290
291    /// Forgets `connection`'s station, as its connection closes.
292    ///
293    /// # Errors
294    /// Returns [`TapError::Poisoned`] when the station table is poisoned.
295    pub fn forget_station(&self, connection: u64) -> Result<(), TapError> {
296        lock(&self.shared.stations, "tap stations")?.remove(&connection);
297        Ok(())
298    }
299
300    /// Names `connection` as the owner of the subscription whose subscriber
301    /// pid is `subscriber`, until [`Self::forget_receiver`].
302    ///
303    /// # Errors
304    /// Returns [`TapError::Poisoned`] when the receiver table is poisoned.
305    pub fn name_receiver(&self, subscriber: u64, connection: u64) -> Result<(), TapError> {
306        lock(&self.shared.receivers, "tap receivers")?.insert(subscriber, connection);
307        Ok(())
308    }
309
310    /// Forgets the subscription whose subscriber pid is `subscriber`, as it
311    /// leaves its channel.
312    ///
313    /// # Errors
314    /// Returns [`TapError::Poisoned`] when the receiver table is poisoned.
315    pub fn forget_receiver(&self, subscriber: u64) -> Result<(), TapError> {
316        lock(&self.shared.receivers, "tap receivers")?.remove(&subscriber);
317        Ok(())
318    }
319
320    /// Forgets every subscription of `connection`, as it closes.
321    ///
322    /// # Errors
323    /// Returns [`TapError::Poisoned`] when the receiver table is poisoned.
324    pub fn forget_receivers_of(&self, connection: u64) -> Result<(), TapError> {
325        lock(&self.shared.receivers, "tap receivers")?.retain(|_, owner| *owner != connection);
326        Ok(())
327    }
328
329    /// A buffer for a publish to record its recipients in, only while a
330    /// subscription is live: with none it is one atomic load, no lock and no
331    /// allocation, and the publish records nothing. A buffer lent comes from
332    /// those given back, so it allocates only while the pool and each buffer
333    /// grow to the widest concurrent fan-out.
334    ///
335    /// # Errors
336    /// Returns [`TapError::Poisoned`] when the buffer pool is poisoned.
337    pub fn lend_recipients(&self) -> Result<Option<PublishRecord>, TapError> {
338        if self.shared.active.load(Ordering::Acquire) == 0 {
339            return Ok(None);
340        }
341        self.shared.lent.fetch_add(1, Ordering::Relaxed);
342        Ok(Some(
343            lock(&self.shared.buffers, "tap buffers")?
344                .pop()
345                .unwrap_or_default(),
346        ))
347    }
348
349    /// Takes back a buffer [`Self::lend_recipients`] lent, emptied, for the
350    /// next recording publish.
351    ///
352    /// # Errors
353    /// Returns [`TapError::Poisoned`] when the buffer pool is poisoned.
354    pub fn give_back(&self, mut buffer: PublishRecord) -> Result<(), TapError> {
355        buffer.clear();
356        lock(&self.shared.buffers, "tap buffers")?.push(buffer);
357        Ok(())
358    }
359
360    /// Recipient buffers lent since the tap was made: publishes that asked
361    /// the fan-out for their recipients.
362    #[must_use]
363    pub fn recipients_lent(&self) -> u64 {
364        self.shared.lent.load(Ordering::Relaxed)
365    }
366
367    /// Subscriptions whose drop found the subscriber list poisoned and so
368    /// could not leave it.
369    #[must_use]
370    pub fn unsubscribe_failures(&self) -> u64 {
371        self.shared.unsubscribe_failures.load(Ordering::Relaxed)
372    }
373}
374
375/// One subscriber's handle. Dropping it unsubscribes.
376#[derive(Debug)]
377pub struct TapSubscription {
378    shared: Arc<TapShared>,
379    subscriber: Arc<Subscriber>,
380}
381
382/// `elapsed` in whole milliseconds; `None` beyond what a u64 holds, which
383/// the caller names rather than writing the most a u64 holds.
384fn elapsed_ms(elapsed: std::time::Duration) -> Option<u64> {
385    u64::try_from(elapsed.as_millis()).ok()
386}
387
388/// The `named` first-named stations of `recipients` recipients, in order, in
389/// one allocation: `first_at(index)` is the station the recipient at `index`
390/// names first, if it names one, with the instant that recipient was queued. `None` when none is named.
391///
392/// # Errors
393/// Returns [`TapError::StationsDisagree`] when the fill finds other than
394/// `named` stations.
395fn gather<'station>(
396    recipients: usize,
397    named: usize,
398    first_at: impl Fn(usize) -> Option<(&'station Arc<str>, Instant)>,
399) -> Result<Option<Arc<[Reached]>>, TapError> {
400    let Some(first) = (0..recipients).find_map(&first_at) else {
401        return if named == 0 {
402            Ok(None)
403        } else {
404            Err(TapError::StationsDisagree { named, found: 0 })
405        };
406    };
407    // A range mapped is of known length, so the collect allocates once.
408    // Each step finds the next first-named station. A step that finds none
409    // fills its slot with `first` only so the slice is whole; the count of
410    // those found then refuses the list, so that slot never leaves here.
411    let mut next = 0;
412    let mut found = 0_usize;
413    let reached = |(station, queued_at): (&Arc<str>, Instant)| Reached {
414        station: Arc::clone(station),
415        queued_at,
416    };
417    let list: Arc<[Reached]> = (0..named)
418        .map(|_| {
419            match (next..recipients)
420                .find_map(|index| first_at(index).map(|station| (index, station)))
421            {
422                Some((index, station)) => {
423                    next = index + 1;
424                    found += 1;
425                    reached(station)
426                }
427                None => reached(first),
428            }
429        })
430        .collect();
431    let beyond = (next..recipients)
432        .filter(|index| first_at(*index).is_some())
433        .count();
434    if found != named || beyond != 0 {
435        return Err(TapError::StationsDisagree {
436            named,
437            found: found + beyond,
438        });
439    }
440    Ok(Some(list))
441}
442
443#[cfg(test)]
444mod gather_tests {
445    use std::sync::Arc;
446    use std::time::{Duration, Instant};
447
448    use super::{Reached, TapError, elapsed_ms, gather};
449
450    /// Buttercup's V2, Hermes 04:27: a hop further out than u64
451    /// milliseconds is named, never written as the most a u64 holds.
452    #[test]
453    fn an_elapsed_time_beyond_u64_milliseconds_has_no_reading() {
454        assert_eq!(elapsed_ms(Duration::from_millis(7)), Some(7));
455        assert_eq!(elapsed_ms(Duration::MAX), None);
456    }
457
458    /// Buttercup's finding 2 (Hermes 03:05): when the filling pass finds
459    /// fewer stations than the count named, the list is refused by name,
460    /// never filled in with a station that stands in for the missing one.
461    /// The count named two; the fill finds only the ledger, as if the audit
462    /// worker had left between the passes.
463    #[test]
464    fn a_fill_that_disagrees_with_the_count_is_refused() {
465        let ledger: Arc<str> = Arc::from("orders.ledger");
466        let queued = Instant::now();
467        let gathered = gather(2, 2, |index| (index == 0).then_some((&ledger, queued)));
468        assert!(
469            matches!(
470                gathered,
471                Err(TapError::StationsDisagree { named: 2, found: 1 })
472            ),
473            "{gathered:?}"
474        );
475    }
476
477    /// Positive control: passes that agree build the list, and none named
478    /// builds none.
479    #[test]
480    fn a_fill_that_agrees_builds_the_list() -> Result<(), TapError> {
481        let ledger: Arc<str> = Arc::from("orders.ledger");
482        let audit: Arc<str> = Arc::from("orders.audit");
483        let queued = Instant::now();
484        let first_at = |index: usize| match index {
485            0 => Some((&ledger, queued)),
486            2 => Some((&audit, queued)),
487            _ => None,
488        };
489        let reached = |station: &Arc<str>| Reached {
490            station: Arc::clone(station),
491            queued_at: queued,
492        };
493        assert_eq!(
494            gather(3, 2, first_at)?,
495            Some(Arc::from(vec![reached(&ledger), reached(&audit)]))
496        );
497        assert_eq!(gather(3, 0, |_| None)?, None);
498        Ok(())
499    }
500}