Skip to content

Commit eaa64a1

Browse files
refactored some of the retry stuff to dedupe logic
1 parent a5dcae6 commit eaa64a1

6 files changed

Lines changed: 145 additions & 109 deletions

File tree

tests/integration/client/orkes/test_orkes_clients.py

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from conductor.client.workflow.conductor_workflow import ConductorWorkflow
2727
from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
2828
from conductor.client.workflow.task.simple_task import SimpleTask
29-
from tests.integration.retry_helpers import retry_scenario, TERMINAL_WORKFLOW_STATES
29+
from tests.integration.retry_helpers import retry_scenario, wait_for_workflow_terminal
3030

3131
SUFFIX = str(uuid())
3232
WORKFLOW_NAME = 'IntegrationTestOrkesClientsWf_' + SUFFIX
@@ -471,30 +471,15 @@ def __poll_workflow_until_complete(self, workflow_id, timeout_seconds=60,
471471
poll_interval=2):
472472
"""Poll ``workflow_id`` until it reaches a terminal state or the timeout
473473
passes, returning the last observed Workflow (or None if it could never
474-
be fetched). Transient poll errors are logged and retried within the
474+
be fetched). Transient poll errors are swallowed and retried within the
475475
timeout so a slow-but-eventually-complete run isn't reported as a bare
476-
failure.
476+
failure. Thin wrapper over the shared ``wait_for_workflow_terminal``.
477477
"""
478-
deadline = time.monotonic() + timeout_seconds
479-
workflow = None
480-
while True:
481-
try:
482-
workflow = self.workflow_client.get_workflow(
483-
workflow_id, include_tasks=True)
484-
except Exception as e: # transient blip against the shared server
485-
print(f"[test_workflow] error polling {workflow_id}: {e}")
486-
status = getattr(workflow, "status", None)
487-
if status in TERMINAL_WORKFLOW_STATES:
488-
print(f"[test_workflow] {workflow_id} reached {status}")
489-
return workflow
490-
if time.monotonic() >= deadline:
491-
print(
492-
f"[test_workflow] {workflow_id} still {status} after "
493-
f"{timeout_seconds}s; giving up wait"
494-
)
495-
return workflow
496-
print(f"[test_workflow] {workflow_id} still {status}; waiting")
497-
time.sleep(poll_interval)
478+
return wait_for_workflow_terminal(
479+
self.workflow_client, workflow_id,
480+
timeout_seconds=timeout_seconds, poll_interval=poll_interval,
481+
include_tasks=True, swallow='all',
482+
log=lambda msg: print(f"[test_workflow] {msg}"))
498483

499484
def __test_unregister_workflow_definition(self):
500485
self.metadata_client.unregister_workflow_def(WORKFLOW_NAME, 1)

