--- title: "Working with Transactions" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Working with Transactions} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} # Evaluate chunks only where the ducklake DuckDB extension is already # installed. The probe never downloads anything, so building this vignette # needs no network access. ducklake_available <- ducklake::ducklake_extension_available() knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = ducklake_available ) # Use a unique temp directory for this vignette to avoid conflicts during R CMD check vignette_temp_dir <- file.path(tempdir(), "transactions_vignette") dir.create(vignette_temp_dir, showWarnings = FALSE, recursive = TRUE) knitr::opts_knit$set(root.dir = vignette_temp_dir) ``` ```{r setup, message=FALSE} library(ducklake) library(dplyr) # Setup for examples attach_ducklake("transactions_lake", lake_path = vignette_temp_dir) ``` ## Introduction Transactions are essential for maintaining data integrity when making multiple related changes to your data lake. DuckLake provides full ACID (Atomicity, Consistency, Isolation, Durability) transaction support, ensuring that either all operations succeed or none do. DuckLake offers two approaches for working with transactions: 1. **`with_transaction()` (Recommended)**: A modern, R-idiomatic approach that automatically handles errors and rollbacks 2. **`begin_transaction()` / `commit_transaction()` / `rollback_transaction()`**: Manual transaction control for advanced use cases This vignette demonstrates both approaches and explains when to use each one. ## Setup: Loading Initial Data We'll use the `mtcars` dataset throughout this vignette to demonstrate transaction workflows. ```{r load-data} # Load initial data with_transaction( create_table(mtcars, "cars"), author = "Tutorial", commit_message = "Initial load of mtcars dataset" ) # View the data get_ducklake_table("cars") |> select(mpg, cyl, hp, wt) |> head() ``` ## Approach 1: with_transaction() (Recommended) The `with_transaction()` function provides automatic error handling and cleanup, similar to the [withr](https://withr.r-lib.org/) `with_*()` pattern used throughout the R ecosystem. This is the **recommended approach** for most use cases. ### Why use with_transaction()? - **Automatic rollback on error**: If any operation fails, all changes are automatically rolled back - **Cleaner code**: No need to manually call `begin_transaction()` and `commit_transaction()` - **Built-in metadata support**: Easily add author and commit messages - **Safer**: Prevents accidentally leaving transactions open - **R-idiomatic**: Follows familiar patterns from packages like `withr` ### Single Operation with Metadata ```{r with-transaction-single} # Correct values in place, with automatic metadata tracking: the filter # becomes the WHERE clause of a single UPDATE with_transaction( get_ducklake_table("cars") |> filter(cyl == 4) |> mutate(mpg = mpg * 1.05) |> ducklake_exec(), author = "Data Team", commit_message = "Apply the revised 4-cylinder efficiency factor" ) # Verify the change get_ducklake_table("cars") |> select(mpg, cyl) |> head() ``` ### Multiple Operations in a Single Transaction You can group multiple operations together by wrapping them in curly braces: ```{r with-transaction-multiple} # Multiple related changes in one atomic transaction with_transaction({ # Add efficiency rating get_ducklake_table("cars") |> mutate( efficiency = case_when( mpg >= 25 ~ "high", mpg >= 20 ~ "medium", TRUE ~ "low" ) ) |> replace_table("cars") # Create a summary table get_ducklake_table("cars") |> group_by(cyl) |> summarize( avg_mpg = mean(mpg, na.rm = TRUE), avg_hp = mean(hp, na.rm = TRUE), count = n() ) |> create_table("cars_summary") }, author = "Data Team", commit_message = "Add efficiency ratings and summary table") # View results get_ducklake_table("cars") |> select(mpg, cyl, efficiency) |> head() get_ducklake_table("cars_summary") |> collect() ``` ### Automatic Rollback on Error One of the key benefits of `with_transaction()` is automatic error handling: ```{r with-transaction-error} # This transaction will fail and automatically rollback tryCatch( with_transaction({ # This will succeed get_ducklake_table("cars") |> mutate(test_column = "temporary") |> replace_table("cars") # This will fail stop("Simulated error - something went wrong!") }, author = "Data Team", commit_message = "This will be rolled back"), error = function(e) { message("Transaction automatically rolled back: ", e$message) } ) # Verify that test_column was NOT added (transaction was rolled back) get_ducklake_table("cars") |> colnames() # View all versioned changes list_table_snapshots("cars") ``` ## Approach 2: Manual Transaction Control For advanced use cases where you need explicit control over transaction boundaries, DuckLake provides manual transaction functions. ### When to use manual transactions? - **Interactive workflows**: When you want to inspect data between operations before committing - **Conditional commits**: When commit logic depends on runtime conditions - **Long-running transactions**: When you need fine-grained control over transaction lifecycle - **Legacy code**: When migrating from other transaction systems ### Basic Manual Transaction Workflow ```{r manual-basic} # Start a transaction begin_transaction() # Make changes: declare a column, then fill it in place add_table_column("cars", "weight_kg", "DOUBLE") get_ducklake_table("cars") |> mutate(weight_kg = wt * 453.592) |> ducklake_exec() # Commit the changes with metadata commit_transaction( author = "Data Team", commit_message = "Add weight in kg" ) # Verify changes get_ducklake_table("cars") |> filter(cyl == 4) |> select(wt, weight_kg) |> head() ``` ### Manual Rollback Sometimes you may want to inspect data before deciding whether to commit: ```{r manual-rollback} # Start a transaction begin_transaction() # Make a test change add_table_column("cars", "test_flag", "BOOLEAN", default = TRUE) # Check the result test_result <- get_ducklake_table("cars") |> select(mpg, test_flag) |> head() |> collect() test_result # Decide to rollback rollback_transaction() # Verify the change was NOT applied "test_flag" %in% colnames(get_ducklake_table("cars")) # View all versioned changes list_table_snapshots("cars") ``` ### Snapshot Metadata: At Commit Time vs After the Fact DuckLake supports two ways to attach metadata (author, commit message, and optional extra info) to a snapshot. **At commit time (recommended)**: Pass `author`, `commit_message`, and/or `commit_extra_info` to `commit_transaction()` or `with_transaction()`. This uses the DuckLake v1.0 `set_commit_message()` API to record metadata as part of the transaction itself, before the commit is finalized. ```{r metadata-at-commit} # Metadata set at commit time (preferred approach) begin_transaction() add_table_column("cars", "hp_per_liter", "DOUBLE") get_ducklake_table("cars") |> mutate(hp_per_liter = hp / (cyl * 0.5)) |> ducklake_exec() commit_transaction( author = "Performance Team", commit_message = "Add horsepower per liter metric", commit_extra_info = '{"ticket": "DATA-123"}' ) get_ducklake_table("cars") |> select(hp, cyl, hp_per_liter) |> head() ``` **Once per session**: a session that commits as one person can name the author once, with `options(ducklake.author = "Performance Team")`. Every `commit_transaction()`, `with_transaction()`, and `restore_table_version()` call in the session then records that author unless the call names another. The creation snapshot gets it too, when `attach_ducklake()` creates a lake. `set_snapshot_metadata()` does not read the option: the person labeling a snapshot after the fact is not always the one who committed it. **After the fact**: `set_snapshot_metadata()` fills in the metadata of a snapshot that was committed without any, such as a quick interactive change. The latest snapshot is the target unless `snapshot_id` names another, such as snapshot 0 of a lake created before ducklake labeled it at attach time. It updates the `ducklake_snapshot_changes` metadata table directly, outside DuckLake's transaction model, so by default it only fills empty fields and refuses to replace a value that is already there: ```{r metadata-after-fact} # A quick interactive change, committed without metadata begin_transaction() rows_update( get_ducklake_table("cars"), data.frame(mpg = 21, hp_per_liter = 36.7), by = "mpg" ) commit_transaction() # Fill in the metadata afterwards set_snapshot_metadata( ducklake_name = "transactions_lake", author = "Performance Team", commit_message = "Correct hp_per_liter for the 21 mpg cars" ) # Replacing an existing value takes overwrite = TRUE, and leaves no trace # of the previous value try( set_snapshot_metadata("transactions_lake", commit_message = "Reworded") ) ``` Both approaches populate the same fields. Set metadata at commit time whenever you can: it is recorded inside the transaction, by DuckLake itself. Keep `set_snapshot_metadata()` for the occasional snapshot that was committed without it. Where the snapshot history is the audit trail, treat `overwrite = TRUE` as what it is: an edit to that trail. ## Viewing Transaction History Regardless of which approach you use, all transactions are tracked with complete metadata: ```{r view-history} # View recent transaction history list_table_snapshots("cars") |> select(snapshot_id, snapshot_time, author, commit_message) |> tail(5) ``` ## Working with several writers When a lake has more than one writer (a PostgreSQL or SQLite catalog shared by several sessions, or a Quack server), two transactions can race to commit the next snapshot. DuckLake resolves the race itself: the transaction that lost checks whether its changes conflict with the winner's, and when they do not, it is retried against the new snapshot. Two sessions appending to the same table, or editing different tables, both commit. Real conflicts surface as an error in the losing session, whose changes are rolled back: two transactions deleting from the same data file, an insert into a table the other transaction dropped or altered, or two creates of the same name. Re-run that transaction on the current data. The retry policy is a per-session DuckDB setting: 10 attempts, 100 ms apart, with the wait growing by 1.5 each time. `set_ducklake_retry()` adjusts it for a busy catalog: ```{r retry, eval=FALSE} set_ducklake_retry(max_retries = 20, wait_ms = 250, backoff = 2) ``` ## Comparison: with_transaction() vs Manual Control | Feature | `with_transaction()` | Manual (`begin/commit/rollback`) | |---------|---------------------|----------------------------------| | **Ease of use** | ✅ Simple, one function | ❌ Requires multiple function calls | | **Error handling** | ✅ Automatic rollback | ❌ Must handle manually | | **Metadata** | ✅ Inline with transaction | ✅ Inline via parameter, or filled in later via `set_snapshot_metadata()` | | **Safety** | ✅ Can't forget to commit | ❌ Risk of open transactions | | **Use case** | Most production workflows | Interactive/conditional workflows | | **Code clarity** | ✅ Clear transaction scope | ⚠ Scope can be unclear | ## Best Practices 1. **Default to `with_transaction()`**: Use it for all standard workflows 2. **Always add metadata**: Include `author` and `commit_message` for audit trails; `options(ducklake.author = ...)` names the author once for a whole session 3. **Keep transactions focused**: Group related changes, but avoid overly long transactions; when other sessions write to the same lake, a long-open transaction is the one most likely to conflict 4. **Handle errors gracefully**: When using manual transactions, always use `tryCatch()` to ensure rollback 5. **Test rollback behavior**: Verify that your error handling works correctly ## Key Concepts Summary - **`with_transaction(expr, author, commit_message, commit_extra_info)`**: Modern, automatic transaction handling (recommended) - **`begin_transaction()`**: Start a manual transaction - **`commit_transaction(author, commit_message, commit_extra_info)`**: Apply changes from a manual transaction, with optional metadata set at commit time via the DuckLake v1.0 API - **`rollback_transaction()`**: Discard changes from a manual transaction - **`set_snapshot_metadata()`**: Fill in metadata a snapshot was committed without, the most recent one unless `snapshot_id` says otherwise (`overwrite = TRUE` to replace a value) Transactions ensure data integrity and provide complete audit trails for all changes in your DuckLake. ```{r cleanup, include=FALSE} detach_ducklake("transactions_lake") ```