Merge existing policies on CREATE OR REPLACE (RTAS) - #652
Merged
mkuchenbecker merged 8 commits intoAug 3, 2026
Merged
Conversation
RTAS re-materialized the policies table property only from the request, so a replace that omitted policies silently wiped retention, sharing, PII column tags, replication, and history. Policies are table metadata and must survive a replace: merge the existing table's policies with the request's, each plane the request provides winning and every omitted plane carried forward. Co-authored-by: Copilot <[email protected]>
mkuchenbecker
commented
Jul 22, 2026
Capture the merge contract: each plane merged, omitted planes carried forward, provided planes win (partial merge), planes can be added via RTAS, columnTags empty-vs-nonempty, and sharingEnabled takes the request value. Adds a unit test over mergePolicies and an e2e test that RTAS applies a requested policy. Co-authored-by: Copilot <[email protected]>
mkuchenbecker
commented
Jul 23, 2026
Replace the mergePolicies unit test with black-box tests that drive real Spark SQL against a real embedded OpenHouse server (OpenHouseSparkITest): set policies via ALTER TABLE ... SET POLICY, run CREATE OR REPLACE TABLE ... AS SELECT, and assert via SHOW TBLPROPERTIES that retention, sharing, PII column tags, and history survive the replace. Also fix RTASTest.testRTAS, which asserted the old buggy behavior (policies wiped on RTAS); it now asserts the history policy is preserved. Co-authored-by: Copilot <[email protected]>
mkuchenbecker
marked this pull request as ready for review
July 23, 2026 21:11
cbb330
previously requested changes
Jul 23, 2026
This was referenced Jul 24, 2026
Contributor
Author
mkuchenbecker
dismissed
cbb330’s stale review
July 31, 2026 18:25
No re-review within a week; requested change is staged as a separate PR for review.
…ge semantics The per-plane policy merge for CREATE OR REPLACE built the result from the request, so every plane except sharingEnabled was correctly carried forward from the existing table while sharingEnabled -- a primitive boolean with no "unset" state -- was always reset to whatever the request happened to carry. A non-Spark client sending a partial policies payload (e.g. retention only) would silently disable sharing on a table that had it enabled: exactly the policy-loss class this change set out to fix, for the one plane it missed. Base the merge builder on the existing policies instead of the request, so any plane the request does not explicitly override -- including sharingEnabled -- is carried forward. The five object planes keep their explicit request-wins-if-provided behavior; sharingEnabled is now preserved from the existing table across a replace (change it via ALTER TABLE ... SET POLICY (SHARING=...)). Document the merge contract on mergePolicies, including the columnTags overwrite semantics (a non-empty request map replaces the existing map wholesale; an absent/empty map preserves it, so clearing all tags is not expressible through a replace). Add REST-level (H2 RepositoryTest) tests for the partial-payload path, which Spark RTAS cannot exercise (Spark always sends policies == null): sharing preserved on a partial payload, an omitted plane (history) carried forward while retention is overridden, and columnTags overwrite dropping a prior tag. Co-authored-by: Copilot <[email protected]>
mergePolicies special-cases columnTags with MapUtils.isEmpty so that a non-empty replace payload that omits column tags preserves the existing tags rather than wiping them. That branch was not exercised: the existing tests covered the non-empty overwrite path and the fully-null request short-circuit, but not a non-null request that omits column tags while the table has them. Add a RepositoryTest case that creates a table with a col1 PII tag, replaces it with a payload providing only retention, and asserts the col1 tag is carried forward while the new retention is applied. Co-authored-by: Copilot <[email protected]>
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Summary
CREATE OR REPLACE ... AS SELECT(RTAS) silently dropped the table's policies. The replace path rebuilt thepoliciestable property purely from the incoming request, so a replace that omitted policies wiped the existing retention, sharing, PII column tags, replication, and history, even though ordinary user table properties survived.Fix
Policies are table metadata that a replace must not silently drop. Before the replace properties are built, the existing table's policies are merged with the request's policies. The merge is based on the existing policies, so any plane that the request does not explicitly provide is carried forward from the existing table, and each plane that the request does provide overrides the existing value.
Merge behavior
The table below describes how each policy plane behaves during a replace.
retentionreplicationhistorylockStatecolumnTagssharingEnabledTwo consequences follow from this behavior. First, because column tags use overwrite semantics and an empty map is treated the same as an omitted field, a replace cannot clear all column tags. Clearing tags is done with
ALTER TABLE ... MODIFY COLUMN ... UNSET TAG. Second, becausesharingEnabledis a primitive boolean with no unset state, its value is always preserved across a replace. Sharing is changed withALTER TABLE ... SET POLICY (SHARING=...).Spark RTAS has no policy clause, so it always sends a request with no policies, and the entire existing policies object is carried forward unchanged. A partial policy payload can only arrive from a client that calls the REST API directly.
This behavior is consistent with the intent of RTAS, which should preserve table properties so that a replace does not require re-granting access to the same entity.
Testing Done
The REST level partial payload path is exercised through
RepositoryTest, which is the layer that can send a partialPoliciesobject. Spark cannot reach this path because it always sends a request with no policies.testReplaceMergesExistingPoliciesreplaces a table without policies and asserts that the retention policy survives.testReplaceAppliesRequestedPoliciesasserts that a retention policy provided on the request is applied.testReplaceWithPartialPoliciesPreservesSharingsends a partial payload containing only retention and asserts thatsharingEnabledstays true while the new retention is applied.testReplaceWithPartialPoliciesPreservesOmittedPlanesoverrides only retention and asserts that the omitted history plane is carried forward.testReplaceWithPartialPoliciesPreservesColumnTagssends a payload that provides retention but omits column tags, and asserts that the existing column tag is carried forward.testReplaceOverwritesColumnTagssends a new column tag map and asserts that it replaces the existing map in full, dropping the previous tag.Black box coverage is exercised through
RtasPolicyPreservationTestagainst an embedded OpenHouse server driven by Spark SQL. It asserts that retention, sharing, the PII column tag, and history all survive aREPLACE TABLE ... AS SELECT.The existing
SnapshotsControllerTest.testPutSnapshotsReplaceCommitstill passes, which confirms that a replace on a table that never had policies still yields none../gradlew :services:tables:testand:integrations:spark:spark-3.1:openhouse-spark-itest:catalogTestpass on JDK 17, and Spotless is clean on the module.