Skip to content

fix(sweeper): re-queue on lock miss to eliminate multi-minute workflow pauses#1259

Open
NicholasDCole wants to merge 3 commits into
mainfrom
feature/fix_lock_contention_wait_issue
Open

fix(sweeper): re-queue on lock miss to eliminate multi-minute workflow pauses#1259
NicholasDCole wants to merge 3 commits into
mainfrom
feature/fix_lock_contention_wait_issue

Conversation

@NicholasDCole

@NicholasDCole NicholasDCole commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Problem

Reported on Conductor 3.30.2 (Spanner persistence, Redis queue + locks): a workflow with many JOIN boundaries stalls for an intermittent ~10 minutes per run after upgrading from 3.21.4.

Root cause

The active sweeper (org.conductoross.conductor.core.execution.WorkflowSweeper, default via conductor.app.sweeper.enabled) bare-returned when it couldn't acquire the workflow lock — no decide(), no re-queue:

if (!executionLockService.acquireLock(workflowId)) {
    log.error("Couldn't acquire lock to sweep workflow {}", workflowId);
    return;   // <-- workflow parks
}

A lock-contended workflow (e.g. the post-completion decide() at a FORK/JOIN boundary) then had no near-term wake-up. Its only remaining decider-queue entry is postponed by ExecutionService.adjustDeciderQueuePostpone() to responseTimeoutSeconds * 1000 when a worker polls a task. With the affected task defs carrying responseTimeoutSeconds=600, that fallback is 600 s ≈ 10 minutes — the observed pause. It's intermittent ("sometimes") because it only surfaces when the event-driven decide is missed.

Fix

On a lock miss, re-queue the workflow to the decider queue with a bounded backoff (lockLeaseTime/2, capped by maxPostponeDurationSeconds) so a contended workflow is retried in seconds instead of parking:

if (!executionLockService.acquireLock(workflowId)) {
    long backoffSeconds = lockContentionBackoffSeconds();
    log.error("Couldn't acquire lock to sweep workflow {}", workflowId);
    queueDAO.push(DECIDER_QUEUE, workflowId, 0, backoffSeconds);
    return;
}
  • The identical backoff math previously inlined in the decide()==null path is factored into a shared lockContentionBackoffSeconds() helper.
  • The "Couldn't acquire lock to sweep workflow" error log is kept verbatim so existing log-based diagnostics/alerts still fire.
  • This changes only recovery latency; it does not alter the (correct) responseTimeout-based postpone for genuinely long-running tasks.

Tests

Test Purpose
WorkflowSweeperTest.sweepReQueuesOnLockMissWithBoundedBackoffInsteadOfParking Regression — fails before fix (zero pushes), passes after (30 s re-queue)
WorkflowSweeperTest.sweepReQueuesWith30sBackoffWhenDecideCannotAcquireLock Pins the decide()==null backoff at lockLeaseTime/2
ExecutionServiceTest.testPollSetsDeciderQueuePostponeToResponseTimeout_reproducesTenMinutePause Documents the 600 s decider postpone that is the pause duration's source
TestWorkflowSweeper (legacy path) Legacy-sweeper reproduction of the same responseTimeout-driven fallback, retained as a diagnostic

All green; spotless clean.

Note for affected deployments

As an immediate config mitigation before adopting a build with this fix, lowering responseTimeoutSeconds on the task defs (e.g. 600 → 60) shrinks the fallback pause proportionally.

Test evidence (validated with / without the fix)

All tests were run both with the fix in place and with the lock-miss re-queue reverted (call site A in WorkflowSweeper.sweep() restored to the original bare return, i.e. delete queueDAO.push(DECIDER_QUEUE, workflowId, 0, backoffSeconds)), on the same commit otherwise. The two tests that assert the fix flip from pass → fail when the fix is removed, confirming they are genuine regression gates (not vacuous). Environment: local_only execution lock; the e2e spec runs against the real Redis-backed decider queue the harness provisions via Testcontainers (redis:6.2-alpine).

✅ With the fix (current branch)

$ ./gradlew :conductor-core:test \
    --tests "org.conductoross.conductor.core.execution.WorkflowSweeperTest" \
    --tests "com.netflix.conductor.service.ExecutionServiceTest" \
    --tests "com.netflix.conductor.core.reconciliation.TestWorkflowSweeper"
WorkflowSweeperTest      tests=6  failures=0  skipped=0
ExecutionServiceTest     tests=13 failures=0  skipped=0
TestWorkflowSweeper      tests=20 failures=0  skipped=0

$ ./gradlew :conductor-test-harness:test \
    --tests "com.netflix.conductor.test.integration.DynamicForkJoinLockContentionSpec"
DynamicForkJoinLockContentionSpec  tests=2  failures=0  skipped=0
  ✓ sweeper re-queues a lock-contended workflow instead of leaving it parked at the response timeout (17.3s)
  ✓ control: with no lock contention the same JOIN schedules the next task immediately (0.46s)
