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 36066509b81 Fix PeriodicImpulse/PeriodicSequence watermark regression 
(#39026) (#39465)
36066509b81 is described below

commit 36066509b813a547419ea5a7f60cb2c0f21dccd5
Author: Udit Jain <[email protected]>
AuthorDate: Wed Aug 19 03:07:30 2026 +0530

    Fix PeriodicImpulse/PeriodicSequence watermark regression (#39026) (#39465)
    
    * Fix PeriodicImpulse/PeriodicSequence watermark regression (#39026)
    
    The watermark reported by ImpulseSeqGenDoFn stalled at the last emitted
    element's timestamp when the SDF deferred to wait for the next fire time.
    For a side input firing every N seconds, downstream watermark age climbed
    to N then snapped back (saw-tooth), a regression introduced when process()
    was rewritten between 2.66.0 and 2.74.0.
    
    ---------
    
    Signed-off-by: uditjainstjis <[email protected]>
    Co-authored-by: uditjainstjis <[email protected]>
    Co-authored-by: Yi Hu <[email protected]>
    Co-authored-by: Yi Hu <[email protected]>
---
 .../apache_beam/transforms/periodicsequence.py     | 13 +++--
 .../transforms/periodicsequence_test.py            | 61 ++++++++++++++++++++++
 2 files changed, 71 insertions(+), 3 deletions(-)

diff --git a/sdks/python/apache_beam/transforms/periodicsequence.py 
b/sdks/python/apache_beam/transforms/periodicsequence.py
index e2bdc3c6c0f..7b90fa3bb2f 100644
--- a/sdks/python/apache_beam/transforms/periodicsequence.py
+++ b/sdks/python/apache_beam/transforms/periodicsequence.py
@@ -166,9 +166,16 @@ class ImpulseSeqGenDoFn(beam.DoFn):
       current_output_timestamp = start + interval * current_output_index
 
       if current_output_timestamp > time.time():
-        # we are too ahead of time, let's wait.
-        restriction_tracker.defer_remainder(
-            timestamp.Timestamp(current_output_timestamp))
+        # We are ahead of time, let's wait. No element will be produced before
+        # the next fire time, so advance the watermark up to that timestamp.
+        next_output_timestamp = timestamp.Timestamp(current_output_timestamp)
+        if not self._is_pre_timestamped:
+          current_watermark = watermark_estimator.current_watermark()
+          if current_watermark is None or \
+              next_output_timestamp > current_watermark:
+            # ensure watermark is monotonic
+            watermark_estimator.set_watermark(next_output_timestamp)
+        restriction_tracker.defer_remainder(next_output_timestamp)
         break
 
       if not restriction_tracker.try_claim(current_output_index):
diff --git a/sdks/python/apache_beam/transforms/periodicsequence_test.py 
b/sdks/python/apache_beam/transforms/periodicsequence_test.py
index 6fbc68daed8..367d36d8a4c 100644
--- a/sdks/python/apache_beam/transforms/periodicsequence_test.py
+++ b/sdks/python/apache_beam/transforms/periodicsequence_test.py
@@ -29,12 +29,17 @@ from parameterized import parameterized
 
 import apache_beam as beam
 from apache_beam.io.restriction_trackers import OffsetRange
+from apache_beam.io.restriction_trackers import OffsetRestrictionTracker
+from apache_beam.io.watermark_estimators import ManualWatermarkEstimator
+from apache_beam.runners.sdf_utils import RestrictionTrackerView
+from apache_beam.runners.sdf_utils import ThreadsafeRestrictionTracker
 from apache_beam.testing.test_pipeline import TestPipeline
 from apache_beam.testing.util import assert_that
 from apache_beam.testing.util import equal_to
 from apache_beam.testing.util import is_empty
 from apache_beam.transforms import trigger
 from apache_beam.transforms import window
+from apache_beam.transforms.periodicsequence import ImpulseSeqGenDoFn
 from apache_beam.transforms.periodicsequence import PeriodicImpulse
 from apache_beam.transforms.periodicsequence import PeriodicSequence
 from apache_beam.transforms.periodicsequence import RebaseMode
@@ -368,5 +373,61 @@ class PeriodicImpulseTest(unittest.TestCase):
                 rebase=RebaseMode.REBASE_START))
 
 
+class ImpulseSeqGenDoFnWatermarkTest(unittest.TestCase):
+  """Drives ``ImpulseSeqGenDoFn.process`` directly to assert the reported
+  watermark when the DoFn defers because the next fire time is in the future.
+  """
+  @staticmethod
+  def _run_process(dofn, element, restriction, initial_watermark=None):
+    tracker = ThreadsafeRestrictionTracker(
+        OffsetRestrictionTracker(restriction))
+    view = RestrictionTrackerView(tracker)
+    estimator = ManualWatermarkEstimator(initial_watermark)
+    outputs = list(
+        dofn.process(
+            element, restriction_tracker=view, watermark_estimator=estimator))
+    return outputs, estimator
+
+  def test_watermark_advances_to_next_fire_on_defer(self):
+    # Regression test for https://github.com/apache/beam/issues/39026.
+    # With a long fire_interval, index 0 fires now and index 1 is scheduled far
+    # in the future, so the DoFn defers. The reported watermark must advance to
+    # the next fire time rather than stalling at the last emitted element's
+    # timestamp (which caused a saw-tooth watermark age from 2.74.0).
+    interval = 100
+    start = time.time() - 5
+    element = (start, start + 10000, interval)
+
+    outputs, estimator = self._run_process(
+        ImpulseSeqGenDoFn(), element, OffsetRange(0, 5))
+
+    self.assertEqual(len(outputs), 1)
+    last_emitted = Timestamp(start)
+    next_fire = Timestamp(start + interval)
+    self.assertEqual(outputs[0].timestamp, last_emitted)
+    # Watermark advanced past the last emitted element, up to the next fire.
+    self.assertEqual(estimator.current_watermark(), next_fire)
+    self.assertGreater(estimator.current_watermark(), last_emitted)
+
+  def test_watermark_not_advanced_past_emitted_for_pre_timestamped(self):
+    # For pre-timestamped data the provided event times may be out of order, so
+    # the watermark must not be advanced to the schedule time on defer; it 
stays
+    # at the latest emitted event timestamp.
+    interval = 100
+    start = time.time() - 5
+    element = (start, start + 10000, interval)
+    data = [(Timestamp(1), 'a'), (Timestamp(2), 'b'), (Timestamp(3), 'c')]
+
+    outputs, estimator = self._run_process(
+        ImpulseSeqGenDoFn(data), element, OffsetRange(0, 5))
+
+    self.assertEqual(len(outputs), 1)
+    self.assertEqual(outputs[0].value, 'a')
+    self.assertEqual(outputs[0].timestamp, Timestamp(1))
+    # Watermark stays at the emitted event time, not the future schedule time.
+    self.assertEqual(estimator.current_watermark(), Timestamp(1))
+    self.assertLess(estimator.current_watermark(), Timestamp(start + interval))
+
+
 if __name__ == '__main__':
   unittest.main()

Reply via email to