Add a black-box RTAS behavior test suite - #657
Conversation
RTAS defines a new table body, so it is intentionally allowed to drop columns and repartition — evolutions the incremental ALTER path forbids. Add a black-box SQL pin test so a future change can't silently start applying the update-path schema/partition guards to the replace path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the single spec-change/column-drop pin with RtasBehaviorTest, a cohesive set of black-box SQL tests (real embedded server) for CREATE OR REPLACE TABLE AS SELECT: may drop a column, add a column, change the partition spec, remove partitioning, replace the data, preserve the table identity (openhouse.tableUUID), and is rejected when replace.enabled is not set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Broadens the RTAS integration coverage per the RTAS product spec, all as black-box Spark SQL against the embedded OpenHouse server. RtasBehaviorTest (+4): - orc->parquet file-format change via RTAS lands parquet data files - encryption-off via RTAS is permitted and leaves the table readable - pre-replace snapshot stays readable via VERSION AS OF on a disconnected timeline (old schema+body); pins current behavior vs the spec future time-travel-should-error gate - restore to a pre-replace snapshot works, with data loss on the replaced body RtasDmlAfterReplaceTest (new, 7): after a table is replaced via RTAS, ordinary DML still behaves correctly. Copy-on-write insert/delete/update/merge/insert- overwrite/read, plus a merge-on-read delete that still emits ORC position delete files (asserted via .delete_files metadata, matching the existing DeleteFileReplicationTestSpark strategy to avoid the shaded delete-loader classpath collision on readback). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| .collect(Collectors.toMap(r -> r.getString(0), r -> r.getString(1))); | ||
| } | ||
|
|
||
| private static List<String> dataFilePaths(SparkSession spark, String table) { |
There was a problem hiding this comment.
used in one funciton, not needed ot be a helper.
There was a problem hiding this comment.
Done (7dbce83) — inlined dataFilePaths (it was only used in this one test).
| .collect(Collectors.toList()); | ||
| } | ||
|
|
||
| private static Map<String, String> tableProperties(SparkSession spark, String table) { |
There was a problem hiding this comment.
remove the helper and inline.
There was a problem hiding this comment.
tableProperties is used in 5 tests, so I kept it as a shared helper rather than duplicate the same SHOW TBLPROPERTIES stream in each. I inlined the genuinely single-use helpers (dataFilePaths, tableArg). Happy to inline this one too if you'd rather. (7dbce83)
| spark.sql("ALTER TABLE " + table + " SET TBLPROPERTIES ('replace.enabled'='true')"); | ||
| } | ||
|
|
||
| private static List<String> columnsOf(SparkSession spark, String table) { |
There was a problem hiding this comment.
remove the helper and inline.
There was a problem hiding this comment.
columnsOf is used in 3 tests — same reasoning as the tableProperties thread: kept it shared to avoid duplicating the DESCRIBE TABLE stream. Single-use helpers were inlined. (7dbce83)
| @@ -0,0 +1,232 @@ | |||
| package com.linkedin.openhouse.spark.catalogtest; | |||
There was a problem hiding this comment.
why is this in 3.5 dir vs 3.1? I would prefer all tests added in 3.5.
There was a problem hiding this comment.
Moved — all RTAS tests now live in the spark-3.5 itest dir (3.5-only). This file (RtasDmlAfterReplaceTest) is deleted; its coverage is folded into the new RtasDmlMatrixTest. (7dbce83)
| * <p>The outcome is asserted purely from the {@code .delete_files} metadata table. The table body | ||
| * is intentionally NOT read back: applying position deletes on read goes through Iceberg's own | ||
| * (shaded) delete-loader, which collides with the unshaded Parquet/ORC classes on this shared | ||
| * integration-test task's classpath. The dedicated {@code DeleteFileReplicationTestSpark} uses |
There was a problem hiding this comment.
Test with ORC too. Fix the classpath issue.
There was a problem hiding this comment.
Fixed the root cause rather than working around it. The itest classpath carried both the shaded ORC bundled in the runtime uber jar (org.apache.iceberg.shaded...orc) and the unshaded iceberg-data/orc-core, so Iceberg's merge-on-read delete loader mixed the two on read-back (the ClassCastException). Excluded iceberg-data from the module (same as apps/spark-3.5), so merge-on-read now reads back on both ORC and Parquet. The new matrix exercises ORC × merge-on-read across the full DML set. (7dbce83)
| List<Row> restored = | ||
| spark.sql("SELECT id, data FROM " + table + " ORDER BY id").collectAsList(); | ||
| assertEquals(2, restored.size(), "restore should bring back the pre-replace row count"); | ||
| assertEquals( | ||
| 1L, restored.get(0).getLong(0), "restore should bring back the pre-replace body"); | ||
| assertEquals( | ||
| 2L, restored.get(1).getLong(0), "restore should bring back the pre-replace body"); | ||
| assertTrue( | ||
| spark.sql("SELECT id FROM " + table + " WHERE id = 100").collectAsList().isEmpty(), | ||
| "the replaced body should be lost after restoring the earlier snapshot"); |
There was a problem hiding this comment.
you need to test DML after replace. You have not applied teh lessons of the framework. Write a Set of DML tests that take in a table. That should work normally, after rtas, after rtas+restore, with or without MoR enabled.
The test for dml is "i have a table, do dml tests" and we are preparing different tables for that. Its fine to test the restore behaviour like we are, but DML needs to be tested after the restore.
I see it as (BaseTable, BaseTAble + RTAS, BaseTable + RTAS + Restore, (Basetable + Mor), (Basetable + Mor) +RTAS,...).foreach(table -> runAllDmlTests(Table))
We get to the list of preparations by multiplexing over properties.
There was a problem hiding this comment.
Rebuilt as RtasDmlMatrixTest. Table preparations are multiplexed over properties — write mode (copy-on-write / merge-on-read) × file format (Parquet / ORC) × partitioning × lifecycle (base, RTAS, RTAS+restore) — and the same DML set (read / insert / delete / update / merge / insert-overwrite) runs against every preparation. Every prep converges to the same canonical seed, so one assertion is valid for all of them: 144 cases (155 RTAS tests total). DML is now tested after RTAS and after RTAS+restore, with and without merge-on-read.
One behavior note: with PARTITIONED BY (data) each seed row is its own single-row file, so a single-row merge-on-read DELETE removes the whole file as a metadata-only delete (no position delete) — correct Iceberg behavior — so the delete-file assertion is scoped to the unpartitioned cases; row-level correctness is asserted everywhere. (7dbce83)
…ck classpath Addresses the review feedback on the RTAS test suite: - Fix the merge-on-read read-back classpath collision instead of working around it: exclude iceberg-data from the spark-3.5 itest classpath (as apps/spark-3.5 already does) so the shaded ORC in the runtime uber jar no longer clashes with the unshaded ORC used by Iceberg's delete loader. This lets merge-on-read ORC/Parquet tables read back correctly in-module. - Move the RTAS tests into the spark-3.5 itest dir (3.5-only) and inline the single-use helpers in RtasBehaviorTest. - Replace the one-off DML test with RtasDmlMatrixTest: a parameterized matrix multiplexing table preparations over write mode (copy-on-write / merge-on- read), file format (Parquet / ORC), partitioning, and lifecycle (base / RTAS / RTAS+restore), running the same DML set (read/insert/delete/update/ merge/insert-overwrite) against every preparation. Every preparation converges to the same canonical seed, so one assertion is valid for all; 144 cases. Merge-on-read delete-file production is asserted for the unpartitioned cases (a single-row-per-file partitioned delete is a whole-file metadata delete and correctly writes no position delete). RtasBehaviorTest (11) + RtasDmlMatrixTest (144) = 155 RTAS cases; full catalogTest green on spark-3.5. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| for (WriteMode writeMode : WriteMode.values()) { | ||
| for (FileFormat format : FileFormat.values()) { | ||
| for (Partitioning partitioning : Partitioning.values()) { | ||
| for (Lifecycle lifecycle : Lifecycle.values()) { | ||
| for (DmlOp op : DmlOp.values()) { |
There was a problem hiding this comment.
map and stream functions, not nested fors.
There was a problem hiding this comment.
Done — cases() is now a flatMap cartesian product over the axis enums, no nested loops. (621dc27)
| BASE, | ||
| RTAS, | ||
| RTAS_RESTORE; |
There was a problem hiding this comment.
add block comment explanations
There was a problem hiding this comment.
Added block comments on each axis enum, the preparation thunks, withTable, and the delete-file gating. (621dc27)
| } | ||
|
|
||
| /** Builds a table that ends holding exactly the canonical seed rows, per the requested axes. */ | ||
| private void prepareSeededTable( |
There was a problem hiding this comment.
rather than switch on prepare seeded table just treat each preparation as a lamba that creates a table. A thunk, this can ensure the table is cleaned up. and manage the lifecycle
withTable(params) {
table -> {
table with scoped lifetime
}
}
There was a problem hiding this comment.
Done. Each Lifecycle constant is now a preparation thunk (prepare(...) builds+seeds the table), and withTable(axes, table -> { ... }) gives the table a scoped lifetime — it's always dropped in finally. The prepare switch is gone. (621dc27)
| boolean expectDeleteFiles = | ||
| writeMode.isMergeOnRead() && partitioning == Partitioning.UNPARTITIONED; | ||
| switch (op) { | ||
| case READ: |
There was a problem hiding this comment.
this CASE seems overly verbose. I would remove this runDml function.
if you have the test tables you could to a testTables.foreach(TestInsert) vs a "run DML" function.
There was a problem hiding this comment.
Removed runDml. Each DML operation is now a DmlCheck enum constant with its own run(...) that does the op and asserts, so the test body is just "prepare a table, apply the op". (621dc27)
mkuchenbecker
left a comment
There was a problem hiding this comment.
Overall I think the structure needs work. Think of it more funcitonal than data driven.
Build up the set of tables as preparations. For each preparaation, run each DML test (each can be their own test). The whole point is that we have a list of preparations that can be plugged into each dml test and it should work.
Addresses the review feedback on RtasDmlMatrixTest:
- cases() is now a stream/flatMap cartesian product instead of nested for
loops.
- Table preparations are thunks: each Lifecycle constant knows how to
build+seed its table, and a withTable(...) { table -> ... } helper gives the
table a scoped lifetime and always drops it, replacing the prepare switch.
- DML operations are functions: each DmlCheck constant performs one operation
and asserts its outcome, replacing the runDml switch. The test body is just
"prepare a table, apply the op".
- Added block-comment explanations on the axes, the preparation thunks, and
the delete-file gating.
No behavior change: still 144 cases, all green; teeth-check reproduces the
same 36-failure signature (24 DELETE row-level + 12 merge-on-read
unpartitioned update/merge delete-file) under mutation.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||
| /** The cartesian product of every axis, one JUnit case per combination. */ | ||
| static Stream<Arguments> cases() { | ||
| return Arrays.stream(WriteMode.values()) |
There was a problem hiding this comment.
this doesn't need to be one giant statement. reduce nesting
There was a problem hiding this comment.
The giant nested statement is gone: with each DML operation now a composable check run in sequence on one prepared table, the parameter source is just preparations() (the 4 preparation axes), and the per-op DmlCheck/runDml dispatch was removed entirely. (982bc1f)
| table -> { | ||
| // Sanity: every preparation must converge to the canonical seed before DML runs. | ||
| assertRows(spark, table, SEED_ROWS); | ||
| op.run(spark, table, expectDeleteFiles(writeMode, partitioning)); |
There was a problem hiding this comment.
just inline and remove dmlcheck
There was a problem hiding this comment.
Removed DmlCheck. Each DML operation is now an inline composable check (readCheck/insertCheck/deleteCheck/updateCheck/mergeCheck/insertOverwriteCheck) applied directly to the prepared table. (982bc1f)
| * delete) — correct Iceberg behavior — hence delete files are only expected for the unpartitioned | ||
| * merge-on-read cases, where the seed rows share a data file. | ||
| */ | ||
| private static boolean expectDeleteFiles(WriteMode writeMode, Partitioning partitioning) { |
There was a problem hiding this comment.
get rid of this pattern. This is logic embedded in a test. The test construciton should do the assertion even if we end up duplicating some setup.
There was a problem hiding this comment.
Removed. There's no expectDeleteFiles flag / conditional-assert pattern anymore — merge-on-read delete-file production is asserted only in the dedicated mergeOnRead*WritesDeleteFiles tests, which are constructed for exactly the case (unpartitioned merge-on-read) that produces position delete files. (982bc1f)
| + ")"); | ||
| } | ||
|
|
||
| private static void insertValues(SparkSession spark, String table, String values) { |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| return writeMode.isMergeOnRead() && partitioning == Partitioning.UNPARTITIONED; | ||
| } | ||
|
|
||
| private static void createTable(SparkSession spark, String table, String using, String props) { |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| spark.sql("INSERT INTO " + table + " VALUES " + values); | ||
| } | ||
|
|
||
| private static void replaceAsSelect( |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| + " AS s(id, data)"); | ||
| } | ||
|
|
||
| private static void restoreToSnapshot(SparkSession spark, String table, long snapshotId) { |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| + ")"); | ||
| } | ||
|
|
||
| private static long latestSnapshotId(SparkSession spark, String table) { |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| .getLong(0); | ||
| } | ||
|
|
||
| private static String tableProps(WriteMode writeMode, FileFormat format) { |
There was a problem hiding this comment.
remove helper
There was a problem hiding this comment.
Addressed in the rewrite. The composable-check redesign removed the shared helper this referred to (the preparation is now a plain switch calling the composed steps, and the DML checks are inline). (982bc1f)
| } | ||
|
|
||
| private static void assertDeleteFilesPresent( | ||
| SparkSession spark, String table, boolean expectDeleteFiles) { |
There was a problem hiding this comment.
don't assert if you don;t expect delete files.
There was a problem hiding this comment.
Fixed. The general DML checks no longer assert on delete files at all; only the dedicated unpartitioned merge-on-read tests — which are built specifically to produce position delete files — assert they exist. (982bc1f)
Follow-up to the review: remove the remaining abstraction. - Removed the DmlCheck enum: each DML operation is now its own explicit test (read/insert/delete/update/merge/insertOverwrite), parameterized over the table preparations, doing its own SQL and assertion directly. - Inlined the small SQL builder helpers (createTable/insertValues/ replaceAsSelect/restore/latestSnapshot/tableProps) into the Lifecycle preparations and withTable. - Replaced the expectDeleteFiles/assertDeleteFilesPresent conditional (logic embedded in a shared test) with dedicated merge-on-read tests, constructed for the case that produces position delete files (unpartitioned MoR), that assert unconditionally. - Reduced the preparations() stream nesting (no DmlCheck level) and kept the cartesian product as stream functions. 162 cases (144 row-correctness across preparations + 18 merge-on-read delete-file), all green; teeth-check reproduces 42 failures under mutation (24 row + 18 delete-file), reverts to green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rop' into mkuchenbecker/r5-pin-rtas-spec-drop
| */ | ||
| RTAS_RESTORE { | ||
| @Override | ||
| void prepare(SparkSession spark, String table, String using, String props) { |
There was a problem hiding this comment.
make props kv
There was a problem hiding this comment.
Done. Table properties are now authored as a key/value Map<String,String> (tableProps) and rendered into the TBLPROPERTIES (...) body at the SQL boundary (tblProperties), instead of a hand-concatenated string. (982bc1f)
| * back the seed and drops the junk body. | ||
| */ | ||
| RTAS_RESTORE { | ||
| @Override |
There was a problem hiding this comment.
override is a smell here. you have over-abstracted. This should just be a function call.
There was a problem hiding this comment.
Done. Removed the enum abstract-method override; Lifecycle is now a plain enum and prepare(...) dispatches with a simple switch that calls the composed steps directly — just a function call, no anonymous-subclass hierarchy. (982bc1f)
|
|
||
| /** | ||
| * How the table under test came to exist. Each constant builds a table and leaves it holding | ||
| * exactly the canonical seed rows, so the DML assertions are identical across all three. |
There was a problem hiding this comment.
Assersions should be delta-based not absolute. This is too brittle. Tests should compose.
There was a problem hiding this comment.
Done. The DML checks are now delta-based and compose: each check asserts its own effect relative to the current table state (a count delta, or a row it just wrote), so they're order-independent and repeatable — insertCheck(t); insertCheck(t) both pass — and the whole sequence runs against a single prepared table. (982bc1f)
Reworks the DML matrix around composition. Each DML operation is a composable check that asserts its own effect relative to the current table state (a count delta, a row it just wrote) rather than an absolute post-preparation snapshot, so the checks are order-independent and repeatable: insertCheck(t); insertCheck(t) both pass. A single prepared table is therefore handed through the whole sequence of checks. - One @ParameterizedTest per preparation (writeMode x fileFormat x partitioning x lifecycle) runs read/insert/insert-again/delete/update/ merge/insertOverwrite in sequence on the one table. - A preparation is composed from small steps (createSeeded, rtas, restore); a Lifecycle is just a particular composition of them. - Merge-on-read delete-file production stays in dedicated unpartitioned-MoR tests, where a partial-file delete actually writes position deletes. The fully multiplied matrix is the coverage (preparations x DML checks); the JUnit invocation count is not gamed. All green; every composable check and the delete-file assertion were teeth-checked (each detected a mutation). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses review feedback on RtasDmlMatrixTest: - Replace the Lifecycle enum's per-constant abstract-method override with a plain switch in prepare() — the preparation is just a function call, not an anonymous-subclass hierarchy. - Author table properties as a key/value Map and render them to the TBLPROPERTIES body at the SQL boundary, instead of hand-concatenating a properties string. No behavior change: 42 invocations green (144 row-correctness + 18 merge-on-read delete-file); the merge-on-read delete-file tests continue to pass, which confirms the key/value properties render correctly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
A cohesive set of black-box RTAS tests, driven through Spark SQL against a real embedded OpenHouse
server (
RtasBehaviorTest). RTAS (CREATE OR REPLACE TABLE ... AS SELECT) defines a brand new tablebody, so it is intentionally allowed schema/partition evolutions the incremental
ALTER TABLEpathforbids, while still preserving the table's identity. The suite pins that contract so a future change
can't silently apply the update-path guards to the replace path or lose the table identity.
Tests (
RtasBehaviorTest)testRtasMayDropColumn— RTAS may drop a column.testRtasMayAddColumn— RTAS may add a column.testRtasMayChangePartitionSpec— RTAS may repartition by a different column.testRtasMayRemovePartitioning— RTAS may replace a partitioned table with an unpartitioned body.testRtasReplacesData— the row set after RTAS reflects theSELECT.testRtasPreservesTableIdentity—openhouse.tableUUIDis preserved across the replace.testRtasRejectedWhenReplaceNotEnabled— RTAS is rejected unlessreplace.enabledis set.Testing Done
RtasBehaviorTest(all 7) — green on the spark-3.5 itest. Spotless clean.