This is an automated email from the ASF dual-hosted git repository.

jrmccluskey pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 84713ce61e1 Implement Iceberg Table Metadata Driver transform (#39883)
84713ce61e1 is described below

commit 84713ce61e18bd448c739efb6a515de3ee66976d
Author: Jack McCluskey <[email protected]>
AuthorDate: Wed Sep 9 09:36:59 2026 -0400

    Implement Iceberg Table Metadata Driver transform (#39883)
    
    * Implement Iceberg Table Metadata Driver transform
    
    * Rename unit test for clarity
    
    * Uncap cache size by default
    
    * Handle NoSuchTableExceptions in CatalogPollingDoFn
    
    * unbounded global window support
    
    * Streamline test definitions
    
    * add schema evolution test case, route through Deduplicate to re-emit panes
    
    * Add reshuffle and polling buckets
    
    * Side input tests + multiple tables, fix breakages
    
    * Explicit exception handling, time-based metadata merging
    
    * lastSeen impl
    
    * Extra cleanup
    
    * change eviction behavior for missing signals, streamline last seen logic
    
    * Clean up unused constructors
    
    * Clean up asView overloads
    
    * spotless
---
 .../beam/sdk/io/iceberg/SerializableTableSpec.java |    7 +
 .../beam/sdk/io/iceberg/TableMetadataDriver.java   |  539 +++++++
 .../sdk/io/iceberg/SerializableTableSpecTest.java  |    6 +
 .../sdk/io/iceberg/TableMetadataDriverTest.java    | 1501 ++++++++++++++++++++
 4 files changed, 2053 insertions(+)

diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
index c6ee4a97699..e89a341f55c 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpec.java
@@ -97,6 +97,9 @@ public abstract class SerializableTableSpec implements 
Serializable {
   @SchemaFieldNumber("11")
   public abstract List<String> getEncryptedKeyJsons();
 
+  @SchemaFieldNumber("12")
+  public abstract long getLastUpdatedMillis();
+
   private transient volatile @MonotonicNonNull Map<Integer, Schema> 
cachedSchemas;
   private transient volatile @MonotonicNonNull Map<Integer, PartitionSpec> 
cachedPartitionSpecs;
   private transient volatile @MonotonicNonNull Map<Integer, SortOrder> 
cachedSortOrders;
@@ -285,6 +288,8 @@ public abstract class SerializableTableSpec implements 
Serializable {
 
     public abstract Builder setEncryptedKeyJsons(List<String> 
encryptedKeyJsons);
 
+    public abstract Builder setLastUpdatedMillis(long lastUpdatedMillis);
+
     @SchemaIgnore
     public Builder setFileIO(FileIO fileIO) {
       return setFileIoJson(FileIOParser.toJson(fileIO));
@@ -324,6 +329,7 @@ public abstract class SerializableTableSpec implements 
Serializable {
     }
 
     TableMetadata metadata = ((HasTableOperations) 
table).operations().current();
+    long lastUpdatedMillis = metadata != null ? metadata.lastUpdatedMillis() : 
0L;
     List<String> encryptedKeyJsons = Collections.emptyList();
     if (metadata != null && metadata.encryptionKeys() != null) {
       encryptedKeyJsons =
@@ -360,6 +366,7 @@ public abstract class SerializableTableSpec implements 
Serializable {
         .setProperties(table.properties())
         .setFileIoJson(FileIOParser.toJson(table.io()))
         .setEncryptedKeyJsons(encryptedKeyJsons)
+        .setLastUpdatedMillis(lastUpdatedMillis)
         .build();
   }
 
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java
new file mode 100644
index 00000000000..cf5d6310a1d
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriver.java
@@ -0,0 +1,539 @@
+/*
+ * 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 org.apache.beam.sdk.io.iceberg;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.beam.sdk.annotations.Internal;
+import org.apache.beam.sdk.coders.KvCoder;
+import org.apache.beam.sdk.coders.MapCoder;
+import org.apache.beam.sdk.coders.NullableCoder;
+import org.apache.beam.sdk.coders.StringUtf8Coder;
+import org.apache.beam.sdk.coders.VarLongCoder;
+import org.apache.beam.sdk.coders.VoidCoder;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.state.MapState;
+import org.apache.beam.sdk.state.ReadableState;
+import org.apache.beam.sdk.state.StateSpec;
+import org.apache.beam.sdk.state.StateSpecs;
+import org.apache.beam.sdk.transforms.Combine;
+import org.apache.beam.sdk.transforms.Deduplicate;
+import org.apache.beam.sdk.transforms.Distinct;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.DoFn.StateId;
+import org.apache.beam.sdk.transforms.Filter;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.Reshuffle;
+import org.apache.beam.sdk.transforms.Sample;
+import org.apache.beam.sdk.transforms.SerializableFunction;
+import org.apache.beam.sdk.transforms.View;
+import org.apache.beam.sdk.transforms.WithKeys;
+import org.apache.beam.sdk.transforms.display.DisplayData;
+import org.apache.beam.sdk.transforms.windowing.AfterPane;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.transforms.windowing.GlobalWindows;
+import org.apache.beam.sdk.transforms.windowing.PaneInfo;
+import org.apache.beam.sdk.transforms.windowing.Repeatedly;
+import org.apache.beam.sdk.transforms.windowing.Window;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.ValueInSingleWindow;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.NoSuchTableException;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A driver transform that extracts table identifiers from incoming {@link 
Row}s, deduplicates them
+ * per window, optionally bounds the cache size up to {@code maximumCacheSize} 
(batch pipelines
+ * only), loads their declarative metadata from the Iceberg catalog, and emits 
{@link KV} pairs of
+ * table identifier strings to {@link SerializableTableSpec} (or {@code null} 
if the table does not
+ * exist or fails to load). This is intended to be used in Beam pipelines that 
may utilize a large
+ * number of workers to handle Iceberg writes, where having every worker 
thread query for table
+ * metadata results in an excessive amount of requests and a high level of 
redundancy.
+ *
+ * <p>Can also be materialized into a broadcasted {@link PCollectionView} via 
{@link
+ * #asView(IcebergCatalogConfig, DynamicDestinations)}. By default, the cache 
size is uncapped. If
+ * {@code maximumCacheSize} is configured and the number of distinct tables in 
a window exceeds it,
+ * up to {@code maximumCacheSize} tables are sampled into the broadcasted 
view, while remaining
+ * destinations fall back to worker-local catalog loading. Note that {@code 
maximumCacheSize} is
+ * currently supported for bounded batch pipelines only.
+ *
+ * <p>For unbounded streaming pipelines in {@link GlobalWindows}, {@link 
Deduplicate} is used to
+ * deduplicate table identifiers over the configured {@code refreshInterval} 
(defaulting to {@link
+ * #DEFAULT_REFRESH_INTERVAL}), allowing periodic refresh of table metadata 
when schemas evolve.
+ * Missing table signals ({@code null} specs) trigger side-input view 
materialization without
+ * caching the missing tables, ensuring downstream consumers are never blocked 
waiting for the side
+ * input.
+ */
+@Internal
+@AutoValue
+public abstract class TableMetadataDriver
+    extends PTransform<PCollection<Row>, PCollection<KV<String, @Nullable 
SerializableTableSpec>>> {
+
+  public static final Duration DEFAULT_REFRESH_INTERVAL = 
Duration.standardMinutes(5);
+  public static final int DEFAULT_POLLING_BUCKETS = 1;
+
+  @FunctionalInterface
+  public interface Clock extends Serializable {
+    long currentTimeMillis();
+  }
+
+  public abstract IcebergCatalogConfig getCatalogConfig();
+
+  public abstract DynamicDestinations getDynamicDestinations();
+
+  public abstract @Nullable Integer getMaximumCacheSize();
+
+  public abstract @Nullable Duration getRefreshInterval();
+
+  /**
+   * Returns the number of parallel buckets/workers used to query the Iceberg 
catalog, or {@code
+   * null} for default.
+   */
+  public abstract @Nullable Integer getPollingBuckets();
+
+  public static Builder builder() {
+    return new AutoValue_TableMetadataDriver.Builder();
+  }
+
+  public abstract Builder toBuilder();
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setCatalogConfig(IcebergCatalogConfig 
catalogConfig);
+
+    public abstract Builder setDynamicDestinations(DynamicDestinations 
dynamicDestinations);
+
+    public abstract Builder setMaximumCacheSize(@Nullable Integer 
maximumCacheSize);
+
+    public abstract Builder setRefreshInterval(@Nullable Duration 
refreshInterval);
+
+    /**
+     * Sets the number of parallel buckets (worker tasks) used to query the 
Iceberg catalog.
+     *
+     * <p>Defaults to {@link #DEFAULT_POLLING_BUCKETS} (1), which serializes 
all catalog lookups to
+     * avoid overwhelming catalog metastores (e.g. Hive Metastore, REST 
catalog). For pipelines
+     * writing to a large number of distinct dynamic tables (e.g. hundreds of 
tables per window),
+     * consider increasing this value (e.g. 5–10) to parallelize catalog 
lookups while still
+     * bounding load.
+     */
+    public abstract Builder setPollingBuckets(@Nullable Integer 
pollingBuckets);
+
+    abstract TableMetadataDriver autoBuild();
+
+    public TableMetadataDriver build() {
+      TableMetadataDriver driver = autoBuild();
+      Integer maxCacheSize = driver.getMaximumCacheSize();
+      if (maxCacheSize != null) {
+        Preconditions.checkArgument(
+            maxCacheSize > 0, "maximumCacheSize must be greater than 0, got 
%s", maxCacheSize);
+      }
+      Duration refreshInterval = driver.getRefreshInterval();
+      if (refreshInterval != null) {
+        Preconditions.checkArgument(
+            refreshInterval.isLongerThan(Duration.ZERO),
+            "refreshInterval must be positive, got %s",
+            refreshInterval);
+      }
+      Integer pollingBuckets = driver.getPollingBuckets();
+      if (pollingBuckets != null) {
+        Preconditions.checkArgument(
+            pollingBuckets > 0, "pollingBuckets must be greater than 0, got 
%s", pollingBuckets);
+      }
+      return driver;
+    }
+  }
+
+  /**
+   * Helper that applies this {@link TableMetadataDriver} and creates a {@link 
PCollectionView} of
+   * {@link Map} of table identifier strings to {@link SerializableTableSpec}.
+   */
+  public PTransform<PCollection<Row>, PCollectionView<Map<String, 
SerializableTableSpec>>>
+      asView() {
+    return asView(this, null, null);
+  }
+
+  /**
+   * Helper that applies {@link TableMetadataDriver} with default 
configuration and creates an
+   * uncapped {@link PCollectionView} of {@link Map} of table identifier 
strings to {@link
+   * SerializableTableSpec}.
+   */
+  public static PTransform<PCollection<Row>, PCollectionView<Map<String, 
SerializableTableSpec>>>
+      asView(IcebergCatalogConfig catalogConfig, DynamicDestinations 
dynamicDestinations) {
+    return builder()
+        .setCatalogConfig(catalogConfig)
+        .setDynamicDestinations(dynamicDestinations)
+        .build()
+        .asView();
+  }
+
+  @VisibleForTesting
+  static PTransform<PCollection<Row>, PCollectionView<Map<String, 
SerializableTableSpec>>> asView(
+      TableMetadataDriver driver, @Nullable Clock clock) {
+    return asView(driver, null, clock);
+  }
+
+  @VisibleForTesting
+  static PTransform<PCollection<Row>, PCollectionView<Map<String, 
SerializableTableSpec>>> asView(
+      TableMetadataDriver driver, @Nullable Duration cacheTtl, @Nullable Clock 
clock) {
+    Preconditions.checkNotNull(driver, "driver must not be null");
+    return new PTransform<PCollection<Row>, PCollectionView<Map<String, 
SerializableTableSpec>>>() {
+      @Override
+      public PCollectionView<Map<String, SerializableTableSpec>> 
expand(PCollection<Row> input) {
+        boolean isStreaming = input.isBounded() == 
PCollection.IsBounded.UNBOUNDED;
+
+        Duration customInterval = driver.getRefreshInterval();
+        Duration interval = customInterval != null ? customInterval : 
DEFAULT_REFRESH_INTERVAL;
+
+        PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+            input.apply("GenerateTableMetadata", driver);
+
+        if (isStreaming) {
+          AccumulateTableMetadataMapDoFn accumulateDoFn =
+              new AccumulateTableMetadataMapDoFn(interval, cacheTtl, clock);
+          return specs
+              .apply("KeyForGlobalCache", WithKeys.of((Void) null))
+              .setCoder(KvCoder.of(VoidCoder.of(), specs.getCoder()))
+              .apply("AccumulateCacheMap", ParDo.of(accumulateDoFn))
+              .setCoder(MapCoder.of(StringUtf8Coder.of(), 
SerializableTableSpec.getCoder()))
+              .apply(
+                  "StreamingCacheWindow",
+                  Window.<Map<String, SerializableTableSpec>>into(new 
GlobalWindows())
+                      
.triggering(Repeatedly.forever(AfterPane.elementCountAtLeast(1)))
+                      .discardingFiredPanes())
+              .apply(
+                  "CreateMetadataSingletonView",
+                  Combine.globally(new MapMergerFn()).asSingletonView());
+        }
+
+        return specs
+            .apply(
+                "FilterValidSpecsForView",
+                Filter.by(
+                    (SerializableFunction<KV<String, @Nullable 
SerializableTableSpec>, Boolean>)
+                        kv -> kv.getValue() != null))
+            .apply("CreateTableMetadataView", View.asMap());
+      }
+    };
+  }
+
+  @Override
+  public PCollection<KV<String, @Nullable SerializableTableSpec>> 
expand(PCollection<Row> input) {
+    PCollection<String> tableIds =
+        input
+            .apply("ExtractTableIds", ParDo.of(new 
ExtractTableIdsDoFn(getDynamicDestinations())))
+            .setCoder(StringUtf8Coder.of())
+            .apply("MetadataGlobalWindow", Window.into(new GlobalWindows()));
+
+    boolean isStreaming = input.isBounded() == PCollection.IsBounded.UNBOUNDED;
+
+    PCollection<String> distinctTableIds;
+    if (isStreaming) {
+      Duration customInterval = getRefreshInterval();
+      Duration interval =
+          checkNotNull(customInterval != null ? customInterval : 
DEFAULT_REFRESH_INTERVAL);
+      distinctTableIds =
+          tableIds.apply(
+              "DeduplicateTableIds", 
Deduplicate.<String>values().withDuration(interval));
+    } else {
+      distinctTableIds = tableIds.apply("DistinctTableIds", Distinct.create());
+    }
+
+    PCollection<String> cachedTableIds;
+    Integer maxCacheSize = getMaximumCacheSize();
+    if (maxCacheSize != null) {
+      if (isStreaming) {
+        throw new UnsupportedOperationException(
+            "maximumCacheSize is currently not supported for unbounded 
streaming pipelines.");
+      }
+      cachedTableIds = distinctTableIds.apply("CapCacheSize", 
Sample.any(maxCacheSize));
+    } else {
+      cachedTableIds = distinctTableIds;
+    }
+
+    @Nullable Integer configuredBuckets = getPollingBuckets();
+    int pollingBuckets = configuredBuckets != null ? configuredBuckets : 
DEFAULT_POLLING_BUCKETS;
+    PCollection<String> pollingTableIds =
+        cachedTableIds.apply(
+            "ReshufflePollingBuckets",
+            Reshuffle.<String>viaRandomKey().withNumBuckets(pollingBuckets));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        pollingTableIds
+            .apply("PollTableMetadata", ParDo.of(new 
CatalogPollingDoFn(getCatalogConfig())))
+            .setCoder(
+                KvCoder.of(
+                    StringUtf8Coder.of(), 
NullableCoder.of(SerializableTableSpec.getCoder())));
+
+    if (isStreaming) {
+      return specs.apply(
+          "ApplyStreamingTrigger",
+          Window.<KV<String, @Nullable SerializableTableSpec>>into(new 
GlobalWindows())
+              .triggering(Repeatedly.forever(AfterPane.elementCountAtLeast(1)))
+              .discardingFiredPanes());
+    }
+    return specs;
+  }
+
+  @Override
+  public void populateDisplayData(DisplayData.Builder builder) {
+    super.populateDisplayData(builder);
+    builder.addIfNotNull(
+        DisplayData.item("maximumCacheSize", getMaximumCacheSize())
+            .withLabel("Maximum Cache Size"));
+    builder.addIfNotNull(
+        DisplayData.item("refreshInterval", getRefreshInterval())
+            .withLabel("Table Metadata Refresh Interval"));
+    builder.addIfNotNull(
+        DisplayData.item("pollingBuckets", getPollingBuckets())
+            .withLabel("Catalog Polling Buckets"));
+  }
+
+  static class ExtractTableIdsDoFn extends DoFn<Row, String> {
+    private final DynamicDestinations dynamicDestinations;
+
+    ExtractTableIdsDoFn(DynamicDestinations dynamicDestinations) {
+      this.dynamicDestinations = dynamicDestinations;
+    }
+
+    @ProcessElement
+    public void processElement(
+        @Element Row element,
+        BoundedWindow window,
+        PaneInfo paneInfo,
+        @Timestamp Instant timestamp,
+        OutputReceiver<String> out) {
+      String tableIdentifier =
+          dynamicDestinations.getTableStringIdentifier(
+              ValueInSingleWindow.of(element, timestamp, window, paneInfo));
+      if (tableIdentifier != null && !tableIdentifier.trim().isEmpty()) {
+        out.output(tableIdentifier.trim());
+      }
+    }
+  }
+
+  static class CatalogPollingDoFn
+      extends DoFn<String, KV<String, @Nullable SerializableTableSpec>> {
+    private static final Logger LOG = 
LoggerFactory.getLogger(CatalogPollingDoFn.class);
+    private static final Counter TABLES_POLLED_COUNTER =
+        Metrics.counter(TableMetadataDriver.class, "tablesPolled");
+    private static final Counter TABLES_SKIPPED_MISSING_COUNTER =
+        Metrics.counter(TableMetadataDriver.class, "tablesSkippedMissing");
+    private static final Counter TABLES_PARSE_FAILED_COUNTER =
+        Metrics.counter(TableMetadataDriver.class, "tablesParseFailed");
+    private static final Counter TABLES_SPEC_CREATION_FAILED_COUNTER =
+        Metrics.counter(TableMetadataDriver.class, "tablesSpecCreationFailed");
+
+    private final IcebergCatalogConfig catalogConfig;
+
+    CatalogPollingDoFn(IcebergCatalogConfig catalogConfig) {
+      this.catalogConfig = catalogConfig;
+    }
+
+    @ProcessElement
+    public void processElement(
+        @Element String tableIdString,
+        OutputReceiver<KV<String, @Nullable SerializableTableSpec>> out) {
+      TableIdentifier tableId;
+      try {
+        tableId = IcebergUtils.parseTableIdentifier(tableIdString);
+      } catch (IllegalArgumentException e) {
+        LOG.warn(
+            "Failed to parse table identifier '{}'. Emitting empty metadata 
signal for side-input view.",
+            tableIdString,
+            e);
+        TABLES_PARSE_FAILED_COUNTER.inc();
+        out.output(KV.of(tableIdString, null));
+        return;
+      }
+
+      Table table;
+      try {
+        table = catalogConfig.catalog().loadTable(tableId);
+      } catch (NoSuchTableException e) {
+        LOG.info(
+            "Table '{}' does not exist in catalog. Emitting empty metadata 
signal for side-input view.",
+            tableIdString);
+        TABLES_SKIPPED_MISSING_COUNTER.inc();
+        out.output(KV.of(tableIdString, null));
+        return;
+      }
+      SerializableTableSpec spec;
+      try {
+        spec = SerializableTableSpec.fromTable(tableIdString, table);
+      } catch (IllegalArgumentException e) {
+        LOG.warn(
+            "Failed to create SerializableTableSpec for table '{}'. Emitting 
empty metadata signal for side-input view.",
+            tableIdString,
+            e);
+        TABLES_SPEC_CREATION_FAILED_COUNTER.inc();
+        out.output(KV.of(tableIdString, null));
+        return;
+      }
+      TABLES_POLLED_COUNTER.inc();
+      out.output(KV.of(tableIdString, spec));
+    }
+  }
+
+  static class AccumulateTableMetadataMapDoFn
+      extends DoFn<
+          KV<Void, KV<String, @Nullable SerializableTableSpec>>,
+          Map<String, SerializableTableSpec>> {
+    private static final Logger LOG = 
LoggerFactory.getLogger(AccumulateTableMetadataMapDoFn.class);
+    private static final Counter TABLES_EVICTED_COUNTER =
+        Metrics.counter(TableMetadataDriver.class, "tablesEvictedUnused");
+    static final int DEFAULT_TTL_MULTIPLIER = 3;
+
+    @StateId("tableCache")
+    private final StateSpec<MapState<String, SerializableTableSpec>> 
cacheStateSpec =
+        StateSpecs.map(StringUtf8Coder.of(), SerializableTableSpec.getCoder());
+
+    @StateId("lastSeen")
+    private final StateSpec<MapState<String, Long>> lastSeenStateSpec =
+        StateSpecs.map(StringUtf8Coder.of(), VarLongCoder.of());
+
+    private final Duration refreshInterval;
+    private final Duration cacheTtl;
+    private final Clock clock;
+
+    AccumulateTableMetadataMapDoFn(
+        Duration refreshInterval, @Nullable Duration cacheTtl, @Nullable Clock 
clock) {
+      this.refreshInterval = refreshInterval != null ? refreshInterval : 
DEFAULT_REFRESH_INTERVAL;
+      this.cacheTtl =
+          cacheTtl != null ? cacheTtl : 
this.refreshInterval.multipliedBy(DEFAULT_TTL_MULTIPLIER);
+      this.clock = clock != null ? clock : System::currentTimeMillis;
+    }
+
+    @ProcessElement
+    public void processElement(
+        @Element KV<Void, KV<String, @Nullable SerializableTableSpec>> element,
+        @StateId("tableCache") MapState<String, SerializableTableSpec> 
cacheState,
+        @StateId("lastSeen") MapState<String, Long> lastSeenState,
+        OutputReceiver<Map<String, SerializableTableSpec>> out) {
+      long now = clock.currentTimeMillis();
+      KV<String, @Nullable SerializableTableSpec> kv = element.getValue();
+      String tableId = kv.getKey();
+      @Nullable SerializableTableSpec newSpec = kv.getValue();
+
+      if (newSpec != null) {
+        ReadableState<SerializableTableSpec> existingState = 
cacheState.get(tableId);
+        SerializableTableSpec existingSpec = existingState != null ? 
existingState.read() : null;
+        if (existingSpec == null || isNewer(newSpec, existingSpec)) {
+          cacheState.put(tableId, newSpec);
+        }
+        lastSeenState.put(tableId, now);
+      }
+
+      // Populate a local map with the state for constant-time lookups during
+      // cache entry expiration.
+      Map<String, Long> lastSeenMap = new HashMap<>();
+      for (Map.Entry<String, Long> entry : lastSeenState.entries().read()) {
+        lastSeenMap.put(entry.getKey(), entry.getValue());
+      }
+
+      long expirationCutoff = now - cacheTtl.getMillis();
+      List<String> expiredTables = new ArrayList<>();
+      Map<String, SerializableTableSpec> mapSnapshot = new HashMap<>();
+
+      for (Map.Entry<String, SerializableTableSpec> entry : 
cacheState.entries().read()) {
+        String id = entry.getKey();
+        Long lastSeen = lastSeenMap.get(id);
+        if (lastSeen == null) {
+          lastSeen = now;
+          lastSeenState.put(id, now);
+        }
+        if (lastSeen < expirationCutoff) {
+          expiredTables.add(id);
+        } else {
+          mapSnapshot.put(id, entry.getValue());
+        }
+      }
+
+      for (String expired : expiredTables) {
+        cacheState.remove(expired);
+        lastSeenState.remove(expired);
+        TABLES_EVICTED_COUNTER.inc();
+        LOG.info("Evicted unused table '{}' from side-input metadata cache.", 
expired);
+      }
+
+      out.output(Collections.unmodifiableMap(mapSnapshot));
+    }
+  }
+
+  static boolean isNewer(SerializableTableSpec candidate, 
SerializableTableSpec current) {
+    if (candidate.getLastUpdatedMillis() != current.getLastUpdatedMillis()) {
+      return candidate.getLastUpdatedMillis() > current.getLastUpdatedMillis();
+    }
+    if (candidate.getSchemaId() != current.getSchemaId()) {
+      return candidate.getSchemaId() > current.getSchemaId();
+    }
+    if (candidate.getSpecId() != current.getSpecId()) {
+      return candidate.getSpecId() > current.getSpecId();
+    }
+    if (candidate.getOrderId() != current.getOrderId()) {
+      return candidate.getOrderId() > current.getOrderId();
+    }
+    return candidate.getLocation().compareTo(current.getLocation()) > 0;
+  }
+
+  static class MapMergerFn extends Combine.BinaryCombineFn<Map<String, 
SerializableTableSpec>> {
+    @Override
+    public Map<String, SerializableTableSpec> apply(
+        Map<String, SerializableTableSpec> left, Map<String, 
SerializableTableSpec> right) {
+      if (left == null || left.isEmpty()) {
+        return right != null ? right : Collections.emptyMap();
+      }
+      if (right == null || right.isEmpty()) {
+        return left;
+      }
+      Map<String, SerializableTableSpec> merged = new HashMap<>(left);
+      for (Map.Entry<String, SerializableTableSpec> entry : right.entrySet()) {
+        String tableId = entry.getKey();
+        SerializableTableSpec rightSpec = entry.getValue();
+        SerializableTableSpec leftSpec = merged.get(tableId);
+        if (leftSpec == null || isNewer(rightSpec, leftSpec)) {
+          merged.put(tableId, rightSpec);
+        }
+      }
+      return Collections.unmodifiableMap(merged);
+    }
+
+    @Override
+    public Map<String, SerializableTableSpec> identity() {
+      return Collections.emptyMap();
+    }
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
index 664f4512f42..a2f2abadfe4 100644
--- 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/SerializableTableSpecTest.java
@@ -45,6 +45,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Immuta
 import org.apache.hadoop.conf.Configuration;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.HasTableOperations;
 import org.apache.iceberg.NullOrder;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
@@ -132,6 +133,10 @@ public class SerializableTableSpecTest {
     assertNotNull(spec.getEncryptedKeyJsons());
     assertNotNull(spec.getEncryptedKeys());
     assertTrue(spec.getEncryptedKeys().isEmpty());
+    assertEquals(
+        ((HasTableOperations) 
table).operations().current().lastUpdatedMillis(),
+        spec.getLastUpdatedMillis());
+    assertTrue(spec.getLastUpdatedMillis() > 0);
   }
 
   @Test
@@ -279,6 +284,7 @@ public class SerializableTableSpecTest {
     assertEquals(original.getProperties(), decoded.getProperties());
     assertEquals(original.getFileIoJson(), decoded.getFileIoJson());
     assertEquals(original.getEncryptedKeyJsons(), 
decoded.getEncryptedKeyJsons());
+    assertEquals(original.getLastUpdatedMillis(), 
decoded.getLastUpdatedMillis());
     assertNotNull(decoded.getFileIO());
   }
 
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriverTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriverTest.java
new file mode 100644
index 00000000000..c46b155921b
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/TableMetadataDriverTest.java
@@ -0,0 +1,1501 @@
+/*
+ * 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 org.apache.beam.sdk.io.iceberg;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+import org.apache.beam.sdk.coders.RowCoder;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.testing.TestStream;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.display.DisplayData;
+import org.apache.beam.sdk.transforms.windowing.FixedWindows;
+import org.apache.beam.sdk.transforms.windowing.Window;
+import org.apache.beam.sdk.values.KV;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.Row;
+import org.apache.beam.sdk.values.TimestampedValue;
+import org.apache.beam.sdk.values.ValueInSingleWindow;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableList;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.FileFormat;
+import org.apache.iceberg.PartitionKey;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.types.Types;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+public class TableMetadataDriverTest implements Serializable {
+
+  @Rule public transient TestPipeline pipeline = TestPipeline.create();
+  @Rule public transient TemporaryFolder tempFolder = new TemporaryFolder();
+
+  private String warehouseLocation;
+  private IcebergCatalogConfig catalogConfig;
+
+  private static final Schema BEAM_SCHEMA =
+      Schema.builder()
+          .addInt64Field("id")
+          .addStringField("data")
+          .addNullableStringField("dest")
+          .build();
+
+  private static final org.apache.iceberg.Schema ICEBERG_SCHEMA =
+      IcebergUtils.beamSchemaToIcebergSchema(
+          Schema.builder().addInt64Field("id").addStringField("data").build());
+
+  private static final TableIdentifier TABLE_ID = 
TableIdentifier.of("default", "table");
+
+  private static final DynamicDestinations SINGLE_TABLE_DYNAMIC_DESTINATIONS =
+      DynamicDestinations.singleTable(TABLE_ID, BEAM_SCHEMA);
+
+  private static final DynamicDestinations DYNAMIC_DESTINATIONS =
+      new DynamicDestinations() {
+        @Override
+        public Schema getDataSchema() {
+          return BEAM_SCHEMA;
+        }
+
+        @Override
+        public Row getData(Row element) {
+          return element;
+        }
+
+        @Override
+        public IcebergDestination instantiateDestination(String destination) {
+          return IcebergDestination.builder()
+              
.setTableIdentifier(IcebergUtils.parseTableIdentifier(destination))
+              .build();
+        }
+
+        @Override
+        public String getTableStringIdentifier(ValueInSingleWindow<Row> 
element) {
+          return element.getValue().getString("dest");
+        }
+      };
+
+  @Before
+  public void setUp() throws Exception {
+    warehouseLocation = "file:" + tempFolder.newFolder().getAbsolutePath();
+    catalogConfig =
+        IcebergCatalogConfig.builder()
+            .setCatalogName("hadoop")
+            .setCatalogProperties(ImmutableMap.of("type", "hadoop", 
"warehouse", warehouseLocation))
+            .build();
+  }
+
+  private Catalog getCatalog() {
+    return CatalogUtil.loadCatalog(
+        CatalogUtil.ICEBERG_CATALOG_HADOOP,
+        "hadoop",
+        ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, 
warehouseLocation),
+        new Configuration());
+  }
+
+  @Test
+  public void testSingleTableExtractionAndSpecOutput() {
+    Table realTable = getCatalog().createTable(TABLE_ID, ICEBERG_SCHEMA);
+
+    List<Row> rows = new ArrayList<>();
+    for (int i = 0; i < 5; i++) {
+      rows.add(
+          Row.withSchema(BEAM_SCHEMA)
+              .withFieldValue("id", (long) i)
+              .withFieldValue("data", "val_" + i)
+              .withFieldValue("dest", null)
+              .build());
+    }
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .build());
+
+    String expectedTableIdString = 
IcebergUtils.tableIdentifierToString(TABLE_ID);
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(1, list.size());
+              KV<String, @Nullable SerializableTableSpec> kv = list.get(0);
+              assertEquals(expectedTableIdString, kv.getKey());
+              SerializableTableSpec spec = kv.getValue();
+              assertNotNull(spec);
+              assertEquals(realTable.name(), spec.getName());
+              assertEquals(realTable.location(), spec.getLocation());
+              assertEquals(realTable.schema().asStruct(), 
spec.getSchema().asStruct());
+              assertEquals(realTable.spec(), spec.getPartitionSpec());
+              assertNotNull(spec.getFileIO());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testMultipleDynamicDestinationsExtraction() {
+    Catalog catalog = getCatalog();
+    TableIdentifier tableA = TableIdentifier.of("default", "table_a");
+    TableIdentifier tableB = TableIdentifier.of("default", "table_b");
+    TableIdentifier tableC = TableIdentifier.of("default", "table_c");
+
+    catalog.createTable(tableA, ICEBERG_SCHEMA);
+    catalog.createTable(tableB, ICEBERG_SCHEMA);
+    catalog.createTable(tableC, ICEBERG_SCHEMA);
+
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.table_a").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.table_b").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(3L, "v3", 
"default.table_c").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(4L, "v4", 
"default.table_a").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(5L, "v5", 
"default.table_b").build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(3, list.size());
+              Map<String, SerializableTableSpec> map =
+                  
list.stream().collect(ImmutableMap.toImmutableMap(KV::getKey, KV::getValue));
+              assertTrue(map.containsKey("default.table_a"));
+              assertTrue(map.containsKey("default.table_b"));
+              assertTrue(map.containsKey("default.table_c"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testWindowedDeduplication() {
+    Catalog catalog = getCatalog();
+    TableIdentifier table1 = TableIdentifier.of("default", "t1");
+    TableIdentifier table2 = TableIdentifier.of("default", "t2");
+
+    catalog.createTable(table1, ICEBERG_SCHEMA);
+    catalog.createTable(table2, ICEBERG_SCHEMA);
+
+    List<Row> rows = new ArrayList<>();
+    for (int i = 0; i < 100; i++) {
+      String dest = (i % 2 == 0) ? "default.t1" : "default.t2";
+      rows.add(Row.withSchema(BEAM_SCHEMA).addValues((long) i, "val_" + i, 
dest).build());
+    }
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(2, list.size());
+              Map<String, SerializableTableSpec> map =
+                  
list.stream().collect(ImmutableMap.toImmutableMap(KV::getKey, KV::getValue));
+              assertTrue(map.containsKey("default.t1"));
+              assertTrue(map.containsKey("default.t2"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testUnboundedGlobalWindowStreamingDeduplication() {
+    Catalog catalog = getCatalog();
+    TableIdentifier table1 = TableIdentifier.of("default", "stream_t1");
+    TableIdentifier table2 = TableIdentifier.of("default", "stream_t2");
+
+    catalog.createTable(table1, ICEBERG_SCHEMA);
+    catalog.createTable(table2, ICEBERG_SCHEMA);
+
+    Row row1 = Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.stream_t1").build();
+    Row row2 = Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.stream_t2").build();
+    Row row3 = Row.withSchema(BEAM_SCHEMA).addValues(3L, "v3", 
"default.stream_t1").build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row1)
+            .addElements(row2)
+            .addElements(row3)
+            .advanceProcessingTime(Duration.standardSeconds(5))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input = pipeline.apply("StreamInput", stream);
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(2, list.size());
+              Map<String, SerializableTableSpec> map =
+                  
list.stream().collect(ImmutableMap.toImmutableMap(KV::getKey, KV::getValue));
+              assertTrue(map.containsKey("default.stream_t1"));
+              assertTrue(map.containsKey("default.stream_t2"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testMetadataRefreshedAcrossIntervals() {
+    Catalog catalog = getCatalog();
+    TableIdentifier tableId = TableIdentifier.of("default", "evolving_table");
+    catalog.createTable(tableId, ICEBERG_SCHEMA);
+
+    Row row1 =
+        Row.withSchema(BEAM_SCHEMA).addValues(1L, "initial_data", 
"default.evolving_table").build();
+    Row row2 =
+        Row.withSchema(BEAM_SCHEMA)
+            .addValues(2L, "trigger_update", "default.evolving_table")
+            .build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row1)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(row2)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input =
+        pipeline
+            .apply("StreamInput", stream)
+            .apply(
+                "EvolveSchemaOnTriggerRow",
+                ParDo.of(
+                    new DoFn<Row, Row>() {
+                      @ProcessElement
+                      public void processElement(@Element Row row, 
OutputReceiver<Row> out) {
+                        if ("trigger_update".equals(row.getString("data"))) {
+                          Table table =
+                              catalogConfig
+                                  .catalog()
+                                  .loadTable(
+                                      
IcebergUtils.parseTableIdentifier("default.evolving_table"));
+                          table
+                              .updateSchema()
+                              .addColumn("new_col", Types.StringType.get())
+                              .commit();
+                        }
+                        out.output(row);
+                      }
+                    }))
+            .setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build());
+
+    // Downstream consumer transform verifying that updated metadata is 
received
+    PCollection<String> consumerReceivedSchemas =
+        specs.apply(
+            "ConsumerTransform",
+            ParDo.of(
+                new DoFn<KV<String, @Nullable SerializableTableSpec>, 
String>() {
+                  @ProcessElement
+                  public void processElement(
+                      @Element KV<String, @Nullable SerializableTableSpec> 
element,
+                      OutputReceiver<String> out) {
+                    SerializableTableSpec spec = 
checkNotNull(element.getValue());
+                    boolean hasNewCol = spec.getSchema().findField("new_col") 
!= null;
+                    out.output(hasNewCol ? "UPDATED_SCHEMA" : 
"INITIAL_SCHEMA");
+                  }
+                }));
+
+    PAssert.that(consumerReceivedSchemas).containsInAnyOrder("INITIAL_SCHEMA", 
"UPDATED_SCHEMA");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testMetadataRefreshedAcrossIntervalsAsSideInput() {
+    Catalog catalog = getCatalog();
+    TableIdentifier tableId = TableIdentifier.of("default", 
"evolving_side_input_table");
+    catalog.createTable(tableId, ICEBERG_SCHEMA);
+
+    String tableIdStr = "default.evolving_side_input_table";
+    Row row1 = Row.withSchema(BEAM_SCHEMA).addValues(1L, "initial_data", 
tableIdStr).build();
+    Row row2 = Row.withSchema(BEAM_SCHEMA).addValues(2L, "trigger_update", 
tableIdStr).build();
+    Row row3 = Row.withSchema(BEAM_SCHEMA).addValues(3L, "post_update_data", 
tableIdStr).build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row1)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(row2)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(row3)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input =
+        pipeline
+            .apply("StreamInput", stream)
+            .apply(
+                "EvolveSchemaOnTriggerRow",
+                ParDo.of(
+                    new DoFn<Row, Row>() {
+                      @ProcessElement
+                      public void processElement(@Element Row row, 
OutputReceiver<Row> out) {
+                        if ("trigger_update".equals(row.getString("data"))) {
+                          Table table =
+                              catalogConfig
+                                  .catalog()
+                                  .loadTable(
+                                      IcebergUtils.parseTableIdentifier(
+                                          
"default.evolving_side_input_table"));
+                          table
+                              .updateSchema()
+                              .addColumn("new_col", Types.StringType.get())
+                              .commit();
+                        }
+                        out.output(row);
+                      }
+                    }))
+            .setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build()
+                .asView());
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        if ("trigger_update".equals(row.getString("data"))) {
+                          return;
+                        }
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        SerializableTableSpec spec = 
viewMap.get(row.getString("dest"));
+                        assertNotNull("Expected spec in side input view", 
spec);
+
+                        SideInputTable sideInputTable = new 
SideInputTable(spec);
+                        boolean hasNewCol = 
sideInputTable.schema().findField("new_col") != null;
+                        out.output(
+                            row.getString("data")
+                                + ":"
+                                + (hasNewCol ? "UPDATED_SCHEMA" : 
"INITIAL_SCHEMA"));
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved)
+        .containsInAnyOrder("initial_data:INITIAL_SCHEMA", 
"post_update_data:UPDATED_SCHEMA");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testMetadataRefreshedAcrossIntervalsAsSideInputWithMultipleTables() {
+    Catalog catalog = getCatalog();
+    TableIdentifier tableA = TableIdentifier.of("default", "multi_table_a");
+    TableIdentifier tableB = TableIdentifier.of("default", "multi_table_b");
+    catalog.createTable(tableA, ICEBERG_SCHEMA);
+    catalog.createTable(tableB, ICEBERG_SCHEMA);
+
+    String tableAStr = "default.multi_table_a";
+    String tableBStr = "default.multi_table_b";
+
+    Row rowSeedA = Row.withSchema(BEAM_SCHEMA).addValues(0L, "seed_a", 
tableAStr).build();
+    Row rowSeedB = Row.withSchema(BEAM_SCHEMA).addValues(0L, "seed_b", 
tableBStr).build();
+    Row rowA1 = Row.withSchema(BEAM_SCHEMA).addValues(1L, "a1", 
tableAStr).build();
+    Row rowB1 = Row.withSchema(BEAM_SCHEMA).addValues(2L, "b1", 
tableBStr).build();
+    Row rowTriggerUpdateA =
+        Row.withSchema(BEAM_SCHEMA).addValues(3L, "trigger_update_a", 
tableAStr).build();
+    Row rowA2 = Row.withSchema(BEAM_SCHEMA).addValues(4L, "a2", 
tableAStr).build();
+    Row rowB2 = Row.withSchema(BEAM_SCHEMA).addValues(5L, "b2", 
tableBStr).build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(rowSeedA, rowSeedB)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowA1, rowB1)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowTriggerUpdateA)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowA2, rowB2)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input =
+        pipeline
+            .apply("StreamInput", stream)
+            .apply(
+                "EvolveSchemaOnTriggerRow",
+                ParDo.of(
+                    new DoFn<Row, Row>() {
+                      @ProcessElement
+                      public void processElement(@Element Row row, 
OutputReceiver<Row> out) {
+                        if ("trigger_update_a".equals(row.getString("data"))) {
+                          Table table =
+                              catalogConfig
+                                  .catalog()
+                                  .loadTable(
+                                      
IcebergUtils.parseTableIdentifier("default.multi_table_a"));
+                          table
+                              .updateSchema()
+                              .addColumn("new_col_a", Types.StringType.get())
+                              .commit();
+                        }
+                        out.output(row);
+                      }
+                    }))
+            .setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build()
+                .asView());
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        String data = row.getString("data");
+                        if ("seed_a".equals(data)
+                            || "seed_b".equals(data)
+                            || "trigger_update_a".equals(data)) {
+                          return;
+                        }
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        SerializableTableSpec spec = 
viewMap.get(row.getString("dest"));
+                        assertNotNull(
+                            "Expected table " + row.getString("dest") + " in 
side input view",
+                            spec);
+
+                        SideInputTable sideInputTable = new 
SideInputTable(spec);
+                        boolean hasNewColA = 
sideInputTable.schema().findField("new_col_a") != null;
+                        out.output(
+                            row.getString("data")
+                                + ":"
+                                + (hasNewColA ? "UPDATED_SCHEMA" : 
"INITIAL_SCHEMA"));
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved)
+        .containsInAnyOrder(
+            "a1:INITIAL_SCHEMA", "b1:INITIAL_SCHEMA", "a2:UPDATED_SCHEMA", 
"b2:INITIAL_SCHEMA");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testStreamingNonExistentTableEmitsEmptyMapWithoutBlockingConsumer() {
+    Row row =
+        Row.withSchema(BEAM_SCHEMA)
+            .addValues(1L, "v1", "default.non_existent_streaming_table")
+            .build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row)
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input = pipeline.apply("StreamInput", stream);
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build()
+                .asView());
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        assertNotNull("View map should not be null", viewMap);
+                        assertTrue(
+                            "View map should be empty when all polled tables 
do not exist",
+                            viewMap.isEmpty());
+                        out.output("CONSUMER_UNBLOCKED_EMPTY_MAP");
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    
PAssert.that(consumerObserved).containsInAnyOrder("CONSUMER_UNBLOCKED_EMPTY_MAP");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testStreamingMixedExistingAndNonExistentTables() {
+    Catalog catalog = getCatalog();
+    TableIdentifier validTable = TableIdentifier.of("default", 
"mixed_valid_table");
+    catalog.createTable(validTable, ICEBERG_SCHEMA);
+
+    Row seedValidRow =
+        Row.withSchema(BEAM_SCHEMA)
+            .addValues(0L, "seed_valid", "default.mixed_valid_table")
+            .build();
+    Row seedMissingRow =
+        Row.withSchema(BEAM_SCHEMA)
+            .addValues(0L, "seed_missing", "default.mixed_missing_table")
+            .build();
+    Row validRow =
+        Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.mixed_valid_table").build();
+    Row missingRow =
+        Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.mixed_missing_table").build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(seedValidRow, seedMissingRow)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(validRow, missingRow)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input = pipeline.apply("StreamInput", stream);
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(2))
+                .build()
+                .asView());
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        String data = row.getString("data");
+                        if ("seed_valid".equals(data) || 
"seed_missing".equals(data)) {
+                          return;
+                        }
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        assertNotNull(viewMap);
+                        String dest = row.getString("dest");
+                        if ("default.mixed_valid_table".equals(dest)) {
+                          assertNotNull(viewMap.get(dest));
+                          out.output("VALID_TABLE_FOUND");
+                        } else {
+                          assertTrue(!viewMap.containsKey(dest));
+                          out.output("MISSING_TABLE_NOT_FOUND");
+                        }
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved)
+        .containsInAnyOrder("VALID_TABLE_FOUND", "MISSING_TABLE_NOT_FOUND");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testMaximumCacheSizeInStreamingThrowsUnsupportedOperationException() {
+    pipeline.enableAbandonedNodeEnforcement(false);
+    Row row = Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.test_table").build();
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row)
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input = pipeline.apply("StreamInput", stream);
+
+    assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            input.apply(
+                TableMetadataDriver.builder()
+                    .setCatalogConfig(catalogConfig)
+                    .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                    .setMaximumCacheSize(5)
+                    .build()));
+  }
+
+  @Test
+  public void testMalformedTableIdentifierSkippedWithoutFailingBundle() {
+    Row validRow = Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.valid_table").build();
+    Row malformedRow =
+        Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.invalid..name///").build();
+
+    getCatalog().createTable(TableIdentifier.of("default", "valid_table"), 
ICEBERG_SCHEMA);
+
+    PCollection<Row> input =
+        pipeline.apply(Create.of(validRow, 
malformedRow)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              Map<String, @Nullable SerializableTableSpec> map = new 
HashMap<>();
+              for (KV<String, @Nullable SerializableTableSpec> elem : 
elements) {
+                map.put(elem.getKey(), elem.getValue());
+              }
+              assertEquals(2, map.size());
+              assertNotNull(map.get("default.valid_table"));
+              assertNull(map.get("default.invalid..name///"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testMaximumCacheSizeCap() {
+    Catalog catalog = getCatalog();
+    for (int i = 1; i <= 6; i++) {
+      catalog.createTable(TableIdentifier.of("default", "cap_table_" + i), 
ICEBERG_SCHEMA);
+    }
+
+    List<Row> rows = new ArrayList<>();
+    for (int i = 1; i <= 6; i++) {
+      rows.add(
+          Row.withSchema(BEAM_SCHEMA)
+              .addValues((long) i, "v_" + i, "default.cap_table_" + i)
+              .build());
+    }
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    int maxCacheSize = 3;
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setMaximumCacheSize(maxCacheSize)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(maxCacheSize, list.size());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testUncappedByDefault() {
+    Catalog catalog = getCatalog();
+    for (int i = 1; i <= 10; i++) {
+      catalog.createTable(TableIdentifier.of("default", "uncapped_table_" + 
i), ICEBERG_SCHEMA);
+    }
+
+    List<Row> rows = new ArrayList<>();
+    for (int i = 1; i <= 10; i++) {
+      rows.add(
+          Row.withSchema(BEAM_SCHEMA)
+              .addValues((long) i, "v_" + i, "default.uncapped_table_" + i)
+              .build());
+    }
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    // Without setting maximumCacheSize, all 10 distinct tables are emitted
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(10, list.size());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testNonExistentTableIsSkippedWithoutFailingBundle() {
+    Catalog catalog = getCatalog();
+    TableIdentifier validTable = TableIdentifier.of("default", 
"existing_table");
+    catalog.createTable(validTable, ICEBERG_SCHEMA);
+
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.existing_table").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.non_existent_table").build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    // Both existing and missing table entries are emitted; missing table has 
null spec
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              Map<String, @Nullable SerializableTableSpec> map = new 
HashMap<>();
+              for (KV<String, @Nullable SerializableTableSpec> elem : 
elements) {
+                map.put(elem.getKey(), elem.getValue());
+              }
+              assertEquals(2, map.size());
+              assertNotNull(map.get("default.existing_table"));
+              assertNull(map.get("default.non_existent_table"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testFiltersNullAndBlankTableIdentifiers() {
+    TableIdentifier validTableId = TableIdentifier.of("default", 
"valid_dest_table");
+    getCatalog().createTable(validTableId, ICEBERG_SCHEMA);
+
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", null).build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", "").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(3L, "v3", "   ").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(4L, "v4", 
"default.valid_dest_table").build(),
+            Row.withSchema(BEAM_SCHEMA)
+                .addValues(5L, "v5", "  default.valid_dest_table  ")
+                .build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(1, list.size());
+              assertEquals("default.valid_dest_table", list.get(0).getKey());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testInvalidMaximumCacheSizeThrowsException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setMaximumCacheSize(0)
+                .build());
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setMaximumCacheSize(-5)
+                .build());
+  }
+
+  @Test
+  public void testInvalidRefreshIntervalThrowsException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.ZERO)
+                .build());
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setRefreshInterval(Duration.standardSeconds(-5))
+                .build());
+  }
+
+  @Test
+  public void testInvalidPollingBucketsThrowsException() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setPollingBuckets(0)
+                .build());
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .setPollingBuckets(-2)
+                .build());
+  }
+
+  @Test
+  public void testConfigurablePollingBuckets() {
+    Catalog catalog = getCatalog();
+    TableIdentifier table1 = TableIdentifier.of("default", "bucket_t1");
+    TableIdentifier table2 = TableIdentifier.of("default", "bucket_t2");
+    catalog.createTable(table1, ICEBERG_SCHEMA);
+    catalog.createTable(table2, ICEBERG_SCHEMA);
+
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.bucket_t1").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.bucket_t2").build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .setPollingBuckets(2)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(2, list.size());
+              Map<String, SerializableTableSpec> map =
+                  
list.stream().collect(ImmutableMap.toImmutableMap(KV::getKey, KV::getValue));
+              assertTrue(map.containsKey("default.bucket_t1"));
+              assertTrue(map.containsKey("default.bucket_t2"));
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testWindowPreservation() {
+    Catalog catalog = getCatalog();
+    TableIdentifier tableW1 = TableIdentifier.of("default", "table_w1");
+    TableIdentifier tableW2 = TableIdentifier.of("default", "table_w2");
+
+    catalog.createTable(tableW1, ICEBERG_SCHEMA);
+    catalog.createTable(tableW2, ICEBERG_SCHEMA);
+
+    Instant t1 = new Instant(1000);
+    Instant t2 = new Instant(70000);
+
+    PCollection<Row> input =
+        pipeline
+            .apply(
+                Create.timestamped(
+                    TimestampedValue.of(
+                        Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.table_w1").build(),
+                        t1),
+                    TimestampedValue.of(
+                        Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.table_w2").build(),
+                        t2)))
+            .setCoder(RowCoder.of(BEAM_SCHEMA))
+            .apply(Window.into(FixedWindows.of(Duration.standardMinutes(1))));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs)
+        .satisfies(
+            elements -> {
+              List<KV<String, @Nullable SerializableTableSpec>> list =
+                  ImmutableList.copyOf(elements);
+              assertEquals(2, list.size());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testEmptyInputProducesEmptyOutput() {
+    getCatalog().createTable(TABLE_ID, ICEBERG_SCHEMA);
+
+    PCollection<Row> input = 
pipeline.apply(Create.empty(RowCoder.of(BEAM_SCHEMA)));
+
+    PCollection<KV<String, @Nullable SerializableTableSpec>> specs =
+        input.apply(
+            TableMetadataDriver.builder()
+                .setCatalogConfig(catalogConfig)
+                .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+                .build());
+
+    PAssert.that(specs).empty();
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testDisplayData() {
+    TableMetadataDriver driver =
+        TableMetadataDriver.builder()
+            .setCatalogConfig(catalogConfig)
+            .setDynamicDestinations(SINGLE_TABLE_DYNAMIC_DESTINATIONS)
+            .setMaximumCacheSize(42)
+            .setRefreshInterval(Duration.standardMinutes(10))
+            .setPollingBuckets(3)
+            .build();
+
+    DisplayData displayData = DisplayData.from(driver);
+    Map<DisplayData.Identifier, DisplayData.Item> items = displayData.asMap();
+
+    assertNotNull(displayData);
+    boolean hasCacheSize = false;
+    boolean hasRefreshInterval = false;
+    boolean hasPollingBuckets = false;
+    for (DisplayData.Item item : items.values()) {
+      if ("maximumCacheSize".equals(item.getKey())) {
+        assertEquals(42L, item.getValue());
+        hasCacheSize = true;
+      }
+      if ("refreshInterval".equals(item.getKey())) {
+        hasRefreshInterval = true;
+      }
+      if ("pollingBuckets".equals(item.getKey())) {
+        assertEquals(3L, item.getValue());
+        hasPollingBuckets = true;
+      }
+    }
+    assertTrue(hasCacheSize);
+    assertTrue(hasRefreshInterval);
+    assertTrue(hasPollingBuckets);
+  }
+
+  @Test
+  public void testViewAsMapIntegration() {
+    PartitionSpec partitionSpec = 
PartitionSpec.builderFor(ICEBERG_SCHEMA).identity("data").build();
+    getCatalog().createTable(TABLE_ID, ICEBERG_SCHEMA, partitionSpec);
+
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(10L, "partition_val_a", 
null).build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(20L, "partition_val_b", 
null).build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.asView(catalogConfig, 
SINGLE_TABLE_DYNAMIC_DESTINATIONS));
+
+    String expectedTableIdString = 
IcebergUtils.tableIdentifierToString(TABLE_ID);
+
+    PCollection<String> writtenFiles =
+        input.apply(
+            "WriteWithSideInputTable",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c)
+                          throws Exception {
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        SerializableTableSpec spec = 
viewMap.get(expectedTableIdString);
+                        assertNotNull(spec);
+
+                        SideInputTable sideInputTable = new 
SideInputTable(spec);
+                        PartitionKey partitionKey =
+                            new PartitionKey(sideInputTable.spec(), 
sideInputTable.schema());
+                        Record record = 
GenericRecord.create(sideInputTable.schema());
+                        record.setField("id", row.getInt64("id"));
+                        record.setField("data", row.getString("data"));
+                        partitionKey.partition(record);
+
+                        RecordWriter writer =
+                            new RecordWriter(
+                                sideInputTable,
+                                FileFormat.PARQUET,
+                                "side_input_test_file_" + row.getInt64("id"),
+                                partitionKey,
+                                ImmutableMap.of());
+                        writer.write(record);
+                        writer.close();
+
+                        out.output(writer.getDataFile().path().toString());
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(writtenFiles)
+        .satisfies(
+            files -> {
+              List<String> paths = ImmutableList.copyOf(files);
+              assertEquals(2, paths.size());
+              return null;
+            });
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testMapMergerFnCommutativeAndTimestampAware() {
+    TableIdentifier tableIdA = TableIdentifier.of("default", "merge_table_a");
+    Table realTableA = getCatalog().createTable(tableIdA, ICEBERG_SCHEMA);
+    SerializableTableSpec baseA = SerializableTableSpec.fromTable(tableIdA, 
realTableA);
+    SerializableTableSpec specAOld = 
baseA.toBuilder().setLastUpdatedMillis(1000L).build();
+    SerializableTableSpec specANew = 
baseA.toBuilder().setLastUpdatedMillis(2000L).build();
+
+    TableIdentifier tableIdB = TableIdentifier.of("default", "merge_table_b");
+    Table realTableB = getCatalog().createTable(tableIdB, ICEBERG_SCHEMA);
+    SerializableTableSpec baseB = SerializableTableSpec.fromTable(tableIdB, 
realTableB);
+    SerializableTableSpec specB = 
baseB.toBuilder().setLastUpdatedMillis(1500L).build();
+
+    TableMetadataDriver.MapMergerFn fn = new TableMetadataDriver.MapMergerFn();
+
+    Map<String, SerializableTableSpec> map1 = ImmutableMap.of("tableA", 
specAOld, "tableB", specB);
+    Map<String, SerializableTableSpec> map2 = ImmutableMap.of("tableA", 
specANew);
+
+    // Left has old, right has new: right wins for tableA
+    Map<String, SerializableTableSpec> merged1 = fn.apply(map1, map2);
+    assertEquals(2, merged1.size());
+    assertEquals(2000L, merged1.get("tableA").getLastUpdatedMillis());
+    assertEquals(1500L, merged1.get("tableB").getLastUpdatedMillis());
+
+    // Commutativity: left has new, right has old: left wins for tableA
+    Map<String, SerializableTableSpec> merged2 = fn.apply(map2, map1);
+    assertEquals(2, merged2.size());
+    assertEquals(2000L, merged2.get("tableA").getLastUpdatedMillis());
+    assertEquals(1500L, merged2.get("tableB").getLastUpdatedMillis());
+
+    // Identical results in both merge directions
+    assertEquals(merged1, merged2);
+  }
+
+  @Test
+  public void testMapMergerFnTieBreaksBySchemaIdCommutatively() {
+    TableIdentifier tableId = TableIdentifier.of("default", "tie_break_table");
+    Table realTable = getCatalog().createTable(tableId, ICEBERG_SCHEMA);
+    SerializableTableSpec baseSpec = SerializableTableSpec.fromTable(tableId, 
realTable);
+    SerializableTableSpec specSchema0 =
+        
baseSpec.toBuilder().setLastUpdatedMillis(1000L).setSchemaId(0).build();
+    SerializableTableSpec specSchema1 =
+        
baseSpec.toBuilder().setLastUpdatedMillis(1000L).setSchemaId(1).build();
+
+    TableMetadataDriver.MapMergerFn fn = new TableMetadataDriver.MapMergerFn();
+
+    Map<String, SerializableTableSpec> mapA = ImmutableMap.of("table", 
specSchema0);
+    Map<String, SerializableTableSpec> mapB = ImmutableMap.of("table", 
specSchema1);
+
+    Map<String, SerializableTableSpec> mergedAB = fn.apply(mapA, mapB);
+    Map<String, SerializableTableSpec> mergedBA = fn.apply(mapB, mapA);
+
+    assertEquals(1, mergedAB.get("table").getSchemaId());
+    assertEquals(1, mergedBA.get("table").getSchemaId());
+    assertEquals(mergedAB, mergedBA);
+  }
+
+  static class ControllableTestClock implements TableMetadataDriver.Clock {
+    private static final AtomicLong CURRENT_TIME = new AtomicLong(0L);
+
+    public static void setTime(long millis) {
+      CURRENT_TIME.set(millis);
+    }
+
+    @Override
+    public long currentTimeMillis() {
+      return CURRENT_TIME.get();
+    }
+  }
+
+  @Test
+  public void testUnusedTablesEvictedFromStreamingCache() {
+    TableIdentifier tableIdA = TableIdentifier.of("default", "evict_table_a");
+    TableIdentifier tableIdB = TableIdentifier.of("default", "evict_table_b");
+    getCatalog().createTable(tableIdA, ICEBERG_SCHEMA);
+    getCatalog().createTable(tableIdB, ICEBERG_SCHEMA);
+
+    String tableAStr = IcebergUtils.tableIdentifierToString(tableIdA);
+    String tableBStr = IcebergUtils.tableIdentifierToString(tableIdB);
+
+    Duration refreshInterval = Duration.standardSeconds(5);
+    ControllableTestClock.setTime(1000L);
+    ControllableTestClock testClock = new ControllableTestClock();
+
+    Row rowSeedA = Row.withSchema(BEAM_SCHEMA).addValues(0L, "seed_a", 
tableAStr).build();
+    Row rowSeedB = Row.withSchema(BEAM_SCHEMA).addValues(0L, "seed_b", 
tableBStr).build();
+    Row rowA1 = Row.withSchema(BEAM_SCHEMA).addValues(1L, "a1", 
tableAStr).build();
+    Row rowB1 = Row.withSchema(BEAM_SCHEMA).addValues(2L, "b1", 
tableBStr).build();
+    Row rowTriggerEvictA =
+        Row.withSchema(BEAM_SCHEMA).addValues(3L, "trigger_evict_a", 
tableAStr).build();
+    Row rowA2 = Row.withSchema(BEAM_SCHEMA).addValues(4L, "a2", 
tableAStr).build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(rowSeedA, rowSeedB)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowA1, rowB1)
+            .advanceProcessingTime(Duration.standardSeconds(6))
+            .addElements(rowTriggerEvictA)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowA2)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input =
+        pipeline
+            .apply("StreamInput", stream)
+            .apply(
+                "AdvanceClockOnTriggerRow",
+                ParDo.of(
+                    new DoFn<Row, Row>() {
+                      @ProcessElement
+                      public void processElement(@Element Row row, 
OutputReceiver<Row> out) {
+                        if ("trigger_evict_a".equals(row.getString("data"))) {
+                          ControllableTestClock.setTime(20000L);
+                        }
+                        out.output(row);
+                      }
+                    }))
+            .setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.asView(
+                TableMetadataDriver.builder()
+                    .setCatalogConfig(catalogConfig)
+                    .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                    .setRefreshInterval(refreshInterval)
+                    .build(),
+                testClock));
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        String data = row.getString("data");
+                        if ("seed_a".equals(data)
+                            || "seed_b".equals(data)
+                            || "trigger_evict_a".equals(data)) {
+                          return;
+                        }
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        boolean hasA = viewMap.containsKey(tableAStr);
+                        boolean hasB = viewMap.containsKey(tableBStr);
+                        out.output(data + ":hasA=" + hasA + ",hasB=" + hasB);
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved)
+        .containsInAnyOrder(
+            "a1:hasA=true,hasB=true", "b1:hasA=true,hasB=true", 
"a2:hasA=true,hasB=false");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testBatchAllNonExistentTablesEmitsEmptyMapWithoutBlockingConsumer() {
+    List<Row> rows =
+        ImmutableList.of(
+            Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
"default.missing_1").build(),
+            Row.withSchema(BEAM_SCHEMA).addValues(2L, "v2", 
"default.missing_2").build());
+
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView", TableMetadataDriver.asView(catalogConfig, 
DYNAMIC_DESTINATIONS));
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        out.output("size=" + viewMap.size());
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved).containsInAnyOrder("size=0", "size=0");
+
+    pipeline.run();
+  }
+
+  @Test
+  public void testAsViewWithDriverInstance() {
+    TableIdentifier tableId = TableIdentifier.of("default", 
"as_view_driver_test");
+    getCatalog().createTable(tableId, ICEBERG_SCHEMA);
+    String tableStr = IcebergUtils.tableIdentifierToString(tableId);
+
+    List<Row> rows =
+        ImmutableList.of(Row.withSchema(BEAM_SCHEMA).addValues(1L, "v1", 
tableStr).build());
+    PCollection<Row> input = 
pipeline.apply(Create.of(rows)).setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    TableMetadataDriver driver =
+        TableMetadataDriver.builder()
+            .setCatalogConfig(catalogConfig)
+            .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+            .build();
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply("CreateMetadataView", driver.asView());
+
+    PCollection<Boolean> hasTable =
+        input.apply(
+            "CheckSideInput",
+            ParDo.of(
+                    new DoFn<Row, Boolean>() {
+                      @ProcessElement
+                      public void processElement(OutputReceiver<Boolean> out, 
ProcessContext c) {
+                        
out.output(c.sideInput(metadataView).containsKey(tableStr));
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(hasTable).containsInAnyOrder(true);
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testStreamingMissingTableSignalDoesNotEvictBeforeExpirationCutoff() {
+    TableIdentifier tableId = TableIdentifier.of("default", "dropped_table");
+    getCatalog().createTable(tableId, ICEBERG_SCHEMA);
+    String tableStr = IcebergUtils.tableIdentifierToString(tableId);
+
+    Duration refreshInterval = Duration.standardSeconds(2);
+    ControllableTestClock.setTime(1000L);
+    ControllableTestClock testClock = new ControllableTestClock();
+
+    Row row1 = Row.withSchema(BEAM_SCHEMA).addValues(1L, "initial", 
tableStr).build();
+    Row rowDrop = Row.withSchema(BEAM_SCHEMA).addValues(2L, "trigger_drop", 
tableStr).build();
+    Row rowPostDrop = Row.withSchema(BEAM_SCHEMA).addValues(3L, "post_drop", 
tableStr).build();
+    Row rowTriggerEvict =
+        Row.withSchema(BEAM_SCHEMA).addValues(4L, "trigger_evict", 
tableStr).build();
+    Row rowAfterCutoff =
+        Row.withSchema(BEAM_SCHEMA).addValues(5L, "after_cutoff", 
tableStr).build();
+
+    TestStream<Row> stream =
+        TestStream.create(RowCoder.of(BEAM_SCHEMA))
+            .advanceWatermarkTo(new Instant(0))
+            .addElements(row1)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowDrop)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowPostDrop)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowTriggerEvict)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .addElements(rowAfterCutoff)
+            .advanceProcessingTime(Duration.standardSeconds(3))
+            .advanceWatermarkToInfinity();
+
+    PCollection<Row> input =
+        pipeline
+            .apply("StreamInput", stream)
+            .apply(
+                "ControlClockAndCatalogOnTriggerRows",
+                ParDo.of(
+                    new DoFn<Row, Row>() {
+                      @ProcessElement
+                      public void processElement(@Element Row row, 
OutputReceiver<Row> out) {
+                        String data = row.getString("data");
+                        if ("trigger_drop".equals(data)) {
+                          ControllableTestClock.setTime(2000L);
+                          catalogConfig
+                              .catalog()
+                              .dropTable(
+                                  
IcebergUtils.parseTableIdentifier("default.dropped_table"));
+                        } else if ("trigger_evict".equals(data)) {
+                          ControllableTestClock.setTime(20000L);
+                        }
+                        out.output(row);
+                      }
+                    }))
+            .setCoder(RowCoder.of(BEAM_SCHEMA));
+
+    PCollectionView<Map<String, SerializableTableSpec>> metadataView =
+        input.apply(
+            "CreateMetadataView",
+            TableMetadataDriver.asView(
+                TableMetadataDriver.builder()
+                    .setCatalogConfig(catalogConfig)
+                    .setDynamicDestinations(DYNAMIC_DESTINATIONS)
+                    .setRefreshInterval(refreshInterval)
+                    .build(),
+                testClock));
+
+    PCollection<String> consumerObserved =
+        input.apply(
+            "ConsumeSideInput",
+            ParDo.of(
+                    new DoFn<Row, String>() {
+                      @ProcessElement
+                      public void processElement(
+                          @Element Row row, OutputReceiver<String> out, 
ProcessContext c) {
+                        String data = row.getString("data");
+                        if ("trigger_drop".equals(data) || 
"trigger_evict".equals(data)) {
+                          return;
+                        }
+                        Map<String, SerializableTableSpec> viewMap = 
c.sideInput(metadataView);
+                        out.output(data + ":hasTable=" + 
viewMap.containsKey(tableStr));
+                      }
+                    })
+                .withSideInputs(metadataView));
+
+    PAssert.that(consumerObserved)
+        .containsInAnyOrder(
+            "initial:hasTable=true", "post_drop:hasTable=true", 
"after_cutoff:hasTable=false");
+
+    pipeline.run();
+  }
+}

Reply via email to