From fa2d137b68fa5ec528e077509f6644a36562ffa5 Mon Sep 17 00:00:00 2001 From: vignesh_A Date: Wed, 8 Jul 2026 13:35:40 +0530 Subject: [PATCH 1/2] fix(jdbc): handle trailing-slash mismatch in optimized sibling overlap check --- CHANGELOG.md | 1 + .../relational/jdbc/QueryGenerator.java | 36 ++++++++++--- .../relational/jdbc/QueryGeneratorTest.java | 52 +++++++++++++++++-- ...bstractLocalIcebergCatalogOverlapTest.java | 32 ++++++++++++ 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8b1c9dfc..955ef1e1c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti - `TableCleanupTaskHandler` now clamps `TABLE_METADATA_CLEANUP_BATCH_SIZE` to at least 1. Previously a non-positive realm override caused an infinite loop (0) or `IllegalArgumentException` (<0) when splitting metadata files for cleanup. - `ManifestFileCleanupTaskHandler` now handles Iceberg v2 delete manifests in addition to data manifests. Previously, `DROP TABLE PURGE` on a v2 table that had been updated via merge-on-read DML left position-delete files and their manifests as orphans in object storage; the cleanup task failed silently because `ManifestFiles.read()` rejects delete manifests. - OPA authorizer now includes the realm identifier in the authorization context sent to OPA (`input.context.realm`). This ensures OPA policies can enforce tenant isolation across realms, preventing potential collisions if identical principal or resource names exist in different realms. +- Fixed a false-negative in the JDBC optimized location-overlap check (`OPTIMIZED_SIBLING_CHECK`). Ancestor locations stored in `location_without_scheme` without a trailing slash were not matched by the generated ancestor equality terms, allowing nested table/namespace locations to be created under existing prefixes. The query now emits both slash-terminated and non-slash-terminated prefix terms and uses a slash-terminated `LIKE` pattern for descendant matching. ## [1.6.0] diff --git a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java index d35773a81c..0668cebecf 100644 --- a/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java +++ b/persistence/relational-jdbc/src/main/java/org/apache/polaris/persistence/relational/jdbc/QueryGenerator.java @@ -23,6 +23,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -390,6 +391,10 @@ static PreparedQuery generateEntityTableExistQuery() { * a path on one storage type may give a false positive for overlapping with another storage type. * This should be combined with a check using `StorageLocation`. * + *

