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 1d953ba4d perf(table): cache manifest content across scans (#2147)
1d953ba4d is described below

commit 1d953ba4d09256c0f715186692f08c49a5d6de29
Author: Minh Vu <[email protected]>
AuthorDate: Fri Oct 23 18:16:08 2026 +0200

    perf(table): cache manifest content across scans (#2147)
    
    * perf(table): cache manifest content across scans
    
    * fix(table): bound manifest cache population buffers
    
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): verify manifest length for in-flight cache readers
    
    * style(table): gofmt manifest cache regression test
    
    * fix(table): retain manifest cache and handle load failures safely
    
    * style(table): satisfy manifest cache lint checks
    
    * style(table): complete manifest cache formatting fixes
    
    * test(table): cover cache refresh, single-flight failures and canceled 
producers
    
    * style(table): format refreshed manifest cache tests and commit path
    
    * fix(table): preserve manifest cache load errors
    
    Keep both population and fallback failures discoverable through errors.Is 
and errors.As. Exercise warm cache reuse across transaction scans and commit, 
and clarify the shared budget and failure behavior.
    
    Signed-off-by: Codex <[email protected]>
    Signed-off-by: Minh Vu <[email protected]>
    
    * fix(table): honor canceled manifest-cache waiters when loads finish
    
    * test(table): pin Java manifest cache expiration semantics
    
    * fix(table): fall back for every failed shared manifest load
    
    * fix(table): preserve shared load errors and cover panic waiters
    
    * fix(table): read manifest cache settings from catalog FileIO props
    
    * style(table): gofmt Scan literal in manifest cache PR
    
    ---------
    
    Signed-off-by: Codex <[email protected]>
    Signed-off-by: Minh Vu <[email protected]>
---
 catalog/glue/glue.go                          |   2 +
 catalog/hadoop/hadoop.go                      |   7 +-
 catalog/hadoop/hadoop_test.go                 |  59 ++
 catalog/hive/hive.go                          |   7 +-
 catalog/sql/sql.go                            |   1 +
 table/manifest_content_cache.go               | 447 ++++++++++++
 table/manifest_content_cache_bench_test.go    | 114 +++
 table/manifest_content_cache_internal_test.go | 952 ++++++++++++++++++++++++++
 table/properties.go                           |  27 +
 table/scanner.go                              |  19 +-
 table/table.go                                |  71 +-
 table/transaction.go                          |  11 +-
 website/src/configuration.md                  |  28 +-
 13 files changed, 1699 insertions(+), 46 deletions(-)

diff --git a/catalog/glue/glue.go b/catalog/glue/glue.go
index 7824a601f..9e554c9c8 100644
--- a/catalog/glue/glue.go
+++ b/catalog/glue/glue.go
@@ -518,6 +518,7 @@ func (c *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
                metadataLocation,
                io.LoadFSFunc(c.props, metadataLocation),
                c,
+               table.WithScanPlanningIOProperties(c.props),
        )
        if err != nil {
                return nil, fmt.Errorf("failed to read table metadata from %s: 
%w", metadataLocation, err)
@@ -1120,6 +1121,7 @@ func (c *Catalog) convertGlueToIceberg(ctx 
context.Context, glueTable *types.Tab
                io.LoadFSFunc(c.props, metadataLocation),
                c,
                table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
        )
        if err != nil {
                return nil, fmt.Errorf("failed to create iceberg table from 
location %s: %w", metadataLocation, err)
diff --git a/catalog/hadoop/hadoop.go b/catalog/hadoop/hadoop.go
index 38ab76bdc..b59ecd2e4 100644
--- a/catalog/hadoop/hadoop.go
+++ b/catalog/hadoop/hadoop.go
@@ -594,6 +594,7 @@ func (c *Catalog) CreateTable(ctx context.Context, ident 
table.Identifier, sc *i
                io.LoadFSFunc(c.props, metaPath),
                c,
                table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
        )
 
        return tbl, nil
@@ -614,7 +615,11 @@ func (c *Catalog) loadTable(ctx context.Context, ident 
table.Identifier) (*table
                return nil, 0, fmt.Errorf("failed to initialize metrics 
reporter: %w", err)
        }
 
-       tbl, err := table.NewFromLocation(ctx, ident, metaPath, 
io.LoadFSFunc(c.props, metaPath), c, table.WithMetricsReporter(reporter))
+       tbl, err := table.NewFromLocation(
+               ctx, ident, metaPath, io.LoadFSFunc(c.props, metaPath), c,
+               table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
+       )
        if err != nil {
                return nil, 0, err
        }
diff --git a/catalog/hadoop/hadoop_test.go b/catalog/hadoop/hadoop_test.go
index 3baa3ede0..f3761237c 100644
--- a/catalog/hadoop/hadoop_test.go
+++ b/catalog/hadoop/hadoop_test.go
@@ -2147,6 +2147,65 @@ func (s *HadoopCatalogTestSuite) 
TestLoadTableNotExists() {
        s.ErrorIs(err, catalog.ErrNoSuchTable)
 }
 
+func (s *HadoopCatalogTestSuite) 
TestLoadTableEnablesCatalogManifestContentCache() {
+       ctx := context.Background()
+       cat, err := NewCatalog("test", s.warehouse, iceberg.Properties{
+               table.IOManifestCacheEnabledKey: "true",
+       })
+       s.Require().NoError(err)
+
+       identifier := table.Identifier{"cache-ns", "cache-table"}
+       s.Require().NoError(cat.CreateNamespace(ctx, identifier[:1], nil))
+       created, err := cat.CreateTable(ctx, identifier, s.testSchema())
+       s.Require().NoError(err)
+
+       builder, err := iceberg.NewDataFileBuilder(
+               *iceberg.UnpartitionedSpec, iceberg.EntryContentData,
+               filepath.Join(created.Location(), "sample.parquet"), 
iceberg.ParquetFile,
+               nil, nil, nil, 1, 1,
+       )
+       s.Require().NoError(err)
+       txn := created.NewTransaction()
+       s.Require().NoError(txn.AddDataFiles(ctx, 
[]iceberg.DataFile{builder.Build()}, nil))
+       _, err = txn.Commit(ctx)
+       s.Require().NoError(err)
+
+       loaded, err := cat.LoadTable(ctx, identifier)
+       s.Require().NoError(err)
+       s.NotContains(loaded.SavedConfig(), table.IOManifestCacheEnabledKey)
+
+       plan := func(tbl *table.Table) error {
+               tasks, err := 
tbl.Scan(table.WithMaxConcurrency(1)).PlanFiles(ctx)
+               if err == nil {
+                       s.Len(tasks, 1)
+               }
+
+               return err
+       }
+       s.Require().NoError(plan(loaded))
+
+       fs, err := loaded.FS(ctx)
+       s.Require().NoError(err)
+       manifests, err := loaded.CurrentSnapshot().Manifests(fs)
+       s.Require().NoError(err)
+       s.Require().Len(manifests, 1)
+
+       manifestPath := manifests[0].FilePath()
+       if parsed, parseErr := url.Parse(manifestPath); parseErr == nil && 
parsed.Scheme == "file" {
+               manifestPath = parsed.Path
+       }
+       movedPath := manifestPath + ".temporarily-moved"
+       s.Require().NoError(os.Rename(manifestPath, movedPath))
+       defer func() { s.Require().NoError(os.Rename(movedPath, manifestPath)) 
}()
+
+       // A fresh handle cannot read the missing manifest, while the warmed
+       // handle can still plan using its cached immutable manifest bytes.
+       cold, err := cat.LoadTable(ctx, identifier)
+       s.Require().NoError(err)
+       s.Require().Error(plan(cold))
+       s.Require().NoError(plan(loaded))
+}
+
 func (s *HadoopCatalogTestSuite) TestLoadTablePropagatesMetadataReadError() {
        ctx := context.Background()
        walkErr := errors.New("metadata walk failed")
diff --git a/catalog/hive/hive.go b/catalog/hive/hive.go
index 58c3f1bed..209247ec9 100644
--- a/catalog/hive/hive.go
+++ b/catalog/hive/hive.go
@@ -195,6 +195,7 @@ func (c *Catalog) LoadTable(ctx context.Context, identifier 
table.Identifier) (*
                io.LoadFSFunc(c.opts.props, metadataLocation),
                c,
                table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.opts.props),
        )
 }
 
@@ -287,6 +288,7 @@ func (c *Catalog) RegisterTable(ctx context.Context, 
identifier table.Identifier
                io.LoadFSFunc(c.opts.props, metadataLocation),
                c,
                table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.opts.props),
        )
        if err != nil {
                return nil, fmt.Errorf("failed to read table metadata from %s: 
%w", metadataLocation, err)
@@ -602,7 +604,10 @@ func (c *Catalog) CommitTable(ctx context.Context, 
identifier table.Identifier,
                if err != nil {
                        return nil, "", err
                }
-               current, err = table.NewFromLocation(ctx, identifier, 
metadataLoc, io.LoadFSFunc(c.opts.props, metadataLoc), c)
+               current, err = table.NewFromLocation(
+                       ctx, identifier, metadataLoc, 
io.LoadFSFunc(c.opts.props, metadataLoc), c,
+                       table.WithScanPlanningIOProperties(c.opts.props),
+               )
                if err != nil {
                        return nil, "", err
                }
diff --git a/catalog/sql/sql.go b/catalog/sql/sql.go
index a7eed9229..c799c19c2 100644
--- a/catalog/sql/sql.go
+++ b/catalog/sql/sql.go
@@ -1044,6 +1044,7 @@ func (c *Catalog) LoadTable(ctx context.Context, 
identifier table.Identifier) (*
                io.LoadFSFunc(c.props, result.MetadataLocation.String),
                c,
                table.WithMetricsReporter(reporter),
+               table.WithScanPlanningIOProperties(c.props),
        )
 }
 
diff --git a/table/manifest_content_cache.go b/table/manifest_content_cache.go
new file mode 100644
index 000000000..7079016d1
--- /dev/null
+++ b/table/manifest_content_cache.go
@@ -0,0 +1,447 @@
+// 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 table
+
+import (
+       "bytes"
+       "container/list"
+       "context"
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "log"
+       "path"
+       "strconv"
+       "sync"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+)
+
+// manifestContentCache stores immutable manifest bytes so repeated scans can
+// reuse the same object-store read while still decoding and filtering entries
+// independently for each scan.
+type manifestContentCache struct {
+       mu sync.Mutex
+
+       expirationIntervalMs int64
+       now                  func() time.Time
+       maxTotalBytes        int64
+       maxContentLength     int64
+       totalBytes           int64
+
+       entries map[string]*manifestContentCacheEntry
+       lru     list.List
+       loads   map[string]*manifestContentCacheLoad
+}
+
+type manifestContentCacheEntry struct {
+       location   string
+       content    []byte
+       lastAccess time.Time
+       element    *list.Element
+}
+
+type manifestContentCacheLoad struct {
+       ready    chan struct{}
+       content  []byte
+       err      error
+       canceled bool
+}
+
+func newManifestContentCache(expirationIntervalMs, maxTotalBytes, 
maxContentLength int64) *manifestContentCache {
+       return &manifestContentCache{
+               expirationIntervalMs: expirationIntervalMs,
+               now:                  time.Now,
+               maxTotalBytes:        maxTotalBytes,
+               maxContentLength:     maxContentLength,
+               entries:              
make(map[string]*manifestContentCacheEntry),
+               loads:                
make(map[string]*manifestContentCacheLoad),
+       }
+}
+
+func newManifestContentCacheForConfig(config iceberg.Properties) 
*manifestContentCache {
+       if !config.GetBool(IOManifestCacheEnabledKey, 
IOManifestCacheEnabledDefault) {
+               return nil
+       }
+
+       expirationIntervalMs, ok := manifestContentCacheConfigInt64(config,
+               IOManifestCacheExpirationIntervalMsKey, 
IOManifestCacheExpirationIntervalMsDefault)
+       if !ok {
+               return nil
+       }
+       maxTotalBytes, ok := manifestContentCacheConfigInt64(config,
+               IOManifestCacheMaxTotalBytesKey, 
IOManifestCacheMaxTotalBytesDefault)
+       if !ok {
+               return nil
+       }
+       maxContentLength, ok := manifestContentCacheConfigInt64(config,
+               IOManifestCacheMaxContentLengthKey, 
IOManifestCacheMaxContentLengthDefault)
+       if !ok {
+               return nil
+       }
+       if expirationIntervalMs < 0 || maxTotalBytes <= 0 || maxContentLength 
<= 0 {
+               log.Printf("Warning: disabling manifest content cache: invalid 
limits (expiration=%d, total=%d, content=%d)",
+                       expirationIntervalMs, maxTotalBytes, maxContentLength)
+
+               return nil
+       }
+
+       return newManifestContentCache(expirationIntervalMs, maxTotalBytes, 
maxContentLength)
+}
+
+func manifestContentCacheConfigInt64(config iceberg.Properties, key string, 
fallback int64) (int64, bool) {
+       raw, ok := config[key]
+       if !ok {
+               return fallback, true
+       }
+       value, err := strconv.ParseInt(raw, 10, 64)
+       if err != nil {
+               log.Printf("Warning: disabling manifest content cache: invalid 
%s=%q: %v", key, raw, err)
+
+               return 0, false
+       }
+
+       return value, true
+}
+
+// Reuse retained immutable manifests when a table is refreshed or committed.
+// Independent table handles still have independent byte budgets.
+func reuseManifestContentCache(previous, configured *manifestContentCache) 
*manifestContentCache {
+       if previous != nil && configured != nil &&
+               previous.expirationIntervalMs == 
configured.expirationIntervalMs &&
+               previous.maxTotalBytes == configured.maxTotalBytes &&
+               previous.maxContentLength == configured.maxContentLength {
+               return previous
+       }
+
+       return configured
+}
+
+func (c *manifestContentCache) wrap(
+       ctx context.Context,
+       base iceio.IO,
+       manifest iceberg.ManifestFile,
+) iceio.IO {
+       if c == nil {
+               return base
+       }
+
+       return &manifestContentCacheIO{
+               ctx:      ctx,
+               base:     base,
+               cache:    c,
+               manifest: manifest,
+       }
+}
+
+func (c *manifestContentCache) open(
+       ctx context.Context,
+       base iceio.IO,
+       manifest iceberg.ManifestFile,
+) (iceio.File, error) {
+       location := manifest.FilePath()
+       expectedLength := manifest.Length()
+       if expectedLength <= 0 || expectedLength > c.maxContentLength || 
expectedLength > c.maxTotalBytes {
+               return base.Open(location)
+       }
+
+       for {
+               if err := ctx.Err(); err != nil {
+                       return nil, err
+               }
+
+               c.mu.Lock()
+               if content, ok := c.getLocked(location, expectedLength); ok {
+                       c.mu.Unlock()
+
+                       return newCachedManifestFile(location, content), nil
+               }
+               if load, ok := c.loads[location]; ok {
+                       c.mu.Unlock()
+                       select {
+                       case <-load.ready:
+                       case <-ctx.Done():
+                               return nil, ctx.Err()
+                       }
+                       // Both channels can become ready together. Prefer 
cancellation
+                       // even if the shared load also finished in the same 
instant.
+                       if err := ctx.Err(); err != nil {
+                               return nil, err
+                       }
+                       if load.canceled {
+                               continue
+                       }
+                       // Concurrent callers may describe the same path with 
different lengths.
+                       // Never reuse in-flight bytes that do not match this 
caller's descriptor.
+                       if load.err != nil {
+                               // A failed population must not make concurrent 
scans fail
+                               // when the ordinary FileIO path would have 
succeeded.
+                               file, err := base.Open(location)
+                               if err != nil {
+                                       return nil, fmt.Errorf("open manifest 
%s: cache population failed: %w; fallback open failed: %w",
+                                               location, load.err, err)
+                               }
+
+                               return file, nil
+                       }
+                       if int64(len(load.content)) != expectedLength {
+                               return base.Open(location)
+                       }
+
+                       return newCachedManifestFile(location, load.content), 
nil
+               }
+
+               load := &manifestContentCacheLoad{ready: make(chan struct{})}
+               c.loads[location] = load
+               c.mu.Unlock()
+
+               var content []byte
+               var err error
+               var canceled bool
+               func() {
+                       defer func() {
+                               if recovered := recover(); recovered != nil {
+                                       // Always release the single-flight 
waiters, even if a
+                                       // FileIO implementation panics during 
population.
+                                       c.finishLoad(location, expectedLength, 
load, nil,
+                                               fmt.Errorf("manifest content 
load panicked: %v", recovered), false)
+                                       panic(recovered)
+                               }
+                               c.finishLoad(location, expectedLength, load, 
content, err, canceled)
+                       }()
+                       content, err = readManifestContent(base, location, 
expectedLength)
+                       canceled = err != nil && ctx.Err() != nil
+               }()
+               if err != nil {
+                       if canceled {
+                               return nil, ctx.Err()
+                       }
+
+                       // Preserve the ordinary-open fallback for the 
producer, and
+                       // explain repeated uncached reads for bad manifest 
descriptors.
+                       log.Printf("Warning: manifest cache population failed 
for %s: %v; falling back to FileIO", location, err)
+                       file, fallbackErr := base.Open(location)
+                       if fallbackErr != nil {
+                               return nil, fmt.Errorf("open manifest %s: cache 
population failed: %w; fallback open failed: %w",
+                                       location, err, fallbackErr)
+                       }
+
+                       return file, nil
+               }
+
+               return newCachedManifestFile(location, content), nil
+       }
+}
+
+func (c *manifestContentCache) getLocked(location string, expectedLength 
int64) ([]byte, bool) {
+       entry, ok := c.entries[location]
+       if !ok {
+               return nil, false
+       }
+       if int64(len(entry.content)) != expectedLength {
+               c.removeEntryLocked(entry)
+
+               return nil, false
+       }
+
+       if c.expirationIntervalMs > 0 {
+               now := c.now()
+               if c.expired(entry, now) {
+                       c.removeEntryLocked(entry)
+
+                       return nil, false
+               }
+               entry.lastAccess = now
+       }
+       c.lru.MoveToFront(entry.element)
+
+       return entry.content, true
+}
+
+func (c *manifestContentCache) expired(entry *manifestContentCacheEntry, now 
time.Time) bool {
+       // Dividing a monotonic duration avoids overflowing on large configured
+       // millisecond values and is immune to wall-clock jumps.
+       return c.expirationIntervalMs > 0 &&
+               now.Sub(entry.lastAccess)/time.Millisecond >= 
time.Duration(c.expirationIntervalMs)
+}
+
+func (c *manifestContentCache) finishLoad(
+       location string,
+       expectedLength int64,
+       load *manifestContentCacheLoad,
+       content []byte,
+       err error,
+       canceled bool,
+) {
+       c.mu.Lock()
+       defer c.mu.Unlock()
+
+       if current, ok := c.loads[location]; ok && current == load {
+               delete(c.loads, location)
+               if err == nil && int64(len(content)) == expectedLength {
+                       c.addEntryLocked(location, content)
+               }
+       }
+
+       load.content = content
+       load.err = err
+       load.canceled = canceled
+       close(load.ready)
+}
+
+func (c *manifestContentCache) addEntryLocked(location string, content []byte) 
{
+       size := int64(len(content))
+       if size > c.maxContentLength || size > c.maxTotalBytes {
+               return
+       }
+       if previous, ok := c.entries[location]; ok {
+               c.removeEntryLocked(previous)
+       }
+       if c.expirationIntervalMs > 0 {
+               now := c.now()
+               // The tail is the least recently accessed entry. Reclaim 
expired
+               // entries when inserting, without a background expiration 
goroutine.
+               for tail := c.lru.Back(); tail != nil; tail = c.lru.Back() {
+                       oldest, ok := tail.Value.(*manifestContentCacheEntry)
+                       if !ok {
+                               c.lru.Remove(tail)
+
+                               continue
+                       }
+                       if !c.expired(oldest, now) {
+                               break
+                       }
+                       c.removeEntryLocked(oldest)
+               }
+       }
+       for c.totalBytes > c.maxTotalBytes-size && c.lru.Len() > 0 {
+               tail := c.lru.Back()
+               oldest, ok := tail.Value.(*manifestContentCacheEntry)
+               if !ok {
+                       c.lru.Remove(tail)
+
+                       continue
+               }
+               c.removeEntryLocked(oldest)
+       }
+
+       entry := &manifestContentCacheEntry{
+               location: location,
+               content:  content,
+       }
+       if c.expirationIntervalMs > 0 {
+               entry.lastAccess = c.now()
+       }
+       entry.element = c.lru.PushFront(entry)
+       c.entries[location] = entry
+       c.totalBytes += size
+}
+
+func (c *manifestContentCache) removeEntryLocked(entry 
*manifestContentCacheEntry) {
+       delete(c.entries, entry.location)
+       c.lru.Remove(entry.element)
+       c.totalBytes -= int64(len(entry.content))
+}
+
+func readManifestContent(base iceio.IO, location string, expectedLength int64) 
(content []byte, err error) {
+       file, err := base.Open(location)
+       if err != nil {
+               return nil, err
+       }
+       defer func() {
+               if closeErr := file.Close(); err == nil {
+                       err = closeErr
+               }
+       }()
+
+       // The advertised length has already passed the cache's size limits. 
Read
+       // into an exact-sized buffer so retained capacity matches byte 
accounting
+       // and a larger actual file cannot grow the population buffer without 
bound.
+       content = make([]byte, expectedLength)
+       if _, err = io.ReadFull(file, content); err != nil {
+               return nil, err
+       }
+
+       var extra [1]byte
+       if _, err = io.ReadFull(file, extra[:]); !errors.Is(err, io.EOF) {
+               if err == nil {
+                       return nil, fmt.Errorf("manifest content exceeds 
expected length %d", expectedLength)
+               }
+
+               return nil, err
+       }
+
+       return content, nil
+}
+
+// Used only while the owning scan goroutine is active. Manifest reads call
+// Open; other optional FileIO interfaces are deliberately not forwarded.
+type manifestContentCacheIO struct {
+       ctx      context.Context
+       base     iceio.IO
+       cache    *manifestContentCache
+       manifest iceberg.ManifestFile
+}
+
+func (m *manifestContentCacheIO) Open(name string) (iceio.File, error) {
+       if name != m.manifest.FilePath() {
+               return m.base.Open(name)
+       }
+
+       return m.cache.open(m.ctx, m.base, m.manifest)
+}
+
+func (m *manifestContentCacheIO) Remove(name string) error {
+       return m.base.Remove(name)
+}
+
+type cachedManifestFile struct {
+       *bytes.Reader
+       location string
+}
+
+func newCachedManifestFile(location string, content []byte) 
*cachedManifestFile {
+       return &cachedManifestFile{
+               Reader:   bytes.NewReader(content),
+               location: location,
+       }
+}
+
+func (*cachedManifestFile) Close() error { return nil }
+
+func (f *cachedManifestFile) Stat() (fs.FileInfo, error) {
+       return cachedManifestFileInfo{
+               name: path.Base(f.location),
+               size: f.Size(),
+       }, nil
+}
+
+type cachedManifestFileInfo struct {
+       name string
+       size int64
+}
+
+func (f cachedManifestFileInfo) Name() string     { return f.name }
+func (f cachedManifestFileInfo) Size() int64      { return f.size }
+func (cachedManifestFileInfo) Mode() fs.FileMode  { return 0o444 }
+func (cachedManifestFileInfo) ModTime() time.Time { return time.Time{} }
+func (cachedManifestFileInfo) IsDir() bool        { return false }
+func (cachedManifestFileInfo) Sys() any           { return nil }
diff --git a/table/manifest_content_cache_bench_test.go 
b/table/manifest_content_cache_bench_test.go
new file mode 100644
index 000000000..42e2a9e8a
--- /dev/null
+++ b/table/manifest_content_cache_bench_test.go
@@ -0,0 +1,114 @@
+// 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 table
+
+import (
+       "context"
+       "fmt"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+
+       iceio "github.com/apache/iceberg-go/io"
+)
+
+var manifestContentCacheBenchmarkSink int
+
+func BenchmarkPlanFilesManifestContentCache(b *testing.B) {
+       for _, manifestCount := range []int{8, 32} {
+               for _, latency := range []time.Duration{0, time.Millisecond} {
+                       for _, cached := range []bool{false, true} {
+                               cacheName := "without-cache"
+                               if cached {
+                                       cacheName = "with-cache"
+                               }
+                               b.Run(fmt.Sprintf("manifests=%d/latency=%s/%s", 
manifestCount, latency, cacheName), func(b *testing.B) {
+                                       tbl := 
newRowLimitPlanningBenchmarkTable(b, manifestCount)
+                                       baseFS, err := 
tbl.fsF(context.Background())
+                                       if err != nil {
+                                               b.Fatal(err)
+                                       }
+                                       fs := 
&manifestContentCacheBenchmarkIO{IO: baseFS, latency: latency}
+                                       tbl.fsF = testFSF(fs)
+                                       if cached {
+                                               tbl.manifestContentCache = 
newManifestContentCache(0, 100<<20, 8<<20)
+                                       } else {
+                                               tbl.manifestContentCache = nil
+                                       }
+
+                                       scan := tbl.Scan(WithMaxConcurrency(1))
+                                       tasks, err := 
scan.PlanFiles(context.Background())
+                                       if err != nil {
+                                               b.Fatal(err)
+                                       }
+                                       if len(tasks) != manifestCount {
+                                               b.Fatalf("planned %d tasks, 
want %d", len(tasks), manifestCount)
+                                       }
+                                       fs.reset()
+
+                                       b.ReportAllocs()
+                                       b.ResetTimer()
+                                       for b.Loop() {
+                                               tasks, err := 
scan.PlanFiles(context.Background())
+                                               if err != nil {
+                                                       b.Fatal(err)
+                                               }
+                                               
manifestContentCacheBenchmarkSink = len(tasks)
+                                       }
+                                       b.StopTimer()
+                                       
b.ReportMetric(float64(fs.count())/float64(b.N), "manifest-opens/op")
+                               })
+                       }
+               }
+       }
+}
+
+type manifestContentCacheBenchmarkIO struct {
+       iceio.IO
+       latency time.Duration
+
+       mu    sync.Mutex
+       opens int64
+}
+
+func (fs *manifestContentCacheBenchmarkIO) Open(name string) (iceio.File, 
error) {
+       if strings.Contains(name, "/manifest-") {
+               if fs.latency > 0 {
+                       time.Sleep(fs.latency)
+               }
+               fs.mu.Lock()
+               fs.opens++
+               fs.mu.Unlock()
+       }
+
+       return fs.IO.Open(name)
+}
+
+func (fs *manifestContentCacheBenchmarkIO) reset() {
+       fs.mu.Lock()
+       fs.opens = 0
+       fs.mu.Unlock()
+}
+
+func (fs *manifestContentCacheBenchmarkIO) count() int64 {
+       fs.mu.Lock()
+       defer fs.mu.Unlock()
+
+       return fs.opens
+}
diff --git a/table/manifest_content_cache_internal_test.go 
b/table/manifest_content_cache_internal_test.go
new file mode 100644
index 000000000..f1abe524f
--- /dev/null
+++ b/table/manifest_content_cache_internal_test.go
@@ -0,0 +1,952 @@
+// 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 table
+
+import (
+       "bytes"
+       "context"
+       "io"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/apache/iceberg-go"
+       iceio "github.com/apache/iceberg-go/io"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+func TestTableScansReuseManifestContent(t *testing.T) {
+       fs := newTrackingCallsIO()
+       tbl, manifestListPath, manifestPath := 
newManifestContentCacheTestTable(t, fs,
+               iceberg.Properties{IOManifestCacheEnabledKey: "true"})
+
+       for range 2 {
+               tasks, err := 
tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+               require.NoError(t, err)
+               require.Len(t, tasks, 1)
+       }
+
+       assert.Equal(t, 1, fs.openCount[manifestListPath], "manifest list 
should use the decoded list cache")
+       assert.Equal(t, 1, fs.openCount[manifestPath], "manifest bytes should 
be read once across scans")
+}
+
+func TestTransactionScansAndCommitReuseManifestContent(t *testing.T) {
+       fs := newTrackingCallsIO()
+       tbl, _, manifestPath := newManifestContentCacheTestTable(t, fs,
+               iceberg.Properties{IOManifestCacheEnabledKey: "true"})
+       tbl.cat = &committingLabelCatalog{metadata: tbl.metadata}
+       tasks, err := tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+
+       txn := tbl.NewTransaction()
+       require.NoError(t, txn.SetProperties(iceberg.Properties{"updated": 
"true"}))
+       scan, err := txn.Scan(WithMaxConcurrency(1))
+       require.NoError(t, err)
+       tasks, err = scan.PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       committed, err := txn.Commit(t.Context())
+       require.NoError(t, err)
+       tasks, err = 
committed.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+
+       assert.Equal(t, 1, fs.openCount[manifestPath],
+               "transaction and committed scans must reuse the warm immutable 
manifests")
+       assert.Equal(t, "true", committed.Properties()["updated"])
+       assert.NotContains(t, tbl.Properties(), "updated", "metadata stays 
independent of cached bytes")
+}
+
+func TestManifestContentCacheSkipsOversizedManifest(t *testing.T) {
+       fs := newTrackingCallsIO()
+       tbl, _, manifestPath := newManifestContentCacheTestTable(t, fs, 
iceberg.Properties{
+               IOManifestCacheEnabledKey:          "true",
+               IOManifestCacheMaxContentLengthKey: "1",
+       })
+
+       for range 2 {
+               tasks, err := 
tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+               require.NoError(t, err)
+               require.Len(t, tasks, 1)
+       }
+
+       assert.Equal(t, 2, fs.openCount[manifestPath], "oversized manifests 
must bypass the content cache")
+}
+
+func TestManifestContentCacheBoundsPopulationAndPreservesFullReads(t 
*testing.T) {
+       const location = "mem://manifest-content-cache/length.avro"
+       content := bytes.Repeat([]byte("x"), 32)
+       for _, tt := range []struct {
+               name           string
+               expectedLength int64
+               wantOpens      int
+               firstReadBytes int
+       }{
+               {name: "matching length", expectedLength: 32, wantOpens: 1, 
firstReadBytes: 32},
+               {name: "longer content", expectedLength: 8, wantOpens: 2, 
firstReadBytes: 9},
+               {name: "shorter content", expectedLength: 64, wantOpens: 2, 
firstReadBytes: 32},
+               {name: "unknown length", expectedLength: 0, wantOpens: 1, 
firstReadBytes: 32},
+       } {
+               t.Run(tt.name, func(t *testing.T) {
+                       base := iceio.NewMemFS()
+                       require.NoError(t, base.WriteFile(location, content))
+                       fs := &manifestContentCountingIO{IO: base}
+                       cache := newManifestContentCache(0, 128, 64)
+                       manifest := iceberg.NewManifestFile(2, location, 
tt.expectedLength, 0, 1).Build()
+
+                       file, err := cache.open(t.Context(), fs, manifest)
+                       require.NoError(t, err)
+                       got, err := io.ReadAll(file)
+                       require.NoError(t, err)
+                       require.NoError(t, file.Close())
+                       assert.Equal(t, content, got, "cache population must 
not truncate the ordinary read")
+                       require.Len(t, fs.files, tt.wantOpens)
+                       assert.Equal(t, tt.firstReadBytes, 
fs.files[0].bytesRead)
+                       if tt.expectedLength == int64(len(content)) {
+                               require.Contains(t, cache.entries, location)
+                               assert.Equal(t, len(content), 
cap(cache.entries[location].content))
+                               assert.Equal(t, int64(len(content)), 
cache.totalBytes)
+                       } else {
+                               assert.Empty(t, cache.entries)
+                               assert.Zero(t, cache.totalBytes)
+                       }
+               })
+       }
+}
+
+type manifestContentCountingIO struct {
+       iceio.IO
+       files []*manifestContentCountingFile
+}
+
+func (f *manifestContentCountingIO) Open(name string) (iceio.File, error) {
+       file, err := f.IO.Open(name)
+       if err != nil {
+               return nil, err
+       }
+       counted := &manifestContentCountingFile{File: file}
+       f.files = append(f.files, counted)
+
+       return counted, nil
+}
+
+type manifestContentCountingFile struct {
+       iceio.File
+       bytesRead int
+}
+
+func (f *manifestContentCountingFile) Read(p []byte) (int, error) {
+       n, err := f.File.Read(p)
+       f.bytesRead += n
+
+       return n, err
+}
+
+func TestManifestContentCacheEvictsLeastRecentlyUsed(t *testing.T) {
+       fs := newTrackingCallsIO()
+       const (
+               firstPath  = "mem://manifest-content-cache/first.avro"
+               secondPath = "mem://manifest-content-cache/second.avro"
+               thirdPath  = "mem://manifest-content-cache/third.avro"
+       )
+       firstBytes := []byte("aaaa")
+       secondBytes := []byte("bbbb")
+       require.NoError(t, fs.WriteFile(firstPath, firstBytes))
+       require.NoError(t, fs.WriteFile(secondPath, secondBytes))
+       require.NoError(t, fs.WriteFile(thirdPath, []byte("cccc")))
+
+       first := iceberg.NewManifestFile(2, firstPath, int64(len(firstBytes)), 
0, 1).Build()
+       second := iceberg.NewManifestFile(2, secondPath, 
int64(len(secondBytes)), 0, 1).Build()
+       third := iceberg.NewManifestFile(2, thirdPath, 4, 0, 1).Build()
+       cache := newManifestContentCache(0, 8, 4)
+
+       readCachedManifest(t, cache, fs, first)
+       readCachedManifest(t, cache, fs, second)
+       readCachedManifest(t, cache, fs, first)
+       readCachedManifest(t, cache, fs, third)
+
+       assert.Contains(t, cache.entries, firstPath, "access must refresh LRU 
order")
+       assert.NotContains(t, cache.entries, secondPath)
+       assert.Equal(t, 1, fs.openCount[firstPath])
+       assert.Equal(t, 1, fs.openCount[secondPath])
+       assert.Equal(t, int64(8), cache.totalBytes)
+}
+
+func TestManifestContentCacheExpiresIdleEntries(t *testing.T) {
+       const location = "mem://manifest-content-cache/expired.avro"
+       fs := newTrackingCallsIO()
+       require.NoError(t, fs.WriteFile(location, []byte("aaaa")))
+       manifest := iceberg.NewManifestFile(2, location, 4, 0, 1).Build()
+       cache := newManifestContentCache(60_000, 8, 4)
+
+       now := time.Now()
+       cache.now = func() time.Time { return now }
+       readCachedManifest(t, cache, fs, manifest)
+       now = now.Add(time.Hour)
+       readCachedManifest(t, cache, fs, manifest)
+
+       assert.Equal(t, 2, fs.openCount[location])
+       assert.Equal(t, int64(4), cache.totalBytes)
+       assert.Len(t, cache.entries, 1)
+}
+
+// Java's manifest-content cache uses zero for no time-based expiration,
+// unlike the separate catalog table cache, where zero disables caching.
+func TestManifestContentCacheZeroExpirationKeepsWarmEntries(t *testing.T) {
+       const location = "mem://manifest-content-cache/no-expiration.avro"
+       content := []byte("manifest")
+       fs := newTrackingCallsIO()
+       require.NoError(t, fs.WriteFile(location, content))
+       config := iceberg.Properties{
+               IOManifestCacheEnabledKey:              "true",
+               IOManifestCacheExpirationIntervalMsKey: "0",
+       }
+       cache := newManifestContentCacheForConfig(config)
+       require.NotNil(t, cache)
+
+       now := time.Now()
+       cache.now = func() time.Time { return now }
+       manifest := iceberg.NewManifestFile(2, location, int64(len(content)), 
0, 1).Build()
+       readCachedManifest(t, cache, fs, manifest)
+       now = now.Add(72 * time.Hour)
+       readCachedManifest(t, cache, fs, manifest)
+
+       assert.Equal(t, 1, fs.openCount[location])
+       assert.Equal(t, int64(len(content)), cache.totalBytes)
+}
+
+func TestManifestContentCacheRejectsNegativeExpiration(t *testing.T) {
+       config := iceberg.Properties{
+               IOManifestCacheEnabledKey:              "true",
+               IOManifestCacheExpirationIntervalMsKey: "-1",
+       }
+       require.Nil(t, newManifestContentCacheForConfig(config))
+}
+
+func TestManifestContentCacheSharesInFlightRead(t *testing.T) {
+       const manifestPath = "mem://manifest-content-cache/shared.avro"
+       content := []byte("manifest")
+       base := iceio.NewMemFS()
+       require.NoError(t, base.WriteFile(manifestPath, content))
+
+       fs := &blockingSnapshotManifestIO{
+               IO:          base,
+               blockedPath: manifestPath,
+               started:     make(chan struct{}),
+               release:     make(chan struct{}),
+               opens:       make(map[string]int),
+       }
+       var release sync.Once
+       t.Cleanup(func() { release.Do(func() { close(fs.release) }) })
+
+       manifest := iceberg.NewManifestFile(2, manifestPath, 
int64(len(content)), 0, 1).Build()
+       cache := newManifestContentCache(0, 1<<20, 1<<20)
+       const callers = 16
+       errs := make(chan error, callers)
+       joined := make(chan struct{}, callers)
+       for range callers {
+               go func() {
+                       // Only followers call Done() while awaiting the 
blocked load.
+                       ctx := &countingContext{Context: t.Context(), entered: 
joined}
+                       file, err := cache.open(ctx, fs, manifest)
+                       if err == nil {
+                               _, err = io.ReadAll(file)
+                               if closeErr := file.Close(); err == nil {
+                                       err = closeErr
+                               }
+                       }
+                       errs <- err
+               }()
+       }
+
+       select {
+       case <-fs.started:
+       case <-time.After(5 * time.Second):
+               t.Fatal("manifest read did not start")
+       }
+       // All followers must enter the shared-load wait path. Otherwise late
+       // callers could hit the populated cache and mask broken single-flight.
+       for range callers - 1 {
+               select {
+               case <-joined:
+               case <-time.After(5 * time.Second):
+                       t.Fatal("not all callers joined the in-flight manifest 
read")
+               }
+       }
+
+       ctx, cancel := context.WithCancel(t.Context())
+       defer cancel()
+       waiterCtx := &notifyingContext{Context: ctx, entered: make(chan 
struct{})}
+       waiterErr := make(chan error, 1)
+       go func() {
+               file, err := cache.open(waiterCtx, fs, manifest)
+               if file != nil {
+                       file.Close()
+               }
+               waiterErr <- err
+       }()
+       select {
+       case <-waiterCtx.entered:
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter did not join the manifest read")
+       }
+       cancel()
+       select {
+       case err := <-waiterErr:
+               require.ErrorIs(t, err, context.Canceled)
+       case <-time.After(5 * time.Second):
+               t.Fatal("canceled waiter did not return")
+       }
+       release.Do(func() { close(fs.release) })
+
+       for range callers {
+               require.NoError(t, <-errs)
+       }
+
+       fs.mu.Lock()
+       openCount := fs.opens[manifestPath]
+       fs.mu.Unlock()
+       assert.Equal(t, 1, openCount, "concurrent misses should share one 
backend read")
+}
+
+func TestManifestContentCacheInFlightLengthMismatch(t *testing.T) {
+       const location = "mem://manifest-content-cache/inflight-length.avro"
+       content := []byte("manifest")
+       base := iceio.NewMemFS()
+       require.NoError(t, base.WriteFile(location, content))
+       fs := &blockingSnapshotManifestIO{
+               IO: base, blockedPath: location, started: make(chan struct{}),
+               release: make(chan struct{}), opens: make(map[string]int),
+       }
+       var release sync.Once
+       t.Cleanup(func() { release.Do(func() { close(fs.release) }) })
+
+       cache := newManifestContentCache(0, 1024, 1024)
+       actual := iceberg.NewManifestFile(2, location, int64(len(content)), 0, 
1).Build()
+       wrongLength := iceberg.NewManifestFile(2, location, 
int64(len(content)-1), 0, 1).Build()
+       type result struct {
+               content []byte
+               err     error
+       }
+       read := func(ctx context.Context, manifest iceberg.ManifestFile) <-chan 
result {
+               out := make(chan result, 1)
+               go func() {
+                       file, err := cache.open(ctx, fs, manifest)
+                       if err != nil {
+                               out <- result{err: err}
+
+                               return
+                       }
+                       got, err := io.ReadAll(file)
+                       if closeErr := file.Close(); err == nil {
+                               err = closeErr
+                       }
+                       out <- result{content: got, err: err}
+               }()
+
+               return out
+       }
+
+       producer := read(t.Context(), actual)
+       select {
+       case <-fs.started:
+       case <-time.After(5 * time.Second):
+               t.Fatal("manifest read did not start")
+       }
+
+       waiterCtx := &notifyingContext{Context: t.Context(), entered: make(chan 
struct{})}
+       waiter := read(waiterCtx, wrongLength)
+       select {
+       case <-waiterCtx.entered:
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter did not join the manifest read")
+       }
+       release.Do(func() { close(fs.release) })
+
+       for _, got := range []result{<-producer, <-waiter} {
+               require.NoError(t, got.err)
+               assert.Equal(t, content, got.content)
+       }
+       fs.mu.Lock()
+       opens := fs.opens[location]
+       fs.mu.Unlock()
+       assert.Equal(t, 2, opens, "a descriptor with a different length must 
bypass in-flight bytes")
+}
+
+func newManifestContentCacheTestTable(
+       t testing.TB,
+       fs *trackingCallsIO,
+       cacheProperties iceberg.Properties,
+) (*Table, string, string) {
+       t.Helper()
+
+       const tableLocation = "mem://manifest-content-cache"
+       meta, err := NewMetadata(simpleSchema(), iceberg.UnpartitionedSpec, 
UnsortedSortOrder,
+               tableLocation, iceberg.Properties{PropertyFormatVersion: "2"})
+       require.NoError(t, err)
+       builder, err := MetadataBuilderFromBase(meta, "")
+       require.NoError(t, err)
+
+       const snapshotID = int64(1)
+       manifestPath := tableLocation + "/metadata/manifest.avro"
+       manifest := writeManifest(t, fs.trackingIO, snapshotID, 1, manifestPath,
+               tableLocation+"/data/file.parquet")
+       manifestListPath := tableLocation + "/metadata/snap.avro"
+       writeManifestList(t, fs.trackingIO, snapshotID, manifestListPath, 
[]iceberg.ManifestFile{manifest})
+
+       schemaID := meta.CurrentSchema().ID
+       require.NoError(t, builder.AddSnapshot(&Snapshot{
+               SnapshotID:     snapshotID,
+               SequenceNumber: 1,
+               TimestampMs:    meta.LastUpdatedMillis() + 1,
+               ManifestList:   manifestListPath,
+               Summary:        &Summary{Operation: OpAppend},
+               SchemaID:       &schemaID,
+       }))
+       require.NoError(t, builder.SetSnapshotRef(MainBranch, snapshotID, 
BranchRef))
+       built, err := builder.Build()
+       require.NoError(t, err)
+
+       tbl := New(Identifier{"db", "manifest-content-cache"}, built, 
tableLocation+"/metadata/metadata.json",
+               testFSF(fs), nil, WithSavedConfig(cacheProperties))
+
+       return tbl, manifestListPath, manifestPath
+}
+
+func readCachedManifest(
+       t testing.TB,
+       cache *manifestContentCache,
+       fs iceio.IO,
+       manifest iceberg.ManifestFile,
+) {
+       t.Helper()
+
+       file, err := cache.open(context.Background(), fs, manifest)
+       require.NoError(t, err)
+       _, err = io.ReadAll(file)
+       require.NoError(t, err)
+       require.NoError(t, file.Close())
+}
+
+func TestManifestContentCacheReuseAcrossCompatibleConfig(t *testing.T) {
+       config := iceberg.Properties{IOManifestCacheEnabledKey: "true"}
+       previous := newManifestContentCacheForConfig(config)
+       require.Same(t, previous, reuseManifestContentCache(previous, 
newManifestContentCacheForConfig(config)))
+
+       changed := iceberg.Properties{IOManifestCacheEnabledKey: "true", 
IOManifestCacheMaxTotalBytesKey: "12345"}
+       require.NotSame(t, previous, reuseManifestContentCache(previous, 
newManifestContentCacheForConfig(changed)))
+       require.Nil(t, reuseManifestContentCache(previous, 
newManifestContentCacheForConfig(nil)))
+}
+
+func TestManifestContentCacheUsesCatalogIOProperties(t *testing.T) {
+       fs := newTrackingCallsIO()
+       base, _, manifestPath := newManifestContentCacheTestTable(t, fs, nil)
+       require.Nil(t, base.manifestContentCache)
+
+       catalogProps := iceberg.Properties{
+               IOManifestCacheEnabledKey:       "true",
+               IOManifestCacheMaxTotalBytesKey: "1048576",
+       }
+       loaded := New(base.identifier, base.metadata, base.metadataLocation,
+               base.fsF, nil, WithScanPlanningIOProperties(catalogProps))
+       require.NotNil(t, loaded.manifestContentCache)
+       require.Equal(t, int64(1048576), 
loaded.manifestContentCache.maxTotalBytes)
+       require.Empty(t, loaded.SavedConfig(), "catalog FileIO settings must 
not become saved table config")
+
+       for range 2 {
+               tasks, err := 
loaded.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+               require.NoError(t, err)
+               require.Len(t, tasks, 1)
+       }
+       assert.Equal(t, 1, fs.openCount[manifestPath], "catalog-configured 
cache must reuse manifest bytes")
+
+       disabled := New(base.identifier, base.metadata, base.metadataLocation,
+               base.fsF, nil,
+               WithScanPlanningIOProperties(catalogProps),
+               WithSavedConfig(iceberg.Properties{IOManifestCacheEnabledKey: 
"false"}))
+       require.Nil(t, disabled.manifestContentCache, "saved table settings 
must override catalog settings")
+}
+
+func TestManifestContentCacheInvalidNumericConfig(t *testing.T) {
+       for _, key := range []string{
+               IOManifestCacheExpirationIntervalMsKey,
+               IOManifestCacheMaxTotalBytesKey,
+               IOManifestCacheMaxContentLengthKey,
+       } {
+               badValues := []string{"bad", "-1"}
+               if key != IOManifestCacheExpirationIntervalMsKey {
+                       badValues = append(badValues, "0")
+               }
+               for _, bad := range badValues {
+                       t.Run(key+"/"+bad, func(t *testing.T) {
+                               config := 
iceberg.Properties{IOManifestCacheEnabledKey: "true", key: bad}
+                               assert.Nil(t, 
newManifestContentCacheForConfig(config))
+                       })
+               }
+       }
+}
+
+type manifestContentCacheFailOnceIO struct {
+       iceio.IO
+       failed bool
+}
+
+func (f *manifestContentCacheFailOnceIO) Open(name string) (iceio.File, error) 
{
+       if !f.failed {
+               f.failed = true
+
+               return nil, io.ErrUnexpectedEOF
+       }
+
+       return f.IO.Open(name)
+}
+
+func TestManifestContentCacheFailedPopulationFallsBackWithoutCaching(t 
*testing.T) {
+       const location = "mem://manifest-content-cache/fallback.avro"
+       base := iceio.NewMemFS()
+       require.NoError(t, base.WriteFile(location, []byte("test")))
+       fs := &manifestContentCacheFailOnceIO{IO: base}
+       cache := newManifestContentCache(0, 16, 16)
+       manifest := iceberg.NewManifestFile(2, location, 4, 0, 1).Build()
+
+       readCachedManifest(t, cache, fs, manifest)
+       assert.Empty(t, cache.entries, "ordinary-open fallback must not 
populate the cache")
+       readCachedManifest(t, cache, fs, manifest)
+       assert.Contains(t, cache.entries, location)
+}
+
+type manifestContentCacheOpenErrorsIO struct {
+       iceio.IO
+       openErrors []error
+}
+
+func (f *manifestContentCacheOpenErrorsIO) Open(string) (iceio.File, error) {
+       err := f.openErrors[0]
+       f.openErrors = f.openErrors[1:]
+
+       return nil, err
+}
+
+func TestManifestContentCachePreservesPopulationAndFallbackErrors(t 
*testing.T) {
+       fs := &manifestContentCacheOpenErrorsIO{
+               openErrors: []error{io.ErrUnexpectedEOF, io.ErrClosedPipe},
+       }
+       cache := newManifestContentCache(0, 16, 16)
+       manifest := iceberg.NewManifestFile(2, 
"mem://manifest-content-cache/errors.avro", 4, 0, 1).Build()
+
+       file, err := cache.open(t.Context(), fs, manifest)
+       assert.Nil(t, file)
+       assert.ErrorIs(t, err, io.ErrUnexpectedEOF, "retain the original 
population failure")
+       assert.ErrorIs(t, err, io.ErrClosedPipe, "retain the fallback failure")
+       assert.Empty(t, fs.openErrors, "only the producer's single fallback 
should run")
+       assert.Empty(t, cache.entries)
+       assert.Empty(t, cache.loads)
+}
+
+type manifestContentCachePanicIO struct {
+       iceio.IO
+}
+
+func (manifestContentCachePanicIO) Open(string) (iceio.File, error) {
+       panic("unexpected backend panic")
+}
+
+func TestManifestContentCachePanicReleasesInFlightLoad(t *testing.T) {
+       cache := newManifestContentCache(0, 16, 16)
+       const location = "mem://manifest-content-cache/panic.avro"
+       manifest := iceberg.NewManifestFile(2, location, 4, 0, 1).Build()
+       assert.Panics(t, func() {
+               _, _ = cache.open(t.Context(), manifestContentCachePanicIO{}, 
manifest)
+       })
+       assert.Empty(t, cache.loads)
+}
+
+func TestTableRefreshKeepsWarmManifestCacheForMatchingConfig(t *testing.T) {
+       config := iceberg.Properties{IOManifestCacheEnabledKey: "true"}
+       fs := newTrackingCallsIO()
+       tbl, _, manifestPath := newManifestContentCacheTestTable(t, fs, config)
+       tasks, err := tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       require.Equal(t, 1, fs.openCount[manifestPath])
+       oldCache := tbl.manifestContentCache
+
+       newTable := func(config iceberg.Properties) *Table {
+               return New(tbl.identifier, tbl.metadata, tbl.metadataLocation,
+                       tbl.fsF, nil, WithSavedConfig(config))
+       }
+       cat := &snapshotManifestRefreshCatalog{fresh: newTable(config)}
+       tbl.cat = cat
+
+       require.NoError(t, tbl.Refresh(t.Context()))
+       require.Same(t, oldCache, tbl.manifestContentCache)
+       tasks, err = tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       assert.Equal(t, 1, fs.openCount[manifestPath], "refresh must reuse 
immutable manifest content")
+
+       changed := iceberg.Properties{
+               IOManifestCacheEnabledKey:          "true",
+               IOManifestCacheMaxContentLengthKey: "1",
+       }
+       cat.fresh = newTable(changed)
+       require.NoError(t, tbl.Refresh(t.Context()))
+       require.NotSame(t, oldCache, tbl.manifestContentCache)
+       tasks, err = tbl.Scan(WithMaxConcurrency(1)).PlanFiles(t.Context())
+       require.NoError(t, err)
+       require.Len(t, tasks, 1)
+       assert.Equal(t, 2, fs.openCount[manifestPath], "changed limits must 
bypass the old content cache")
+
+       cat.fresh = newTable(nil)
+       require.NoError(t, tbl.Refresh(t.Context()))
+       require.Nil(t, tbl.manifestContentCache)
+}
+
+func TestManifestContentCacheReclaimsExpiredEntriesOnInsert(t *testing.T) {
+       fs := newTrackingCallsIO()
+       const (
+               firstPath  = "mem://manifest-content-cache/expired-1.avro"
+               secondPath = "mem://manifest-content-cache/expired-2.avro"
+               thirdPath  = "mem://manifest-content-cache/new.avro"
+       )
+       for _, name := range []string{firstPath, secondPath, thirdPath} {
+               require.NoError(t, fs.WriteFile(name, []byte("test")))
+       }
+       cache := newManifestContentCache(1000, 16, 4)
+       now := time.Now()
+       cache.now = func() time.Time { return now }
+       for _, name := range []string{firstPath, secondPath} {
+               readCachedManifest(t, cache, fs, iceberg.NewManifestFile(2, 
name, 4, 0, 1).Build())
+       }
+       require.Equal(t, int64(8), cache.totalBytes)
+
+       now = now.Add(2 * time.Second)
+       readCachedManifest(t, cache, fs, iceberg.NewManifestFile(2, thirdPath, 
4, 0, 1).Build())
+       assert.NotContains(t, cache.entries, firstPath)
+       assert.NotContains(t, cache.entries, secondPath)
+       assert.Contains(t, cache.entries, thirdPath)
+       assert.Equal(t, int64(4), cache.totalBytes)
+}
+
+type manifestContentFailingBlockedIO struct {
+       iceio.IO
+       started          chan struct{}
+       release          chan struct{}
+       firstReadIsShort bool
+       firstOpenPanics  bool
+       fallbackError    error
+
+       mu    sync.Mutex
+       opens int
+}
+
+func (f *manifestContentFailingBlockedIO) Open(name string) (iceio.File, 
error) {
+       f.mu.Lock()
+       f.opens++
+       attempt := f.opens
+       f.mu.Unlock()
+       if attempt == 1 {
+               close(f.started)
+               <-f.release
+               if f.firstOpenPanics {
+                       panic("unexpected backend panic")
+               }
+               if !f.firstReadIsShort {
+                       return nil, io.ErrUnexpectedEOF
+               }
+       }
+       if f.fallbackError != nil {
+               return nil, f.fallbackError
+       }
+
+       return f.IO.Open(name)
+}
+
+// Failed population is a cache optimization failure, not a scan failure.
+// Every in-flight caller must still be able to fall back to ordinary FileIO.
+func TestManifestContentCacheFailedSharedLoadFallsBackForAllCallers(t 
*testing.T) {
+       for _, tc := range []struct {
+               name             string
+               expectedLength   int64
+               firstReadIsShort bool
+       }{
+               {name: "backend error", expectedLength: 4},
+               {name: "manifest length mismatch", expectedLength: 5, 
firstReadIsShort: true},
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       const path = 
"mem://manifest-content-cache/failed-shared.avro"
+                       base := iceio.NewMemFS()
+                       require.NoError(t, base.WriteFile(path, []byte("test")))
+                       fs := &manifestContentFailingBlockedIO{
+                               IO: base, started: make(chan struct{}), 
release: make(chan struct{}),
+                               firstReadIsShort: tc.firstReadIsShort,
+                       }
+                       var release sync.Once
+                       t.Cleanup(func() { release.Do(func() { 
close(fs.release) }) })
+                       cache := newManifestContentCache(0, 16, 16)
+                       manifest := iceberg.NewManifestFile(2, path, 
tc.expectedLength, 0, 1).Build()
+
+                       producerErr := make(chan error, 1)
+                       go func() {
+                               file, err := cache.open(t.Context(), fs, 
manifest)
+                               if err == nil {
+                                       _, err = io.ReadAll(file)
+                                       if closeErr := file.Close(); err == nil 
{
+                                               err = closeErr
+                                       }
+                               }
+                               producerErr <- err
+                       }()
+                       select {
+                       case <-fs.started:
+                       case <-time.After(5 * time.Second):
+                               t.Fatal("cache producer did not start")
+                       }
+
+                       const followers = 8
+                       entered := make(chan struct{}, followers)
+                       waiterErrors := make(chan error, followers)
+                       for range followers {
+                               go func() {
+                                       ctx := &countingContext{Context: 
t.Context(), entered: entered}
+                                       file, err := cache.open(ctx, fs, 
manifest)
+                                       if err == nil {
+                                               _, err = io.ReadAll(file)
+                                               if closeErr := file.Close(); 
err == nil {
+                                                       err = closeErr
+                                               }
+                                       }
+                                       waiterErrors <- err
+                               }()
+                       }
+                       for range followers {
+                               select {
+                               case <-entered:
+                               case <-time.After(5 * time.Second):
+                                       t.Fatal("waiter did not join failed 
shared load")
+                               }
+                       }
+                       release.Do(func() { close(fs.release) })
+                       require.NoError(t, <-producerErr, "the producer should 
use one ordinary-open fallback")
+                       for range followers {
+                               require.NoError(t, <-waiterErrors, "every 
waiter should use its own ordinary-open fallback")
+                       }
+                       fs.mu.Lock()
+                       opens := fs.opens
+                       fs.mu.Unlock()
+                       assert.Equal(t, 2+followers, opens, "one failed 
population plus one ordinary open per caller")
+                       assert.Empty(t, cache.entries, "failed population must 
not retain fallback bytes")
+
+                       readCachedManifest(t, cache, fs, manifest)
+                       if tc.firstReadIsShort {
+                               assert.Empty(t, cache.entries, "a wrong 
manifest length must never be cached")
+                       } else {
+                               assert.Contains(t, cache.entries, path, "a 
later scan may populate the cache")
+                       }
+               })
+       }
+}
+
+func TestManifestContentCachePanickingProducerUnblocksWaiter(t *testing.T) {
+       const location = "mem://manifest-content-cache/panicking-producer.avro"
+       base := iceio.NewMemFS()
+       require.NoError(t, base.WriteFile(location, []byte("test")))
+       fs := &manifestContentFailingBlockedIO{
+               IO: base, started: make(chan struct{}), release: make(chan 
struct{}),
+               firstOpenPanics: true,
+       }
+       var release sync.Once
+       t.Cleanup(func() { release.Do(func() { close(fs.release) }) })
+       cache := newManifestContentCache(0, 16, 16)
+       manifest := iceberg.NewManifestFile(2, location, 4, 0, 1).Build()
+
+       producerPanic := make(chan any, 1)
+       go func() {
+               defer func() { producerPanic <- recover() }()
+               _, _ = cache.open(t.Context(), fs, manifest)
+       }()
+       select {
+       case <-fs.started:
+       case <-time.After(5 * time.Second):
+               t.Fatal("panicking producer did not start")
+       }
+
+       joined := make(chan struct{}, 1)
+       waiterErr := make(chan error, 1)
+       go func() {
+               ctx := &countingContext{Context: t.Context(), entered: joined}
+               file, err := cache.open(ctx, fs, manifest)
+               if err == nil {
+                       _, err = io.ReadAll(file)
+                       if closeErr := file.Close(); err == nil {
+                               err = closeErr
+                       }
+               }
+               waiterErr <- err
+       }()
+       select {
+       case <-joined:
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter did not join the panicking producer")
+       }
+       release.Do(func() { close(fs.release) })
+       select {
+       case got := <-producerPanic:
+               assert.Equal(t, "unexpected backend panic", got)
+       case <-time.After(5 * time.Second):
+               t.Fatal("panicking producer did not finish")
+       }
+       select {
+       case err := <-waiterErr:
+               require.NoError(t, err, "waiter must fall back after a producer 
panics")
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter remained blocked after producer panic")
+       }
+       fs.mu.Lock()
+       opens := fs.opens
+       fs.mu.Unlock()
+       assert.Equal(t, 2, opens, "one panicking population and one waiter 
fallback")
+       assert.Empty(t, cache.loads)
+       assert.Empty(t, cache.entries)
+       readCachedManifest(t, cache, fs, manifest)
+       assert.Contains(t, cache.entries, location, "a later scan can fill the 
cache")
+}
+
+func TestManifestContentCacheFailedSharedLoadPreservesBothErrors(t *testing.T) 
{
+       const location = "mem://manifest-content-cache/fallback-errors.avro"
+       fs := &manifestContentFailingBlockedIO{
+               IO: iceio.NewMemFS(), started: make(chan struct{}), release: 
make(chan struct{}),
+               fallbackError: io.ErrClosedPipe,
+       }
+       var release sync.Once
+       t.Cleanup(func() { release.Do(func() { close(fs.release) }) })
+       cache := newManifestContentCache(0, 16, 16)
+       manifest := iceberg.NewManifestFile(2, location, 4, 0, 1).Build()
+
+       producerErr := make(chan error, 1)
+       go func() {
+               file, err := cache.open(t.Context(), fs, manifest)
+               if file != nil {
+                       _ = file.Close()
+               }
+               producerErr <- err
+       }()
+       select {
+       case <-fs.started:
+       case <-time.After(5 * time.Second):
+               t.Fatal("producer did not start")
+       }
+       joined := make(chan struct{}, 1)
+       waiterErr := make(chan error, 1)
+       go func() {
+               ctx := &countingContext{Context: t.Context(), entered: joined}
+               file, err := cache.open(ctx, fs, manifest)
+               if file != nil {
+                       _ = file.Close()
+               }
+               waiterErr <- err
+       }()
+       select {
+       case <-joined:
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter did not join failed population")
+       }
+       release.Do(func() { close(fs.release) })
+
+       for _, result := range []struct {
+               name string
+               errs <-chan error
+       }{
+               {"producer", producerErr},
+               {"waiter", waiterErr},
+       } {
+               select {
+               case err := <-result.errs:
+                       require.ErrorIs(t, err, io.ErrUnexpectedEOF, 
result.name)
+                       require.ErrorIs(t, err, io.ErrClosedPipe, result.name)
+                       assert.Contains(t, err.Error(), location, result.name)
+               case <-time.After(5 * time.Second):
+                       t.Fatalf("%s did not finish", result.name)
+               }
+       }
+       fs.mu.Lock()
+       opens := fs.opens
+       fs.mu.Unlock()
+       assert.Equal(t, 3, opens, "failed population plus one fallback per 
caller")
+       assert.Empty(t, cache.loads)
+       assert.Empty(t, cache.entries)
+}
+
+func TestManifestContentCacheRetriesAfterProducerCancellation(t *testing.T) {
+       const path = "mem://manifest-content-cache/canceled-producer.avro"
+       base := iceio.NewMemFS()
+       require.NoError(t, base.WriteFile(path, []byte("test")))
+       fs := &manifestContentFailingBlockedIO{
+               IO: base, started: make(chan struct{}), release: make(chan 
struct{}),
+       }
+       var release sync.Once
+       t.Cleanup(func() { release.Do(func() { close(fs.release) }) })
+       cache := newManifestContentCache(0, 16, 16)
+       manifest := iceberg.NewManifestFile(2, path, 4, 0, 1).Build()
+       ctx, cancel := context.WithCancel(t.Context())
+       defer cancel()
+
+       producerErr := make(chan error, 1)
+       go func() {
+               file, err := cache.open(ctx, fs, manifest)
+               if file != nil {
+                       _ = file.Close()
+               }
+               producerErr <- err
+       }()
+       select {
+       case <-fs.started:
+       case <-time.After(5 * time.Second):
+               t.Fatal("cache producer did not start")
+       }
+
+       joined := make(chan struct{}, 1)
+       waiterErr := make(chan error, 1)
+       go func() {
+               waiterCtx := &countingContext{Context: t.Context(), entered: 
joined}
+               file, err := cache.open(waiterCtx, fs, manifest)
+               if err == nil {
+                       _, err = io.ReadAll(file)
+                       if closeErr := file.Close(); err == nil {
+                               err = closeErr
+                       }
+               }
+               waiterErr <- err
+       }()
+       select {
+       case <-joined:
+       case <-time.After(5 * time.Second):
+               t.Fatal("waiter did not join canceled producer")
+       }
+       cancel()
+       release.Do(func() { close(fs.release) })
+       require.ErrorIs(t, <-producerErr, context.Canceled)
+       require.NoError(t, <-waiterErr)
+       fs.mu.Lock()
+       opens := fs.opens
+       fs.mu.Unlock()
+       assert.Equal(t, 2, opens, "a live waiter should retry the canceled 
producer just once")
+       assert.Contains(t, cache.entries, path)
+}
diff --git a/table/properties.go b/table/properties.go
index 0743e660f..f2ba0cb59 100644
--- a/table/properties.go
+++ b/table/properties.go
@@ -46,6 +46,33 @@ const (
        ReadManifestListCacheEnabledKey     = "read.manifest-list-cache.enabled"
        ReadManifestListCacheEnabledDefault = true
 
+       // IOManifestCacheEnabledKey controls Java-compatible raw manifest-file
+       // content caching. These settings come from catalog/FileIO properties;
+       // saved per-table configuration overrides matching catalog keys.
+       IOManifestCacheEnabledKey     = "io.manifest.cache-enabled"
+       IOManifestCacheEnabledDefault = false
+
+       // IOManifestCacheExpirationIntervalMsKey controls how long an unused 
cached
+       // manifest remains eligible for reuse. Expired bytes are removed 
lazily on
+       // cache access or insertion. Zero disables time-based expiration, 
matching
+       // Java Iceberg's ContentCache. Invalid negative values warn and disable
+       // the Go cache rather than throwing a Java ValidationException.
+       IOManifestCacheExpirationIntervalMsKey     = 
"io.manifest.cache.expiration-interval-ms"
+       IOManifestCacheExpirationIntervalMsDefault = 60 * 1000
+
+       // IOManifestCacheMaxTotalBytesKey bounds total retained manifest 
content per
+       // independently loaded table. Commit-derived handles share the same 
budget.
+       // Non-positive or unparsable values warn and disable the Go cache, 
whereas
+       // Java Iceberg rejects them.
+       IOManifestCacheMaxTotalBytesKey     = 
"io.manifest.cache.max-total-bytes"
+       IOManifestCacheMaxTotalBytesDefault = 100 * 1024 * 1024
+
+       // IOManifestCacheMaxContentLengthKey skips caching individual manifests
+       // larger than this size. Non-positive or unparsable values warn and 
disable
+       // the Go cache, whereas Java Iceberg rejects them.
+       IOManifestCacheMaxContentLengthKey     = 
"io.manifest.cache.max-content-length"
+       IOManifestCacheMaxContentLengthDefault = 8 * 1024 * 1024
+
        // These Java-compatible properties are reserved for future task-group
        // planning. They are exported so applications can share configuration
        // names across Iceberg implementations.
diff --git a/table/scanner.go b/table/scanner.go
index 2bef72fed..5109c914e 100644
--- a/table/scanner.go
+++ b/table/scanner.go
@@ -544,14 +544,15 @@ func classifyDataFile(f iceberg.DataFile) (dataFileKind, 
error) {
 // close it when they are done, including early exits after remote planning
 // succeeds but before all records are consumed.
 type Scan struct {
-       identifier          Identifier
-       metadata            Metadata
-       metadataLocation    string
-       ioF                 FSysF
-       manifestCache       *snapshotManifestCache
-       planner             ScanPlanner
-       scanPlanningIOProps iceberg.Properties
-       planningMode        ScanPlanningMode
+       identifier           Identifier
+       metadata             Metadata
+       metadataLocation     string
+       ioF                  FSysF
+       manifestCache        *snapshotManifestCache
+       manifestContentCache *manifestContentCache
+       planner              ScanPlanner
+       scanPlanningIOProps  iceberg.Properties
+       planningMode         ScanPlanningMode
        // planningModeSet records an explicit WithScanPlanningMode, so a mode 
that
        // conflicts with the catalog directive fails instead of being 
overridden.
        planningModeSet bool
@@ -1298,6 +1299,7 @@ func (scan *Scan) 
collectManifestEntriesWithSchemaOptionsAndMinSequenceNum(
                        if err != nil {
                                return err
                        }
+                       fs = scan.manifestContentCache.wrap(gctx, fs, mf)
                        partEval, err := 
partitionEvaluators.Get(int(mf.PartitionSpecID()))
                        if err != nil {
                                return fmt.Errorf("failed to build partition 
evaluator for spec %d: %w", mf.PartitionSpecID(), err)
@@ -1453,6 +1455,7 @@ func (scan *Scan) planDataManifestTasksWithOptions(
                        if err != nil {
                                return err
                        }
+                       fs = scan.manifestContentCache.wrap(gctx, fs, manifest)
                        partEval, err := 
partitionEvaluators.Get(int(manifest.PartitionSpecID()))
                        if err != nil {
                                return fmt.Errorf("failed to build partition 
evaluator for spec %d: %w", manifest.PartitionSpecID(), err)
diff --git a/table/table.go b/table/table.go
index 085a245f2..b2c12d29c 100644
--- a/table/table.go
+++ b/table/table.go
@@ -95,13 +95,14 @@ type CatalogIO interface {
 }
 
 type Table struct {
-       identifier       Identifier
-       metadata         Metadata
-       metadataLocation string
-       cat              CatalogIO
-       fsF              FSysF
-       manifestCache    *snapshotManifestCache
-       planner          ScanPlanner
+       identifier           Identifier
+       metadata             Metadata
+       metadataLocation     string
+       cat                  CatalogIO
+       fsF                  FSysF
+       manifestCache        *snapshotManifestCache
+       manifestContentCache *manifestContentCache
+       planner              ScanPlanner
        // scanPlanningIOProps are the table-scoped FileIO properties supplied 
by a
        // catalog load response. They are separate from metadata properties 
because
        // REST catalogs can return FileIO configuration in the response's 
config
@@ -249,6 +250,7 @@ func (t *Table) Refresh(ctx context.Context) error {
        t.fsF = fresh.fsF
        t.metadataLocation = fresh.metadataLocation
        t.manifestCache = newSnapshotManifestCacheForMetadata(fresh.metadata)
+       t.manifestContentCache = 
reuseManifestContentCache(t.manifestContentCache, fresh.manifestContentCache)
        t.planner = fresh.planner
        t.scanPlanningIOProps = maps.Clone(fresh.scanPlanningIOProps)
        t.scanPlanningDirective = fresh.scanPlanningDirective
@@ -852,7 +854,7 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                }
        }
 
-       return New(
+       next := New(
                t.identifier,
                newMeta,
                newLoc,
@@ -863,7 +865,10 @@ func (t Table) doCommit(ctx context.Context, updates 
[]Update, reqs []Requiremen
                WithScanPlanningDirective(t.scanPlanningDirective, 
t.hasScanPlanningDirective),
                WithLabels(t.labels),
                WithSavedConfig(t.savedConfig),
-       ), nil
+       )
+       next.manifestContentCache = 
reuseManifestContentCache(t.manifestContentCache, next.manifestContentCache)
+
+       return next, nil
 }
 
 // rewriteRefSnapshotRequirements returns a copy of reqs with every
@@ -1370,22 +1375,23 @@ func WithArrowBatchSize(n int) ScanOption {
 func (t Table) Scan(opts ...ScanOption) *Scan {
        directive, directiveErr := t.ScanPlanningDirective()
        s := &Scan{
-               identifier:          slices.Clone(t.identifier),
-               metadata:            t.metadata,
-               metadataLocation:    t.metadataLocation,
-               ioF:                 t.fsF,
-               manifestCache:       t.manifestCache,
-               planner:             t.planner,
-               scanPlanningIOProps: maps.Clone(t.scanPlanningIOProps),
-               directive:           directive,
-               directiveErr:        directiveErr,
-               planningMode:        ScanPlanningLocal,
-               rowFilter:           iceberg.AlwaysTrue{},
-               selectedFields:      []string{"*"},
-               caseSensitive:       true,
-               limit:               ScanNoLimit,
-               concurrency:         runtime.GOMAXPROCS(0),
-               reporter:            t.MetricsReporter(),
+               identifier:           slices.Clone(t.identifier),
+               metadata:             t.metadata,
+               metadataLocation:     t.metadataLocation,
+               ioF:                  t.fsF,
+               manifestCache:        t.manifestCache,
+               manifestContentCache: t.manifestContentCache,
+               planner:              t.planner,
+               scanPlanningIOProps:  maps.Clone(t.scanPlanningIOProps),
+               directive:            directive,
+               directiveErr:         directiveErr,
+               planningMode:         ScanPlanningLocal,
+               rowFilter:            iceberg.AlwaysTrue{},
+               selectedFields:       []string{"*"},
+               caseSensitive:        true,
+               limit:                ScanNoLimit,
+               concurrency:          runtime.GOMAXPROCS(0),
+               reporter:             t.MetricsReporter(),
        }
 
        for _, opt := range opts {
@@ -1418,10 +1424,9 @@ func WithMetricsReporter(r metrics.Reporter) Option {
        }
 }
 
-// WithScanPlanningIOProperties supplies the table-scoped FileIO properties
-// that a remote scan planner needs when it builds a plan-scoped FileIO. This
-// is intended for catalog implementations whose table-load response carries
-// FileIO configuration separately from table metadata properties.
+// WithScanPlanningIOProperties supplies FileIO/catalog properties for
+// manifest content caching and remote scan planning. These can include
+// settings returned separately from table metadata by a REST catalog.
 func WithScanPlanningIOProperties(props iceberg.Properties) Option {
        if props == nil {
                return noopTableOption
@@ -1499,6 +1504,14 @@ func New(ident Identifier, meta Metadata, 
metadataLocation string, fsF FSysF, ca
        for _, opt := range opts {
                opt(t)
        }
+       // FileIO/catalog settings are the default. Per-table saved settings
+       // (including REST LoadTable overrides) take precedence when supplied.
+       cacheConfig := t.savedConfig
+       if len(t.scanPlanningIOProps) > 0 {
+               cacheConfig = maps.Clone(t.scanPlanningIOProps)
+               maps.Copy(cacheConfig, t.savedConfig)
+       }
+       t.manifestContentCache = newManifestContentCacheForConfig(cacheConfig)
 
        return t
 }
diff --git a/table/transaction.go b/table/transaction.go
index 9b51b322d..59a6c65df 100644
--- a/table/transaction.go
+++ b/table/transaction.go
@@ -3516,11 +3516,12 @@ func (t *Transaction) Scan(opts ...ScanOption) (*Scan, 
error) {
        }
 
        s := &Scan{
-               identifier:       slices.Clone(t.tbl.identifier),
-               metadata:         updatedMeta,
-               metadataLocation: t.tbl.metadataLocation,
-               ioF:              t.tbl.fsF,
-               manifestCache:    
newSnapshotManifestCacheForMetadata(updatedMeta),
+               identifier:           slices.Clone(t.tbl.identifier),
+               metadata:             updatedMeta,
+               metadataLocation:     t.tbl.metadataLocation,
+               ioF:                  t.tbl.fsF,
+               manifestCache:        
newSnapshotManifestCacheForMetadata(updatedMeta),
+               manifestContentCache: t.tbl.manifestContentCache,
                // Catalog planners can only see committed table state, not 
metadata
                // staged inside this transaction. Keep transaction scans local 
so auto
                // mode cannot silently return stale tasks. The catalog's
diff --git a/website/src/configuration.md b/website/src/configuration.md
index 4db0d1d7b..0ec57818c 100644
--- a/website/src/configuration.md
+++ b/website/src/configuration.md
@@ -363,12 +363,36 @@ Property key constants are in 
[`table/properties.go`](https://github.com/apache/
 |---|---|---|
 | `read.split.target-size` | `134217728` | Target size for coalescing safe 
row-group ranges when splitting large local Parquet files. A range may exceed 
the target when no supplied offset can divide it safely. |
 | `read.manifest-list-cache.enabled` | `true` | Reuse decoded snapshot 
manifest lists across scans of a table handle. Set to `false` to disable 
retention and shared in-flight reads. Applied when loading or refreshing a 
table, and when creating a transaction scan. |
+| `io.manifest.cache-enabled` | `false` | Reuse raw manifest file content 
across local scans. Read from catalog FileIO properties in REST, Glue, SQL, 
Hive and Hadoop; REST table metadata and load-response config may override the 
catalog value. |
+| `io.manifest.cache.expiration-interval-ms` | `60000` | Idle expiration in 
milliseconds. Zero disables time-based expiration; negative or unparsable 
values warn and disable the cache. Expired entries are reclaimed on cache 
access/insertion, without a background cleanup task. |
+| `io.manifest.cache.max-total-bytes` | `104857600` | Retained raw bytes per 
independently loaded table. Handles returned by commit share their source 
table's cache and budget. Concurrent population buffers and bytes held by 
active readers are not counted. Non-positive or unparsable values warn and 
disable caching. |
+| `io.manifest.cache.max-content-length` | `8388608` | Maximum size of one 
manifest eligible for content caching. Non-positive or unparsable values warn 
and disable caching. |
+
+The catalog's FileIO properties configure manifest caching for all built-in
+catalogs. With direct `table.New` calls, use `WithScanPlanningIOProperties`
+or `WithSavedConfig`; saved config wins when the same key is present in both.
+Matching expiration interval, total byte limit and per-manifest byte limit
+preserve warm cache entries across refresh and commit. Changing a limit 
replaces
+the cache.
+
+Unlike Java, which rejects invalid manifest-cache limits, Go warns and disables
+the content cache for negative or unparsable expiration or
+non-positive/unparsable size limits.
 
 The manifest-list cache retains at most 64 snapshots and 32,768 manifest
 descriptors in total per table handle. These are count limits, not byte limits:
 memory use also depends on manifest paths, partition summaries, and key 
metadata.
-Refreshing the table replaces its cache. This caches decoded manifest lists;
-it does not implement Java's `io.manifest.cache.*` file-content cache settings.
+Refreshing the table replaces its manifest-list cache.
+
+The optional manifest-content cache is separate. It stores immutable raw 
manifest
+bytes and still decodes, projects, and filters entries independently for each
+scan. Cache misses for the same manifest share one in-flight read. If 
population
+fails, its producer and each existing waiter independently fall back to one
+ordinary FileIO open; if the producer was canceled, live waiters can start
+another shared load. Failed content is not
+retained, so a later scan can retry. Manifests larger than the configured
+per-file limit bypass the cache. The current integration covers local scan
+planning; manifest lists continue to use the decoded manifest-list cache.
 
 The Java-compatible `read.split.planning-lookback` and 
`read.split.open-file-cost`
 names and defaults are also exported by the Go API for shared configuration,

Reply via email to