Skip to content

Commit 47eb9da

Browse files
authored
Add AftersynchronizedProcessing Time as continuation trigger in Python SDK (#35913)
1 parent 5634b06 commit 47eb9da

4 files changed

Lines changed: 196 additions & 4 deletions

File tree

sdks/python/apache_beam/transforms/core.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3280,6 +3280,18 @@ def infer_output_type(self, input_type):
32803280
return typehints.KV[
32813281
key_type, typehints.WindowedValue[value_type]] # type: ignore[misc]
32823282

3283+
def get_windowing(self, inputs):
3284+
# Switch to the continuation trigger associated with the current trigger.
3285+
windowing = inputs[0].windowing
3286+
triggerfn = windowing.triggerfn.get_continuation_trigger()
3287+
return Windowing(
3288+
windowfn=windowing.windowfn,
3289+
triggerfn=triggerfn,
3290+
accumulation_mode=windowing.accumulation_mode,
3291+
timestamp_combiner=windowing.timestamp_combiner,
3292+
allowed_lateness=windowing.allowed_lateness,
3293+
environment_id=windowing.environment_id)
3294+
32833295
def expand(self, pcoll):
32843296
from apache_beam.transforms.trigger import DataLossReason
32853297
from apache_beam.transforms.trigger import DefaultTrigger

sdks/python/apache_beam/transforms/ptransform_test.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from apache_beam.metrics import Metrics
4545
from apache_beam.metrics.metric import MetricsFilter
4646
from apache_beam.options.pipeline_options import PipelineOptions
47+
from apache_beam.options.pipeline_options import StandardOptions
4748
from apache_beam.options.pipeline_options import TypeOptions
4849
from apache_beam.portability import common_urns
4950
from apache_beam.testing.test_pipeline import TestPipeline
@@ -57,6 +58,9 @@
5758
from apache_beam.transforms.display import DisplayData
5859
from apache_beam.transforms.display import DisplayDataItem
5960
from apache_beam.transforms.ptransform import PTransform
61+
from apache_beam.transforms.trigger import AccumulationMode
62+
from apache_beam.transforms.trigger import AfterProcessingTime
63+
from apache_beam.transforms.trigger import _AfterSynchronizedProcessingTime
6064
from apache_beam.transforms.window import TimestampedValue
6165
from apache_beam.typehints import with_input_types
6266
from apache_beam.typehints import with_output_types
@@ -506,6 +510,21 @@ def test_group_by_key_unbounded_global_default_trigger(self):
506510
with TestPipeline(options=test_options) as pipeline:
507511
pipeline | TestStream() | beam.GroupByKey()
508512

513+
def test_group_by_key_trigger(self):
514+
options = PipelineOptions(['--allow_unsafe_triggers'])
515+
options.view_as(StandardOptions).streaming = True
516+
with TestPipeline(runner='BundleBasedDirectRunner',
517+
options=options) as pipeline:
518+
pcoll = pipeline | 'Start' >> beam.Create([(0, 0)])
519+
triggered = pcoll | 'Trigger' >> beam.WindowInto(
520+
window.GlobalWindows(),
521+
trigger=AfterProcessingTime(1),
522+
accumulation_mode=AccumulationMode.DISCARDING)
523+
output = triggered | 'Gbk' >> beam.GroupByKey()
524+
self.assertTrue(
525+
isinstance(
526+
output.windowing.triggerfn, _AfterSynchronizedProcessingTime))
527+
509528
def test_group_by_key_unsafe_trigger(self):
510529
test_options = PipelineOptions()
511530
test_options.view_as(TypeOptions).allow_unsafe_triggers = False

sdks/python/apache_beam/transforms/trigger.py

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ def from_runner_api(proto, context):
304304
'after_each': AfterEach,
305305
'after_end_of_window': AfterWatermark,
306306
'after_processing_time': AfterProcessingTime,
307-
# after_processing_time, after_synchronized_processing_time
307+
'after_synchronized_processing_time': _AfterSynchronizedProcessingTime,
308308
'always': Always,
309309
'default': DefaultTrigger,
310310
'element_count': AfterCount,
@@ -317,6 +317,17 @@ def from_runner_api(proto, context):
317317
def to_runner_api(self, unused_context):
318318
pass
319319

320+
@abstractmethod
321+
def get_continuation_trigger(self):
322+
"""Returns:
323+
Trigger to use after a GroupBy to preserve the intention of this
324+
trigger. Specifically, triggers that are time based and intended
325+
to provide speculative results should continue providing speculative
326+
results. Triggers that fire once (or multiple times) should
327+
continue firing once (or multiple times).
328+
"""
329+
pass
330+
320331

321332
class DefaultTrigger(TriggerFn):
322333
"""Semantically Repeatedly(AfterWatermark()), but more optimized."""
@@ -366,6 +377,9 @@ def to_runner_api(self, unused_context):
366377
def has_ontime_pane(self):
367378
return True
368379

380+
def get_continuation_trigger(self):
381+
return self
382+
369383

370384
class AfterProcessingTime(TriggerFn):
371385
"""Fire exactly once after a specified delay from processing time."""
@@ -418,6 +432,11 @@ def to_runner_api(self, context):
418432
after_processing_time=beam_runner_api_pb2.Trigger.AfterProcessingTime(
419433
timestamp_transforms=[delay_proto]))
420434

