Skip to content

Commit a86e611

Browse files
Retry starting SubprocessServer on failure (#39159)
* Retry starting SubprocessServer on failure Wraps the subprocess startup and connection sequence in a retry loop with a limit of 3 attempts. This mitigates "address already in use" errors caused by race conditions during dynamic port allocation. * Formatting * Apply suggestion Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent ed0a583 commit a86e611

2 files changed

Lines changed: 66 additions & 45 deletions

File tree

sdks/python/apache_beam/utils/subprocess_server.py

Lines changed: 50 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -201,45 +201,56 @@ def __exit__(self, *unused_args):
201201
self.stop()
202202

203203
def start(self):
204-
try:
205-
process, endpoint = self.start_process()
206-
wait_secs = .1
207-
channel_options = [
208-
("grpc.max_receive_message_length", -1),
209-
("grpc.max_send_message_length", -1),
210-
# Default: 20000ms (20s), increased to 10 minutes for stability
211-
("grpc.keepalive_timeout_ms", 600_000),
212-
# Default: 2, set to 0 to allow unlimited pings without data
213-
("grpc.http2.max_pings_without_data", 0),
214-
# Default: False, set to True to allow keepalive pings when no calls
215-
("grpc.keepalive_permit_without_calls", True),
216-
# Default: 2, set to 0 to allow unlimited ping strikes
217-
("grpc.http2.max_ping_strikes", 0),
218-
# Default: 0 (disabled), enable socket reuse for better handling
219-
("grpc.so_reuseport", 1),
220-
]
221-
self._grpc_channel = grpc.insecure_channel(
222-
endpoint, options=channel_options)
223-
channel_ready = grpc.channel_ready_future(self._grpc_channel)
224-
while True:
225-
if process is not None and process.poll() is not None:
226-
_LOGGER.error("Failed to start job service with %s", process.args)
227-
raise RuntimeError(
228-
'Service failed to start up with error %s' % process.poll())
229-
try:
230-
channel_ready.result(timeout=wait_secs)
231-
break
232-
except (grpc.FutureTimeoutError, grpc.RpcError):
233-
wait_secs *= 1.2
234-
logging.log(
235-
logging.WARNING if wait_secs > 1 else logging.DEBUG,
236-
'Waiting for grpc channel to be ready at %s.',
237-
endpoint)
238-
return self._stub_class(self._grpc_channel)
239-
except: # pylint: disable=bare-except
240-
_LOGGER.exception("Error bringing up service")
241-
self.stop_force()
242-
raise
204+
max_retries = 3
205+
for attempt in range(max_retries):
206+
try:
207+
process, endpoint = self.start_process()
208+
wait_secs = .1
209+
channel_options = [
210+
("grpc.max_receive_message_length", -1),
211+
("grpc.max_send_message_length", -1),
212+
# Default: 20000ms (20s), increased to 10 minutes for stability
213+
("grpc.keepalive_timeout_ms", 600_000),
214+
# Default: 2, set to 0 to allow unlimited pings without data
215+
("grpc.http2.max_pings_without_data", 0),
216+
# Default: False, set to True to allow keepalive pings when no calls
217+
("grpc.keepalive_permit_without_calls", True),
218+
# Default: 2, set to 0 to allow unlimited ping strikes
219+
("grpc.http2.max_ping_strikes", 0),
220+
# Default: 0 (disabled), enable socket reuse for better handling
221+
("grpc.so_reuseport", 1),
222+
]
223+
self._grpc_channel = grpc.insecure_channel(
224+
endpoint, options=channel_options)
225+
channel_ready = grpc.channel_ready_future(self._grpc_channel)
226+
while True:
227+
if process is not None and process.poll() is not None:
228+
_LOGGER.error("Failed to start job service with %s", process.args)
229+
raise RuntimeError(
230+
'Service failed to start up with error %s' % process.poll())
231+
try:
232+
channel_ready.result(timeout=wait_secs)
233+
break
234+
except (grpc.FutureTimeoutError, grpc.RpcError):
235+
wait_secs *= 1.2
236+
logging.log(
237+
logging.WARNING if wait_secs > 1 else logging.DEBUG,
238+
'Waiting for grpc channel to be ready at %s.',
239+
endpoint)
240+
return self._stub_class(self._grpc_channel)
241+
except Exception as e:
242+
_LOGGER.warning(
243+
"Error bringing up service (attempt %d of %d): %s",
244+
attempt + 1,
245+
max_retries,
246+
e)
247+
self.stop_force()
248+
if attempt == max_retries - 1:
249+
raise
250+
time.sleep(1)
251+
except: # pylint: disable=bare-except
252+
self.stop_force()
253+
raise
243254

244255
def start_process(self):
245256
if self._owner_id is not None:

sdks/python/apache_beam/utils/subprocess_server_test.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -514,9 +514,16 @@ def __init__(self):
514514
def poll(self):
515515
return 1 # Simulate that process exited/failed
516516

517+
constructor_calls = 0
518+
519+
def custom_constructor(*args):
520+
nonlocal constructor_calls
521+
constructor_calls += 1
522+
return (dummy_process, "localhost:12345")
523+
517524
dummy_process = DummyProcess()
518525
cache = subprocess_server._SharedCache(
519-
lambda *args: (dummy_process, "localhost:12345"), custom_destructor)
526+
custom_constructor, custom_destructor)
520527

521528
# 1. Register an independent, unrelated owner in the cache first.
522529
other_owner = cache.register()
@@ -536,11 +543,14 @@ def __init__(self):
536543
self.assertEqual(cache._cache[cache_key].owners, {other_owner})
537544

538545
# 2. Verify starting the server (which registers its own owner and retrieves from cache) raises RuntimeError
539-
with self.assertRaises(RuntimeError):
540-
server.start()
541-
542-
# 3. Verify that the destructor was called on the process, meaning no leak (even though other_owner was still registered!)
543-
self.assertEqual(destructor_calls, [(dummy_process, "localhost:12345")])
546+
with patch('time.sleep'):
547+
with self.assertRaises(RuntimeError):
548+
server.start()
549+
self.assertEqual(constructor_calls, 3)
550+
551+
# 3. Verify that the destructor was called on the process for each retry attempt (3 total),
552+
# meaning there is no leak (even though other_owner was still registered).
553+
self.assertEqual(destructor_calls, [(dummy_process, "localhost:12345")] * 3)
544554

545555
# 4. Verify that the server has cleaned up its owner_id
546556
self.assertIsNone(server._owner_id)

0 commit comments

Comments
 (0)