1use std::collections::HashMap;
9use std::fmt;
10use std::time::Duration;
11
12use arrow::compute::concat_batches;
13use iceberg::spec::{MAIN_BRANCH, SnapshotReference, SnapshotRetention, TableMetadataBuilder};
14use timelake_format::head::NO_HEAD;
15use timelake_format::{FORMAT_VERSION, FormatVersion};
16
17use crate::commit::{Change, commit, publish};
18use crate::error::{Error, Result};
19use crate::lake::metadata_uri;
20use crate::maintain::{MaintainOptions, compaction_plan};
21use crate::schema::align_batch;
22use crate::storage::Uploads;
23use crate::table::Table;
24use crate::{Session, head, property, summary};
25
26#[derive(Clone, Debug)]
28pub struct UpgradeOptions {
29 pub dry_run: bool,
31 pub compact: bool,
33 pub keep_snapshots: Duration,
35}
36
37impl Default for UpgradeOptions {
38 fn default() -> Self {
39 Self { dry_run: false, compact: true, keep_snapshots: Duration::from_secs(7 * 24 * 3600) }
40 }
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum UpgradeStep {
47 SetFormatVersion {
49 from: FormatVersion,
51 to: FormatVersion,
53 },
54 BuildLastValueIndex,
56 Compact {
58 partitions: usize,
60 files: usize,
62 },
63 WriteHead,
65}
66
67impl fmt::Display for UpgradeStep {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 match self {
70 Self::SetFormatVersion { from, to } => write!(f, "set table format {from} → {to}"),
71 Self::BuildLastValueIndex => f.write_str("build the last-value index"),
72 Self::Compact { partitions, files } => {
73 write!(f, "rewrite {files} unsorted or small files in {partitions} partitions")
74 }
75 Self::WriteHead => f.write_str("write the head object"),
76 }
77 }
78}
79
80#[derive(Clone, Debug)]
82pub struct UpgradeReport {
83 pub steps: Vec<UpgradeStep>,
85 pub version_before: u64,
87 pub snapshot_before: Option<i64>,
89 pub keep_snapshots_until_ms: Option<i64>,
92}
93
94impl Table {
95 pub async fn upgrade(&self, opts: &UpgradeOptions) -> Result<(Table, UpgradeReport)> {
99 let table = self.refresh().await?;
100 let steps = plan(&table, opts).await?;
101 let mut report = UpgradeReport {
102 steps,
103 version_before: table.version(),
104 snapshot_before: table.metadata().current_snapshot_id(),
105 keep_snapshots_until_ms: None,
106 };
107 if opts.dry_run || report.steps.is_empty() {
108 return Ok((table, report));
109 }
110 if report.steps == [UpgradeStep::WriteHead] {
111 head::write(&table).await?;
112 return Ok((table, report));
113 }
114 let keep_ms = i64::try_from(opts.keep_snapshots.as_millis()).unwrap_or(i64::MAX);
115 let until = chrono::Utc::now().timestamp_millis().saturating_add(keep_ms);
116 report.keep_snapshots_until_ms = Some(until);
117
118 let mut table = set_properties(
120 &table,
121 &[
122 (property::FORMAT_VERSION, FORMAT_VERSION.to_string()),
123 (property::KEEP_SNAPSHOTS_UNTIL, until.to_string()),
124 ],
125 )
126 .await?;
127 for step in &report.steps {
128 table = match step {
129 UpgradeStep::BuildLastValueIndex => build_index(&table).await?,
130 UpgradeStep::Compact { .. } => {
131 let o = MaintainOptions { retention: false, expire: false, ..Default::default() };
132 table.maintain(o).await?.0
133 }
134 UpgradeStep::SetFormatVersion { .. } | UpgradeStep::WriteHead => table,
135 };
136 }
137 if report.steps.contains(&UpgradeStep::WriteHead) {
138 head::write(&table).await?;
139 }
140 Ok((table, report))
141 }
142
143 pub async fn rollback(&self, snapshot_id: i64) -> Result<Table> {
147 let mut base = self.refresh().await?;
148 loop {
149 if base.metadata().snapshot_by_id(snapshot_id).is_none() {
150 return Err(Error::NotFound(format!("{}: snapshot {snapshot_id}", base.name())));
151 }
152 let previous = Some(metadata_uri(base.location(), base.version()));
153 let reference = SnapshotReference::new(snapshot_id, SnapshotRetention::branch(None, None, None));
154 let metadata = TableMetadataBuilder::new_from_metadata(base.metadata().clone(), previous)
155 .set_ref(MAIN_BRANCH, reference)?
156 .build()?
157 .metadata;
158 match publish(&base, metadata, None).await? {
159 Some(table) => return Ok(table),
160 None => base = base.refresh().await?,
161 }
162 }
163 }
164}
165
166async fn plan(table: &Table, opts: &UpgradeOptions) -> Result<Vec<UpgradeStep>> {
168 let c = table.contract();
169 let mut steps = vec![];
170 if c.format_version < FORMAT_VERSION {
171 steps.push(UpgradeStep::SetFormatVersion { from: c.format_version, to: FORMAT_VERSION });
172 }
173 let has_rows = table.summary_value(summary::TOTAL_RECORDS).is_some_and(|n| n != "0");
174 if c.is_time_series() && has_rows && table.last_value_index().is_none() {
175 steps.push(UpgradeStep::BuildLastValueIndex);
176 }
177 if opts.compact {
178 let o = MaintainOptions { retention: false, expire: false, ..Default::default() };
179 let compaction = compaction_plan(table, &o).await?;
180 if !compaction.is_empty() {
181 let files = compaction.iter().map(|(_, files)| files.len()).sum();
182 steps.push(UpgradeStep::Compact { partitions: compaction.len(), files });
183 }
184 }
185 let head_current = head::stored_version(table).await? == Some(table.version());
186 if c.head_format != NO_HEAD && (!steps.is_empty() || !head_current) {
187 steps.push(UpgradeStep::WriteHead);
188 }
189 Ok(steps)
190}
191
192pub(crate) async fn set_properties(table: &Table, properties: &[(&str, String)]) -> Result<Table> {
194 let mut base = table.clone();
195 loop {
196 let previous = Some(metadata_uri(base.location(), base.version()));
197 let props = properties.iter().map(|(k, v)| ((*k).to_string(), v.clone())).collect();
198 let metadata = TableMetadataBuilder::new_from_metadata(base.metadata().clone(), previous)
199 .set_properties(props)?
200 .build()?
201 .metadata;
202 match publish(&base, metadata, base.known_files().cloned()).await? {
203 Some(table) => return Ok(table),
204 None => base = base.refresh().await?,
205 }
206 }
207}
208
209async fn build_index(table: &Table) -> Result<Table> {
211 let session = Session::new(table.lake().clone());
212 let batches = session.latest(table.name().clone()).await?.collect().await?;
213 let schema = table.schema();
214 let aligned = batches.iter().map(|b| align_batch(b, &schema)).collect::<Result<Vec<_>>>()?;
215 let latest = concat_batches(&schema, &aligned)?;
216 Ok(commit(table, Change::Index { latest }, HashMap::new(), Uploads::default()).await?.0)
217}
218
219#[cfg(test)]
220mod tests {
221 use std::collections::HashMap;
222
223 use super::*;
224 use crate::testing::{T0, append_without_index, batch, faulty_lake, opts};
225 use crate::{AppendOptions, MaintainOptions};
226
227 fn rows(t: &Table) -> u64 {
228 t.summary_value(summary::TOTAL_RECORDS).and_then(|v| v.parse().ok()).unwrap_or(0)
229 }
230
231 async fn old_table() -> (crate::Lake, Table) {
234 let (lake, _store) = faulty_lake();
235 let mut o = opts();
236 o.properties = HashMap::from([
237 (property::FORMAT_VERSION.into(), FormatVersion::UNVERSIONED.to_string()),
238 (property::HEAD_FORMAT.into(), NO_HEAD.into()),
239 ]);
240 let t = lake.create_table("iot.r", &crate::testing::schema(), o).await.unwrap();
241 let t = append_without_index(&t, &[batch(&[(T0, 1, 1.0), (T0 + 1, 2, 2.0)])]).await;
242 let t = append_without_index(&t, &[batch(&[(T0 + 2, 1, 3.0)])]).await;
243 let props = [(property::HEAD_FORMAT, "arrow".to_string())];
245 let t = set_properties(&t, &props).await.unwrap();
246 crate::flush().await;
247 lake.storage().delete(&head::uri(t.location())).await.unwrap();
248 (lake, t)
249 }
250
251 #[tokio::test]
252 async fn upgrade_brings_a_table_to_the_current_format_and_rolls_back() {
253 let (_lake, t) = old_table().await;
254 let dry = UpgradeOptions { dry_run: true, ..Default::default() };
255 let (same, plan) = t.upgrade(&dry).await.unwrap();
256 assert_eq!(
257 plan.steps,
258 [
259 UpgradeStep::SetFormatVersion { from: FormatVersion::UNVERSIONED, to: FORMAT_VERSION },
260 UpgradeStep::BuildLastValueIndex,
261 UpgradeStep::Compact { partitions: 1, files: 2 },
262 UpgradeStep::WriteHead,
263 ]
264 );
265 assert_eq!(same.version(), t.version(), "a dry run commits nothing");
266
267 let (up, report) = t.upgrade(&UpgradeOptions::default()).await.unwrap();
268 assert_eq!(report.steps, plan.steps);
269 assert_eq!(up.contract().format_version, FORMAT_VERSION);
270 assert!(up.last_value_index().is_some());
271 assert_eq!((up.files().await.unwrap().len(), rows(&up)), (1, 3));
272 assert_eq!(head::stored_version(&up).await.unwrap(), Some(up.version()));
273 assert!(up.contract().keep_snapshots_until.is_some());
274
275 let (up, m) = up.maintain(MaintainOptions::default()).await.unwrap();
277 assert_eq!(m.snapshots_expired, 0);
278 let back = up.rollback(report.snapshot_before.unwrap()).await.unwrap();
279 assert_eq!((back.files().await.unwrap().len(), rows(&back)), (2, 3));
280 assert!(back.last_value_index().is_none());
281
282 let (again, _) = back.upgrade(&UpgradeOptions::default()).await.unwrap();
284 let (_, nothing) = again.upgrade(&UpgradeOptions::default()).await.unwrap();
285 assert!(nothing.steps.is_empty(), "{:?}", nothing.steps);
286 }
287
288 #[tokio::test]
290 async fn rolled_back_batches_can_be_appended_again() {
291 let (lake, _store) = faulty_lake();
292 let t = lake.create_table("iot.r", &crate::testing::schema(), opts()).await.unwrap();
293 let o = |id: &str| AppendOptions { batch_id: Some(id.into()), maintain: Some(false), ..Default::default() };
294 let (t, _) = t.append(&[batch(&[(T0, 1, 1.0)])], o("a")).await.unwrap();
295 let before = t.metadata().current_snapshot_id().unwrap();
296 let (t, _) = t.append(&[batch(&[(T0 + 1, 1, 2.0)])], o("b")).await.unwrap();
297 let t = t.rollback(before).await.unwrap();
298 let (t, r) = t.append(&[batch(&[(T0 + 1, 1, 2.0)])], o("b")).await.unwrap();
299 assert_eq!((r.rows, rows(&t)), (1, 2));
300 let (_, r) = t.append(&[batch(&[(T0, 1, 1.0)])], o("a")).await.unwrap();
301 assert!(r.commit.duplicate);
302 }
303}