tests/integration/retry_helpers.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,83 @@ def retry_scenario(label, func, *args, deadline=None,
140140
deadline - now, transient)
141141
time.sleep(delay)
142142
attempt += 1
143+
144+
145+
def wait_for_workflow_terminal(
146+
client, workflow_id, *,
147+
timeout_seconds=None, deadline=None, poll_interval=2,
148+
include_tasks=False, terminal_states=TERMINAL_WORKFLOW_STATES,
149+
is_terminal=None, swallow='transient',
150+
log=None, on_poll=None, on_giveup=None):
151+
"""Poll ``client.get_workflow(workflow_id, include_tasks=...)`` until the
152+
workflow reaches a terminal state (or the wall-clock budget is exhausted),
153+
returning the last observed ``Workflow`` (or ``None`` if it could never be
154+
fetched). This is the single shared "poll a workflow to terminal" primitive
155+
for the integration suites — callers wrap it to preserve their own return
156+
shape / side effects.
157+
158+
Args:
159+
client: anything exposing ``get_workflow(workflow_id, include_tasks=...)``
160+
(both ``OrkesWorkflowClient`` and ``WorkflowExecutor`` qualify).
161+
timeout_seconds: relative budget; an internal ``time.monotonic()``
162+
deadline is derived from it. Ignored when ``deadline`` is given.
163+
deadline: absolute ``time.monotonic()`` value to stop at. When both this
164+
and ``timeout_seconds`` are ``None`` the workflow is polled exactly
165+
once (no wait).
166+
poll_interval: seconds between polls.
167+
include_tasks: passed through to ``get_workflow``.
168+
terminal_states: statuses considered terminal (default
169+
``TERMINAL_WORKFLOW_STATES``); used by the default predicate.
170+
is_terminal: optional ``predicate(workflow) -> bool`` overriding the
171+
default "status in terminal_states" check (e.g. to also gate on an
172+
expected task count).
173+
swallow: error policy for a failing poll — ``'transient'`` swallows only
174+
transient blips (status 0/None) and re-raises real errors;
175+
``'all'`` swallows every exception and keeps polling; ``'none'`` lets
176+
any exception propagate.
177+
log: optional ``callable(str)`` for progress lines (e.g. ``logger.info``
178+
or ``print``). Defaults to ``logger.debug``.
179+
on_poll: optional ``callable(workflow)`` invoked after each successful
180+
poll, before the terminal check (e.g. to print per-task diagnostics).
181+
on_giveup: optional ``callable(workflow)`` invoked once when the budget
182+
is exhausted without reaching terminal (e.g. to dump diagnostics).
183+
"""
184+
if log is None:
185+
log = logger.debug
186+
if is_terminal is None:
187+
def is_terminal(wf):
188+
return getattr(wf, 'status', None) in terminal_states
189+
if deadline is None and timeout_seconds is not None:
190+
deadline = time.monotonic() + timeout_seconds
191+
192+
start = time.monotonic()
193+
workflow = None
194+
while True:
195+
try:
196+
workflow = client.get_workflow(
197+
workflow_id, include_tasks=include_tasks)
198+
except Exception as e:
199+
if swallow == 'none':
200+
raise
201+
if swallow == 'transient' and not is_transient(e):
202+
raise
203+
log('error polling workflow %s (%.0fs elapsed): %s' % (
204+
workflow_id, time.monotonic() - start, e))
205+
else:
206+
if on_poll is not None:
207+
on_poll(workflow)
208+
if is_terminal(workflow):
209+
log('workflow %s reached %s after %.0fs' % (
210+
workflow_id, getattr(workflow, 'status', None),
211+
time.monotonic() - start))
212+
return workflow
213+
if deadline is None or time.monotonic() >= deadline:
214+
log('workflow %s still %s after %.0fs; giving up wait' % (
215+
workflow_id, getattr(workflow, 'status', None),
216+
time.monotonic() - start))
217+
if on_giveup is not None:
218+
on_giveup(workflow)
219+
return workflow
220+
log('workflow %s still %s; waiting' % (
221+
workflow_id, getattr(workflow, 'status', None)))
222+
time.sleep(poll_interval)

tests/integration/test_async_lease_extension.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,13 @@
3838
from conductor.client.http.models.task_def import TaskDef
3939
from conductor.client.http.models.workflow_task import WorkflowTask
4040
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
41-
from conductor.client.http.rest import ApiException
4241
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4342
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4443

4544
from tests.integration.retry_helpers import (
4645
TERMINAL_WORKFLOW_STATES,
47-
is_transient as _is_transient,
4846
retry_on_transient as _retry_on_transient,
47+
wait_for_workflow_terminal,
4948
)
5049

5150
logging.basicConfig(level=logging.INFO)
@@ -271,22 +270,17 @@ def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
271270
"""Poll until workflow reaches a terminal state. If it doesn't within
272271
the budget, dump server-side diagnostics so the ensuing assertion shows
273272
*why* (e.g. a task stuck in SCHEDULED with no poller) rather than only a
274-
bare status mismatch.
273+
bare status mismatch. Delegates the polling loop to the shared
274+
``wait_for_workflow_terminal`` (transient blips swallowed, real errors
275+
raised), then does a definitive final fetch + diagnostics on give-up.
275276
"""
276-
deadline = time.monotonic() + timeout_seconds
277-
while time.monotonic() < deadline:
278-
try:
279-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
280-
except ApiException as e:
281-
# A transient blip on a single poll shouldn't abort the wait;
282-
# keep polling until the budget is exhausted.
283-
if _is_transient(e):
284-
time.sleep(poll_interval)
285-
continue
286-
raise
287-
if wf.status in self.TERMINAL_STATES:
288-
return wf
289-
time.sleep(poll_interval)
277+
wf = wait_for_workflow_terminal(
278+
self.workflow_client, wf_id,
279+
timeout_seconds=timeout_seconds, poll_interval=poll_interval,
280+
include_tasks=True, terminal_states=self.TERMINAL_STATES,
281+
swallow='transient', log=lambda _msg: None)
282+
if wf is not None and wf.status in self.TERMINAL_STATES:
283+
return wf
290284
wf = _retry_on_transient(self.workflow_client.get_workflow,
291285
wf_id, include_tasks=True)
292286
if wf.status not in self.TERMINAL_STATES:

