Skip to content

Commit a8c5414

Browse files
feat: add worker signal to skip task result update
1 parent 2f269bd commit a8c5414

7 files changed

Lines changed: 141 additions & 27 deletions

File tree

src/conductor/client/automator/async_task_runner.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from conductor.client.configuration.configuration import Configuration
1010
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
11-
from conductor.client.context.task_context import _set_task_context, _clear_task_context, TaskInProgress
11+
from conductor.client.context.task_context import _set_task_context, _clear_task_context, TaskInProgress, LEAVE_TASK_UNRESOLVED
1212
from conductor.client.event.task_runner_events import (
1313
TaskRunnerEvent, PollStarted, PollCompleted, PollFailure,
1414
TaskExecutionStarted, TaskExecutionCompleted, TaskExecutionFailure,
@@ -619,6 +619,14 @@ async def __async_execute_and_update_task(self, task: Task) -> None:
619619
task_result = await self.__async_execute_task(task)
620620
if task_result is None:
621621
return
622+
# Worker returned LEAVE_TASK_UNRESOLVED: send no update and
623+
# stop extending the lease (finally untracks) so the server
624+
# resolves the task itself (e.g. responseTimeout).
625+
if task_result is LEAVE_TASK_UNRESOLVED:
626+
logger.debug(
627+
"Async task %s left unresolved by worker; not updating server",
628+
task.task_id)
629+
return
622630
self._untrack_lease(task.task_id)
623631
# Update task and get next task from v2 response
624632
task = await self.__async_update_task(task_result)
@@ -693,6 +701,15 @@ async def __async_execute_task(self, task: Task) -> TaskResult:
693701
# Direct await of async worker function - NO THREADS!
694702
task_output = await self.worker.execute_function(**task_input)
695703

704+
# Worker deliberately left the task unresolved: report nothing to
705+
# the server (see LEAVE_TASK_UNRESOLVED docs). The finally block
706+
# clears the task context; the caller stops managing the task.
707+
if task_output is LEAVE_TASK_UNRESOLVED:
708+
logger.debug(
709+
"Async task %s left unresolved by worker; not updating server",
710+
task.task_id)
711+
return LEAVE_TASK_UNRESOLVED
712+
696713
# Handle different return types (same as TaskRunner:441-474)
697714
if isinstance(task_output, TaskResult):
698715
# Already a TaskResult - use as-is

src/conductor/client/automator/task_runner.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from conductor.client.configuration.configuration import Configuration
1313
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
14-
from conductor.client.context.task_context import _set_task_context, _clear_task_context, TaskInProgress
14+
from conductor.client.context.task_context import _set_task_context, _clear_task_context, TaskInProgress, LEAVE_TASK_UNRESOLVED
1515
from conductor.client.event.task_runner_events import (
1616
TaskRunnerEvent, PollStarted, PollCompleted, PollFailure,
1717
TaskExecutionStarted, TaskExecutionCompleted, TaskExecutionFailure,
@@ -568,6 +568,14 @@ def __execute_and_update_task(self, task: Task) -> None:
568568
logger.debug("Task %s is running async, will update when complete", task.task_id)
569569
async_running = True
570570
return
571+
# Worker returned LEAVE_TASK_UNRESOLVED: send no update and stop
572+
# extending the lease so the server can resolve the task itself
573+
# (e.g. responseTimeout). The finally block untracks the lease.
574+
if task_result is LEAVE_TASK_UNRESOLVED:
575+
logger.debug(
576+
"Task %s left unresolved by worker; not updating server",
577+
task.task_id)
578+
return
571579
self._untrack_lease(task.task_id)
572580
# Update task and get next task from v2 response
573581
task = self.__update_task(task_result)
@@ -790,6 +798,13 @@ def __execute_task(self, task: Task) -> TaskResult:
790798
_clear_task_context()
791799
return None
792800

801+
# Worker deliberately left the task unresolved: report nothing to
802+
# the server and let the caller stop managing the task (see
803+
# LEAVE_TASK_UNRESOLVED docs).
804+
if task_output is LEAVE_TASK_UNRESOLVED:
805+
_clear_task_context()
806+
return LEAVE_TASK_UNRESOLVED
807+
793808
# Handle different return types
794809
if isinstance(task_output, TaskResult):
795810
# Already a TaskResult - use as-is

src/conductor/client/context/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ def process_video(video_id: str) -> Union[GeneratedVideo, TaskInProgress]:
2626
TaskContext,
2727
get_task_context,
2828
TaskInProgress,
29+
LEAVE_TASK_UNRESOLVED,
2930
)
3031

3132
__all__ = [
3233
'TaskContext',
3334
'get_task_context',
3435
'TaskInProgress',
36+
'LEAVE_TASK_UNRESOLVED',
3537
]

src/conductor/client/context/task_context.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,51 @@ def __repr__(self) -> str:
9696
return f"TaskInProgress(callback_after={self.callback_after_seconds}s, output={self.output})"
9797

9898

99+
class _LeaveTaskUnresolved:
100+
"""Sentinel return value telling the SDK to report NO result for the task.
101+
102+
When a worker returns ``LEAVE_TASK_UNRESOLVED`` the task runner sends no
103+
update to the server (no COMPLETED / FAILED / IN_PROGRESS) and stops
104+
extending the lease. The task is left exactly as the server already has it
105+
(IN_PROGRESS with its existing lease), so the *server* decides its fate —
106+
e.g. it TIMES_OUT after ``responseTimeoutSeconds`` if nothing updates it.
107+
108+
This is distinct from:
109+
- a normal return value -> task COMPLETED
110+
- raising an exception -> task FAILED
111+
- ``TaskInProgress`` -> task reported IN_PROGRESS with a callback
112+
(which resets the response-timeout clock)
113+
114+
It is fully backward compatible: workers that never return this behave
115+
exactly as before. Useful when the task's resolution is genuinely
116+
out-of-band (handed to another process/system that will update it later),
117+
or in tests that need the server — not the worker — to resolve a task.
118+
119+
Usage:
120+
from conductor.client.context import LEAVE_TASK_UNRESOLVED
121+
122+
@worker_task(task_definition_name='handoff_task')
123+
def handoff(job_id: str):
124+
enqueue_for_external_system(job_id)
125+
# Don't complete/fail here; something else will update the task.
126+
return LEAVE_TASK_UNRESOLVED
127+
"""
128+
129+
_instance = None
130+
131+
def __new__(cls):
132+
if cls._instance is None:
133+
cls._instance = super().__new__(cls)
134+
return cls._instance
135+
136+
def __repr__(self) -> str:
137+
return "LEAVE_TASK_UNRESOLVED"
138+
139+
140+
# Singleton sentinel; compare with `is`.
141+
LEAVE_TASK_UNRESOLVED = _LeaveTaskUnresolved()
142+
143+
99144
# Context variable for storing TaskContext (thread-safe and async-safe)
100145
_task_context_var: ContextVar[Optional['TaskContext']] = ContextVar('task_context', default=None)
101146

src/conductor/client/worker/worker.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,12 @@ def execute(self, task: Task) -> TaskResult:
514514
task_output.workflow_instance_id = task.workflow_instance_id
515515
return task_output
516516
# Import here to avoid circular dependency
517-
from conductor.client.context.task_context import TaskInProgress
517+
from conductor.client.context.task_context import TaskInProgress, LEAVE_TASK_UNRESOLVED
518+
if task_output is LEAVE_TASK_UNRESOLVED:
519+
# Worker is deliberately not reporting a result. Hand the
520+
# sentinel back so the TaskRunner skips the server update and
521+
# leaves the task as-is (see LEAVE_TASK_UNRESOLVED docs).
522+
return task_output
518523
if isinstance(task_output, TaskInProgress):
519524
# Return TaskInProgress as-is for TaskRunner to handle
520525
return task_output
@@ -600,6 +605,16 @@ def check_completed_async_tasks(self) -> list:
600605
# Get result (won't block since future is done)
601606
task_output = future.result(timeout=0)
602607

608+
# Worker deliberately left the task unresolved: drop it
609+
# from the pending set and report nothing to the server.
610+
from conductor.client.context.task_context import LEAVE_TASK_UNRESOLVED
611+
if task_output is LEAVE_TASK_UNRESOLVED:
612+
logger.debug(
613+
"Async task %s (%s) returned LEAVE_TASK_UNRESOLVED; "
614+
"not reporting a result", task_id, task.task_def_name)
615+
tasks_to_remove.append(task_id)
616+
continue
617+
603618
# Process result same as sync execution
604619
if isinstance(task_output, TaskResult):
605620
task_output.task_id = task.task_id

tests/integration/test_async_lease_extension.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
from conductor.client.automator.task_handler import TaskHandler, get_registered_workers
3535
from conductor.client.configuration.configuration import Configuration
36+
from conductor.client.context.task_context import LEAVE_TASK_UNRESOLVED
3637
from conductor.client.worker.worker_task import worker_task
3738
from conductor.client.http.models.workflow_def import WorkflowDef
3839
from conductor.client.http.models.task_def import TaskDef
@@ -74,9 +75,10 @@ def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
7475
# Short response timeout — task must heartbeat to stay alive
7576
RESPONSE_TIMEOUT_SECONDS = 10
7677

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.
78+
# The heartbeat worker sleeps this long (well beyond responseTimeout) to prove
79+
# that lease extension keeps a genuinely long-running task alive. The
80+
# no-heartbeat worker no longer sleeps — it returns LEAVE_TASK_UNRESOLVED so the
81+
# server times the task out deterministically (see async_lease_no_heartbeat_task).
8082
TASK_SLEEP_SECONDS = 50
8183

8284
# Short task duration for performance test — well within timeout
@@ -131,13 +133,20 @@ async def async_lease_heartbeat_task(job_id: str) -> dict:
131133
),
132134
overwrite_task_def=True,
133135
)
134-
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)
139-
logger.info("[async_no_heartbeat] Completed job %s", job_id)
140-
return {'job_id': job_id, 'status': 'completed', 'slept': TASK_SLEEP_SECONDS}
136+
async def async_lease_no_heartbeat_task(job_id: str):
137+
"""Async task without heartbeat that deliberately never resolves itself.
138+
139+
Returning LEAVE_TASK_UNRESOLVED makes the SDK lease the task but report no
140+
result and stop extending the lease. With no heartbeat, the lease expires
141+
after responseTimeoutSeconds and the server TIMES_OUT the task — with no
142+
race against a worker completion. (The old version slept 50s then completed,
143+
so a slow server sweeper could let the completion win, producing the flaky
144+
"expected TIMED_OUT but got COMPLETED" failures.)
145+
"""
146+
logger.info("[async_no_heartbeat] Picked up job %s; leaving it unresolved so "
147+
"the server times it out (responseTimeout=%ss)",
148+
job_id, RESPONSE_TIMEOUT_SECONDS)
149+
return LEAVE_TASK_UNRESOLVED
141150

