Skip to main content

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}