Streamhouse · STH-04
Iceberg as streaming storage — commit interval vs small files
- 4 h
- requires STH-03
From your world. Your Iceberg tables: the small-files problem you already fight with compaction, now caused by streaming commit frequency
Stream CDC into Iceberg (and Paimon) — tune commit interval vs file count
You must mirror a Debezium CDC feed (op = c/u/d) onto the lake. The mirror is a table that
reflects the current state (updates and deletes applied, not only appended). You will build the
sink in two ways. You will see the small-files trade-off that frequent commits cause.
Implement sink_tuning.py with four functions that return SQL strings. The test provides the
Iceberg/Paimon catalogs and the bounded debezium-json source. The test also runs the jobs.
def iceberg_target_ddl(table: str) -> str: ... # upsert-capable Iceberg v2 table
def paimon_target_ddl(table: str) -> str: ... # primary-key Paimon table (fixed bucket)
def apply_sql(src: str, tgt: str) -> str: ... # INSERT…SELECT applying the changelog by key
def cdc_source_ddl(name: str, path: str) -> str: ... # bounded debezium-json source over `path`
Requirements
iceberg_target_ddl— columnsid INT, name STRING, amt INT, aPRIMARY KEY (id) NOT ENFORCED, andWITH ('format-version' = '2', 'write.upsert.enabled' = 'true'). The PK, v2 and upsert make the table apply-D/+Uevents by key. Without them, the table only appends and will not reconcile.paimon_target_ddl— same columns,PRIMARY KEY (id) NOT ENFORCED, andWITH ('bucket' = '1')(fixed bucket → deterministic small table). The LSM of Paimon merges by key.apply_sql—INSERT INTO <tgt> SELECT id, name, amt FROM <src>. The debezium-json source already carries the RowKinds. So this one statement applies inserts, updates and deletes.cdc_source_ddl— aCREATE TEMPORARY TABLEfilesystemconnector table overpathwith'format'='debezium-json'and the same columns +PRIMARY KEY (id) NOT ENFORCED.- It must be
TEMPORARY. It is not an Iceberg/Paimon table, so those catalogs cannot persist it. - It is bounded, so the streaming sink finishes and commits. An unbounded source would hang with zero commits.
- It must be
What the tests check
- Reconciliation (G6): The final Iceberg table equals an independent DuckDB fold of the same change events (with the updates and the delete applied). The Paimon table equals it too. Both engines agree on the same CDC.
- Commit interval vs file count: Write all events in one job. This commits exactly one snapshot. Split the same events across three jobs. This commits three snapshots. More commits ⇒ more snapshots ⇒ more (smaller) data files on the lake. The test asserts the snapshot count. This is a deterministic property of commit cadence. The test does not measure wall-clock time.
- The same relationship (one commit per job) holds for the Paimon table.
Note: The Iceberg source table is declared
TEMPORARY. It is afilesystemconnector table, not an Iceberg table. So the Iceberg catalog must not persist it. Thecdc_source_ddlof the exercise must return aCREATE TEMPORARY TABLE ….
A lab_streamhouse Trino assertion is an optional extra layer. Everything above runs with no
Docker on the embedded Flink SQL runner.
Starter code
"""Starter — make the CDC mirror reflect the CURRENT state and keep the source
BOUNDED + TEMPORARY. The stubs below create an append-only Iceberg table (no PK,
no upsert) and persist the source in the catalog, so deletes/updates never apply
and the table will not reconcile with the oracle (G1 fails) until you fix them.
See README.
"""
def iceberg_target_ddl(table: str) -> str:
# TODO: upsert-capable — add PRIMARY KEY (id) NOT ENFORCED and
# WITH ('format-version'='2','write.upsert.enabled'='true').
return f"CREATE TABLE {table} (id INT, name STRING, amt INT)"
def paimon_target_ddl(table: str) -> str:
# TODO: primary-key table WITH ('bucket'='1').
return f"CREATE TABLE {table} (id INT, name STRING, amt INT) WITH ('bucket' = '1')"
def apply_sql(src: str, tgt: str) -> str:
return f"INSERT INTO {tgt} SELECT id, name, amt FROM {src}"
def cdc_source_ddl(name: str, path: str) -> str:
# TODO: make this a CREATE TEMPORARY TABLE (a filesystem/debezium-json table
# cannot be persisted in the Iceberg/Paimon catalog).
return (
f"CREATE TABLE {name} (id INT, name STRING, amt INT, "
f"PRIMARY KEY (id) NOT ENFORCED) WITH ('connector'='filesystem', "
f"'path'='{path}', 'format'='debezium-json')"
)
Hints
Hint 1
The Iceberg target must be upsert-capable so deletes/updates apply by key: declare PRIMARY KEY (id) NOT ENFORCED and WITH ('format-version'='2'). Without the PK the sink only appends and the final state won't reconcile with the oracle.
Hint 2
The sink commits one snapshot per job over the bounded debezium-json source. Writing ALL events in one INSERT…SELECT yields one snapshot and one data file; splitting the same events across many inserts yields many snapshots and many small files — that is the commit-interval-vs-file-count trade-off the test measures.
Hint 3
For the Paimon comparison, declare a primary-key table WITH ('bucket'='1') (fixed bucket for a deterministic small table). The apply SQL is the same INSERT…SELECT; Paimon's LSM merges by key.
uv run de start STH-04/01-commit-vs-files
# edit workspace/STH-04/01-commit-vs-files/
uv run de test