Skip to main content

kevy_rt/
blocked.rs

1// The pieces consumed by the follow-up XREAD BLOCK / XREADGROUP BLOCK
2// sprints (`BlockKind::XReadBlock`, `BlockKind::XReadGroupBlock`,
3// `BlockHint::XReadBlock`, …) are marked here; once those sprints connect
4// callers the corresponding warnings re-fire automatically.
5#![expect(dead_code, reason = "stream BlockKind / BlockHint variants land in v2-7d.3 / .4")]
6
7//! Per-shard blocked-client registry, shared by `BLPOP` / `BRPOP` /
8//! `XREAD BLOCK` / `XREADGROUP BLOCK`.
9//!
10//! Design: when a command blocks, the conn's `argv` + `proto` is stashed
11//! under every key it watches. A subsequent write to any of those keys wakes
12//! the oldest waiter (FIFO per key, matching Redis); a periodic tick sweeps
13//! waiters past their `deadline_ms` and fires a nil reply.
14//!
15//! The registry holds no reactor / socket state — `Shard` owns the wake +
16//! reply emission paths. `BlockedClients::pop_*` returns the bookkeeping;
17//! the caller decides what RESP frame to write.
18
19use crate::Commands;
20use crate::shard::Shard;
21use kevy_resp::{Argv, RespVersion};
22use std::collections::{HashMap, VecDeque};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25/// Unix wall-clock milliseconds — the time base both the dispatcher (when
26/// computing a waiter's `deadline_ms = now_ms + timeout_ms`) and the reactor
27/// tick (when checking `deadline_ms <= now_ms`) read. System-time jumps
28/// (NTP slew, manual clock change) are accepted: a backwards jump may make
29/// a waiter expire late, but BLOCK is not a wall-clock contract.
30#[inline]
31pub(crate) fn unix_now_ms() -> u64 {
32    SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
33}
34
35/// Emit the RESP nil reply that a timed-out blocking command returns.
36/// Shape depends on both proto and kind:
37/// - RESP3: `_\r\n` (the null type) for all kinds.
38/// - RESP2 `BLPOP` / `BRPOP`: nil array `*-1\r\n` (Redis returns nil array
39///   so the multi-bulk reply slot stays well-typed).
40/// - RESP2 `XREAD` / `XREADGROUP`: nil bulk `$-1\r\n` (matches "no streams
41///   updated in this window" — also Redis's choice).
42pub(crate) fn encode_block_timeout(out: &mut Vec<u8>, kind: BlockKind, proto: RespVersion) {
43    match (proto, kind) {
44        (RespVersion::V3, _) => out.extend_from_slice(b"_\r\n"),
45        (RespVersion::V2, BlockKind::Blpop | BlockKind::Brpop | BlockKind::Bzpopmin) => {
46            out.extend_from_slice(b"*-1\r\n");
47        }
48        (RespVersion::V2, BlockKind::XReadBlock | BlockKind::XReadGroupBlock) => {
49            out.extend_from_slice(b"$-1\r\n");
50        }
51        // BRPOPLPUSH on timeout returns nil bulk (the would-be moved
52        // element). Same shape as XREAD timeout.
53        (RespVersion::V2, BlockKind::Brpoplpush) => {
54            out.extend_from_slice(b"$-1\r\n");
55        }
56    }
57}
58
59/// Which blocking command a waiter is parked in. Drives both timeout-nil
60/// shape and wake-retry dispatch.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum BlockKind {
63    /// `BLPOP key [key ...] timeout` — block until one of the keys has an
64    /// element, then pop from the left. On timeout the reply is a nil
65    /// ARRAY, not a nil bulk; the shape is part of what this drives.
66    Blpop,
67    /// `BRPOP` — the same, popping from the right.
68    Brpop,
69    /// `BZPOPMIN key [key ...] timeout` — block until a sorted set has a
70    /// member, then pop the lowest-scored one. Same arm-and-serve flow as
71    /// `BLPOP`; the reply shape adds a third bulk (the score).
72    Bzpopmin,
73    /// `BRPOPLPUSH source destination timeout` — atomic blocking
74    /// right-pop from `source` + left-push to `destination`. Parks
75    /// on `source` only. Reply: single bulk of the moved element on
76    /// success, nil bulk on timeout. Deprecated since Redis 6.2 in
77    /// favour of BLMOVE, but Bee Queue (and many older clients)
78    /// still emit it.
79    Brpoplpush,
80    /// `XREAD BLOCK` — park until an entry past the given id arrives on
81    /// one of the streams. Read-only: no PEL, so a wake serves without
82    /// touching group state.
83    XReadBlock,
84    /// `XREADGROUP BLOCK` — the same wait, but a wake is a WRITE: the
85    /// delivery updates the group's pending list and last-delivered id on
86    /// the stream's own shard, and is logged there.
87    XReadGroupBlock,
88}
89
90/// How a command wants to block, if at all. Returned by
91/// [`Commands::resolve`] inside [`crate::ResolvedCmd`] so the verb-table
92/// lookup happens once per command. `None` is the zero-cost default for
93/// every non-blocking verb (≥ 99.9 % of dispatches in steady state).
94///
95/// `keys` is every key the conn watches (≥ 1). The dispatcher picks the
96/// park strategy from them:
97/// - **single key on the conn's own shard** → the in-shard fast path
98///   (`BlockedClients`): register + wake without any cross-core hop.
99/// - **single remote key, or any multi-key form** → the cross-shard
100///   arbiter (`block_xshard`): the conn parks on its origin
101///   shard and watch registrations fan out to each key's owning shard.
102///
103/// For `BLPOP` / `BRPOP` the keys are list keys; for `XREAD BLOCK` /
104/// `XREADGROUP BLOCK` they are the STREAMS keys (in request order).
105#[derive(Clone, Debug, Default)]
106pub enum BlockHint {
107    #[default]
108    /// The command does not block — every verb but the handful above.
109    None,
110    /// The command parks until one of `keys` is served or the deadline
111    /// passes.
112    Block {
113        /// Which blocking verb, which decides both the timeout reply shape
114        /// and how a wake is retried.
115        kind: BlockKind,
116        /// The keys to arm on, in the order the caller gave them — a wake
117        /// serves the earliest-listed key that has data, not the first to
118        /// receive it.
119        keys: Vec<Vec<u8>>,
120        /// `0` = block forever (Redis convention). Anything else is the
121        /// wall-clock millis the dispatcher will add to `unix_now_ms()` to
122        /// derive the waiter's `deadline_ms`.
123        timeout_ms: u64,
124    },
125}
126
127pub(crate) struct BlockedClient {
128    pub(crate) conn_id: u64,
129    pub(crate) kind: BlockKind,
130    /// Unix-ms wall clock when this waiter expires. `u64::MAX` = block forever.
131    pub(crate) deadline_ms: u64,
132    pub(crate) argv: Argv,
133    pub(crate) proto: RespVersion,
134}
135
136/// FIFO per key; secondary index by conn for O(1) cleanup on wake / close.
137#[derive(Default)]
138pub(crate) struct BlockedClients {
139    by_key: HashMap<Vec<u8>, VecDeque<BlockedClient>>,
140    by_conn: HashMap<u64, Vec<Vec<u8>>>,
141}
142
143impl BlockedClients {
144    pub(crate) fn new() -> Self {
145        Self::default()
146    }
147
148    /// Was a write on `key` watched by any blocker? `is_empty()` short-circuit
149    /// keeps the hot push/xadd path free of map lookups when nothing's parked.
150    #[inline]
151    pub(crate) fn is_empty(&self) -> bool {
152        self.by_key.is_empty()
153    }
154
155    #[inline]
156    pub(crate) fn is_watched(&self, key: &[u8]) -> bool {
157        self.by_key.contains_key(key)
158    }
159
160    /// Register one waiter on each of `keys`. The same waiter is cloned into
161    /// every key's FIFO; the wake path drops the surviving copies via
162    /// `drop_for_conn` once any one fires (so a multi-key BLPOP woken by key
163    /// A does not also fire on a later push to key B).
164    pub(crate) fn add(
165        &mut self,
166        conn_id: u64,
167        keys: &[Vec<u8>],
168        kind: BlockKind,
169        deadline_ms: u64,
170        argv: Argv,
171        proto: RespVersion,
172    ) {
173        for key in keys {
174            let bc = BlockedClient { conn_id, kind, deadline_ms, argv: argv.clone(), proto };
175            self.by_key.entry(key.clone()).or_default().push_back(bc);
176        }
177        self.by_conn.insert(conn_id, keys.to_vec());
178    }
179
180    /// Pop and return the oldest waiter on `key`. Caller must then call
181    /// `drop_for_conn(waiter.conn_id)` to scrub copies on this conn's other
182    /// watched keys (multi-key BLPOP), then retry `waiter.argv`.
183    pub(crate) fn pop_oldest_on_key(&mut self, key: &[u8]) -> Option<BlockedClient> {
184        let queue = self.by_key.get_mut(key)?;
185        let waiter = queue.pop_front();
186        if queue.is_empty() {
187            self.by_key.remove(key);
188        }
189        waiter
190    }
191
192    /// Drop every waiter copy belonging to `conn_id`. Called on (a) successful
193    /// wake (purge stale copies on other keys), and (b) connection close.
194    pub(crate) fn drop_for_conn(&mut self, conn_id: u64) {
195        let Some(keys) = self.by_conn.remove(&conn_id) else {
196            return;
197        };
198        for key in keys {
199            let Some(queue) = self.by_key.get_mut(&key) else {
200                continue;
201            };
202            queue.retain(|w| w.conn_id != conn_id);
203            if queue.is_empty() {
204                self.by_key.remove(&key);
205            }
206        }
207    }
208
209    /// Pop one representative waiter per conn whose `deadline_ms <= now_ms`.
210    /// All copies on the conn's other watched keys are removed too, so each
211    /// expired conn fires exactly one timeout reply.
212    pub(crate) fn pop_expired(&mut self, now_ms: u64) -> Vec<BlockedClient> {
213        let conns = self.expired_conn_ids(now_ms);
214        let mut out = Vec::with_capacity(conns.len());
215        for conn_id in conns {
216            if let Some(rep) = self.representative(conn_id) {
217                out.push(rep);
218            }
219            self.drop_for_conn(conn_id);
220        }
221        out
222    }
223
224    fn expired_conn_ids(&self, now_ms: u64) -> Vec<u64> {
225        let mut seen: Vec<u64> = Vec::new();
226        for queue in self.by_key.values() {
227            for w in queue {
228                if w.deadline_ms <= now_ms && !seen.contains(&w.conn_id) {
229                    seen.push(w.conn_id);
230                }
231            }
232        }
233        seen
234    }
235
236    fn representative(&self, conn_id: u64) -> Option<BlockedClient> {
237        let keys = self.by_conn.get(&conn_id)?;
238        let first_key = keys.first()?;
239        let queue = self.by_key.get(first_key)?;
240        queue.iter().find(|w| w.conn_id == conn_id).map(|w| BlockedClient {
241            conn_id: w.conn_id,
242            kind: w.kind,
243            deadline_ms: w.deadline_ms,
244            argv: w.argv.clone(),
245            proto: w.proto,
246        })
247    }
248}
249
250impl<C: Commands> Shard<C> {
251    /// Periodic reactor tick: fire one timeout reply per blocked waiter whose
252    /// `deadline_ms <= now`. Cheap when no one is parked (`is_empty()` short-
253    /// circuit). Called from both the epoll and io_uring reactor loops on the
254    /// same cadence as the active-TTL reaper.
255    pub(crate) fn tick_blocked_timeouts(&mut self) {
256        if self.blocked.is_empty() {
257            return;
258        }
259        let now_ms = unix_now_ms();
260        for w in self.blocked.pop_expired(now_ms) {
261            let Some(conn) = self.conns.get_mut(&w.conn_id) else {
262                continue;
263            };
264            conn.blocked = false;
265            encode_block_timeout(&mut conn.output, w.kind, w.proto);
266            // The parked command's seq was never retired: `try_inline_local`
267            // returns early on the park-on-miss branch WITHOUT bumping
268            // `next_emit`, precisely because the reply is deferred to here.
269            // Retiring it now is what keeps `seq - next_emit` a valid index
270            // into `conn.pending` for every later command. Without it the
271            // conn runs one behind forever, and the first command that takes
272            // the pending path (a cross-shard forward, or anything queued
273            // behind another) folds its reply into a slot that does not
274            // exist — the reply is dropped and the connection wedges with
275            // the request dispatched and no response. The wake path
276            // (`wake_blocked_on_key`) and both cross-shard paths
277            // (`block_xshard::deliver_block` / the xshard timeout sweep)
278            // already do this; this one was the odd sibling out.
279            conn.next_emit += 1;
280            self.dirty.push(w.conn_id);
281        }
282    }
283
284    /// Wake the oldest waiter on `key` (FIFO, matching Redis) and retry its
285    /// command. Called by the dispatcher after a write that may have produced
286    /// new data for blocked readers — `LPUSH` / `RPUSH` for `BLPOP` /
287    /// `BRPOP`; `XADD` for `XREAD BLOCK` / `XREADGROUP BLOCK`. The retry
288    /// re-runs the original command via `Commands::dispatch_into`; if the
289    /// data has already been consumed in a race window, the retry sees an
290    /// empty list / stream and a `None` from this fn — the waiter has
291    /// already been popped out of the registry so it stays unblocked (the
292    /// next tick or a fresh client request resolves it). One push wakes one
293    /// waiter only (Redis semantics — a single LPUSH does not feed two
294    /// BLPOP clients).
295    pub(crate) fn wake_blocked_on_key(&mut self, key: &[u8]) {
296        if self.blocked.is_empty() {
297            return;
298        }
299        let Some(waiter) = self.blocked.pop_oldest_on_key(key) else {
300            return;
301        };
302        self.blocked.drop_for_conn(waiter.conn_id);
303        let Some(conn) = self.conns.get_mut(&waiter.conn_id) else {
304            return;
305        };
306        conn.blocked = false;
307        let proto = waiter.proto;
308        match proto {
309            RespVersion::V2 => {
310                self.commands.dispatch_into(&mut self.store, &waiter.argv, &mut conn.output)
311            }
312            RespVersion::V3 => {
313                self.commands.dispatch_into_resp3(&mut self.store, &waiter.argv, &mut conn.output)
314            }
315        }
316        conn.next_emit += 1;
317        self.dirty.push(waiter.conn_id);
318    }
319}