1use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct StoragePlan {
16 pub partition: PartitionStrategy,
18 pub compression: CompressionStrategy,
20 pub index: IndexStrategy,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(tag = "strategy", rename_all = "snake_case")]
30pub enum PartitionStrategy {
31 None,
33 RangeByTimeDimension {
35 field: String,
37 granularity: PartitionGranularity,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 retention: Option<RetentionPolicy>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
46 look_ahead: Option<u32>,
47 #[serde(default, skip_serializing_if = "PartitionWindow::is_default")]
63 window: PartitionWindow,
64 },
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
70pub struct PartitionWindow {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub history_days: Option<u32>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub forecast_days: Option<u32>,
81
82 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub partition_budget: Option<u32>,
87}
88
89impl PartitionWindow {
90 #[must_use]
93 pub fn is_default(&self) -> bool {
94 self == &Self::default()
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum PartitionGranularity {
106 Day,
108 Week,
110 Month,
112 Quarter,
114 Year,
116}
117
118impl PartitionGranularity {
119 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct RetentionPolicy {
152 pub keep: u32,
155 #[serde(default)]
158 pub action: RetentionAction,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
163#[serde(rename_all = "snake_case")]
164pub enum RetentionAction {
165 #[default]
167 Drop,
168 Archive,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(tag = "kind", rename_all = "snake_case")]
178pub enum CompressionStrategy {
179 None,
181 Page,
183 Row,
185 Columnstore,
187 ColumnstoreArchive,
189 Lz4,
191 Zstd {
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 level: Option<u8>,
196 },
197 Snappy,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(tag = "kind", rename_all = "snake_case")]
207pub enum IndexStrategy {
208 None,
210 ClusteredColumnstore,
212 NonClusteredColumnstore,
214 ZOrder {
216 columns: Vec<String>,
218 },
219 ClusterBy {
221 columns: Vec<String>,
223 },
224}
225
226impl StoragePlan {
227 #[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 #[must_use]
239 pub fn is_partitioned(&self) -> bool {
240 !matches!(self.partition, PartitionStrategy::None)
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct PartitionMaintenance {
250 pub extend_proc_name: String,
252 #[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}