Skip to content

Commit 75d1a72

Browse files
attempt to make the lease extension tests more reliable
1 parent 2f269bd commit 75d1a72

4 files changed

Lines changed: 135 additions & 35 deletions

File tree

.github/workflows/pull_request.yml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,41 @@ jobs:
129129
pip install pytest
130130
131131
- name: Run integration tests
132-
run: bash scripts/run_integration_tests.sh --bucket=${{ matrix.bucket }}
132+
run: >-
133+
bash scripts/run_integration_tests.sh --bucket=${{ matrix.bucket }}
134+
-s --log-cli-level=INFO
135+
--log-cli-format='%(asctime)s %(levelname)s %(name)s: %(message)s'
136+
137+
integration-test-http2:
138+
runs-on: ubuntu-latest
139+
timeout-minutes: 30
140+
strategy:
141+
fail-fast: false
142+
matrix:
143+
bucket: [test-all, long-sync, long-async, core]
144+
name: integration-test (${{ matrix.bucket }})
145+
env:
146+
CONDUCTOR_SERVER_URL: ${{ vars.SDKDEV_V5_SERVER_URL }}
147+
CONDUCTOR_AUTH_KEY: ${{ vars.SDKDEV_V5_AUTH_KEY }}
148+
CONDUCTOR_AUTH_SECRET: ${{ secrets.SDKDEV_V5_AUTH_SECRET }}
149+
steps:
150+
- name: Checkout code
151+
uses: actions/checkout@v4
152+
153+
- name: Set up Python
154+
uses: actions/setup-python@v5
155+
with:
156+
python-version: '3.12'
157+
cache: 'pip'
158+
159+
- name: Install dependencies
160+
run: |
161+
python -m pip install --upgrade pip
162+
pip install -e .
163+
pip install pytest
164+
165+
- name: Run integration tests
166+
run: >-
167+
bash scripts/run_integration_tests.sh --bucket=${{ matrix.bucket }}
168+
-s --log-cli-level=INFO
169+
--log-cli-format='%(asctime)s %(levelname)s %(name)s: %(message)s'

scripts/run_integration_tests.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@
4747
# # short tracebacks + a one-line reason for every failure/skip, with live logs
4848
# ./scripts/run_integration_tests.sh -ra --tb=short --log-cli-level=INFO
4949
#
50+
# # watch a slow bucket live: -s streams print() and the worker/child-process
51+
# # task logs; --log-cli-level=INFO streams the main-process logger.info lines
52+
# ./scripts/run_integration_tests.sh --bucket=long-sync -s --log-cli-level=INFO
53+
#
54+
# # same, but with timestamped live logs (what CI uses)
55+
# ./scripts/run_integration_tests.sh --bucket=long-sync -s \
56+
# --log-cli-level=INFO \
57+
# --log-cli-format='%(asctime)s %(levelname)s %(name)s: %(message)s'
58+
#
5059
# # stop at the first failure instead of waiting for the whole suite
5160
# ./scripts/run_integration_tests.sh -x --tb=long
5261
#

tests/integration/test_async_lease_extension.py

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,26 @@ def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
7474
# Short response timeout — task must heartbeat to stay alive
7575
RESPONSE_TIMEOUT_SECONDS = 10
7676

77-
# Task sleeps longer than the response timeout to prove heartbeat works.
78-
# Must be long enough that the server's workflow sweeper catches the expired
79-
# task BEFORE the worker completes.
77+
# The heartbeat task sleeps longer than the response timeout so that, without
78+
# heartbeats, its lease would expire mid-execution. Heartbeats keep it alive and
79+
# it still completes. This only needs to exceed RESPONSE_TIMEOUT_SECONDS.
8080
TASK_SLEEP_SECONDS = 50
8181

82+
# The no-heartbeat task must NOT report COMPLETED before the server times its
83+
# expired lease out; otherwise the completion "wins" the race and the task ends
84+
# up COMPLETED (the historical flake on a loaded shared server, where the
85+
# sweeper runs late). Holding the task far longer than any server timeout path
86+
# guarantees the server always times it out first. The test doesn't wait this
87+
# long: it polls for the terminal state and returns as soon as the timeout
88+
# lands, and teardown force-terminates the still-sleeping worker process.
89+
NO_HEARTBEAT_HOLD_SECONDS = 600
90+
91+
# Poll budget/interval for waiting on the workflow to reach the expected
92+
# terminal state. Generous ceiling so slow-server variance is absorbed; the
93+
# happy path returns in well under a minute.
94+
POLL_TIMEOUT_SECONDS = 600
95+
POLL_INTERVAL_SECONDS = 5
96+
8297
# Short task duration for performance test — well within timeout
8398
FAST_TASK_SLEEP_SECONDS = 2
8499

