#1321 — Duplicate execution of async system tasks (queue-message reservation)
Issue: https://github.com/conductor-oss/conductor/issues/1321
Summary
An async system task whose synchronous execution outlasts the queue's redelivery
window is executed a second time in parallel by another system-task-worker.
For LLM_CHAT_COMPLETE this means duplicate paid provider calls (observed: all 4
agent-loop turns billed twice). The fix keeps a task's queue message present but
invisible while it runs, so the scheduler's own repair logic no longer re-queues
a task that is legitimately in flight.
Background: how a system task's message flows
SystemTaskWorker.pollAndExecute (per task-type queue), then AsyncSystemTaskExecutor:
queueDAO.pop(queue)→ the message moves to the queue's unacked set (invisible).executionService.ackTaskReceived(taskId)→queueDAO.ack→ the message is removed.AsyncSystemTaskExecutor.executeruns on a worker thread; the task'sstart()/execute()runs the work (for annotated@WorkerTaskadapters, the provider call runs synchronously here).- Only in
AsyncSystemTaskExecutor'sfinally— after the work returns — is the message removed (terminal) or re-posted (postpone, non-terminal).
Between step 2 and step 4 a running task has no message in the queue.
Root cause: a violated invariant + the sweeper's repair
WorkflowSweeper (org.conductoross.conductor.core.execution.WorkflowSweeper, on
by default) enforces on every sweep (~30s): "every running task MUST be in the
queue." Its repair re-pushes any repairable task whose message is missing:
if (isTaskRepairable.test(task) && !queueDAO.containsMessage(queue, taskId)) {
queueDAO.push(queue, taskId, task.getCallbackAfterSeconds()); // re-queue
}
For async system tasks isTaskRepairable is true in SCHEDULED and
IN_PROGRESS. So while the task runs (message removed at step 2, task still
non-terminal), a sweep sees "running task, no message" → re-pushes it → a
second worker pops it and runs the same task again → duplicate.
Remote worker tasks never hit this: their poll does not remove the message,
and their repair predicate requires SCHEDULED. Async system tasks have neither
guard. This is generic to every async system task — the message is briefly
absent for all of them; it is only reliably hit by long-running (LLM/A2A) tasks
whose window (minutes) is wider than the 30s sweep.
Fix (what): don't remove the message at poll; reserve it for the run
Two small changes, both on the shared execution path (no per-task-type gating):
-
SystemTaskWorkerstops removing the message at poll. TheexecutionService.ackTaskReceived(taskId)call is dropped. The popped message stays in the queue (invisible while unacked), so a task that is about to run still has a message —containsMessageis true — and the sweeper's repair leaves it alone. (This removed the only use ofExecutionServiceinSystemTaskWorker, so that dependency is gone.) -
AsyncSystemTaskExecutorreserves the message for the run. Before invokingstart()/execute(), it extends the message's visibility to the task'sresponseTimeoutSeconds:
// before the SCHEDULED/IN_PROGRESS invocation:
queueDAO.setUnackTimeout(queueName, taskId, responseTimeoutSeconds * 1000);
The executor already loads the TaskModel, so responseTimeoutSeconds is in
hand — no new config property and no extra read. It falls back to the default
response timeout (TaskDef.ONE_HOUR, 3600s) when the task has none (e.g. an
annotated task with no registered task def).
Together: the message is present from pop (repair can't re-push it — no
ack→reserve race), and invisible for responseTimeout (the unack sweep won't
redeliver it mid-run). The executor's finally still owns the outcome — it
removes (terminal) or postpones (non-terminal / worker-requested callback),
overriding the reservation — so normal completion, the async-complete flow, and
long-running IN_PROGRESS + callbackAfterSeconds re-invocation are all unchanged.
- A blocking overrun is timed out, not re-executed. The reservation only lasts
responseTimeout; if the actual run outlives it, the message does reappear. To avoid re-running it in parallel, the executor persistsstartTimebefore the first invocation (the status is left unchanged so a system task whosestart()branches onSCHEDULED— e.g.SUB_WORKFLOW— still works), and on redelivery of a still-SCHEDULEDtask checks whether it started and has not responded withinresponseTimeout:
if (scheduled // SCHEDULED only — see below
&& task.getStartTime() > 0
&& task.getUpdateTime() > 0 // skip just-scheduled tasks
&& now - task.getUpdateTime() >= responseTimeoutMs) {
task.setStatus(TIMED_OUT); // don't invoke again — let retry/timeout policy decide
}
Only SCHEDULED is handled here. A blocking start() runs synchronously and
never moves the task to IN_PROGRESS, so it stays SCHEDULED for its whole run —
that is the only overrun the normal response-timeout path can't already see.
IN_PROGRESS response-timeouts are owned by DeciderService.isResponseTimedOut
(invoked from decide()), which budgets responseTimeout + callbackAfterSeconds.
Applying this executor check to IN_PROGRESS tasks would fire earlier than
DeciderService (it omits callbackAfterSeconds) and wrongly time out a task that
is merely waiting for its next scheduled callback whenever
callbackAfterSeconds >= responseTimeout.
The updateTime > 0 guard is also required: a task whose mapper sets startTime at
scheduling (e.g. JOIN) has updateTime == 0 until first persisted, so without it
now - 0 always exceeds the timeout and the task is wrongly timed out on its first
poll.
So a blocking run that exceeds responseTimeout is marked TIMED_OUT (retriable)
and the retry/timeout policy creates a new attempt or fails the workflow — it is
never re-run in parallel under the same taskId.
Why responseTimeout (not a new property)
responseTimeout is exactly "how long the task is allowed to run", which is what
the reservation should cover, and it already exists per task. Adding a dedicated
lease property would duplicate it. It only needs a fallback (the platform default,
TaskDef.ONE_HOUR) for tasks with no configured timeout.
Why not gate to one task type
The race is generic and the reservation is behavior-preserving: the executor's
finally always removes/re-posts the message once the task returns, so for a fast
task the reservation is immediately superseded. The one trade-off, uniform across
all tasks: a crash mid-execute leaves the message reserved and recovered after
responseTimeout instead of by the ~30s repair. That is bounded and acceptable,
and is the price of not letting repair fight in-flight tasks.
Alternatives considered
- Adapter-level reserve (PR #1367): reserve inside
AnnotatedWorkflowSystemTask. Covers only annotated tasks, and reserves after the poller's ack — leaving a small ack→reserve race (a sweep in that gap still re-queues). This approach removes the ack entirely, so there is no such window, and it covers all async system tasks. - Non-blocking poll model (#1359): run the method off the worker thread and poll a future — eliminates the in-flight window, but a much larger change.
Known limitations
- Crash mid-execute → recovery after
responseTimeout(bounded), slower than repair's ~30s. - A blocking run that overruns
responseTimeoutis timed out and retried per the task's policy — so a task whoseresponseTimeoutis set shorter than its real work will time out repeatedly and eventually fail. Set a realisticresponseTimeoutfor long tasks (e.g. LLM); absent one, theTaskDef.ONE_HOURdefault applies. This is a de-facto 1-hour execution cap only for blocking (SCHEDULED-throughout) tasks;IN_PROGRESSwaiters (HUMAN, WAIT) are unaffected — their timeout stays governed byDeciderService.isResponseTimedOut. pop→ reserve window. The message is left unacked at the queue's default unack timeout betweenpop(poller thread) andreserveInflightMessage(executor thread). This is safe while a queue's semaphore permits equal its pool threads (ExecutionConfig): a popped id always has a free thread, so it reserves within milliseconds — far inside the default unack timeout. If a future change decouples in-flight permits from thread count (e.g. a per-typepermitCount, PR #1204), a popped id could queue behind busy threads and the pre-reserve gap could exceed the unack timeout → redelivery → the very duplicate this fixes. In that case, reserve on the poller thread immediately afterpopinstead of in the executor.- Extra persistence on the schedule path. Persisting
startTimebeforestart()adds oneexecutionDAOFacade.updateTask(and its index write) per async system task, once per task (only on the first,SCHEDULEDinvocation — not per callback). Required sostartTimeis durable before a blockingstart(). - Zombie late-write (#1322, not fixed here): the original invocation of a
timed-out task keeps running and, on completion, its
finallystill writes its result under the original taskId, which can overwrite theTIMED_OUT/retry state. Rejecting that late write is a separate follow-on (#1322).
Testing
AsyncSystemTaskExecutorTest:- a SCHEDULED task is reserved via
queueDAO.setUnackTimeout(queue, taskId, responseTimeout*1000)beforestart(); a task with noresponseTimeoutfalls back toTaskDef.ONE_HOUR(3600s). - a redelivered SCHEDULED task whose blocking
start()outlivedresponseTimeoutis markedTIMED_OUTand not re-invoked. - a just-scheduled task with
startTimeset butupdateTime == 0(e.g.JOIN) is reserved and started, not timed out (theupdateTime > 0guard). - an
IN_PROGRESStask whose callback interval exceedsresponseTimeoutis reserved andexecute()d, not timed out (theSCHEDULED-only gate — the response-timeout is left toDeciderService.isResponseTimedOut). WorkflowSweeperTest: an in-flight async system task (isAsync, notasyncComplete) whose queue message is present (containsMessage == true) is not re-pushed by repair — the regression guard for #202 / #630.TestSystemTaskWorker: the poll hands the task to the executor and does notack/removethe message.- End-to-end: the full
conductor-test-harnesssuite passes, including the fork/join integration tests (ForkJoinSyncModeIntegrationTestet al.) that exerciseJOINthrough the async executor and would fail if a freshly-scheduledJOINwere timed out.