Skip to main content

netget/client/memcached/
mod.rs

1//! Memcached client: NetGet dials a memcached server and the model drives the text protocol.
2//!
3//! Three tasks per connection, all registered with the client so `stop_client` ends them:
4//!
5//! * **transport** owns the socket and the FIFO of requests in flight. It writes requests,
6//!   reads replies one whole response at a time (`wire::parse_reply`), and queues each for
7//!   the model. It never waits on the model, so a turn parked for a human cannot stall it.
8//! * **turns** asks the model about each queued reply, in order, and hands every action it
9//!   returns back to the transport. The chain request → reply → model → request continues
10//!   through that queue, so it needs no recursion and no depth counter.
11//! * **commands** executes actions injected from outside (the dashboard's `[ send ]`, MCP
12//!   `send_to_client`) through the same path the model's actions take.
13
14pub mod actions;
15pub mod wire;
16
17pub use actions::MemcachedClientProtocol;
18
19use std::collections::VecDeque;
20use std::net::SocketAddr;
21use std::sync::Arc;
22
23use anyhow::{anyhow, Context, Result};
24use serde_json::json;
25use tokio::io::{AsyncReadExt, AsyncWriteExt};
26use tokio::net::TcpStream;
27use tokio::sync::{mpsc, oneshot};
28use tracing::{debug, error, info, warn};
29
30use crate::client::llm_budget::call_llm_for_client;
31use crate::client::memcached::actions::{
32    request_from_action, MEMCACHED_CONNECTED_EVENT, MEMCACHED_COUNTER_EVENT,
33    MEMCACHED_DELETED_EVENT, MEMCACHED_ERROR_EVENT, MEMCACHED_EXISTS_EVENT, MEMCACHED_MISS_EVENT,
34    MEMCACHED_NOT_FOUND_EVENT, MEMCACHED_NOT_STORED_EVENT, MEMCACHED_OK_EVENT,
35    MEMCACHED_STATS_EVENT, MEMCACHED_STORED_EVENT, MEMCACHED_TOUCHED_EVENT, MEMCACHED_VALUE_EVENT,
36    MEMCACHED_VERSION_EVENT, REQUEST_RESULT,
37};
38use crate::client::memcached::wire::{parse_reply, stats_json, Expect, Reply, Request};
39use crate::llm::actions::client_trait::{Client, ClientActionResult};
40use crate::llm::actions::protocol_trait::Protocol;
41use crate::llm::ollama_client::OllamaClient;
42use crate::protocol::Event;
43use crate::state::app_state::AppState;
44use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
45use crate::state::{AccessLogOwner, ClientId, ClientStatus};
46
47/// Replies that may wait for the model while a turn is running. The transport never blocks
48/// on this queue: when it is full a reply is dropped with `decision=turn_queue_full`.
49const TURN_QUEUE_CAPACITY: usize = 256;
50
51/// Requests written and not yet answered. A model that pipelines past this is refused rather
52/// than letting the FIFO grow without bound.
53const MAX_IN_FLIGHT: usize = 128;
54
55/// Requests waiting for the transport to write them.
56const OUTBOUND_CAPACITY: usize = 64;
57
58/// What the turn and command tasks ask the transport to do.
59enum Outbound {
60    Request {
61        request: Request,
62        ack: oneshot::Sender<Result<usize, String>>,
63    },
64    Disconnect {
65        ack: oneshot::Sender<()>,
66    },
67}
68
69/// One thing the model is asked about.
70enum Turn {
71    Connected,
72    Reply { expect: Expect, reply: Reply },
73}
74
75/// What one action did.
76enum Applied {
77    Sent(usize),
78    Nothing,
79    Disconnect,
80}
81
82pub struct MemcachedClient;
83
84impl MemcachedClient {
85    pub async fn connect_with_llm_actions(
86        remote_addr: String,
87        llm_client: OllamaClient,
88        app_state: Arc<AppState>,
89        status_tx: mpsc::UnboundedSender<String>,
90        client_id: ClientId,
91    ) -> Result<SocketAddr> {
92        // A client that lost its target must fail, never guess one.
93        if remote_addr.trim().is_empty() {
94            return Err(anyhow!(
95                "memcached client needs a remote_addr (host:port); refusing to connect \
96                 without one"
97            ));
98        }
99        let stream = TcpStream::connect(&remote_addr)
100            .await
101            .with_context(|| format!("Failed to connect to memcached at {remote_addr}"))?;
102        let local_addr = stream.local_addr()?;
103        let peer = stream.peer_addr()?;
104        info!("Memcached client {client_id} connected to {peer} (local {local_addr})");
105        app_state
106            .update_client_status(client_id, ClientStatus::Connected)
107            .await;
108        let _ = status_tx.send(format!("[CLIENT] Memcached client {client_id} connected"));
109        let _ = status_tx.send("__UPDATE_UI__".to_string());
110
111        let protocol = Arc::new(MemcachedClientProtocol::new());
112        let (outbound_tx, outbound_rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
113        let (turn_tx, turn_rx) = mpsc::channel::<Turn>(TURN_QUEUE_CAPACITY);
114
115        // Registered before the connected turn can run, so the operator can reach the client
116        // while that turn is parked on a manual rule.
117        let command_rx =
118            crate::client::command_support::register_command_channel(&app_state, client_id).await;
119        app_state
120            .spawn_client_task(
121                client_id,
122                command_loop(
123                    command_rx,
124                    protocol.clone(),
125                    outbound_tx.clone(),
126                    client_id,
127                    app_state.clone(),
128                    status_tx.clone(),
129                ),
130            )
131            .await;
132
133        let turn_abort = app_state
134            .spawn_client_task(
135                client_id,
136                run_turns(
137                    turn_rx,
138                    outbound_tx,
139                    protocol,
140                    llm_client,
141                    app_state.clone(),
142                    status_tx.clone(),
143                    client_id,
144                    peer.to_string(),
145                ),
146            )
147            .await;
148
149        let _ = turn_tx.try_send(Turn::Connected);
150        app_state
151            .spawn_client_task(
152                client_id,
153                run_transport(
154                    stream,
155                    outbound_rx,
156                    turn_tx,
157                    app_state.clone(),
158                    status_tx,
159                    client_id,
160                    turn_abort,
161                ),
162            )
163            .await;
164
165        Ok(local_addr)
166    }
167}
168
169/// Own the socket for the life of the connection.
170async fn run_transport(
171    stream: TcpStream,
172    mut outbound_rx: mpsc::Receiver<Outbound>,
173    turn_tx: mpsc::Sender<Turn>,
174    app_state: Arc<AppState>,
175    status_tx: mpsc::UnboundedSender<String>,
176    client_id: ClientId,
177    turn_abort: tokio::task::AbortHandle,
178) {
179    let (mut reader, mut writer) = tokio::io::split(stream);
180    let mut pending: VecDeque<Expect> = VecDeque::new();
181    let mut buf: Vec<u8> = Vec::with_capacity(16 * 1024);
182    let mut chunk = vec![0u8; 16 * 1024];
183
184    let status = loop {
185        tokio::select! {
186            read = reader.read(&mut chunk) => {
187                let n = match read {
188                    Ok(0) => {
189                        info!("Memcached client {client_id} closed by server");
190                        break ClientStatus::Disconnected;
191                    }
192                    Ok(n) => n,
193                    Err(e) => {
194                        error!("Memcached client {client_id} read error: {e}");
195                        break ClientStatus::Error(e.to_string());
196                    }
197                };
198                buf.extend_from_slice(&chunk[..n]);
199                if let Err(e) = drain_replies(&mut buf, &mut pending, &turn_tx, client_id) {
200                    error!("Memcached client {client_id} protocol error: {e}");
201                    break ClientStatus::Error(e);
202                }
203            }
204            out = outbound_rx.recv() => match out {
205                None => break ClientStatus::Disconnected,
206                Some(Outbound::Disconnect { ack }) => {
207                    let _ = writer.shutdown().await;
208                    let _ = ack.send(());
209                    info!("Memcached client {client_id} disconnected on request");
210                    break ClientStatus::Disconnected;
211                }
212                Some(Outbound::Request { request, ack }) => {
213                    if pending.len() >= MAX_IN_FLIGHT {
214                        let _ = ack.send(Err(format!(
215                            "{MAX_IN_FLIGHT} requests are already waiting for a reply"
216                        )));
217                        continue;
218                    }
219                    let bytes = request.encode();
220                    let written = async {
221                        writer.write_all(&bytes).await?;
222                        writer.flush().await
223                    }
224                    .await;
225                    match written {
226                        Ok(()) => {
227                            debug!(
228                                "Memcached client {client_id} sent {} ({} bytes)",
229                                request.command(),
230                                bytes.len()
231                            );
232                            pending.push_back(request.expect());
233                            let _ = ack.send(Ok(bytes.len()));
234                        }
235                        Err(e) => {
236                            let _ = ack.send(Err(format!("write failed: {e}")));
237                            error!("Memcached client {client_id} write error: {e}");
238                            break ClientStatus::Error(e.to_string());
239                        }
240                    }
241                }
242            }
243        }
244    };
245
246    turn_abort.abort();
247    app_state.remove_client_handle(client_id).await;
248    app_state.update_client_status(client_id, status).await;
249    let _ = status_tx.send(format!(
250        "[CLIENT] Memcached client {client_id} disconnected"
251    ));
252    let _ = status_tx.send("__UPDATE_UI__".to_string());
253}
254
255/// Take every complete reply off the front of `buf`, pairing each with its request.
256fn drain_replies(
257    buf: &mut Vec<u8>,
258    pending: &mut VecDeque<Expect>,
259    turn_tx: &mpsc::Sender<Turn>,
260    client_id: ClientId,
261) -> Result<(), String> {
262    while !buf.is_empty() {
263        let Some(expect) = pending.front() else {
264            return Err(format!(
265                "server sent {} octets with no request in flight",
266                buf.len()
267            ));
268        };
269        match parse_reply(buf, expect).map_err(|e| e.to_string())? {
270            None => return Ok(()),
271            Some((reply, used)) => {
272                buf.drain(..used);
273                let expect = pending.pop_front().expect("front() was Some");
274                if turn_tx.try_send(Turn::Reply { expect, reply }).is_err() {
275                    warn!(
276                        "Memcached client {client_id} dropped a reply: the model is \
277                         {TURN_QUEUE_CAPACITY} replies behind decision=turn_queue_full"
278                    );
279                }
280            }
281        }
282    }
283    Ok(())
284}
285
286/// The events one reply becomes. A `get` becomes one per requested key, in request order.
287fn events_for(expect: &Expect, reply: Reply) -> Vec<Event> {
288    use crate::utils::sanitize::line_field;
289    match (expect, reply) {
290        (Expect::Values { keys, .. }, Reply::Values { items, .. }) => keys
291            .iter()
292            .map(|key| match items.iter().find(|i| &i.key == key) {
293                None => Event::new(&MEMCACHED_MISS_EVENT, json!({ "key": key })),
294                Some(item) => match std::str::from_utf8(&item.data) {
295                    Ok(text) => {
296                        let mut data = json!({
297                            "key": key,
298                            "value": text,
299                            "flags": item.flags,
300                        });
301                        if let Some(cas) = item.cas {
302                            data["cas"] = json!(cas);
303                        }
304                        Event::new(&MEMCACHED_VALUE_EVENT, data)
305                    }
306                    Err(_) => Event::new(
307                        &MEMCACHED_ERROR_EVENT,
308                        json!({
309                            "kind": "non_text_value",
310                            "message": format!(
311                                "the value stored under {key:?} is {} bytes that are not \
312                                 valid UTF-8; this client carries values as text only and \
313                                 does not re-encode them",
314                                item.data.len()
315                            ),
316                            "command": "get",
317                            "key": key,
318                        }),
319                    ),
320                },
321            })
322            .collect(),
323        (Expect::Stats { group }, Reply::Stats { entries }) => {
324            let mut data = json!({ "stats": stats_json(&entries) });
325            if let Some(g) = group {
326                data["group"] = json!(g);
327            }
328            vec![Event::new(&MEMCACHED_STATS_EVENT, data)]
329        }
330        (expect, Reply::Error { kind, message }) => {
331            let (command, key) = match expect {
332                Expect::Values { keys, with_cas } => (
333                    if *with_cas { "gets" } else { "get" },
334                    keys.first().cloned(),
335                ),
336                Expect::Status { command, key } => (*command, key.clone()),
337                Expect::Stats { .. } => ("stats", None),
338            };
339            let mut data = json!({
340                "kind": kind,
341                "message": line_field(&message),
342                "command": command,
343            });
344            if let Some(k) = key {
345                data["key"] = json!(k);
346            }
347            vec![Event::new(&MEMCACHED_ERROR_EVENT, data)]
348        }
349        (Expect::Status { command, key }, Reply::Status { line }) => {
350            let key = key.clone().unwrap_or_default();
351            let command = *command;
352            let event = match (command, line.as_str()) {
353                (_, "STORED") => Event::new(
354                    &MEMCACHED_STORED_EVENT,
355                    json!({"command": command, "key": key}),
356                ),
357                (_, "NOT_STORED") => Event::new(
358                    &MEMCACHED_NOT_STORED_EVENT,
359                    json!({"command": command, "key": key}),
360                ),
361                (_, "EXISTS") => Event::new(
362                    &MEMCACHED_EXISTS_EVENT,
363                    json!({"command": command, "key": key}),
364                ),
365                (_, "NOT_FOUND") => Event::new(
366                    &MEMCACHED_NOT_FOUND_EVENT,
367                    json!({"command": command, "key": key}),
368                ),
369                ("delete", "DELETED") => Event::new(&MEMCACHED_DELETED_EVENT, json!({"key": key})),
370                ("touch", "TOUCHED") => Event::new(&MEMCACHED_TOUCHED_EVENT, json!({"key": key})),
371                ("flush_all", "OK") => Event::new(&MEMCACHED_OK_EVENT, json!({"command": command})),
372                ("version", line) if line.starts_with("VERSION ") => Event::new(
373                    &MEMCACHED_VERSION_EVENT,
374                    json!({"version": line_field(&line["VERSION ".len()..])}),
375                ),
376                ("incr" | "decr", line) if line.parse::<u64>().is_ok() => Event::new(
377                    &MEMCACHED_COUNTER_EVENT,
378                    json!({
379                        "command": command,
380                        "key": key,
381                        "value": line.parse::<u64>().unwrap_or_default(),
382                    }),
383                ),
384                (_, line) => Event::new(
385                    &MEMCACHED_ERROR_EVENT,
386                    json!({
387                        "kind": "unexpected_reply",
388                        "message": format!(
389                            "the server answered {:?}, which is not a reply to {command}",
390                            line_field(line)
391                        ),
392                        "command": command,
393                        "key": key,
394                    }),
395                ),
396            };
397            vec![event]
398        }
399        // `parse_reply` only produces a reply of the shape its `Expect` asks for.
400        (_, other) => {
401            warn!("Memcached reply {other:?} does not match its request {expect:?}");
402            Vec::new()
403        }
404    }
405}
406
407/// Answer queued replies with the model, one at a time and in arrival order.
408#[allow(clippy::too_many_arguments)]
409async fn run_turns(
410    mut turn_rx: mpsc::Receiver<Turn>,
411    outbound_tx: mpsc::Sender<Outbound>,
412    protocol: Arc<MemcachedClientProtocol>,
413    llm_client: OllamaClient,
414    app_state: Arc<AppState>,
415    status_tx: mpsc::UnboundedSender<String>,
416    client_id: ClientId,
417    remote_addr: String,
418) {
419    while let Some(turn) = turn_rx.recv().await {
420        let events = match turn {
421            Turn::Connected => vec![Event::new(
422                &MEMCACHED_CONNECTED_EVENT,
423                json!({ "remote_addr": remote_addr }),
424            )],
425            Turn::Reply { expect, reply } => events_for(&expect, reply),
426        };
427        for event in events {
428            let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
429                continue;
430            };
431            let memory = app_state
432                .get_memory_for_client(client_id)
433                .await
434                .unwrap_or_default();
435            match call_llm_for_client(
436                &llm_client,
437                &app_state,
438                client_id.to_string(),
439                &instruction,
440                &memory,
441                Some(&event),
442                protocol.as_ref(),
443                &status_tx,
444            )
445            .await
446            {
447                Ok(result) => {
448                    if let Some(mem) = result.memory_updates {
449                        app_state.set_memory_for_client(client_id, mem).await;
450                    }
451                    info!(
452                        "Memcached client {client_id} {} decision={} actions={}",
453                        event.id(),
454                        if result.actions.is_empty() {
455                            "model_silent"
456                        } else {
457                            "model_actions"
458                        },
459                        result.actions.len()
460                    );
461                    for action in result.actions {
462                        match apply_action(protocol.as_ref(), &outbound_tx, action).await {
463                            Ok(Applied::Disconnect) => return,
464                            Ok(_) => {}
465                            Err(e) => {
466                                error!("Memcached client {client_id} action failed: {e}");
467                                let _ = status_tx.send(format!(
468                                    "[ERROR] Memcached client {client_id} action failed: {e}"
469                                ));
470                            }
471                        }
472                    }
473                }
474                Err(e) => {
475                    error!(
476                        "Memcached client {client_id} {} decision=llm_error: {e}",
477                        event.id()
478                    );
479                }
480            }
481        }
482    }
483}
484
485/// Execute one action and hand its request to the transport. Shared by the model's turns and
486/// injected commands, so both take exactly one path onto the wire.
487async fn apply_action(
488    protocol: &MemcachedClientProtocol,
489    outbound_tx: &mpsc::Sender<Outbound>,
490    action: serde_json::Value,
491) -> Result<Applied> {
492    match protocol.execute_action(action)? {
493        ClientActionResult::Custom { name, data } if name == REQUEST_RESULT => {
494            let request = request_from_action(&data)?
495                .ok_or_else(|| anyhow!("a request action produced no request"))?;
496            let (ack, done) = oneshot::channel();
497            outbound_tx
498                .send(Outbound::Request { request, ack })
499                .await
500                .map_err(|_| anyhow!("the connection is closed"))?;
501            let bytes = done
502                .await
503                .map_err(|_| anyhow!("the connection closed before the request was written"))?
504                .map_err(|e| anyhow!(e))?;
505            Ok(Applied::Sent(bytes))
506        }
507        ClientActionResult::Disconnect => {
508            let (ack, done) = oneshot::channel();
509            if outbound_tx.send(Outbound::Disconnect { ack }).await.is_ok() {
510                let _ = done.await;
511            }
512            Ok(Applied::Disconnect)
513        }
514        _ => Ok(Applied::Nothing),
515    }
516}
517
518/// Drain injected commands until the channel closes or one of them disconnects.
519async fn command_loop(
520    mut command_rx: mpsc::Receiver<ClientCommand>,
521    protocol: Arc<MemcachedClientProtocol>,
522    outbound_tx: mpsc::Sender<Outbound>,
523    client_id: ClientId,
524    app_state: Arc<AppState>,
525    status_tx: mpsc::UnboundedSender<String>,
526) {
527    while let Some(command) = command_rx.recv().await {
528        let action = command.action.clone();
529        let outcome = match apply_action(protocol.as_ref(), &outbound_tx, action.clone()).await {
530            Ok(Applied::Sent(bytes_sent)) => ClientSendOutcome::Sent { bytes_sent },
531            Ok(Applied::Nothing) => ClientSendOutcome::Executed {
532                detail: "executed (nothing to write)".to_string(),
533            },
534            Ok(Applied::Disconnect) => ClientSendOutcome::Disconnected,
535            Err(e) => ClientSendOutcome::Rejected {
536                error: e.to_string(),
537            },
538        };
539        app_state
540            .record_access_log(
541                AccessLogOwner::Client(client_id.as_u32()),
542                protocol.protocol_name(),
543                None,
544                "injected_action",
545                action,
546                vec![serde_json::to_value(&outcome).unwrap_or_default()],
547            )
548            .await;
549        let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
550        let _ = status_tx.send("__UPDATE_UI__".to_string());
551        crate::client::command_support::reply(command, Ok(outcome));
552        if disconnect {
553            break;
554        }
555    }
556}