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


##########
table/scan_planning.go:
##########
@@ -53,6 +55,89 @@ 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 (
+       // ScanPlanningDirectiveNone is the zero value. When returned with a nil
+       // error from Table.ScanPlanningDirective, it means the catalog 
supplied no
+       // directive and does not constrain where scans are planned. It is also
+       // returned alongside a non-nil error for an unrecognized directive, in
+       // which case it carries no meaning.
+       ScanPlanningDirectiveNone 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
+// taken from the `config` block of the table-load response. Table metadata
+// properties and catalog properties are not consulted. Values are matched
+// case-insensitively.
+//
+// It returns ScanPlanningDirectiveNone and a nil error when the catalog
+// supplied no directive. Any value other than `client` or `server`, including
+// an empty value or a mode introduced by a future version of the REST spec,
+// returns an error wrapping iceberg.ErrInvalidArgument; callers should treat
+// that as a directive they cannot honor rather than as the absence of one.
+//
+// The directive is only known for tables built from a load response
+// (LoadTable, CreateTable, RegisterTable, and their refreshes); tables
+// returned by Transaction.Commit or StagedTable keep the directive of the
+// table they were derived from. Tables returned by a catalog's UpdateTable
+// report no directive, because commit responses do not carry table config;
+// reload the table to obtain it.
+//
+// The scanner does not yet enforce this directive; callers must apply it
+// themselves via WithScanPlanningMode.
+func (t Table) ScanPlanningDirective() (ScanPlanningDirective, error) {
+       if !t.hasScanPlanningDirective {
+               return ScanPlanningDirectiveNone, nil
+       }
+
+       switch d := 
ScanPlanningDirective(strings.ToLower(t.scanPlanningDirective)); d {
+       case ScanPlanningDirectiveClient, ScanPlanningDirectiveServer:
+               return d, nil
+       default:
+               return ScanPlanningDirectiveNone, 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. ok reports whether the key was
+// present, so callers can pass a comma-ok map read straight through:
+//
+//     v, ok := cfg[table.ScanPlanningModeKey]
+//     table.WithScanPlanningDirective(v, ok)
+//
+// When ok is false the table reports no directive. A present but empty value
+// is reported as unrecognized.
+func WithScanPlanningDirective(value string, ok bool) Option {

Review Comment:
   comma-ok does close the present-but-empty trap from last round for our own 
wiring (ok=false expresses absence), so that part's good. my remaining worry is 
that this is an exported option we freeze at v1, and the two-arg shape is easy 
to misuse from outside: a third-party catalog that writes 
`WithScanPlanningDirective(cfg[key], true)`, or passes a constant `true`, turns 
every directive-less table into a hard error out of `ScanPlanningDirective()`.
   
   I'd expose the config-block form and do the lookup internally, since 
`scanPlanningDirectiveFromConfig` already does exactly that read:
   
   ```go
   func WithScanPlanningConfig(cfg iceberg.Properties) Option
   ```
   
   or keep the two-arg form unexported and let `WithScanPlanningDirectiveFrom` 
be the only exported way to copy state. either one closes the footgun before 
we're stuck with the surface.



##########
catalog/rest/rest.go:
##########
@@ -1894,7 +1909,8 @@ 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).
+       // A commit response carries no labels or table config (they are 
load-time
+       // enrichment), so the returned table reports no scan-planning 
directive.

Review Comment:
   a `server` table committed through `Catalog.UpdateTable` comes back `(None, 
nil)`, and `None`+nil is our "unconstrained, local is fine" signal, which is 
the unsafe direction to fail. the commit response genuinely carries no config 
so I'm not asking UpdateTable to invent one, but I'd settle the "we don't know" 
semantics now instead of leaning on this comment, since enforcement will key 
off the contract once it lands. a distinct "reload required" state (rather than 
reusing `None`), or carrying the prior directive through where we have it, both 
work.



##########
table/scan_planning.go:
##########
@@ -53,6 +55,89 @@ 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 (
+       // ScanPlanningDirectiveNone is the zero value. When returned with a nil
+       // error from Table.ScanPlanningDirective, it means the catalog 
supplied no
+       // directive and does not constrain where scans are planned. It is also
+       // returned alongside a non-nil error for an unrecognized directive, in
+       // which case it carries no meaning.
+       ScanPlanningDirectiveNone 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
+// taken from the `config` block of the table-load response. Table metadata
+// properties and catalog properties are not consulted. Values are matched
+// case-insensitively.
+//
+// It returns ScanPlanningDirectiveNone and a nil error when the catalog
+// supplied no directive. Any value other than `client` or `server`, including
+// an empty value or a mode introduced by a future version of the REST spec,
+// returns an error wrapping iceberg.ErrInvalidArgument; callers should treat
+// that as a directive they cannot honor rather than as the absence of one.
+//
+// The directive is only known for tables built from a load response
+// (LoadTable, CreateTable, RegisterTable, and their refreshes); tables
+// returned by Transaction.Commit or StagedTable keep the directive of the
+// table they were derived from. Tables returned by a catalog's UpdateTable
+// report no directive, because commit responses do not carry table config;
+// reload the table to obtain it.
+//
+// The scanner does not yet enforce this directive; callers must apply it
+// themselves via WithScanPlanningMode.
+func (t Table) ScanPlanningDirective() (ScanPlanningDirective, error) {
+       if !t.hasScanPlanningDirective {
+               return ScanPlanningDirectiveNone, nil
+       }
+
+       switch d := 
ScanPlanningDirective(strings.ToLower(t.scanPlanningDirective)); d {
+       case ScanPlanningDirectiveClient, ScanPlanningDirectiveServer:
+               return d, nil
+       default:
+               return ScanPlanningDirectiveNone, 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. ok reports whether the key was
+// present, so callers can pass a comma-ok map read straight through:
+//
+//     v, ok := cfg[table.ScanPlanningModeKey]
+//     table.WithScanPlanningDirective(v, ok)
+//
+// When ok is false the table reports no directive. A present but empty value
+// is reported as unrecognized.
+func WithScanPlanningDirective(value string, ok bool) Option {
+       return func(t *Table) {
+               t.scanPlanningDirective = value
+               t.hasScanPlanningDirective = ok
+       }
+}
+
+// WithScanPlanningDirectiveFrom copies src's scan-planning directive verbatim,
+// including whether one was present and any unrecognized raw value. Catalogs
+// use it when rebuilding a table from a response that carries no table config,
+// such as a credential refresh.
+func WithScanPlanningDirectiveFrom(src *Table) Option {
+       return WithScanPlanningDirective(src.scanPlanningDirective, 
src.hasScanPlanningDirective)

Review Comment:
   this derefs `src` when the option is built, so 
`WithScanPlanningDirectiveFrom(nil)` panics up front rather than at apply time. 
our only caller passes a non-nil `tbl`, but every other `With*` option is 
nil-tolerant and this is exported for third-party catalogs, so I'd return a 
no-op when `src == nil` or document that `src` must be non-nil.



##########
catalog/rest/rest_test.go:
##########
@@ -1773,6 +1773,176 @@ func (r *RestCatalogSuite) TestLoadTableLabels() {
        r.Nil(labels.Field(1))                                                  
   // field present in schema but unlabeled
 }
 
+func (r *RestCatalogSuite) TestLoadTableScanPlanningDirective() {
+       for _, tc := range []struct {
+               name       string
+               config     string
+               properties string
+               catOpts    []rest.Option
+               want       table.ScanPlanningDirective
+               wantErr    bool
+       }{
+               {name: "absent", config: `{}`, want: 
table.ScanPlanningDirectiveNone},
+               {name: "client", config: `{"scan-planning-mode": "client"}`, 
want: table.ScanPlanningDirectiveClient},
+               {name: "server", config: `{"scan-planning-mode": "server"}`, 
want: table.ScanPlanningDirectiveServer},
+               {name: "unrecognized", config: `{"scan-planning-mode": 
"bogus"}`, want: table.ScanPlanningDirectiveNone, wantErr: true},
+               // Only the load response's config block carries the directive.
+               {name: "metadata_property_only", config: `{}`, properties: 
`{"scan-planning-mode": "server"}`, want: table.ScanPlanningDirectiveNone},
+               {
+                       name:    "catalog_property_only",
+                       config:  `{}`,
+                       catOpts: 
[]rest.Option{rest.WithAdditionalProps(iceberg.Properties{"scan-planning-mode": 
"client"})},
+                       want:    table.ScanPlanningDirectiveNone,
+               },
+               {
+                       name:       "config_wins_over_metadata_property",
+                       config:     `{"scan-planning-mode": "client"}`,
+                       properties: `{"scan-planning-mode": "server"}`,
+                       want:       table.ScanPlanningDirectiveClient,
+               },
+       } {
+               r.Run(tc.name, func() {
+                       tableName := "table_" + tc.name
+                       properties := tc.properties
+                       if properties == "" {
+                               properties = `{}`
+                       }
+                       
r.mux.HandleFunc("/v1/namespaces/fokko/tables/"+tableName, func(w 
http.ResponseWriter, req *http.Request) {
+                               r.Require().Equal(http.MethodGet, req.Method)
+                               w.Write([]byte(`{
+                                       "metadata-location": 
"s3://warehouse/database/table/metadata/00001.metadata.json",
+                                       "metadata": {
+                                               "format-version": 1,
+                                               "table-uuid": 
"b55d9dda-6561-423a-8bfc-787980ce421f",
+                                               "location": 
"s3://warehouse/database/table",
+                                               "last-updated-ms": 
1646787054459,
+                                               "last-column-id": 2,
+                                               "schema": 
{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+                                               "current-schema-id": 0,
+                                               "schemas": 
[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+                                               "partition-spec": [],
+                                               "default-spec-id": 0,
+                                               "partition-specs": 
[{"spec-id":0,"fields":[]}],
+                                               "last-partition-id": 999,
+                                               "default-sort-order-id": 0,
+                                               "sort-orders": 
[{"order-id":0,"fields":[]}],
+                                               "properties": ` + properties + `
+                                       },
+                                       "config": ` + tc.config + `
+                               }`))
+                       })
+
+                       cat, err := rest.NewCatalog(context.Background(), 
"rest", r.srv.URL,
+                               
append([]rest.Option{rest.WithOAuthToken(TestToken)}, tc.catOpts...)...)
+                       r.Require().NoError(err)
+
+                       tbl, err := cat.LoadTable(context.Background(), 
catalog.ToIdentifier("fokko", tableName))
+                       r.Require().NoError(err)
+                       got, err := tbl.ScanPlanningDirective()
+                       if tc.wantErr {
+                               r.ErrorIs(err, iceberg.ErrInvalidArgument)
+                               r.Equal(tc.want, got)
+
+                               return
+                       }
+                       r.Require().NoError(err)
+                       r.Equal(tc.want, got)
+               })
+       }
+}
+
+// TestCreateAndRegisterTableScanPlanningDirective pins that the other load
+// response paths read the directive from the config block too.
+func (r *RestCatalogSuite) TestCreateAndRegisterTableScanPlanningDirective() {
+       const metadataLoc = 
"s3://warehouse/database/table/metadata/00001.metadata.json"
+
+       for _, op := range []struct {
+               name string
+               path string
+               call func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error)
+       }{
+               {
+                       name: "create",
+                       path: "/v1/namespaces/fokko/tables",
+                       call: func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error) {
+                               return cat.CreateTable(context.Background(), 
ident, tableSchemaSimple)
+                       },
+               },
+               {
+                       name: "register",
+                       path: "/v1/namespaces/fokko/register",
+                       call: func(cat *rest.Catalog, ident table.Identifier) 
(*table.Table, error) {
+                               return cat.RegisterTable(context.Background(), 
ident, metadataLoc)
+                       },
+               },
+       } {
+               for _, tc := range []struct {
+                       name       string
+                       config     string
+                       properties string
+                       want       table.ScanPlanningDirective
+                       wantErr    bool
+               }{
+                       {name: "absent", config: `{}`, properties: `{}`, want: 
table.ScanPlanningDirectiveNone},
+                       {name: "server", config: `{"scan-planning-mode": 
"server"}`, properties: `{}`, want: table.ScanPlanningDirectiveServer},
+                       {name: "unrecognized", config: `{"scan-planning-mode": 
"bogus"}`, properties: `{}`, wantErr: true},
+                       {name: "metadata_property_only", config: `{}`, 
properties: `{"scan-planning-mode": "server"}`, want: 
table.ScanPlanningDirectiveNone},
+               } {
+                       r.Run(op.name+"_"+tc.name, func() {
+                               mux := http.NewServeMux()
+                               srv := httptest.NewServer(mux)
+                               defer srv.Close()
+
+                               mux.HandleFunc("/v1/config", func(w 
http.ResponseWriter, _ *http.Request) {
+                                       
json.NewEncoder(w).Encode(map[string]any{
+                                               "defaults":  map[string]any{},
+                                               "overrides": map[string]any{},
+                                               "endpoints": 
rest.AllEndpointStrings,
+                                       })
+                               })
+                               mux.HandleFunc(op.path, func(w 
http.ResponseWriter, req *http.Request) {
+                                       r.Require().Equal(http.MethodPost, 
req.Method)
+                                       w.Write([]byte(`{
+                                               "metadata-location": "` + 
metadataLoc + `",
+                                               "metadata": {
+                                                       "format-version": 1,
+                                                       "table-uuid": 
"b55d9dda-6561-423a-8bfc-787980ce421f",
+                                                       "location": 
"s3://warehouse/database/table",
+                                                       "last-updated-ms": 
1646787054459,
+                                                       "last-column-id": 2,
+                                                       "schema": 
{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]},
+                                                       "current-schema-id": 0,
+                                                       "schemas": 
[{"type":"struct","schema-id":0,"fields":[{"id":1,"name":"id","required":false,"type":"int"},{"id":2,"name":"data","required":false,"type":"string"}]}],
+                                                       "partition-spec": [],
+                                                       "default-spec-id": 0,
+                                                       "partition-specs": 
[{"spec-id":0,"fields":[]}],
+                                                       "last-partition-id": 
999,
+                                                       
"default-sort-order-id": 0,
+                                                       "sort-orders": 
[{"order-id":0,"fields":[]}],
+                                                       "properties": ` + 
tc.properties + `
+                                               },
+                                               "config": ` + tc.config + `
+                                       }`))
+                               })
+
+                               cat, err := 
rest.NewCatalog(context.Background(), "rest", srv.URL, 
rest.WithOAuthToken(TestToken))
+                               r.Require().NoError(err)
+
+                               tbl, err := op.call(cat, 
catalog.ToIdentifier("fokko", "fokko2"))
+                               r.Require().NoError(err)
+                               got, err := tbl.ScanPlanningDirective()
+                               if tc.wantErr {
+                                       r.ErrorIs(err, 
iceberg.ErrInvalidArgument)

Review Comment:
   the `wantErr` branch checks the error but not that `got == 
ScanPlanningDirectiveNone`, so the "value returned alongside an error carries 
no meaning" contract is only pinned in the LoadTable test. mirror that one 
here: set `want: table.ScanPlanningDirectiveNone` and assert `r.Equal(tc.want, 
got)` before the return.



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