---
title: "Getting started with icebergr"
author: "Youzhi Yu
University of Chicago"
bibliography: vignette_reference.bib
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{Getting started with icebergr}
%\VignetteEngine{knitr::rmarkdown}
%\VignetteEncoding{UTF-8}
---
```{r, include = FALSE}
knitr::opts_chunk$set(
collapse = TRUE,
comment = "#>"
)
# Handles and catalogs print their `location:` / `warehouse:`, which is a
# temporary directory unique to whichever machine built this page. Printing it
# would bake that path into the shipped HTML, so it is replaced here.
show <- function(x) {
writeLines(gsub(tempdir(), "", capture.output(print(x)), fixed = TRUE))
}
```
Every example here runs offline against a table built on your own machine. No
catalog server, no network, no credentials.
```{r setup}
library(icebergr)
```
## What this package is for
Iceberg [@icebergspec] is the open table format that Snowflake, Databricks,
BigQuery, AWS and Dremio have all standardised on. Apache maintains clients for
Java, Python, Rust and Go, but not for R, where Iceberg has been reached through
a query engine such as DuckDB [@raasveldt2019duckdb]. A client is a different
thing: it hands you the table itself, with its snapshots, its schema as of each
of them and its scan plan, as R objects.
`icebergr` binds `iceberg-rust` [@icebergrust] directly through extendr
[@extendr]. Arrow is the interchange layer, so scan results arrive in R over the
C stream interface [@arrowcdata] without a serialisation round trip.
`vignette("table-format")` explains the format itself, and why a client rather
than an exporter is the thing worth having.
## A table to work with
`icebergr_example_table()` builds a real Iceberg table in a temporary warehouse
directory: two appends, so there is history to travel through and more than one
data file for a filter to prune.
```{r}
tbl <- icebergr_example_table(rows = 200)
show(tbl)
```
It is generated rather than shipped because Iceberg records absolute paths in its
metadata and its Avro manifests, so a table built on one machine does not
resolve on another.
## Inspecting a table
```{r}
icebergr_schema(tbl)
```
`type` is the Iceberg type, not the R type. The mapping to R happens on read.
An unpartitioned table returns zero partition fields:
```{r}
icebergr_partitions(tbl)
```
## Reading
`icebergr_scan()` describes a read; `icebergr_collect()` performs it.
```{r}
icebergr_collect(icebergr_scan(tbl, limit = 5))
```
Scanning the whole table is common enough to have a shorthand:
```{r}
nrow(icebergr_collect(tbl))
```
### Pushdown
`filter` and `select` are pushed into scan planning. This is the entire
performance argument for Iceberg over reading raw Parquet: manifests carry
per-file statistics, so whole files and row groups are eliminated before any
bytes are read.
```{r}
icebergr_collect(
icebergr_scan(
tbl,
filter = id > 1000 & amount > 900,
select = c("id", "event", "amount")
)
)
```
Filters may use `==`, `!=`, `<`, `<=`, `>`, `>=`, `&`, `|`, `!`, `%in%`,
`is.na()`, `is.nan()` and `startsWith()` -- the last only against a `string`
column, since Iceberg defines a prefix comparison for no other type. A bare name
is read as a column when the table has one of that name, and otherwise evaluated
in the calling environment:
```{r}
# The second append holds ids 1001 to 1200, so this keeps the last fifty.
threshold <- 1150
icebergr_collect(icebergr_scan(tbl, filter = id > threshold, select = "id"))
```
Anything more elaborate is refused rather than quietly ignored, so you always
know whether a filter was pushed down:
```{r, error = TRUE}
icebergr_collect(icebergr_scan(tbl, filter = sqrt(amount) > 10))
```
### Verifying that pushdown happened
Comparing results proves nothing: a filter applied in R afterwards gives the same
rows. What distinguishes pushdown is how much was planned to be read.
```{r}
icebergr_scan_plan(icebergr_scan(tbl))[, c("record_count", "file_size_in_bytes")]
```
```{r}
icebergr_scan_plan(icebergr_scan(tbl, filter = id > 1000))[, c("record_count")]
```
Fewer files, and fewer records, than the table holds.
### One caveat about `limit`
`limit` is **not** pushdown. `iceberg-rust` has no row limit in its scan API, so
the same files are planned and rows are counted as batches arrive. It bounds how
much is decoded, not how much is planned. `print()` says so:
```{r}
icebergr_scan(tbl, filter = id > 1000, limit = 10)
```
## Time travel
Every write creates a snapshot.
```{r}
history <- icebergr_snapshots(tbl)
history[, c("snapshot_id", "operation", "added_records", "total_records")]
```
Snapshot ids are **character**, not numeric. Iceberg assigns them as random
64-bit integers, and an R numeric carries only 53 bits, so passing one through a
double would silently select the wrong snapshot.
Read an earlier state by id:
```{r}
nrow(icebergr_collect(icebergr_scan(tbl, snapshot_id = history$snapshot_id[[1]])))
```
Or by time, which is resolved against the history to the snapshot that was
current at that moment:
```{r}
nrow(icebergr_collect(icebergr_scan(tbl, as_of = history$timestamp[[1]])))
```
Iceberg records a schema per snapshot, so `filter` and `select` are resolved
against the schema of the snapshot actually being read rather than the current
one. A column another engine has since renamed or dropped is therefore still
nameable as of the snapshot that had it, and `icebergr_schema()` will show you
what those columns were:
```{r}
icebergr_schema(tbl, snapshot_id = history$snapshot_id[[1]])
```
## Writing
Writes are append-only. Nothing already in the table is rewritten or removed.
```{r}
new_rows <- data.frame(
id = c(9001L, 9002L),
event = c("purchase", "refund"),
amount = c(42.5, -12.25),
day = as.Date(c("2024-07-01", "2024-07-02")),
recorded_at = as.POSIXct(c("2024-07-01 09:00:00", "2024-07-02 10:30:00"), tz = "UTC")
)
tbl <- icebergr_append(tbl, new_rows)
nrow(icebergr_collect(tbl))
```
`icebergr_append()` returns an *updated* handle rather than mutating the old one,
so reassign it. The old handle still reads the older snapshot, which is
occasionally useful and never surprising.
Columns are matched by name, not position, so order does not matter. A column the
table does not have is an error rather than a silent drop:
```{r, error = TRUE}
icebergr_append(tbl, transform(new_rows, unexpected = 1))
```
### Creating a table
A data frame is enough to define a schema. Only the column names and types are
used; no rows are written.
```{r}
warehouse <- tempfile("warehouse")
dir.create(warehouse)
catalog <- icebergr_catalog("memory", warehouse = warehouse)
icebergr_create_namespace(catalog, "analytics")
measurements <- data.frame(
sensor = character(),
reading = double(),
taken_at = as.POSIXct(character(), tz = "UTC")
)
sensors <- icebergr_create_table(catalog, "analytics.measurements", measurements)
icebergr_schema(sensors)
```
## Type fidelity
Most R types survive unchanged. The exception worth knowing is `factor`: Iceberg
has no dictionary type, so levels cannot be carried and values come back as
`character`.
```{r}
types <- data.frame(
i = 1L,
d = 1.5,
s = "text",
b = TRUE,
day = as.Date("2024-01-01"),
ts = as.POSIXct("2024-01-01 12:00:00", tz = "UTC"),
f = factor("a", levels = c("a", "b"))
)
type_tbl <- icebergr_create_table(catalog, "analytics.types", types)
type_tbl <- icebergr_append(type_tbl, types)
vapply(icebergr_collect(type_tbl), function(x) class(x)[[1]], character(1))
```
## Knowing what is not supported
`icebergr` is deliberately narrow. Rather than discovering a gap at
runtime, ask:
```{r}
support <- icebergr_spec_support()
support$spec_versions
```
```{r}
features <- support$features
unsupported <- !is.na(features$supported) & !features$supported
features[unsupported, c("feature", "reason")]
```
Some of those are absent from `iceberg-rust` itself, not just from this package;
the `reason` column says which.
## Where next
Six vignettes go deeper on one theme each:
* `vignette("table-format")`: what Iceberg is, and what it fixes
* `vignette("pushdown")`: how a filter prunes files and row groups
* `vignette("time-travel")`: snapshots, timestamps and rollbacks
* `vignette("writing")`: appends, table creation, and what is refused
* `vignette("types")`: the R, Arrow and Iceberg type correspondence
* `vignette("catalog-configuration")`: REST, AWS Glue, object storage,
and how credentials are handled
# References