Skip to content

[Bug] Iceberg table snapshot wrappers discard server-side scan planning and bypass the unsupported-mode guard #68035

Description

@zy-kkk

Search before asking

  • I had searched in the issues and found no similar issues.

Version

  • Doris master at 28577df9f6d (2026-09-15), with a local build-packaging fix for macOS. No scan-planning code changes were applied.
  • Iceberg Java SDK 1.11.0.
  • One FE and one BE on macOS arm64, with matching newly built connector and BE Java scanner artifacts.
  • Docker: Apache Iceberg REST fixture and MinIO. The fixture advertises the scan-planning endpoints.
  • Fixture image: apache/iceberg-rest-fixture@sha256:db8de90b5b7693d4ac334c336f91d9bbe320d7b19f4f514d26de84cdfbcbfe8d.
  • MinIO image: minio/minio:RELEASE.2025-01-20T14-49-07Z.

What's Wrong?

Doris can silently plan an Iceberg table locally even when the REST catalog requires server-side scan planning. The existing rejectServerSideScanPlanning() guard does not detect the table after Doris creates its snapshot read view.

The SDK correctly returns a RESTTable, whose newScan() returns RESTTableScan and whose allowDistributedPlanning() returns false. However, both of these methods reconstruct any BaseTable subclass as a plain BaseTable:

  • IcebergStatementScope.snapshotReadTable(Table)
  • IcebergTableCache.TableOwner.snapshotReadTable()

Since RESTTable extends BaseTable, the reconstruction discards its scan implementation and the SupportsDistributedScanPlanning marker. IcebergScanPlanProvider.resolveTable() checks the guard after obtaining this wrapped table:

SDK RESTTable
  newScan() = RESTTableScan
  allowDistributedPlanning() = false
    ↓ snapshot read wrapper
plain BaseTable
  newScan() = DataTableScan
  no SupportsDistributedScanPlanning marker
    ↓ rejectServerSideScanPlanning()
guard does not match; local metadata reads proceed

This is observable even with a server-side table override. With the Doris catalog configured as client and the load-table response configured as server, the FE logs:

Scan planning mode mismatch for table plan_db.t1: client config=client, server config=server. Server config will take precedence.

Nevertheless, the query performs local planning and does not call /plan.

Relevant code at the tested commit

The snapshot wrapper preserves the operations and metadata but constructs a plain table:

return new BaseTable(new IcebergSnapshotTableOperations(operations, metadata),
        table.name(), baseTable.reporter());

Statement snapshot conversion:

private static Table snapshotReadTable(Table table) {
if (!(table instanceof BaseTable)) {
return table;
}
BaseTable baseTable = (BaseTable) table;
TableOperations operations = baseTable.operations();
TableMetadata metadata = operations.current();
// Keep IO, encryption and location behavior from the raw table while pinning only metadata.
return new BaseTable(new IcebergSnapshotTableOperations(operations, metadata),
table.name(), baseTable.reporter());
}

The same conversion in the table-cache snapshot path:

private Table snapshotReadTable() {
if (!(table instanceof BaseTable)) {
return table;
}
BaseTable baseTable = (BaseTable) table;
TableOperations operations = baseTable.operations();
TableMetadata metadata = snapshotMetadataJson == null
? operations.current()
: TableMetadataParser.fromJson(snapshotMetadataLocation, snapshotMetadataJson);
return new BaseTable(new IcebergSnapshotTableOperations(operations, metadata),
table.name(), baseTable.reporter());
}

The guard runs after obtaining the snapshot read table and depends on its runtime interface:

