netget/server/dynamo/mod.rs
1//! DynamoDB-compatible server implementation
2//!
3//! Implements a DynamoDB-compatible HTTP/JSON API on port 8000.
4//! The LLM controls all database operations and maintains "virtual" data through conversation context.
5
6pub mod actions;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full, Limited};
14use hyper::body::{Body, Incoming};
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Request, Response};
18use hyper_util::rt::TokioIo;
19use tokio::sync::mpsc;
20use tracing::{debug, error, info, warn};
21
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ActionResult;
24use crate::logging::emit::Log;
25use crate::server::accept_bounded::{
26 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
27};
28use crate::server::connection::ConnectionId;
29use crate::server::DynamoProtocol;
30use crate::state::app_state::AppState;
31use crate::{console_error, console_info};
32
33/// Largest request body this server will buffer.
34///
35/// The body was read with an unbounded `req.into_body().collect()`, so a single request
36/// could grow the process without limit. DynamoDB caps an item at 400 KiB and a
37/// BatchWriteItem at 16 MiB; 4 MiB covers every single-item operation with headroom, and
38/// the model could not usefully read a larger body anyway.
39pub const MAX_REQUEST_BYTES: usize = 4 * 1024 * 1024;
40
41/// How long a peer that has produced nothing at all may hold this connection.
42///
43/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
44/// DynamoDB client sends it inside its dial path, so a peer that has connected and sent no byte
45/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
46/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
47///
48/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
49/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
50/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
51/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
52/// without consuming it, so the request line is still there for hyper afterwards.
53const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
54
55/// How long an established connection may sit **silent** between requests.
56///
57/// This bounds the silence and never a transfer. A request being answered holds
58/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
59/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
60/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
61/// ever runs on a connection with nothing in flight.
62///
63/// Three minutes. DynamoDB is strictly request/response over a pooled connection and the SDKs'
64/// own per-attempt read timeouts are measured in seconds, so a client still waiting on us has
65/// given up long before this; what is left is a pool holding a connection it is not using. Three
66/// minutes is three times the 60-second idle timeout AWS's own load balancers ship, and an SDK
67/// whose pooled connection was closed opens another, as RFC 9112 requires of it.
68const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(180);
69
70/// Concurrent connections this server admits.
71///
72/// Each admitted connection may hold one whole in-memory JSON response, so the cap is what turns
73/// that per-connection bound into a total one. The AWS SDKs default to 50 connections per
74/// endpoint, so 256 is above even a client deliberately parallelising and far below what socket
75/// exhaustion needs.
76const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
77
78/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
79///
80/// `503 Service Unavailable` with `Retry-After`, in DynamoDB's own vocabulary:
81/// a 503 with `Retry-After` is what every AWS SDK's throttling
82/// path already understands, and a human with `curl` reads it directly.
83/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
84/// reason, under `decision=fail_closed_connection_cap`.
85const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
86 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
87
88/// DynamoDB server that delegates API operations to LLM
89pub struct DynamoServer;
90
91impl DynamoServer {
92 /// Spawn the DynamoDB server with integrated LLM actions
93 pub async fn spawn_with_llm_actions(
94 listen_addr: SocketAddr,
95 llm_client: OllamaClient,
96 app_state: Arc<AppState>,
97 status_tx: mpsc::UnboundedSender<String>,
98 _send_first: bool,
99 server_id: crate::state::ServerId,
100 ) -> anyhow::Result<SocketAddr> {
101 let listener =
102 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
103 let local_addr = listener.local_addr()?;
104 console_info!(status_tx, "DynamoDB server listening on {}", local_addr);
105
106 let protocol = Arc::new(DynamoProtocol::new());
107
108 // Spawn server loop
109 let task_registrar = app_state.clone();
110 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
111 let accept_handle = tokio::spawn(async move {
112 loop {
113 match accept_bounded(
114 &listener,
115 &limiter,
116 CONNECTION_CAP_REFUSAL,
117 "DYNAMODB",
118 Some(&status_tx),
119 )
120 .await
121 {
122 Ok((stream, remote_addr, permit)) => {
123 let connection_id =
124 ConnectionId::new(app_state.get_next_unified_id().await);
125 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
126 info!("DynamoDB connection {} from {}", connection_id, remote_addr);
127 Log::new(Some(&status_tx))
128 .info(format!("DynamoDB connection from {}", remote_addr));
129
130 // Add connection to ServerInstance
131 use crate::state::server::{
132 ConnectionState as ServerConnectionState, ConnectionStatus,
133 ProtocolConnectionInfo,
134 };
135 let now = crate::utils::clock::Instant::now();
136 let conn_state = ServerConnectionState {
137 id: connection_id,
138 remote_addr,
139 local_addr: local_addr_conn,
140 bytes_sent: 0,
141 bytes_received: 0,
142 packets_sent: 0,
143 packets_received: 0,
144 last_activity: now,
145 status: ConnectionStatus::Active,
146 status_changed_at: now,
147 protocol_info: ProtocolConnectionInfo::empty(),
148 };
149 app_state
150 .add_connection_to_server(server_id, conn_state)
151 .await;
152 let _ = status_tx.send("__UPDATE_UI__".to_string());
153
154 let llm_client_clone = llm_client.clone();
155 let app_state_clone = app_state.clone();
156 let status_tx_clone = status_tx.clone();
157 let protocol_clone = protocol.clone();
158
159 // Spawn a task to handle this connection
160 // Tracked, not detached: stop_server must abort this task too.
161 let task_owner = app_state.clone();
162 task_owner
163 .spawn_server_task(server_id, async move {
164 // Held for the life of the connection, so the
165 // cap counts live clients, not accepts.
166 let _permit = permit;
167
168 // The first-byte bound, before hyper sees the
169 // socket. `peek` waits for data without
170 // consuming it, so the request line is still
171 // there for hyper afterwards, and it bounds
172 // exactly the case that needs bounding: a peer
173 // that connected and said nothing at all.
174 let spoke = matches!(
175 tokio::time::timeout(
176 FIRST_BYTE_READ_TIMEOUT,
177 stream.peek(&mut [0u8; 1]),
178 )
179 .await,
180 Ok(Ok(n)) if n > 0
181 );
182
183 if spoke {
184 let io = TokioIo::new(stream);
185
186 // Clone for service closure
187 let status_for_service = status_tx_clone.clone();
188 let app_state_for_service = app_state_clone.clone();
189
190 // Whether this connection is answering
191 // anything. The watchdog below reads it, so
192 // only genuine silence - never work in
193 // flight - can close a connection.
194 let activity =
195 Arc::new(ConnectionActivity::new());
196 let activity_for_service = Arc::clone(&activity);
197 // Create a service that handles DynamoDB requests with LLM
198 let service = service_fn(move |req: Request<Incoming>| {
199 let llm_clone = llm_client_clone.clone();
200 let state_clone = app_state_for_service.clone();
201 let status_clone = status_for_service.clone();
202 let protocol_clone = protocol_clone.clone();
203 // Busy for the whole of this request - the
204 // model round-trip included, and a `manual`
205 // rule parked for a human - so the idle
206 // watchdog can never close the connection an
207 // answer belongs to.
208 let activity = Arc::clone(&activity_for_service);
209 async move {
210 let _busy = activity.busy();
211 handle_dynamo_request_with_llm(
212 req,
213 connection_id,
214 llm_clone,
215 state_clone,
216 status_clone,
217 protocol_clone,
218 server_id,
219 )
220 .await
221 }
222 });
223
224 // Serve HTTP/1 on this connection
225 let conn = http1::Builder::new().serve_connection(io, service);
226 tokio::pin!(conn);
227 tokio::select! {
228 result = &mut conn => {
229 if let Err(err) = result {
230 error!("Error serving DynamoDB connection: {:?}", err);
231 }
232 }
233 // The idle bound, over `ConnectionActivity`
234 // rather than over a read: hyper owns every
235 // read once `serve_connection` starts, and
236 // keeps polling for frames while a request is
237 // being answered, so a deadline on those reads
238 // would fire mid-answer. A connection with work
239 // in flight is not idle at all.
240 _ = watch_idle(
241 Arc::clone(&activity),
242 IDLE_BETWEEN_REQUESTS_TIMEOUT,
243 ) => {
244 Log::new(Some(&status_tx_clone)).debug(format!(
245 "DynamoDB connection {} idle for {}s; closing",
246 connection_id,
247 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
248 ));
249 }
250 }
251 } else {
252 Log::new(Some(&status_tx_clone)).debug(format!(
253 "DynamoDB peer {} sent no request within {}s; closing \
254 decision=fail_closed_first_byte_timeout",
255 remote_addr,
256 FIRST_BYTE_READ_TIMEOUT.as_secs()
257 ));
258 }
259
260 // Mark connection as closed
261 app_state_clone
262 .close_connection_on_server(server_id, connection_id)
263 .await;
264 Log::new(Some(&status_tx_clone))
265 .info(format!("DynamoDB connection {} closed", connection_id));
266 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
267 })
268 .await;
269 }
270 Err(e) => {
271 console_error!(status_tx, "Failed to accept DynamoDB connection: {}", e);
272 break;
273 }
274 }
275 }
276 });
277
278 task_registrar
279 .register_server_task(server_id, accept_handle)
280 .await;
281
282 Ok(local_addr)
283 }
284}
285
286/// Handle a single DynamoDB request with LLM
287/// Handle a single DynamoDB request with LLM, then record what crossed the wire.
288///
289/// The rail's byte counters and the connection-scoped task prompts read
290/// `bytes_received`/`bytes_sent`, and this server left both at the zero it registered the
291/// connection with, so every DynamoDB peer showed no traffic at all however much it moved.
292async fn handle_dynamo_request_with_llm(
293 req: Request<Incoming>,
294 connection_id: ConnectionId,
295 llm_client: OllamaClient,
296 app_state: Arc<AppState>,
297 status_tx: mpsc::UnboundedSender<String>,
298 protocol: Arc<DynamoProtocol>,
299 server_id: crate::state::ServerId,
300) -> Result<Response<Full<Bytes>>, Infallible> {
301 let received = req.body().size_hint().lower();
302 let response = handle_dynamo_request_inner(
303 req,
304 llm_client,
305 app_state.clone(),
306 status_tx.clone(),
307 protocol,
308 server_id,
309 )
310 .await;
311
312 let sent = response
313 .as_ref()
314 .ok()
315 .and_then(|resp| resp.body().size_hint().exact())
316 .unwrap_or(0);
317 app_state
318 .update_connection_stats(
319 server_id,
320 connection_id,
321 Some(received),
322 Some(sent),
323 Some(1),
324 Some(1),
325 )
326 .await;
327 let _ = status_tx.send("__UPDATE_UI__".to_string());
328
329 response
330}
331
332async fn handle_dynamo_request_inner(
333 req: Request<Incoming>,
334 llm_client: OllamaClient,
335 app_state: Arc<AppState>,
336 status_tx: mpsc::UnboundedSender<String>,
337 protocol: Arc<DynamoProtocol>,
338 server_id: crate::state::ServerId,
339) -> Result<Response<Full<Bytes>>, Infallible> {
340 // Extract request details
341 let method = req.method().to_string();
342 let uri = req.uri().to_string();
343
344 // Extract DynamoDB operation from x-amz-target header
345 // Format: "DynamoDB_20120810.GetItem"
346 let operation = req
347 .headers()
348 .get("x-amz-target")
349 .and_then(|v| v.to_str().ok())
350 .and_then(|s| s.split('.').nth(1))
351 .unwrap_or("Unknown")
352 .to_string();
353
354 // Read the JSON body, capped at MAX_REQUEST_BYTES. Refuse rather than continue with an
355 // empty body: an operation whose parameters were truncated away used to reach the model
356 // as a well-formed request with no arguments, and be answered as one. 413
357 // RequestEntityTooLarge is DynamoDB's own error for this.
358 let body_bytes = match Limited::new(req.into_body(), MAX_REQUEST_BYTES)
359 .collect()
360 .await
361 {
362 Ok(collected) => collected.to_bytes(),
363 Err(e) => {
364 warn!(
365 "DynamoDB {} decision=fail_closed_body_rejected (limit {} bytes): {}",
366 operation, MAX_REQUEST_BYTES, e
367 );
368 console_error!(
369 status_tx,
370 "DynamoDB refusing {}: request body exceeds {} bytes",
371 operation,
372 MAX_REQUEST_BYTES
373 );
374 return Ok(build_dynamo_response(
375 413,
376 serde_json::json!({
377 "__type": "RequestEntityTooLarge",
378 "message": "request body rejected",
379 })
380 .to_string(),
381 ));
382 }
383 };
384
385 let body_str = String::from_utf8_lossy(&body_bytes).to_string();
386
387 debug!(
388 "DynamoDB request: {} {} operation={} ({} bytes)",
389 method,
390 uri,
391 operation,
392 body_bytes.len()
393 );
394 Log::new(Some(&status_tx)).debug(format!(
395 "DynamoDB {} operation={} ({} bytes)",
396 method,
397 operation,
398 body_bytes.len()
399 ));
400
401 // Try to extract table name from JSON body
402 let table_name = if !body_str.is_empty() {
403 serde_json::from_str::<serde_json::Value>(&body_str)
404 .ok()
405 .and_then(|v| {
406 v.get("TableName")
407 .and_then(|t| t.as_str())
408 .map(String::from)
409 })
410 } else {
411 None
412 };
413
414 Log::new(Some(&status_tx)).trace(format!("DynamoDB request body: {}", body_str));
415
416 // Create DynamoDB request event
417 let event = crate::protocol::Event::new(
418 &actions::DYNAMO_REQUEST_EVENT,
419 serde_json::json!({
420 "operation": operation,
421 "table_name": table_name,
422 "request_body": body_str,
423 }),
424 );
425
426 let llm_result = crate::llm::action_helper::call_llm(
427 &llm_client,
428 &app_state,
429 server_id,
430 None, // Connection ID not needed for stateless HTTP
431 &event,
432 protocol.as_ref(),
433 )
434 .await;
435
436 // Process action results to build HTTP response
437 match llm_result {
438 Ok(execution_result) => {
439 // Look for DynamoDB-specific response actions
440 for result in execution_result.protocol_results {
441 match result {
442 ActionResult::Custom { name, data } => {
443 if name == "dynamo_response" {
444 let status =
445 data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
446 let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
447
448 // `decision=` is a stable grep target, as in `src/server/radius/`:
449 // an operator has to be able to tell the model answering from
450 // netget failing to reach one.
451 let decision = if (400..=599).contains(&status) {
452 "model_reject"
453 } else {
454 "model_answer"
455 };
456 debug!(
457 "DynamoDB {} decision={} status={}",
458 operation, decision, status
459 );
460 let log = Log::new(Some(&status_tx));
461 log.debug(format!(
462 "DynamoDB {} decision={} → {}",
463 operation, decision, status
464 ));
465 log.trace(format!("DynamoDB response body: {}", body));
466
467 return Ok(build_dynamo_response(status, body.to_string()));
468 }
469 }
470 _ => {
471 // Other actions don't affect HTTP response
472 }
473 }
474 }
475
476 // The handler ran but produced no `dynamo_response` — a model that refused, a static
477 // handler with an empty action list, or an answer whose actions were all
478 // unrecognised.
479 //
480 // This used to answer 200 `{}`, which every AWS SDK reads as a successful call. For
481 // PutItem or DeleteItem an empty body IS the documented success response, so a model
482 // that declined the write was reported to the caller as having performed it; for
483 // GetItem it reads as "no such item", a claim about the data that nothing supports.
484 //
485 // Fail closed with the same AWS error envelope the backend-error arm below uses.
486 warn!(
487 "DynamoDB: no response action produced (decision=fail_closed_no_action); \
488 answering 500 rather than an empty 200"
489 );
490 console_error!(
491 status_tx,
492 "DynamoDB answering 500 InternalServerError: no response action produced \
493 (decision=fail_closed_no_action)"
494 );
495 let error_response = serde_json::json!({
496 "__type": "InternalServerError",
497 "message": crate::utils::WireFailure::Unavailable.prefixed_text(),
498 });
499
500 Ok(build_dynamo_response(500, error_response.to_string()))
501 }
502 Err(e) => {
503 // netget could not reach a decision at all. The peer gets a category, the log
504 // gets the error. The two categories map onto DynamoDB's own retryable and
505 // non-retryable faults so an SDK backs off rather than recording a permanent
506 // failure: `ServiceUnavailable` (503) is retried by every AWS SDK's default
507 // policy, `InternalServerError` (500) is the terminal one. Collapsing both onto
508 // 500, as this used to, makes transient backend saturation look permanent.
509 let failure = crate::utils::WireFailure::classify(&e);
510 warn!(
511 "DynamoDB {} decision=fail_closed_llm_error category={:?}: {}",
512 operation, failure, e
513 );
514 console_error!(
515 status_tx,
516 "DynamoDB {} failing closed ({:?}): {}",
517 operation,
518 failure,
519 e
520 );
521
522 let (status, aws_type) = match failure {
523 crate::utils::WireFailure::Overloaded => (503, "ServiceUnavailable"),
524 crate::utils::WireFailure::Unavailable => (500, "InternalServerError"),
525 };
526
527 let mut response = build_dynamo_response(
528 status,
529 serde_json::json!({
530 "__type": aws_type,
531 "message": failure.text(),
532 })
533 .to_string(),
534 );
535 if failure == crate::utils::WireFailure::Overloaded {
536 response
537 .headers_mut()
538 .insert("Retry-After", hyper::header::HeaderValue::from_static("5"));
539 }
540 Ok(response)
541 }
542 }
543}
544
545/// Build an AWS-JSON response.
546///
547/// `status` originates in model output. `Response::builder().status()` rejects anything
548/// outside 100-999 and the previous `.unwrap()` turned that into a panic, killing the
549/// hyper connection task and leaving the client waiting on a socket that never answers.
550/// `DynamoProtocol::execute_action` already rejects out-of-range values with a message the
551/// model sees; this is the belt-and-braces path.
552fn build_dynamo_response(status: u16, body: String) -> Response<Full<Bytes>> {
553 let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
554 error!(
555 "Invalid DynamoDB status code {}, sending 500 instead",
556 status
557 );
558 hyper::StatusCode::INTERNAL_SERVER_ERROR
559 });
560
561 // Timestamp-derived request id, echoed in x-amzn-RequestId.
562 let request_id = format!(
563 "{:x}",
564 crate::utils::clock::SystemTime::now()
565 .duration_since(crate::utils::clock::UNIX_EPOCH)
566 .map(|d| d.as_nanos())
567 .unwrap_or(0)
568 );
569
570 Response::builder()
571 .status(status)
572 .header("Content-Type", "application/x-amz-json-1.0")
573 .header("x-amzn-RequestId", request_id)
574 .body(Full::new(Bytes::from(body)))
575 .unwrap_or_else(|_| Response::new(Full::new(Bytes::from("{}"))))
576}