Skip to main content

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}