Equality terms are generated for each prefix of the location in both slash-terminated and + * non-slash-terminated forms so that ancestors stored with or without a trailing slash are both + * matched. + * * @param realmId A realm to search within * @param schemaVersion The schema version of entities table to query * @param catalogId A catalog entity to search within @@ -405,18 +410,35 @@ public static PreparedQuery generateOverlapQuery( List conditions = new ArrayList<>(); List parameters = new ArrayList<>(); - String[] components = locationWithoutScheme.split("/"); - StringBuilder pathBuilder = new StringBuilder(); + // Normalize by stripping any trailing slash so the scan below only emits meaningful prefixes. + // The slash-terminated form is re-added as the last prefix term below. + String normalizedLocation = locationWithoutScheme; + if (normalizedLocation.endsWith("/")) { + normalizedLocation = normalizedLocation.substring(0, normalizedLocation.length() - 1); + } + + Set prefixTerms = new LinkedHashSet<>(); + for (int i = 0; i < normalizedLocation.length(); i++) { + if (normalizedLocation.charAt(i) == '/') { + if (i > 0) { + prefixTerms.add(normalizedLocation.substring(0, i)); + } + prefixTerms.add(normalizedLocation.substring(0, i + 1)); + } + } + prefixTerms.add(normalizedLocation); + prefixTerms.add(normalizedLocation + "/"); - for (String component : components) { - pathBuilder.append(component).append("/"); + for (String prefix : prefixTerms) { conditions.add("location_without_scheme = ?"); - parameters.add(pathBuilder.toString()); + parameters.add(prefix); } - // Add LIKE condition to match children + // Add LIKE condition to match children. Slash-terminate the location so the pattern only + // matches true descendants (e.g. //bucket/ns/tA/% matches //bucket/ns/tA/child but not + // //bucket/ns/tA_backup). conditions.add("location_without_scheme LIKE ?"); - parameters.add(locationWithoutScheme + "%"); + parameters.add(StorageLocation.ensureTrailingSlash(locationWithoutScheme) + "%"); String locationClause = String.join(" OR ", conditions); String clause = " WHERE realm_id = ? AND catalog_id = ? AND (" + locationClause + ")"; diff --git a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java index 04ae231a88..3b61f6bbe1 100644 --- a/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java +++ b/persistence/relational-jdbc/src/test/java/org/apache/polaris/persistence/relational/jdbc/QueryGeneratorTest.java @@ -390,6 +390,7 @@ void testGenerateOverlapQuery() { + " properties, internal_properties, grant_records_version, location_without_scheme FROM" + " POLARIS_SCHEMA.ENTITIES WHERE realm_id = ? AND catalog_id = ? AND (location_without_scheme = ?" + " OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR" + + " location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR" + " location_without_scheme = ? OR location_without_scheme LIKE ?)", QueryGenerator.generateOverlapQuery("realmId", 2, -123, "s3://bucket/tmp/location/").sql()); Assertions.assertThatCollection( @@ -400,8 +401,29 @@ void testGenerateOverlapQuery() { -123L, "/", "//", + "//bucket", "//bucket/", + "//bucket/tmp", "//bucket/tmp/", + "//bucket/tmp/location", + "//bucket/tmp/location/", + "//bucket/tmp/location/%"); + + // A location without a trailing slash produces the same prefix terms so that ancestors stored + // in either form are matched. + Assertions.assertThatCollection( + QueryGenerator.generateOverlapQuery("realmId", 2, -123, "s3://bucket/tmp/location") + .parameters()) + .containsExactly( + "realmId", + -123L, + "/", + "//", + "//bucket", + "//bucket/", + "//bucket/tmp", + "//bucket/tmp/", + "//bucket/tmp/location", "//bucket/tmp/location/", "//bucket/tmp/location/%"); @@ -409,25 +431,45 @@ void testGenerateOverlapQuery() { "SELECT id, catalog_id, parent_id, type_code, name, entity_version, sub_type_code," + " create_timestamp, drop_timestamp, purge_timestamp, to_purge_timestamp, last_update_timestamp," + " properties, internal_properties, grant_records_version, location_without_scheme FROM" - + " POLARIS_SCHEMA.ENTITIES WHERE realm_id = ? AND catalog_id = ? AND (location_without_scheme = ? OR location_without_scheme = ?" - + " OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme LIKE ?)", + + " POLARIS_SCHEMA.ENTITIES WHERE realm_id = ? AND catalog_id = ? AND (location_without_scheme = ?" + + " OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR" + + " location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR" + + " location_without_scheme LIKE ?)", QueryGenerator.generateOverlapQuery("realmId", 2, -123, "/tmp/location/").sql()); Assertions.assertThatCollection( QueryGenerator.generateOverlapQuery("realmId", 2, -123, "/tmp/location/").parameters()) .containsExactly( - "realmId", -123L, "/", "//", "///", "///tmp/", "///tmp/location/", "///tmp/location/%"); + "realmId", + -123L, + "/", + "//", + "///", + "///tmp", + "///tmp/", + "///tmp/location", + "///tmp/location/", + "///tmp/location/%"); assertEquals( "SELECT id, catalog_id, parent_id, type_code, name, entity_version, sub_type_code," + " create_timestamp, drop_timestamp, purge_timestamp, to_purge_timestamp, last_update_timestamp," + " properties, internal_properties, grant_records_version, location_without_scheme" + " FROM POLARIS_SCHEMA.ENTITIES WHERE realm_id = ? AND catalog_id = ? AND (location_without_scheme = ?" - + " OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme LIKE ?)", + + " OR location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme = ? OR" + + " location_without_scheme = ? OR location_without_scheme = ? OR location_without_scheme LIKE ?)", QueryGenerator.generateOverlapQuery("realmId", 2, -123, "s3://バケツ/\"loc.ation\"/").sql()); Assertions.assertThatCollection( QueryGenerator.generateOverlapQuery("realmId", 2, -123, "s3://バケツ/\"loc.ation\"/") .parameters()) .containsExactly( - "realmId", -123L, "/", "//", "//バケツ/", "//バケツ/\"loc.ation\"/", "//バケツ/\"loc.ation\"/%"); + "realmId", + -123L, + "/", + "//", + "//バケツ", + "//バケツ/", + "//バケツ/\"loc.ation\"", + "//バケツ/\"loc.ation\"/", + "//バケツ/\"loc.ation\"/%"); } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java index 9b7503b325..6a39e1adf6 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java @@ -294,4 +294,36 @@ public void testParentChildLocationOverlapWithOptimizedSiblingCheck() { .hasMessageContaining("Unable to create entity at location") .hasMessageContaining("conflicts with existing table or namespace"); } + + @Test + public void testParentPrefixOverlapWithTrailingSlashMismatch() { + Namespace ns = Namespace.of("ns-for-trailing-slash-overlap"); + catalog().createNamespace(ns); + + // Create a parent table with an explicit location that has no trailing slash. This is the + // storage form that triggered the OPTIMIZED_SIBLING_CHECK false negative: the stored + // location_without_scheme lacks a trailing slash while the overlap query's ancestor equality + // terms are always slash-terminated. + TableIdentifier parentTable = TableIdentifier.of(ns, "trailing-parent"); + String parentLoc = STORAGE_LOCATION + "/trailing-overlap/parent"; + catalog().buildTable(parentTable, SCHEMA).withLocation(parentLoc).create(); + + // Creating a child table under the slash-less parent location must be rejected. + TableIdentifier childTable = TableIdentifier.of(ns, "trailing-child"); + String childLoc = STORAGE_LOCATION + "/trailing-overlap/parent/child"; + assertThatThrownBy( + () -> catalog().buildTable(childTable, SCHEMA).withLocation(childLoc).create()) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Unable to create entity at location") + .hasMessageContaining("conflicts with existing table or namespace"); + + // The same must hold when the slash-terminated form of the same prefix is used. + TableIdentifier siblingTable = TableIdentifier.of(ns, "trailing-sibling"); + String siblingLoc = STORAGE_LOCATION + "/trailing-overlap/parent/"; + assertThatThrownBy( + () -> catalog().buildTable(siblingTable, SCHEMA).withLocation(siblingLoc).create()) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("Unable to create entity at location") + .hasMessageContaining("conflicts with existing table or namespace"); + } } From 96f6f7c6e4810afdcc87f7e9fb906dad74454fcb Mon Sep 17 00:00:00 2001 From: vignesh_A Date: Fri, 10 Jul 2026 01:18:39 +0530 Subject: [PATCH 2/2] test(jdbc): make trailing-slash overlap test depend on QueryGenerator fix Disable ADD_TRAILING_SLASH_TO_LOCATION in overlap profiles so parent locations are stored without a trailing slash, and assert the stored location remains slash-less. Without that, default slash normalization masked the OPTIMIZED_SIBLING_CHECK false negative this PR fixes. --- .../AbstractLocalIcebergCatalogOverlapTest.java | 16 ++++++++++++---- .../LocalIcebergCatalogNoSqlOverlapTest.java | 2 ++ .../iceberg/LocalIcebergCatalogOverlapTest.java | 2 ++ ...LocalIcebergCatalogRelationalOverlapTest.java | 3 +++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java index 6a39e1adf6..a28e4b0be8 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogOverlapTest.java @@ -19,6 +19,7 @@ package org.apache.polaris.service.catalog.iceberg; import static org.apache.polaris.service.admin.PolarisAuthzTestBase.SCHEMA; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; @@ -297,17 +298,24 @@ public void testParentChildLocationOverlapWithOptimizedSiblingCheck() { @Test public void testParentPrefixOverlapWithTrailingSlashMismatch() { + // Profiles disable ADD_TRAILING_SLASH_TO_LOCATION so the parent is stored without a trailing + // slash. That is the OPTIMIZED_SIBLING_CHECK false-negative for JDBC: ancestor equality terms + // used to be slash-terminated only, so location_without_scheme without '/' was missed. Namespace ns = Namespace.of("ns-for-trailing-slash-overlap"); catalog().createNamespace(ns); - // Create a parent table with an explicit location that has no trailing slash. This is the - // storage form that triggered the OPTIMIZED_SIBLING_CHECK false negative: the stored - // location_without_scheme lacks a trailing slash while the overlap query's ancestor equality - // terms are always slash-terminated. TableIdentifier parentTable = TableIdentifier.of(ns, "trailing-parent"); String parentLoc = STORAGE_LOCATION + "/trailing-overlap/parent"; + assertThat(parentLoc).doesNotEndWith("/"); catalog().buildTable(parentTable, SCHEMA).withLocation(parentLoc).create(); + // Guardrail: if trailing-slash normalization were still on, this test would not exercise the + // slash-less location_without_scheme path that QueryGenerator must handle. + assertThat(catalog().loadTable(parentTable).location()) + .as("parent must remain slash-less so overlap uses non-slash-terminated stored location") + .isEqualTo(parentLoc) + .doesNotEndWith("/"); + // Creating a child table under the slash-less parent location must be rejected. TableIdentifier childTable = TableIdentifier.of(ns, "trailing-child"); String childLoc = STORAGE_LOCATION + "/trailing-overlap/parent/child"; diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogNoSqlOverlapTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogNoSqlOverlapTest.java index 8bb3aa5bb5..3c8490a6e0 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogNoSqlOverlapTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogNoSqlOverlapTest.java @@ -46,6 +46,8 @@ public Map getConfigOverrides() { overrides.put("polaris.features.\"ALLOW_TABLE_LOCATION_OVERLAP\"", "false"); overrides.put("polaris.features.\"OPTIMIZED_SIBLING_CHECK\"", "true"); overrides.put("polaris.features.\"ALLOW_OPTIMIZED_SIBLING_CHECK\"", "true"); + // Keep locations as written so slash-less base locations are stored as-is. + overrides.put("polaris.features.\"ADD_TRAILING_SLASH_TO_LOCATION\"", "false"); return overrides; } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogOverlapTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogOverlapTest.java index a66101a450..7646600413 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogOverlapTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogOverlapTest.java @@ -44,6 +44,8 @@ public Map getConfigOverrides() { overrides.put("polaris.features.\"ALLOW_TABLE_LOCATION_OVERLAP\"", "false"); overrides.put("polaris.features.\"OPTIMIZED_SIBLING_CHECK\"", "true"); overrides.put("polaris.features.\"ALLOW_OPTIMIZED_SIBLING_CHECK\"", "true"); + // Keep locations as written so slash-less base locations are stored as-is. + overrides.put("polaris.features.\"ADD_TRAILING_SLASH_TO_LOCATION\"", "false"); return overrides; } } diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogRelationalOverlapTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogRelationalOverlapTest.java index 946174d9c4..139974fbdd 100644 --- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogRelationalOverlapTest.java +++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalogRelationalOverlapTest.java @@ -45,6 +45,9 @@ public Map getConfigOverrides() { overrides.put("polaris.features.\"ALLOW_TABLE_LOCATION_OVERLAP\"", "false"); overrides.put("polaris.features.\"OPTIMIZED_SIBLING_CHECK\"", "true"); overrides.put("polaris.features.\"ALLOW_OPTIMIZED_SIBLING_CHECK\"", "true"); + // Keep locations as written so JDBC optimized sibling checks exercise slash-less + // location_without_scheme values (the false-negative fixed in QueryGenerator). + overrides.put("polaris.features.\"ADD_TRAILING_SLASH_TO_LOCATION\"", "false"); overrides.put("polaris.persistence.type", "relational-jdbc"); overrides.put("polaris.persistence.auto-bootstrap-types", "relational-jdbc"); overrides.put("quarkus.datasource.db-kind", "h2");