@@ -132,12 +147,16 @@ async def async_lease_heartbeat_task(job_id: str) -> dict:
132147
overwrite_task_def=True,
133148
)
134149
async def async_lease_no_heartbeat_task(job_id: str) -> dict:
135-
"""Async long-running task without heartbeat — should time out."""
136-
logger.info("[async_no_heartbeat] Starting job %s, sleeping %ss (timeout=%ss)",
137-
job_id, TASK_SLEEP_SECONDS, RESPONSE_TIMEOUT_SECONDS)
138-
await asyncio.sleep(TASK_SLEEP_SECONDS)
150+
"""Async long-running task without heartbeat — should time out.
151+
152+
Holds the task well past every server timeout path so the server always
153+
times the expired lease out before this ever tries to report completion.
154+
"""
155+
logger.info("[async_no_heartbeat] Starting job %s, holding %ss (timeout=%ss)",
156+
job_id, NO_HEARTBEAT_HOLD_SECONDS, RESPONSE_TIMEOUT_SECONDS)
157+
await asyncio.sleep(NO_HEARTBEAT_HOLD_SECONDS)
139158
logger.info("[async_no_heartbeat] Completed job %s", job_id)
140-
return {'job_id': job_id, 'status': 'completed', 'slept': TASK_SLEEP_SECONDS}
159+
return {'job_id': job_id, 'status': 'completed', 'slept': NO_HEARTBEAT_HOLD_SECONDS}
141160

142161

143162
@worker_task(
@@ -267,25 +286,27 @@ def _start_workflow(self, wf_name, job_id):
267286

268287
TERMINAL_STATES = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
269288

270-
def _wait_for_workflow(self, wf_id, timeout_seconds=90):
289+
def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
290+
poll_interval=POLL_INTERVAL_SECONDS):
271291
"""Poll until workflow reaches a terminal state. If it doesn't within
272292
the budget, dump server-side diagnostics so the ensuing assertion shows
273293
*why* (e.g. a task stuck in SCHEDULED with no poller) rather than only a
274294
bare status mismatch.
275295
"""
276-
for _ in range(timeout_seconds):
296+
deadline = time.monotonic() + timeout_seconds
297+
while time.monotonic() < deadline:
277298
try:
278299
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
279300
except ApiException as e:
280301
# A transient blip on a single poll shouldn't abort the wait;
281302
# keep polling until the budget is exhausted.
282303
if _is_transient(e):
283-
time.sleep(1)
304+
time.sleep(poll_interval)
284305
continue
285306
raise
286307
if wf.status in self.TERMINAL_STATES:
287308
return wf
288-
time.sleep(1)
309+
time.sleep(poll_interval)
289310
wf = _retry_on_transient(self.workflow_client.get_workflow,
290311
wf_id, include_tasks=True)
291312
if wf.status not in self.TERMINAL_STATES:
@@ -338,7 +359,7 @@ def test_01_async_with_heartbeat_completes(self):
338359
self._register_workflow(wf_name, HEARTBEAT_TASK)
339360

340361
wf_id = self._start_workflow(wf_name, 'ASYNC-HB-001')
341-
wf = self._wait_for_workflow(wf_id, timeout_seconds=80)
362+
wf = self._wait_for_workflow(wf_id)
342363

343364
print(f"\n Workflow ID: {wf_id}")
344365
print(f" Final status: {wf.status}")
@@ -360,14 +381,14 @@ def test_02_async_without_heartbeat_times_out(self):
360381
"""Async task WITHOUT lease_extend_enabled times out when sleep > responseTimeout."""
361382
print("\n" + "=" * 80)
362383
print("TEST: Async without heartbeat — task should TIME OUT")
363-
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task sleeps {TASK_SLEEP_SECONDS}s")
384+
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task holds {NO_HEARTBEAT_HOLD_SECONDS}s")
364385
print("=" * 80)
365386