tests/integration/test_comprehensive_e2e.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
TaskExecutionStarted, TaskExecutionCompleted, TaskExecutionFailure,
5757
TaskUpdateFailure
5858
)
59+
from tests.integration.retry_helpers import wait_for_workflow_terminal
5960

6061
# Event collector
6162
class EventCollector(TaskRunnerEventsListener):
@@ -281,18 +282,24 @@ def _run_workflow_to_terminal(self, workflow_client, timeout_s=90):
281282
wf_id = workflow_client.start_workflow(start_workflow_request=req)
282283
print(f"✓ Started workflow: {wf_id}")
283284

284-
deadline = time.time() + timeout_s
285-
wf = None
286-
while time.time() < deadline:
287-
wf = workflow_client.get_workflow(wf_id, include_tasks=True)
285+
# "Terminal" here is stricter than the usual terminal-status check: we
286+
# also require the full expected task set to be present, so the caller
287+
# never asserts against a half-scheduled workflow (the flaky "4 != 5
288+
# tasks" case). Delegates polling to the shared wait_for_workflow_terminal.
289+
def _fully_materialized(wf):
290+
return getattr(wf, 'status', None) in ('COMPLETED', 'FAILED') \
291+
and len(wf.tasks or []) == self.EXPECTED_TASK_COUNT
292+
293+
def _show(wf):
288294
print(f" Status: {wf.status} - tasks={len(wf.tasks or [])}")
289-
if wf.status in ('COMPLETED', 'FAILED') \
290-
and len(wf.tasks or []) == self.EXPECTED_TASK_COUNT:
291-
return wf_id, wf
292295
for task in (wf.tasks or []):
293296
print(f'task {task.task_def_name} : {task.status}')
294-
time.sleep(1)
295297

298+
wf = wait_for_workflow_terminal(
299+
workflow_client, wf_id,
300+
timeout_seconds=timeout_s, poll_interval=1,
301+
include_tasks=True, is_terminal=_fully_materialized,
302+
swallow='none', log=lambda _msg: None, on_poll=_show)
296303
return wf_id, wf
297304

298305
def test_03_execute_workflow(self):

tests/integration/test_lease_extension.py

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,13 @@
3636
from conductor.client.http.models.task_def import TaskDef
3737
from conductor.client.http.models.workflow_task import WorkflowTask
3838
from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
39-
from conductor.client.http.rest import ApiException
4039
from conductor.client.orkes.orkes_workflow_client import OrkesWorkflowClient
4140
from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
4241

4342
from tests.integration.retry_helpers import (
4443
TERMINAL_WORKFLOW_STATES,
45-
is_transient as _is_transient,
4644
retry_on_transient as _retry_on_transient,
45+
wait_for_workflow_terminal,
4746
)
4847

