Skip to main content

netget/client/zookeeper/
mod.rs

1//! ZooKeeper client implementation
2pub mod actions;
3
4pub use actions::ZookeeperClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::sync::mpsc;
11use tracing::{debug, error, info};
12use zookeeper_async::{Acl, CreateMode, WatchedEvent, ZooKeeper};
13
14use crate::client::llm_budget::call_llm_for_client;
15use crate::client::zookeeper::actions::{
16    ZOOKEEPER_CLIENT_CHILDREN_RECEIVED_EVENT, ZOOKEEPER_CLIENT_CONNECTED_EVENT,
17    ZOOKEEPER_CLIENT_DATA_RECEIVED_EVENT, ZOOKEEPER_CLIENT_OPERATION_COMPLETE_EVENT,
18};
19use crate::llm::actions::client_trait::{Client, ClientActionResult};
20use crate::llm::ollama_client::OllamaClient;
21use crate::protocol::{Event, EventType};
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26/// How long to wait for the initial session before refusing to start.
27///
28/// Distinct from [`SESSION_TIMEOUT`], which governs an *established* session. See the note at
29/// the call site for why an explicit bound is required here.
30const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
31
32/// Session timeout requested at handshake. ZooKeeper clamps it into `2 * tickTime ..=
33/// 20 * tickTime` and tells us what it settled on; NetGet's own server clamps to
34/// `4000..=40000` ms, so 10s is inside the negotiable range of both.
35const SESSION_TIMEOUT: Duration = Duration::from_secs(10);
36
37/// The live ZooKeeper session, shared between the connected-event handler and the
38/// injected-command loop.
39///
40/// `ZooKeeper`'s operations all take `&self` and it is `Send + Sync`, so this needs no
41/// `Mutex` - unlike the etcd client, whose `etcd_client::Client` wants `&mut self`.
42type SharedZk = Arc<ZooKeeper>;
43
44/// One completed ZooKeeper operation: what to tell the operator, and the event to raise.
45struct ZkOutcome {
46    detail: String,
47    event_type: &'static EventType,
48    event_data: serde_json::Value,
49}
50
51/// What one executed action did. Shared vocabulary between the connected-event handler
52/// and the injected-command loop.
53enum Applied {
54    /// The action ran; `detail` says what it did.
55    Ran(String),
56    /// The action asked to end the session.
57    Disconnect,
58}
59
60/// ZooKeeper client that connects to a ZooKeeper server
61pub struct ZookeeperClient;
62
63impl ZookeeperClient {
64    /// Connect to a ZooKeeper server with integrated LLM actions
65    pub async fn connect_with_llm_actions(
66        remote_addr: String,
67        llm_client: OllamaClient,
68        app_state: Arc<AppState>,
69        status_tx: mpsc::UnboundedSender<String>,
70        client_id: ClientId,
71    ) -> Result<SocketAddr> {
72        info!(
73            "ZooKeeper client {} connecting to {}",
74            client_id, remote_addr
75        );
76
77        // `remote_addr` is a ZooKeeper *connect string*, not a single address: comma-separated
78        // `host:port` pairs with an optional `/chroot` suffix. It is handed to the library
79        // as-is rather than parsed as one `SocketAddr` - the old code did that and rejected
80        // every legitimate ensemble or chrooted address before even trying to connect.
81        // Bounded, because `ZooKeeper::connect` is not.
82        //
83        // A ZooKeeper client is *designed* to retry its connect string forever — that is
84        // correct for a long-lived session against a real ensemble, and wrong for the one call
85        // that decides whether this client starts at all. Against a closed port it never
86        // returns, so `connect_with_llm_actions` never returns, and whatever asked for the
87        // client (the dashboard's create form, `open_client`, an MCP caller) waits with it.
88        // `SESSION_TIMEOUT` does not help: that is the *session* timeout negotiated after a
89        // connection exists.
90        //
91        // Caught by `every_registered_client_protocol_refuses_a_closed_port_promptly`, where
92        // ZooKeeper was the only protocol of 91 to hang rather than refuse.
93        let zk: SharedZk = Arc::new(
94            tokio::time::timeout(
95                CONNECT_TIMEOUT,
96                ZooKeeper::connect(&remote_addr, SESSION_TIMEOUT, |_ev: WatchedEvent| {}),
97            )
98            .await
99            .with_context(|| {
100                format!(
101                    "Timed out after {CONNECT_TIMEOUT:?} connecting to ZooKeeper at \
102                     {remote_addr}. The client library retries a connect string indefinitely, \
103                     so this bound is what turns an unreachable ensemble into a refusal."
104                )
105            })?
106            .with_context(|| format!("Failed to connect to ZooKeeper at {remote_addr}"))?,
107        );
108
109        info!(
110            "ZooKeeper client {} established a session with {}",
111            client_id, remote_addr
112        );
113
114        app_state
115            .with_client_mut(client_id, |client| {
116                client
117                    .set_protocol_field("zookeeper_connected".to_string(), serde_json::json!(true));
118                client.set_protocol_field(
119                    "connect_string".to_string(),
120                    serde_json::json!(remote_addr),
121                );
122            })
123            .await;
124
125        // Update client state
126        app_state
127            .update_client_status(client_id, ClientStatus::Connected)
128            .await;
129        let _ = status_tx.send(format!("[CLIENT] ZooKeeper client {} connected", client_id));
130        let _ = status_tx.send("__UPDATE_UI__".to_string());
131
132        // Command channel for injected actions (the dashboard's [ send ] / composer).
133        // Registered BEFORE the connected-event LLM call below, which a manual `*` routing
134        // rule can park for minutes - the operator must be able to reach ZooKeeper while it
135        // waits.
136        //
137        // This task is also what keeps the session alive: `remove_client` drops the command
138        // sender, so `recv()` returns `None` the moment the client goes away and the loop
139        // closes the session on its way out.
140        let command_rx =
141            crate::client::command_support::register_command_channel(&app_state, client_id).await;
142        let cmd_task = tokio::spawn(Self::command_loop(
143            command_rx,
144            client_id,
145            zk.clone(),
146            app_state.clone(),
147            llm_client.clone(),
148            status_tx.clone(),
149        ));
150        app_state.register_client_task(client_id, cmd_task).await;
151
152        // Raise the connected event. Nothing did this before: the client marked itself
153        // Connected and returned, so `zookeeper_connected` was declared and never emitted and
154        // the LLM was never consulted at all.
155        let protocol = Arc::new(ZookeeperClientProtocol::new());
156        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
157            let event = Event::new(
158                &ZOOKEEPER_CLIENT_CONNECTED_EVENT,
159                serde_json::json!({
160                    "remote_addr": remote_addr,
161                }),
162            );
163            let memory = app_state
164                .get_memory_for_client(client_id)
165                .await
166                .unwrap_or_default();
167
168            match call_llm_for_client(
169                &llm_client,
170                &app_state,
171                client_id.to_string(),
172                &instruction,
173                &memory,
174                Some(&event),
175                protocol.as_ref(),
176                &status_tx,
177            )
178            .await
179            {
180                Ok(result) => {
181                    if let Some(mem) = result.memory_updates {
182                        app_state.set_memory_for_client(client_id, mem).await;
183                    }
184
185                    // Execute them through the same path injected commands use, so the
186                    // ZooKeeper call for each verb exists exactly once.
187                    for action in result.actions {
188                        let executed = match protocol.execute_action(action) {
189                            Ok(executed) => executed,
190                            Err(e) => {
191                                error!("ZooKeeper client {} rejected action: {}", client_id, e);
192                                continue;
193                            }
194                        };
195                        match Self::apply_action(
196                            executed,
197                            client_id,
198                            &zk,
199                            &app_state,
200                            &llm_client,
201                            &status_tx,
202                        )
203                        .await
204                        {
205                            Ok(Applied::Ran(detail)) => {
206                                info!("ZooKeeper client {}: {}", client_id, detail)
207                            }
208                            Ok(Applied::Disconnect) => {
209                                info!("ZooKeeper client {} disconnecting after connect", client_id);
210                                let _ = zk.close().await;
211                                app_state.remove_client_handle(client_id).await;
212                                app_state
213                                    .update_client_status(client_id, ClientStatus::Disconnected)
214                                    .await;
215                                let _ = status_tx.send("__UPDATE_UI__".to_string());
216                                break;
217                            }
218                            Err(e) => {
219                                error!("ZooKeeper client {} action failed: {}", client_id, e)
220                            }
221                        }
222                    }
223                }
224                Err(e) => {
225                    error!(
226                        "ZooKeeper client {} LLM call failed on connect: {}",
227                        client_id, e
228                    );
229                }
230            }
231        }
232
233        // ZooKeeper does not expose the session's local socket, so report the conventional
234        // placeholder the rest of the client layer uses for connectionless/library clients.
235        Ok("127.0.0.1:0".parse().unwrap())
236    }
237
238    /// Drain injected commands until the channel closes (the client was removed) or an
239    /// injected `disconnect` ends the session.
240    ///
241    /// `command_support::handle_stream_client_command` cannot serve this client: there is no
242    /// write half NetGet owns - `zookeeper-async` holds the socket and its own I/O task - and
243    /// every ZooKeeper verb yields `ClientActionResult::Custom`. So the action goes through
244    /// [`Self::apply_action`], the same function the connected-event path uses.
245    async fn command_loop(
246        mut command_rx: mpsc::Receiver<ClientCommand>,
247        client_id: ClientId,
248        zk: SharedZk,
249        app_state: Arc<AppState>,
250        llm_client: OllamaClient,
251        status_tx: mpsc::UnboundedSender<String>,
252    ) {
253        use crate::llm::actions::protocol_trait::Protocol;
254
255        let protocol = ZookeeperClientProtocol::new();
256
257        while let Some(command) = command_rx.recv().await {
258            let action = command.action.clone();
259            let outcome = match protocol.execute_action(action.clone()) {
260                Err(e) => Ok(ClientSendOutcome::Rejected {
261                    error: e.to_string(),
262                }),
263                Ok(executed) => match Self::apply_action(
264                    executed,
265                    client_id,
266                    &zk,
267                    &app_state,
268                    &llm_client,
269                    &status_tx,
270                )
271                .await
272                {
273                    // Never `Sent`: `zookeeper-async` owns the socket and never reports how
274                    // many bytes a request serialised to, so a byte count here would be
275                    // invented. `Executed` carries what ZooKeeper actually answered instead.
276                    Ok(Applied::Ran(detail)) => Ok(ClientSendOutcome::Executed { detail }),
277                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
278                    Err(e) => Err(e),
279                },
280            };
281
282            let outcome_json = match &outcome {
283                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
284                Err(e) => serde_json::json!({"error": e.to_string()}),
285            };
286            app_state
287                .record_access_log(
288                    AccessLogOwner::Client(client_id.as_u32()),
289                    protocol.protocol_name(),
290                    None,
291                    "injected_action",
292                    action,
293                    vec![outcome_json],
294                )
295                .await;
296
297            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
298            if let Err(e) = &outcome {
299                error!(
300                    "ZooKeeper client {} injected action failed: {}",
301                    client_id, e
302                );
303                let _ = status_tx.send(format!(
304                    "[WARN] Client {} injected action failed: {}",
305                    client_id, e
306                ));
307            }
308            let _ = status_tx.send("__UPDATE_UI__".to_string());
309            crate::client::command_support::reply(command, outcome);
310
311            if disconnect {
312                app_state
313                    .update_client_status(client_id, ClientStatus::Disconnected)
314                    .await;
315                break;
316            }
317        }
318
319        // Every exit path lands here: end the ZooKeeper session (otherwise the server holds
320        // it until the negotiated timeout expires) and stop the dashboard offering [ send ].
321        if let Err(e) = zk.close().await {
322            debug!(
323                "ZooKeeper client {} session close returned: {}",
324                client_id, e
325            );
326        }
327        app_state.remove_client_handle(client_id).await;
328        let _ = status_tx.send("__UPDATE_UI__".to_string());
329        info!("ZooKeeper client {} command loop ended", client_id);
330    }
331
332    /// Run one executed action against the live ZooKeeper session.
333    ///
334    /// The ZooKeeper round-trip is awaited - so the reported detail describes an operation
335    /// that really happened - while the event it raises goes to the LLM from its own
336    /// registered task. That split matters: a client whose events are routed to a manual
337    /// handler would otherwise park the command loop for the length of a human's think time,
338    /// and `[ send ]` would report a timeout for an operation that in fact succeeded.
339    async fn apply_action(
340        executed: ClientActionResult,
341        client_id: ClientId,
342        zk: &SharedZk,
343        app_state: &Arc<AppState>,
344        llm_client: &OllamaClient,
345        status_tx: &mpsc::UnboundedSender<String>,
346    ) -> Result<Applied> {
347        let outcome = match executed {
348            ClientActionResult::Custom { name, data } if name == "create_znode" => {
349                let path = required_str(&data, "path", "create_znode")?;
350                let value = required_str(&data, "data", "create_znode")?;
351                Self::perform_create(client_id, zk, path, value).await?
352            }
353            ClientActionResult::Custom { name, data } if name == "get_data" => {
354                let path = required_str(&data, "path", "get_data")?;
355                Self::perform_get_data(client_id, zk, path).await?
356            }
357            ClientActionResult::Custom { name, data } if name == "set_data" => {
358                let path = required_str(&data, "path", "set_data")?;
359                let value = required_str(&data, "data", "set_data")?;
360                Self::perform_set_data(client_id, zk, path, value).await?
361            }
362            ClientActionResult::Custom { name, data } if name == "delete_znode" => {
363                let path = required_str(&data, "path", "delete_znode")?;
364                Self::perform_delete(client_id, zk, path).await?
365            }
366            ClientActionResult::Custom { name, data } if name == "get_children" => {
367                let path = required_str(&data, "path", "get_children")?;
368                Self::perform_get_children(client_id, zk, path).await?
369            }
370            ClientActionResult::Disconnect => {
371                info!("ZooKeeper client {} disconnecting", client_id);
372                return Ok(Applied::Disconnect);
373            }
374            ClientActionResult::WaitForMore => {
375                return Ok(Applied::Ran("wait_for_more".to_string()))
376            }
377            ClientActionResult::NoAction => return Ok(Applied::Ran("no_action".to_string())),
378            // Not swallowed: an action this client cannot carry out says so, rather than
379            // looking identical to success.
380            ClientActionResult::Custom { name, .. } => {
381                return Ok(Applied::Ran(format!(
382                    "custom result '{name}' is not handled by the ZooKeeper client"
383                )))
384            }
385            ClientActionResult::SendData(_) => {
386                return Ok(Applied::Ran(
387                    "send_data has no meaning for a ZooKeeper client (zookeeper-async owns \
388                     the socket)"
389                        .to_string(),
390                ))
391            }
392            ClientActionResult::Multiple(_) => {
393                return Ok(Applied::Ran(
394                    "multiple results are not produced by the ZooKeeper client".to_string(),
395                ))
396            }
397        };
398
399        let detail = outcome.detail.clone();
400        let state_clone = app_state.clone();
401        let llm_clone = llm_client.clone();
402        let status_clone = status_tx.clone();
403        let notify_handle = tokio::spawn(async move {
404            Self::notify_event(
405                client_id,
406                outcome.event_type,
407                outcome.event_data,
408                state_clone,
409                llm_clone,
410                status_clone,
411            )
412            .await;
413        });
414        app_state
415            .register_client_task(client_id, notify_handle)
416            .await;
417
418        Ok(Applied::Ran(detail))
419    }
420
421    async fn perform_create(
422        client_id: ClientId,
423        zk: &SharedZk,
424        path: String,
425        data: String,
426    ) -> Result<ZkOutcome> {
427        info!("ZooKeeper client {} creating znode {}", client_id, path);
428        let created = zk
429            .create(
430                &path,
431                data.clone().into_bytes(),
432                Acl::open_unsafe().clone(),
433                CreateMode::Persistent,
434            )
435            .await
436            .with_context(|| format!("create of {path} failed"))?;
437
438        Ok(ZkOutcome {
439            detail: format!("create_znode '{path}' -> created '{created}'"),
440            event_type: &ZOOKEEPER_CLIENT_OPERATION_COMPLETE_EVENT,
441            event_data: serde_json::json!({
442                "operation": "create",
443                "path": path,
444                "created_path": created,
445            }),
446        })
447    }
448
449    async fn perform_get_data(
450        client_id: ClientId,
451        zk: &SharedZk,
452        path: String,
453    ) -> Result<ZkOutcome> {
454        info!("ZooKeeper client {} reading znode {}", client_id, path);
455        // `watch: false` throughout: this client has no watch mechanism, and asking the
456        // server to set one it will never read would leak a watch per call.
457        let (bytes, stat) = zk
458            .get_data(&path, false)
459            .await
460            .with_context(|| format!("get_data of {path} failed"))?;
461        let text = String::from_utf8_lossy(&bytes).to_string();
462
463        Ok(ZkOutcome {
464            detail: format!(
465                "get_data '{}' -> {} byte(s), version {}",
466                path,
467                bytes.len(),
468                stat.version
469            ),
470            event_type: &ZOOKEEPER_CLIENT_DATA_RECEIVED_EVENT,
471            event_data: serde_json::json!({
472                "path": path,
473                "data": text,
474                "version": stat.version,
475            }),
476        })
477    }
478
479    async fn perform_set_data(
480        client_id: ClientId,
481        zk: &SharedZk,
482        path: String,
483        data: String,
484    ) -> Result<ZkOutcome> {
485        info!("ZooKeeper client {} writing znode {}", client_id, path);
486        // `version: None` means -1, "any version" - this client keeps no version state to
487        // do a compare-and-swap with, and inventing one would fail every write.
488        let stat = zk
489            .set_data(&path, data.clone().into_bytes(), None)
490            .await
491            .with_context(|| format!("set_data of {path} failed"))?;
492
493        Ok(ZkOutcome {
494            detail: format!("set_data '{}' -> version {}", path, stat.version),
495            event_type: &ZOOKEEPER_CLIENT_OPERATION_COMPLETE_EVENT,
496            event_data: serde_json::json!({
497                "operation": "set_data",
498                "path": path,
499                "version": stat.version,
500            }),
501        })
502    }
503
504    async fn perform_delete(client_id: ClientId, zk: &SharedZk, path: String) -> Result<ZkOutcome> {
505        info!("ZooKeeper client {} deleting znode {}", client_id, path);
506        zk.delete(&path, None)
507            .await
508            .with_context(|| format!("delete of {path} failed"))?;
509
510        Ok(ZkOutcome {
511            detail: format!("delete_znode '{path}' -> deleted"),
512            event_type: &ZOOKEEPER_CLIENT_OPERATION_COMPLETE_EVENT,
513            event_data: serde_json::json!({
514                "operation": "delete",
515                "path": path,
516            }),
517        })
518    }
519
520    async fn perform_get_children(
521        client_id: ClientId,
522        zk: &SharedZk,
523        path: String,
524    ) -> Result<ZkOutcome> {
525        info!(
526            "ZooKeeper client {} listing children of {}",
527            client_id, path
528        );
529        let children = zk
530            .get_children(&path, false)
531            .await
532            .with_context(|| format!("get_children of {path} failed"))?;
533
534        Ok(ZkOutcome {
535            detail: format!("get_children '{}' -> {}", path, children.join(", ")),
536            event_type: &ZOOKEEPER_CLIENT_CHILDREN_RECEIVED_EVENT,
537            event_data: serde_json::json!({
538                "path": path,
539                "children": children,
540            }),
541        })
542    }
543
544    /// Hand one completed operation to the LLM as its protocol event.
545    async fn notify_event(
546        client_id: ClientId,
547        event_type: &'static EventType,
548        event_data: serde_json::Value,
549        app_state: Arc<AppState>,
550        llm_client: OllamaClient,
551        status_tx: mpsc::UnboundedSender<String>,
552    ) {
553        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
554            return;
555        };
556
557        let event = Event::new(event_type, event_data);
558        let protocol = Arc::new(ZookeeperClientProtocol::new());
559        let memory = app_state
560            .get_memory_for_client(client_id)
561            .await
562            .unwrap_or_default();
563
564        match call_llm_for_client(
565            &llm_client,
566            &app_state,
567            client_id.to_string(),
568            &instruction,
569            &memory,
570            Some(&event),
571            protocol.as_ref(),
572            &status_tx,
573        )
574        .await
575        {
576            Ok(result) => {
577                if let Some(mem) = result.memory_updates {
578                    app_state.set_memory_for_client(client_id, mem).await;
579                }
580                // Follow-up actions are routed back through the client's own command
581                // channel rather than executed here, so every action - LLM-produced or
582                // injected - is applied by the one loop that owns the session, and this
583                // task stays a leaf.
584                for action in result.actions {
585                    if let Err(e) = app_state
586                        .send_to_client(client_id, action, Duration::from_secs(60))
587                        .await
588                    {
589                        debug!(
590                            "ZooKeeper client {} could not apply a follow-up action: {}",
591                            client_id, e
592                        );
593                    }
594                }
595            }
596            Err(e) => {
597                error!("LLM error for ZooKeeper client {}: {}", client_id, e);
598            }
599        }
600    }
601}
602
603/// Pull a required string out of an executed action's data, naming the verb when it is
604/// missing. `execute_action` already validates these, so a failure here means the protocol
605/// and this module disagree - which is worth an explicit error rather than a silent default.
606fn required_str(data: &serde_json::Value, field: &str, verb: &str) -> Result<String> {
607    data.get(field)
608        .and_then(|v| v.as_str())
609        .map(|s| s.to_string())
610        .with_context(|| format!("Missing '{field}' in {verb}"))
611}