This is an automated email from the ASF dual-hosted git repository.

jrmccluskey 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 8f60e69177b Fixed DirectRunner PubSub subscriber client lifecycle 
(#39079)
8f60e69177b is described below

commit 8f60e69177beef5dbcdc038a98795e4c464fa138
Author: Lalit Yadav <[email protected]>
AuthorDate: Tue Jun 30 10:14:53 2026 -0500

    Fixed DirectRunner PubSub subscriber client lifecycle (#39079)
    
    * Fix DirectRunner PubSub subscriber client cleanup
    
    * Avoid global lock during PubSub subscription creation
---
 sdks/python/apache_beam/io/gcp/pubsub_test.py      |  79 ++++++++++++--
 .../runners/direct/transform_evaluator.py          | 113 ++++++++++++++++-----
 2 files changed, 157 insertions(+), 35 deletions(-)

diff --git a/sdks/python/apache_beam/io/gcp/pubsub_test.py 
b/sdks/python/apache_beam/io/gcp/pubsub_test.py
index c35de62fca1..050a69aff6c 100644
--- a/sdks/python/apache_beam/io/gcp/pubsub_test.py
+++ b/sdks/python/apache_beam/io/gcp/pubsub_test.py
@@ -502,6 +502,70 @@ 
transform_evaluator.TransformEvaluatorRegistry._test_evaluators_overrides = {
 @unittest.skipIf(pubsub is None, 'GCP dependencies are not installed')
 @mock.patch('google.cloud.pubsub.SubscriberClient')
 class TestReadFromPubSub(unittest.TestCase):
+  def setUp(self):
+    _PubSubReadEvaluator._subscription_cache.clear()
+    _PubSubReadEvaluator._subscriber_client_cache.clear()
+
+  def test_subscriber_client_is_reused_for_transform(self, mock_pubsub):
+    class Transform(object):
+      pass
+
+    transform = Transform()
+    first_client = _PubSubReadEvaluator._get_subscriber_client(transform)
+    second_client = _PubSubReadEvaluator._get_subscriber_client(transform)
+
+    self.assertIs(first_client, second_client)
+    mock_pubsub.assert_called_once_with()
+    first_client.close.assert_not_called()
+
+  def test_subscription_creation_does_not_hold_global_cache_lock(
+      self, mock_pubsub):
+    class Transform(object):
+      pass
+
+    def subscription_path(project, subscription):
+      return 'projects/%s/subscriptions/%s' % (project, subscription)
+
+    transform = Transform()
+    client = mock_pubsub.return_value
+    client.subscription_path.side_effect = subscription_path
+    client.topic_path.return_value = 'projects/topic_project/topics/topic'
+    global_lock_available = []
+
+    def create_subscription(name, topic):
+      self.assertTrue(name.startswith('projects/sub_project/subscriptions/'))
+      self.assertEqual('projects/topic_project/topics/topic', topic)
+      acquired = _PubSubReadEvaluator._subscriber_client_cache_lock.acquire(
+          blocking=False)
+      global_lock_available.append(acquired)
+      if acquired:
+        _PubSubReadEvaluator._subscriber_client_cache_lock.release()
+
+    client.create_subscription.side_effect = create_subscription
+
+    sub_name = _PubSubReadEvaluator.get_subscription(
+        transform, 'topic_project', 'topic', 'sub_project', None)
+
+    self.assertTrue(global_lock_available)
+    self.assertTrue(global_lock_available[0])
+    self.assertTrue(sub_name.startswith('projects/sub_project/subscriptions/'))
+
+  def test_subscriber_client_cleanup_is_idempotent(self, unused_mock_pubsub):
+    client = mock.Mock()
+    subscriber_client = transform_evaluator._PubSubSubscriberClient(client)
+    subscriber_client.set_temporary_subscription('subscription')
+
+    subscriber_client.close()
+    subscriber_client.close()
+
+    client.assert_has_calls([
+        mock.call.delete_subscription(subscription='subscription'),
+        mock.call.close()
+    ])
+    client.delete_subscription.assert_called_once_with(
+        subscription='subscription')
+    client.close.assert_called_once_with()
+
   def test_read_messages_success(self, mock_pubsub):
     data = b'data'
     publish_time_secs = 1520861821
@@ -533,7 +597,8 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.assert_called_once_with()
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_strings_success(self, mock_pubsub):
     data = '🤷 ¯\\_(ツ)_/¯'
@@ -555,7 +620,7 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_data_success(self, mock_pubsub):
     data_encoded = '🤷 ¯\\_(ツ)_/¯'.encode('utf-8')
@@ -575,7 +640,7 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_messages_timestamp_attribute_milli_success(self, mock_pubsub):
     data = b'data'
@@ -610,7 +675,7 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_messages_timestamp_attribute_rfc3339_success(self, 
mock_pubsub):
     data = b'data'
@@ -645,7 +710,7 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_messages_timestamp_attribute_missing(self, mock_pubsub):
     data = b'data'
@@ -681,7 +746,7 @@ class TestReadFromPubSub(unittest.TestCase):
     mock_pubsub.return_value.acknowledge.assert_has_calls(
         [mock.call(subscription=mock.ANY, ack_ids=[ack_id])])
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_messages_timestamp_attribute_fail_parse(self, mock_pubsub):
     data = b'data'
@@ -710,7 +775,7 @@ class TestReadFromPubSub(unittest.TestCase):
       p.run()
     mock_pubsub.return_value.acknowledge.assert_not_called()
 
-    mock_pubsub.return_value.close.assert_has_calls([mock.call()])
+    mock_pubsub.return_value.close.assert_not_called()
 
   def test_read_message_id_label_unsupported(self, unused_mock_pubsub):
     # id_label is unsupported in DirectRunner.
diff --git a/sdks/python/apache_beam/runners/direct/transform_evaluator.py 
b/sdks/python/apache_beam/runners/direct/transform_evaluator.py
index 2349a0881e4..6702ec3362b 100644
--- a/sdks/python/apache_beam/runners/direct/transform_evaluator.py
+++ b/sdks/python/apache_beam/runners/direct/transform_evaluator.py
@@ -19,11 +19,12 @@
 
 # pytype: skip-file
 
-import atexit
 import collections
 import logging
 import random
+import threading
 import time
+import weakref
 from collections import abc
 from typing import TYPE_CHECKING
 from typing import Any
@@ -577,13 +578,46 @@ class _TestStreamEvaluator(_TransformEvaluator):
         self, self.bundles, unprocessed_bundles, None, {None: self.watermark})
 
 
+class _PubSubSubscriberClient(object):
+  """SubscriberClient state cached for one DirectRunner Pub/Sub read."""
+  def __init__(self, client):
+    self.client = client
+    self._temporary_subscription = None
+    self._closed = False
+    self._lock = threading.Lock()
+
+  def set_temporary_subscription(self, subscription):
+    self._temporary_subscription = subscription
+
+  def close(self):
+    if self._closed:
+      return
+    self._closed = True
+
+    try:
+      if self._temporary_subscription:
+        self.client.delete_subscription(
+            subscription=self._temporary_subscription)
+    except Exception:
+      _LOGGER.warning(
+          'Failed to delete temporary Pub/Sub subscription %s',
+          self._temporary_subscription,
+          exc_info=True)
+
+    try:
+      self.client.close()
+    except Exception:
+      _LOGGER.warning(
+          'Failed to close Pub/Sub subscriber client', exc_info=True)
+
+
 class _PubSubReadEvaluator(_TransformEvaluator):
   """TransformEvaluator for PubSub read."""
 
-  # A mapping of transform to _PubSubSubscriptionWrapper.
-  # TODO(https://github.com/apache/beam/issues/19751): Prevents garbage
-  # collection of pipeline instances.
-  _subscription_cache: dict[AppliedPTransform, str] = {}
+  # Weak-keyed per-transform caches avoid keeping completed pipelines alive.
+  _subscription_cache = weakref.WeakKeyDictionary()
+  _subscriber_client_cache = weakref.WeakKeyDictionary()
+  _subscriber_client_cache_lock = threading.Lock()
 
   def __init__(
       self,
@@ -627,18 +661,46 @@ class _PubSubReadEvaluator(_TransformEvaluator):
     if short_sub_name:
       return pubsub.SubscriberClient.subscription_path(project, short_sub_name)
 
-    if transform in cls._subscription_cache:
-      return cls._subscription_cache[transform]
+    with cls._subscriber_client_cache_lock:
+      sub_name = cls._subscription_cache.get(transform)
+      if sub_name:
+        return sub_name
 
-    sub_client = pubsub.SubscriberClient()
-    sub_name = sub_client.subscription_path(
-        sub_project,
-        'beam_%d_%x' % (int(time.time()), random.randrange(1 << 32)))
-    topic_name = sub_client.topic_path(project, short_topic_name)
-    sub_client.create_subscription(name=sub_name, topic=topic_name)
-    atexit.register(sub_client.delete_subscription, subscription=sub_name)
-    cls._subscription_cache[transform] = sub_name
-    return cls._subscription_cache[transform]
+      subscriber_client = cls._get_subscriber_client_state_unlocked(transform)
+
+    with subscriber_client._lock:
+      with cls._subscriber_client_cache_lock:
+        sub_name = cls._subscription_cache.get(transform)
+        if sub_name:
+          return sub_name
+
+      sub_client = subscriber_client.client
+      sub_name = sub_client.subscription_path(
+          sub_project,
+          'beam_%d_%x' % (int(time.time()), random.randrange(1 << 32)))
+      topic_name = sub_client.topic_path(project, short_topic_name)
+      sub_client.create_subscription(name=sub_name, topic=topic_name)
+      subscriber_client.set_temporary_subscription(sub_name)
+
+      with cls._subscriber_client_cache_lock:
+        cls._subscription_cache[transform] = sub_name
+
+      return sub_name
+
+  @classmethod
+  def _get_subscriber_client(cls, transform):
+    with cls._subscriber_client_cache_lock:
+      return cls._get_subscriber_client_state_unlocked(transform).client
+
+  @classmethod
+  def _get_subscriber_client_state_unlocked(cls, transform):
+    subscriber_client = cls._subscriber_client_cache.get(transform)
+    if subscriber_client is None:
+      from google.cloud import pubsub
+      subscriber_client = _PubSubSubscriberClient(pubsub.SubscriberClient())
+      cls._subscriber_client_cache[transform] = subscriber_client
+      weakref.finalize(transform, subscriber_client.close)
+    return subscriber_client
 
   def start_bundle(self):
     pass
@@ -648,8 +710,6 @@ class _PubSubReadEvaluator(_TransformEvaluator):
 
   def _read_from_pubsub(
       self, timestamp_attribute) -> list[tuple[Timestamp, 'PubsubMessage']]:
-    from google.cloud import pubsub
-
     from apache_beam.io.gcp.pubsub import PubsubMessage
 
     def _get_element(message):
@@ -678,16 +738,13 @@ class _PubSubReadEvaluator(_TransformEvaluator):
     # evaluator fails with an exception before emitting a bundle. However,
     # the DirectRunner currently doesn't retry work items anyway, so the
     # pipeline would enter an inconsistent state on any error.
-    sub_client = pubsub.SubscriberClient()
-    try:
-      response = sub_client.pull(
-          subscription=self._sub_name, max_messages=10, timeout=30)
-      results = [_get_element(rm.message) for rm in response.received_messages]
-      ack_ids = [rm.ack_id for rm in response.received_messages]
-      if ack_ids:
-        sub_client.acknowledge(subscription=self._sub_name, ack_ids=ack_ids)
-    finally:
-      sub_client.close()
+    sub_client = self._get_subscriber_client(self._applied_ptransform)
+    response = sub_client.pull(
+        subscription=self._sub_name, max_messages=10, timeout=30)
+    results = [_get_element(rm.message) for rm in response.received_messages]
+    ack_ids = [rm.ack_id for rm in response.received_messages]
+    if ack_ids:
+      sub_client.acknowledge(subscription=self._sub_name, ack_ids=ack_ids)
 
     return results
 

Reply via email to