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