rifts 0.3.4

Rift Realtime Protocol / 1.0 — server + client implementation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! Topic registry -- lazily spawns [`TopicActor`]s keyed by topic name.
//!
//! The [`TopicRegistry`] is the main entry point for obtaining actor
//! references in the actor subsystem.  It uses a lock-free [`DashMap`]
//! to map each topic name (e.g. `"room/1"`) to a [`LocalActorRef`]
//! backed by a dedicated [`TopicActor`].  Actors are spawned lazily
//! on the first request for a given topic and reused on subsequent
//! calls.
//!
//! # Reverse indices
//!
//! Beyond the primary actor map, the registry maintains two reverse
//! indices that the [`ActorBroker`](crate::broker::ActorBroker) uses to
//! implement `unsubscribe`, `drop_sink`, and `subscriber_count` without
//! broadcasting a query to every actor:
//!
//! - **`sub_to_topic`** -- maps a [`SubscriptionId`] to the topic name
//!   it belongs to, enabling O(1) "which topic owns this subscription?"
//!   lookups.
//! - **`sink_to_subs`** -- maps a sink ID (`u64`) to the set of
//!   [`SubscriptionId`]s registered through that sink, enabling O(1)
//!   "remove all subscriptions for this connection" operations.
//!
//! # Lifecycle
//!
//! When an actor's channel closes (either because the actor received a
//! [`Shutdown`](crate::actor::TopicMsg::Shutdown) message or panicked),
//! [`get_or_spawn`](TopicRegistry::get_or_spawn) detects the dead
//! channel via [`LocalActorRef::is_closed`], removes the stale entry,
//! and transparently spawns a fresh actor on the next call.
//!
//! # Type parameters
//!
//! The registry is generic over the four storage backends that an actor
//! needs:
//!
//! * `O` -- [`OffsetStore`](crate::storage::OffsetStore): monotonic
//!   offset allocator per topic.
//! * `L` -- [`LogStore`](crate::storage::LogStore): append-only
//!   message log per topic.
//! * `D` -- [`DedupeStore`](crate::storage::DedupeStore): sliding-window
//!   deduplication by message ID.
//! * `S` -- [`SnapshotStore`](crate::storage::SnapshotStore): latest
//!   snapshot per topic for catch-up.

use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;

use dashmap::DashMap;
use tokio::sync::mpsc;

use crate::actor::actor_ref::LocalActorRef;
use crate::actor::messages::TopicMsg;
use crate::actor::topic_actor::TopicActor;
use crate::broker::fanout::SubscriptionId;
use crate::storage::{DedupeStore, LogStore, OffsetStore, SnapshotStore};
use crate::topic::profile::TopicProfile;

/// A lazily-populated, thread-safe map of topic names to actor references.
///
/// `TopicRegistry` is the central coordination point in the actor
/// subsystem.  On the first request for a given topic it spawns a new
/// [`TopicActor`] task, stores its [`LocalActorRef`], and returns a
/// clone of the handle.  Subsequent requests for the same topic return
/// the existing handle without spawning.
///
/// The registry also maintains two reverse indices used by
/// [`ActorBroker`](crate::broker::ActorBroker) to implement efficient
/// `unsubscribe`, `drop_sink`, and `subscriber_count` operations:
///
/// - `sub_to_topic`: [`SubscriptionId`] -> topic name
/// - `sink_to_subs`: sink ID (`u64`) -> set of [`SubscriptionId`]
///
/// # Thread safety
///
/// All maps are `DashMap`s, so the registry can be shared across tasks
/// via `Arc<TopicRegistry<...>>` without external locking.
///
/// # Type parameters
///
/// * `O` -- the [`OffsetStore`](crate::storage::OffsetStore)
///   implementation used by each spawned actor for monotonic offset
///   allocation.
/// * `L` -- the [`LogStore`](crate::storage::LogStore) implementation
///   used by each spawned actor for append-only message persistence.
/// * `D` -- the [`DedupeStore`](crate::storage::DedupeStore)
///   implementation used by each spawned actor for deduplication.
/// * `S` -- the [`SnapshotStore`](crate::storage::SnapshotStore)
///   implementation used by each spawned actor for snapshot retrieval.
pub struct TopicRegistry<O, L, D, S> {
    /// Primary map from topic name to the actor's `LocalActorRef`.
    actors: DashMap<String, LocalActorRef<TopicMsg>>,

    /// Reverse index: subscription ID -> topic name.
    ///
    /// Enables O(1) lookup of which topic a given subscription belongs
    /// to, which is needed when processing `unsubscribe` calls.
    sub_to_topic: DashMap<SubscriptionId, String>,

    /// Reverse index: sink ID -> set of subscription IDs.
    ///
    /// Enables O(1) lookup of all subscriptions registered through a
    /// given connection sink, which is needed when processing
    /// `drop_sink` calls on disconnection.
    sink_to_subs: DashMap<u64, HashSet<SubscriptionId>>,

    /// Shared offset allocator, cloned into each spawned actor.
    offsets: Arc<O>,

