zy-kkk opened a new issue, #68035:
URL: https://github.com/apache/doris/issues/68035

   ### Search before asking
   
   - [x] 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:
   
   ```text
   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:
   
   ```text
   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:
   
   ```java
   return new BaseTable(new IcebergSnapshotTableOperations(operations, 
metadata),
           table.name(), baseTable.reporter());
   ```
   
   Statement snapshot conversion:
   
   
https://github.com/apache/doris/blob/28577df9f6df3446f7fd1d0c90032fb7d85c7733/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergStatementScope.java#L257-L267
   
   The same conversion in the table-cache snapshot path:
   
   
https://github.com/apache/doris/blob/28577df9f6df3446f7fd1d0c90032fb7d85c7733/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergTableCache.java#L284-L295
   
   The guard runs after obtaining the snapshot read table and depends on its 
runtime interface:
   
   
https://github.com/apache/doris/blob/28577df9f6df3446f7fd1d0c90032fb7d85c7733/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergScanPlanProvider.java#L3105-L3123
   
   #### 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:
   
   ```text
   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):
   
   ```sql
   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`:
   
   ```json
   {"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:
   
   ```sql
   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:
   
   ```java
   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:
   
   ```text
   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.
   
   <details>
   <summary>Docker Compose (compose.yaml)</summary>
   
   ```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
   ```
   
   </details>
   
   <details>
   <summary>Policy proxy (server.py)</summary>
   
   ```python
   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()
   ```
   
   </details>
   
   <details>
   <summary>Official generic-reader client (ReferenceReader.java)</summary>
   
   ```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"));
               }
           }
       }
   }
   ```
   
   </details>
   
   <details>
   <summary>Restricted client identity policy (policy.json)</summary>
   
   ```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/*"
         ]
       }
     ]
   }
   ```
   
   </details>
   
   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.
   
   <details>
   <summary>Recorded full-read comparison</summary>
   
   ```json
   {
     "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
     }
   }
   ```
   
   </details>
   
   <details>
   <summary>Reference policy-reader request trace</summary>
   
   ```json
   {"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}
   ```
   
   </details>
   
   <details>
   <summary>Method-level guard control (PolicyGuardProbe.java)</summary>
   
   ```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()));
       }
   }
   ```
   
   </details>
   
   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?
   
   - Related: #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:
   
   ```java
   // 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 a 
separate feature issue (number to be added after publication). 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?
   
   - [x] Yes I am willing to submit a PR!
   
   ### Code of Conduct
   
   - [x] I agree to follow this project's Code of Conduct.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to