BUILD SUCCESSFUL

❌ With the fix reverted (bare return on lock miss)

Unit regressionWorkflowSweeperTest.sweepReQueuesOnLockMissWithBoundedBackoffInsteadOfParking (tests=6, failures=1):

Wanted but not invoked:
queueDAO.push(
    "_deciderQueue",
    "workflow-id",
    0,
    30L
);
-> at WorkflowSweeperTest.sweepReQueuesOnLockMissWithBoundedBackoffInsteadOfParking(WorkflowSweeperTest.java:221)
Actually, there were zero interactions with this mock.

The contended sweep() never re-queued the workflow — exactly the bug (the 30s = lockLeaseTime/2 backoff push is absent).

E2EDynamicForkJoinLockContentionSpec › "sweeper re-queues a lock-contended workflow…" (tests=2, failures=1; the control still passes):

Condition not satisfied:

queueDAO.containsMessage(Utils.DECIDER_QUEUE, workflowInstanceId)
|        |               |     |              |
|        false           |     _deciderQueue  bfd6fe7a-9aa6-4cfe-b714-06ea94c9c65a
|                        class com.netflix.conductor.core.utils.Utils
<io.orkes.conductor.mq.dao.RedisQueueDAO@... queueNamespace=integtest.test ...>

After the JOIN completes under a held workflow lock (post-JOIN decide() returns null, integration_task_4 never scheduled) and the decider entry is cleared, a contended sweep() leaves nothing in _deciderQueue for the workflow — it is parked with no near-term wake-up (in production, until the responseTimeoutSeconds=600 postpone fires). The control case (no contention) schedules integration_task_4 immediately in both variants, isolating the lock as the sole cause.

Restore check

Restoring the fix returns WorkflowSweeper.java to this PR's committed content (git diff empty) and both suites go green again.

🤖 Generated with Claude Code

NicholasDCole and others added 2 commits July 7, 2026 17:47
…w pauses

The active WorkflowSweeper.sweep() bare-returned when it couldn't acquire the
workflow lock, doing nothing to advance or reschedule the workflow. A
lock-contended workflow (e.g. at a JOIN boundary) then fell back to its
decider-queue entry, which a polled task postpones out to
responseTimeoutSeconds via ExecutionService.adjustDeciderQueuePostpone().
With responseTimeoutSeconds=600 this surfaces as an intermittent ~10-minute
pause per contended transition (reported on 3.30.2 with Spanner persistence
and Redis queue/locks).

Fix: on a lock miss, re-queue the workflow to the decider queue with a bounded
backoff (lockLeaseTime/2, capped by maxPostponeDurationSeconds) so it is
retried in seconds instead of parking. The identical backoff math previously
inlined in the decide()==null path is factored into a shared
lockContentionBackoffSeconds() helper. The existing "Couldn't acquire lock to
sweep workflow" error log is kept verbatim so log-based diagnostics still fire.

Tests:
- WorkflowSweeperTest.sweepReQueuesOnLockMissWithBoundedBackoffInsteadOfParking
  regression: fails before the fix (no push), passes after (30s re-queue).
- WorkflowSweeperTest.sweepReQueuesWith30sBackoffWhenDecideCannotAcquireLock
  pins the decide()==null backoff at lockLeaseTime/2.
- ExecutionServiceTest.testPollSetsDeciderQueuePostponeToResponseTimeout_reproducesTenMinutePause
  documents the 600s decider postpone that is the source of the pause duration.
- TestWorkflowSweeper: legacy-sweeper repro of the same responseTimeout-driven
  fallback, retained as a diagnostic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pause

Adds an integration spec that drives the real DynamicFanInOutTest workflow
(integration_task_1 -> FORK_JOIN_DYNAMIC -> JOIN -> integration_task_4) through
real fork/JOIN execution and validates the sweeper lock-miss re-queue fix.

- Regression feature: holds the workflow lock from a foreign thread, executes
  the JOIN (completes, but the post-JOIN decide can't get the lock so
  integration_task_4 is not scheduled -> the stall), sweeps under contention and
  asserts the workflow was re-queued to DECIDER_QUEUE (containsMessage), then
  releases the lock and confirms it runs to COMPLETED. Fails against the pre-fix
  bare-return (containsMessage == false); passes with the fix.
- Control feature: with the lock free, the same JOIN schedules the next task in
  the same decide (no stall) - isolates the lock as the sole variable.

Uses LocalOnlyLock (the harness lock; the lock-miss branch is implementation-
agnostic) with the real Redis-backed decider queue the harness already
provisions via Testcontainers. Determinism comes from the fix's lockLeaseTime/2
(30s) re-queue backoff: the re-pushed message is not due, so the background
sweeper cannot pop it during the containsMessage assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@NicholasDCole NicholasDCole requested a review from manan164 July 8, 2026 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants