Skip to main content

razeri_plugin_contract/
storage_strategy.rs

1// Copyright (C) 2026 Gabriel Shea
2// Licensed under either of the Apache License, Version 2.0 or the MIT
3// license, at your option.
4
5//! Agnostic storage strategy types: partition, compression, index.
6//!
7//! These platform-neutral types are consumed by the `StorageRenderer` trait so
8//! each dialect can translate them to native SQL. No platform-specific data
9//! ever appears here.
10
11use serde::{Deserialize, Serialize};
12
13/// Agnostic storage strategy for a single materialized table.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct StoragePlan {
16    /// Partitioning strategy applied to the table.
17    pub partition: PartitionStrategy,
18    /// Compression strategy applied to the table's storage pages.
19    pub compression: CompressionStrategy,
20    /// Index strategy applied to the table (clustered, columnstore, Z-order, etc.).
21    pub index: IndexStrategy,
22}
23
24/// Partitioning strategy for a table.
25///
26/// `RangeByTimeDimension` is the only supported partitioning strategy; it
27/// requires the entity to expose a single `FieldRole::TimeDimension` field.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "strategy", rename_all = "snake_case")]
30pub enum PartitionStrategy {
31    /// No partitioning; the table is stored as a single segment.
32    None,
33    /// Range partition on a single `FieldRole::TimeDimension` field.
34    RangeByTimeDimension {
35        /// Name of the time-dimension field used as the partition key.
36        field: String,
37        /// Granularity of each partition range (day, week, month, etc.).
38        granularity: PartitionGranularity,
39        /// Optional retention policy that removes or archives old partitions.
40        #[serde(default, skip_serializing_if = "Option::is_none")]
41        retention: Option<RetentionPolicy>,
42        /// How many partitions to pre-create ahead of `now()` for the extend
43        /// procedure. Defaults: 7 for `Day`, 4 for `Week`, 2 for `Month`,
44        /// 1 for `Quarter`/`Year`.
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        look_ahead: Option<u32>,
47        /// Time-window sizing inputs used to auto-select an appropriate
48        /// granularity that fits within `partition_budget` (default 2000)
49        /// when the dialect imposes a per-table partition cap (e.g. SQL
50        /// Server's 5000-partition limit).
51        ///
52        /// These fields are advisory: when present, the materializer's
53        /// `select_grain` helper uses them to coarsen the granularity from
54        /// the requested grain (e.g. `Day` → `Week` → `Month` → `Year`)
55        /// until `(history_days + forecast_days) / grain ≤ partition_budget`.
56        ///
57        /// Authoring sources (in priority order):
58        ///   1. Explicit values in the design YAML (highest authority).
59        ///   2. AI wizard inferring from `Strand` profiling
60        ///      (`min_value`/`max_value`).
61        ///   3. Defaults baked into the materializer.
62        #[serde(default, skip_serializing_if = "PartitionWindow::is_default")]
63        window: PartitionWindow,
64    },
65}
66
67/// Time-window sizing inputs that drive automatic granularity selection
68/// and forecast-aware partition extension.
69#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
70pub struct PartitionWindow {
71    /// Expected historical horizon in days. Combined with `forecast_days`
72    /// to compute the total time span the partition function must cover.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub history_days: Option<u32>,
75
76    /// Expected forward horizon in days (forecast / projection data).
77    /// Causes the `extend_proc` to pre-allocate partitions far enough
78    /// ahead to receive future-dated rows.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub forecast_days: Option<u32>,
81
82    /// Maximum number of partitions before the materializer auto-coarsens
83    /// the granularity. Defaults to 2000 (well under SQL Server's 5000
84    /// hard limit, leaving headroom for years of `extend_proc` runs).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub partition_budget: Option<u32>,
87}
88
89impl PartitionWindow {
90    /// Returns `true` when no sizing fields are populated (used by serde
91    /// `skip_serializing_if` to keep YAML/JSON outputs tidy).
92    #[must_use]
93    pub fn is_default(&self) -> bool {
94        self == &Self::default()
95    }
96}
97
98/// Granularity of each partition range for time-based partitioning.
99///
100/// Coarser granularities reduce partition count but increase scan cost per query.
101/// Use [`PartitionGranularity::next_coarser`] to step down when the dialect's
102/// partition limit would be exceeded.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum PartitionGranularity {
106    /// One partition per calendar day.
107    Day,
108    /// One partition per ISO week (Monday–Sunday).
109    Week,
110    /// One partition per calendar month.
111    Month,
112    /// One partition per calendar quarter.
113    Quarter,
114    /// One partition per calendar year.
115    Year,
116}
117
118impl PartitionGranularity {
119    /// Returns the next coarser granularity (`Day` → `Week` → `Month` →
120    /// `Quarter` → `Year`), or `None` when already at `Year`.
121    ///
122    /// Drives the auto-coarsening loop in the materializer's `select_grain`
123    /// helper when the requested grain would exceed `partition_budget`.
124    #[must_use]
125    pub const fn next_coarser(self) -> Option<Self> {
126        match self {
127            Self::Day => Some(Self::Week),
128            Self::Week => Some(Self::Month),
129            Self::Month => Some(Self::Quarter),
130            Self::Quarter => Some(Self::Year),
131            Self::Year => None,
132        }
133    }
134
135    /// Approximate number of days per granule. Used for partition-count
136    /// arithmetic in `select_grain`.
137    #[must_use]
138    pub const fn approx_days(self) -> u32 {
139        match self {
140            Self::Day => 1,
141            Self::Week => 7,
142            Self::Month => 30,
143            Self::Quarter => 92,
144            Self::Year => 365,
145        }
146    }
147}
148
149/// Policy for removing or archiving old partitions during the retain procedure.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct RetentionPolicy {
152    /// Number of granules (days/weeks/etc.) to keep before the retain
153    /// procedure removes older partitions.
154    pub keep: u32,
155    /// Whether the retain procedure DROPs (destructive) or SWITCHes/DETACHes
156    /// to a sibling archive table.
157    #[serde(default)]
158    pub action: RetentionAction,
159}
160
161/// How old partitions are disposed of when the retain procedure runs.
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum RetentionAction {
165    /// Destructively `DROP` the partition (or detach and delete).
166    #[default]
167    Drop,
168    /// Move the partition to a sibling archive table instead of dropping it.
169    Archive,
170}
171
172/// Compression strategy for a materialized table.
173///
174/// Platform renderers translate these variants into the appropriate dialect-native
175/// `WITH (DATA_COMPRESSION = ...)`, `USING DELTA`, or `CODEC(...)` syntax.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(tag = "kind", rename_all = "snake_case")]
178pub enum CompressionStrategy {
179    /// No compression applied.
180    None,
181    /// SQL Server page-level compression.
182    Page,
183    /// SQL Server row-level compression.
184    Row,
185    /// Columnstore compression (columnar layout; SQL Server, Synapse, Azure Fabric).
186    Columnstore,
187    /// Columnstore archival compression (higher ratio, slower scans).
188    ColumnstoreArchive,
189    /// LZ4 block compression (Delta Lake, `ClickHouse`).
190    Lz4,
191    /// Zstd compression (Delta Lake, `ClickHouse`, `DuckDB`).
192    Zstd {
193        /// Compression level (1 = fastest, higher = smaller; dialect-specific range).
194        #[serde(default, skip_serializing_if = "Option::is_none")]
195        level: Option<u8>,
196    },
197    /// Snappy block compression (Parquet, Delta Lake).
198    Snappy,
199}
200
201/// Index strategy for a materialized table.
202///
203/// Platform renderers translate these variants into dialect-native DDL
204/// (`CREATE CLUSTERED COLUMNSTORE INDEX`, `CLUSTER BY`, `Z-ORDER BY`, etc.).
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(tag = "kind", rename_all = "snake_case")]
207pub enum IndexStrategy {
208    /// No explicit index DDL generated.
209    None,
210    /// Clustered columnstore index (SQL Server, Synapse, Fabric).
211    ClusteredColumnstore,
212    /// Non-clustered columnstore index (SQL Server).
213    NonClusteredColumnstore,
214    /// Delta-style `Z-ORDER` on the listed columns.
215    ZOrder {
216        /// Columns to include in the Z-order optimization.
217        columns: Vec<String>,
218    },
219    /// `Snowflake` `CLUSTER BY` (or Delta Liquid Clustering, `ClickHouse` `ORDER BY`).
220    ClusterBy {
221        /// Columns to include in the cluster key.
222        columns: Vec<String>,
223    },
224}
225
226impl StoragePlan {
227    /// Returns a plan that produces no SQL on any dialect.
228    #[must_use]
229    pub fn none() -> Self {
230        Self {
231            partition: PartitionStrategy::None,
232            compression: CompressionStrategy::None,
233            index: IndexStrategy::None,
234        }
235    }
236
237    /// Returns `true` if the plan declares a partitioning strategy other than `None`.
238    #[must_use]
239    pub fn is_partitioned(&self) -> bool {
240        !matches!(self.partition, PartitionStrategy::None)
241    }
242}
243
244/// Names of the maintenance procedures generated for a partitioned entity.
245///
246/// Populated by the materializer and consumed by orchestration so
247/// schedulers know what to invoke for sliding-window upkeep.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct PartitionMaintenance {
250    /// Name of the procedure that pre-creates upcoming partitions.
251    pub extend_proc_name: String,
252    /// Name of the procedure that drops/archives expired partitions
253    /// (only present when a `RetentionPolicy` is configured).
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub retain_proc_name: Option<String>,
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn storage_plan_none_is_empty() {
264        let plan = StoragePlan::none();
265        assert!(!plan.is_partitioned());
266        assert!(matches!(plan.partition, PartitionStrategy::None));
267        assert!(matches!(plan.compression, CompressionStrategy::None));
268        assert!(matches!(plan.index, IndexStrategy::None));
269    }
270
271    #[test]
272    fn range_partition_round_trips_via_yaml() {
273        let plan = StoragePlan {
274            partition: PartitionStrategy::RangeByTimeDimension {
275                field: "sale_date".into(),
276                granularity: PartitionGranularity::Day,
277                retention: Some(RetentionPolicy { keep: 730, action: RetentionAction::Drop }),
278                look_ahead: Some(7),
279                window: PartitionWindow::default(),
280            },
281            compression: CompressionStrategy::Page,
282            index: IndexStrategy::ClusteredColumnstore,
283        };
284        let yaml = serde_yaml::to_string(&plan).unwrap();
285        let parsed: StoragePlan = serde_yaml::from_str(&yaml).unwrap();
286        assert_eq!(plan, parsed);
287        assert!(plan.is_partitioned());
288    }
289
290    #[test]
291    fn partition_window_default_is_skipped_in_yaml() {
292        let plan = StoragePlan {
293            partition: PartitionStrategy::RangeByTimeDimension {
294                field: "sale_date".into(),
295                granularity: PartitionGranularity::Day,
296                retention: None,
297                look_ahead: None,
298                window: PartitionWindow::default(),
299            },
300            compression: CompressionStrategy::None,
301            index: IndexStrategy::None,
302        };
303        let yaml = serde_yaml::to_string(&plan).unwrap();
304        assert!(!yaml.contains("window"), "default window should not serialize: {yaml}");
305    }
306
307    #[test]
308    fn partition_window_round_trips_via_yaml() {
309        let window = PartitionWindow {
310            history_days: Some(1825),
311            forecast_days: Some(365),
312            partition_budget: Some(2000),
313        };
314        let plan = StoragePlan {
315            partition: PartitionStrategy::RangeByTimeDimension {
316                field: "sale_date".into(),
317                granularity: PartitionGranularity::Day,
318                retention: None,
319                look_ahead: None,
320                window: window.clone(),
321            },
322            compression: CompressionStrategy::None,
323            index: IndexStrategy::None,
324        };
325        let yaml = serde_yaml::to_string(&plan).unwrap();
326        let parsed: StoragePlan = serde_yaml::from_str(&yaml).unwrap();
327        assert_eq!(plan, parsed);
328        let PartitionStrategy::RangeByTimeDimension { window: w, .. } = parsed.partition else {
329            unreachable!()
330        };
331        assert_eq!(w, window);
332    }
333
334    #[test]
335    fn next_coarser_walks_to_year_then_stops() {
336        assert_eq!(PartitionGranularity::Day.next_coarser(), Some(PartitionGranularity::Week));
337        assert_eq!(PartitionGranularity::Week.next_coarser(), Some(PartitionGranularity::Month));
338        assert_eq!(PartitionGranularity::Month.next_coarser(), Some(PartitionGranularity::Quarter));
339        assert_eq!(PartitionGranularity::Quarter.next_coarser(), Some(PartitionGranularity::Year));
340        assert_eq!(PartitionGranularity::Year.next_coarser(), None);
341    }
342
343    #[test]
344    fn zstd_with_level_serializes() {
345        let c = CompressionStrategy::Zstd { level: Some(3) };
346        let json = serde_json::to_string(&c).unwrap();
347        assert!(json.contains("\"zstd\""));
348        assert!(json.contains('3'));
349    }
350
351    #[test]
352    fn retention_action_defaults_to_drop() {
353        let yaml = "keep: 90\n";
354        let policy: RetentionPolicy = serde_yaml::from_str(yaml).unwrap();
355        assert_eq!(policy.keep, 90);
356        assert_eq!(policy.action, RetentionAction::Drop);
357    }
358
359    #[test]
360    fn z_order_round_trips_via_json() {
361        let idx = IndexStrategy::ZOrder { columns: vec!["sale_date".into(), "store_id".into()] };
362        let json = serde_json::to_string(&idx).unwrap();
363        let parsed: IndexStrategy = serde_json::from_str(&json).unwrap();
364        assert_eq!(idx, parsed);
365    }
366
367    #[test]
368    fn cluster_by_round_trips_via_json() {
369        let idx = IndexStrategy::ClusterBy { columns: vec!["created_at".into()] };
370        let json = serde_json::to_string(&idx).unwrap();
371        let parsed: IndexStrategy = serde_json::from_str(&json).unwrap();
372        assert_eq!(idx, parsed);
373    }
374
375    #[test]
376    fn partition_strategy_none_serializes_with_tag() {
377        let p = PartitionStrategy::None;
378        let json = serde_json::to_string(&p).unwrap();
379        assert!(json.contains("\"none\""));
380    }
381
382    #[test]
383    fn partition_maintenance_round_trips() {
384        let m = PartitionMaintenance {
385            extend_proc_name: "core_sales_partition_extend".into(),
386            retain_proc_name: Some("core_sales_partition_retain".into()),
387        };
388        let json = serde_json::to_string(&m).unwrap();
389        let parsed: PartitionMaintenance = serde_json::from_str(&json).unwrap();
390        assert_eq!(m, parsed);
391
392        let no_retain = PartitionMaintenance {
393            extend_proc_name: "core_orders_partition_extend".into(),
394            retain_proc_name: None,
395        };
396        let json = serde_json::to_string(&no_retain).unwrap();
397        assert!(!json.contains("retain_proc_name"));
398    }
399}