Skip to main content

chronix_security/tenant/
registry.rs

1//! Namespace registry — CRUD for tenant namespaces.
2//!
3//! Manages the lifecycle of namespaces: creation, deletion, listing,
4//! and configuration updates. Each namespace encapsulates its own
5//! quota limits and usage counters.
6//!
7//! Optionally persists namespace state to disk via JSON snapshots.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use dashmap::DashMap;
14use serde::{Deserialize, Serialize};
15use tracing::{debug, info};
16
17use chronix_core::{NamespaceId, NamespaceQuota, NamespaceUsage};
18
19use crate::tenant::error::{Result, TenantError};
20
21/// Metadata for a registered namespace.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct NamespaceInfo {
24    /// Unique namespace identifier.
25    pub id: NamespaceId,
26    /// Human-readable description.
27    pub description: String,
28    /// Owner principal (user ID that created the namespace).
29    pub owner: String,
30    /// Resource quota limits.
31    pub quota: NamespaceQuota,
32    /// When the namespace was created (ms since epoch).
33    pub created_at_ms: u64,
34}
35
36/// Current state of a namespace including live usage.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct NamespaceState {
39    /// Namespace metadata.
40    pub info: NamespaceInfo,
41    /// Real-time resource usage.
42    pub usage: NamespaceUsage,
43}
44
45/// Thread-safe namespace registry.
46///
47/// Provides fast, concurrent access to namespace metadata and usage
48/// counters using [`DashMap`] for lock-free reads.
49///
50/// # Default Namespace
51///
52/// The `default` namespace is always present and cannot be deleted.
53/// It is used when multi-tenancy is disabled or no namespace header
54/// is provided.
55#[derive(Debug)]
56pub struct NamespaceRegistry {
57    /// Namespace states keyed by namespace name.
58    namespaces: DashMap<String, NamespaceState>,
59    /// Optional directory for persisting namespace state.
60    persist_dir: Option<PathBuf>,
61}
62
63/// File name for the namespace snapshot inside the persist directory.
64const SNAPSHOT_FILE: &str = "namespaces.json";
65
66/// Atomic write-to-tmp + fsync + rename for namespace state.
67fn write_snapshot(dir: &Path, states: &[NamespaceState]) -> Result<()> {
68    let json = serde_json::to_string_pretty(states)
69        .map_err(|e| TenantError::InvalidConfig(format!("cannot serialize namespaces: {e}")))?;
70
71    let tmp_path = dir.join("namespaces.json.tmp");
72    let final_path = dir.join(SNAPSHOT_FILE);
73    std::fs::write(&tmp_path, &json)
74        .map_err(|e| TenantError::Persist(format!("cannot write namespace snapshot: {e}")))?;
75
76    // Clean up tmp file on fsync or rename failure to avoid
77    // stale tmp files leaking on disk.
78    let result = (|| -> Result<()> {
79        let f = std::fs::File::open(&tmp_path).map_err(|e| {
80            TenantError::Persist(format!("cannot open tmp snapshot for fsync: {e}"))
81        })?;
82        f.sync_all()
83            .map_err(|e| TenantError::Persist(format!("cannot fsync namespace snapshot: {e}")))?;
84
85        std::fs::rename(&tmp_path, &final_path)
86            .map_err(|e| TenantError::Persist(format!("cannot rename namespace snapshot: {e}")))?;
87        Ok(())
88    })();
89
90    if result.is_err() {
91        let _ = std::fs::remove_file(&tmp_path);
92    }
93
94    result?;
95
96    debug!("namespace snapshot saved");
97    Ok(())
98}
99impl NamespaceRegistry {
100    /// Create a new in-memory registry with only the `default` namespace.
101    ///
102    /// State is not persisted. Use `open` for durable registries.
103    #[must_use]
104    pub fn new() -> Self {
105        let registry = Self {
106            namespaces: DashMap::new(),
107            persist_dir: None,
108        };
109
110        registry.ensure_default();
111        registry
112    }
113
114    /// Open a durable namespace registry backed by a directory.
115    ///
116    /// If a previous snapshot exists, it is loaded. Otherwise the directory
117    /// is created and an initial snapshot with only the `default` namespace
118    /// is written.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the directory cannot be created or the snapshot
123    /// file is corrupt.
124    pub fn open(dir: impl Into<PathBuf>) -> Result<Self> {
125        let dir = dir.into();
126        std::fs::create_dir_all(&dir)
127            .map_err(|e| TenantError::InvalidConfig(format!("cannot create namespace dir: {e}")))?;
128
129        let snapshot_path = dir.join(SNAPSHOT_FILE);
130        let registry = if snapshot_path.exists() {
131            let data = std::fs::read_to_string(&snapshot_path).map_err(|e| {
132                TenantError::InvalidConfig(format!("cannot read namespace snapshot: {e}"))
133            })?;
134            let states: Vec<NamespaceState> = serde_json::from_str(&data).map_err(|e| {
135                TenantError::InvalidConfig(format!("corrupt namespace snapshot: {e}"))
136            })?;
137            let namespaces = DashMap::new();
138            for state in states {
139                namespaces.insert(state.info.id.as_str().to_string(), state);
140            }
141            info!(count = namespaces.len(), "loaded namespace snapshot");
142            Self {
143                namespaces,
144                persist_dir: Some(dir),
145            }
146        } else {
147            Self {
148                namespaces: DashMap::new(),
149                persist_dir: Some(dir),
150            }
151        };
152
153        registry.ensure_default();
154
155        // Write initial snapshot if it didn't exist
156        if !snapshot_path.exists() {
157            registry.save_snapshot_inner()?;
158        }
159
160        Ok(registry)
161    }
162
163    /// Ensure the default namespace exists.
164    fn ensure_default(&self) {
165        if !self.namespaces.contains_key(NamespaceId::DEFAULT) {
166            let default_info = NamespaceInfo {
167                id: NamespaceId::default_namespace(),
168                description: "Default namespace".to_string(),
169                owner: "system".to_string(),
170                quota: NamespaceQuota::default(),
171                created_at_ms: now_ms(),
172            };
173            self.namespaces.insert(
174                NamespaceId::DEFAULT.to_string(),
175                NamespaceState {
176                    info: default_info,
177                    usage: NamespaceUsage::default(),
178                },
179            );
180        }
181    }
182
183    /// Persist current namespace state to disk (atomic write-to-tmp + rename).
184    ///
185    /// No-op if the registry has no `persist_dir`.
186    fn save_snapshot_inner(&self) -> Result<()> {
187        let Some(dir) = &self.persist_dir else {
188            return Ok(());
189        };
190
191        let states: Vec<NamespaceState> =
192            self.namespaces.iter().map(|r| r.value().clone()).collect();
193
194        write_snapshot(dir, &states)
195    }
196
197    /// Create a new namespace.
198    ///
199    /// # Errors
200    ///
201    /// Returns `NamespaceAlreadyExists` if a namespace with the same name
202    /// already exists, or `Schema` if the name is invalid.
203    pub fn create_namespace(
204        &self,
205        id: NamespaceId,
206        description: impl Into<String>,
207        owner: impl Into<String>,
208        quota: NamespaceQuota,
209    ) -> Result<NamespaceInfo> {
210        let name = id.as_str().to_string();
211
212        // Use DashMap::entry() to atomically check-and-insert, avoiding the
213        // TOCTOU race between contains_key() and insert().
214        use dashmap::mapref::entry::Entry;
215        match self.namespaces.entry(name.clone()) {
216            Entry::Occupied(_) => Err(TenantError::NamespaceAlreadyExists(name)),
217            Entry::Vacant(vacant) => {
218                let info = NamespaceInfo {
219                    id,
220                    description: description.into(),
221                    owner: owner.into(),
222                    quota,
223                    created_at_ms: now_ms(),
224                };
225
226                let state = NamespaceState {
227                    info: info.clone(),
228                    usage: NamespaceUsage::default(),
229                };
230
231                vacant.insert(state);
232
233                info!(namespace = %name, "namespace created");
234                #[allow(clippy::cast_precision_loss)]
235                metrics::gauge!("chronix_namespace_count").set(self.namespaces.len() as f64);
236
237                // Synchronous, like the delete below. A create that is
238                // acknowledged and then lost is the delete case mirrored:
239                // the operator provisions a tenant, hands out a credential
240                // bound to it, and after the next restart every request from
241                // that tenant answers `400 namespace not found`. The
242                // in-memory entry is rolled back so the running process does
243                // not disagree with its own restart.
244                if let Err(e) = self.save_snapshot_inner() {
245                    self.namespaces.remove(&name);
246                    #[allow(clippy::cast_precision_loss)]
247                    metrics::gauge!("chronix_namespace_count").set(self.namespaces.len() as f64);
248                    return Err(e);
249                }
250                Ok(info)
251            }
252        }
253    }
254
255    /// Delete a namespace.
256    ///
257    /// # Errors
258    ///
259    /// Returns `NamespaceNotFound` if the namespace does not exist.
260    /// The `default` namespace cannot be deleted.
261    pub fn delete_namespace(&self, name: &str) -> Result<NamespaceInfo> {
262        if name == NamespaceId::DEFAULT {
263            return Err(TenantError::InvalidConfig(
264                "cannot delete the default namespace".to_string(),
265            ));
266        }
267
268        let (_, state) = self
269            .namespaces
270            .remove(name)
271            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
272
273        info!(namespace = %name, "namespace deleted");
274        #[allow(clippy::cast_precision_loss)]
275        metrics::gauge!("chronix_namespace_count").set(self.namespaces.len() as f64);
276
277        // Persist synchronously — deletes are destructive and must be
278        // durable before returning to the caller.
279        self.save_snapshot_inner()?;
280        Ok(state.info)
281    }
282
283    /// Get namespace info by name.
284    ///
285    /// # Errors
286    ///
287    /// Returns `NamespaceNotFound` if the namespace does not exist.
288    pub fn get(&self, name: &str) -> Result<NamespaceState> {
289        self.namespaces
290            .get(name)
291            .map(|r| r.value().clone())
292            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))
293    }
294
295    /// Check if a namespace exists.
296    #[must_use]
297    pub fn exists(&self, name: &str) -> bool {
298        self.namespaces.contains_key(name)
299    }
300
301    /// List all namespace names.
302    #[must_use]
303    pub fn list_names(&self) -> Vec<String> {
304        self.namespaces.iter().map(|r| r.key().clone()).collect()
305    }
306
307    /// List all namespace states.
308    #[must_use]
309    pub fn list_all(&self) -> Vec<NamespaceState> {
310        self.namespaces.iter().map(|r| r.value().clone()).collect()
311    }
312
313    /// Get the number of registered namespaces.
314    #[must_use]
315    pub fn count(&self) -> usize {
316        self.namespaces.len()
317    }
318
319    /// Update the quota for a namespace.
320    ///
321    /// # Errors
322    ///
323    /// Returns `NamespaceNotFound` if the namespace does not exist.
324    pub fn update_quota(&self, name: &str, quota: NamespaceQuota) -> Result<()> {
325        // Validate quota upper bounds before applying.
326        quota.validate().map_err(TenantError::InvalidConfig)?;
327        {
328            let mut entry = self
329                .namespaces
330                .get_mut(name)
331                .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
332            entry.value_mut().info.quota = quota;
333        }
334        debug!(namespace = %name, "quota updated");
335        // Synchronous for the same reason: a quota the API reports as
336        // applied and the next restart reverts is a setting that is accepted
337        // and ignored.
338        self.save_snapshot_inner()?;
339        Ok(())
340    }
341
342    /// Get a reference to the usage counters for a namespace.
343    ///
344    /// Returns `None` if the namespace does not exist.
345    #[must_use]
346    pub fn get_usage(&self, name: &str) -> Option<NamespaceUsage> {
347        self.namespaces.get(name).map(|r| r.value().usage.clone())
348    }
349
350    /// Update usage counters for a namespace.
351    ///
352    /// # Errors
353    ///
354    /// Returns `NamespaceNotFound` if the namespace does not exist.
355    pub fn update_usage(&self, name: &str, usage: NamespaceUsage) -> Result<()> {
356        let mut entry = self
357            .namespaces
358            .get_mut(name)
359            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
360
361        entry.value_mut().usage = usage;
362        Ok(())
363    }
364
365    /// Increment the series count for a namespace.
366    ///
367    /// Uses saturating addition to prevent `u64` overflow.
368    ///
369    /// # Errors
370    ///
371    /// Returns `NamespaceNotFound` if the namespace does not exist.
372    pub fn increment_series(&self, name: &str, count: u64) -> Result<()> {
373        let mut entry = self
374            .namespaces
375            .get_mut(name)
376            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
377
378        entry.value_mut().usage.series_count =
379            entry.value().usage.series_count.saturating_add(count);
380        Ok(())
381    }
382
383    /// Increment the storage bytes for a namespace.
384    ///
385    /// Uses saturating addition to prevent `u64` overflow.
386    ///
387    /// # Errors
388    ///
389    /// Returns `NamespaceNotFound` if the namespace does not exist.
390    pub fn increment_storage(&self, name: &str, bytes: u64) -> Result<()> {
391        let mut entry = self
392            .namespaces
393            .get_mut(name)
394            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
395
396        entry.value_mut().usage.storage_bytes =
397            entry.value().usage.storage_bytes.saturating_add(bytes);
398        Ok(())
399    }
400
401    /// Increment the measurement count for a namespace.
402    ///
403    /// Uses saturating addition to prevent `u32` overflow.
404    ///
405    /// # Errors
406    ///
407    /// Returns `NamespaceNotFound` if the namespace does not exist.
408    pub fn increment_measurements(&self, name: &str, count: u32) -> Result<()> {
409        let mut entry = self
410            .namespaces
411            .get_mut(name)
412            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
413
414        entry.value_mut().usage.measurements =
415            entry.value().usage.measurements.saturating_add(count);
416        Ok(())
417    }
418
419    /// Atomically check write quota and increment usage counters.
420    ///
421    /// Holds the DashMap shard lock for the namespace across both the
422    /// quota check and the usage update, eliminating the TOCTOU race
423    /// between a separate `check_write()` and `increment_series()`.
424    ///
425    /// The ingestion rate is tracked as a **windowed counter** that resets
426    /// once per second. This prevents the rate from accumulating
427    /// indefinitely and permanently blocking writes.
428    ///
429    /// # Errors
430    ///
431    /// Returns `NamespaceNotFound` if the namespace does not exist.
432    /// Returns `QuotaExceeded` if any resource limit would be breached.
433    pub fn check_and_increment_write(
434        &self,
435        name: &str,
436        new_series: u64,
437        new_points: u64,
438    ) -> Result<()> {
439        let mut entry = self
440            .namespaces
441            .get_mut(name)
442            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
443
444        // Read quota limits and current usage into local copies to avoid
445        // overlapping borrows between value() and value_mut().
446        let max_series = entry.value().info.quota.max_series_count;
447        let max_rate = entry.value().info.quota.max_ingestion_rate;
448        let max_storage = entry.value().info.quota.max_storage_bytes;
449        let current_series = entry.value().usage.series_count;
450        let current_storage = entry.value().usage.storage_bytes;
451
452        // 1. Series count
453        let projected_series = current_series.saturating_add(new_series);
454        if projected_series > max_series {
455            return Err(TenantError::QuotaExceeded {
456                namespace: name.to_string(),
457                resource: "series_count".to_string(),
458                current: current_series,
459                limit: max_series,
460            });
461        }
462
463        // 2. Ingestion rate — windowed per second.
464        //    Reset the counter if we've moved into a new 1-second window.
465        let now_s = now_ms() / 1000;
466        let last_window = entry.value().usage.ingestion_rate_window_s;
467        let current_rate = if last_window == now_s {
468            entry.value().usage.ingestion_rate
469        } else {
470            // New window — reset rate.
471            0.0
472        };
473
474        #[allow(clippy::cast_precision_loss)]
475        let projected_rate = current_rate + new_points as f64;
476        #[allow(clippy::cast_precision_loss)]
477        let rate_limit = max_rate as f64;
478        if projected_rate > rate_limit {
479            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
480            let current = current_rate as u64;
481            return Err(TenantError::QuotaExceeded {
482                namespace: name.to_string(),
483                resource: "ingestion_rate".to_string(),
484                current,
485                limit: max_rate,
486            });
487        }
488
489        // 3. Storage (soft check — bytes tracked asynchronously)
490        if current_storage > max_storage {
491            return Err(TenantError::QuotaExceeded {
492                namespace: name.to_string(),
493                resource: "storage_bytes".to_string(),
494                current: current_storage,
495                limit: max_storage,
496            });
497        }
498
499        // All checks passed — atomically update usage while still holding
500        // the shard lock.
501        let state_mut = entry.value_mut();
502        state_mut.usage.series_count = projected_series;
503        state_mut.usage.ingestion_rate = projected_rate;
504        state_mut.usage.ingestion_rate_window_s = now_s;
505
506        Ok(())
507    }
508
509    /// Atomically check measurement quota and increment count.
510    ///
511    /// Like [`check_and_increment_write`](Self::check_and_increment_write),
512    /// this holds the shard lock across check and update.
513    ///
514    /// # Errors
515    ///
516    /// Returns `NamespaceNotFound` if the namespace does not exist.
517    /// Returns `QuotaExceeded` if the measurement limit would be breached.
518    pub fn check_and_increment_measurement(&self, name: &str) -> Result<()> {
519        let mut entry = self
520            .namespaces
521            .get_mut(name)
522            .ok_or_else(|| TenantError::NamespaceNotFound(name.to_string()))?;
523
524        let state = entry.value();
525        let projected = u64::from(state.usage.measurements) + 1;
526        let limit = u64::from(state.info.quota.max_measurements);
527
528        if projected > limit {
529            return Err(TenantError::QuotaExceeded {
530                namespace: name.to_string(),
531                resource: "measurements".to_string(),
532                current: u64::from(state.usage.measurements),
533                limit,
534            });
535        }
536
537        entry.value_mut().usage.measurements = state.usage.measurements.saturating_add(1);
538        Ok(())
539    }
540
541    /// Compute usage ratios for all namespaces.
542    ///
543    /// Returns a list of `(namespace, resource, ratio)` tuples suitable for
544    /// metric emission or admin API responses. The ratio is `current / limit`
545    /// (0.0 when the limit is 0).
546    #[must_use]
547    pub fn usage_ratios(&self) -> Vec<UsageRatio> {
548        let mut ratios = Vec::new();
549
550        for entry in &self.namespaces {
551            let ns = entry.key();
552            let state = entry.value();
553            let q = &state.info.quota;
554            let u = &state.usage;
555
556            ratios.push(UsageRatio {
557                namespace: ns.clone(),
558                resource: "series_count".to_string(),
559                current: u.series_count,
560                limit: q.max_series_count,
561                ratio: ratio(u.series_count, q.max_series_count),
562            });
563            ratios.push(UsageRatio {
564                namespace: ns.clone(),
565                resource: "storage_bytes".to_string(),
566                current: u.storage_bytes,
567                limit: q.max_storage_bytes,
568                ratio: ratio(u.storage_bytes, q.max_storage_bytes),
569            });
570            ratios.push(UsageRatio {
571                namespace: ns.clone(),
572                resource: "measurements".to_string(),
573                current: u64::from(u.measurements),
574                limit: u64::from(q.max_measurements),
575                ratio: ratio(u64::from(u.measurements), u64::from(q.max_measurements)),
576            });
577        }
578
579        ratios
580    }
581
582    /// Emit `chronix_namespace_usage_ratio` gauges for all namespaces.
583    ///
584    /// Intended to be called periodically (e.g. every 10 s) from a
585    /// background task so dashboards always have fresh data, not just
586    /// on write paths.
587    pub fn emit_usage_metrics(&self) {
588        for r in self.usage_ratios() {
589            metrics::gauge!(
590                "chronix_namespace_usage_ratio",
591                "namespace" => r.namespace,
592                "resource" => r.resource,
593            )
594            .set(r.ratio);
595        }
596    }
597}
598
599/// Per-resource usage ratio for a namespace.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct UsageRatio {
602    /// Namespace identifier.
603    pub namespace: String,
604    /// Resource type (e.g. `"series_count"`, `"storage_bytes"`).
605    pub resource: String,
606    /// Current usage value.
607    pub current: u64,
608    /// Configured limit.
609    pub limit: u64,
610    /// Usage ratio (0.0–1.0+). Values > 1.0 indicate over-quota.
611    pub ratio: f64,
612}
613
614/// Compute a safe ratio avoiding division by zero.
615///
616/// When the limit is zero and usage is non-zero, returns [`f64::MAX`]
617/// to indicate over-quota. This avoids returning `f64::INFINITY` which
618/// is not valid JSON (RFC 7159) and breaks `serde_json` serialization.
619#[allow(clippy::cast_precision_loss)]
620fn ratio(current: u64, limit: u64) -> f64 {
621    if limit == 0 {
622        return if current > 0 { f64::MAX } else { 0.0 };
623    }
624    current as f64 / limit as f64
625}
626
627impl Default for NamespaceRegistry {
628    fn default() -> Self {
629        Self::new()
630    }
631}
632
633fn now_ms() -> u64 {
634    #[allow(clippy::cast_possible_truncation)]
635    SystemTime::now()
636        .duration_since(std::time::UNIX_EPOCH)
637        .map(|d| d.as_millis() as u64)
638        .unwrap_or(0)
639}
640
641/// Create a shared, reference-counted registry.
642#[must_use]
643pub fn shared_registry() -> Arc<NamespaceRegistry> {
644    Arc::new(NamespaceRegistry::new())
645}
646
647#[cfg(test)]
648mod tests {
649
650    /// **Creating a tenant must be durable before it is acknowledged.**
651    ///
652    /// A delete was — with a comment saying destructive operations must be —
653    /// and a create was not: it snapshotted *best effort* on a background
654    /// thread and returned `Ok` regardless. So an operator could provision a
655    /// namespace, be told it exists, hand out a credential bound to it, and
656    /// find after the next restart that every request from that tenant
657    /// answers `400 namespace not found`. The mirror of the delete case, and
658    /// the asymmetry is the tell that only one of the two was thought about.
659    #[test]
660    fn creating_a_namespace_is_durable_or_it_fails() {
661        let dir = tempfile::tempdir().unwrap();
662        let registry = NamespaceRegistry::open(dir.path()).unwrap();
663
664        // A directory where the snapshot wants to write its temp file.
665        std::fs::create_dir_all(dir.path().join("namespaces.json.tmp")).unwrap();
666
667        let err = registry.create_namespace(
668            NamespaceId::new("tenant-a").unwrap(),
669            "a tenant",
670            "ops",
671            NamespaceQuota::default(),
672        );
673        assert!(
674            err.is_err(),
675            "a namespace that cannot be persisted must not be acknowledged"
676        );
677    }
678
679    /// The same for a quota change, which `PUT /namespaces/{name}/quota`
680    /// returns as though it had taken effect.
681    #[test]
682    fn changing_a_quota_is_durable_or_it_fails() {
683        let dir = tempfile::tempdir().unwrap();
684        let registry = NamespaceRegistry::open(dir.path()).unwrap();
685        registry
686            .create_namespace(
687                NamespaceId::new("tenant-a").unwrap(),
688                "a tenant",
689                "ops",
690                NamespaceQuota::default(),
691            )
692            .unwrap();
693
694        std::fs::create_dir_all(dir.path().join("namespaces.json.tmp")).unwrap();
695
696        let quota = NamespaceQuota {
697            max_series_count: 42,
698            ..NamespaceQuota::default()
699        };
700        assert!(
701            registry.update_quota("tenant-a", quota).is_err(),
702            "a quota that cannot be persisted reverts at the next restart, so \
703             reporting it as applied is a setting that is accepted and ignored"
704        );
705    }
706    use super::*;
707
708    #[test]
709    fn default_namespace_exists_on_creation() {
710        let registry = NamespaceRegistry::new();
711        assert!(registry.exists(NamespaceId::DEFAULT));
712        assert_eq!(registry.count(), 1);
713    }
714
715    #[test]
716    fn create_and_get_namespace() {
717        let registry = NamespaceRegistry::new();
718        let id = NamespaceId::new("team-alpha").unwrap();
719        let info = registry
720            .create_namespace(id, "Team Alpha", "admin", NamespaceQuota::default())
721            .unwrap();
722
723        assert_eq!(info.id.as_str(), "team-alpha");
724        assert_eq!(info.owner, "admin");
725        assert_eq!(registry.count(), 2);
726
727        let state = registry.get("team-alpha").unwrap();
728        assert_eq!(state.info.description, "Team Alpha");
729    }
730
731    #[test]
732    fn create_duplicate_fails() {
733        let registry = NamespaceRegistry::new();
734        let id = NamespaceId::new("my-ns").unwrap();
735        registry
736            .create_namespace(id, "first", "admin", NamespaceQuota::default())
737            .unwrap();
738
739        let id2 = NamespaceId::new("my-ns").unwrap();
740        let err = registry
741            .create_namespace(id2, "second", "admin", NamespaceQuota::default())
742            .unwrap_err();
743
744        assert!(matches!(err, TenantError::NamespaceAlreadyExists(_)));
745    }
746
747    #[test]
748    fn delete_namespace() {
749        let registry = NamespaceRegistry::new();
750        let id = NamespaceId::new("temp-ns").unwrap();
751        registry
752            .create_namespace(id, "temp", "admin", NamespaceQuota::default())
753            .unwrap();
754
755        assert_eq!(registry.count(), 2);
756        registry.delete_namespace("temp-ns").unwrap();
757        assert_eq!(registry.count(), 1);
758        assert!(!registry.exists("temp-ns"));
759    }
760
761    #[test]
762    fn cannot_delete_default_namespace() {
763        let registry = NamespaceRegistry::new();
764        let err = registry.delete_namespace("default").unwrap_err();
765        assert!(matches!(err, TenantError::InvalidConfig(_)));
766    }
767
768    #[test]
769    fn delete_nonexistent_fails() {
770        let registry = NamespaceRegistry::new();
771        let err = registry.delete_namespace("nope").unwrap_err();
772        assert!(matches!(err, TenantError::NamespaceNotFound(_)));
773    }
774
775    #[test]
776    fn list_namespaces() {
777        let registry = NamespaceRegistry::new();
778        let id = NamespaceId::new("ns-a").unwrap();
779        registry
780            .create_namespace(id, "A", "admin", NamespaceQuota::default())
781            .unwrap();
782        let id = NamespaceId::new("ns-b").unwrap();
783        registry
784            .create_namespace(id, "B", "admin", NamespaceQuota::default())
785            .unwrap();
786
787        let mut names = registry.list_names();
788        names.sort();
789        assert_eq!(names, vec!["default", "ns-a", "ns-b"]);
790    }
791
792    #[test]
793    fn update_quota() {
794        let registry = NamespaceRegistry::new();
795        let id = NamespaceId::new("quota-ns").unwrap();
796        registry
797            .create_namespace(id, "quota test", "admin", NamespaceQuota::default())
798            .unwrap();
799
800        let new_quota = NamespaceQuota {
801            max_series_count: 500,
802            max_ingestion_rate: 1000,
803            max_storage_bytes: 1024,
804            max_measurements: 10,
805            max_request_rps: 0,
806            max_request_burst: 0,
807        };
808
809        registry
810            .update_quota("quota-ns", new_quota.clone())
811            .unwrap();
812        let state = registry.get("quota-ns").unwrap();
813        assert_eq!(state.info.quota, new_quota);
814    }
815
816    #[test]
817    fn usage_tracking() {
818        let registry = NamespaceRegistry::new();
819        let id = NamespaceId::new("usage-ns").unwrap();
820        registry
821            .create_namespace(id, "usage test", "admin", NamespaceQuota::default())
822            .unwrap();
823
824        registry.increment_series("usage-ns", 100).unwrap();
825        registry.increment_storage("usage-ns", 4096).unwrap();
826        registry.increment_measurements("usage-ns", 5).unwrap();
827
828        let usage = registry.get_usage("usage-ns").unwrap();
829        assert_eq!(usage.series_count, 100);
830        assert_eq!(usage.storage_bytes, 4096);
831        assert_eq!(usage.measurements, 5);
832    }
833
834    #[test]
835    fn shared_registry_is_arc() {
836        let reg = shared_registry();
837        let reg2 = reg.clone();
838        let id = NamespaceId::new("shared-test").unwrap();
839        reg.create_namespace(id, "test", "admin", NamespaceQuota::default())
840            .unwrap();
841        assert!(reg2.exists("shared-test"));
842    }
843
844    #[test]
845    fn namespace_id_validation() {
846        assert!(NamespaceId::new("valid-name").is_ok());
847        assert!(NamespaceId::new("a123").is_ok());
848        assert!(NamespaceId::new("").is_err()); // empty
849        assert!(NamespaceId::new("Invalid").is_err()); // uppercase
850        assert!(NamespaceId::new("has space").is_err()); // space
851        assert!(NamespaceId::new("-leading").is_err()); // leading hyphen
852        assert!(NamespaceId::new("trailing-").is_err()); // trailing hyphen
853        assert!(NamespaceId::new("a".repeat(64)).is_err()); // too long
854        assert!(NamespaceId::new("a".repeat(63)).is_ok()); // max length ok
855    }
856
857    // ── Usage ratios ──────────────────────────────────────────
858
859    #[test]
860    fn usage_ratios_empty_registry() {
861        let registry = NamespaceRegistry::new();
862        let ratios = registry.usage_ratios();
863        // Only "default" namespace → 3 resources
864        assert_eq!(ratios.len(), 3);
865        for r in &ratios {
866            assert_eq!(r.namespace, "default");
867            assert!((r.ratio - 0.0).abs() < f64::EPSILON);
868        }
869    }
870
871    #[test]
872    fn usage_ratios_with_usage() {
873        let registry = NamespaceRegistry::new();
874        let id = NamespaceId::new("metrics-ns").unwrap();
875        let quota = NamespaceQuota {
876            max_series_count: 100,
877            max_ingestion_rate: 1000,
878            max_storage_bytes: 10_000,
879            max_measurements: 20,
880            max_request_rps: 0,
881            max_request_burst: 0,
882        };
883        registry
884            .create_namespace(id, "test", "admin", quota)
885            .unwrap();
886
887        registry.increment_series("metrics-ns", 50).unwrap();
888        registry.increment_storage("metrics-ns", 7500).unwrap();
889        registry.increment_measurements("metrics-ns", 10).unwrap();
890
891        let ratios = registry.usage_ratios();
892        let ns_ratios: Vec<_> = ratios
893            .iter()
894            .filter(|r| r.namespace == "metrics-ns")
895            .collect();
896        assert_eq!(ns_ratios.len(), 3);
897
898        let series = ns_ratios
899            .iter()
900            .find(|r| r.resource == "series_count")
901            .unwrap();
902        assert!((series.ratio - 0.5).abs() < f64::EPSILON);
903        assert_eq!(series.current, 50);
904        assert_eq!(series.limit, 100);
905
906        let storage = ns_ratios
907            .iter()
908            .find(|r| r.resource == "storage_bytes")
909            .unwrap();
910        assert!((storage.ratio - 0.75).abs() < f64::EPSILON);
911
912        let measurements = ns_ratios
913            .iter()
914            .find(|r| r.resource == "measurements")
915            .unwrap();
916        assert!((measurements.ratio - 0.5).abs() < f64::EPSILON);
917    }
918
919    #[test]
920    fn emit_usage_metrics_does_not_panic() {
921        let registry = NamespaceRegistry::new();
922        let id = NamespaceId::new("emit-ns").unwrap();
923        registry
924            .create_namespace(id, "test", "admin", NamespaceQuota::default())
925            .unwrap();
926        registry.increment_series("emit-ns", 42).unwrap();
927
928        // Should not panic (metrics crate handles missing recorder gracefully).
929        registry.emit_usage_metrics();
930    }
931
932    #[test]
933    fn usage_ratio_zero_limit() {
934        // current > 0, limit == 0 → over-quota → f64::MAX (JSON-safe)
935        let r = ratio(100, 0);
936        assert_eq!(r, f64::MAX);
937
938        // current == 0, limit == 0 → 0.0
939        let r = ratio(0, 0);
940        assert!((r - 0.0).abs() < f64::EPSILON);
941    }
942
943    #[test]
944    fn usage_ratio_over_quota() {
945        let r = ratio(200, 100);
946        assert!((r - 2.0).abs() < f64::EPSILON);
947    }
948
949    #[test]
950    fn open_creates_and_loads_snapshot() {
951        let tmp = tempfile::tempdir().unwrap();
952        let dir = tmp.path().join("namespaces");
953
954        // Open creates directory and initial snapshot
955        {
956            let reg = NamespaceRegistry::open(&dir).unwrap();
957            let id = NamespaceId::new("test-ns").unwrap();
958            reg.create_namespace(id, "Test", "admin", NamespaceQuota::default())
959                .unwrap();
960            assert_eq!(reg.count(), 2);
961        }
962
963        // Reopen loads the snapshot
964        {
965            let reg = NamespaceRegistry::open(&dir).unwrap();
966            assert_eq!(reg.count(), 2);
967            assert!(reg.exists("test-ns"));
968            assert!(reg.exists(NamespaceId::DEFAULT));
969        }
970    }
971
972    #[test]
973    fn open_delete_persists() {
974        let tmp = tempfile::tempdir().unwrap();
975        let dir = tmp.path().join("namespaces");
976
977        {
978            let reg = NamespaceRegistry::open(&dir).unwrap();
979            let id = NamespaceId::new("ephemeral").unwrap();
980            reg.create_namespace(id, "Ephemeral", "admin", NamespaceQuota::default())
981                .unwrap();
982            reg.delete_namespace("ephemeral").unwrap();
983        }
984
985        {
986            let reg = NamespaceRegistry::open(&dir).unwrap();
987            assert_eq!(reg.count(), 1);
988            assert!(!reg.exists("ephemeral"));
989        }
990    }
991
992    #[test]
993    fn open_quota_update_persists() {
994        let tmp = tempfile::tempdir().unwrap();
995        let dir = tmp.path().join("namespaces");
996
997        {
998            let reg = NamespaceRegistry::open(&dir).unwrap();
999            let id = NamespaceId::new("quotans").unwrap();
1000            reg.create_namespace(id, "Quota Test", "admin", NamespaceQuota::default())
1001                .unwrap();
1002            reg.update_quota(
1003                "quotans",
1004                NamespaceQuota {
1005                    max_series_count: 42,
1006                    ..NamespaceQuota::default()
1007                },
1008            )
1009            .unwrap();
1010        }
1011
1012        {
1013            let reg = NamespaceRegistry::open(&dir).unwrap();
1014            let state = reg.get("quotans").unwrap();
1015            assert_eq!(state.info.quota.max_series_count, 42);
1016        }
1017    }
1018}