142151

143152
@worker_task(
@@ -357,10 +366,11 @@ def test_01_async_with_heartbeat_completes(self):
357366
print("\n PASS: Async task completed with heartbeat keeping lease alive")
358367

359368
def test_02_async_without_heartbeat_times_out(self):
360-
"""Async task WITHOUT lease_extend_enabled times out when sleep > responseTimeout."""
369+
"""Async task WITHOUT lease_extend_enabled times out when it isn't resolved."""
361370
print("\n" + "=" * 80)
362371
print("TEST: Async without heartbeat — task should TIME OUT")
363-
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task sleeps {TASK_SLEEP_SECONDS}s")
372+
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, "
373+
f"worker leaves task unresolved (server must time it out)")
364374
print("=" * 80)
365375

366376
wf_name = f'test_async_lease_no_heartbeat_{RUN_ID}'

tests/integration/test_lease_extension.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
from conductor.client.automator.task_handler import TaskHandler
3333
from conductor.client.configuration.configuration import Configuration
34+
from conductor.client.context.task_context import LEAVE_TASK_UNRESOLVED
3435
from conductor.client.worker.worker_task import worker_task
3536
from conductor.client.http.models.workflow_def import WorkflowDef
3637
from conductor.client.http.models.task_def import TaskDef
@@ -72,9 +73,10 @@ def _retry_on_transient(func, *args, retries=4, base_delay=1, **kwargs):
7273
# Short response timeout — task must heartbeat to stay alive
7374
RESPONSE_TIMEOUT_SECONDS = 10
7475

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.
76+
# The heartbeat worker sleeps this long (well beyond responseTimeout) to prove
77+
# that lease extension keeps a genuinely long-running task alive. The
78+
# no-heartbeat worker no longer sleeps — it returns LEAVE_TASK_UNRESOLVED so the
79+
# server times the task out deterministically (see lease_no_heartbeat_task).
7880
TASK_SLEEP_SECONDS = 50
7981