: IcebergStatementScope.sharedBorrowedTable(
session, handle.getDbName(), handle.getTableName(),
() -> tableCache.borrow(
TableIdentifier.of(handle.getDbName(), handle.getTableName()), directLoader),
directLoader);
rejectServerSideScanPlanning(raw, handle);
return wrapTableForScan(raw);
}
private static void rejectServerSideScanPlanning(Table table, IcebergTableHandle handle) {
if (table instanceof SupportsDistributedScanPlanning
&& !((SupportsDistributedScanPlanning) table).allowDistributedPlanning()) {
// Iceberg 1.11 marks REST server-planned tables this way. Doris reads manifests and table.io()
// before planFiles(), when REST scan-scoped credentials do not exist, so fail before any local I/O.
throw new DorisConnectorException("Iceberg server-side scan planning is not supported for table "
+ handle.getDbName() + "." + handle.getTableName()
+ "; configure the REST catalog to use client-side scan planning");
}
}

Observed consequences

  1. Rows outside a simulated server-side filter are returned. A test REST proxy adds id <= 2 to each task's residual-filter. The official Iceberg generic reader returns rows 1 and 2, while Doris returns rows 1, 2, 3 and 4. Doris never requests the plan carrying that filter.
  2. The intended unsupported-mode error is lost. Calling the existing guard with a real, unwrapped RESTTable throws the expected error. Calling the same guard after either snapshot wrapper does not throw.
  3. With restricted storage permissions, queries fail with an unrelated S3 403. When the client can read data files but is explicitly denied access to the table's metadata files, Doris attempts to read the manifest list locally and fails. The same credentials work for SDK server-side planning and data-file access.

The row-filter case uses a controlled test proxy and client credentials that can read the full table. It demonstrates a result difference under that simulated policy, not a finding against a particular production catalog's authorization implementation.

What You Expected?

Until Doris supports server-side scan planning, a table requiring it should be rejected before local manifest access with the existing error:

Iceberg server-side scan planning is not supported for table plan_db.t1; configure the REST catalog to use client-side scan planning

Snapshot pinning and table caching must not silently turn a server-planned table into a locally planned table. The same rule should apply whether server comes from client catalog configuration or the server's table-level override.

Full server-side planning support can be a separate change. That implementation would need to consume the server's tasks, apply residuals and use any scan-scoped credentials; simply removing the guard would not resolve these issues.

How to Reproduce?

Common setup

Run the REST fixture at http://127.0.0.1:18181 with a MinIO warehouse at s3://warehouse/wh/. MinIO is accessible to FE/BE at http://127.0.0.1:19000; the fixture uses its Docker-network endpoint and has access to both metadata and data files.

Create a client-planned catalog and a small table (the credentials below are local test credentials):

CREATE CATALOG rest_client PROPERTIES (
    'type' = 'iceberg',
    'iceberg.catalog.type' = 'rest',
    'uri' = 'http://127.0.0.1:18181',
    's3.access_key' = 'admin',
    's3.secret_key' = 'password',
    's3.endpoint' = 'http://127.0.0.1:19000',
    's3.region' = 'us-east-1',
    'use_path_style' = 'true'
);
SWITCH rest_client;
CREATE DATABASE IF NOT EXISTS plan_db;
CREATE TABLE plan_db.t1 (id INT, v STRING)
PROPERTIES ('write-format' = 'parquet');
INSERT INTO plan_db.t1 VALUES (1, 'a'), (2, 'b'), (3, 'c');
INSERT INTO plan_db.t1 VALUES (4, 'd');

This produces two Parquet files containing four rows. The tests below use this same table; no delete files are involved.

A. End-to-end row-filter comparison

Place a test proxy at http://127.0.0.1:18182, forwarding to the fixture with these two changes for plan_db.t1:

  1. In the load-table response, set config["scan-planning-mode"] = "server".
  2. In the synchronous /plan response, AND the following expression with each task's original residual-filter:
{"type": "lt-eq", "term": "id", "value": 2}

Keep the original file paths and record counts unchanged, and record incoming requests. The proxy used here only handles this synchronous test case; it is not a general policy service.

Create another Doris catalog with the same storage credentials, using the proxy URI and explicitly setting 'scan-planning-mode' = 'client'. The server's table-level override should take precedence:

