dynomite/net/client.rs
1//! CLIENT-role connection driver.
2//!
3//! The CLIENT role is the engine's view of an inbound connection
4//! from a client (RESP or memcache). The driver:
5//!
6//! 1. Reads bytes from the [`crate::io::reactor::Transport`] into
7//! the connection's recv mbuf chain.
8//! 2. Drives the appropriate datastore parser
9//! ([`crate::proto::redis::redis_parse_req`] or
10//! [`crate::proto::memcache::memcache_parse_req`]) over the
11//! chain.
12//! 3. Hands every fully-parsed request to the configured
13//! [`crate::net::Dispatcher`] and waits for a response on the
14//! per-connection mpsc channel.
15//! 4. Writes the response bytes back to the transport.
16//!
17//! The driver runs a single tokio `select!` per iteration, draining
18//! pending response bytes first so the loop's read / write
19//! arms never block on a saturated peer.
20//!
21//! The driver does not reach into the cluster layer or the entropy
22//! reconciliation directly; both plug in through the [`Dispatcher`]
23//! hook the proxy installs, keeping the driver decoupled from
24//! routing and repair.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio::sync::mpsc;
31
32use crate::conf::DataStore;
33use crate::core::types::MsgId;
34use crate::io::reactor::ConnRole;
35use crate::msg::{response, Msg, MsgParseResult, MsgType};
36use crate::net::conn::Conn;
37use crate::net::dispatcher::{DispatchOutcome, Dispatcher, OutboundEnvelope};
38use crate::net::NetError;
39
40/// Read buffer size for the client driver.
41///
42/// Sized to the typical RESP bulk header so inline GET/SET
43/// commands fit in a single read; larger payloads are appended
44/// across iterations.
45const CLIENT_READ_CHUNK: usize = 4096;
46
47/// Outcome reported by [`client_loop`] when it finishes.
48#[derive(Debug, Clone, Copy, Eq, PartialEq)]
49pub enum ClientLoopOutcome {
50 /// Peer closed (EOF) and queues drained cleanly.
51 Eof,
52 /// Driver was asked to exit by the dispatcher.
53 Cancelled,
54}
55
56/// Sink that applies an inbound cross-node object-replica op to
57/// the local datastore.
58///
59/// The dnode peer-receive loop calls [`Self::apply`] once per
60/// [`DmsgType::RiakReplica`](crate::proto::dnode::DmsgType::RiakReplica)
61/// frame with the frame's opaque payload bytes (the `dyniak`
62/// routing layer owns the payload encoding). The engine does not
63/// interpret the payload; it hands it to the sink, which decodes
64/// it and applies the write to its LOCAL object store.
65///
66/// Applying a replica op is terminal: the sink must NOT re-forward
67/// it to other peers, so a replica write fans out exactly once.
68/// The call is fire-and-forget from the wire's perspective (no
69/// reply frame is produced), matching Riak's eventual-consistency
70/// model where anti-entropy and read-repair reconcile any peer
71/// that missed a write.
72pub trait ReplicaApplySink: Send + Sync {
73 /// Apply the replica op carried by `payload` to the local
74 /// datastore. Errors are logged by the implementor and
75 /// swallowed; the receive loop never blocks on the result.
76 fn apply<'a>(&'a self, payload: &'a [u8]) -> BoxFuture<'a, ()>;
77}
78
79/// Boxed future returned by [`ReplicaApplySink::apply`].
80pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
81
82/// Client-side request handler bundle.
83///
84/// Built by [`crate::net::proxy::Proxy`] and passed into
85/// [`client_loop`].
86pub struct ClientHandler {
87 dispatcher: Arc<dyn Dispatcher>,
88 response_tx: mpsc::Sender<OutboundEnvelope>,
89 data_store: DataStore,
90 next_msg_id: u64,
91 read_timeout: Option<Duration>,
92 gossip: Option<Arc<crate::cluster::gossip::GossipHandler>>,
93 replica_sink: Option<Arc<dyn ReplicaApplySink>>,
94}
95
96impl ClientHandler {
97 /// Build a client handler.
98 ///
99 /// # Examples
100 ///
101 /// ```
102 /// use dynomite::conf::DataStore;
103 /// use dynomite::net::{ClientHandler, NoopDispatcher};
104 /// use std::sync::Arc;
105 /// use tokio::sync::mpsc;
106 /// let (tx, _rx) = mpsc::channel(1);
107 /// let _h = ClientHandler::new(Arc::new(NoopDispatcher), tx, DataStore::Valkey);
108 /// ```
109 #[must_use]
110 pub fn new(
111 dispatcher: Arc<dyn Dispatcher>,
112 response_tx: mpsc::Sender<OutboundEnvelope>,
113 data_store: DataStore,
114 ) -> Self {
115 Self {
116 dispatcher,
117 response_tx,
118 data_store,
119 next_msg_id: 1,
120 read_timeout: None,
121 gossip: None,
122 replica_sink: None,
123 }
124 }
125
126 /// Set the per-read timeout. None disables it.
127 #[must_use]
128 pub fn with_read_timeout(mut self, t: Option<Duration>) -> Self {
129 self.read_timeout = t;
130 self
131 }
132
133 /// Attach a gossip handler. Inbound peer connections served
134 /// through this handler dispatch gossip-class dnode frames
135 /// into the supplied handler instead of the datastore parser.
136 /// Data-plane connections (CLIENT role) leave it `None`.
137 #[must_use]
138 pub fn with_gossip(mut self, gossip: Arc<crate::cluster::gossip::GossipHandler>) -> Self {
139 self.gossip = Some(gossip);
140 self
141 }
142
143 /// Attach a [`ReplicaApplySink`]. Inbound peer connections
144 /// served through this handler apply
145 /// [`DmsgType::RiakReplica`](crate::proto::dnode::DmsgType::RiakReplica)
146 /// frames to the local datastore via the sink instead of
147 /// routing them through the datastore parser, and never
148 /// re-forward them.
149 #[must_use]
150 pub fn with_replica_sink(mut self, sink: Arc<dyn ReplicaApplySink>) -> Self {
151 self.replica_sink = Some(sink);
152 self
153 }
154
155 /// Borrow the attached replica-apply sink, if any.
156 #[must_use]
157 pub fn replica_sink(&self) -> Option<&Arc<dyn ReplicaApplySink>> {
158 self.replica_sink.as_ref()
159 }
160
161 /// Borrow the attached gossip handler, if any.
162 #[must_use]
163 pub fn gossip(&self) -> Option<&Arc<crate::cluster::gossip::GossipHandler>> {
164 self.gossip.as_ref()
165 }
166
167 /// Datastore the handler parses.
168 #[must_use]
169 pub fn data_store(&self) -> DataStore {
170 self.data_store
171 }
172
173 /// Borrow the dispatcher this handler routes parsed requests
174 /// into. Exposed so role-specific drivers (CLIENT,
175 /// DNODE_PEER_CLIENT) can share the same dispatch contract.
176 ///
177 /// # Examples
178 ///
179 /// ```
180 /// use dynomite::conf::DataStore;
181 /// use dynomite::net::{ClientHandler, NoopDispatcher};
182 /// use std::sync::Arc;
183 /// use tokio::sync::mpsc;
184 /// let (tx, _rx) = mpsc::channel(1);
185 /// let h = ClientHandler::new(Arc::new(NoopDispatcher), tx, DataStore::Valkey);
186 /// let _ = h.dispatcher();
187 /// ```
188 #[must_use]
189 pub fn dispatcher(&self) -> &Arc<dyn Dispatcher> {
190 &self.dispatcher
191 }
192
193 /// Borrow the per-connection response sender. The dispatcher
194 /// uses a clone of this channel to push asynchronously-produced
195 /// responses back to the FSM.
196 ///
197 /// # Examples
198 ///
199 /// ```
200 /// use dynomite::conf::DataStore;
201 /// use dynomite::net::{ClientHandler, NoopDispatcher};
202 /// use std::sync::Arc;
203 /// use tokio::sync::mpsc;
204 /// let (tx, _rx) = mpsc::channel(1);
205 /// let h = ClientHandler::new(Arc::new(NoopDispatcher), tx, DataStore::Valkey);
206 /// let _clone = h.response_tx().clone();
207 /// ```
208 #[must_use]
209 pub fn response_tx(&self) -> &mpsc::Sender<OutboundEnvelope> {
210 &self.response_tx
211 }
212
213 fn alloc_msg_id(&mut self) -> MsgId {
214 let id = self.next_msg_id;
215 self.next_msg_id = self.next_msg_id.wrapping_add(1).max(1);
216 id
217 }
218}
219
220/// Drive the client FSM until the peer closes or the dispatcher
221/// asks the driver to exit.
222///
223/// `rx` receives responses produced by the dispatcher; the driver
224/// writes the response bytes to the transport in the order it
225/// received them.
226///
227/// # Errors
228/// Any transport-level error is returned. Parse errors are surfaced
229/// as [`NetError::Parse`] and end the loop after sending a synthetic
230/// error response when possible.
231#[tracing::instrument(
232 name = "client_loop",
233 skip_all,
234 fields(
235 role = ?conn.role(),
236 peer = tracing::field::Empty,
237 ),
238)]
239pub async fn client_loop(
240 mut conn: Conn,
241 mut handler: ClientHandler,
242 mut rx: mpsc::Receiver<OutboundEnvelope>,
243) -> Result<(), NetError> {
244 debug_assert!(matches!(
245 conn.role(),
246 ConnRole::Client | ConnRole::DnodePeerClient
247 ));
248
249 let mut read_buf = vec![0u8; CLIENT_READ_CHUNK];
250 let mut accumulated: Vec<u8> = Vec::new();
251 let mut pending_writes: Vec<u8> = Vec::new();
252
253 loop {
254 // Flush any buffered response bytes first so the loop
255 // exit conditions never block on a full peer.
256 if !pending_writes.is_empty() {
257 let transport = conn.transport_mut().ok_or(NetError::Closed)?;
258 transport.write_all(&pending_writes).await?;
259 conn.record_send(pending_writes.len());
260 pending_writes.clear();
261 }
262
263 if conn.is_eof() && conn.imsg_q().is_empty() && conn.omsg_q().is_empty() {
264 conn.set_done();
265 return Ok(());
266 }
267
268 let read_fut = async {
269 let n = match conn.transport_mut() {
270 Some(t) => t.read(&mut read_buf).await,
271 None => return Ok::<usize, std::io::Error>(0),
272 };
273 n
274 };
275
276 tokio::select! {
277 res = read_fut => {
278 let n = res?;
279 if n == 0 {
280 conn.set_eof();
281 if conn.omsg_q().is_empty() {
282 conn.set_done();
283 return Ok(());
284 }
285 continue;
286 }
287 conn.record_recv(n);
288 accumulated.extend_from_slice(&read_buf[..n]);
289 drive_parser(&mut conn, &mut handler, &mut accumulated).await?;
290 }
291 Some(env) = rx.recv() => {
292 handle_response(&mut conn, &env, &mut pending_writes);
293 }
294 else => {
295 conn.set_done();
296 return Ok(());
297 }
298 }
299 }
300}
301
302#[tracing::instrument(
303 name = "client.parse_loop",
304 skip_all,
305 fields(accumulated = accumulated.len()),
306)]
307async fn drive_parser(
308 conn: &mut Conn,
309 handler: &mut ClientHandler,
310 accumulated: &mut Vec<u8>,
311) -> Result<(), NetError> {
312 use crate::proto::memcache::memcache_parse_req;
313 use crate::proto::redis::redis_parse_req;
314
315 while !accumulated.is_empty() {
316 let id = handler.alloc_msg_id();
317 let mut msg = Msg::new(id, MsgType::Unknown, true);
318 let consumed_before = msg.parser_pos();
319 let parse_result = match handler.data_store {
320 DataStore::Valkey | DataStore::Dyniak => redis_parse_req(&mut msg, accumulated),
321 DataStore::Memcache => memcache_parse_req(&mut msg, accumulated),
322 };
323 match parse_result {
324 MsgParseResult::Ok => {
325 let consumed = msg.parser_pos();
326 if consumed == 0 {
327 return Err(NetError::Parse(
328 "parser reported Ok with no bytes consumed".to_string(),
329 ));
330 }
331 // Per-request span - one is created for every
332 // fully-parsed inbound message. Cross-task work
333 // (dispatch.plan, backend.send / parse, peer.send /
334 // parse, client.send) attaches as children via the
335 // captured `tracing::Span` on the OutboundRequest /
336 // OutboundEnvelope envelopes.
337 let req_span = tracing::info_span!(
338 "client.parse",
339 msg_id = msg.id(),
340 msg_type = ?msg.ty(),
341 bytes = consumed,
342 );
343 let was_quit = msg.flags().quit;
344 let quit_msg_id = if was_quit { Some(msg.id()) } else { None };
345 let inline_send: Option<OutboundEnvelope> = req_span.in_scope(|| {
346 // Carry the consumed wire bytes inside the
347 // msg so the dispatcher can forward them to a
348 // backend without having to re-encode. The bytes
349 // live on the msg's own mbuf chain across
350 // recv -> filter -> forward.
351 let pool = conn.mbuf_pool().clone();
352 let mut buf = pool.get();
353 buf.recv(&accumulated[..consumed]);
354 msg.mbufs_mut().push_back(buf);
355 msg.recompute_mlen();
356 accumulated.drain(0..consumed);
357 let _ = consumed_before;
358 conn.outstanding_mut().insert(msg.id(), msg.id());
359 // The placeholder enqueue cannot fail under
360 // normal operation; if it does we surface it
361 // via the outer `?`.
362 conn.enqueue_out(Msg::new(msg.id(), msg.ty(), true))
363 .map_err(|e: NetError| e)?;
364 let outcome = handler
365 .dispatcher
366 .dispatch(msg, handler.response_tx.clone());
367 let inline = match outcome {
368 DispatchOutcome::Pending | DispatchOutcome::Drop => None,
369 DispatchOutcome::Inline(rsp) | DispatchOutcome::Error(rsp) => {
370 Some(OutboundEnvelope {
371 req_id: rsp.id(),
372 rsp,
373 span: tracing::Span::current(),
374 source_peer_idx: None,
375 })
376 }
377 };
378 Ok::<Option<OutboundEnvelope>, NetError>(inline)
379 })?;
380 if let Some(env) = inline_send {
381 let _ = handler.response_tx.send(env).await;
382 }
383 if let Some(qid) = quit_msg_id {
384 // Real Redis replies `+OK\r\n` to QUIT and then
385 // closes the client connection. Synthesize the
386 // reply here (the dispatcher returned `Drop`
387 // because there is no key to route) and send it
388 // through the same `response_tx` used by every
389 // other reply, which pops the placeholder we
390 // pushed onto `omsg_q` above. Without this the
391 // outer client loop's exit condition
392 // (`omsg_q.is_empty()`) is never met and the
393 // connection deadlocks until the kernel times
394 // out the read.
395 let pool = conn.mbuf_pool().clone();
396 let mut anchor = Msg::new(qid, MsgType::ReqRedisQuit, true);
397 anchor.set_parent_id(qid);
398 let rsp = response::make_simple_redis(&anchor, &pool, b"+OK\r\n");
399 let env = OutboundEnvelope {
400 req_id: qid,
401 rsp,
402 span: req_span.clone(),
403 source_peer_idx: None,
404 };
405 let _ = handler.response_tx.send(env).await;
406 // Close the connection after replying.
407 conn.set_eof();
408 return Ok(());
409 }
410 }
411 MsgParseResult::Again
412 | MsgParseResult::Repair
413 | MsgParseResult::Fragment
414 | MsgParseResult::Noop => {
415 let consumed = msg.parser_pos();
416 if consumed > 0 {
417 accumulated.drain(0..consumed);
418 } else {
419 return Ok(());
420 }
421 }
422 MsgParseResult::Error | MsgParseResult::OomError | MsgParseResult::DynoConfig => {
423 return Err(NetError::Parse(format!("{parse_result:?}")));
424 }
425 }
426 }
427 Ok(())
428}
429
430fn handle_response(conn: &mut Conn, env: &OutboundEnvelope, pending: &mut Vec<u8>) {
431 let _enter = env.span.enter();
432 let bytes_len: usize = env.rsp.mbufs().iter().map(|b| b.readable().len()).sum();
433 let _send_span =
434 tracing::info_span!("client.send", req_id = env.req_id, bytes = bytes_len,).entered();
435 for buf in env.rsp.mbufs() {
436 pending.extend_from_slice(buf.readable());
437 }
438 // Pop the matching outstanding entry.
439 conn.outstanding_mut().remove(&env.req_id);
440 // Pop the placeholder we pushed on enqueue.
441 if let Some(front) = conn.omsg_q_mut().front() {
442 if front.id() == env.req_id {
443 let _ = conn.omsg_q_mut().pop_front();
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn alloc_msg_id_is_monotonic() {
454 let (tx, _rx) = mpsc::channel(1);
455 let mut h = ClientHandler::new(Arc::new(crate::net::NoopDispatcher), tx, DataStore::Valkey);
456 let a = h.alloc_msg_id();
457 let b = h.alloc_msg_id();
458 assert_eq!(a + 1, b);
459 }
460}