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}
167
168/// MVCC isolation level requested by [`ExecuteCommand::Begin`].
169///
170/// Mirrors `mongreldb_core::txn::IsolationLevel` one-to-one (`Snapshot`,
171/// `ReadCommitted`, `Serializable`). The protocol crate cannot depend on the
172/// core crate (the server wave depends on both), so the enum is duplicated
173/// here and converted at the service boundary.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
175pub enum IsolationLevel {
176 /// Snapshot isolation: the transaction reads one consistent snapshot.
177 #[default]
178 Snapshot,
179 /// Read committed: each statement reads the latest committed state.
180 ReadCommitted,
181 /// Serializable: snapshot reads plus certification at commit.
182 Serializable,
183}
184
185/// One bound parameter value of a [`ExecuteCommand::Sql`] or
186/// [`ExecuteCommand::ExecutePrepared`] request.
187///
188/// The canonical model carries typed scalar values; composite Arrow-native
189/// parameters land with the server wave. [`ParameterValue::type_name`]
190/// provides the canonical type names recorded in
191/// [`crate::prepared::PreparedStatementBinding::parameter_types`].
192#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
193pub enum ParameterValue {
194 /// SQL `NULL`.
195 Null,
196 /// A boolean.
197 Bool(bool),
198 /// A 64-bit signed integer.
199 Integer(i64),
200 /// A double-precision float.
201 Float(f64),
202 /// A UTF-8 string.
203 Text(String),
204 /// Opaque binary data.
205 Bytes(Vec<u8>),
206}
207
208impl ParameterValue {
209 /// The canonical type name of this value, as recorded in a prepared
210 /// statement's `parameter_types`.
211 pub fn type_name(&self) -> &'static str {
212 match self {
213 Self::Null => "NULL",
214 Self::Bool(_) => "BOOL",
215 Self::Integer(_) => "INT64",
216 Self::Float(_) => "FLOAT64",
217 Self::Text(_) => "TEXT",
218 Self::Bytes(_) => "BYTES",
219 }
220 }
221}
222
223/// Administrative operations carried by [`ExecuteCommand::Admin`].
224///
225/// Intentionally lean: user/role management, the operations gated by the
226/// catalog's admin permission today. Further admin verbs are appended in
227/// later stages; discriminants are never reordered or reused (spec
228/// section 4.10).
229#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
230pub enum AdminCommand {
231 /// Create a catalog user.
232 CreateUser {
233 /// Case-sensitive username of the new user.
234 username: String,
235 },
236 /// Drop a catalog user.
237 DropUser {
238 /// Case-sensitive username to drop.
239 username: String,
240 },
241 /// Grant a role to a user.
242 GrantRole {
243 /// Case-sensitive username receiving the role.
244 username: String,
245 /// Role to grant.
246 role: String,
247 },
248 /// Revoke a role from a user.
249 RevokeRole {
250 /// Case-sensitive username losing the role.
251 username: String,
252 /// Role to revoke.
253 role: String,
254 },
255}
256
257/// The operation an [`ExecuteRequest`] asks the server to perform (S1D-001).
258///
259/// Variants are never reordered and discriminants never reused (spec
260/// section 4.10); new commands are only appended.
261#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
262pub enum ExecuteCommand {
263 /// Parse, plan, and execute a SQL statement.
264 Sql {
265 /// The SQL text.
266 text: String,
267 /// Bound parameter values, in statement order.
268 params: Vec<ParameterValue>,
269 },
270 /// Execute a previously prepared statement.
271 ExecutePrepared {
272 /// Session-scoped prepared statement handle from
273 /// [`crate::services::QueryService::prepare`].
274 statement_id: StatementId,
275 /// Bound parameter values, in statement order.
276 params: Vec<ParameterValue>,
277 },
278 /// Begin a transaction on the session.
279 Begin {
280 /// Requested MVCC isolation level.
281 isolation: IsolationLevel,
282 },
283 /// Commit the session's active transaction.
284 Commit,
285 /// Roll back the session's active transaction.
286 Rollback,
287 /// Cancel a running query (S1D-006).
288 Cancel {
289 /// The query to cancel.
290 query_id: QueryId,
291 },
292 /// Fetch the schema of one table.
293 GetSchema {
294 /// Table name.
295 table: String,
296 },
297 /// An administrative operation.
298 Admin(AdminCommand),
299}
300
301/// Conservative default bounds a server applies when a request does not
302/// state one explicitly (spec section 4.9): 100 000 rows.
303pub const DEFAULT_MAX_ROWS: u64 = 100_000;
304/// Conservative default result-size bound: 64 MiB of Arrow IPC payload.
305pub const DEFAULT_MAX_BYTES: u64 = 64 * 1024 * 1024;
306/// Conservative default candidate-count bound: one million candidates.
307pub const DEFAULT_MAX_CANDIDATE_COUNT: u64 = 1_000_000;
308
309/// Per-request result bounds (spec sections 4.9 and 10.4, S1D-001).
310///
311/// Every request is bounded: `None` does NOT mean unbounded, it means the
312/// server applies its configured default (see the `DEFAULT_*` constants for
313/// the conservative shipped defaults) and may clamp explicit values down to
314/// configured ceilings. The effective limits are what the executor
315/// enforces; exceeding them fails the request with
316/// [`mongreldb_types::errors::ErrorCategory::ResourceExhausted`].
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
318pub struct ResultLimits {
319 /// Maximum result rows; `None` applies the server default.
320 pub max_rows: Option<u64>,
321 /// Maximum result bytes; `None` applies the server default.
322 pub max_bytes: Option<u64>,
323 /// Maximum candidate rows scanned during retrieval; `None` applies the
324 /// server default.
325 pub max_candidate_count: Option<u64>,
326}
327
328impl ResultLimits {
329 /// Effective row bound after applying server-enforceable defaults.
330 pub fn effective_max_rows(&self) -> u64 {
331 self.max_rows.unwrap_or(DEFAULT_MAX_ROWS)
332 }
333
334 /// Effective byte bound after applying server-enforceable defaults.
335 pub fn effective_max_bytes(&self) -> u64 {
336 self.max_bytes.unwrap_or(DEFAULT_MAX_BYTES)
337 }
338
339 /// Effective candidate-count bound after applying server-enforceable
340 /// defaults.
341 pub fn effective_max_candidate_count(&self) -> u64 {
342 self.max_candidate_count
343 .unwrap_or(DEFAULT_MAX_CANDIDATE_COUNT)
344 }
345}
346
347/// The canonical request every protocol adapter converts into (S1D-001).
348#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
349pub struct ExecuteRequest {
350 /// Unique identifier of this request, chosen by the caller; never reused,
351 /// so retries with the same id can be deduplicated.
352 pub request_id: [u8; 16],
353 /// Identifier of the query execution this request drives.
354 pub query_id: QueryId,
355 /// Session the request runs on, if sessionful.
356 pub session_id: Option<SessionId>,
357 /// Logical database the request targets.
358 pub database_id: DatabaseId,
359 /// Authenticated identity the request acts as.
360 pub principal: AuthenticatedIdentity,
361 /// The operation to perform.
362 pub command: ExecuteCommand,
363 /// Wall-clock deadline in microseconds since the Unix epoch; see the
364 /// module-level documentation for why this is not an `Instant`.
365 pub deadline_unix_micros: Option<u64>,
366 /// Per-request result bounds; always effective, never unbounded.
367 pub result_limits: ResultLimits,
368 /// Optional resource group the request is admitted into (spec
369 /// section 10.5, workload classes and resource governance).
370 pub resource_group: Option<String>,
371 /// Optional durable idempotency key: present iff the caller may safely
372 /// replay the request after an ambiguous outcome (spec section 11.7).
373 pub idempotency_key: Option<String>,
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::test_support::assert_serde_round_trip;
380
381 fn sample_principal() -> AuthenticatedIdentity {
382 AuthenticatedIdentity::CatalogUser {
383 username: "alice".to_owned(),
384 user_id: 42,
385 created_version: 7,
386 }
387 }
388
389 fn sample_params() -> Vec<ParameterValue> {
390 vec![
391 ParameterValue::Null,
392 ParameterValue::Bool(true),
393 ParameterValue::Integer(-42),
394 ParameterValue::Float(2.5),
395 ParameterValue::Text("hello".to_owned()),
396 ParameterValue::Bytes(vec![0xde, 0xad, 0xbe, 0xef]),
397 ]
398 }
399
400 #[test]
401 fn session_id_text_form_round_trips() {
402 let bytes = [
403 0x00, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76,
404 0x54, 0x32,
405 ];
406 let id = SessionId::from_bytes(bytes);
407 assert_eq!(id.to_hex(), "000123456789abcdeffedcba98765432");
408 assert_eq!(id.to_string(), id.to_hex());
409 assert_eq!(
410 format!("{id:?}"),
411 "SessionId(000123456789abcdeffedcba98765432)"
412 );
413 assert_eq!(id.to_hex().parse::<SessionId>().unwrap(), id);
414 assert_eq!(
415 "00012345-6789-abcd-effe-dcba98765432"
416 .parse::<SessionId>()
417 .unwrap(),
418 id,
419 "hyphenated UUID form parses"
420 );
421 assert_eq!(
422 id.to_hex()
423 .to_ascii_uppercase()
424 .parse::<SessionId>()
425 .unwrap(),
426 id,
427 "uppercase hex parses"
428 );
429 assert_eq!(id.as_bytes(), &bytes);
430 assert_eq!(SessionId::ZERO.as_bytes(), &[0u8; 16]);
431 }
432
433 #[test]
434 fn session_id_parse_rejects_bad_input() {
435 assert_eq!(
436 "".parse::<SessionId>(),
437 Err(SessionIdParseError::InvalidLength(0))
438 );
439 assert_eq!(
440 "abcd".parse::<SessionId>(),
441 Err(SessionIdParseError::InvalidLength(4))
442 );
443 assert_eq!(
444 "g".repeat(32).parse::<SessionId>(),
445 Err(SessionIdParseError::InvalidCharacter('g'))
446 );
447 }
448
449 #[test]
450 fn session_id_serde_round_trip() {
451 assert_serde_round_trip(&SessionId::from_bytes([0x5a; 16]));
452 assert_serde_round_trip(&SessionId::ZERO);
453 }
454
455 #[test]
456 fn authenticated_identity_serde_round_trip() {
457 assert_serde_round_trip(&AuthenticatedIdentity::Credentialless);
458 assert_serde_round_trip(&sample_principal());
459 assert_serde_round_trip(&AuthenticatedIdentity::ServicePrincipal {
460 name: "replication".to_owned(),
461 });
462 }
463
464 #[test]
465 fn isolation_level_serde_round_trip_and_default() {
466 assert_eq!(IsolationLevel::default(), IsolationLevel::Snapshot);
467 for level in [
468 IsolationLevel::Snapshot,
469 IsolationLevel::ReadCommitted,
470 IsolationLevel::Serializable,
471 ] {
472 assert_serde_round_trip(&level);
473 }
474 }
475
476 #[test]
477 fn parameter_value_serde_round_trip_and_type_names() {
478 for value in sample_params() {
479 assert_serde_round_trip(&value);
480 }
481 let names: Vec<&'static str> = sample_params()
482 .iter()
483 .map(ParameterValue::type_name)
484 .collect();
485 assert_eq!(names, ["NULL", "BOOL", "INT64", "FLOAT64", "TEXT", "BYTES"]);
486 }
487
488 #[test]
489 fn admin_command_serde_round_trip() {
490 for command in [
491 AdminCommand::CreateUser {
492 username: "alice".to_owned(),
493 },
494 AdminCommand::DropUser {
495 username: "alice".to_owned(),
496 },
497 AdminCommand::GrantRole {
498 username: "alice".to_owned(),
499 role: "analyst".to_owned(),
500 },
501 AdminCommand::RevokeRole {
502 username: "alice".to_owned(),
503 role: "analyst".to_owned(),
504 },
505 ] {
506 assert_serde_round_trip(&command);
507 }
508 }
509
510 #[test]
511 fn execute_command_serde_round_trip_every_variant() {
512 for command in [
513 ExecuteCommand::Sql {
514 text: "SELECT * FROM t WHERE a = ?".to_owned(),
515 params: sample_params(),
516 },
517 ExecuteCommand::ExecutePrepared {
518 statement_id: StatementId::new(9),
519 params: sample_params(),
520 },
521 ExecuteCommand::Begin {
522 isolation: IsolationLevel::Serializable,
523 },
524 ExecuteCommand::Commit,
525 ExecuteCommand::Rollback,
526 ExecuteCommand::Cancel {
527 query_id: QueryId::new_random(),
528 },
529 ExecuteCommand::GetSchema {
530 table: "events".to_owned(),
531 },
532 ExecuteCommand::Admin(AdminCommand::GrantRole {
533 username: "alice".to_owned(),
534 role: "analyst".to_owned(),
535 }),
536 ] {
537 assert_serde_round_trip(&command);
538 }
539 }
540
541 #[test]
542 fn result_limits_defaults_are_bounded() {
543 let limits = ResultLimits::default();
544 assert_eq!(limits.max_rows, None);
545 assert_eq!(limits.effective_max_rows(), DEFAULT_MAX_ROWS);
546 assert_eq!(limits.effective_max_bytes(), DEFAULT_MAX_BYTES);
547 assert_eq!(
548 limits.effective_max_candidate_count(),
549 DEFAULT_MAX_CANDIDATE_COUNT
550 );
551 let explicit = ResultLimits {
552 max_rows: Some(10),
553 max_bytes: Some(1024),
554 max_candidate_count: Some(50),
555 };
556 assert_eq!(explicit.effective_max_rows(), 10);
557 assert_eq!(explicit.effective_max_bytes(), 1024);
558 assert_eq!(explicit.effective_max_candidate_count(), 50);
559 assert_serde_round_trip(&limits);
560 assert_serde_round_trip(&explicit);
561 }
562
563 #[test]
564 fn execute_request_serde_round_trip() {
565 let full = ExecuteRequest {
566 request_id: [0x11; 16],
567 query_id: QueryId::new_random(),
568 session_id: Some(SessionId::from_bytes([0x22; 16])),
569 database_id: DatabaseId::new_random(),
570 principal: sample_principal(),
571 command: ExecuteCommand::Sql {
572 text: "SELECT 1".to_owned(),
573 params: sample_params(),
574 },
575 deadline_unix_micros: Some(1_758_000_000_000_000),
576 result_limits: ResultLimits {
577 max_rows: Some(500),
578 max_bytes: None,
579 max_candidate_count: Some(10_000),
580 },
581 resource_group: Some("interactive".to_owned()),
582 idempotency_key: Some("req-42".to_owned()),
583 };
584 assert_serde_round_trip(&full);
585 let minimal = ExecuteRequest {
586 session_id: None,
587 deadline_unix_micros: None,
588 result_limits: ResultLimits::default(),
589 resource_group: None,
590 idempotency_key: None,
591 principal: AuthenticatedIdentity::Credentialless,
592 command: ExecuteCommand::Commit,
593 ..full.clone()
594 };
595 assert_serde_round_trip(&minimal);
596 }
597}