366387
wf_name = f'test_async_lease_no_heartbeat_{RUN_ID}'
367388
self._register_workflow(wf_name, NO_HEARTBEAT_TASK)
368389

369390
wf_id = self._start_workflow(wf_name, 'ASYNC-NOHB-001')
370-
wf = self._wait_for_workflow(wf_id, timeout_seconds=80)
391+
wf = self._wait_for_workflow(wf_id)
371392

372393
print(f"\n Workflow ID: {wf_id}")
373394
print(f" Final status: {wf.status}")

tests/integration/test_lease_extension.py

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,27 @@ def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
7272
# Short response timeout — task must heartbeat to stay alive
7373
RESPONSE_TIMEOUT_SECONDS = 10
7474

75-
# Task sleeps longer than the response timeout to prove heartbeat works.
76-
# Must be long enough that the server's workflow sweeper (which runs every
77-
# ~30s) catches the expired task BEFORE the worker completes.
75+
# The heartbeat task sleeps longer than the response timeout so that, without
76+
# heartbeats, its lease would expire mid-execution. Heartbeats keep it alive and
77+
# it still completes. This only needs to exceed RESPONSE_TIMEOUT_SECONDS.
7878
TASK_SLEEP_SECONDS = 50
7979

80+
# The no-heartbeat task must NOT report COMPLETED before the server times its
81+
# expired lease out; otherwise the completion "wins" the race and the task ends
82+
# up COMPLETED (the historical flake on a loaded shared server, where the
83+
# sweeper runs late). Holding the task far longer than any server timeout path
84+
# (responseTimeout + sweeper, and the task's own timeoutSeconds) guarantees the
85+
# server always times it out first. The test itself doesn't wait this long: it
86+
# polls for the terminal state and returns as soon as the timeout lands, and
87+
# teardown force-terminates the still-sleeping worker process.
88+
NO_HEARTBEAT_HOLD_SECONDS = 600
89+
90+
# Poll budget/interval for waiting on the workflow to reach the expected
91+
# terminal state. Generous ceiling so slow-server variance is absorbed; the
92+
# happy path returns in well under a minute.
93+
POLL_TIMEOUT_SECONDS = 600
94+
POLL_INTERVAL_SECONDS = 5
95+
8096
# Per-run suffix so this suite's task/workflow names don't collide with other
8197
# runs (or other PRs/developers) on the shared dev server. With fixed names,
8298
# concurrent runs poll the same queues and steal/strand each other's tasks,
@@ -124,12 +140,16 @@ def lease_heartbeat_task(job_id: str) -> dict:
124140
overwrite_task_def=True,
125141
)
126142
def lease_no_heartbeat_task(job_id: str) -> dict:
127-
"""Long-running task without heartbeat — should time out."""
128-
logger.info("[no_heartbeat_task] Starting job %s, sleeping %ss (timeout=%ss)",
129-
job_id, TASK_SLEEP_SECONDS, RESPONSE_TIMEOUT_SECONDS)
130-
time.sleep(TASK_SLEEP_SECONDS)
143+
"""Long-running task without heartbeat — should time out.
144+
145+
Holds the task well past every server timeout path so the server always
146+
times the expired lease out before this ever tries to report completion.
147+
"""
148+
logger.info("[no_heartbeat_task] Starting job %s, holding %ss (timeout=%ss)",
149+
job_id, NO_HEARTBEAT_HOLD_SECONDS, RESPONSE_TIMEOUT_SECONDS)
150+
time.sleep(NO_HEARTBEAT_HOLD_SECONDS)
131151
logger.info("[no_heartbeat_task] Completed job %s", job_id)
132-
return {'job_id': job_id, 'status': 'completed', 'slept': TASK_SLEEP_SECONDS}
152+
return {'job_id': job_id, 'status': 'completed', 'slept': NO_HEARTBEAT_HOLD_SECONDS}
133153

134154

135155
# -- Test class --------------------------------------------------------------
@@ -176,25 +196,27 @@ def _start_workflow(self, wf_name, job_id):
176196

177197
TERMINAL_STATES = ('COMPLETED', 'FAILED', 'TIMED_OUT', 'TERMINATED')
178198

