Skip to main content

netget/server/elasticsearch/
mod.rs

1//! Elasticsearch/OpenSearch server implementation
2//!
3//! Implements an Elasticsearch-compatible HTTP/JSON API on port 9200.
4//! The LLM controls search queries, indexing, and maintains "virtual" data through conversation context.
5
6pub mod actions;
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10
11use bytes::Bytes;
12use http_body_util::{BodyExt, Full};
13use hyper::body::Incoming;
14use hyper::server::conn::http1;
15use hyper::service::service_fn;
16use hyper::{Request, Response};
17use hyper_util::rt::TokioIo;
18use tokio::sync::mpsc;
19use tracing::{debug, error, info, warn};
20
21use crate::llm::ollama_client::OllamaClient;
22use crate::llm::ActionResult;
23use crate::logging::emit::Log;
24use crate::server::connection::ConnectionId;
25use crate::server::ElasticsearchProtocol;
26use crate::state::app_state::AppState;
27use crate::{console_error, console_info};
28
29/// How much of a request body is read before the request is refused with 413.
30///
31/// Same value and same reasoning as `http_common::MAX_REQUEST_BODY_BYTES`, defined locally
32/// because `server::http_common` is gated on `any(feature = "http", "http2", "oauth2", …)`
33/// and `elasticsearch` is not in that list — the same exit `xmlrpc` takes. Adding
34/// `elasticsearch` to the gate in `src/server/mod.rs` would let this share the constant.
35pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
36
37/// How long a peer that has connected and produced nothing may hold a slot.
38///
39/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
40/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
41/// nothing. That state carries no protocol yet, which is why this number is the same across
42/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
43/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
44/// two deployed norms.
45///
46/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
47/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
48/// defaulted duration to `None` when no timer is present and applies no deadline at all.
49const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
50
51/// How long a connection with nothing outstanding may sit idle between requests.
52///
53/// nginx's `keepalive_timeout` default. Elasticsearch's official clients — the Java `RestClient`,
54/// `elasticsearch-py` — hold a *pool* of connections for the life of the application, which looks
55/// like an argument for minutes, and is not: a scroll, a `?wait_for_completion` request or a bulk
56/// batch in progress is a request **in flight**, and the watchdog reads
57/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which reports a
58/// connection with work in flight — a model round-trip, or an event a `manual` rule parked for a
59/// human — as not idle at all. What this bound measures is a pooled connection with nothing
60/// outstanding, and reopening one of those costs a loopback handshake.
61const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
62
63/// Concurrent connections this server admits.
64///
65/// Below the shared `DEFAULT_MAX_CONNECTIONS` of 256 on purpose: each admitted connection may
66/// buffer one body of up to [`MAX_REQUEST_BODY_BYTES`] (8 MiB), which is the largest
67/// per-connection cost in netget's HTTP family, and the cap is what turns that per-connection
68/// bound into a total one. 128 holds the worst case to the same ~1 GiB ceiling the 4 MiB and
69/// 64 KiB servers reach at 256, so the number that varies is the one that has to.
70const MAX_CONNECTIONS: usize = 128;
71
72/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
73///
74/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
75/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
76/// error reaches the wire (see `crate::utils::wire_failure`).
77const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
78    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
79
80/// Elasticsearch server that delegates search/index operations to LLM
81pub struct ElasticsearchServer;
82
83impl ElasticsearchServer {
84    /// Spawn the Elasticsearch server with integrated LLM actions
85    pub async fn spawn_with_llm_actions(
86        listen_addr: SocketAddr,
87        llm_client: OllamaClient,
88        app_state: Arc<AppState>,
89        status_tx: mpsc::UnboundedSender<String>,
90        server_id: crate::state::ServerId,
91    ) -> anyhow::Result<SocketAddr> {
92        let listener =
93            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
94        let local_addr = listener.local_addr()?;
95        console_info!(
96            status_tx,
97            "Elasticsearch server listening on {}",
98            local_addr
99        );
100
101        let protocol = Arc::new(ElasticsearchProtocol::new());
102
103        // Spawn server loop
104        let task_registrar = app_state.clone();
105        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
106        let accept_handle = tokio::spawn(async move {
107            loop {
108                match crate::server::accept_bounded::accept_bounded(
109                    &listener,
110                    &limiter,
111                    CONNECTION_CAP_REFUSAL,
112                    "Elasticsearch",
113                    Some(&status_tx),
114                )
115                .await
116                {
117                    Ok((stream, remote_addr, permit)) => {
118                        let connection_id =
119                            ConnectionId::new(app_state.get_next_unified_id().await);
120                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
121                        info!(
122                            "Elasticsearch connection {} from {}",
123                            connection_id, remote_addr
124                        );
125                        Log::new(Some(&status_tx))
126                            .info(format!("Elasticsearch connection from {}", remote_addr));
127
128                        // Add connection to ServerInstance
129                        use crate::state::server::{
130                            ConnectionState as ServerConnectionState, ConnectionStatus,
131                            ProtocolConnectionInfo,
132                        };
133                        let now = crate::utils::clock::Instant::now();
134                        let conn_state = ServerConnectionState {
135                            id: connection_id,
136                            remote_addr,
137                            local_addr: local_addr_conn,
138                            bytes_sent: 0,
139                            bytes_received: 0,
140                            packets_sent: 0,
141                            packets_received: 0,
142                            last_activity: now,
143                            status: ConnectionStatus::Active,
144                            status_changed_at: now,
145                            protocol_info: ProtocolConnectionInfo::empty(),
146                        };
147                        app_state
148                            .add_connection_to_server(server_id, conn_state)
149                            .await;
150                        let _ = status_tx.send("__UPDATE_UI__".to_string());
151
152                        let llm_client_clone = llm_client.clone();
153                        let app_state_clone = app_state.clone();
154                        let status_tx_clone = status_tx.clone();
155                        let protocol_clone = protocol.clone();
156
157                        // Spawn a task to handle this connection
158                        // Tracked, not detached: stop_server must abort this task too.
159                        let task_owner = app_state.clone();
160                        task_owner
161                            .spawn_server_task(server_id, async move {
162                                // Held for the life of the connection, so the cap counts live
163                                // clients rather than accepts.
164                                let _permit = permit;
165
166                                // First-byte bound, before hyper sees the socket.
167                                //
168                                // hyper owns every read once `serve_connection` starts, and a
169                                // deadline on those reads would be wrong here rather than merely
170                                // awkward: hyper keeps polling the connection for more input
171                                // while a request is being answered, so such a deadline would
172                                // fire in the middle of a model round-trip. `peek` waits for data
173                                // without consuming it, so the request line is still there for
174                                // hyper afterwards, and it bounds exactly the case that needs
175                                // bounding - a peer that has connected and sent nothing at all.
176                                let spoke = matches!(
177                                    tokio::time::timeout(
178                                        FIRST_BYTE_READ_TIMEOUT,
179                                        stream.peek(&mut [0u8; 1]),
180                                    )
181                                    .await,
182                                    Ok(Ok(n)) if n > 0
183                                );
184
185                                let io = TokioIo::new(stream);
186
187                                // Clone for service closure
188                                let status_for_service = status_tx_clone.clone();
189                                let app_state_for_service = app_state_clone.clone();
190
191                                // Tracks whether this connection is answering anything. A
192                                // request waiting on the model, or parked for a human by a
193                                // `manual` rule, holds the count above zero, so the idle watchdog
194                                // below cannot close the connection the answer belongs to however
195                                // long it takes - only genuine silence counts.
196                                let activity = std::sync::Arc::new(
197                                    crate::server::accept_bounded::ConnectionActivity::new(),
198                                );
199                                let activity_for_service = std::sync::Arc::clone(&activity);
200
201                                // Create a service that handles Elasticsearch requests with LLM
202                                let service = service_fn(move |req: Request<Incoming>| {
203                                    let llm_clone = llm_client_clone.clone();
204                                    let state_clone = app_state_for_service.clone();
205                                    let status_clone = status_for_service.clone();
206                                    let protocol_clone = protocol_clone.clone();
207                                    let activity = std::sync::Arc::clone(&activity_for_service);
208                                    async move {
209                                        let _busy = activity.busy();
210                                        handle_elasticsearch_request_with_llm(
211                                            req,
212                                            connection_id,
213                                            llm_clone,
214                                            state_clone,
215                                            status_clone,
216                                            protocol_clone,
217                                            server_id,
218                                        )
219                                        .await
220                                    }
221                                });
222
223                                // Serve HTTP/1 on this connection, bounded at both ends.
224                                if !spoke {
225                                    Log::new(Some(&status_tx_clone)).debug(format!(
226                                        "Elasticsearch peer {} sent nothing for {}s; closing before \
227                                         any request",
228                                        remote_addr,
229                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
230                                    ));
231                                } else {
232                                    let conn = http1::Builder::new().serve_connection(io, service);
233                                    tokio::pin!(conn);
234                                    tokio::select! {
235                                        result = &mut conn => {
236                                            if let Err(err) = result {
237                                                error!(
238                                                    "Error serving Elasticsearch \
239                                                     connection: {:?}",
240                                                    err
241                                                );
242                                            }
243                                        }
244                                        _ = crate::server::accept_bounded::watch_idle(
245                                            std::sync::Arc::clone(&activity),
246                                            IDLE_BETWEEN_REQUESTS_TIMEOUT,
247                                        ) => {
248                                            Log::new(Some(&status_tx_clone)).debug(format!(
249                                                "Elasticsearch connection {} idle for {}s; closing",
250                                                connection_id,
251                                                IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
252                                            ));
253                                        }
254                                    }
255                                }
256
257                                // Mark connection as closed
258                                app_state_clone
259                                    .close_connection_on_server(server_id, connection_id)
260                                    .await;
261                                Log::new(Some(&status_tx_clone)).info(format!(
262                                    "Elasticsearch connection {} closed",
263                                    connection_id
264                                ));
265                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
266                            })
267                            .await;
268                    }
269                    Err(e) => {
270                        console_error!(
271                            status_tx,
272                            "Failed to accept Elasticsearch connection: {}",
273                            e
274                        );
275                        break;
276                    }
277                }
278            }
279        });
280
281        task_registrar
282            .register_server_task(server_id, accept_handle)
283            .await;
284
285        Ok(local_addr)
286    }
287}
288
289/// Handle a single Elasticsearch request with LLM
290async fn handle_elasticsearch_request_with_llm(
291    req: Request<Incoming>,
292    _connection_id: ConnectionId,
293    llm_client: OllamaClient,
294    app_state: Arc<AppState>,
295    status_tx: mpsc::UnboundedSender<String>,
296    protocol: Arc<ElasticsearchProtocol>,
297    server_id: crate::state::ServerId,
298) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
299    // Extract request details
300    let method = req.method().to_string();
301    let uri = req.uri().to_string();
302    let path = req.uri().path().to_string();
303
304    // Read the JSON body, bounded.
305    //
306    // `Incoming` has no default limit, so this used to buffer whatever an unauthenticated
307    // peer chose to send — a single `POST /_bulk` was enough — and the body is then embedded
308    // whole in an LLM prompt, so there is no legitimate large one either. `Limited` errors as
309    // soon as the cap is passed rather than after buffering it. Elasticsearch's own answer
310    // to an oversized request is 413 with a `circuit_breaking_exception`-shaped envelope.
311    //
312    // An unreadable body must **not** fall through as empty, which is what the old `Err` arm
313    // did: the handler was then shown a request with no body and answered it as though the
314    // client had sent none, so a truncated bulk index read as an empty one.
315    let body_bytes = match http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES)
316        .collect()
317        .await
318    {
319        Ok(collected) => collected.to_bytes(),
320        Err(e) => {
321            error!(
322                "Elasticsearch {} {}: refusing request body ({}); limit is {} bytes",
323                method, path, e, MAX_REQUEST_BODY_BYTES
324            );
325            console_error!(
326                status_tx,
327                "Elasticsearch {} {} → 413 (request body over {} bytes)",
328                method,
329                path,
330                MAX_REQUEST_BODY_BYTES
331            );
332            let reason = format!(
333                "netget: request body exceeds {} bytes",
334                MAX_REQUEST_BODY_BYTES
335            );
336            let body = serde_json::json!({
337                "error": {
338                    "root_cause": [{"type": "circuit_breaking_exception", "reason": reason}],
339                    "type": "circuit_breaking_exception",
340                    "reason": reason,
341                },
342                "status": 413,
343            })
344            .to_string();
345            return Ok(build_es_response(413, body));
346        }
347    };
348
349    let body_str = String::from_utf8_lossy(&body_bytes).to_string();
350
351    debug!(
352        "Elasticsearch request: {} {} ({} bytes)",
353        method,
354        uri,
355        body_bytes.len()
356    );
357    Log::new(Some(&status_tx)).debug(format!(
358        "Elasticsearch {} {} ({} bytes)",
359        method,
360        path,
361        body_bytes.len()
362    ));
363
364    // Detect operation type from path and method
365    let (operation, index, doc_id) = detect_elasticsearch_operation(&method, &path);
366
367    Log::new(Some(&status_tx)).trace(format!("Elasticsearch request body: {}", body_str));
368
369    // Create Elasticsearch request event
370    let event = crate::protocol::Event::new(
371        &actions::ELASTICSEARCH_REQUEST_EVENT,
372        serde_json::json!({
373            "method": method,
374            "path": path,
375            "operation": operation,
376            "index": index,
377            "doc_id": doc_id,
378            "request_body": body_str,
379        }),
380    );
381
382    let llm_result = crate::llm::action_helper::call_llm(
383        &llm_client,
384        &app_state,
385        server_id,
386        None, // Connection ID not needed for stateless HTTP
387        &event,
388        protocol.as_ref(),
389    )
390    .await;
391
392    // Process action results to build HTTP response
393    match llm_result {
394        Ok(execution_result) => {
395            // Look for Elasticsearch-specific response actions
396            for result in execution_result.protocol_results {
397                match result {
398                    ActionResult::Custom { name, data } => {
399                        if name == "elasticsearch_response" {
400                            let status =
401                                data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
402                            let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
403
404                            debug!("Elasticsearch response: status={}", status);
405                            let log = Log::new(Some(&status_tx));
406                            log.debug(format!("Elasticsearch → {} response", status));
407                            log.trace(format!("Elasticsearch response body: {}", body));
408
409                            return Ok(build_es_response(status, body.to_string()));
410                        }
411                    }
412                    _ => {
413                        // Other actions don't affect HTTP response
414                    }
415                }
416            }
417
418            // The handler ran but produced no Elasticsearch response — a model that refused, a
419            // static handler with an empty action list, or an answer whose actions were all
420            // unrecognised.
421            //
422            // This used to answer 200 `{"acknowledged": true}`. That is the OAuth2 shape: the
423            // single most affirmative body in the Elasticsearch API, returned precisely when
424            // nothing affirmed anything. A client issuing a create-index, a mapping update or a
425            // delete-by-query reads `acknowledged: true` as "the cluster applied it", so a model
426            // that declined the operation was reported as having performed it.
427            //
428            // Fail closed with the same envelope the backend-error arm below builds, so the two
429            // are consistent and neither can be mistaken for a result.
430            warn!(
431                "Elasticsearch: no response action produced (decision=fail_closed_no_action); \
432                 answering 500 rather than acknowledged:true"
433            );
434            console_error!(
435                status_tx,
436                "Elasticsearch answering 500 server_error: no response action produced \
437                 (decision=fail_closed_no_action)"
438            );
439            let reason = crate::utils::WireFailure::Unavailable.prefixed_text();
440            let error_response = serde_json::json!({
441                "error": {
442                    "root_cause": [{
443                        "type": "server_error",
444                        "reason": reason,
445                    }],
446                    "type": "server_error",
447                    "reason": reason,
448                },
449                "status": 500,
450            })
451            .to_string();
452
453            Ok(build_es_response(500, error_response))
454        }
455        Err(e) => {
456            // Elasticsearch's error envelope is what every client parses, and `status` inside
457            // the body must agree with the HTTP status or clients report the wrong thing.
458            //
459            // 503 with `type: "unavailable_shards_exception"` when the backend is merely
460            // saturated: that is the type Elasticsearch itself uses for "come back later", and
461            // clients retry it. 500 `server_error` otherwise. Neither can be read as a search
462            // result - an empty `hits` array with a 200 would say the index contains nothing
463            // matching, which is a statement about the data.
464            let overloaded = crate::llm::is_overload_error(&e);
465            let (status, kind) = if overloaded {
466                (503u16, "unavailable_shards_exception")
467            } else {
468                (500u16, "server_error")
469            };
470            error!(
471                "LLM error for Elasticsearch request (overload={}, status {}): {}",
472                overloaded, status, e
473            );
474            console_error!(
475                status_tx,
476                "Elasticsearch answering {} {}: {}",
477                status,
478                kind,
479                e
480            );
481
482            let reason = crate::utils::WireFailure::classify(&e).prefixed_text();
483            let error_response = serde_json::json!({
484                "error": {
485                    "root_cause": [{
486                        "type": kind,
487                        "reason": reason
488                    }],
489                    "type": kind,
490                    "reason": reason
491                },
492                "status": status
493            })
494            .to_string();
495
496            Ok(build_es_response(status, error_response))
497        }
498    }
499}
500
501/// Build an Elasticsearch JSON response.
502///
503/// `status` originates in model output. `Response::builder().status()` rejects anything
504/// outside 100-999 and the previous `.unwrap()` turned that into a panic, killing the
505/// hyper connection task and leaving the client waiting on a socket that never answers.
506/// `ElasticsearchProtocol::execute_action` already rejects out-of-range values with a
507/// message the model sees; this is the belt-and-braces path.
508fn build_es_response(status: u16, body: String) -> Response<Full<Bytes>> {
509    let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
510        error!(
511            "Invalid Elasticsearch status code {}, sending 500 instead",
512            status
513        );
514        hyper::StatusCode::INTERNAL_SERVER_ERROR
515    });
516
517    Response::builder()
518        .status(status)
519        .header("Content-Type", "application/json; charset=UTF-8")
520        .header("X-elastic-product", "Elasticsearch")
521        .body(Full::new(Bytes::from(body)))
522        .unwrap_or_else(|_| Response::new(Full::new(Bytes::from("{}"))))
523}
524
525/// Detect Elasticsearch operation from HTTP method and path
526fn detect_elasticsearch_operation(
527    method: &str,
528    path: &str,
529) -> (String, Option<String>, Option<String>) {
530    let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
531
532    match (method, parts.as_slice()) {
533        // Root endpoint
534        ("GET", [""]) => ("cluster_info".to_string(), None, None),
535
536        // Search operations
537        ("GET" | "POST", ["_search"]) => ("search".to_string(), None, None),
538        ("GET" | "POST", [index, "_search"]) => {
539            ("search".to_string(), Some(index.to_string()), None)
540        }
541
542        // Document operations
543        ("POST", [index, "_doc"]) | ("PUT", [index, "_doc"]) => {
544            ("index".to_string(), Some(index.to_string()), None)
545        }
546        ("POST" | "PUT", [index, "_doc", id]) | ("PUT", [index, "_create", id]) => (
547            "index".to_string(),
548            Some(index.to_string()),
549            Some(id.to_string()),
550        ),
551        ("GET", [index, "_doc", id]) => (
552            "get".to_string(),
553            Some(index.to_string()),
554            Some(id.to_string()),
555        ),
556        ("DELETE", [index, "_doc", id]) => (
557            "delete".to_string(),
558            Some(index.to_string()),
559            Some(id.to_string()),
560        ),
561
562        // Bulk operations
563        ("POST" | "PUT", ["_bulk"]) => ("bulk".to_string(), None, None),
564        ("POST" | "PUT", [index, "_bulk"]) => ("bulk".to_string(), Some(index.to_string()), None),
565
566        // Index management
567        ("PUT", [index]) if !index.starts_with('_') => {
568            ("create_index".to_string(), Some(index.to_string()), None)
569        }
570        ("DELETE", [index]) if !index.starts_with('_') => {
571            ("delete_index".to_string(), Some(index.to_string()), None)
572        }
573        ("GET", [index]) if !index.starts_with('_') => {
574            ("index_info".to_string(), Some(index.to_string()), None)
575        }
576
577        // Cluster operations
578        ("GET", ["_cluster", "health"]) => ("cluster_health".to_string(), None, None),
579        ("GET", ["_cluster", "stats"]) => ("cluster_stats".to_string(), None, None),
580        ("GET", ["_cat", endpoint]) => (format!("cat_{}", endpoint), None, None),
581
582        // Default
583        _ => ("unknown".to_string(), None, None),
584    }
585}