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}