Skip to main content

mongreldb_protocol/
request.rs

1//! Canonical request model (spec section 10.4, S1D-001).
2//!
3//! Every protocol adapter (native RPC, HTTP/JSON, Kit, MySQL wire) converts
4//! its wire form into the canonical [`ExecuteRequest`] defined here, so
5//! admission, authorization, resource governance, and execution see exactly
6//! one request shape regardless of how the request arrived.
7//!
8//! # Deadline representation
9//!
10//! The spec model carries `deadline: Option<Instant>`, but
11//! [`std::time::Instant`] is opaque, monotonic, and not serde-able, so the
12//! canonical model instead stores `deadline_unix_micros`: wall-clock
13//! microseconds since the Unix epoch — the same time base as
14//! [`mongreldb_types::hlc::HlcTimestamp::physical_micros`]. This form is
15//! serde-stable in every format, comparable across processes, and converts
16//! to a monotonic `Instant` once at server admission; queue wait, planning,
17//! execution, serialization, and network backpressure all count toward it
18//! (S1D-006). `None` means the server applies its configured default
19//! deadline: a request is never unbounded (spec section 4.9).
20//!
21//! # Identity mapping
22//!
23//! [`AuthenticatedIdentity`] is the protocol-side canonical form of the
24//! engine-side identity (`mongreldb_core::auth::Principal`, the handle
25//! identity carried by every open database handle):
26//!
27//! - [`AuthenticatedIdentity::CatalogUser`] pins the immutable catalog
28//!   identity: `username`, `user_id`, and `created_version` map to
29//!   `Principal::username`, `Principal::user_id`, and
30//!   `Principal::created_epoch`. Roles, permissions, and the admin flag are
31//!   deliberately NOT part of the wire identity: the server re-resolves them
32//!   from the catalog at session open, so username reuse cannot revive a
33//!   stale principal.
34//! - [`AuthenticatedIdentity::Credentialless`] corresponds to deployments
35//!   with catalog auth disabled (embedded mode, or a daemon without
36//!   `--auth-users`): there is no `Principal` at all.
37//! - [`AuthenticatedIdentity::ServicePrincipal`] corresponds to internal
38//!   server components (replication, CDC, maintenance jobs) that act without
39//!   catalog credentials. External adapters MUST never mint one; the
40//!   authorization boundary fails closed on unrecognized principals.
41
42use core::fmt;
43use core::str::FromStr;
44
45use mongreldb_types::ids::{DatabaseId, QueryId};
46
47use crate::prepared::StatementId;
48
49/// Identifies one client session.
50///
51/// Session IDs are allocated by the server at session open (128 bits, drawn
52/// from a CSPRNG by the allocating service; this crate is dependency-frozen
53/// and deliberately does not mint them). The all-zero value is reserved.
54///
55/// Canonical text form: strict lowercase hexadecimal, 32 characters.
56#[repr(transparent)]
57#[derive(
58    Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
59)]
60pub struct SessionId(pub [u8; 16]);
61
62impl SessionId {
63    /// The all-zero session identifier (reserved).
64    pub const ZERO: Self = Self([0u8; 16]);
65
66    /// Wraps raw bytes without copying.
67    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
68        Self(bytes)
69    }
70
71    /// Borrows the raw 16 bytes.
72    pub const fn as_bytes(&self) -> &[u8; 16] {
73        &self.0
74    }
75
76    /// Canonical text form: strict lowercase hexadecimal (32 chars).
77    pub fn to_hex(self) -> String {
78        let mut out = String::with_capacity(32);
79        for byte in self.0 {
80            out.push(char::from_digit((byte >> 4) as u32, 16).expect("nibble"));
81            out.push(char::from_digit((byte & 0x0f) as u32, 16).expect("nibble"));
82        }
83        out
84    }
85}
86
87impl fmt::Display for SessionId {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(&self.to_hex())
90    }
91}
92
93impl fmt::Debug for SessionId {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "SessionId({})", self.to_hex())
96    }
97}
98
99/// Error returned when parsing a textual [`SessionId`] fails.
100#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
101pub enum SessionIdParseError {
102    /// The text had the wrong number of characters.
103    #[error("invalid session id length: expected 32 hex digits, got {0} chars")]
104    InvalidLength(usize),
105    /// The text contained a non-hexadecimal character.
106    #[error("invalid hex character `{0}` in session id")]
107    InvalidCharacter(char),
108}
109
110impl FromStr for SessionId {
111    type Err = SessionIdParseError;
112
113    /// Parses the canonical 32-character hex form. Lenient by contract, like
114    /// the `mongreldb-types` identifiers: hyphens are ignored at any position
115    /// (so the hyphenated UUID form `8-4-4-4-12` parses) and uppercase hex
116    /// digits are accepted.
117    fn from_str(text: &str) -> Result<Self, Self::Err> {
118        let compact: String = text.chars().filter(|c| *c != '-').collect();
119        if compact.chars().count() != 32 {
120            return Err(SessionIdParseError::InvalidLength(compact.chars().count()));
121        }
122        let mut bytes = [0u8; 16];
123        let mut chars = compact.chars();
124        for byte in &mut bytes {
125            let hi = chars.next().expect("length checked above");
126            let lo = chars.next().expect("length checked above");
127            let hi = hi
128                .to_digit(16)
129                .ok_or(SessionIdParseError::InvalidCharacter(hi))?;
130            let lo = lo
131                .to_digit(16)
132                .ok_or(SessionIdParseError::InvalidCharacter(lo))?;
133            *byte = ((hi << 4) | lo) as u8;
134        }
135        Ok(Self(bytes))
136    }
137}
138
139/// The authenticated identity attached to a request (S1D-001).
140///
141/// See the module-level documentation for the mapping to the engine-side
142/// identity (`mongreldb_core::auth::Principal`).
143#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
144pub enum AuthenticatedIdentity {
145    /// No catalog credentials: embedded mode or a daemon running without
146    /// catalog auth. Carries no identity; the storage authorization layer
147    /// applies its no-auth policy.
148    Credentialless,
149    /// A catalog-authenticated user. Pins the immutable identity
150    /// (`user_id` + `created_version`); grants are re-resolved by the server
151    /// at session open so a recreated username cannot inherit stale rights.
152    CatalogUser {
153        /// Case-sensitive username.
154        username: String,
155        /// Immutable catalog user id (`Principal::user_id`).
156        user_id: u64,
157        /// User generation paired with `user_id` (`Principal::created_epoch`).
158        created_version: u64,
159    },
160    /// An internal server component (replication, CDC, maintenance) acting
161    /// without catalog credentials. Never accepted from external adapters.
162    ServicePrincipal {
163        /// Stable name of the internal component, e.g. `"replication"`.
164        name: String,
165    },
166    /// Externally authenticated service/OIDC identity mapped to one catalog
167    /// user. Immutable user identity prevents username recreation from
168    /// inheriting an existing session.
169    ExternalPrincipal {
170        provider: String,
171        subject: String,
172        username: String,
173        user_id: u64,
174        created_version: u64,
175        scopes: Vec<String>,
176    },
177}
178
179/// MVCC isolation level requested by [`ExecuteCommand::Begin`].
180///
181/// Mirrors `mongreldb_core::txn::IsolationLevel` one-to-one (`Snapshot`,
182/// `ReadCommitted`, `Serializable`). The protocol crate cannot depend on the
183/// core crate (the server wave depends on both), so the enum is duplicated
184/// here and converted at the service boundary.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
186pub enum IsolationLevel {
187    /// Snapshot isolation: the transaction reads one consistent snapshot.
188    #[default]
189    Snapshot,
190    /// Read committed: each statement reads the latest committed state.
191    ReadCommitted,
192    /// Serializable: snapshot reads plus certification at commit.
193    Serializable,
194}
195
196/// One bound parameter value of a [`ExecuteCommand::Sql`] or
197/// [`ExecuteCommand::ExecutePrepared`] request.
198///
199/// The canonical model carries typed scalar values; composite Arrow-native
200/// parameters land with the server wave. [`ParameterValue::type_name`]
201/// provides the canonical type names recorded in
202/// [`crate::prepared::PreparedStatementBinding::parameter_types`].
203#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
204pub enum ParameterValue {
205    /// SQL `NULL`.
206    Null,
207    /// A boolean.
208    Bool(bool),
209    /// A 64-bit signed integer.
210    Integer(i64),
211    /// A double-precision float.
212    Float(f64),
213    /// A UTF-8 string.
214    Text(String),
215    /// Opaque binary data.
216    Bytes(Vec<u8>),
217}
218
219impl ParameterValue {
220    /// The canonical type name of this value, as recorded in a prepared
221    /// statement's `parameter_types`.
222    pub fn type_name(&self) -> &'static str {
223        match self {
224            Self::Null => "NULL",
225            Self::Bool(_) => "BOOL",
226            Self::Integer(_) => "INT64",
227            Self::Float(_) => "FLOAT64",
228            Self::Text(_) => "TEXT",
229            Self::Bytes(_) => "BYTES",
230        }
231    }
232}
233
234/// Administrative operations carried by [`ExecuteCommand::Admin`].
235///
236/// Intentionally lean: user/role management, the operations gated by the
237/// catalog's admin permission today. Further admin verbs are appended in
238/// later stages; discriminants are never reordered or reused (spec
239/// section 4.10).
240#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
241pub enum AdminCommand {
242    /// Create a catalog user.
243    CreateUser {
244        /// Case-sensitive username of the new user.
245        username: String,
246    },
247    /// Drop a catalog user.
248    DropUser {
249        /// Case-sensitive username to drop.
250        username: String,
251    },
252    /// Grant a role to a user.
253    GrantRole {
254        /// Case-sensitive username receiving the role.
255        username: String,
256        /// Role to grant.
257        role: String,
258    },
259    /// Revoke a role from a user.
260    RevokeRole {
261        /// Case-sensitive username losing the role.
262        username: String,
263        /// Role to revoke.
264        role: String,
265    },
266}
267
268/// The operation an [`ExecuteRequest`] asks the server to perform (S1D-001).
269///
270/// Variants are never reordered and discriminants never reused (spec
271/// section 4.10); new commands are only appended.
272#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
273pub enum ExecuteCommand {
274    /// Parse, plan, and execute a SQL statement.
275    Sql {
276        /// The SQL text.
277        text: String,
278        /// Bound parameter values, in statement order.
279        params: Vec<ParameterValue>,
280    },
281    /// Execute a previously prepared statement.
282    ExecutePrepared {
283        /// Session-scoped prepared statement handle from
284        /// [`crate::services::QueryService::prepare`].
285        statement_id: StatementId,
286        /// Bound parameter values, in statement order.
287        params: Vec<ParameterValue>,
288    },
289    /// Begin a transaction on the session.
290    Begin {
291        /// Requested MVCC isolation level.
292        isolation: IsolationLevel,
293    },
294    /// Commit the session's active transaction.
295    Commit,
296    /// Roll back the session's active transaction.
297    Rollback,
298    /// Cancel a running query (S1D-006).
299    Cancel {
300        /// The query to cancel.
301        query_id: QueryId,
302    },
303    /// Fetch the schema of one table.
304    GetSchema {
305        /// Table name.
306        table: String,
307    },
308    /// An administrative operation.
309    Admin(AdminCommand),
310}
311
312/// Conservative default bounds a server applies when a request does not
313/// state one explicitly (spec section 4.9): 100 000 rows.
314pub const DEFAULT_MAX_ROWS: u64 = 100_000;
315/// Conservative default result-size bound: 64 MiB of Arrow IPC payload.
316pub const DEFAULT_MAX_BYTES: u64 = 64 * 1024 * 1024;
317/// Conservative default candidate-count bound: one million candidates.
318pub const DEFAULT_MAX_CANDIDATE_COUNT: u64 = 1_000_000;
319
320/// Per-request result bounds (spec sections 4.9 and 10.4, S1D-001).
321///
322/// Every request is bounded: `None` does NOT mean unbounded, it means the
323/// server applies its configured default (see the `DEFAULT_*` constants for
324/// the conservative shipped defaults) and may clamp explicit values down to
325/// configured ceilings. The effective limits are what the executor
326/// enforces; exceeding them fails the request with
327/// [`mongreldb_types::errors::ErrorCategory::ResourceExhausted`].
328#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
329pub struct ResultLimits {
330    /// Maximum result rows; `None` applies the server default.
331    pub max_rows: Option<u64>,
332    /// Maximum result bytes; `None` applies the server default.
333    pub max_bytes: Option<u64>,
334    /// Maximum candidate rows scanned during retrieval; `None` applies the
335    /// server default.
336    pub max_candidate_count: Option<u64>,
337}
338
339impl ResultLimits {
340    /// Effective row bound after applying server-enforceable defaults.
341    pub fn effective_max_rows(&self) -> u64 {
342        self.max_rows.unwrap_or(DEFAULT_MAX_ROWS)
343    }
344
345    /// Effective byte bound after applying server-enforceable defaults.
346    pub fn effective_max_bytes(&self) -> u64 {
347        self.max_bytes.unwrap_or(DEFAULT_MAX_BYTES)
348    }
349
350    /// Effective candidate-count bound after applying server-enforceable
351    /// defaults.
352    pub fn effective_max_candidate_count(&self) -> u64 {
353        self.max_candidate_count
354            .unwrap_or(DEFAULT_MAX_CANDIDATE_COUNT)
355    }
356}
357
358/// The canonical request every protocol adapter converts into (S1D-001).
359#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
360pub struct ExecuteRequest {
361    /// Unique identifier of this request, chosen by the caller; never reused,
362    /// so retries with the same id can be deduplicated.
363    pub request_id: [u8; 16],
364    /// Identifier of the query execution this request drives.
365    pub query_id: QueryId,
366    /// Session the request runs on, if sessionful.
367    pub session_id: Option<SessionId>,
368    /// Logical database the request targets.
369    pub database_id: DatabaseId,
370    /// Authenticated identity the request acts as.
371    pub principal: AuthenticatedIdentity,
372    /// The operation to perform.
373    pub command: ExecuteCommand,
374    /// Wall-clock deadline in microseconds since the Unix epoch; see the
375    /// module-level documentation for why this is not an `Instant`.
376    pub deadline_unix_micros: Option<u64>,
377    /// Per-request result bounds; always effective, never unbounded.
378    pub result_limits: ResultLimits,
379    /// Optional resource group the request is admitted into (spec
380    /// section 10.5, workload classes and resource governance).
381    pub resource_group: Option<String>,
382    /// Optional durable idempotency key: present iff the caller may safely
383    /// replay the request after an ambiguous outcome (spec section 11.7).
384    pub idempotency_key: Option<String>,
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::test_support::assert_serde_round_trip;
391
392    fn sample_principal() -> AuthenticatedIdentity {
393        AuthenticatedIdentity::CatalogUser {
394            username: "alice".to_owned(),
395            user_id: 42,
396            created_version: 7,
397        }
398    }
399
400    fn sample_params() -> Vec<ParameterValue> {
401        vec![
402            ParameterValue::Null,
403            ParameterValue::Bool(true),
404            ParameterValue::Integer(-42),
405            ParameterValue::Float(2.5),
406            ParameterValue::Text("hello".to_owned()),
407            ParameterValue::Bytes(vec![0xde, 0xad, 0xbe, 0xef]),
408        ]
409    }
410
411    #[test]
412    fn session_id_text_form_round_trips() {
413        let bytes = [
414            0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76,
415            0x54, 0x32,
416        ];
417        let id = SessionId::from_bytes(bytes);
418        assert_eq!(id.to_hex(), "000123456789abcdeffedcba98765432");
419        assert_eq!(id.to_string(), id.to_hex());
420        assert_eq!(
421            format!("{id:?}"),
422            "SessionId(000123456789abcdeffedcba98765432)"
423        );
424        assert_eq!(id.to_hex().parse::<SessionId>().unwrap(), id);
425        assert_eq!(
426            "00012345-6789-abcd-effe-dcba98765432"
427                .parse::<SessionId>()
428                .unwrap(),
429            id,
430            "hyphenated UUID form parses"
431        );
432        assert_eq!(
433            id.to_hex()
434                .to_ascii_uppercase()
435                .parse::<SessionId>()
436                .unwrap(),
437            id,
438            "uppercase hex parses"
439        );
440        assert_eq!(id.as_bytes(), &bytes);
441        assert_eq!(SessionId::ZERO.as_bytes(), &[0u8; 16]);
442    }
443
444    #[test]
445    fn session_id_parse_rejects_bad_input() {
446        assert_eq!(
447            "".parse::<SessionId>(),
448            Err(SessionIdParseError::InvalidLength(0))
449        );
450        assert_eq!(
451            "abcd".parse::<SessionId>(),
452            Err(SessionIdParseError::InvalidLength(4))
453        );
454        assert_eq!(
455            "g".repeat(32).parse::<SessionId>(),
456            Err(SessionIdParseError::InvalidCharacter('g'))
457        );
458    }
459
460    #[test]
461    fn session_id_serde_round_trip() {
462        assert_serde_round_trip(&SessionId::from_bytes([0x5a; 16]));
463        assert_serde_round_trip(&SessionId::ZERO);
464    }
465
466    #[test]
467    fn authenticated_identity_serde_round_trip() {
468        assert_serde_round_trip(&AuthenticatedIdentity::Credentialless);
469        assert_serde_round_trip(&sample_principal());
470        assert_serde_round_trip(&AuthenticatedIdentity::ServicePrincipal {
471            name: "replication".to_owned(),
472        });
473    }
474
475    #[test]
476    fn isolation_level_serde_round_trip_and_default() {
477        assert_eq!(IsolationLevel::default(), IsolationLevel::Snapshot);
478        for level in [
479            IsolationLevel::Snapshot,
480            IsolationLevel::ReadCommitted,
481            IsolationLevel::Serializable,
482        ] {
483            assert_serde_round_trip(&level);
484        }
485    }
486
487    #[test]
488    fn parameter_value_serde_round_trip_and_type_names() {
489        for value in sample_params() {
490            assert_serde_round_trip(&value);
491        }
492        let names: Vec<&'static str> = sample_params()
493            .iter()
494            .map(ParameterValue::type_name)
495            .collect();
496        assert_eq!(names, ["NULL", "BOOL", "INT64", "FLOAT64", "TEXT", "BYTES"]);
497    }
498
499    #[test]
500    fn admin_command_serde_round_trip() {
501        for command in [
502            AdminCommand::CreateUser {
503                username: "alice".to_owned(),
504            },
505            AdminCommand::DropUser {
506                username: "alice".to_owned(),
507            },
508            AdminCommand::GrantRole {
509                username: "alice".to_owned(),
510                role: "analyst".to_owned(),
511            },
512            AdminCommand::RevokeRole {
513                username: "alice".to_owned(),
514                role: "analyst".to_owned(),
515            },
516        ] {
517            assert_serde_round_trip(&command);
518        }
519    }
520
521    #[test]
522    fn execute_command_serde_round_trip_every_variant() {
523        for command in [
524            ExecuteCommand::Sql {
525                text: "SELECT * FROM t WHERE a = ?".to_owned(),
526                params: sample_params(),
527            },
528            ExecuteCommand::ExecutePrepared {
529                statement_id: StatementId::new(9),
530                params: sample_params(),
531            },
532            ExecuteCommand::Begin {
533                isolation: IsolationLevel::Serializable,
534            },
535            ExecuteCommand::Commit,
536            ExecuteCommand::Rollback,
537            ExecuteCommand::Cancel {
538                query_id: QueryId::new_random(),
539            },
540            ExecuteCommand::GetSchema {
541                table: "events".to_owned(),
542            },
543            ExecuteCommand::Admin(AdminCommand::GrantRole {
544                username: "alice".to_owned(),
545                role: "analyst".to_owned(),
546            }),
547        ] {
548            assert_serde_round_trip(&command);
549        }
550    }
551
552    #[test]
553    fn result_limits_defaults_are_bounded() {
554        let limits = ResultLimits::default();
555        assert_eq!(limits.max_rows, None);
556        assert_eq!(limits.effective_max_rows(), DEFAULT_MAX_ROWS);
557        assert_eq!(limits.effective_max_bytes(), DEFAULT_MAX_BYTES);
558        assert_eq!(
559            limits.effective_max_candidate_count(),
560            DEFAULT_MAX_CANDIDATE_COUNT
561        );
562        let explicit = ResultLimits {
563            max_rows: Some(10),
564            max_bytes: Some(1024),
565            max_candidate_count: Some(50),
566        };
567        assert_eq!(explicit.effective_max_rows(), 10);
568        assert_eq!(explicit.effective_max_bytes(), 1024);
569        assert_eq!(explicit.effective_max_candidate_count(), 50);
570        assert_serde_round_trip(&limits);
571        assert_serde_round_trip(&explicit);
572    }
573
574    #[test]
575    fn execute_request_serde_round_trip() {
576        let full = ExecuteRequest {
577            request_id: [0x11; 16],
578            query_id: QueryId::new_random(),
579            session_id: Some(SessionId::from_bytes([0x22; 16])),
580            database_id: DatabaseId::new_random(),
581            principal: sample_principal(),
582            command: ExecuteCommand::Sql {
583                text: "SELECT 1".to_owned(),
584                params: sample_params(),
585            },
586            deadline_unix_micros: Some(1_758_000_000_000_000),
587            result_limits: ResultLimits {
588                max_rows: Some(500),
589                max_bytes: None,
590                max_candidate_count: Some(10_000),
591            },
592            resource_group: Some("interactive".to_owned()),
593            idempotency_key: Some("req-42".to_owned()),
594        };
595        assert_serde_round_trip(&full);
596        let minimal = ExecuteRequest {
597            session_id: None,
598            deadline_unix_micros: None,
599            result_limits: ResultLimits::default(),
600            resource_group: None,
601            idempotency_key: None,
602            principal: AuthenticatedIdentity::Credentialless,
603            command: ExecuteCommand::Commit,
604            ..full.clone()
605        };
606        assert_serde_round_trip(&minimal);
607    }
608}