laskoviymishka commented on code in PR #2162:
URL: https://github.com/apache/iceberg-go/pull/2162#discussion_r4234113981


##########
README.md:
##########
@@ -138,14 +138,27 @@ submission, polling, and task retrieval. Cancellation is 
best-effort and is not
 required for automatic remote planning. In `auto` mode, catalogs
 without a planner retain local planning.
 
-The scanner does not honor the REST `scan-planning-mode` configuration key
-(`client`/`server`). A catalog requiring `server` mode may rely on planning to
-vend plan-scoped storage credentials. Default local planning, explicit `local`,
-or an `auto` fallback can instead read manifests with the table's storage
-credentials, which may use a different identity or lack the required access.
-For these deployments, explicitly select `table.ScanPlanningRemote` on every
-scan; do not rely on the server configuration or `auto` to enforce remote
-planning. Remote mode returns an error if a required capability is missing.
+Scans follow the catalog's `scan-planning-mode` directive (`client`/`server`).
+As in the Java client, the directive comes from the table-load response's
+`config` block; when the server sends none, the catalog's own
+`scan-planning-mode` property applies, and a mismatch between the two is
+logged with the server value winning. Read it with 
`tbl.ScanPlanningDirective()`.
+
+| Directive | Scan option | Result |
+| :-------- | :---------- | :----- |
+| `server` | none, `auto`, or `remote` | Plan remotely. |
+| `server` | `local` | Error. |
+| `client` | none, `auto`, or `local` | Plan locally. |
+| `client` | `remote` | Error. |
+| none | any | As described above. |
+
+The directive is checked when a scan is planned, not when the table is loaded,
+so a `server` table whose catalog does not advertise the plan endpoint can
+still be loaded and committed to; planning it returns an error. An unrecognized
+directive value also fails only at planning. Incremental scans and scans inside

Review Comment:
   I'd either route incremental scans through `effectivePlanningMode()` or 
spell out why they're exempt and pin it with a test. The directive exists 
because a `server` catalog may vend plan-scoped credentials, but incremental 
append/changelog scans switch on `planningMode` directly and never consult the 
directive, so on a `server` table they read manifests locally with the table's 
own identity, which is the same gap the old README paragraph warned about, just 
moved to a sibling entry point.
   
   The note here says they "plan as before" but not whether that is intended or 
just not-yet-done. If they stay exempt I'd state the rationale and open a 
follow-up; either way a test that an incremental scan on a `server` table 
doesn't error and doesn't call the planner would lock the behavior in.



##########
catalog/rest/rest.go:
##########
@@ -1339,11 +1364,20 @@ func (r *Catalog) RefreshTableCredentials(ctx 
context.Context, tbl *table.Table)
        // Keep a reporter the caller set on the table rather than reverting it 
to
        // the catalog default, as Refresh does.
        opts := []table.Option{table.WithSavedConfig(tbl.SavedConfig())}
