Skip to main content

timelake_format/
contract.rs

1//! The Timelake table contract (SPEC §7.1): which column is time, which columns identify a series,
2//! and the layout/maintenance policy derived from them. Stored as `timelake.*` Iceberg table
3//! properties (see [`property`]), so the contract travels with the table and other
4//! engines simply ignore it.
5
6use std::collections::HashMap;
7use std::fmt;
8use std::str::FromStr;
9use std::time::Duration;
10
11use chrono::Datelike;
12
13use crate::{FORMAT_VERSION, FormatError, FormatVersion, Result, property};
14
15const HOUR_MICROS: i64 = 3_600_000_000;
16const DAY_MICROS: i64 = 24 * HOUR_MICROS;
17const DAY_SECS: u64 = 86_400;
18
19/// Time partition granularity, like TimescaleDB's `chunk_time_interval`.
20///
21/// Parses from `hour`, `day` or `month` (and spellings such as `1h`, `1 day`).
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
23pub enum Chunk {
24    /// One partition per hour.
25    Hour,
26    /// One partition per day (the default).
27    Day,
28    /// One partition per calendar month.
29    Month,
30}
31
32impl Chunk {
33    /// The canonical name, as stored in the table properties.
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Hour => "hour",
37            Self::Day => "day",
38            Self::Month => "month",
39        }
40    }
41
42    /// Iceberg transform value (hours/days/months since the epoch) of a timestamp in microseconds.
43    pub fn value(self, micros: i64) -> Result<i32> {
44        let out_of_range = || FormatError::new(format!("timestamp {micros}µs is out of range for {self} partitions"));
45        let t = chrono::DateTime::from_timestamp_micros(micros).ok_or_else(out_of_range)?;
46        let v = match self {
47            Self::Hour => micros.div_euclid(HOUR_MICROS),
48            Self::Day => micros.div_euclid(DAY_MICROS),
49            Self::Month => (i64::from(t.year()) - 1970) * 12 + i64::from(t.month0()),
50        };
51        i32::try_from(v).map_err(|_| out_of_range())
52    }
53
54    /// Start of a partition, in microseconds since the epoch.
55    pub fn start_micros(self, value: i32) -> Result<i64> {
56        let start = match self {
57            Self::Hour => Some(i64::from(value) * HOUR_MICROS),
58            Self::Day => i64::from(value).checked_mul(DAY_MICROS),
59            Self::Month => {
60                let (year, month0) = (1970 + value.div_euclid(12), value.rem_euclid(12).unsigned_abs());
61                chrono::NaiveDate::from_ymd_opt(year, month0 + 1, 1)
62                    .and_then(|d| d.and_hms_opt(0, 0, 0))
63                    .map(|t| t.and_utc().timestamp_micros())
64            }
65        };
66        start.ok_or_else(|| self.out_of_range(value))
67    }
68
69    /// End (exclusive) of a partition, in microseconds since the epoch.
70    pub fn end_micros(self, value: i32) -> Result<i64> {
71        self.start_micros(value.checked_add(1).ok_or_else(|| self.out_of_range(value))?)
72    }
73
74    /// Human-readable partition value, used in data file paths (`time_day=2026-01-01`). Values
75    /// outside the supported time range fall back to the plain number.
76    pub fn label(self, value: i32) -> String {
77        let Some(t) = self.start_micros(value).ok().and_then(chrono::DateTime::from_timestamp_micros) else {
78            return value.to_string();
79        };
80        let format = match self {
81            Self::Hour => "%Y-%m-%d-%H",
82            Self::Day => "%Y-%m-%d",
83            Self::Month => "%Y-%m",
84        };
85        t.format(format).to_string()
86    }
87
88    fn out_of_range(self, value: i32) -> FormatError {
89        FormatError::new(format!("{self} partition {value} is out of range"))
90    }
91}
92
93impl FromStr for Chunk {
94    type Err = FormatError;
95
96    fn from_str(s: &str) -> Result<Self> {
97        match s.trim().to_ascii_lowercase().as_str() {
98            "hour" | "1h" | "1 hour" => Ok(Self::Hour),
99            "day" | "1d" | "1 day" | "24h" => Ok(Self::Day),
100            "month" | "1 month" => Ok(Self::Month),
101            other => Err(FormatError::new(format!("chunk interval must be hour, day or month, got {other:?}"))),
102        }
103    }
104}
105
106impl fmt::Display for Chunk {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(self.as_str())
109    }
110}
111
112/// Options for creating a table. Only `time_column` is required for a time-series table; leave it
113/// unset for a plain (dimension) table such as device metadata.
114#[derive(Clone, Debug, Default)]
115pub struct TableOptions {
116    /// The timestamp column; makes the table a time-series table.
117    pub time_column: Option<String>,
118    /// Columns that identify a series (e.g. `device_id`); requires a time column.
119    pub series_key: Vec<String>,
120    /// Time partition size (default: [`Chunk::Day`]).
121    pub chunk: Option<Chunk>,
122    /// Remove duplicate `(series_key, time)` rows during compaction (default: on with a series key).
123    pub dedup: Option<bool>,
124    /// Drop partitions entirely older than this.
125    pub retention: Option<Duration>,
126    /// Extra Iceberg/Timelake table properties.
127    pub properties: HashMap<String, String>,
128}
129
130/// The parsed contract of an existing table.
131#[derive(Clone, Debug)]
132pub struct Contract {
133    /// The timestamp column, if this is a time-series table.
134    pub time_column: Option<String>,
135    /// Columns that identify a series.
136    pub series_key: Vec<String>,
137    /// Time partition size.
138    pub chunk: Option<Chunk>,
139    /// Remove rows with the same `(series_key, time)` during compaction; the latest write wins.
140    pub dedup: bool,
141    /// Partitions entirely older than this are dropped by maintenance.
142    pub retention: Option<Duration>,
143    /// Compaction writes files of about this size.
144    pub target_file_size: u64,
145    /// Rows per Parquet row group.
146    pub row_group_rows: usize,
147    /// Appends run inline maintenance when thresholds are crossed.
148    pub auto_maintain: bool,
149    /// Snapshots younger than this are kept.
150    pub snapshot_retention: Duration,
151    /// The newest snapshots kept regardless of age.
152    pub min_snapshots: usize,
153    /// Snapshots beyond the newest `max_snapshots` are expired even if younger than the retention.
154    pub max_snapshots: usize,
155    /// Inline auto-maintenance merges a size tier of a partition once it has this many files.
156    pub compact_min_files: usize,
157    /// Format of the head object, or `none` to not write one.
158    pub head_format: String,
159    /// Bytes the head may spend on optional sections (the last-value index).
160    pub head_inline_bytes: u64,
161    /// The table format version (see [`crate::FORMAT_VERSION`]).
162    pub format_version: FormatVersion,
163    /// No snapshot expires before this time (milliseconds since the epoch).
164    pub keep_snapshots_until: Option<i64>,
165}
166
167impl Contract {
168    /// Defaults for properties a table doesn't set.
169    const DEFAULT_TARGET_FILE_SIZE: u64 = 256 << 20;
170    const DEFAULT_ROW_GROUP_ROWS: usize = 1 << 20;
171    const DEFAULT_SNAPSHOT_RETENTION: Duration = Duration::from_secs(3600);
172    const DEFAULT_MIN_SNAPSHOTS: usize = 5;
173    const DEFAULT_MAX_SNAPSHOTS: usize = 100;
174    const DEFAULT_COMPACT_MIN_FILES: usize = 8;
175    const DEFAULT_HEAD_FORMAT: &str = "arrow";
176    const DEFAULT_HEAD_INLINE_BYTES: u64 = 1 << 20;
177
178    /// Reads the contract from Iceberg table properties, with defaults for what isn't set.
179    pub fn from_properties(properties: &HashMap<String, String>) -> Result<Self> {
180        let p = Properties(properties);
181        Ok(Self {
182            time_column: p.get(property::TIME_COLUMN).map(str::to_string),
183            series_key: p
184                .get(property::SERIES_KEY)
185                .map(|s| s.split(',').map(str::trim).filter(|c| !c.is_empty()).map(str::to_string).collect())
186                .unwrap_or_default(),
187            chunk: p.get(property::CHUNK).map(str::parse).transpose()?,
188            dedup: p.flag(property::DEDUP, false)?,
189            retention: p.duration(property::RETENTION)?,
190            target_file_size: p.number(property::TARGET_FILE_SIZE, Self::DEFAULT_TARGET_FILE_SIZE)?,
191            row_group_rows: p.number(property::ROW_GROUP_ROWS, Self::DEFAULT_ROW_GROUP_ROWS)?,
192            auto_maintain: p.flag(property::AUTO_MAINTAIN, true)?,
193            snapshot_retention: p.duration(property::SNAPSHOT_RETENTION)?.unwrap_or(Self::DEFAULT_SNAPSHOT_RETENTION),
194            min_snapshots: p.number(property::MIN_SNAPSHOTS, Self::DEFAULT_MIN_SNAPSHOTS)?,
195            max_snapshots: p.number(property::MAX_SNAPSHOTS, Self::DEFAULT_MAX_SNAPSHOTS)?,
196            compact_min_files: p.number(property::COMPACT_MIN_FILES, Self::DEFAULT_COMPACT_MIN_FILES)?,
197            head_format: p.get(property::HEAD_FORMAT).unwrap_or(Self::DEFAULT_HEAD_FORMAT).to_ascii_lowercase(),
198            head_inline_bytes: p.number(property::HEAD_INLINE_BYTES, Self::DEFAULT_HEAD_INLINE_BYTES)?,
199            format_version: p.get(property::FORMAT_VERSION).map_or(Ok(FormatVersion::UNVERSIONED), str::parse)?,
200            keep_snapshots_until: p
201                .get(property::KEEP_SNAPSHOTS_UNTIL)
202                .map(|_| p.number(property::KEEP_SNAPSHOTS_UNTIL, 0))
203                .transpose()?,
204        })
205    }
206
207    /// Errors when this Timelake can't read the table correctly: its format is newer than
208    /// [`FORMAT_VERSION`] in a way that breaks older readers.
209    pub fn check_readable(&self) -> Result<()> {
210        if self.format_version.readable_by(FORMAT_VERSION) {
211            return Ok(());
212        }
213        Err(FormatError::new(format!(
214            "table format {} is newer than this Timelake reads ({FORMAT_VERSION}): upgrade Timelake",
215            self.format_version
216        )))
217    }
218
219    /// Errors when this Timelake must not write to the table: it wouldn't keep what the table's
220    /// newer format relies on.
221    pub fn check_writable(&self) -> Result<()> {
222        if self.format_version.writable_by(FORMAT_VERSION) {
223            return Ok(());
224        }
225        Err(FormatError::new(format!(
226            "table format {} is newer than this Timelake writes ({FORMAT_VERSION}): upgrade Timelake",
227            self.format_version
228        )))
229    }
230
231    /// Whether the table has a time column.
232    pub fn is_time_series(&self) -> bool {
233        self.time_column.is_some()
234    }
235
236    /// Sort key: series key columns, then time.
237    pub fn sort_columns(&self) -> Vec<String> {
238        let mut v = self.series_key.clone();
239        v.extend(self.time_column.clone());
240        v
241    }
242}
243
244/// Typed access to table properties; blank values count as unset.
245struct Properties<'a>(&'a HashMap<String, String>);
246
247impl Properties<'_> {
248    fn get(&self, key: &str) -> Option<&str> {
249        self.0.get(key).map(|s| s.trim()).filter(|s| !s.is_empty())
250    }
251
252    fn number<T: FromStr>(&self, key: &str, default: T) -> Result<T> {
253        self.get(key).map_or(Ok(default), |s| {
254            s.parse().map_err(|_| FormatError::new(format!("{key} must be a non-negative integer, got {s:?}")))
255        })
256    }
257
258    fn flag(&self, key: &str, default: bool) -> Result<bool> {
259        self.get(key).map_or(Ok(default), |s| {
260            s.to_ascii_lowercase()
261                .parse()
262                .map_err(|_| FormatError::new(format!("{key} must be true or false, got {s:?}")))
263        })
264    }
265
266    fn duration(&self, key: &str) -> Result<Option<Duration>> {
267        self.get(key).map(parse_duration).transpose()
268    }
269}
270
271/// Parses durations like `90d`, `12h`, `30m`, `45s`, `2w`, `1y`, or spelled out (`1 day`, `3 hours`).
272pub fn parse_duration(s: &str) -> Result<Duration> {
273    let s = s.trim();
274    let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
275    let (n, unit) = s.split_at(split);
276    let n: u64 = n.parse().map_err(|_| FormatError::new(format!("bad duration {s:?}")))?;
277    let unit_secs = match unit.trim().to_ascii_lowercase().as_str() {
278        "s" | "sec" | "secs" | "second" | "seconds" => 1,
279        "m" | "min" | "mins" | "minute" | "minutes" => 60,
280        "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
281        "d" | "day" | "days" => DAY_SECS,
282        "w" | "week" | "weeks" => 7 * DAY_SECS,
283        "y" | "year" | "years" => 365 * DAY_SECS,
284        _ => return Err(FormatError::new(format!("bad duration unit in {s:?} (use s, m, h, d, w, y)"))),
285    };
286    let secs = n.checked_mul(unit_secs).ok_or_else(|| FormatError::new(format!("duration {s:?} is too long")))?;
287    Ok(Duration::from_secs(secs))
288}
289
290/// Formats whole seconds of a duration in the largest exact unit (`90d`, `36h`, `45s`), the
291/// inverse of [`parse_duration`].
292pub fn format_duration(d: Duration) -> String {
293    let secs = d.as_secs();
294    match [(DAY_SECS, 'd'), (3600, 'h'), (60, 'm')]
295        .into_iter()
296        .find(|(unit, _)| secs != 0 && secs.is_multiple_of(*unit))
297    {
298        Some((unit, suffix)) => format!("{}{suffix}", secs / unit),
299        None => format!("{secs}s"),
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::property::*;
307
308    const T: i64 = 1_767_225_600_000_000; // 2026-01-01T00:00:00Z
309
310    #[test]
311    fn chunk_values_round_trip() {
312        for c in [Chunk::Hour, Chunk::Day, Chunk::Month] {
313            let v = c.value(T + 1).unwrap();
314            assert_eq!(c.start_micros(v).unwrap(), T, "{c:?}");
315            assert!(c.end_micros(v).unwrap() > T);
316        }
317        assert_eq!(Chunk::Day.label(Chunk::Day.value(T).unwrap()), "2026-01-01");
318        assert_eq!(Chunk::Hour.label(Chunk::Hour.value(T).unwrap()), "2026-01-01-00");
319        assert_eq!(Chunk::Month.label(Chunk::Month.value(T).unwrap()), "2026-01");
320        assert_eq!(Chunk::Month.value(T).unwrap(), (2026 - 1970) * 12);
321        // Before the epoch, values are negative and still round down.
322        assert_eq!(Chunk::Day.value(-1).unwrap(), -1);
323        assert_eq!(Chunk::Month.value(-1).unwrap(), -1);
324        assert_eq!(Chunk::Month.start_micros(-1).unwrap(), -31 * DAY_MICROS);
325    }
326
327    #[test]
328    fn out_of_range_times_are_errors() {
329        for c in [Chunk::Hour, Chunk::Day, Chunk::Month] {
330            assert!(c.value(i64::MAX).is_err(), "{c:?}");
331            assert!(c.value(i64::MIN).is_err(), "{c:?}");
332        }
333        assert!(Chunk::Day.start_micros(i32::MAX).is_err());
334        assert!(Chunk::Month.start_micros(i32::MAX).is_err());
335        assert!(Chunk::Hour.end_micros(i32::MAX).is_err());
336        assert_eq!(Chunk::Month.label(i32::MAX), i32::MAX.to_string());
337    }
338
339    #[test]
340    fn chunk_parses_and_displays() {
341        for (s, c) in [("hour", Chunk::Hour), (" 1 Day ", Chunk::Day), ("24h", Chunk::Day), ("month", Chunk::Month)] {
342            assert_eq!(s.parse::<Chunk>().unwrap(), c, "{s}");
343        }
344        assert!("week".parse::<Chunk>().is_err());
345        for c in [Chunk::Hour, Chunk::Day, Chunk::Month] {
346            assert_eq!(c.to_string().parse::<Chunk>().unwrap(), c);
347        }
348    }
349
350    #[test]
351    fn durations() {
352        assert_eq!(parse_duration("90d").unwrap().as_secs(), 90 * 86_400);
353        assert_eq!(parse_duration("1h").unwrap().as_secs(), 3600);
354        assert_eq!(parse_duration(" 0s ").unwrap(), Duration::ZERO);
355        assert!(parse_duration("1 parsec").is_err());
356        assert!(parse_duration("d").is_err());
357        assert!(parse_duration("-1d").is_err());
358    }
359
360    #[test]
361    fn durations_accept_singular_and_plural_units() {
362        assert_eq!(parse_duration("1 day").unwrap().as_secs(), 86_400);
363        assert_eq!(parse_duration("1 hour").unwrap().as_secs(), 3600);
364        assert_eq!(parse_duration("1 week").unwrap().as_secs(), 7 * 86_400);
365        assert_eq!(parse_duration("2 Weeks").unwrap().as_secs(), 14 * 86_400);
366        assert_eq!(parse_duration("1 minute").unwrap().as_secs(), 60);
367        assert_eq!(parse_duration("1 year").unwrap().as_secs(), 365 * 86_400);
368    }
369
370    #[test]
371    fn duration_overflow_is_an_error() {
372        assert!(parse_duration("99999999999999999y").is_err());
373        assert!(parse_duration("99999999999999999999s").is_err());
374    }
375
376    #[test]
377    fn durations_format_in_the_largest_exact_unit() {
378        for (secs, s) in [(90 * 86_400, "90d"), (36 * 3600, "36h"), (90, "90s"), (120, "2m"), (0, "0s")] {
379            assert_eq!(format_duration(Duration::from_secs(secs)), s);
380            assert_eq!(parse_duration(s).unwrap().as_secs(), secs);
381        }
382    }
383
384    fn props(kv: &[(&str, &str)]) -> HashMap<String, String> {
385        kv.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
386    }
387
388    #[test]
389    fn contract_defaults() {
390        let c = Contract::from_properties(&HashMap::new()).unwrap();
391        assert!(!c.is_time_series() && !c.dedup && c.auto_maintain);
392        assert_eq!(c.retention, None);
393        assert_eq!(c.snapshot_retention, Duration::from_secs(3600));
394        assert_eq!((c.min_snapshots, c.max_snapshots, c.compact_min_files), (5, 100, 8));
395        assert_eq!(
396            (c.head_format.as_str(), c.head_inline_bytes, c.format_version),
397            ("arrow", 1 << 20, FormatVersion::UNVERSIONED)
398        );
399        assert!(c.sort_columns().is_empty());
400    }
401
402    #[test]
403    fn contract_parses_properties() {
404        let c = Contract::from_properties(&props(&[
405            (TIME_COLUMN, "time"),
406            (SERIES_KEY, "site, device,"),
407            (CHUNK, "hour"),
408            (DEDUP, "True"),
409            (AUTO_MAINTAIN, "FALSE"),
410            (RETENTION, "1 day"),
411            (MIN_SNAPSHOTS, "3"),
412            (TARGET_FILE_SIZE, " 1024 "),
413            (HEAD_FORMAT, "JSON"),
414            (HEAD_INLINE_BYTES, "0"),
415        ]))
416        .unwrap();
417        assert_eq!((c.head_format.as_str(), c.head_inline_bytes), ("json", 0));
418        assert_eq!(c.sort_columns(), ["site", "device", "time"]);
419        assert_eq!(c.chunk, Some(Chunk::Hour));
420        assert!(c.dedup && !c.auto_maintain);
421        assert_eq!(c.retention, Some(Duration::from_secs(86_400)));
422        assert_eq!((c.min_snapshots, c.target_file_size), (3, 1024));
423    }
424
425    #[test]
426    fn contract_rejects_bad_values() {
427        for kv in [(DEDUP, "yes"), (AUTO_MAINTAIN, "0"), (CHUNK, "week"), (RETENTION, "forever"), (MAX_SNAPSHOTS, "-1")]
428        {
429            assert!(Contract::from_properties(&props(&[kv])).is_err(), "{kv:?}");
430        }
431    }
432}