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

ahmedabu98 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 f8b0616e7fa [Iceberg CDC] Add watch for snapshots source (#38834)
f8b0616e7fa is described below

commit f8b0616e7fac3921389a8b051b8a33a6db8c5aa4
Author: Ahmed Abualsaud <[email protected]>
AuthorDate: Thu Jul 9 17:06:33 2026 -0400

    [Iceberg CDC] Add watch for snapshots source (#38834)
    
    * add watch for snapshots source
    
    * sync
    
    * add check for table version
    
    * add internal annotation
---
 .../beam/sdk/io/iceberg/IcebergScanConfig.java     |   5 +
 .../org/apache/beam/sdk/io/iceberg/ReadUtils.java  |   5 +-
 .../org/apache/beam/sdk/io/iceberg/TableCache.java |   2 +
 .../sdk/io/iceberg/cdc/WatchForSnapshotsSdf.java   | 298 +++++++++++++++++++++
 .../io/iceberg/cdc/WatchForSnapshotsSdfTest.java   | 265 ++++++++++++++++++
 5 files changed, 573 insertions(+), 2 deletions(-)

diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
index 848d995f941..bdcf652a5df 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/IcebergScanConfig.java
@@ -253,6 +253,9 @@ public abstract class IcebergScanConfig implements 
Serializable {
   @Pure
   public abstract @Nullable List<String> getDropFields();
 
+  @Pure
+  public abstract @Nullable Duration getMaxSnapshotDiscoveryDelay();
+
   @Pure
   public abstract List<String> getMetadataColumns();
 
@@ -342,6 +345,8 @@ public abstract class IcebergScanConfig implements 
Serializable {
 
     public abstract Builder setDropFields(@Nullable List<String> fields);
 
+    public abstract Builder setMaxSnapshotDiscoveryDelay(@Nullable Duration 
delay);
+
     public abstract Builder setMetadataColumns(List<String> metadataColumns);
 
     public abstract IcebergScanConfig build();
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadUtils.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadUtils.java
index ea2dc7589c4..3e70ae12687 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadUtils.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ReadUtils.java
@@ -161,10 +161,11 @@ public class ReadUtils {
     // incremental append scan can only be configured with an *exclusive* 
starting snapshot,
     // so we need to provide this snapshot's parent id.
     if (fromSnapshot != null) {
-      fromSnapshot = table.snapshot(fromSnapshot).parentId();
+      Snapshot snapshot = table.snapshot(fromSnapshot);
+      fromSnapshot = snapshot != null ? snapshot.parentId() : null;
     }
 
-    // 4. if snapshot is still null, the scan will default to the oldest 
snapshot, i.e. EARLIEST
+    // if snapshot is still null, the scan will default to the oldest 
snapshot, i.e. EARLIEST
     return fromSnapshot;
   }
 
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableCache.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableCache.java
index e13f0597752..63523bb995f 100644
--- 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableCache.java
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/TableCache.java
@@ -22,6 +22,7 @@ import java.time.Instant;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
+import org.apache.beam.sdk.annotations.Internal;
 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.cache.Cache;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.cache.CacheBuilder;
@@ -40,6 +41,7 @@ import org.checkerframework.checker.nullness.qual.Nullable;
  * refreshed after a caller started its request, the caller reuses that 
refresh instead of making
  * another catalog call.
  */
+@Internal
 public class TableCache {
   static final Duration DEFAULT_REFRESH_INTERVAL = Duration.ofMinutes(2);
 
diff --git 
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdf.java
 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdf.java
new file mode 100644
index 00000000000..473fea7bfa8
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdf.java
@@ -0,0 +1,298 @@
+/*
+ * 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.cdc;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.ReadUtils;
+import org.apache.beam.sdk.io.iceberg.SnapshotInfo;
+import org.apache.beam.sdk.io.iceberg.TableCache;
+import org.apache.beam.sdk.io.range.OffsetRange;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Gauge;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.transforms.DoFn;
+import 
org.apache.beam.sdk.transforms.splittabledofn.GrowableOffsetRangeTracker;
+import org.apache.beam.sdk.transforms.splittabledofn.ManualWatermarkEstimator;
+import org.apache.beam.sdk.transforms.splittabledofn.OffsetRangeTracker;
+import org.apache.beam.sdk.transforms.splittabledofn.RestrictionTracker;
+import 
org.apache.beam.sdk.transforms.splittabledofn.WatermarkEstimators.Manual;
+import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.util.Preconditions;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.MoreObjects;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
+import org.apache.iceberg.util.SnapshotUtil;
+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;
+
+/**
+ * SplittableDoFn that watches an Iceberg table for new {@link Snapshot}s and 
emits them one at a
+ * time, advancing the watermark per snapshot. Each snapshot is later 
processed by {@link
+ * ChangelogScanner}.
+ *
+ * <p>The restriction tracks Snapshots via their <b>sequence numbers</b>, 
which are monotonic,
+ * unlike snapshot IDs. The initial range starts at the sequence number of the 
user-configured
+ * starting snapshot (or one if none configured) and runs to {@link 
Long#MAX_VALUE}. Each call to
+ * {@code @ProcessElement} claims the sequence numbers of newly discovered 
snapshots in
+ * chronological order.
+ *
+ * <p>Uses a {@link Manual} watermark estimator. After emitting a snapshot, 
the watermark is set to
+ * that snapshot's commit time. On empty polls, the watermark is bumped to 
{@code now() -
+ * MAX_SNAPSHOT_DISCOVERY_DELAY} to prevent downstream windows from stalling 
indefinitely during
+ * quiet periods.
+ */
[email protected]
+class WatchForSnapshotsSdf extends DoFn<String, Long> {
+  private static final Logger LOG = 
LoggerFactory.getLogger(WatchForSnapshotsSdf.class);
+  private static final Duration DEFAULT_POLL_INTERVAL = 
Duration.standardSeconds(60);
+  private static final long DEFAULT_STARTING_SEQ = 1L;
+
+  private static final Counter snapshotsEmitted =
+      Metrics.counter(WatchForSnapshotsSdf.class, "snapshotsEmitted");
+  private static final Gauge latestEmittedSnapshotId =
+      Metrics.gauge(WatchForSnapshotsSdf.class, "latestEmittedSnapshotId");
+  // TODO(ahmedabu98): consider exposing this as a config option
+  private static final Duration MAX_SNAPSHOT_DISCOVERY_DELAY = 
Duration.standardMinutes(5);
+  private static final Long POLL_FOREVER = Long.MAX_VALUE;
+
+  private final IcebergScanConfig scanConfig;
+  private final Duration pollInterval;
+
+  WatchForSnapshotsSdf(IcebergScanConfig scanConfig) {
+    this.scanConfig = scanConfig;
+    this.pollInterval =
+        MoreObjects.firstNonNull(scanConfig.getPollInterval(), 
DEFAULT_POLL_INTERVAL);
+  }
+
+  @GetInitialRestriction
+  public OffsetRange initialRestriction() {
+    Table table =
+        TableCache.getRefreshed(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier());
+    checkArgument(
+        TableUtil.formatVersion(table) >= 2,
+        "Reading CDC records from an Iceberg table requires a table version > 
2, "
+            + "but table %s has version %s",
+        scanConfig.getTableIdentifier(),
+        TableUtil.formatVersion(table));
+
+    long toSnapshotExclusiveSeq = POLL_FOREVER;
+    @Nullable Long toSnapshotId = ReadUtils.getToSnapshot(table, scanConfig);
+    if (toSnapshotId != null) {
+      toSnapshotExclusiveSeq =
+          Preconditions.checkStateNotNull(
+                      table.snapshot(toSnapshotId),
+                      "Configured end snapshot %s does not exist",
+                      toSnapshotId)
+                  .sequenceNumber()
+              + 1;
+    }
+
+    @Nullable Long fromSnapshotInclusiveId = 
ReadUtils.getFromSnapshotInclusive(table, scanConfig);
+    long fromSnapshotInclusiveSeq;
+    if (fromSnapshotInclusiveId == null) {
+      fromSnapshotInclusiveSeq = DEFAULT_STARTING_SEQ; // sequence numbers 
start at 1
+    } else {
+      Snapshot fromSnapshotInclusive =
+          Preconditions.checkArgumentNotNull(
+              table.snapshot(fromSnapshotInclusiveId),
+              "The specified starting snapshot %s does not exist",
+              fromSnapshotInclusiveId);
+      fromSnapshotInclusiveSeq = fromSnapshotInclusive.sequenceNumber();
+
+      boolean sameLineage =
+          toSnapshotId == null
+              ? SnapshotUtil.isAncestorOf(table, fromSnapshotInclusiveId)
+              : SnapshotUtil.isAncestorOf(table, toSnapshotId, 
fromSnapshotInclusiveId);
+      checkArgument(
+          sameLineage,
+          "Configured starting snapshot %s is not an ancestor of %s",
+          fromSnapshotInclusiveId,
+          toSnapshotId == null ? "the current table" : "end snapshot " + 
toSnapshotId);
+    }
+
+    return new OffsetRange(
+        fromSnapshotInclusiveSeq, Math.max(fromSnapshotInclusiveSeq, 
toSnapshotExclusiveSeq));
+  }
+
+  @NewTracker
+  public RestrictionTracker<OffsetRange, Long> newTracker(@Restriction 
OffsetRange restriction) {
+    if (restriction.getTo() == POLL_FOREVER) {
+      return new GrowableOffsetRangeTracker(
+          restriction.getFrom(), this::estimateCurrentRangeEndExclusive);
+    }
+
+    return new OffsetRangeTracker(restriction);
+  }
+
+  private long estimateCurrentRangeEndExclusive() {
+    Table table =
+        TableCache.getAndRefreshIfStale(
+            scanConfig.getCatalogConfig(), scanConfig.getTableIdentifier());
+
+    @Nullable Long toSnapshotId = ReadUtils.getToSnapshot(table, scanConfig);
+    if (toSnapshotId != null) {
+      @Nullable Snapshot toSnapshot = table.snapshot(toSnapshotId);
+      return toSnapshot == null ? DEFAULT_STARTING_SEQ : 
toSnapshot.sequenceNumber() + 1;
+    }
+
+    @Nullable Snapshot current = table.currentSnapshot();
+    return current == null ? DEFAULT_STARTING_SEQ : current.sequenceNumber() + 
1;
+  }
+
+  @GetRestrictionCoder
+  public Coder<OffsetRange> restrictionCoder() {
+    return new OffsetRange.Coder();
+  }
+
+  @GetInitialWatermarkEstimatorState
+  public Instant initialWatermarkState() {
+    return BoundedWindow.TIMESTAMP_MIN_VALUE;
+  }
+
+  @NewWatermarkEstimator
+  public ManualWatermarkEstimator<Instant> newWatermarkEstimator(
+      @WatermarkEstimatorState Instant state) {
+    return new Manual(state);
+  }
+
+  @ProcessElement
+  public ProcessContinuation process(
+      RestrictionTracker<OffsetRange, Long> tracker,
+      ManualWatermarkEstimator<Instant> watermark,
+      OutputReceiver<Long> out) {
+    Table table =
+        TableCache.getRefreshed(scanConfig.getCatalogConfig(), 
scanConfig.getTableIdentifier());
+
+    @Nullable Long userToSnapshotId = ReadUtils.getToSnapshot(table, 
scanConfig);
+    boolean bounded = userToSnapshotId != null;
+
+    @Nullable Snapshot current = table.currentSnapshot();
+    if (current == null) {
+      // no snapshots yet.
+      LOG.info("Skipping scan: table is empty with no snapshots yet");
+      return pauseOrStop(watermark, bounded);
+    }
+
+    // Resolve the upper bound: user-specified bounded mode, or "current" for 
unbounded.
+    long toSnapshotId;
+    long toSnapshotSeq;
+    if (userToSnapshotId != null) {
+      toSnapshotId = userToSnapshotId;
+      toSnapshotSeq =
+          Preconditions.checkStateNotNull(
+                  table.snapshot(userToSnapshotId),
+                  "Configured toSnapshotId %s does not exist",
+                  userToSnapshotId)
+              .sequenceNumber();
+    } else {
+      toSnapshotId = current.snapshotId();
+      toSnapshotSeq = current.sequenceNumber();
+    }
+
+    long nextSeqInclusive = tracker.currentRestriction().getFrom();
+    if (toSnapshotSeq < nextSeqInclusive) {
+      // Nothing new since last poll.
+      LOG.info("Skipping scan: nothing new since last poll.");
+      return pauseOrStop(watermark, bounded);
+    }
+
+    // Collect snapshots in [nextSeqInclusive, toSnapshotSeq] chronologically
+    String tableId = scanConfig.getTableIdentifier();
+    List<SnapshotInfo> fresh = snapshotsAfter(table, tableId, 
nextSeqInclusive, toSnapshotId);
+    LOG.info("Collected snapshots: {}", fresh);
+
+    for (SnapshotInfo snap : fresh) {
+      if (!tracker.tryClaim(snap.getSequenceNumber())) {
+        return ProcessContinuation.stop();
+      }
+      Instant ts = Instant.ofEpochMilli(snap.getTimestampMillis());
+      out.outputWithTimestamp(snap.getSnapshotId(), ts);
+
+      if (watermark.currentWatermark().isBefore(ts)) {
+        watermark.setWatermark(ts);
+      }
+      snapshotsEmitted.inc();
+      latestEmittedSnapshotId.set(snap.getSnapshotId());
+      LOG.info(
+          "Emitted snapshot {} (sequence id: {}, commit ts: {})",
+          snap.getSnapshotId(),
+          snap.getSequenceNumber(),
+          ts);
+    }
+
+    return pauseOrStop(watermark, bounded);
+  }
+
+  /**
+   * On an empty poll, bump the watermark to {@code now() - 
MAX_SNAPSHOT_DISCOVERY_DELAY} so
+   * downstream windows can still fire. Returns {@code stop()} when end 
snapshot has been reached,
+   * otherwise {@code resume()} after the poll interval.
+   */
+  private ProcessContinuation pauseOrStop(
+      ManualWatermarkEstimator<Instant> watermark, boolean bounded) {
+    Duration delay =
+        MoreObjects.firstNonNull(
+            scanConfig.getMaxSnapshotDiscoveryDelay(), 
MAX_SNAPSHOT_DISCOVERY_DELAY);
+    Instant idleWatermark = Instant.now().minus(delay);
+    if (watermark.currentWatermark().isBefore(idleWatermark)) {
+      LOG.info(
+          "Sitting idle for {} seconds. Bumping watermark to {}",
+          TimeUnit.MILLISECONDS.toSeconds(
+              Instant.now().getMillis() - 
watermark.currentWatermark().getMillis()),
+          idleWatermark);
+      watermark.setWatermark(idleWatermark);
+    }
+    return bounded
+        ? ProcessContinuation.stop()
+        : ProcessContinuation.resume().withResumeDelay(pollInterval);
+  }
+
+  /**
+   * Returns snapshots with sequence number in {@code [nextSeqInclusive, 
toSnapshotSeq]}, keyed off
+   * the lineage ending at {@code toSnapshotId}.
+   */
+  @SuppressWarnings("return") // ancestorsOf accepts null returns as a "stop" 
signal
+  static List<SnapshotInfo> snapshotsAfter(
+      Table table, String tableIdentifier, long nextSeqInclusive, long 
toSnapshotId) {
+
+    List<SnapshotInfo> snapshots = new ArrayList<>();
+    // ancestorsOf returns an iterable of snapshots looking backwards.
+    // we'll need to reverse it to process snapshots chronologically.
+    for (Snapshot snapshot :
+        SnapshotUtil.ancestorsOf(
+            toSnapshotId, snapshotId -> snapshotId != null ? 
table.snapshot(snapshotId) : null)) {
+      if (snapshot.sequenceNumber() < nextSeqInclusive) {
+        break;
+      }
+      snapshots.add(SnapshotInfo.fromSnapshot(snapshot, tableIdentifier));
+    }
+    Collections.reverse(snapshots);
+    return snapshots;
+  }
+}
diff --git 
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdfTest.java
 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdfTest.java
new file mode 100644
index 00000000000..c62a9d6fb4e
--- /dev/null
+++ 
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/cdc/WatchForSnapshotsSdfTest.java
@@ -0,0 +1,265 @@
+/*
+ * 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.cdc;
+
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.apache.iceberg.types.Types.NestedField.required;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.lessThanOrEqualTo;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.beam.sdk.io.iceberg.IcebergCatalogConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergIO.ReadRows.StartingStrategy;
+import org.apache.beam.sdk.io.iceberg.IcebergScanConfig;
+import org.apache.beam.sdk.io.iceberg.IcebergUtils;
+import org.apache.beam.sdk.io.iceberg.TestDataWarehouse;
+import org.apache.beam.sdk.io.iceberg.TestFixtures;
+import org.apache.beam.sdk.io.range.OffsetRange;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.splittabledofn.ManualWatermarkEstimator;
+import org.apache.beam.sdk.values.OutputBuilder;
+import org.apache.beam.sdk.values.TimestampedValue;
+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.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.types.Types;
+import org.joda.time.Duration;
+import org.joda.time.Instant;
+import org.junit.ClassRule;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestName;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link WatchForSnapshotsSdf}. */
+@RunWith(JUnit4.class)
+public class WatchForSnapshotsSdfTest {
+  private static final Schema CDC_SCHEMA =
+      new Schema(
+          ImmutableList.of(
+              required(1, "id", Types.LongType.get()), optional(2, "data", 
Types.StringType.get())),
+          ImmutableSet.of(1));
+
+  @ClassRule public static final TemporaryFolder TEMPORARY_FOLDER = new 
TemporaryFolder();
+
+  @Rule public TestDataWarehouse warehouse = new 
TestDataWarehouse(TEMPORARY_FOLDER, "default");
+  @Rule public TestName testName = new TestName();
+
+  @Test
+  public void earliestStreamingRestrictionEmitsSnapshotsInSequenceOrder() 
throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    commitAppend(table, "s1.parquet", records("one", 1L));
+    commitAppend(table, "s2.parquet", records("two", 2L));
+    commitAppend(table, "s3.parquet", records("three", 3L));
+    List<Snapshot> snapshots = Lists.newArrayList(table.snapshots());
+
+    WatchForSnapshotsSdf sdf =
+        new WatchForSnapshotsSdf(
+            scanConfigBuilder(table, tableId)
+                .setStreaming(true)
+                .setStartingStrategy(StartingStrategy.EARLIEST)
+                .setPollInterval(Duration.millis(1L))
+                .build());
+
+    OffsetRange restriction = sdf.initialRestriction();
+    assertEquals(1L, restriction.getFrom());
+    assertEquals(Long.MAX_VALUE, restriction.getTo());
+
+    CapturingOutputReceiver out = new CapturingOutputReceiver();
+    ManualWatermarkEstimator<Instant> watermark =
+        sdf.newWatermarkEstimator(sdf.initialWatermarkState());
+    DoFn.ProcessContinuation continuation =
+        sdf.process(sdf.newTracker(restriction), watermark, out);
+
+    Long[] expectedSnapshotIds = 
snapshots.stream().map(Snapshot::snapshotId).toArray(Long[]::new);
+    List<Long> actualSnapshotIds =
+        
out.values.stream().map(TimestampedValue::getValue).collect(Collectors.toList());
+    assertTrue(continuation.shouldResume());
+    assertEquals(Duration.millis(1L), continuation.resumeDelay());
+    assertThat(actualSnapshotIds, contains(expectedSnapshotIds));
+    assertEquals(
+        Instant.ofEpochMilli(snapshots.get(snapshots.size() - 
1).timestampMillis()),
+        watermark.currentWatermark());
+  }
+
+  @Test
+  public void 
boundedSnapshotRangeUsesInclusiveLowerAndExclusiveUpperSequence() throws 
Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    commitAppend(table, "s1.parquet", records("one", 1L));
+    commitAppend(table, "s2.parquet", records("two", 2L));
+    commitAppend(table, "s3.parquet", records("three", 3L));
+    commitAppend(table, "s4.parquet", records("four", 4L));
+    List<Snapshot> snapshots = Lists.newArrayList(table.snapshots());
+    Snapshot second = snapshots.get(1);
+    Snapshot third = snapshots.get(2);
+
+    WatchForSnapshotsSdf sdf =
+        new WatchForSnapshotsSdf(
+            scanConfigBuilder(table, tableId)
+                .setStreaming(true)
+                .setFromSnapshotInclusive(second.snapshotId())
+                .setToSnapshot(third.snapshotId()) // this is inclusive
+                .setPollInterval(Duration.standardSeconds(30L))
+                .build());
+
+    OffsetRange restriction = sdf.initialRestriction();
+    assertEquals(second.sequenceNumber(), restriction.getFrom());
+    assertEquals(third.sequenceNumber() + 1, restriction.getTo());
+
+    CapturingOutputReceiver out = new CapturingOutputReceiver();
+    DoFn.ProcessContinuation continuation =
+        sdf.process(
+            sdf.newTracker(restriction),
+            sdf.newWatermarkEstimator(sdf.initialWatermarkState()),
+            out);
+
+    List<Long> outputSnapshotIds =
+        
out.values.stream().map(TimestampedValue::getValue).collect(Collectors.toList());
+
+    assertFalse(continuation.shouldResume());
+    assertThat(outputSnapshotIds, contains(second.snapshotId(), 
third.snapshotId()));
+    assertEquals(2, out.values.size());
+    assertEquals(Instant.ofEpochMilli(second.timestampMillis()), 
out.values.get(0).getTimestamp());
+    assertEquals(Instant.ofEpochMilli(third.timestampMillis()), 
out.values.get(1).getTimestamp());
+  }
+
+  @Test
+  public void streamingDefaultStartsAtLatestSnapshotAndEarliestStartsAtFirst() 
throws Exception {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    commitAppend(table, "s1.parquet", records("one", 1L));
+    commitAppend(table, "s2.parquet", records("two", 2L));
+    Snapshot latest = table.currentSnapshot();
+
+    WatchForSnapshotsSdf defaultSdf =
+        new WatchForSnapshotsSdf(
+            scanConfigBuilder(table, tableId)
+                .setStreaming(true)
+                .setPollInterval(Duration.standardSeconds(1L))
+                .build());
+    WatchForSnapshotsSdf earliestSdf =
+        new WatchForSnapshotsSdf(
+            scanConfigBuilder(table, tableId)
+                .setStreaming(true)
+                .setStartingStrategy(StartingStrategy.EARLIEST)
+                .setPollInterval(Duration.standardSeconds(1L))
+                .build());
+
+    assertEquals(latest.sequenceNumber(), 
defaultSdf.initialRestriction().getFrom());
+    assertEquals(1L, earliestSdf.initialRestriction().getFrom());
+  }
+
+  @Test
+  public void emptyTableReturnsResumeAndAdvancesIdleWatermark() {
+    TableIdentifier tableId = tableId();
+    Table table = warehouse.createTable(tableId, CDC_SCHEMA, null, 
tableProperties());
+    WatchForSnapshotsSdf sdf =
+        new WatchForSnapshotsSdf(
+            scanConfigBuilder(table, tableId)
+                .setStreaming(true)
+                .setMaxSnapshotDiscoveryDelay(Duration.ZERO)
+                .setPollInterval(Duration.millis(25L))
+                .build());
+    ManualWatermarkEstimator<Instant> watermark =
+        sdf.newWatermarkEstimator(sdf.initialWatermarkState());
+    Instant beforeProcess = Instant.now();
+    CapturingOutputReceiver out = new CapturingOutputReceiver();
+
+    DoFn.ProcessContinuation continuation =
+        sdf.process(sdf.newTracker(sdf.initialRestriction()), watermark, out);
+
+    assertTrue(continuation.shouldResume());
+    assertEquals(Duration.millis(25L), continuation.resumeDelay());
+    assertThat(out.values, empty());
+    assertThat(watermark.currentWatermark(), 
greaterThan(beforeProcess.minus(Duration.millis(1L))));
+    assertThat(watermark.currentWatermark(), lessThanOrEqualTo(Instant.now()));
+  }
+
+  private TableIdentifier tableId() {
+    return TableIdentifier.of("default", testName.getMethodName());
+  }
+
+  private IcebergScanConfig.Builder scanConfigBuilder(Table table, 
TableIdentifier tableId) {
+    return IcebergScanConfig.builder()
+        .setCatalogConfig(
+            IcebergCatalogConfig.builder()
+                .setCatalogName("name")
+                .setCatalogProperties(
+                    ImmutableMap.of("type", "hadoop", "warehouse", 
warehouse.location))
+                .build())
+        .setTableIdentifier(tableId)
+        .setSchema(IcebergUtils.icebergSchemaToBeamSchema(table.schema()))
+        .setUseCdc(true);
+  }
+
+  private static Map<String, String> tableProperties() {
+    return ImmutableMap.of(TableProperties.FORMAT_VERSION, "2");
+  }
+
+  private void commitAppend(Table table, String fileName, List<Record> 
records) throws IOException {
+    DataFile file =
+        warehouse.writeRecords(testName.getMethodName() + "-" + fileName, 
table.schema(), records);
+    table.newFastAppend().appendFile(file).commit();
+    table.refresh();
+  }
+
+  private static List<Record> records(String data, long... ids) {
+    ImmutableList.Builder<Record> records = ImmutableList.builder();
+    for (long id : ids) {
+      records.add(TestFixtures.createRecord(CDC_SCHEMA, ImmutableMap.of("id", 
id, "data", data)));
+    }
+    return records.build();
+  }
+
+  private static final class CapturingOutputReceiver implements 
DoFn.OutputReceiver<Long> {
+    private final ImmutableList.Builder<TimestampedValue<Long>> builder = 
ImmutableList.builder();
+    private List<TimestampedValue<Long>> values = ImmutableList.of();
+
+    @Override
+    public OutputBuilder<Long> builder(Long value) {
+      throw new UnsupportedOperationException("Use outputWithTimestamp in this 
test receiver.");
+    }
+
+    @Override
+    public void outputWithTimestamp(Long value, Instant timestamp) {
+      builder.add(TimestampedValue.of(value, timestamp));
+      values = builder.build();
+    }
+  }
+}

Reply via email to