179-
def _wait_for_workflow(self, wf_id, timeout_seconds=60):
199+
def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
200+
poll_interval=POLL_INTERVAL_SECONDS):
180201
"""Poll until workflow reaches a terminal state. If it doesn't within
181202
the budget, dump server-side diagnostics so the ensuing assertion shows
182203
*why* (e.g. a task stuck in SCHEDULED with no poller) rather than only a
183204
bare status mismatch.
184205
"""
185-
for i in range(timeout_seconds):
206+
deadline = time.monotonic() + timeout_seconds
207+
while time.monotonic() < deadline:
186208
try:
187209
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
188210
except ApiException as e:
189211
# A transient blip on a single poll shouldn't abort the wait;
190212
# keep polling until the budget is exhausted.
191213
if _is_transient(e):
192-
time.sleep(1)
214+
time.sleep(poll_interval)
193215
continue
194216
raise
195217
if wf.status in self.TERMINAL_STATES:
196218
return wf
197-
time.sleep(1)
219+
time.sleep(poll_interval)
198220
wf = _retry_on_transient(self.workflow_client.get_workflow,
199221
wf_id, include_tasks=True)
200222
if wf.status not in self.TERMINAL_STATES:
@@ -230,8 +252,14 @@ def _dump_workflow_diagnostics(self, wf):
230252
except Exception as e: # diagnostics must never mask the real failure
231253
print(f" [diag] {task.task_def_name}: failed to fetch poll data: {e!r}")
232254

233-
def _run_workers_in_background(self, duration_seconds=60):
234-
"""Start workers in a background thread, return stop function."""
255+
def _run_workers_in_background(self, duration_seconds=POLL_TIMEOUT_SECONDS + 60):
256+
"""Start workers in a background thread, return stop function.
257+
258+
Workers stay alive across the whole poll window; the test's `finally`
259+
calls the returned stop() as soon as it finishes (usually in well under
260+
a minute), and the timer is only a backstop. stop() is idempotent so the
261+
backstop firing after the test already stopped is harmless.
262+
"""
235263
handler = TaskHandler(
236264
configuration=self.config,
237265
scan_for_annotated_workers=True,
@@ -240,7 +268,12 @@ def _run_workers_in_background(self, duration_seconds=60):
240268
# Expose the handler so _dump_workflow_diagnostics can report liveness.
241269
self.__class__._active_handler = handler
242270

271+
stopped = threading.Event()
272+
243273
def stop():
274+
if stopped.is_set():
275+
return
276+
stopped.set()
244277
handler.stop_processes()
245278

246279
# Auto-stop after duration
@@ -260,12 +293,12 @@ def test_01_with_heartbeat_completes(self):
260293
wf_name = f'test_lease_heartbeat_{RUN_ID}'
261294
self._register_workflow(wf_name, HEARTBEAT_TASK)
262295

263-
stop_workers = self._run_workers_in_background(duration_seconds=90)
296+
stop_workers = self._run_workers_in_background()
264297
time.sleep(3) # let workers start
265298

266299
try:
267300
wf_id = self._start_workflow(wf_name, 'HEARTBEAT-001')
268-
wf = self._wait_for_workflow(wf_id, timeout_seconds=80)
301+
wf = self._wait_for_workflow(wf_id)
269302

270303
print(f"\n Final status: {wf.status}")
271304
for task in (wf.tasks or []):
@@ -289,18 +322,18 @@ def test_02_without_heartbeat_times_out(self):
289322
"""Task WITHOUT lease_extend_enabled times out when sleep > responseTimeout."""
290323
print("\n" + "=" * 80)
291324
print("TEST: Without heartbeat — task should TIME OUT")
292-
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task sleeps {TASK_SLEEP_SECONDS}s")
325+
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task holds {NO_HEARTBEAT_HOLD_SECONDS}s")
293326
print("=" * 80)
294327

295328
wf_name = f'test_lease_no_heartbeat_{RUN_ID}'
296329
self._register_workflow(wf_name, NO_HEARTBEAT_TASK)
297330

298-
stop_workers = self._run_workers_in_background(duration_seconds=90)
331+
stop_workers = self._run_workers_in_background()
299332
time.sleep(3) # let workers start
300333

301334
try:
302335
wf_id = self._start_workflow(wf_name, 'NO-HEARTBEAT-001')
303-
wf = self._wait_for_workflow(wf_id, timeout_seconds=80)
336+
wf = self._wait_for_workflow(wf_id)
304337

305338
print(f"\n Final status: {wf.status}")
306339
for task in (wf.tasks or []):

0 commit comments

Comments
 (0)