2525import sys
2626import time
2727import unittest
28+ from uuid import uuid4
2829
2930import pytest
3031
5758# Number of fast tasks for performance comparison
5859PERF_TASK_COUNT = 5
5960
61+ # Per-run suffix so this suite's task/workflow names don't collide with other
62+ # runs (or other PRs/developers) on the shared dev server. With fixed names,
63+ # concurrent runs poll the same queues and steal/strand each other's tasks,
64+ # producing non-deterministic SCHEDULED/RUNNING failures.
65+ RUN_ID = uuid4 ().hex [:8 ]
66+ HEARTBEAT_TASK = f'async_lease_heartbeat_task_{ RUN_ID } '
67+ NO_HEARTBEAT_TASK = f'async_lease_no_heartbeat_task_{ RUN_ID } '
68+ FAST_HB_TASK = f'async_lease_fast_with_hb_{ RUN_ID } '
69+ FAST_NO_HB_TASK = f'async_lease_fast_no_hb_{ RUN_ID } '
70+
6071
6172# -- Async Workers -----------------------------------------------------------
6273
6374@worker_task (
64- task_definition_name = 'async_lease_heartbeat_task' ,
75+ task_definition_name = HEARTBEAT_TASK ,
6576 lease_extend_enabled = True ,
6677 register_task_def = True ,
6778 task_def = TaskDef (
68- name = 'async_lease_heartbeat_task' ,
79+ name = HEARTBEAT_TASK ,
6980 response_timeout_seconds = RESPONSE_TIMEOUT_SECONDS ,
7081 timeout_seconds = 180 ,
7182 retry_count = 0 ,
@@ -82,11 +93,11 @@ async def async_lease_heartbeat_task(job_id: str) -> dict:
8293
8394
8495@worker_task (
85- task_definition_name = 'async_lease_no_heartbeat_task' ,
96+ task_definition_name = NO_HEARTBEAT_TASK ,
8697 lease_extend_enabled = False ,
8798 register_task_def = True ,
8899 task_def = TaskDef (
89- name = 'async_lease_no_heartbeat_task' ,
100+ name = NO_HEARTBEAT_TASK ,
90101 response_timeout_seconds = RESPONSE_TIMEOUT_SECONDS ,
91102 timeout_seconds = 120 ,
92103 retry_count = 0 ,
@@ -103,11 +114,11 @@ async def async_lease_no_heartbeat_task(job_id: str) -> dict:
103114
104115
105116@worker_task (
106- task_definition_name = 'async_lease_fast_with_hb' ,
117+ task_definition_name = FAST_HB_TASK ,
107118 lease_extend_enabled = True ,
108119 register_task_def = True ,
109120 task_def = TaskDef (
110- name = 'async_lease_fast_with_hb' ,
121+ name = FAST_HB_TASK ,
111122 response_timeout_seconds = 60 ,
112123 timeout_seconds = 120 ,
113124 retry_count = 0 ,
@@ -121,11 +132,11 @@ async def async_lease_fast_with_hb(job_id: str) -> dict:
121132
122133
123134@worker_task (
124- task_definition_name = 'async_lease_fast_no_hb' ,
135+ task_definition_name = FAST_NO_HB_TASK ,
125136 lease_extend_enabled = False ,
126137 register_task_def = True ,
127138 task_def = TaskDef (
128- name = 'async_lease_fast_no_hb' ,
139+ name = FAST_NO_HB_TASK ,
129140 response_timeout_seconds = 60 ,
130141 timeout_seconds = 120 ,
131142 retry_count = 0 ,
@@ -147,10 +158,10 @@ class TestAsyncLeaseExtension(unittest.TestCase):
147158 # it doesn't spin up every @worker_task registered across the imported test
148159 # suite (that was ~24 worker processes started/torn down per test).
149160 WORKER_TASK_NAMES = {
150- 'async_lease_heartbeat_task' ,
151- 'async_lease_no_heartbeat_task' ,
152- 'async_lease_fast_with_hb' ,
153- 'async_lease_fast_no_hb' ,
161+ HEARTBEAT_TASK ,
162+ NO_HEARTBEAT_TASK ,
163+ FAST_HB_TASK ,
164+ FAST_NO_HB_TASK ,
154165 }
155166
156167 @classmethod
@@ -224,14 +235,25 @@ def _start_workflow(self, wf_name, job_id):
224235 logger .info ("Started workflow %s: %s" , wf_name , wf_id )
225236 return wf_id
226237
238+ TERMINAL_STATES = ('COMPLETED' , 'FAILED' , 'TIMED_OUT' , 'TERMINATED' )
239+
227240 def _wait_for_workflow (self , wf_id , timeout_seconds = 90 ):
228- """Poll until workflow reaches a terminal state."""
241+ """Poll until workflow reaches a terminal state. If it doesn't within
242+ the budget, dump server-side diagnostics so the ensuing assertion shows
243+ *why* (e.g. a task stuck in SCHEDULED with no poller) rather than only a
244+ bare status mismatch.
245+ """
229246 for _ in range (timeout_seconds ):
230247 wf = self .workflow_client .get_workflow (wf_id , include_tasks = True )
231- if wf .status in ( 'COMPLETED' , 'FAILED' , 'TIMED_OUT' , 'TERMINATED' ) :
248+ if wf .status in self . TERMINAL_STATES :
232249 return wf
233250 time .sleep (1 )
234- return self .workflow_client .get_workflow (wf_id , include_tasks = True )
251+ wf = self .workflow_client .get_workflow (wf_id , include_tasks = True )
252+ if wf .status not in self .TERMINAL_STATES :
253+ print (f" [diag] workflow { wf_id } still { wf .status } "
254+ f"after { timeout_seconds } s" )
255+ self ._dump_workflow_diagnostics (wf )
256+ return wf
235257
236258 def _dump_workflow_diagnostics (self , wf ):
237259 """Print task statuses plus server-side poll data / queue size so a
@@ -273,8 +295,8 @@ def test_01_async_with_heartbeat_completes(self):
273295 print (f" responseTimeoutSeconds={ RESPONSE_TIMEOUT_SECONDS } s, task sleeps { TASK_SLEEP_SECONDS } s" )
274296 print ("=" * 80 )
275297
276- wf_name = 'test_async_lease_heartbeat '
277- self ._register_workflow (wf_name , 'async_lease_heartbeat_task' )
298+ wf_name = f'test_async_lease_heartbeat_ { RUN_ID } '
299+ self ._register_workflow (wf_name , HEARTBEAT_TASK )
278300
279301 wf_id = self ._start_workflow (wf_name , 'ASYNC-HB-001' )
280302 wf = self ._wait_for_workflow (wf_id , timeout_seconds = 80 )
@@ -288,7 +310,7 @@ def test_01_async_with_heartbeat_completes(self):
288310 f"Workflow should COMPLETE with heartbeat, got { wf .status } " )
289311
290312 tasks_by_ref = {t .reference_task_name : t for t in wf .tasks }
291- task = tasks_by_ref .get ('async_lease_heartbeat_task_ref ' )
313+ task = tasks_by_ref .get (f' { HEARTBEAT_TASK } _ref ' )
292314 self .assertIsNotNone (task )
293315 self .assertEqual (task .status , 'COMPLETED' )
294316 self .assertEqual (task .output_data .get ('job_id' ), 'ASYNC-HB-001' )
@@ -302,8 +324,8 @@ def test_02_async_without_heartbeat_times_out(self):
302324 print (f" responseTimeoutSeconds={ RESPONSE_TIMEOUT_SECONDS } s, task sleeps { TASK_SLEEP_SECONDS } s" )
303325 print ("=" * 80 )
304326
305- wf_name = 'test_async_lease_no_heartbeat '
306- self ._register_workflow (wf_name , 'async_lease_no_heartbeat_task' )
327+ wf_name = f'test_async_lease_no_heartbeat_ { RUN_ID } '
328+ self ._register_workflow (wf_name , NO_HEARTBEAT_TASK )
307329
308330 wf_id = self ._start_workflow (wf_name , 'ASYNC-NOHB-001' )
309331 wf = self ._wait_for_workflow (wf_id , timeout_seconds = 80 )
@@ -313,14 +335,11 @@ def test_02_async_without_heartbeat_times_out(self):
313335 for task in (wf .tasks or []):
314336 print (f" Task { task .task_def_name } : { task .status } " )
315337
316- if wf .status not in ('FAILED' , 'TIMED_OUT' ):
317- self ._dump_workflow_diagnostics (wf )
318-
319338 self .assertIn (wf .status , ('FAILED' , 'TIMED_OUT' ),
320339 f"Workflow should FAIL/TIMEOUT without heartbeat, got { wf .status } " )
321340
322341 tasks_by_ref = {t .reference_task_name : t for t in wf .tasks }
323- task = tasks_by_ref .get ('async_lease_no_heartbeat_task_ref ' )
342+ task = tasks_by_ref .get (f' { NO_HEARTBEAT_TASK } _ref ' )
324343 self .assertIsNotNone (task )
325344 self .assertIn (task .status , ('TIMED_OUT' , 'FAILED' , 'CANCELED' ),
326345 f"Task should be TIMED_OUT/FAILED, got { task .status } " )
@@ -333,10 +352,10 @@ def test_03_no_performance_overhead(self):
333352 print (f" Running { PERF_TASK_COUNT } tasks each, sleep={ FAST_TASK_SLEEP_SECONDS } s" )
334353 print ("=" * 80 )
335354
336- wf_with_hb = 'test_async_perf_with_hb '
337- wf_no_hb = 'test_async_perf_no_hb '
338- self ._register_workflow (wf_with_hb , 'async_lease_fast_with_hb' )
339- self ._register_workflow (wf_no_hb , 'async_lease_fast_no_hb' )
355+ wf_with_hb = f'test_async_perf_with_hb_ { RUN_ID } '
356+ wf_no_hb = f'test_async_perf_no_hb_ { RUN_ID } '
357+ self ._register_workflow (wf_with_hb , FAST_HB_TASK )
358+ self ._register_workflow (wf_no_hb , FAST_NO_HB_TASK )
340359
341360 # Run tasks WITH heartbeat tracking
342361 hb_workflow_ids = []
0 commit comments