This is an automated email from the ASF dual-hosted git repository.
zeroshade pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-go.git
The following commit(s) were added to refs/heads/main by this push:
new c0dc53fd2 feat(rest): freshness-aware table loading with If-None-Match
/ 304 (#2188)
c0dc53fd2 is described below
commit c0dc53fd27620f5cd34ff997901e645f8089fee4
Author: Revanth <[email protected]>
AuthorDate: Mon Oct 19 10:27:32 2026 -0500
feat(rest): freshness-aware table loading with If-None-Match / 304 (#2188)
* feat(rest): freshness-aware table loading with If-None-Match / 304
* fix(rest): copy cached labels per table and key cache hits on snapshot
mode
---
catalog/rest/rest.go | 60 +++++-
catalog/rest/table_cache.go | 169 +++++++++++++++
catalog/rest/table_cache_internal_test.go | 88 ++++++++
catalog/rest/table_cache_test.go | 331 ++++++++++++++++++++++++++++++
4 files changed, 646 insertions(+), 2 deletions(-)
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index d0f727b92..33ffe5c60 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -112,6 +112,9 @@ var (
ErrCommitFailed = fmt.Errorf("%w: %w", ErrRESTError,
table.ErrCommitFailed)
ErrCommitStateUnknown = fmt.Errorf("%w: commit failed due to unknown
reason", ErrRESTError)
ErrOAuthError = fmt.Errorf("%w: oauth error", ErrRESTError)
+
+ // errNotModified reports a 304 to a request sent with withIfNoneMatch.
+ errNotModified = errors.New("not modified")
)
// SnapshotMode controls which snapshots are included in a loadTable response.
@@ -381,6 +384,11 @@ type reqConfig struct {
allowNoContent bool
requireBody bool
queryParams url.Values
+ // allowNotModified turns a 304 into errNotModified. withIfNoneMatch
sets it
+ // together with the header, so a 304 to a request that sent no ETag
stays an
+ // error.
+ allowNotModified bool
+ onHeaders func(http.Header)
}
type reqOption func(*reqConfig)
@@ -434,6 +442,20 @@ func requireBody() reqOption {
return func(c *reqConfig) { c.requireBody = true }
}
+// withIfNoneMatch sends If-None-Match with etag and reports a 304 Not
+// Modified response as errNotModified instead of an error response.
+func withIfNoneMatch(etag string) reqOption {
+ return func(c *reqConfig) {
+ withHeaders(map[string]string{"If-None-Match": etag})(c)
+ c.allowNotModified = true
+ }
+}
+
+// withResponseHeaders passes the headers of a 200 response to fn.
+func withResponseHeaders(fn func(http.Header)) reqOption {
+ return func(c *reqConfig) { c.onHeaders = fn }
+}
+
// Appends query parameters to the request.
func withQueryParams(params url.Values) reqOption {
return func(c *reqConfig) {
@@ -511,10 +533,18 @@ func do[T any](ctx context.Context, method string,
baseURI *url.URL, path []stri
return ret, err
}
+ if cfg.allowNotModified && rsp.StatusCode == http.StatusNotModified {
+ return ret, errNotModified
+ }
+
if rsp.StatusCode != http.StatusOK {
return ret, handleNon200(rsp, override, cfg.errorTypeOverride)
}
+ if cfg.onHeaders != nil {
+ cfg.onHeaders(rsp.Header)
+ }
+
if method == http.MethodHead || method == http.MethodDelete {
return ret, err
}
@@ -886,6 +916,9 @@ type Catalog struct {
namespaceSeparator string
snapshotMode SnapshotMode
+ // tables holds ETag-tagged load responses for freshness-aware loading;
nil
+ // when rest-table-cache.max-entries is 0.
+ tables *tableCache
// reporter builds and caches the catalog's metrics reporter once, so
it is
// constructed per-catalog rather than per table load. Released by
Close.
@@ -1213,6 +1246,9 @@ func (r *Catalog) fetchConfig(ctx context.Context, opts
*options) (*options, err
return nil, fmt.Errorf("%w: invalid %s %q (want %q or %q)",
ErrRESTError, keySnapshotLoadingMode, r.snapshotMode,
SnapshotModeAll, SnapshotModeRefs)
}
+ if r.tables, err = newTableCache(cfg); err != nil {
+ return nil, err
+ }
// Negotiate capabilities from the endpoints the server advertises,
falling
// back to a backward-compatible default set when none are provided.
@@ -1252,6 +1288,7 @@ func (r *Catalog) Close() error {
r.metricsDispatcher.close()
}
r.closeErr = r.reporter.Close()
+ r.tables.purge()
})
return r.closeErr
@@ -1946,15 +1983,29 @@ func (r *Catalog) loadTableWithMode(ctx
context.Context, identifier table.Identi
return nil, err
}
- var opts []reqOption
+ var etag string
+ opts := []reqOption{withResponseHeaders(func(h http.Header) { etag =
h.Get("ETag") })}
if mode != "" {
opts = append(opts, withQueryParams(url.Values{"snapshots":
{string(mode)}}))
}
+ cached, hit := r.tables.get(identifier, mode)
+ if hit {
+ opts = append(opts, withIfNoneMatch(cached.etag))
+ }
ret, err := doGet[loadTableResponse](ctx, r.baseURI, path,
r.cl, map[int]error{http.StatusNotFound:
catalog.ErrNoSuchTable}, opts...)
- if err != nil {
+ switch {
+ case errors.Is(err, errNotModified):
+ ret = cached.rsp
+ case errors.Is(err, catalog.ErrNoSuchTable):
+ r.tables.invalidate(identifier)
+
return nil, err
+ case err != nil:
+ return nil, err
+ case etag != "":
+ r.tables.put(identifier, mode, etag, ret)
}
config := maps.Clone(r.props)
@@ -2042,6 +2093,7 @@ func (r *Catalog) DropTable(ctx context.Context,
identifier table.Identifier) er
_, err = doDelete[struct{}](ctx, uri, []string{}, r.cl,
map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable})
+ r.tables.invalidate(identifier)
return err
}
@@ -2068,6 +2120,7 @@ func (r *Catalog) PurgeTable(ctx context.Context,
identifier table.Identifier) e
_, err = doDelete[struct{}](ctx, uri, []string{}, r.cl,
map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable})
+ r.tables.invalidate(identifier)
return err
}
@@ -2111,6 +2164,7 @@ func (r *Catalog) RenameTable(ctx context.Context, from,
to table.Identifier) (*
if err != nil {
return nil, err
}
+ r.tables.invalidate(from)
return r.LoadTable(ctx, to)
}
@@ -2348,6 +2402,8 @@ func (r *Catalog) CheckTableExists(ctx context.Context,
identifier table.Identif
r.cl, map[int]error{http.StatusNotFound:
catalog.ErrNoSuchTable})
if err != nil {
if errors.Is(err, catalog.ErrNoSuchTable) {
+ r.tables.invalidate(identifier)
+
return false, nil
}
diff --git a/catalog/rest/table_cache.go b/catalog/rest/table_cache.go
new file mode 100644
index 000000000..a27a5e930
--- /dev/null
+++ b/catalog/rest/table_cache.go
@@ -0,0 +1,169 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package rest
+
+import (
+ "fmt"
+ "maps"
+ "strconv"
+ "time"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ lru "github.com/hashicorp/golang-lru/v2"
+)
+
+// Catalog properties controlling the table cache used for freshness-aware
+// table loading. The names and defaults match Iceberg Java's
+// RESTCatalogProperties so the same configuration works across clients.
+const (
+ keyTableCacheMaxEntries = "rest-table-cache.max-entries"
+ keyTableCacheExpireAfterWriteMs =
"rest-table-cache.expire-after-write-ms"
+
+ defaultTableCacheMaxEntries = 100
+ defaultTableCacheExpireAfterWrite = 5 * time.Minute
+)
+
+// tableCacheEntry is a load response the server tagged with an ETag. The
+// parsed response is kept rather than a *table.Table because Table.Refresh
+// mutates its receiver: every 304 builds a fresh table from the response so
+// callers never share one. The response's Labels are mutable through the
+// table, so the entry holds its own copy and hands out a copy on every hit.
+type tableCacheEntry struct {
+ etag string
+ // mode is the snapshot loading mode the response was loaded with. A
refs
+ // response replayed for an all request would silently drop snapshots,
so
+ // a load with another mode does not reuse the entry.
+ mode SnapshotMode
+ rsp loadTableResponse
+ written time.Time
+}
+
+// tableCache maps a table identifier to its last ETag-tagged load response so
+// LoadTable can send If-None-Match and reuse the response on 304 Not Modified.
+// Entries expire a fixed time after they are written; reads do not extend
+// them. A nil *tableCache is a disabled cache and every method is a no-op.
+type tableCache struct {
+ entries *lru.Cache[string, tableCacheEntry]
+ ttl time.Duration
+ now func() time.Time
+}
+
+// newTableCache builds the cache from the merged catalog properties. It
+// returns nil when max-entries is 0, which disables freshness-aware loading.
+func newTableCache(props iceberg.Properties) (*tableCache, error) {
+ maxEntries := defaultTableCacheMaxEntries
+ if v, ok := props[keyTableCacheMaxEntries]; ok {
+ n, err := strconv.Atoi(v)
+ if err != nil || n < 0 {
+ return nil, fmt.Errorf("%w: invalid %s %q: want a
non-negative integer",
+ ErrRESTError, keyTableCacheMaxEntries, v)
+ }
+ maxEntries = n
+ }
+
+ ttl := defaultTableCacheExpireAfterWrite
+ if v, ok := props[keyTableCacheExpireAfterWriteMs]; ok {
+ ms, err := strconv.ParseInt(v, 10, 64)
+ if err != nil || ms <= 0 {
+ return nil, fmt.Errorf("%w: invalid %s %q: want a
positive integer",
+ ErrRESTError, keyTableCacheExpireAfterWriteMs,
v)
+ }
+ ttl = time.Duration(ms) * time.Millisecond
+ }
+
+ if maxEntries == 0 {
+ return nil, nil
+ }
+
+ entries, err := lru.New[string, tableCacheEntry](maxEntries)
+ if err != nil {
+ return nil, err
+ }
+
+ return &tableCache{entries: entries, ttl: ttl, now: time.Now}, nil
+}
+
+// tableCacheKey is unambiguous for any identifier, including parts that
+// contain separators.
+func tableCacheKey(ident table.Identifier) string {
+ return fmt.Sprintf("%q", []string(ident))
+}
+
+func (c *tableCache) get(ident table.Identifier, mode SnapshotMode)
(tableCacheEntry, bool) {
+ if c == nil {
+ return tableCacheEntry{}, false
+ }
+
+ key := tableCacheKey(ident)
+ e, ok := c.entries.Get(key)
+ if !ok || e.mode != mode {
+ return tableCacheEntry{}, false
+ }
+ if c.now().Sub(e.written) >= c.ttl {
+ c.entries.Remove(key)
+
+ return tableCacheEntry{}, false
+ }
+ e.rsp.Labels = cloneLabels(e.rsp.Labels)
+
+ return e, true
+}
+
+func (c *tableCache) put(ident table.Identifier, mode SnapshotMode, etag
string, rsp loadTableResponse) {
+ if c == nil {
+ return
+ }
+
+ rsp.Labels = cloneLabels(rsp.Labels)
+ c.entries.Add(tableCacheKey(ident), tableCacheEntry{etag: etag, mode:
mode, rsp: rsp, written: c.now()})
+}
+
+// cloneLabels deep-copies l, so a table built from a cached response cannot
+// change the labels of the cache entry or of another table.
+func cloneLabels(l *iceberg.Labels) *iceberg.Labels {
+ if l == nil {
+ return nil
+ }
+
+ out := &iceberg.Labels{ObjectLabels: maps.Clone(l.ObjectLabels)}
+ if l.Fields != nil {
+ out.Fields = make([]iceberg.FieldLabel, len(l.Fields))
+ for i, f := range l.Fields {
+ out.Fields[i] = iceberg.FieldLabel{FieldID: f.FieldID,
Labels: maps.Clone(f.Labels)}
+ }
+ }
+
+ return out
+}
+
+func (c *tableCache) invalidate(ident table.Identifier) {
+ if c == nil {
+ return
+ }
+
+ c.entries.Remove(tableCacheKey(ident))
+}
+
+func (c *tableCache) purge() {
+ if c == nil {
+ return
+ }
+
+ c.entries.Purge()
+}
diff --git a/catalog/rest/table_cache_internal_test.go
b/catalog/rest/table_cache_internal_test.go
new file mode 100644
index 000000000..a2ed48198
--- /dev/null
+++ b/catalog/rest/table_cache_internal_test.go
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package rest
+
+import (
+ "testing"
+ "time"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/table"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTableCacheDefaults(t *testing.T) {
+ c, err := newTableCache(nil)
+ require.NoError(t, err)
+ require.NotNil(t, c)
+ assert.Equal(t, 5*time.Minute, c.ttl)
+
+ for i := range defaultTableCacheMaxEntries + 1 {
+ c.put(table.Identifier{"ns", string(rune('a' + i%26)),
string(rune('a' + i/26))}, SnapshotModeAll, "e", loadTableResponse{})
+ }
+ assert.Equal(t, defaultTableCacheMaxEntries, c.entries.Len())
+}
+
+func TestTableCacheExpiresAfterWrite(t *testing.T) {
+ c, err :=
newTableCache(iceberg.Properties{keyTableCacheExpireAfterWriteMs: "1000"})
+ require.NoError(t, err)
+
+ now := time.Unix(0, 0)
+ c.now = func() time.Time { return now }
+ ident := table.Identifier{"ns", "tbl"}
+
+ c.put(ident, SnapshotModeAll, `"v1"`, loadTableResponse{})
+
+ // Reads do not extend an entry's lifetime.
+ now = now.Add(999 * time.Millisecond)
+ e, ok := c.get(ident, SnapshotModeAll)
+ require.True(t, ok)
+ assert.Equal(t, `"v1"`, e.etag)
+
+ now = now.Add(time.Millisecond)
+ _, ok = c.get(ident, SnapshotModeAll)
+ assert.False(t, ok)
+ assert.Zero(t, c.entries.Len())
+}
+
+func TestTableCacheKeyIsUnambiguous(t *testing.T) {
+ assert.NotEqual(t,
+ tableCacheKey(table.Identifier{"a.b", "c"}),
+ tableCacheKey(table.Identifier{"a", "b.c"}))
+ assert.NotEqual(t,
+ tableCacheKey(table.Identifier{`a" "b`, "c"}),
+ tableCacheKey(table.Identifier{"a", "b", "c"}))
+}
+
+// TestTableCacheIgnoresEntryForOtherSnapshotMode checks that a response loaded
+// with one snapshot mode is not reused for a load with another: a refs
response
+// replayed for an all request would silently drop snapshots.
+func TestTableCacheIgnoresEntryForOtherSnapshotMode(t *testing.T) {
+ c, err := newTableCache(nil)
+ require.NoError(t, err)
+ ident := table.Identifier{"ns", "tbl"}
+
+ c.put(ident, SnapshotModeRefs, `"v1"`, loadTableResponse{})
+
+ _, ok := c.get(ident, SnapshotModeAll)
+ assert.False(t, ok)
+ e, ok := c.get(ident, SnapshotModeRefs)
+ require.True(t, ok)
+ assert.Equal(t, `"v1"`, e.etag)
+}
diff --git a/catalog/rest/table_cache_test.go b/catalog/rest/table_cache_test.go
new file mode 100644
index 000000000..55a5c88bc
--- /dev/null
+++ b/catalog/rest/table_cache_test.go
@@ -0,0 +1,331 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package rest_test
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync"
+ "testing"
+
+ "github.com/apache/iceberg-go"
+ "github.com/apache/iceberg-go/catalog"
+ "github.com/apache/iceberg-go/catalog/rest"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// etagServer serves one table and answers loads conditionally: 304 when the
+// request's If-None-Match equals the current ETag, otherwise 200 with the
+// ETag. It records the If-None-Match of every load.
+type etagServer struct {
+ mu sync.Mutex
+ etag string
+ exists bool
+ body string
+ ifNoneMatch []string
+}
+
+func (s *etagServer) lastIfNoneMatch() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return s.ifNoneMatch[len(s.ifNoneMatch)-1]
+}
+
+func newETagCatalog(t *testing.T, props iceberg.Properties) (*rest.Catalog,
*etagServer) {
+ t.Helper()
+
+ s := &etagServer{etag: `"v1"`, exists: true, body:
createTableRestExample}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _
*http.Request) {
+ assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ "endpoints": rest.AllEndpointStrings,
+ }))
+ })
+ mux.HandleFunc("/v1/namespaces/fokko/tables/table", func(w
http.ResponseWriter, req *http.Request) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ switch req.Method {
+ case http.MethodGet:
+ s.ifNoneMatch = append(s.ifNoneMatch,
req.Header.Get("If-None-Match"))
+ case http.MethodHead:
+ case http.MethodDelete:
+ s.exists = false
+ w.WriteHeader(http.StatusNoContent)
+
+ return
+ }
+ if !s.exists {
+ w.WriteHeader(http.StatusNotFound)
+
+ return
+ }
+ if req.Method == http.MethodHead {
+ w.WriteHeader(http.StatusNoContent)
+
+ return
+ }
+ if s.etag != "" && req.Header.Get("If-None-Match") == s.etag {
+ w.WriteHeader(http.StatusNotModified)
+
+ return
+ }
+ if s.etag != "" {
+ w.Header().Set("ETag", s.etag)
+ }
+ _, err := w.Write([]byte(s.body))
+ assert.NoError(t, err)
+ })
+ mux.HandleFunc("/v1/tables/rename", func(w http.ResponseWriter, _
*http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ })
+ mux.HandleFunc("/v1/namespaces/fokko/tables/renamed", func(w
http.ResponseWriter, _ *http.Request) {
+ _, err := w.Write([]byte(createTableRestExample))
+ assert.NoError(t, err)
+ })
+ srv := httptest.NewServer(mux)
+ t.Cleanup(srv.Close)
+
+ cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
+ rest.WithOAuthToken(TestToken), rest.WithAdditionalProps(props))
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = cat.Close() })
+
+ return cat, s
+}
+
+var etagIdent = catalog.ToIdentifier("fokko", "table")
+
+func TestLoadTableNotModifiedReusesCachedResponse(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t, nil)
+
+ first, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Empty(t, srv.lastIfNoneMatch())
+
+ second, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Equal(t, `"v1"`, srv.lastIfNoneMatch())
+
+ // A 304 builds a new table from the cached response: Table.Refresh
mutates
+ // its receiver, so two loads must never share one.
+ assert.NotSame(t, first, second)
+ assert.True(t, first.Equals(*second))
+ assert.Equal(t, first.MetadataLocation(), second.MetadataLocation())
+ assert.Equal(t, first.Properties(), second.Properties())
+}
+
+// TestLoadTableNotModifiedDoesNotShareLabels checks that tables built from one
+// cached response get independent labels. Labels are mutable through the
table,
+// so a change made through one table must not reach the cache or another
table.
+func TestLoadTableNotModifiedDoesNotShareLabels(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t, nil)
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal([]byte(createTableRestExample),
&body))
+ body["labels"] = map[string]any{
+ "object-labels": map[string]string{"owner": "data-eng"},
+ "fields": []any{map[string]any{"field-id": 1, "labels":
map[string]string{"pii": "no"}}},
+ }
+ raw, err := json.Marshal(body)
+ require.NoError(t, err)
+ srv.mu.Lock()
+ srv.body = string(raw)
+ srv.mu.Unlock()
+
+ first, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ first.Labels().Object()["owner"] = "changed"
+ first.Labels().Field(1)["pii"] = "yes"
+
+ for range 2 {
+ next, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ require.Equal(t, `"v1"`, srv.lastIfNoneMatch(), "the load must
be served from the cache")
+ assert.Equal(t, "data-eng", next.Labels().Object()["owner"])
+ assert.Equal(t, "no", next.Labels().Field(1)["pii"])
+ next.Labels().Object()["owner"] = "changed-again"
+ }
+}
+
+func TestLoadTableModifiedReplacesCachedETag(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t, nil)
+
+ _, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+
+ srv.mu.Lock()
+ srv.etag = `"v2"`
+ srv.mu.Unlock()
+
+ _, err = cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Equal(t, `"v1"`, srv.lastIfNoneMatch())
+
+ _, err = cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Equal(t, `"v2"`, srv.lastIfNoneMatch())
+}
+
+func TestLoadTableWithoutETagIsNotCached(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t, nil)
+ srv.etag = ""
+
+ for range 2 {
+ _, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Empty(t, srv.lastIfNoneMatch())
+ }
+}
+
+func TestLoadTableCacheDisabled(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t,
iceberg.Properties{"rest-table-cache.max-entries": "0"})
+
+ for range 2 {
+ _, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Empty(t, srv.lastIfNoneMatch())
+ }
+}
+
+func TestLoadTableUnsolicitedNotModified(t *testing.T) {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _
*http.Request) {
+ assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ "endpoints": rest.AllEndpointStrings,
+ }))
+ })
+ mux.HandleFunc("/v1/namespaces/fokko/tables/table", func(w
http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotModified)
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ cat, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
rest.WithOAuthToken(TestToken))
+ require.NoError(t, err)
+
+ _, err = cat.LoadTable(context.Background(), etagIdent)
+ require.ErrorIs(t, err, rest.ErrRESTError)
+}
+
+func TestTableCacheInvalidation(t *testing.T) {
+ tests := []struct {
+ name string
+ op func(ctx context.Context, cat *rest.Catalog, srv
*etagServer) error
+ }{
+ {"drop", func(ctx context.Context, cat *rest.Catalog, srv
*etagServer) error {
+ if err := cat.DropTable(ctx, etagIdent); err != nil {
+ return err
+ }
+ srv.mu.Lock()
+ srv.exists = true
+ srv.mu.Unlock()
+
+ return nil
+ }},
+ {"purge", func(ctx context.Context, cat *rest.Catalog, srv
*etagServer) error {
+ if err := cat.PurgeTable(ctx, etagIdent); err != nil {
+ return err
+ }
+ srv.mu.Lock()
+ srv.exists = true
+ srv.mu.Unlock()
+
+ return nil
+ }},
+ {"rename", func(ctx context.Context, cat *rest.Catalog, _
*etagServer) error {
+ _, err := cat.RenameTable(ctx, etagIdent,
catalog.ToIdentifier("fokko", "renamed"))
+
+ return err
+ }},
+ {"exists reports missing", func(ctx context.Context, cat
*rest.Catalog, srv *etagServer) error {
+ srv.mu.Lock()
+ srv.exists = false
+ srv.mu.Unlock()
+ if ok, err := cat.CheckTableExists(ctx, etagIdent); err
!= nil || ok {
+ return assert.AnError
+ }
+ srv.mu.Lock()
+ srv.exists = true
+ srv.mu.Unlock()
+
+ return nil
+ }},
+ {"load reports missing", func(ctx context.Context, cat
*rest.Catalog, srv *etagServer) error {
+ srv.mu.Lock()
+ srv.exists = false
+ srv.mu.Unlock()
+ if _, err := cat.LoadTable(ctx, etagIdent);
!assert.ErrorIs(t, err, catalog.ErrNoSuchTable) {
+ return assert.AnError
+ }
+ srv.mu.Lock()
+ srv.exists = true
+ srv.mu.Unlock()
+
+ return nil
+ }},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ cat, srv := newETagCatalog(t, nil)
+
+ _, err := cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ require.NoError(t, tt.op(ctx, cat, srv))
+
+ _, err = cat.LoadTable(ctx, etagIdent)
+ require.NoError(t, err)
+ assert.Empty(t, srv.lastIfNoneMatch())
+ })
+ }
+}
+
+func TestTableCacheInvalidProperties(t *testing.T) {
+ for _, props := range []iceberg.Properties{
+ {"rest-table-cache.max-entries": "-1"},
+ {"rest-table-cache.max-entries": "many"},
+ {"rest-table-cache.expire-after-write-ms": "0"},
+ {"rest-table-cache.expire-after-write-ms": "-5"},
+ } {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/v1/config", func(w http.ResponseWriter, _
*http.Request) {
+ assert.NoError(t,
json.NewEncoder(w).Encode(map[string]any{
+ "defaults": map[string]any{}, "overrides":
map[string]any{},
+ }))
+ })
+ srv := httptest.NewServer(mux)
+
+ _, err := rest.NewCatalog(context.Background(), "rest", srv.URL,
+ rest.WithOAuthToken(TestToken),
rest.WithAdditionalProps(props))
+ assert.ErrorIs(t, err, rest.ErrRESTError, "%v", props)
+ srv.Close()
+ }
+}