Skip to main content

razeri_plugin_contract/
storage_strategy.rs

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