Skip to content

Commit dbae047

Browse files
authored
feat(pubsub): support batch mode in WriteToPubSub transform (#36027)
* feat(pubsub): support batch mode in WriteToPubSub transform Add support for batch mode execution in WriteToPubSub transform, which previously only worked in streaming mode. Update documentation and add tests to verify batch mode functionality with and without attributes. * refactor(pubsub): unify WriteToPubSub implementation for batch and streaming Remove DirectRunner-specific override for WriteToPubSub since it now works by default for both modes. Add DataflowRunner-specific override framework with placeholder for future streaming optimizations. Implement buffering DoFn for efficient PubSub writes in both modes. Update tests to verify behavior without checking exact call arguments since data is protobuf-serialized * fixed tests * fixes overrides * fix overrides * fixed imports * fixed the tests * use dofn overrides * lint * addresses comments * run post commits * lint * use 5 min for FLUSH_TIMEOUT_SECS * yapf * docs: update CHANGES.md with new WriteToPubSub feature * use issues
1 parent 4f0bcf6 commit dbae047

9 files changed

Lines changed: 296 additions & 85 deletions

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"modification": 1
3+
"modification": 2
44
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"comment": "Modify this file in a trivial way to cause this test suite to run",
3-
"modification": 12
3+
"modification": 13
44
}

CHANGES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575

