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 852d0720ac0 Refactor _SharedCache to handle context vs non-context 
ownership (#38620)
852d0720ac0 is described below

commit 852d0720ac087252414edf4181e4107a61c70722
Author: Shunping Huang <[email protected]>
AuthorDate: Wed May 27 11:25:05 2026 -0400

    Refactor _SharedCache to handle context vs non-context ownership (#38620)
    
    * Refactor logging in subprocess_server.py for improved observability
    
    * Introduce context-aware ownership in SubprocessServer cache
    
    Refactors `_SharedCache` to support an `is_context` flag for registered 
owners.
    This prevents independent non-context subprocesses (e.g., prism runner and
    expansion service) from sharing ownership of each other's keys, while 
allowing
    context wrappers to properly track all active subprocesses.
    
    * Address comments.
---
 sdks/python/apache_beam/utils/subprocess_server.py | 40 +++++++++------
 .../apache_beam/utils/subprocess_server_test.py    | 57 ++++++++++++++++++++--
 2 files changed, 78 insertions(+), 19 deletions(-)

diff --git a/sdks/python/apache_beam/utils/subprocess_server.py 
b/sdks/python/apache_beam/utils/subprocess_server.py
index 0b09b364362..6e78ce88e0d 100644
--- a/sdks/python/apache_beam/utils/subprocess_server.py
+++ b/sdks/python/apache_beam/utils/subprocess_server.py
@@ -72,7 +72,7 @@ class _SharedCache:
   def __init__(self, constructor, destructor):
     self._constructor = constructor
     self._destructor = destructor
-    self._live_owners = set()
+    self._live_owners = {}
     self._cache = {}
     self._lock = threading.Lock()
     self._counter = 0
@@ -82,10 +82,10 @@ class _SharedCache:
     self._counter += 1
     return self._counter
 
-  def register(self):
+  def register(self, is_context=False):
     with self._lock:
       owner = self._next_id()
-      self._live_owners.add(owner)
+      self._live_owners[owner] = is_context
     return owner
 
   def purge(self, owner):
@@ -97,7 +97,7 @@ class _SharedCache:
             "shutdown, the subprocess was already cleaned up earlier.",
             owner)
         return
-      self._live_owners.remove(owner)
+      del self._live_owners[owner]
       for key, entry in list(self._cache.items()):
         if owner in entry.owners:
           entry.owners.remove(owner)
@@ -108,14 +108,23 @@ class _SharedCache:
     for value in to_delete:
       self._destructor(value)
 
-  def get(self, *key):
-    if not self._live_owners:
-      raise RuntimeError("At least one owner must be registered.")
+  def get(self, *key, owner=None):
     with self._lock:
+      if not self._live_owners:
+        raise RuntimeError("At least one owner must be registered.")
+      if owner is not None and owner not in self._live_owners:
+        raise RuntimeError("The requesting owner must be registered.")
+
       if key not in self._cache:
         self._cache[key] = _SharedCacheEntry(self._constructor(*key), set())
-      for owner in self._live_owners:
+      if owner is not None:
         self._cache[key].owners.add(owner)
+        for live_owner, is_context in self._live_owners.items():
+          if is_context:
+            self._cache[key].owners.add(live_owner)
+      else:
+        for live_owner in self._live_owners:
+          self._cache[key].owners.add(live_owner)
       return self._cache[key].obj
 
   def force_remove(self, *key):
@@ -180,7 +189,7 @@ class SubprocessServer(object):
     These subprocesses may be shared with other contexts as well.
     """
     try:
-      unique_id = cls._cache.register()
+      unique_id = cls._cache.register(is_context=True)
       yield
     finally:
       cls._cache.purge(unique_id)
@@ -214,7 +223,7 @@ class SubprocessServer(object):
       channel_ready = grpc.channel_ready_future(self._grpc_channel)
       while True:
         if process is not None and process.poll() is not None:
-          _LOGGER.error("Started job service with %s", process.args)
+          _LOGGER.error("Failed to start job service with %s", process.args)
           raise RuntimeError(
               'Service failed to start up with error %s' % process.poll())
         try:
@@ -235,15 +244,16 @@ class SubprocessServer(object):
   def start_process(self):
     if self._owner_id is not None:
       self._cache.purge(self._owner_id)
-    self._owner_id = self._cache.register()
-    return self._cache.get(tuple(self._cmd), self._port, self._logger)
+    self._owner_id = self._cache.register(is_context=False)
+    return self._cache.get(
+        tuple(self._cmd), self._port, self._logger, owner=self._owner_id)
 
   def _really_start_process(cmd, port, logger):
     if not port:
       port, = pick_port(None)
       cmd = [arg.replace('{{PORT}}', str(port)) for arg in cmd]  # pylint: 
disable=not-an-iterable
     endpoint = 'localhost:%s' % port
-    _LOGGER.info("Starting service with %s", str(cmd).replace("',", "'"))
+    _LOGGER.warning("Really starting service at %s with cmd: %s", endpoint, 
cmd)
     process = subprocess.Popen(
         cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
 
@@ -295,9 +305,11 @@ class SubprocessServer(object):
         self._grpc_channel = None
 
   def _really_stop_process(process_and_endpoint):
-    process, _ = process_and_endpoint  # pylint: disable=unpacking-non-sequence
+    process, endpoint = process_and_endpoint  # pylint: 
disable=unpacking-non-sequence
     if not process:
       return
+    _LOGGER.warning(
+        "Really destroying service at %s with cmd: %s", endpoint, process.args)
     for _ in range(5):
       if process.poll() is not None:
         break
diff --git a/sdks/python/apache_beam/utils/subprocess_server_test.py 
b/sdks/python/apache_beam/utils/subprocess_server_test.py
index a44b89b17e3..a008ae05c52 100644
--- a/sdks/python/apache_beam/utils/subprocess_server_test.py
+++ b/sdks/python/apache_beam/utils/subprocess_server_test.py
@@ -402,16 +402,16 @@ class CacheTest(unittest.TestCase):
       self.assertEqual(len(registered_callbacks), 1)
 
   def test_concurrent_purge_race_condition(self):
-    # Concurrent threads attempting to check memebership and call purge for 
the same owner.
-    # Here we explicitly define a synchronized set to mimic the behavior of 
_live_owners.
-    # This set will block two threads on __contains__, allowing us to test the 
race condition.
+    # Concurrent threads attempting to check membership and call purge for the 
same owner.
+    # Here we explicitly define a synchronized dict to mimic the behavior of 
_live_owners.
+    # This dict will block two threads on __contains__, allowing us to test 
the race condition.
     cache = subprocess_server._SharedCache(lambda x: "obj", lambda x: None)
     owner = cache.register()
 
     barrier = threading.Barrier(2)
     exceptions = []
 
-    class SynchronizedSet(set):
+    class SynchronizedDict(dict):
       def __contains__(self, item):
         res = super().__contains__(item)
         try:
@@ -421,7 +421,7 @@ class CacheTest(unittest.TestCase):
           pass
         return res
 
-    cache._live_owners = SynchronizedSet(cache._live_owners)
+    cache._live_owners = SynchronizedDict(cache._live_owners)
 
     def purge_worker():
       try:
@@ -551,6 +551,53 @@ class CacheTest(unittest.TestCase):
     # Clean up the other owner
     cache.purge(other_owner)
 
+  def test_non_context_owners_do_not_share_keys(self):
+    cache = subprocess_server._SharedCache(self.with_prefix, lambda x: None)
+    # owner1 is a non-context owner (e.g., prism)
+    owner1 = cache.register(is_context=False)
+    a = cache.get('a', owner=owner1)
+
+    # owner2 is another non-context owner (e.g., short-lived expansion service)
+    owner2 = cache.register(is_context=False)
+    b = cache.get('b', owner=owner2)
+
+    # Verify that owner1 does not own 'b'
+    self.assertNotIn(owner1, cache._cache[('b', )].owners)
+
+    # Verify that owner2 does not own 'a'
+    self.assertNotIn(owner2, cache._cache[('a', )].owners)
+
+    # Purging owner2 should immediately destroy/remove 'b'
+    cache.purge(owner2)
+    self.assertNotIn(('b', ), cache._cache)
+
+    # 'a' is still alive because owner1 is still registered
+    self.assertIn(('a', ), cache._cache)
+
+    # Purging owner1 should destroy/remove 'a'
+    cache.purge(owner1)
+    self.assertNotIn(('a', ), cache._cache)
+
+  def test_context_owner_owns_all_keys(self):
+    cache = subprocess_server._SharedCache(self.with_prefix, lambda x: None)
+    # owner1 is a non-context owner (e.g., prism)
+    owner1 = cache.register(is_context=False)
+
+    # owner2 is a context owner (e.g., cache_subprocesses)
+    owner2 = cache.register(is_context=True)
+
+    # owner3 is another non-context owner (e.g., short-lived service)
+    owner3 = cache.register(is_context=False)
+
+    # owner3 requests 'b'
+    b = cache.get('b', owner=owner3)
+
+    # owner2 (context) should own 'b'
+    self.assertIn(owner2, cache._cache[('b', )].owners)
+
+    # owner1 (non-context) should NOT own 'b'
+    self.assertNotIn(owner1, cache._cache[('b', )].owners)
+
 
 if __name__ == '__main__':
   unittest.main()

Reply via email to