CREATE CATALOG rest_policy_probe PROPERTIES (
    'type' = 'iceberg',
    'iceberg.catalog.type' = 'rest',
    'uri' = 'http://127.0.0.1:18182',
    's3.access_key' = 'admin',
    's3.secret_key' = 'password',
    's3.endpoint' = 'http://127.0.0.1:19000',
    's3.region' = 'us-east-1',
    'use_path_style' = 'true',
    'scan-planning-mode' = 'client'
);
SELECT * FROM rest_policy_probe.plan_db.t1 ORDER BY id;

Compare against the official Iceberg 1.11.0 generic reader using the same REST and storage settings:

Table table = catalog.loadTable(TableIdentifier.of("plan_db", "t1"));
try (CloseableIterable<Record> rows = IcebergGenerics.read(table).build()) {
    for (Record row : rows) {
        // Collect the actual records read from Parquet; sort by id for comparison.
    }
}

No client-side filter is supplied. Iceberg's GenericReader reads the Parquet files and applies each task's residual.

Execution Actual rows Observed POST /plan through proxy
Official reader, direct fixture without injected policy (1,a), (2,b), (3,c), (4,d) Not applicable: proxy not used
Official reader, through policy proxy (1,a), (2,b) 1
Doris SQL, through the same proxy (1,a), (2,b), (3,c), (4,d) 0

The no-policy baseline confirms that the underlying data is unchanged. The filtered reference result comes from actual Parquet reads, not from manually filtering the Doris result.

B. End-to-end restricted-storage comparison