    /// Shared log store, cloned into each spawned actor.
    log: Arc<L>,

    /// Shared deduplication store, cloned into each spawned actor.
    dedupe: Arc<D>,

    /// Shared snapshot store, cloned into each spawned actor.
    snapshots: Arc<S>,

    /// Default topic profile applied to newly spawned actors.
    ///
    /// Controls settings such as retention policy and maximum log size.
    default_profile: TopicProfile,

    /// Time window for deduplication (how long a `message_id` is
    /// remembered before it can be reused).
    dedupe_window: Duration,
}

impl<
    O: OffsetStore + 'static,
    L: LogStore + 'static,
    D: DedupeStore + 'static,
    S: SnapshotStore + 'static,
> TopicRegistry<O, L, D, S>
{
    /// Create a new, empty topic registry.
    ///
    /// The registry starts with no spawned actors.  Actors will be
    /// spawned on demand when [`get_or_spawn`](Self::get_or_spawn) is
    /// called for a topic name that does not yet have a live actor.
    ///
    /// # Arguments
    ///
    /// * `offsets` -- shared [`OffsetStore`] instance cloned into every
    ///   spawned actor.
    /// * `log` -- shared [`LogStore`] instance cloned into every
    ///   spawned actor.
    /// * `dedupe` -- shared [`DedupeStore`] instance cloned into every
    ///   spawned actor.
    /// * `snapshots` -- shared [`SnapshotStore`] instance cloned into
    ///   every spawned actor.
    /// * `default_profile` -- [`TopicProfile`] applied to every newly
    ///   spawned actor; controls retention and other topic-level
    ///   settings.
    /// * `dedupe_window` -- duration for which a published `message_id`
    ///   is remembered in the deduplication store.
    ///
    /// # Returns
    ///
    /// A new `TopicRegistry` ready to be shared across tasks.
    pub fn new(
        offsets: Arc<O>,
        log: Arc<L>,
        dedupe: Arc<D>,
        snapshots: Arc<S>,
        default_profile: TopicProfile,
        dedupe_window: Duration,
    ) -> Self {
        Self {
            actors: DashMap::new(),
            sub_to_topic: DashMap::new(),
            sink_to_subs: DashMap::new(),
            offsets,
            log,
            dedupe,
            snapshots,
            default_profile,
            dedupe_window,
        }
    }

    /// Get or spawn the actor for a topic.
    ///
    /// If a live actor already exists for the given topic name, its
    /// [`LocalActorRef`] is cloned and returned.  If the existing
    /// actor's channel is closed (actor died or shut down), the stale
    /// entry is removed and a fresh actor is spawned transparently.
    ///
    /// The spawned actor is added to a `tokio::spawn` task and will
    /// run until it receives a [`Shutdown`](TopicMsg::Shutdown) message
    /// or its `mpsc` sender is dropped.
    ///
    /// # Arguments
    ///
    /// * `topic` -- the topic name (e.g. `"room/1"`, `"orders/42"`).
    ///
    /// # Returns
    ///
    /// A [`LocalActorRef<TopicMsg>`] handle that can be used to send
    /// messages to the actor.
    pub fn get_or_spawn(&self, topic: &str) -> LocalActorRef<TopicMsg> {
        // Fast path: existing live actor.
        if let Some(r) = self.actors.get(topic) {
            if !r.is_closed() {
                return r.clone();
            }
            // Actor died — remove stale entry.
            self.actors.remove(topic);
        }
        // Slow path: spawn.
        let (tx, rx) = mpsc::channel(256);
        let actor = TopicActor::new(
            topic.to_string(),
            self.default_profile.clone(),
            self.offsets.clone(),
            self.log.clone(),
            self.dedupe.clone(),
            self.snapshots.clone(),
            self.dedupe_window,
        );
        let actor_ref = LocalActorRef::new(tx);
        tokio::spawn(async move { actor.run(rx).await });
        self.actors.insert(topic.to_string(), actor_ref.clone());
        actor_ref
    }

    /// Record a `(subscription_id, topic, sink_id)` triple in the
    /// reverse indices.
    ///
    /// This method should be called after the actor confirms a
    /// [`Subscribe`](TopicMsg::Subscribe) request.  It populates both
    /// the `sub_to_topic` and `sink_to_subs` maps so that future
    /// `unsubscribe` and `drop_sink` operations can resolve without
    /// broadcasting to every actor.
    ///
    /// # Arguments
    ///
    /// * `sid` -- the [`SubscriptionId`] returned by the actor.
    /// * `topic` -- the topic name the subscription belongs to.
    /// * `sink_id` -- the numeric identifier of the connection sink
    ///   through which the subscription was registered.
    pub fn register_subscription(&self, sid: SubscriptionId, topic: &str, sink_id: u64) {
        self.sub_to_topic.insert(sid, topic.to_string());
        self.sink_to_subs.entry(sink_id).or_default().insert(sid);
    }

    /// Look up the topic name that a subscription ID belongs to.
    ///
    /// Returns `None` if the subscription ID is not registered (either
    /// never registered or already unregistered).
    ///
    /// # Arguments
    ///
    /// * `sid` -- the [`SubscriptionId`] to look up.
    ///
    /// # Returns
    ///
    /// `Some(topic_name)` if the subscription exists, `None` otherwise.
    pub fn topic_for_subscription(&self, sid: &SubscriptionId) -> Option<String> {
        self.sub_to_topic.get(sid).map(|v| v.value().clone())
    }

    /// Return all subscription IDs registered for a given sink.
    ///
    /// This is used when a connection disconnects and all of its
    /// subscriptions need to be cleaned up.
    ///
    /// # Arguments
    ///
    /// * `sink_id` -- the numeric identifier of the connection sink.
    ///
    /// # Returns
    ///
    /// A vector of [`SubscriptionId`]s registered through the given
    /// sink.  Returns an empty vector if no subscriptions exist for
    /// the sink.
    pub fn subs_for_sink(&self, sink_id: u64) -> Vec<SubscriptionId> {
        self.sink_to_subs
            .get(&sink_id)
            .map(|s| s.value().iter().copied().collect())
            .unwrap_or_default()
    }

    /// Remove a subscription from both reverse indices.
    ///
    /// This method atomically removes the subscription from the
    /// `sub_to_topic` map and from the corresponding set in the
    /// `sink_to_subs` map.  If the sink's subscription set becomes
    /// empty after removal, the entire sink entry is cleaned up.
    ///
    /// # Arguments
    ///
    /// * `sid` -- the [`SubscriptionId`] to remove.
    ///
    /// # Returns
    ///
    /// `Some(topic_name)` if the subscription existed and was removed,
    /// `None` if the subscription ID was not found.
    pub fn unregister_subscription(&self, sid: &SubscriptionId) -> Option<String> {
        self.sub_to_topic.remove(sid).map(|(_, topic)| {
            for mut entry in self.sink_to_subs.iter_mut() {
                if entry.value_mut().remove(sid) {
                    if entry.value().is_empty() {
                        let k = *entry.key();
                        drop(entry);
                        self.sink_to_subs.remove(&k);
                    }
                    break;
                }
            }
            topic
        })
    }

    /// Count the number of active subscriptions targeting a specific topic.
    ///
    /// This performs a linear scan of the `sub_to_topic` reverse index.
    /// It is primarily used for diagnostics and monitoring rather than
    /// hot-path logic.
    ///
    /// # Arguments
    ///
    /// * `topic` -- the topic name to count subscriptions for.
    ///
    /// # Returns
    ///
    /// The number of subscriptions currently registered for the given
    /// topic.
    pub fn count_subscriptions_for_topic(&self, topic: &str) -> usize {
        self.sub_to_topic
            .iter()
            .filter(|kv| kv.value() == topic)
            .count()
    }

    /// Returns the number of spawned actors currently tracked by the
    /// registry.
    ///
    /// This counts entries in the primary `actors` map, which may
    /// include actors whose channels have closed but have not yet been
    /// reaped.
    ///
    /// # Returns
    ///
    /// The number of topic-to-actor entries in the registry.
    pub fn len(&self) -> usize {
        self.actors.len()
    }

    /// Returns `true` if no actors are currently tracked by the
    /// registry.
    ///
    /// # Returns
    ///
    /// `true` if the internal actor map is empty, `false` otherwise.
    pub fn is_empty(&self) -> bool {
        self.actors.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::{
        MemoryDedupeStore, MemoryLogStore, MemoryOffsetStore, MemorySnapshotStore,
    };
    use std::time::Duration;

    #[tokio::test]
    async fn spawn_and_reuse() {
        let registry: TopicRegistry<
            MemoryOffsetStore,
            MemoryLogStore,
            MemoryDedupeStore,
            MemorySnapshotStore,
        > = TopicRegistry::new(
            Arc::new(MemoryOffsetStore::new()),
            Arc::new(MemoryLogStore::new()),
            Arc::new(MemoryDedupeStore::new()),
            Arc::new(MemorySnapshotStore::new()),
            TopicProfile::default(),
            Duration::from_secs(60),
        );
        let a = registry.get_or_spawn("room/1");
        let b = registry.get_or_spawn("room/1");
        // Same topic should return the same actor ref (by sender equality).
        assert_eq!(a.sender().capacity(), b.sender().capacity());
        assert_eq!(registry.len(), 1);
    }

    #[tokio::test]
    async fn different_topics_different_actors() {
        let registry: TopicRegistry<
            MemoryOffsetStore,
            MemoryLogStore,
            MemoryDedupeStore,
            MemorySnapshotStore,
        > = TopicRegistry::new(
            Arc::new(MemoryOffsetStore::new()),
            Arc::new(MemoryLogStore::new()),
            Arc::new(MemoryDedupeStore::new()),
            Arc::new(MemorySnapshotStore::new()),
            TopicProfile::default(),
            Duration::from_secs(60),
        );
        let _a = registry.get_or_spawn("room/1");
        let _b = registry.get_or_spawn("room/2");
        assert_eq!(registry.len(), 2);
    }
}