Skip to main content

yeti_types/schema/
store.rs

1//! `@store` directive types.
2//!
3//! `@store` owns the storage-engine axis: durability tier, physical
4//! volume, eviction window, compression. Replaces the older
5//! `@table(storage:)` + `@table(expiration:)` args.
6//!
7//! The 4-tier `durability` knob is a single ordered scale —
8//! `memory < lossy < soft < strong` — that maps directly to
9//! `BackendType` + `RocksDB` WAL / sync flags. Single axis eliminates
10//! invalid combinations the older two-axis design admitted (e.g.
11//! `backend: "memory", durability: "strong"`).
12
13use serde::{Deserialize, Serialize};
14
15use crate::backend::BackendType;
16
17/// Durability tier from `@store(durability:)`.
18///
19/// Monotonically increasing in cost and decreasing in crash-loss window.
20/// See the design doc for the per-tier semantics and the
21/// crash-loss windows.
22// Variant order is durability-ascending (Memory < Lossy < Soft < Strong), so
23// the derived `Ord` lets a database take the MAX tier across its tables — the
24// RocksDB WAL is per-database, so one soft/strong table pulls the whole DB up.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
26pub enum DurabilityTier {
27    /// `"memory"` — `BackendType::InMemory`, nothing survives restart.
28    Memory,
29    /// `"lossy"` — `RocksDB` with WAL **disabled** (`disable_wal = true`).
30    /// Survives clean restart; loses up to a memtable flush window
31    /// (~minutes) on crash.
32    Lossy,
33    /// `"soft"` — `RocksDB` with WAL on, OS-controlled fsync
34    /// (`sync_writes = false`). Survives crash; loses ~seconds on
35    /// power-loss. `RocksDB` default.
36    Soft,
37    /// `"strong"` — `RocksDB` with WAL on + `WriteOptions::sync = true`
38    /// (per-write fsync). Zero loss; full cost.
39    Strong,
40}
41
42impl DurabilityTier {
43    /// Parse a `@store(durability:)` string. Case-insensitive.
44    #[must_use]
45    pub fn parse(s: &str) -> Option<Self> {
46        match s.to_ascii_lowercase().as_str() {
47            "memory" => Some(Self::Memory),
48            "lossy" => Some(Self::Lossy),
49            "soft" => Some(Self::Soft),
50            "strong" => Some(Self::Strong),
51            _ => None,
52        }
53    }
54
55    /// The `BackendType` this tier maps to.
56    #[must_use]
57    pub const fn backend_type(self) -> BackendType {
58        match self {
59            Self::Memory => BackendType::InMemory,
60            Self::Lossy | Self::Soft | Self::Strong => BackendType::Disk,
61        }
62    }
63
64    /// Whether this tier requires the WAL to be enabled at backend
65    /// open time. `Memory` has nothing to write to; `Lossy` opts out;
66    /// `Soft` + `Strong` need WAL on.
67    #[must_use]
68    pub const fn requires_wal(self) -> bool {
69        match self {
70            Self::Memory | Self::Lossy => false,
71            Self::Soft | Self::Strong => true,
72        }
73    }
74
75    /// Whether this tier requests per-write `fsync` via
76    /// `WriteOptions::sync = true`. Only `Strong` does.
77    #[must_use]
78    pub const fn sync_writes(self) -> bool {
79        matches!(self, Self::Strong)
80    }
81}
82
83impl Default for DurabilityTier {
84    /// Defaults to `Soft` — `RocksDB` on-disk, WAL on, no per-write
85    /// fsync. Matches yeti's default for user-data
86    /// tables (`BackendType::Disk` with WAL enabled).
87    fn default() -> Self {
88        Self::Soft
89    }
90}
91
92/// Named data-retention class from `@store(class:)`.
93///
94/// A retention class is an operator-facing handle for a canonical
95/// eviction window: rather than computing `evictAfter` in raw seconds, a
96/// schema declares `@store(class: "hot")` and the loader resolves the
97/// class to the seconds value that populates
98/// [`StoreConfig::evict_after`]. From that point the existing TTL
99/// machinery does the rest — the per-record [`expiration header`] and the
100/// `yeti.expiration_filter` compaction filter reclaim aged records with
101/// no class-specific reclaim code.
102///
103/// The canonical durations are fixed by this enum so a class means the
104/// same window across every table and every deployment. A schema that
105/// needs a non-canonical window keeps using raw `@store(evictAfter: N)`;
106/// the two are mutually exclusive (declaring both is a schema error).
107///
108/// [`expiration header`]: crate::backend
109// Variant order is window-ascending (Hot < Warm < AuditArchive) so the
110// derived `Ord` sorts classes by retention length.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
112pub enum RetentionClass {
113    /// `"hot"` — 30 days. Operational data with a short useful life:
114    /// recent events, session-scoped state, ephemeral caches that still
115    /// want crash durability.
116    Hot,
117    /// `"warm"` — 90 days. Data kept for a quarter: rolling analytics
118    /// windows, recent history surfaced in dashboards.
119    Warm,
120    /// `"audit-archive"` — 7 years (2557 days). Compliance-retained
121    /// records: audit logs, financial records, anything under a
122    /// multi-year statutory hold.
123    AuditArchive,
124}
125
126impl RetentionClass {
127    /// Seconds in one day.
128    const SECS_PER_DAY: u64 = 86_400;
129
130    /// Parse a `@store(class:)` string. Case-insensitive; the
131    /// `audit-archive` class also accepts the `audit_archive` spelling so
132    /// either separator works in a schema file.
133    #[must_use]
134    pub fn parse(s: &str) -> Option<Self> {
135        match s.to_ascii_lowercase().as_str() {
136            "hot" => Some(Self::Hot),
137            "warm" => Some(Self::Warm),
138            "audit-archive" | "audit_archive" => Some(Self::AuditArchive),
139            _ => None,
140        }
141    }
142
143    /// The canonical eviction window for this class, in seconds. This is
144    /// the value routed into [`StoreConfig::evict_after`], so a
145    /// class-tagged table flows through the identical TTL + compaction
146    /// path as a raw `evictAfter`.
147    #[must_use]
148    pub const fn evict_after_secs(self) -> u64 {
149        match self {
150            // 30 days.
151            Self::Hot => 30 * Self::SECS_PER_DAY,
152            // 90 days.
153            Self::Warm => 90 * Self::SECS_PER_DAY,
154            // 7 years (365 * 7 = 2555, plus 2 leap days = 2557).
155            Self::AuditArchive => 2557 * Self::SECS_PER_DAY,
156        }
157    }
158
159    /// The canonical name as it appears in a schema directive.
160    #[must_use]
161    pub const fn as_str(self) -> &'static str {
162        match self {
163            Self::Hot => "hot",
164            Self::Warm => "warm",
165            Self::AuditArchive => "audit-archive",
166        }
167    }
168}
169
170/// `@store` directive — storage-engine axis.
171///
172/// Bare `@store` (no args) inherits all defaults. Each field is
173/// optional; omitted fields fall back to platform defaults.
174#[derive(Debug, Clone, Default)]
175pub struct StoreConfig {
176    /// `@store(durability: ...)`. None = platform default
177    /// (currently `Soft` for user data; `Strong` for system
178    /// databases like `yeti-auth`, `yeti-queue`).
179    pub durability: Option<DurabilityTier>,
180    /// `@store(volume: ...)`. None = use the app's default volume.
181    /// When set: if the value starts with `/` or contains `://`,
182    /// treat as a literal path/URL; otherwise resolve through
183    /// `yeti-config.yaml storage.volumes`. Reserved value
184    /// `"adaptive"` is parsed but rejected at backend open until
185    /// Ships adaptive-tier storage.
186    pub volume: Option<String>,
187    /// `@store(evictAfter: N)` — seconds before a record is evicted.
188    /// Replaces the older `@table(expiration: N)` arg.
189    ///
190    /// When `@store(class:)` is set, the loader resolves the class to
191    /// its canonical window (see [`RetentionClass::evict_after_secs`])
192    /// and stores the result here, so every downstream consumer reads a
193    /// single `evict_after` seconds value regardless of how the schema
194    /// expressed retention. Declaring both `class` and a raw
195    /// `evictAfter` is a schema error — the two are mutually exclusive.
196    pub evict_after: Option<u64>,
197    /// `@store(class: "hot|warm|audit-archive")` — the named retention
198    /// class this table opted into, if any. Kept alongside the
199    /// resolved [`Self::evict_after`] so tooling can report the operator's
200    /// declared intent, not just the derived seconds.
201    pub retention_class: Option<RetentionClass>,
202    /// `@store(compression: bool)` — per-table override of the
203    /// deployment-wide compression setting.
204    pub compression: Option<bool>,
205    /// `@store(flushIntervalMs: N)` — bounded crash-loss window in ms.
206    /// Provides a fourth point on the durability scale
207    /// between `lossy` (~minutes) and `soft` (~seconds): periodic
208    /// `fsync()` of the WAL every N ms. Mutually exclusive with
209    /// `durability: "strong"` (already fsync-per-write) and
210    /// `durability: "lossy"` (no WAL to fsync). Runtime wiring to
211    /// `RocksDB`'s `wal_bytes_per_sync` / background `SyncWAL` is
212    /// deferred — this commit lands the directive surface so schemas
213    /// can declare it.
214    pub flush_interval_ms: Option<u64>,
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{DurabilityTier, RetentionClass};
220
221    #[test]
222    fn retention_class_parse_is_case_insensitive() {
223        assert_eq!(RetentionClass::parse("hot"), Some(RetentionClass::Hot));
224        assert_eq!(RetentionClass::parse("HOT"), Some(RetentionClass::Hot));
225        assert_eq!(RetentionClass::parse("Warm"), Some(RetentionClass::Warm));
226        assert_eq!(
227            RetentionClass::parse("audit-archive"),
228            Some(RetentionClass::AuditArchive)
229        );
230        // Underscore spelling is an accepted alias.
231        assert_eq!(
232            RetentionClass::parse("AUDIT_ARCHIVE"),
233            Some(RetentionClass::AuditArchive)
234        );
235        assert_eq!(RetentionClass::parse("frozen"), None);
236        assert_eq!(RetentionClass::parse(""), None);
237    }
238
239    #[test]
240    fn retention_class_canonical_windows() {
241        // 30 days / 90 days / 7 years (2557 days incl. leap days).
242        assert_eq!(RetentionClass::Hot.evict_after_secs(), 30 * 86_400);
243        assert_eq!(RetentionClass::Warm.evict_after_secs(), 90 * 86_400);
244        assert_eq!(
245            RetentionClass::AuditArchive.evict_after_secs(),
246            2557 * 86_400
247        );
248        // Windows are strictly ascending, matching the derived Ord.
249        assert!(RetentionClass::Hot < RetentionClass::Warm);
250        assert!(RetentionClass::Warm < RetentionClass::AuditArchive);
251        assert!(RetentionClass::Hot.evict_after_secs() < RetentionClass::Warm.evict_after_secs());
252        assert!(
253            RetentionClass::Warm.evict_after_secs()
254                < RetentionClass::AuditArchive.evict_after_secs()
255        );
256    }
257
258    #[test]
259    fn retention_class_as_str_round_trips_through_parse() {
260        for class in [
261            RetentionClass::Hot,
262            RetentionClass::Warm,
263            RetentionClass::AuditArchive,
264        ] {
265            assert_eq!(RetentionClass::parse(class.as_str()), Some(class));
266        }
267    }
268
269    #[test]
270    fn durability_default_is_soft() {
271        // Guards against an accidental default change while we're in here.
272        assert_eq!(DurabilityTier::default(), DurabilityTier::Soft);
273    }
274}