kevy_client/subscribe.rs
1//! Pub/sub consumer side — a connection dedicated to receiving messages.
2//!
3//! `SUBSCRIBE` / `PSUBSCRIBE` morph a connection into a one-way event
4//! stream: the client no longer sends ordinary commands and instead reads
5//! an unbounded sequence of `subscribe`, `message`, `pmessage`,
6//! `unsubscribe`, … frames until the connection is closed. That semantic
7//! doesn't fit the one-shot `Connection::request` shape, so subscribed
8//! traffic gets its own type, [`Subscriber`].
9//!
10//! Two backends, switched on the URL:
11//! - `kevy://` / `redis://` / `tcp://` — dedicated TCP socket
12//! - `mem://<name>` / `file:///path` — in-process bus, via the URL
13//! registry in [`crate::resolve_store`]. Anonymous `mem://` (no name)
14//! has no bus and is rejected; use a named bus to actually receive
15//! messages from a [`crate::Connection::publish`] on the same URL.
16//!
17//! ```no_run
18//! use kevy_client::{Subscriber, PubsubEvent};
19//!
20//! let mut sub = Subscriber::connect_channels("kevy://localhost:6379", &[b"news"])?;
21//! loop {
22//! if let PubsubEvent::Message { channel, payload } = sub.recv()? {
23//! println!("{}: {}", String::from_utf8_lossy(&channel),
24//! String::from_utf8_lossy(&payload));
25//! }
26//! }
27//! # Ok::<(), kevy_client::KevyError>(())
28//! ```
29
30use crate::{KevyError, KevyResult};
31use std::collections::VecDeque;
32use std::io::{Read, Write};
33use std::net::TcpStream;
34use std::time::Duration;
35
36use kevy_embedded::Subscription;
37use kevy_resp::{Reply, encode_command};
38use kevy_resp_client::ReplyReadBuf;
39
40use crate::subscribe_io::{await_acks, frame_to_event, invalid, recv_remote, send_to, shape};
41use crate::{Target, parse_url, resolve_store};
42
43/// One subscribed connection. Owns either a TCP socket or an in-process
44/// [`Subscription`]; the variant is chosen by the URL scheme in
45/// [`Subscriber::connect_channels`] / [`Subscriber::connect`].
46#[derive(Debug)]
47pub struct Subscriber {
48 inner: Inner,
49}
50
51#[derive(Debug)]
52enum Inner {
53 /// TCP RESP2 connection, drained one reply at a time.
54 Remote {
55 stream: TcpStream,
56 buf: ReplyReadBuf,
57 /// Events read while waiting for a subscribe ack, held in arrival
58 /// order for [`Subscriber::recv`]. Waiting for the ack means
59 /// reading whatever arrives first, and a message for an
60 /// already-subscribed channel can arrive before the ack for a new
61 /// one — dropping it to get at the ack would trade a race for a
62 /// lost message.
63 pending: VecDeque<PubsubEvent>,
64 },
65 /// In-process bus subscription. `timeout` mirrors the TCP
66 /// `SO_RCVTIMEO` behaviour for [`Subscriber::recv`] / [`Subscriber::set_read_timeout`].
67 Embedded {
68 subscription: Subscription,
69 timeout: Option<Duration>,
70 },
71}
72
73// The pubsub frame vocabulary is canonical in `kevy_resp_client`,
74// shared with the async client — one enum, no per-crate mirrors.
75pub use kevy_resp_client::PubsubEvent;
76
77impl Subscriber {
78 /// Open a fresh connection without subscribing to anything yet. Call
79 /// [`Self::subscribe`] / [`Self::psubscribe`] next.
80 ///
81 /// Accepted URLs:
82 /// - `kevy://`, `redis://`, `tcp://` — TCP RESP server
83 /// - `mem://<name>`, `file:///path` — in-process shared bus
84 /// - `mem://` (anonymous), `rediss://`, `kevys://`, `redis://user:pass@…`
85 /// are rejected with [`KevyError::Unsupported`]
86 pub fn connect(url: &str) -> KevyResult<Self> {
87 let target = parse_url(url)?;
88 let inner = match target {
89 Target::EmbedMemoryAnonymous => {
90 return Err(KevyError::Unsupported("anonymous mem:// has no other producer; use mem://<name> for a shared bus".into()));
91 }
92 Target::EmbedMemoryNamed(_) | Target::EmbedPersist(_) => Inner::Embedded {
93 subscription: resolve_store(&target)?.subscribe(&[]),
94 timeout: None,
95 },
96 Target::Remote(remote_url) => {
97 let (host, port) = remote_host_port(&remote_url)?;
98 let stream = TcpStream::connect((host.as_str(), port))?;
99 stream.set_nodelay(true).ok();
100 Inner::Remote {
101 stream,
102 buf: ReplyReadBuf::with_capacity(8192),
103 pending: VecDeque::new(),
104 }
105 }
106 };
107 Ok(Self { inner })
108 }
109
110 /// Connect and subscribe to one or more channels in one step. Returns
111 /// `ErrorKind::InvalidInput` if `channels` is empty (use
112 /// [`Self::connect`] for an empty start).
113 pub fn connect_channels(url: &str, channels: &[&[u8]]) -> KevyResult<Self> {
114 if channels.is_empty() {
115 return Err(KevyError::InvalidInput("Subscriber::connect_channels needs ≥ 1 channel — use Subscriber::connect() for empty start".into()));
116 }
117 let mut s = Self::connect(url)?;
118 s.subscribe(channels)?;
119 Ok(s)
120 }
121
122 /// `SUBSCRIBE channel [channel ...]`. Per-channel `Subscribe` acks
123 /// are delivered via [`Self::recv`].
124 pub fn subscribe(&mut self, channels: &[&[u8]]) -> KevyResult<()> {
125 if channels.is_empty() {
126 return Err(KevyError::InvalidInput("SUBSCRIBE needs ≥ 1 channel".into()));
127 }
128 match &mut self.inner {
129 Inner::Remote { stream, buf, pending } => {
130 send_to(stream, b"SUBSCRIBE", channels)?;
131 await_acks(stream, buf, pending, channels.len(), false)
132 }
133 Inner::Embedded { subscription, .. } => {
134 subscription.subscribe(channels);
135 Ok(())
136 }
137 }
138 }
139
140 /// `PSUBSCRIBE pattern [pattern ...]`. Patterns use Redis glob syntax
141 /// (`*`, `?`, `[…]`).
142 pub fn psubscribe(&mut self, patterns: &[&[u8]]) -> KevyResult<()> {
143 if patterns.is_empty() {
144 return Err(KevyError::InvalidInput("PSUBSCRIBE needs ≥ 1 pattern".into()));
145 }
146 match &mut self.inner {
147 Inner::Remote { stream, buf, pending } => {
148 send_to(stream, b"PSUBSCRIBE", patterns)?;
149 await_acks(stream, buf, pending, patterns.len(), true)
150 }
151 Inner::Embedded { subscription, .. } => {
152 subscription.psubscribe(patterns);
153 Ok(())
154 }
155 }
156 }
157
158 /// `UNSUBSCRIBE [channel ...]`. Empty `channels` unsubscribes from
159 /// every channel (Redis wire semantics).
160 pub fn unsubscribe(&mut self, channels: &[&[u8]]) -> KevyResult<()> {
161 match &mut self.inner {
162 Inner::Remote { stream, .. } => send_to(stream, b"UNSUBSCRIBE", channels),
163 Inner::Embedded { subscription, .. } => {
164 subscription.unsubscribe(channels);
165 Ok(())
166 }
167 }
168 }
169
170 /// `PUNSUBSCRIBE [pattern ...]`. Empty `patterns` unsubscribes from
171 /// every pattern.
172 pub fn punsubscribe(&mut self, patterns: &[&[u8]]) -> KevyResult<()> {
173 match &mut self.inner {
174 Inner::Remote { stream, .. } => send_to(stream, b"PUNSUBSCRIBE", patterns),
175 Inner::Embedded { subscription, .. } => {
176 subscription.punsubscribe(patterns);
177 Ok(())
178 }
179 }
180 }
181
182 /// Block until the next pubsub frame arrives. Apply
183 /// [`Self::set_read_timeout`] for bounded blocking.
184 /// Connection close / bus tear-down yields `ErrorKind::UnexpectedEof`.
185 pub fn recv(&mut self) -> KevyResult<PubsubEvent> {
186 match &mut self.inner {
187 Inner::Remote { stream, buf, pending } => match pending.pop_front() {
188 Some(ev) => Ok(ev),
189 None => recv_remote(stream, buf),
190 },
191 Inner::Embedded {
192 subscription,
193 timeout,
194 } => {
195 let frame = match *timeout {
196 Some(d) => subscription.recv_timeout(d)?,
197 None => subscription.recv()?,
198 };
199 Ok(frame_to_event(frame))
200 }
201 }
202 }
203
204 /// Block until the next published `Message` / `Pmessage` arrives,
205 /// silently skipping subscription-acknowledgement frames
206 /// ([`PubsubEvent::Subscribe`] / [`PubsubEvent::Unsubscribe`] /
207 /// [`PubsubEvent::Psubscribe`] / [`PubsubEvent::Punsubscribe`]) along
208 /// the way.
209 ///
210 /// This is the form most callers want — almost no consumer of
211 /// pubsub needs to see the ack frames (they're a wire-protocol
212 /// detail), so a loop+match around [`Self::recv`] is essentially
213 /// boilerplate. Returns `(channel, payload)`. For pattern matches,
214 /// `channel` is the concrete channel the publisher used (matching
215 /// Redis's `pmessage` shape, where `pattern` is discarded — use
216 /// [`Self::recv`] directly if you need it).
217 ///
218 /// Errors from [`Self::recv`] (connection close, timeout, etc.)
219 /// propagate unchanged.
220 pub fn recv_message(&mut self) -> KevyResult<(Vec<u8>, Vec<u8>)> {
221 loop {
222 match self.recv()? {
223 PubsubEvent::Message { channel, payload } => return Ok((channel, payload)),
224 PubsubEvent::Pmessage { channel, payload, .. } => {
225 return Ok((channel, payload));
226 }
227 // Ack frames (and any frame kind a future protocol
228 // adds — the enum is non-exhaustive) — keep waiting
229 // for the next real message.
230 _ => {}
231 }
232 }
233 }
234
235 /// Negotiate RESP3 on this connection by sending `HELLO 3` and
236 /// draining the ack. Subsequent `SUBSCRIBE` / `PSUBSCRIBE` /
237 /// `PUBLISH` deliveries arrive as push frames (`>N\r\n…`) instead
238 /// of the legacy RESP2 array shape (`*N\r\n…`); [`Self::recv`]
239 /// accepts both transparently, so existing code keeps working with
240 /// no other changes.
241 ///
242 /// Remote-only: the embedded backend has no proto negotiation
243 /// concept (frames go through the in-process bus typed). Calling
244 /// `hello3` on an embedded [`Subscriber`] returns
245 /// [`KevyError::Unsupported`].
246 ///
247 /// Must be called BEFORE any [`Self::subscribe`] /
248 /// [`Self::psubscribe`] — Redis requires `HELLO` be the first
249 /// command on a connection that uses it.
250 pub fn hello3(&mut self) -> KevyResult<PubsubEvent> {
251 match &mut self.inner {
252 Inner::Embedded { .. } => Err(KevyError::Unsupported("HELLO 3 is a remote/TCP-only operation; embedded backend has no proto switch".into())),
253 Inner::Remote { stream, buf, .. } => {
254 let mut frame = Vec::new();
255 encode_command(&mut frame, &[b"HELLO".to_vec(), b"3".to_vec()]);
256 stream.write_all(&frame)?;
257 // The HELLO 3 ack itself comes back as a RESP3 Map
258 // (`%7\r\n…`). parse_reply accepts it (P1); we drain
259 // and discard since the proto switch is the actual
260 // semantic — the body's just server metadata.
261 let mut chunk = [0u8; 4096];
262 loop {
263 match buf.parse_next() {
264 Ok(Some(reply)) => return classify_hello3_reply(reply),
265 Ok(None) => {}
266 Err(_) => {
267 return Err(KevyError::Protocol("malformed HELLO 3 reply".into()));
268 }
269 }
270 let n = stream.read(&mut chunk)?;
271 if n == 0 {
272 return Err(KevyError::Closed);
273 }
274 buf.extend(&chunk[..n]);
275 }
276 }
277 }
278 }
279
280 /// Apply (or clear) a read timeout. After setting `Some(dur)`,
281 /// [`Self::recv`] returns [`KevyError::Io`] with kind `WouldBlock` /
282 /// `TimedOut` when no frame arrives within `dur`.
283 pub fn set_read_timeout(&mut self, dur: Option<Duration>) -> KevyResult<()> {
284 match &mut self.inner {
285 Inner::Remote { stream, .. } => Ok(stream.set_read_timeout(dur)?),
286 Inner::Embedded { timeout, .. } => {
287 *timeout = dur;
288 Ok(())
289 }
290 }
291 }
292
293 /// Borrowing iterator over every pubsub frame — ack frames included.
294 /// Each `next()` is one blocking [`Self::recv`]. Terminates (`None`)
295 /// when the underlying stream / bus is gone ([`KevyError::Closed`]);
296 /// every other error is surfaced as `Some(Err(_))` so the caller can
297 /// decide whether to retry (e.g. a read timeout) or break.
298 ///
299 /// kevy stays 0-deps so this is a `std::iter::Iterator`, not a
300 /// `futures::Stream`. Async runtimes consume it via
301 /// `spawn_blocking` (see `docs/pubsub.md`).
302 pub fn events(&mut self) -> SubscriberEvents<'_> {
303 SubscriberEvents { sub: self }
304 }
305
306 /// Borrowing iterator that silently skips `(p)?(un)?subscribe` acks
307 /// and yields the payload tuples consumers actually want. Mirrors
308 /// [`Self::recv_message`] in iterator form. For `Pmessage` the
309 /// pattern is discarded — fall back to [`Self::events`] if you need it.
310 pub fn messages(&mut self) -> SubscriberMessages<'_> {
311 SubscriberMessages { sub: self }
312 }
313}
314
315/// Iterator returned by [`Subscriber::events`]. Yields every pubsub
316/// frame (acks + payloads). See the method docs for termination + error
317/// semantics.
318#[derive(Debug)]
319pub struct SubscriberEvents<'a> {
320 sub: &'a mut Subscriber,
321}
322
323impl Iterator for SubscriberEvents<'_> {
324 type Item = KevyResult<PubsubEvent>;
325 fn next(&mut self) -> Option<Self::Item> {
326 match self.sub.recv() {
327 Err(KevyError::Closed) => None,
328 other => Some(other),
329 }
330 }
331}
332
333/// Iterator returned by [`Subscriber::messages`]. Yields one
334/// `(channel, payload)` per published `message` / `pmessage`; ack frames
335/// are silently consumed and not yielded.
336#[derive(Debug)]
337pub struct SubscriberMessages<'a> {
338 sub: &'a mut Subscriber,
339}
340
341impl Iterator for SubscriberMessages<'_> {
342 type Item = KevyResult<(Vec<u8>, Vec<u8>)>;
343 fn next(&mut self) -> Option<Self::Item> {
344 match self.sub.recv_message() {
345 Err(KevyError::Closed) => None,
346 other => Some(other),
347 }
348 }
349}
350
351/// Classify the drained `HELLO 3` ack. Reply::Map / Reply::Array are
352/// both acceptable (a server that rejected V3 would emit an Error
353/// reply — surfaced via the error branch below).
354fn classify_hello3_reply(reply: Reply) -> KevyResult<PubsubEvent> {
355 match reply {
356 Reply::Map(_) | Reply::Array(_) => Ok(PubsubEvent::Subscribe {
357 channel: b"HELLO".to_vec(),
358 count: 3,
359 }),
360 Reply::Error(e) => Err(KevyError::Protocol(String::from_utf8_lossy(&e).into_owned())),
361 other => Err(invalid(format!(
362 "unexpected HELLO 3 reply shape: {}",
363 shape(&other)
364 ))),
365 }
366}
367
368// `send_to` / `recv_remote` / `frame_to_event` / `classify` and the
369// per-field reply unwrap helpers live in [`crate::subscribe_io`] —
370// split out so this file stays under the 500-LOC house rule.
371
372// ─────────────────────────────────────────────────────────────────────────
373// Remote host:port extraction. Reuses the same authority parsing logic
374// kevy-resp-client::from_url applies, but only needs host+port (pub/sub
375// is global, not db-scoped — any /N path segment is ignored).
376// ─────────────────────────────────────────────────────────────────────────
377
378fn remote_host_port(url: &str) -> KevyResult<(String, u16)> {
379 let (_scheme, rest) = url.split_once("://").ok_or_else(|| {
380 KevyError::InvalidInput("URL missing '://'".into())
381 })?;
382 if rest.contains('@') {
383 return Err(KevyError::Unsupported("userinfo (user:pass@host) is unsupported — kevy has no AUTH".into()));
384 }
385 let authority = rest.split('/').next().unwrap_or("");
386 let (host, port) = match authority.rsplit_once(':') {
387 Some((h, p)) => {
388 let port: u16 = p.parse().map_err(|_| {
389 KevyError::InvalidInput(format!("bad port: {p}"))
390 })?;
391 (h.to_string(), port)
392 }
393 None => (authority.to_string(), 6379),
394 };
395 if host.is_empty() {
396 return Err(KevyError::InvalidInput("empty host".into()));
397 }
398 Ok((host, port))
399}
400
401#[cfg(test)]
402#[path = "subscribe_tests.rs"]
403mod tests;