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

Abacn 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 167b8649fc9 [Java] Bound the Watch deduplication state with a 
timestamp cursor (#39746)
167b8649fc9 is described below

commit 167b8649fc9251dbb3bfeb9884fb4f2f8a8a3561
Author: Elia Liu <[email protected]>
AuthorDate: Thu Aug 27 01:55:46 2026 +1000

    [Java] Bound the Watch deduplication state with a timestamp cursor (#39746)
    
    * [Java] Bound the Watch deduplication state with a timestamp cursor
    
    Watch remembers the key of every output it has emitted, so the restriction 
of
    an input that is watched indefinitely grows without bound.
    
    withTimestampCursor retires a key once the greatest timestamp emitted for 
that
    input has moved more than the allowed lateness past it, so the completed set
    holds a trailing window. Deduplication still goes by output key. An output
    whose timestamp is below that mark is taken as already seen and is dropped,
    which suits a poll function whose outputs arrive in roughly non-decreasing
    timestamp order.
    
    * Cover Java and Python in the Watch timestamp-cursor CHANGES entry
---
 CHANGES.md                                         |   1 +
 .../java/org/apache/beam/sdk/transforms/Watch.java | 239 ++++++++++++--
 .../org/apache/beam/sdk/transforms/WatchTest.java  | 348 ++++++++++++++++++++-
 3 files changed, 565 insertions(+), 23 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index 44e8533996b..151cbe14aaa 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -70,6 +70,7 @@
 ## New Features / Improvements
 
 * X feature added (Java/Python) 
([#X](https://github.com/apache/beam/issues/X)).
+* (Java/Python) `Watch` can bound its deduplication state by event time, 
retiring an output key once the greatest emitted timestamp has moved more than 
the allowed lateness past it. Java adds 
`Watch.growthOf(...).withTimestampCursor()`. Python adds `allowed_lateness` for 
the existing `timestamp_cursor` option 
([#18459](https://github.com/apache/beam/issues/18459)).
 * (Java) Spark Structured Streaming runner: stateful ParDo with state, timers, 
`@RequiresTimeSortedInput` and tagged outputs is now supported in batch mode 
([#39779](https://github.com/apache/beam/issues/39779)).
 * (Python) Added support for Vertex AI Model Monitoring V2 in RunInference 
([#39738](https://github.com/apache/beam/issues/39738)).
 
diff --git 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Watch.java 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Watch.java
index 793fac048df..736839a2ed3 100644
--- a/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Watch.java
+++ b/sdks/java/core/src/main/java/org/apache/beam/sdk/transforms/Watch.java
@@ -120,6 +120,11 @@ import org.slf4j.LoggerFactory;
  * Growth.PollResult#withWatermark} if the {@link Growth.PollFn} can provide a 
more optimistic
  * estimate.
  *
+ * <p>By default the transform remembers the key of every output it has 
emitted, so the state of an
+ * input that is watched indefinitely grows without bound. {@link 
Growth#withTimestampCursor} bounds
+ * that state by event time, for a {@link Growth.PollFn} whose outputs arrive 
in roughly
+ * non-decreasing timestamp order.
+ *
  * <p>Note: This transform works only in runners supporting Splittable DoFn: 
see <a
  * 
href="https://beam.apache.org/documentation/runners/capability-matrix/";>capability
 matrix</a>.
  */
@@ -677,6 +682,8 @@ public class Watch {
 
     abstract @Nullable Coder<OutputT> getOutputCoder();
 
+    abstract @Nullable Duration getTimestampCursorAllowedLateness();
+
     abstract Builder<InputT, OutputT, KeyT> toBuilder();
 
     @AutoValue.Builder
@@ -695,6 +702,9 @@ public class Watch {
 
       abstract Builder<InputT, OutputT, KeyT> setOutputCoder(Coder<OutputT> 
outputCoder);
 
+      abstract Builder<InputT, OutputT, KeyT> 
setTimestampCursorAllowedLateness(
+          Duration allowedLateness);
+
       abstract Growth<InputT, OutputT, KeyT> build();
     }
 
@@ -728,6 +738,38 @@ public class Watch {
       return toBuilder().setOutputCoder(outputCoder).build();
     }
 
+    /** Like {@link #withTimestampCursor(Duration)} with no lateness allowed. 
*/
+    public Growth<InputT, OutputT, KeyT> withTimestampCursor() {
+      return withTimestampCursor(Duration.ZERO);
+    }
+
+    /**
+     * Bounds the deduplication state by event time.
+     *
+     * <p>Deduplication still goes by output key, but a key is retired once 
the greatest timestamp
+     * emitted for this input has moved more than {@code allowedLateness} past 
it, so the state
+     * holds a trailing window rather than every key ever seen.
+     *
+     * <p>An output whose timestamp is below that mark is taken as already 
seen and is dropped, so
+     * this suits a {@link PollFn} whose outputs arrive in roughly 
non-decreasing timestamp order.
+     * Widen {@code allowedLateness} for a source that reports outputs further 
out of order, at the
+     * cost of a larger state.
+     *
+     * <p>Retiring a key costs the guarantee that an output is emitted once 
for all time. An output
+     * that a {@link PollFn} reports again at or above the current floor after 
its key was retired
+     * looks new and is emitted a second time. Updating a running pipeline to 
widen {@code
+     * allowedLateness}, or to drop the cursor altogether, lowers the floor 
over keys that are
+     * already gone and can emit them again for the same reason.
+     */
+    public Growth<InputT, OutputT, KeyT> withTimestampCursor(Duration 
allowedLateness) {
+      checkArgument(allowedLateness != null, "allowedLateness can not be 
null");
+      checkArgument(
+          !allowedLateness.isShorterThan(Duration.ZERO),
+          "allowedLateness must not be negative, but was %s",
+          allowedLateness);
+      return 
toBuilder().setTimestampCursorAllowedLateness(allowedLateness).build();
+    }
+
     @Override
     public PCollection<KV<InputT, OutputT>> expand(PCollection<InputT> input) {
       checkNotNull(getPollInterval(), "pollInterval");
@@ -899,20 +941,34 @@ public class Watch {
         return stop();
       }
 
+      PollingGrowthState<TerminationStateT> pollingRestriction =
+          (PollingGrowthState<TerminationStateT>) currentRestriction;
+
+      @Nullable Duration allowedLateness = 
spec.getTimestampCursorAllowedLateness();
+      @Nullable Instant cursor = pollingRestriction.getCursor();
+      if (retentionFloorAtMaxTimestamp(cursor, allowedLateness)) {
+        // Nothing can be claimed above the floor, so claim an empty round and 
stop.
+        LOG.info("{} - will not poll, retention floor is already at max 
timestamp.", c.element());
+        tracker.tryClaim(
+            KV.of(
+                PollResult.<OutputT>incomplete(Collections.emptyList()),
+                pollingRestriction.getTerminationState()));
+        return stop();
+      }
+
       // Poll for additional elements.
       Instant now = Instant.now();
       Growth.PollResult<OutputT> res =
           spec.getPollFn().getClosure().apply(c.element(), 
wrapProcessContext(c));
 
-      PollingGrowthState<TerminationStateT> pollingRestriction =
-          (PollingGrowthState<TerminationStateT>) currentRestriction;
       // Produce a poll result that only contains never seen before results in 
timestamp
       // sorted order.
       Growth.PollResult<OutputT> newResults =
           computeNeverSeenBeforeResults(pollingRestriction, res);
 
       // If we had zero new results, attempt to update the watermark if the 
poll result
-      // provided a watermark. Otherwise attempt to claim all pending outputs.
+      // provided a watermark or the retention floor bounds future outputs. 
Otherwise attempt
+      // to claim all pending outputs.
       LOG.info(
           "{} - current round of polling took {} ms and returned {} results, "
               + "of which {} were new.",
@@ -944,6 +1000,26 @@ public class Watch {
         // computeNeverSeenBeforeResults returns the elements in timestamp 
sorted order so
         // we can get the timestamp from the first element.
         computedWatermark = newResults.getOutputs().get(0).getTimestamp();
+      } else if (allowedLateness != null && cursor != null) {
+        // Nothing below the retention floor is ever emitted, so a round with 
no new results and
+        // no explicit watermark can still advance the watermark to the floor.
+        computedWatermark = retentionFloor(cursor, allowedLateness);
+      }
+
+      if (allowedLateness != null && !newResults.getOutputs().isEmpty()) {
+        // The cursor only ever advances, and lands on the greatest timestamp 
emitted so far. Once
+        // it carries the retention floor to the maximum timestamp, every 
later output falls below
+        // the floor and would be dropped, so polling stops.
+        Instant newCursor =
+            Ordering.natural()
+                .nullsFirst()
+                .max(
+                    cursor,
+                    newResults.getOutputs().get(newResults.getOutputs().size() 
- 1).getTimestamp());
+        if (retentionFloorAtMaxTimestamp(newCursor, allowedLateness)) {
+          LOG.info("{} - will stop polling, retention floor reached max 
timestamp.", c.element());
+          return stop();
+        }
       }
 
       Instant currentTime = Instant.now();
@@ -979,8 +1055,14 @@ public class Watch {
       // Collect results to include as newly pending. Note that the poll 
result may in theory
       // contain multiple outputs mapping to the same output key - we need to 
ignore duplicates
       // here already.
+      Instant retentionFloor = retentionFloor(state, 
spec.getTimestampCursorAllowedLateness());
       Map<HashCode, TimestampedValue<OutputT>> newPending = Maps.newHashMap();
       for (TimestampedValue<OutputT> output : pollResult.getOutputs()) {
+        if (retentionFloor != null && 
output.getTimestamp().isBefore(retentionFloor)) {
+          // The key that would prove this output already seen has been 
retired, so treat the
+          // output as seen.
+          continue;
+        }
         OutputT value = output.getValue();
         HashCode hash = hash128(value);
         if (state.getCompleted().containsKey(hash) || 
newPending.containsKey(hash)) {
@@ -989,8 +1071,8 @@ public class Watch {
         // TODO (https://github.com/apache/beam/issues/18459):
         // Consider adding only at most N pending elements and ignoring others,
         // instead relying on future poll rounds to provide them, in order to 
avoid
-        // blowing up the state. Combined with garbage collection of 
PollingGrowthState.completed,
-        // this would make the transform scalable to very large poll results.
+        // blowing up the state. Combined with a timestamp cursor, this would 
make the transform
+        // scalable to very large poll results.
         newPending.put(hash, output);
       }
 
@@ -1012,7 +1094,8 @@ public class Watch {
     @NewTracker
     public GrowthTracker<OutputT, TerminationStateT> newTracker(
         @Restriction GrowthState restriction) {
-      return new GrowthTracker<>(restriction, coderFunnel);
+      return new GrowthTracker<>(
+          restriction, coderFunnel, spec.getTimestampCursorAllowedLateness());
     }
 
     @GetRestrictionCoder
@@ -1026,6 +1109,43 @@ public class Watch {
   /** A base class for all restrictions related to the {@link Growth} 
SplittableDoFn. */
   abstract static class GrowthState {}
 
+  /**
+   * The timestamp below which a key is retired from {@link 
PollingGrowthState#getCompleted}, or
+   * null when every key is retained.
+   *
+   * <p>An output at or above the floor is still deduplicated by key; one 
below it is taken as
+   * already seen.
+   */
+  private static @Nullable Instant retentionFloor(
+      PollingGrowthState<?> state, @Nullable Duration allowedLateness) {
+    if (allowedLateness == null || state.getCursor() == null) {
+      return null;
+    }
+    return retentionFloor(state.getCursor(), allowedLateness);
+  }
+
+  /** The retention floor for a cursor, saturated at the minimum timestamp. */
+  private static Instant retentionFloor(Instant cursor, Duration 
allowedLateness) {
+    long floorMillis;
+    try {
+      floorMillis = Math.subtractExact(cursor.getMillis(), 
allowedLateness.getMillis());
+    } catch (ArithmeticException e) {
+      floorMillis = BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis();
+    }
+    return new Instant(Math.max(floorMillis, 
BoundedWindow.TIMESTAMP_MIN_VALUE.getMillis()));
+  }
+
+  /**
+   * Whether the retention floor has reached the maximum timestamp, after 
which no further output
+   * can be claimed and polling on would only drop outputs.
+   */
+  private static boolean retentionFloorAtMaxTimestamp(
+      @Nullable Instant cursor, @Nullable Duration allowedLateness) {
+    return cursor != null
+        && allowedLateness != null
+        && !retentionFloor(cursor, 
allowedLateness).isBefore(BoundedWindow.TIMESTAMP_MAX_VALUE);
+  }
+
   /**
    * Stores the prior pending poll results related to the {@link Growth} 
SplittableDoFn. Used to
    * represent the primary restriction during checkpoint which should be 
replayed if the primary
@@ -1055,27 +1175,40 @@ public class Watch {
   abstract static class PollingGrowthState<TerminationStateT> extends 
GrowthState {
     public static <TerminationStateT> PollingGrowthState<TerminationStateT> of(
         TerminationStateT terminationState) {
-      return new AutoValue_Watch_PollingGrowthState<>(ImmutableMap.of(), null, 
terminationState);
+      return new AutoValue_Watch_PollingGrowthState<>(
+          ImmutableMap.of(), null, terminationState, null);
     }
 
     public static <TerminationStateT> PollingGrowthState<TerminationStateT> of(
         ImmutableMap<HashCode, Instant> completed,
         Instant pollWatermark,
         TerminationStateT terminationState) {
-      return new AutoValue_Watch_PollingGrowthState<>(completed, 
pollWatermark, terminationState);
+      return of(completed, pollWatermark, terminationState, null);
+    }
+
+    public static <TerminationStateT> PollingGrowthState<TerminationStateT> of(
+        ImmutableMap<HashCode, Instant> completed,
+        @Nullable Instant pollWatermark,
+        TerminationStateT terminationState,
+        @Nullable Instant cursor) {
+      return new AutoValue_Watch_PollingGrowthState<>(
+          completed, pollWatermark, terminationState, cursor);
     }
 
     // Hashes and timestamps of outputs that have already been output and 
should be omitted
-    // from future polls. Timestamps are preserved to allow garbage-collecting 
this state
-    // in the future, e.g. dropping elements from "completed" and from
-    // computeNeverSeenBeforeResults() if their timestamp is more than X 
behind the watermark.
-    // As of writing, we don't do this, but preserve the information for 
forward compatibility
-    // in case of pipeline update. TODO: do this.
+    // from future polls. Under a timestamp cursor the entries the cursor has 
moved past are
+    // dropped, which bounds this map; otherwise every key ever seen is kept.
     public abstract ImmutableMap<HashCode, Instant> getCompleted();
 
     public abstract @Nullable Instant getPollWatermark();
 
     public abstract TerminationStateT getTerminationState();
+
+    /**
+     * The greatest timestamp emitted for this input so far, or null when the 
transform is not
+     * bounding its state by event time or has emitted nothing yet.
+     */
+    public abstract @Nullable Instant getCursor();
   }
 
   @VisibleForTesting
@@ -1088,6 +1221,10 @@ public class Watch {
     // Used to hash values.
     private final Funnel<OutputT> coderFunnel;
 
+    // How far below the cursor a completed key is still retained, or null 
when the state is not
+    // bounded by event time.
+    private final @Nullable Duration allowedLateness;
+
     // non-null after first successful tryClaim()
     private Growth.@Nullable PollResult<OutputT> claimedPollResult;
     private @Nullable TerminationStateT claimedTerminationState;
@@ -1098,9 +1235,11 @@ public class Watch {
     // Whether we should stop claiming poll results.
     private boolean shouldStop;
 
-    GrowthTracker(GrowthState state, Funnel<OutputT> coderFunnel) {
+    GrowthTracker(
+        GrowthState state, Funnel<OutputT> coderFunnel, @Nullable Duration 
allowedLateness) {
       this.state = state;
       this.coderFunnel = coderFunnel;
+      this.allowedLateness = allowedLateness;
       this.shouldStop = false;
     }
 
@@ -1135,13 +1274,30 @@ public class Watch {
         ImmutableMap.Builder<HashCode, Instant> newCompleted = 
ImmutableMap.builder();
         newCompleted.putAll(currentState.getCompleted());
         newCompleted.putAll(claimedHashes);
+        ImmutableMap<HashCode, Instant> completed = newCompleted.build();
+
+        // A round that is not bounding the state retains every key, so it 
drops the cursor that
+        // would retire them and returns the restriction to the pre-cursor 
format.
+        Instant cursor = null;
+        if (allowedLateness != null) {
+          // The cursor only ever advances, and retires the keys it has moved 
past.
+          cursor = currentState.getCursor();
+          for (Instant timestamp : claimedHashes.values()) {
+            cursor = Ordering.natural().nullsFirst().max(cursor, timestamp);
+          }
+          if (cursor != null) {
+            completed = retainAtOrAfter(completed, retentionFloor(cursor, 
allowedLateness));
+          }
+        }
+
         residual =
             PollingGrowthState.of(
-                newCompleted.build(),
+                completed,
                 Ordering.natural()
                     .nullsFirst()
                     .max(currentState.getPollWatermark(), 
claimedPollResult.watermark),
-                claimedTerminationState);
+                claimedTerminationState,
+                cursor);
         state = NonPollingGrowthState.of(claimedPollResult);
       }
 
@@ -1153,6 +1309,18 @@ public class Watch {
       return Hashing.murmur3_128().hashObject(value, coderFunnel);
     }
 
+    /** Drops the completed keys the retention floor has retired, which bounds 
the state. */
+    private static ImmutableMap<HashCode, Instant> retainAtOrAfter(
+        ImmutableMap<HashCode, Instant> completed, Instant floor) {
+      ImmutableMap.Builder<HashCode, Instant> retained = 
ImmutableMap.builder();
+      for (Map.Entry<HashCode, Instant> entry : completed.entrySet()) {
+        if (!entry.getValue().isBefore(floor)) {
+          retained.put(entry);
+        }
+      }
+      return retained.build();
+    }
+
     @Override
     public void checkDone() throws IllegalStateException {
       checkState(
@@ -1181,11 +1349,22 @@ public class Watch {
       ImmutableMap<HashCode, Instant> newClaimedHashes = 
newClaimedHashesBuilder.build();
 
       if (state instanceof PollingGrowthState) {
+        PollingGrowthState<?> pollingState = (PollingGrowthState<?>) state;
         // If we have previously claimed one of these hashes then return false.
         if (!Collections.disjoint(
-            newClaimedHashes.keySet(), ((PollingGrowthState) 
state).getCompleted().keySet())) {
+            newClaimedHashes.keySet(), pollingState.getCompleted().keySet())) {
           return false;
         }
+        // An output the cursor has already moved past cannot be claimed, 
since the key that would
+        // prove it never seen before has been retired.
+        Instant retentionFloor = retentionFloor(pollingState, allowedLateness);
+        if (retentionFloor != null) {
+          for (Instant timestamp : newClaimedHashes.values()) {
+            if (timestamp.isBefore(retentionFloor)) {
+              return false;
+            }
+          }
+        }
       } else {
         Set<HashCode> expectedHashesToClaim = new HashSet<>();
         for (TimestampedValue<OutputT> value :
@@ -1249,6 +1428,7 @@ public class Watch {
 
     private static final int POLLING_GROWTH_STATE = 0;
     private static final int NON_POLLING_GROWTH_STATE = 1;
+    private static final int CURSOR_POLLING_GROWTH_STATE = 2;
 
     public static <OutputT, TerminationStateT> GrowthStateCoder<OutputT, 
TerminationStateT> of(
         Coder<OutputT> outputCoder, Coder<TerminationStateT> 
terminationStateCoder) {
@@ -1259,6 +1439,7 @@ public class Watch {
         MapCoder.of(HashCode128Coder.of(), InstantCoder.of());
     private static final Coder<Instant> NULLABLE_INSTANT_CODER =
         NullableCoder.of(InstantCoder.of());
+    private static final Coder<Instant> INSTANT_CODER = InstantCoder.of();
 
     private final Coder<OutputT> outputCoder;
     private final Coder<List<TimestampedValue<OutputT>>> 
timestampedOutputCoder;
@@ -1275,8 +1456,17 @@ public class Watch {
     @Override
     public void encode(GrowthState value, OutputStream os) throws IOException {
       if (value instanceof PollingGrowthState) {
-        VarInt.encode(POLLING_GROWTH_STATE, os);
-        encodePollingGrowthState((PollingGrowthState<TerminationStateT>) 
value, os);
+        PollingGrowthState<TerminationStateT> polling =
+            (PollingGrowthState<TerminationStateT>) value;
+        // A state without a cursor keeps the pre-cursor byte format.
+        if (polling.getCursor() == null) {
+          VarInt.encode(POLLING_GROWTH_STATE, os);
+          encodePollingGrowthState(polling, os);
+        } else {
+          VarInt.encode(CURSOR_POLLING_GROWTH_STATE, os);
+          encodePollingGrowthState(polling, os);
+          INSTANT_CODER.encode(polling.getCursor(), os);
+        }
       } else if (value instanceof NonPollingGrowthState) {
         VarInt.encode(NON_POLLING_GROWTH_STATE, os);
         encodeNonPollingGrowthState((NonPollingGrowthState<OutputT>) value, 
os);
@@ -1305,7 +1495,9 @@ public class Watch {
         case NON_POLLING_GROWTH_STATE:
           return decodeNonPollingGrowthState(is);
         case POLLING_GROWTH_STATE:
-          return decodePollingGrowthState(is);
+          return decodePollingGrowthState(is, false);
+        case CURSOR_POLLING_GROWTH_STATE:
+          return decodePollingGrowthState(is, true);
         default:
           throw new IOException("Unknown growth state type " + type);
       }
@@ -1317,11 +1509,14 @@ public class Watch {
       return NonPollingGrowthState.of(new Growth.PollResult<>(values, 
watermark));
     }
 
-    private GrowthState decodePollingGrowthState(InputStream is) throws 
IOException {
+    private GrowthState decodePollingGrowthState(InputStream is, boolean 
hasCursor)
+        throws IOException {
       TerminationStateT terminationState = terminationStateCoder.decode(is);
       Instant watermark = NULLABLE_INSTANT_CODER.decode(is);
       Map<HashCode, Instant> completed = COMPLETED_CODER.decode(is);
-      return PollingGrowthState.of(ImmutableMap.copyOf(completed), watermark, 
terminationState);
+      Instant cursor = hasCursor ? INSTANT_CODER.decode(is) : null;
+      return PollingGrowthState.of(
+          ImmutableMap.copyOf(completed), watermark, terminationState, cursor);
     }
 
     @Override
diff --git 
a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/WatchTest.java 
b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/WatchTest.java
index 277d49a7240..ae32c6314e9 100644
--- a/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/WatchTest.java
+++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/transforms/WatchTest.java
@@ -28,9 +28,12 @@ import static org.joda.time.Duration.standardSeconds;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
 
 import java.io.IOException;
 import java.io.Serializable;
@@ -58,6 +61,7 @@ import org.apache.beam.sdk.transforms.Watch.WatchGrowthFn;
 import org.apache.beam.sdk.transforms.splittabledofn.ManualWatermarkEstimator;
 import org.apache.beam.sdk.transforms.splittabledofn.WatermarkEstimators;
 import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
+import org.apache.beam.sdk.util.CoderUtils;
 import org.apache.beam.sdk.values.KV;
 import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.PCollectionView;
@@ -69,6 +73,7 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Funnel;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Funnels;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.HashCode;
 import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Hashing;
+import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.io.BaseEncoding;
 import org.joda.time.Duration;
 import org.joda.time.Instant;
 import org.joda.time.ReadableDuration;
@@ -183,6 +188,36 @@ public class WatchTest implements Serializable {
     p.run();
   }
 
+  @Test
+  @Category({NeedsRunner.class, UsesUnboundedSplittableParDo.class})
+  public void testMultiplePollsWithTimestampCursor() {
+    List<Integer> all = Arrays.asList(0, 1, 2, 3, 4, 5, 6, 7, 8, 9);
+
+    PCollection<Integer> res =
+        p.apply(Create.of("a"))
+            .apply(
+                Watch.growthOf(
+                        new StablyTimedPollFn<String, Integer>(
+                            all, standardSeconds(3) /* timeToOutputEverything 
*/))
+                    .withPollInterval(Duration.millis(300))
+                    .withTimestampCursor()
+                    .withOutputCoder(VarIntCoder.of()))
+            .apply("Drop input", Values.create());
+
+    PAssert.that(res).containsInAnyOrder(all);
+
+    p.run();
+  }
+
+  @Test
+  public void testTimestampCursorRejectsNegativeAllowedLateness() {
+    Watch.Growth<String, Integer, Integer> growth =
+        Watch.growthOf(
+            new StablyTimedPollFn<String, Integer>(Arrays.asList(0), 
standardSeconds(1)));
+    assertThrows(
+        IllegalArgumentException.class, () -> 
growth.withTimestampCursor(standardSeconds(-1)));
+  }
+
   @Test
   @Category({NeedsRunner.class, UsesUnboundedSplittableParDo.class})
   public void testMultiplePollsWithKeyExtractor() {
@@ -323,6 +358,45 @@ public class WatchTest implements Serializable {
     CoderProperties.coderDecodeEncodeEqual(coder, nonPollingState);
   }
 
+  @Test
+  public void testCoderWithTimestampCursor() throws Exception {
+    Instant now = Instant.now();
+    ImmutableMap<HashCode, Instant> completed =
+        
ImmutableMap.of(HashCode.fromString("0123456789abcdef0123456789abcdef"), now);
+    GrowthState withoutCursor = PollingGrowthState.of(completed, now, "STATE");
+    GrowthState withCursor = PollingGrowthState.of(completed, now, "STATE", 
now);
+    Coder<GrowthState> coder =
+        Watch.GrowthStateCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of());
+
+    CoderProperties.coderDecodeEncodeEqual(coder, withCursor);
+    // A state without a cursor keeps the pre-cursor bytes, so a pipeline 
written before the cursor
+    // existed can still be updated.
+    assertEquals(0, CoderUtils.encodeToByteArray(coder, withoutCursor)[0]);
+  }
+
+  @Test
+  public void testCoderKeepsPreCursorEncodedForm() throws Exception {
+    // Encoded forms produced before the cursor existed; an update must keep 
them byte for byte.
+    Instant ts = new Instant(1234567890123L);
+    GrowthState polling =
+        PollingGrowthState.of(
+            
ImmutableMap.of(HashCode.fromString("0123456789abcdef0123456789abcdef"), ts),
+            ts,
+            "STATE");
+    GrowthState nonPolling =
+        NonPollingGrowthState.of(Growth.PollResult.incomplete(ts, 
Arrays.asList("A", "B")));
+    Coder<GrowthState> coder =
+        Watch.GrowthStateCoder.of(StringUtf8Coder.of(), StringUtf8Coder.of());
+
+    assertEquals(
+        "00055354415445018000011F71FB04CB0000000101234567"
+            + "89ABCDEF0123456789ABCDEF8000011F71FB04CB",
+        BaseEncoding.base16().encode(CoderUtils.encodeToByteArray(coder, 
polling)));
+    assertEquals(
+        "01000000000201418000011F71FB04CB01428000011F71FB04CB",
+        BaseEncoding.base16().encode(CoderUtils.encodeToByteArray(coder, 
nonPolling)));
+  }
+
   /**
    * Gradually emits all items from the given list, pairing each one with a 
UUID that identifies the
    * round of polling, so a client can check how many rounds of polling there 
were.
@@ -370,6 +444,38 @@ public class WatchTest implements Serializable {
     }
   }
 
+  /**
+   * Gradually emits all items from the given list, giving item {@code i} a 
timestamp of its own so
+   * that every poll reports an item at the same timestamp. Items are paired 
onto shared timestamps,
+   * so a poll boundary can fall between two items that carry the same one.
+   */
+  private static class StablyTimedPollFn<InputT, OutputT> extends 
PollFn<InputT, OutputT> {
+
+    private final Instant baseTime;
+    private final List<OutputT> outputs;
+    private final Duration timeToOutputEverything;
+
+    StablyTimedPollFn(List<OutputT> outputs, Duration timeToOutputEverything) {
+      this.baseTime = Instant.now();
+      this.outputs = outputs;
+      this.timeToOutputEverything = timeToOutputEverything;
+    }
+
+    @Override
+    public PollResult<OutputT> apply(InputT element, Context c) throws 
Exception {
+      Duration elapsed = new Duration(baseTime, Instant.now());
+      double fractionElapsed = 1.0 * elapsed.getMillis() / 
timeToOutputEverything.getMillis();
+      int numToEmit = (int) Math.min(outputs.size(), fractionElapsed * 
outputs.size());
+      List<TimestampedValue<OutputT>> toEmit = Lists.newArrayList();
+      for (int i = 0; i < numToEmit; ++i) {
+        toEmit.add(TimestampedValue.of(outputs.get(i), 
baseTime.plus(standardSeconds(i / 2))));
+      }
+      return numToEmit == outputs.size()
+          ? PollResult.complete(toEmit)
+          : PollResult.incomplete(toEmit);
+    }
+  }
+
   @Test
   public void testTerminationConditionsNever() {
     Watch.Growth.Never<Object> c = never();
@@ -441,6 +547,11 @@ public class WatchTest implements Serializable {
   }
 
   private static GrowthTracker<String, Integer> newTracker(GrowthState state) {
+    return newTracker(state, null);
+  }
+
+  private static GrowthTracker<String, Integer> newTracker(
+      GrowthState state, Duration allowedLateness) {
     Funnel<String> coderFunnel =
         (from, into) -> {
           try {
@@ -449,7 +560,7 @@ public class WatchTest implements Serializable {
             throw new RuntimeException(e);
           }
         };
-    return new GrowthTracker<>(state, coderFunnel);
+    return new GrowthTracker<>(state, coderFunnel, allowedLateness);
   }
 
   private static HashCode hash128(String value) {
@@ -468,6 +579,11 @@ public class WatchTest implements Serializable {
     return newTracker(PollingGrowthState.of(never().forNewInput(Instant.now(), 
null)));
   }
 
+  private static GrowthTracker<String, Integer> 
newPollingGrowthTracker(Duration allowedLateness) {
+    return newTracker(
+        PollingGrowthState.of(never().forNewInput(Instant.now(), null)), 
allowedLateness);
+  }
+
   @Test
   public void 
testPollingGrowthTrackerUsesElementTimestampIfNoWatermarkProvided() throws 
Exception {
     Instant now = Instant.now();
@@ -500,6 +616,91 @@ public class WatchTest implements Serializable {
     assertTrue(processContinuation.shouldResume());
   }
 
+  @Test
+  public void testPollingGrowthTrackerDropsOutputsBehindCursor() throws 
Exception {
+    Instant now = Instant.now();
+    Watch.Growth<String, String, String> growth =
+        Watch.growthOf(
+                new PollFn<String, String>() {
+                  @Override
+                  public PollResult<String> apply(String element, Context c) 
throws Exception {
+                    return PollResult.incomplete(
+                        Arrays.asList(
+                            TimestampedValue.of("retired", 
now.plus(standardSeconds(1))),
+                            TimestampedValue.of("atCursor", 
now.plus(standardSeconds(3))),
+                            TimestampedValue.of("fresh", 
now.plus(standardSeconds(5)))));
+                  }
+                })
+            .withPollInterval(standardSeconds(10))
+            .withTimestampCursor();
+    WatchGrowthFn<String, String, String, Integer> growthFn =
+        new WatchGrowthFn(
+            growth, StringUtf8Coder.of(), SerializableFunctions.identity(), 
StringUtf8Coder.of());
+    GrowthTracker<String, Integer> tracker =
+        newTracker(
+            PollingGrowthState.of(
+                ImmutableMap.of(),
+                null,
+                never().forNewInput(now, null),
+                now.plus(standardSeconds(3))),
+            Duration.ZERO);
+    DoFn.ProcessContext context = mock(DoFn.ProcessContext.class);
+    ManualWatermarkEstimator<Instant> watermarkEstimator =
+        new WatermarkEstimators.Manual(BoundedWindow.TIMESTAMP_MIN_VALUE);
+
+    ProcessContinuation processContinuation =
+        growthFn.process(context, tracker, watermarkEstimator);
+
+    // The output below the cursor has no key left to prove it was seen, so it 
is taken as seen. An
+    // output at the cursor is still retained, so it is deduplicated by key 
rather than dropped.
+    verify(context)
+        .output(
+            KV.of(
+                null,
+                Arrays.asList(
+                    TimestampedValue.of("atCursor", 
now.plus(standardSeconds(3))),
+                    TimestampedValue.of("fresh", 
now.plus(standardSeconds(5))))));
+    assertEquals(now.plus(standardSeconds(3)), 
watermarkEstimator.currentWatermark());
+    assertTrue(processContinuation.shouldResume());
+  }
+
+  @Test
+  public void testPollingGrowthTrackerEmptyRoundAdvancesWatermarkToFloor() 
throws Exception {
+    Instant now = Instant.now();
+    Watch.Growth<String, String, String> growth =
+        Watch.growthOf(
+                new PollFn<String, String>() {
+                  @Override
+                  public PollResult<String> apply(String element, Context c) 
throws Exception {
+                    // A re-listed output below the floor, and no explicit 
watermark.
+                    return PollResult.incomplete(
+                        Arrays.asList(
+                            TimestampedValue.of("retired", 
now.minus(standardSeconds(1)))));
+                  }
+                })
+            .withPollInterval(standardSeconds(10))
+            .withTimestampCursor();
+    WatchGrowthFn<String, String, String, Integer> growthFn =
+        new WatchGrowthFn(
+            growth, StringUtf8Coder.of(), SerializableFunctions.identity(), 
StringUtf8Coder.of());
+    GrowthTracker<String, Integer> tracker =
+        newTracker(
+            PollingGrowthState.of(ImmutableMap.of(), null, 
never().forNewInput(now, null), now),
+            Duration.ZERO);
+    DoFn.ProcessContext context = mock(DoFn.ProcessContext.class);
+    ManualWatermarkEstimator<Instant> watermarkEstimator =
+        new WatermarkEstimators.Manual(BoundedWindow.TIMESTAMP_MIN_VALUE);
+
+    ProcessContinuation processContinuation =
+        growthFn.process(context, tracker, watermarkEstimator);
+
+    // Nothing below the retention floor is ever emitted, so the floor is a 
sound watermark for a
+    // round that computed none.
+    verify(context, org.mockito.Mockito.never()).output(any());
+    assertEquals(now, watermarkEstimator.currentWatermark());
+    assertTrue(processContinuation.shouldResume());
+  }
+
   @Test
   public void testPollingGrowthTrackerCheckpointNonEmpty() {
     Instant now = Instant.now();
@@ -531,6 +732,151 @@ public class WatchTest implements Serializable {
         residual.getCompleted().keySet(),
         containsInAnyOrder(hash128("a"), hash128("b"), hash128("c"), 
hash128("d")));
     assertEquals(1, (int) residual.getTerminationState());
+    assertNull(residual.getCursor());
+  }
+
+  @Test
+  public void testPollingGrowthTrackerRetiresCompletedBehindCursor() {
+    Instant now = Instant.now();
+    GrowthTracker<String, Integer> tracker = 
newPollingGrowthTracker(Duration.ZERO);
+
+    PollResult<String> claim =
+        PollResult.incomplete(
+            Arrays.asList(
+                TimestampedValue.of("a", now.plus(standardSeconds(1))),
+                TimestampedValue.of("b", now.plus(standardSeconds(2))),
+                TimestampedValue.of("c", now.plus(standardSeconds(4))),
+                TimestampedValue.of("d", now.plus(standardSeconds(4)))));
+
+    assertTrue(tracker.tryClaim(KV.of(claim, 1 /* termination state */)));
+
+    PollingGrowthState<Integer> residual =
+        (PollingGrowthState<Integer>) tracker.trySplit(0).getResidual();
+
+    assertEquals(now.plus(standardSeconds(4)), residual.getCursor());
+    // A key at the cursor is retained, so an output that arrives later at the 
same timestamp is
+    // still deduplicated by key.
+    assertThat(residual.getCompleted().keySet(), 
containsInAnyOrder(hash128("c"), hash128("d")));
+  }
+
+  @Test
+  public void testPollingGrowthTrackerAllowedLatenessRetainsCompleted() {
+    Instant now = Instant.now();
+    GrowthTracker<String, Integer> tracker = 
newPollingGrowthTracker(standardSeconds(2));
+
+    PollResult<String> claim =
+        PollResult.incomplete(
+            Arrays.asList(
+                TimestampedValue.of("a", now.plus(standardSeconds(1))),
+                TimestampedValue.of("b", now.plus(standardSeconds(2))),
+                TimestampedValue.of("c", now.plus(standardSeconds(4))),
+                TimestampedValue.of("d", now.plus(standardSeconds(4)))));
+
+    assertTrue(tracker.tryClaim(KV.of(claim, 1 /* termination state */)));
+
+    PollingGrowthState<Integer> residual =
+        (PollingGrowthState<Integer>) tracker.trySplit(0).getResidual();
+
+    assertEquals(now.plus(standardSeconds(4)), residual.getCursor());
+    assertThat(
+        residual.getCompleted().keySet(),
+        containsInAnyOrder(hash128("b"), hash128("c"), hash128("d")));
+  }
+
+  @Test
+  public void testPollingGrowthTrackerRoundWithoutCursorDropsStaleCursor() 
throws Exception {
+    Instant now = Instant.now();
+    // A round that is not bounding the state retains every key, so the cursor 
that would retire
+    // them is dropped and the restriction returns to the pre-cursor encoding.
+    GrowthState state =
+        PollingGrowthState.of(
+            ImmutableMap.of(), null, never().forNewInput(now, null), 
now.plus(standardSeconds(10)));
+    GrowthTracker<String, Integer> tracker = newTracker(state, null);
+
+    assertTrue(
+        tracker.tryClaim(
+            KV.of(
+                PollResult.incomplete(
+                    Arrays.asList(TimestampedValue.of("a", 
now.plus(standardSeconds(20))))),
+                1)));
+
+    PollingGrowthState<Integer> residual =
+        (PollingGrowthState<Integer>) tracker.trySplit(0).getResidual();
+
+    assertNull(residual.getCursor());
+    assertEquals(1, residual.getCompleted().size());
+    Coder<GrowthState> coder = Watch.GrowthStateCoder.of(StringUtf8Coder.of(), 
VarIntCoder.of());
+    assertEquals(0, CoderUtils.encodeToByteArray(coder, residual)[0]);
+  }
+
+  @Test
+  public void testPollingGrowthTrackerAllowedLatenessKeepsMaxCursorClaimable() 
throws Exception {
+    // A cursor at the maximum timestamp still leaves the allowed lateness 
window claimable.
+    GrowthState state =
+        PollingGrowthState.of(
+            ImmutableMap.of(),
+            null,
+            never().forNewInput(Instant.now(), null),
+            BoundedWindow.TIMESTAMP_MAX_VALUE);
+    GrowthTracker<String, Integer> tracker = newTracker(state, 
Duration.standardHours(1));
+
+    assertTrue(
+        tracker.tryClaim(
+            KV.of(
+                PollResult.incomplete(
+                    Arrays.asList(
+                        TimestampedValue.of(
+                            "late",
+                            BoundedWindow.TIMESTAMP_MAX_VALUE.minus(
+                                Duration.standardMinutes(30))))),
+                1)));
+  }
+
+  @Test
+  public void testPollingGrowthTrackerHugeAllowedLatenessDoesNotOverflow() {
+    Instant now = Instant.now();
+    GrowthState state =
+        PollingGrowthState.of(ImmutableMap.of(), null, 
never().forNewInput(now, null), now);
+    GrowthTracker<String, Integer> tracker = newTracker(state, 
Duration.millis(Long.MAX_VALUE));
+
+    // The floor saturates at the minimum timestamp rather than throwing.
+    assertTrue(
+        tracker.tryClaim(
+            KV.of(
+                PollResult.incomplete(
+                    Arrays.asList(TimestampedValue.of("a", 
BoundedWindow.TIMESTAMP_MIN_VALUE))),
+                1)));
+  }
+
+  @Test
+  public void testPollingGrowthTrackerRejectsClaimBehindCursor() {
+    Instant now = Instant.now();
+    GrowthTracker<String, Integer> tracker = 
newPollingGrowthTracker(Duration.ZERO);
+
+    assertTrue(
+        tracker.tryClaim(
+            KV.of(
+                PollResult.incomplete(
+                    Arrays.asList(TimestampedValue.of("a", 
now.plus(standardSeconds(4))))),
+                1)));
+
+    PollingGrowthState<Integer> residual =
+        (PollingGrowthState<Integer>) tracker.trySplit(0).getResidual();
+
+    assertFalse(
+        newTracker(residual, Duration.ZERO)
+            .tryClaim(
+                KV.of(
+                    PollResult.incomplete(
+                        Arrays.asList(TimestampedValue.of("b", 
now.plus(standardSeconds(3))))),
+                    2)));
+    assertTrue(
+        newTracker(residual, Duration.ZERO)
+            .tryClaim(
+                KV.of(
+                    PollResult.incomplete(
+                        Arrays.asList(TimestampedValue.of("b", 
now.plus(standardSeconds(4))))),
+                    2)));
   }
 
   @Test

Reply via email to