kevy_embedded/pubsub.rs
1//! In-process pub/sub bus for embedded `Store`.
2//!
3//! Mirrors the Redis/kevy server pub/sub semantics inside a single process:
4//! `Store::publish` walks the channel + pattern subscriber tables and
5//! enqueues a [`PubsubFrame`] onto each matching [`Subscription`]'s
6//! `std::sync::mpsc` channel. Each `Subscription` drains its own queue via
7//! [`Subscription::recv`] / [`Subscription::recv_timeout`] /
8//! [`Subscription::try_recv`].
9//!
10//! The bus lives inside `Inner` and is reached only under the embedded
11//! mutex; per-publish we clone the matching senders out, drop the lock,
12//! then `send()` — so a slow receiver can't stall publishes on unrelated
13//! channels.
14
15// A send to a subscriber that has gone away is the normal end of a
16// subscription, not an error to handle: the receiver drops when the
17// client disconnects, and the bus removes it on the next sweep.
18// Reporting here would turn every ordinary disconnect into a log line.
19#![expect(
20 clippy::let_underscore_must_use,
21 reason = "a dropped receiver is how a subscription ends"
22)]
23
24use crate::{KevyError, KevyResult};
25use std::collections::HashSet;
26use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, TryRecvError, channel};
27use std::sync::{Arc, Mutex, RwLock};
28use std::time::Duration;
29
30use crate::store::Inner;
31
32/// One pub/sub event delivered to a [`Subscription`].
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum PubsubFrame {
35 /// Ack: `SUBSCRIBE` succeeded on `channel`.
36 Subscribe {
37 /// Channel that was just subscribed.
38 channel: Vec<u8>,
39 /// Total channels + patterns this subscription holds after the op.
40 count: usize,
41 },
42 /// Ack: `PSUBSCRIBE` succeeded on `pattern`.
43 Psubscribe {
44 /// Pattern that was just subscribed.
45 pattern: Vec<u8>,
46 /// Total channels + patterns this subscription holds after the op.
47 count: usize,
48 },
49 /// Ack: `UNSUBSCRIBE` removed `channel` (or "all", when `None`).
50 Unsubscribe {
51 /// Channel that was just unsubscribed (`None` = "all").
52 channel: Option<Vec<u8>>,
53 /// Total channels + patterns still held after the op.
54 count: usize,
55 },
56 /// Ack: `PUNSUBSCRIBE` removed `pattern` (or "all", when `None`).
57 Punsubscribe {
58 /// Pattern that was just unsubscribed (`None` = "all").
59 pattern: Option<Vec<u8>>,
60 /// Total channels + patterns still held after the op.
61 count: usize,
62 },
63 /// A `PUBLISH` reached a channel this subscription holds directly.
64 Message {
65 /// Channel the publish was made to.
66 channel: Vec<u8>,
67 /// Raw payload bytes.
68 payload: Vec<u8>,
69 },
70 /// A `PUBLISH` reached a channel matching one of this subscription's
71 /// patterns.
72 Pmessage {
73 /// Pattern the channel matched.
74 pattern: Vec<u8>,
75 /// Channel the publish was made to.
76 channel: Vec<u8>,
77 /// Raw payload bytes.
78 payload: Vec<u8>,
79 },
80}
81
82impl PubsubFrame {
83 /// The raw message payload, moved out of the frame.
84 ///
85 /// `Some(payload)` for the two delivery frames ([`Message`](Self::Message)
86 /// and [`Pmessage`](Self::Pmessage)); `None` for every control/ack frame
87 /// (subscribe / unsubscribe / …), which carries no payload. Consuming
88 /// `self` lets a scalar drain hand a push subscriber just the bytes with
89 /// no extra copy — the pub/sub analog of the KV scalar door. The channel
90 /// and the message-vs-pmessage distinction are dropped; a caller that
91 /// needs either keeps matching on the frame.
92 #[must_use]
93 pub fn into_payload(self) -> Option<Vec<u8>> {
94 match self {
95 PubsubFrame::Message { payload, .. } | PubsubFrame::Pmessage { payload, .. } => {
96 Some(payload)
97 }
98 _ => None,
99 }
100 }
101}
102
103// `BusEntry` + `PubsubBus` live in [`crate::pubsub_bus`] — split out so
104// this file stays under the 500-LOC house rule. Re-exported below so
105// `crate::store::Inner` keeps its existing `pubsub::PubsubBus` import.
106pub(crate) use crate::pubsub_bus::PubsubBus;
107
108/// A handle to one subscription — owns the receive end of the bus channel.
109///
110/// Drop unsubscribes from everything automatically. While the handle is
111/// alive, [`recv`](Self::recv) / [`recv_timeout`](Self::recv_timeout) /
112/// [`try_recv`](Self::try_recv) drain queued [`PubsubFrame`]s in arrival
113/// order.
114///
115/// **Threading.** `Subscription` is `Send + Sync` —
116/// `Arc<Subscription>` works, so multiple async tasks (or
117/// `spawn_blocking` jobs) can share one subscription and call `recv`
118/// concurrently. The underlying `std::sync::mpsc::Receiver` is
119/// !Sync, so we wrap it (and the matching ack `Sender`) in a `Mutex`;
120/// concurrent `recv` callers serialise on that lock, with each call
121/// receiving a *different* frame in arrival order (single-consumer
122/// semantics — NOT broadcast fanout). `try_recv` is non-blocking even
123/// under contention: if the lock is held by a blocking `recv`,
124/// `try_recv` returns `Ok(None)` rather than waiting.
125///
126/// If you need broadcast fanout (every subscriber sees every message),
127/// open a separate `Subscription` per consumer — they're cheap.
128#[allow(missing_debug_implementations)]
129pub struct Subscription {
130 inner: Arc<RwLock<Inner>>,
131 // Keeps the AOF/reaper alive as long as a Subscription does — so
132 // dropping every `Store` clone while a subscriber is still active
133 // leaves the keyspace intact until the subscriber also goes away.
134 _guard: Arc<crate::store::DropGuard>,
135 // `Receiver<T>` is `Send + !Sync`; wrap so `Subscription: Sync`.
136 // Hot path (recv) acquires + holds the lock during the blocking
137 // wait — single consumer at a time; concurrent recv callers
138 // serialise and each get a different frame. See type-level
139 // doc-comment for the trade-off.
140 receiver: Mutex<Receiver<PubsubFrame>>,
141 // `Sender<T>` is also !Sync (Send + Clone but cannot be shared by
142 // reference across threads). Wrap so the ack-frame path (called
143 // from subscribe/unsubscribe / Drop) can run from any thread.
144 sender: Mutex<Sender<PubsubFrame>>,
145 id: u64,
146 channels: HashSet<Vec<u8>>,
147 patterns: HashSet<Vec<u8>>,
148}
149
150impl Subscription {
151 pub(crate) fn new(inner: Arc<RwLock<Inner>>, guard: Arc<crate::store::DropGuard>) -> Self {
152 let (sender, receiver) = channel();
153 let id = inner.write().unwrap_or_else(std::sync::PoisonError::into_inner).bus.alloc_id();
154 Self {
155 inner,
156 _guard: guard,
157 receiver: Mutex::new(receiver),
158 sender: Mutex::new(sender),
159 id,
160 channels: HashSet::new(),
161 patterns: HashSet::new(),
162 }
163 }
164
165 /// Clone of the inbound `Sender`. Used both for ack frames (Subscribe /
166 /// Unsubscribe / ...) and to register a sender clone inside
167 /// `PubsubBus`. Calling this acquires the sender lock briefly (~20 ns).
168 fn sender_clone(&self) -> Sender<PubsubFrame> {
169 self.sender.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone()
170 }
171
172 /// `SUBSCRIBE channel [channel ...]`. Per-channel `Subscribe` acks are
173 /// enqueued onto the receive queue in order.
174 pub fn subscribe(&mut self, channels: &[&[u8]]) {
175 let s = self.sender_clone();
176 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
177 for ch in channels {
178 let owned = ch.to_vec();
179 let added = g.bus.add_channel(self.id, &s, owned.clone());
180 if added {
181 self.channels.insert(owned.clone());
182 }
183 let count = g.bus.count_for(self.id);
184 let _ = s.send(PubsubFrame::Subscribe { channel: owned, count });
185 }
186 }
187
188 /// `PSUBSCRIBE pattern [pattern ...]`. Patterns use Redis glob syntax
189 /// (`*`, `?`, `[abc]`).
190 pub fn psubscribe(&mut self, patterns: &[&[u8]]) {
191 let s = self.sender_clone();
192 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
193 for pat in patterns {
194 let owned = pat.to_vec();
195 let added = g.bus.add_pattern(self.id, &s, owned.clone());
196 if added {
197 self.patterns.insert(owned.clone());
198 }
199 let count = g.bus.count_for(self.id);
200 let _ = s.send(PubsubFrame::Psubscribe { pattern: owned, count });
201 }
202 }
203
204 /// `UNSUBSCRIBE [channel ...]`. Empty `channels` removes every channel
205 /// subscription this handle holds (matching the Redis wire shape:
206 /// individual ack frames for each channel that was actually removed,
207 /// or a single `Unsubscribe { channel: None }` if none were held).
208 pub fn unsubscribe(&mut self, channels: &[&[u8]]) {
209 if channels.is_empty() {
210 self.drain_channel_subs();
211 return;
212 }
213 let s = self.sender_clone();
214 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
215 for ch in channels {
216 let owned = ch.to_vec();
217 let _ = g.bus.remove_channel(self.id, &owned);
218 self.channels.remove(&owned);
219 let count = g.bus.count_for(self.id);
220 let _ = s.send(PubsubFrame::Unsubscribe { channel: Some(owned), count });
221 }
222 }
223
224 /// `PUNSUBSCRIBE [pattern ...]`. Empty `patterns` removes every pattern.
225 pub fn punsubscribe(&mut self, patterns: &[&[u8]]) {
226 if patterns.is_empty() {
227 self.drain_pattern_subs();
228 return;
229 }
230 let s = self.sender_clone();
231 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
232 for pat in patterns {
233 let owned = pat.to_vec();
234 let _ = g.bus.remove_pattern(self.id, &owned);
235 self.patterns.remove(&owned);
236 let count = g.bus.count_for(self.id);
237 let _ = s.send(PubsubFrame::Punsubscribe { pattern: Some(owned), count });
238 }
239 }
240
241 fn drain_channel_subs(&mut self) {
242 let s = self.sender_clone();
243 let owned: Vec<Vec<u8>> = self.channels.drain().collect();
244 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
245 if owned.is_empty() {
246 let count = g.bus.count_for(self.id);
247 let _ = s.send(PubsubFrame::Unsubscribe { channel: None, count });
248 return;
249 }
250 for ch in owned {
251 let _ = g.bus.remove_channel(self.id, &ch);
252 let count = g.bus.count_for(self.id);
253 let _ = s.send(PubsubFrame::Unsubscribe { channel: Some(ch), count });
254 }
255 }
256
257 fn drain_pattern_subs(&mut self) {
258 let s = self.sender_clone();
259 let owned: Vec<Vec<u8>> = self.patterns.drain().collect();
260 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
261 if owned.is_empty() {
262 let count = g.bus.count_for(self.id);
263 let _ = s.send(PubsubFrame::Punsubscribe { pattern: None, count });
264 return;
265 }
266 for p in owned {
267 let _ = g.bus.remove_pattern(self.id, &p);
268 let count = g.bus.count_for(self.id);
269 let _ = s.send(PubsubFrame::Punsubscribe { pattern: Some(p), count });
270 }
271 }
272
273 /// Block until one frame is queued. `Err(io::ErrorKind::UnexpectedEof)`
274 /// once the underlying bus tears down (last `Store` clone dropped).
275 ///
276 /// Acquires the receiver mutex for the entire blocking wait — other
277 /// `recv`/`recv_timeout` callers serialise behind this one. Concurrent
278 /// `try_recv` calls return `Ok(None)` while a `recv` is blocked (no
279 /// wait on the lock); see the type-level doc for the trade-off.
280 pub fn recv(&self) -> KevyResult<PubsubFrame> {
281 let g = self.receiver.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
282 g.recv().map_err(|_| KevyError::Closed)
283 }
284
285 /// Bounded blocking recv. `Err(KevyError::TimedOut)` when `dur`
286 /// elapses; `Err(KevyError::Closed)` when the bus is gone.
287 pub fn recv_timeout(&self, dur: Duration) -> KevyResult<PubsubFrame> {
288 let g = self.receiver.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
289 g.recv_timeout(dur).map_err(|e| match e {
290 RecvTimeoutError::Timeout => KevyError::TimedOut,
291 RecvTimeoutError::Disconnected => KevyError::Closed,
292 })
293 }
294
295 /// Non-blocking recv. `Ok(None)` if the queue is empty;
296 /// `Err(KevyError::Closed)` when the bus is gone.
297 ///
298 /// Uses `try_lock` so a concurrent blocking `recv` doesn't make
299 /// `try_recv` itself block — lock contention is reported as `Ok(None)`
300 /// (semantically: "no frame available right now"). Same shape callers
301 /// already handle for an empty queue.
302 pub fn try_recv(&self) -> KevyResult<Option<PubsubFrame>> {
303 let Ok(g) = self.receiver.try_lock() else {
304 return Ok(None);
305 };
306 match g.try_recv() {
307 Ok(f) => Ok(Some(f)),
308 Err(TryRecvError::Empty) => Ok(None),
309 Err(TryRecvError::Disconnected) => Err(KevyError::Closed),
310 }
311 }
312}
313
314impl std::fmt::Debug for Subscription {
315 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316 f.debug_struct("Subscription")
317 .field("id", &self.id)
318 .field("channels", &self.channels.len())
319 .field("patterns", &self.patterns.len())
320 .finish_non_exhaustive()
321 }
322}
323
324impl Drop for Subscription {
325 fn drop(&mut self) {
326 // Best-effort cleanup. Recover from poison (a panic elsewhere left the
327 // bus intact) so our entries are always removed.
328 let mut g = self.inner.write().unwrap_or_else(std::sync::PoisonError::into_inner);
329 g.bus.remove_all_for(self.id);
330 }
331}
332
333#[cfg(test)]
334#[path = "pubsub_tests.rs"]
335mod tests;