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}