Skip to main content

fraiseql_server/usage/
aggregator.rs

1//! In-memory usage counter store.
2//!
3//! Counters are keyed by `(tenant_id, period_yyyy_mm, entity_type)` and stored
4//! as lock-free [`AtomicU64`] values inside a [`DashMap`].
5//!
6//! # Memory growth
7//!
8//! This is a **v1, unbounded** store: entries are never evicted. Growth is
9//! proportional to the product of `#tenants × #periods × #entity_types`.
10//! For a deployment with 100 tenants, 12 months retention, and 50 entity types
11//! that is at most 60 000 entries — approximately 5 MB.  Eviction policies and
12//! persistent storage are out of scope for v1.
13//!
14//! # Restarts
15//!
16//! Counters are **in-memory only**; they reset to zero on process restart.
17//! The aggregator is wired into `AppState` and exposed via `GET /api/v1/admin/usage`.
18
19use std::{
20    collections::HashMap,
21    sync::{
22        Arc, OnceLock,
23        atomic::{AtomicU64, Ordering},
24    },
25};
26
27use dashmap::DashMap;
28use serde::Serialize;
29
30use super::events::MutationAuditEvent;
31
32// ── Global aggregator ──────────────────────────────────────────────────────
33
34static GLOBAL_USAGE_AGGREGATOR: OnceLock<Arc<UsageAggregator>> = OnceLock::new();
35
36/// Return a reference to the process-wide [`UsageAggregator`].
37///
38/// Initialised on first call and shared for the lifetime of the process.
39/// Both [`MutationAuditLayer`](super::layer::MutationAuditLayer) (tracing
40/// subscriber) and the HTTP query endpoint use the same `Arc`, so counters
41/// written by the layer are immediately visible to the endpoint.
42///
43/// [`MutationAuditLayer`]: crate::usage::layer::MutationAuditLayer
44#[must_use]
45pub fn global_aggregator() -> &'static Arc<UsageAggregator> {
46    GLOBAL_USAGE_AGGREGATOR.get_or_init(|| Arc::new(UsageAggregator::new()))
47}
48
49// ── Period validation ──────────────────────────────────────────────────────
50
51/// Validate a usage period string in `"YYYY-MM"` format.
52///
53/// Returns `true` when the period is exactly seven ASCII characters with a
54/// `-` separator at index 4, a four-digit year, and a month in `01..=12`.
55///
56/// # Examples
57///
58/// ```
59/// use fraiseql_server::usage::aggregator::validate_period;
60///
61/// assert!(validate_period("2026-04"));
62/// assert!(!validate_period("2026-13")); // invalid month
63/// assert!(!validate_period("2026"));    // missing month
64/// assert!(!validate_period("26-04"));   // short year
65/// ```
66#[must_use]
67pub fn validate_period(period: &str) -> bool {
68    let bytes = period.as_bytes();
69    if bytes.len() != 7 || bytes[4] != b'-' {
70        return false;
71    }
72    let year_str = &period[..4];
73    let month_str = &period[5..];
74    if !year_str.bytes().all(|b| b.is_ascii_digit()) {
75        return false;
76    }
77    if !month_str.bytes().all(|b| b.is_ascii_digit()) {
78        return false;
79    }
80    let month: u8 = month_str.parse().unwrap_or(0);
81    (1..=12).contains(&month)
82}
83
84// ── UsageSummary ───────────────────────────────────────────────────────────
85
86/// Per-period mutation counts for a single tenant.
87///
88/// The `mutations` map has entity-type names as keys and the total mutation
89/// count for that entity type in the queried period as values.
90///
91/// Serialises to:
92/// ```json
93/// { "mutations": { "User": 42, "Order": 7 } }
94/// ```
95#[non_exhaustive]
96#[derive(Debug, Clone, Serialize)]
97pub struct UsageSummary {
98    /// Mutation counts keyed by entity type.
99    pub mutations: HashMap<String, u64>,
100}
101
102// ── UsageAggregator ────────────────────────────────────────────────────────
103
104/// Thread-safe, in-memory usage counter store with optional persistence backend.
105///
106/// Cheaply cloneable via [`Arc`] — all clones share the same underlying map.
107///
108/// ## Persistence
109///
110/// By default, the aggregator uses [`NoopBackend`] and counters are lost on
111/// restart.  Pass a [`RedisBackend`] (or any [`UsageBackend`] impl) to
112/// [`UsageAggregator::new_with_backend`] to enable durable storage.
113///
114/// ```rust,no_run
115/// # use fraiseql_server::usage::aggregator::{UsageAggregator, NoopBackend};
116/// # use std::sync::Arc;
117/// let agg = UsageAggregator::new_with_backend(Arc::new(NoopBackend));
118/// ```
119///
120/// # Example
121///
122/// ```
123/// use std::sync::Arc;
124/// use fraiseql_server::usage::aggregator::UsageAggregator;
125/// use fraiseql_server::usage::events::MutationAuditEvent;
126///
127/// let agg = Arc::new(UsageAggregator::new());
128/// let event = MutationAuditEvent::new("create_user", "User", "create", "acme", "2026-05");
129/// agg.record(&event);
130/// let summary = agg.query("acme", "2026-05");
131/// assert_eq!(summary.mutations["User"], 1);
132/// ```
133pub struct UsageAggregator {
134    /// Key: `(tenant_id, period_yyyy_mm, entity_type)`.
135    counters: DashMap<(String, String, String), AtomicU64>,
136    /// Optional persistence backend; defaults to [`NoopBackend`].
137    ///
138    /// Wrapped in `RwLock` so the backend can be swapped after initialization
139    /// (e.g. to upgrade from `NoopBackend` to `PostgresBackend` once the DB pool
140    /// is available at server startup, after the tracing subscriber has already
141    /// taken a reference via [`global_aggregator`]).
142    backend:  std::sync::RwLock<std::sync::Arc<dyn UsageBackend>>,
143}
144
145impl std::fmt::Debug for UsageAggregator {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        f.debug_struct("UsageAggregator")
148            .field("entry_count", &self.counters.len())
149            .finish_non_exhaustive()
150    }
151}
152
153impl UsageAggregator {
154    /// Create an empty aggregator with no persistence (in-memory only).
155    #[must_use]
156    pub fn new() -> Self {
157        Self {
158            counters: DashMap::new(),
159            backend:  std::sync::RwLock::new(std::sync::Arc::new(NoopBackend)),
160        }
161    }
162
163    /// Create an empty aggregator backed by the given persistence backend.
164    #[must_use]
165    pub fn new_with_backend(backend: std::sync::Arc<dyn UsageBackend>) -> Self {
166        Self {
167            counters: DashMap::new(),
168            backend:  std::sync::RwLock::new(backend),
169        }
170    }
171
172    /// Replace the persistence backend at runtime.
173    ///
174    /// Called during server startup to upgrade from the default [`NoopBackend`]
175    /// to a durable backend (e.g. [`PostgresBackend`]) once the database pool
176    /// is available.  Any in-flight in-memory counters are preserved.
177    ///
178    /// # Panics
179    ///
180    /// Panics if the backend `RwLock` is poisoned (unrecoverable state).
181    pub fn set_backend(&self, backend: std::sync::Arc<dyn UsageBackend>) {
182        *self.backend.write().expect("backend lock poisoned") = backend;
183    }
184
185    /// Record one mutation audit event, incrementing the appropriate counter.
186    ///
187    /// This method is lock-free on the hot path: it uses [`AtomicU64::fetch_add`]
188    /// after the initial shard lock in [`DashMap::entry`].
189    pub fn record(&self, event: &MutationAuditEvent) {
190        let key = (event.tenant_id.clone(), event.period.clone(), event.entity_type.clone());
191        self.counters
192            .entry(key)
193            .or_insert_with(|| AtomicU64::new(0))
194            .fetch_add(1, Ordering::Relaxed);
195    }
196
197    /// Return the usage summary for a tenant and period.
198    ///
199    /// Returns `UsageSummary { mutations: {} }` (never an error) when no events
200    /// have been recorded for the given `(tenant_id, period)` pair.
201    pub fn query(&self, tenant_id: &str, period: &str) -> UsageSummary {
202        let mut mutations: HashMap<String, u64> = HashMap::new();
203        for entry in &self.counters {
204            let (t, p, e) = entry.key();
205            if t == tenant_id && p == period {
206                mutations.insert(e.clone(), entry.value().load(Ordering::Relaxed));
207            }
208        }
209        UsageSummary { mutations }
210    }
211
212    /// Return the total number of distinct counter entries (for monitoring).
213    #[must_use]
214    pub fn entry_count(&self) -> usize {
215        self.counters.len()
216    }
217
218    /// Flush all current counters to the persistence backend.
219    ///
220    /// A no-op when using the default [`NoopBackend`].
221    ///
222    /// # Errors
223    ///
224    /// Propagates errors from the underlying [`UsageBackend::flush`].
225    ///
226    /// # Panics
227    ///
228    /// Panics if the backend `RwLock` is poisoned (unrecoverable state).
229    pub async fn flush_to_backend(&self) -> Result<(), String> {
230        let snapshot: HashMap<(String, String, String), u64> = self
231            .counters
232            .iter()
233            .map(|entry| (entry.key().clone(), entry.value().load(Ordering::Relaxed)))
234            .collect();
235        // Clone the Arc before awaiting so we don't hold the RwLock across await points.
236        let backend = self.backend.read().expect("backend lock poisoned").clone();
237        backend.flush(&snapshot).await
238    }
239
240    /// Load persisted counters from the backend into the in-memory map.
241    ///
242    /// Existing in-memory counters are **merged** (not replaced): the loaded
243    /// value is added to any in-flight in-memory count so that events recorded
244    /// between the last flush and this load are not lost.
245    ///
246    /// # Errors
247    ///
248    /// Propagates errors from the underlying [`UsageBackend::load`].
249    ///
250    /// # Panics
251    ///
252    /// Panics if the backend `RwLock` is poisoned (unrecoverable state).
253    pub async fn load_from_backend(&self) -> Result<(), String> {
254        // Clone the Arc before awaiting so we don't hold the RwLock across await points.
255        let backend = self.backend.read().expect("backend lock poisoned").clone();
256        let persisted = backend.load().await?;
257        for (key, count) in persisted {
258            self.counters
259                .entry(key)
260                .or_insert_with(|| AtomicU64::new(0))
261                .fetch_add(count, Ordering::Relaxed);
262        }
263        Ok(())
264    }
265}
266
267impl Default for UsageAggregator {
268    fn default() -> Self {
269        Self::new()
270    }
271}
272
273// ── Persistence backend ────────────────────────────────────────────────────
274
275/// Persistence backend for usage counters.
276///
277/// Implementations flush the aggregator's in-memory counters to a durable
278/// store and reload them on startup. The default [`NoopBackend`] is a no-op
279/// that preserves current in-memory-only behaviour.
280#[async_trait::async_trait]
281pub trait UsageBackend: Send + Sync {
282    /// Flush all current counter values to the backing store.
283    ///
284    /// The `counters` map has the form `(tenant_id, period_yyyy_mm, entity_type) → count`.
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the backing store is unavailable or the write fails.
289    async fn flush(
290        &self,
291        counters: &std::collections::HashMap<(String, String, String), u64>,
292    ) -> Result<(), String>;
293
294    /// Load all persisted counters from the backing store.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if the backing store is unavailable or the read fails.
299    async fn load(
300        &self,
301    ) -> Result<std::collections::HashMap<(String, String, String), u64>, String>;
302}
303
304/// No-op backend — counters are in-memory only, lost on restart.
305///
306/// This is the default when no persistence backend is configured.
307#[derive(Debug, Default)]
308pub struct NoopBackend;
309
310// ── Redis backend ──────────────────────────────────────────────────────────
311
312/// Redis-backed usage persistence.
313///
314/// Counters are stored as Redis hashes with the key pattern:
315/// `fraiseql:usage:{tenant_id}:{period_yyyy_mm}` where each hash field is an
316/// `entity_type` and the value is the cumulative mutation count.
317///
318/// Enable with the `redis-usage` Cargo feature.
319#[cfg(feature = "redis-usage")]
320#[derive(Debug, Clone)]
321pub struct RedisBackend {
322    client: ::redis::aio::ConnectionManager,
323}
324
325#[cfg(feature = "redis-usage")]
326impl RedisBackend {
327    /// Create a new Redis backend from an existing connection manager.
328    #[must_use]
329    pub const fn new(client: ::redis::aio::ConnectionManager) -> Self {
330        Self { client }
331    }
332
333    fn redis_key(tenant_id: &str, period: &str) -> String {
334        format!("fraiseql:usage:{tenant_id}:{period}")
335    }
336}
337
338#[cfg(feature = "redis-usage")]
339#[async_trait::async_trait]
340impl UsageBackend for RedisBackend {
341    async fn flush(
342        &self,
343        counters: &std::collections::HashMap<(String, String, String), u64>,
344    ) -> Result<(), String> {
345        use ::redis::AsyncCommands as _;
346
347        // Group counters by (tenant, period) so we can HSET per Redis key
348        let mut grouped: std::collections::HashMap<String, Vec<(&str, u64)>> =
349            std::collections::HashMap::new();
350        for ((tenant, period, entity), &count) in counters {
351            let key = Self::redis_key(tenant, period);
352            grouped.entry(key).or_default().push((entity.as_str(), count));
353        }
354
355        let mut conn = self.client.clone();
356        for (key, fields) in &grouped {
357            if !fields.is_empty() {
358                conn.hset_multiple::<_, _, _, ()>(key, fields.as_slice())
359                    .await
360                    .map_err(|e| format!("Redis flush error: {e}"))?;
361            }
362        }
363        Ok(())
364    }
365
366    async fn load(
367        &self,
368    ) -> Result<std::collections::HashMap<(String, String, String), u64>, String> {
369        use ::redis::AsyncCommands as _;
370
371        let mut conn = self.client.clone();
372
373        // SCAN for all keys matching fraiseql:usage:*
374        let mut result = std::collections::HashMap::new();
375        let keys: Vec<String> = conn
376            .keys("fraiseql:usage:*")
377            .await
378            .map_err(|e| format!("Redis load scan error: {e}"))?;
379
380        for key in &keys {
381            // Key format: fraiseql:usage:{tenant}:{period}
382            let parts: Vec<&str> = key.splitn(4, ':').collect();
383            if parts.len() != 4 {
384                continue;
385            }
386            let tenant = parts[2].to_owned();
387            let period = parts[3].to_owned();
388
389            let hash: std::collections::HashMap<String, u64> = conn
390                .hgetall(key)
391                .await
392                .map_err(|e| format!("Redis load hgetall error for {key}: {e}"))?;
393
394            for (entity, count) in hash {
395                result.insert((tenant.clone(), period.clone(), entity), count);
396            }
397        }
398        Ok(result)
399    }
400}
401
402#[async_trait::async_trait]
403impl UsageBackend for NoopBackend {
404    async fn flush(
405        &self,
406        _counters: &std::collections::HashMap<(String, String, String), u64>,
407    ) -> Result<(), String> {
408        Ok(())
409    }
410
411    async fn load(
412        &self,
413    ) -> Result<std::collections::HashMap<(String, String, String), u64>, String> {
414        Ok(std::collections::HashMap::new())
415    }
416}
417
418// ── PostgreSQL backend ─────────────────────────────────────────────────────
419
420/// PostgreSQL-backed usage persistence.
421///
422/// Counters are stored in a `fraiseql_usage_counters` table using UPSERT
423/// semantics. The schema is created automatically on [`PostgresBackend::new`]
424/// if it does not already exist.
425///
426/// The table schema is:
427///
428/// ```sql
429/// CREATE TABLE fraiseql_usage_counters (
430///     tenant_id   TEXT NOT NULL,
431///     period      TEXT NOT NULL,
432///     entity_type TEXT NOT NULL,
433///     count       BIGINT NOT NULL DEFAULT 0,
434///     updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
435///     PRIMARY KEY (tenant_id, period, entity_type)
436/// );
437/// ```
438#[derive(Debug, Clone)]
439pub struct PostgresBackend {
440    pool: sqlx::PgPool,
441}
442
443impl PostgresBackend {
444    /// Create a new PostgreSQL backend, ensuring the schema exists.
445    ///
446    /// # Errors
447    ///
448    /// Returns an error if the schema migration fails.
449    pub async fn new(pool: sqlx::PgPool) -> Result<Self, String> {
450        sqlx::query(
451            "CREATE TABLE IF NOT EXISTS fraiseql_usage_counters (
452                tenant_id   TEXT NOT NULL,
453                period      TEXT NOT NULL,
454                entity_type TEXT NOT NULL,
455                count       BIGINT NOT NULL DEFAULT 0,
456                updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
457                PRIMARY KEY (tenant_id, period, entity_type)
458            )",
459        )
460        .execute(&pool)
461        .await
462        .map_err(|e| format!("PostgresBackend schema migration failed: {e}"))?;
463
464        Ok(Self { pool })
465    }
466}
467
468#[async_trait::async_trait]
469impl UsageBackend for PostgresBackend {
470    async fn flush(
471        &self,
472        counters: &std::collections::HashMap<(String, String, String), u64>,
473    ) -> Result<(), String> {
474        if counters.is_empty() {
475            return Ok(());
476        }
477
478        // UPSERT each counter — SET count = excluded.count so repeated flushes
479        // of the same snapshot are idempotent (last writer wins per key).
480        for ((tenant_id, period, entity_type), &count) in counters {
481            sqlx::query(
482                "INSERT INTO fraiseql_usage_counters
483                    (tenant_id, period, entity_type, count, updated_at)
484                 VALUES ($1, $2, $3, $4, NOW())
485                 ON CONFLICT (tenant_id, period, entity_type)
486                 DO UPDATE SET count = EXCLUDED.count, updated_at = NOW()",
487            )
488            .bind(tenant_id)
489            .bind(period)
490            .bind(entity_type)
491            .bind(count.cast_signed())
492            .execute(&self.pool)
493            .await
494            .map_err(|e| format!("PostgresBackend flush error: {e}"))?;
495        }
496        Ok(())
497    }
498
499    async fn load(
500        &self,
501    ) -> Result<std::collections::HashMap<(String, String, String), u64>, String> {
502        let rows: Vec<(String, String, String, i64)> = sqlx::query_as(
503            "SELECT tenant_id, period, entity_type, count
504             FROM fraiseql_usage_counters",
505        )
506        .fetch_all(&self.pool)
507        .await
508        .map_err(|e| format!("PostgresBackend load error: {e}"))?;
509
510        let result = rows
511            .into_iter()
512            .map(|(tenant_id, period, entity_type, count)| {
513                ((tenant_id, period, entity_type), count.max(0).cast_unsigned())
514            })
515            .collect();
516
517        Ok(result)
518    }
519}