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}