@@ -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
7373RESPONSE_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 .
7878TASK_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)
126142def 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