|
| 1 | +package com.linkedin.openhouse.spark.catalogtest.e2e; |
| 2 | + |
| 3 | +import static org.junit.jupiter.api.Assertions.*; |
| 4 | + |
| 5 | +import com.linkedin.openhouse.tablestest.OpenHouseSparkITest; |
| 6 | +import java.util.List; |
| 7 | +import java.util.Set; |
| 8 | +import java.util.stream.Collectors; |
| 9 | +import org.apache.spark.sql.Row; |
| 10 | +import org.apache.spark.sql.SparkSession; |
| 11 | +import org.junit.jupiter.api.Test; |
| 12 | + |
| 13 | +public class ChangelogViewTest extends OpenHouseSparkITest { |
| 14 | + |
| 15 | + private static final String DATABASE = "d1_changelog"; |
| 16 | + |
| 17 | + @Test |
| 18 | + void testChangelogViewForAppends() throws Exception { |
| 19 | + try (SparkSession spark = getSparkSession()) { |
| 20 | + String name = DATABASE + ".changelog_appends"; |
| 21 | + spark.sql("DROP TABLE IF EXISTS openhouse." + name); |
| 22 | + spark.sql("CREATE TABLE openhouse." + name + " (id int, data string)"); |
| 23 | + |
| 24 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a'), (2, 'b')"); |
| 25 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (3, 'c')"); |
| 26 | + |
| 27 | + // Get snapshot IDs |
| 28 | + List<Row> snapshots = |
| 29 | + spark |
| 30 | + .sql("SELECT snapshot_id FROM openhouse." + name + ".snapshots ORDER BY committed_at") |
| 31 | + .collectAsList(); |
| 32 | + assertEquals(2, snapshots.size()); |
| 33 | + long snap1 = snapshots.get(0).getLong(0); |
| 34 | + long snap2 = snapshots.get(1).getLong(0); |
| 35 | + |
| 36 | + // Create changelog view between the two snapshots |
| 37 | + spark.sql( |
| 38 | + String.format( |
| 39 | + "CALL openhouse.system.create_changelog_view(" |
| 40 | + + "table => '%s', " |
| 41 | + + "options => map('start-snapshot-id', '%d', 'end-snapshot-id', '%d'))", |
| 42 | + name, snap1, snap2)); |
| 43 | + |
| 44 | + // The default view name is <table>_changes |
| 45 | + List<Row> changes = spark.sql("SELECT * FROM changelog_appends_changes").collectAsList(); |
| 46 | + assertEquals(1, changes.size(), "Should have 1 change (the appended row)"); |
| 47 | + |
| 48 | + // Verify change type and data |
| 49 | + Row change = changes.get(0); |
| 50 | + assertEquals("INSERT", change.getAs("_change_type")); |
| 51 | + assertEquals(3, (int) change.getAs("id")); |
| 52 | + assertEquals("c", change.getAs("data")); |
| 53 | + |
| 54 | + spark.sql("DROP TABLE openhouse." + name); |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + @Test |
| 59 | + void testChangelogViewForOverwrite() throws Exception { |
| 60 | + try (SparkSession spark = getSparkSession()) { |
| 61 | + String name = DATABASE + ".changelog_overwrite"; |
| 62 | + spark.sql("DROP TABLE IF EXISTS openhouse." + name); |
| 63 | + spark.sql("CREATE TABLE openhouse." + name + " (id int, data string)"); |
| 64 | + |
| 65 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a'), (2, 'b')"); |
| 66 | + spark.sql("INSERT OVERWRITE openhouse." + name + " VALUES (3, 'c')"); |
| 67 | + |
| 68 | + List<Row> snapshots = |
| 69 | + spark |
| 70 | + .sql("SELECT snapshot_id FROM openhouse." + name + ".snapshots ORDER BY committed_at") |
| 71 | + .collectAsList(); |
| 72 | + assertEquals(2, snapshots.size()); |
| 73 | + long snap1 = snapshots.get(0).getLong(0); |
| 74 | + long snap2 = snapshots.get(1).getLong(0); |
| 75 | + |
| 76 | + spark.sql( |
| 77 | + String.format( |
| 78 | + "CALL openhouse.system.create_changelog_view(" |
| 79 | + + "table => '%s', " |
| 80 | + + "options => map('start-snapshot-id', '%d', 'end-snapshot-id', '%d'))", |
| 81 | + name, snap1, snap2)); |
| 82 | + |
| 83 | + List<Row> changes = spark.sql("SELECT * FROM changelog_overwrite_changes").collectAsList(); |
| 84 | + |
| 85 | + // Overwrite should produce DELETEs for old rows and INSERTs for new rows |
| 86 | + Set<String> changeTypes = |
| 87 | + changes.stream().map(r -> r.getAs("_change_type").toString()).collect(Collectors.toSet()); |
| 88 | + assertTrue(changeTypes.contains("DELETE"), "Should have DELETE changes for overwritten rows"); |
| 89 | + assertTrue(changeTypes.contains("INSERT"), "Should have INSERT changes for new rows"); |
| 90 | + |
| 91 | + // Verify the deletes are for the original rows |
| 92 | + List<Row> deletes = |
| 93 | + changes.stream() |
| 94 | + .filter(r -> "DELETE".equals(r.getAs("_change_type"))) |
| 95 | + .collect(Collectors.toList()); |
| 96 | + assertEquals(2, deletes.size(), "Should delete both original rows"); |
| 97 | + Set<Integer> deletedIds = |
| 98 | + deletes.stream().map(r -> (int) r.getAs("id")).collect(Collectors.toSet()); |
| 99 | + assertTrue(deletedIds.containsAll(Set.of(1, 2))); |
| 100 | + |
| 101 | + // Verify the insert is the new row |
| 102 | + List<Row> inserts = |
| 103 | + changes.stream() |
| 104 | + .filter(r -> "INSERT".equals(r.getAs("_change_type"))) |
| 105 | + .collect(Collectors.toList()); |
| 106 | + assertEquals(1, inserts.size(), "Should insert one new row"); |
| 107 | + assertEquals(3, (int) inserts.get(0).getAs("id")); |
| 108 | + assertEquals("c", inserts.get(0).getAs("data")); |
| 109 | + |
| 110 | + spark.sql("DROP TABLE openhouse." + name); |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + @Test |
| 115 | + void testChangelogViewForDelete() throws Exception { |
| 116 | + try (SparkSession spark = getSparkSession()) { |
| 117 | + String name = DATABASE + ".changelog_delete"; |
| 118 | + spark.sql("DROP TABLE IF EXISTS openhouse." + name); |
| 119 | + spark.sql( |
| 120 | + "CREATE TABLE openhouse." |
| 121 | + + name |
| 122 | + + " (id int, data string) TBLPROPERTIES ('format-version'='2')"); |
| 123 | + |
| 124 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a'), (2, 'b'), (3, 'c')"); |
| 125 | + spark.sql("DELETE FROM openhouse." + name + " WHERE id = 2"); |
| 126 | + |
| 127 | + List<Row> snapshots = |
| 128 | + spark |
| 129 | + .sql("SELECT snapshot_id FROM openhouse." + name + ".snapshots ORDER BY committed_at") |
| 130 | + .collectAsList(); |
| 131 | + assertEquals(2, snapshots.size()); |
| 132 | + long snap1 = snapshots.get(0).getLong(0); |
| 133 | + long snap2 = snapshots.get(1).getLong(0); |
| 134 | + |
| 135 | + spark.sql( |
| 136 | + String.format( |
| 137 | + "CALL openhouse.system.create_changelog_view(" |
| 138 | + + "table => '%s', " |
| 139 | + + "options => map('start-snapshot-id', '%d', 'end-snapshot-id', '%d'))", |
| 140 | + name, snap1, snap2)); |
| 141 | + |
| 142 | + List<Row> changes = spark.sql("SELECT * FROM changelog_delete_changes").collectAsList(); |
| 143 | + |
| 144 | + // DELETE should produce a DELETE change for the removed row |
| 145 | + assertEquals(1, changes.size(), "Should have 1 change for the deleted row"); |
| 146 | + Row change = changes.get(0); |
| 147 | + assertEquals("DELETE", change.getAs("_change_type")); |
| 148 | + assertEquals(2, (int) change.getAs("id")); |
| 149 | + assertEquals("b", change.getAs("data")); |
| 150 | + |
| 151 | + spark.sql("DROP TABLE openhouse." + name); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + @Test |
| 156 | + void testChangelogViewWithNetChanges() throws Exception { |
| 157 | + try (SparkSession spark = getSparkSession()) { |
| 158 | + String name = DATABASE + ".changelog_net"; |
| 159 | + spark.sql("DROP TABLE IF EXISTS openhouse." + name); |
| 160 | + spark.sql( |
| 161 | + "CREATE TABLE openhouse." |
| 162 | + + name |
| 163 | + + " (id int, data string) TBLPROPERTIES ('format-version'='2')"); |
| 164 | + |
| 165 | + // Insert, then delete, then re-insert the same id across multiple snapshots |
| 166 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a'), (2, 'b')"); |
| 167 | + spark.sql("DELETE FROM openhouse." + name + " WHERE id = 1"); |
| 168 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a_updated'), (3, 'c')"); |
| 169 | + |
| 170 | + List<Row> snapshots = |
| 171 | + spark |
| 172 | + .sql("SELECT snapshot_id FROM openhouse." + name + ".snapshots ORDER BY committed_at") |
| 173 | + .collectAsList(); |
| 174 | + assertEquals(3, snapshots.size()); |
| 175 | + long snap1 = snapshots.get(0).getLong(0); |
| 176 | + long snap3 = snapshots.get(2).getLong(0); |
| 177 | + |
| 178 | + // Use net_changes to collapse intermediate changes. |
| 179 | + // compute_updates defaults to true when identifier_columns is set, but |
| 180 | + // net_changes and compute_updates cannot both be true, so disable compute_updates. |
| 181 | + spark.sql( |
| 182 | + String.format( |
| 183 | + "CALL openhouse.system.create_changelog_view(" |
| 184 | + + "table => '%s', " |
| 185 | + + "options => map('start-snapshot-id', '%d', 'end-snapshot-id', '%d'), " |
| 186 | + + "net_changes => true, " |
| 187 | + + "compute_updates => false, " |
| 188 | + + "identifier_columns => array('id'))", |
| 189 | + name, snap1, snap3)); |
| 190 | + |
| 191 | + List<Row> changes = |
| 192 | + spark |
| 193 | + .sql("SELECT * FROM changelog_net_changes ORDER BY _change_type, id") |
| 194 | + .collectAsList(); |
| 195 | + |
| 196 | + // Net changes: id=1 was deleted then re-inserted → net UPDATE |
| 197 | + // id=2 unchanged → no change |
| 198 | + // id=3 inserted → net INSERT |
| 199 | + Set<String> changeTypes = |
| 200 | + changes.stream().map(r -> r.getAs("_change_type").toString()).collect(Collectors.toSet()); |
| 201 | + |
| 202 | + // id=3 should appear as INSERT |
| 203 | + List<Row> inserts = |
| 204 | + changes.stream() |
| 205 | + .filter(r -> "INSERT".equals(r.getAs("_change_type"))) |
| 206 | + .collect(Collectors.toList()); |
| 207 | + assertTrue( |
| 208 | + inserts.stream().anyMatch(r -> (int) r.getAs("id") == 3), "id=3 should be a net INSERT"); |
| 209 | + |
| 210 | + spark.sql("DROP TABLE openhouse." + name); |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + @Test |
| 215 | + void testChangelogViewMultipleSnapshotsSpan() throws Exception { |
| 216 | + try (SparkSession spark = getSparkSession()) { |
| 217 | + String name = DATABASE + ".changelog_multi"; |
| 218 | + spark.sql("DROP TABLE IF EXISTS openhouse." + name); |
| 219 | + spark.sql("CREATE TABLE openhouse." + name + " (id int, data string)"); |
| 220 | + |
| 221 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (1, 'a')"); |
| 222 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (2, 'b')"); |
| 223 | + spark.sql("INSERT INTO openhouse." + name + " VALUES (3, 'c')"); |
| 224 | + |
| 225 | + List<Row> snapshots = |
| 226 | + spark |
| 227 | + .sql("SELECT snapshot_id FROM openhouse." + name + ".snapshots ORDER BY committed_at") |
| 228 | + .collectAsList(); |
| 229 | + assertEquals(3, snapshots.size()); |
| 230 | + long snap1 = snapshots.get(0).getLong(0); |
| 231 | + long snap3 = snapshots.get(2).getLong(0); |
| 232 | + |
| 233 | + spark.sql( |
| 234 | + String.format( |
| 235 | + "CALL openhouse.system.create_changelog_view(" |
| 236 | + + "table => '%s', " |
| 237 | + + "options => map('start-snapshot-id', '%d', 'end-snapshot-id', '%d'))", |
| 238 | + name, snap1, snap3)); |
| 239 | + |
| 240 | + List<Row> changes = |
| 241 | + spark.sql("SELECT * FROM changelog_multi_changes ORDER BY id").collectAsList(); |
| 242 | + |
| 243 | + // Should see INSERTs for rows added in snapshots 2 and 3 |
| 244 | + assertEquals(2, changes.size(), "Should see 2 inserts spanning snapshots 2 and 3"); |
| 245 | + assertTrue(changes.stream().allMatch(r -> "INSERT".equals(r.getAs("_change_type")))); |
| 246 | + |
| 247 | + Set<Integer> insertedIds = |
| 248 | + changes.stream().map(r -> (int) r.getAs("id")).collect(Collectors.toSet()); |
| 249 | + assertEquals(Set.of(2, 3), insertedIds); |
| 250 | + |
| 251 | + spark.sql("DROP TABLE openhouse." + name); |
| 252 | + } |
| 253 | + } |
| 254 | +} |
0 commit comments