Use the original fixture without the policy proxy. Give a new client identity:

  • permission to read warehouse/wh/plan_db/t1/data/*;
  • an explicit deny on s3:GetObject for warehouse/wh/plan_db/t1/metadata/* (this also denies S3 HEAD access).

Keep the fixture's own storage credentials unrestricted. The test bucket already had a public policy, so an explicit identity-policy deny was used and its effect verified.

Create a fresh Doris catalog using the restricted credentials and 'scan-planning-mode' = 'server', then run a full-row SELECT.

Observed results:

  • Catalog creation and SHOW TABLES succeed.
  • The SDK's RESTTableScan.planFiles() succeeds and returns two file tasks. Reading a byte from each data file with the same credentials succeeds; directly accessing the manifest list returns 403.
  • Doris SELECT fails with ERROR 1105 (HY000), errCode = 2, and Forbidden (Service: S3, Status Code: 403, ...), instead of the unsupported-mode error.

The FE stack identifies the local access:

IcebergScanPlanProvider.streamingSplitEstimate
  → BaseSnapshot.dataManifests
  → BaseSnapshot.cacheManifests
  → ManifestLists.read
  → S3InputFile.getLength
  → DefaultS3Client.headObject
  → S3Exception: Forbidden (403)

C. Guard control using a real RESTTable

A separate Java probe loads a real SDK RESTTable and invokes the running plugin's existing private guard and snapshot methods reflectively, without modifying their implementation:

Guard input Outcome
Raw SDK RESTTable Throws the expected DorisConnectorException
Result of IcebergStatementScope.snapshotReadTable Plain BaseTable; guard returns normally
Result of IcebergTableCache.TableOwner.snapshotReadTable Plain BaseTable; guard returns normally

This is a method-level control, not an end-to-end SQL test of a fix. The cache probe isolates the snapshot conversion; it does not exercise the entire cache lifecycle.

Reproduction sources and recorded results

The sources below are from the local reproduction. Save the proxy and reference reader as server.py and ReferenceReader.java. Start the Compose stack, create the common table above, then start the proxy with python3 server.py and run the policy catalog SQL above. The proxy logs its requests to requests.jsonl beside its source.

For the reference reader, use Java 17 and the tested Doris FE distribution's lib/* and plugins/connector/iceberg/lib/*, plus Iceberg data/parquet/orc 1.11.0, Parquet hadoop-bundle/avro 1.17.0, ORC core 1.8.4 and Hive storage-api 2.8.1 jars. Put the Parquet 1.17.0 jars before Doris FE jars on the reference classpath to avoid old Parquet classes from FE. Run java -cp "$REFERENCE_CP" ReferenceReader.java http://127.0.0.1:18182; use port 18181 for the unfiltered baseline. This is a reference-reader classpath only, not a change to the running Doris installation.

The bucket and credentials are for this isolated local test. The Compose initializer enables public bucket access; the restricted-storage control uses an explicit identity-policy Deny to override it.

Docker Compose (compose.yaml)
services:
  minio:
    image: minio/minio:RELEASE.2025-01-20T14-49-07Z
    container_name: lakespace-minio
    command: server /data --console-address ":9001"
    environment:
      - MINIO_ROOT_USER=admin
      - MINIO_ROOT_PASSWORD=password
      - MINIO_DOMAIN=minio
    ports:
      - "19000:9000"
      - "19001:9001"
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 3s
      timeout: 5s
      retries: 30

  mc:
    image: minio/mc:RELEASE.2025-01-17T23-25-50Z
    container_name: lakespace-mc
    depends_on:
      minio:
        condition: service_healthy
    entrypoint: >
      /bin/sh -c "
      mc alias set local http://minio:9000 admin password &&
      mc mb -p local/warehouse &&
      mc anonymous set public local/warehouse &&
      echo bucket-ready"

  rest:
    image: apache/iceberg-rest-fixture@sha256:db8de90b5b7693d4ac334c336f91d9bbe320d7b19f4f514d26de84cdfbcbfe8d
    container_name: lakespace-iceberg-rest
    depends_on:
      mc:
        condition: service_completed_successfully
    ports:
      - "18181:8181"
    environment:
      - AWS_ACCESS_KEY_ID=admin
      - AWS_SECRET_ACCESS_KEY=password
      - AWS_REGION=us-east-1
      - CATALOG_WAREHOUSE=s3://warehouse/wh/
      - CATALOG_IO__IMPL=org.apache.iceberg.aws.s3.S3FileIO
      - CATALOG_S3_ENDPOINT=http://minio:9000
      - CATALOG_S3_PATH__STYLE__ACCESS=true
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8181/v1/config"]
      interval: 3s
      timeout: 5s
      retries: 30
Policy proxy (server.py)
import json
import urllib.request
import urllib.error
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path

LOG = Path(__file__).with_name('requests.jsonl')
UPSTREAM = 'http://127.0.0.1:18181'
TABLE_PATH = '/v1/namespaces/plan_db/tables/t1'
POLICY = {'type': 'lt-eq', 'term': 'id', 'value': 2}
OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))

class Handler(BaseHTTPRequestHandler):
    def handle_request(self):
        data = self.rfile.read(int(self.headers.get('Content-Length', 0)))
        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in ('host', 'content-length', 'connection', 'accept-encoding')}
        request = urllib.request.Request(UPSTREAM + self.path, data=data or None,
                                         headers=headers, method=self.command)
        try:
            response = OPENER.open(request, timeout=30)
        except urllib.error.HTTPError as e:
            response = e
        with response:
            body = response.read()
            status = response.status
            modified = None
            if status == 200 and self.path.split('?')[0] == TABLE_PATH and self.command == 'GET':
                payload = json.loads(body)
                payload.setdefault('config', {})['scan-planning-mode'] = 'server'
                body = json.dumps(payload).encode()
                modified = 'force-server'
            if status == 200 and self.path == TABLE_PATH + '/plan' and self.command == 'POST':
                payload = json.loads(body)
                assert payload['status'] == 'completed' and not payload.get('plan-tasks'), payload
                for task in payload['file-scan-tasks']:
                    original = task.get('residual-filter', True)
                    task['residual-filter'] = POLICY if original is True else {
                        'type': 'and', 'left': original, 'right': POLICY}
                body = json.dumps(payload).encode()
                modified = 'append-id-le-2'
            with LOG.open('a') as log:
                log.write(json.dumps({'method': self.command, 'path': self.path,
                                      'status': status, 'modified': modified}) + '\n')
            self.send_response(status)
            self.send_header('Content-Type', response.headers.get('Content-Type', 'application/json'))
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
    do_GET = do_POST = do_DELETE = do_HEAD = handle_request

print('Policy proxy listening on 127.0.0.1:18182', flush=True)
ThreadingHTTPServer(('127.0.0.1', 18182), Handler).serve_forever()
Official generic-reader client (ReferenceReader.java)
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Map;
import org.apache.iceberg.Table;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.data.IcebergGenerics;
import org.apache.iceberg.rest.RESTCatalog;

class ReferenceReader {
    public static void main(String[] args) throws Exception {
        try (RESTCatalog catalog = new RESTCatalog()) {
            catalog.initialize("reference", Map.of(
                    "uri", args[0],
                    "scan-planning-mode", "client",
                    "s3.endpoint", "http://127.0.0.1:19000",
                    "s3.path-style-access", "true",
                    "s3.access-key-id", "admin",
                    "s3.secret-access-key", "password",
                    "client.region", "us-east-1"));
            Table table = catalog.loadTable(TableIdentifier.of("plan_db", "t1"));
            System.err.println("TABLE=" + table.getClass().getName());
            System.err.println("SCAN=" + table.newScan().getClass().getName());
            var rows = new ArrayList<org.apache.iceberg.data.Record>();
            // No client WHERE: apply only the residual returned by the server via the official reader.
            try (var records = IcebergGenerics.read(table).build()) {
                for (var record : records) rows.add(record.copy());
            }
            rows.sort(Comparator.comparingInt(r -> (Integer) r.getField("id")));
            System.out.println("id\tv");
            for (var record : rows) {
                System.out.println(record.getField("id") + "\t" + record.getField("v"));
            }
        }
    }
}
Restricted client identity policy (policy.json)
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::warehouse"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::warehouse/wh/plan_db/t1/data/*"
      ]
    },
    {
      "Effect": "Deny",
      "Action": [
        "s3:GetObject"
      ],
      "Resource": [
        "arn:aws:s3:::warehouse/wh/plan_db/t1/metadata/*"
      ]
    }
  ]
}

Attach the policy to a separate MinIO test user using mc admin policy create and mc admin policy attach --user. Configure only the restricted Doris catalog with that user's credentials; keep the REST fixture's original admin credentials.

Recorded full-read comparison
{
  "reference-baseline": {
    "rows": [
      "1\ta",
      "2\tb",
      "3\tc",
      "4\td"
    ],
    "plan_requests": 0
  },
  "reference-policy": {
    "rows": [
      "1\ta",
      "2\tb"
    ],
    "plan_requests": 1
  },
  "doris-policy": {
    "rows": [
      "1\ta",
      "2\tb",
      "3\tc",
      "4\td"
    ],
    "plan_requests": 0
  }
}
Reference policy-reader request trace
{"method": "GET", "path": "/v1/config", "status": 200, "modified": null}
{"method": "GET", "path": "/v1/namespaces/plan_db/tables/t1?snapshots=all", "status": 200, "modified": "force-server"}
{"method": "POST", "path": "/v1/namespaces/plan_db/tables/t1/plan", "status": 200, "modified": "append-id-le-2"}
{"method": "DELETE", "path": "/v1/namespaces/plan_db/tables/t1/plan/sync-02915d45-5652-466a-9354-edc4dc6edbf9", "status": 204, "modified": null}
Method-level guard control (PolicyGuardProbe.java)
import java.lang.reflect.Method;
import java.util.Map;
import org.apache.iceberg.Table;
import org.apache.iceberg.SupportsDistributedScanPlanning;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.rest.RESTCatalog;

class PolicyGuardProbe {
    public static void main(String[] args) throws Exception {
        try (RESTCatalog catalog = new RESTCatalog()) {
            catalog.initialize("probe", Map.of(
                    "uri", "http://127.0.0.1:18182",
                    "scan-planning-mode", "client",
                    "s3.endpoint", "http://127.0.0.1:19000",
                    "s3.path-style-access", "true",
                    "s3.access-key-id", "admin",
                    "s3.secret-access-key", "password",
                    "client.region", "us-east-1"));
            Table raw = catalog.loadTable(TableIdentifier.of("plan_db", "t1"));
            print("SDK", raw);
            Class<?> handleClass = Class.forName("org.apache.doris.connector.iceberg.IcebergTableHandle");
            Object handle = handleClass.getConstructor(String.class, String.class).newInstance("plan_db", "t1");
            Class<?> provider = Class.forName("org.apache.doris.connector.iceberg.IcebergScanPlanProvider");
            Method guard = provider.getDeclaredMethod("rejectServerSideScanPlanning", Table.class, handleClass);
            guard.setAccessible(true);
            checkGuard(guard, raw, handle, "RAW");
            try (var tasks = raw.newScan().planFiles()) {
                for (var task : tasks) {
                    System.out.println("TASK: " + task.file().location() + " residual=" + task.residual());
                    var evaluator = new org.apache.iceberg.expressions.Evaluator(raw.schema().asStruct(), task.residual(), true);
                    for (int id = 1; id <= 4; id++) {
                        final int value = id;
                        org.apache.iceberg.StructLike row = new org.apache.iceberg.StructLike() {
                            public int size() { return 2; }
                            public <T> T get(int pos, Class<T> type) { return type.cast(pos == 0 ? Integer.valueOf(value) : "x"); }
                            public <T> void set(int pos, T v) { throw new UnsupportedOperationException(); }
                        };
                        System.out.println("POLICY id=" + id + " allowed=" + evaluator.eval(row));
                    }
                }
            }
            Class<?> scope = Class.forName("org.apache.doris.connector.iceberg.IcebergStatementScope");
            Method snapshot = scope.getDeclaredMethod("snapshotReadTable", Table.class);
            snapshot.setAccessible(true);
            Table frozen = (Table) snapshot.invoke(null, raw);
            print("Doris snapshotReadTable", frozen);
            checkGuard(guard, frozen, handle, "FROZEN");
            Class<?> ownerClass = Class.forName("org.apache.doris.connector.iceberg.IcebergTableCache$TableOwner");
            var constructor = ownerClass.getDeclaredConstructor(Table.class, Runnable.class, boolean.class, boolean.class);
            constructor.setAccessible(true);
            Object owner = constructor.newInstance(raw, (Runnable) () -> {}, false, false);
            Method cacheSnapshot = ownerClass.getDeclaredMethod("snapshotReadTable");
            cacheSnapshot.setAccessible(true);
            Table cached = (Table) cacheSnapshot.invoke(owner);
            print("Doris cache snapshotReadTable", cached);
            checkGuard(guard, cached, handle, "CACHE_FROZEN");
        }
    }
    private static void checkGuard(Method guard, Table table, Object handle, String label) throws Exception {
        try {
            guard.invoke(null, table, handle);
            System.out.println(label + " GUARD: PASSED");
        } catch (java.lang.reflect.InvocationTargetException e) {
            System.out.println(label + " GUARD: " + e.getCause().getClass().getName() + ": " + e.getCause().getMessage());
        }
    }
    private static void print(String label, Table table) {
        System.out.println(label + ": table=" + table.getClass().getName()
                + ", scan=" + table.newScan().getClass().getName()
                + ", guardMatches=" + (table instanceof SupportsDistributedScanPlanning
                    && !((SupportsDistributedScanPlanning) table).allowDistributedPlanning()));
    }
}

The guard probe requires the running FE connector jars on its classpath and uses the same local endpoints. It does not modify those jars.

Anything Else?

  • Feature tracking: [Feature] Support Iceberg REST server-side scan planning #68036. This bug tracks restoring rejection; the feature tracks complete server-side planning support.

  • Related: [fix](iceberg) Fix historical scans after schema evolution #67687 upgraded Iceberg to 1.11.0 and added the unsupported-mode guard.

  • The raw scan-planning-mode property already reaches the SDK through IcebergCatalogFactory's property forwarding. Adding a new property alias alone would not fix the behavior above.

  • The current IcebergScanPlanProviderTest.serverPlannedTable helper creates a proxy implementing Table and SupportsDistributedScanPlanning. It is not a BaseTable, so it does not exercise the conversion that affects a real RESTTable.

  • Regression coverage should include real REST-loaded tables through both snapshot paths, client-configured and server-forced planning modes, rejection before local manifest access, and unchanged client-planned queries.

  • No claim is made here about asynchronous planning, credential vending, delete files, or a specific production catalog's policy implementation; these were not part of this reproduction.

Proposed fix for PR 1 (subject to implementation validation)

Check the original table at the boundary where it enters a snapshot read view, before rebuilding RESTTable as BaseTable and before operations that depend on local manifests.

  1. Extract a shared mode check. Reuse the existing SupportsDistributedScanPlanning / !allowDistributedPlanning() condition and unsupported-mode error across read paths.
  2. Cover both wrapping paths. Check the original SDK table on statement-scoped direct loads and cache load/borrow paths. Audit cache metadata sizing and serialization for manifest access as well; checking only after TableOwner.snapshotReadTable() returns is too late. Keep the check inside the existing resource-management scope so rejection releases load guards and borrowed references correctly.
  3. Retain scan-entry checks. Keep the existing scan-side guard, but do not rely on it to identify a plain BaseTable after capabilities have been lost. Audit metadata-table base-table resolution for the same wrapping issue.
  4. Preserve normal snapshot semantics. Client-planned tables should retain their current snapshot, cache and ownership behavior. Do not fix this by unconditionally returning the original mutable REST table, which could change metadata consistency within a query.

Illustrative ordering only, not a final API or patch:

// Within the existing load/lease resource scope:
Table raw = loadOrBorrowTable();
rejectUnsupportedPlanning(raw); // Inspect capabilities before wrapping/local manifest work.
Table readView = createSnapshotReadView(raw);

Validation should cover real RESTTable instances, cache enabled/disabled, client-requested and server-forced modes, empty and populated tables, metadata-table base resolution and cleanup on rejection. Both existing end-to-end reproductions should change from extra rows or S3 403 to an explicit unsupported-mode error, while client-planned queries and snapshot-isolation tests continue to pass.

This restores rejection without enabling server-side planning. Final check placement requires auditing callers and resource lifecycles; complete support remains in the feature's subsequent PR.

Proposed follow-up plan

  1. PR 1: restore the guard. Fix the snapshot-wrapper paths while retaining metadata isolation and resource ownership. Add real RESTTable tests; verify that the row-filter and restricted-storage reproductions both fail explicitly before local manifest reads, while client-planned queries remain correct. This PR closes this bug issue.
  2. PR 2: implement server-side planning. Track complete support in [Feature] Support Iceberg REST server-side scan planning #68036. Before enabling supported reads, implement REST task planning and execution together with residual enforcement, scan-scoped credentials, lifecycle handling and regression tests. In that version, the policy test must return only rows 1 and 2, and the restricted-storage query must succeed.

The feature remains open after PR 1. Preparatory changes for PR 2 may be split if needed, but must not enable partially supported scans.

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

  • I agree to follow this project's Code of Conduct.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/icebergkind/fixCategorizes issue or PR as related to a bug.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions