Skip to content

Commit fc288ad

Browse files
berndverstBernd VerstCopilot
authored
Fix flaky sync client recreate tests by joining the recreate thread (#214)
The synchronous client's fire-and-forget recreate thread signals `_recreate_done_event` from inside `_run_recreate`'s `finally` block, so the event becomes set while the thread is still alive. `_schedule_recreate` single-flights on `existing.is_alive()` and its early return sits *before* `_recreate_done_event.clear()`. A trigger issued in that window is therefore dropped and the event is left set from the previous recreate, so the next `wait()` returns immediately off the stale signal and assertions about the following recreate fail. Two tests chained recreates through this pattern and were intermittently flaky under CPU contention: - test_sync_client_close_closes_all_retired_sdk_channels_immediately - test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation Add an `await_sync_recreate` helper that waits on the event and then joins the recreate thread, so `is_alive()` is deterministically False before the next trigger is issued, and use it at all five wait sites. This is a test-synchronization defect only; the production single-flight behaviour is correct and intentional, so no production code changes. The async client is unaffected -- it gates on `asyncio.Task.done()`. Fixes #212 Co-authored-by: Bernd Verst <beverst@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 677577fc-869c-463f-9243-0f4802a9f040
1 parent 2269c44 commit fc288ad

1 file changed

Lines changed: 31 additions & 8 deletions

File tree

tests/durabletask/test_client.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,10 @@ def install_resilient_test_stubs(client):
214214
call so newly created stubs continue to participate in failure tracking.
215215
216216
The interceptor's ``_on_recreate`` callback is intentionally left alone:
217-
it is the client's ``_schedule_recreate`` (fire-and-forget), and tests
218-
that need to observe the recreate's completion can wait on
219-
``client._recreate_done_event``.
217+
it is the client's ``_schedule_recreate`` (fire-and-forget). Synchronous
218+
tests that chain recreates must observe completion via
219+
``await_sync_recreate``; waiting on ``client._recreate_done_event`` alone
220+
is not sufficient (see that helper's docstring).
220221
"""
221222
is_async = inspect.iscoroutinefunction(client._maybe_recreate_channel)
222223
wrapper_cls = _ResilientAsyncTestStub if is_async else _ResilientSyncTestStub
@@ -241,6 +242,28 @@ def wrapped_recreate():
241242
client._maybe_recreate_channel = wrapped_recreate
242243

243244

245+
def await_sync_recreate(client: TaskHubGrpcClient, timeout: float = 5.0) -> None:
246+
"""Block until the synchronous client's recreate thread has fully exited.
247+
248+
``_recreate_done_event`` is set by ``_run_recreate`` from *inside* the
249+
recreate thread, so the event is signalled while that thread is still
250+
alive. ``_schedule_recreate`` single-flights on ``is_alive()``, so a
251+
trigger issued in that window is dropped *before* it reaches
252+
``_recreate_done_event.clear()`` -- leaving the event set from the
253+
previous recreate. A later ``wait()`` would then return immediately off
254+
that stale signal, and assertions about the next recreate would fail.
255+
256+
Tests that chain recreates must therefore wait on the same condition the
257+
single-flight guard reads. Joining the thread makes ``is_alive()``
258+
deterministically ``False`` before the next trigger is issued.
259+
"""
260+
assert client._recreate_done_event.wait(timeout=timeout)
261+
recreate_thread = client._recreate_thread
262+
if recreate_thread is not None:
263+
recreate_thread.join(timeout=timeout)
264+
assert not recreate_thread.is_alive()
265+
266+
244267
class FakePayloadStore(PayloadStore):
245268
TOKEN_PREFIX = 'fake://'
246269

@@ -669,10 +692,10 @@ def test_sync_client_close_closes_all_retired_sdk_channels_immediately():
669692
# Wait for the first fire-and-forget recreate to complete so the
670693
# single-flight guard in _schedule_recreate does not drop the second
671694
# trigger.
672-
assert client._recreate_done_event.wait(timeout=5.0)
695+
await_sync_recreate(client)
673696
with pytest.raises(FakeRpcError):
674697
client.get_orchestration_state("abc")
675-
assert client._recreate_done_event.wait(timeout=5.0)
698+
await_sync_recreate(client)
676699

677700
client.close()
678701

@@ -912,21 +935,21 @@ def test_sync_client_recreate_cooldown_prevents_immediate_repeated_recreation():
912935
client.get_orchestration_state("abc")
913936
# Wait for the fire-and-forget recreate to complete before asserting
914937
# the channel was swapped.
915-
assert client._recreate_done_event.wait(timeout=5.0)
938+
await_sync_recreate(client)
916939
assert client._channel is second_channel
917940
assert mock_get_channel.call_count == 2
918941

919942
with pytest.raises(FakeRpcError):
920943
client.get_orchestration_state("abc")
921944
# Cooldown should fire-and-forget but exit without recreating; wait
922945
# for the no-op recreate to complete so the assertion is deterministic.
923-
assert client._recreate_done_event.wait(timeout=5.0)
946+
await_sync_recreate(client)
924947
assert client._channel is second_channel
925948
assert mock_get_channel.call_count == 2
926949

927950
with pytest.raises(FakeRpcError):
928951
client.get_orchestration_state("abc")
929-
assert client._recreate_done_event.wait(timeout=5.0)
952+
await_sync_recreate(client)
930953
assert client._channel is third_channel
931954

932955
expected_channel_call = call(

0 commit comments

Comments
 (0)