tableConf) {
+ // Get client-configured mode (set in catalog properties during initialization)
+ RESTCatalogProperties.ScanPlanningMode clientMode = clientConfiguredScanPlanningMode;
+
+ // Get server-provided mode
+ // Priority: table-level config > catalog-level config (from ConfigResponse)
+ String tableLevelModeConfig = tableConf.get(RESTCatalogProperties.SCAN_PLANNING_MODE);
+ RESTCatalogProperties.ScanPlanningMode serverMode;
+ if (tableLevelModeConfig != null) {
+ serverMode = RESTCatalogProperties.ScanPlanningMode.fromString(tableLevelModeConfig);
+ } else {
+ // Fall back to catalog-level server config (from ConfigResponse.overrides())
+ serverMode = catalogLevelScanPlanningMode;
+ }
+
+ // Check server capabilities
+ boolean serverSupportsPlanning = endpoints.contains(Endpoint.V1_SUBMIT_TABLE_SCAN_PLAN);
+
+ // Negotiate scan planning strategy
+ // Rules: ONLY beats PREFERRED, both PREFERRED = client wins, one side only = use it
+ ScanPlanningNegotiator.PlanningDecision decision =
+ ScanPlanningNegotiator.negotiate(
+ clientMode, serverMode, serverSupportsPlanning, finalIdentifier);
+
+ // Apply the decision
+ if (decision == ScanPlanningNegotiator.PlanningDecision.USE_CATALOG_PLANNING) {
return new RESTTable(
ops,
fullTableName(finalIdentifier),
@@ -518,6 +558,8 @@ private RESTTable restTableForScanPlanning(
paths,
endpoints);
}
+
+ // USE_CLIENT_PLANNING
return null;
}
@@ -589,7 +631,7 @@ public Table registerTable(
trackFileIO(ops);
- RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient);
+ RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf);
if (restTable != null) {
return restTable;
}
@@ -858,7 +900,7 @@ public Table create() {
trackFileIO(ops);
- RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient);
+ RESTTable restTable = restTableForScanPlanning(ops, ident, tableClient, tableConf);
if (restTable != null) {
return restTable;
}
diff --git a/core/src/main/java/org/apache/iceberg/rest/ScanPlanningNegotiator.java b/core/src/main/java/org/apache/iceberg/rest/ScanPlanningNegotiator.java
new file mode 100644
index 000000000000..0c07fcc8489f
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/rest/ScanPlanningNegotiator.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg.rest;
+
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.rest.RESTCatalogProperties.ScanPlanningMode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles negotiation between client and server scan planning mode configurations.
+ *
+ * This class encapsulates the decision logic for determining whether to use client-side or
+ * server-side scan planning based on:
+ *
+ *
+ * - Client mode (CLIENT_ONLY, CLIENT_PREFERRED, CATALOG_PREFERRED, CATALOG_ONLY, or null)
+ *
- Server mode (CLIENT_ONLY, CLIENT_PREFERRED, CATALOG_PREFERRED, CATALOG_ONLY, or null)
+ *
- Server capabilities (supports planning endpoint or not)
+ *
+ *
+ * Negotiation Rules
+ *
+ *
When both client and server configure scan planning mode, the values are negotiated:
+ *
+ *
+ * - Incompatible hard requirements: CLIENT_ONLY + CATALOG_ONLY = FAIL
+ *
- ONLY wins over PREFERRED: When one side has "ONLY" and the other has "PREFERRED",
+ * the ONLY requirement wins (inflexible beats flexible)
+ *
- Both PREFERRED: When both are PREFERRED (different types), client config wins
+ *
- Both same: When both have the same value, use that planning type
+ *
- Only one configured: Use the configured side (client or server)
+ *
- Neither configured: Use default (CLIENT_PREFERRED)
+ *
+ */
+public class ScanPlanningNegotiator {
+ private static final Logger LOG = LoggerFactory.getLogger(ScanPlanningNegotiator.class);
+
+ private ScanPlanningNegotiator() {}
+
+ /** Result of scan planning negotiation. */
+ public enum PlanningDecision {
+ USE_CLIENT_PLANNING,
+ USE_CATALOG_PLANNING
+ }
+
+ /**
+ * Negotiates scan planning strategy between client and server configurations.
+ *
+ * Precedence: Client config > Server config > Default (CLIENT_PREFERRED)
+ *
+ * @param clientMode the client's scan planning mode (from catalog properties), may be null
+ * @param serverMode the server's scan planning mode (from LoadTableResponse.config()), may be
+ * null
+ * @param serverSupportsPlanning whether the server advertises the scan planning endpoint
+ * @param tableIdentifier the table identifier (for error messages and logging)
+ * @return the negotiated planning decision
+ * @throws IllegalStateException if client and server requirements are incompatible (CLIENT_ONLY
+ * vs CATALOG_ONLY)
+ * @throws UnsupportedOperationException if required server-side planning but server doesn't
+ * support it
+ */
+ public static PlanningDecision negotiate(
+ ScanPlanningMode clientMode,
+ ScanPlanningMode serverMode,
+ boolean serverSupportsPlanning,
+ TableIdentifier tableIdentifier) {
+
+ // Determine effective mode through negotiation
+ ScanPlanningMode effectiveMode;
+ String modeSource;
+
+ if (clientMode != null && serverMode != null) {
+ // Both client and server have configured modes - negotiate
+ effectiveMode = negotiateBetweenClientAndServer(clientMode, serverMode, tableIdentifier);
+ modeSource =
+ String.format(
+ "negotiated (client: %s, server: %s)", clientMode.modeName(), serverMode.modeName());
+ } else if (clientMode != null) {
+ // Only client configured
+ effectiveMode = clientMode;
+ modeSource = "client config";
+ } else if (serverMode != null) {
+ // Only server configured
+ effectiveMode = serverMode;
+ modeSource = "server config";
+ } else {
+ // Neither configured, use default
+ effectiveMode = ScanPlanningMode.CLIENT_PREFERRED;
+ modeSource = "default";
+ }
+
+ // Apply the effective mode
+ return applyMode(effectiveMode, serverSupportsPlanning, tableIdentifier, modeSource);
+ }
+
+ /**
+ * Negotiate between client and server modes when both are configured.
+ *
+ *
Rules:
+ *
+ *
+ * - Both same = Use that mode
+ *
- Both ONLY but want opposite planning = FAIL (incompatible)
+ *
- One ONLY = ONLY wins (inflexible beats flexible)
+ *
- Both PREFERRED = Client config wins
+ *
+ *
+ * @return the negotiated mode
+ * @throws IllegalStateException if both are ONLY but want opposite planning locations
+ */
+ private static ScanPlanningMode negotiateBetweenClientAndServer(
+ ScanPlanningMode clientMode, ScanPlanningMode serverMode, TableIdentifier tableIdentifier) {
+
+ // Fast path: both are the same - no negotiation needed
+ if (clientMode == serverMode) {
+ LOG.debug(
+ "Client and server agree on scan planning mode {} for table {}",
+ clientMode.modeName(),
+ tableIdentifier);
+ return clientMode;
+ }
+
+ // Check for incompatible hard requirements: both are ONLY but want opposite planning locations
+ if (clientMode.isOnly() && serverMode.isOnly()) {
+ // Both have hard requirements but want different things
+ throw new IllegalStateException(
+ String.format(
+ "Incompatible scan planning requirements for table %s: "
+ + "client requires %s but server requires %s. "
+ + "Either change client config or update server mode.",
+ tableIdentifier,
+ clientMode.prefersClient() ? "client-side planning" : "server-side planning",
+ serverMode.prefersClient() ? "client-side planning" : "server-side planning"));
+ }
+
+ // ONLY wins over PREFERRED (inflexible beats flexible)
+ if (clientMode.isOnly()) {
+ LOG.debug(
+ "Client mode {} (hard requirement) wins over server mode {} (flexible) for table {}",
+ clientMode.modeName(),
+ serverMode.modeName(),
+ tableIdentifier);
+ return clientMode;
+ }
+
+ if (serverMode.isOnly()) {
+ LOG.debug(
+ "Server mode {} (hard requirement) wins over client mode {} (flexible) for table {}",
+ serverMode.modeName(),
+ clientMode.modeName(),
+ tableIdentifier);
+ return serverMode;
+ }
+
+ // Both are PREFERRED - client config wins
+ LOG.debug(
+ "Both client ({}) and server ({}) are flexible (PREFERRED). Client config wins for table {}",
+ clientMode.modeName(),
+ serverMode.modeName(),
+ tableIdentifier);
+ return clientMode;
+ }
+
+ /**
+ * Apply the effective mode and determine planning decision.
+ *
+ * @return the planning decision based on effective mode and server capabilities
+ * @throws UnsupportedOperationException if CATALOG_ONLY but server doesn't support planning
+ */
+ private static PlanningDecision applyMode(
+ ScanPlanningMode effectiveMode,
+ boolean serverSupportsPlanning,
+ TableIdentifier tableIdentifier,
+ String modeSource) {
+
+ switch (effectiveMode) {
+ case CLIENT_ONLY:
+ case CLIENT_PREFERRED:
+ LOG.debug(
+ "Using client-side planning for table {} (mode: {}, source: {})",
+ tableIdentifier,
+ effectiveMode.modeName(),
+ modeSource);
+ return PlanningDecision.USE_CLIENT_PLANNING;
+
+ case CATALOG_PREFERRED:
+ // Prefer server-side, but fall back to client if unavailable
+ if (!serverSupportsPlanning) {
+ LOG.warn(
+ "Table {} prefers server-side planning (mode: CATALOG_PREFERRED from {}) "
+ + "but server doesn't support it. Falling back to client-side planning. "
+ + "Consider upgrading server or changing mode to CLIENT_PREFERRED.",
+ tableIdentifier,
+ modeSource);
+ return PlanningDecision.USE_CLIENT_PLANNING;
+ }
+ LOG.debug(
+ "Using server-side planning for table {} (mode: CATALOG_PREFERRED, source: {})",
+ tableIdentifier,
+ modeSource);
+ return PlanningDecision.USE_CATALOG_PLANNING;
+
+ case CATALOG_ONLY:
+ // Must use server-side, fail if unavailable
+ if (!serverSupportsPlanning) {
+ throw new UnsupportedOperationException(
+ String.format(
+ "Scan planning mode requires server-side planning (CATALOG_ONLY from %s) "
+ + "for table %s but server does not support planning endpoint. "
+ + "Either change scan planning mode or upgrade server to support scan planning.",
+ modeSource, tableIdentifier));
+ }
+ LOG.debug(
+ "Using server-side planning for table {} (mode: CATALOG_ONLY, source: {})",
+ tableIdentifier,
+ modeSource);
+ return PlanningDecision.USE_CATALOG_PLANNING;
+
+ default:
+ throw new IllegalStateException("Unknown scan planning mode: " + effectiveMode);
+ }
+ }
+}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
index df4ba3214aea..4ac59532c89d 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTCatalog.java
@@ -3528,4 +3528,40 @@ private static List allRequests(RESTCatalogAdapter adapter) {
verify(adapter, atLeastOnce()).execute(captor.capture(), any(), any(), any());
return captor.getAllValues();
}
+
+ @Test
+ public void scanPlanningModeFromString() {
+ // Null returns CLIENT_PREFERRED default
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString(null))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CLIENT_PREFERRED);
+
+ // Valid mode names
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("client-only"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CLIENT_ONLY);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("client-preferred"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CLIENT_PREFERRED);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("catalog-preferred"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CATALOG_PREFERRED);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("catalog-only"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CATALOG_ONLY);
+
+ // Case-insensitive parsing
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("CLIENT-ONLY"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CLIENT_ONLY);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("Client-Preferred"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CLIENT_PREFERRED);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("CATALOG-PREFERRED"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CATALOG_PREFERRED);
+ assertThat(RESTCatalogProperties.ScanPlanningMode.fromString("CaTaLoG-oNlY"))
+ .isEqualTo(RESTCatalogProperties.ScanPlanningMode.CATALOG_ONLY);
+
+ // Invalid mode throws exception with all valid modes listed
+ assertThatThrownBy(() -> RESTCatalogProperties.ScanPlanningMode.fromString("invalid-mode"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Invalid scan planning mode: invalid-mode")
+ .hasMessageContaining("client-only")
+ .hasMessageContaining("client-preferred")
+ .hasMessageContaining("catalog-preferred")
+ .hasMessageContaining("catalog-only");
+ }
}
diff --git a/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java
index f84197b0f16e..fbc375766ed2 100644
--- a/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java
+++ b/core/src/test/java/org/apache/iceberg/rest/TestRESTScanPlanning.java
@@ -72,6 +72,7 @@
import org.apache.iceberg.relocated.com.google.common.collect.Lists;
import org.apache.iceberg.rest.responses.ConfigResponse;
import org.apache.iceberg.rest.responses.ErrorResponse;
+import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.gzip.GzipHandler;
import org.eclipse.jetty.servlet.ServletContextHandler;
@@ -123,7 +124,7 @@ public T execute(
.collect(Collectors.toList()))
.withOverrides(
ImmutableMap.of(
- RESTCatalogProperties.REST_SCAN_PLANNING_ENABLED, "true"))
+ RESTCatalogProperties.SCAN_PLANNING_MODE, "catalog-preferred"))
.build());
}
Object body = roundTripSerialize(request.body(), "request");
@@ -922,8 +923,8 @@ public T execute(
ImmutableMap.of(
CatalogProperties.FILE_IO_IMPL,
"org.apache.iceberg.inmemory.InMemoryFileIO",
- RESTCatalogProperties.REST_SCAN_PLANNING_ENABLED,
- "true"));
+ RESTCatalogProperties.SCAN_PLANNING_MODE,
+ "catalog-preferred"));
return new CatalogWithAdapter(catalog, adapter);
}
@@ -945,6 +946,75 @@ public void serverDoesNotSupportPlanningEndpoint() throws IOException {
.isEqualTo(FILE_A.location());
}
+ @Test
+ public void scanPlanningModeRequired() throws IOException {
+ // Test REQUIRED mode - should throw exception when server doesn't support planning
+ // Create a catalog that doesn't advertise scan planning endpoints
+ RESTCatalogAdapter adapter =
+ Mockito.spy(
+ new RESTCatalogAdapter(backendCatalog) {
+ @Override
+ public T execute(
+ HTTPRequest request,
+ Class responseType,
+ Consumer errorHandler,
+ Consumer