1use std::fmt;
19
20use crate::{Chunk, FormatError, FormatVersion, Result, parse_duration};
21
22pub const PREFIX: &str = "timelake.";
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Level {
28 Create,
30 Table,
32 Session,
34 Call,
36}
37
38impl Level {
39 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Create => "create",
43 Self::Table => "table",
44 Self::Session => "session",
45 Self::Call => "call",
46 }
47 }
48}
49
50impl fmt::Display for Level {
51 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52 f.write_str(self.as_str())
53 }
54}
55
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum DefaultValue {
59 Value(&'static str),
61 Unset(&'static str),
63 Derived(&'static str),
65}
66
67impl fmt::Display for DefaultValue {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::Value(s) | Self::Unset(s) | Self::Derived(s) => f.write_str(s),
71 }
72 }
73}
74
75#[derive(Debug)]
77pub struct Setting {
78 pub name: &'static str,
80 pub kind: &'static str,
82 pub default: DefaultValue,
84 pub levels: &'static [Level],
86 pub since: &'static str,
88 pub description: &'static str,
90 check: fn(&str) -> Result<()>,
91}
92
93impl Setting {
94 pub fn property(&self) -> String {
96 format!("{PREFIX}{}", self.name)
97 }
98
99 pub fn sql_name(&self) -> String {
101 format!("{PREFIX}{}", self.name.replace('-', "_"))
102 }
103
104 pub fn allows(&self, level: Level) -> bool {
106 self.levels.contains(&level)
107 }
108
109 pub fn check(&self, value: &str) -> Result<()> {
111 (self.check)(value.trim()).map_err(|e| FormatError::new(format!("{}: {e}", self.property())))
112 }
113}
114
115pub fn find(name: &str) -> Option<&'static Setting> {
118 let name = name.trim().to_ascii_lowercase().replace('_', "-");
119 let name = name.strip_prefix(PREFIX).unwrap_or(&name);
120 SETTINGS.iter().find(|s| s.name == name)
121}
122
123pub fn check_properties<'a>(
127 properties: impl IntoIterator<Item = (&'a String, &'a String)>,
128 level: Level,
129) -> Result<Vec<(String, String)>> {
130 let mut out = vec![];
131 for (key, value) in properties {
132 if !key.trim().to_ascii_lowercase().starts_with(PREFIX) {
133 out.push((key.clone(), value.clone()));
134 continue;
135 }
136 let setting = find(key).ok_or_else(|| unknown(key))?;
137 if !setting.allows(level) {
138 return Err(not_settable(setting, level));
139 }
140 setting.check(value)?;
141 out.push((setting.property(), value.trim().to_string()));
142 }
143 Ok(out)
144}
145
146pub fn unknown(name: &str) -> FormatError {
148 FormatError::new(format!("unknown setting {name:?} (`timelake settings` lists them)"))
149}
150
151pub fn not_settable(setting: &Setting, level: Level) -> FormatError {
153 let name = setting.property();
154 let msg = if setting.levels.is_empty() {
155 format!("{name} is set by Timelake itself, not by hand")
156 } else if setting.levels == [Level::Create] {
157 let options = "time_column, series_key, chunk, dedup, ingest_time";
158 format!("{name} is fixed when the table is created, by its options ({options})")
159 } else {
160 let levels: Vec<&str> = setting.levels.iter().map(|l| l.as_str()).collect();
161 format!("{name} can't be set at the {level} level, only at: {}", levels.join(", "))
162 };
163 FormatError::new(msg)
164}
165
166pub static SETTINGS: &[Setting] = &[
168 Setting {
169 name: "time-column",
170 kind: "column",
171 default: DefaultValue::Unset("none"),
172 levels: &[Level::Create],
173 since: "0.1.0",
174 description: "The time column. Its presence makes the table a time-series table.",
175 check: column,
176 },
177 Setting {
178 name: "series-key",
179 kind: "columns",
180 default: DefaultValue::Unset("none"),
181 levels: &[Level::Create],
182 since: "0.1.0",
183 description: "Comma-separated columns that identify a series.",
184 check: columns,
185 },
186 Setting {
187 name: "chunk-interval",
188 kind: "hour, day, or month",
189 default: DefaultValue::Derived("day for time-series tables"),
190 levels: &[Level::Create],
191 since: "0.1.0",
192 description: "Partition size: how much time one partition covers.",
193 check: chunk,
194 },
195 Setting {
196 name: "dedup",
197 kind: "boolean",
198 default: DefaultValue::Derived("true with a series key, else false"),
199 levels: &[Level::Create],
200 since: "0.1.0",
201 description: "Rows with the same (series, time) count once; the latest write wins. Applied within an append, \
202 by the Timelake reader, and by compaction. Fixed at creation: files written with it off may \
203 hold copies, which the reader would then miss.",
204 check: boolean,
205 },
206 Setting {
207 name: "ingest-time-column",
208 kind: "column",
209 default: DefaultValue::Derived("_ingest_ts for time-series tables"),
210 levels: &[Level::Create],
211 since: "0.5.0",
212 description: "The column holding each row's insertion time (`ingest_time=False` or `--no-ingest-time` leaves \
213 it out). Each append fills it with the time the append started, unless the input brings its \
214 own values. Among rows with the same (series, time), the latest insertion wins, then the \
215 later commit.",
216 check: column,
217 },
218 Setting {
219 name: "retention",
220 kind: "duration",
221 default: DefaultValue::Unset("forever"),
222 levels: &[Level::Create, Level::Table],
223 since: "0.1.0",
224 description: "Maintenance drops partitions that end before now minus this duration, e.g. 90d.",
225 check: duration,
226 },
227 Setting {
228 name: "auto-maintain",
229 kind: "boolean",
230 default: DefaultValue::Value("true"),
231 levels: &[Level::Table, Level::Call],
232 since: "0.1.0",
233 description: "Run size-tiered compaction, retention, and snapshot expiry inline after appends. One append \
234 can override it (`maintain=` in Python, `--no-maintain` on the CLI).",
235 check: boolean,
236 },
237 Setting {
238 name: "compact-min-files",
239 kind: "count",
240 default: DefaultValue::Value("8"),
241 levels: &[Level::Table],
242 since: "0.1.0",
243 description: "Inline compaction merges a size tier of a partition once it has this many files.",
244 check: count,
245 },
246 Setting {
247 name: "compact-cold-after",
248 kind: "duration or off",
249 default: DefaultValue::Value("1h"),
250 levels: &[Level::Table, Level::Call],
251 since: "0.5.0",
252 description: "Inline maintenance rewrites a partition whole (sorted, duplicates removed) once the table holds \
253 data newer than the partition's end by this much and the partition has two small files, \
254 whether the append touched it or not. `off` leaves it to the size tiers.",
255 check: duration_or_off,
256 },
257 Setting {
258 name: "read-dedup",
259 kind: "boolean",
260 default: DefaultValue::Derived("as dedup"),
261 levels: &[Level::Table, Level::Session],
262 since: "0.5.0",
263 description: "The Timelake reader returns only the latest write of each (series, time), before compaction \
264 has removed the older copies. Has no effect on tables with dedup off.",
265 check: boolean,
266 },
267 Setting {
268 name: "read-dedup-strategy",
269 kind: "merge or keys-first",
270 default: DefaultValue::Value("merge"),
271 levels: &[Level::Table, Level::Session],
272 since: "0.5.0",
273 description: "How the reader removes copies: `merge` merges the files that may share keys in key order; \
274 `keys-first` lists the older copies from the key columns, then reads the files like any scan \
275 and leaves them out (slower on the industrial IoT benchmark).",
276 check: read_dedup_strategy,
277 },
278 Setting {
279 name: "filter-pushdown",
280 kind: "auto, true or false",
281 default: DefaultValue::Value("auto"),
282 levels: &[Level::Table, Level::Session],
283 since: "0.7.0",
284 description: "The Timelake reader applies filters while decoding Parquet pages, cheapest first, and decodes \
285 the other columns only for the rows that pass. Less decoding when filters keep few rows, but \
286 each row group's filter columns and other columns are fetched one after the other. `auto` is \
287 on for local files and off on object storage, where first queries wait for the extra round \
288 trip; `true` suits repeat queries served from the cache.",
289 check: auto_or_boolean,
290 },
291 Setting {
292 name: "time-weighted-mean",
293 kind: "auto, segments or merge",
294 default: DefaultValue::Value("auto"),
295 levels: &[Level::Table, Level::Session],
296 since: "0.7.0",
297 description: "How `time_weighted_mean` gets each series' readings in time order. It sums runs of readings \
298 in order and joins them; runs that overlap in time can't be joined. `segments`: as the files \
299 come (an overlap is an error). `merge`: files that overlap on the series and time, or aren't \
300 sorted, are merged in order first (as dedup on read does). `auto`: `merge` for tables with \
301 dedup off and such files, else `segments`.",
302 check: time_weighted_mean,
303 },
304 Setting {
305 name: "newest-first",
306 kind: "boolean",
307 default: DefaultValue::Value("true"),
308 levels: &[Level::Table, Level::Session],
309 since: "0.7.0",
310 description: "A query sorted by the time column, newest first, with a limit (`ORDER BY time DESC LIMIT k`) \
311 reads the newest time partition first and older ones only while they can still hold one of \
312 the k newest rows.",
313 check: boolean,
314 },
315 Setting {
316 name: "in-list-pruning",
317 kind: "boolean",
318 default: DefaultValue::Value("true"),
319 levels: &[Level::Table, Level::Session],
320 since: "0.7.0",
321 description: "The Timelake reader skips row groups and pages whose range of a column holds none of the \
322 values of a `column IN (…)` filter with more than 20 values, which DataFusion doesn't prune \
323 with: lists in the query, and, on scans that don't filter rows while decoding (object \
324 storage by default), the key lists of a join's runtime filter.",
325 check: boolean,
326 },
327 Setting {
328 name: "target-file-size-bytes",
329 kind: "bytes",
330 default: DefaultValue::Value("268435456"),
331 levels: &[Level::Table],
332 since: "0.1.0",
333 description: "Size at which compaction output and writes roll to a new file (256 MiB).",
334 check: count,
335 },
336 Setting {
337 name: "row-group-rows",
338 kind: "count",
339 default: DefaultValue::Value("1048576"),
340 levels: &[Level::Table],
341 since: "0.1.0",
342 description: "Parquet row group size. Page indexes keep single-series reads selective inside large row \
343 groups.",
344 check: count,
345 },
346 Setting {
347 name: "snapshot-retention",
348 kind: "duration",
349 default: DefaultValue::Value("1h"),
350 levels: &[Level::Table],
351 since: "0.1.0",
352 description: "Expire snapshots older than this, keeping at least `min-snapshots` and at most \
353 `max-snapshots`.",
354 check: duration,
355 },
356 Setting {
357 name: "min-snapshots",
358 kind: "count",
359 default: DefaultValue::Value("5"),
360 levels: &[Level::Table],
361 since: "0.1.0",
362 description: "Snapshots always kept, whatever their age.",
363 check: count,
364 },
365 Setting {
366 name: "max-snapshots",
367 kind: "count",
368 default: DefaultValue::Value("100"),
369 levels: &[Level::Table],
370 since: "0.1.0",
371 description: "Snapshots kept at most: older ones expire even if younger than `snapshot-retention`.",
372 check: count,
373 },
374 Setting {
375 name: "head-format",
376 kind: "codec name or none",
377 default: DefaultValue::Value("arrow"),
378 levels: &[Level::Table],
379 since: "0.3.0",
380 description: "Format of the head object (`metadata/timelake-head`): `arrow`, `json`, a registered codec, or \
381 `none` for no head.",
382 check: name,
383 },
384 Setting {
385 name: "head-inline-bytes",
386 kind: "bytes",
387 default: DefaultValue::Value("1048576"),
388 levels: &[Level::Table],
389 since: "0.3.0",
390 description: "Room in the head for the last-value index, then the Parquet footers of the newest files \
391 (1 MiB). What doesn't fit is read separately.",
392 check: count,
393 },
394 Setting {
395 name: "keep-snapshots-until",
396 kind: "milliseconds since the epoch",
397 default: DefaultValue::Unset("none"),
398 levels: &[Level::Table],
399 since: "0.3.0",
400 description: "No snapshot expires before this time. `timelake upgrade` sets it so the upgrade can be rolled \
401 back.",
402 check: count,
403 },
404 Setting {
405 name: "rollup-routing",
406 kind: "boolean",
407 default: DefaultValue::Value("true"),
408 levels: &[Level::Table, Level::Session],
409 since: "0.5.0",
410 description: "The Timelake reader answers a `GROUP BY time_bucket(…)` query on this table from a rollup \
411 that matches it. Buckets changed since the rollup's last refresh, and partial buckets at the \
412 edges of the time range, still come from this table, so the answer is the same either way.",
413 check: boolean,
414 },
415 Setting {
416 name: "rollup-refresh-interval",
417 kind: "duration or off",
418 default: DefaultValue::Value("1m"),
419 levels: &[Level::Table],
420 since: "0.5.0",
421 description: "On a rollup: inline maintenance after an append to its raw table refreshes it once its last \
422 refresh is this old. `off` leaves it to `timelake maintain` and `REFRESH MATERIALIZED ROLLUP`.",
423 check: duration_or_off,
424 },
425 Setting {
426 name: "data-page-rows",
427 kind: "count",
428 default: DefaultValue::Derived("20,000; 8,192 when written from WebAssembly"),
429 levels: &[Level::Table],
430 since: "0.6.0",
431 description: "Rows per Parquet data page in the files written. Smaller pages make compaction from a Worker \
432 use less memory (it holds a page per column of each file it merges: 8,192 rows peaked at 77 MiB \
433 merging 24 TSBS files, 20,000 at 123 MiB) and page pruning finer, at a few percent more bytes \
434 (TSBS: +3.6%) and slower scans below about 8,000 rows.",
435 check: count,
436 },
437 Setting {
438 name: "version-check-interval",
439 kind: "duration",
440 default: DefaultValue::Value("1s"),
441 levels: &[Level::Table, Level::Session],
442 since: "0.6.0",
443 description: "The Timelake reader uses a table version it loaded or checked this long before checking for a \
444 newer one, so queries in that time send no request for it and can miss commits by other \
445 processes. `0s` checks on every query. This process's own commits are seen at once, and \
446 writers always check.",
447 check: duration,
448 },
449 Setting {
450 name: "rollups",
451 kind: "table names",
452 default: DefaultValue::Unset("none"),
453 levels: &[],
454 since: "0.5.0",
455 description: "On a raw table: its rollups, comma-separated. `CREATE` and `DROP MATERIALIZED ROLLUP` keep it.",
456 check: columns,
457 },
458 Setting {
459 name: "rollup-source",
460 kind: "table name",
461 default: DefaultValue::Unset("none"),
462 levels: &[],
463 since: "0.5.0",
464 description: "On a rollup: the raw table it summarises.",
465 check: name,
466 },
467 Setting {
468 name: "rollup-bucket",
469 kind: "duration",
470 default: DefaultValue::Unset("none"),
471 levels: &[],
472 since: "0.5.0",
473 description: "On a rollup: the width of its time buckets.",
474 check: duration,
475 },
476 Setting {
477 name: "rollup-aggregates",
478 kind: "JSON",
479 default: DefaultValue::Unset("none"),
480 levels: &[],
481 since: "0.5.0",
482 description: "On a rollup: its aggregates, each an output column, a function, and the column it reads.",
483 check: name,
484 },
485 Setting {
486 name: "format-version",
487 kind: "semantic version",
488 default: DefaultValue::Derived("the current format for new tables; 0.1.0 if absent"),
489 levels: &[],
490 since: "0.3.0",
491 description: "The table format. Timelake refuses tables of a newer format it can't handle; `timelake upgrade` \
492 brings a table to the current format.",
493 check: format_version,
494 },
495];
496
497fn boolean(s: &str) -> Result<()> {
498 match s.to_ascii_lowercase().as_str() {
499 "true" | "false" => Ok(()),
500 _ => Err(FormatError::new(format!("must be true or false, got {s:?}"))),
501 }
502}
503
504fn auto_or_boolean(s: &str) -> Result<()> {
505 match s.to_ascii_lowercase().as_str() {
506 "auto" | "true" | "false" => Ok(()),
507 _ => Err(FormatError::new(format!("must be auto, true or false, got {s:?}"))),
508 }
509}
510
511fn time_weighted_mean(s: &str) -> Result<()> {
512 match s.to_ascii_lowercase().as_str() {
513 "auto" | "segments" | "merge" => Ok(()),
514 _ => Err(FormatError::new(format!("must be auto, segments or merge, got {s:?}"))),
515 }
516}
517
518fn count(s: &str) -> Result<()> {
519 s.parse::<u64>().map(drop).map_err(|_| FormatError::new(format!("must be a non-negative integer, got {s:?}")))
520}
521
522fn duration(s: &str) -> Result<()> {
523 parse_duration(s).map(drop)
524}
525
526fn duration_or_off(s: &str) -> Result<()> {
527 if s.eq_ignore_ascii_case("off") { Ok(()) } else { duration(s) }
528}
529
530fn chunk(s: &str) -> Result<()> {
531 s.parse::<Chunk>().map(drop)
532}
533
534fn read_dedup_strategy(s: &str) -> Result<()> {
535 match s.to_ascii_lowercase().as_str() {
536 "merge" | "keys-first" | "keys_first" => Ok(()),
537 _ => Err(FormatError::new(format!("must be merge or keys-first, got {s:?}"))),
538 }
539}
540
541fn format_version(s: &str) -> Result<()> {
542 s.parse::<FormatVersion>().map(drop)
543}
544
545fn name(s: &str) -> Result<()> {
546 if s.is_empty() { Err(FormatError::new("must not be empty")) } else { Ok(()) }
547}
548
549fn column(s: &str) -> Result<()> {
550 name(s)
551}
552
553fn columns(s: &str) -> Result<()> {
554 if s.split(',').any(|c| !c.trim().is_empty()) { Ok(()) } else { Err(FormatError::new("must name a column")) }
555}
556
557#[cfg(test)]
558mod tests {
559 use std::collections::HashMap;
560
561 use super::*;
562 use crate::{Contract, property};
563
564 #[test]
565 fn names_are_unique_kebab_case_and_found_in_both_spellings() {
566 for (i, s) in SETTINGS.iter().enumerate() {
567 assert!(s.name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), "{}", s.name);
568 assert!(SETTINGS[..i].iter().all(|t| t.name != s.name), "{} twice", s.name);
569 for spelling in [s.property(), s.sql_name(), s.name.to_string(), s.sql_name().to_ascii_uppercase()] {
570 assert_eq!(find(&spelling).map(|f| f.name), Some(s.name), "{spelling}");
571 }
572 }
573 assert!(find("timelake.no-such-setting").is_none());
574 }
575
576 #[test]
578 fn every_property_is_registered() {
579 for key in [
580 property::TIME_COLUMN,
581 property::SERIES_KEY,
582 property::CHUNK,
583 property::DEDUP,
584 property::RETENTION,
585 property::TARGET_FILE_SIZE,
586 property::ROW_GROUP_ROWS,
587 property::AUTO_MAINTAIN,
588 property::SNAPSHOT_RETENTION,
589 property::MIN_SNAPSHOTS,
590 property::MAX_SNAPSHOTS,
591 property::COMPACT_MIN_FILES,
592 property::COMPACT_COLD_AFTER,
593 property::INGEST_TIME_COLUMN,
594 property::READ_DEDUP,
595 property::READ_DEDUP_STRATEGY,
596 property::FILTER_PUSHDOWN,
597 property::IN_LIST_PRUNING,
598 property::NEWEST_FIRST,
599 property::TIME_WEIGHTED_MEAN,
600 property::HEAD_FORMAT,
601 property::HEAD_INLINE_BYTES,
602 property::KEEP_SNAPSHOTS_UNTIL,
603 property::FORMAT_VERSION,
604 property::ROLLUPS,
605 property::ROLLUP_ROUTING,
606 property::ROLLUP_SOURCE,
607 property::ROLLUP_BUCKET,
608 property::ROLLUP_AGGREGATES,
609 property::ROLLUP_REFRESH_INTERVAL,
610 property::VERSION_CHECK_INTERVAL,
611 property::DATA_PAGE_ROWS,
612 ] {
613 assert_eq!(find(key).map(Setting::property).as_deref(), Some(key));
614 }
615 assert_eq!(SETTINGS.len(), 32, "a new setting needs a property constant and an entry above");
616 }
617
618 #[test]
620 fn defaults_match_the_contract() {
621 let unset = format!("{:?}", Contract::from_properties(&HashMap::new()).unwrap());
622 for s in SETTINGS {
623 if let DefaultValue::Value(v) = s.default {
624 s.check(v).unwrap();
625 let set = HashMap::from([(s.property(), v.to_string())]);
626 assert_eq!(format!("{:?}", Contract::from_properties(&set).unwrap()), unset, "{}", s.name);
627 }
628 }
629 }
630
631 #[test]
632 fn values_are_checked() {
633 let check = |name: &str, v: &str| find(name).unwrap().check(v);
634 assert!(check("read-dedup", "TRUE").is_ok() && check("read-dedup", "yes").is_err());
635 assert!(check("compact-cold-after", "off").is_ok() && check("compact-cold-after", "soon").is_err());
636 assert!(check("read-dedup-strategy", "keys-first").is_ok() && check("read-dedup-strategy", "hash").is_err());
637 assert!(check("chunk-interval", "1h").is_ok() && check("chunk-interval", "week").is_err());
638 assert!(check("min-snapshots", "-1").is_err() && check("series-key", " , ").is_err());
639 let err = check("read-dedup", "yes").unwrap_err().to_string();
640 assert!(err.starts_with("timelake.read-dedup: must be true or false"), "{err}");
641 }
642
643 #[test]
644 fn properties_are_checked_for_their_level() {
645 let props = |kv: &[(&str, &str)]| -> HashMap<String, String> {
646 kv.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
647 };
648 let ok = check_properties(
649 &props(&[("timelake.read_dedup", " false "), ("write.parquet.compression-codec", "zstd")]),
650 Level::Table,
651 )
652 .unwrap();
653 assert_eq!(
654 ok.into_iter().collect::<HashMap<_, _>>(),
655 props(&[("timelake.read-dedup", "false"), ("write.parquet.compression-codec", "zstd")])
656 );
657 for (kv, msg) in [
658 (("timelake.chunk-interval", "hour"), "fixed when the table is created"),
659 (("timelake.format-version", "0.3.0"), "set by Timelake itself"),
660 (("timelake.no-such", "1"), "unknown setting"),
661 (("timelake.retention", "forever"), "bad duration"),
662 ] {
663 let err = check_properties(&props(&[kv]), Level::Table).unwrap_err().to_string();
664 assert!(err.contains(msg), "{kv:?}: {err}");
665 }
666 let err = check_properties(&props(&[("timelake.min-snapshots", "1")]), Level::Session).unwrap_err();
667 assert!(err.to_string().contains("only at: table"), "{err}");
668 }
669}