Skip to main content

netget/server/etcd/
mod.rs

1//! etcd v3 server implementation with gRPC KV service
2//!
3//! Implements etcd v3 KV service where the LLM controls all key-value operations
4//! through actions. Uses pre-compiled protobuf definitions from build.rs.
5
6pub mod actions;
7
8// Re-export protocol for external use
9pub use actions::EtcdProtocol;
10
11use anyhow::Result;
12use std::net::SocketAddr;
13use std::sync::Arc;
14use tokio::sync::{mpsc, Mutex};
15use tracing::{debug, error, trace};
16
17#[cfg(not(target_arch = "wasm32"))]
18use crate::llm::action_helper::call_llm;
19#[cfg(not(target_arch = "wasm32"))]
20use crate::llm::ollama_client::OllamaClient;
21#[cfg(not(target_arch = "wasm32"))]
22use crate::protocol::Event;
23#[cfg(not(target_arch = "wasm32"))]
24use crate::server::etcd::actions::ETCD_RANGE_REQUEST_EVENT;
25#[cfg(not(target_arch = "wasm32"))]
26use crate::state::app_state::AppState;
27#[cfg(not(target_arch = "wasm32"))]
28use bytes::Bytes;
29#[cfg(not(target_arch = "wasm32"))]
30use http_body_util::{BodyExt, Limited};
31#[cfg(not(target_arch = "wasm32"))]
32use hyper::{body::Incoming, header::HeaderValue, Request, Response, StatusCode};
33#[cfg(not(target_arch = "wasm32"))]
34use prost::Message;
35
36// Include generated protobuf code
37#[cfg(not(target_arch = "wasm32"))]
38mod etcdserverpb {
39    include!(concat!(env!("OUT_DIR"), "/etcdserverpb.rs"));
40}
41#[cfg(not(target_arch = "wasm32"))]
42mod mvccpb {
43    include!(concat!(env!("OUT_DIR"), "/mvccpb.rs"));
44}
45
46#[cfg(not(target_arch = "wasm32"))]
47use crate::logging::emit::Log;
48#[cfg(not(target_arch = "wasm32"))]
49use etcdserverpb::{
50    CompactionRequest, CompactionResponse, DeleteRangeRequest, DeleteRangeResponse, PutRequest,
51    PutResponse, RangeRequest, RangeResponse, ResponseHeader, TxnRequest, TxnResponse,
52};
53#[cfg(not(target_arch = "wasm32"))]
54use mvccpb::KeyValue;
55
56/// Cluster identity plus the monotonic revision counter that every etcd response header
57/// carries.
58///
59/// Deliberately **not** a key-value store: per the no-storage rule this protocol keeps no
60/// keys, and the handler answers every Range/Put/Delete. A `kvs: HashMap<Vec<u8>, KeyValue>`
61/// field used to sit here behind `#[allow(dead_code)]` and was never read or written; it is
62/// gone rather than left as a half-built store.
63#[cfg(not(target_arch = "wasm32"))]
64struct EtcdMeta {
65    /// Current revision counter
66    revision: i64,
67    /// Cluster ID
68    cluster_id: u64,
69    /// Member ID
70    member_id: u64,
71}
72
73#[cfg(not(target_arch = "wasm32"))]
74impl EtcdMeta {
75    fn new(cluster_id: u64, member_id: u64) -> Self {
76        Self {
77            revision: 0,
78            cluster_id,
79            member_id,
80        }
81    }
82
83    fn get_response_header(&self) -> ResponseHeader {
84        ResponseHeader {
85            cluster_id: self.cluster_id,
86            member_id: self.member_id,
87            revision: self.revision,
88            raft_term: 1, // Simplified: always term 1
89        }
90    }
91
92    fn increment_revision(&mut self) {
93        self.revision += 1;
94    }
95}
96
97/// Matches etcd's own `--max-request-bytes` default (1.5 MiB).
98#[cfg(not(target_arch = "wasm32"))]
99/// How long to wait for a peer's first byte after it connects.
100///
101/// HTTP/2 is client-speaks-first — the connection preface and the client's SETTINGS frame are
102/// the first thing on the wire and the server says nothing before them — and every gRPC client
103/// sends them inside its dial path. A peer that has connected and sent nothing has begun no
104/// connection at all, which is the state an unauthenticated flood lives in.
105const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
106
107/// How long a connection may do nothing at all once it is up.
108///
109/// etcd's own `--grpc-keepalive-interval` defaults to two hours, which is a bound in name only,
110/// so there is no upstream number worth copying here. Fifteen minutes instead, and what makes
111/// it safe is a property of *this* server rather than of etcd: every RPC here is unary —
112/// `handle_grpc_request` returns a `Response<GrpcBody>`, so even Watch is answered as one
113/// complete message rather than held open as a stream. There is therefore no legitimate
114/// long-lived silent request, and a connection with no RPC for fifteen minutes is a client that
115/// has gone away. A request still being answered is not silence: the watchdog reads
116/// `ConnectionActivity`, which reports a busy connection as not idle at all.
117const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
118
119/// Concurrent connections this server admits.
120///
121/// Each connection may buffer a request of up to [`MAX_REQUEST_BYTES`], so the cap is what
122/// turns that per-connection bound into a total one.
123const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
124
125/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
126///
127/// HTTP/1.1 `503 Service Unavailable` with a `Retry-After`, deliberately in the older protocol.
128/// A refused peer has not sent the HTTP/2 preface yet, so nothing has been negotiated, and an
129/// HTTP/2 GOAWAY would have to be preceded by a SETTINGS frame this server is declining to
130/// exchange. Every HTTP client and every human with `curl` can read a 503; a gRPC client
131/// reports a transport failure either way, but the bytes on the wire now say why.
132const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
133    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
134
135pub const MAX_REQUEST_BYTES: usize = 1_572_864;
136
137// gRPC status codes used by this server (google.rpc.Code).
138#[cfg(not(target_arch = "wasm32"))]
139const GRPC_UNKNOWN: i32 = 2;
140#[cfg(not(target_arch = "wasm32"))]
141const GRPC_NOT_FOUND: i32 = 5;
142#[cfg(not(target_arch = "wasm32"))]
143const GRPC_INVALID_ARGUMENT: i32 = 3;
144#[cfg(not(target_arch = "wasm32"))]
145const GRPC_RESOURCE_EXHAUSTED: i32 = 8;
146#[cfg(not(target_arch = "wasm32"))]
147const GRPC_UNIMPLEMENTED: i32 = 12;
148#[cfg(not(target_arch = "wasm32"))]
149const GRPC_INTERNAL: i32 = 13;
150#[cfg(not(target_arch = "wasm32"))]
151const GRPC_UNAVAILABLE: i32 = 14;
152
153/// The gRPC status a failed handler call must be reported as.
154///
155/// `14 UNAVAILABLE` when the failure was the LLM backend being saturated, `13 INTERNAL`
156/// otherwise. The distinction is not cosmetic: `UNAVAILABLE` is in gRPC's default set of
157/// retryable codes and is what a `grpc-service-config` `retryableStatusCodes` list keys on,
158/// while `INTERNAL` is explicitly *not* retryable. Reporting a transient rate-limiter refusal
159/// as `INTERNAL` turns a backlog that would clear in seconds into a hard application error for
160/// every caller — and for etcd in particular, into a failed lock acquisition that no client
161/// will retry.
162///
163/// Neither code can be confused with success: both are non-zero, and `grpc_status_reply`
164/// carries no message body at all, so there is no shape here that an empty-but-OK Range
165/// response could be mistaken for.
166///
167/// `pub` so `tests/` can exercise the classification directly — the project forbids
168/// `#[cfg(test)]` modules in `src/`, and driving the rate limiter to refusal through a spawned
169/// binary needs bounds the E2E harness cannot set.
170#[cfg(not(target_arch = "wasm32"))]
171pub fn grpc_status_for_llm_failure(err: &anyhow::Error) -> i32 {
172    if crate::llm::is_overload_error(err) {
173        GRPC_UNAVAILABLE
174    } else {
175        GRPC_INTERNAL
176    }
177}
178
179/// Build the client-visible failure for a handler call that could not be made, logging it on
180/// both channels first. Never returns anything a client could read as an answer.
181#[cfg(not(target_arch = "wasm32"))]
182fn llm_failure(
183    status_tx: &mpsc::UnboundedSender<String>,
184    method: &str,
185    e: anyhow::Error,
186) -> GrpcFailure {
187    let status = grpc_status_for_llm_failure(&e);
188    let name = if status == GRPC_UNAVAILABLE {
189        "UNAVAILABLE"
190    } else {
191        "INTERNAL"
192    };
193    Log::new(Some(status_tx)).warn(format!(
194        "etcd {} could not be answered: the handler failed ({}). Replying grpc-status {} \
195         ({}); no key-value data is being invented.",
196        method, e, status, name
197    ));
198    GrpcFailure::new(
199        status,
200        crate::utils::WireFailure::classify(&e).prefixed_text(),
201    )
202}
203
204/// A failure to be reported to the client as a gRPC status rather than as a transport error.
205#[cfg(not(target_arch = "wasm32"))]
206struct GrpcFailure {
207    status: i32,
208    message: String,
209}
210
211#[cfg(not(target_arch = "wasm32"))]
212impl GrpcFailure {
213    fn new(status: i32, message: impl Into<String>) -> Self {
214        Self {
215            status,
216            message: message.into(),
217        }
218    }
219
220    /// Map the `code` string of an `etcd_error` action onto a gRPC status code. etcd itself
221    /// reports key-space failures this way, so a handler returning `KEY_NOT_FOUND` produces
222    /// `5 NOT_FOUND` rather than a generic internal error.
223    fn from_action_code(code: &str, message: String) -> Self {
224        let status = match code.to_ascii_uppercase().as_str() {
225            "KEY_NOT_FOUND" | "NOT_FOUND" => GRPC_NOT_FOUND,
226            "INVALID_ARGUMENT" | "BAD_REQUEST" => GRPC_INVALID_ARGUMENT,
227            "UNIMPLEMENTED" | "NOT_IMPLEMENTED" => GRPC_UNIMPLEMENTED,
228            "RESOURCE_EXHAUSTED" => GRPC_RESOURCE_EXHAUSTED,
229            "INTERNAL" => GRPC_INTERNAL,
230            _ => GRPC_UNKNOWN,
231        };
232        Self::new(status, message)
233    }
234}
235
236#[cfg(not(target_arch = "wasm32"))]
237impl From<anyhow::Error> for GrpcFailure {
238    fn from(e: anyhow::Error) -> Self {
239        // Classify here too, not only at the explicit call sites: `?` on an anyhow error is
240        // reachable from several places in this module and an overload must never be reported
241        // as a non-retryable INTERNAL just because it took the implicit path.
242        //
243        // `grpc-message` is client-visible, so it carries the category and not the error -
244        // see `crate::utils::wire_failure`.
245        let status = grpc_status_for_llm_failure(&e);
246        Self::new(status, crate::utils::WireFailure::classify(&e).text())
247    }
248}
249
250#[cfg(not(target_arch = "wasm32"))]
251impl From<prost::DecodeError> for GrpcFailure {
252    fn from(e: prost::DecodeError) -> Self {
253        // A body this server cannot decode is the client's malformed input, not a server bug -
254        // but the decoder's own message is ours, so it is logged rather than sent.
255        debug!("etcd request message did not decode: {e}");
256        Self::new(GRPC_INVALID_ARGUMENT, "could not decode request message")
257    }
258}
259
260#[cfg(not(target_arch = "wasm32"))]
261impl From<prost::EncodeError> for GrpcFailure {
262    fn from(e: prost::EncodeError) -> Self {
263        debug!("etcd response encoding failed: {e}");
264        Self::new(GRPC_INTERNAL, "could not encode response")
265    }
266}
267
268/// Scan a handler's action results for an `etcd_error` and turn it into a gRPC status.
269///
270/// `etcd_error` is offered to the model on every etcd event. Before this, no handler looked
271/// for it, so a model correctly reporting "key not found" had its answer dropped and the
272/// client saw an empty success instead.
273#[cfg(not(target_arch = "wasm32"))]
274fn take_etcd_error(
275    results: &[crate::llm::actions::protocol_trait::ActionResult],
276) -> Option<GrpcFailure> {
277    for result in results {
278        if let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } = result {
279            if name == "etcd_error" {
280                let code = data
281                    .get("code")
282                    .and_then(|v| v.as_str())
283                    .unwrap_or("UNKNOWN");
284                let message = data
285                    .get("message")
286                    .and_then(|v| v.as_str())
287                    .unwrap_or("etcdserver: request failed")
288                    .to_string();
289                return Some(GrpcFailure::from_action_code(code, message));
290            }
291        }
292    }
293    None
294}
295
296/// Reduce a status message to the visible-ASCII subset `grpc-message` can carry.
297///
298/// `HeaderValue` accepts only visible ASCII, and these messages come from LLM output and
299/// `anyhow` chains — netget's own LLM errors begin with a literal `✗`, and multi-line `anyhow`
300/// context chains are routine. Without this the reply fell back to a static `"internal error"`
301/// and the caller lost the reason entirely, which is how the fail-closed status came back
302/// carrying nothing an operator could act on.
303///
304/// Substitution rather than rejection: a mangled character is a far better outcome than a
305/// discarded explanation. Capped at 512 characters because a status message is a diagnostic,
306/// not a payload, and every character here is ASCII by then so the cut cannot split a
307/// codepoint.
308#[cfg(not(target_arch = "wasm32"))]
309fn header_safe(message: &str) -> String {
310    let mut out = String::with_capacity(message.len().min(512));
311    let mut last_was_space = false;
312    for c in message.chars() {
313        let c = if (' '..='~').contains(&c) { c } else { ' ' };
314        if c == ' ' {
315            if last_was_space {
316                continue;
317            }
318            last_was_space = true;
319        } else {
320            last_was_space = false;
321        }
322        if out.chars().count() >= 512 {
323            break;
324        }
325        out.push(c);
326    }
327    let trimmed = out.trim();
328    if trimmed.is_empty() {
329        "internal error".to_string()
330    } else {
331        trimmed.to_string()
332    }
333}
334
335/// Build a body-less gRPC reply carrying a status code.
336///
337/// `HeaderValue::from_str` can fail: `message` originates in LLM output and `anyhow` chains,
338/// either of which may contain non-ASCII or control characters that are illegal in a header
339/// value. It is put through [`header_safe`] first so the caller keeps the explanation, and the
340/// fallback below stays as a last resort rather than the common case — it used to be the common
341/// case, because netget's own LLM error strings start with `✗`.
342/// A gRPC response body: the length-prefixed message, then a **trailing HEADERS frame**
343/// carrying `grpc-status`.
344///
345/// This exists because putting `grpc-status` in the *initial* headers beside a DATA body is not
346/// gRPC, and `etcdctl` — grpc-go, the reference implementation — refuses such a stream outright:
347///
348/// ```text
349/// Error: rpc error: code = Internal desc = server closed the stream without sending trailers
350/// ```
351///
352/// The specification allows the status in the initial headers **only** for a Trailers-Only
353/// reply, which by definition carries no message. So this server's *error* replies were
354/// accidentally correct — an empty body makes them Trailers-Only — and every *success* reply was
355/// malformed. That asymmetry is why no test caught it for so long: the failure paths were the
356/// ones being asserted on, and tonic is lenient enough to accept the rest.
357///
358/// `tests/server/etcd/real_client_test.rs` is the second, stricter client that finds it.
359type GrpcBody = http_body_util::combinators::BoxBody<Bytes, std::convert::Infallible>;
360
361/// A message frame followed by trailers carrying `grpc-status`/`grpc-message`.
362///
363/// Used for every reply that has a body. Errors do not come through here — see
364/// `grpc_status_reply`, which is deliberately Trailers-Only instead.
365#[cfg(not(target_arch = "wasm32"))]
366fn grpc_body_with_trailers(body: Bytes, status: i32, message: &str) -> GrpcBody {
367    use http_body_util::StreamBody;
368
369    let mut trailers = hyper::HeaderMap::new();
370    trailers.insert(
371        "grpc-status",
372        HeaderValue::from_str(&status.to_string())
373            .unwrap_or_else(|_| HeaderValue::from_static("13")),
374    );
375    trailers.insert(
376        "grpc-message",
377        HeaderValue::from_str(message).unwrap_or_else(|_| HeaderValue::from_static("")),
378    );
379
380    let frames: Vec<std::result::Result<hyper::body::Frame<Bytes>, std::convert::Infallible>> = vec![
381        Ok(hyper::body::Frame::data(body)),
382        Ok(hyper::body::Frame::trailers(trailers)),
383    ];
384
385    BodyExt::boxed(StreamBody::new(futures::stream::iter(frames)))
386}
387
388/// An empty body, for a Trailers-Only reply whose status rides in the initial headers.
389#[cfg(not(target_arch = "wasm32"))]
390fn empty_grpc_body() -> GrpcBody {
391    BodyExt::boxed(http_body_util::Empty::<Bytes>::new())
392}
393
394#[cfg(not(target_arch = "wasm32"))]
395fn grpc_status_reply(status: i32, message: &str) -> Response<GrpcBody> {
396    let message = &header_safe(message);
397    // **Trailers-Only**, and deliberately so. With no message to send, the specification puts the
398    // status in the initial headers and ends the stream there — which is what real etcd does for
399    // an error and what every gRPC client expects. This is the one placement that is correct;
400    // the bug was applying it to replies that *do* carry a message.
401    let mut res = Response::new(empty_grpc_body());
402    *res.status_mut() = StatusCode::OK;
403    let headers = res.headers_mut();
404    headers.insert(
405        "content-type",
406        HeaderValue::from_static("application/grpc+proto"),
407    );
408    headers.insert(
409        "grpc-status",
410        HeaderValue::from_str(&status.to_string())
411            .unwrap_or_else(|_| HeaderValue::from_static("13")),
412    );
413    headers.insert(
414        "grpc-message",
415        HeaderValue::from_str(message)
416            .unwrap_or_else(|_| HeaderValue::from_static("internal error")),
417    );
418    res
419}
420
421/// etcd v3 server
422pub struct EtcdServer;
423
424#[cfg(not(target_arch = "wasm32"))]
425impl EtcdServer {
426    /// Spawn etcd server with LLM-controlled KV operations
427    pub async fn spawn_with_llm_actions(
428        listen_addr: SocketAddr,
429        llm_client: OllamaClient,
430        app_state: Arc<AppState>,
431        status_tx: mpsc::UnboundedSender<String>,
432        server_id: crate::state::ServerId,
433        startup_params: Option<crate::protocol::StartupParams>,
434    ) -> Result<SocketAddr> {
435        // Extract cluster configuration
436        let cluster_name = startup_params
437            .as_ref()
438            .map(|p| p.get_optional_string("cluster_name"))
439            .transpose()?
440            .flatten()
441            .unwrap_or_else(|| "netget-cluster".to_string());
442
443        let cluster_id = 0x6574636400000001u64; // "etcd" + 1
444        let member_id = 0x6d656d6265720001u64; // "member" + 1 (shortened to fit u64)
445
446        Log::new(Some(&status_tx)).info(format!(
447            "etcd server starting on {} (cluster: {})",
448            listen_addr, cluster_name
449        ));
450
451        // Create in-memory meta
452        let meta = Arc::new(Mutex::new(EtcdMeta::new(cluster_id, member_id)));
453        let protocol = Arc::new(EtcdProtocol::new());
454
455        // Bind to address
456        let listener = tokio::net::TcpListener::bind(listen_addr).await?;
457        let local_addr = listener.local_addr()?;
458
459        Log::new(Some(&status_tx)).info(format!("etcd server listening on {}", local_addr));
460
461        // Spawn server task
462        let task_registrar = app_state.clone();
463        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
464        let accept_handle = tokio::spawn(async move {
465            loop {
466                match crate::server::accept_bounded::accept_bounded(
467                    &listener,
468                    &limiter,
469                    CONNECTION_CAP_REFUSAL,
470                    "etcd",
471                    Some(&status_tx),
472                )
473                .await
474                {
475                    Ok((stream, peer_addr, permit)) => {
476                        Log::new(Some(&status_tx))
477                            .debug(format!("etcd connection from {}", peer_addr));
478
479                        // This server tracked no connections at all: no `ConnectionId`, no
480                        // `add_connection_to_server`, no stats. The dashboard rail therefore
481                        // drew every etcd server with an empty `peers` node however much
482                        // traffic it was serving, and `stop_server` had nothing to show for
483                        // the sockets it was closing.
484                        let connection_id = crate::server::connection::ConnectionId::new(
485                            app_state.get_next_unified_id().await,
486                        );
487                        {
488                            use crate::state::server::{
489                                ConnectionState as ServerConnectionState, ConnectionStatus,
490                                ProtocolConnectionInfo,
491                            };
492                            let now = crate::utils::clock::Instant::now();
493                            app_state
494                                .add_connection_to_server(
495                                    server_id,
496                                    ServerConnectionState {
497                                        id: connection_id,
498                                        remote_addr: peer_addr,
499                                        local_addr,
500                                        bytes_sent: 0,
501                                        bytes_received: 0,
502                                        packets_sent: 0,
503                                        packets_received: 0,
504                                        last_activity: now,
505                                        status: ConnectionStatus::Active,
506                                        status_changed_at: now,
507                                        protocol_info: ProtocolConnectionInfo::empty(),
508                                    },
509                                )
510                                .await;
511                        }
512                        let _ = status_tx.send("__UPDATE_UI__".to_string());
513
514                        let llm_clone = llm_client.clone();
515                        let state_clone = app_state.clone();
516                        let status_clone = status_tx.clone();
517                        let meta_clone = meta.clone();
518                        let protocol_clone = protocol.clone();
519                        let conn_owner = app_state.clone();
520
521                        // Tracked, not detached: stop_server must abort this task too.
522                        let task_owner = app_state.clone();
523                        task_owner
524                            .spawn_server_task(server_id, async move {
525                                // Held for the life of the connection, so the cap counts live
526                                // clients rather than accepts.
527                                let _permit = permit;
528                                if let Err(e) = Self::handle_connection(
529                                    stream,
530                                    peer_addr,
531                                    local_addr,
532                                    connection_id,
533                                    llm_clone,
534                                    state_clone,
535                                    status_clone,
536                                    server_id,
537                                    meta_clone,
538                                    protocol_clone,
539                                )
540                                .await
541                                {
542                                    error!("etcd connection error: {}", e);
543                                }
544                                // Every exit of `serve_connection` lands here - a clean GOAWAY, a
545                                // reset, or an error - so the rail stops showing a dead HTTP/2
546                                // connection. etcd is not `.connectionless()`, so nothing else
547                                // would ever reap it.
548                                conn_owner
549                                    .close_connection_on_server(server_id, connection_id)
550                                    .await;
551                            })
552                            .await;
553                    }
554                    Err(e) => {
555                        // A persistent accept error (EMFILE, listener torn down) recurs
556                        // immediately, so continuing spins a hot loop that floods the
557                        // unbounded status channel. Stop the listener instead.
558                        Log::new(Some(&status_tx))
559                            .error(format!("etcd accept failed, listener stopped: {}", e));
560                        break;
561                    }
562                }
563            }
564        });
565
566        task_registrar
567            .register_server_task(server_id, accept_handle)
568            .await;
569
570        Ok(local_addr)
571    }
572
573    #[allow(clippy::too_many_arguments)]
574    async fn handle_connection(
575        stream: tokio::net::TcpStream,
576        peer_addr: SocketAddr,
577        local_addr: SocketAddr,
578        connection_id: crate::server::connection::ConnectionId,
579        llm_client: OllamaClient,
580        app_state: Arc<AppState>,
581        status_tx: mpsc::UnboundedSender<String>,
582        server_id: crate::state::ServerId,
583        meta: Arc<Mutex<EtcdMeta>>,
584        protocol: Arc<EtcdProtocol>,
585    ) -> Result<()> {
586        use hyper::service::service_fn;
587        use hyper_util::rt::TokioIo;
588
589        // First-byte bound, before hyper sees the socket.
590        //
591        // hyper owns every read once `serve_connection` starts, and wrapping the read half in
592        // a deadline would be *wrong* here rather than merely awkward: hyper keeps polling the
593        // connection for new frames while a request is being answered, so a deadline on reads
594        // would fire in the middle of an LLM round-trip. `peek` waits for data without
595        // consuming it, so the HTTP/2 preface is still there for hyper afterwards — and it
596        // bounds exactly the case that needs bounding, a peer that has connected and sent
597        // nothing at all.
598        match tokio::time::timeout(FIRST_BYTE_READ_TIMEOUT, stream.peek(&mut [0u8; 1])).await {
599            Ok(Ok(0)) | Ok(Err(_)) => return Ok(()),
600            Ok(Ok(_)) => {}
601            Err(_) => {
602                Log::new(Some(&status_tx)).debug(format!(
603                    "etcd peer {} sent nothing for {}s; closing before the HTTP/2 preface",
604                    peer_addr,
605                    FIRST_BYTE_READ_TIMEOUT.as_secs()
606                ));
607                return Ok(());
608            }
609        }
610
611        let io = TokioIo::new(stream);
612
613        // Tracks whether this connection is answering anything. A request waiting on the model,
614        // or parked for a human at the dashboard, holds the count above zero, so the idle
615        // watchdog below cannot close the connection the answer belongs to however long it
616        // takes — only genuine silence counts.
617        let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
618        let activity_for_service = Arc::clone(&activity);
619
620        let service = service_fn(move |req: Request<Incoming>| {
621            let llm = llm_client.clone();
622            let state = app_state.clone();
623            let status = status_tx.clone();
624            let meta_ref = meta.clone();
625            let proto = protocol.clone();
626            let activity = Arc::clone(&activity_for_service);
627
628            async move {
629                let _busy = activity.busy();
630                let response = Self::handle_grpc_request(
631                    req,
632                    peer_addr,
633                    local_addr,
634                    llm,
635                    state.clone(),
636                    status,
637                    server_id,
638                    meta_ref,
639                    proto,
640                )
641                .await;
642
643                // Counted per RPC rather than per byte on the socket: hyper owns the HTTP/2
644                // framing, so the only honest figure available here is the size of the gRPC
645                // message body. Recording something keeps `last_activity` fresh and the
646                // rail's arrows moving, which is what they are read for.
647                if let Ok(res) = &response {
648                    use hyper::body::Body as _;
649                    state
650                        .update_connection_stats(
651                            server_id,
652                            connection_id,
653                            None,
654                            Some(res.body().size_hint().lower()),
655                            None,
656                            Some(1),
657                        )
658                        .await;
659                }
660                response
661            }
662        });
663
664        let conn = hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
665            .serve_connection(io, service);
666        tokio::pin!(conn);
667
668        tokio::select! {
669            result = &mut conn => result?,
670            _ = crate::server::accept_bounded::watch_idle(
671                Arc::clone(&activity),
672                IDLE_BETWEEN_REQUESTS_TIMEOUT,
673            ) => {
674                debug!(
675                    "etcd connection {} idle for {}s; closing",
676                    connection_id,
677                    IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
678                );
679            }
680        }
681
682        Ok(())
683    }
684
685    async fn handle_grpc_request(
686        req: Request<Incoming>,
687        _peer_addr: SocketAddr,
688        _local_addr: SocketAddr,
689        llm_client: OllamaClient,
690        app_state: Arc<AppState>,
691        status_tx: mpsc::UnboundedSender<String>,
692        server_id: crate::state::ServerId,
693        meta: Arc<Mutex<EtcdMeta>>,
694        protocol: Arc<EtcdProtocol>,
695    ) -> Result<Response<GrpcBody>> {
696        // Never return Err from here. hyper turns a service error into an abrupt HTTP/2
697        // stream reset with no gRPC status, which a client reports as a transport failure
698        // with no explanation; worse, an error out of service_fn tears down the whole
699        // multiplexed connection, killing every other in-flight RPC on it. Every failure
700        // below becomes a well-formed gRPC status reply instead.
701        Ok(
702            match Self::route_grpc_request(
703                req, llm_client, app_state, status_tx, server_id, meta, protocol,
704            )
705            .await
706            {
707                Ok(response) => response,
708                Err(GrpcFailure { status, message }) => grpc_status_reply(status, &message),
709            },
710        )
711    }
712
713    async fn route_grpc_request(
714        req: Request<Incoming>,
715        llm_client: OllamaClient,
716        app_state: Arc<AppState>,
717        status_tx: mpsc::UnboundedSender<String>,
718        server_id: crate::state::ServerId,
719        meta: Arc<Mutex<EtcdMeta>>,
720        protocol: Arc<EtcdProtocol>,
721    ) -> std::result::Result<Response<GrpcBody>, GrpcFailure> {
722        // Store owned copies before consuming req
723        let path = req.uri().path().to_string();
724        let method = req.method().as_str().to_string();
725
726        Log::new(Some(&status_tx)).debug(format!("etcd gRPC {} {}", method, path));
727
728        // Cap the body before buffering it. HTTP/2 flow control bounds the window, not the
729        // total, so an unbounded `collect()` lets one client grow the process without limit.
730        // The limit matches etcd's own --max-request-bytes default of 1.5 MiB.
731        let body = Limited::new(req.into_body(), MAX_REQUEST_BYTES);
732        let whole_body = body.collect().await.map_err(|e| {
733            GrpcFailure::new(
734                GRPC_RESOURCE_EXHAUSTED,
735                format!(
736                    "request body rejected (limit {} bytes): {}",
737                    MAX_REQUEST_BYTES, e
738                ),
739            )
740        })?;
741        let whole_body = whole_body.to_bytes();
742
743        // Parse gRPC frame: 1 byte compression flag + 4 byte big-endian length + message.
744        if whole_body.len() < 5 {
745            return Err(GrpcFailure::new(
746                GRPC_INTERNAL,
747                format!("gRPC frame too short: {} bytes", whole_body.len()),
748            ));
749        }
750
751        if whole_body[0] != 0 {
752            // The message is compressed; nothing here can decompress it, and silently
753            // handing the compressed bytes to prost would decode as garbage.
754            return Err(GrpcFailure::new(
755                GRPC_UNIMPLEMENTED,
756                "compressed gRPC messages are not supported".to_string(),
757            ));
758        }
759
760        // Honour the declared length rather than taking "everything after byte 5": the tail
761        // may hold a second frame (which this unary server does not handle) and a short
762        // declared length would otherwise feed trailing bytes into the protobuf decoder.
763        let declared =
764            u32::from_be_bytes([whole_body[1], whole_body[2], whole_body[3], whole_body[4]])
765                as usize;
766        let available = whole_body.len() - 5;
767        if declared > available {
768            return Err(GrpcFailure::new(
769                GRPC_INTERNAL,
770                format!(
771                    "gRPC frame declares {} bytes but only {} follow the header",
772                    declared, available
773                ),
774            ));
775        }
776        let msg_bytes = &whole_body[5..5 + declared];
777
778        // Route to appropriate handler based on path
779        let response_bytes = match path.as_str() {
780            "/etcdserverpb.KV/Range" => {
781                Self::handle_range(
782                    msg_bytes, llm_client, app_state, status_tx, server_id, meta, protocol,
783                )
784                .await?
785            }
786            "/etcdserverpb.KV/Put" => {
787                Self::handle_put(
788                    msg_bytes, llm_client, app_state, status_tx, server_id, meta, protocol,
789                )
790                .await?
791            }
792            "/etcdserverpb.KV/DeleteRange" => {
793                Self::handle_delete_range(
794                    msg_bytes, llm_client, app_state, status_tx, server_id, meta, protocol,
795                )
796                .await?
797            }
798            "/etcdserverpb.KV/Txn" => {
799                Self::handle_txn(
800                    msg_bytes, llm_client, app_state, status_tx, server_id, meta, protocol,
801                )
802                .await?
803            }
804            "/etcdserverpb.KV/Compact" => {
805                Self::handle_compact(
806                    msg_bytes, llm_client, app_state, status_tx, server_id, meta, protocol,
807                )
808                .await?
809            }
810            // Only the KV service is implemented. Answering with UNIMPLEMENTED lets the
811            // client report a real gRPC error and keeps the connection usable; the previous
812            // bail! reset the whole HTTP/2 connection, taking concurrent RPCs with it.
813            other => {
814                return Err(GrpcFailure::new(
815                    GRPC_UNIMPLEMENTED,
816                    format!(
817                        "etcd server implements only etcdserverpb.KV; no method {}",
818                        other
819                    ),
820                ));
821            }
822        };
823
824        // Build gRPC response with framing
825        let mut response_with_frame = Vec::with_capacity(5 + response_bytes.len());
826        response_with_frame.push(0); // Not compressed
827        response_with_frame.extend_from_slice(&(response_bytes.len() as u32).to_be_bytes());
828        response_with_frame.extend_from_slice(&response_bytes);
829
830        // The status goes in TRAILERS, not here. `grpc-status` in the initial headers beside a
831        // DATA body is what made `etcdctl` refuse every successful call.
832        let mut res = Response::new(grpc_body_with_trailers(
833            Bytes::from(response_with_frame),
834            0,
835            "",
836        ));
837        *res.status_mut() = StatusCode::OK;
838        res.headers_mut().insert(
839            "content-type",
840            HeaderValue::from_static("application/grpc+proto"),
841        );
842
843        Ok(res)
844    }
845
846    async fn handle_range(
847        msg_bytes: &[u8],
848        llm_client: OllamaClient,
849        app_state: Arc<AppState>,
850        status_tx: mpsc::UnboundedSender<String>,
851        server_id: crate::state::ServerId,
852        meta: Arc<Mutex<EtcdMeta>>,
853        protocol: Arc<EtcdProtocol>,
854    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
855        let request = RangeRequest::decode(msg_bytes)?;
856
857        let key_str = String::from_utf8_lossy(&request.key);
858        Log::new(Some(&status_tx)).debug(format!("etcd Range request: key={}", key_str));
859
860        trace!("etcd Range request: {:?}", request);
861
862        // Create event for LLM
863        let event = Event::new(
864            &ETCD_RANGE_REQUEST_EVENT,
865            serde_json::json!({
866                "key": key_str,
867                "range_end": if request.range_end.is_empty() { None } else { Some(String::from_utf8_lossy(&request.range_end).to_string()) },
868                "limit": request.limit,
869            }),
870        );
871
872        // Call LLM for decision
873        let execution_result = call_llm(
874            &llm_client,
875            &app_state,
876            server_id,
877            None,
878            &event,
879            protocol.as_ref(),
880        )
881        .await
882        .map_err(|e| llm_failure(&status_tx, "Range", e))?;
883
884        if let Some(failure) = take_etcd_error(&execution_result.protocol_results) {
885            return Err(failure);
886        }
887
888        // Process LLM action results to build response
889        let meta_lock = meta.lock().await;
890        let mut kvs = vec![];
891        let mut more = false;
892        let mut count = 0;
893        let mut answered = false;
894
895        for protocol_result in &execution_result.protocol_results {
896            if let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
897                protocol_result
898            {
899                if name == "etcd_range_response" {
900                    answered = true;
901                    // Parse LLM response
902                    if let Some(kvs_array) = data.get("kvs").and_then(|v| v.as_array()) {
903                        for kv_json in kvs_array {
904                            let key = kv_json.get("key").and_then(|v| v.as_str()).unwrap_or("");
905                            let value = kv_json.get("value").and_then(|v| v.as_str()).unwrap_or("");
906                            let create_revision = kv_json
907                                .get("create_revision")
908                                .and_then(|v| v.as_i64())
909                                .unwrap_or(0);
910                            let mod_revision = kv_json
911                                .get("mod_revision")
912                                .and_then(|v| v.as_i64())
913                                .unwrap_or(0);
914                            let version =
915                                kv_json.get("version").and_then(|v| v.as_i64()).unwrap_or(0);
916                            let lease = kv_json.get("lease").and_then(|v| v.as_i64()).unwrap_or(0);
917
918                            kvs.push(KeyValue {
919                                key: key.as_bytes().to_vec(),
920                                create_revision,
921                                mod_revision,
922                                version,
923                                lease,
924                                value: value.as_bytes().to_vec(),
925                            });
926                        }
927                    }
928                    more = data.get("more").and_then(|v| v.as_bool()).unwrap_or(false);
929                    count = data
930                        .get("count")
931                        .and_then(|v| v.as_i64())
932                        .unwrap_or(kvs.len() as i64);
933                }
934            }
935        }
936
937        // The same fail-closed rule `handle_put` already applies, for the same reason.
938        // `kvs: [], count: 0` is not the absence of an answer - in etcd it *is* an answer, and
939        // a definite one: the key does not exist. A model that declined to answer, a static
940        // handler with an empty action list, or a reply whose actions were all unrecognised
941        // used to reach the client as "no such key", which is indistinguishable from a real
942        // lookup that found nothing. A handler that genuinely means "nothing here" says so
943        // with `etcd_range_response` carrying an empty `kvs`, and that still works.
944        //
945        // INTERNAL rather than UNAVAILABLE: nothing is saturated, so inviting a retry would
946        // be wrong.
947        if !answered {
948            drop(meta_lock);
949            Log::new(Some(&status_tx)).warn(
950                "etcd Range could not be answered: the handler produced no etcd_range_response \
951                 (decision=fail_closed_no_action). Replying grpc-status 13 (INTERNAL); \
952                 \"key not found\" is not being invented."
953                    .to_string(),
954            );
955            return Err(GrpcFailure::new(
956                GRPC_INTERNAL,
957                crate::utils::WireFailure::Unavailable.prefixed_text(),
958            ));
959        }
960
961        let response = RangeResponse {
962            header: Some(meta_lock.get_response_header()),
963            kvs,
964            more,
965            count,
966        };
967
968        let mut buf = Vec::new();
969        response.encode(&mut buf)?;
970        Ok(buf)
971    }
972
973    async fn handle_put(
974        msg_bytes: &[u8],
975        llm_client: OllamaClient,
976        app_state: Arc<AppState>,
977        status_tx: mpsc::UnboundedSender<String>,
978        server_id: crate::state::ServerId,
979        meta: Arc<Mutex<EtcdMeta>>,
980        protocol: Arc<EtcdProtocol>,
981    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
982        let request = PutRequest::decode(msg_bytes)?;
983
984        let key_str = String::from_utf8_lossy(&request.key);
985        let value_str = String::from_utf8_lossy(&request.value);
986        Log::new(Some(&status_tx)).debug(format!(
987            "etcd Put request: key={}, value={}",
988            key_str, value_str
989        ));
990
991        // Call LLM with put request event
992        use crate::protocol::Event;
993        use crate::server::etcd::actions::ETCD_PUT_REQUEST_EVENT;
994
995        let event = Event::new(
996            &ETCD_PUT_REQUEST_EVENT,
997            serde_json::json!({
998                "key": key_str,
999                "value": value_str,
1000                "lease": request.lease,
1001            }),
1002        );
1003
1004        Log::new(Some(&status_tx)).debug("etcd calling LLM for Put request");
1005
1006        let execution_result = crate::llm::action_helper::call_llm(
1007            &llm_client,
1008            &app_state,
1009            server_id,
1010            None,
1011            &event,
1012            protocol.as_ref(),
1013        )
1014        .await
1015        .map_err(|e| llm_failure(&status_tx, "Put", e))?;
1016
1017        // Display messages from LLM
1018        for message in &execution_result.messages {
1019            Log::new(Some(&status_tx)).info(format!("{}", message));
1020        }
1021
1022        Log::new(Some(&status_tx)).debug(format!(
1023            "etcd got {} protocol results",
1024            execution_result.protocol_results.len()
1025        ));
1026
1027        if let Some(failure) = take_etcd_error(&execution_result.protocol_results) {
1028            return Err(failure);
1029        }
1030
1031        // Process LLM action results to build response
1032        let mut meta_lock = meta.lock().await;
1033        let mut revision: i64 = 0;
1034        let mut answered = false;
1035
1036        for protocol_result in &execution_result.protocol_results {
1037            if let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
1038                protocol_result
1039            {
1040                if name == "etcd_put_response" {
1041                    answered = true;
1042                    // LLM provided put response
1043                    revision = data
1044                        .get("revision")
1045                        .and_then(|v| v.as_i64())
1046                        .unwrap_or_else(|| {
1047                            meta_lock.increment_revision();
1048                            meta_lock.revision
1049                        });
1050
1051                    // Update meta revision if LLM provided one
1052                    if revision > meta_lock.revision {
1053                        meta_lock.revision = revision;
1054                    }
1055                }
1056            }
1057        }
1058
1059        // A Put with no `etcd_put_response` used to fall through here, bump the revision and
1060        // return a normal PutResponse — so a model that declined the write, a static handler
1061        // with an empty action list, or an answer whose actions were all unrecognised was
1062        // reported to the client as a committed write, complete with a fresh revision number
1063        // that nothing had written. A client cannot tell that from a real commit.
1064        //
1065        // Refuse with the same gRPC failure shape the backend-error path uses. INTERNAL, not
1066        // UNAVAILABLE: nothing is saturated, so inviting a retry would be wrong.
1067        if !answered {
1068            drop(meta_lock);
1069            Log::new(Some(&status_tx)).warn(
1070                "etcd Put could not be answered: the handler produced no etcd_put_response \
1071                 (decision=fail_closed_no_action). Replying grpc-status 13 (INTERNAL); no \
1072                 revision is being invented."
1073                    .to_string(),
1074            );
1075            return Err(GrpcFailure::new(
1076                GRPC_INTERNAL,
1077                crate::utils::WireFailure::Unavailable.prefixed_text(),
1078            ));
1079        }
1080
1081        let response = PutResponse {
1082            header: Some(meta_lock.get_response_header()),
1083            prev_kv: None,
1084        };
1085
1086        let mut buf = Vec::new();
1087        response.encode(&mut buf)?;
1088        Ok(buf)
1089    }
1090
1091    async fn handle_delete_range(
1092        msg_bytes: &[u8],
1093        llm_client: OllamaClient,
1094        app_state: Arc<AppState>,
1095        status_tx: mpsc::UnboundedSender<String>,
1096        server_id: crate::state::ServerId,
1097        meta: Arc<Mutex<EtcdMeta>>,
1098        protocol: Arc<EtcdProtocol>,
1099    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
1100        let request = DeleteRangeRequest::decode(msg_bytes)?;
1101
1102        let key_str = String::from_utf8_lossy(&request.key);
1103        Log::new(Some(&status_tx)).debug(format!("etcd DeleteRange request: key={}", key_str));
1104
1105        // Call LLM with delete request event
1106        use crate::protocol::Event;
1107        use crate::server::etcd::actions::ETCD_DELETE_REQUEST_EVENT;
1108
1109        let event = Event::new(
1110            &ETCD_DELETE_REQUEST_EVENT,
1111            serde_json::json!({
1112                "key": key_str,
1113                "range_end": if request.range_end.is_empty() {
1114                    serde_json::Value::Null
1115                } else {
1116                    serde_json::json!(String::from_utf8_lossy(&request.range_end))
1117                },
1118            }),
1119        );
1120
1121        Log::new(Some(&status_tx)).debug("etcd calling LLM for Delete request");
1122
1123        let execution_result = crate::llm::action_helper::call_llm(
1124            &llm_client,
1125            &app_state,
1126            server_id,
1127            None,
1128            &event,
1129            protocol.as_ref(),
1130        )
1131        .await
1132        .map_err(|e| llm_failure(&status_tx, "DeleteRange", e))?;
1133
1134        // Display messages from LLM
1135        for message in &execution_result.messages {
1136            Log::new(Some(&status_tx)).info(format!("{}", message));
1137        }
1138
1139        Log::new(Some(&status_tx)).debug(format!(
1140            "etcd got {} protocol results",
1141            execution_result.protocol_results.len()
1142        ));
1143
1144        if let Some(failure) = take_etcd_error(&execution_result.protocol_results) {
1145            return Err(failure);
1146        }
1147
1148        // Process LLM action results to build response
1149        let mut meta_lock = meta.lock().await;
1150        let mut deleted: i64 = 0;
1151        let mut answered = false;
1152
1153        for protocol_result in &execution_result.protocol_results {
1154            if let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
1155                protocol_result
1156            {
1157                if name == "etcd_delete_range_response" {
1158                    answered = true;
1159                    // LLM provided delete response
1160                    deleted = data.get("deleted").and_then(|v| v.as_i64()).unwrap_or(0);
1161                }
1162            }
1163        }
1164
1165        // Same rule as Put and Range. An unanswered DeleteRange used to bump the revision and
1166        // return `deleted: 0` under a fresh revision number - a client reads that as a delete
1167        // that ran and matched nothing, which is a successful, committed operation. Refuse
1168        // instead, and do not move the revision counter for something that did not happen.
1169        if !answered {
1170            drop(meta_lock);
1171            Log::new(Some(&status_tx)).warn(
1172                "etcd DeleteRange could not be answered: the handler produced no \
1173                 etcd_delete_range_response (decision=fail_closed_no_action). Replying \
1174                 grpc-status 13 (INTERNAL); no deletion is being reported.\
1175                 "
1176                .to_string(),
1177            );
1178            return Err(GrpcFailure::new(
1179                GRPC_INTERNAL,
1180                crate::utils::WireFailure::Unavailable.prefixed_text(),
1181            ));
1182        }
1183
1184        meta_lock.increment_revision();
1185
1186        let response = DeleteRangeResponse {
1187            header: Some(meta_lock.get_response_header()),
1188            deleted,
1189            prev_kvs: vec![],
1190        };
1191
1192        let mut buf = Vec::new();
1193        response.encode(&mut buf)?;
1194        Ok(buf)
1195    }
1196
1197    /// Handle a Txn.
1198    ///
1199    /// The comparison predicates are described to the handler and the handler decides whether
1200    /// they hold; the *nested* operations inside the success/failure branches are still not
1201    /// executed, so `responses` is always empty. This used to hardcode `succeeded: false`
1202    /// with no LLM call at all, which made every etcd distributed-lock acquisition fail
1203    /// unconditionally - and `etcd_txn_request` was advertised as an event that never fired.
1204    async fn handle_txn(
1205        msg_bytes: &[u8],
1206        llm_client: OllamaClient,
1207        app_state: Arc<AppState>,
1208        status_tx: mpsc::UnboundedSender<String>,
1209        server_id: crate::state::ServerId,
1210        meta: Arc<Mutex<EtcdMeta>>,
1211        protocol: Arc<EtcdProtocol>,
1212    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
1213        use crate::server::etcd::actions::ETCD_TXN_REQUEST_EVENT;
1214
1215        let request = TxnRequest::decode(msg_bytes)?;
1216
1217        Log::new(Some(&status_tx)).debug(format!(
1218            "etcd Txn request: {} compare(s), {} success op(s), {} failure op(s)",
1219            request.compare.len(),
1220            request.success.len(),
1221            request.failure.len()
1222        ));
1223
1224        // Describe the predicates as structured JSON. Protobuf enums are rendered as their
1225        // spec names rather than raw numbers so a handler can reason about them.
1226        let compares: Vec<serde_json::Value> = request
1227            .compare
1228            .iter()
1229            .map(|c| {
1230                serde_json::json!({
1231                    "key": String::from_utf8_lossy(&c.key),
1232                    "range_end": if c.range_end.is_empty() {
1233                        serde_json::Value::Null
1234                    } else {
1235                        serde_json::json!(String::from_utf8_lossy(&c.range_end))
1236                    },
1237                    "result": match c.result {
1238                        0 => "EQUAL",
1239                        1 => "GREATER",
1240                        2 => "LESS",
1241                        3 => "NOT_EQUAL",
1242                        _ => "UNKNOWN",
1243                    },
1244                    "target": match c.target {
1245                        0 => "VERSION",
1246                        1 => "CREATE",
1247                        2 => "MOD",
1248                        3 => "VALUE",
1249                        4 => "LEASE",
1250                        _ => "UNKNOWN",
1251                    },
1252                })
1253            })
1254            .collect();
1255
1256        let event = Event::new(
1257            &ETCD_TXN_REQUEST_EVENT,
1258            serde_json::json!({
1259                "compare_count": request.compare.len(),
1260                "success_count": request.success.len(),
1261                "failure_count": request.failure.len(),
1262                "compares": compares,
1263            }),
1264        );
1265
1266        let execution_result = call_llm(
1267            &llm_client,
1268            &app_state,
1269            server_id,
1270            None,
1271            &event,
1272            protocol.as_ref(),
1273        )
1274        .await
1275        .map_err(|e| llm_failure(&status_tx, "Txn", e))?;
1276
1277        for message in &execution_result.messages {
1278            Log::new(Some(&status_tx)).info(format!("{}", message));
1279        }
1280
1281        if let Some(failure) = take_etcd_error(&execution_result.protocol_results) {
1282            return Err(failure);
1283        }
1284
1285        let mut succeeded = false;
1286        for protocol_result in &execution_result.protocol_results {
1287            if let crate::llm::actions::protocol_trait::ActionResult::Custom { name, data } =
1288                protocol_result
1289            {
1290                if name == "etcd_txn_response" {
1291                    succeeded = data
1292                        .get("succeeded")
1293                        .and_then(|v| v.as_bool())
1294                        .unwrap_or(false);
1295                }
1296            }
1297        }
1298
1299        let mut meta_lock = meta.lock().await;
1300        meta_lock.increment_revision();
1301
1302        let response = TxnResponse {
1303            header: Some(meta_lock.get_response_header()),
1304            succeeded,
1305            responses: vec![],
1306        };
1307
1308        let mut buf = Vec::new();
1309        response.encode(&mut buf)?;
1310        Ok(buf)
1311    }
1312
1313    async fn handle_compact(
1314        msg_bytes: &[u8],
1315        _llm_client: OllamaClient,
1316        _app_state: Arc<AppState>,
1317        status_tx: mpsc::UnboundedSender<String>,
1318        _server_id: crate::state::ServerId,
1319        meta: Arc<Mutex<EtcdMeta>>,
1320        _protocol: Arc<EtcdProtocol>,
1321    ) -> std::result::Result<Vec<u8>, GrpcFailure> {
1322        let request = CompactionRequest::decode(msg_bytes)?;
1323
1324        Log::new(Some(&status_tx)).debug(format!(
1325            "etcd Compact request: revision={}",
1326            request.revision
1327        ));
1328
1329        let meta_lock = meta.lock().await;
1330
1331        let response = CompactionResponse {
1332            header: Some(meta_lock.get_response_header()),
1333        };
1334
1335        let mut buf = Vec::new();
1336        response.encode(&mut buf)?;
1337        Ok(buf)
1338    }
1339}