Skip to main content

soaprs_memory/
realtime.rs

1//! In-memory realtime presence, membership, and delivery adapters.
2
3use std::{
4    collections::{BTreeMap, BTreeSet, VecDeque},
5    future::Future,
6    num::NonZeroUsize,
7    pin::Pin,
8    sync::{Mutex, RwLock},
9    task::{Context, Poll, Waker},
10};
11
12use soaprs_auth::{Principal, PrincipalId};
13use soaprs_core::{BoxFuture, SoapError, SoapResult};
14use soaprs_realtime::{
15    BackpressurePolicy, ChannelId, ChannelMembership, ConnectionContext, ConnectionId,
16    ConnectionPresence, DeliveryOutcome, JoinOutcome, LeaveOutcome, OutboundDelivery,
17    RealtimeDelivery,
18};
19
20/// Thread-safe active connection directory.
21#[derive(Debug)]
22pub struct MemoryConnectionPresence<P> {
23    connections: RwLock<BTreeMap<ConnectionId, ConnectionContext<P>>>,
24}
25
26impl<P> MemoryConnectionPresence<P> {
27    /// Creates an empty connection directory.
28    pub const fn new() -> Self {
29        Self {
30            connections: RwLock::new(BTreeMap::new()),
31        }
32    }
33
34    fn read(
35        &self,
36    ) -> SoapResult<std::sync::RwLockReadGuard<'_, BTreeMap<ConnectionId, ConnectionContext<P>>>>
37    {
38        self.connections
39            .read()
40            .map_err(|_| SoapError::infrastructure("in-memory presence read lock poisoned"))
41    }
42
43    fn write(
44        &self,
45    ) -> SoapResult<std::sync::RwLockWriteGuard<'_, BTreeMap<ConnectionId, ConnectionContext<P>>>>
46    {
47        self.connections
48            .write()
49            .map_err(|_| SoapError::infrastructure("in-memory presence write lock poisoned"))
50    }
51}
52
53impl<P> Default for MemoryConnectionPresence<P> {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl<P> ConnectionPresence<P> for MemoryConnectionPresence<P>
60where
61    P: Principal + Clone + Send + Sync,
62{
63    fn register(&self, context: ConnectionContext<P>) -> BoxFuture<'_, SoapResult<()>> {
64        Box::pin(async move {
65            let mut connections = self.write()?;
66            if connections.contains_key(context.connection_id()) {
67                return Err(SoapError::conflict(format!(
68                    "realtime connection `{}` is already registered",
69                    context.connection_id()
70                )));
71            }
72            connections.insert(context.connection_id().clone(), context);
73            Ok(())
74        })
75    }
76
77    fn remove<'a>(
78        &'a self,
79        connection_id: &'a ConnectionId,
80    ) -> BoxFuture<'a, SoapResult<Option<ConnectionContext<P>>>> {
81        Box::pin(async move { Ok(self.write()?.remove(connection_id)) })
82    }
83
84    fn connection<'a>(
85        &'a self,
86        connection_id: &'a ConnectionId,
87    ) -> BoxFuture<'a, SoapResult<Option<ConnectionContext<P>>>> {
88        Box::pin(async move { Ok(self.read()?.get(connection_id).cloned()) })
89    }
90
91    fn connections(&self) -> BoxFuture<'_, SoapResult<Vec<ConnectionContext<P>>>> {
92        Box::pin(async move { Ok(self.read()?.values().cloned().collect()) })
93    }
94
95    fn principal_connections<'a>(
96        &'a self,
97        principal_id: &'a PrincipalId,
98    ) -> BoxFuture<'a, SoapResult<Vec<ConnectionContext<P>>>> {
99        Box::pin(async move {
100            Ok(self
101                .read()?
102                .values()
103                .filter(|context| {
104                    context.authentication().is_some_and(|authentication| {
105                        authentication.principal().principal_id() == principal_id
106                    })
107                })
108                .cloned()
109                .collect())
110        })
111    }
112}
113
114#[derive(Debug, Default)]
115struct MembershipState {
116    channels: BTreeMap<ChannelId, BTreeSet<ConnectionId>>,
117    connections: BTreeMap<ConnectionId, BTreeSet<ChannelId>>,
118}
119
120/// Thread-safe bidirectional channel membership.
121#[derive(Debug, Default)]
122pub struct MemoryChannelMembership {
123    state: RwLock<MembershipState>,
124}
125
126impl MemoryChannelMembership {
127    /// Creates empty channel membership.
128    pub const fn new() -> Self {
129        Self {
130            state: RwLock::new(MembershipState {
131                channels: BTreeMap::new(),
132                connections: BTreeMap::new(),
133            }),
134        }
135    }
136
137    fn read(&self) -> SoapResult<std::sync::RwLockReadGuard<'_, MembershipState>> {
138        self.state
139            .read()
140            .map_err(|_| SoapError::infrastructure("in-memory membership read lock poisoned"))
141    }
142
143    fn write(&self) -> SoapResult<std::sync::RwLockWriteGuard<'_, MembershipState>> {
144        self.state
145            .write()
146            .map_err(|_| SoapError::infrastructure("in-memory membership write lock poisoned"))
147    }
148}
149
150impl ChannelMembership for MemoryChannelMembership {
151    fn join<'a>(
152        &'a self,
153        connection_id: &'a ConnectionId,
154        channel_id: &'a ChannelId,
155    ) -> BoxFuture<'a, SoapResult<JoinOutcome>> {
156        Box::pin(async move {
157            let mut state = self.write()?;
158            let joined = state
159                .channels
160                .entry(channel_id.clone())
161                .or_default()
162                .insert(connection_id.clone());
163            state
164                .connections
165                .entry(connection_id.clone())
166                .or_default()
167                .insert(channel_id.clone());
168            Ok(if joined {
169                JoinOutcome::Joined
170            } else {
171                JoinOutcome::AlreadyMember
172            })
173        })
174    }
175
176    fn leave<'a>(
177        &'a self,
178        connection_id: &'a ConnectionId,
179        channel_id: &'a ChannelId,
180    ) -> BoxFuture<'a, SoapResult<LeaveOutcome>> {
181        Box::pin(async move {
182            let mut state = self.write()?;
183            let removed = state
184                .channels
185                .get_mut(channel_id)
186                .is_some_and(|members| members.remove(connection_id));
187            if state
188                .channels
189                .get(channel_id)
190                .is_some_and(BTreeSet::is_empty)
191            {
192                state.channels.remove(channel_id);
193            }
194            if let Some(channels) = state.connections.get_mut(connection_id) {
195                channels.remove(channel_id);
196                if channels.is_empty() {
197                    state.connections.remove(connection_id);
198                }
199            }
200            Ok(if removed {
201                LeaveOutcome::Left
202            } else {
203                LeaveOutcome::NotMember
204            })
205        })
206    }
207
208    fn members<'a>(
209        &'a self,
210        channel_id: &'a ChannelId,
211    ) -> BoxFuture<'a, SoapResult<Vec<ConnectionId>>> {
212        Box::pin(async move {
213            Ok(self
214                .read()?
215                .channels
216                .get(channel_id)
217                .map(|members| members.iter().cloned().collect())
218                .unwrap_or_default())
219        })
220    }
221
222    fn channels<'a>(
223        &'a self,
224        connection_id: &'a ConnectionId,
225    ) -> BoxFuture<'a, SoapResult<Vec<ChannelId>>> {
226        Box::pin(async move {
227            Ok(self
228                .read()?
229                .connections
230                .get(connection_id)
231                .map(|channels| channels.iter().cloned().collect())
232                .unwrap_or_default())
233        })
234    }
235
236    fn remove_connection<'a>(
237        &'a self,
238        connection_id: &'a ConnectionId,
239    ) -> BoxFuture<'a, SoapResult<Vec<ChannelId>>> {
240        Box::pin(async move {
241            let mut state = self.write()?;
242            let channels = state
243                .connections
244                .remove(connection_id)
245                .unwrap_or_default()
246                .into_iter()
247                .collect::<Vec<_>>();
248            for channel_id in &channels {
249                if let Some(members) = state.channels.get_mut(channel_id) {
250                    members.remove(connection_id);
251                    if members.is_empty() {
252                        state.channels.remove(channel_id);
253                    }
254                }
255            }
256            Ok(channels)
257        })
258    }
259}
260
261struct DeliveryState<M> {
262    queue: VecDeque<OutboundDelivery<M>>,
263    waiters: Vec<Waker>,
264}
265
266/// Runtime-independent bounded or unbounded outbound delivery recorder.
267///
268/// Bounded instances expose the contract semantics of wait, reject, and drop
269/// backpressure. Draining the queue wakes pending waiters.
270pub struct MemoryRealtimeDelivery<M> {
271    capacity: Option<NonZeroUsize>,
272    state: Mutex<DeliveryState<M>>,
273}
274
275impl<M> MemoryRealtimeDelivery<M> {
276    /// Creates an unbounded recorder that accepts every delivery.
277    pub const fn new() -> Self {
278        Self {
279            capacity: None,
280            state: Mutex::new(DeliveryState {
281                queue: VecDeque::new(),
282                waiters: Vec::new(),
283            }),
284        }
285    }
286
287    /// Creates a recorder with explicit non-zero queue capacity.
288    pub const fn bounded(capacity: NonZeroUsize) -> Self {
289        Self {
290            capacity: Some(capacity),
291            state: Mutex::new(DeliveryState {
292                queue: VecDeque::new(),
293                waiters: Vec::new(),
294            }),
295        }
296    }
297
298    /// Returns the current accepted queue length.
299    pub fn len(&self) -> SoapResult<usize> {
300        Ok(self.lock()?.queue.len())
301    }
302
303    /// Reports whether no accepted deliveries are queued.
304    pub fn is_empty(&self) -> SoapResult<bool> {
305        self.len().map(|length| length == 0)
306    }
307
308    /// Removes every accepted delivery in enqueue order and wakes waiters.
309    pub fn drain(&self) -> SoapResult<Vec<OutboundDelivery<M>>> {
310        let (deliveries, waiters) = {
311            let mut state = self.lock()?;
312            let deliveries = state.queue.drain(..).collect();
313            let waiters = std::mem::take(&mut state.waiters);
314            (deliveries, waiters)
315        };
316        for waiter in waiters {
317            waiter.wake();
318        }
319        Ok(deliveries)
320    }
321
322    fn lock(&self) -> SoapResult<std::sync::MutexGuard<'_, DeliveryState<M>>> {
323        self.state
324            .lock()
325            .map_err(|_| SoapError::infrastructure("in-memory delivery lock poisoned"))
326    }
327
328    fn has_capacity(&self, length: usize) -> bool {
329        self.capacity.is_none_or(|capacity| length < capacity.get())
330    }
331}
332
333impl<M> Default for MemoryRealtimeDelivery<M> {
334    fn default() -> Self {
335        Self::new()
336    }
337}
338
339impl<M> RealtimeDelivery<M> for MemoryRealtimeDelivery<M>
340where
341    M: Send,
342{
343    fn deliver(&self, delivery: OutboundDelivery<M>) -> BoxFuture<'_, SoapResult<DeliveryOutcome>> {
344        Box::pin(MemoryDeliveryFuture {
345            delivery: Mutex::new(Some(delivery)),
346            sink: self,
347        })
348    }
349}
350
351struct MemoryDeliveryFuture<'a, M> {
352    delivery: Mutex<Option<OutboundDelivery<M>>>,
353    sink: &'a MemoryRealtimeDelivery<M>,
354}
355
356impl<M> Future for MemoryDeliveryFuture<'_, M>
357where
358    M: Send,
359{
360    type Output = SoapResult<DeliveryOutcome>;
361
362    fn poll(self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
363        let future = self.as_ref().get_ref();
364        let mut state = match self.sink.lock() {
365            Ok(state) => state,
366            Err(error) => return Poll::Ready(Err(error)),
367        };
368        if self.sink.has_capacity(state.queue.len()) {
369            let mut delivery = match future.delivery.lock() {
370                Ok(delivery) => delivery,
371                Err(_) => {
372                    return Poll::Ready(Err(SoapError::infrastructure(
373                        "in-memory delivery future lock poisoned",
374                    )));
375                }
376            };
377            let Some(delivery) = delivery.take() else {
378                return Poll::Ready(Err(SoapError::infrastructure(
379                    "in-memory delivery future was polled after completion",
380                )));
381            };
382            state.queue.push_back(delivery);
383            return Poll::Ready(Ok(DeliveryOutcome::Accepted));
384        }
385
386        let backpressure = match future.delivery.lock() {
387            Ok(delivery) => delivery.as_ref().map(|delivery| delivery.backpressure),
388            Err(_) => {
389                return Poll::Ready(Err(SoapError::infrastructure(
390                    "in-memory delivery future lock poisoned",
391                )));
392            }
393        };
394        let Some(backpressure) = backpressure else {
395            return Poll::Ready(Err(SoapError::infrastructure(
396                "in-memory delivery future lost its message",
397            )));
398        };
399        match backpressure {
400            BackpressurePolicy::Wait => {
401                if !state
402                    .waiters
403                    .iter()
404                    .any(|waiter| waiter.will_wake(context.waker()))
405                {
406                    state.waiters.push(context.waker().clone());
407                }
408                Poll::Pending
409            }
410            BackpressurePolicy::Reject => Poll::Ready(Err(SoapError::unavailable(
411                "realtime outbound delivery queue is full",
412            ))),
413            BackpressurePolicy::DropNewest => match future.delivery.lock() {
414                Ok(mut delivery) => {
415                    *delivery = None;
416                    Poll::Ready(Ok(DeliveryOutcome::Dropped))
417                }
418                Err(_) => Poll::Ready(Err(SoapError::infrastructure(
419                    "in-memory delivery future lock poisoned",
420                ))),
421            },
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use std::{
429        num::NonZeroUsize,
430        pin::pin,
431        task::{Context, Poll, Waker},
432        time::SystemTime,
433    };
434
435    use soaprs_auth::{Authentication, Principal, StandardPrincipal};
436    use soaprs_contract_tests::{
437        block_on, verify_channel_membership_contract, verify_connection_presence_contract,
438    };
439    use soaprs_core::{MessageEnvelope, MessageMetadata, SoapErrorKind};
440    use soaprs_realtime::{
441        BackpressurePolicy, ChannelId, ConnectionContext, ConnectionId, DeliveryOutcome,
442        OutboundDelivery, RealtimeDelivery,
443    };
444
445    use super::{MemoryChannelMembership, MemoryConnectionPresence, MemoryRealtimeDelivery};
446
447    #[test]
448    fn presence_and_membership_pass_shared_contracts() {
449        let Some(first_id) = ConnectionId::new("connection-b").ok() else {
450            panic!("valid connection id");
451        };
452        let Some(second_id) = ConnectionId::new("connection-a").ok() else {
453            panic!("valid connection id");
454        };
455        let principal = || {
456            StandardPrincipal::new("player-1")
457                .and_then(|principal| principal.role("player"))
458                .and_then(|principal| Authentication::new("session", principal))
459        };
460        let (Some(first_auth), Some(second_auth)) = (principal().ok(), principal().ok()) else {
461            panic!("valid authentication");
462        };
463        let first = ConnectionContext::new(first_id.clone(), SystemTime::UNIX_EPOCH)
464            .authenticated(first_auth);
465        let second = ConnectionContext::new(second_id.clone(), SystemTime::UNIX_EPOCH)
466            .authenticated(second_auth);
467        let principal_id = first
468            .authentication()
469            .map(|authentication| authentication.principal().principal_id().clone());
470        let Some(principal_id) = principal_id else {
471            panic!("authenticated fixture");
472        };
473
474        let presence = MemoryConnectionPresence::new();
475        assert!(
476            block_on(verify_connection_presence_contract(
477                &presence,
478                first,
479                second,
480                &principal_id,
481            ))
482            .is_ok()
483        );
484
485        let Some(first_channel) = ChannelId::new("channel-b").ok() else {
486            panic!("valid channel id");
487        };
488        let Some(second_channel) = ChannelId::new("channel-a").ok() else {
489            panic!("valid channel id");
490        };
491        let membership = MemoryChannelMembership::new();
492        assert!(
493            block_on(verify_channel_membership_contract(
494                &membership,
495                &first_id,
496                &second_id,
497                &first_channel,
498                &second_channel,
499            ))
500            .is_ok()
501        );
502    }
503
504    #[test]
505    fn bounded_delivery_exposes_wait_reject_and_drop_backpressure() {
506        let capacity = NonZeroUsize::new(1).unwrap_or(NonZeroUsize::MIN);
507        let delivery = MemoryRealtimeDelivery::bounded(capacity);
508        let Some(connection_id) = ConnectionId::new("connection-1").ok() else {
509            panic!("valid connection id");
510        };
511        let message = |id: &'static str| {
512            MessageEnvelope::new(
513                id.to_owned(),
514                MessageMetadata::new(id, SystemTime::UNIX_EPOCH),
515            )
516        };
517
518        let first = OutboundDelivery::direct(connection_id.clone(), message("first"));
519        assert_eq!(
520            block_on(delivery.deliver(first)).ok(),
521            Some(DeliveryOutcome::Accepted)
522        );
523
524        let dropped = OutboundDelivery::direct(connection_id.clone(), message("dropped"))
525            .backpressure(BackpressurePolicy::DropNewest);
526        assert_eq!(
527            block_on(delivery.deliver(dropped)).ok(),
528            Some(DeliveryOutcome::Dropped)
529        );
530
531        let rejected = OutboundDelivery::direct(connection_id.clone(), message("rejected"))
532            .backpressure(BackpressurePolicy::Reject);
533        assert_eq!(
534            block_on(delivery.deliver(rejected))
535                .as_ref()
536                .map_err(soaprs_core::SoapError::kind),
537            Err(SoapErrorKind::Unavailable)
538        );
539
540        let waiting = OutboundDelivery::direct(connection_id, message("waiting"));
541        let mut waiting = pin!(delivery.deliver(waiting));
542        let mut context = Context::from_waker(Waker::noop());
543        assert!(matches!(waiting.as_mut().poll(&mut context), Poll::Pending));
544
545        let first_batch = delivery.drain();
546        assert_eq!(
547            first_batch
548                .ok()
549                .and_then(|batch| batch.first().map(|item| item.envelope.message.clone())),
550            Some("first".to_owned())
551        );
552        assert!(matches!(
553            waiting.as_mut().poll(&mut context),
554            Poll::Ready(Ok(DeliveryOutcome::Accepted))
555        ));
556        assert_eq!(delivery.len().ok(), Some(1));
557    }
558}