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}