435+
def get_continuation_trigger(self):
436+
# The continuation of an AfterProcessingTime trigger is an
437+
# _AfterSynchronizedProcessingTime trigger.
438+
return _AfterSynchronizedProcessingTime()
439+
421440
def has_ontime_pane(self):
422441
return False
423442

@@ -466,6 +485,9 @@ def to_runner_api(self, context):
466485
return beam_runner_api_pb2.Trigger(
467486
always=beam_runner_api_pb2.Trigger.Always())
468487

488+
def get_continuation_trigger(self):
489+
return self
490+
469491

470492
class _Never(TriggerFn):
471493
"""A trigger that never fires.
@@ -518,6 +540,9 @@ def to_runner_api(self, context):
518540
return beam_runner_api_pb2.Trigger(
519541
never=beam_runner_api_pb2.Trigger.Never())
520542

543+
def get_continuation_trigger(self):
544+
return self
545+
521546

522547
class AfterWatermark(TriggerFn):
523548
"""Fire exactly once when the watermark passes the end of the window.
@@ -531,9 +556,19 @@ class AfterWatermark(TriggerFn):
531556
LATE_TAG = _CombiningValueStateTag('is_late', any)
532557

533558
def __init__(self, early=None, late=None):
534-
# TODO(zhoufek): Maybe don't wrap early/late if they are already Repeatedly
535-
self.early = Repeatedly(early) if early else None
536-
self.late = Repeatedly(late) if late else None
559+
self.early = self._wrap_if_not_repeatedly(early)
560+
self.late = self._wrap_if_not_repeatedly(late)
561+
562+
@staticmethod
563+
def _wrap_if_not_repeatedly(trigger):
564+
if trigger and not isinstance(trigger, Repeatedly):
565+
return Repeatedly(trigger)
566+
return trigger
567+
568+
def get_continuation_trigger(self):
569+
return AfterWatermark(
570+
self.early.get_continuation_trigger() if self.early else None,
571+
self.late.get_continuation_trigger() if self.late else None)
537572

538573
def __repr__(self):
539574
qualifiers = []
@@ -673,6 +708,9 @@ def should_fire(self, time_domain, watermark, window, context):
673708
def on_fire(self, watermark, window, context):
674709
return True
675710

711+
def get_continuation_trigger(self):
712+
return AfterCount(1)
713+
676714
def reset(self, window, context):
677715
context.clear_state(self.COUNT_TAG)
678716

@@ -741,6 +779,9 @@ def to_runner_api(self, context):
741779
def has_ontime_pane(self):
742780
return self.underlying.has_ontime_pane()
743781

782+
def get_continuation_trigger(self):
783+
return Repeatedly(self.underlying.get_continuation_trigger())
784+
744785

745786
class _ParallelTriggerFn(TriggerFn, metaclass=ABCMeta):
746787
def __init__(self, *triggers):
@@ -760,6 +801,12 @@ def __hash__(self):
760801
def combine_op(self, trigger_results):
761802
pass
762803

804+
def get_continuation_trigger(self):
805+
return self.__class__(
806+
*(
807+
subtrigger.get_continuation_trigger()
808+
for subtrigger in self.triggers))
809+
763810
def on_element(self, element, window, context):
764811
for ix, trigger in enumerate(self.triggers):
765812
trigger.on_element(element, window, self._sub_context(context, ix))
@@ -897,6 +944,13 @@ def on_fire(self, watermark, window, context):
897944
context.add_state(self.INDEX_TAG, ix)
898945
return ix == len(self.triggers)
899946

