--- title: "What Iceberg Is, and What It Fixes" author: "Youzhi Yu
University of Chicago" bibliography: vignette_reference.bib output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{What Iceberg Is, and What It Fixes} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(icebergr) ``` A table format is not a file format. Parquet says how one file is laid out; Iceberg says which files constitute a table right now, what their schema is, and what the table looked like an hour ago. This vignette is about that distinction, because it is the reason reading Parquet is not the same as reading an Iceberg table, and the reason `arrow` cannot substitute for a client. ## The problem inherited from Hive The original convention, from Hive [@thusoo2009hive], was that a table *is* a directory, and a partition *is* a subdirectory. Everything follows from that: to know what a table contains, list the filesystem. Three consequences made it unworkable at scale, and they are what the lakehouse table formats were designed against [@armbrust2021lakehouse]: * **No atomicity.** A writer that must add ten files has no way to make the ten appear together. Readers see a partial commit. * **Listing is the query plan.** Planning cost scales with the number of files and, on object storage, each listing is a network round trip with eventual consistency behind it. * **Schema is a directory convention.** Renaming a column, or changing how a table is partitioned, means rewriting paths, so in practice nobody does it. Delta Lake [@armbrust2020delta] and Iceberg [@icebergspec] answer the same question in the same shape (keep the file list in metadata, commit by swapping a pointer) and differ in how that metadata is organised. A comparison of the three main implementations, with a benchmark, is in @jain2023lakehouse. ## What Iceberg keeps instead Iceberg's metadata is a tree, and every level of it is immutable: | Level | Holds | Written as | | --- | --- | --- | | Table metadata | current schema, partition specs, snapshot log, properties | JSON | | Snapshot | one manifest list: the state of the table at one instant | referenced from the metadata | | Manifest list | the manifests in this snapshot, with partition ranges | Avro | | Manifest | data files, with per-column bounds, null counts and row counts | Avro | | Data file | the rows | Parquet, ORC or Avro | A commit writes new metadata and then atomically swaps the pointer to it. Nothing is mutated, which is why a reader mid-commit sees the old table rather than half of the new one, and why yesterday's snapshot is still readable today: it is still there, and still consistent. `icebergr` exposes each level. The schema and partition spec come from the table metadata: ```{r} tbl <- icebergr_example_table(rows = 200) icebergr_schema(tbl) icebergr_partitions(tbl) ``` Its snapshots are the history: ```{r} icebergr_snapshots(tbl)[, c("snapshot_id", "operation", "added_records")] ``` And the manifests are what makes a scan plan possible without opening any data file, since `icebergr_scan_plan()` reads bounds out of the manifest rather than out of Parquet: ```{r} plan <- icebergr_scan_plan(icebergr_scan(tbl)) # `data_file_path` is elided: it is an absolute path in a temporary # warehouse, so it would differ on every machine. plan[, c("record_count", "file_size_in_bytes")] ``` ## Why the column statistics matter so much Because per-column bounds live in the manifest, a filter can eliminate a file before it is opened. That is the whole performance argument, and it is inherited twice over: once from the manifest, and once from Parquet's own footer, whose row-group statistics allow the same trick within a file. The columnar layout underneath is Dremel's [@melnik2010dremel]: repetition and definition levels, which is how a nested `struct` or `list` survives being stored one column at a time. The gains from reading only the columns a query needs, and only the row ranges that can match, are the ones measured for column stores generally in @abadi2008column, and re-measured for Parquet and ORC specifically, with their modern encodings, in @zeng2023columnar. `vignette("pushdown")` shows what `icebergr` pushes down and how to confirm it happened. ## Concurrency, without a lock server Iceberg commits optimistically: a writer reads the current metadata, prepares a new snapshot, and swaps the pointer only if the pointer has not moved. If it has, the commit fails and is retried. All three lakehouse formats work this way [@jain2023lakehouse]. What a reader gets from this is snapshot isolation in the sense of @berenson1995critique: a scan runs entirely against one snapshot, so a long-running read never observes a write that landed halfway through it. In `icebergr` this is explicit rather than incidental, because a table handle *is* a snapshot. Another session's commit is invisible until you ask for it: ```{r, eval = FALSE} tbl <- icebergr_reload(tbl) # now points at the newest snapshot ``` That is a feature, not a staleness bug: two scans off the same handle are guaranteed to agree. ## Where R sits in this The design that makes an R client possible at all is the one described in @pedreira2023composable: a data system decomposed into reusable components with Arrow as the interchange format between them, rather than a monolith with one front end. `icebergr` uses three of those components (`iceberg-rust` [@icebergrust] for metadata and planning, Parquet readers underneath it, and the Arrow C stream interface [@arrowcdata] to hand the result to R) and adds no execution engine of its own. The alternative route, going through DuckDB [@raasveldt2019duckdb], is a good one for queries and remains available; since version 1.4 it can also write to a table in a REST catalog. What a query engine hands back is the answer to a query. A client hands back the table: a handle bound to one snapshot, the schema as of any of them, and the plan before a byte of data is read. Engines whose whole design centres on this format, Photon for instance [@behm2022photon], treat the metadata as a first-class input for the same reason. ## What this package does not do Iceberg's spec is larger than any single client implements, and this package is narrower than `iceberg-rust`. `icebergr_spec_support()` reports the boundary for your own build, and distinguishes the two cases, missing here versus missing upstream: ```{r} support <- icebergr_spec_support() features <- support$features head(features[ !is.na(features$supported) & !features$supported, c("feature", "reason") ], 8) ``` Reading a table another engine wrote with features this package does not implement still works, as long as the scan does not need them. That is the property the format was designed to have. # References