Skip to content

Commit d97899b

Browse files
Add Sample.Any to the Python SDK to match Java's Sample.any (#39442)
* Add Sample.Any to the Python SDK to match Java's Sample.any Python only had Sample.FixedSizeGlobally, which runs a uniform reservoir sample and returns a single list. Add Sample.Any, the equivalent of Java's Sample.any, which returns up to n arbitrary elements as a PCollection without the random sampling cost. If the input has fewer than n elements, all are returned. Includes unit tests on the DirectRunner and a CHANGES.md entry. Fixes #18552 * Reject negative n in Sample.Any Match Java's Sample.any, which rejects a negative limit at construction time. Add a regression test. * Add explicit type hints to the FlatMap in Sample.Any Address review feedback: declare with_input_types(list[T]) and with_output_types(T) on the FlatMap that flattens the combiner's output. --------- Co-authored-by: Jack McCluskey <34928439+jrmccluskey@users.noreply.github.com>
1 parent 559d22c commit d97899b

3 files changed

Lines changed: 113 additions & 0 deletions

File tree

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@
123123
* (Python) Added `Watch`, a transform that polls a growing set of outputs for each input element, deduplicates outputs across poll rounds, and stops per a user-supplied termination condition
124124
([#21521](https://github.com/apache/beam/issues/21521)).
125125
* (Python) Added support to analyze core dumps created after python worker segmentation faults with `pystack` (or `gdb` if installed) using the `--profiler_agent=coredump` pipeline option. ([#39484](https://github.com/apache/beam/issues/39484)).
126+
* (Python) Added `Sample.Any`, the Python equivalent of Java's `Sample.any`, which returns up to n arbitrary elements from a PCollection ([#18552](https://github.com/apache/beam/issues/18552)).
126127
* (Java) Added per-element OpenTelemetry trace propagation across stages in the Dataflow Streaming Runner. Enable it with `--experiments=enable_otel_defaults,element_metadata_supported,disable_portable_worker`. Cloud Trace incurs additional cost. ([#33176](https://github.com/apache/beam/issues/33176))
127128
* (Java) Added OpenTelemetry header propagation support for both reads and writes in KafkaIO and PubSubIO. ([#33176](https://github.com/apache/beam/issues/33176))
128129
* (Java) Added OpenTelemetry tracing support for SpannerIO change streams ([#33176](https://github.com/apache/beam/issues/33176))

sdks/python/apache_beam/transforms/combiners.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,35 @@ def display_data(self):
597597
def default_label(self):
598598
return 'FixedSizePerKey(%d)' % self._n
599599

600+
@with_input_types(T)
601+
@with_output_types(T)
602+
class Any(ptransform.PTransform):
603+
"""Returns up to n arbitrary elements from the input PCollection.
604+
605+
This is the Python equivalent of Java's ``Sample.any``. Unlike
606+
``FixedSizeGlobally`` it does not sample uniformly at random, and it returns
607+
the selected elements rather than a single list. If the input has fewer than
608+
n elements, all of them are returned.
609+
"""
610+
def __init__(self, n):
611+
if n < 0:
612+
raise ValueError('Expected non-negative n, received %s.' % n)
613+
self._n = n
614+
615+
def expand(self, pcoll):
616+
return (
617+
pcoll
618+
| core.CombineGlobally(_SampleAnyCombineFn(
619+
self._n)).without_defaults()
620+
| core.FlatMap(lambda elements: elements).with_input_types(
621+
list[T]).with_output_types(T))
622+
623+
def display_data(self):
624+
return {'n': self._n}
625+
626+
def default_label(self):
627+
return 'Any(%d)' % self._n
628+
600629

601630
@with_input_types(T)
602631
@with_output_types(list[T])
@@ -636,6 +665,35 @@ def teardown(self):
636665
self._top_combiner.teardown()
637666

638667

668+
@with_input_types(T)
669+
@with_output_types(list[T])
670+
class _SampleAnyCombineFn(core.CombineFn):
671+
"""CombineFn that keeps up to n arbitrary elements (no random sampling)."""
672+
def __init__(self, n):
673+
super().__init__()
674+
self._n = n
675+
676+
def create_accumulator(self):
677+
return []
678+
679+
def add_input(self, accumulator, element):
680+
if len(accumulator) < self._n:
681+
accumulator.append(element)
682+
return accumulator
683+
684+
def merge_accumulators(self, accumulators):
685+
result = []
686+
for accumulator in accumulators:
687+
for element in accumulator:
688+
if len(result) >= self._n:
689+
return result
690+
result.append(element)
691+
return result
692+
693+
def extract_output(self, accumulator):
694+
return accumulator
695+
696+
639697
class _TupleCombineFnBase(core.CombineFn):
640698
def __init__(self, *combiners, merge_accumulators_batch_size=None):
641699
self._combiners = [core.CombineFn.maybe_from_callable(c) for c in combiners]

sdks/python/apache_beam/transforms/combiners_test.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ def individual_test_per_key_dd(sampleFn, n):
253253

254254
individual_test_per_key_dd(combine.Sample.FixedSizePerKey, 5)
255255
individual_test_per_key_dd(combine.Sample.FixedSizeGlobally, 5)
256+
individual_test_per_key_dd(combine.Sample.Any, 5)
256257

257258
def test_combine_globally_display_data(self):
258259
transform = beam.CombineGlobally(combine.Smallest(5))
@@ -359,6 +360,59 @@ def match(actual):
359360

360361
assert_that(result, matcher())
361362

363+
def test_sample_any(self):
364+
with TestPipeline() as pipeline:
365+
pcoll = pipeline | 'start' >> Create([1, 2, 3, 4, 5])
366+
result = pcoll | 'sample-any' >> combine.Sample.Any(3)
367+
368+
def check(actual):
369+
assert len(actual) == 3, actual
370+
for element in actual:
371+
assert element in [1, 2, 3, 4, 5], element
372+
373+
assert_that(result, check)
374+
375+
def test_sample_any_at_most_input_size(self):
376+
with TestPipeline() as pipeline:
377+
pcoll = pipeline | 'start' >> Create([1, 2])
378+
result = pcoll | 'sample-any' >> combine.Sample.Any(5)
379+
assert_that(result, equal_to([1, 2]))
380+
381+
def test_sample_any_windowed(self):
382+
with TestPipeline() as pipeline:
383+
pcoll = (
384+
pipeline
385+
| 'start' >> Create([1, 2, 3, 4])
386+
| 'timestamp' >> Map(lambda x: TimestampedValue(x, x * 10))
387+
| 'window' >> WindowInto(FixedWindows(15)))
388+
result = pcoll | 'sample-any' >> combine.Sample.Any(1)
389+
390+
def check(actual):
391+
# Timestamps 10, 20, 30, 40 fall into fixed windows [0, 15), [15, 30)
392+
# and [30, 45), holding {1}, {2} and {3, 4}. One element is sampled from
393+
# each window that has elements.
394+
assert len(actual) == 3, actual
395+
for element in actual:
396+
assert element in [1, 2, 3, 4], element
397+
398+
assert_that(result, check)
399+
400+
def test_sample_any_empty(self):
401+
with TestPipeline() as pipeline:
402+
pcoll = pipeline | 'start' >> Create([])
403+
result = pcoll | 'sample-any' >> combine.Sample.Any(3)
404+
assert_that(result, equal_to([]))
405+
406+
def test_sample_any_zero(self):
407+
with TestPipeline() as pipeline:
408+
pcoll = pipeline | 'start' >> Create([1, 2, 3])
409+
result = pcoll | 'sample-any' >> combine.Sample.Any(0)
410+
assert_that(result, equal_to([]))
411+
412+
def test_sample_any_negative_n(self):
413+
with self.assertRaises(ValueError):
414+
combine.Sample.Any(-1)
415+
362416
def test_tuple_combine_fn(self):
363417
with TestPipeline() as p:
364418
result = (

0 commit comments

Comments
 (0)