Skip to content

Commit fe86af0

Browse files
further measures to ensure no contention between test runs
1 parent f81e5e4 commit fe86af0

4 files changed

Lines changed: 180 additions & 66 deletions

File tree

tests/integration/test_async_lease_extension.py

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import sys
2626
import time
2727
import unittest
28+
from uuid import uuid4
2829

2930
import pytest
3031

@@ -57,15 +58,25 @@
5758
# Number of fast tasks for performance comparison
5859
PERF_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 = []

tests/integration/test_comprehensive_e2e.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from dataclasses import dataclass
3333
from typing import Optional, List, Dict, Union
3434
from collections import defaultdict
35+
from uuid import uuid4
3536

3637
from conductor.client.worker.exception import NonRetryableException
3738

@@ -70,15 +71,28 @@ def on_task_execution_failure(self, e): self.events['exec_failed'].append(e)
7071
def on_task_update_failure(self, e): self.events['update_failed'].append(e)
7172

7273

74+
# Per-run suffix so this suite's task/workflow names don't collide with other
75+
# runs (or other PRs/developers) on the shared dev server. With fixed names,
76+
# concurrent runs poll the same queues and steal/strand each other's tasks,
77+
# producing non-deterministic SCHEDULED failures.
78+
RUN_ID = uuid4().hex[:8]
79+
SYNC_BASIC = f'sync_basic_{RUN_ID}'
80+
ASYNC_BASIC = f'async_basic_{RUN_ID}'
81+
COMPLEX_SCHEMA = f'complex_schema_{RUN_ID}'
82+
TASK_IN_PROGRESS = f'task_in_progress_{RUN_ID}'
83+
FAILING_TASK = f'failing_task_{RUN_ID}'
84+
WF_NAME = f'e2e_comprehensive_test_{RUN_ID}'
85+
86+
7387
# Test workers covering all scenarios
74-
@worker_task(task_definition_name='sync_basic', thread_count=5, register_task_def=True)
88+
@worker_task(task_definition_name=SYNC_BASIC, thread_count=5, register_task_def=True)
7589
def sync_basic(value: str, count: int) -> dict:
7690
ctx = get_task_context()
7791
ctx.add_log(f"Processing {value}")
7892
return {'value': value, 'count': count, 'worker': 'sync'}
7993

8094

81-
@worker_task(task_definition_name='async_basic', thread_count=10, register_task_def=True)
95+
@worker_task(task_definition_name=ASYNC_BASIC, thread_count=10, register_task_def=True)
8296
async def async_basic(message: str) -> dict:
8397
await asyncio.sleep(0.1)
8498
return {'message': message, 'worker': 'async'}
@@ -92,17 +106,17 @@ class OrderData:
92106

93107

94108
@worker_task(
95-
task_definition_name='complex_schema',
109+
task_definition_name=COMPLEX_SCHEMA,
96110
register_task_def=True,
97111
strict_schema=True,
98-
task_def=TaskDef(name='complex_schema', retry_count=2, timeout_policy='RETRY')
112+
task_def=TaskDef(name=COMPLEX_SCHEMA, retry_count=2, timeout_policy='RETRY')
99113
)
100114
def complex_schema(data: OrderData, optional: Optional[str]) -> dict:
101115
assert data.id is not None
102116
return {'id': data.id, 'amount': data.amount, 'tag_count': len(data.tags)}
103117

104118

105-
@worker_task(task_definition_name='task_in_progress')
119+
@worker_task(task_definition_name=TASK_IN_PROGRESS)
106120
def task_in_progress(job_id: str) -> Union[dict, TaskInProgress]:
107121
ctx = get_task_context()
108122
polls = ctx.get_poll_count()
@@ -111,7 +125,7 @@ def task_in_progress(job_id: str) -> Union[dict, TaskInProgress]:
111125
return {'job_id': job_id, 'polls': polls}
112126

113127

