Skip to main content

netget/client/cassandra/
mod.rs

1//! Cassandra client implementation using ScyllaDB Rust driver
2pub mod actions;
3
4pub use actions::CassandraClientProtocol;
5
6use crate::llm::actions::client_trait::{Client, ClientActionResult};
7use anyhow::{Context, Result};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use tokio::sync::mpsc;
11use tracing::{debug, error, info, trace, warn};
12
13use crate::client::cassandra::actions::{
14    CASSANDRA_CLIENT_CONNECTED_EVENT, CASSANDRA_CLIENT_RESULT_RECEIVED_EVENT,
15};
16use crate::client::llm_budget::call_llm_for_client;
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{ClientId, ClientStatus};
23use serde_json::json;
24
25use scylla::client::session::Session;
26use scylla::client::session_builder::SessionBuilder;
27use scylla::frame::Compression;
28
29/// What one executed action did to the Cassandra session.
30enum Applied {
31    /// A CQL statement ran; the rows are what the model is told about.
32    Query {
33        query: String,
34        rows: Vec<serde_json::Value>,
35        row_count: usize,
36    },
37    /// The session should end.
38    Disconnect,
39    /// The action executed but touched the session in no way.
40    Nothing(&'static str),
41}
42
43/// How many query → result → query hops one action may set off.
44///
45/// `report_result` asks the model what to do with a result set, and the answer may be another
46/// query, whose result is reported in turn. The recursion was boxed but **not bounded**: a
47/// model that answers every result with another query looped for as long as the LLM budget
48/// lasted, hammering the server with no way to stop short of the client being removed. Boxing
49/// only makes the type finite; the cap is what makes the chain finite.
50const MAX_FOLLOWUP_DEPTH: u8 = 6;
51
52/// Map a CQL consistency-level name onto the driver's enum.
53///
54/// Names are the ones the native protocol and every driver use. Anything else returns `None`
55/// so the caller can refuse: silently running at the session default would serve a weaker
56/// guarantee than the model asked for.
57fn parse_consistency(name: &str) -> Option<scylla::frame::types::Consistency> {
58    use scylla::frame::types::Consistency;
59    match name.trim().to_ascii_uppercase().as_str() {
60        "ANY" => Some(Consistency::Any),
61        "ONE" => Some(Consistency::One),
62        "TWO" => Some(Consistency::Two),
63        "THREE" => Some(Consistency::Three),
64        "QUORUM" => Some(Consistency::Quorum),
65        "ALL" => Some(Consistency::All),
66        "LOCAL_QUORUM" => Some(Consistency::LocalQuorum),
67        "EACH_QUORUM" => Some(Consistency::EachQuorum),
68        "SERIAL" => Some(Consistency::Serial),
69        "LOCAL_SERIAL" => Some(Consistency::LocalSerial),
70        "LOCAL_ONE" => Some(Consistency::LocalOne),
71        _ => None,
72    }
73}
74
75/// Cassandra client that connects to a Cassandra/ScyllaDB server
76pub struct CassandraClient;
77
78impl CassandraClient {
79    /// Connect to a Cassandra server with integrated LLM actions
80    pub async fn connect_with_llm_actions(
81        remote_addr: String,
82        llm_client: OllamaClient,
83        app_state: Arc<AppState>,
84        status_tx: mpsc::UnboundedSender<String>,
85        client_id: ClientId,
86        startup_params: Option<crate::protocol::StartupParams>,
87    ) -> Result<SocketAddr> {
88        // Parse startup parameters
89        let keyspace = startup_params
90            .as_ref()
91            .map(|p| p.get_optional_string("keyspace"))
92            .transpose()?
93            .flatten();
94
95        let username = startup_params
96            .as_ref()
97            .map(|p| p.get_optional_string("username"))
98            .transpose()?
99            .flatten();
100
101        let password = startup_params
102            .as_ref()
103            .map(|p| p.get_optional_string("password"))
104            .transpose()?
105            .flatten();
106
107        info!(
108            "Cassandra client {} connecting to {}",
109            client_id, remote_addr
110        );
111
112        // Build session
113        let mut builder = SessionBuilder::new()
114            .known_node(&remote_addr)
115            .compression(Some(Compression::Lz4));
116
117        // Add authentication if provided
118        if let (Some(user), Some(pass)) = (username, password) {
119            builder = builder.user(&user, &pass);
120        }
121
122        // Set keyspace if provided
123        if let Some(ks) = &keyspace {
124            builder = builder.use_keyspace(ks, false);
125        }
126
127        // Connect to Cassandra
128        let session = builder
129            .build()
130            .await
131            .context(format!("Failed to connect to Cassandra at {}", remote_addr))?;
132
133        // `Session` is `Sync` and every query method takes `&self`, so a bare `Arc` is enough
134        // to share it between the LLM path and the injected-command loop — no mutex needed.
135        let session_arc = Arc::new(session);
136        let protocol = Arc::new(CassandraClientProtocol::new());
137
138        // Parse address to get SocketAddr
139        let socket_addr: SocketAddr = remote_addr
140            .parse()
141            .context(format!("Invalid address format: {}", remote_addr))?;
142
143        info!(
144            "Cassandra client {} connected to {}",
145            client_id, socket_addr
146        );
147
148        // Update client state
149        app_state
150            .update_client_status(client_id, ClientStatus::Connected)
151            .await;
152        let _ = status_tx.send(format!("[CLIENT] Cassandra client {} connected", client_id));
153        let _ = status_tx.send("__UPDATE_UI__".to_string());
154
155        // Command channel for injected actions (the dashboard's [ send ]).
156        // Registered BEFORE the connected-event LLM call, which this protocol *awaits inline*
157        // — so under a manual `*` rule the call below parks and client creation parks with
158        // it. The command loop is a separate task precisely so [ send ] still works then.
159        let command_rx =
160            crate::client::command_support::register_command_channel(&app_state, client_id).await;
161        let cmd_task = tokio::spawn(Self::command_loop(
162            command_rx,
163            protocol.clone(),
164            session_arc.clone(),
165            client_id,
166            llm_client.clone(),
167            app_state.clone(),
168            status_tx.clone(),
169        ));
170        app_state.register_client_task(client_id, cmd_task).await;
171
172        // Call LLM with connected event
173        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
174            let event = Event::new(
175                &CASSANDRA_CLIENT_CONNECTED_EVENT,
176                json!({
177                    "remote_addr": remote_addr,
178                }),
179            );
180
181            let memory = app_state
182                .get_memory_for_client(client_id)
183                .await
184                .unwrap_or_default();
185
186            // Initial LLM call after connection
187            match call_llm_for_client(
188                &llm_client,
189                &app_state,
190                client_id.to_string(),
191                &instruction,
192                &memory,
193                Some(&event),
194                protocol.as_ref(),
195                &status_tx,
196            )
197            .await
198            {
199                Ok(ClientLlmResult {
200                    actions,
201                    memory_updates,
202                }) => {
203                    // Update memory
204                    if let Some(mem) = memory_updates {
205                        app_state.set_memory_for_client(client_id, mem).await;
206                    }
207
208                    // Execute initial actions
209                    Self::execute_actions(
210                        actions,
211                        protocol.clone(),
212                        session_arc.clone(),
213                        client_id,
214                        llm_client.clone(),
215                        app_state.clone(),
216                        status_tx.clone(),
217                        0,
218                    )
219                    .await;
220                }
221                Err(e) => {
222                    error!(
223                        "Initial LLM call error for Cassandra client {}: {}",
224                        client_id, e
225                    );
226                }
227            }
228        }
229
230        Ok(socket_addr)
231    }
232
233    /// Drain injected commands until the channel closes (the client was removed or stopped)
234    /// or an injected `disconnect` ends the session.
235    ///
236    /// This task is also what keeps the session alive: before it existed the only `Arc<Session>`
237    /// belonged to the connect-time LLM path and was dropped when `connect_with_llm_actions`
238    /// returned.
239    #[allow(clippy::too_many_arguments)]
240    async fn command_loop(
241        mut command_rx: mpsc::Receiver<ClientCommand>,
242        protocol: Arc<CassandraClientProtocol>,
243        session: Arc<Session>,
244        client_id: ClientId,
245        llm_client: OllamaClient,
246        app_state: Arc<AppState>,
247        status_tx: mpsc::UnboundedSender<String>,
248    ) {
249        use crate::llm::actions::protocol_trait::Protocol;
250        use crate::state::AccessLogOwner;
251
252        while let Some(command) = command_rx.recv().await {
253            let action = command.action.clone();
254
255            let mut follow_up: Option<(Vec<serde_json::Value>, usize)> = None;
256            let outcome = match protocol.execute_action(action.clone()) {
257                Err(e) => Ok(ClientSendOutcome::Rejected {
258                    error: e.to_string(),
259                }),
260                Ok(result) => match Self::apply_action(result, &session, client_id).await {
261                    Err(e) => Err(e),
262                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
263                    Ok(Applied::Nothing(what)) => Ok(ClientSendOutcome::Executed {
264                        detail: what.to_string(),
265                    }),
266                    Ok(Applied::Query {
267                        query,
268                        rows,
269                        row_count,
270                    }) => {
271                        let detail = format!(
272                            "CQL executed by the scylla driver ({} rows); \
273                             no byte count — the driver owns the socket: {}",
274                            row_count,
275                            crate::utils::truncate::truncate_for_log(&query, 80)
276                        );
277                        follow_up = Some((rows, row_count));
278                        Ok(ClientSendOutcome::Executed { detail })
279                    }
280                },
281            };
282
283            let outcome_json = match &outcome {
284                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
285                Err(e) => serde_json::json!({"error": e.to_string()}),
286            };
287            app_state
288                .record_access_log(
289                    AccessLogOwner::Client(client_id.as_u32()),
290                    protocol.protocol_name(),
291                    None,
292                    "injected_action",
293                    action,
294                    vec![outcome_json],
295                )
296                .await;
297
298            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
299            if let Err(e) = &outcome {
300                error!(
301                    "Cassandra client {} injected action failed: {}",
302                    client_id, e
303                );
304                let _ = status_tx.send(format!(
305                    "[WARN] Client {} injected action failed: {}",
306                    client_id, e
307                ));
308            }
309            let _ = status_tx.send("__UPDATE_UI__".to_string());
310            crate::client::command_support::reply(command, outcome);
311
312            if disconnect {
313                Self::mark_disconnected(client_id, &app_state, &status_tx).await;
314                break;
315            }
316
317            // The model sees an injected query's result exactly as it sees one it asked for —
318            // after the reply, so the dashboard is not held for an LLM round-trip.
319            if let Some((rows, row_count)) = follow_up {
320                Self::report_result(
321                    rows,
322                    row_count,
323                    &protocol,
324                    &session,
325                    client_id,
326                    &llm_client,
327                    &app_state,
328                    &status_tx,
329                    0,
330                )
331                .await;
332            }
333        }
334
335        app_state.remove_client_handle(client_id).await;
336        let _ = status_tx.send("__UPDATE_UI__".to_string());
337    }
338
339    /// Execute a list of actions returned by the LLM.
340    ///
341    /// `depth` is how many follow-up hops led here; 0 for the connected event and for an
342    /// injected command. See [`MAX_FOLLOWUP_DEPTH`].
343    #[allow(clippy::too_many_arguments)]
344    async fn execute_actions(
345        actions: Vec<serde_json::Value>,
346        protocol: Arc<CassandraClientProtocol>,
347        session: Arc<Session>,
348        client_id: ClientId,
349        llm_client: OllamaClient,
350        app_state: Arc<AppState>,
351        status_tx: mpsc::UnboundedSender<String>,
352        depth: u8,
353    ) {
354        for action in actions {
355            let result = match protocol.execute_action(action) {
356                Ok(result) => result,
357                Err(e) => {
358                    error!("Cassandra client {} action error: {}", client_id, e);
359                    continue;
360                }
361            };
362
363            match Self::apply_action(result, &session, client_id).await {
364                Ok(Applied::Query {
365                    rows,
366                    row_count,
367                    query,
368                }) => {
369                    if depth >= MAX_FOLLOWUP_DEPTH {
370                        warn!(
371                            "Cassandra client {} stopped a follow-up chain at depth {}: '{}' \
372                             returned {} row(s) but the result is not being reported, so the \
373                             model will not answer it",
374                            client_id, depth, query, row_count
375                        );
376                        let _ = status_tx.send(format!(
377                            "[WARN] Cassandra client {} hit the follow-up depth limit ({}); the \
378                             last query ran but its rows were not reported",
379                            client_id, MAX_FOLLOWUP_DEPTH
380                        ));
381                    } else {
382                        Self::report_result(
383                            rows,
384                            row_count,
385                            &protocol,
386                            &session,
387                            client_id,
388                            &llm_client,
389                            &app_state,
390                            &status_tx,
391                            depth,
392                        )
393                        .await;
394                    }
395                }
396                Ok(Applied::Disconnect) => {
397                    info!("Cassandra client {} disconnecting", client_id);
398                    Self::mark_disconnected(client_id, &app_state, &status_tx).await;
399                    break;
400                }
401                Ok(Applied::Nothing(what)) => {
402                    debug!(
403                        "Cassandra client {} action had no effect: {}",
404                        client_id, what
405                    );
406                }
407                Err(e) => {
408                    error!("Cassandra client {} query error: {}", client_id, e);
409                    let _ = status_tx.send(format!("[CLIENT] Cassandra query error: {}", e));
410                }
411            }
412        }
413    }
414
415    /// Run one executed action against the session. Shared by the LLM path and injected
416    /// commands so the CQL execution path exists exactly once.
417    async fn apply_action(
418        result: ClientActionResult,
419        session: &Arc<Session>,
420        client_id: ClientId,
421    ) -> Result<Applied> {
422        match result {
423            ClientActionResult::Custom { name, data } if name == "cql_query" => {
424                let query_str = data
425                    .get("query")
426                    .and_then(|v| v.as_str())
427                    .context("Missing 'query' in cql_query action data")?
428                    .to_string();
429
430                // `consistency` is advertised to the model as a parameter of
431                // `execute_cql_query`, so it has to reach the wire. It used to be read, logged
432                // and dropped under a comment claiming scylla exposes no per-statement
433                // override; `scylla::statement::unprepared::Statement::set_consistency` is
434                // exactly that, and `query_unpaged` takes `impl Into<Statement>`. A model
435                // asking for QUORUM was silently served at the session default, which for
436                // scylla is LOCAL_QUORUM — a different durability guarantee than the one
437                // requested, reported in the log as though it had been honoured.
438                let mut statement =
439                    scylla::statement::unprepared::Statement::new(query_str.clone());
440                let requested = data.get("consistency").and_then(|v| v.as_str());
441                if let Some(name) = requested {
442                    // An unrecognised level is refused rather than quietly downgraded: running
443                    // at a weaker consistency than asked for is the failure this fixes.
444                    let level = parse_consistency(name).ok_or_else(|| {
445                        anyhow::anyhow!(
446                            "unknown consistency level '{name}'; use one of ANY, ONE, TWO, \
447                             THREE, QUORUM, ALL, LOCAL_QUORUM, EACH_QUORUM, SERIAL, \
448                             LOCAL_SERIAL, LOCAL_ONE"
449                        )
450                    })?;
451                    statement.set_consistency(level);
452                }
453                debug!(
454                    "Cassandra client {} executing query (consistency {}): {}",
455                    client_id,
456                    requested.unwrap_or("session default"),
457                    query_str
458                );
459
460                let query_result = session
461                    .query_unpaged(statement, &[])
462                    .await
463                    .context("CQL query failed")?;
464
465                // Converted inline rather than in a helper so the driver's `QueryResult`
466                // type never has to be named (its module path moves between scylla releases).
467                let (rows, row_count) = {
468                    use scylla::value::Row;
469                    match query_result.into_rows_result() {
470                        Ok(rows_result) => match rows_result.rows::<Row>() {
471                            Ok(rows_iter) => {
472                                let collected: Vec<_> =
473                                    rows_iter.collect::<Result<Vec<_>, _>>().unwrap_or_default();
474                                let count = collected.len();
475                                let data: Vec<serde_json::Value> = collected
476                                    .into_iter()
477                                    .map(|row| {
478                                        let columns: Vec<String> = (0..row.columns.len())
479                                            .map(|i| format!("{:?}", row.columns[i]))
480                                            .collect();
481                                        json!({ "columns": columns })
482                                    })
483                                    .collect();
484                                (data, count)
485                            }
486                            Err(e) => {
487                                debug!(
488                                    "Cassandra client {} result deserialization error: {}",
489                                    client_id, e
490                                );
491                                (
492                                    vec![json!({
493                                        "message": "Query succeeded but result parsing not supported for this schema",
494                                        "error": format!("{}", e),
495                                    })],
496                                    0,
497                                )
498                            }
499                        },
500                        Err(e) => {
501                            // Not a rows result (e.g. INSERT / UPDATE / DELETE succeeded)
502                            debug!(
503                                "Cassandra client {} query succeeded (non-SELECT): {}",
504                                client_id, e
505                            );
506                            (Vec::new(), 0)
507                        }
508                    }
509                };
510                trace!("Cassandra client {} received {} rows", client_id, row_count);
511
512                Ok(Applied::Query {
513                    query: query_str,
514                    rows,
515                    row_count,
516                })
517            }
518            ClientActionResult::Custom { name, .. } => {
519                debug!(
520                    "Cassandra client {} ignoring custom result '{}'",
521                    client_id, name
522                );
523                Ok(Applied::Nothing("custom result not handled by this client"))
524            }
525            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
526            ClientActionResult::WaitForMore => Ok(Applied::Nothing("wait_for_more")),
527            ClientActionResult::NoAction => Ok(Applied::Nothing("no_action")),
528            ClientActionResult::SendData(_) => Ok(Applied::Nothing(
529                "send_data is not meaningful for a scylla session",
530            )),
531            ClientActionResult::Multiple(_) => Ok(Applied::Nothing(
532                "multiple results not handled by this client",
533            )),
534        }
535    }
536
537    /// Raise `cassandra_result_received` and run whatever the model answers.
538    #[allow(clippy::too_many_arguments)]
539    #[allow(clippy::too_many_arguments)]
540    async fn report_result(
541        rows_data: Vec<serde_json::Value>,
542        row_count: usize,
543        protocol: &Arc<CassandraClientProtocol>,
544        session: &Arc<Session>,
545        client_id: ClientId,
546        llm_client: &OllamaClient,
547        app_state: &Arc<AppState>,
548        status_tx: &mpsc::UnboundedSender<String>,
549        depth: u8,
550    ) {
551        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
552            return;
553        };
554
555        let event = Event::new(
556            &CASSANDRA_CLIENT_RESULT_RECEIVED_EVENT,
557            json!({
558                "rows": rows_data,
559                "row_count": row_count,
560            }),
561        );
562
563        let memory = app_state
564            .get_memory_for_client(client_id)
565            .await
566            .unwrap_or_default();
567
568        match call_llm_for_client(
569            llm_client,
570            app_state,
571            client_id.to_string(),
572            &instruction,
573            &memory,
574            Some(&event),
575            protocol.as_ref(),
576            status_tx,
577        )
578        .await
579        {
580            Ok(ClientLlmResult {
581                actions: next_actions,
582                memory_updates,
583            }) => {
584                // Update memory
585                if let Some(mem) = memory_updates {
586                    app_state.set_memory_for_client(client_id, mem).await;
587                }
588
589                // Execute next actions. Boxed because an `async fn` that awaits itself has
590                // an infinitely-sized future; bounded by `MAX_FOLLOWUP_DEPTH` because boxing
591                // alone leaves the *chain* unbounded, which is the part that mattered.
592                Box::pin(Self::execute_actions(
593                    next_actions,
594                    protocol.clone(),
595                    session.clone(),
596                    client_id,
597                    llm_client.clone(),
598                    app_state.clone(),
599                    status_tx.clone(),
600                    depth + 1,
601                ))
602                .await;
603            }
604            Err(e) => {
605                error!("LLM error for Cassandra client {}: {}", client_id, e);
606            }
607        }
608    }
609
610    async fn mark_disconnected(
611        client_id: ClientId,
612        app_state: &Arc<AppState>,
613        status_tx: &mpsc::UnboundedSender<String>,
614    ) {
615        app_state
616            .update_client_status(client_id, ClientStatus::Disconnected)
617            .await;
618        let _ = status_tx.send(format!(
619            "[CLIENT] Cassandra client {} disconnected",
620            client_id
621        ));
622        let _ = status_tx.send("__UPDATE_UI__".to_string());
623    }
624}