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