114-
@worker_task(task_definition_name='failing_task')
128+
@worker_task(task_definition_name=FAILING_TASK)
115129
def failing_task(should_fail: bool) -> dict:
116130
if should_fail:
117131
raise NonRetryableException("Test failure")
@@ -126,11 +140,11 @@ class TestComprehensiveE2E(unittest.TestCase):
126140
# SCHEDULED forever (which is exactly how a non-starting task_in_progress
127141
# worker manifested as a "4 != 5 tasks" failure in CI).
128142
EXPECTED_WORKERS = (
129-
'sync_basic',
130-
'async_basic',
131-
'complex_schema',
132-
'task_in_progress',
133-
'failing_task',
143+
SYNC_BASIC,
144+
ASYNC_BASIC,
145+
COMPLEX_SCHEMA,
146+
TASK_IN_PROGRESS,
147+
FAILING_TASK,
134148
)
135149

136150
@classmethod
@@ -163,18 +177,18 @@ def test_01_create_workflow(self):
163177

164178
metadata_client = OrkesMetadataClient(self.config)
165179

166-
workflow = WorkflowDef(name='e2e_comprehensive_test', version=1)
180+
workflow = WorkflowDef(name=WF_NAME, version=1)
167181
tasks = [
168-
WorkflowTask(name='sync_basic', task_reference_name='sync_1',
182+
WorkflowTask(name=SYNC_BASIC, task_reference_name='sync_1',
169183
input_parameters={'value': 'test', 'count': 1}),
170-
WorkflowTask(name='async_basic', task_reference_name='async_1',
184+
WorkflowTask(name=ASYNC_BASIC, task_reference_name='async_1',
171185
input_parameters={'message': 'hello'}),
172-
WorkflowTask(name='complex_schema', task_reference_name='complex_1',
186+
WorkflowTask(name=COMPLEX_SCHEMA, task_reference_name='complex_1',
173187
input_parameters={'data': {'id': '123', 'amount': 99.99, 'tags': ['a', 'b']},
174188
'optional': None}),
175-
WorkflowTask(name='task_in_progress', task_reference_name='tip_1',
189+
WorkflowTask(name=TASK_IN_PROGRESS, task_reference_name='tip_1',
176190
input_parameters={'job_id': 'JOB1'}),
177-
WorkflowTask(name='failing_task', task_reference_name='fail_1',
191+
WorkflowTask(name=FAILING_TASK, task_reference_name='fail_1',
178192
input_parameters={'should_fail': True}),
179193
]
180194
workflow._tasks = tasks
@@ -260,7 +274,7 @@ def _run_workflow_to_terminal(self, workflow_client, timeout_s=90):
260274
workflow (which is what produced the flaky "4 != 5 tasks" failure).
261275
"""
262276
req = StartWorkflowRequest()
263-
req.name = 'e2e_comprehensive_test'
277+
req.name = WF_NAME
264278
req.version = 1
265279
req.input = {}
266280

@@ -375,7 +389,7 @@ def test_05_verify_task_definitions(self):
375389

376390
metadata_client = OrkesMetadataClient(self.config)
377391

378-
tasks_to_check = ['sync_basic', 'async_basic', 'complex_schema']
392+
tasks_to_check = [SYNC_BASIC, ASYNC_BASIC, COMPLEX_SCHEMA]
379393

380394
for task_name in tasks_to_check:
381395
task_def = metadata_client.get_task_def(task_name)
@@ -384,15 +398,15 @@ def test_05_verify_task_definitions(self):
384398
print(f"✓ Task definition exists: {task_name}")
385399

386400
# Check schemas if they should exist
387-
if task_name in ['sync_basic', 'async_basic', 'complex_schema']:
401+
if task_name in tasks_to_check:
388402
# These have type hints, should have schemas
389403
if hasattr(task_def, 'input_schema') and task_def.input_schema:
390404
print(f" ✓ Has input schema")
391405
if hasattr(task_def, 'output_schema') and task_def.output_schema:
392406
print(f" ✓ Has output schema")
393407

394408
# Check complex_schema has TaskDef configuration
395-
complex_def = metadata_client.get_task_def('complex_schema')
409+
complex_def = metadata_client.get_task_def(COMPLEX_SCHEMA)
396410
self.assertEqual(complex_def.retry_count, 2, "Retry count from task_def should be applied")
397411
self.assertEqual(complex_def.timeout_policy, 'RETRY', "Timeout policy should be set")
398412

0 commit comments

Comments
 (0)