Skip to main content

fraiseql_core/cache/
fact_table_version.rs

1//! Fact table versioning for aggregation query caching.
2//!
3//! This module provides multiple strategies for caching aggregation queries on fact tables.
4//! Users can choose the strategy that best fits their data pipeline and freshness requirements.
5//!
6//! # Strategies
7//!
8//! | Strategy | Best For | Trade-off |
9//! |----------|----------|-----------|
10//! | `Disabled` | Real-time accuracy | No caching benefit |
11//! | `VersionTable` | ETL/batch loads | Requires version bump discipline |
12//! | `TimeBased` | Dashboards with acceptable lag | May serve stale data |
13//! | `SchemaVersion` | Append-only/immutable facts | Only invalidates on deploy |
14//!
15//! # Version Table Schema
16//!
17//! When using `VersionTable` strategy, create the following table:
18//!
19//! ```sql
20//! CREATE TABLE IF NOT EXISTS tf_versions (
21//!     table_name TEXT PRIMARY KEY,
22//!     version BIGINT NOT NULL DEFAULT 1,
23//!     updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
24//! );
25//!
26//! -- Helper function to bump version
27//! CREATE OR REPLACE FUNCTION bump_tf_version(p_table_name TEXT)
28//! RETURNS BIGINT AS $$
29//! DECLARE
30//!     new_version BIGINT;
31//! BEGIN
32//!     INSERT INTO tf_versions (table_name, version, updated_at)
33//!     VALUES (p_table_name, 1, NOW())
34//!     ON CONFLICT (table_name) DO UPDATE
35//!     SET version = tf_versions.version + 1,
36//!         updated_at = NOW()
37//!     RETURNING version INTO new_version;
38//!     RETURN new_version;
39//! END;
40//! $$ LANGUAGE plpgsql;
41//!
42//! -- Optional: Auto-bump trigger (adds write overhead)
43//! CREATE OR REPLACE FUNCTION tf_auto_version_bump()
44//! RETURNS TRIGGER AS $$
45//! BEGIN
46//!     PERFORM bump_tf_version(TG_TABLE_NAME);
47//!     RETURN NULL;
48//! END;
49//! $$ LANGUAGE plpgsql;
50//!
51//! -- Apply to specific fact tables:
52//! CREATE TRIGGER tf_sales_version_bump
53//! AFTER INSERT OR UPDATE OR DELETE ON tf_sales
54//! FOR EACH STATEMENT EXECUTE FUNCTION tf_auto_version_bump();
55//! ```
56//!
57//! # Example Configuration
58//!
59//! ```rust
60//! use fraiseql_core::cache::fact_table_version::{
61//!     FactTableVersionStrategy, FactTableCacheConfig,
62//! };
63//! use std::collections::HashMap;
64//!
65//! let mut config = FactTableCacheConfig::default();
66//!
67//! // ETL-loaded sales data - use version table
68//! config.set_strategy("tf_sales", FactTableVersionStrategy::VersionTable);
69//!
70//! // Real-time page views - cache for 5 minutes
71//! config.set_strategy("tf_page_views", FactTableVersionStrategy::TimeBased {
72//!     ttl_seconds: 300,
73//! });
74//!
75//! // Historical exchange rates - never changes
76//! config.set_strategy("tf_historical_rates", FactTableVersionStrategy::SchemaVersion);
77//! ```
78
79use std::{
80    collections::HashMap,
81    time::{Duration, Instant},
82};
83
84use serde::{Deserialize, Serialize};
85
86/// Versioning strategy for fact table aggregation caching.
87///
88/// Different strategies offer different trade-offs between cache hit rate,
89/// data freshness, and operational complexity.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(tag = "type", rename_all = "snake_case")]
92#[non_exhaustive]
93pub enum FactTableVersionStrategy {
94    /// No caching for aggregations (always query database).
95    ///
96    /// Use when: Real-time accuracy is required.
97    /// Trade-off: No caching benefit, every query hits database.
98    Disabled,
99
100    /// Read version from `tf_versions` table.
101    ///
102    /// Cache key includes the version number, so when version bumps,
103    /// old cache entries are automatically ignored.
104    ///
105    /// Use when: Data is loaded via ETL/batch processes that can bump versions.
106    /// Trade-off: Requires discipline to bump version after data changes.
107    ///
108    /// # Version Bumping
109    ///
110    /// After loading data, call:
111    /// ```sql
112    /// SELECT bump_tf_version('tf_sales');
113    /// ```
114    ///
115    /// Or use triggers for automatic bumping (adds write overhead).
116    VersionTable,
117
118    /// Time-based TTL caching.
119    ///
120    /// Cache entries expire after the specified duration regardless of
121    /// whether the underlying data has changed.
122    ///
123    /// Use when: Some staleness is acceptable (e.g., dashboards).
124    /// Trade-off: May serve stale data within TTL window.
125    TimeBased {
126        /// Cache TTL in seconds.
127        ttl_seconds: u64,
128    },
129
130    /// Use schema version only (invalidate on deployment).
131    ///
132    /// Cache is only invalidated when the schema version changes,
133    /// which typically happens during deployments.
134    ///
135    /// Use when: Fact table data is immutable or append-only and
136    /// queries always filter to recent data.
137    /// Trade-off: Stale data until next deployment.
138    SchemaVersion,
139}
140
141impl Default for FactTableVersionStrategy {
142    /// Default strategy is `Disabled` for safety (no stale data).
143    fn default() -> Self {
144        Self::Disabled
145    }
146}
147
148impl FactTableVersionStrategy {
149    /// Create a time-based strategy with the given TTL.
150    ///
151    /// A TTL of `0` means nothing is ever fresh: the strategy behaves as
152    /// [`Disabled`](Self::Disabled) — no version component is generated and
153    /// nothing is cached.
154    #[must_use]
155    pub const fn time_based(ttl_seconds: u64) -> Self {
156        Self::TimeBased { ttl_seconds }
157    }
158
159    /// Check if caching is enabled for this strategy.
160    #[must_use]
161    pub const fn is_caching_enabled(&self) -> bool {
162        !matches!(self, Self::Disabled | Self::TimeBased { ttl_seconds: 0 })
163    }
164
165    /// Get TTL for time-based strategy, if applicable.
166    #[must_use]
167    pub const fn ttl_seconds(&self) -> Option<u64> {
168        match self {
169            Self::TimeBased { ttl_seconds } => Some(*ttl_seconds),
170            _ => None,
171        }
172    }
173}
174
175/// Configuration for fact table aggregation caching.
176///
177/// Maps fact table names to their versioning strategies.
178#[derive(Debug, Clone, Default, Serialize, Deserialize)]
179pub struct FactTableCacheConfig {
180    /// Default strategy for tables not explicitly configured.
181    #[serde(default)]
182    pub default_strategy: FactTableVersionStrategy,
183
184    /// Per-table strategy overrides.
185    #[serde(default)]
186    pub table_strategies: HashMap<String, FactTableVersionStrategy>,
187}
188
189impl FactTableCacheConfig {
190    /// Create a new config with the given default strategy.
191    #[must_use]
192    pub fn with_default(strategy: FactTableVersionStrategy) -> Self {
193        Self {
194            default_strategy: strategy,
195            table_strategies: HashMap::new(),
196        }
197    }
198
199    /// Set strategy for a specific table.
200    pub fn set_strategy(&mut self, table_name: &str, strategy: FactTableVersionStrategy) {
201        self.table_strategies.insert(table_name.to_string(), strategy);
202    }
203
204    /// Get strategy for a table (falls back to default).
205    #[must_use]
206    pub fn get_strategy(&self, table_name: &str) -> &FactTableVersionStrategy {
207        self.table_strategies.get(table_name).unwrap_or(&self.default_strategy)
208    }
209
210    /// Check if caching is enabled for a table.
211    #[must_use]
212    pub fn is_caching_enabled(&self, table_name: &str) -> bool {
213        self.get_strategy(table_name).is_caching_enabled()
214    }
215}
216
217/// Cached version information for a fact table.
218#[derive(Debug, Clone)]
219pub struct CachedVersion {
220    /// The version number.
221    pub version:    i64,
222    /// When the version was fetched.
223    pub fetched_at: Instant,
224}
225
226impl CachedVersion {
227    /// Create a new cached version.
228    #[must_use]
229    pub fn new(version: i64) -> Self {
230        Self {
231            version,
232            fetched_at: Instant::now(),
233        }
234    }
235
236    /// Check if the cached version is still fresh.
237    ///
238    /// Versions are cached for a short time (default 1 second) to avoid
239    /// hammering the `tf_versions` table on every query.
240    #[must_use]
241    pub fn is_fresh(&self, max_age: Duration) -> bool {
242        self.fetched_at.elapsed() < max_age
243    }
244}
245
246/// Version provider for fact tables.
247///
248/// Fetches and caches version numbers from the `tf_versions` table.
249#[derive(Debug)]
250pub struct FactTableVersionProvider {
251    /// Cached versions (`table_name` -> version).
252    versions:          std::sync::RwLock<HashMap<String, CachedVersion>>,
253    /// How long to cache version lookups.
254    version_cache_ttl: Duration,
255}
256
257impl Default for FactTableVersionProvider {
258    fn default() -> Self {
259        Self::new(Duration::from_secs(1))
260    }
261}
262
263impl FactTableVersionProvider {
264    /// Create a new version provider.
265    ///
266    /// # Arguments
267    ///
268    /// * `version_cache_ttl` - How long to cache version lookups (default 1 second)
269    #[must_use]
270    pub fn new(version_cache_ttl: Duration) -> Self {
271        Self {
272            versions: std::sync::RwLock::new(HashMap::new()),
273            version_cache_ttl,
274        }
275    }
276
277    /// Get cached version if still fresh, otherwise return None.
278    #[must_use]
279    pub fn get_cached_version(&self, table_name: &str) -> Option<i64> {
280        let versions = self.versions.read().ok()?;
281        let cached = versions.get(table_name)?;
282        if cached.is_fresh(self.version_cache_ttl) {
283            Some(cached.version)
284        } else {
285            None
286        }
287    }
288
289    /// Update cached version for a table.
290    pub fn set_cached_version(&self, table_name: &str, version: i64) {
291        if let Ok(mut versions) = self.versions.write() {
292            versions.insert(table_name.to_string(), CachedVersion::new(version));
293        }
294    }
295
296    /// Clear cached version for a table.
297    pub fn clear_cached_version(&self, table_name: &str) {
298        if let Ok(mut versions) = self.versions.write() {
299            versions.remove(table_name);
300        }
301    }
302
303    /// Clear all cached versions.
304    pub fn clear_all(&self) {
305        if let Ok(mut versions) = self.versions.write() {
306            versions.clear();
307        }
308    }
309}
310
311/// Generate cache key component for a fact table based on its versioning strategy.
312///
313/// This function returns the version component to include in cache keys.
314///
315/// # Returns
316///
317/// - `Some(version_string)` - Version to include in cache key
318/// - `None` - Caching disabled, should not cache
319#[must_use]
320pub fn generate_version_key_component(
321    _table_name: &str,
322    strategy: &FactTableVersionStrategy,
323    table_version: Option<i64>,
324    schema_version: &str,
325) -> Option<String> {
326    match strategy {
327        // A zero TTL means nothing is ever fresh — same contract as Disabled (#784).
328        FactTableVersionStrategy::Disabled
329        | FactTableVersionStrategy::TimeBased { ttl_seconds: 0 } => None,
330
331        FactTableVersionStrategy::VersionTable => {
332            // Require a version from tf_versions table
333            table_version.map(|v| format!("tv:{v}"))
334        },
335
336        FactTableVersionStrategy::TimeBased { ttl_seconds } => {
337            // Use time bucket as version (floor to TTL boundary)
338            let now = std::time::SystemTime::now()
339                .duration_since(std::time::UNIX_EPOCH)
340                .unwrap_or_default()
341                .as_secs();
342            let bucket = now / ttl_seconds;
343            Some(format!("tb:{bucket}"))
344        },
345
346        FactTableVersionStrategy::SchemaVersion => {
347            // Use schema version only
348            Some(format!("sv:{schema_version}"))
349        },
350    }
351}
352
353/// SQL to query version from `tf_versions` table.
354pub const VERSION_TABLE_QUERY: &str = r"
355    SELECT version FROM tf_versions WHERE table_name = $1
356";
357
358/// SQL to create the `tf_versions` table and helper functions.
359pub const VERSION_TABLE_SCHEMA: &str = r"
360-- Fact table version tracking for aggregation cache
361CREATE TABLE IF NOT EXISTS tf_versions (
362    table_name TEXT PRIMARY KEY,
363    version BIGINT NOT NULL DEFAULT 1,
364    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
365);
366
367-- Index for fast lookups
368CREATE INDEX IF NOT EXISTS idx_tf_versions_updated_at ON tf_versions (updated_at);
369
370-- Helper function to bump version (call after data loads)
371CREATE OR REPLACE FUNCTION bump_tf_version(p_table_name TEXT)
372RETURNS BIGINT AS $$
373DECLARE
374    new_version BIGINT;
375BEGIN
376    INSERT INTO tf_versions (table_name, version, updated_at)
377    VALUES (p_table_name, 1, NOW())
378    ON CONFLICT (table_name) DO UPDATE
379    SET version = tf_versions.version + 1,
380        updated_at = NOW()
381    RETURNING version INTO new_version;
382    RETURN new_version;
383END;
384$$ LANGUAGE plpgsql;
385
386-- Optional: Trigger function for automatic version bumping
387-- Note: This adds overhead to every INSERT/UPDATE/DELETE
388CREATE OR REPLACE FUNCTION tf_auto_version_bump()
389RETURNS TRIGGER AS $$
390BEGIN
391    PERFORM bump_tf_version(TG_TABLE_NAME);
392    RETURN NULL;
393END;
394$$ LANGUAGE plpgsql;
395
396-- Example: Apply auto-bump trigger to a fact table
397-- CREATE TRIGGER tf_sales_version_bump
398-- AFTER INSERT OR UPDATE OR DELETE ON tf_sales
399-- FOR EACH STATEMENT EXECUTE FUNCTION tf_auto_version_bump();
400";