-
Notifications
You must be signed in to change notification settings - Fork 80
feat(spark-3.5): [stacked] Add VACUUM SQL extension for Iceberg table maintenance #661
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
mkuchenbecker
wants to merge
5
commits into
mkuchenb/spark35-sql-standalone
Choose a base branch
from
mkuchenb/spark35-sql-vacuum
base: mkuchenb/spark35-sql-standalone
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
40ad62b
[spark-3.5] Add VACUUM SQL extension for Iceberg table maintenance
84bb609
[spark-3.5] VACUUM: run REMOVE ORPHAN FILES before snapshot expiration
f3d7f07
[spark-3.5] VACUUM: gate behind Alpha opt-in property + add public docs
10a91b2
Apply suggestions from code review
mkuchenbecker 153fc94
[spark-3.5] VACUUM: wire retention cutoffs to OpenHouse table properties
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
159 changes: 159 additions & 0 deletions
159
...k-itest/src/test/java/com/linkedin/openhouse/spark/statementtest/VacuumStatementTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| package com.linkedin.openhouse.spark.statementtest; | ||
|
|
||
| import com.linkedin.openhouse.spark.sql.catalyst.parser.extensions.OpenhouseParseException; | ||
| import java.nio.file.Files; | ||
| import lombok.SneakyThrows; | ||
| import org.apache.hadoop.fs.Path; | ||
| import org.apache.spark.sql.SparkSession; | ||
| import org.junit.jupiter.api.AfterAll; | ||
| import org.junit.jupiter.api.AfterEach; | ||
| import org.junit.jupiter.api.Assertions; | ||
| import org.junit.jupiter.api.BeforeAll; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.TestInstance; | ||
|
|
||
| @TestInstance(TestInstance.Lifecycle.PER_CLASS) | ||
| public class VacuumStatementTest { | ||
|
|
||
| private static SparkSession spark = null; | ||
|
|
||
| private long snapshotCount(String table) { | ||
| return spark.sql("SELECT * FROM " + table + ".snapshots").count(); | ||
| } | ||
|
|
||
| private long rowCount(String table) { | ||
| return spark.sql("SELECT * FROM " + table).count(); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumExpiresSnapshots() { | ||
| // Three inserts create three snapshots. | ||
| Assertions.assertEquals(3, snapshotCount("openhouse.db.table")); | ||
|
|
||
| // RETAIN 0 HOURS expires everything but the current snapshot; the table stays readable. | ||
| spark.sql("VACUUM openhouse.db.table RETAIN 0 HOURS").collect(); | ||
|
|
||
| Assertions.assertEquals(1, snapshotCount("openhouse.db.table")); | ||
| Assertions.assertEquals(3, rowCount("openhouse.db.table")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumWithDefaultRetentionSucceeds() { | ||
| // No RETAIN: each procedure applies its own default retention. Table remains readable. | ||
| spark.sql("VACUUM openhouse.db.table").collect(); | ||
| Assertions.assertEquals(3, rowCount("openhouse.db.table")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumHonorsHistoryPolicyVersions() { | ||
| // The OpenHouse history policy (the `policies` property) sets versions=2. With no RETAIN, | ||
| // VACUUM must honor that via retain_last, keeping exactly the last 2 of the 3 snapshots. | ||
| spark | ||
| .sql( | ||
| "ALTER TABLE openhouse.db.table SET TBLPROPERTIES (" | ||
| + "'policies' = '{\"history\":{\"maxAge\":0,\"granularity\":\"DAY\",\"versions\":2}}')") | ||
| .show(); | ||
| Assertions.assertEquals(3, snapshotCount("openhouse.db.table")); | ||
|
|
||
| spark.sql("VACUUM openhouse.db.table").collect(); | ||
|
|
||
| Assertions.assertEquals(2, snapshotCount("openhouse.db.table")); | ||
| Assertions.assertEquals(3, rowCount("openhouse.db.table")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumRemoveOrphanFilesPreservesLiveData() { | ||
| // A 24-hour window is safely above Iceberg's orphan-file removal floor and must not delete any | ||
| // file the table references, so all rows survive. | ||
| spark.sql("VACUUM openhouse.db.table REMOVE ORPHAN FILES RETAIN 24 HOURS").collect(); | ||
| Assertions.assertEquals(3, rowCount("openhouse.db.table")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumLowerCase() { | ||
| spark.sql("vacuum openhouse.db.table retain 0 hours").collect(); | ||
| Assertions.assertEquals(1, snapshotCount("openhouse.db.table")); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumNonOpenhouseTableThrows() { | ||
| Assertions.assertThrows( | ||
| Exception.class, () -> spark.sql("VACUUM openhouse.db.not_openhouse").collect()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumNotEnabledThrows() { | ||
| // VACUUM is Alpha and opt-in: an OpenHouse table that has not set openhouse.vacuum.enabled=true | ||
| // is rejected. | ||
| Assertions.assertThrows( | ||
| UnsupportedOperationException.class, | ||
| () -> spark.sql("VACUUM openhouse.db.not_enabled").collect()); | ||
| } | ||
|
|
||
| @Test | ||
| public void testVacuumInvalidSyntaxThrows() { | ||
| Assertions.assertThrows( | ||
| OpenhouseParseException.class, | ||
| () -> spark.sql("VACUUM openhouse.db.table RETAIN 5 DAYS").collect()); | ||
| } | ||
|
|
||
| @SneakyThrows | ||
| @BeforeAll | ||
| public void setupSpark() { | ||
| Path unittest = new Path(Files.createTempDirectory("unittest").toString()); | ||
| spark = | ||
| SparkSession.builder() | ||
| .master("local[2]") | ||
| .config( | ||
| "spark.sql.extensions", | ||
| ("org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions," | ||
| + "com.linkedin.openhouse.spark.extensions.OpenhouseSparkSessionExtensions")) | ||
| .config("spark.sql.catalog.openhouse", "org.apache.iceberg.spark.SparkCatalog") | ||
| .config("spark.sql.catalog.openhouse.type", "hadoop") | ||
| .config("spark.sql.catalog.openhouse.warehouse", unittest.toString()) | ||
| .getOrCreate(); | ||
| } | ||
|
|
||
| @BeforeEach | ||
| public void setup() { | ||
| spark | ||
| .sql( | ||
| "CREATE TABLE openhouse.db.table (id bigint, data string, `openhouse.tableId` string) USING iceberg") | ||
| .show(); | ||
| spark | ||
| .sql( | ||
| "ALTER TABLE openhouse.db.table SET TBLPROPERTIES (" | ||
| + "'openhouse.tableId' = 'tableid', 'openhouse.vacuum.enabled' = 'true')") | ||
| .show(); | ||
| spark.sql("INSERT INTO openhouse.db.table VALUES (1, 'a', 'tableid')").show(); | ||
| spark.sql("INSERT INTO openhouse.db.table VALUES (2, 'b', 'tableid')").show(); | ||
| spark.sql("INSERT INTO openhouse.db.table VALUES (3, 'c', 'tableid')").show(); | ||
|
|
||
| // OpenHouse table that has NOT opted into the Alpha VACUUM feature. | ||
| spark | ||
| .sql( | ||
| "CREATE TABLE openhouse.db.not_enabled (id bigint, data string, `openhouse.tableId` string) USING iceberg") | ||
| .show(); | ||
| spark | ||
| .sql( | ||
| "ALTER TABLE openhouse.db.not_enabled SET TBLPROPERTIES ('openhouse.tableId' = 'tableid')") | ||
| .show(); | ||
|
|
||
| spark | ||
| .sql("CREATE TABLE openhouse.db.not_openhouse (id bigint, data string) USING iceberg") | ||
| .show(); | ||
| } | ||
|
|
||
| @AfterEach | ||
| public void tearDown() { | ||
| spark.sql("DROP TABLE IF EXISTS openhouse.db.table").show(); | ||
| spark.sql("DROP TABLE IF EXISTS openhouse.db.not_enabled").show(); | ||
| spark.sql("DROP TABLE IF EXISTS openhouse.db.not_openhouse").show(); | ||
| } | ||
|
|
||
| @AfterAll | ||
| public void tearDownSpark() { | ||
| spark.close(); | ||
| } | ||
| } |
82 changes: 82 additions & 0 deletions
82
integrations/spark/spark-3.5/openhouse-spark-runtime/docs/VACUUM.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # VACUUM | ||
|
|
||
| **Status: Alpha.** `VACUUM` is opt-in per table. Please See [Enabling VACUUM](#enabling-vacuum)). | ||
|
|
||
| `VACUUM` is an OpenHouse Spark SQL extension that reclaims storage for an OpenHouse | ||
| Iceberg table by removing files that are no longer needed. It is thin, ergonomic sugar | ||
| over the underlying Iceberg maintenance stored procedures. | ||
|
|
||
| ## Syntax | ||
|
|
||
| ```sql | ||
| VACUUM <table> [REMOVE ORPHAN FILES] [RETAIN <n> HOURS] | ||
| ``` | ||
|
|
||
| - `<table>` — an OpenHouse table identifier (e.g. `openhouse.db.table`). | ||
| - `REMOVE ORPHAN FILES` — *(optional)* also delete orphaned files (see below). Off by default. | ||
| - `RETAIN <n> HOURS` — *(optional)* retention window in whole hours. When omitted, each | ||
| underlying operation uses its own default retention. | ||
|
|
||
| ## Behavior | ||
|
|
||
| Running `VACUUM` reclaims files beyond the retention window that are no longer referenced | ||
| by the current version of the table. | ||
|
|
||
| 1. **Orphan-file deletion** (`REMOVE ORPHAN FILES`, opt-in). Orphan files are files under the table's location that are not referenced by any table metadata typically left behind by failed or aborted writes. This step only deletes files from storage; it does not commit table metadata, so it succeeds even when the table is out of write quota. | ||
|
|
||
| 2. **Snapshot expiration** always runs. It removes snapshots older than the retention window and deletes the data, delete, manifest, and manifest-list files that those expired snapshots exclusively referenced. This command adds a commit and can conflict with in-flight transactions. | ||
|
|
||
| 3. **Retention** (`RETAIN <n> HOURS`) bounds both operations: only files older than `now - n hours` are eligible. The cutoff is resolved to a concrete timestamp in the session time zone at execution time. When `RETAIN` is omitted, snapshot expiration falls back to the table's configured snapshot-age retention and orphan-file deletion's configured default. | ||
|
|
||
|
|
||
| ## Enabling VACUUM | ||
|
|
||
| `VACUUM` is Alpha and must be enabled on each table before use: | ||
|
|
||
| ```sql | ||
| ALTER TABLE openhouse.db.table | ||
| SET TBLPROPERTIES ('openhouse.vacuum.enabled' = 'true'); | ||
| ``` | ||
|
|
||
| | Property | Value | Meaning | | ||
| | -------------------------- | -------- | ------------------------------------------- | | ||
| | `openhouse.vacuum.enabled` | `'true'` | Opt this table into the Alpha `VACUUM` command. | | ||
|
|
||
| Any other value (or the property being absent) leaves `VACUUM` disabled for the table, and | ||
| running the command throws an `UnsupportedOperationException` that explains how to enable it. | ||
| `VACUUM` is only supported on OpenHouse tables; running it on a non-OpenHouse table also | ||
| throws. | ||
|
|
||
| ## Examples | ||
|
|
||
| Enable the feature, then expire snapshots older than 24 hours: | ||
|
|
||
| ```sql | ||
| ALTER TABLE openhouse.db.table | ||
| SET TBLPROPERTIES ('openhouse.vacuum.enabled' = 'true'); | ||
|
|
||
| VACUUM openhouse.db.table RETAIN 24 HOURS; | ||
| ``` | ||
|
|
||
| Expire snapshots using the table's default retention: | ||
|
|
||
| ```sql | ||
| VACUUM openhouse.db.table; | ||
| ``` | ||
|
|
||
| Also remove orphaned files, retaining anything from the last 168 hours (7 days): | ||
|
|
||
| ```sql | ||
| VACUUM openhouse.db.table REMOVE ORPHAN FILES RETAIN 168 HOURS; | ||
| ``` | ||
|
|
||
| ## Notes and caveats | ||
|
|
||
| - **`REMOVE ORPHAN FILES` is expensive.** It performs a recursive listing of the table's | ||
| location to find unreferenced files. On tables with very large file counts this can be | ||
| slow and memory-intensive, and may require a larger Spark driver to avoid running out of | ||
| memory. | ||
| - **Low Retention causes in-flight operations to fail** A query sees the same snapshot of the table they start with, and expiring a snapshot that is in-use will cause transactions to fail. Deleting orphans of in-flight transactions can cause failure. 24 hours is the suggested minimum but can be lowered to mitigate emergency scenarios. | ||
| - **Snapshot expiration requires write quota**; orphan-file deletion does not. This is why | ||
| orphan-file deletion runs first — on a table that is out of quota, orphan cleanup still | ||
| proceeds even though expiration cannot commit. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
.../src/main/scala/com/linkedin/openhouse/spark/sql/catalyst/plans/logical/VacuumTable.scala
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.linkedin.openhouse.spark.sql.catalyst.plans.logical | ||
|
|
||
| import org.apache.spark.sql.catalyst.plans.logical.LeafCommand | ||
|
|
||
| case class VacuumTable(tableName: Seq[String], removeOrphanFiles: Boolean, retainHours: Option[Int]) extends LeafCommand { | ||
| override def simpleString(maxFields: Int): String = { | ||
| s"VacuumTable: ${tableName} removeOrphanFiles=${removeOrphanFiles} retainHours=${retainHours.getOrElse("default")}" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.