Skip to content

Commit a8e7ffa

Browse files
authored
Fix race conditions, error recovery, and exit handlers in job servers (#38423)
1 parent 81828fd commit a8e7ffa

3 files changed

Lines changed: 140 additions & 9 deletions

File tree

sdks/python/apache_beam/runners/portability/job_server.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,13 @@ def start(self):
9494
def stop(self):
9595
with self._lock:
9696
if self._started:
97-
self._job_server.stop()
98-
self._started = False
97+
try:
98+
self._job_server.stop()
99+
finally:
100+
self._started = False
101+
# Unregister the atexit handler to prevent duplicate
102+
# registrations when the server is restarted/reused.
103+
atexit.unregister(self.stop)
99104

100105

101106
class SubprocessJobServer(JobServer):

sdks/python/apache_beam/utils/subprocess_server.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,11 @@ def register(self):
8888
return owner
8989

9090
def purge(self, owner):
91-
if owner not in self._live_owners:
92-
raise ValueError(f"{owner} not in {self._live_owners}")
93-
self._live_owners.remove(owner)
9491
to_delete = []
9592
with self._lock:
93+
if owner not in self._live_owners:
94+
raise ValueError(f"{owner} not in {self._live_owners}")
95+
self._live_owners.remove(owner)
9696
for key, entry in list(self._cache.items()):
9797
if owner in entry.owners:
9898
entry.owners.remove(owner)
@@ -255,15 +255,17 @@ def stop(self):
255255

256256
def stop_process(self):
257257
if self._owner_id is not None:
258-
self._cache.purge(self._owner_id)
259-
self._owner_id = None
258+
try:
259+
self._cache.purge(self._owner_id)
260+
finally:
261+
# Make sure _owner_id is set to None even if purge fails.
262+
self._owner_id = None
260263
if self._grpc_channel:
261264
try:
262265
self._grpc_channel.close()
263266
except: # pylint: disable=bare-except
264267
_LOGGER.error(
265-
"Could not close the gRPC channel started for the "
266-
"expansion service")
268+
"Could not close the gRPC channel started with cmd %s", self._cmd)
267269
finally:
268270
self._grpc_channel = None
269271

sdks/python/apache_beam/utils/subprocess_server_test.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
# pytype: skip-file
2121

22+
import atexit
2223
import glob
2324
import os
2425
import random
@@ -29,7 +30,9 @@
2930
import tempfile
3031
import threading
3132
import unittest
33+
from unittest.mock import patch
3234

35+
from apache_beam.runners.portability import job_server
3336
from apache_beam.utils import subprocess_server
3437

3538

@@ -302,6 +305,127 @@ def test_interleaved_owners(self):
302305
self.assertNotEqual(cache.get('b'), b)
303306
cache.purge(owner3)
304307

308+
def test_destructor_exception_partial_state(self):
309+
# In SubprocessServer.stop_process(), we need to make sure self._owner_id is always
310+
# set to None if it is not already set, even if a destructor exception happens
311+
# during purge(owner_id).
312+
313+
destructor_calls = []
314+
315+
def faulty_destructor(obj):
316+
destructor_calls.append(obj)
317+
raise RuntimeError("Destructor failed")
318+
319+
custom_cache = subprocess_server._SharedCache(
320+
lambda *args: "process_obj", faulty_destructor)
321+
322+
class CustomServer(subprocess_server.SubprocessServer):
323+
_cache = custom_cache
324+
325+
def __init__(self):
326+
super().__init__(lambda channel: None, ["dummy_cmd"], port=12345)
327+
328+
server = CustomServer()
329+
server.start_process()
330+
owner_id = server._owner_id
331+
self.assertIsNotNone(owner_id)
332+
self.assertIn(owner_id, custom_cache._live_owners)
333+
334+
# First stop attempt fails in the destructor
335+
with self.assertRaises(RuntimeError):
336+
server.stop_process()
337+
338+
# Verify fixed state: owner is purged from cache set, AND self._owner_id is successfully cleared to None
339+
self.assertNotIn(owner_id, custom_cache._live_owners)
340+
self.assertIsNone(server._owner_id)
341+
342+
# Second stop attempt safely does nothing (no ValueError raised)
343+
try:
344+
server.stop_process()
345+
except ValueError:
346+
self.fail("ValueError should not be raised here.")
347+
348+
def test_duplicate_atexit_registration_on_restart(self):
349+
# Make sure we don't have duplicate atexit registration when reusing a
350+
# StopOnExistJobServer instance.
351+
352+
class DummyJobServer(job_server.JobServer):
353+
def start(self):
354+
return "localhost:8080"
355+
356+
def stop(self):
357+
pass
358+
359+
wrapper = job_server.StopOnExitJobServer(DummyJobServer())
360+
361+
registered_callbacks = []
362+
363+
def mock_register(cb):
364+
registered_callbacks.append(cb)
365+
366+
def mock_unregister(cb):
367+
if cb in registered_callbacks:
368+
registered_callbacks.remove(cb)
369+
370+
with patch('atexit.register', side_effect=mock_register), \
371+
patch('atexit.unregister', side_effect=mock_unregister, create=True):
372+
# First start registers stop callback
373+
wrapper.start()
374+
self.assertTrue(wrapper._started)
375+
self.assertEqual(len(registered_callbacks), 1)
376+
377+
# Explicit stop clears _started AND unregisters the callback
378+
wrapper.stop()
379+
self.assertFalse(wrapper._started)
380+
self.assertEqual(len(registered_callbacks), 0)
381+
382+
# Re-starting registers the callback again, leaving exactly 1 active callback
383+
wrapper.start()
384+
self.assertTrue(wrapper._started)
385+
self.assertEqual(len(registered_callbacks), 1)
386+
387+
def test_concurrent_purge_race_condition(self):
388+
# Concurrent threads attempting to check memebership and call purge for the same owner.
389+
# Here we explicitly define a synchronized set to mimic the behavior of _live_owners.
390+
# This set will block two threads on __contains__, allowing us to test the race condition.
391+
cache = subprocess_server._SharedCache(lambda x: "obj", lambda x: None)
392+
owner = cache.register()
393+
394+
barrier = threading.Barrier(2)
395+
exceptions = []
396+
397+
class SynchronizedSet(set):
398+
def __contains__(self, item):
399+
res = super().__contains__(item)
400+
try:
401+
# Force both threads to align right after checking membership but before removal
402+
barrier.wait(timeout=0.2)
403+
except threading.BrokenBarrierError:
404+
pass
405+
return res
406+
407+
cache._live_owners = SynchronizedSet(cache._live_owners)
408+
409+
def purge_worker():
410+
try:
411+
cache.purge(owner)
412+
except Exception as e:
413+
exceptions.append(e)
414+
415+
t1 = threading.Thread(target=purge_worker)
416+
t2 = threading.Thread(target=purge_worker)
417+
418+
t1.start()
419+
t2.start()
420+
421+
t1.join()
422+
t2.join()
423+
424+
# Exactly one thread should raise the expected ValueError because they are cleanly serialized
425+
self.assertEqual(len(exceptions), 1)
426+
self.assertIsInstance(exceptions[0], ValueError)
427+
self.assertNotIsInstance(exceptions[0], KeyError)
428+
305429

306430
if __name__ == '__main__':
307431
unittest.main()

0 commit comments

Comments
 (0)