Skip to main content

mongreldb_server/
lib.rs

1//! mongreldb-server — a long-lived process holding a multi-table `Database`
2//! open, serving SQL + table-qualified native APIs over HTTP.
3//!
4//! Endpoints:
5//!   GET    /health                    → 200 OK
6//!   GET    /tables                    → ["t1", "t2", ...]
7//!   POST   /tables                    → create table
8//!   DELETE /tables/{name}              → drop table
9//!   POST   /tables/{name}/put          → upsert one row
10//!   POST   /tables/{name}/count        → { "count": N }
11//!   POST   /tables/{name}/commit       → { "epoch": N, "epoch_text": "N" }
12//!   POST   /sql                       → Arrow IPC bytes
13//!   POST   /txn                       → exact atomic commit receipt
14//!
15//! Usage: `mongreldb-server <db_dir> [port]`
16
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, Mutex};
19use std::time::Duration;
20
21use axum::extract::{Path, State};
22use axum::http::header;
23use axum::http::StatusCode;
24use axum::response::{IntoResponse, Response};
25use axum::routing::{get, post};
26use axum::Json;
27use mongreldb_core::schema::{Schema, TypeId};
28use mongreldb_core::{CancellationReason, Database, Value};
29use mongreldb_query::{
30    CancelOutcome, CompactFinishedQuery, ExternalTableModule, ManagedQueryBatches, MongrelSession,
31    QueryId, RegisteredQueryGuard, RegisteredSqlQuery, SqlQueryOptions, SqlQueryPhase,
32    SqlQueryRegistry, SqlStreamCompletion,
33};
34use serde::{Deserialize, Serialize};
35use serde_json::json;
36use sha2::Digest;
37use zeroize::Zeroizing;
38
39mod audit;
40mod kit;
41mod metrics;
42mod pre_cancel;
43mod procedure;
44mod sessions;
45mod sql_idempotency;
46mod sql_pages;
47mod trigger;
48
49pub use sessions::{spawn_session_reaper, SessionStore};
50
51fn client_closed_request_status() -> StatusCode {
52    StatusCode::from_u16(499).unwrap_or(StatusCode::BAD_REQUEST)
53}
54
55fn cancellation_checkpoint_error(query: &RegisteredSqlQuery) -> mongreldb_query::MongrelQueryError {
56    query.checkpoint().err().unwrap_or_else(|| {
57        mongreldb_query::MongrelQueryError::InvalidQueryState(
58            "cancellation notification observed without a terminal checkpoint".into(),
59        )
60    })
61}
62
63/// Map an engine error to the appropriate HTTP status code for defense-in-depth.
64/// Auth errors get 401/403; everything else stays 500. This ensures that even
65/// after the HTTP auth middleware lets a request through, the storage layer's
66/// permission checks surface as the right status (not a generic 500).
67fn status_for_error(e: &mongreldb_core::MongrelError) -> StatusCode {
68    use mongreldb_core::MongrelError;
69    match e {
70        MongrelError::AuthRequired | MongrelError::InvalidCredentials { .. } => {
71            StatusCode::UNAUTHORIZED
72        }
73        MongrelError::AuthNotRequired => StatusCode::BAD_REQUEST,
74        MongrelError::PermissionDenied { .. } => StatusCode::FORBIDDEN,
75        MongrelError::InvalidArgument(_) => StatusCode::CONFLICT,
76        MongrelError::Conflict(_) => StatusCode::CONFLICT,
77        MongrelError::ReadOnlyReplica => StatusCode::CONFLICT,
78        MongrelError::NotFound(_) => StatusCode::NOT_FOUND,
79        MongrelError::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
80        MongrelError::WorkBudgetExceeded => StatusCode::TOO_MANY_REQUESTS,
81        MongrelError::Cancelled => client_closed_request_status(),
82        MongrelError::CursorStale(_) => StatusCode::CONFLICT,
83        MongrelError::CursorExpired => StatusCode::GONE,
84        _ => StatusCode::INTERNAL_SERVER_ERROR,
85    }
86}
87
88/// Map a query-layer error (which wraps engine errors via `Core(...)`) to the
89/// appropriate HTTP status code.
90fn status_for_query_error(e: &mongreldb_query::MongrelQueryError) -> StatusCode {
91    use mongreldb_query::MongrelQueryError;
92    match e {
93        MongrelQueryError::Core(core) => status_for_error(core),
94        MongrelQueryError::DeadlineExceeded { .. } => StatusCode::GATEWAY_TIMEOUT,
95        MongrelQueryError::QueryCancelled { .. } => client_closed_request_status(),
96        MongrelQueryError::QueryIdConflict { .. } => StatusCode::CONFLICT,
97        MongrelQueryError::QueryRegistryFull => StatusCode::SERVICE_UNAVAILABLE,
98        MongrelQueryError::ResultLimitExceeded { .. } => StatusCode::PAYLOAD_TOO_LARGE,
99        MongrelQueryError::TransactionAborted => StatusCode::CONFLICT,
100        MongrelQueryError::NoSqlTransaction | MongrelQueryError::SavepointNotFound { .. } => {
101            StatusCode::CONFLICT
102        }
103        MongrelQueryError::CommitOutcome { .. } => StatusCode::CONFLICT,
104        MongrelQueryError::OutcomeUnknown { .. } => StatusCode::CONFLICT,
105        _ => StatusCode::INTERNAL_SERVER_ERROR,
106    }
107}
108
109/// Extractor that pulls the authenticated [`mongreldb_core::Principal`] (if the
110/// auth middleware injected one) from request extensions without erroring when
111/// absent (e.g. token-authenticated requests carry no `Principal`).
112struct OptionalPrincipal(Option<mongreldb_core::Principal>);
113
114impl<S> axum::extract::FromRequestParts<S> for OptionalPrincipal
115where
116    S: Send + Sync,
117{
118    type Rejection = std::convert::Infallible;
119
120    async fn from_request_parts(
121        parts: &mut axum::http::request::Parts,
122        _state: &S,
123    ) -> Result<Self, Self::Rejection> {
124        Ok(OptionalPrincipal(
125            parts.extensions.get::<mongreldb_core::Principal>().cloned(),
126        ))
127    }
128}
129
130struct AppState {
131    db: Arc<Database>,
132    idem: kit::IdempotencyStore,
133    external_modules: Vec<Arc<dyn ExternalTableModule>>,
134    auth_token: Option<String>,
135    /// When true, authenticate via catalog users (HTTP Basic auth).
136    user_auth: bool,
137    /// Daemon-wide Prometheus-style counters, shared by all handlers.
138    metrics: Arc<metrics::Metrics>,
139    /// `/sql` requests slower than this are logged as slow queries.
140    slow_query_threshold: std::time::Duration,
141    /// Bounded security audit log (auth + DDL/privilege events).
142    audit: Arc<audit::AuditLog>,
143    /// Token-keyed pool of live sessions for cross-request interactive
144    /// transactions (`X-Session-ID` on `/sql`).
145    sessions: Arc<sessions::SessionStore>,
146    /// Bounds CPU-heavy AI work submitted to Tokio's blocking pool.
147    ai_semaphore: Arc<tokio::sync::Semaphore>,
148    /// Process-wide SQL registry. Cancellation never takes a session lock.
149    query_registry: Arc<SqlQueryRegistry>,
150    /// Serializes registration with cancel-before-registration bookkeeping.
151    query_lifecycle: Mutex<()>,
152    /// Short-lived, owner/session-bound cancellations received before SQL.
153    pre_cancellations: pre_cancel::PreCancelStore,
154    /// Owner- and request-bound durable SQL write receipts.
155    sql_idempotency: Arc<sql_idempotency::SqlIdempotencyStore>,
156    /// Bounded projected results retained for stable SQL continuation cursors.
157    sql_pages: sql_pages::SqlPageStore,
158    /// Admission control for ordinary SQL, separate from AI workers.
159    sql_semaphore: Arc<tokio::sync::Semaphore>,
160    /// Admission control for retained-page work. Never competes with SQL.
161    sql_page_semaphore: Arc<tokio::sync::Semaphore>,
162    sql_page_default_timeout: std::time::Duration,
163    sql_page_max_timeout: std::time::Duration,
164    sql_default_timeout: std::time::Duration,
165    sql_max_timeout: std::time::Duration,
166    sql_cancel_grace: std::time::Duration,
167    sql_max_output_bytes: usize,
168    sql_max_output_rows: usize,
169    accepting_sql: Arc<AtomicBool>,
170    /// Lazily generated process-local HMAC key. Restart invalidates cursors.
171    cursor_mac_key: CursorMacKey,
172}
173
174#[derive(Default)]
175struct CursorMacKey {
176    key: Mutex<Option<[u8; 32]>>,
177}
178
179impl CursorMacKey {
180    fn get(&self) -> mongreldb_core::Result<[u8; 32]> {
181        let mut key = match self.key.lock() {
182            Ok(key) => key,
183            Err(poisoned) => poisoned.into_inner(),
184        };
185        if let Some(key) = *key {
186            return Ok(key);
187        }
188        let mut generated = [0u8; 32];
189        mongreldb_core::encryption::fill_random(&mut generated)?;
190        *key = Some(generated);
191        Ok(generated)
192    }
193}
194
195/// Handle retained by the daemon to coordinate graceful SQL shutdown without
196/// coupling signal handling to Axum's router internals.
197#[derive(Clone)]
198pub struct ServerControl {
199    query_registry: Arc<SqlQueryRegistry>,
200    sessions: Arc<sessions::SessionStore>,
201    accepting_sql: Arc<AtomicBool>,
202    cancel_grace: std::time::Duration,
203    metrics: Arc<metrics::Metrics>,
204}
205
206impl ServerControl {
207    pub async fn shutdown(&self) -> usize {
208        self.accepting_sql.store(false, Ordering::Release);
209        self.query_registry
210            .cancel_all(CancellationReason::ServerShutdown);
211        let deadline = tokio::time::Instant::now() + self.cancel_grace;
212        while self.query_registry.active_count() > 0 && tokio::time::Instant::now() < deadline {
213            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
214        }
215        self.sessions.close_all();
216        let stuck_queries = self.query_registry.active_statuses();
217        for status in &stuck_queries {
218            eprintln!(
219                "[sql-cancel-stuck] query_id={} phase={}",
220                status.query_id,
221                query_phase_name(status.phase)
222            );
223        }
224        let stuck = stuck_queries.len();
225        self.metrics.add_sql_stuck_after_cancel(stuck);
226        stuck
227    }
228}
229
230pub fn build_app(db: Arc<Database>) -> axum::Router {
231    build_app_with_config(
232        db,
233        std::iter::empty::<Arc<dyn ExternalTableModule>>(),
234        None,
235        None,
236    )
237}
238
239pub fn build_app_with_external_modules(
240    db: Arc<Database>,
241    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
242) -> axum::Router {
243    build_app_with_config(db, external_modules, None, None)
244}
245
246/// Build the daemon router with optional auth token and max-connections limit.
247pub fn build_app_with_config(
248    db: Arc<Database>,
249    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
250    auth_token: Option<String>,
251    max_connections: Option<usize>,
252) -> axum::Router {
253    build_app_full(db, external_modules, auth_token, max_connections, false)
254}
255
256/// Build the daemon router with full auth configuration including user-based auth.
257/// Sessions are enabled with a default capacity (256) and idle timeout (300 s).
258pub fn build_app_full(
259    db: Arc<Database>,
260    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
261    auth_token: Option<String>,
262    max_connections: Option<usize>,
263    user_auth: bool,
264) -> axum::Router {
265    let sessions = Arc::new(sessions::SessionStore::new(
266        default_max_sessions(),
267        default_session_idle_timeout(),
268    ));
269    build_app_with_sessions(
270        db,
271        external_modules,
272        auth_token,
273        max_connections,
274        user_auth,
275        sessions,
276    )
277}
278
279/// Build the daemon router with an explicit, externally-owned session store.
280/// The caller (typically `main`) keeps the `Arc<SessionStore>` so it can spawn
281/// the idle reaper against the same map the handlers use.
282pub fn build_app_with_sessions(
283    db: Arc<Database>,
284    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
285    auth_token: Option<String>,
286    max_connections: Option<usize>,
287    user_auth: bool,
288    sessions: Arc<sessions::SessionStore>,
289) -> axum::Router {
290    build_app_with_sessions_and_control(
291        db,
292        external_modules,
293        auth_token,
294        max_connections,
295        user_auth,
296        sessions,
297    )
298    .0
299}
300
301/// Build the daemon router and return a handle for graceful query shutdown.
302pub fn build_app_with_sessions_and_control(
303    db: Arc<Database>,
304    external_modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
305    auth_token: Option<String>,
306    max_connections: Option<usize>,
307    user_auth: bool,
308    sessions: Arc<sessions::SessionStore>,
309) -> (axum::Router, ServerControl) {
310    db.set_replication_wal_retention_segments(default_replication_wal_segments());
311    if let Err(error) = db.set_history_retention_epochs(default_history_retention_epochs()) {
312        eprintln!("[history] failed to configure retention: {error}");
313    }
314    let max_active_queries = default_sql_max_active_queries();
315    let query_registry = Arc::new(SqlQueryRegistry::new_with_limits(
316        max_active_queries,
317        default_sql_finished_detail_max_entries(),
318        default_sql_finished_detail_max_bytes(),
319        default_sql_finished_compact_max_entries(),
320        default_sql_finished_compact_max_bytes(),
321        default_sql_finished_query_ttl(),
322    ));
323    let accepting_sql = Arc::new(AtomicBool::new(true));
324    let sql_cancel_grace = default_sql_cancel_grace();
325    let sql_max_timeout = default_sql_max_timeout();
326    let sql_default_timeout = default_sql_default_timeout().min(sql_max_timeout);
327    let metrics = Arc::new(metrics::Metrics::default());
328    let (idempotency_root, idempotency_integrity) =
329        match sql_idempotency::IdempotencyIntegrity::for_database(&db) {
330            Ok((root, integrity)) => (root, Some(integrity)),
331            Err(error) => {
332                eprintln!("[idempotency] durable integrity key unavailable: {error}");
333                (db.durable_root(), None)
334            }
335        };
336    let sql_idempotency = Arc::new(sql_idempotency::SqlIdempotencyStore::new_with_integrity(
337        Arc::clone(&idempotency_root),
338        idempotency_integrity.clone(),
339        default_sql_idempotency_ttl(),
340        default_sql_idempotency_max_entries(),
341    ));
342    let server_control = ServerControl {
343        query_registry: Arc::clone(&query_registry),
344        sessions: Arc::clone(&sessions),
345        accepting_sql: Arc::clone(&accepting_sql),
346        cancel_grace: sql_cancel_grace,
347        metrics: Arc::clone(&metrics),
348    };
349    let state = Arc::new(AppState {
350        idem: kit::IdempotencyStore::new_with_integrity(
351            idempotency_root,
352            idempotency_integrity,
353            default_sql_idempotency_ttl(),
354            default_sql_idempotency_max_entries(),
355        ),
356        db,
357        external_modules: external_modules.into_iter().collect(),
358        auth_token,
359        user_auth,
360        metrics,
361        slow_query_threshold: metrics::slow_query_threshold(),
362        audit: Arc::new(audit::AuditLog::new(8192)),
363        sessions,
364        ai_semaphore: Arc::new(tokio::sync::Semaphore::new(default_ai_max_concurrent())),
365        query_registry,
366        query_lifecycle: Mutex::new(()),
367        pre_cancellations: pre_cancel::PreCancelStore::new(
368            default_sql_pre_cancel_ttl(),
369            default_sql_pre_cancel_max_entries(),
370            default_sql_pre_cancel_max_bytes(),
371            default_sql_pre_cancel_max_entries_per_owner(),
372            default_sql_pre_cancel_rate_window(),
373            default_sql_pre_cancel_rate_per_owner(),
374        ),
375        sql_idempotency,
376        sql_pages: sql_pages::SqlPageStore::new(
377            default_sql_page_ttl(),
378            default_sql_page_max_entries(),
379            default_sql_page_max_bytes(),
380            default_sql_page_max_entries_per_owner(),
381        ),
382        sql_semaphore: Arc::new(tokio::sync::Semaphore::new(default_sql_max_concurrent())),
383        sql_page_semaphore: Arc::new(tokio::sync::Semaphore::new(
384            default_sql_page_max_concurrent(),
385        )),
386        sql_page_default_timeout: default_sql_page_default_timeout()
387            .min(default_sql_page_max_timeout()),
388        sql_page_max_timeout: default_sql_page_max_timeout(),
389        sql_default_timeout,
390        sql_max_timeout,
391        sql_cancel_grace,
392        sql_max_output_bytes: default_sql_max_output_bytes(),
393        sql_max_output_rows: default_sql_max_output_rows(),
394        accepting_sql,
395        cursor_mac_key: CursorMacKey::default(),
396    });
397    let router = axum::Router::new()
398        .route("/health", get(health))
399        .route("/build-info", get(build_info))
400        .route("/capabilities", get(capabilities))
401        .route(
402            "/history/retention",
403            get(history_retention).put(set_history_retention),
404        )
405        .route("/metrics", get(metrics_handler))
406        .route("/audit", get(audit_handler))
407        .route("/tables", get(list_tables).post(create_table))
408        .route("/tables/{name}", axum::routing::delete(drop_table))
409        .route("/tables/{name}/put", post(put_row))
410        .route("/tables/{name}/count", get(count))
411        .route("/tables/{name}/commit", post(commit))
412        .route("/sql", post(sql))
413        .route("/sql/continue", post(continue_sql_page))
414        .route("/queries/{query_id}", get(query_status))
415        .route("/queries/{query_id}/cancel", post(cancel_query))
416        .route("/txn", post(txn))
417        .route("/sessions", post(create_session))
418        .route("/sessions/{id}", axum::routing::delete(close_session))
419        .route("/sessions/{id}/prepare", post(prepare_statement))
420        .route("/sessions/{id}/execute", post(execute_statement))
421        .route(
422            "/sessions/{id}/statements/{name}",
423            axum::routing::delete(deallocate_statement),
424        )
425        .route("/procedures", get(procedure::list).post(procedure::create))
426        .route(
427            "/procedures/{name}",
428            get(procedure::describe)
429                .put(procedure::replace)
430                .delete(procedure::drop_procedure),
431        )
432        .route("/procedures/{name}/call", post(procedure::call))
433        .route("/triggers", get(trigger::list).post(trigger::create))
434        .route(
435            "/triggers/{name}",
436            get(trigger::describe)
437                .put(trigger::replace)
438                .delete(trigger::drop_trigger),
439        )
440        // Typed Kit-aware surface (authoritative validation + constraints).
441        .route("/kit/schema", get(kit::schema_all))
442        .route("/kit/schema/{table}", get(kit::schema_one))
443        .route("/kit/txn", post(kit::kit_txn))
444        .route("/kit/query", post(kit::kit_query))
445        .route("/kit/retrieve", post(kit::kit_retrieve))
446        .route("/kit/ann_rerank", post(kit::kit_ann_rerank))
447        .route("/kit/ai/metrics", get(kit::kit_ai_metrics))
448        .route("/kit/set_similarity", post(kit::kit_set_similarity))
449        .route("/kit/search", post(kit::kit_search))
450        .route("/kit/create_table", post(kit::kit_create_table))
451        .route("/kit/procedures/{name}/call", post(procedure::kit_call))
452        .route("/compact", post(compact_all))
453        .route("/tables/{name}/compact", post(compact_table))
454        .route("/wal/stream", get(wal_stream))
455        .route("/replication/snapshot", get(replication_snapshot))
456        .route("/events", get(events_stream))
457        .with_state(state.clone());
458
459    // A credential-enforced database must never expose the authenticated
460    // daemon handle when the caller forgot to configure an HTTP auth mode.
461    // With neither mode enabled the middleware rejects every route.
462    let router = if state.auth_token.is_some() || state.user_auth || state.db.require_auth_enabled()
463    {
464        router.layer(axum::middleware::from_fn_with_state(
465            state.clone(),
466            auth_middleware,
467        ))
468    } else {
469        router
470    };
471
472    // Apply connection limit if configured.
473    let router = if let Some(max) = max_connections {
474        router.layer(tower::limit::ConcurrencyLimitLayer::new(max))
475    } else {
476        router
477    };
478    (router, server_control)
479}
480
481/// Auth middleware supporting three modes:
482/// 1. **Token** (`--auth-token <token>`): checks `Authorization: Bearer <token>`.
483/// 2. **User auth** (`--auth-users`): checks `Authorization: Basic <base64(user:pass)>`
484///    against catalog users (Argon2id-verified). Injects a `Principal` into
485///    request extensions.
486/// 3. **Both**: token OR valid user credentials accepted.
487async fn auth_middleware(
488    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
489    mut req: axum::extract::Request,
490    next: axum::middleware::Next,
491) -> Result<axum::response::Response, axum::http::StatusCode> {
492    let header = req
493        .headers()
494        .get("authorization")
495        .and_then(|v| v.to_str().ok())
496        .unwrap_or("");
497
498    // Track the attempted identity + failure reason so EVERY 401 path emits
499    // exactly one `login.fail` audit event (missing, malformed, wrong token,
500    // wrong password are all logged — no unauthenticated probe goes unrecorded).
501    let mut attempted = String::new();
502    let mut fail_reason = "no credentials provided".to_string();
503
504    // Mode 1: Token auth (Bearer).
505    if let Some(token) = &state.auth_token {
506        if let Some(provided) = header.strip_prefix("Bearer ") {
507            attempted = "token".to_string();
508            if provided == token {
509                state
510                    .audit
511                    .record("token", "login.ok", "bearer token accepted");
512                return Ok(next.run(req).await);
513            }
514            fail_reason = "invalid bearer token".to_string();
515        }
516    }
517
518    // Mode 2: User auth (Basic).
519    if state.user_auth {
520        if let Some(encoded) = header.strip_prefix("Basic ") {
521            if let Ok(decoded) = base64_decode(encoded) {
522                let decoded = Zeroizing::new(decoded);
523                if let Ok(creds) = std::str::from_utf8(&decoded) {
524                    if let Some((username, password)) = creds.split_once(':') {
525                        attempted = username.to_string();
526                        if let Ok(Some(principal)) =
527                            state.db.authenticate_principal(username, password)
528                        {
529                            let username = principal.username.clone();
530                            drop(decoded);
531                            state
532                                .audit
533                                .record(&username, "login.ok", "basic credentials accepted");
534                            req.extensions_mut().insert(principal);
535                            return Ok(next.run(req).await);
536                        }
537                        fail_reason = "invalid basic credentials".to_string();
538                    } else {
539                        fail_reason = "malformed basic credentials (no ':')".to_string();
540                    }
541                } else {
542                    fail_reason = "malformed basic credentials (non-utf8)".to_string();
543                }
544            } else {
545                fail_reason = "malformed basic credentials (bad base64)".to_string();
546            }
547        }
548    }
549
550    let who = if attempted.is_empty() {
551        "anonymous"
552    } else {
553        attempted.as_str()
554    };
555    state.audit.record(who, "login.fail", fail_reason);
556    Err(axum::http::StatusCode::UNAUTHORIZED)
557}
558
559/// Minimal Base64 decoder (no extra dep).
560fn base64_decode(input: &str) -> Result<Vec<u8>, ()> {
561    const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
562    let input: Vec<u8> = input
563        .bytes()
564        .filter(|&b| b != b'\n' && b != b'\r' && b != b' ')
565        .collect();
566    let mut out = Vec::with_capacity(input.len() * 3 / 4);
567    let mut buf = 0u32;
568    let mut bits = 0u32;
569    for &b in &input {
570        if b == b'=' {
571            break;
572        }
573        let val = TABLE.iter().position(|&t| t == b).ok_or(())? as u32;
574        buf = (buf << 6) | val;
575        bits += 6;
576        if bits >= 8 {
577            bits -= 8;
578            out.push((buf >> bits) as u8);
579        }
580    }
581    Ok(out)
582}
583
584/// `GET /wal/stream?since=<epoch>` — return complete committed WAL
585/// transactions after the follower epoch. A 409 response means retained WAL
586/// cannot close the gap and the follower must fetch `/replication/snapshot`.
587/// Records are newline-delimited JSON.
588/// newline-delimited JSON for replication followers. Each line is a JSON
589/// object `{ "seq": N, "txn_id": N, "op": {...} }`.
590async fn wal_stream(
591    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
592    OptionalPrincipal(principal): OptionalPrincipal,
593    axum::extract::Query(params): axum::extract::Query<WalStreamParams>,
594) -> Result<Response, StatusCode> {
595    state
596        .db
597        .require_for(
598            request_principal(&state, &principal).as_ref(),
599            &mongreldb_core::Permission::Admin,
600        )
601        .map_err(|error| status_for_error(&error))?;
602    let since = params.since.unwrap_or(0);
603    let db = Arc::clone(&state.db);
604    let batch = tokio::task::spawn_blocking(move || db.replication_batch_since(since))
605        .await
606        .map_err(|_e| StatusCode::INTERNAL_SERVER_ERROR)?;
607    let batch = batch.map_err(|e| {
608        eprintln!("wal_stream error: {e}");
609        StatusCode::INTERNAL_SERVER_ERROR
610    })?;
611    if batch.requires_snapshot {
612        let mut response = (
613            StatusCode::CONFLICT,
614            "replication snapshot required: WAL retention gap or spilled run",
615        )
616            .into_response();
617        response.headers_mut().insert(
618            "x-mongreldb-replication-status",
619            "snapshot-required"
620                .parse()
621                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
622        );
623        set_replication_headers(
624            &mut response,
625            &batch.source_id,
626            batch.from_epoch,
627            batch.current_epoch,
628            batch.earliest_epoch,
629            batch.commit_count,
630            &batch.records_sha256,
631        )?;
632        return Ok(response);
633    }
634    let mut body = String::new();
635    for record in &batch.records {
636        let json = serde_json::to_string(record).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
637        body.push_str(&json);
638        body.push('\n');
639    }
640    let mut response = (
641        [
642            (header::CONTENT_TYPE, "application/x-ndjson".to_string()),
643            (header::CACHE_CONTROL, "no-cache".to_string()),
644        ],
645        body,
646    )
647        .into_response();
648    set_replication_headers(
649        &mut response,
650        &batch.source_id,
651        batch.from_epoch,
652        batch.current_epoch,
653        batch.earliest_epoch,
654        batch.commit_count,
655        &batch.records_sha256,
656    )?;
657    Ok(response)
658}
659
660async fn replication_snapshot(
661    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
662    OptionalPrincipal(principal): OptionalPrincipal,
663) -> Response {
664    if let Err(error) = state.db.require_for(
665        request_principal(&state, &principal).as_ref(),
666        &mongreldb_core::Permission::Admin,
667    ) {
668        return (status_for_error(&error), error.to_string()).into_response();
669    }
670    let db = Arc::clone(&state.db);
671    let snapshot = match tokio::task::spawn_blocking(move || db.replication_snapshot()).await {
672        Ok(Ok(snapshot)) => snapshot,
673        Ok(Err(error)) => {
674            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
675        }
676        Err(error) => {
677            return (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response()
678        }
679    };
680    let epoch = snapshot.epoch();
681    // ponytail: bootstrap buffers one image; add framed file streaming when
682    // real snapshot sizes make this memory ceiling measurable.
683    match snapshot.encode() {
684        Ok(bytes) => {
685            let mut response =
686                ([(header::CONTENT_TYPE, "application/octet-stream")], bytes).into_response();
687            let Ok(value) = epoch.to_string().parse() else {
688                return (
689                    StatusCode::INTERNAL_SERVER_ERROR,
690                    "invalid replication epoch response header",
691                )
692                    .into_response();
693            };
694            response
695                .headers_mut()
696                .insert("x-mongreldb-current-epoch", value);
697            let source_id = snapshot
698                .source_id()
699                .iter()
700                .map(|byte| format!("{byte:02x}"))
701                .collect::<String>();
702            let Ok(source_id) = source_id.parse() else {
703                return (
704                    StatusCode::INTERNAL_SERVER_ERROR,
705                    "invalid replication source response header",
706                )
707                    .into_response();
708            };
709            response
710                .headers_mut()
711                .insert("x-mongreldb-source-id", source_id);
712            response
713        }
714        Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(),
715    }
716}
717
718fn set_replication_headers(
719    response: &mut Response,
720    source_id: &[u8; 32],
721    from: u64,
722    current: u64,
723    earliest: Option<u64>,
724    commit_count: u64,
725    records_sha256: &[u8; 32],
726) -> Result<(), StatusCode> {
727    let source_id = source_id
728        .iter()
729        .map(|byte| format!("{byte:02x}"))
730        .collect::<String>();
731    response.headers_mut().insert(
732        "x-mongreldb-source-id",
733        source_id
734            .parse()
735            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
736    );
737    response.headers_mut().insert(
738        "x-mongreldb-from-epoch",
739        from.to_string()
740            .parse()
741            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
742    );
743    response.headers_mut().insert(
744        "x-mongreldb-current-epoch",
745        current
746            .to_string()
747            .parse()
748            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
749    );
750    response.headers_mut().insert(
751        "x-mongreldb-commit-count",
752        commit_count
753            .to_string()
754            .parse()
755            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
756    );
757    let digest = records_sha256
758        .iter()
759        .map(|byte| format!("{byte:02x}"))
760        .collect::<String>();
761    response.headers_mut().insert(
762        "x-mongreldb-records-sha256",
763        digest
764            .parse()
765            .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
766    );
767    if let Some(earliest) = earliest {
768        response.headers_mut().insert(
769            "x-mongreldb-earliest-epoch",
770            earliest
771                .to_string()
772                .parse()
773                .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
774        );
775    }
776    Ok(())
777}
778
779#[derive(serde::Deserialize)]
780struct WalStreamParams {
781    since: Option<u64>,
782}
783
784/// `GET /events` — long-lived SSE for durable WAL-backed row changes plus
785/// ephemeral SQL NOTIFY messages. `Last-Event-ID` resumes from a stable
786/// `<commit_epoch>:<operation_index>` id. A retention gap returns 409 before
787/// the stream starts, or a terminal `gap` SSE if the client falls behind.
788async fn events_stream(
789    axum::extract::State(state): axum::extract::State<Arc<AppState>>,
790    OptionalPrincipal(principal): OptionalPrincipal,
791    headers: axum::http::HeaderMap,
792) -> Result<Response, StatusCode> {
793    use axum::response::sse::{Event, KeepAlive, Sse};
794    use futures::Stream;
795    use std::collections::VecDeque;
796    use std::convert::Infallible;
797
798    state
799        .db
800        .require_for(
801            request_principal(&state, &principal).as_ref(),
802            &mongreldb_core::Permission::Admin,
803        )
804        .map_err(|error| status_for_error(&error))?;
805
806    struct State {
807        db: Arc<Database>,
808        receiver: tokio::sync::broadcast::Receiver<mongreldb_core::ChangeEvent>,
809        change_wake: tokio::sync::broadcast::Receiver<()>,
810        interval: tokio::time::Interval,
811        pending: VecDeque<mongreldb_core::ChangeEvent>,
812        last_id: Option<String>,
813        poll_now: bool,
814        done: bool,
815    }
816
817    fn event(change: mongreldb_core::ChangeEvent) -> Event {
818        let id = change.id.clone();
819        let kind = if change.op == "notify" {
820            "notify"
821        } else {
822            "change"
823        };
824        let mut event = Event::default().event(kind).data(
825            serde_json::to_string(&change)
826                .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#)),
827        );
828        if let Some(id) = id {
829            event = event.id(id);
830        }
831        event
832    }
833
834    let last_id = match headers.get("last-event-id") {
835        Some(value) => Some(
836            value
837                .to_str()
838                .map_err(|_| StatusCode::BAD_REQUEST)?
839                .to_owned(),
840        ),
841        None => None,
842    };
843    let receiver = state.db.subscribe_changes();
844    let change_wake = state.db.subscribe_change_commits();
845    let db = Arc::clone(&state.db);
846    let resume = last_id.clone();
847    let initial = tokio::task::spawn_blocking(move || db.change_events_since(resume.as_deref()))
848        .await
849        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
850        .map_err(|error| match error {
851            mongreldb_core::MongrelError::InvalidArgument(_) => StatusCode::BAD_REQUEST,
852            _ => StatusCode::INTERNAL_SERVER_ERROR,
853        })?;
854    if initial.gap {
855        return Ok((
856            StatusCode::CONFLICT,
857            Json(json!({
858                "error": "cdc retention gap",
859                "earliest_epoch": initial.earliest_epoch,
860                "current_epoch": initial.current_epoch,
861            })),
862        )
863            .into_response());
864    }
865
866    let stream: std::pin::Pin<Box<dyn Stream<Item = Result<Event, Infallible>> + Send>> = Box::pin(
867        futures::stream::unfold(
868            State {
869                db: Arc::clone(&state.db),
870                receiver,
871                change_wake,
872                interval: tokio::time::interval(std::time::Duration::from_millis(250)),
873                pending: initial.events.into(),
874                last_id,
875                poll_now: false,
876                done: false,
877            },
878            |mut stream| async move {
879                if stream.done {
880                    return None;
881                }
882                loop {
883                    if stream.poll_now {
884                        stream.poll_now = false;
885                        let db = Arc::clone(&stream.db);
886                        let last_id = stream.last_id.clone();
887                        match tokio::task::spawn_blocking(move || {
888                            db.change_events_since(last_id.as_deref())
889                        })
890                        .await
891                        {
892                            Ok(Ok(batch)) if batch.gap => {
893                                stream.done = true;
894                                let gap = Event::default().event("gap").data(
895                                    json!({
896                                        "error": "cdc retention gap",
897                                        "earliest_epoch": batch.earliest_epoch,
898                                        "current_epoch": batch.current_epoch,
899                                    })
900                                    .to_string(),
901                                );
902                                return Some((Ok(gap), stream));
903                            }
904                            Ok(Ok(batch)) => stream.pending.extend(batch.events),
905                            Ok(Err(error)) => {
906                                stream.done = true;
907                                return Some((
908                                    Ok(Event::default().event("error").data(error.to_string())),
909                                    stream,
910                                ));
911                            }
912                            Err(error) => {
913                                stream.done = true;
914                                return Some((
915                                    Ok(Event::default().event("error").data(error.to_string())),
916                                    stream,
917                                ));
918                            }
919                        }
920                    }
921                    if let Some(change) = stream.pending.pop_front() {
922                        if let Some(id) = &change.id {
923                            stream.last_id = Some(id.clone());
924                        }
925                        return Some((Ok(event(change)), stream));
926                    }
927                    tokio::select! {
928                        received = stream.receiver.recv() => {
929                            match received {
930                                Ok(change) => return Some((Ok(event(change)), stream)),
931                                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {},
932                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {
933                                    return None;
934                                }
935                            }
936                        }
937                        received = stream.change_wake.recv() => {
938                            match received {
939                                Ok(()) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
940                                    stream.poll_now = true;
941                                }
942                                Err(tokio::sync::broadcast::error::RecvError::Closed) => {}
943                            }
944                        }
945                        _ = stream.interval.tick() => {
946                            stream.poll_now = true;
947                        }
948                    }
949                }
950            },
951        ),
952    );
953
954    Ok(Sse::new(stream)
955        .keep_alive(
956            KeepAlive::new()
957                .interval(std::time::Duration::from_secs(15))
958                .text("keep-alive"),
959        )
960        .into_response())
961}
962
963/// Launch the §5.9 background auto-compaction sweep (run-count cost trigger).
964/// One OS thread, sleeping `interval` between sweeps; each tick locks each
965/// table individually and calls `Table::maybe_compact`. Best-effort: a
966/// compaction error is logged and never aborts the sweep.
967pub fn spawn_auto_compactor(db: Arc<Database>) {
968    if let Err(error) = std::thread::Builder::new()
969        .name("mongreldb-auto-compact".into())
970        .spawn(move || loop {
971            std::thread::sleep(std::time::Duration::from_secs(30));
972            for name in db.table_names() {
973                let Ok(handle) = db.table(&name) else {
974                    continue;
975                };
976                let mut t = handle.lock();
977                let before = t.run_count();
978                match t.maybe_compact() {
979                    Ok(true) => {
980                        eprintln!(
981                            "[auto-compact] {name}: {} runs -> {}",
982                            before,
983                            t.run_count()
984                        );
985                    }
986                    Ok(false) => {}
987                    Err(e) => {
988                        eprintln!("[auto-compact] {name}: compaction failed: {e}");
989                    }
990                }
991            }
992        })
993    {
994        eprintln!("[auto-compact] failed to start background thread: {error}");
995    }
996}
997
998async fn health() -> StatusCode {
999    StatusCode::OK
1000}
1001
1002#[derive(Debug, Serialize)]
1003struct SqlCancellationCapabilities {
1004    version: u8,
1005    client_query_ids: bool,
1006    cancel_endpoint: bool,
1007    query_status: bool,
1008    pre_registration_cancel: bool,
1009    stream_disconnect_cancels: bool,
1010}
1011
1012#[derive(Debug, Serialize)]
1013struct SqlIdempotencyCapabilities {
1014    version: u8,
1015    durable_pre_execution_intent: bool,
1016    replay_committed_receipt: bool,
1017    indeterminate_never_reexecutes: bool,
1018}
1019
1020#[derive(Debug, Serialize)]
1021struct SqlPaginationCapabilities {
1022    version: u8,
1023    continuation_endpoint: &'static str,
1024    retained_snapshot: bool,
1025    projection_required: bool,
1026    byte_and_token_hints: bool,
1027}
1028
1029#[derive(Debug, Serialize)]
1030struct CapabilitiesResponse {
1031    sql_cancellation: SqlCancellationCapabilities,
1032    sql_idempotency: SqlIdempotencyCapabilities,
1033    sql_pagination: SqlPaginationCapabilities,
1034}
1035
1036async fn capabilities() -> Json<CapabilitiesResponse> {
1037    Json(CapabilitiesResponse {
1038        sql_cancellation: SqlCancellationCapabilities {
1039            version: 2,
1040            client_query_ids: true,
1041            cancel_endpoint: true,
1042            query_status: true,
1043            pre_registration_cancel: true,
1044            stream_disconnect_cancels: true,
1045        },
1046        sql_idempotency: SqlIdempotencyCapabilities {
1047            version: 1,
1048            durable_pre_execution_intent: true,
1049            replay_committed_receipt: true,
1050            indeterminate_never_reexecutes: true,
1051        },
1052        sql_pagination: SqlPaginationCapabilities {
1053            version: 1,
1054            continuation_endpoint: "/sql/continue",
1055            retained_snapshot: true,
1056            projection_required: true,
1057            byte_and_token_hints: true,
1058        },
1059    })
1060}
1061
1062async fn build_info() -> Json<mongreldb_query::BuildInfo> {
1063    Json(mongreldb_query::build_info())
1064}
1065
1066#[derive(Debug, Deserialize)]
1067struct HistoryRetentionRequest {
1068    #[serde(default)]
1069    history_retention_epochs: serde_json::Value,
1070}
1071
1072#[derive(Debug, Serialize)]
1073struct HistoryRetentionResponse {
1074    history_retention_epochs: u64,
1075    earliest_retained_epoch: u64,
1076}
1077
1078fn history_retention_response(db: &Database) -> HistoryRetentionResponse {
1079    HistoryRetentionResponse {
1080        history_retention_epochs: db.history_retention_epochs(),
1081        earliest_retained_epoch: db.earliest_retained_epoch().0,
1082    }
1083}
1084
1085/// `GET /history/retention` — inspect the durable MVCC history window.
1086async fn history_retention(
1087    State(state): State<Arc<AppState>>,
1088    OptionalPrincipal(principal): OptionalPrincipal,
1089) -> Response {
1090    if let Err(error) = state.db.require_for(
1091        request_principal(&state, &principal).as_ref(),
1092        &mongreldb_core::Permission::Admin,
1093    ) {
1094        return (status_for_error(&error), error.to_string()).into_response();
1095    }
1096    Json(history_retention_response(&state.db)).into_response()
1097}
1098
1099/// `PUT /history/retention` — set the durable MVCC history window.
1100async fn set_history_retention(
1101    State(state): State<Arc<AppState>>,
1102    OptionalPrincipal(principal): OptionalPrincipal,
1103    Json(request): Json<HistoryRetentionRequest>,
1104) -> Response {
1105    if let Err(error) = state.db.require_for(
1106        request_principal(&state, &principal).as_ref(),
1107        &mongreldb_core::Permission::Admin,
1108    ) {
1109        return (status_for_error(&error), error.to_string()).into_response();
1110    }
1111    let Some(epochs) = request.history_retention_epochs.as_u64() else {
1112        return (
1113            StatusCode::BAD_REQUEST,
1114            Json(json!({"error": "history_retention_epochs must be a u64"})),
1115        )
1116            .into_response();
1117    };
1118    match state.db.set_history_retention_epochs(epochs) {
1119        Ok(()) => Json(history_retention_response(&state.db)).into_response(),
1120        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
1121    }
1122}
1123
1124/// `GET /audit` — recent security-audit events (auth + DDL/privilege) as a JSON
1125/// array, oldest-first. Subject to the same auth middleware as every other
1126/// route. This is a best-effort in-memory ring buffer, not a tamper-evident
1127/// log (see `audit` module docs).
1128async fn audit_handler(
1129    State(state): State<Arc<AppState>>,
1130    OptionalPrincipal(principal): OptionalPrincipal,
1131) -> Response {
1132    if let Err(error) = state.db.require_for(
1133        request_principal(&state, &principal).as_ref(),
1134        &mongreldb_core::Permission::Admin,
1135    ) {
1136        return (status_for_error(&error), error.to_string()).into_response();
1137    }
1138    let recent = state.audit.recent();
1139    Json(recent).into_response()
1140}
1141
1142/// Default max live sessions when `--max-sessions` is not given.
1143fn default_max_sessions() -> usize {
1144    std::env::var("MONGRELBL_MAX_SESSIONS")
1145        .ok()
1146        .and_then(|v| v.parse().ok())
1147        .unwrap_or(256)
1148}
1149
1150/// Default idle-session timeout when `--session-idle-timeout` is not given.
1151fn default_session_idle_timeout() -> std::time::Duration {
1152    std::time::Duration::from_secs(
1153        std::env::var("MONGRELBL_SESSION_IDLE_TIMEOUT_SECS")
1154            .ok()
1155            .and_then(|v| v.parse().ok())
1156            .unwrap_or(300),
1157    )
1158}
1159
1160fn default_replication_wal_segments() -> usize {
1161    let replication = std::env::var("MONGRELDB_REPLICATION_WAL_SEGMENTS")
1162        .ok()
1163        .and_then(|value| value.parse().ok())
1164        .unwrap_or(16);
1165    let cdc = std::env::var("MONGRELDB_CDC_WAL_SEGMENTS")
1166        .ok()
1167        .and_then(|value| value.parse().ok())
1168        .unwrap_or(16);
1169    replication.max(cdc)
1170}
1171
1172fn default_history_retention_epochs() -> u64 {
1173    std::env::var("MONGRELDB_HISTORY_RETENTION_EPOCHS")
1174        .ok()
1175        .and_then(|value| value.parse().ok())
1176        .unwrap_or(1024)
1177}
1178
1179fn default_ai_max_concurrent() -> usize {
1180    std::env::var("MONGRELDB_AI_MAX_CONCURRENT")
1181        .ok()
1182        .and_then(|value| value.parse().ok())
1183        .filter(|value| *value > 0)
1184        .unwrap_or(4)
1185}
1186
1187fn positive_env_u64(name: &str, default: u64) -> u64 {
1188    std::env::var(name)
1189        .ok()
1190        .and_then(|value| value.parse().ok())
1191        .filter(|value| *value > 0)
1192        .unwrap_or(default)
1193}
1194
1195fn positive_env_usize(name: &str, default: usize) -> usize {
1196    std::env::var(name)
1197        .ok()
1198        .and_then(|value| value.parse().ok())
1199        .filter(|value| *value > 0)
1200        .unwrap_or(default)
1201}
1202
1203fn default_sql_default_timeout() -> std::time::Duration {
1204    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_DEFAULT_TIMEOUT_MS", 30_000))
1205}
1206
1207fn default_sql_max_timeout() -> std::time::Duration {
1208    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_MAX_TIMEOUT_MS", 300_000))
1209}
1210
1211fn default_sql_max_concurrent() -> usize {
1212    positive_env_usize(
1213        "MONGRELDB_SQL_MAX_CONCURRENT",
1214        std::thread::available_parallelism()
1215            .map(usize::from)
1216            .unwrap_or(4),
1217    )
1218}
1219
1220fn default_sql_max_active_queries() -> usize {
1221    positive_env_usize("MONGRELDB_SQL_MAX_ACTIVE_QUERIES", 1_024)
1222}
1223
1224fn default_sql_page_max_concurrent() -> usize {
1225    positive_env_usize("MONGRELDB_SQL_PAGE_MAX_CONCURRENT", 16)
1226}
1227
1228fn default_sql_page_default_timeout() -> std::time::Duration {
1229    std::time::Duration::from_millis(positive_env_u64(
1230        "MONGRELDB_SQL_PAGE_DEFAULT_TIMEOUT_MS",
1231        5_000,
1232    ))
1233}
1234
1235fn default_sql_page_max_timeout() -> std::time::Duration {
1236    std::time::Duration::from_millis(positive_env_u64(
1237        "MONGRELDB_SQL_PAGE_MAX_TIMEOUT_MS",
1238        30_000,
1239    ))
1240}
1241
1242fn default_sql_finished_query_ttl() -> std::time::Duration {
1243    std::time::Duration::from_secs(positive_env_u64(
1244        "MONGRELDB_SQL_FINISHED_QUERY_TTL_SECS",
1245        60,
1246    ))
1247}
1248
1249fn default_sql_finished_detail_max_entries() -> usize {
1250    positive_env_usize("MONGRELDB_SQL_FINISHED_DETAIL_MAX_ENTRIES", 2_048)
1251}
1252
1253fn default_sql_finished_detail_max_bytes() -> usize {
1254    positive_env_usize("MONGRELDB_SQL_FINISHED_DETAIL_MAX_BYTES", 8 * 1024 * 1024)
1255}
1256
1257fn default_sql_finished_compact_max_entries() -> usize {
1258    positive_env_usize("MONGRELDB_SQL_FINISHED_COMPACT_MAX_ENTRIES", 100_000)
1259}
1260
1261fn default_sql_finished_compact_max_bytes() -> usize {
1262    positive_env_usize("MONGRELDB_SQL_FINISHED_COMPACT_MAX_BYTES", 32 * 1024 * 1024)
1263}
1264
1265fn default_sql_pre_cancel_ttl() -> std::time::Duration {
1266    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_PRE_CANCEL_TTL_MS", 15_000))
1267}
1268
1269fn default_sql_pre_cancel_max_entries() -> usize {
1270    positive_env_usize("MONGRELDB_SQL_PRE_CANCEL_MAX_ENTRIES", 2_048)
1271}
1272
1273fn default_sql_pre_cancel_max_bytes() -> usize {
1274    positive_env_usize("MONGRELDB_SQL_PRE_CANCEL_MAX_BYTES", 1024 * 1024)
1275}
1276
1277fn default_sql_pre_cancel_max_entries_per_owner() -> usize {
1278    positive_env_usize("MONGRELDB_SQL_PRE_CANCEL_MAX_PER_OWNER", 256)
1279}
1280
1281fn default_sql_pre_cancel_rate_window() -> std::time::Duration {
1282    std::time::Duration::from_millis(positive_env_u64(
1283        "MONGRELDB_SQL_PRE_CANCEL_RATE_WINDOW_MS",
1284        1_000,
1285    ))
1286}
1287
1288fn default_sql_pre_cancel_rate_per_owner() -> usize {
1289    positive_env_usize("MONGRELDB_SQL_PRE_CANCEL_RATE_PER_OWNER", 256)
1290}
1291
1292pub(crate) fn default_sql_idempotency_ttl() -> std::time::Duration {
1293    std::time::Duration::from_secs(positive_env_u64(
1294        "MONGRELDB_SQL_IDEMPOTENCY_TTL_SECS",
1295        86_400,
1296    ))
1297}
1298
1299pub(crate) fn default_sql_idempotency_max_entries() -> usize {
1300    positive_env_usize("MONGRELDB_SQL_IDEMPOTENCY_MAX_ENTRIES", 4_096)
1301}
1302
1303fn default_sql_page_ttl() -> std::time::Duration {
1304    std::time::Duration::from_secs(positive_env_u64("MONGRELDB_SQL_PAGE_TTL_SECS", 60))
1305}
1306
1307fn default_sql_page_max_entries() -> usize {
1308    positive_env_usize("MONGRELDB_SQL_PAGE_MAX_ENTRIES", 128)
1309}
1310
1311fn default_sql_page_max_bytes() -> usize {
1312    positive_env_usize("MONGRELDB_SQL_PAGE_MAX_RETAINED_BYTES", 128 * 1024 * 1024)
1313}
1314
1315fn default_sql_page_max_entries_per_owner() -> usize {
1316    positive_env_usize("MONGRELDB_SQL_PAGE_MAX_PER_OWNER", 16)
1317}
1318
1319fn default_sql_cancel_grace() -> std::time::Duration {
1320    std::time::Duration::from_millis(positive_env_u64("MONGRELDB_SQL_CANCEL_GRACE_MS", 1_000))
1321}
1322
1323fn default_sql_max_output_bytes() -> usize {
1324    positive_env_usize("MONGRELDB_SQL_MAX_OUTPUT_BYTES", 64 * 1024 * 1024)
1325}
1326
1327fn default_sql_max_output_rows() -> usize {
1328    positive_env_usize("MONGRELDB_SQL_MAX_OUTPUT_ROWS", 1_000_000)
1329}
1330
1331/// Stable request ownership. Usernames and the literal bearer token are never
1332/// ownership keys, so replacement credentials cannot inherit live resources.
1333fn request_owner(state: &AppState, principal: &Option<mongreldb_core::Principal>) -> String {
1334    if let Some(p) = principal {
1335        return format!("user:{}:{}", p.user_id, p.created_epoch);
1336    }
1337    if let Some(token) = state.auth_token.as_deref() {
1338        let mut digest = sha2::Sha256::new();
1339        digest.update(b"mongreldb-server-bearer-owner-v1\0");
1340        digest.update((token.len() as u64).to_le_bytes());
1341        digest.update(token.as_bytes());
1342        return format!("bearer:{}", sql_idempotency::hex(&digest.finalize()));
1343    }
1344    if state.user_auth || state.db.require_auth_enabled() {
1345        return "unauthenticated".into();
1346    }
1347    "anonymous".into()
1348}
1349
1350fn request_principal(
1351    state: &AppState,
1352    principal: &Option<mongreldb_core::Principal>,
1353) -> Option<mongreldb_core::Principal> {
1354    if let Some(principal) = principal {
1355        return state
1356            .db
1357            .resolve_current_principal(principal)
1358            .or_else(|| Some(principal.clone()));
1359    }
1360    state.auth_token.as_ref().and_then(|_| {
1361        if state.db.require_auth_enabled() {
1362            return state
1363                .db
1364                .principal_snapshot()
1365                .and_then(|principal| state.db.resolve_current_principal(&principal))
1366                .filter(|principal| principal.is_admin);
1367        }
1368        Some(mongreldb_core::Principal {
1369            user_id: 0,
1370            created_epoch: 0,
1371            username: "token".into(),
1372            is_admin: true,
1373            roles: Vec::new(),
1374            permissions: Vec::new(),
1375        })
1376    })
1377}
1378
1379fn current_request_principal(
1380    state: &AppState,
1381    principal: &Option<mongreldb_core::Principal>,
1382) -> Option<mongreldb_core::Principal> {
1383    if let Some(principal) = principal {
1384        return state.db.resolve_current_principal(principal);
1385    }
1386    request_principal(state, principal)
1387}
1388
1389fn request_identity_is_current(
1390    state: &AppState,
1391    principal: &Option<mongreldb_core::Principal>,
1392) -> bool {
1393    if principal.is_some() || state.auth_token.is_some() {
1394        return current_request_principal(state, principal).is_some();
1395    }
1396    !state.user_auth && !state.db.require_auth_enabled()
1397}
1398
1399/// `POST /sessions` — open a long-lived session for cross-request interactive
1400/// transactions. Returns `{"session_id": "..."}`; send `X-Session-ID: <token>`
1401/// on subsequent `/sql` requests to route to it. The session is owned by the
1402/// authenticated principal and auto-expires after the idle timeout.
1403async fn create_session(
1404    State(state): State<Arc<AppState>>,
1405    OptionalPrincipal(principal): OptionalPrincipal,
1406) -> Response {
1407    if !state.accepting_sql.load(Ordering::Acquire) {
1408        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
1409    }
1410    if !request_identity_is_current(&state, &principal) {
1411        return StatusCode::UNAUTHORIZED.into_response();
1412    }
1413    let owner = request_owner(&state, &principal);
1414    let session = match MongrelSession::open_with_external_modules_as(
1415        Arc::clone(&state.db),
1416        state.external_modules.iter().cloned(),
1417        request_principal(&state, &principal),
1418    ) {
1419        Ok(session) => session.with_query_registry(Arc::clone(&state.query_registry)),
1420        Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
1421    };
1422    match state.sessions.create(session, owner.clone()) {
1423        Some(token) => {
1424            state.audit.record(owner, "session.open", "session created");
1425            Json(json!({ "session_id": token })).into_response()
1426        }
1427        None => (
1428            StatusCode::SERVICE_UNAVAILABLE,
1429            "session limit reached; close an idle session or raise --max-sessions",
1430        )
1431            .into_response(),
1432    }
1433}
1434
1435/// `DELETE /sessions/{id}` — close a session, discarding any open (staged but
1436/// uncommitted) transaction. Only the owning principal may close it.
1437async fn close_session(
1438    State(state): State<Arc<AppState>>,
1439    OptionalPrincipal(principal): OptionalPrincipal,
1440    Path(id): Path<String>,
1441) -> Response {
1442    if !request_identity_is_current(&state, &principal) {
1443        return StatusCode::NOT_FOUND.into_response();
1444    }
1445    let owner = request_owner(&state, &principal);
1446    if let Some(entry) = state.sessions.take_for_close(&id, &owner) {
1447        entry
1448            .session
1449            .query_registry()
1450            .cancel_session(&id, CancellationReason::SessionClosed);
1451        let deadline = tokio::time::Instant::now() + state.sql_cancel_grace;
1452        while entry.session.query_registry().active_for_session(&id) > 0
1453            && tokio::time::Instant::now() < deadline
1454        {
1455            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1456        }
1457        state.audit.record(owner, "session.close", "session closed");
1458        StatusCode::OK.into_response()
1459    } else {
1460        (
1461            StatusCode::NOT_FOUND,
1462            "session not found or not owned by caller",
1463        )
1464            .into_response()
1465    }
1466}
1467
1468/// Choose a buffered response serialization: `"arrow"` (IPC file) or JSON.
1469/// Streaming IPC is dispatched before query collection.
1470async fn dispatch_buffered_sql_format(
1471    state: &AppState,
1472    format: Option<&str>,
1473    output: ManagedQueryBatches,
1474    query_id: QueryId,
1475    test_hook: Option<mongreldb_query::SqlTestHook>,
1476    output_limits: (usize, usize),
1477) -> Response {
1478    let format = format.unwrap_or("json").to_owned();
1479    let (max_rows, max_bytes) = output_limits;
1480    // Keep the lifecycle guard outside the worker. If the worker panics, the
1481    // join-error path must record a serialization failure, not let dropping a
1482    // moved guard misclassify it as a client disconnect.
1483    let serialization_batches = output.batches().to_vec();
1484    let serialization_query = output.query().clone();
1485    let serialized = tokio::task::spawn_blocking(move || {
1486        serialize_buffered_output(
1487            &format,
1488            &serialization_batches,
1489            &serialization_query,
1490            max_rows,
1491            max_bytes,
1492            test_hook.as_ref(),
1493        )
1494    })
1495    .await;
1496    let result = match serialized {
1497        Ok(result) => result,
1498        Err(error) => {
1499            output
1500                .query()
1501                .record_serialization_failure("SERIALIZATION_WORKER_FAILED");
1502            output.fail();
1503            state.metrics.inc_sql_errors();
1504            return terminal_server_error_response(
1505                state,
1506                query_id,
1507                StatusCode::INTERNAL_SERVER_ERROR,
1508                "SERIALIZATION_WORKER_FAILED",
1509                error.to_string(),
1510            );
1511        }
1512    };
1513    match result {
1514        Ok(serialized) => {
1515            if let Err(error) = output.try_complete() {
1516                state.metrics.inc_sql_errors();
1517                return tracked_query_error_response(state, &error, Some(query_id));
1518            }
1519            state.metrics.add_sql_output_bytes(serialized.bytes.len());
1520            let content_type = if serialized.arrow {
1521                "application/vnd.apache.arrow.file"
1522            } else {
1523                "application/json"
1524            };
1525            with_query_id(
1526                ([(header::CONTENT_TYPE, content_type)], serialized.bytes).into_response(),
1527                query_id,
1528            )
1529        }
1530        Err(BufferedSerializationError::Query(error)) => {
1531            output.fail();
1532            state.metrics.inc_sql_errors();
1533            tracked_query_error_response(state, &error, Some(query_id))
1534        }
1535        Err(BufferedSerializationError::Limit(message)) => {
1536            output.fail_result_limit();
1537            state.metrics.inc_sql_errors();
1538            terminal_server_error_response(
1539                state,
1540                query_id,
1541                StatusCode::PAYLOAD_TOO_LARGE,
1542                "RESULT_LIMIT_EXCEEDED",
1543                message,
1544            )
1545        }
1546        Err(BufferedSerializationError::Encoding(message)) => {
1547            output.fail_serialization();
1548            state.metrics.inc_sql_errors();
1549            terminal_server_error_response(
1550                state,
1551                query_id,
1552                StatusCode::INTERNAL_SERVER_ERROR,
1553                "SERIALIZATION_FAILED",
1554                message,
1555            )
1556        }
1557    }
1558}
1559
1560#[derive(Debug)]
1561enum PaginatedSerializationError {
1562    Query(mongreldb_query::MongrelQueryError),
1563    Limit(String),
1564    Projection(String),
1565    Encoding(String),
1566}
1567
1568struct SerializedPageRows {
1569    rows: Vec<serde_json::Value>,
1570    retained_bytes: usize,
1571}
1572
1573struct PaginatedJsonReader<'a> {
1574    cursor: std::io::Cursor<&'a [u8]>,
1575    query: &'a RegisteredSqlQuery,
1576    test_hook: Option<&'a mongreldb_query::SqlTestHook>,
1577}
1578
1579impl std::io::Read for PaginatedJsonReader<'_> {
1580    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
1581        if let Some(hook) = self.test_hook {
1582            hook(mongreldb_query::SqlTestHookPoint::DuringPaginationDeserialization);
1583        }
1584        self.query
1585            .checkpoint()
1586            .map_err(|error| std::io::Error::other(error.to_string()))?;
1587        // Bound work between cancellation checks even for one very large row.
1588        let length = buffer.len().min(64 * 1024);
1589        std::io::Read::read(&mut self.cursor, &mut buffer[..length])
1590    }
1591}
1592
1593const PAGINATED_VALUE_NODE_BYTES: usize = 64;
1594const PAGINATED_OBJECT_ENTRY_BYTES: usize = 128;
1595const PAGINATED_MEMORY_LIMIT_ERROR: &str = "SQL retained output memory limit exceeded";
1596
1597struct PaginatedDecodeBudget<'a> {
1598    used: usize,
1599    limit: usize,
1600    nodes: usize,
1601    exceeded: bool,
1602    query: &'a RegisteredSqlQuery,
1603    test_hook: Option<&'a mongreldb_query::SqlTestHook>,
1604}
1605
1606impl PaginatedDecodeBudget<'_> {
1607    fn begin_value<E: serde::de::Error>(&mut self) -> Result<(), E> {
1608        self.nodes = self.nodes.saturating_add(1);
1609        if self.nodes & 255 == 0 {
1610            if let Some(hook) = self.test_hook {
1611                hook(mongreldb_query::SqlTestHookPoint::DuringPaginationDeserialization);
1612            }
1613            self.query.checkpoint().map_err(E::custom)?;
1614        }
1615        self.charge::<E>(PAGINATED_VALUE_NODE_BYTES)
1616    }
1617
1618    fn charge<E: serde::de::Error>(&mut self, bytes: usize) -> Result<(), E> {
1619        let next = self.used.saturating_add(bytes);
1620        if next > self.limit {
1621            self.exceeded = true;
1622            return Err(E::custom(PAGINATED_MEMORY_LIMIT_ERROR));
1623        }
1624        self.used = next;
1625        Ok(())
1626    }
1627}
1628
1629struct BudgetedJsonValueSeed<'a, 'query> {
1630    budget: &'a mut PaginatedDecodeBudget<'query>,
1631}
1632
1633impl<'de> serde::de::DeserializeSeed<'de> for BudgetedJsonValueSeed<'_, '_> {
1634    type Value = serde_json::Value;
1635
1636    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1637    where
1638        D: serde::Deserializer<'de>,
1639    {
1640        self.budget.begin_value::<D::Error>()?;
1641        deserializer.deserialize_any(BudgetedJsonValueVisitor {
1642            budget: self.budget,
1643        })
1644    }
1645}
1646
1647struct BudgetedJsonValueVisitor<'a, 'query> {
1648    budget: &'a mut PaginatedDecodeBudget<'query>,
1649}
1650
1651impl<'de> serde::de::Visitor<'de> for BudgetedJsonValueVisitor<'_, '_> {
1652    type Value = serde_json::Value;
1653
1654    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1655        formatter.write_str("a JSON value")
1656    }
1657
1658    fn visit_bool<E>(self, value: bool) -> Result<Self::Value, E> {
1659        Ok(serde_json::Value::Bool(value))
1660    }
1661
1662    fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E> {
1663        Ok(serde_json::Value::Number(value.into()))
1664    }
1665
1666    fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E> {
1667        Ok(serde_json::Value::Number(value.into()))
1668    }
1669
1670    fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
1671    where
1672        E: serde::de::Error,
1673    {
1674        serde_json::Number::from_f64(value)
1675            .map(serde_json::Value::Number)
1676            .ok_or_else(|| E::custom("JSON number is not finite"))
1677    }
1678
1679    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1680    where
1681        E: serde::de::Error,
1682    {
1683        self.budget.charge::<E>(value.len())?;
1684        Ok(serde_json::Value::String(value.to_owned()))
1685    }
1686
1687    fn visit_borrowed_str<E>(self, value: &'de str) -> Result<Self::Value, E>
1688    where
1689        E: serde::de::Error,
1690    {
1691        self.visit_str(value)
1692    }
1693
1694    fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
1695    where
1696        E: serde::de::Error,
1697    {
1698        self.budget.charge::<E>(value.capacity())?;
1699        Ok(serde_json::Value::String(value))
1700    }
1701
1702    fn visit_none<E>(self) -> Result<Self::Value, E> {
1703        Ok(serde_json::Value::Null)
1704    }
1705
1706    fn visit_unit<E>(self) -> Result<Self::Value, E> {
1707        Ok(serde_json::Value::Null)
1708    }
1709
1710    fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1711    where
1712        D: serde::Deserializer<'de>,
1713    {
1714        deserializer.deserialize_any(self)
1715    }
1716
1717    fn visit_newtype_struct<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1718    where
1719        D: serde::Deserializer<'de>,
1720    {
1721        deserializer.deserialize_any(self)
1722    }
1723
1724    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1725    where
1726        A: serde::de::SeqAccess<'de>,
1727    {
1728        let mut values = Vec::new();
1729        while let Some(value) = sequence.next_element_seed(BudgetedJsonValueSeed {
1730            budget: self.budget,
1731        })? {
1732            values.push(value);
1733        }
1734        Ok(serde_json::Value::Array(values))
1735    }
1736
1737    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1738    where
1739        A: serde::de::MapAccess<'de>,
1740    {
1741        let mut values = serde_json::Map::new();
1742        while let Some(key) = map.next_key::<String>()? {
1743            self.budget
1744                .charge::<A::Error>(PAGINATED_OBJECT_ENTRY_BYTES.saturating_add(key.capacity()))?;
1745            let value = map.next_value_seed(BudgetedJsonValueSeed {
1746                budget: self.budget,
1747            })?;
1748            values.insert(key, value);
1749        }
1750        Ok(serde_json::Value::Object(values))
1751    }
1752}
1753
1754struct BudgetedJsonRowsSeed<'a, 'query> {
1755    budget: &'a mut PaginatedDecodeBudget<'query>,
1756}
1757
1758impl<'de> serde::de::DeserializeSeed<'de> for BudgetedJsonRowsSeed<'_, '_> {
1759    type Value = Vec<serde_json::Value>;
1760
1761    fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1762    where
1763        D: serde::Deserializer<'de>,
1764    {
1765        deserializer.deserialize_seq(BudgetedJsonRowsVisitor {
1766            budget: self.budget,
1767        })
1768    }
1769}
1770
1771struct BudgetedJsonRowsVisitor<'a, 'query> {
1772    budget: &'a mut PaginatedDecodeBudget<'query>,
1773}
1774
1775impl<'de> serde::de::Visitor<'de> for BudgetedJsonRowsVisitor<'_, '_> {
1776    type Value = Vec<serde_json::Value>;
1777
1778    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1779        formatter.write_str("a JSON array of SQL rows")
1780    }
1781
1782    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
1783    where
1784        A: serde::de::SeqAccess<'de>,
1785    {
1786        let mut rows = Vec::new();
1787        while let Some(row) = sequence.next_element_seed(BudgetedJsonValueSeed {
1788            budget: self.budget,
1789        })? {
1790            rows.push(row);
1791        }
1792        Ok(rows)
1793    }
1794}
1795
1796#[allow(clippy::too_many_arguments)]
1797async fn dispatch_paginated_sql(
1798    state: &AppState,
1799    output: ManagedQueryBatches,
1800    query_id: QueryId,
1801    owner: &str,
1802    pagination: ResolvedSqlPagination,
1803    output_limits: (usize, usize),
1804    test_hook: Option<mongreldb_query::SqlTestHook>,
1805    binding: sql_pages::SqlPageBinding,
1806) -> Response {
1807    let projection = pagination.projection;
1808    let serialization_projection = projection.clone();
1809    let serialization_batches = output.batches().to_vec();
1810    let serialization_query = output.query().clone();
1811    let response_test_hook = test_hook.clone();
1812    let retained_memory_limit = output_limits.1.min(state.sql_pages.max_retained_bytes());
1813    let serialized = tokio::task::spawn_blocking(move || {
1814        serialize_paginated_rows(
1815            &serialization_batches,
1816            &serialization_query,
1817            &serialization_projection,
1818            output_limits.0,
1819            output_limits.1,
1820            retained_memory_limit,
1821            test_hook.as_ref(),
1822        )
1823    })
1824    .await;
1825    let serialized = match serialized {
1826        Ok(result) => result,
1827        Err(error) => {
1828            output
1829                .query()
1830                .record_serialization_failure("SERIALIZATION_WORKER_FAILED");
1831            output.fail();
1832            state.metrics.inc_sql_errors();
1833            return terminal_server_error_response(
1834                state,
1835                query_id,
1836                StatusCode::INTERNAL_SERVER_ERROR,
1837                "SERIALIZATION_WORKER_FAILED",
1838                error.to_string(),
1839            );
1840        }
1841    };
1842    let serialized = match serialized {
1843        Ok(serialized) => serialized,
1844        Err(PaginatedSerializationError::Query(error)) => {
1845            output.fail();
1846            state.metrics.inc_sql_errors();
1847            return tracked_query_error_response(state, &error, Some(query_id));
1848        }
1849        Err(PaginatedSerializationError::Limit(message)) => {
1850            output.fail_result_limit();
1851            state.metrics.inc_sql_errors();
1852            return terminal_server_error_response(
1853                state,
1854                query_id,
1855                StatusCode::PAYLOAD_TOO_LARGE,
1856                "RESULT_LIMIT_EXCEEDED",
1857                message,
1858            );
1859        }
1860        Err(PaginatedSerializationError::Projection(message)) => {
1861            output.fail_with_error(
1862                "INVALID_SQL_PROJECTION",
1863                mongreldb_query::QueryTerminalErrorCategory::Execution,
1864            );
1865            state.metrics.inc_sql_errors();
1866            return terminal_server_error_response(
1867                state,
1868                query_id,
1869                StatusCode::BAD_REQUEST,
1870                "INVALID_SQL_PROJECTION",
1871                message,
1872            );
1873        }
1874        Err(PaginatedSerializationError::Encoding(message)) => {
1875            output.fail_serialization();
1876            state.metrics.inc_sql_errors();
1877            return terminal_server_error_response(
1878                state,
1879                query_id,
1880                StatusCode::INTERNAL_SERVER_ERROR,
1881                "SERIALIZATION_FAILED",
1882                message,
1883            );
1884        }
1885    };
1886    let retained_bytes = serialized.retained_bytes;
1887    let current_binding = sql_pages::SqlPageBinding {
1888        security_version: state.db.security_version(),
1889        catalog_epoch: state.db.catalog_snapshot().db_epoch,
1890    };
1891    if current_binding != binding {
1892        output.fail_with_error(
1893            "SQL_CURSOR_EXPIRED",
1894            mongreldb_query::QueryTerminalErrorCategory::Execution,
1895        );
1896        return with_query_id(
1897            sql_cursor_error_response(
1898                StatusCode::CONFLICT,
1899                "SQL_CURSOR_EXPIRED",
1900                "authorization or schema changed while retaining the SQL result",
1901                query_id,
1902            ),
1903            query_id,
1904        );
1905    }
1906    let retained = match state.sql_pages.insert(
1907        owner,
1908        serialized.rows,
1909        projection,
1910        pagination.limits,
1911        retained_bytes,
1912        binding,
1913    ) {
1914        Ok(retained) => retained,
1915        Err(sql_pages::InsertError::Full | sql_pages::InsertError::OwnerLimit) => {
1916            output.fail_with_error(
1917                "SQL_PAGE_STORE_FULL",
1918                mongreldb_query::QueryTerminalErrorCategory::Execution,
1919            );
1920            state.metrics.inc_sql_errors();
1921            return terminal_server_error_response(
1922                state,
1923                query_id,
1924                StatusCode::SERVICE_UNAVAILABLE,
1925                "SQL_PAGE_STORE_FULL",
1926                "retained SQL result capacity reached",
1927            );
1928        }
1929        Err(sql_pages::InsertError::EntropyUnavailable) => {
1930            output.fail_with_error(
1931                "ENTROPY_UNAVAILABLE",
1932                mongreldb_query::QueryTerminalErrorCategory::Execution,
1933            );
1934            state.metrics.inc_sql_errors();
1935            return terminal_server_error_response(
1936                state,
1937                query_id,
1938                StatusCode::INTERNAL_SERVER_ERROR,
1939                "ENTROPY_UNAVAILABLE",
1940                "OS CSPRNG unavailable",
1941            );
1942        }
1943    };
1944    let cursor_mac_key = match state.cursor_mac_key.get() {
1945        Ok(key) => key,
1946        Err(_) => {
1947            state.sql_pages.discard(&retained);
1948            output.fail_with_error(
1949                "ENTROPY_UNAVAILABLE",
1950                mongreldb_query::QueryTerminalErrorCategory::Execution,
1951            );
1952            state.metrics.inc_sql_errors();
1953            return terminal_server_error_response(
1954                state,
1955                query_id,
1956                StatusCode::INTERNAL_SERVER_ERROR,
1957                "ENTROPY_UNAVAILABLE",
1958                "OS CSPRNG unavailable",
1959            );
1960        }
1961    };
1962    let page = match sql_pages::SqlPageStore::first_page(&retained, &cursor_mac_key) {
1963        Ok(page) => page,
1964        Err(sql_pages::PageError::RowExceedsLimits) => {
1965            state.sql_pages.discard(&retained);
1966            output.fail_result_limit();
1967            state.metrics.inc_sql_errors();
1968            return terminal_server_error_response(
1969                state,
1970                query_id,
1971                StatusCode::PAYLOAD_TOO_LARGE,
1972                "RESULT_LIMIT_EXCEEDED",
1973                "one projected row exceeds the page byte or token limit",
1974            );
1975        }
1976        Err(sql_pages::PageError::OffsetInvalid) => {
1977            state.sql_pages.discard(&retained);
1978            output.fail_with_error(
1979                "INVALID_PAGE_OFFSET",
1980                mongreldb_query::QueryTerminalErrorCategory::Serialization,
1981            );
1982            state.metrics.inc_sql_errors();
1983            return terminal_server_error_response(
1984                state,
1985                query_id,
1986                StatusCode::INTERNAL_SERVER_ERROR,
1987                "INVALID_PAGE_OFFSET",
1988                "failed to create the first SQL result page",
1989            );
1990        }
1991        Err(sql_pages::PageError::Cancelled) => unreachable!("first-page rendering has no control"),
1992    };
1993    let discard_after_response = page.next_cursor.is_none();
1994    let page_byte_count = page.byte_count;
1995    let encoded_page = tokio::task::spawn_blocking(move || serialize_sql_page(page)).await;
1996    let encoded_page = match encoded_page {
1997        Ok(Ok(encoded_page)) => encoded_page,
1998        Ok(Err(error)) => {
1999            state.sql_pages.discard(&retained);
2000            output.fail_serialization();
2001            state.metrics.inc_sql_errors();
2002            return terminal_server_error_response(
2003                state,
2004                query_id,
2005                StatusCode::INTERNAL_SERVER_ERROR,
2006                "SERIALIZATION_FAILED",
2007                error.to_string(),
2008            );
2009        }
2010        Err(_) => {
2011            state.sql_pages.discard(&retained);
2012            output.fail_serialization();
2013            state.metrics.inc_sql_errors();
2014            return terminal_server_error_response(
2015                state,
2016                query_id,
2017                StatusCode::INTERNAL_SERVER_ERROR,
2018                "SERIALIZATION_WORKER_FAILED",
2019                "SQL page response serialization worker failed",
2020            );
2021        }
2022    };
2023    if let Some(hook) = response_test_hook {
2024        hook(mongreldb_query::SqlTestHookPoint::AfterPageResponseSerialization);
2025    }
2026    if let Err(error) = output.query().checkpoint() {
2027        state.sql_pages.discard(&retained);
2028        state.metrics.inc_sql_errors();
2029        return tracked_query_error_response(state, &error, Some(query_id));
2030    }
2031    if let Err(error) = output.try_complete() {
2032        state.sql_pages.discard(&retained);
2033        state.metrics.inc_sql_errors();
2034        return tracked_query_error_response(state, &error, Some(query_id));
2035    }
2036    if discard_after_response {
2037        state.sql_pages.discard(&retained);
2038    }
2039    state.metrics.add_sql_output_bytes(page_byte_count);
2040    with_query_id(sql_page_response(encoded_page), query_id)
2041}
2042
2043fn serialize_paginated_rows(
2044    batches: &[arrow::record_batch::RecordBatch],
2045    query: &RegisteredSqlQuery,
2046    projection: &[String],
2047    max_rows: usize,
2048    max_bytes: usize,
2049    retained_memory_limit: usize,
2050    test_hook: Option<&mongreldb_query::SqlTestHook>,
2051) -> Result<SerializedPageRows, PaginatedSerializationError> {
2052    const ROW_CHECKPOINT_INTERVAL: usize = 256;
2053    if batches.is_empty() {
2054        let retained_bytes = sql_pages::accounted_bytes(2, &[], projection, || query.checkpoint())
2055            .map_err(PaginatedSerializationError::Query)?;
2056        if retained_bytes > retained_memory_limit {
2057            return Err(PaginatedSerializationError::Limit(
2058                PAGINATED_MEMORY_LIMIT_ERROR.into(),
2059            ));
2060        }
2061        return Ok(SerializedPageRows {
2062            rows: Vec::new(),
2063            retained_bytes,
2064        });
2065    }
2066    let fields = batches[0].schema();
2067    let mut indices = Vec::with_capacity(projection.len());
2068    for name in projection {
2069        let matches: Vec<_> = fields
2070            .fields()
2071            .iter()
2072            .enumerate()
2073            .filter_map(|(index, field)| (field.name() == name).then_some(index))
2074            .collect();
2075        if matches.len() != 1 {
2076            return Err(PaginatedSerializationError::Projection(format!(
2077                "projected output column {name:?} is missing or ambiguous"
2078            )));
2079        }
2080        indices.push(matches[0]);
2081    }
2082
2083    let mut writer_output = LimitedOutput::new(max_bytes);
2084    let mut rows = 0usize;
2085    let encoding = (|| {
2086        let mut writer = arrow::json::writer::ArrayWriter::new(&mut writer_output);
2087        for batch in batches {
2088            let batch = batch.project(&indices).map_err(|error| error.to_string())?;
2089            for offset in (0..batch.num_rows()).step_by(ROW_CHECKPOINT_INTERVAL) {
2090                if let Some(hook) = test_hook {
2091                    hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
2092                }
2093                query.checkpoint().map_err(|error| error.to_string())?;
2094                let length = ROW_CHECKPOINT_INTERVAL.min(batch.num_rows() - offset);
2095                rows = rows.saturating_add(length);
2096                if rows > max_rows {
2097                    return Err("SQL retained output row limit exceeded".into());
2098                }
2099                let slice = batch.slice(offset, length);
2100                writer
2101                    .write_batches(&[&slice])
2102                    .map_err(|error| error.to_string())?;
2103            }
2104        }
2105        writer.finish().map_err(|error| error.to_string())
2106    })();
2107    if let Err(error) = encoding {
2108        if let Err(query_error) = query.checkpoint() {
2109            return Err(PaginatedSerializationError::Query(query_error));
2110        }
2111        if writer_output.exceeded || rows > max_rows {
2112            return Err(PaginatedSerializationError::Limit(error));
2113        }
2114        return Err(PaginatedSerializationError::Encoding(error));
2115    }
2116    let bytes = writer_output.bytes.len();
2117    let retained_base = sql_pages::accounted_bytes(bytes, &[], projection, || query.checkpoint())
2118        .map_err(PaginatedSerializationError::Query)?;
2119    if retained_base > retained_memory_limit {
2120        return Err(PaginatedSerializationError::Limit(
2121            PAGINATED_MEMORY_LIMIT_ERROR.into(),
2122        ));
2123    }
2124    let reader = PaginatedJsonReader {
2125        cursor: std::io::Cursor::new(writer_output.bytes.as_slice()),
2126        query,
2127        test_hook,
2128    };
2129    let mut deserializer =
2130        serde_json::Deserializer::from_reader(std::io::BufReader::with_capacity(64 * 1024, reader));
2131    let mut budget = PaginatedDecodeBudget {
2132        used: retained_base,
2133        limit: retained_memory_limit,
2134        nodes: 0,
2135        exceeded: false,
2136        query,
2137        test_hook,
2138    };
2139    let rows = match serde::de::DeserializeSeed::deserialize(
2140        BudgetedJsonRowsSeed {
2141            budget: &mut budget,
2142        },
2143        &mut deserializer,
2144    ) {
2145        Ok(rows) => {
2146            if let Err(error) = deserializer.end() {
2147                return Err(PaginatedSerializationError::Encoding(error.to_string()));
2148            }
2149            rows
2150        }
2151        Err(error) => {
2152            if let Err(query_error) = query.checkpoint() {
2153                return Err(PaginatedSerializationError::Query(query_error));
2154            }
2155            if budget.exceeded {
2156                return Err(PaginatedSerializationError::Limit(
2157                    PAGINATED_MEMORY_LIMIT_ERROR.into(),
2158                ));
2159            }
2160            return Err(PaginatedSerializationError::Encoding(error.to_string()));
2161        }
2162    };
2163    if let Some(hook) = test_hook {
2164        hook(mongreldb_query::SqlTestHookPoint::AfterSerialization);
2165    }
2166    let retained_bytes =
2167        sql_pages::accounted_bytes(bytes, &rows, projection, || query.checkpoint())
2168            .map_err(PaginatedSerializationError::Query)?;
2169    if retained_bytes > retained_memory_limit {
2170        return Err(PaginatedSerializationError::Limit(
2171            PAGINATED_MEMORY_LIMIT_ERROR.into(),
2172        ));
2173    }
2174    Ok(SerializedPageRows {
2175        rows,
2176        retained_bytes,
2177    })
2178}
2179
2180fn serialize_sql_page(page: sql_pages::SqlPage) -> Result<Vec<u8>, serde_json::Error> {
2181    serde_json::to_vec(&json!({
2182        "status": "completed",
2183        "rows": page.rows,
2184        "next_cursor": page.next_cursor,
2185        "page": {
2186            "offset": page.offset,
2187            "row_count": page.row_count,
2188            "total_rows": page.total_rows,
2189            "byte_count": page.byte_count,
2190            "estimated_tokens": page.estimated_tokens,
2191            "limits": page.limits,
2192            "projection": page.projection,
2193            "expires_at_ms": page.expires_at_ms,
2194            "snapshot": "retained_result",
2195            "token_estimate": "ceil(projected_json_bytes/4)",
2196        }
2197    }))
2198}
2199
2200enum ControlledPageSerializationError {
2201    Query(mongreldb_query::MongrelQueryError),
2202    Encoding,
2203}
2204
2205struct ControlledPageWriter<'a> {
2206    bytes: Vec<u8>,
2207    query: &'a RegisteredSqlQuery,
2208}
2209
2210impl std::io::Write for ControlledPageWriter<'_> {
2211    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
2212        self.query
2213            .checkpoint()
2214            .map_err(|error| std::io::Error::other(error.to_string()))?;
2215        self.bytes.extend_from_slice(buffer);
2216        Ok(buffer.len())
2217    }
2218
2219    fn flush(&mut self) -> std::io::Result<()> {
2220        Ok(())
2221    }
2222}
2223
2224fn serialize_sql_page_controlled(
2225    page: sql_pages::SqlPage,
2226    query: &RegisteredSqlQuery,
2227) -> Result<Vec<u8>, ControlledPageSerializationError> {
2228    query
2229        .checkpoint()
2230        .map_err(ControlledPageSerializationError::Query)?;
2231    let value = json!({
2232        "status": "completed",
2233        "rows": page.rows,
2234        "next_cursor": page.next_cursor,
2235        "page": {
2236            "offset": page.offset,
2237            "row_count": page.row_count,
2238            "total_rows": page.total_rows,
2239            "byte_count": page.byte_count,
2240            "estimated_tokens": page.estimated_tokens,
2241            "limits": page.limits,
2242            "projection": page.projection,
2243            "expires_at_ms": page.expires_at_ms,
2244            "snapshot": "retained_result",
2245            "token_estimate": "ceil(projected_json_bytes/4)",
2246        }
2247    });
2248    let mut writer = ControlledPageWriter {
2249        bytes: Vec::new(),
2250        query,
2251    };
2252    if let Err(error) = serde_json::to_writer(&mut writer, &value) {
2253        return match query.checkpoint() {
2254            Err(error) => Err(ControlledPageSerializationError::Query(error)),
2255            Ok(()) => {
2256                let _ = error;
2257                Err(ControlledPageSerializationError::Encoding)
2258            }
2259        };
2260    }
2261    query
2262        .checkpoint()
2263        .map_err(ControlledPageSerializationError::Query)?;
2264    Ok(writer.bytes)
2265}
2266
2267fn sql_page_response(body: Vec<u8>) -> Response {
2268    ([(header::CONTENT_TYPE, "application/json")], body).into_response()
2269}
2270
2271/// Validate a prepared-statement name: bare identifier only
2272/// (`[A-Za-z_][A-Za-z0-9_]*`). Prevents SQL injection via the name, which is
2273/// interpolated into `PREPARE <name> AS ...` / `EXECUTE <name>(...)`.
2274fn validate_stmt_name(name: &str) -> Result<(), String> {
2275    let mut chars = name.chars();
2276    match chars.next() {
2277        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
2278        _ => return Err("statement name must start with a letter or underscore".into()),
2279    }
2280    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_') {
2281        return Err("statement name may contain only letters, digits, or underscore".into());
2282    }
2283    Ok(())
2284}
2285
2286/// Render a JSON value as a safe SQL literal for `EXECUTE` parameter binding.
2287/// Values are escaped so a client cannot inject SQL through a parameter.
2288/// Returns `Err` for non-scalar JSON (arrays/objects) so the caller rejects the
2289/// request with 400 rather than silently binding NULL.
2290fn render_sql_literal(v: &serde_json::Value) -> Result<String, String> {
2291    match v {
2292        serde_json::Value::Null => Ok("NULL".into()),
2293        serde_json::Value::Bool(b) => {
2294            if *b {
2295                Ok("TRUE".into())
2296            } else {
2297                Ok("FALSE".into())
2298            }
2299        }
2300        serde_json::Value::Number(n) => Ok(n.to_string()),
2301        // Single-quote, doubling embedded single quotes (SQL-standard escape).
2302        serde_json::Value::String(s) => {
2303            let mut out = String::with_capacity(s.len() + 2);
2304            out.push('\'');
2305            for c in s.chars() {
2306                if c == '\'' {
2307                    out.push_str("''");
2308                } else {
2309                    out.push(c);
2310                }
2311            }
2312            out.push('\'');
2313            Ok(out)
2314        }
2315        // Arrays/objects are not valid scalar params; reject explicitly.
2316        _ => Err("prepared-statement parameters must be scalar (null/bool/number/string)".into()),
2317    }
2318}
2319
2320#[derive(Deserialize)]
2321struct PrepareRequest {
2322    name: String,
2323    sql: String,
2324    #[serde(default)]
2325    query_id: Option<QueryId>,
2326    #[serde(default)]
2327    timeout_ms: Option<u64>,
2328}
2329
2330/// `POST /sessions/{id}/prepare` — parse+plan `sql` once and store it under
2331/// `name` on the session. Subsequent `EXECUTE name(...)` calls (via this
2332/// endpoint or `EXECUTE` SQL) reuse the cached plan, skipping re-planning.
2333async fn prepare_statement(
2334    State(state): State<Arc<AppState>>,
2335    OptionalPrincipal(principal): OptionalPrincipal,
2336    Path(id): Path<String>,
2337    headers: axum::http::HeaderMap,
2338    Json(req): Json<PrepareRequest>,
2339) -> Response {
2340    if !state.accepting_sql.load(Ordering::Acquire) {
2341        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
2342    }
2343    if !request_identity_is_current(&state, &principal) {
2344        return StatusCode::NOT_FOUND.into_response();
2345    }
2346    if let Err(msg) = validate_stmt_name(&req.name) {
2347        return (StatusCode::BAD_REQUEST, msg).into_response();
2348    }
2349    let owner = request_owner(&state, &principal);
2350    let Some(entry) = state.sessions.get(&id, &owner) else {
2351        return (
2352            StatusCode::NOT_FOUND,
2353            "session not found or not owned by caller",
2354        )
2355            .into_response();
2356    };
2357    let (options, query_id) = match resolve_query_options(
2358        &state,
2359        &headers,
2360        req.query_id,
2361        req.timeout_ms,
2362        owner,
2363        Some(id),
2364    ) {
2365        Ok(options) => options,
2366        Err(response) => return *response,
2367    };
2368    let query = match register_controlled_query(&state, &entry.session, options) {
2369        Ok(query) => query,
2370        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2371    };
2372    let registration = RegisteredQueryGuard::new(query);
2373    if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
2374        registration.fail();
2375        return with_query_id(remote_boolean_ai_error(), query_id);
2376    }
2377    let _sql_permit = match acquire_sql_permit(&state, &entry.session, registration.query()).await {
2378        Ok(permit) => permit,
2379        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2380    };
2381    let _guard = tokio::select! {
2382        guard = entry.lock.lock() => guard,
2383        _ = registration.query().control().cancelled() => {
2384            return tracked_query_error_response(
2385                &state,
2386                &cancellation_checkpoint_error(registration.query()),
2387                Some(query_id),
2388            );
2389        }
2390    };
2391    if entry.is_closed() {
2392        return with_query_id(
2393            (StatusCode::NOT_FOUND, "session no longer available").into_response(),
2394            query_id,
2395        );
2396    }
2397    entry.touch();
2398    let sql = format!("PREPARE {} AS {}", req.name, req.sql);
2399    let query = registration.into_query();
2400    match entry.session.run_with_query(&sql, query).await {
2401        Ok(_) => with_query_id(
2402            Json(json!({ "prepared": req.name })).into_response(),
2403            query_id,
2404        ),
2405        Err(error) => tracked_query_error_response(&state, &error, Some(query_id)),
2406    }
2407}
2408
2409#[derive(Deserialize)]
2410struct ExecuteRequest {
2411    name: String,
2412    params: Vec<serde_json::Value>,
2413    #[serde(default)]
2414    format: Option<String>,
2415    #[serde(default)]
2416    query_id: Option<QueryId>,
2417    #[serde(default)]
2418    timeout_ms: Option<u64>,
2419}
2420
2421/// `POST /sessions/{id}/execute` — run a previously-prepared statement with
2422/// typed parameters, reusing its cached plan. Returns the same formats as
2423/// `/sql` (`json` default, `arrow`, `arrow-stream`).
2424async fn execute_statement(
2425    State(state): State<Arc<AppState>>,
2426    OptionalPrincipal(principal): OptionalPrincipal,
2427    Path(id): Path<String>,
2428    headers: axum::http::HeaderMap,
2429    Json(req): Json<ExecuteRequest>,
2430) -> Response {
2431    if !state.accepting_sql.load(Ordering::Acquire) {
2432        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
2433    }
2434    if !request_identity_is_current(&state, &principal) {
2435        return StatusCode::NOT_FOUND.into_response();
2436    }
2437    if let Err(msg) = validate_stmt_name(&req.name) {
2438        return (StatusCode::BAD_REQUEST, msg).into_response();
2439    }
2440    let owner = request_owner(&state, &principal);
2441    let Some(entry) = state.sessions.get(&id, &owner) else {
2442        return (
2443            StatusCode::NOT_FOUND,
2444            "session not found or not owned by caller",
2445        )
2446            .into_response();
2447    };
2448    let (options, query_id) = match resolve_query_options(
2449        &state,
2450        &headers,
2451        req.query_id,
2452        req.timeout_ms,
2453        owner,
2454        Some(id),
2455    ) {
2456        Ok(options) => options,
2457        Err(response) => return *response,
2458    };
2459    let query = match register_controlled_query(&state, &entry.session, options) {
2460        Ok(query) => query,
2461        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2462    };
2463    let registration = RegisteredQueryGuard::new(query);
2464    let sql_permit = match acquire_sql_permit(&state, &entry.session, registration.query()).await {
2465        Ok(permit) => permit,
2466        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2467    };
2468    let _guard = tokio::select! {
2469        guard = entry.lock.lock() => guard,
2470        _ = registration.query().control().cancelled() => {
2471            return tracked_query_error_response(
2472                &state,
2473                &cancellation_checkpoint_error(registration.query()),
2474                Some(query_id),
2475            );
2476        }
2477    };
2478    if entry.is_closed() {
2479        return with_query_id(
2480            (StatusCode::NOT_FOUND, "session no longer available").into_response(),
2481            query_id,
2482        );
2483    }
2484    entry.touch();
2485    state.metrics.inc_sql_queries();
2486    let literals: Vec<String> = match req
2487        .params
2488        .iter()
2489        .map(render_sql_literal)
2490        .collect::<Result<_, _>>()
2491    {
2492        Ok(v) => v,
2493        Err(msg) => {
2494            state.metrics.inc_sql_errors();
2495            return (StatusCode::BAD_REQUEST, msg).into_response();
2496        }
2497    };
2498    let sql = format!("EXECUTE {}({})", req.name, literals.join(", "));
2499    let start = std::time::Instant::now();
2500    let result = if req.format.as_deref() == Some("arrow-stream") {
2501        let query = registration.into_query();
2502        match entry
2503            .session
2504            .run_stream_with_query_for_serialization(&sql, query)
2505            .await
2506        {
2507            Ok((stream, completion)) => Ok(sql_arrow_stream_response_controlled(
2508                stream,
2509                completion,
2510                sql_permit,
2511                (state.sql_max_output_rows, state.sql_max_output_bytes),
2512                &state,
2513                query_id,
2514                entry.session.sql_test_hook(),
2515            )),
2516            Err(error) => Err(error),
2517        }
2518    } else {
2519        let query = registration.into_query();
2520        match entry
2521            .session
2522            .run_with_query_for_serialization_with_limits(
2523                &sql,
2524                query,
2525                mongreldb_query::SqlCollectionLimits::new(
2526                    state.sql_max_output_rows,
2527                    state.sql_max_output_bytes,
2528                ),
2529            )
2530            .await
2531        {
2532            Ok(output) => Ok(dispatch_buffered_sql_format(
2533                &state,
2534                req.format.as_deref(),
2535                output,
2536                query_id,
2537                entry.session.sql_test_hook(),
2538                (state.sql_max_output_rows, state.sql_max_output_bytes),
2539            )
2540            .await),
2541            Err(error) => Err(error),
2542        }
2543    };
2544    let elapsed = start.elapsed();
2545    if elapsed >= state.slow_query_threshold {
2546        state.metrics.inc_slow_queries();
2547        eprintln!(
2548            "[slow-query] {}\u{00b5}s \u{2014} EXECUTE {}",
2549            elapsed.as_micros(),
2550            req.name
2551        );
2552    }
2553    match result {
2554        Ok(response) => with_query_id(response, query_id),
2555        Err(e) => {
2556            state.metrics.inc_sql_errors();
2557            // A reference to an unprepared/unknown statement is a client error.
2558            let msg = format!("{e}");
2559            let status = if msg.contains("does not exist") {
2560                StatusCode::NOT_FOUND
2561            } else {
2562                status_for_query_error(&e)
2563            };
2564            if status == status_for_query_error(&e) {
2565                tracked_query_error_response(&state, &e, Some(query_id))
2566            } else {
2567                with_query_id(
2568                    (status, format!("{msg} ({}µs)", elapsed.as_micros())).into_response(),
2569                    query_id,
2570                )
2571            }
2572        }
2573    }
2574}
2575
2576/// `DELETE /sessions/{id}/statements/{name}` — drop a prepared statement from
2577/// the session (SQL `DEALLOCATE`).
2578async fn deallocate_statement(
2579    State(state): State<Arc<AppState>>,
2580    OptionalPrincipal(principal): OptionalPrincipal,
2581    Path((id, name)): Path<(String, String)>,
2582    headers: axum::http::HeaderMap,
2583) -> Response {
2584    if !state.accepting_sql.load(Ordering::Acquire) {
2585        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
2586    }
2587    if !request_identity_is_current(&state, &principal) {
2588        return StatusCode::NOT_FOUND.into_response();
2589    }
2590    if let Err(msg) = validate_stmt_name(&name) {
2591        return (StatusCode::BAD_REQUEST, msg).into_response();
2592    }
2593    let owner = request_owner(&state, &principal);
2594    let Some(entry) = state.sessions.get(&id, &owner) else {
2595        return (
2596            StatusCode::NOT_FOUND,
2597            "session not found or not owned by caller",
2598        )
2599            .into_response();
2600    };
2601    let (options, query_id) =
2602        match resolve_query_options(&state, &headers, None, None, owner, Some(id)) {
2603            Ok(options) => options,
2604            Err(response) => return *response,
2605        };
2606    let query = match register_controlled_query(&state, &entry.session, options) {
2607        Ok(query) => query,
2608        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2609    };
2610    let registration = RegisteredQueryGuard::new(query);
2611    let _sql_permit = match acquire_sql_permit(&state, &entry.session, registration.query()).await {
2612        Ok(permit) => permit,
2613        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
2614    };
2615    let _guard = tokio::select! {
2616        guard = entry.lock.lock() => guard,
2617        _ = registration.query().control().cancelled() => {
2618            return tracked_query_error_response(
2619                &state,
2620                &cancellation_checkpoint_error(registration.query()),
2621                Some(query_id),
2622            );
2623        }
2624    };
2625    if entry.is_closed() {
2626        return with_query_id(
2627            (StatusCode::NOT_FOUND, "session no longer available").into_response(),
2628            query_id,
2629        );
2630    }
2631    entry.touch();
2632    state.metrics.inc_sql_queries();
2633    let sql = format!("DEALLOCATE {name}");
2634    let start = std::time::Instant::now();
2635    let result = entry
2636        .session
2637        .run_with_query(&sql, registration.into_query())
2638        .await;
2639    let elapsed = start.elapsed();
2640    if elapsed >= state.slow_query_threshold {
2641        state.metrics.inc_slow_queries();
2642        eprintln!(
2643            "[slow-query] {}\u{00b5}s query_id={} operation=DEALLOCATE",
2644            elapsed.as_micros(),
2645            query_id,
2646        );
2647    }
2648    match result {
2649        Ok(_) => with_query_id(
2650            Json(json!({ "deallocated": name })).into_response(),
2651            query_id,
2652        ),
2653        Err(error) => {
2654            state.metrics.inc_sql_errors();
2655            tracked_query_error_response(&state, &error, Some(query_id))
2656        }
2657    }
2658}
2659
2660/// `mongreldb_tables` gauge. Subject to the same auth middleware as every other
2661/// route (scrape with the configured Bearer token / Basic credentials).
2662async fn metrics_handler(
2663    State(state): State<Arc<AppState>>,
2664    OptionalPrincipal(principal): OptionalPrincipal,
2665) -> Response {
2666    if let Err(error) = state.db.require_for(
2667        request_principal(&state, &principal).as_ref(),
2668        &mongreldb_core::Permission::Admin,
2669    ) {
2670        return (status_for_error(&error), error.to_string()).into_response();
2671    }
2672    let body = state.metrics.prometheus_text(
2673        state.db.table_names().len(),
2674        state.query_registry.stats(),
2675        (
2676            state.pre_cancellations.len(),
2677            state.pre_cancellations.approximate_bytes(),
2678        ),
2679    );
2680    (
2681        [(
2682            header::CONTENT_TYPE,
2683            "text/plain; version=0.0.4; charset=utf-8".to_string(),
2684        )],
2685        body,
2686    )
2687        .into_response()
2688}
2689
2690/// `POST /compact` — compact all mounted tables.
2691async fn compact_all(
2692    State(state): State<Arc<AppState>>,
2693    OptionalPrincipal(principal): OptionalPrincipal,
2694) -> (StatusCode, Json<serde_json::Value>) {
2695    if let Err(error) = state.db.require_for(
2696        request_principal(&state, &principal).as_ref(),
2697        &mongreldb_core::Permission::Ddl,
2698    ) {
2699        return (
2700            status_for_error(&error),
2701            Json(json!({ "status": "error", "message": error.to_string() })),
2702        );
2703    }
2704    match state.db.compact() {
2705        Ok((compacted, skipped)) => (
2706            StatusCode::OK,
2707            Json(json!({
2708                "status": "ok",
2709                "compacted": compacted,
2710                "skipped": skipped,
2711            })),
2712        ),
2713        Err(e) => (
2714            StatusCode::INTERNAL_SERVER_ERROR,
2715            Json(json!({ "status": "error", "message": format!("{e}") })),
2716        ),
2717    }
2718}
2719
2720/// `POST /tables/{name}/compact` — compact a single table.
2721async fn compact_table(
2722    State(state): State<Arc<AppState>>,
2723    OptionalPrincipal(principal): OptionalPrincipal,
2724    Path(name): Path<String>,
2725) -> (StatusCode, Json<serde_json::Value>) {
2726    if let Err(error) = state.db.require_for(
2727        request_principal(&state, &principal).as_ref(),
2728        &mongreldb_core::Permission::Ddl,
2729    ) {
2730        return (
2731            status_for_error(&error),
2732            Json(json!({ "status": "error", "table": name, "message": error.to_string() })),
2733        );
2734    }
2735    match state.db.compact_table(&name) {
2736        Ok(true) => (
2737            StatusCode::OK,
2738            Json(json!({ "status": "compacted", "table": name })),
2739        ),
2740        Ok(false) => (
2741            StatusCode::OK,
2742            Json(json!({ "status": "skipped", "table": name, "reason": "fewer than 2 runs" })),
2743        ),
2744        Err(e) => (
2745            StatusCode::INTERNAL_SERVER_ERROR,
2746            Json(json!({ "status": "error", "table": name, "message": format!("{e}") })),
2747        ),
2748    }
2749}
2750
2751#[derive(Deserialize)]
2752struct CreateTableRequest {
2753    name: String,
2754    columns: Vec<ColumnDefJson>,
2755}
2756
2757#[derive(Deserialize)]
2758struct ColumnDefJson {
2759    id: u16,
2760    name: String,
2761    ty: String,
2762    primary_key: bool,
2763    #[serde(default)]
2764    nullable: bool,
2765}
2766
2767async fn create_table(
2768    State(state): State<Arc<AppState>>,
2769    OptionalPrincipal(principal): OptionalPrincipal,
2770    Json(req): Json<CreateTableRequest>,
2771) -> Response {
2772    if let Err(error) = state.db.require_for(
2773        request_principal(&state, &principal).as_ref(),
2774        &mongreldb_core::Permission::Ddl,
2775    ) {
2776        return (status_for_error(&error), error.to_string()).into_response();
2777    }
2778    let mut columns = Vec::new();
2779    for c in &req.columns {
2780        let ty = match c.ty.as_str() {
2781            "int64" | "bigint" => TypeId::Int64,
2782            "float64" | "double" => TypeId::Float64,
2783            "bytes" | "varchar" | "text" => TypeId::Bytes,
2784            "bool" => TypeId::Bool,
2785            other => {
2786                return (StatusCode::BAD_REQUEST, format!("unknown type: {other}")).into_response()
2787            }
2788        };
2789        let mut flags = mongreldb_core::schema::ColumnFlags::empty();
2790        if c.primary_key {
2791            flags = flags.with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY);
2792        }
2793        if c.nullable {
2794            flags = flags.with(mongreldb_core::schema::ColumnFlags::NULLABLE);
2795        }
2796        columns.push(mongreldb_core::schema::ColumnDef {
2797            id: c.id,
2798            name: c.name.clone(),
2799            ty,
2800            flags,
2801            default_value: None,
2802        });
2803    }
2804    let schema = Schema {
2805        schema_id: 0,
2806        columns,
2807        indexes: vec![],
2808        colocation: vec![],
2809        constraints: Default::default(),
2810        clustered: false,
2811    };
2812    if let Err(msg) = validate_table_name(&req.name) {
2813        return (StatusCode::BAD_REQUEST, msg).into_response();
2814    }
2815    match state.db.create_table(&req.name, schema) {
2816        Ok(id) => Json(json!({
2817            "table_id": id,
2818            "table_id_text": id.to_string()
2819        }))
2820        .into_response(),
2821        Err(error) => crate::kit::durable_core_error_response(&error)
2822            .unwrap_or_else(|| (status_for_error(&error), error.to_string()).into_response()),
2823    }
2824}
2825
2826async fn list_tables(
2827    State(state): State<Arc<AppState>>,
2828    OptionalPrincipal(principal): OptionalPrincipal,
2829) -> Json<Vec<String>> {
2830    let principal = request_principal(&state, &principal);
2831    Json(
2832        state
2833            .db
2834            .table_names()
2835            .into_iter()
2836            .filter(|table| {
2837                state
2838                    .db
2839                    .select_column_ids_for(table, principal.as_ref())
2840                    .is_ok()
2841            })
2842            .collect(),
2843    )
2844}
2845
2846async fn drop_table(
2847    State(state): State<Arc<AppState>>,
2848    OptionalPrincipal(principal): OptionalPrincipal,
2849    Path(name): Path<String>,
2850) -> Response {
2851    if let Err(error) = state.db.require_for(
2852        request_principal(&state, &principal).as_ref(),
2853        &mongreldb_core::Permission::Ddl,
2854    ) {
2855        return (status_for_error(&error), error.to_string()).into_response();
2856    }
2857    match state.db.drop_table_with_epoch(&name) {
2858        Ok(epoch) => Json(json!({
2859            "status": "committed",
2860            "epoch": epoch.0,
2861            "epoch_text": epoch.0.to_string()
2862        }))
2863        .into_response(),
2864        Err(error) => crate::kit::durable_core_error_response(&error)
2865            .unwrap_or_else(|| (status_for_error(&error), error.to_string()).into_response()),
2866    }
2867}
2868
2869#[derive(Deserialize)]
2870struct PutRequest {
2871    row: Vec<serde_json::Value>,
2872}
2873
2874pub(crate) fn json_to_value(v: &serde_json::Value, expected: &TypeId) -> Value {
2875    match (v, expected) {
2876        (serde_json::Value::Number(n), TypeId::Float64) => {
2877            n.as_f64().map(Value::Float64).unwrap_or(Value::Null)
2878        }
2879        (serde_json::Value::Number(n), TypeId::Int64) => {
2880            n.as_i64().map(Value::Int64).unwrap_or(Value::Null)
2881        }
2882        (serde_json::Value::String(s), TypeId::Bytes) => Value::Bytes(s.as_bytes().to_vec()),
2883        (serde_json::Value::String(s), TypeId::Enum { variants }) => {
2884            if variants.iter().any(|v| v == s) {
2885                Value::Bytes(s.as_bytes().to_vec())
2886            } else {
2887                Value::Null
2888            }
2889        }
2890        (serde_json::Value::Bool(b), TypeId::Bool) => Value::Bool(*b),
2891        // Embedding input: a JSON array of numbers, validated against the
2892        // declared dimension. Mismatched length or non-numeric elements → Null.
2893        (serde_json::Value::Array(arr), TypeId::Embedding { dim }) => {
2894            if arr.len() as u32 != *dim {
2895                return Value::Null;
2896            }
2897            let vec: Option<Vec<f32>> =
2898                arr.iter().map(|el| el.as_f64().map(|f| f as f32)).collect();
2899            vec.map(Value::Embedding).unwrap_or(Value::Null)
2900        }
2901        (serde_json::Value::Null, _) => Value::Null,
2902        // Lenient fallbacks for unknown/loosely-typed JSON.
2903        (serde_json::Value::Number(n), _) => {
2904            if let Some(i) = n.as_i64() {
2905                Value::Int64(i)
2906            } else if let Some(f) = n.as_f64() {
2907                Value::Float64(f)
2908            } else {
2909                Value::Null
2910            }
2911        }
2912        (serde_json::Value::String(s), _) => Value::Bytes(s.as_bytes().to_vec()),
2913        (serde_json::Value::Bool(b), _) => Value::Bool(*b),
2914        _ => Value::Null,
2915    }
2916}
2917
2918fn legacy_json_to_value(value: &serde_json::Value, expected: &TypeId) -> Result<Value, String> {
2919    if value.is_null() {
2920        return Ok(Value::Null);
2921    }
2922    match expected {
2923        TypeId::Bool => value
2924            .as_bool()
2925            .map(Value::Bool)
2926            .ok_or_else(|| "expected a boolean".into()),
2927        TypeId::Int8
2928        | TypeId::Int16
2929        | TypeId::Int32
2930        | TypeId::Int64
2931        | TypeId::UInt8
2932        | TypeId::UInt16
2933        | TypeId::UInt32
2934        | TypeId::UInt64
2935        | TypeId::TimestampNanos
2936        | TypeId::Date32
2937        | TypeId::Date64
2938        | TypeId::Time64 => value
2939            .as_i64()
2940            .map(Value::Int64)
2941            .ok_or_else(|| "expected a signed 64-bit integer".into()),
2942        TypeId::Float32 | TypeId::Float64 => value
2943            .as_f64()
2944            .filter(|value| value.is_finite())
2945            .map(Value::Float64)
2946            .ok_or_else(|| "expected a finite number".into()),
2947        TypeId::Bytes => match value.as_str() {
2948            Some(value) => Ok(Value::Bytes(value.as_bytes().to_vec())),
2949            None => decode_tagged_hex(value, "bytes").map(Value::Bytes),
2950        },
2951        TypeId::Enum { variants } => {
2952            let bytes = match value.as_str() {
2953                Some(value) => value.as_bytes().to_vec(),
2954                None => decode_tagged_hex(value, "bytes")?,
2955            };
2956            let value =
2957                std::str::from_utf8(&bytes).map_err(|_| "enum variant is not UTF-8".to_string())?;
2958            if !variants.iter().any(|variant| variant == value) {
2959                return Err("expected a declared enum variant".into());
2960            }
2961            Ok(Value::Bytes(bytes))
2962        }
2963        TypeId::Embedding { dim } => {
2964            let values = value
2965                .as_array()
2966                .ok_or_else(|| "expected an embedding array".to_string())?;
2967            if values.len() != *dim as usize {
2968                return Err(format!("expected an embedding with {dim} values"));
2969            }
2970            values
2971                .iter()
2972                .map(|value| {
2973                    value
2974                        .as_f64()
2975                        .map(|value| value as f32)
2976                        .filter(|value| value.is_finite())
2977                        .ok_or_else(|| "embedding values must be finite numbers".to_string())
2978                })
2979                .collect::<Result<Vec<_>, _>>()
2980                .map(Value::Embedding)
2981        }
2982        TypeId::Decimal128 { .. } => {
2983            let object = exact_tagged_object(value, "decimal", &["unscaled"])?;
2984            let text = object["unscaled"]
2985                .as_str()
2986                .ok_or_else(|| "decimal unscaled value must be a string".to_string())?;
2987            let value = text
2988                .parse::<i128>()
2989                .map_err(|_| "decimal unscaled value is invalid".to_string())?;
2990            if value.to_string() != text {
2991                return Err("decimal unscaled value is not canonical".into());
2992            }
2993            Ok(Value::Decimal(value))
2994        }
2995        TypeId::Interval => {
2996            let object = exact_tagged_object(value, "interval", &["months", "days", "nanos"])?;
2997            let months = canonical_i64(&object["months"], "interval months")?;
2998            let days = canonical_i64(&object["days"], "interval days")?
2999                .try_into()
3000                .map_err(|_| "interval days is outside i32 range".to_string())?;
3001            let nanos = canonical_i64(&object["nanos"], "interval nanos")?;
3002            Ok(Value::Interval {
3003                months,
3004                days,
3005                nanos,
3006            })
3007        }
3008        TypeId::Uuid => {
3009            let bytes = decode_tagged_hex(value, "uuid")?;
3010            let bytes: [u8; 16] = bytes
3011                .try_into()
3012                .map_err(|_| "UUID must contain exactly 16 bytes".to_string())?;
3013            Ok(Value::Uuid(bytes))
3014        }
3015        TypeId::Json => {
3016            let bytes = decode_tagged_hex(value, "json")?;
3017            std::str::from_utf8(&bytes).map_err(|_| "JSON value is not UTF-8".to_string())?;
3018            serde_json::from_slice::<serde_json::Value>(&bytes)
3019                .map_err(|error| format!("JSON value is invalid: {error}"))?;
3020            Ok(Value::Json(bytes))
3021        }
3022        TypeId::Array { .. } => Err("legacy put does not support array columns".into()),
3023    }
3024}
3025
3026fn exact_tagged_object<'a>(
3027    value: &'a serde_json::Value,
3028    expected_kind: &str,
3029    fields: &[&str],
3030) -> Result<&'a serde_json::Map<String, serde_json::Value>, String> {
3031    let object = value
3032        .as_object()
3033        .ok_or_else(|| format!("expected tagged {expected_kind} value"))?;
3034    if object.len() != fields.len() + 1
3035        || object
3036            .get("$mongreldb_type")
3037            .and_then(|value| value.as_str())
3038            != Some(expected_kind)
3039        || fields.iter().any(|field| !object.contains_key(*field))
3040    {
3041        return Err(format!("invalid tagged {expected_kind} value"));
3042    }
3043    Ok(object)
3044}
3045
3046fn decode_tagged_hex(value: &serde_json::Value, expected_kind: &str) -> Result<Vec<u8>, String> {
3047    let object = exact_tagged_object(value, expected_kind, &["hex"])?;
3048    let encoded = object["hex"]
3049        .as_str()
3050        .ok_or_else(|| format!("tagged {expected_kind} hex must be a string"))?;
3051    if encoded.len() % 2 != 0 {
3052        return Err(format!("tagged {expected_kind} hex has odd length"));
3053    }
3054    encoded
3055        .as_bytes()
3056        .chunks_exact(2)
3057        .map(|pair| {
3058            let high = hex_nibble(pair[0])?;
3059            let low = hex_nibble(pair[1])?;
3060            Ok((high << 4) | low)
3061        })
3062        .collect()
3063}
3064
3065fn hex_nibble(value: u8) -> Result<u8, String> {
3066    match value {
3067        b'0'..=b'9' => Ok(value - b'0'),
3068        b'a'..=b'f' => Ok(value - b'a' + 10),
3069        _ => Err("hex value must use lowercase ASCII digits".into()),
3070    }
3071}
3072
3073fn canonical_i64(value: &serde_json::Value, field: &str) -> Result<i64, String> {
3074    let text = value
3075        .as_str()
3076        .ok_or_else(|| format!("{field} must be a string"))?;
3077    let value = text
3078        .parse::<i64>()
3079        .map_err(|_| format!("{field} is invalid"))?;
3080    if value.to_string() != text {
3081        return Err(format!("{field} is not canonical"));
3082    }
3083    Ok(value)
3084}
3085
3086#[cfg(test)]
3087mod legacy_wire_tests {
3088    use super::*;
3089
3090    #[test]
3091    fn typed_values_are_exact_and_malformed_values_fail_closed() {
3092        assert_eq!(
3093            legacy_json_to_value(
3094                &json!({"$mongreldb_type": "bytes", "hex": "00ff61"}),
3095                &TypeId::Bytes,
3096            )
3097            .unwrap(),
3098            Value::Bytes(vec![0, 0xff, b'a'])
3099        );
3100        for (value, ty) in [
3101            (
3102                json!({"$mongreldb_type": "bytes", "hex": "00FF"}),
3103                TypeId::Bytes,
3104            ),
3105            (
3106                json!({"$mongreldb_type": "decimal", "unscaled": "01"}),
3107                TypeId::Decimal128 {
3108                    precision: 38,
3109                    scale: 0,
3110                },
3111            ),
3112            (
3113                json!({"$mongreldb_type": "uuid", "hex": "00"}),
3114                TypeId::Uuid,
3115            ),
3116            (
3117                json!({"$mongreldb_type": "json", "hex": "7b"}),
3118                TypeId::Json,
3119            ),
3120            (json!([1.0]), TypeId::Embedding { dim: 2 }),
3121            (json!([1e100, 1.0]), TypeId::Embedding { dim: 2 }),
3122        ] {
3123            assert!(legacy_json_to_value(&value, &ty).is_err(), "{value}");
3124        }
3125    }
3126}
3127
3128/// Parse a flat JSON array `[col_id, val, col_id, val, ...]` into typed cell
3129/// pairs, validating the schema. Returns `Err(message)` on any malformed pair.
3130fn parse_cells(
3131    row: &[serde_json::Value],
3132    schema: &mongreldb_core::schema::Schema,
3133) -> Result<Vec<(u16, Value)>, String> {
3134    if row.len() & 1 != 0 {
3135        return Err("row must be an even-length array of [col_id, value] pairs".into());
3136    }
3137    let mut out = Vec::with_capacity(row.len() / 2);
3138    let mut seen = std::collections::HashSet::new();
3139    for chunk in row.chunks(2) {
3140        let col_id = chunk[0]
3141            .as_u64()
3142            .and_then(|value| u16::try_from(value).ok())
3143            .ok_or("column id must be an unsigned 16-bit integer")?;
3144        if !seen.insert(col_id) {
3145            return Err(format!("duplicate column id {col_id}"));
3146        }
3147        let expected = schema
3148            .columns
3149            .iter()
3150            .find(|c| c.id == col_id)
3151            .map(|c| c.ty.clone())
3152            .ok_or_else(|| format!("unknown column id {col_id}"))?;
3153        let val = legacy_json_to_value(&chunk[1], &expected)?;
3154        out.push((col_id, val));
3155    }
3156    Ok(out)
3157}
3158
3159/// Basic validation for a table name: non-empty and no path separators.
3160pub(crate) fn validate_table_name(name: &str) -> Result<(), String> {
3161    if name.is_empty() {
3162        return Err("table name must not be empty".into());
3163    }
3164    if name.contains('/') || name.contains('\\') || name.contains('\0') {
3165        return Err("table name contains invalid characters".into());
3166    }
3167    Ok(())
3168}
3169
3170async fn put_row(
3171    State(state): State<Arc<AppState>>,
3172    OptionalPrincipal(principal): OptionalPrincipal,
3173    Path(name): Path<String>,
3174    Json(req): Json<PutRequest>,
3175) -> Response {
3176    let handle = match state.db.table(&name) {
3177        Ok(h) => h,
3178        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
3179    };
3180    let schema = handle.lock().schema().clone();
3181    let row = match parse_cells(&req.row, &schema) {
3182        Ok(r) => r,
3183        Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
3184    };
3185    state.metrics.inc_puts();
3186    let principal = request_principal(&state, &principal);
3187    match state.db.put_for(&name, row, principal.as_ref()) {
3188        Ok(rid) => Json(json!({ "row_id": rid.0.to_string() })).into_response(),
3189        Err(e) => (status_for_error(&e), e.to_string()).into_response(),
3190    }
3191}
3192
3193async fn count(
3194    State(state): State<Arc<AppState>>,
3195    OptionalPrincipal(principal): OptionalPrincipal,
3196    Path(name): Path<String>,
3197) -> Response {
3198    let principal = request_principal(&state, &principal);
3199    match state.db.count_for(&name, principal.as_ref()) {
3200        Ok(count) => Json(json!({ "count": count })).into_response(),
3201        Err(error) => (status_for_error(&error), error.to_string()).into_response(),
3202    }
3203}
3204
3205async fn commit(
3206    State(state): State<Arc<AppState>>,
3207    OptionalPrincipal(principal): OptionalPrincipal,
3208    Path(name): Path<String>,
3209) -> Response {
3210    if let Err(error) = state.db.require_for(
3211        request_principal(&state, &principal).as_ref(),
3212        &mongreldb_core::Permission::Update {
3213            table: name.clone(),
3214        },
3215    ) {
3216        return (status_for_error(&error), error.to_string()).into_response();
3217    }
3218    let handle = match state.db.table(&name) {
3219        Ok(h) => h,
3220        Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
3221    };
3222    let mut g = handle.lock();
3223    state.metrics.inc_commits();
3224    match g.commit() {
3225        Ok(epoch) => Json(json!({
3226            "epoch": epoch.0,
3227            "epoch_text": epoch.0.to_string()
3228        }))
3229        .into_response(),
3230        Err(error) => crate::kit::durable_core_error_response(&error)
3231            .unwrap_or_else(|| (status_for_error(&error), error.to_string()).into_response()),
3232    }
3233}
3234
3235#[derive(Deserialize)]
3236struct SqlRequest {
3237    sql: String,
3238    /// Output format: `"json"` (the default) for a JSON array of row objects,
3239    /// `"arrow"` for Arrow IPC file bytes.
3240    #[serde(default)]
3241    format: Option<String>,
3242    /// Body values take precedence over the equivalent convenience headers.
3243    #[serde(default)]
3244    query_id: Option<QueryId>,
3245    #[serde(default)]
3246    timeout_ms: Option<u64>,
3247    #[serde(default)]
3248    max_output_rows: Option<u64>,
3249    #[serde(default)]
3250    max_output_bytes: Option<u64>,
3251    #[serde(default)]
3252    idempotency_key: Option<String>,
3253    #[serde(default)]
3254    pagination: Option<SqlPaginationRequest>,
3255}
3256
3257#[derive(Clone, Debug, Deserialize, Serialize)]
3258struct SqlPaginationRequest {
3259    page_size_rows: u64,
3260    projection: Vec<String>,
3261    #[serde(default)]
3262    max_page_bytes: Option<u64>,
3263    #[serde(default)]
3264    max_page_tokens: Option<u64>,
3265}
3266
3267#[derive(Clone)]
3268struct ResolvedSqlPagination {
3269    projection: Vec<String>,
3270    limits: sql_pages::SqlPageLimits,
3271}
3272
3273struct ResolvedSqlRequest {
3274    request: SqlRequest,
3275    output_limits: (usize, usize),
3276    idempotency: Option<sql_idempotency::SqlIdempotencyExecution>,
3277    pagination: Option<ResolvedSqlPagination>,
3278}
3279
3280fn query_error_response(
3281    error: &mongreldb_query::MongrelQueryError,
3282    query_id: Option<QueryId>,
3283) -> Response {
3284    query_error_response_with_status(error, query_id, None)
3285}
3286
3287fn query_error_response_with_status(
3288    error: &mongreldb_query::MongrelQueryError,
3289    query_id: Option<QueryId>,
3290    status: Option<&mongreldb_query::QueryStatus>,
3291) -> Response {
3292    use mongreldb_query::MongrelQueryError;
3293    let (base_code, id) = match error {
3294        MongrelQueryError::QueryCancelled { query_id, .. } => ("QUERY_CANCELLED", Some(*query_id)),
3295        MongrelQueryError::DeadlineExceeded { query_id, .. } => {
3296            ("DEADLINE_EXCEEDED", Some(*query_id))
3297        }
3298        MongrelQueryError::QueryIdConflict { query_id } => ("QUERY_ID_CONFLICT", Some(*query_id)),
3299        MongrelQueryError::QueryRegistryFull => ("QUERY_REGISTRY_FULL", query_id),
3300        MongrelQueryError::ResultLimitExceeded { query_id, .. } => {
3301            ("RESULT_LIMIT_EXCEEDED", Some(*query_id))
3302        }
3303        MongrelQueryError::TransactionAborted => ("TRANSACTION_ABORTED", query_id),
3304        MongrelQueryError::NoSqlTransaction => ("NO_SQL_TRANSACTION", query_id),
3305        MongrelQueryError::SavepointNotFound { .. } => ("SAVEPOINT_NOT_FOUND", query_id),
3306        MongrelQueryError::CommitOutcome { query_id, .. } => ("COMMIT_OUTCOME", Some(*query_id)),
3307        MongrelQueryError::OutcomeUnknown { query_id, .. } => {
3308            ("QUERY_OUTCOME_UNKNOWN", Some(*query_id))
3309        }
3310        _ => ("QUERY_FAILED", query_id),
3311    };
3312    let (
3313        error_committed,
3314        error_committed_statements,
3315        error_last_commit_epoch,
3316        error_first_commit_statement_index,
3317        error_last_commit_statement_index,
3318    ) = match error {
3319        MongrelQueryError::QueryCancelled {
3320            committed,
3321            committed_statements,
3322            last_commit_epoch,
3323            first_commit_statement_index,
3324            last_commit_statement_index,
3325            ..
3326        }
3327        | MongrelQueryError::DeadlineExceeded {
3328            committed,
3329            committed_statements,
3330            last_commit_epoch,
3331            first_commit_statement_index,
3332            last_commit_statement_index,
3333            ..
3334        }
3335        | MongrelQueryError::ResultLimitExceeded {
3336            committed,
3337            committed_statements,
3338            last_commit_epoch,
3339            first_commit_statement_index,
3340            last_commit_statement_index,
3341            ..
3342        } => (
3343            *committed,
3344            *committed_statements,
3345            *last_commit_epoch,
3346            *first_commit_statement_index,
3347            *last_commit_statement_index,
3348        ),
3349        MongrelQueryError::CommitOutcome {
3350            committed,
3351            committed_statements,
3352            last_commit_epoch,
3353            first_commit_statement_index,
3354            last_commit_statement_index,
3355            ..
3356        } => (
3357            *committed,
3358            *committed_statements,
3359            *last_commit_epoch,
3360            *first_commit_statement_index,
3361            *last_commit_statement_index,
3362        ),
3363        _ => (false, 0, None, None, None),
3364    };
3365    let committed = status.map_or_else(
3366        || error_committed,
3367        |status| status.durable_outcome.committed,
3368    );
3369    let outcome_unknown = matches!(error, MongrelQueryError::OutcomeUnknown { .. })
3370        || status.is_some_and(|status| status.outcome_unknown);
3371    let code = status
3372        .and_then(|status| {
3373            status
3374                .terminal_error
3375                .as_ref()
3376                .map(|error| error.code.as_str())
3377        })
3378        .unwrap_or(match (base_code, committed) {
3379            ("QUERY_CANCELLED", true) => "QUERY_CANCELLED_AFTER_COMMIT",
3380            ("DEADLINE_EXCEEDED", true) => "DEADLINE_AFTER_COMMIT",
3381            _ => base_code,
3382        });
3383    let response_status = if outcome_unknown {
3384        "outcome_unknown"
3385    } else {
3386        status
3387            .and_then(mongreldb_query::QueryStatus::terminal_state)
3388            .map(terminal_state_name)
3389            .unwrap_or_else(|| match (error, committed) {
3390                (MongrelQueryError::QueryCancelled { .. }, true) => "cancelled_after_commit",
3391                (MongrelQueryError::QueryCancelled { .. }, false) => "cancelled_before_commit",
3392                (MongrelQueryError::DeadlineExceeded { .. }, true) => "deadline_after_commit",
3393                (MongrelQueryError::DeadlineExceeded { .. }, false) => "deadline_before_commit",
3394                (_, true) => "committed_with_error",
3395                _ => "failed_before_commit",
3396            })
3397    };
3398    let (completed_statements, statement_index) = status.map_or_else(
3399        || match error {
3400            MongrelQueryError::QueryCancelled {
3401                completed_statements,
3402                cancelled_statement_index,
3403                ..
3404            }
3405            | MongrelQueryError::DeadlineExceeded {
3406                completed_statements,
3407                cancelled_statement_index,
3408                ..
3409            } => (*completed_statements, *cancelled_statement_index),
3410            MongrelQueryError::ResultLimitExceeded {
3411                completed_statements,
3412                statement_index,
3413                ..
3414            } => (*completed_statements, *statement_index),
3415            MongrelQueryError::CommitOutcome {
3416                completed_statements,
3417                statement_index,
3418                ..
3419            } => (*completed_statements, *statement_index),
3420            _ => (0, 0),
3421        },
3422        |status| (status.completed_statements, status.statement_index),
3423    );
3424    let committed_statements = status.map_or(error_committed_statements, |status| {
3425        status.durable_outcome.committed_statements
3426    });
3427    let last_commit_epoch = status.map_or(error_last_commit_epoch, |status| {
3428        status.durable_outcome.last_commit_epoch
3429    });
3430    let first_commit_statement_index = status
3431        .map_or(error_first_commit_statement_index, |status| {
3432            status.durable_outcome.first_commit_statement_index
3433        });
3434    let last_commit_statement_index = status.map_or(error_last_commit_statement_index, |status| {
3435        status.durable_outcome.last_commit_statement_index
3436    });
3437    let cancellation_reason = status
3438        .map(|status| status.cancellation_reason)
3439        .or(match error {
3440            MongrelQueryError::QueryCancelled { reason, .. } => Some(*reason),
3441            MongrelQueryError::DeadlineExceeded { .. } => Some(CancellationReason::Deadline),
3442            _ => None,
3443        })
3444        .map(cancellation_reason_name);
3445    let cancel_outcome = match error {
3446        MongrelQueryError::QueryCancelled { .. } | MongrelQueryError::DeadlineExceeded { .. } => {
3447            Some("accepted")
3448        }
3449        _ => status.and_then(query_cancel_outcome),
3450    };
3451    let outcome = if outcome_unknown {
3452        json!({
3453            "committed": null,
3454            "committed_statements": null,
3455            "last_commit_epoch": null,
3456            "last_commit_epoch_text": null,
3457            "first_commit_statement_index": null,
3458            "last_commit_statement_index": null,
3459            "completed_statements": null,
3460            "statement_index": null,
3461            "serialization": "unknown",
3462        })
3463    } else {
3464        status.map_or_else(
3465            || {
3466                json!({
3467                    "committed": committed,
3468                    "committed_statements": committed_statements,
3469                    "last_commit_epoch": last_commit_epoch,
3470                    "last_commit_epoch_text": epoch_text(last_commit_epoch),
3471                    "first_commit_statement_index": first_commit_statement_index,
3472                    "last_commit_statement_index": last_commit_statement_index,
3473                    "completed_statements": completed_statements,
3474                    "statement_index": statement_index,
3475                    "serialization": "unknown",
3476                })
3477            },
3478            |status| query_outcome_json(Some(status)),
3479        )
3480    };
3481    let http_status = match code {
3482        "QUERY_CANCELLED_AFTER_COMMIT" | "DEADLINE_AFTER_COMMIT" => StatusCode::CONFLICT,
3483        "QUERY_CANCELLED" => client_closed_request_status(),
3484        "DEADLINE_EXCEEDED" => StatusCode::GATEWAY_TIMEOUT,
3485        _ => status_for_query_error(error),
3486    };
3487    let mut response = (
3488        http_status,
3489        Json(json!({
3490            "query_id": id.map(|value| value.to_string()),
3491            "status": response_status,
3492            "terminal_state": response_status,
3493            "committed": (!outcome_unknown).then_some(committed),
3494            "committed_statements": (!outcome_unknown).then_some(committed_statements),
3495            "last_commit_epoch": (!outcome_unknown).then_some(last_commit_epoch).flatten(),
3496            "last_commit_epoch_text": (!outcome_unknown).then_some(epoch_text(last_commit_epoch)).flatten(),
3497            "first_commit_statement_index": (!outcome_unknown).then_some(first_commit_statement_index).flatten(),
3498            "last_commit_statement_index": (!outcome_unknown).then_some(last_commit_statement_index).flatten(),
3499            "completed_statements": (!outcome_unknown).then_some(completed_statements),
3500            "statement_index": (!outcome_unknown).then_some(statement_index),
3501            "cancel_outcome": cancel_outcome,
3502            "cancellation_reason": cancellation_reason,
3503            "retryable": matches!(error, MongrelQueryError::QueryRegistryFull),
3504            "server_state": status.map(|status| query_phase_name(status.phase)),
3505            "outcome": outcome,
3506            "error": {
3507                "code": code,
3508                "message": error.to_string(),
3509                "query_id": id.map(|value| value.to_string()),
3510                "committed": (!outcome_unknown).then_some(committed),
3511                "retryable": matches!(error, MongrelQueryError::QueryRegistryFull),
3512            }
3513        })),
3514    )
3515        .into_response();
3516    if let Some(id) = id {
3517        add_query_id_header(&mut response, id);
3518    }
3519    response
3520}
3521
3522fn record_query_error(metrics: &metrics::Metrics, error: &mongreldb_query::MongrelQueryError) {
3523    match error {
3524        mongreldb_query::MongrelQueryError::QueryCancelled { reason, .. } => {
3525            metrics.inc_sql_cancelled(*reason)
3526        }
3527        mongreldb_query::MongrelQueryError::DeadlineExceeded { .. } => {
3528            metrics.inc_sql_deadline_exceeded();
3529            metrics.inc_sql_cancelled(CancellationReason::Deadline);
3530        }
3531        _ => {}
3532    }
3533}
3534
3535fn tracked_query_error_response(
3536    state: &AppState,
3537    error: &mongreldb_query::MongrelQueryError,
3538    query_id: Option<QueryId>,
3539) -> Response {
3540    record_query_error(&state.metrics, error);
3541    if let mongreldb_query::MongrelQueryError::QueryCancelled { query_id, .. } = error {
3542        if let Some(requested_at) = state
3543            .query_registry
3544            .status(*query_id)
3545            .and_then(|status| status.cancel_requested_at)
3546        {
3547            state
3548                .metrics
3549                .observe_sql_cancel_latency(requested_at.elapsed());
3550        }
3551    }
3552    let status = if matches!(
3553        error,
3554        mongreldb_query::MongrelQueryError::QueryIdConflict { .. }
3555            | mongreldb_query::MongrelQueryError::QueryRegistryFull
3556    ) {
3557        None
3558    } else {
3559        query_id
3560            .or(match error {
3561                mongreldb_query::MongrelQueryError::QueryCancelled { query_id, .. }
3562                | mongreldb_query::MongrelQueryError::DeadlineExceeded { query_id, .. }
3563                | mongreldb_query::MongrelQueryError::CommitOutcome { query_id, .. }
3564                | mongreldb_query::MongrelQueryError::OutcomeUnknown { query_id, .. } => {
3565                    Some(*query_id)
3566                }
3567                _ => None,
3568            })
3569            .and_then(|query_id| state.query_registry.status(query_id))
3570    };
3571    query_error_response_with_status(error, query_id, status.as_ref())
3572}
3573
3574fn add_query_id_header(response: &mut Response, query_id: QueryId) {
3575    if let Ok(value) = axum::http::HeaderValue::from_str(&query_id.to_string()) {
3576        response.headers_mut().insert("x-mongreldb-query-id", value);
3577    }
3578}
3579
3580fn with_query_id(mut response: Response, query_id: QueryId) -> Response {
3581    add_query_id_header(&mut response, query_id);
3582    response
3583}
3584
3585fn bad_query_control_request(message: impl Into<String>, query_id: Option<QueryId>) -> Response {
3586    let mut response = (
3587        StatusCode::BAD_REQUEST,
3588        Json(json!({
3589            "query_id": query_id.map(|value| value.to_string()),
3590            "status": "failed_before_commit",
3591            "terminal_state": "failed_before_commit",
3592            "committed": false,
3593            "committed_statements": 0,
3594            "last_commit_epoch": null,
3595            "last_commit_epoch_text": null,
3596            "first_commit_statement_index": null,
3597            "last_commit_statement_index": null,
3598            "completed_statements": 0,
3599            "statement_index": 0,
3600            "cancel_outcome": null,
3601            "cancellation_reason": null,
3602            "retryable": false,
3603            "server_state": "failed",
3604            "outcome": {
3605                "committed": false,
3606                "committed_statements": 0,
3607                "last_commit_epoch": null,
3608                "last_commit_epoch_text": null,
3609                "first_commit_statement_index": null,
3610                "last_commit_statement_index": null,
3611                "completed_statements": 0,
3612                "statement_index": 0,
3613                "serialization": "not_started",
3614            },
3615            "error": {
3616                "code": "INVALID_QUERY_OPTIONS",
3617                "message": message.into(),
3618                "query_id": query_id.map(|value| value.to_string()),
3619                "committed": false,
3620                "retryable": false,
3621            }
3622        })),
3623    )
3624        .into_response();
3625    if let Some(query_id) = query_id {
3626        add_query_id_header(&mut response, query_id);
3627    }
3628    response
3629}
3630
3631fn resolve_query_options(
3632    state: &AppState,
3633    headers: &axum::http::HeaderMap,
3634    body_query_id: Option<QueryId>,
3635    body_timeout_ms: Option<u64>,
3636    owner: String,
3637    session_id: Option<String>,
3638) -> std::result::Result<(SqlQueryOptions, QueryId), Box<Response>> {
3639    let query_id = match body_query_id {
3640        Some(query_id) => query_id,
3641        None => match headers.get("x-mongreldb-query-id") {
3642            Some(value) => {
3643                let value = value.to_str().map_err(|_| {
3644                    Box::new(bad_query_control_request(
3645                        "X-MongrelDB-Query-ID is not valid text",
3646                        None,
3647                    ))
3648                })?;
3649                value
3650                    .parse()
3651                    .map_err(|error: mongreldb_query::MongrelQueryError| {
3652                        Box::new(bad_query_control_request(error.to_string(), None))
3653                    })?
3654            }
3655            None => {
3656                QueryId::random().map_err(|error| Box::new(query_error_response(&error, None)))?
3657            }
3658        },
3659    };
3660    let timeout_ms = match body_timeout_ms {
3661        Some(timeout_ms) => timeout_ms,
3662        None => match headers.get("x-mongreldb-timeout-ms") {
3663            Some(value) => value
3664                .to_str()
3665                .ok()
3666                .and_then(|value| value.parse::<u64>().ok())
3667                .ok_or_else(|| {
3668                    Box::new(bad_query_control_request(
3669                        "X-MongrelDB-Timeout-Ms must be a positive integer",
3670                        Some(query_id),
3671                    ))
3672                })?,
3673            None => state
3674                .sql_default_timeout
3675                .as_millis()
3676                .min(u128::from(u64::MAX)) as u64,
3677        },
3678    };
3679    if timeout_ms == 0 {
3680        return Err(Box::new(bad_query_control_request(
3681            "timeout_ms must be positive",
3682            Some(query_id),
3683        )));
3684    }
3685    let timeout = std::time::Duration::from_millis(timeout_ms);
3686    if timeout > state.sql_max_timeout {
3687        return Err(Box::new(bad_query_control_request(
3688            format!(
3689                "timeout_ms exceeds server maximum of {}",
3690                state.sql_max_timeout.as_millis()
3691            ),
3692            Some(query_id),
3693        )));
3694    }
3695    Ok((
3696        SqlQueryOptions {
3697            query_id: Some(query_id),
3698            timeout: Some(timeout),
3699            owner: Some(owner),
3700            session_id,
3701            parent_control: None,
3702        },
3703        query_id,
3704    ))
3705}
3706
3707fn resolve_sql_output_limits(
3708    state: &AppState,
3709    request: &SqlRequest,
3710    query_id: QueryId,
3711) -> std::result::Result<(usize, usize), Box<Response>> {
3712    fn resolve(
3713        requested: Option<u64>,
3714        configured: usize,
3715        name: &str,
3716        query_id: QueryId,
3717    ) -> std::result::Result<usize, Box<Response>> {
3718        if requested == Some(0) {
3719            return Err(Box::new(bad_query_control_request(
3720                format!("{name} must be positive"),
3721                Some(query_id),
3722            )));
3723        }
3724        let requested = requested
3725            .and_then(|value| usize::try_from(value).ok())
3726            .unwrap_or(usize::MAX);
3727        Ok(requested.min(configured))
3728    }
3729
3730    Ok((
3731        resolve(
3732            request.max_output_rows,
3733            state.sql_max_output_rows,
3734            "max_output_rows",
3735            query_id,
3736        )?,
3737        resolve(
3738            request.max_output_bytes,
3739            state.sql_max_output_bytes,
3740            "max_output_bytes",
3741            query_id,
3742        )?,
3743    ))
3744}
3745
3746fn resolve_sql_pagination(
3747    headers: &axum::http::HeaderMap,
3748    request: &SqlRequest,
3749    output_limits: (usize, usize),
3750    registration: RegisteredQueryGuard,
3751    query_id: QueryId,
3752) -> Result<(RegisteredQueryGuard, Option<ResolvedSqlPagination>), Box<Response>> {
3753    let Some(pagination) = request.pagination.as_ref() else {
3754        return Ok((registration, None));
3755    };
3756    if requested_sql_idempotency_key(headers, request)
3757        .ok()
3758        .flatten()
3759        .is_some()
3760    {
3761        return Err(Box::new(registered_sql_error_response(
3762            registration,
3763            query_id,
3764            StatusCode::BAD_REQUEST,
3765            "INCOMPATIBLE_SQL_CONTROLS",
3766            "idempotency_key cannot be combined with SQL pagination",
3767            false,
3768        )));
3769    }
3770    if request
3771        .format
3772        .as_deref()
3773        .is_some_and(|format| format != "json")
3774    {
3775        return Err(Box::new(registered_sql_error_response(
3776            registration,
3777            query_id,
3778            StatusCode::BAD_REQUEST,
3779            "PAGINATION_REQUIRES_JSON",
3780            "SQL pagination supports JSON responses only",
3781            false,
3782        )));
3783    }
3784    registration.query().set_sql_metadata(&request.sql);
3785    if !mongreldb_query::is_single_read_only_query(&request.sql) {
3786        return Err(Box::new(registered_sql_error_response(
3787            registration,
3788            query_id,
3789            StatusCode::BAD_REQUEST,
3790            "PAGINATION_REQUIRES_SINGLE_READ_QUERY",
3791            "SQL pagination accepts exactly one read-only query statement",
3792            false,
3793        )));
3794    }
3795    let page_size = match usize::try_from(pagination.page_size_rows) {
3796        Ok(0) | Err(_) => {
3797            return Err(Box::new(registered_sql_error_response(
3798                registration,
3799                query_id,
3800                StatusCode::BAD_REQUEST,
3801                "INVALID_PAGINATION_OPTIONS",
3802                "pagination.page_size_rows must be positive",
3803                false,
3804            )))
3805        }
3806        Ok(value) => value.min(output_limits.0),
3807    };
3808    if pagination.projection.is_empty() || pagination.projection.len() > 128 {
3809        return Err(Box::new(registered_sql_error_response(
3810            registration,
3811            query_id,
3812            StatusCode::BAD_REQUEST,
3813            "INVALID_SQL_PROJECTION",
3814            "pagination.projection must contain between 1 and 128 output column names",
3815            false,
3816        )));
3817    }
3818    let mut seen = std::collections::HashSet::new();
3819    let metadata_bytes = pagination
3820        .projection
3821        .iter()
3822        .map(String::len)
3823        .fold(0usize, usize::saturating_add);
3824    if metadata_bytes > 16 * 1024
3825        || pagination.projection.iter().any(|column| {
3826            column.is_empty()
3827                || column == "*"
3828                || column.len() > 256
3829                || !seen.insert(column.as_str())
3830        })
3831    {
3832        return Err(Box::new(registered_sql_error_response(
3833            registration,
3834            query_id,
3835            StatusCode::BAD_REQUEST,
3836            "INVALID_SQL_PROJECTION",
3837            "pagination.projection requires unique explicit output names of at most 256 bytes",
3838            false,
3839        )));
3840    }
3841    let max_page_bytes = match pagination.max_page_bytes {
3842        Some(0) => {
3843            return Err(Box::new(registered_sql_error_response(
3844                registration,
3845                query_id,
3846                StatusCode::BAD_REQUEST,
3847                "INVALID_PAGINATION_OPTIONS",
3848                "pagination.max_page_bytes must be positive",
3849                false,
3850            )))
3851        }
3852        Some(value) => usize::try_from(value)
3853            .unwrap_or(usize::MAX)
3854            .min(output_limits.1),
3855        None => output_limits.1.min(1024 * 1024),
3856    };
3857    let token_cap = (output_limits.1.saturating_add(3) / 4).max(1);
3858    let max_page_tokens = match pagination.max_page_tokens {
3859        Some(0) => {
3860            return Err(Box::new(registered_sql_error_response(
3861                registration,
3862                query_id,
3863                StatusCode::BAD_REQUEST,
3864                "INVALID_PAGINATION_OPTIONS",
3865                "pagination.max_page_tokens must be positive",
3866                false,
3867            )))
3868        }
3869        Some(value) => usize::try_from(value).unwrap_or(usize::MAX).min(token_cap),
3870        None => (max_page_bytes.saturating_add(3) / 4).max(1),
3871    };
3872    Ok((
3873        registration,
3874        Some(ResolvedSqlPagination {
3875            projection: pagination.projection.clone(),
3876            limits: sql_pages::SqlPageLimits {
3877                rows: page_size,
3878                bytes: max_page_bytes,
3879                tokens: max_page_tokens,
3880            },
3881        }),
3882    ))
3883}
3884
3885fn requested_sql_idempotency_key(
3886    headers: &axum::http::HeaderMap,
3887    request: &SqlRequest,
3888) -> Result<Option<String>, &'static str> {
3889    let header = match headers.get("idempotency-key") {
3890        Some(value) => Some(
3891            value
3892                .to_str()
3893                .map_err(|_| "Idempotency-Key must be valid UTF-8")?,
3894        ),
3895        None => None,
3896    };
3897    match (request.idempotency_key.as_deref(), header) {
3898        (Some(body), Some(header)) if body != header => {
3899            Err("body idempotency_key and Idempotency-Key header must match")
3900        }
3901        (Some(body), _) => Ok(Some(body.to_owned())),
3902        (None, Some(header)) => Ok(Some(header.to_owned())),
3903        (None, None) => Ok(None),
3904    }
3905}
3906
3907fn sql_idempotency_binding(
3908    request: &SqlRequest,
3909    output_limits: (usize, usize),
3910    session_id: Option<&str>,
3911    expires_after_ms: u64,
3912) -> Result<sql_idempotency::SqlIdempotencyBinding, serde_json::Error> {
3913    let request_semantics = serde_json::to_vec(&json!({
3914        "format": request.format.as_deref().unwrap_or("json"),
3915        "max_output_rows": output_limits.0,
3916        "max_output_bytes": output_limits.1,
3917        "pagination": request.pagination.as_ref(),
3918    }))?;
3919    let session_semantics = session_id.map_or_else(
3920        || b"ephemeral".to_vec(),
3921        |session_id| {
3922            let mut semantics = b"session\0".to_vec();
3923            semantics.extend_from_slice(session_id.as_bytes());
3924            semantics
3925        },
3926    );
3927    Ok(sql_idempotency::SqlIdempotencyBinding {
3928        sql_fingerprint: mongreldb_query::normalized_sql_fingerprint(&request.sql),
3929        // `/sql` currently has no separate bind-parameter array. SQL literals
3930        // are covered by the normalized SQL fingerprint above.
3931        parameter_hash: sql_idempotency::hash(b"[]"),
3932        request_semantics_hash: sql_idempotency::hash(&request_semantics),
3933        session_semantics_hash: sql_idempotency::hash(&session_semantics),
3934        expires_after_ms,
3935    })
3936}
3937
3938struct SqlIdempotencyContext<'a> {
3939    headers: &'a axum::http::HeaderMap,
3940    request: &'a SqlRequest,
3941    output_limits: (usize, usize),
3942    owner: &'a str,
3943    session_id: Option<&'a str>,
3944    session_in_transaction: bool,
3945    query_id: QueryId,
3946}
3947
3948async fn begin_sql_idempotency(
3949    state: &AppState,
3950    context: SqlIdempotencyContext<'_>,
3951    registration: RegisteredQueryGuard,
3952) -> Result<
3953    (
3954        RegisteredQueryGuard,
3955        Option<sql_idempotency::SqlIdempotencyExecution>,
3956    ),
3957    Response,
3958> {
3959    let SqlIdempotencyContext {
3960        headers,
3961        request,
3962        output_limits,
3963        owner,
3964        session_id,
3965        session_in_transaction,
3966        query_id,
3967    } = context;
3968    let key = match requested_sql_idempotency_key(headers, request) {
3969        Ok(key) => key,
3970        Err(message) => {
3971            return Err(registered_sql_error_response(
3972                registration,
3973                query_id,
3974                StatusCode::BAD_REQUEST,
3975                "INVALID_IDEMPOTENCY_KEY",
3976                message,
3977                false,
3978            ))
3979        }
3980    };
3981    let Some(key) = key else {
3982        return Ok((registration, None));
3983    };
3984    match mongreldb_query::classify_sql_idempotency(&request.sql) {
3985        mongreldb_query::SqlIdempotencyClass::ReadOnly
3986        | mongreldb_query::SqlIdempotencyClass::Unsupported => {
3987            return Err(registered_sql_error_response(
3988                registration,
3989                query_id,
3990                StatusCode::BAD_REQUEST,
3991                "IDEMPOTENCY_REQUIRES_SINGLE_WRITE",
3992                "idempotency_key accepts one non-transaction SQL write statement",
3993                false,
3994            ));
3995        }
3996        mongreldb_query::SqlIdempotencyClass::SingleWrite => {}
3997    }
3998    if let Err(message) = sql_idempotency::SqlIdempotencyStore::validate_key(&key) {
3999        return Err(registered_sql_error_response(
4000            registration,
4001            query_id,
4002            StatusCode::BAD_REQUEST,
4003            "INVALID_IDEMPOTENCY_KEY",
4004            message,
4005            false,
4006        ));
4007    }
4008    if request
4009        .format
4010        .as_deref()
4011        .is_some_and(|format| format != "json")
4012    {
4013        return Err(registered_sql_error_response(
4014            registration,
4015            query_id,
4016            StatusCode::BAD_REQUEST,
4017            "IDEMPOTENCY_REQUIRES_JSON",
4018            "SQL idempotency supports buffered JSON responses only",
4019            false,
4020        ));
4021    }
4022    if session_in_transaction {
4023        return Err(registered_sql_error_response(
4024            registration,
4025            query_id,
4026            StatusCode::CONFLICT,
4027            "IDEMPOTENCY_UNSUPPORTED_IN_TRANSACTION",
4028            "SQL idempotency cannot be used inside an open session transaction",
4029            false,
4030        ));
4031    }
4032    registration.query().set_sql_metadata(&request.sql);
4033    let binding = match sql_idempotency_binding(
4034        request,
4035        output_limits,
4036        session_id,
4037        state.sql_idempotency.expires_after_ms(),
4038    ) {
4039        Ok(binding) => binding,
4040        Err(_) => {
4041            return Err(registered_sql_error_response(
4042                registration,
4043                query_id,
4044                StatusCode::INTERNAL_SERVER_ERROR,
4045                "SERIALIZATION_FAILED",
4046                "failed to serialize SQL idempotency request semantics",
4047                false,
4048            ))
4049        }
4050    };
4051    let begin = tokio::select! {
4052        begin = state.sql_idempotency.begin(owner, &key, binding) => begin,
4053        _ = registration.query().control().cancelled() => {
4054            return Err(tracked_query_error_response(
4055                state,
4056                &cancellation_checkpoint_error(registration.query()),
4057                Some(query_id),
4058            ));
4059        }
4060    };
4061    match begin {
4062        sql_idempotency::BeginResult::Execute(execution) => Ok((registration, Some(execution))),
4063        sql_idempotency::BeginResult::Replay {
4064            receipt,
4065            expires_at_ms,
4066        } => match restore_idempotency_replay(registration, &receipt) {
4067            Ok(()) => Err(sql_idempotency_receipt_response(
4068                query_id,
4069                &receipt,
4070                true,
4071                expires_at_ms,
4072                true,
4073            )),
4074            Err(error) => Err(tracked_query_error_response(state, &error, Some(query_id))),
4075        },
4076        sql_idempotency::BeginResult::Mismatch => Err(registered_sql_error_response(
4077            registration,
4078            query_id,
4079            StatusCode::CONFLICT,
4080            "IDEMPOTENCY_KEY_REUSE_MISMATCH",
4081            "idempotency key was already used with different SQL or request semantics",
4082            false,
4083        )),
4084        sql_idempotency::BeginResult::Indeterminate { created_at_ms } => Err(
4085            sql_idempotency_indeterminate_response(registration, query_id, created_at_ms),
4086        ),
4087        sql_idempotency::BeginResult::Full => Err(registered_sql_error_response(
4088            registration,
4089            query_id,
4090            StatusCode::SERVICE_UNAVAILABLE,
4091            "IDEMPOTENCY_STORE_FULL",
4092            "SQL idempotency receipt store is full",
4093            true,
4094        )),
4095        sql_idempotency::BeginResult::Unavailable => Err(registered_sql_error_response(
4096            registration,
4097            query_id,
4098            StatusCode::SERVICE_UNAVAILABLE,
4099            "IDEMPOTENCY_STORE_UNAVAILABLE",
4100            "could not durably reserve the SQL idempotency key",
4101            true,
4102        )),
4103    }
4104}
4105
4106fn restore_idempotency_replay(
4107    registration: RegisteredQueryGuard,
4108    receipt: &sql_idempotency::SqlDurableReceipt,
4109) -> mongreldb_query::Result<()> {
4110    use mongreldb_query::{
4111        DurableOutcome, QueryTerminalError, QueryTerminalErrorCategory, QueryTerminalState,
4112        SerializationOutcome,
4113    };
4114
4115    let invalid_receipt = |field: &str| {
4116        mongreldb_query::MongrelQueryError::InvalidQueryState(format!(
4117            "durable SQL idempotency receipt has invalid {field}"
4118        ))
4119    };
4120    let terminal_state = match receipt.status.as_str() {
4121        "completed" => QueryTerminalState::Completed,
4122        "failed_before_commit" => QueryTerminalState::FailedBeforeCommit,
4123        "cancelled_before_commit" => QueryTerminalState::CancelledBeforeCommit,
4124        "deadline_before_commit" => QueryTerminalState::DeadlineBeforeCommit,
4125        "committed" => QueryTerminalState::Committed,
4126        "committed_with_error" => QueryTerminalState::CommittedWithError,
4127        "partially_committed" => QueryTerminalState::PartiallyCommitted,
4128        "cancelled_after_commit" => QueryTerminalState::CancelledAfterCommit,
4129        "deadline_after_commit" => QueryTerminalState::DeadlineAfterCommit,
4130        _ => return Err(invalid_receipt("terminal state")),
4131    };
4132    let serialization = match receipt.outcome.serialization.as_str() {
4133        "not_started" => SerializationOutcome::NotStarted,
4134        "in_progress" => SerializationOutcome::InProgress,
4135        "succeeded" => SerializationOutcome::Succeeded,
4136        "failed" => SerializationOutcome::Failed,
4137        _ => return Err(invalid_receipt("serialization state")),
4138    };
4139    let terminal_error = match receipt.terminal_error.as_ref() {
4140        Some(error) => Some(QueryTerminalError {
4141            code: error.code.clone(),
4142            category: match error.category.as_str() {
4143                "cancellation" => QueryTerminalErrorCategory::Cancellation,
4144                "deadline" => QueryTerminalErrorCategory::Deadline,
4145                "result_limit" => QueryTerminalErrorCategory::ResultLimit,
4146                "serialization" => QueryTerminalErrorCategory::Serialization,
4147                "execution" => QueryTerminalErrorCategory::Execution,
4148                _ => return Err(invalid_receipt("terminal error category")),
4149            },
4150        }),
4151        None => None,
4152    };
4153    let cancellation_reason = CancellationReason::from_protocol_str(&receipt.cancellation_reason)
4154        .ok_or_else(|| invalid_receipt("cancellation reason"))?;
4155    let phase = match receipt.server_state.as_str() {
4156        "completed" => SqlQueryPhase::Completed,
4157        "cancelled" => SqlQueryPhase::Cancelled,
4158        "failed" => SqlQueryPhase::Failed,
4159        _ => return Err(invalid_receipt("server state")),
4160    };
4161    let query = registration.into_query();
4162    query.restore_replayed_outcome(
4163        DurableOutcome {
4164            committed: receipt.outcome.committed,
4165            committed_statements: receipt.outcome.committed_statements,
4166            last_commit_epoch: receipt.outcome.last_commit_epoch,
4167            first_commit_statement_index: receipt.outcome.first_commit_statement_index,
4168            last_commit_statement_index: receipt.outcome.last_commit_statement_index,
4169        },
4170        receipt.outcome.completed_statements,
4171        receipt.outcome.statement_index,
4172        serialization,
4173        terminal_error,
4174        terminal_state,
4175        cancellation_reason,
4176        phase,
4177    );
4178    query.try_complete()
4179}
4180
4181fn sql_idempotency_indeterminate_response(
4182    registration: RegisteredQueryGuard,
4183    query_id: QueryId,
4184    created_at_ms: Option<u64>,
4185) -> Response {
4186    registration.query().mark_outcome_unknown();
4187    registration.fail();
4188    with_query_id(
4189        (
4190            StatusCode::CONFLICT,
4191            Json(json!({
4192                "query_id": query_id.to_string(),
4193                "status": "outcome_unknown",
4194                "terminal_state": "outcome_unknown",
4195                "committed": null,
4196                "committed_statements": null,
4197                "last_commit_epoch": null,
4198                "last_commit_epoch_text": null,
4199                "first_commit_statement_index": null,
4200                "last_commit_statement_index": null,
4201                "completed_statements": null,
4202                "statement_index": null,
4203                "cancel_outcome": null,
4204                "cancellation_reason": null,
4205                "retryable": false,
4206                "server_state": "failed",
4207                "idempotency_replayed": true,
4208                "idempotency_intent_created_at_ms": created_at_ms,
4209                "outcome": {
4210                    "committed": null,
4211                    "committed_statements": null,
4212                    "last_commit_epoch": null,
4213                    "last_commit_epoch_text": null,
4214                    "first_commit_statement_index": null,
4215                    "last_commit_statement_index": null,
4216                    "completed_statements": null,
4217                    "statement_index": null,
4218                    "serialization": "unknown",
4219                },
4220                "error": {
4221                    "code": "QUERY_OUTCOME_UNKNOWN",
4222                    "message": "a durable write intent exists without a durable receipt; the SQL was not re-executed",
4223                    "query_id": query_id.to_string(),
4224                    "committed": null,
4225                    "retryable": false,
4226                }
4227            })),
4228        )
4229            .into_response(),
4230        query_id,
4231    )
4232}
4233
4234fn registered_sql_error_response(
4235    registration: RegisteredQueryGuard,
4236    query_id: QueryId,
4237    status: StatusCode,
4238    code: &'static str,
4239    message: impl Into<String>,
4240    retryable: bool,
4241) -> Response {
4242    let message = message.into();
4243    registration
4244        .query()
4245        .record_terminal_error(code, mongreldb_query::QueryTerminalErrorCategory::Execution);
4246    registration.fail();
4247    with_query_id(
4248        (
4249            status,
4250            Json(json!({
4251                "query_id": query_id.to_string(),
4252                "status": "failed_before_commit",
4253                "terminal_state": "failed_before_commit",
4254                "committed": false,
4255                "committed_statements": 0,
4256                "last_commit_epoch": null,
4257                "last_commit_epoch_text": null,
4258                "first_commit_statement_index": null,
4259                "last_commit_statement_index": null,
4260                "completed_statements": 0,
4261                "statement_index": 0,
4262                "cancel_outcome": null,
4263                "cancellation_reason": null,
4264                "retryable": retryable,
4265                "server_state": "failed",
4266                "outcome": {
4267                    "committed": false,
4268                    "committed_statements": 0,
4269                    "last_commit_epoch": null,
4270                    "last_commit_epoch_text": null,
4271                    "first_commit_statement_index": null,
4272                    "last_commit_statement_index": null,
4273                    "completed_statements": 0,
4274                    "statement_index": 0,
4275                    "serialization": "not_started",
4276                },
4277                "error": {
4278                    "code": code,
4279                    "message": message,
4280                    "query_id": query_id.to_string(),
4281                    "committed": false,
4282                    "retryable": retryable,
4283                }
4284            })),
4285        )
4286            .into_response(),
4287        query_id,
4288    )
4289}
4290
4291fn register_controlled_query(
4292    state: &AppState,
4293    session: &MongrelSession,
4294    options: SqlQueryOptions,
4295) -> std::result::Result<RegisteredSqlQuery, mongreldb_query::MongrelQueryError> {
4296    let query_id = options.query_id.ok_or_else(|| {
4297        mongreldb_query::MongrelQueryError::InvalidQueryState(
4298            "server query registration requires a query id".into(),
4299        )
4300    })?;
4301    let owner = options.owner.clone().unwrap_or_default();
4302    let session_id = options.session_id.clone();
4303    let _lifecycle = state
4304        .query_lifecycle
4305        .lock()
4306        .unwrap_or_else(|error| error.into_inner());
4307    let pre_cancel_reason = match state.pre_cancellations.lookup_for_registration(
4308        query_id,
4309        &owner,
4310        session_id.as_deref(),
4311    ) {
4312        pre_cancel::RegistrationLookup::NoReservation => None,
4313        pre_cancel::RegistrationLookup::Matching(reason) => Some(reason),
4314        pre_cancel::RegistrationLookup::ReservedByAnotherIdentity => {
4315            return Err(mongreldb_query::MongrelQueryError::QueryIdConflict { query_id });
4316        }
4317    };
4318    let query = session.register_query(options)?;
4319    let Some(reason) = pre_cancel_reason else {
4320        return Ok(query);
4321    };
4322    state
4323        .pre_cancellations
4324        .take(query_id, &owner, session_id.as_deref());
4325    query.request_cancel(reason);
4326    let error = query.checkpoint().err().unwrap_or_else(|| {
4327        mongreldb_query::MongrelQueryError::InvalidQueryState(format!(
4328            "pre-cancelled query {query_id} remained runnable"
4329        ))
4330    });
4331    query.fail();
4332    Err(error)
4333}
4334
4335async fn acquire_sql_permit(
4336    state: &AppState,
4337    session: &MongrelSession,
4338    query: &RegisteredSqlQuery,
4339) -> std::result::Result<tokio::sync::OwnedSemaphorePermit, mongreldb_query::MongrelQueryError> {
4340    session.fire_test_hook(mongreldb_query::SqlTestHookPoint::WaitingForSqlPermit);
4341    tokio::select! {
4342        permit = Arc::clone(&state.sql_semaphore).acquire_owned() => permit.map_err(|_| {
4343            mongreldb_query::MongrelQueryError::InvalidQueryState(
4344                "SQL admission semaphore closed".into(),
4345            )
4346        }),
4347        _ = query.control().cancelled() => Err(cancellation_checkpoint_error(query)),
4348    }
4349}
4350
4351fn caller_may_manage_query(
4352    state: &AppState,
4353    principal: &Option<mongreldb_core::Principal>,
4354    owner: Option<&str>,
4355) -> bool {
4356    let current = current_request_principal(state, principal);
4357    if (principal.is_some()
4358        || state.auth_token.is_some()
4359        || state.user_auth
4360        || state.db.require_auth_enabled())
4361        && current.is_none()
4362    {
4363        return false;
4364    }
4365    current.is_some_and(|principal| principal.is_admin)
4366        || owner == Some(request_owner(state, principal).as_str())
4367}
4368
4369fn query_phase_name(phase: SqlQueryPhase) -> &'static str {
4370    match phase {
4371        SqlQueryPhase::Queued => "queued",
4372        SqlQueryPhase::Planning => "planning",
4373        SqlQueryPhase::Executing => "executing",
4374        SqlQueryPhase::Streaming => "streaming",
4375        SqlQueryPhase::Serializing => "serializing",
4376        SqlQueryPhase::CommitCritical => "commit_critical",
4377        SqlQueryPhase::Cancelling => "cancelling",
4378        SqlQueryPhase::Completed => "completed",
4379        SqlQueryPhase::Failed => "failed",
4380        SqlQueryPhase::Cancelled => "cancelled",
4381    }
4382}
4383
4384fn commit_fence_outcome_name(outcome: mongreldb_query::CommitFenceOutcome) -> &'static str {
4385    match outcome {
4386        mongreldb_query::CommitFenceOutcome::NotReached => "not_reached",
4387        mongreldb_query::CommitFenceOutcome::CancelWon => "cancel_won",
4388        mongreldb_query::CommitFenceOutcome::CommitWon => "commit_won",
4389    }
4390}
4391
4392fn terminal_state_name(state: mongreldb_query::QueryTerminalState) -> &'static str {
4393    use mongreldb_query::QueryTerminalState;
4394    match state {
4395        QueryTerminalState::OutcomeUnknown => "outcome_unknown",
4396        QueryTerminalState::Completed => "completed",
4397        QueryTerminalState::FailedBeforeCommit => "failed_before_commit",
4398        QueryTerminalState::CancelledBeforeCommit => "cancelled_before_commit",
4399        QueryTerminalState::DeadlineBeforeCommit => "deadline_before_commit",
4400        QueryTerminalState::Committed => "committed",
4401        QueryTerminalState::CommittedWithError => "committed_with_error",
4402        QueryTerminalState::PartiallyCommitted => "partially_committed",
4403        QueryTerminalState::CancelledAfterCommit => "cancelled_after_commit",
4404        QueryTerminalState::DeadlineAfterCommit => "deadline_after_commit",
4405    }
4406}
4407
4408fn serialization_outcome_name(outcome: mongreldb_query::SerializationOutcome) -> &'static str {
4409    use mongreldb_query::SerializationOutcome;
4410    match outcome {
4411        SerializationOutcome::NotStarted => "not_started",
4412        SerializationOutcome::InProgress => "in_progress",
4413        SerializationOutcome::Succeeded => "succeeded",
4414        SerializationOutcome::Failed => "failed",
4415    }
4416}
4417
4418fn terminal_error_category_name(
4419    category: mongreldb_query::QueryTerminalErrorCategory,
4420) -> &'static str {
4421    use mongreldb_query::QueryTerminalErrorCategory;
4422    match category {
4423        QueryTerminalErrorCategory::Cancellation => "cancellation",
4424        QueryTerminalErrorCategory::Deadline => "deadline",
4425        QueryTerminalErrorCategory::ResultLimit => "result_limit",
4426        QueryTerminalErrorCategory::Serialization => "serialization",
4427        QueryTerminalErrorCategory::Execution => "execution",
4428    }
4429}
4430
4431fn terminal_error_retryable(error: Option<&mongreldb_query::QueryTerminalError>) -> bool {
4432    error.is_some_and(|error| {
4433        matches!(
4434            error.code.as_str(),
4435            "IDEMPOTENCY_STORE_FULL" | "IDEMPOTENCY_STORE_UNAVAILABLE"
4436        )
4437    })
4438}
4439
4440fn epoch_text(epoch: Option<u64>) -> Option<String> {
4441    epoch.map(|epoch| epoch.to_string())
4442}
4443
4444fn cancellation_reason_name(reason: CancellationReason) -> &'static str {
4445    reason.as_str()
4446}
4447
4448fn query_cancel_outcome(status: &mongreldb_query::QueryStatus) -> Option<&'static str> {
4449    match status.phase {
4450        SqlQueryPhase::CommitCritical => Some("too_late"),
4451        SqlQueryPhase::Completed | SqlQueryPhase::Failed | SqlQueryPhase::Cancelled => {
4452            Some("already_finished")
4453        }
4454        SqlQueryPhase::Cancelling => Some("accepted"),
4455        _ => None,
4456    }
4457}
4458
4459fn query_outcome_json(status: Option<&mongreldb_query::QueryStatus>) -> serde_json::Value {
4460    let Some(status) = status else {
4461        return json!({
4462            "committed": false,
4463            "committed_statements": 0,
4464            "last_commit_epoch": null,
4465            "last_commit_epoch_text": null,
4466            "first_commit_statement_index": null,
4467            "last_commit_statement_index": null,
4468            "completed_statements": 0,
4469            "statement_index": 0,
4470            "serialization": "not_started",
4471        });
4472    };
4473    if status.outcome_unknown {
4474        return json!({
4475            "committed": null,
4476            "committed_statements": null,
4477            "last_commit_epoch": null,
4478            "last_commit_epoch_text": null,
4479            "first_commit_statement_index": null,
4480            "last_commit_statement_index": null,
4481            "completed_statements": null,
4482            "statement_index": null,
4483            "serialization": "unknown",
4484        });
4485    }
4486    json!({
4487        "committed": status.durable_outcome.committed,
4488        "committed_statements": (!status.outcome_unknown).then_some(status.durable_outcome.committed_statements),
4489        "last_commit_epoch": (!status.outcome_unknown).then_some(status.durable_outcome.last_commit_epoch).flatten(),
4490        "last_commit_epoch_text": (!status.outcome_unknown).then_some(epoch_text(status.durable_outcome.last_commit_epoch)).flatten(),
4491        "first_commit_statement_index": (!status.outcome_unknown).then_some(status.durable_outcome.first_commit_statement_index).flatten(),
4492        "last_commit_statement_index": (!status.outcome_unknown).then_some(status.durable_outcome.last_commit_statement_index).flatten(),
4493        "completed_statements": (!status.outcome_unknown).then_some(status.completed_statements),
4494        "statement_index": (!status.outcome_unknown).then_some(status.statement_index),
4495        "serialization": serialization_outcome_name(status.serialization_outcome),
4496    })
4497}
4498
4499fn sql_terminal_idempotency_receipt(
4500    status: &mongreldb_query::QueryStatus,
4501) -> Option<sql_idempotency::SqlDurableReceipt> {
4502    if status.outcome_unknown {
4503        return None;
4504    }
4505    let terminal_state = status.terminal_state()?;
4506    if !status.durable_outcome.committed
4507        && terminal_state != mongreldb_query::QueryTerminalState::Completed
4508    {
4509        return None;
4510    }
4511    Some(sql_idempotency::SqlDurableReceipt {
4512        original_query_id: status.query_id.to_string(),
4513        status: status
4514            .terminal_state()
4515            .map(terminal_state_name)
4516            .unwrap_or("committed")
4517            .to_owned(),
4518        server_state: query_phase_name(status.phase).to_owned(),
4519        cancellation_reason: cancellation_reason_name(status.cancellation_reason).to_owned(),
4520        outcome: sql_idempotency::SqlReceiptOutcome {
4521            committed: status.durable_outcome.committed,
4522            committed_statements: status.durable_outcome.committed_statements,
4523            last_commit_epoch: status.durable_outcome.last_commit_epoch,
4524            last_commit_epoch_text: epoch_text(status.durable_outcome.last_commit_epoch),
4525            first_commit_statement_index: status.durable_outcome.first_commit_statement_index,
4526            last_commit_statement_index: status.durable_outcome.last_commit_statement_index,
4527            completed_statements: status.completed_statements,
4528            statement_index: status.statement_index,
4529            serialization: serialization_outcome_name(status.serialization_outcome).to_owned(),
4530        },
4531        terminal_error: status.terminal_error.as_ref().map(|error| {
4532            sql_idempotency::SqlReceiptTerminalError {
4533                code: error.code.clone(),
4534                category: terminal_error_category_name(error.category).to_owned(),
4535            }
4536        }),
4537    })
4538}
4539
4540fn sql_idempotency_receipt_response(
4541    query_id: QueryId,
4542    receipt: &sql_idempotency::SqlDurableReceipt,
4543    replayed: bool,
4544    expires_at_ms: u64,
4545    persisted: bool,
4546) -> Response {
4547    let mut response = Json(json!({
4548        "query_id": query_id.to_string(),
4549        "original_query_id": receipt.original_query_id,
4550        "status": receipt.status,
4551        "terminal_state": receipt.status,
4552        "server_state": receipt.server_state,
4553        "cancel_outcome": "already_finished",
4554        "cancellation_reason": receipt.cancellation_reason,
4555        "committed": receipt.outcome.committed,
4556        "committed_statements": receipt.outcome.committed_statements,
4557        "last_commit_epoch": receipt.outcome.last_commit_epoch,
4558        "last_commit_epoch_text": receipt.outcome.last_commit_epoch_text.as_deref(),
4559        "first_commit_statement_index": receipt.outcome.first_commit_statement_index,
4560        "last_commit_statement_index": receipt.outcome.last_commit_statement_index,
4561        "completed_statements": receipt.outcome.completed_statements,
4562        "statement_index": receipt.outcome.statement_index,
4563        "retryable": false,
4564        "idempotency_replayed": replayed,
4565        "idempotency_persisted": persisted,
4566        "idempotency_expires_at_ms": expires_at_ms,
4567        "outcome": receipt.outcome,
4568        "terminal_error": receipt.terminal_error,
4569    }))
4570    .into_response();
4571    response.headers_mut().insert(
4572        "idempotency-replayed",
4573        axum::http::HeaderValue::from_static(if replayed { "true" } else { "false" }),
4574    );
4575    response.headers_mut().insert(
4576        "idempotency-persisted",
4577        axum::http::HeaderValue::from_static(if persisted { "true" } else { "false" }),
4578    );
4579    if let Ok(value) = axum::http::HeaderValue::from_str(&receipt.original_query_id) {
4580        response
4581            .headers_mut()
4582            .insert("x-mongreldb-original-query-id", value);
4583    }
4584    with_query_id(response, query_id)
4585}
4586
4587fn terminal_server_error_response(
4588    state: &AppState,
4589    query_id: QueryId,
4590    http_status: StatusCode,
4591    base_code: &'static str,
4592    message: impl Into<String>,
4593) -> Response {
4594    let status = state.query_registry.status(query_id);
4595    let committed = status
4596        .as_ref()
4597        .is_some_and(|status| status.durable_outcome.committed);
4598    let code = if committed && base_code.starts_with("SERIALIZATION_") {
4599        "SERIALIZATION_FAILED_AFTER_COMMIT"
4600    } else {
4601        base_code
4602    };
4603    let response_status = status
4604        .as_ref()
4605        .and_then(mongreldb_query::QueryStatus::terminal_state)
4606        .map(terminal_state_name)
4607        .unwrap_or(if committed {
4608            "committed_with_error"
4609        } else {
4610            "failed_before_commit"
4611        });
4612    let outcome = query_outcome_json(status.as_ref());
4613    with_query_id(
4614        (
4615            http_status,
4616            Json(json!({
4617                "query_id": query_id.to_string(),
4618                "status": response_status,
4619                "terminal_state": response_status,
4620                "committed": committed,
4621                "committed_statements": status.as_ref().map_or(0, |status| status.durable_outcome.committed_statements),
4622                "last_commit_epoch": status.as_ref().and_then(|status| status.durable_outcome.last_commit_epoch),
4623                "last_commit_epoch_text": epoch_text(status.as_ref().and_then(|status| status.durable_outcome.last_commit_epoch)),
4624                "first_commit_statement_index": status.as_ref().and_then(|status| status.durable_outcome.first_commit_statement_index),
4625                "last_commit_statement_index": status.as_ref().and_then(|status| status.durable_outcome.last_commit_statement_index),
4626                "completed_statements": status.as_ref().map_or(0, |status| status.completed_statements),
4627                "statement_index": status.as_ref().map_or(0, |status| status.statement_index),
4628                "cancel_outcome": null,
4629                "cancellation_reason": status.as_ref().map(|status| cancellation_reason_name(status.cancellation_reason)),
4630                "retryable": false,
4631                "server_state": status.as_ref().map(|status| query_phase_name(status.phase)),
4632                "outcome": outcome,
4633                "error": {
4634                    "code": code,
4635                    "message": message.into(),
4636                    "query_id": query_id.to_string(),
4637                    "committed": committed,
4638                    "retryable": false,
4639                }
4640            })),
4641        )
4642            .into_response(),
4643        query_id,
4644    )
4645}
4646
4647fn query_not_found_response(query_id: Option<QueryId>) -> Response {
4648    let mut response = (
4649        StatusCode::NOT_FOUND,
4650        Json(json!({
4651            "query_id": query_id.map(|value| value.to_string()),
4652            "status": "unknown",
4653            "terminal_state": null,
4654            "committed": null,
4655            "committed_statements": null,
4656            "last_commit_epoch": null,
4657            "last_commit_epoch_text": null,
4658            "first_commit_statement_index": null,
4659            "last_commit_statement_index": null,
4660            "completed_statements": null,
4661            "statement_index": null,
4662            "cancel_outcome": "not_found",
4663            "cancellation_reason": null,
4664            "retryable": false,
4665            "server_state": "not_found",
4666            "outcome": {
4667                "committed": null,
4668                "committed_statements": null,
4669                "last_commit_epoch": null,
4670                "last_commit_epoch_text": null,
4671                "first_commit_statement_index": null,
4672                "last_commit_statement_index": null,
4673                "completed_statements": null,
4674                "statement_index": null,
4675                "serialization": "unknown",
4676            },
4677            "error": {
4678                "code": "QUERY_NOT_FOUND",
4679                "message": "query not found",
4680                "query_id": query_id.map(|value| value.to_string()),
4681                "committed": null,
4682                "retryable": false,
4683            }
4684        })),
4685    )
4686        .into_response();
4687    if let Some(query_id) = query_id {
4688        add_query_id_header(&mut response, query_id);
4689    }
4690    response
4691}
4692
4693fn query_session_header(
4694    headers: &axum::http::HeaderMap,
4695    query_id: Option<QueryId>,
4696) -> std::result::Result<Option<String>, Box<Response>> {
4697    match headers.get("x-session-id") {
4698        Some(value) => match value.to_str() {
4699            Ok(value) if value.len() <= 256 => Ok(Some(value.to_owned())),
4700            _ => Err(Box::new(bad_query_control_request(
4701                "X-Session-ID must be valid text no longer than 256 bytes",
4702                query_id,
4703            ))),
4704        },
4705        None => Ok(None),
4706    }
4707}
4708
4709fn pre_cancelled_query_response(
4710    query_id: QueryId,
4711    reason: CancellationReason,
4712    status: StatusCode,
4713) -> Response {
4714    with_query_id(
4715        (
4716            status,
4717            Json(json!({
4718                "query_id": query_id.to_string(),
4719                "status": "cancelled_before_start",
4720                "terminal_state": "cancelled_before_start",
4721                "state": "pre_cancelled",
4722                "server_state": "pre_cancelled",
4723                "cancel_outcome": "pre_cancelled",
4724                "committed": false,
4725                "committed_statements": 0,
4726                "last_commit_epoch": null,
4727                "last_commit_epoch_text": null,
4728                "first_commit_statement_index": null,
4729                "last_commit_statement_index": null,
4730                "completed_statements": 0,
4731                "statement_index": 0,
4732                "cancellation_reason": cancellation_reason_name(reason),
4733                "outcome": {
4734                    "committed": false,
4735                    "committed_statements": 0,
4736                    "last_commit_epoch": null,
4737                    "last_commit_epoch_text": null,
4738                    "first_commit_statement_index": null,
4739                    "last_commit_statement_index": null,
4740                    "completed_statements": 0,
4741                    "statement_index": 0,
4742                    "serialization": "not_started",
4743                },
4744                "terminal_error": {
4745                    "code": "QUERY_CANCELLED",
4746                    "category": "cancellation",
4747                },
4748                "retryable": false,
4749            })),
4750        )
4751            .into_response(),
4752        query_id,
4753    )
4754}
4755
4756fn compact_finished_query_response(status: &CompactFinishedQuery) -> Response {
4757    let query_id = status.query_id;
4758    let durable = &status.durable_outcome;
4759    let outcome_unknown =
4760        status.terminal_state == mongreldb_query::QueryTerminalState::OutcomeUnknown;
4761    let terminal_error = status.terminal_error.as_ref().map(|error| {
4762        json!({
4763            "code": error.code,
4764            "category": terminal_error_category_name(error.category),
4765        })
4766    });
4767    with_query_id(
4768        Json(json!({
4769            "detail": "compact",
4770            "query_id": query_id.to_string(),
4771            "status": terminal_state_name(status.terminal_state),
4772            "terminal_state": terminal_state_name(status.terminal_state),
4773            "state": query_phase_name(status.phase),
4774            "server_state": query_phase_name(status.phase),
4775            "cancel_outcome": "already_finished",
4776            "code": "QUERY_ALREADY_FINISHED",
4777            "committed": (!outcome_unknown).then_some(durable.committed),
4778            "committed_statements": (!outcome_unknown).then_some(durable.committed_statements),
4779            "last_commit_epoch": (!outcome_unknown).then_some(durable.last_commit_epoch).flatten(),
4780            "last_commit_epoch_text": (!outcome_unknown).then_some(epoch_text(durable.last_commit_epoch)).flatten(),
4781            "first_commit_statement_index": (!outcome_unknown).then_some(durable.first_commit_statement_index).flatten(),
4782            "last_commit_statement_index": (!outcome_unknown).then_some(durable.last_commit_statement_index).flatten(),
4783            "completed_statements": (!outcome_unknown).then_some(status.completed_statements),
4784            "statement_index": (!outcome_unknown).then_some(status.statement_index),
4785            "cancellation_reason": cancellation_reason_name(status.cancellation_reason),
4786            "outcome": {
4787                "committed": (!outcome_unknown).then_some(durable.committed),
4788                "committed_statements": (!outcome_unknown).then_some(durable.committed_statements),
4789                "last_commit_epoch": (!outcome_unknown).then_some(durable.last_commit_epoch).flatten(),
4790                "last_commit_epoch_text": (!outcome_unknown).then_some(epoch_text(durable.last_commit_epoch)).flatten(),
4791                "first_commit_statement_index": (!outcome_unknown).then_some(durable.first_commit_statement_index).flatten(),
4792                "last_commit_statement_index": (!outcome_unknown).then_some(durable.last_commit_statement_index).flatten(),
4793                "completed_statements": (!outcome_unknown).then_some(status.completed_statements),
4794                "statement_index": (!outcome_unknown).then_some(status.statement_index),
4795                "serialization": serialization_outcome_name(status.serialization_outcome),
4796            },
4797            "terminal_error": terminal_error,
4798            "retryable": terminal_error_retryable(status.terminal_error.as_ref()),
4799        }))
4800        .into_response(),
4801        query_id,
4802    )
4803}
4804
4805async fn query_status(
4806    State(state): State<Arc<AppState>>,
4807    OptionalPrincipal(principal): OptionalPrincipal,
4808    Path(query_id): Path<String>,
4809    headers: axum::http::HeaderMap,
4810) -> Response {
4811    let Ok(query_id) = query_id.parse::<QueryId>() else {
4812        return query_not_found_response(None);
4813    };
4814    if !request_identity_is_current(&state, &principal) {
4815        return query_not_found_response(Some(query_id));
4816    }
4817    let requested_session = match query_session_header(&headers, Some(query_id)) {
4818        Ok(session_id) => session_id,
4819        Err(response) => return *response,
4820    };
4821    let owner = request_owner(&state, &principal);
4822    let is_admin =
4823        current_request_principal(&state, &principal).is_some_and(|principal| principal.is_admin);
4824    let _lifecycle = state
4825        .query_lifecycle
4826        .lock()
4827        .unwrap_or_else(|error| error.into_inner());
4828    let Some(status) = state.query_registry.status(query_id) else {
4829        if let Some(finished) = state.query_registry.compact_finished_status(query_id) {
4830            if !caller_may_manage_query(&state, &principal, finished.owner.as_deref())
4831                || requested_session
4832                    .as_deref()
4833                    .is_some_and(|session| finished.session_id.as_deref() != Some(session))
4834            {
4835                return query_not_found_response(Some(query_id));
4836            }
4837            return compact_finished_query_response(&finished);
4838        }
4839        let reason = state
4840            .pre_cancellations
4841            .reason(query_id, &owner, requested_session.as_deref())
4842            .or_else(|| {
4843                is_admin.then(|| match requested_session.as_deref() {
4844                    Some(session_id) => state
4845                        .pre_cancellations
4846                        .reason_for_query_in_session(query_id, session_id),
4847                    None => state.pre_cancellations.reason_for_query(query_id),
4848                })?
4849            });
4850        if let Some(reason) = reason {
4851            return pre_cancelled_query_response(query_id, reason, StatusCode::OK);
4852        }
4853        return query_not_found_response(Some(query_id));
4854    };
4855    if !caller_may_manage_query(&state, &principal, status.owner.as_deref())
4856        || requested_session
4857            .as_deref()
4858            .is_some_and(|session| status.session_id.as_deref() != Some(session))
4859    {
4860        return query_not_found_response(Some(query_id));
4861    }
4862    let terminal_status = status.terminal_state().map(terminal_state_name);
4863    let terminal_error = status.terminal_error.as_ref().map(|error| {
4864        json!({
4865            "code": error.code,
4866            "category": terminal_error_category_name(error.category),
4867        })
4868    });
4869    let retryable = terminal_error_retryable(status.terminal_error.as_ref());
4870    let cancel_outcome = query_cancel_outcome(&status);
4871    let outcome = query_outcome_json(Some(&status));
4872    let response = Json(json!({
4873        "query_id": query_id.to_string(),
4874        "status": terminal_status.unwrap_or(if status.durable_outcome.committed {
4875            "committed"
4876        } else {
4877            "running"
4878        }),
4879        "terminal_state": terminal_status,
4880        "state": query_phase_name(status.phase),
4881        "server_state": query_phase_name(status.phase),
4882        "started_ms_ago": status.started_at.elapsed().as_millis(),
4883        "deadline_ms_remaining": status.deadline.map(|deadline| {
4884            deadline.saturating_duration_since(std::time::Instant::now()).as_millis()
4885        }),
4886        "session_id": status.session_id,
4887        "operation": status.operation,
4888        "committed": (!status.outcome_unknown).then_some(status.committed),
4889        "committed_statements": (!status.outcome_unknown).then_some(status.durable_outcome.committed_statements),
4890        "last_commit_epoch": (!status.outcome_unknown).then_some(status.durable_outcome.last_commit_epoch).flatten(),
4891        "last_commit_epoch_text": (!status.outcome_unknown).then_some(epoch_text(status.durable_outcome.last_commit_epoch)).flatten(),
4892        "first_commit_statement_index": (!status.outcome_unknown).then_some(status.durable_outcome.first_commit_statement_index).flatten(),
4893        "last_commit_statement_index": (!status.outcome_unknown).then_some(status.durable_outcome.last_commit_statement_index).flatten(),
4894        "cancellation_reason": cancellation_reason_name(status.cancellation_reason),
4895        "completed_statements": (!status.outcome_unknown).then_some(status.completed_statements),
4896        "statement_index": (!status.outcome_unknown).then_some(status.statement_index),
4897        "cancel_outcome": cancel_outcome,
4898        "retryable": retryable,
4899        "outcome": outcome,
4900        "terminal_error": terminal_error,
4901        "trace": {
4902            "queue_duration_us": status.queue_duration.as_micros(),
4903            "planning_duration_us": status.planning_duration.as_micros(),
4904            "execution_duration_us": status.execution_duration.as_micros(),
4905            "serialization_duration_us": status.serialization_duration.as_micros(),
4906            "cancel_requested_phase": status.cancel_requested_phase.map(query_phase_name),
4907            "cancel_observed_phase": status.cancel_observed_phase.map(query_phase_name),
4908            "commit_fence_outcome": commit_fence_outcome_name(status.commit_fence_outcome),
4909        },
4910    }))
4911    .into_response();
4912    with_query_id(response, query_id)
4913}
4914
4915async fn cancel_query(
4916    State(state): State<Arc<AppState>>,
4917    OptionalPrincipal(principal): OptionalPrincipal,
4918    Path(query_id): Path<String>,
4919    headers: axum::http::HeaderMap,
4920) -> Response {
4921    let Ok(query_id) = query_id.parse::<QueryId>() else {
4922        return query_not_found_response(None);
4923    };
4924    if !request_identity_is_current(&state, &principal) {
4925        return query_not_found_response(Some(query_id));
4926    }
4927    let requested_session = match query_session_header(&headers, Some(query_id)) {
4928        Ok(session_id) => session_id,
4929        Err(response) => return *response,
4930    };
4931    let owner = request_owner(&state, &principal);
4932    let _lifecycle = state
4933        .query_lifecycle
4934        .lock()
4935        .unwrap_or_else(|error| error.into_inner());
4936    state.metrics.inc_sql_cancel_requests();
4937    let Some(status) = state.query_registry.status(query_id) else {
4938        if let Some(finished) = state.query_registry.compact_finished_status(query_id) {
4939            if !caller_may_manage_query(&state, &principal, finished.owner.as_deref())
4940                || requested_session
4941                    .as_deref()
4942                    .is_some_and(|session| finished.session_id.as_deref() != Some(session))
4943            {
4944                return query_not_found_response(Some(query_id));
4945            }
4946            return compact_finished_query_response(&finished);
4947        }
4948        return match state.pre_cancellations.insert(
4949            query_id,
4950            &owner,
4951            requested_session.as_deref(),
4952            CancellationReason::ClientRequest,
4953        ) {
4954            Ok(()) => {
4955                state.metrics.inc_sql_commit_cancel_winner_cancel();
4956                pre_cancelled_query_response(
4957                    query_id,
4958                    CancellationReason::ClientRequest,
4959                    StatusCode::ACCEPTED,
4960                )
4961            }
4962            Err(pre_cancel::InsertError::MetadataTooLarge) => bad_query_control_request(
4963                "query owner or session metadata exceeds 256 bytes",
4964                Some(query_id),
4965            ),
4966            Err(
4967                pre_cancel::InsertError::Full
4968                | pre_cancel::InsertError::OwnerLimit
4969                | pre_cancel::InsertError::RateLimited,
4970            ) => with_query_id(
4971                (
4972                    StatusCode::TOO_MANY_REQUESTS,
4973                    Json(json!({
4974                        "query_id": query_id.to_string(),
4975                        "status": "failed_before_commit",
4976                        "terminal_state": "failed_before_commit",
4977                        "server_state": "failed",
4978                        "cancel_outcome": null,
4979                        "cancellation_reason": null,
4980                        "committed": false,
4981                        "committed_statements": 0,
4982                        "last_commit_epoch": null,
4983                        "last_commit_epoch_text": null,
4984                        "first_commit_statement_index": null,
4985                        "last_commit_statement_index": null,
4986                        "completed_statements": 0,
4987                        "statement_index": 0,
4988                        "retryable": true,
4989                        "outcome": {
4990                            "committed": false,
4991                            "committed_statements": 0,
4992                            "last_commit_epoch": null,
4993                            "last_commit_epoch_text": null,
4994                            "first_commit_statement_index": null,
4995                            "last_commit_statement_index": null,
4996                            "completed_statements": 0,
4997                            "statement_index": 0,
4998                            "serialization": "not_started",
4999                        },
5000                        "error": {
5001                            "code": "QUERY_REGISTRY_FULL",
5002                            "message": "pre-registration cancellation limit reached",
5003                            "query_id": query_id.to_string(),
5004                            "committed": false,
5005                            "retryable": true,
5006                        }
5007                    })),
5008                )
5009                    .into_response(),
5010                query_id,
5011            ),
5012        };
5013    };
5014    if !caller_may_manage_query(&state, &principal, status.owner.as_deref())
5015        || requested_session
5016            .as_deref()
5017            .is_some_and(|session| status.session_id.as_deref() != Some(session))
5018    {
5019        return query_not_found_response(Some(query_id));
5020    }
5021    let (http_status, mut body) = match state.query_registry.cancel(query_id) {
5022        CancelOutcome::Accepted => (
5023            {
5024                state.metrics.inc_sql_commit_cancel_winner_cancel();
5025                StatusCode::ACCEPTED
5026            },
5027            json!({
5028                "query_id": query_id.to_string(),
5029                "state": "cancellation_requested",
5030                "cancel_outcome": "accepted",
5031            }),
5032        ),
5033        CancelOutcome::AlreadyCancelling => (
5034            StatusCode::OK,
5035            json!({
5036                "query_id": query_id.to_string(),
5037                "state": "cancelling",
5038                "cancel_outcome": "already_cancelling",
5039            }),
5040        ),
5041        CancelOutcome::TooLate => (
5042            {
5043                state.metrics.inc_sql_commit_cancel_winner_commit();
5044                StatusCode::CONFLICT
5045            },
5046            json!({
5047                "query_id": query_id.to_string(),
5048                "state": "commit_critical",
5049                "cancel_outcome": "too_late",
5050                "committed": status.durable_outcome.committed,
5051                "outcome": query_outcome_json(Some(&status)),
5052                "retryable": false,
5053                "error": {
5054                    "code": "CANCEL_TOO_LATE",
5055                    "message": "the query has entered its durable commit phase",
5056                    "committed": status.durable_outcome.committed,
5057                    "retryable": false,
5058                }
5059            }),
5060        ),
5061        CancelOutcome::AlreadyFinished => (
5062            StatusCode::OK,
5063            json!({
5064                "query_id": query_id.to_string(),
5065                "state": "finished",
5066                "status": status.terminal_state().map(terminal_state_name),
5067                "cancel_outcome": "already_finished",
5068                "code": "QUERY_ALREADY_FINISHED",
5069                "committed": status.durable_outcome.committed,
5070                "outcome": query_outcome_json(Some(&status)),
5071                "retryable": false,
5072            }),
5073        ),
5074        CancelOutcome::NotFound => return query_not_found_response(Some(query_id)),
5075    };
5076    let status = state.query_registry.status(query_id).unwrap_or(status);
5077    let response_status = status.terminal_state().map(terminal_state_name).unwrap_or(
5078        if status.durable_outcome.committed {
5079            "committed"
5080        } else {
5081            "running"
5082        },
5083    );
5084    if let Some(body) = body.as_object_mut() {
5085        body.insert("status".into(), json!(response_status));
5086        body.insert(
5087            "terminal_state".into(),
5088            json!(status.terminal_state().map(terminal_state_name)),
5089        );
5090        body.insert(
5091            "committed".into(),
5092            json!((!status.outcome_unknown).then_some(status.durable_outcome.committed)),
5093        );
5094        body.insert(
5095            "committed_statements".into(),
5096            json!((!status.outcome_unknown).then_some(status.durable_outcome.committed_statements)),
5097        );
5098        body.insert(
5099            "last_commit_epoch".into(),
5100            json!((!status.outcome_unknown)
5101                .then_some(status.durable_outcome.last_commit_epoch)
5102                .flatten()),
5103        );
5104        body.insert(
5105            "last_commit_epoch_text".into(),
5106            json!((!status.outcome_unknown)
5107                .then_some(epoch_text(status.durable_outcome.last_commit_epoch))
5108                .flatten()),
5109        );
5110        body.insert(
5111            "first_commit_statement_index".into(),
5112            json!((!status.outcome_unknown)
5113                .then_some(status.durable_outcome.first_commit_statement_index)
5114                .flatten()),
5115        );
5116        body.insert(
5117            "last_commit_statement_index".into(),
5118            json!((!status.outcome_unknown)
5119                .then_some(status.durable_outcome.last_commit_statement_index)
5120                .flatten()),
5121        );
5122        body.insert(
5123            "completed_statements".into(),
5124            json!((!status.outcome_unknown).then_some(status.completed_statements)),
5125        );
5126        body.insert(
5127            "statement_index".into(),
5128            json!((!status.outcome_unknown).then_some(status.statement_index)),
5129        );
5130        body.insert(
5131            "cancellation_reason".into(),
5132            json!(cancellation_reason_name(status.cancellation_reason)),
5133        );
5134        body.insert("retryable".into(), json!(false));
5135        body.insert("server_state".into(), json!(query_phase_name(status.phase)));
5136        body.insert("outcome".into(), query_outcome_json(Some(&status)));
5137    }
5138    with_query_id((http_status, Json(body)).into_response(), query_id)
5139}
5140
5141#[derive(Deserialize)]
5142#[serde(deny_unknown_fields)]
5143struct SqlContinuationRequest {
5144    cursor: String,
5145    #[serde(default)]
5146    operation_id: Option<QueryId>,
5147    #[serde(default)]
5148    timeout_ms: Option<u64>,
5149}
5150
5151fn register_page_operation(
5152    state: &AppState,
5153    options: SqlQueryOptions,
5154) -> mongreldb_query::Result<RegisteredSqlQuery> {
5155    let query_id = options.query_id.ok_or_else(|| {
5156        mongreldb_query::MongrelQueryError::InvalidQueryState(
5157            "page operation registration requires an operation id".into(),
5158        )
5159    })?;
5160    let owner = options.owner.clone().unwrap_or_default();
5161    let session_id = options.session_id.clone();
5162    let _lifecycle = state
5163        .query_lifecycle
5164        .lock()
5165        .unwrap_or_else(|error| error.into_inner());
5166    let reason = match state.pre_cancellations.lookup_for_registration(
5167        query_id,
5168        &owner,
5169        session_id.as_deref(),
5170    ) {
5171        pre_cancel::RegistrationLookup::NoReservation => None,
5172        pre_cancel::RegistrationLookup::Matching(reason) => Some(reason),
5173        pre_cancel::RegistrationLookup::ReservedByAnotherIdentity => {
5174            return Err(mongreldb_query::MongrelQueryError::QueryIdConflict { query_id });
5175        }
5176    };
5177    let query = state.query_registry.register(options)?;
5178    if let Some(reason) = reason {
5179        state
5180            .pre_cancellations
5181            .take(query_id, &owner, session_id.as_deref());
5182        query.request_cancel(reason);
5183        let error = cancellation_checkpoint_error(&query);
5184        query.fail();
5185        return Err(error);
5186    }
5187    Ok(query)
5188}
5189
5190async fn continue_sql_page(
5191    State(state): State<Arc<AppState>>,
5192    OptionalPrincipal(principal): OptionalPrincipal,
5193    headers: axum::http::HeaderMap,
5194    Json(request): Json<SqlContinuationRequest>,
5195) -> Response {
5196    let query_id = match request.operation_id {
5197        Some(query_id) => query_id,
5198        None => match QueryId::random() {
5199            Ok(query_id) => query_id,
5200            Err(error) => return query_error_response(&error, None),
5201        },
5202    };
5203    if !request_identity_is_current(&state, &principal) {
5204        return with_query_id(
5205            sql_cursor_error_response(
5206                StatusCode::NOT_FOUND,
5207                "SQL_CURSOR_NOT_FOUND",
5208                "SQL continuation result is unavailable",
5209                query_id,
5210            ),
5211            query_id,
5212        );
5213    }
5214    let owner = request_owner(&state, &principal);
5215    let session_id = match query_session_header(&headers, Some(query_id)) {
5216        Ok(session_id) => session_id,
5217        Err(response) => return *response,
5218    };
5219    let timeout_ms = request.timeout_ms.unwrap_or_else(|| {
5220        state
5221            .sql_page_default_timeout
5222            .as_millis()
5223            .min(u128::from(u64::MAX)) as u64
5224    });
5225    if timeout_ms == 0 || Duration::from_millis(timeout_ms) > state.sql_page_max_timeout {
5226        return bad_query_control_request(
5227            format!(
5228                "timeout_ms must be positive and no greater than {}",
5229                state.sql_page_max_timeout.as_millis()
5230            ),
5231            Some(query_id),
5232        );
5233    }
5234    let query = match register_page_operation(
5235        &state,
5236        SqlQueryOptions {
5237            query_id: Some(query_id),
5238            timeout: Some(Duration::from_millis(timeout_ms)),
5239            owner: Some(owner.clone()),
5240            session_id,
5241            parent_control: None,
5242        },
5243    ) {
5244        Ok(query) => query,
5245        Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
5246    };
5247    query.set_sql_metadata("CONTINUE SQL PAGE");
5248    let _permit = match tokio::select! {
5249        permit = Arc::clone(&state.sql_page_semaphore).acquire_owned() => permit.map_err(|_| {
5250            mongreldb_query::MongrelQueryError::InvalidQueryState(
5251                "SQL page admission semaphore closed".into(),
5252            )
5253        }),
5254        _ = query.control().cancelled() => Err(cancellation_checkpoint_error(&query)),
5255    } {
5256        Ok(permit) => permit,
5257        Err(error) => {
5258            query.fail();
5259            return tracked_query_error_response(&state, &error, Some(query_id));
5260        }
5261    };
5262    if let Err(error) = query.transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing) {
5263        query.fail();
5264        return tracked_query_error_response(&state, &error, Some(query_id));
5265    }
5266    let fail = |status, code, message| {
5267        query.record_terminal_error(code, mongreldb_query::QueryTerminalErrorCategory::Execution);
5268        query.fail();
5269        with_query_id(
5270            sql_cursor_error_response(status, code, message, query_id),
5271            query_id,
5272        )
5273    };
5274    if request.cursor.is_empty() || request.cursor.len() > 2_048 {
5275        return fail(
5276            StatusCode::BAD_REQUEST,
5277            "INVALID_SQL_CURSOR",
5278            "invalid SQL continuation cursor",
5279        );
5280    }
5281    if let Err(error) = query.checkpoint() {
5282        query.fail();
5283        return tracked_query_error_response(&state, &error, Some(query_id));
5284    }
5285    let cursor_mac_key = match state.cursor_mac_key.get() {
5286        Ok(key) => key,
5287        Err(_) => {
5288            return fail(
5289                StatusCode::INTERNAL_SERVER_ERROR,
5290                "ENTROPY_UNAVAILABLE",
5291                "OS CSPRNG unavailable",
5292            );
5293        }
5294    };
5295    match state.sql_pages.continue_page_with_control(
5296        &request.cursor,
5297        &owner,
5298        &cursor_mac_key,
5299        sql_pages::SqlPageBinding {
5300            security_version: state.db.security_version(),
5301            catalog_epoch: state.db.catalog_snapshot().db_epoch,
5302        },
5303        &query,
5304    ) {
5305        Ok(page) => {
5306            let page_byte_count = page.byte_count;
5307            if let Err(error) = query.begin_serialization() {
5308                query.fail();
5309                return tracked_query_error_response(&state, &error, Some(query_id));
5310            }
5311            let serialization_query = query.clone();
5312            match tokio::task::spawn_blocking(move || {
5313                serialize_sql_page_controlled(page, &serialization_query)
5314            })
5315            .await
5316            {
5317                Ok(Ok(body)) => {
5318                    if let Err(error) = query.try_complete() {
5319                        return tracked_query_error_response(&state, &error, Some(query_id));
5320                    }
5321                    state.metrics.add_sql_output_bytes(page_byte_count);
5322                    with_query_id(sql_page_response(body), query_id)
5323                }
5324                Ok(Err(ControlledPageSerializationError::Query(error))) => {
5325                    query.fail();
5326                    tracked_query_error_response(&state, &error, Some(query_id))
5327                }
5328                Ok(Err(ControlledPageSerializationError::Encoding)) => fail(
5329                    StatusCode::INTERNAL_SERVER_ERROR,
5330                    "SERIALIZATION_FAILED",
5331                    "failed to serialize SQL continuation page",
5332                ),
5333                Err(_) => fail(
5334                    StatusCode::INTERNAL_SERVER_ERROR,
5335                    "SERIALIZATION_WORKER_FAILED",
5336                    "SQL continuation serialization worker failed",
5337                ),
5338            }
5339        }
5340        Err(sql_pages::CursorError::Cancelled) => {
5341            let error = cancellation_checkpoint_error(&query);
5342            query.fail();
5343            tracked_query_error_response(&state, &error, Some(query_id))
5344        }
5345        Err(sql_pages::CursorError::Invalid) => fail(
5346            StatusCode::BAD_REQUEST,
5347            "INVALID_SQL_CURSOR",
5348            "invalid SQL continuation cursor",
5349        ),
5350        Err(sql_pages::CursorError::Expired) => fail(
5351            StatusCode::GONE,
5352            "SQL_CURSOR_EXPIRED",
5353            "SQL continuation cursor expired",
5354        ),
5355        Err(sql_pages::CursorError::NotFound) => fail(
5356            StatusCode::NOT_FOUND,
5357            "SQL_CURSOR_NOT_FOUND",
5358            "SQL continuation result is unavailable",
5359        ),
5360        Err(sql_pages::CursorError::PageLimit) => fail(
5361            StatusCode::PAYLOAD_TOO_LARGE,
5362            "RESULT_LIMIT_EXCEEDED",
5363            "one projected row exceeds the page byte or token limit",
5364        ),
5365    }
5366}
5367
5368fn sql_cursor_error_response(
5369    status: StatusCode,
5370    code: &'static str,
5371    message: &'static str,
5372    query_id: QueryId,
5373) -> Response {
5374    (
5375        status,
5376        Json(json!({
5377            "query_id": query_id.to_string(),
5378            "status": "failed_before_commit",
5379            "terminal_state": "failed_before_commit",
5380            "server_state": "failed",
5381            "committed": false,
5382            "committed_statements": 0,
5383            "last_commit_epoch": null,
5384            "last_commit_epoch_text": null,
5385            "first_commit_statement_index": null,
5386            "last_commit_statement_index": null,
5387            "completed_statements": 0,
5388            "statement_index": 0,
5389            "cancel_outcome": "already_finished",
5390            "cancellation_reason": "none",
5391            "retryable": false,
5392            "outcome": {
5393                "committed": false,
5394                "committed_statements": 0,
5395                "last_commit_epoch": null,
5396                "last_commit_epoch_text": null,
5397                "first_commit_statement_index": null,
5398                "last_commit_statement_index": null,
5399                "completed_statements": 0,
5400                "statement_index": 0,
5401                "serialization": "not_started",
5402            },
5403            "error": {
5404                "code": code,
5405                "message": message,
5406                "query_id": query_id.to_string(),
5407                "committed": false,
5408                "retryable": false,
5409            }
5410        })),
5411    )
5412        .into_response()
5413}
5414
5415async fn sql(
5416    State(state): State<Arc<AppState>>,
5417    OptionalPrincipal(principal): OptionalPrincipal,
5418    headers: axum::http::HeaderMap,
5419    Json(req): Json<SqlRequest>,
5420) -> Response {
5421    if !state.accepting_sql.load(Ordering::Acquire) {
5422        return (StatusCode::SERVICE_UNAVAILABLE, "server is shutting down").into_response();
5423    }
5424    if !request_identity_is_current(&state, &principal) {
5425        return StatusCode::UNAUTHORIZED.into_response();
5426    }
5427    // Session routing: an `X-Session-ID` header routes the request to a pooled
5428    // long-lived session, enabling cross-request `BEGIN`/`INSERT`/`COMMIT`
5429    // transactions. Without the header, a fresh ephemeral session is used
5430    // (the historical behavior).
5431    let session_id = match query_session_header(&headers, None) {
5432        Ok(session_id) => session_id,
5433        Err(response) => return *response,
5434    };
5435
5436    let owner = request_owner(&state, &principal);
5437    if let Some(sid) = session_id {
5438        let Some(entry) = state.sessions.get(&sid, &owner) else {
5439            return (
5440                StatusCode::NOT_FOUND,
5441                "session not found or not owned by caller",
5442            )
5443                .into_response();
5444        };
5445        let (options, query_id) = match resolve_query_options(
5446            &state,
5447            &headers,
5448            req.query_id,
5449            req.timeout_ms,
5450            owner.clone(),
5451            Some(sid.clone()),
5452        ) {
5453            Ok(options) => options,
5454            Err(response) => return *response,
5455        };
5456        let query = match register_controlled_query(&state, &entry.session, options) {
5457            Ok(query) => query,
5458            Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
5459        };
5460        let registration = RegisteredQueryGuard::new(query);
5461        if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
5462            registration.fail();
5463            return with_query_id(remote_boolean_ai_error(), query_id);
5464        }
5465        let output_limits = match resolve_sql_output_limits(&state, &req, query_id) {
5466            Ok(limits) => limits,
5467            Err(response) => {
5468                registration.fail();
5469                return *response;
5470            }
5471        };
5472        let (registration, pagination) =
5473            match resolve_sql_pagination(&headers, &req, output_limits, registration, query_id) {
5474                Ok(resolved) => resolved,
5475                Err(response) => return *response,
5476            };
5477        let sql_permit =
5478            match acquire_sql_permit(&state, &entry.session, registration.query()).await {
5479                Ok(permit) => permit,
5480                Err(error) => {
5481                    return tracked_query_error_response(&state, &error, Some(query_id));
5482                }
5483            };
5484        // Registration and global admission happen before this session lock.
5485        let _guard = tokio::select! {
5486            guard = entry.lock.lock() => guard,
5487            _ = registration.query().control().cancelled() => {
5488                return tracked_query_error_response(
5489                    &state,
5490                    &cancellation_checkpoint_error(registration.query()),
5491                    Some(query_id),
5492                );
5493            }
5494        };
5495        // Re-check closed: the session may have been closed/evicted between
5496        // get() and acquiring the lock.
5497        if entry.is_closed() {
5498            return (StatusCode::NOT_FOUND, "session no longer available").into_response();
5499        }
5500        if req.idempotency_key.is_some() || headers.contains_key("idempotency-key") {
5501            entry
5502                .session
5503                .fire_test_hook(mongreldb_query::SqlTestHookPoint::BeforeServerIdempotencyCheck);
5504        }
5505        let (registration, idempotency) = match begin_sql_idempotency(
5506            &state,
5507            SqlIdempotencyContext {
5508                headers: &headers,
5509                request: &req,
5510                output_limits,
5511                owner: &owner,
5512                session_id: Some(&sid),
5513                session_in_transaction: entry.session.staged_sql_operation_count().is_some(),
5514                query_id,
5515            },
5516            registration,
5517        )
5518        .await
5519        {
5520            Ok(resolved) => resolved,
5521            Err(response) => return response,
5522        };
5523        entry.touch();
5524        let query = registration.into_query();
5525        execute_sql(
5526            &state,
5527            &principal,
5528            &entry.session,
5529            ResolvedSqlRequest {
5530                request: req,
5531                output_limits,
5532                idempotency,
5533                pagination,
5534            },
5535            query,
5536            query_id,
5537            sql_permit,
5538        )
5539        .await
5540    } else {
5541        let session = match MongrelSession::open_with_external_modules_as(
5542            Arc::clone(&state.db),
5543            state.external_modules.iter().cloned(),
5544            request_principal(&state, &principal),
5545        ) {
5546            Ok(session) => session.with_query_registry(Arc::clone(&state.query_registry)),
5547            Err(e) => return (status_for_query_error(&e), e.to_string()).into_response(),
5548        };
5549        let (options, query_id) = match resolve_query_options(
5550            &state,
5551            &headers,
5552            req.query_id,
5553            req.timeout_ms,
5554            owner.clone(),
5555            None,
5556        ) {
5557            Ok(options) => options,
5558            Err(response) => return *response,
5559        };
5560        let query = match register_controlled_query(&state, &session, options) {
5561            Ok(query) => query,
5562            Err(error) => return tracked_query_error_response(&state, &error, Some(query_id)),
5563        };
5564        let registration = RegisteredQueryGuard::new(query);
5565        if mongreldb_query::contains_boolean_ai_predicate(&req.sql) {
5566            registration.fail();
5567            return with_query_id(remote_boolean_ai_error(), query_id);
5568        }
5569        let output_limits = match resolve_sql_output_limits(&state, &req, query_id) {
5570            Ok(limits) => limits,
5571            Err(response) => {
5572                registration.fail();
5573                return *response;
5574            }
5575        };
5576        let (registration, pagination) =
5577            match resolve_sql_pagination(&headers, &req, output_limits, registration, query_id) {
5578                Ok(resolved) => resolved,
5579                Err(response) => return *response,
5580            };
5581        let (registration, idempotency) = match begin_sql_idempotency(
5582            &state,
5583            SqlIdempotencyContext {
5584                headers: &headers,
5585                request: &req,
5586                output_limits,
5587                owner: &owner,
5588                session_id: None,
5589                session_in_transaction: false,
5590                query_id,
5591            },
5592            registration,
5593        )
5594        .await
5595        {
5596            Ok(resolved) => resolved,
5597            Err(response) => return response,
5598        };
5599        let sql_permit = match acquire_sql_permit(&state, &session, registration.query()).await {
5600            Ok(permit) => permit,
5601            Err(error) => {
5602                if let Some(idempotency) = idempotency {
5603                    idempotency.abort();
5604                }
5605                return tracked_query_error_response(&state, &error, Some(query_id));
5606            }
5607        };
5608        let query = registration.into_query();
5609        execute_sql(
5610            &state,
5611            &principal,
5612            &session,
5613            ResolvedSqlRequest {
5614                request: req,
5615                output_limits,
5616                idempotency,
5617                pagination,
5618            },
5619            query,
5620            query_id,
5621            sql_permit,
5622        )
5623        .await
5624    }
5625}
5626
5627/// Run one SQL request against a given session: bump counters, audit DDL
5628/// (after execution, with redaction), log slow queries on both success and
5629/// failure, and dispatch the response format. Shared by the pooled-session and
5630/// fresh-session paths.
5631async fn execute_sql(
5632    state: &AppState,
5633    principal: &Option<mongreldb_core::Principal>,
5634    session: &MongrelSession,
5635    request: ResolvedSqlRequest,
5636    query: RegisteredSqlQuery,
5637    query_id: QueryId,
5638    sql_permit: tokio::sync::OwnedSemaphorePermit,
5639) -> Response {
5640    let ResolvedSqlRequest {
5641        request: req,
5642        output_limits,
5643        idempotency,
5644        pagination,
5645    } = request;
5646    // Keep a direct handle until the durable receipt is persisted. Finished
5647    // tombstone eviction must not make a committed idempotent write ambiguous.
5648    let idempotency_query = idempotency.as_ref().map(|_| query.clone());
5649    state.metrics.inc_sql_queries();
5650    let audited = audit::is_audited_sql(&req.sql);
5651    let actor = request_owner(state, principal);
5652    let page_binding = sql_pages::SqlPageBinding {
5653        security_version: state.db.security_version(),
5654        catalog_epoch: state.db.catalog_snapshot().db_epoch,
5655    };
5656    let start = std::time::Instant::now();
5657    // NOTE: deliberately NOT using `run_sql_traced` here. Its thread-local
5658    // push/pop spans an `.await`, and on a multi-threaded tokio runtime the
5659    // task can resume on a different thread, corrupting the trace stack and
5660    // leaking scopes. Wall-clock timing is sufficient for slow-query detection
5661    // and works across awaits.
5662    let result = if let Some(pagination) = pagination {
5663        match session
5664            .run_with_query_for_serialization_with_limits(
5665                &req.sql,
5666                query,
5667                mongreldb_query::SqlCollectionLimits::new(output_limits.0, output_limits.1),
5668            )
5669            .await
5670        {
5671            Ok(output) => Ok(dispatch_paginated_sql(
5672                state,
5673                output,
5674                query_id,
5675                &actor,
5676                pagination,
5677                output_limits,
5678                session.sql_test_hook(),
5679                page_binding,
5680            )
5681            .await),
5682            Err(error) => Err(error),
5683        }
5684    } else if req.format.as_deref() == Some("arrow-stream") {
5685        match session
5686            .run_stream_with_query_for_serialization(&req.sql, query)
5687            .await
5688        {
5689            Ok((stream, completion)) => Ok(sql_arrow_stream_response_controlled(
5690                stream,
5691                completion,
5692                sql_permit,
5693                output_limits,
5694                state,
5695                query_id,
5696                session.sql_test_hook(),
5697            )),
5698            Err(error) => Err(error),
5699        }
5700    } else {
5701        match session
5702            .run_with_query_for_serialization_with_limits(
5703                &req.sql,
5704                query,
5705                mongreldb_query::SqlCollectionLimits::new(output_limits.0, output_limits.1),
5706            )
5707            .await
5708        {
5709            Ok(output) => Ok(dispatch_buffered_sql_format(
5710                state,
5711                req.format.as_deref(),
5712                output,
5713                query_id,
5714                session.sql_test_hook(),
5715                output_limits,
5716            )
5717            .await),
5718            Err(error) => Err(error),
5719        }
5720    };
5721    let elapsed = start.elapsed();
5722    // Slow-query logging covers BOTH success and failure (the slowest errors
5723    // matter most for diagnosis), checked before branching on the outcome.
5724    if elapsed >= state.slow_query_threshold {
5725        state.metrics.inc_slow_queries();
5726        eprintln!(
5727            "[slow-query] {}\u{00b5}s query_id={} operation={}",
5728            elapsed.as_micros(),
5729            query_id,
5730            safe_sql_operation(&req.sql)
5731        );
5732    }
5733    // Audit DDL/privilege AFTER execution so the outcome (ok/fail) is captured.
5734    // `redacted_ddl_detail` never logs credential literals.
5735    if audited {
5736        let (action, detail) = audit::redacted_ddl_detail(&req.sql, result.is_ok());
5737        state.audit.record(actor, action, detail);
5738    }
5739    let response = match result {
5740        Ok(response) => with_query_id(response, query_id),
5741        Err(e) => {
5742            state.metrics.inc_sql_errors();
5743            tracked_query_error_response(state, &e, Some(query_id))
5744        }
5745    };
5746    let Some(idempotency) = idempotency else {
5747        return response;
5748    };
5749    let status = idempotency_query.map(|query| query.status());
5750    if let Some(receipt) = status.as_ref().and_then(sql_terminal_idempotency_receipt) {
5751        let (expires_at_ms, persisted) = idempotency.commit(receipt.clone());
5752        return sql_idempotency_receipt_response(
5753            query_id,
5754            &receipt,
5755            false,
5756            expires_at_ms,
5757            persisted,
5758        );
5759    }
5760    if status.as_ref().is_some_and(can_abort_idempotency_intent) {
5761        idempotency.abort();
5762    }
5763    response
5764}
5765
5766fn can_abort_idempotency_intent(status: &mongreldb_query::QueryStatus) -> bool {
5767    !status.outcome_unknown
5768        && !status.durable_outcome.committed
5769        && matches!(
5770            status.terminal_state(),
5771            Some(
5772                mongreldb_query::QueryTerminalState::FailedBeforeCommit
5773                    | mongreldb_query::QueryTerminalState::CancelledBeforeCommit
5774                    | mongreldb_query::QueryTerminalState::DeadlineBeforeCommit
5775            )
5776        )
5777}
5778
5779fn safe_sql_operation(sql: &str) -> String {
5780    sql.split_whitespace()
5781        .next()
5782        .unwrap_or("UNKNOWN")
5783        .chars()
5784        .filter(|character| character.is_ascii_alphabetic())
5785        .take(16)
5786        .collect::<String>()
5787        .to_ascii_uppercase()
5788}
5789
5790fn remote_boolean_ai_error() -> Response {
5791    (
5792        StatusCode::BAD_REQUEST,
5793        "Boolean ANN/Sparse SQL is disabled remotely; use scored SQL functions",
5794    )
5795        .into_response()
5796}
5797
5798#[derive(Debug)]
5799struct SerializedOutput {
5800    bytes: Vec<u8>,
5801    arrow: bool,
5802}
5803
5804#[derive(Debug)]
5805enum BufferedSerializationError {
5806    Query(mongreldb_query::MongrelQueryError),
5807    Limit(String),
5808    Encoding(String),
5809}
5810
5811struct LimitedOutput {
5812    bytes: Vec<u8>,
5813    max_bytes: usize,
5814    exceeded: bool,
5815}
5816
5817impl LimitedOutput {
5818    fn new(max_bytes: usize) -> Self {
5819        Self {
5820            bytes: Vec::new(),
5821            max_bytes,
5822            exceeded: false,
5823        }
5824    }
5825}
5826
5827impl std::io::Write for LimitedOutput {
5828    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
5829        if self.bytes.len().saturating_add(bytes.len()) > self.max_bytes {
5830            self.exceeded = true;
5831            return Err(std::io::Error::other("SQL output byte limit exceeded"));
5832        }
5833        self.bytes.extend_from_slice(bytes);
5834        Ok(bytes.len())
5835    }
5836
5837    fn flush(&mut self) -> std::io::Result<()> {
5838        Ok(())
5839    }
5840}
5841
5842fn serialize_buffered_output(
5843    format: &str,
5844    batches: &[arrow::record_batch::RecordBatch],
5845    query: &RegisteredSqlQuery,
5846    max_rows: usize,
5847    max_bytes: usize,
5848    test_hook: Option<&mongreldb_query::SqlTestHook>,
5849) -> std::result::Result<SerializedOutput, BufferedSerializationError> {
5850    const ROW_CHECKPOINT_INTERVAL: usize = 256;
5851    let mut rows = 0usize;
5852    let mut writer_output = LimitedOutput::new(max_bytes);
5853
5854    if format == "arrow" {
5855        if batches.is_empty() {
5856            if let Some(hook) = test_hook {
5857                hook(mongreldb_query::SqlTestHookPoint::AfterSerialization);
5858            }
5859            return Ok(SerializedOutput {
5860                bytes: Vec::new(),
5861                arrow: true,
5862            });
5863        }
5864        let schema = batches[0].schema();
5865        let encoding_result = (|| {
5866            let mut writer =
5867                arrow::ipc::writer::FileWriter::try_new(&mut writer_output, schema.as_ref())
5868                    .map_err(|error| error.to_string())?;
5869            for batch in batches {
5870                for offset in (0..batch.num_rows()).step_by(ROW_CHECKPOINT_INTERVAL) {
5871                    if let Some(hook) = test_hook {
5872                        hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
5873                    }
5874                    query.checkpoint().map_err(|error| error.to_string())?;
5875                    let length = ROW_CHECKPOINT_INTERVAL.min(batch.num_rows() - offset);
5876                    rows = rows.saturating_add(length);
5877                    if rows > max_rows {
5878                        return Err("SQL output row limit exceeded".into());
5879                    }
5880                    writer
5881                        .write(&batch.slice(offset, length))
5882                        .map_err(|error| error.to_string())?;
5883                }
5884            }
5885            writer.finish().map_err(|error| error.to_string())
5886        })();
5887        if let Err(error) = encoding_result {
5888            if let Err(query_error) = query.checkpoint() {
5889                return Err(BufferedSerializationError::Query(query_error));
5890            }
5891            if writer_output.exceeded || rows > max_rows {
5892                return Err(BufferedSerializationError::Limit(error));
5893            }
5894            return Err(BufferedSerializationError::Encoding(error));
5895        }
5896        if let Some(hook) = test_hook {
5897            hook(mongreldb_query::SqlTestHookPoint::AfterSerialization);
5898        }
5899        return Ok(SerializedOutput {
5900            bytes: writer_output.bytes,
5901            arrow: true,
5902        });
5903    }
5904
5905    let encoding_result = (|| {
5906        let mut writer = arrow::json::writer::ArrayWriter::new(&mut writer_output);
5907        for batch in batches {
5908            for offset in (0..batch.num_rows()).step_by(ROW_CHECKPOINT_INTERVAL) {
5909                if let Some(hook) = test_hook {
5910                    hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
5911                }
5912                query.checkpoint().map_err(|error| error.to_string())?;
5913                let length = ROW_CHECKPOINT_INTERVAL.min(batch.num_rows() - offset);
5914                rows = rows.saturating_add(length);
5915                if rows > max_rows {
5916                    return Err("SQL output row limit exceeded".into());
5917                }
5918                let slice = batch.slice(offset, length);
5919                writer
5920                    .write_batches(&[&slice])
5921                    .map_err(|error| error.to_string())?;
5922            }
5923        }
5924        writer.finish().map_err(|error| error.to_string())
5925    })();
5926    if let Err(error) = encoding_result {
5927        if let Err(query_error) = query.checkpoint() {
5928            return Err(BufferedSerializationError::Query(query_error));
5929        }
5930        if writer_output.exceeded || rows > max_rows {
5931            return Err(BufferedSerializationError::Limit(error));
5932        }
5933        return Err(BufferedSerializationError::Encoding(error));
5934    }
5935    if let Some(hook) = test_hook {
5936        hook(mongreldb_query::SqlTestHookPoint::AfterSerialization);
5937    }
5938    Ok(SerializedOutput {
5939        bytes: writer_output.bytes,
5940        arrow: false,
5941    })
5942}
5943
5944/// Serialize a DataFusion record-batch stream as Arrow streaming IPC. The body
5945/// holds only the active query batch and one serialized IPC message.
5946#[cfg(test)]
5947fn sql_arrow_stream_response(batches: mongreldb_query::MongrelRecordBatchStream) -> Response {
5948    use futures::{stream, StreamExt};
5949
5950    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
5951
5952    let schema = batches.schema();
5953    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
5954        Ok(w) => w,
5955        Err(e) => {
5956            return (
5957                StatusCode::INTERNAL_SERVER_ERROR,
5958                format!("arrow stream init error: {e}"),
5959            )
5960                .into_response()
5961        }
5962    };
5963    // `try_new` synchronously writes the schema message; drain it so it becomes
5964    // the first chunk yielded to the client.
5965    let schema_chunk: Vec<u8> = std::mem::take(writer.get_mut());
5966    let batch_stream = stream::unfold(
5967        (batches, Some(writer)),
5968        |(mut batches, writer)| async move {
5969            let mut writer = writer?;
5970            match batches.next().await {
5971                Some(Ok(batch)) => match writer.write(&batch) {
5972                    Ok(()) => {
5973                        let chunk = std::mem::take(writer.get_mut());
5974                        Some((Ok(chunk), (batches, Some(writer))))
5975                    }
5976                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
5977                },
5978                Some(Err(error)) => Some((Err(std::io::Error::other(error)), (batches, None))),
5979                None => match writer.finish() {
5980                    Ok(()) => {
5981                        let chunk = std::mem::take(writer.get_mut());
5982                        Some((Ok(chunk), (batches, None)))
5983                    }
5984                    Err(error) => Some((Err(std::io::Error::other(error)), (batches, None))),
5985                },
5986            }
5987        },
5988    );
5989
5990    // Schema first, then batches + EOS. Each item is already `Result<Vec<u8>,
5991    // io::Error>` so a mid-stream encode failure surfaces as a body error.
5992    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
5993    let full = stream::iter([schema_item]).chain(batch_stream);
5994    let body = axum::body::Body::from_stream(full);
5995    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
5996}
5997
5998fn sql_arrow_stream_response_controlled(
5999    batches: mongreldb_query::MongrelRecordBatchStream,
6000    completion: SqlStreamCompletion,
6001    sql_permit: tokio::sync::OwnedSemaphorePermit,
6002    limits: (usize, usize),
6003    state: &AppState,
6004    query_id: QueryId,
6005    test_hook: Option<mongreldb_query::SqlTestHook>,
6006) -> Response {
6007    use futures::{stream, StreamExt};
6008
6009    const STREAM_CT: &str = "application/vnd.apache.arrow.stream";
6010    let (max_rows, max_bytes) = limits;
6011    let schema = batches.schema();
6012    let mut writer = match arrow::ipc::writer::StreamWriter::try_new(Vec::new(), schema.as_ref()) {
6013        Ok(writer) => writer,
6014        Err(error) => {
6015            completion.fail_serialization();
6016            drop(batches);
6017            return terminal_server_error_response(
6018                state,
6019                query_id,
6020                StatusCode::INTERNAL_SERVER_ERROR,
6021                "SERIALIZATION_FAILED",
6022                format!("arrow stream init error: {error}"),
6023            );
6024        }
6025    };
6026    let schema_chunk = std::mem::take(writer.get_mut());
6027    if schema_chunk.len() > max_bytes {
6028        completion.fail_result_limit();
6029        drop(batches);
6030        return terminal_server_error_response(
6031            state,
6032            query_id,
6033            StatusCode::PAYLOAD_TOO_LARGE,
6034            "RESULT_LIMIT_EXCEEDED",
6035            "SQL output byte limit exceeded",
6036        );
6037    }
6038    let metrics = Arc::clone(&state.metrics);
6039    metrics.add_sql_output_bytes(schema_chunk.len());
6040    let batch_stream = stream::unfold(
6041        (
6042            batches,
6043            Some(writer),
6044            completion,
6045            Some(sql_permit),
6046            0usize,
6047            schema_chunk.len(),
6048            metrics,
6049        ),
6050        move |(mut batches, writer, completion, permit, rows, bytes, metrics)| {
6051            let test_hook = test_hook.clone();
6052            async move {
6053                let mut writer = writer?;
6054                match batches.next().await {
6055                    Some(Ok(batch)) => {
6056                        let next_rows = rows.saturating_add(batch.num_rows());
6057                        if next_rows > max_rows {
6058                            completion.fail_result_limit();
6059                            return Some((
6060                                Err(std::io::Error::other("SQL output row limit exceeded")),
6061                                (batches, None, completion, permit, next_rows, bytes, metrics),
6062                            ));
6063                        }
6064                        match writer.write(&batch) {
6065                            Ok(()) => {
6066                                let chunk = std::mem::take(writer.get_mut());
6067                                let next_bytes = bytes.saturating_add(chunk.len());
6068                                if next_bytes > max_bytes {
6069                                    completion.fail_result_limit();
6070                                    return Some((
6071                                        Err(std::io::Error::other(
6072                                            "SQL output byte limit exceeded",
6073                                        )),
6074                                        (
6075                                            batches, None, completion, permit, next_rows,
6076                                            next_bytes, metrics,
6077                                        ),
6078                                    ));
6079                                }
6080                                metrics.add_sql_output_bytes(chunk.len());
6081                                Some((
6082                                    Ok(chunk),
6083                                    (
6084                                        batches,
6085                                        Some(writer),
6086                                        completion,
6087                                        permit,
6088                                        next_rows,
6089                                        next_bytes,
6090                                        metrics,
6091                                    ),
6092                                ))
6093                            }
6094                            Err(error) => {
6095                                completion.fail_serialization();
6096                                Some((
6097                                    Err(std::io::Error::other(error)),
6098                                    (batches, None, completion, permit, rows, bytes, metrics),
6099                                ))
6100                            }
6101                        }
6102                    }
6103                    Some(Err(error)) => Some((
6104                        Err(std::io::Error::other(error)),
6105                        (batches, None, completion, permit, rows, bytes, metrics),
6106                    )),
6107                    None => match writer.finish() {
6108                        Ok(()) => {
6109                            let chunk = std::mem::take(writer.get_mut());
6110                            let next_bytes = bytes.saturating_add(chunk.len());
6111                            if next_bytes > max_bytes {
6112                                completion.fail_result_limit();
6113                                return Some((
6114                                    Err(std::io::Error::other("SQL output byte limit exceeded")),
6115                                    (batches, None, completion, permit, rows, next_bytes, metrics),
6116                                ));
6117                            }
6118                            if let Some(hook) = test_hook {
6119                                hook(mongreldb_query::SqlTestHookPoint::AfterSerialization);
6120                            }
6121                            match completion.try_complete() {
6122                                Ok(()) => {
6123                                    metrics.add_sql_output_bytes(chunk.len());
6124                                    Some((
6125                                        Ok(chunk),
6126                                        (
6127                                            batches, None, completion, permit, rows, next_bytes,
6128                                            metrics,
6129                                        ),
6130                                    ))
6131                                }
6132                                Err(error) => {
6133                                    metrics.inc_sql_errors();
6134                                    Some((
6135                                        Err(std::io::Error::other(error.to_string())),
6136                                        (batches, None, completion, permit, rows, bytes, metrics),
6137                                    ))
6138                                }
6139                            }
6140                        }
6141                        Err(error) => {
6142                            completion.fail_serialization();
6143                            Some((
6144                                Err(std::io::Error::other(error)),
6145                                (batches, None, completion, permit, rows, bytes, metrics),
6146                            ))
6147                        }
6148                    },
6149                }
6150            }
6151        },
6152    );
6153    let schema_item: Result<Vec<u8>, std::io::Error> = Ok(schema_chunk);
6154    let body = axum::body::Body::from_stream(stream::iter([schema_item]).chain(batch_stream));
6155    ([(header::CONTENT_TYPE, STREAM_CT)], body).into_response()
6156}
6157
6158#[derive(Deserialize)]
6159struct TxnOp {
6160    table: String,
6161    op: String,
6162    cells: Option<Vec<serde_json::Value>>,
6163    row_id: Option<u64>,
6164}
6165
6166#[derive(Deserialize)]
6167struct TxnRequest {
6168    ops: Vec<TxnOp>,
6169}
6170
6171async fn txn(
6172    State(state): State<Arc<AppState>>,
6173    OptionalPrincipal(principal): OptionalPrincipal,
6174    Json(req): Json<TxnRequest>,
6175) -> Response {
6176    // Pre-validate every op against the live schemas before entering the
6177    // transaction, so malformed input returns 400 without consuming an epoch
6178    // or poisoning a txn.
6179    let mut parsed: Vec<(String, TxnAction)> = Vec::with_capacity(req.ops.len());
6180    for op in &req.ops {
6181        match op.op.as_str() {
6182            "put" => {
6183                let cells_json = match op.cells.as_ref() {
6184                    Some(c) if !c.is_empty() => c,
6185                    _ => {
6186                        return (StatusCode::BAD_REQUEST, "put op requires non-empty cells")
6187                            .into_response()
6188                    }
6189                };
6190                let handle = match state.db.table(&op.table) {
6191                    Ok(h) => h,
6192                    Err(e) => return (StatusCode::NOT_FOUND, e.to_string()).into_response(),
6193                };
6194                let schema = handle.lock().schema().clone();
6195                let cells = match parse_cells(cells_json, &schema) {
6196                    Ok(c) => c,
6197                    Err(msg) => return (StatusCode::BAD_REQUEST, msg).into_response(),
6198                };
6199                parsed.push((op.table.clone(), TxnAction::Put(cells)));
6200            }
6201            "delete" => {
6202                let rid = match op.row_id {
6203                    Some(r) => r,
6204                    None => {
6205                        return (StatusCode::BAD_REQUEST, "delete op requires row_id")
6206                            .into_response()
6207                    }
6208                };
6209                parsed.push((op.table.clone(), TxnAction::Delete(rid)));
6210            }
6211            other => {
6212                return (StatusCode::BAD_REQUEST, format!("unknown op: {other}")).into_response()
6213            }
6214        }
6215    }
6216
6217    state.metrics.inc_txns();
6218    let mut transaction = state.db.begin_as(request_principal(&state, &principal));
6219    let result = (|| {
6220        for (table, action) in &parsed {
6221            match action {
6222                TxnAction::Put(cells) => {
6223                    transaction.put(table, cells.clone())?;
6224                }
6225                TxnAction::Delete(rid) => {
6226                    transaction.delete(table, mongreldb_core::RowId(*rid))?;
6227                }
6228            }
6229        }
6230        transaction.commit()
6231    })();
6232    match result {
6233        Ok(epoch) => Json(json!({
6234            "status": "committed",
6235            "epoch": epoch.0,
6236            "epoch_text": epoch.0.to_string()
6237        }))
6238        .into_response(),
6239        Err(error) => crate::kit::durable_core_error_response(&error)
6240            .unwrap_or_else(|| (status_for_error(&error), error.to_string()).into_response()),
6241    }
6242}
6243
6244enum TxnAction {
6245    Put(Vec<(u16, Value)>),
6246    Delete(u64),
6247}
6248
6249#[cfg(test)]
6250mod auth_tests {
6251    use super::*;
6252    use mongreldb_core::Database;
6253    use tempfile::tempdir;
6254
6255    #[test]
6256    fn slow_query_operation_does_not_include_literals() {
6257        let sql = "CREATE USER alice PASSWORD 'never-log-this'";
6258        let operation = safe_sql_operation(sql);
6259        assert_eq!(operation, "CREATE");
6260        assert!(!operation.contains("never-log-this"));
6261    }
6262
6263    #[tokio::test]
6264    async fn auth_rejects_missing_token() {
6265        let dir = tempdir().unwrap();
6266        let db = Arc::new(Database::create(dir.path()).unwrap());
6267        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
6268        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6269        let addr = listener.local_addr().unwrap();
6270        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6271        // Give the server a moment to start.
6272        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6273
6274        let client = reqwest::Client::new();
6275        let resp = client
6276            .get(format!("http://{addr}/health"))
6277            .send()
6278            .await
6279            .unwrap();
6280        assert_eq!(resp.status(), 401);
6281    }
6282
6283    #[tokio::test]
6284    async fn auth_accepts_valid_token() {
6285        let dir = tempdir().unwrap();
6286        let db = Arc::new(Database::create(dir.path()).unwrap());
6287        let app = build_app_with_config(db, std::iter::empty(), Some("secret".into()), None);
6288        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6289        let addr = listener.local_addr().unwrap();
6290        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6291        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6292
6293        let client = reqwest::Client::new();
6294        let resp = client
6295            .get(format!("http://{addr}/health"))
6296            .header("Authorization", "Bearer secret")
6297            .send()
6298            .await
6299            .unwrap();
6300        assert_eq!(resp.status(), 200);
6301    }
6302
6303    #[tokio::test]
6304    async fn no_auth_when_token_unset() {
6305        let dir = tempdir().unwrap();
6306        let db = Arc::new(Database::create(dir.path()).unwrap());
6307        let app = build_app_with_config(db, std::iter::empty(), None, None);
6308        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6309        let addr = listener.local_addr().unwrap();
6310        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6311        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6312
6313        let client = reqwest::Client::new();
6314        let resp = client
6315            .get(format!("http://{addr}/health"))
6316            .send()
6317            .await
6318            .unwrap();
6319        assert_eq!(resp.status(), 200);
6320    }
6321
6322    #[tokio::test]
6323    async fn capabilities_advertise_sql_cancellation_v2() {
6324        let dir = tempdir().unwrap();
6325        let db = Arc::new(Database::create(dir.path()).unwrap());
6326        let app = build_app_with_config(db, std::iter::empty(), None, None);
6327        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6328        let addr = listener.local_addr().unwrap();
6329        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6330
6331        let body: serde_json::Value = reqwest::Client::new()
6332            .get(format!("http://{addr}/capabilities"))
6333            .send()
6334            .await
6335            .unwrap()
6336            .json()
6337            .await
6338            .unwrap();
6339        assert_eq!(body["sql_cancellation"]["version"], 2);
6340        assert_eq!(body["sql_cancellation"]["client_query_ids"], true);
6341        assert_eq!(body["sql_cancellation"]["cancel_endpoint"], true);
6342        assert_eq!(body["sql_cancellation"]["query_status"], true);
6343        assert_eq!(body["sql_cancellation"]["pre_registration_cancel"], true);
6344        assert_eq!(body["sql_cancellation"]["stream_disconnect_cancels"], true);
6345        assert_eq!(body["sql_idempotency"]["version"], 1);
6346        assert_eq!(
6347            body["sql_idempotency"]["indeterminate_never_reexecutes"],
6348            true
6349        );
6350        assert_eq!(body["sql_pagination"]["version"], 1);
6351        assert_eq!(
6352            body["sql_pagination"]["continuation_endpoint"],
6353            "/sql/continue"
6354        );
6355    }
6356}
6357
6358#[cfg(test)]
6359mod query_response_tests {
6360    use super::*;
6361
6362    #[test]
6363    fn cancellation_reason_names_are_stable_snake_case() {
6364        assert_eq!(cancellation_reason_name(CancellationReason::None), "none");
6365        assert_eq!(
6366            cancellation_reason_name(CancellationReason::ClientRequest),
6367            "client_request"
6368        );
6369        assert_eq!(
6370            cancellation_reason_name(CancellationReason::ClientDisconnected),
6371            "client_disconnected"
6372        );
6373        assert_eq!(
6374            cancellation_reason_name(CancellationReason::SessionClosed),
6375            "session_closed"
6376        );
6377        assert_eq!(
6378            cancellation_reason_name(CancellationReason::ServerShutdown),
6379            "server_shutdown"
6380        );
6381        assert_eq!(
6382            cancellation_reason_name(CancellationReason::Deadline),
6383            "deadline"
6384        );
6385    }
6386
6387    #[test]
6388    fn unknown_outcome_never_proves_idempotency_intent_safe_to_abort() {
6389        let registry = Arc::new(SqlQueryRegistry::default());
6390        let unknown_id: QueryId = "102132435465768798a9bacbdcedfe0f".parse().unwrap();
6391        let unknown = registry
6392            .register(SqlQueryOptions {
6393                query_id: Some(unknown_id),
6394                ..SqlQueryOptions::default()
6395            })
6396            .unwrap();
6397        unknown.mark_outcome_unknown();
6398        unknown.fail();
6399        assert!(!can_abort_idempotency_intent(
6400            &registry.status(unknown_id).unwrap()
6401        ));
6402
6403        let failed_id: QueryId = "2031425364758697a8b9cadbecfd0e1f".parse().unwrap();
6404        let failed = registry
6405            .register(SqlQueryOptions {
6406                query_id: Some(failed_id),
6407                ..SqlQueryOptions::default()
6408            })
6409            .unwrap();
6410        failed.fail();
6411        assert!(can_abort_idempotency_intent(
6412            &registry.status(failed_id).unwrap()
6413        ));
6414    }
6415
6416    #[test]
6417    fn unknown_outcome_never_becomes_durable_receipt() {
6418        let registry = Arc::new(SqlQueryRegistry::default());
6419        let query = registry.register(SqlQueryOptions::default()).unwrap();
6420        query.record_commit(0, 42);
6421        query.mark_outcome_unknown();
6422        query.fail();
6423        let status = registry.status(query.id()).unwrap();
6424        assert!(status.durable_outcome.committed);
6425        assert!(status.outcome_unknown);
6426        assert!(sql_terminal_idempotency_receipt(&status).is_none());
6427    }
6428
6429    #[test]
6430    fn successful_noop_write_becomes_noncommitting_receipt() {
6431        let registry = Arc::new(SqlQueryRegistry::default());
6432        let query = registry.register(SqlQueryOptions::default()).unwrap();
6433        query
6434            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6435            .unwrap();
6436        query.try_complete().unwrap();
6437        let status = registry.status(query.id()).unwrap();
6438        assert!(!status.durable_outcome.committed);
6439        assert!(!can_abort_idempotency_intent(&status));
6440        let receipt = sql_terminal_idempotency_receipt(&status).unwrap();
6441        assert_eq!(receipt.status, "completed");
6442        assert!(!receipt.outcome.committed);
6443        assert_eq!(receipt.outcome.committed_statements, 0);
6444        assert_eq!(receipt.outcome.last_commit_epoch, None);
6445    }
6446
6447    #[test]
6448    fn conflicting_idempotency_key_sources_are_rejected() {
6449        let request = SqlRequest {
6450            sql: "INSERT INTO items VALUES (1)".into(),
6451            format: None,
6452            query_id: None,
6453            timeout_ms: None,
6454            max_output_rows: None,
6455            max_output_bytes: None,
6456            idempotency_key: Some("body-key".into()),
6457            pagination: None,
6458        };
6459        let mut headers = axum::http::HeaderMap::new();
6460        headers.insert("idempotency-key", "header-key".parse().unwrap());
6461        assert_eq!(
6462            requested_sql_idempotency_key(&headers, &request),
6463            Err("body idempotency_key and Idempotency-Key header must match")
6464        );
6465
6466        headers.insert("idempotency-key", "body-key".parse().unwrap());
6467        assert_eq!(
6468            requested_sql_idempotency_key(&headers, &request),
6469            Ok(Some("body-key".into()))
6470        );
6471    }
6472
6473    #[test]
6474    fn idempotency_binding_includes_pagination_semantics() {
6475        let mut request = SqlRequest {
6476            sql: "INSERT INTO items VALUES (1)".into(),
6477            format: None,
6478            query_id: None,
6479            timeout_ms: None,
6480            max_output_rows: None,
6481            max_output_bytes: None,
6482            idempotency_key: Some("key".into()),
6483            pagination: None,
6484        };
6485        let unpaged = sql_idempotency_binding(&request, (100, 1_024), None, 60_000).unwrap();
6486        request.pagination = Some(SqlPaginationRequest {
6487            page_size_rows: 10,
6488            projection: vec!["id".into()],
6489            max_page_bytes: Some(512),
6490            max_page_tokens: Some(128),
6491        });
6492        let paged = sql_idempotency_binding(&request, (100, 1_024), None, 60_000).unwrap();
6493        assert_ne!(unpaged.request_semantics_hash, paged.request_semantics_hash);
6494    }
6495
6496    #[test]
6497    fn paginated_decode_stops_nested_heap_amplification_at_budget() {
6498        let registry = Arc::new(SqlQueryRegistry::default());
6499        let query = registry.register(SqlQueryOptions::default()).unwrap();
6500        let json = format!("[[{}]]", vec!["null"; 10_000].join(","));
6501        let mut deserializer = serde_json::Deserializer::from_slice(json.as_bytes());
6502        let mut budget = PaginatedDecodeBudget {
6503            used: 0,
6504            limit: 4 * 1024,
6505            nodes: 0,
6506            exceeded: false,
6507            query: &query,
6508            test_hook: None,
6509        };
6510        let error = serde::de::DeserializeSeed::deserialize(
6511            BudgetedJsonRowsSeed {
6512                budget: &mut budget,
6513            },
6514            &mut deserializer,
6515        )
6516        .unwrap_err();
6517        assert!(error.to_string().contains(PAGINATED_MEMORY_LIMIT_ERROR));
6518        assert!(budget.exceeded);
6519        assert!(budget.nodes < 100, "decoded {} nodes", budget.nodes);
6520        query.fail();
6521    }
6522
6523    #[tokio::test]
6524    async fn unknown_outcome_response_never_claims_no_commit() {
6525        let registry = Arc::new(SqlQueryRegistry::default());
6526        let query = registry.register(SqlQueryOptions::default()).unwrap();
6527        let query_id = query.id();
6528        query
6529            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6530            .unwrap();
6531        let error = query.outcome_unknown_error("fenced maintenance failed");
6532        query.fail();
6533        let status = registry.status(query_id).unwrap();
6534        assert_eq!(
6535            status.terminal_state(),
6536            Some(mongreldb_query::QueryTerminalState::OutcomeUnknown)
6537        );
6538
6539        let response = query_error_response_with_status(&error, Some(query_id), Some(&status));
6540        assert_eq!(response.status(), StatusCode::CONFLICT);
6541        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6542            .await
6543            .unwrap();
6544        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6545        assert_eq!(body["status"], "outcome_unknown");
6546        assert_eq!(body["error"]["code"], "QUERY_OUTCOME_UNKNOWN");
6547        assert!(body["committed"].is_null());
6548        assert!(body["committed_statements"].is_null());
6549        assert!(body["last_commit_epoch"].is_null());
6550        assert!(body["completed_statements"].is_null());
6551        assert!(body["statement_index"].is_null());
6552        assert!(body["outcome"]["committed"].is_null());
6553        assert!(body["error"]["committed"].is_null());
6554    }
6555
6556    #[tokio::test]
6557    async fn retryable_idempotency_error_survives_terminal_status() {
6558        let registry = Arc::new(SqlQueryRegistry::default());
6559        let query = registry.register(SqlQueryOptions::default()).unwrap();
6560        let query_id = query.id();
6561        let response = registered_sql_error_response(
6562            RegisteredQueryGuard::new(query),
6563            query_id,
6564            StatusCode::SERVICE_UNAVAILABLE,
6565            "IDEMPOTENCY_STORE_UNAVAILABLE",
6566            "could not durably reserve the SQL idempotency key",
6567            true,
6568        );
6569        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6570            .await
6571            .unwrap();
6572        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6573        assert_eq!(body["retryable"], true);
6574        assert_eq!(body["error"]["retryable"], true);
6575
6576        let status = registry.status(query_id).unwrap();
6577        assert_eq!(
6578            status.terminal_error.as_ref().unwrap().code,
6579            "IDEMPOTENCY_STORE_UNAVAILABLE"
6580        );
6581        assert!(terminal_error_retryable(status.terminal_error.as_ref()));
6582    }
6583
6584    #[tokio::test]
6585    async fn committed_idempotency_terminal_error_is_a_receipt() {
6586        let query_id: QueryId = "00112233445566778899aabbccddeeff".parse().unwrap();
6587        let receipt = sql_idempotency::SqlDurableReceipt {
6588            original_query_id: query_id.to_string(),
6589            status: "committed_with_error".into(),
6590            server_state: "failed".into(),
6591            cancellation_reason: "client_disconnected".into(),
6592            outcome: sql_idempotency::SqlReceiptOutcome {
6593                committed: true,
6594                committed_statements: 1,
6595                last_commit_epoch: Some(42),
6596                last_commit_epoch_text: Some("42".into()),
6597                first_commit_statement_index: Some(0),
6598                last_commit_statement_index: Some(0),
6599                completed_statements: 1,
6600                statement_index: 0,
6601                serialization: "failed".into(),
6602            },
6603            terminal_error: Some(sql_idempotency::SqlReceiptTerminalError {
6604                code: "SERIALIZATION_FAILED_AFTER_COMMIT".into(),
6605                category: "serialization".into(),
6606            }),
6607        };
6608        let response = sql_idempotency_receipt_response(query_id, &receipt, false, 99, true);
6609        assert_eq!(response.status(), StatusCode::OK);
6610        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6611            .await
6612            .unwrap();
6613        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6614        assert_eq!(body["status"], "committed_with_error");
6615        assert_eq!(body["server_state"], "failed");
6616        assert_eq!(body["cancellation_reason"], "client_disconnected");
6617        assert_eq!(body["first_commit_statement_index"], 0);
6618        assert_eq!(body["last_commit_statement_index"], 0);
6619        assert_eq!(
6620            body["terminal_error"]["code"],
6621            "SERIALIZATION_FAILED_AFTER_COMMIT"
6622        );
6623    }
6624
6625    #[test]
6626    fn durable_replay_restores_terminal_status_parity() {
6627        let registry = Arc::new(SqlQueryRegistry::default());
6628        let query_id: QueryId = "11223344556677889900aabbccddeeff".parse().unwrap();
6629        let query = registry
6630            .register(SqlQueryOptions {
6631                query_id: Some(query_id),
6632                ..SqlQueryOptions::default()
6633            })
6634            .unwrap();
6635        let receipt = sql_idempotency::SqlDurableReceipt {
6636            original_query_id: "00112233445566778899aabbccddeeff".into(),
6637            status: "cancelled_after_commit".into(),
6638            server_state: "cancelled".into(),
6639            cancellation_reason: "client_disconnected".into(),
6640            outcome: sql_idempotency::SqlReceiptOutcome {
6641                committed: true,
6642                committed_statements: 1,
6643                last_commit_epoch: Some(42),
6644                last_commit_epoch_text: Some("42".into()),
6645                first_commit_statement_index: Some(0),
6646                last_commit_statement_index: Some(0),
6647                completed_statements: 1,
6648                statement_index: 1,
6649                serialization: "failed".into(),
6650            },
6651            terminal_error: Some(sql_idempotency::SqlReceiptTerminalError {
6652                code: "QUERY_CANCELLED_AFTER_COMMIT".into(),
6653                category: "cancellation".into(),
6654            }),
6655        };
6656        restore_idempotency_replay(RegisteredQueryGuard::new(query), &receipt).unwrap();
6657        let status = registry.status(query_id).unwrap();
6658        assert_eq!(status.phase, SqlQueryPhase::Cancelled);
6659        assert_eq!(
6660            status.terminal_state(),
6661            Some(mongreldb_query::QueryTerminalState::CancelledAfterCommit)
6662        );
6663        assert_eq!(
6664            status.cancellation_reason,
6665            CancellationReason::ClientDisconnected
6666        );
6667        assert_eq!(status.durable_outcome.committed_statements, 1);
6668        assert_eq!(status.durable_outcome.last_commit_epoch, Some(42));
6669        assert_eq!(
6670            status.terminal_error.unwrap().code,
6671            "QUERY_CANCELLED_AFTER_COMMIT"
6672        );
6673    }
6674
6675    #[test]
6676    fn durable_replay_rejects_invalid_authenticated_state() {
6677        let registry = Arc::new(SqlQueryRegistry::default());
6678        let query = registry.register(SqlQueryOptions::default()).unwrap();
6679        let receipt = sql_idempotency::SqlDurableReceipt {
6680            original_query_id: query.id().to_string(),
6681            status: "invented_terminal_state".into(),
6682            server_state: "completed".into(),
6683            cancellation_reason: "none".into(),
6684            outcome: sql_idempotency::SqlReceiptOutcome {
6685                committed: true,
6686                committed_statements: 1,
6687                last_commit_epoch: Some(42),
6688                last_commit_epoch_text: Some("42".into()),
6689                first_commit_statement_index: Some(0),
6690                last_commit_statement_index: Some(0),
6691                completed_statements: 1,
6692                statement_index: 0,
6693                serialization: "succeeded".into(),
6694            },
6695            terminal_error: None,
6696        };
6697        let error =
6698            restore_idempotency_replay(RegisteredQueryGuard::new(query), &receipt).unwrap_err();
6699        assert!(error.to_string().contains("invalid terminal state"));
6700    }
6701
6702    #[test]
6703    fn durable_replay_cancel_wins_before_receipt_response() {
6704        let registry = Arc::new(SqlQueryRegistry::default());
6705        let query_id: QueryId = "22334455667788990011aabbccddeeff".parse().unwrap();
6706        let query = registry
6707            .register(SqlQueryOptions {
6708                query_id: Some(query_id),
6709                ..SqlQueryOptions::default()
6710            })
6711            .unwrap();
6712        assert_eq!(
6713            query.request_cancel(CancellationReason::ClientRequest),
6714            CancelOutcome::Accepted
6715        );
6716        let receipt = sql_idempotency::SqlDurableReceipt {
6717            original_query_id: "00112233445566778899aabbccddeeff".into(),
6718            status: "completed".into(),
6719            server_state: "completed".into(),
6720            cancellation_reason: "none".into(),
6721            outcome: sql_idempotency::SqlReceiptOutcome {
6722                committed: true,
6723                committed_statements: 1,
6724                last_commit_epoch: Some(42),
6725                last_commit_epoch_text: Some("42".into()),
6726                first_commit_statement_index: Some(0),
6727                last_commit_statement_index: Some(0),
6728                completed_statements: 1,
6729                statement_index: 0,
6730                serialization: "succeeded".into(),
6731            },
6732            terminal_error: None,
6733        };
6734        let error = restore_idempotency_replay(RegisteredQueryGuard::new(query), &receipt)
6735            .expect_err("accepted cancellation must suppress replay success");
6736        assert!(matches!(
6737            error,
6738            mongreldb_query::MongrelQueryError::QueryCancelled { .. }
6739        ));
6740        let status = registry.status(query_id).unwrap();
6741        assert_eq!(status.phase, SqlQueryPhase::Cancelled);
6742        assert!(status.durable_outcome.committed);
6743    }
6744
6745    #[test]
6746    fn direct_query_handle_preserves_receipt_after_tombstone_eviction() {
6747        let registry = Arc::new(SqlQueryRegistry::new(
6748            1,
6749            1,
6750            usize::MAX,
6751            std::time::Duration::from_secs(60),
6752        ));
6753        let first = registry.register(SqlQueryOptions::default()).unwrap();
6754        first
6755            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6756            .unwrap();
6757        first.record_commit(0, 42);
6758        first.complete_current_statement();
6759        first.try_complete().unwrap();
6760
6761        let second = registry.register(SqlQueryOptions::default()).unwrap();
6762        second
6763            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6764            .unwrap();
6765        second.try_complete().unwrap();
6766
6767        assert!(registry.status(first.id()).is_none());
6768        let receipt = sql_terminal_idempotency_receipt(&first.status()).unwrap();
6769        assert_eq!(receipt.outcome.committed_statements, 1);
6770        assert_eq!(receipt.outcome.last_commit_epoch, Some(42));
6771    }
6772
6773    #[test]
6774    fn cancellation_checkpoint_mismatch_returns_typed_error() {
6775        let registry = Arc::new(SqlQueryRegistry::default());
6776        let query = registry.register(SqlQueryOptions::default()).unwrap();
6777        assert!(matches!(
6778            cancellation_checkpoint_error(&query),
6779            mongreldb_query::MongrelQueryError::InvalidQueryState(_)
6780        ));
6781        assert_eq!(
6782            query.request_cancel(CancellationReason::ClientRequest),
6783            CancelOutcome::Accepted
6784        );
6785        assert!(matches!(
6786            cancellation_checkpoint_error(&query),
6787            mongreldb_query::MongrelQueryError::QueryCancelled { .. }
6788        ));
6789    }
6790
6791    #[tokio::test]
6792    async fn cancellation_after_commit_reports_durable_outcome() {
6793        let registry = Arc::new(SqlQueryRegistry::default());
6794        let query = registry.register(SqlQueryOptions::default()).unwrap();
6795        let query_id = query.id();
6796        query
6797            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6798            .unwrap();
6799        query.record_commit(0, 42);
6800        assert_eq!(
6801            query.request_cancel(CancellationReason::ClientRequest),
6802            CancelOutcome::Accepted
6803        );
6804        let error = query.checkpoint().unwrap_err();
6805        query.fail();
6806        let status = registry.status(query_id).unwrap();
6807
6808        let response = query_error_response_with_status(&error, Some(query_id), Some(&status));
6809        assert_eq!(response.status(), StatusCode::CONFLICT);
6810        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6811            .await
6812            .unwrap();
6813        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6814        assert_eq!(body["status"], "cancelled_after_commit");
6815        assert_eq!(body["error"]["code"], "QUERY_CANCELLED_AFTER_COMMIT");
6816        assert_eq!(body["committed"], true);
6817        assert_eq!(body["outcome"]["committed_statements"], 1);
6818        assert_eq!(body["outcome"]["last_commit_epoch"], 42);
6819        assert_eq!(body["outcome"]["last_commit_epoch_text"], "42");
6820        assert_eq!(body["first_commit_statement_index"], 0);
6821        assert_eq!(body["last_commit_statement_index"], 0);
6822        assert_eq!(body["outcome"]["first_commit_statement_index"], 0);
6823        assert_eq!(body["outcome"]["last_commit_statement_index"], 0);
6824
6825        let response = query_error_response_with_status(&error, Some(query_id), None);
6826        assert_eq!(response.status(), StatusCode::CONFLICT);
6827        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6828            .await
6829            .unwrap();
6830        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6831        assert_eq!(body["status"], "cancelled_after_commit");
6832        assert_eq!(body["error"]["code"], "QUERY_CANCELLED_AFTER_COMMIT");
6833        assert_eq!(body["committed"], true);
6834        assert_eq!(body["committed_statements"], 1);
6835        assert_eq!(body["last_commit_epoch"], 42);
6836        assert_eq!(body["last_commit_epoch_text"], "42");
6837        assert_eq!(body["first_commit_statement_index"], 0);
6838        assert_eq!(body["last_commit_statement_index"], 0);
6839        assert_eq!(body["outcome"]["committed"], true);
6840        assert_eq!(body["outcome"]["committed_statements"], 1);
6841        assert_eq!(body["outcome"]["last_commit_epoch"], 42);
6842        assert_eq!(body["outcome"]["last_commit_epoch_text"], "42");
6843        assert_eq!(body["outcome"]["first_commit_statement_index"], 0);
6844        assert_eq!(body["outcome"]["last_commit_statement_index"], 0);
6845    }
6846
6847    #[tokio::test]
6848    async fn commit_outcome_fallback_preserves_exact_progress() {
6849        let query_id: QueryId = "33445566778899001122aabbccddeeff".parse().unwrap();
6850        let error = mongreldb_query::MongrelQueryError::CommitOutcome {
6851            query_id,
6852            committed: true,
6853            committed_statements: 3,
6854            last_commit_epoch: Some(77),
6855            first_commit_statement_index: Some(1),
6856            last_commit_statement_index: Some(4),
6857            completed_statements: 4,
6858            statement_index: 5,
6859            message: "durable outcome retained".into(),
6860        };
6861        let response = query_error_response_with_status(&error, Some(query_id), None);
6862        assert_eq!(response.status(), StatusCode::CONFLICT);
6863        let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
6864            .await
6865            .unwrap();
6866        let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
6867        assert_eq!(body["status"], "committed_with_error");
6868        assert_eq!(body["committed"], true);
6869        assert_eq!(body["committed_statements"], 3);
6870        assert_eq!(body["last_commit_epoch"], 77);
6871        assert_eq!(body["last_commit_epoch_text"], "77");
6872        assert_eq!(body["first_commit_statement_index"], 1);
6873        assert_eq!(body["last_commit_statement_index"], 4);
6874        assert_eq!(body["completed_statements"], 4);
6875        assert_eq!(body["statement_index"], 5);
6876        assert_eq!(body["outcome"]["committed_statements"], 3);
6877        assert_eq!(body["outcome"]["last_commit_epoch"], 77);
6878        assert_eq!(body["outcome"]["first_commit_statement_index"], 1);
6879        assert_eq!(body["outcome"]["last_commit_statement_index"], 4);
6880        assert_eq!(body["outcome"]["completed_statements"], 4);
6881        assert_eq!(body["outcome"]["statement_index"], 5);
6882    }
6883
6884    #[test]
6885    fn status_cancel_outcome_matches_cancel_endpoint_state() {
6886        let registry = Arc::new(SqlQueryRegistry::default());
6887        let commit = registry.register(SqlQueryOptions::default()).unwrap();
6888        commit
6889            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6890            .unwrap();
6891        commit.enter_commit_critical().unwrap();
6892        assert_eq!(
6893            query_cancel_outcome(&registry.status(commit.id()).unwrap()),
6894            Some("too_late")
6895        );
6896
6897        let completed = registry.register(SqlQueryOptions::default()).unwrap();
6898        completed
6899            .transition(SqlQueryPhase::Queued, SqlQueryPhase::Executing)
6900            .unwrap();
6901        completed.try_complete().unwrap();
6902        assert_eq!(
6903            query_cancel_outcome(&registry.status(completed.id()).unwrap()),
6904            Some("already_finished")
6905        );
6906    }
6907}
6908
6909#[cfg(test)]
6910mod wal_stream_tests {
6911    use super::*;
6912    use mongreldb_client::ReplicationFollower;
6913    use mongreldb_core::Database;
6914    use tempfile::tempdir;
6915
6916    #[tokio::test]
6917    async fn wal_stream_returns_records_after_commit() {
6918        let dir = tempdir().unwrap();
6919        let db = Arc::new(Database::create(dir.path()).unwrap());
6920        let table_schema = mongreldb_core::schema::Schema {
6921            schema_id: 1,
6922            columns: vec![mongreldb_core::schema::ColumnDef {
6923                id: 1,
6924                name: "id".into(),
6925                ty: TypeId::Int64,
6926                flags: mongreldb_core::schema::ColumnFlags::empty()
6927                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
6928                default_value: None,
6929            }],
6930            indexes: vec![],
6931            colocation: vec![],
6932            constraints: Default::default(),
6933            clustered: false,
6934        };
6935        db.create_table("items", table_schema).unwrap();
6936        // Write a row to generate WAL records.
6937        let handle = db.table("items").unwrap();
6938        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
6939        handle.lock().flush().unwrap();
6940
6941        let app = build_app(db);
6942        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6943        let addr = listener.local_addr().unwrap();
6944        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6945        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6946
6947        let resp = reqwest::get(format!("http://{addr}/wal/stream"))
6948            .await
6949            .unwrap();
6950        assert_eq!(resp.status(), 200);
6951        let body = resp.text().await.unwrap();
6952        // Should contain at least one record (the flush commit).
6953        assert!(!body.is_empty(), "wal_stream should return records");
6954        assert!(body.contains("seq"), "response should contain seq field");
6955    }
6956
6957    #[tokio::test]
6958    async fn follower_bootstraps_and_applies_incremental_commit() {
6959        let leader_dir = tempdir().unwrap();
6960        let follower_dir = tempdir().unwrap();
6961        let follower_path = follower_dir.path().join("copy");
6962        let db = Arc::new(Database::create(leader_dir.path()).unwrap());
6963        db.create_table(
6964            "items",
6965            mongreldb_core::schema::Schema {
6966                schema_id: 1,
6967                columns: vec![mongreldb_core::schema::ColumnDef {
6968                    id: 1,
6969                    name: "id".into(),
6970                    ty: TypeId::Int64,
6971                    flags: mongreldb_core::schema::ColumnFlags::empty()
6972                        .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
6973                    default_value: None,
6974                }],
6975                indexes: vec![],
6976                colocation: vec![],
6977                constraints: Default::default(),
6978                clustered: false,
6979            },
6980        )
6981        .unwrap();
6982        let handle = db.table("items").unwrap();
6983        handle.lock().put(vec![(1, Value::Int64(1))]).unwrap();
6984        handle.lock().commit().unwrap();
6985
6986        let app = build_app_with_config(
6987            Arc::clone(&db),
6988            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
6989            Some("replication-secret".into()),
6990            None,
6991        );
6992        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6993        let addr = listener.local_addr().unwrap();
6994        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
6995        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6996
6997        let leader_url = format!("http://{addr}");
6998        let first_path = follower_path.clone();
6999        let (mut follower, initial) = tokio::task::spawn_blocking(move || {
7000            let mut follower = ReplicationFollower::new(&leader_url, first_path)
7001                .unwrap()
7002                .with_bearer_token("replication-secret");
7003            let applied = follower.sync().unwrap();
7004            (follower, applied)
7005        })
7006        .await
7007        .unwrap();
7008        assert_eq!(initial, 0);
7009
7010        handle.lock().put(vec![(1, Value::Int64(2))]).unwrap();
7011        handle.lock().commit().unwrap();
7012        let applied = tokio::task::spawn_blocking(move || {
7013            let count = follower.sync().unwrap();
7014            (follower, count)
7015        })
7016        .await
7017        .unwrap();
7018        follower = applied.0;
7019        assert!(applied.1 > 0);
7020        assert!(follower.last_epoch() > 0);
7021
7022        let replica = Database::open(&follower_path).unwrap();
7023        assert_eq!(replica.table("items").unwrap().lock().count(), 2);
7024        drop(replica);
7025
7026        db.set_spill_threshold(1);
7027        db.transaction(|txn| {
7028            txn.put("items", vec![(1, Value::Int64(3))])?;
7029            Ok(())
7030        })
7031        .unwrap();
7032        let (follower_after_bootstrap, applied) = tokio::task::spawn_blocking(move || {
7033            let count = follower.sync().unwrap();
7034            (follower, count)
7035        })
7036        .await
7037        .unwrap();
7038        assert_eq!(applied, 0, "spilled run should trigger safe rebootstrap");
7039        assert!(follower_after_bootstrap.last_epoch() > 0);
7040        let replica = Database::open(&follower_path).unwrap();
7041        assert_eq!(replica.table("items").unwrap().lock().count(), 3);
7042    }
7043}
7044
7045#[cfg(test)]
7046mod metrics_tests {
7047    use super::*;
7048    use mongreldb_core::Database;
7049    use tempfile::tempdir;
7050
7051    /// Helper: spin up a daemon over a fresh DB with one `items(id int64 pk)`
7052    /// table pre-created, returning the bound address.
7053    async fn setup() -> (tempfile::TempDir, std::net::SocketAddr) {
7054        let dir = tempdir().unwrap();
7055        let db = Arc::new(Database::create(dir.path()).unwrap());
7056        let table_schema = mongreldb_core::schema::Schema {
7057            schema_id: 1,
7058            columns: vec![mongreldb_core::schema::ColumnDef {
7059                id: 1,
7060                name: "id".into(),
7061                ty: TypeId::Int64,
7062                flags: mongreldb_core::schema::ColumnFlags::empty()
7063                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
7064                default_value: None,
7065            }],
7066            indexes: vec![],
7067            colocation: vec![],
7068            constraints: Default::default(),
7069            clustered: false,
7070        };
7071        db.create_table("items", table_schema).unwrap();
7072        let app = build_app(db);
7073        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7074        let addr = listener.local_addr().unwrap();
7075        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
7076        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7077        (dir, addr)
7078    }
7079
7080    #[tokio::test]
7081    async fn metrics_endpoint_returns_prometheus_text() {
7082        let (_dir, addr) = setup().await;
7083        let client = reqwest::Client::new();
7084
7085        // Exercise a few handlers to bump counters.
7086        let _ = client
7087            .post(format!("http://{addr}/tables/items/put"))
7088            .json(&json!({ "row": [1, 1] }))
7089            .send()
7090            .await
7091            .unwrap();
7092        let _ = client
7093            .post(format!("http://{addr}/sql"))
7094            .json(&json!({ "sql": "SELECT count(*) FROM items" }))
7095            .send()
7096            .await
7097            .unwrap();
7098
7099        let resp = client
7100            .get(format!("http://{addr}/metrics"))
7101            .send()
7102            .await
7103            .unwrap();
7104        assert_eq!(resp.status(), 200);
7105        let ct = resp
7106            .headers()
7107            .get("content-type")
7108            .and_then(|v| v.to_str().ok())
7109            .unwrap_or_default()
7110            .to_string();
7111        assert!(
7112            ct.contains("text/plain"),
7113            "content-type is prometheus text: {ct}"
7114        );
7115        let body = resp.text().await.unwrap();
7116        // Prometheus series + type lines are present.
7117        assert!(body.contains("# TYPE mongreldb_sql_queries_total counter"));
7118        assert!(body.contains("# TYPE mongreldb_puts_total counter"));
7119        assert!(body.contains("# TYPE mongreldb_tables gauge"));
7120        // Counters were bumped: at least one query and one put were served.
7121        assert!(
7122            body.contains("mongreldb_sql_queries_total 1"),
7123            "sql_queries counter should reflect the /sql call: {body}"
7124        );
7125        assert!(
7126            body.contains("mongreldb_puts_total 1"),
7127            "puts counter should reflect the put call: {body}"
7128        );
7129        // The tables gauge reflects the single `items` table.
7130        assert!(body.contains("mongreldb_tables 1"));
7131    }
7132
7133    #[tokio::test]
7134    async fn metrics_error_counter_increments_on_bad_sql() {
7135        let (_dir, addr) = setup().await;
7136        let client = reqwest::Client::new();
7137        // A query against a non-existent table errors at the engine layer.
7138        let _ = client
7139            .post(format!("http://{addr}/sql"))
7140            .json(&json!({ "sql": "SELECT * FROM does_not_exist" }))
7141            .send()
7142            .await
7143            .unwrap();
7144        let body = client
7145            .get(format!("http://{addr}/metrics"))
7146            .send()
7147            .await
7148            .unwrap()
7149            .text()
7150            .await
7151            .unwrap();
7152        assert!(
7153            body.contains("mongreldb_sql_errors_total 1"),
7154            "sql_errors should increment on a failed query: {body}"
7155        );
7156    }
7157
7158    #[tokio::test]
7159    async fn arrow_stream_returns_ipc_stream_bytes() {
7160        let (_dir, addr) = setup().await;
7161        let client = reqwest::Client::new();
7162        // Insert a couple of rows so there are real batches to stream, then
7163        // flush so the rows are durable/visible to a fresh SQL session.
7164        for i in 1..=3 {
7165            let resp = client
7166                .post(format!("http://{addr}/tables/items/put"))
7167                .json(&json!({ "row": [1, i] }))
7168                .send()
7169                .await
7170                .unwrap();
7171            assert_eq!(resp.status(), 200, "put should succeed");
7172        }
7173        let _ = client
7174            .post(format!("http://{addr}/tables/items/commit"))
7175            .send()
7176            .await
7177            .unwrap();
7178        // Sanity: the rows are durable and visible to the table handle.
7179        let count_body = client
7180            .get(format!("http://{addr}/tables/items/count"))
7181            .send()
7182            .await
7183            .unwrap()
7184            .text()
7185            .await
7186            .unwrap();
7187        assert!(
7188            count_body.contains("\"count\":3"),
7189            "expected 3 visible rows, got: {count_body}"
7190        );
7191        let resp = client
7192            .post(format!("http://{addr}/sql"))
7193            .json(&json!({ "sql": "SELECT count(*) FROM items", "format": "arrow-stream" }))
7194            .send()
7195            .await
7196            .unwrap();
7197        assert_eq!(resp.status(), 200, "streaming query should succeed");
7198        let ct = resp
7199            .headers()
7200            .get("content-type")
7201            .and_then(|v| v.to_str().ok())
7202            .unwrap_or_default()
7203            .to_string();
7204        assert!(
7205            ct.contains("application/vnd.apache.arrow.stream"),
7206            "content-type should be the arrow stream format: {ct}"
7207        );
7208        let bytes = resp.bytes().await.unwrap();
7209        // Arrow IPC streams begin with the magic continuation marker
7210        // 0xFFFFFFFF followed by the schema message length. The stream must be
7211        // non-empty (3 rows were written) and end with the EOS marker.
7212        assert!(
7213            !bytes.is_empty(),
7214            "arrow stream body should contain schema + batch + EOS"
7215        );
7216        assert!(
7217            bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()),
7218            "arrow stream must begin with the IPC continuation marker"
7219        );
7220        assert!(
7221            bytes.ends_with(&[0u8, 0, 0, 0]),
7222            "arrow stream should end with the EOS marker (trailing zero length)"
7223        );
7224    }
7225}
7226
7227#[cfg(test)]
7228mod streaming_tests {
7229    use super::*;
7230    use arrow::array::Int64Array;
7231    use arrow::datatypes::{DataType, Field, Schema};
7232    use arrow::record_batch::RecordBatch;
7233    use datafusion::common::DataFusionError;
7234    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
7235    use futures::StreamExt;
7236    use std::sync::Arc as StdArc;
7237
7238    fn batch_stream(batches: Vec<RecordBatch>) -> mongreldb_query::MongrelRecordBatchStream {
7239        let schema = batches
7240            .first()
7241            .map(RecordBatch::schema)
7242            .unwrap_or_else(|| StdArc::new(Schema::empty()));
7243        let batches =
7244            futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
7245        Box::pin(RecordBatchStreamAdapter::new(schema, batches))
7246    }
7247
7248    /// Unit-level check: feed two synthetic batches through the streaming
7249    /// serializer and re-parse the resulting IPC stream end-to-end. This
7250    /// validates the per-message chunking (schema + N batches + EOS) without
7251    /// depending on the engine's scan visibility.
7252    #[tokio::test]
7253    async fn arrow_stream_serializes_multiple_batches_roundtrip() {
7254        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
7255        let b1 = RecordBatch::try_new(
7256            schema.clone(),
7257            vec![StdArc::new(Int64Array::from(vec![1, 2]))],
7258        )
7259        .unwrap();
7260        let b2 = RecordBatch::try_new(
7261            schema.clone(),
7262            vec![StdArc::new(Int64Array::from(vec![3, 4, 5]))],
7263        )
7264        .unwrap();
7265
7266        let resp = sql_arrow_stream_response(batch_stream(vec![b1, b2]));
7267        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
7268            .await
7269            .unwrap();
7270
7271        // Begins with the IPC continuation marker.
7272        assert!(bytes.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
7273
7274        // Re-parse the full stream and confirm all rows survived the chunked
7275        // serialization.
7276        let slice: &[u8] = bytes.as_ref();
7277        let mut reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
7278        let mut total_rows = 0;
7279        for batch in reader.by_ref() {
7280            let batch = batch.expect("each IPC message should decode");
7281            total_rows += batch.num_rows();
7282        }
7283        assert_eq!(
7284            total_rows, 5,
7285            "all rows should round-trip through the stream"
7286        );
7287    }
7288
7289    #[tokio::test]
7290    async fn arrow_stream_emits_schema_before_first_batch() {
7291        let schema = StdArc::new(Schema::new(vec![Field::new("v", DataType::Int64, false)]));
7292        let pending = futures::stream::pending::<Result<RecordBatch, DataFusionError>>();
7293        let batches = Box::pin(RecordBatchStreamAdapter::new(schema, pending));
7294        let mut body = sql_arrow_stream_response(batches)
7295            .into_body()
7296            .into_data_stream();
7297
7298        let chunk = tokio::time::timeout(std::time::Duration::from_millis(100), body.next())
7299            .await
7300            .expect("schema chunk should not wait for a query batch")
7301            .unwrap()
7302            .unwrap();
7303        assert!(chunk.starts_with(&0xFFFFFFFFu32.to_le_bytes()));
7304    }
7305
7306    #[tokio::test]
7307    async fn arrow_stream_empty_query_is_valid_ipc() {
7308        let resp = sql_arrow_stream_response(batch_stream(Vec::new()));
7309        let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20)
7310            .await
7311            .unwrap();
7312        let slice: &[u8] = bytes.as_ref();
7313        let reader = arrow::ipc::reader::StreamReader::try_new(slice, None).unwrap();
7314        assert_eq!(reader.count(), 0);
7315    }
7316
7317    #[tokio::test]
7318    async fn buffered_output_limits_are_typed() {
7319        let dir = tempfile::tempdir().unwrap();
7320        let db = StdArc::new(mongreldb_core::Database::create(dir.path()).unwrap());
7321        let session = MongrelSession::open(db).unwrap();
7322        let query = session.register_query(SqlQueryOptions::default()).unwrap();
7323        let output = session
7324            .run_with_query_for_serialization("SELECT 1", query)
7325            .await
7326            .unwrap();
7327
7328        let row_error =
7329            serialize_buffered_output("json", output.batches(), output.query(), 0, 1024, None)
7330                .unwrap_err();
7331        assert!(matches!(row_error, BufferedSerializationError::Limit(_)));
7332        let byte_error =
7333            serialize_buffered_output("json", output.batches(), output.query(), 10, 1, None)
7334                .unwrap_err();
7335        assert!(matches!(byte_error, BufferedSerializationError::Limit(_)));
7336        output.fail();
7337    }
7338}
7339
7340#[cfg(test)]
7341mod audit_tests {
7342    use super::*;
7343    use mongreldb_core::Database;
7344    use tempfile::tempdir;
7345
7346    /// Build a daemon with user-auth enabled plus one catalog user `alice`.
7347    async fn auth_setup(password: &str) -> std::net::SocketAddr {
7348        let dir = tempdir().unwrap();
7349        let db = Arc::new(Database::create(dir.path()).unwrap());
7350        db.create_user("alice", password).unwrap();
7351        db.set_user_admin("alice", true).unwrap();
7352        let app = build_app_full(
7353            db,
7354            std::iter::empty::<Arc<dyn ExternalTableModule>>(),
7355            None,
7356            None,
7357            true,
7358        );
7359        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7360        let addr = listener.local_addr().unwrap();
7361        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
7362        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7363        addr
7364    }
7365
7366    #[tokio::test]
7367    async fn audit_records_login_success_and_failure() {
7368        let addr = auth_setup("s3cret").await;
7369        let client = reqwest::Client::new();
7370
7371        // Successful basic auth.
7372        let resp = client
7373            .get(format!("http://{addr}/health"))
7374            .header("Authorization", basic("alice", "s3cret"))
7375            .send()
7376            .await
7377            .unwrap();
7378        assert_eq!(resp.status(), 200);
7379
7380        // Failed basic auth (wrong password).
7381        let resp = client
7382            .get(format!("http://{addr}/health"))
7383            .header("Authorization", basic("alice", "wrong"))
7384            .send()
7385            .await
7386            .unwrap();
7387        assert_eq!(resp.status(), 401);
7388
7389        let body = client
7390            .get(format!("http://{addr}/audit"))
7391            .header("Authorization", basic("alice", "s3cret"))
7392            .send()
7393            .await
7394            .unwrap()
7395            .text()
7396            .await
7397            .unwrap();
7398        assert!(
7399            body.contains("\"action\":\"login.ok\""),
7400            "audit should record the successful login: {body}"
7401        );
7402        assert!(
7403            body.contains("\"action\":\"login.fail\""),
7404            "audit should record the failed login: {body}"
7405        );
7406        assert!(
7407            body.contains("\"principal\":\"alice\""),
7408            "audit should attribute events to alice: {body}"
7409        );
7410    }
7411
7412    #[tokio::test]
7413    async fn audit_records_ddl_sql() {
7414        let dir = tempdir().unwrap();
7415        let db = Arc::new(Database::create(dir.path()).unwrap());
7416        let app = build_app(db);
7417        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7418        let addr = listener.local_addr().unwrap();
7419        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
7420        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7421
7422        let client = reqwest::Client::new();
7423        let _ = client
7424            .post(format!("http://{addr}/sql"))
7425            .json(&json!({ "sql": "CREATE TABLE t (id BIGINT PRIMARY KEY)" }))
7426            .send()
7427            .await
7428            .unwrap();
7429        // A plain SELECT is NOT audited.
7430        let _ = client
7431            .post(format!("http://{addr}/sql"))
7432            .json(&json!({ "sql": "SELECT 1" }))
7433            .send()
7434            .await
7435            .unwrap();
7436
7437        let body = client
7438            .get(format!("http://{addr}/audit"))
7439            .send()
7440            .await
7441            .unwrap()
7442            .text()
7443            .await
7444            .unwrap();
7445        assert!(
7446            body.contains("\"action\":\"ddl.ok\""),
7447            "audit should record the successful DDL statement: {body}"
7448        );
7449        assert!(
7450            body.contains("CREATE TABLE"),
7451            "audit detail should carry the DDL snippet: {body}"
7452        );
7453        // SELECT must not appear.
7454        assert!(
7455            !body.contains("SELECT 1"),
7456            "non-DDL reads should not be audited: {body}"
7457        );
7458    }
7459
7460    #[tokio::test]
7461    async fn audit_redacts_credential_passwords() {
7462        let dir = tempdir().unwrap();
7463        let db = Arc::new(Database::create(dir.path()).unwrap());
7464        let app = build_app(db);
7465        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7466        let addr = listener.local_addr().unwrap();
7467        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
7468        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7469
7470        let client = reqwest::Client::new();
7471        // A CREATE USER carrying a plaintext password must be audited but the
7472        // password must NEVER reach /audit or stderr.
7473        let _ = client
7474            .post(format!("http://{addr}/sql"))
7475            .json(&json!({ "sql": "CREATE USER alice WITH PASSWORD 'topsecret'" }))
7476            .send()
7477            .await
7478            .unwrap();
7479
7480        let body = client
7481            .get(format!("http://{addr}/audit"))
7482            .send()
7483            .await
7484            .unwrap()
7485            .text()
7486            .await
7487            .unwrap();
7488        assert!(
7489            !body.contains("topsecret"),
7490            "password must never appear in the audit log: {body}"
7491        );
7492        assert!(
7493            body.contains("redacted credential statement"),
7494            "credential DDL should be recorded as redacted: {body}"
7495        );
7496    }
7497
7498    fn basic(user: &str, pass: &str) -> String {
7499        let raw = format!("{user}:{pass}");
7500        format!("Basic {}", base64_encode(raw.as_bytes()))
7501    }
7502
7503    fn base64_encode(input: &[u8]) -> String {
7504        const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
7505        let mut out = String::new();
7506        let mut buf = 0u32;
7507        let mut bits = 0u32;
7508        for &b in input {
7509            buf = (buf << 8) | b as u32;
7510            bits += 8;
7511            while bits >= 6 {
7512                bits -= 6;
7513                out.push(TABLE[((buf >> bits) & 0x3F) as usize] as char);
7514            }
7515        }
7516        if bits > 0 {
7517            out.push(TABLE[((buf << (6 - bits)) & 0x3F) as usize] as char);
7518        }
7519        while !out.len().is_multiple_of(4) {
7520            out.push('=');
7521        }
7522        out
7523    }
7524}
7525
7526#[cfg(test)]
7527mod session_tests {
7528    use super::*;
7529    use mongreldb_core::Database;
7530    use tempfile::tempdir;
7531
7532    /// Spin up a daemon over a fresh DB with one `items(id int64 pk)` table.
7533    /// Returns the TempDir (must be held alive for the test's duration — dropping
7534    /// it deletes the database directory mid-test) and the bound address.
7535    async fn setup() -> (tempfile::TempDir, std::net::SocketAddr) {
7536        let dir = tempdir().unwrap();
7537        let db = Arc::new(Database::create(dir.path()).unwrap());
7538        let table_schema = mongreldb_core::schema::Schema {
7539            schema_id: 1,
7540            columns: vec![mongreldb_core::schema::ColumnDef {
7541                id: 1,
7542                name: "id".into(),
7543                ty: TypeId::Int64,
7544                flags: mongreldb_core::schema::ColumnFlags::empty()
7545                    .with(mongreldb_core::schema::ColumnFlags::PRIMARY_KEY),
7546                default_value: None,
7547            }],
7548            indexes: vec![],
7549            colocation: vec![],
7550            constraints: Default::default(),
7551            clustered: false,
7552        };
7553        db.create_table("items", table_schema).unwrap();
7554        let app = build_app(db);
7555        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
7556        let addr = listener.local_addr().unwrap();
7557        tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
7558        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7559        (dir, addr)
7560    }
7561
7562    /// Open a session and return its token.
7563    async fn open_session(client: &reqwest::Client, addr: &std::net::SocketAddr) -> String {
7564        let resp = client
7565            .post(format!("http://{addr}/sessions"))
7566            .send()
7567            .await
7568            .unwrap();
7569        assert_eq!(resp.status(), 200);
7570        resp.json::<serde_json::Value>()
7571            .await
7572            .unwrap()
7573            .get("session_id")
7574            .unwrap()
7575            .as_str()
7576            .unwrap()
7577            .to_string()
7578    }
7579
7580    async fn sql_on(
7581        client: &reqwest::Client,
7582        addr: &std::net::SocketAddr,
7583        session: &str,
7584        sql: &str,
7585    ) -> reqwest::Response {
7586        client
7587            .post(format!("http://{addr}/sql"))
7588            .header("X-Session-ID", session)
7589            .json(&json!({ "sql": sql }))
7590            .send()
7591            .await
7592            .unwrap()
7593    }
7594
7595    async fn count_items(client: &reqwest::Client, addr: &std::net::SocketAddr) -> u64 {
7596        client
7597            .get(format!("http://{addr}/tables/items/count"))
7598            .send()
7599            .await
7600            .unwrap()
7601            .json::<serde_json::Value>()
7602            .await
7603            .unwrap()
7604            .get("count")
7605            .unwrap()
7606            .as_u64()
7607            .unwrap()
7608    }
7609
7610    #[tokio::test]
7611    async fn cross_request_transaction_commits() {
7612        let (_dir, addr) = setup().await;
7613        let client = reqwest::Client::new();
7614        let session = open_session(&client, &addr).await;
7615
7616        // BEGIN, INSERT, COMMIT — each its own HTTP request on the same session.
7617        let r = sql_on(&client, &addr, &session, "BEGIN").await;
7618        assert_eq!(r.status(), 200);
7619        let r = sql_on(
7620            &client,
7621            &addr,
7622            &session,
7623            "INSERT INTO items (id) VALUES (1)",
7624        )
7625        .await;
7626        assert_eq!(r.status(), 200, "INSERT should stage successfully");
7627        // Not yet committed → not visible to other connections.
7628        assert_eq!(count_items(&client, &addr).await, 0);
7629        let r = sql_on(&client, &addr, &session, "COMMIT").await;
7630        assert_eq!(r.status(), 200);
7631
7632        // After COMMIT the row is durable and visible.
7633        assert_eq!(count_items(&client, &addr).await, 1);
7634    }
7635
7636    #[tokio::test]
7637    async fn cross_request_transaction_rolls_back() {
7638        let (_dir, addr) = setup().await;
7639        let client = reqwest::Client::new();
7640        let session = open_session(&client, &addr).await;
7641
7642        sql_on(&client, &addr, &session, "BEGIN").await;
7643        sql_on(
7644            &client,
7645            &addr,
7646            &session,
7647            "INSERT INTO items (id) VALUES (5)",
7648        )
7649        .await;
7650        // ROLLBACK discards the staged insert.
7651        let r = sql_on(&client, &addr, &session, "ROLLBACK").await;
7652        assert_eq!(r.status(), 200);
7653        assert_eq!(
7654            count_items(&client, &addr).await,
7655            0,
7656            "rollback discards staged writes"
7657        );
7658    }
7659
7660    #[tokio::test]
7661    async fn unknown_session_id_is_404() {
7662        let (_dir, addr) = setup().await;
7663        let client = reqwest::Client::new();
7664        let resp = client
7665            .post(format!("http://{addr}/sql"))
7666            .header("X-Session-ID", "does-not-exist")
7667            .json(&json!({ "sql": "SELECT 1" }))
7668            .send()
7669            .await
7670            .unwrap();
7671        assert_eq!(resp.status(), 404);
7672    }
7673
7674    #[tokio::test]
7675    async fn invalid_session_headers_do_not_autocommit() {
7676        let (_dir, addr) = setup().await;
7677        let client = reqwest::Client::new();
7678
7679        let resp = client
7680            .post(format!("http://{addr}/sql"))
7681            .header(
7682                "X-Session-ID",
7683                reqwest::header::HeaderValue::from_bytes(&[0xff]).unwrap(),
7684            )
7685            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (1)" }))
7686            .send()
7687            .await
7688            .unwrap();
7689        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7690
7691        let resp = client
7692            .post(format!("http://{addr}/sql"))
7693            .header("X-Session-ID", "x".repeat(257))
7694            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (2)" }))
7695            .send()
7696            .await
7697            .unwrap();
7698        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
7699        assert_eq!(count_items(&client, &addr).await, 0);
7700    }
7701
7702    #[tokio::test]
7703    async fn close_session_ends_cross_request_state() {
7704        let (_dir, addr) = setup().await;
7705        let client = reqwest::Client::new();
7706        let session = open_session(&client, &addr).await;
7707
7708        // BEGIN on the session, then close it → staged txn is discarded.
7709        sql_on(&client, &addr, &session, "BEGIN").await;
7710        let r = client
7711            .delete(format!("http://{addr}/sessions/{session}"))
7712            .send()
7713            .await
7714            .unwrap();
7715        assert_eq!(r.status(), 200);
7716
7717        // The session token is now invalid.
7718        let resp = sql_on(&client, &addr, &session, "COMMIT").await;
7719        assert_eq!(resp.status(), 404, "closed session is no longer usable");
7720    }
7721
7722    #[tokio::test]
7723    async fn no_session_header_uses_fresh_ephemeral_session() {
7724        // Without X-Session-ID, BEGIN..COMMIT must still work within a single
7725        // multi-statement /sql body (the historical behavior is preserved).
7726        let (_dir, addr) = setup().await;
7727        let client = reqwest::Client::new();
7728        let resp = client
7729            .post(format!("http://{addr}/sql"))
7730            .json(&json!({ "sql": "INSERT INTO items (id) VALUES (42)" }))
7731            .send()
7732            .await
7733            .unwrap();
7734        assert_eq!(resp.status(), 200);
7735        assert_eq!(count_items(&client, &addr).await, 1);
7736    }
7737
7738    #[tokio::test]
7739    async fn prepared_statement_prepare_execute_and_reuse() {
7740        let (_dir, addr) = setup().await;
7741        let client = reqwest::Client::new();
7742        let session = open_session(&client, &addr).await;
7743        sql_on(
7744            &client,
7745            &addr,
7746            &session,
7747            "INSERT INTO items (id) VALUES (1), (2), (3), (4)",
7748        )
7749        .await;
7750
7751        // Prepare a parameterized query once.
7752        let resp = client
7753            .post(format!("http://{addr}/sessions/{session}/prepare"))
7754            .json(&json!({"name":"gt","sql":"SELECT id FROM items WHERE id > $1"}))
7755            .send()
7756            .await
7757            .unwrap();
7758        assert_eq!(resp.status(), 200);
7759
7760        // Execute with param 2 → ids 3,4.
7761        let resp = client
7762            .post(format!("http://{addr}/sessions/{session}/execute"))
7763            .json(&json!({"name":"gt","params":[2]}))
7764            .send()
7765            .await
7766            .unwrap();
7767        assert_eq!(resp.status(), 200);
7768        let body = resp.json::<serde_json::Value>().await.unwrap();
7769        let arr = body
7770            .as_array()
7771            .expect("execute returns a JSON array of rows");
7772        assert_eq!(arr.len(), 2, "ids > 2 are {{3,4}}: {body}");
7773
7774        // Re-execute with a different param → reuses the cached plan, fewer rows.
7775        let resp = client
7776            .post(format!("http://{addr}/sessions/{session}/execute"))
7777            .json(&json!({"name":"gt","params":[3]}))
7778            .send()
7779            .await
7780            .unwrap();
7781        let body = resp.json::<serde_json::Value>().await.unwrap();
7782        assert_eq!(body.as_array().unwrap().len(), 1, "ids > 3 is {{4}}");
7783    }
7784
7785    #[tokio::test]
7786    async fn prepared_statement_deallocate_then_execute_fails() {
7787        let (_dir, addr) = setup().await;
7788        let client = reqwest::Client::new();
7789        let session = open_session(&client, &addr).await;
7790        let _ = client
7791            .post(format!("http://{addr}/sessions/{session}/prepare"))
7792            .json(&json!({"name":"p","sql":"SELECT $1"}))
7793            .send()
7794            .await
7795            .unwrap();
7796        let deallocate_query_id = "dadadadadadadadadadadadadadadada";
7797        let resp = client
7798            .delete(format!("http://{addr}/sessions/{session}/statements/p"))
7799            .header("X-MongrelDB-Query-ID", deallocate_query_id)
7800            .header("X-MongrelDB-Timeout-Ms", "10000")
7801            .send()
7802            .await
7803            .unwrap();
7804        assert_eq!(resp.status(), 200);
7805        assert_eq!(
7806            resp.headers()
7807                .get("X-MongrelDB-Query-ID")
7808                .unwrap()
7809                .to_str()
7810                .unwrap(),
7811            deallocate_query_id
7812        );
7813        let status = client
7814            .get(format!("http://{addr}/queries/{deallocate_query_id}"))
7815            .send()
7816            .await
7817            .unwrap()
7818            .json::<serde_json::Value>()
7819            .await
7820            .unwrap();
7821        assert_eq!(status["state"], "completed");
7822        assert_eq!(status["operation"], "DEALLOCATE");
7823        // Execute after deallocate must error.
7824        let resp = client
7825            .post(format!("http://{addr}/sessions/{session}/execute"))
7826            .json(&json!({"name":"p","params":[1]}))
7827            .send()
7828            .await
7829            .unwrap();
7830        assert_ne!(resp.status(), 200, "execute after DEALLOCATE must fail");
7831    }
7832
7833    #[tokio::test]
7834    async fn prepared_statement_deallocate_honors_pre_registration_cancel() {
7835        let (_dir, addr) = setup().await;
7836        let client = reqwest::Client::new();
7837        let session = open_session(&client, &addr).await;
7838        let prepared = client
7839            .post(format!("http://{addr}/sessions/{session}/prepare"))
7840            .json(&json!({"name":"p","sql":"SELECT $1"}))
7841            .send()
7842            .await
7843            .unwrap();
7844        assert_eq!(prepared.status(), StatusCode::OK);
7845
7846        let query_id = "dbdbdbdbdbdbdbdbdbdbdbdbdbdbdbdb";
7847        let cancel = client
7848            .post(format!("http://{addr}/queries/{query_id}/cancel"))
7849            .header("X-Session-ID", &session)
7850            .send()
7851            .await
7852            .unwrap();
7853        assert_eq!(cancel.status(), StatusCode::ACCEPTED);
7854        let deallocate = client
7855            .delete(format!("http://{addr}/sessions/{session}/statements/p"))
7856            .header("X-MongrelDB-Query-ID", query_id)
7857            .send()
7858            .await
7859            .unwrap();
7860        assert_eq!(deallocate.status().as_u16(), 499);
7861        assert_eq!(
7862            deallocate.json::<serde_json::Value>().await.unwrap()["error"]["code"],
7863            "QUERY_CANCELLED"
7864        );
7865
7866        let execute = client
7867            .post(format!("http://{addr}/sessions/{session}/execute"))
7868            .json(&json!({"name":"p","params":[1]}))
7869            .send()
7870            .await
7871            .unwrap();
7872        assert_eq!(execute.status(), StatusCode::OK);
7873    }
7874
7875    #[tokio::test]
7876    async fn prepared_statement_rejects_bad_name() {
7877        let (_dir, addr) = setup().await;
7878        let client = reqwest::Client::new();
7879        let session = open_session(&client, &addr).await;
7880        let resp = client
7881            .post(format!("http://{addr}/sessions/{session}/prepare"))
7882            .json(&json!({"name":"1bad","sql":"SELECT 1"}))
7883            .send()
7884            .await
7885            .unwrap();
7886        assert_eq!(
7887            resp.status(),
7888            400,
7889            "statement name starting with a digit must be rejected"
7890        );
7891    }
7892}