4948
logging.basicConfig(level=logging.INFO)
@@ -182,22 +181,17 @@ def _wait_for_workflow(self, wf_id, timeout_seconds=POLL_TIMEOUT_SECONDS,
182181
"""Poll until workflow reaches a terminal state. If it doesn't within
183182
the budget, dump server-side diagnostics so the ensuing assertion shows
184183
*why* (e.g. a task stuck in SCHEDULED with no poller) rather than only a
185-
bare status mismatch.
184+
bare status mismatch. Delegates the polling loop to the shared
185+
``wait_for_workflow_terminal`` (transient blips swallowed, real errors
186+
raised), then does a definitive final fetch + diagnostics on give-up.
186187
"""
187-
deadline = time.monotonic() + timeout_seconds
188-
while time.monotonic() < deadline:
189-
try:
190-
wf = self.workflow_client.get_workflow(wf_id, include_tasks=True)
191-
except ApiException as e:
192-
# A transient blip on a single poll shouldn't abort the wait;
193-
# keep polling until the budget is exhausted.
194-
if _is_transient(e):
195-
time.sleep(poll_interval)
196-
continue
197-
raise
198-
if wf.status in self.TERMINAL_STATES:
199-
return wf
200-
time.sleep(poll_interval)
188+
wf = wait_for_workflow_terminal(
189+
self.workflow_client, wf_id,
190+
timeout_seconds=timeout_seconds, poll_interval=poll_interval,
191+
include_tasks=True, terminal_states=self.TERMINAL_STATES,
192+
swallow='transient', log=lambda _msg: None)
193+
if wf is not None and wf.status in self.TERMINAL_STATES:
194+
return wf
201195
wf = _retry_on_transient(self.workflow_client.get_workflow,
202196
wf_id, include_tasks=True)
203197
if wf.status not in self.TERMINAL_STATES:

tests/integration/workflow/test_workflow_execution.py

Lines changed: 20 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
1313
from conductor.client.workflow.task.simple_task import SimpleTask
1414
from tests.integration.resources.worker.python.python_worker import *
15-
from tests.integration.retry_helpers import retry_scenario, TERMINAL_WORKFLOW_STATES
15+
from tests.integration.retry_helpers import retry_scenario, wait_for_workflow_terminal
1616

1717
WORKFLOW_NAME = "sdk_python_integration_test_workflow"
1818
WORKFLOW_DESCRIPTION = "Python SDK Integration Test"
@@ -207,34 +207,19 @@ def scenario_workflow_execution(
207207

208208
def _wait_for_workflow_terminal(workflow_id, workflow_executor, deadline,
209209
poll_interval=2):
210-
"""Poll until the workflow reaches a terminal state or the shared deadline
211-
passes, logging progress (wf id + elapsed) so a slow-but-eventually-complete
212-
run is visible instead of surfacing as a bare timeout. A transient poll error
213-
is logged and retried. Returns the last observed status; the caller still
214-
runs validate_workflow_status for the actual assertion.
210+
"""Poll until the workflow reaches a terminal state or the shared ``deadline``
211+
(a ``time.time()`` wall-clock value shared across a batch) passes, logging
212+
progress so a slow-but-eventually-complete run is visible instead of a bare
213+
timeout. A transient poll error is logged and retried. Returns the last
214+
observed status; the caller still runs validate_workflow_status for the
215+
actual assertion. Thin wrapper over the shared ``wait_for_workflow_terminal``.
215216
"""
216-
start = time.time()
217-
status = None
218-
while True:
219-
try:
220-
workflow = workflow_executor.get_workflow(
221-
workflow_id=workflow_id, include_tasks=False)
222-
status = workflow.status
223-
except Exception as e: # transient blip against the shared server
224-
logger.warning('error polling workflow %s (%.0fs elapsed): %s',
225-
workflow_id, time.time() - start, e)
226-
if status in TERMINAL_WORKFLOW_STATES:
227-
logger.info('workflow %s reached %s after %.0fs',
228-
workflow_id, status, time.time() - start)
229-
return status
230-
if time.time() >= deadline:
231-
logger.warning(
232-
'workflow %s still %s after %.0fs; giving up wait',
233-
workflow_id, status, time.time() - start)
234-
return status
235-
logger.info('workflow %s still %s after %.0fs; waiting',
236-
workflow_id, status, time.time() - start)
237-
sleep(poll_interval)
217+
workflow = wait_for_workflow_terminal(
218+
workflow_executor, workflow_id,
219+
timeout_seconds=max(0.0, deadline - time.time()),
220+
poll_interval=poll_interval, include_tasks=False,
221+
swallow='all', log=logger.info)
222+
return getattr(workflow, 'status', None)
238223

239224

240225
def generate_workflow(workflow_executor: WorkflowExecutor, workflow_name: str = WORKFLOW_NAME,
@@ -467,22 +452,13 @@ def scenario_execute_workflow_error_handling(workflow_executor: WorkflowExecutor
467452

468453

469454
def _wait_for_workflow_completion(workflow_executor: WorkflowExecutor, workflow_id: str, max_wait_seconds: int = 60):
470-
"""Helper function to wait for workflow completion"""
471-
import time
472-
start_time = time.time()
473-
474-
while time.time() - start_time < max_wait_seconds:
475-
workflow = workflow_executor.get_workflow(workflow_id, True)
476-
477-
if workflow.status in TERMINAL_WORKFLOW_STATES:
478-
logger.debug(f'Workflow {workflow_id} finished with status: {workflow.status}')
479-
return workflow
480-
481-
logger.debug(f'Waiting for workflow {workflow_id}... Status: {workflow.status}')
482-
time.sleep(2)
483-
484-
# Return final state even if not completed
485-
return workflow_executor.get_workflow(workflow_id, True)
455+
"""Helper function to wait for workflow completion. Thin wrapper over the
456+
shared ``wait_for_workflow_terminal``; returns the last observed Workflow.
457+
"""
458+
return wait_for_workflow_terminal(
459+
workflow_executor, workflow_id,
460+
timeout_seconds=max_wait_seconds, poll_interval=2,
461+
include_tasks=True, swallow='none', log=logger.debug)
486462

487463
# ===== SIGNAL TESTS =====
488464

0 commit comments

Comments
 (0)