8082
# Per-run suffix so this suite's task/workflow names don't collide with other
@@ -123,13 +125,20 @@ def lease_heartbeat_task(job_id: str) -> dict:
123125
),
124126
overwrite_task_def=True,
125127
)
126-
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)
131-
logger.info("[no_heartbeat_task] Completed job %s", job_id)
132-
return {'job_id': job_id, 'status': 'completed', 'slept': TASK_SLEEP_SECONDS}
128+
def lease_no_heartbeat_task(job_id: str):
129+
"""Task without heartbeat that deliberately never resolves itself.
130+
131+
Returning LEAVE_TASK_UNRESOLVED makes the SDK lease the task but report no
132+
result and stop extending the lease. With no heartbeat, the lease expires
133+
after responseTimeoutSeconds and the server TIMES_OUT the task — with no
134+
race against a worker completion. (The old version slept 50s then completed,
135+
so a slow server sweeper could let the completion win, producing the flaky
136+
"expected TIMED_OUT but got COMPLETED" failures.)
137+
"""
138+
logger.info("[no_heartbeat_task] Picked up job %s; leaving it unresolved so "
139+
"the server times it out (responseTimeout=%ss)",
140+
job_id, RESPONSE_TIMEOUT_SECONDS)
141+
return LEAVE_TASK_UNRESOLVED
133142

134143

135144
# -- Test class --------------------------------------------------------------
@@ -286,10 +295,11 @@ def test_01_with_heartbeat_completes(self):
286295
stop_workers()
287296

288297
def test_02_without_heartbeat_times_out(self):
289-
"""Task WITHOUT lease_extend_enabled times out when sleep > responseTimeout."""
298+
"""Task WITHOUT lease_extend_enabled times out when it isn't resolved."""
290299
print("\n" + "=" * 80)
291300
print("TEST: Without heartbeat — task should TIME OUT")
292-
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, task sleeps {TASK_SLEEP_SECONDS}s")
301+
print(f" responseTimeoutSeconds={RESPONSE_TIMEOUT_SECONDS}s, "
302+
f"worker leaves task unresolved (server must time it out)")
293303
print("=" * 80)
294304

295305
wf_name = f'test_lease_no_heartbeat_{RUN_ID}'

0 commit comments

Comments
 (0)