Skip to content

Commit 4a13e74

Browse files
vsmanish1772scovichzachschuermann
authored
feat: setTransactionRetentionDuration (delta-io#1013)
feat: add SetTransaction retention duration filtering ## What changes are proposed in this pull request? This PR implements the `delta.setTransactionRetentionDuration` table property in delta-kernel-rs, which allows filtering out expired transaction identifiers based on their `lastUpdated` timestamp. This feature helps prevent unbounded growth of transaction logs and reduces checkpoint file sizes. ### Key changes: 1. **Transaction filtering during log replay**: Added retention timestamp filtering to `SetTransactionVisitor` to skip transactions older than the retention duration 2. **Checkpoint writing**: Updated `CheckpointVisitor` to exclude expired transactions when creating checkpoints 3. **Backward compatibility**: Transactions without `lastUpdated` field are preserved forever (never expire) 4. **Consistent behavior**: The same retention logic is applied in both checkpoint writing and batch transaction reading operations ### Implementation details: - Modified `SetTransactionVisitor` to accept an optional retention timestamp - Updated `CheckpointLogReplayProcessor` to calculate and propagate transaction retention timestamps - Enhanced `CheckpointVisitor` to filter transactions based on retention during checkpoint creation - Single transaction lookups (`get_app_id_version`) intentionally do filter to - Batch operations (future `get_all_app_id_versions`, checkpointing) do filter expired transactions ## How was this change tested? Added comprehensive unit tests covering: 1. **Checkpoint writing tests** (`checkpoint::log_replay::tests`): - `test_checkpoint_visitor_txn_retention`: Verifies individual transaction filtering with various timestamp scenarios - `test_checkpoint_actions_iter_with_txn_retention`: Tests transaction filtering across multiple batches during checkpoint creation 2. **Transaction reading tests** (`actions::set_transaction::tests`): - `test_visitor_retention_with_null_last_updated`: Ensures transactions without `lastUpdated` are never filtered - Modified existing tests to support the new retention timestamp parameter 3. **Test scenarios covered**: - Transactions with `lastUpdated <= retention_timestamp` are filtered out - Transactions with `lastUpdated = None` are always kept (backward compatibility) - Boundary condition: transactions exactly at retention timestamp are filtered - Cross-batch behavior in checkpoint processing - Verification that filtered transactions are excluded from checkpoint files All tests pass with `cargo t --all-features --all-targets`. --------- Co-authored-by: Ryan Johnson <scovich@users.noreply.github.com> Co-authored-by: Zach Schuermann <zachary.zvs@gmail.com>
1 parent 72b1705 commit 4a13e74

10 files changed

Lines changed: 257 additions & 20 deletions

File tree

kernel/src/actions/set_transaction.rs

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ impl SetTransactionScanner {
2020
log_segment: &LogSegment,
2121
application_id: &str,
2222
engine: &dyn Engine,
23+
expiration_timestamp: Option<i64>,
2324
) -> DeltaResult<Option<SetTransaction>> {
24-
let mut transactions =
25-
scan_application_transactions(log_segment, Some(application_id), engine)?;
25+
let mut transactions = scan_application_transactions(
26+
log_segment,
27+
Some(application_id),
28+
engine,
29+
expiration_timestamp,
30+
)?;
2631
Ok(transactions.remove(application_id))
2732
}
2833

@@ -34,8 +39,9 @@ impl SetTransactionScanner {
3439
pub(crate) fn get_all(
3540
log_segment: &LogSegment,
3641
engine: &dyn Engine,
42+
expiration_timestamp: Option<i64>,
3743
) -> DeltaResult<SetTransactionMap> {
38-
scan_application_transactions(log_segment, None, engine)
44+
scan_application_transactions(log_segment, None, engine, expiration_timestamp)
3945
}
4046
}
4147

@@ -46,8 +52,10 @@ fn scan_application_transactions(
4652
log_segment: &LogSegment,
4753
application_id: Option<&str>,
4854
engine: &dyn Engine,
55+
expiration_timestamp: Option<i64>,
4956
) -> DeltaResult<SetTransactionMap> {
50-
let mut visitor = SetTransactionVisitor::new(application_id.map(|s| s.to_owned()));
57+
let mut visitor =
58+
SetTransactionVisitor::new(application_id.map(|s| s.to_owned()), expiration_timestamp);
5159
// If a specific id is requested then we can terminate log replay early as soon as it was
5260
// found. If all ids are requested then we are forced to replay the entire log.
5361
for maybe_data in replay_for_app_ids(log_segment, engine)? {
@@ -91,8 +99,10 @@ mod tests {
9199

92100
use super::*;
93101
use crate::engine::sync::SyncEngine;
102+
use crate::utils::test_utils::parse_json_batch;
94103
use crate::Table;
95104

105+
use crate::arrow::array::StringArray;
96106
use itertools::Itertools;
97107

98108
fn get_latest_transactions(
@@ -108,8 +118,8 @@ mod tests {
108118
let log_segment = snapshot.log_segment();
109119

110120
(
111-
SetTransactionScanner::get_all(log_segment, &engine).unwrap(),
112-
SetTransactionScanner::get_one(log_segment, app_id, &engine).unwrap(),
121+
SetTransactionScanner::get_all(log_segment, &engine, None).unwrap(),
122+
SetTransactionScanner::get_one(log_segment, app_id, &engine, None).unwrap(),
113123
)
114124
}
115125

@@ -165,4 +175,45 @@ mod tests {
165175
.unwrap();
166176
assert_eq!(data.len(), 2);
167177
}
178+
179+
#[test]
180+
fn test_txn_retention_filtering() {
181+
let path = std::fs::canonicalize(PathBuf::from("./tests/data/app-txn-with-last-updated/"));
182+
let url = url::Url::from_directory_path(path.unwrap()).unwrap();
183+
let engine = SyncEngine::new();
184+
185+
let table = Table::new(url);
186+
let snapshot = table.snapshot(&engine, None).unwrap();
187+
let log_segment = snapshot.log_segment();
188+
189+
// Test with no retention (should get all transactions)
190+
let all_txns = SetTransactionScanner::get_all(log_segment, &engine, None).unwrap();
191+
assert_eq!(all_txns.len(), 4);
192+
193+
// Test with retention that filters out old transactions
194+
let expiration_timestamp = Some(100); // Very old timestamp
195+
let filtered_txns =
196+
SetTransactionScanner::get_all(log_segment, &engine, expiration_timestamp).unwrap();
197+
198+
// Exact count depends on test data
199+
assert!(filtered_txns.len() <= all_txns.len());
200+
}
201+
202+
#[test]
203+
fn test_visitor_retention_with_null_last_updated() {
204+
let json_strings: StringArray = vec![
205+
r#"{"txn":{"appId":"app_with_time","version":1,"lastUpdated":100}}"#,
206+
r#"{"txn":{"appId":"app_without_time","version":2}}"#,
207+
]
208+
.into();
209+
let batch = parse_json_batch(json_strings);
210+
211+
let mut visitor = SetTransactionVisitor::new(None, Some(1000));
212+
visitor.visit_rows_of(batch.as_ref()).unwrap();
213+
214+
// app_with_last_updated should be filtered out (100 < 1000)
215+
// app_without_last_updated should be kept
216+
assert_eq!(visitor.set_transactions.len(), 1);
217+
assert!(visitor.set_transactions.contains_key("app_without_time"));
218+
}
168219
}

kernel/src/actions/visitors.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,14 +301,18 @@ pub(crate) type SetTransactionMap = HashMap<String, SetTransaction>;
301301
pub(crate) struct SetTransactionVisitor {
302302
pub(crate) set_transactions: SetTransactionMap,
303303
pub(crate) application_id: Option<String>,
304+
/// Minimum timestamp for transaction retention. Transactions with last_updated
305+
/// older than or equal to this timestamp will be filtered out. None means no filtering.
306+
expiration_timestamp: Option<i64>,
304307
}
305308

306309
impl SetTransactionVisitor {
307310
/// Create a new visitor. When application_id is set then bookkeeping is only for that id only
308-
pub(crate) fn new(application_id: Option<String>) -> Self {
311+
pub(crate) fn new(application_id: Option<String>, expiration_timestamp: Option<i64>) -> Self {
309312
SetTransactionVisitor {
310313
set_transactions: HashMap::default(),
311314
application_id,
315+
expiration_timestamp,
312316
}
313317
}
314318

@@ -353,6 +357,14 @@ impl RowVisitor for SetTransactionVisitor {
353357
.is_none_or(|requested| requested.eq(&app_id))
354358
{
355359
let txn = SetTransactionVisitor::visit_txn(i, app_id, getters)?;
360+
// Check retention: filter out transactions that are old
361+
// If last_updated is None, the transaction never expires
362+
match self.expiration_timestamp.zip(txn.last_updated) {
363+
Some((expiration_ts, last_updated)) if last_updated <= expiration_ts => {
364+
continue
365+
}
366+
_ => (),
367+
}
356368
if !self.set_transactions.contains_key(&txn.app_id) {
357369
self.set_transactions.insert(txn.app_id.clone(), txn);
358370
}

0 commit comments

Comments
 (0)