Skip to main content

mongreldb_core/
resource.rs

1//! Workload classes and resource groups (spec section 10.5, S1E-001/S1E-002).
2//!
3//! Implemented in the Stage 1E wave: every unit of work admitted to the node is
4//! classified into a [`WorkloadClass`] (S1E-001) and runs inside a
5//! [`ResourceGroup`] (S1E-002) that bounds its concurrency, queue depth,
6//! memory, temporary disk, CPU/work, and result size (spec section 4.9 —
7//! bounded resources).
8//!
9//! The resource hierarchy is `node → tenant → resource group → query`, carried
10//! as typed handles ([`NodeHandle`], [`TenantHandle`],
11//! [`ResourceGroupHandle`], [`QueryHandle`]) so a handle for one level can
12//! never be confused with another and every query can walk back up to its
13//! node.
14//!
15//! [`ResourceGroupRegistry`] holds the node-local groups with configured
16//! defaults: control and replication groups are pinned with reserved capacity
17//! (spec section 13.1 — "control and replication have reserved capacity" — and
18//! section 4.9). Groups serialize through serde so they can later be
19//! replicated as cluster settings (S1F-001 catalog work).
20
21use std::collections::BTreeMap;
22use std::fmt;
23use std::str::FromStr;
24
25use mongreldb_types::ids::{NodeId, QueryId};
26use serde::{Deserialize, Serialize};
27
28/// The workload class of a unit of admitted work (spec section 10.5, S1E-001).
29///
30/// The variant set is exactly the spec's; scheduling and memory-governance
31/// policy is derived from it. Serde form is the variant name, stable for
32/// cluster-settings replication.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
34pub enum WorkloadClass {
35    /// Node control plane (membership, catalog, health). Reserved capacity.
36    Control,
37    /// Replication traffic (log shipping, follower reads). Reserved capacity.
38    Replication,
39    /// Foreground point reads/writes.
40    Oltp,
41    /// Foreground ad-hoc SQL.
42    InteractiveSql,
43    /// ANN/full-text candidate retrieval and scoring.
44    AiRetrieval,
45    /// Large scans and aggregations.
46    Analytics,
47    /// Compaction, GC, index rebuilds. Yields to foreground work (§13.1).
48    Maintenance,
49    /// Backup and export.
50    Backup,
51}
52
53impl WorkloadClass {
54    /// Every class, in declaration order.
55    pub const ALL: [WorkloadClass; 8] = [
56        WorkloadClass::Control,
57        WorkloadClass::Replication,
58        WorkloadClass::Oltp,
59        WorkloadClass::InteractiveSql,
60        WorkloadClass::AiRetrieval,
61        WorkloadClass::Analytics,
62        WorkloadClass::Maintenance,
63        WorkloadClass::Backup,
64    ];
65
66    /// Stable lowercase name (matches the scheduler queue names of §13.1).
67    pub fn name(self) -> &'static str {
68        match self {
69            WorkloadClass::Control => "control",
70            WorkloadClass::Replication => "replication",
71            WorkloadClass::Oltp => "oltp",
72            WorkloadClass::InteractiveSql => "interactive_sql",
73            WorkloadClass::AiRetrieval => "ai_retrieval",
74            WorkloadClass::Analytics => "analytics",
75            WorkloadClass::Maintenance => "maintenance",
76            WorkloadClass::Backup => "backup",
77        }
78    }
79
80    /// Classes whose capacity is reserved and must never be fully starved
81    /// (spec §13.1: "control and replication have reserved capacity").
82    pub fn has_reserved_capacity(self) -> bool {
83        matches!(self, WorkloadClass::Control | WorkloadClass::Replication)
84    }
85
86    /// Deferrable classes: the first to be rejected and throttled under
87    /// pressure (spec §13.1 maintenance yields to foreground work; §10.5
88    /// S1E-003 escalation step 1 rejects new low-priority work).
89    pub fn is_low_priority(self) -> bool {
90        matches!(
91            self,
92            WorkloadClass::Analytics | WorkloadClass::Maintenance | WorkloadClass::Backup
93        )
94    }
95}
96
97impl fmt::Display for WorkloadClass {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(self.name())
100    }
101}
102
103/// Errors of resource-group validation and registry operations.
104#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
105pub enum ResourceError {
106    /// The group name was empty.
107    #[error("resource group name must not be empty")]
108    EmptyName,
109    /// `cpu_weight` was zero (§4.9 requires an explicit CPU/work bound).
110    #[error("resource group {name:?}: cpu_weight must be nonzero (§4.9 bounded CPU/work)")]
111    ZeroCpuWeight {
112        /// Offending group name.
113        name: String,
114    },
115    /// `work_units` was zero (§4.9 requires an explicit CPU/work bound).
116    #[error("resource group {name:?}: work_units must be nonzero (§4.9 bounded CPU/work)")]
117    ZeroWorkUnits {
118        /// Offending group name.
119        name: String,
120    },
121    /// A pinned control/replication group was removed or stripped of its
122    /// reserved capacity (spec §13.1).
123    #[error(
124        "resource group {name:?} is pinned: control and replication capacity is reserved (§13.1)"
125    )]
126    Pinned {
127        /// Pinned group name.
128        name: String,
129    },
130    /// No group with that name is registered.
131    #[error("resource group {name:?} not found")]
132    NotFound {
133        /// Missing group name.
134        name: String,
135    },
136}
137
138/// Bounds for one class of admitted work (spec section 10.5, S1E-002).
139///
140/// The field set is exactly the spec's. `priority` is a `u8`, so the spec's
141/// `0..=255` range is enforced by the type itself (and by serde on the
142/// cluster-settings path); [`validate`](Self::validate) covers the remaining
143/// invariants: non-empty name and nonzero weights.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct ResourceGroup {
146    /// Unique (per tenant) group name.
147    pub name: String,
148    /// Maximum concurrently executing queries.
149    pub max_concurrency: usize,
150    /// Maximum queued queries awaiting a concurrency slot.
151    pub max_queue: usize,
152    /// Memory budget in bytes (enforced via the memory governor, S1E-003).
153    pub memory_bytes: u64,
154    /// Temporary-disk budget in bytes (spill manager, S1E-004).
155    pub temporary_disk_bytes: u64,
156    /// Work-unit budget (weighted CPU/I/O accounting units).
157    pub work_units: u64,
158    /// CPU scheduling weight (relative share; must be nonzero).
159    pub cpu_weight: u32,
160    /// Scheduling priority, `0..=255` (higher runs first).
161    pub priority: u8,
162    /// Maximum bytes of one query's result.
163    pub max_result_bytes: u64,
164}
165
166impl ResourceGroup {
167    /// Checks the invariants not carried by the field types: non-empty name
168    /// and nonzero weights (priority's `0..=255` range is the `u8` type).
169    pub fn validate(&self) -> Result<(), ResourceError> {
170        if self.name.is_empty() {
171            return Err(ResourceError::EmptyName);
172        }
173        if self.cpu_weight == 0 {
174            return Err(ResourceError::ZeroCpuWeight {
175                name: self.name.clone(),
176            });
177        }
178        if self.work_units == 0 {
179            return Err(ResourceError::ZeroWorkUnits {
180                name: self.name.clone(),
181            });
182        }
183        Ok(())
184    }
185
186    /// The configured default group for a workload class (spec §13.1 queue
187    /// set). Control and replication receive generous, always-reserved
188    /// capacity; foreground classes outrank analytics, which outranks
189    /// maintenance and backup. Operators reconfigure these through the
190    /// registry; the numbers are starting points, not policy.
191    pub fn for_class(class: WorkloadClass) -> Self {
192        let (name, max_concurrency, max_queue, memory_mib, work_units, cpu_weight, priority) =
193            match class {
194                WorkloadClass::Control => {
195                    ("control", 8usize, 64usize, 256u64, 1 << 20, 256u32, 255u8)
196                }
197                WorkloadClass::Replication => ("replication", 8, 64, 512, 1 << 20, 256, 254),
198                WorkloadClass::Oltp => ("oltp", 64, 256, 1024, 1 << 20, 128, 200),
199                WorkloadClass::InteractiveSql => {
200                    ("interactive_sql", 16, 64, 1024, 1 << 19, 64, 180)
201                }
202                WorkloadClass::AiRetrieval => ("ai_retrieval", 8, 32, 1024, 1 << 18, 32, 150),
203                WorkloadClass::Analytics => ("analytics", 4, 16, 2048, 1 << 17, 16, 100),
204                WorkloadClass::Maintenance => ("maintenance", 2, 8, 512, 1 << 16, 8, 50),
205                WorkloadClass::Backup => ("backup", 1, 4, 256, 1 << 16, 4, 40),
206            };
207        Self {
208            name: name.to_string(),
209            max_concurrency,
210            max_queue,
211            memory_bytes: memory_mib * 1024 * 1024,
212            temporary_disk_bytes: 1024 * 1024 * 1024,
213            work_units,
214            cpu_weight,
215            priority,
216            max_result_bytes: 1024 * 1024 * 1024,
217        }
218    }
219}
220
221/// Numeric tenant identifier. The zero value is reserved.
222///
223/// Mirrors the `id64` style of `mongreldb-types` (numeric IDs allocated
224/// through replicated catalog state); it moves there with the cluster id work
225/// (spec section 7) once tenants become a catalog object.
226#[repr(transparent)]
227#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
228pub struct TenantId(pub u64);
229
230impl TenantId {
231    /// The reserved zero value.
232    pub const ZERO: Self = Self(0);
233
234    /// Wraps a raw value.
235    pub const fn new(value: u64) -> Self {
236        Self(value)
237    }
238
239    /// Returns the raw value.
240    pub const fn get(self) -> u64 {
241        self.0
242    }
243}
244
245impl fmt::Display for TenantId {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(f, "{}", self.0)
248    }
249}
250
251impl fmt::Debug for TenantId {
252    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
253        write!(f, "TenantId({})", self.0)
254    }
255}
256
257impl FromStr for TenantId {
258    type Err = std::num::ParseIntError;
259
260    fn from_str(text: &str) -> Result<Self, Self::Err> {
261        text.parse::<u64>().map(Self)
262    }
263}
264
265/// Typed handle to the node level of the resource hierarchy.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
267pub struct NodeHandle {
268    id: NodeId,
269}
270
271impl NodeHandle {
272    /// A handle for the node `id`.
273    pub fn new(id: NodeId) -> Self {
274        Self { id }
275    }
276
277    /// The node's cluster-wide identifier.
278    pub fn id(self) -> NodeId {
279        self.id
280    }
281
282    /// Descend to tenant `tenant` on this node.
283    pub fn tenant(self, tenant: TenantId) -> TenantHandle {
284        TenantHandle {
285            node: self,
286            id: tenant,
287        }
288    }
289}
290
291/// Typed handle to the tenant level (`node → tenant`).
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
293pub struct TenantHandle {
294    node: NodeHandle,
295    id: TenantId,
296}
297
298impl TenantHandle {
299    /// The node this tenant handle is scoped to.
300    pub fn node(self) -> NodeHandle {
301        self.node
302    }
303
304    /// The tenant identifier.
305    pub fn id(self) -> TenantId {
306        self.id
307    }
308
309    /// Descend to the resource group `name` within this tenant.
310    pub fn group(self, name: impl Into<String>) -> ResourceGroupHandle {
311        ResourceGroupHandle {
312            tenant: self,
313            name: name.into(),
314        }
315    }
316}
317
318/// Typed handle to the resource-group level (`node → tenant → group`).
319#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
320pub struct ResourceGroupHandle {
321    tenant: TenantHandle,
322    name: String,
323}
324
325impl ResourceGroupHandle {
326    /// The tenant this group belongs to.
327    pub fn tenant(&self) -> TenantHandle {
328        self.tenant
329    }
330
331    /// The node this group is scoped to.
332    pub fn node(&self) -> NodeHandle {
333        self.tenant.node()
334    }
335
336    /// The group name (registry lookup key).
337    pub fn name(&self) -> &str {
338        &self.name
339    }
340
341    /// Descend to query `id` admitted into this group under `class`.
342    pub fn query(&self, id: QueryId, class: WorkloadClass) -> QueryHandle {
343        QueryHandle {
344            group: self.clone(),
345            id,
346            class,
347        }
348    }
349}
350
351/// Typed handle to the query level (`node → tenant → group → query`).
352#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
353pub struct QueryHandle {
354    group: ResourceGroupHandle,
355    id: QueryId,
356    class: WorkloadClass,
357}
358
359impl QueryHandle {
360    /// The resource group this query runs in.
361    pub fn group(&self) -> &ResourceGroupHandle {
362        &self.group
363    }
364
365    /// The query's cluster-wide identifier.
366    pub fn id(&self) -> QueryId {
367        self.id
368    }
369
370    /// The workload class the query was admitted under.
371    pub fn class(&self) -> WorkloadClass {
372        self.class
373    }
374
375    /// The tenant this query runs under.
376    pub fn tenant(&self) -> TenantHandle {
377        self.group.tenant()
378    }
379
380    /// The node this query runs on.
381    pub fn node(&self) -> NodeHandle {
382        self.group.node()
383    }
384}
385
386/// Node-local registry of resource groups (S1E-002), with configured defaults
387/// for every workload class (§13.1 queue set).
388///
389/// The control and replication groups are **pinned**: they cannot be removed
390/// and cannot be re-registered without reserved (`max_concurrency > 0` and
391/// `memory_bytes > 0`) capacity — spec §13.1 "control and replication have
392/// reserved capacity" and §4.9 bounded resources.
393///
394/// Serde form is a JSON/object map of name → group (sorted by name, so the
395/// serialized form is deterministic) for later cluster-settings replication
396/// (S1F-001). Deserialization re-asserts the pinned defaults: a replicated
397/// settings document that omits or starves the control/replication groups is
398/// rejected rather than applied.
399pub struct ResourceGroupRegistry {
400    groups: parking_lot::RwLock<BTreeMap<String, ResourceGroup>>,
401}
402
403/// Group names pinned with reserved capacity (§13.1).
404const PINNED_GROUP_NAMES: [&str; 2] = ["control", "replication"];
405
406impl ResourceGroupRegistry {
407    /// An empty registry (no defaults). Prefer [`with_defaults`](Self::with_defaults)
408    /// for any serving node.
409    pub fn new() -> Self {
410        Self {
411            groups: parking_lot::RwLock::new(BTreeMap::new()),
412        }
413    }
414
415    /// A registry seeded with the configured default group of every workload
416    /// class (§13.1), control and replication pinned with reserved capacity.
417    pub fn with_defaults() -> Self {
418        let registry = Self::new();
419        for class in WorkloadClass::ALL {
420            let group = ResourceGroup::for_class(class);
421            registry.groups.write().insert(group.name.clone(), group);
422        }
423        registry
424    }
425
426    /// Registers (or replaces) a group after validation. Replacing a pinned
427    /// control/replication group is rejected unless the replacement keeps
428    /// reserved (`max_concurrency > 0` and `memory_bytes > 0`) capacity.
429    pub fn register(&self, group: ResourceGroup) -> Result<(), ResourceError> {
430        group.validate()?;
431        if PINNED_GROUP_NAMES.contains(&group.name.as_str())
432            && (group.max_concurrency == 0 || group.memory_bytes == 0)
433        {
434            return Err(ResourceError::Pinned {
435                name: group.name.clone(),
436            });
437        }
438        self.groups.write().insert(group.name.clone(), group);
439        Ok(())
440    }
441
442    /// Looks up a group by name.
443    pub fn get(&self, name: &str) -> Option<ResourceGroup> {
444        self.groups.read().get(name).cloned()
445    }
446
447    /// Like [`get`](Self::get), but a typed error for the not-found case.
448    pub fn lookup(&self, name: &str) -> Result<ResourceGroup, ResourceError> {
449        self.get(name).ok_or_else(|| ResourceError::NotFound {
450            name: name.to_string(),
451        })
452    }
453
454    /// Removes a group by name. Pinned control/replication groups cannot be
455    /// removed (§13.1 reserved capacity). Returns `true` if a group was
456    /// removed.
457    pub fn remove(&self, name: &str) -> Result<bool, ResourceError> {
458        if PINNED_GROUP_NAMES.contains(&name) {
459            return Err(ResourceError::Pinned {
460                name: name.to_string(),
461            });
462        }
463        Ok(self.groups.write().remove(name).is_some())
464    }
465
466    /// Sorted names of every registered group.
467    pub fn names(&self) -> Vec<String> {
468        self.groups.read().keys().cloned().collect()
469    }
470
471    /// Number of registered groups.
472    pub fn len(&self) -> usize {
473        self.groups.read().len()
474    }
475
476    /// Whether no groups are registered.
477    pub fn is_empty(&self) -> bool {
478        self.len() == 0
479    }
480}
481
482impl Default for ResourceGroupRegistry {
483    fn default() -> Self {
484        Self::with_defaults()
485    }
486}
487
488impl fmt::Debug for ResourceGroupRegistry {
489    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
490        f.debug_struct("ResourceGroupRegistry")
491            .field("groups", &*self.groups.read())
492            .finish()
493    }
494}
495
496impl Serialize for ResourceGroupRegistry {
497    /// Serializes as a name → group map (`BTreeMap` order: deterministic).
498    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
499    where
500        S: serde::Serializer,
501    {
502        self.groups.read().serialize(serializer)
503    }
504}
505
506impl<'de> Deserialize<'de> for ResourceGroupRegistry {
507    /// Restores a registry from its map form, then re-asserts the pinned
508    /// control/replication defaults: a document that omits or starves them is
509    /// rejected (reserved capacity is a safety invariant, not a preference).
510    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
511    where
512        D: serde::Deserializer<'de>,
513    {
514        let groups = BTreeMap::<String, ResourceGroup>::deserialize(deserializer)?;
515        let registry = Self::new();
516        for (_, group) in groups {
517            registry.register(group).map_err(serde::de::Error::custom)?;
518        }
519        for pinned in PINNED_GROUP_NAMES {
520            if registry.get(pinned).is_none() {
521                return Err(serde::de::Error::custom(format!(
522                    "pinned resource group {pinned:?} missing (§13.1 reserved capacity)"
523                )));
524            }
525        }
526        Ok(registry)
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn valid_group(name: &str) -> ResourceGroup {
535        ResourceGroup {
536            name: name.to_string(),
537            max_concurrency: 4,
538            max_queue: 16,
539            memory_bytes: 1 << 20,
540            temporary_disk_bytes: 1 << 20,
541            work_units: 100,
542            cpu_weight: 1,
543            priority: 128,
544            max_result_bytes: 1 << 20,
545        }
546    }
547
548    #[test]
549    fn workload_class_set_matches_spec() {
550        // S1E-001: exactly these eight classes.
551        assert_eq!(WorkloadClass::ALL.len(), 8);
552        let names: Vec<_> = WorkloadClass::ALL.iter().map(|c| c.name()).collect();
553        assert_eq!(
554            names,
555            vec![
556                "control",
557                "replication",
558                "oltp",
559                "interactive_sql",
560                "ai_retrieval",
561                "analytics",
562                "maintenance",
563                "backup"
564            ]
565        );
566        // Names are unique.
567        let mut deduped = names.clone();
568        deduped.sort_unstable();
569        deduped.dedup();
570        assert_eq!(deduped.len(), 8);
571    }
572
573    #[test]
574    fn control_and_replication_are_the_reserved_classes() {
575        for class in WorkloadClass::ALL {
576            assert_eq!(
577                class.has_reserved_capacity(),
578                matches!(class, WorkloadClass::Control | WorkloadClass::Replication),
579                "reserved capacity: {class}"
580            );
581        }
582    }
583
584    #[test]
585    fn low_priority_classes_are_deferrable() {
586        for class in WorkloadClass::ALL {
587            assert_eq!(
588                class.is_low_priority(),
589                matches!(
590                    class,
591                    WorkloadClass::Analytics | WorkloadClass::Maintenance | WorkloadClass::Backup
592                ),
593                "low priority: {class}"
594            );
595        }
596    }
597
598    #[test]
599    fn workload_class_serde_round_trip() {
600        for class in WorkloadClass::ALL {
601            let json = serde_json::to_string(&class).unwrap();
602            let back: WorkloadClass = serde_json::from_str(&json).unwrap();
603            assert_eq!(back, class);
604        }
605        assert_eq!(
606            serde_json::to_string(&WorkloadClass::AiRetrieval).unwrap(),
607            "\"AiRetrieval\""
608        );
609    }
610
611    #[test]
612    fn validation_accepts_a_valid_group() {
613        assert!(valid_group("g").validate().is_ok());
614        // Priority is u8: 0 and 255 are both valid (range is the type).
615        for priority in [0u8, 255] {
616            let mut g = valid_group("g");
617            g.priority = priority;
618            assert!(g.validate().is_ok());
619        }
620    }
621
622    #[test]
623    fn validation_rejects_empty_name() {
624        let mut g = valid_group("");
625        g.name.clear();
626        assert_eq!(g.validate(), Err(ResourceError::EmptyName));
627    }
628
629    #[test]
630    fn validation_rejects_zero_weights() {
631        let mut g = valid_group("g");
632        g.cpu_weight = 0;
633        assert_eq!(
634            g.validate(),
635            Err(ResourceError::ZeroCpuWeight { name: "g".into() })
636        );
637        let mut g = valid_group("g");
638        g.work_units = 0;
639        assert_eq!(
640            g.validate(),
641            Err(ResourceError::ZeroWorkUnits { name: "g".into() })
642        );
643    }
644
645    #[test]
646    fn resource_group_serde_round_trip_and_priority_range() {
647        let g = valid_group("oltp");
648        let json = serde_json::to_string(&g).unwrap();
649        let back: ResourceGroup = serde_json::from_str(&json).unwrap();
650        assert_eq!(back, g);
651        // The u8 type enforces the spec's 0..=255 priority range on the
652        // (untrusted) cluster-settings path.
653        let too_big = json.replace("\"priority\":128", "\"priority\":256");
654        assert!(serde_json::from_str::<ResourceGroup>(&too_big).is_err());
655    }
656
657    #[test]
658    fn registry_defaults_cover_every_class() {
659        let registry = ResourceGroupRegistry::with_defaults();
660        assert_eq!(registry.len(), WorkloadClass::ALL.len());
661        for class in WorkloadClass::ALL {
662            let group = registry
663                .get(ResourceGroup::for_class(class).name.as_str())
664                .unwrap_or_else(|| panic!("default group for {class}"));
665            assert!(group.validate().is_ok());
666            assert!(group.max_concurrency > 0);
667            assert!(group.memory_bytes > 0);
668        }
669        assert!(registry.lookup("no-such-group").is_err());
670    }
671
672    #[test]
673    fn control_and_replication_defaults_have_top_priority() {
674        let registry = ResourceGroupRegistry::with_defaults();
675        let control = registry.lookup("control").unwrap();
676        let replication = registry.lookup("replication").unwrap();
677        assert_eq!(control.priority, 255);
678        assert_eq!(replication.priority, 254);
679        for name in registry.names() {
680            if name != "control" && name != "replication" {
681                let g = registry.lookup(&name).unwrap();
682                assert!(
683                    g.priority < replication.priority,
684                    "{name} outranks replication"
685                );
686            }
687        }
688    }
689
690    #[test]
691    fn pinned_groups_cannot_be_removed_or_starved() {
692        let registry = ResourceGroupRegistry::with_defaults();
693        for pinned in ["control", "replication"] {
694            assert_eq!(
695                registry.remove(pinned),
696                Err(ResourceError::Pinned {
697                    name: pinned.into()
698                })
699            );
700            // Re-registration is allowed only while reserved capacity is kept.
701            let mut starved = registry.lookup(pinned).unwrap();
702            starved.max_concurrency = 0;
703            assert_eq!(
704                registry.register(starved),
705                Err(ResourceError::Pinned {
706                    name: pinned.into()
707                })
708            );
709            let mut starved = registry.lookup(pinned).unwrap();
710            starved.memory_bytes = 0;
711            assert_eq!(
712                registry.register(starved),
713                Err(ResourceError::Pinned {
714                    name: pinned.into()
715                })
716            );
717            // A capacity-preserving replacement is accepted.
718            let mut kept = registry.lookup(pinned).unwrap();
719            kept.max_concurrency += 1;
720            assert!(registry.register(kept.clone()).is_ok());
721            assert_eq!(registry.lookup(pinned).unwrap(), kept);
722        }
723        // A normal group can be removed.
724        registry.register(valid_group("scratch")).unwrap();
725        assert_eq!(registry.remove("scratch"), Ok(true));
726        assert_eq!(registry.remove("scratch"), Ok(false));
727    }
728
729    #[test]
730    fn register_validates_groups() {
731        let registry = ResourceGroupRegistry::new();
732        let mut bad = valid_group("bad");
733        bad.cpu_weight = 0;
734        assert!(matches!(
735            registry.register(bad),
736            Err(ResourceError::ZeroCpuWeight { .. })
737        ));
738        assert!(registry.get("bad").is_none());
739    }
740
741    #[test]
742    fn registry_serde_round_trip_preserves_groups() {
743        let registry = ResourceGroupRegistry::with_defaults();
744        registry.register(valid_group("tenant_a")).unwrap();
745        let json = serde_json::to_string(&registry).unwrap();
746        let back: ResourceGroupRegistry = serde_json::from_str(&json).unwrap();
747        assert_eq!(back.names(), registry.names());
748        assert_eq!(back.lookup("tenant_a").unwrap(), valid_group("tenant_a"));
749        // Deterministic serialization (sorted map): same registry, same bytes.
750        assert_eq!(serde_json::to_string(&back).unwrap(), json);
751    }
752
753    #[test]
754    fn registry_deserialization_re_enforces_pinned_defaults() {
755        // A replicated settings document missing the pinned groups is rejected.
756        let bare = serde_json::json!({ "oltp": valid_group("oltp") });
757        assert!(serde_json::from_value::<ResourceGroupRegistry>(bare).is_err());
758        // One that starves them is rejected too.
759        let mut starved = valid_group("control");
760        starved.memory_bytes = 0;
761        let doc =
762            serde_json::json!({ "control": starved, "replication": valid_group("replication") });
763        assert!(serde_json::from_value::<ResourceGroupRegistry>(doc).is_err());
764    }
765
766    #[test]
767    fn hierarchy_handles_walk_up_the_chain() {
768        let node = NodeHandle::new(NodeId::new_random());
769        let tenant = node.tenant(TenantId::new(7));
770        let group = tenant.group("oltp");
771        let query_id = QueryId::new_random();
772        let query = group.query(query_id, WorkloadClass::Oltp);
773
774        // Every level reaches back up to the node.
775        assert_eq!(tenant.node(), node);
776        assert_eq!(tenant.id(), TenantId::new(7));
777        assert_eq!(group.tenant(), tenant);
778        assert_eq!(group.node(), node);
779        assert_eq!(group.name(), "oltp");
780        assert_eq!(query.group(), &group);
781        assert_eq!(query.id(), query_id);
782        assert_eq!(query.class(), WorkloadClass::Oltp);
783        assert_eq!(query.tenant(), tenant);
784        assert_eq!(query.node(), node);
785        assert_eq!(node.id(), node.id());
786    }
787
788    #[test]
789    fn hierarchy_handles_serde_round_trip() {
790        let node = NodeHandle::new(NodeId::from_bytes([0xAB; 16]));
791        let query = node
792            .tenant(TenantId::new(42))
793            .group("analytics")
794            .query(QueryId::from_bytes([0xCD; 16]), WorkloadClass::Analytics);
795        let json = serde_json::to_string(&query).unwrap();
796        let back: QueryHandle = serde_json::from_str(&json).unwrap();
797        assert_eq!(back, query);
798        assert_eq!(back.node().id(), NodeId::from_bytes([0xAB; 16]));
799        assert_eq!(back.tenant().id(), TenantId::new(42));
800    }
801
802    #[test]
803    fn tenant_id_forms() {
804        let id = TenantId::new(123);
805        assert_eq!(id.get(), 123);
806        assert_eq!(id.to_string(), "123");
807        assert_eq!(format!("{id:?}"), "TenantId(123)");
808        assert_eq!("123".parse::<TenantId>().unwrap(), id);
809        assert_eq!(TenantId::ZERO.get(), 0);
810    }
811}