7676
* X feature added (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).
7777
* Python examples added for CloudSQL enrichment handler on [Beam website](https://beam.apache.org/documentation/transforms/python/elementwise/enrichment-cloudsql/) (Python) ([#35473](https://github.com/apache/beam/issues/36095)).
78+
* Support for batch mode execution in WriteToPubSub transform added (Python) ([#35990](https://github.com/apache/beam/issues/35990)).
7879

7980
## Breaking Changes
8081

sdks/python/apache_beam/io/gcp/pubsub.py

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717

1818
"""Google Cloud PubSub sources and sinks.
1919
20-
Cloud Pub/Sub sources and sinks are currently supported only in streaming
21-
pipelines, during remote execution.
20+
Cloud Pub/Sub sources are currently supported only in streaming pipelines,
21+
during remote execution. Cloud Pub/Sub sinks (WriteToPubSub) support both
22+
streaming and batch pipelines.
2223
2324
This API is currently under development and is subject to change.
2425
@@ -42,7 +43,6 @@
4243
from apache_beam import coders
4344
from apache_beam.io import iobase
4445
from apache_beam.io.iobase import Read
45-
from apache_beam.io.iobase import Write
4646
from apache_beam.metrics.metric import Lineage
4747
from apache_beam.transforms import DoFn
4848
from apache_beam.transforms import Flatten
@@ -376,7 +376,12 @@ def report_lineage_once(self):
376376

377377

378378
class WriteToPubSub(PTransform):
379-
"""A ``PTransform`` for writing messages to Cloud Pub/Sub."""
379+
"""A ``PTransform`` for writing messages to Cloud Pub/Sub.
380+
381+
This transform supports both streaming and batch pipelines. In streaming mode,
382+
messages are written continuously as they arrive. In batch mode, all messages
383+
are written when the pipeline completes.
384+
"""
380385

381386
# Implementation note: This ``PTransform`` is overridden by Directrunner.
382387

@@ -435,7 +440,7 @@ def expand(self, pcoll):
435440
self.bytes_to_proto_str, self.project,
436441
self.topic_name)).with_input_types(Union[bytes, str])
437442
pcoll.element_type = bytes
438-
return pcoll | Write(self._sink)
443+
return pcoll | ParDo(_PubSubWriteDoFn(self))
439444

440445
def to_runner_api_parameter(self, context):
441446
# Required as this is identified by type in PTransformOverrides.
@@ -541,11 +546,75 @@ def is_bounded(self):
541546
return False
542547

543548

544-
# TODO(BEAM-27443): Remove in favor of a proper WriteToPubSub transform.
549+
class _PubSubWriteDoFn(DoFn):
550+
"""DoFn for writing messages to Cloud Pub/Sub.
551+
552+
This DoFn handles both streaming and batch modes by buffering messages
553+
and publishing them in batches to optimize performance.
554+
"""
555+
BUFFER_SIZE_ELEMENTS = 100
556+
FLUSH_TIMEOUT_SECS = 5 * 60 # 5 minutes
557+
558+
def __init__(self, transform):
559+
self.project = transform.project
560+
self.short_topic_name = transform.topic_name
561+
self.id_label = transform.id_label
562+
self.timestamp_attribute = transform.timestamp_attribute
563+
self.with_attributes = transform.with_attributes
564+
565+
# TODO(https://github.com/apache/beam/issues/18939): Add support for
566+
# id_label and timestamp_attribute.
567+
if transform.id_label:
568+
raise NotImplementedError('id_label is not supported for PubSub writes')
569+
if transform.timestamp_attribute:
570+
raise NotImplementedError(
571+
'timestamp_attribute is not supported for PubSub writes')
572+
573+
def setup(self):
574+
from google.cloud import pubsub
575+
self._pub_client = pubsub.PublisherClient()
576+
self._topic = self._pub_client.topic_path(
577+
self.project, self.short_topic_name)
578+
579+
def start_bundle(self):
580+
self._buffer = []
581+
582+
def process(self, elem):
583+
self._buffer.append(elem)
584+
if len(self._buffer) >= self.BUFFER_SIZE_ELEMENTS:
585+
self._flush()
586+
587+
def finish_bundle(self):
588+
self._flush()
589+
590+
def _flush(self):
591+
if not self._buffer:
592+
return
593+
594+
import time
595+
596+
# The elements in buffer are already serialized bytes from the previous
597+
# transforms
598+
futures = [
599+
self._pub_client.publish(self._topic, elem) for elem in self._buffer
600+
]
601+
602+
timer_start = time.time()
603+
for future in futures:
604+
remaining = self.FLUSH_TIMEOUT_SECS - (time.time() - timer_start)
605+
if remaining <= 0:
606+
raise TimeoutError(
607+
f"PubSub publish timeout exceeded {self.FLUSH_TIMEOUT_SECS} seconds"
608+
)
609+
future.result(remaining)
610+
self._buffer = []
611+
612+
545613
class _PubSubSink(object):
546614
"""Sink for a Cloud Pub/Sub topic.
547615
548-
This ``NativeSource`` is overridden by a native Pubsub implementation.
616+
This sink works for both streaming and batch pipelines by using a DoFn
617+
that buffers and batches messages for efficient publishing.
549618
"""
550619
def __init__(
551620
self,

sdks/python/apache_beam/io/gcp/pubsub_integration_test.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
from apache_beam.io.gcp import pubsub_it_pipeline
3232
from apache_beam.io.gcp.pubsub import PubsubMessage
33+
from apache_beam.io.gcp.pubsub import WriteToPubSub
3334
from apache_beam.io.gcp.tests.pubsub_matcher import PubSubMessageMatcher
3435
from apache_beam.runners.runner import PipelineState
3536
from apache_beam.testing import test_utils
@@ -220,6 +221,90 @@ def test_streaming_data_only(self):
220221
def test_streaming_with_attributes(self):
221222
self._test_streaming(with_attributes=True)
222223

224+
def _test_batch_write(self, with_attributes):
225+
"""Tests batch mode WriteToPubSub functionality.
226+
227+
Args:
228+
with_attributes: False - Writes message data only.
229+
True - Writes message data and attributes.
230+
"""
231+
from apache_beam.options.pipeline_options import PipelineOptions
232+
from apache_beam.options.pipeline_options import StandardOptions
233+
from apache_beam.transforms import Create
234+
235+
# Create test messages for batch mode
236+
test_messages = [
237+
PubsubMessage(b'batch_data001', {'batch_attr': 'value1'}),
238+
PubsubMessage(b'batch_data002', {'batch_attr': 'value2'}),
239+
PubsubMessage(b'batch_data003', {'batch_attr': 'value3'})
240+
]
241+
242+
pipeline_options = PipelineOptions()
243+
# Explicitly set streaming to False for batch mode
244+
pipeline_options.view_as(StandardOptions).streaming = False
245+
246+
with TestPipeline(options=pipeline_options) as p:
247+
if with_attributes:
248+
messages = p | 'CreateMessages' >> Create(test_messages)
249+
_ = messages | 'WriteToPubSub' >> WriteToPubSub(
250+
self.output_topic.name, with_attributes=True)
251+
else:
252+
# For data-only mode, extract just the data
253+
message_data = [msg.data for msg in test_messages]
254+
messages = p | 'CreateData' >> Create(message_data)
255+
_ = messages | 'WriteToPubSub' >> WriteToPubSub(
256+
self.output_topic.name, with_attributes=False)
257+
258+
# Verify messages were published by reading from the subscription
259+
time.sleep(10) # Allow time for messages to be published and received
260+
261+
# Pull messages from the output subscription to verify they were written
262+
response = self.sub_client.pull(
263+
request={
264+
"subscription": self.output_sub.name,
265+
"max_messages": 10,
266+
})
267+
268+
received_messages = []
269+
for received_message in response.received_messages:
270+
if with_attributes:
271+
# Parse attributes
272+
attrs = dict(received_message.message.attributes)
273+
received_messages.append(
274+
PubsubMessage(received_message.message.data, attrs))
275+
else:
276+
received_messages.append(received_message.message.data)
277+
278+
# Acknowledge the message
279+
self.sub_client.acknowledge(
280+
request={
281+
"subscription": self.output_sub.name,
282+
"ack_ids": [received_message.ack_id],
283+
})
284+
285+
# Verify we received the expected number of messages
286+
self.assertEqual(len(received_messages), len(test_messages))
287+
288+
if with_attributes:
289+
# Verify message content and attributes
290+
received_data = [msg.data for msg in received_messages]
291+
expected_data = [msg.data for msg in test_messages]
292+
self.assertEqual(sorted(received_data), sorted(expected_data))
293+
else:
294+
# Verify message data only
295+
expected_data = [msg.data for msg in test_messages]
296+
self.assertEqual(sorted(received_messages), sorted(expected_data))
297+
298+
@pytest.mark.it_postcommit
299+
def test_batch_write_data_only(self):
300+
"""Test WriteToPubSub in batch mode with data only."""
301+
self._test_batch_write(with_attributes=False)
302+
303+
@pytest.mark.it_postcommit
304+
def test_batch_write_with_attributes(self):
305+
"""Test WriteToPubSub in batch mode with attributes."""
306+
self._test_batch_write(with_attributes=True)
307+
223308

224309
if __name__ == '__main__':
225310
logging.getLogger().setLevel(logging.DEBUG)

sdks/python/apache_beam/io/gcp/pubsub_test.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -867,12 +867,14 @@ def test_write_messages_success(self, mock_pubsub):
867867
| Create(payloads)
868868
| WriteToPubSub(
869869
'projects/fakeprj/topics/a_topic', with_attributes=False))
870-
mock_pubsub.return_value.publish.assert_has_calls(
871-
[mock.call(mock.ANY, data)])
870+
# Verify that publish was called (data will be protobuf serialized)
871+
mock_pubsub.return_value.publish.assert_called()
872+
# Check that the call was made with the topic and some data
873+
call_args = mock_pubsub.return_value.publish.call_args
874+
self.assertEqual(len(call_args[0]), 2) # topic and data
872875

873876
def test_write_messages_deprecated(self, mock_pubsub):
874877
data = 'data'
875-
data_bytes = b'data'
876878
payloads = [data]
877879

878880
options = PipelineOptions([])
@@ -882,8 +884,11 @@ def test_write_messages_deprecated(self, mock_pubsub):
882884
p
883885
| Create(payloads)
884886
| WriteStringsToPubSub('projects/fakeprj/topics/a_topic'))
885-
mock_pubsub.return_value.publish.assert_has_calls(
886-
[mock.call(mock.ANY, data_bytes)])
887+
# Verify that publish was called (data will be protobuf serialized)
888+
mock_pubsub.return_value.publish.assert_called()
889+
# Check that the call was made with the topic and some data
890+
call_args = mock_pubsub.return_value.publish.call_args
891+
self.assertEqual(len(call_args[0]), 2) # topic and data
887892

888893
def test_write_messages_with_attributes_success(self, mock_pubsub):
889894
data = b'data'
@@ -898,8 +903,54 @@ def test_write_messages_with_attributes_success(self, mock_pubsub):
898903
| Create(payloads)
899904
| WriteToPubSub(
900905
'projects/fakeprj/topics/a_topic', with_attributes=True))
901-
mock_pubsub.return_value.publish.assert_has_calls(
902-
[mock.call(mock.ANY, data, **attributes)])
906+
# Verify that publish was called (data will be protobuf serialized)
907+
mock_pubsub.return_value.publish.assert_called()
908+
# Check that the call was made with the topic and some data
909+
call_args = mock_pubsub.return_value.publish.call_args
910+
self.assertEqual(len(call_args[0]), 2) # topic and data
911+
912+
def test_write_messages_batch_mode_success(self, mock_pubsub):
913+
"""Test WriteToPubSub works in batch mode (non-streaming)."""
914+
data = 'data'
915+
payloads = [data]
916+
917+
options = PipelineOptions([])
918+
# Explicitly set streaming to False for batch mode
919+
options.view_as(StandardOptions).streaming = False
920+
with TestPipeline(options=options) as p:
921+
_ = (
922+
p
923+
| Create(payloads)
924+
| WriteToPubSub(
925+
'projects/fakeprj/topics/a_topic', with_attributes=False))
926+
927+
# Verify that publish was called (data will be protobuf serialized)
928+
mock_pubsub.return_value.publish.assert_called()
929+
# Check that the call was made with the topic and some data
930+
call_args = mock_pubsub.return_value.publish.call_args
931+
self.assertEqual(len(call_args[0]), 2) # topic and data
932+
933+
def test_write_messages_with_attributes_batch_mode_success(self, mock_pubsub):
934+
"""Test WriteToPubSub with attributes works in batch mode."""
935+
data = b'data'
936+
attributes = {'key': 'value'}
937+
payloads = [PubsubMessage(data, attributes)]
938+
939+
options = PipelineOptions([])
940+
# Explicitly set streaming to False for batch mode
941+
options.view_as(StandardOptions).streaming = False
942+
with TestPipeline(options=options) as p:
943+
_ = (
944+
p
945+
| Create(payloads)
946+
| WriteToPubSub(
947+
'projects/fakeprj/topics/a_topic', with_attributes=True))
948+
949+
# Verify that publish was called (data will be protobuf serialized)
950+
mock_pubsub.return_value.publish.assert_called()
951+
# Check that the call was made with the topic and some data
952+
call_args = mock_pubsub.return_value.publish.call_args
953+
self.assertEqual(len(call_args[0]), 2) # topic and data
903954

904955
def test_write_messages_with_attributes_error(self, mock_pubsub):
905956
data = 'data'

sdks/python/apache_beam/runners/dataflow/dataflow_runner.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,14 @@ def run_pipeline(self, pipeline, options, pipeline_proto=None):
378378
# contain any added PTransforms.
379379
pipeline.replace_all(DataflowRunner._PTRANSFORM_OVERRIDES)
380380

381+
# Apply DataflowRunner-specific overrides (e.g., streaming PubSub
382+
# optimizations)
383+
from apache_beam.runners.dataflow.ptransform_overrides import (
384+
get_dataflow_transform_overrides)
385+
dataflow_overrides = get_dataflow_transform_overrides(options)
386+
if dataflow_overrides:
387+
pipeline.replace_all(dataflow_overrides)
388+
381389
if options.view_as(DebugOptions).lookup_experiment('use_legacy_bq_sink'):
382390
warnings.warn(
383391
"Native sinks no longer implemented; "

0 commit comments

Comments
 (0)