|
19 | 19 |
|
20 | 20 | # pytype: skip-file |
21 | 21 |
|
| 22 | +import atexit |
22 | 23 | import glob |
23 | 24 | import os |
24 | 25 | import random |
|
29 | 30 | import tempfile |
30 | 31 | import threading |
31 | 32 | import unittest |
| 33 | +from unittest.mock import patch |
32 | 34 |
|
| 35 | +from apache_beam.runners.portability import job_server |
33 | 36 | from apache_beam.utils import subprocess_server |
34 | 37 |
|
35 | 38 |
|
@@ -302,6 +305,127 @@ def test_interleaved_owners(self): |
302 | 305 | self.assertNotEqual(cache.get('b'), b) |
303 | 306 | cache.purge(owner3) |
304 | 307 |
|
| 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 | + |
305 | 429 |
|
306 | 430 | if __name__ == '__main__': |
307 | 431 | unittest.main() |
0 commit comments