947+
def get_continuation_trigger(self):
948+
return Repeatedly(
949+
AfterAny(
950+
*(
951+
subtrigger.get_continuation_trigger()
952+
for subtrigger in self.triggers)))
953+
900954
def reset(self, window, context):
901955
context.clear_state(self.INDEX_TAG)
902956
for ix, trigger in enumerate(self.triggers):
@@ -1643,3 +1697,60 @@ def __repr__(self):
16431697
state_str = '\n'.join(
16441698
'%s: %s' % (key, dict(state)) for key, state in self.state.items())
16451699
return 'timers: %s\nstate: %s' % (dict(self.timers), state_str)
1700+
1701+
1702+
class _AfterSynchronizedProcessingTime(TriggerFn):
1703+
"""A "runner's-discretion" trigger downstream of a GroupByKey
1704+
with AfterProcessingTime trigger.
1705+
1706+
In runners that directly execute this
1707+
Python code, the trigger currently always fires,
1708+
but this behavior is neither guaranteed nor
1709+
required by runners, regardless of whether they
1710+
execute triggers via Python.
1711+
1712+
_AfterSynchronizedProcessingTime is experimental
1713+
and internal-only. No backwards compatibility
1714+
guarantees.
1715+
"""
1716+
def __init__(self):
1717+
pass
1718+
1719+
def __repr__(self):
1720+
return '_AfterSynchronizedProcessingTime()'
1721+
1722+
def __eq__(self, other):
1723+
return type(self) == type(other)
1724+
1725+
def __hash__(self):
1726+
return hash(type(self))
1727+
1728+
def on_element(self, _element, _window, _context):
1729+
pass
1730+
1731+
def on_merge(self, _to_be_merged, _merge_result, _context):
1732+
pass
1733+
1734+
def should_fire(self, _time_domain, _timestamp, _window, _context):
1735+
return True
1736+
1737+
def on_fire(self, _timestamp, _window, _context):
1738+
return False
1739+
1740+
def reset(self, _window, _context):
1741+
pass
1742+
1743+
@staticmethod
1744+
def from_runner_api(_proto, _context):
1745+
return _AfterSynchronizedProcessingTime()
1746+
1747+
def to_runner_api(self, _context):
1748+
return beam_runner_api_pb2.Trigger(
1749+
after_synchronized_processing_time=beam_runner_api_pb2.Trigger.
1750+
AfterSynchronizedProcessingTime())
1751+
1752+
def has_ontime_pane(self):
1753+
return False
1754+
1755+
def get_continuation_trigger(self):
1756+
return self

sdks/python/apache_beam/transforms/trigger_test.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,56 @@ def test_trigger_encoding(self):
554554
TriggerFn.from_runner_api(trigger_fn.to_runner_api(context), context))
555555

556556

557+
class ContinuationTriggerTest(unittest.TestCase):
558+
def test_after_all(self):
559+
self.assertEqual(
560+
AfterAll(AfterCount(2), AfterCount(5)).get_continuation_trigger(),
561+
AfterAll(AfterCount(1), AfterCount(1)))
562+
563+
def test_after_any(self):
564+
self.assertEqual(
565+
AfterAny(AfterCount(2), AfterCount(5)).get_continuation_trigger(),
566+
AfterAny(AfterCount(1), AfterCount(1)))
567+
568+
def test_after_count(self):
569+
self.assertEqual(AfterCount(1).get_continuation_trigger(), AfterCount(1))
570+
self.assertEqual(AfterCount(100).get_continuation_trigger(), AfterCount(1))
571+
572+
def test_after_each(self):
573+
self.assertEqual(
574+
AfterEach(AfterCount(2), AfterCount(5)).get_continuation_trigger(),
575+
Repeatedly(AfterAny(AfterCount(1), AfterCount(1))))
576+
577+
def test_after_processing_time(self):
578+
from apache_beam.transforms.trigger import _AfterSynchronizedProcessingTime
579+
self.assertEqual(
580+
AfterProcessingTime(10).get_continuation_trigger(),
581+
_AfterSynchronizedProcessingTime())
582+
583+
def test_after_watermark(self):
584+
self.assertEqual(
585+
AfterWatermark().get_continuation_trigger(), AfterWatermark())
586+
self.assertEqual(
587+
AfterWatermark(early=AfterCount(10),
588+
late=AfterCount(20)).get_continuation_trigger(),
589+
AfterWatermark(early=AfterCount(1), late=AfterCount(1)))
590+
591+
def test_always(self):
592+
self.assertEqual(Always().get_continuation_trigger(), Always())
593+
594+
def test_default(self):
595+
self.assertEqual(
596+
DefaultTrigger().get_continuation_trigger(), DefaultTrigger())
597+
598+
def test_never(self):
599+
self.assertEqual(_Never().get_continuation_trigger(), _Never())
600+
601+
def test_repeatedly(self):
602+
self.assertEqual(
603+
Repeatedly(AfterCount(10)).get_continuation_trigger(),
604+
Repeatedly(AfterCount(1)))
605+
606+
557607
class TriggerPipelineTest(unittest.TestCase):
558608
def test_after_processing_time(self):
559609
test_options = PipelineOptions(

0 commit comments

Comments
 (0)