netget/client/redis/mod.rs
1//! Redis client implementation
2pub mod actions;
3pub mod cluster;
4mod cluster_driver;
5pub mod pubsub;
6pub mod resp;
7
8pub use actions::RedisClientProtocol;
9
10use crate::llm::actions::client_trait::{Client, ClientActionResult};
11use anyhow::{Context, Result};
12use std::net::SocketAddr;
13use std::sync::Arc;
14use tokio::io::{AsyncWriteExt, BufReader};
15use tokio::net::TcpStream;
16use tokio::sync::{mpsc, Mutex};
17use tracing::{debug, error, info, trace};
18
19use crate::client::llm_budget::call_llm_for_client;
20use crate::client::redis::actions::{
21 REDIS_CLIENT_CONNECTED_EVENT, REDIS_CLIENT_MESSAGE_EVENT, REDIS_CLIENT_RESPONSE_RECEIVED_EVENT,
22 REDIS_CLIENT_SUBSCRIPTION_EVENT,
23};
24use crate::llm::ollama_client::OllamaClient;
25use crate::llm::ClientLlmResult;
26use crate::logging::patterns;
27use crate::protocol::Event;
28use crate::state::app_state::AppState;
29use crate::state::{ClientId, ClientStatus};
30use pubsub::{Classified, Session};
31
32/// Redis client that connects to a Redis server
33pub struct RedisClient;
34
35/// The startup parameters `connect` reads.
36#[derive(Debug, Default, Clone, Copy)]
37pub struct RedisClientOptions {
38 /// Cluster mode: route by hash slot, follow MOVED/ASK (`cluster.rs`).
39 pub cluster: bool,
40 /// Send `HELLO 3` on connect.
41 pub resp3: bool,
42}
43
44/// How long the server has to answer the `HELLO 3` sent on connect.
45const HELLO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
46
47/// What one executed action puts on the wire.
48enum Wire {
49 /// Commands for this connection, in order.
50 Commands(Vec<Vec<Vec<u8>>>),
51 /// A `PUBLISH`/`SPUBLISH`, which a subscribed RESP2 connection sends on a side connection.
52 Publish(Vec<Vec<u8>>),
53 /// Drop every subscription this connection holds.
54 UnsubscribeAll,
55 Disconnect,
56 Nothing,
57}
58
59/// Turn an executed action into what it sends. The one place the client's Custom results are
60/// decoded, shared by the stream connection and cluster mode.
61fn wire_for(action_result: ClientActionResult, client_id: ClientId) -> Result<Wire> {
62 let strings = |v: &serde_json::Value| -> Result<Vec<Vec<u8>>> {
63 v.as_array()
64 .ok_or_else(|| anyhow::anyhow!("a command is an array of strings"))?
65 .iter()
66 .map(|a| {
67 a.as_str()
68 .map(|s| s.as_bytes().to_vec())
69 .ok_or_else(|| anyhow::anyhow!("a command argument is a string"))
70 })
71 .collect()
72 };
73 Ok(match action_result {
74 ClientActionResult::Custom { name, data } => match name.as_str() {
75 "redis_command" => {
76 let command = data
77 .get("command")
78 .and_then(|v| v.as_str())
79 .ok_or_else(|| anyhow::anyhow!("Missing command in action data"))?;
80 Wire::Commands(vec![resp::split_command(command)?])
81 }
82 "redis_commands" => Wire::Commands(
83 data.get("commands")
84 .and_then(|v| v.as_array())
85 .ok_or_else(|| anyhow::anyhow!("Missing commands in action data"))?
86 .iter()
87 .map(strings)
88 .collect::<Result<_>>()?,
89 ),
90 "redis_publish" => Wire::Publish(strings(
91 data.get("args")
92 .ok_or_else(|| anyhow::anyhow!("Missing args in action data"))?,
93 )?),
94 "redis_unsubscribe_all" => Wire::UnsubscribeAll,
95 other => {
96 debug!(
97 "Redis client {} ignoring unhandled custom result: {}",
98 client_id, other
99 );
100 Wire::Nothing
101 }
102 },
103 ClientActionResult::Disconnect => Wire::Disconnect,
104 other => {
105 debug!(
106 "Redis client {} ignoring unhandled action result: {:?}",
107 client_id, other
108 );
109 Wire::Nothing
110 }
111 })
112}
113
114/// The event one reply raises: a delivered message, a subscription confirmation, or an
115/// ordinary reply.
116fn event_for(reply: &resp::Reply, session: &Session) -> Event {
117 match session.classify(reply) {
118 Classified::Message {
119 kind,
120 pattern,
121 channel,
122 message,
123 } => {
124 let mut data = serde_json::json!({
125 "kind": kind,
126 "channel": channel,
127 "message": message,
128 "subscriptions": session.snapshot(),
129 "protocol": if reply.reply_type == "push" { 3 } else { 2 },
130 });
131 if let Some(p) = pattern {
132 data["pattern"] = serde_json::Value::String(p);
133 }
134 Event::new(&REDIS_CLIENT_MESSAGE_EVENT, data)
135 }
136 Classified::Subscription {
137 kind,
138 channel,
139 count,
140 } => Event::new(
141 &REDIS_CLIENT_SUBSCRIPTION_EVENT,
142 serde_json::json!({
143 "kind": kind,
144 "channel": channel,
145 "count": count,
146 "subscriptions": session.snapshot(),
147 }),
148 ),
149 Classified::Reply => Event::new(&REDIS_CLIENT_RESPONSE_RECEIVED_EVENT, reply.event_data()),
150 }
151}
152
153impl RedisClient {
154 /// Connect to a Redis server with integrated LLM actions
155 pub async fn connect_with_llm_actions(
156 remote_addr: String,
157 llm_client: OllamaClient,
158 app_state: Arc<AppState>,
159 status_tx: mpsc::UnboundedSender<String>,
160 client_id: ClientId,
161 options: RedisClientOptions,
162 ) -> Result<SocketAddr> {
163 if options.cluster {
164 anyhow::ensure!(
165 !options.resp3,
166 "resp3 is not available in cluster mode: cluster connections speak RESP2"
167 );
168 return cluster_driver::connect(
169 remote_addr,
170 llm_client,
171 app_state,
172 status_tx,
173 client_id,
174 )
175 .await;
176 }
177
178 // Connect to Redis server
179 let stream = TcpStream::connect(&remote_addr)
180 .await
181 .context(format!("Failed to connect to Redis at {}", remote_addr))?;
182
183 let local_addr = stream.local_addr()?;
184 let remote_sock_addr = stream.peer_addr()?;
185
186 info!(
187 "Redis client {} {} {} (local: {})",
188 client_id,
189 patterns::REDIS_CLIENT_CONNECTED,
190 remote_sock_addr,
191 local_addr
192 );
193
194 // Update client state
195 app_state
196 .update_client_status(client_id, ClientStatus::Connected)
197 .await;
198 let _ = status_tx.send(format!("[CLIENT] Redis client {} connected", client_id));
199 let _ = status_tx.send("__UPDATE_UI__".to_string());
200
201 // Split stream
202 let (read_half, write_half) = tokio::io::split(stream);
203 let write_half_arc = Arc::new(Mutex::new(write_half));
204 let mut reader = BufReader::new(read_half);
205 let protocol = Arc::new(RedisClientProtocol::new());
206 let session = Arc::new(Session::new(remote_addr.clone()));
207
208 // RESP3 is agreed before the model hears the client is connected, so its first command
209 // already runs on the protocol it asked for. A server that refuses fails the connect.
210 if options.resp3 {
211 write_half_arc
212 .lock()
213 .await
214 .write_all(&resp::encode_command(&[b"HELLO".to_vec(), b"3".to_vec()]))
215 .await?;
216 let reply = tokio::time::timeout(HELLO_TIMEOUT, resp::read_reply(&mut reader))
217 .await
218 .context("the server did not answer HELLO 3")??
219 .context("the server closed the connection instead of answering HELLO 3")?;
220 session.classify(&reply);
221 anyhow::ensure!(
222 session.is_resp3(),
223 "the server refused RESP3: HELLO 3 was answered with {}",
224 reply.response
225 );
226 info!("Redis client {} speaks RESP3", client_id);
227 }
228
229 // Command channel for injected actions (the dashboard's [ execute_redis_command ]).
230 // Registered BEFORE the connected-event LLM call, which a manual `*` rule can park
231 // for minutes - the operator must be able to reach the client while it waits.
232 let command_rx =
233 crate::client::command_support::register_command_channel(&app_state, client_id).await;
234
235 // `read_reply` is not cancellation-safe, so the commands are drained by their own task
236 // rather than a `select!` arm in the read loop. Both tasks share the write half.
237 let cmd_state = app_state.clone();
238 let cmd_tx = status_tx.clone();
239 let cmd_write = write_half_arc.clone();
240 let cmd_protocol = protocol.clone();
241 let cmd_session = session.clone();
242 let cmd_task = tokio::spawn(async move {
243 Self::command_loop(
244 command_rx,
245 cmd_protocol,
246 cmd_write,
247 client_id,
248 cmd_state,
249 cmd_tx,
250 cmd_session,
251 )
252 .await;
253 });
254 app_state.register_client_task(client_id, cmd_task).await;
255
256 // Call LLM with redis_connected event
257 if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
258 let event = Event::new(
259 &REDIS_CLIENT_CONNECTED_EVENT,
260 serde_json::json!({
261 "remote_addr": remote_sock_addr.to_string(),
262 }),
263 );
264
265 // `initial_memory` is written before `connect()` runs
266 // (`src/cli/client_startup.rs`), so it is available here; passing an empty string
267 // discarded whatever the operator or the model had set up.
268 let memory = app_state
269 .get_memory_for_client(client_id)
270 .await
271 .unwrap_or_default();
272
273 match call_llm_for_client(
274 &llm_client,
275 &app_state,
276 client_id.to_string(),
277 &instruction,
278 &memory,
279 Some(&event),
280 protocol.as_ref(),
281 &status_tx,
282 )
283 .await
284 {
285 Ok(result) => {
286 // A `set_memory` on the connect turn used to be dropped on the floor -
287 // `result.memory_updates` was never read - so the model could not carry
288 // anything from its first turn into the next one. mysql and postgresql
289 // both do this correctly.
290 if let Some(mem) = result.memory_updates {
291 app_state.set_memory_for_client(client_id, mem).await;
292 }
293 for action in result.actions {
294 match protocol.execute_action(action) {
295 Ok(action_result) => {
296 match Self::apply_action(
297 action_result,
298 &write_half_arc,
299 client_id,
300 &session,
301 )
302 .await
303 {
304 Ok(Applied::Disconnect) => {
305 info!("LLM requested disconnect after connect");
306 let _ = write_half_arc.lock().await.shutdown().await;
307 app_state.remove_client_handle(client_id).await;
308 app_state
309 .update_client_status(
310 client_id,
311 ClientStatus::Disconnected,
312 )
313 .await;
314 let _ = status_tx.send("__UPDATE_UI__".to_string());
315 return Ok(local_addr);
316 }
317 Ok(Applied::Sent(_)) => {}
318 Err(e) => {
319 error!("Failed to send Redis command after connect: {}", e)
320 }
321 }
322 }
323 Err(e) => {
324 error!(
325 "Redis client {} could not execute action after connect: {}",
326 client_id, e
327 );
328 }
329 }
330 }
331 }
332 Err(e) => {
333 error!("LLM error on redis_connected event: {}", e);
334 }
335 }
336 }
337
338 // Spawn read loop for Redis responses. Registered with AppState so
339 // stop_client can abort it and release the socket.
340 let task_registrar = app_state.clone();
341 let handle = tokio::spawn(async move {
342 loop {
343 // One complete RESP reply per event — a bulk string or an array is never
344 // split across events. See `resp.rs` for the framing and its bounds.
345 match resp::read_reply(&mut reader).await {
346 Ok(None) => {
347 info!(
348 "Redis client {} {}",
349 client_id,
350 patterns::REDIS_CLIENT_DISCONNECTED
351 );
352 app_state
353 .update_client_status(client_id, ClientStatus::Disconnected)
354 .await;
355 let _ = status_tx
356 .send(format!("[CLIENT] Redis client {} disconnected", client_id));
357 let _ = status_tx.send("__UPDATE_UI__".to_string());
358 break;
359 }
360 Ok(Some(reply)) => {
361 trace!(
362 "Redis client {} received {}: {}",
363 client_id,
364 reply.reply_type,
365 crate::utils::truncate::truncate_for_log(&reply.response, 200)
366 );
367
368 // Call LLM with response
369 if let Some(instruction) =
370 app_state.get_instruction_for_client(client_id).await
371 {
372 // A delivered message and a subscription confirmation are
373 // their own events; everything else is a reply.
374 let event = event_for(&reply, &session);
375
376 let memory = app_state
377 .get_memory_for_client(client_id)
378 .await
379 .unwrap_or_default();
380
381 match call_llm_for_client(
382 &llm_client,
383 &app_state,
384 client_id.to_string(),
385 &instruction,
386 &memory,
387 Some(&event),
388 protocol.as_ref(),
389 &status_tx,
390 )
391 .await
392 {
393 Ok(ClientLlmResult {
394 actions,
395 memory_updates,
396 }) => {
397 // Update memory
398 if let Some(mem) = memory_updates {
399 app_state.set_memory_for_client(client_id, mem).await;
400 }
401
402 // Execute actions
403 //
404 // `disconnect_requested` rather than a bare `break`: the
405 // break here left only the `for action in actions` loop,
406 // so the read loop carried straight on and the socket
407 // stayed open. Nor did it shut the write half, drop the
408 // command handle or set `Disconnected` - all of which the
409 // connect-time path above does. The model could ask this
410 // client to disconnect and nothing at all happened.
411 let mut disconnect_requested = false;
412 for action in actions {
413 match protocol.execute_action(action) {
414 Ok(action_result) => {
415 match Self::apply_action(
416 action_result,
417 &write_half_arc,
418 client_id,
419 &session,
420 )
421 .await
422 {
423 Ok(Applied::Disconnect) => {
424 info!(
425 "Redis client {} disconnecting",
426 client_id
427 );
428 disconnect_requested = true;
429 break;
430 }
431 Ok(Applied::Sent(_)) => {}
432 Err(e) => {
433 error!(
434 "Redis client {} write failed: {}",
435 client_id, e
436 );
437 }
438 }
439 }
440 // Do not swallow. `_ => {}` here meant an action
441 // the client cannot execute — including one it
442 // never advertised — was indistinguishable from
443 // success: nothing went on the wire and nothing
444 // said so. Two tests passed at HEAD while sending
445 // `wait_for_more`, which this client does not
446 // implement.
447 Err(e) => {
448 error!(
449 "Redis client {} could not execute action: {}",
450 client_id, e
451 );
452 let _ = status_tx.send(format!(
453 "[ERROR] Redis client {} action failed: {}",
454 client_id, e
455 ));
456 }
457 }
458 }
459
460 if disconnect_requested {
461 // The same teardown the connect-time path runs:
462 // half-close so the server sees EOF, drop the command
463 // handle so the dashboard stops offering [ send ] on
464 // a client whose loop is gone, and record the status.
465 let _ = write_half_arc.lock().await.shutdown().await;
466 app_state.remove_client_handle(client_id).await;
467 app_state
468 .update_client_status(
469 client_id,
470 ClientStatus::Disconnected,
471 )
472 .await;
473 let _ = status_tx.send(format!(
474 "[CLIENT] Redis client {} disconnected",
475 client_id
476 ));
477 let _ = status_tx.send("__UPDATE_UI__".to_string());
478 break;
479 }
480 }
481 Err(e) => {
482 error!("LLM error for Redis client {}: {}", client_id, e);
483 }
484 }
485 }
486 }
487 Err(e) => {
488 // A socket error, or a reply that is not RESP or breaks a bound in
489 // `resp.rs`. Either way this connection's framing is gone.
490 error!("Redis client {} read error: {}", client_id, e);
491 app_state
492 .update_client_status(client_id, ClientStatus::Error(e.to_string()))
493 .await;
494 let _ = status_tx.send("__UPDATE_UI__".to_string());
495 break;
496 }
497 }
498 }
499 // Every exit path lands here: drop the command handle so the dashboard stops
500 // offering [ send ] on a dead connection (a late send then fails fast). This
501 // also closes the command channel, which ends `command_loop`.
502 app_state.remove_client_handle(client_id).await;
503 let _ = status_tx.send("__UPDATE_UI__".to_string());
504 });
505 task_registrar.register_client_task(client_id, handle).await;
506
507 Ok(local_addr)
508 }
509
510 /// Drain injected commands until the channel closes (client removed) or an injected
511 /// `disconnect` ends the session.
512 ///
513 /// The generic `command_support::handle_stream_client_command` cannot run this client's
514 /// vocabulary because `execute_redis_command` yields `ClientActionResult::Custom`, so the
515 /// action goes through [`Self::apply_action`] - the same function the LLM path uses -
516 /// and the outcome is recorded and replied exactly the way the generic arm does it.
517 async fn command_loop<W>(
518 mut command_rx: tokio::sync::mpsc::Receiver<crate::state::client_handles::ClientCommand>,
519 protocol: Arc<RedisClientProtocol>,
520 write_half: Arc<Mutex<W>>,
521 client_id: ClientId,
522 app_state: Arc<AppState>,
523 status_tx: mpsc::UnboundedSender<String>,
524 session: Arc<Session>,
525 ) where
526 W: tokio::io::AsyncWrite + Unpin + Send + 'static,
527 {
528 use crate::llm::actions::protocol_trait::Protocol;
529 use crate::state::client_handles::ClientSendOutcome;
530 use crate::state::AccessLogOwner;
531
532 while let Some(command) = command_rx.recv().await {
533 let action = command.action.clone();
534 let outcome = match protocol.execute_action(action.clone()) {
535 Err(e) => Ok(ClientSendOutcome::Rejected {
536 error: e.to_string(),
537 }),
538 Ok(result) => Self::apply_action(result, &write_half, client_id, &session)
539 .await
540 .map(|applied| match applied {
541 Applied::Disconnect => ClientSendOutcome::Disconnected,
542 Applied::Sent(0) => ClientSendOutcome::Executed {
543 detail: "executed (nothing to write)".to_string(),
544 },
545 Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
546 }),
547 };
548
549 let outcome_json = match &outcome {
550 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
551 Err(e) => serde_json::json!({"error": e.to_string()}),
552 };
553 app_state
554 .record_access_log(
555 AccessLogOwner::Client(client_id.as_u32()),
556 protocol.protocol_name(),
557 None,
558 "injected_action",
559 action,
560 vec![outcome_json],
561 )
562 .await;
563
564 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
565 if let Err(e) = &outcome {
566 error!("Redis client {} injected action failed: {}", client_id, e);
567 let _ = status_tx.send(format!(
568 "[WARN] Client {} injected action failed: {}",
569 client_id, e
570 ));
571 }
572 let _ = status_tx.send("__UPDATE_UI__".to_string());
573 crate::client::command_support::reply(command, outcome);
574
575 if disconnect {
576 // Half-close so the server reads EOF and the read loop runs its normal
577 // disconnect path.
578 let _ = write_half.lock().await.shutdown().await;
579 break;
580 }
581 }
582 }
583
584 /// Put one executed action on the wire. Shared by the connected-event path, the read
585 /// loop and injected commands so the RESP encoding of every client verb exists exactly
586 /// once.
587 async fn apply_action<W>(
588 action_result: ClientActionResult,
589 write_half: &Arc<Mutex<W>>,
590 client_id: ClientId,
591 session: &Session,
592 ) -> Result<Applied>
593 where
594 W: tokio::io::AsyncWrite + Unpin,
595 {
596 let commands = match wire_for(action_result, client_id)? {
597 Wire::Disconnect => return Ok(Applied::Disconnect),
598 Wire::Nothing => return Ok(Applied::Sent(0)),
599 Wire::Commands(commands) => commands,
600 Wire::UnsubscribeAll => session.unsubscribe_all_commands(),
601 Wire::Publish(args) if session.subscribed_resp2() => {
602 // Redis refuses PUBLISH on a RESP2 connection in subscribed mode, so it goes
603 // over a short second connection to the same server.
604 let receivers =
605 pubsub::publish_on_side_connection(&session.remote_addr, &args).await?;
606 info!(
607 "Redis client {} published on a side connection (this one is subscribed): \
608 {} receiver(s)",
609 client_id, receivers
610 );
611 return Ok(Applied::Sent(resp::encode_command(&args).len()));
612 }
613 Wire::Publish(args) => vec![args],
614 };
615 let mut sent = 0;
616 for args in commands {
617 let cmd_bytes = resp::encode_command(&args);
618 session.note_sent(&args);
619 let mut writer = write_half.lock().await;
620 writer.write_all(&cmd_bytes).await?;
621 writer.flush().await?;
622 info!(
623 "{} {}",
624 patterns::REDIS_CLIENT_SENT_COMMAND,
625 crate::utils::truncate_for_log(
626 &args
627 .iter()
628 .map(|a| String::from_utf8_lossy(a).to_string())
629 .collect::<Vec<_>>()
630 .join(" "),
631 256
632 )
633 );
634 sent += cmd_bytes.len();
635 }
636 Ok(Applied::Sent(sent))
637 }
638}
639
640/// What [`RedisClient::apply_action`] did with one action.
641enum Applied {
642 /// Bytes written (0 when the action produced no wire output).
643 Sent(usize),
644 /// The session should end.
645 Disconnect,
646}