Skip to content

Commit 8f60e69

Browse files
authored
Fixed DirectRunner PubSub subscriber client lifecycle (#39079)
* Fix DirectRunner PubSub subscriber client cleanup * Avoid global lock during PubSub subscription creation
1 parent aec5b5d commit 8f60e69

2 files changed

Lines changed: 157 additions & 35 deletions

File tree

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

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,70 @@ def finish_bundle(self):
502502
@unittest.skipIf(pubsub is None, 'GCP dependencies are not installed')
503503
@mock.patch('google.cloud.pubsub.SubscriberClient')
504504
class TestReadFromPubSub(unittest.TestCase):
505+
def setUp(self):
506+
_PubSubReadEvaluator._subscription_cache.clear()
507+
_PubSubReadEvaluator._subscriber_client_cache.clear()
508+
509+
def test_subscriber_client_is_reused_for_transform(self, mock_pubsub):
510+
class Transform(object):
511+
pass
512+
513+
transform = Transform()
514+
first_client = _PubSubReadEvaluator._get_subscriber_client(transform)
515+
second_client = _PubSubReadEvaluator._get_subscriber_client(transform)
516+
517+
self.assertIs(first_client, second_client)
518+
mock_pubsub.assert_called_once_with()
519+
first_client.close.assert_not_called()
520+
521+
def test_subscription_creation_does_not_hold_global_cache_lock(
522+
self, mock_pubsub):
523+
class Transform(object):
524+
pass
525+
526+
def subscription_path(project, subscription):
527+
return 'projects/%s/subscriptions/%s' % (project, subscription)
528+
529+
transform = Transform()
530+
client = mock_pubsub.return_value
531+
client.subscription_path.side_effect = subscription_path
532+
client.topic_path.return_value = 'projects/topic_project/topics/topic'
533+
global_lock_available = []
534+
535+
def create_subscription(name, topic):
536+
self.assertTrue(name.startswith('projects/sub_project/subscriptions/'))
537+
self.assertEqual('projects/topic_project/topics/topic', topic)
538+
acquired = _PubSubReadEvaluator._subscriber_client_cache_lock.acquire(
539+
blocking=False)
540+
global_lock_available.append(acquired)
541+
if acquired:
542+
_PubSubReadEvaluator._subscriber_client_cache_lock.release()
543+
544+
client.create_subscription.side_effect = create_subscription
545+
546+
sub_name = _PubSubReadEvaluator.get_subscription(
547+
transform, 'topic_project', 'topic', 'sub_project', None)
548+
549+
self.assertTrue(global_lock_available)
550+
self.assertTrue(global_lock_available[0])
551+
self.assertTrue(sub_name.startswith('projects/sub_project/subscriptions/'))
552+
553+
def test_subscriber_client_cleanup_is_idempotent(self, unused_mock_pubsub):
554+
client = mock.Mock()
555+
subscriber_client = transform_evaluator._PubSubSubscriberClient(client)
556+
subscriber_client.set_temporary_subscription('subscription')
557+
558+
subscriber_client.close()
559+
subscriber_client.close()
560+
561+
client.assert_has_calls([
562+
mock.call.delete_subscription(subscription='subscription'),
563+
mock.call.close()
564+
])
565+
client.delete_subscription.assert_called_once_with(
566+
subscription='subscription')
567+
client.close.assert_called_once_with()
568+
505569
def test_read_messages_success(self, mock_pubsub):
506570
data = b'data'
507571
publish_time_secs = 1520861821
@@ -533,7 +597,8 @@ def test_read_messages_success(self, mock_pubsub):
533597
mock_pubsub.return_value.acknowledge.assert_has_calls(
534598
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
535599

536-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
600+
mock_pubsub.assert_called_once_with()
601+
mock_pubsub.return_value.close.assert_not_called()
537602

538603
def test_read_strings_success(self, mock_pubsub):
539604
data = '🤷 ¯\\_(ツ)_/¯'
@@ -555,7 +620,7 @@ def test_read_strings_success(self, mock_pubsub):
555620
mock_pubsub.return_value.acknowledge.assert_has_calls(
556621
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
557622

558-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
623+
mock_pubsub.return_value.close.assert_not_called()
559624

560625
def test_read_data_success(self, mock_pubsub):
561626
data_encoded = '🤷 ¯\\_(ツ)_/¯'.encode('utf-8')
@@ -575,7 +640,7 @@ def test_read_data_success(self, mock_pubsub):
575640
mock_pubsub.return_value.acknowledge.assert_has_calls(
576641
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
577642

578-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
643+
mock_pubsub.return_value.close.assert_not_called()
579644

580645
def test_read_messages_timestamp_attribute_milli_success(self, mock_pubsub):
581646
data = b'data'
@@ -610,7 +675,7 @@ def test_read_messages_timestamp_attribute_milli_success(self, mock_pubsub):
610675
mock_pubsub.return_value.acknowledge.assert_has_calls(
611676
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
612677

613-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
678+
mock_pubsub.return_value.close.assert_not_called()
614679

615680
def test_read_messages_timestamp_attribute_rfc3339_success(self, mock_pubsub):
616681
data = b'data'
@@ -645,7 +710,7 @@ def test_read_messages_timestamp_attribute_rfc3339_success(self, mock_pubsub):
645710
mock_pubsub.return_value.acknowledge.assert_has_calls(
646711
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
647712

648-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
713+
mock_pubsub.return_value.close.assert_not_called()
649714

650715
def test_read_messages_timestamp_attribute_missing(self, mock_pubsub):
651716
data = b'data'
@@ -681,7 +746,7 @@ def test_read_messages_timestamp_attribute_missing(self, mock_pubsub):
681746
mock_pubsub.return_value.acknowledge.assert_has_calls(
682747
[mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
683748

684-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
749+
mock_pubsub.return_value.close.assert_not_called()
685750

686751
def test_read_messages_timestamp_attribute_fail_parse(self, mock_pubsub):
687752
data = b'data'
@@ -710,7 +775,7 @@ def test_read_messages_timestamp_attribute_fail_parse(self, mock_pubsub):
710775
p.run()
711776
mock_pubsub.return_value.acknowledge.assert_not_called()
712777

713-
mock_pubsub.return_value.close.assert_has_calls([mock.call()])
778+
mock_pubsub.return_value.close.assert_not_called()
714779

715780
def test_read_message_id_label_unsupported(self, unused_mock_pubsub):
716781
# id_label is unsupported in DirectRunner.

sdks/python/apache_beam/runners/direct/transform_evaluator.py

Lines changed: 85 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@
1919

2020
# pytype: skip-file
2121

22-
import atexit
2322
import collections
2423
import logging
2524
import random
25+
import threading
2626
import time
27+
import weakref
2728
from collections import abc
2829
from typing import TYPE_CHECKING
2930
from typing import Any
@@ -577,13 +578,46 @@ def finish_bundle(self):
577578
self, self.bundles, unprocessed_bundles, None, {None: self.watermark})
578579

579580

581+
class _PubSubSubscriberClient(object):
582+
"""SubscriberClient state cached for one DirectRunner Pub/Sub read."""
583+
def __init__(self, client):
584+
self.client = client
585+
self._temporary_subscription = None
586+
self._closed = False
587+
self._lock = threading.Lock()
588+
589+
def set_temporary_subscription(self, subscription):
590+
self._temporary_subscription = subscription
591+
592+
def close(self):
593+
if self._closed:
594+
return
595+
self._closed = True
596+
597+
try:
598+
if self._temporary_subscription:
599+
self.client.delete_subscription(
600+
subscription=self._temporary_subscription)
601+
except Exception:
602+
_LOGGER.warning(
603+
'Failed to delete temporary Pub/Sub subscription %s',
604+
self._temporary_subscription,
605+
exc_info=True)
606+
607+
try:
608+
self.client.close()
609+
except Exception:
610+
_LOGGER.warning(
611+
'Failed to close Pub/Sub subscriber client', exc_info=True)
612+
613+
580614
class _PubSubReadEvaluator(_TransformEvaluator):
581615
"""TransformEvaluator for PubSub read."""
582616

583-
# A mapping of transform to _PubSubSubscriptionWrapper.
584-
# TODO(https://github.com/apache/beam/issues/19751): Prevents garbage
585-
# collection of pipeline instances.
586-
_subscription_cache: dict[AppliedPTransform, str] = {}
617+
# Weak-keyed per-transform caches avoid keeping completed pipelines alive.
618+
_subscription_cache = weakref.WeakKeyDictionary()
619+
_subscriber_client_cache = weakref.WeakKeyDictionary()
620+
_subscriber_client_cache_lock = threading.Lock()
587621

588622
def __init__(
589623
self,
@@ -627,18 +661,46 @@ def get_subscription(
627661
if short_sub_name:
628662
return pubsub.SubscriberClient.subscription_path(project, short_sub_name)
629663

630-
if transform in cls._subscription_cache:
631-
return cls._subscription_cache[transform]
664+
with cls._subscriber_client_cache_lock:
665+
sub_name = cls._subscription_cache.get(transform)
666+
if sub_name:
667+
return sub_name
632668

633-
sub_client = pubsub.SubscriberClient()
634-
sub_name = sub_client.subscription_path(
635-
sub_project,
636-
'beam_%d_%x' % (int(time.time()), random.randrange(1 << 32)))
637-
topic_name = sub_client.topic_path(project, short_topic_name)
638-
sub_client.create_subscription(name=sub_name, topic=topic_name)
639-
atexit.register(sub_client.delete_subscription, subscription=sub_name)
640-
cls._subscription_cache[transform] = sub_name
641-
return cls._subscription_cache[transform]
669+
subscriber_client = cls._get_subscriber_client_state_unlocked(transform)
670+
671+
with subscriber_client._lock:
672+
with cls._subscriber_client_cache_lock:
673+
sub_name = cls._subscription_cache.get(transform)
674+
if sub_name:
675+
return sub_name
676+
677+
sub_client = subscriber_client.client
678+
sub_name = sub_client.subscription_path(
679+
sub_project,
680+
'beam_%d_%x' % (int(time.time()), random.randrange(1 << 32)))
681+
topic_name = sub_client.topic_path(project, short_topic_name)
682+
sub_client.create_subscription(name=sub_name, topic=topic_name)
683+
subscriber_client.set_temporary_subscription(sub_name)
684+
685+
with cls._subscriber_client_cache_lock:
686+
cls._subscription_cache[transform] = sub_name
687+
688+
return sub_name
689+
690+
@classmethod
691+
def _get_subscriber_client(cls, transform):
692+
with cls._subscriber_client_cache_lock:
693+
return cls._get_subscriber_client_state_unlocked(transform).client
694+
695+
@classmethod
696+
def _get_subscriber_client_state_unlocked(cls, transform):
697+
subscriber_client = cls._subscriber_client_cache.get(transform)
698+
if subscriber_client is None:
699+
from google.cloud import pubsub
700+
subscriber_client = _PubSubSubscriberClient(pubsub.SubscriberClient())
701+
cls._subscriber_client_cache[transform] = subscriber_client
702+
weakref.finalize(transform, subscriber_client.close)
703+
return subscriber_client
642704

643705
def start_bundle(self):
644706
pass
@@ -648,8 +710,6 @@ def process_element(self, element):
648710

649711
def _read_from_pubsub(
650712
self, timestamp_attribute) -> list[tuple[Timestamp, 'PubsubMessage']]:
651-
from google.cloud import pubsub
652-
653713
from apache_beam.io.gcp.pubsub import PubsubMessage
654714

655715
def _get_element(message):
@@ -678,16 +738,13 @@ def _get_element(message):
678738
# evaluator fails with an exception before emitting a bundle. However,
679739
# the DirectRunner currently doesn't retry work items anyway, so the
680740
# pipeline would enter an inconsistent state on any error.
681-
sub_client = pubsub.SubscriberClient()
682-
try:
683-
response = sub_client.pull(
684-
subscription=self._sub_name, max_messages=10, timeout=30)
685-
results = [_get_element(rm.message) for rm in response.received_messages]
686-
ack_ids = [rm.ack_id for rm in response.received_messages]
687-
if ack_ids:
688-
sub_client.acknowledge(subscription=self._sub_name, ack_ids=ack_ids)
689-
finally:
690-
sub_client.close()
741+
sub_client = self._get_subscriber_client(self._applied_ptransform)
742+
response = sub_client.pull(
743+
subscription=self._sub_name, max_messages=10, timeout=30)
744+
results = [_get_element(rm.message) for rm in response.received_messages]
745+
ack_ids = [rm.ack_id for rm in response.received_messages]
746+
if ack_ids:
747+
sub_client.acknowledge(subscription=self._sub_name, ack_ids=ack_ids)
691748

692749
return results
693750

0 commit comments

Comments
 (0)