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";