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}