+       // Credential refresh does not return table config, so carry the 
existing
+       // scan-planning directive over. An unrecognized directive is not 
exposed by
+       // ScanPlanningDirective, but its raw value is the load config's entry 
in the
+       // saved config.
+       if d, err := tbl.ScanPlanningDirective(); err != nil {
+               opts = append(opts, 
table.WithScanPlanningDirective(tbl.SavedConfig()[table.ScanPlanningModeKey]))

Review Comment:
   I'd carry the raw directive across the refresh instead of re-deriving it 
from `SavedConfig`, which can hold a different value than the table was loaded 
with. Two cases break: if the directive came from the catalog-property fallback 
(`r.props`) it isn't in `SavedConfig` at all, so this reconstructs it as 
`WithScanPlanningDirective("")` and the refreshed table now reports 
"unrecognized"; and if table metadata happens to carry a `scan-planning-mode` 
property, a `bogus` directive silently becomes a valid `server` after refresh, 
even though metadata is explicitly never consulted for the directive.
   
   The existing test only passes because it seeds `SavedConfig` with the same 
value. I'd expose a raw `(value, present)` accessor on `Table` and copy it with 
`withScanPlanningDirectiveState` semantics, which also lets the whole `err != 
nil` branch go away.



##########
table/scan_planning.go:
##########
@@ -53,10 +57,119 @@ const (
        ScanPlanningAuto ScanPlanningMode = "auto"
 )
 
+// ScanPlanningModeKey is the REST table-config key a catalog uses to tell
+// clients which planning mode a table supports. Valid values are `client` and
+// `server`.
+const ScanPlanningModeKey = "scan-planning-mode"
+
+// ScanPlanningDirective is the catalog's `scan-planning-mode` table-config
+// value. It is distinct from ScanPlanningMode, the user-facing scan option.
+type ScanPlanningDirective string
+
+const (
+       // ScanPlanningDirectiveUnknown is the zero-value string denoting an 
unknown,
+       // unrestricted or missing scan planning directive.
+       ScanPlanningDirectiveUnknown ScanPlanningDirective = ""
+       // ScanPlanningDirectiveClient indicates the catalog requires the 
client to
+       // plan scans locally.
+       ScanPlanningDirectiveClient ScanPlanningDirective = "client"
+       // ScanPlanningDirectiveServer indicates the catalog requires scans to 
be
+       // planned remotely by the catalog server.
+       ScanPlanningDirectiveServer ScanPlanningDirective = "server"
+)
+
+// ScanPlanningDirective returns the catalog's `scan-planning-mode` directive
+// recorded when the table was loaded. For a REST catalog this is the value in
+// the `config` block of the table-load response, or, when the server sends
+// none, the catalog's own `scan-planning-mode` property. Table metadata
+// properties are not consulted. Values are matched case-insensitively.
+//
+// It returns an empty directive and a nil error when the catalog supplied no
+// directive, and an error wrapping iceberg.ErrInvalidArgument when the
+// directive holds an unrecognized value.
+//
+// The directive is only known for tables built from a load response
+// (LoadTable, CreateTable, RegisterTable, and their refreshes). Tables 
returned
+// by a catalog's UpdateTable report no directive, because commit responses do
+// not carry table config; reload the table to obtain it.
+//
+// (*Scan).PlanFiles enforces the directive; see ScanPlanningMode.
+func (t Table) ScanPlanningDirective() (ScanPlanningDirective, error) {
+       if !t.hasScanPlanningDirective {
+               return ScanPlanningDirectiveUnknown, nil
+       }
+
+       for _, d := range []ScanPlanningDirective{ScanPlanningDirectiveClient, 
ScanPlanningDirectiveServer} {
+               if strings.EqualFold(t.scanPlanningDirective, string(d)) {
+                       return d, nil
+               }
+       }
+
+       return ScanPlanningDirectiveUnknown, fmt.Errorf("%w: unrecognized %s 
%q, expected %q or %q",
+               iceberg.ErrInvalidArgument, ScanPlanningModeKey, 
t.scanPlanningDirective,
+               ScanPlanningDirectiveClient, ScanPlanningDirectiveServer)
+}
+
+// WithScanPlanningDirective records the raw `scan-planning-mode` value from a
+// catalog's table-load response config. Catalogs should only pass this option
+// when the key is present in that config, so that an empty value is reported
+// as unrecognized rather than absent.

Review Comment:
   Since this is a new exported option we'll carry pre-v1, I'd reconsider the 
raw-string-plus-hidden-presence-flag shape before it locks in. The contract 
"only pass this when the key is present" can't be expressed in the signature, 
so a caller passing `""` for "no directive" gets "present but invalid" and 
every scan on that table fails.
   
   One option is to take `iceberg.Properties` and extract the key internally so 
presence is implicit; another is to keep the string but make absence part of 
the signature (`value string, present bool`). Either works, but I'd rather not 
ship a public option whose correct use depends on a doc comment. wdyt?



##########
README.md:
##########
@@ -138,14 +138,27 @@ submission, polling, and task retrieval. Cancellation is 
best-effort and is not
 required for automatic remote planning. In `auto` mode, catalogs
 without a planner retain local planning.
 
-The scanner does not honor the REST `scan-planning-mode` configuration key
-(`client`/`server`). A catalog requiring `server` mode may rely on planning to
-vend plan-scoped storage credentials. Default local planning, explicit `local`,
-or an `auto` fallback can instead read manifests with the table's storage
-credentials, which may use a different identity or lack the required access.
-For these deployments, explicitly select `table.ScanPlanningRemote` on every
-scan; do not rely on the server configuration or `auto` to enforce remote
-planning. Remote mode returns an error if a required capability is missing.
+Scans follow the catalog's `scan-planning-mode` directive (`client`/`server`).
+As in the Java client, the directive comes from the table-load response's

Review Comment:
   I'd soften "As in the Java client" where it implies full parity. The 
resolution precedence does match Java, but two behaviors here are Go-specific: 
an unrecognized directive value fails lazily at plan time rather than at load 
(Java's `ScanPlanningMode.fromString` throws during catalog init), and 
rejecting an explicit `local` under `server` (or `remote` under `client`) is a 
Go-only per-scan policy Java has no equivalent for. Both are reasonable, I'd 
just not describe them as Java-matching.



##########
catalog/rest/rest.go:
##########
@@ -1894,8 +1928,9 @@ func (r *Catalog) UpdateTable(ctx context.Context, ident 
table.Identifier, requi
        config := maps.Clone(r.props)
        maps.Copy(config, metadata.Properties())
 
-       // A commit response carries no labels (they are load-time enrichment).
-       return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc, 
config, config, false, nil, table.WithSavedConfig(metadata.Properties()))
+       // A commit response carries no labels or table config (they are 
load-time
+       // enrichment), so the returned table reports no scan-planning 
directive.
+       return r.tableFromResponse(ctx, ident, metadata, ret.MetadataLoc, 
config, config, false, nil, nil, table.WithSavedConfig(metadata.Properties()))

Review Comment:
   I'd carry the catalog's directive through `UpdateTable` rather than passing 
`nil` here. Right now a caller that commits through the catalog and then scans 
the returned table (`tbl, _ := cat.UpdateTable(...); 
tbl.Scan().PlanFiles(...)`) gets local planning even when the catalog said 
`server`, so the exact deployment this PR protects (plan-scoped credentials) 
silently reads with the wrong identity instead of failing closed.
   
   The commit response genuinely has no table config, but the catalog property 
is still available: passing `r.scanPlanningDirectiveConfig(ident, nil)` here 
keeps the catalog-wide `client`/`server` enforced at no cost. The README 
documents the gap, but silently relaxing enforcement is worse than failing.



##########
catalog/rest/rest.go:
##########
@@ -1269,20 +1270,44 @@ func (r *Catalog) tableFromResponse(
                }
        }
 
+       defaults := []table.Option{
+               table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(scanPlanningConfig),
+               table.WithLabels(labels),
+       }
+       // The scan-planning directive never comes from table metadata. Load 
paths
+       // pass a config resolved by scanPlanningDirectiveConfig.
+       if directive, ok := loadConfig[table.ScanPlanningModeKey]; ok {
+               defaults = append(defaults, 
table.WithScanPlanningDirective(directive))
+       }
+
        // Caller-supplied opts are applied last so they can override the 
defaults
        // derived above.
-       return table.New(
-               identifier,
-               metadata,
-               loc,
-               fsF,
-               r,
-               append([]table.Option{
-                       table.WithMetricsReporter(reporter),
-                       table.WithScanPlanningIOProperties(scanPlanningConfig),
-                       table.WithLabels(labels),
-               }, opts...)...,
-       ), nil
+       return table.New(identifier, metadata, loc, fsF, r, append(defaults, 
opts...)...), nil
+}
+
+// scanPlanningDirectiveConfig resolves a table's scan-planning-mode directive
+// from a table-load response, as Java's RESTSessionCatalog does: the server's
+// load config takes precedence, and the catalog's own scan-planning-mode
+// property applies when the server sends none. A mismatch between the two is
+// logged. The result is the load config passed to tableFromResponse.
+func (r *Catalog) scanPlanningDirectiveConfig(identifier []string, loadConfig 
iceberg.Properties) iceberg.Properties {
+       server, hasServer := loadConfig[table.ScanPlanningModeKey]
+       client, hasClient := r.props[table.ScanPlanningModeKey]

Review Comment:
   Worth confirming `r.props` already carries the `/v1/config` defaults and 
overrides at this point, not just user-supplied props. Catalogs commonly 
advertise `scan-planning-mode` via the `/v1/config` `defaults` block, and if 
those aren't merged into `r.props` the directive is silently ignored for 
exactly that deployment. A test that sets `scan-planning-mode` in a 
`/v1/config` response and asserts the loaded table reports it would settle it 
(the current `catalog_property_only` case only exercises `WithAdditionalProps`).



##########
table/scanner.go:
##########
@@ -1627,7 +1634,12 @@ func (scan *Scan) planFiles(ctx context.Context, 
projectScanColumns bool) ([]Fil
                scan.asOfTimestamp = nil
        }
 
-       switch scan.planningMode {
+       mode, err := scan.effectivePlanningMode()
+       if err != nil {
+               return nil, err
+       }
+
+       switch mode {

Review Comment:
   I'd keep a defensive `default:` on this switch returning the unknown-mode 
error that got removed. `exhaustive` is satisfied today only because 
`effectivePlanningMode` validates the mode first, but the two now have to stay 
in lockstep: a future fourth `ScanPlanningMode` added to the validation list 
but not to this switch falls through to local planning silently. The old 
`default` cost nothing and closed that gap.



##########
catalog/rest/rest.go:
##########
@@ -1269,20 +1270,44 @@ func (r *Catalog) tableFromResponse(
                }
        }
 
+       defaults := []table.Option{
+               table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(scanPlanningConfig),
+               table.WithLabels(labels),
+       }
+       // The scan-planning directive never comes from table metadata. Load 
paths
+       // pass a config resolved by scanPlanningDirectiveConfig.
+       if directive, ok := loadConfig[table.ScanPlanningModeKey]; ok {
+               defaults = append(defaults, 
table.WithScanPlanningDirective(directive))
+       }
+
        // Caller-supplied opts are applied last so they can override the 
defaults
        // derived above.
-       return table.New(
-               identifier,
-               metadata,
-               loc,
-               fsF,
-               r,
-               append([]table.Option{
-                       table.WithMetricsReporter(reporter),
-                       table.WithScanPlanningIOProperties(scanPlanningConfig),
-                       table.WithLabels(labels),
-               }, opts...)...,
-       ), nil
+       return table.New(identifier, metadata, loc, fsF, r, append(defaults, 
opts...)...), nil
+}
+
+// scanPlanningDirectiveConfig resolves a table's scan-planning-mode directive
+// from a table-load response, as Java's RESTSessionCatalog does: the server's
+// load config takes precedence, and the catalog's own scan-planning-mode
+// property applies when the server sends none. A mismatch between the two is
+// logged. The result is the load config passed to tableFromResponse.
+func (r *Catalog) scanPlanningDirectiveConfig(identifier []string, loadConfig 
iceberg.Properties) iceberg.Properties {
+       server, hasServer := loadConfig[table.ScanPlanningModeKey]
+       client, hasClient := r.props[table.ScanPlanningModeKey]
+
+       switch {
+       case hasServer:
+               if hasClient && !strings.EqualFold(client, server) {
+                       slog.Warn("iceberg: scan-planning-mode mismatch between 
catalog and server; using the server value",

Review Comment:
   I'd log this once per catalog rather than on every load. `Refresh` goes 
through `LoadTable`, so a stable intentional mismatch spams a warning on every 
load through the global `slog` default. Dropping to `Debug` on repeats and 
using `slog.WarnContext(ctx, ...)` since ctx is available upstream would keep a 
hot loader quiet.



-- 
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