fix(reindex): end the ReindexThread shutdown hot-loop and dead-worker queue stall - #37295
fix(reindex): end the ReindexThread shutdown hot-loop and dead-worker queue stall#37295danielsolis-dotcms wants to merge 4 commits into
Conversation
… queue stall
Two defects in ReindexThread shared one root cause: a single ThreadState was
used both as a command channel ("what should the worker do?") and as an implied
liveness signal ("is a worker alive?"), and the shutdown check watched the wrong
flag.
Bug 1 - shutdown hot-loop. runReindexLoop() keyed off
ShutdownCoordinator.isRequestDraining(), which is a transient window flag: it is
set at the start of shutdown Phase 1 and cleared in a finally at its end
(shutdown.request.drain.timeout.seconds, default 15s), while
ReindexThreadShutdownTask only runs in Phase 2. The inner break returned into an
outer loop whose only exit condition was state == STOPPED, so the worker spun -
with no back-off, since the break skips the loop-bottom sleep() - for the whole
window, then resumed indexing against infrastructure about to be torn down. A
unit test measured 9,922,576 log events in ~3s; the existing Logger.infoEvery
mitigation throttles only INFO and still emits DEBUG on every pass.
Bug 2 - dead runnable. unpauseImpl() treated state == PAUSED as proof a runnable
was alive, so after the worker died (uncaught Error, executor shutdown) it
flipped a flag nobody was reading. The queue never drained and nothing was
logged as an error.
Changes:
- Add a terminal ThreadState.SHUTDOWN, distinct from the restartable STOPPED
that unpauseImpl() keys off; all loops test a terminal predicate.
- Use the monotonic isShutdownStarted() as the terminal trigger and log the
transition once via getAndSet. isRequestDraining() stays a "do not start
expensive work" hint in finalizeReIndex()/switchOverIfNeeded() only.
- Track liveness in an AtomicBoolean claimed by compare-and-set before submit
and cleared in a finally covering Error; route both restart paths through one
shutdown-guarded helper that rolls the claim back if submit fails.
- Report a dead-worker restart at ERROR with a constant throttle key.
- Replace ThreadUtils.sleep on this class's waits with an interrupt-aware wait
that restores the interrupt status (ThreadUtils.sleep swallows both the
exception and the flag, leaving a parked worker un-interruptible).
- Correct the broken double-checked locking on the instance field.
Notes:
- AC-007 asked for the runnable's catch(Exception) to be widened to
catch(Throwable). Widening the inner catch would retry an OutOfMemoryError
forever; the finally, not the catch width, is what guarantees the liveness
clear. Implemented as inner catch(Exception) for retry + outer
catch(Throwable) that terminates + finally that clears.
- The full-reindex switchover test is @Ignore-d: it fails identically on
unmodified main (244.0s, same assertion), so it is a pre-existing harness
limitation rather than a regression. Evidence is in its Javadoc.
Tests: 16 unit + 2 integration added, all passing. No DB schema, ES mapping,
REST contract, bom/ or configuration changes - rollback-safe.
Fixes: #36922
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Root causeTwo defects shared one cause: a single Bug 1 — shutdown hot-loop
The inner The width of that window is load-dependent, which is what makes the bug intermittent. Bug 2 — dead runnable
Why the existing mitigation was insufficientThe shutdown line already used |
Test evidenceAutomated
Red→Green was confirmed at each step. Pre-fix, the five US1 tests failed on assertions — including Manual, against a running server
Reviewer notes
Deviations from the issue's acceptance criteria (AC-007 and AC-001) are described in the PR body. Recovery is node-local by design. Known limit in the cluster verification. |
…hread-shutdown-and-liveness
…chover test Replaces the vague "harness limitation" note with the diagnosis from a full-log run. The reindex starts correctly (reindex_working is populated), but the log contains zero ReindexThread lifecycle lines and zero "Running Reindex Switchover" lines: nothing drains the rebuild queue, so switchOverIfNeeded() is never reached. The reindex_working: null that appears exactly 240s later is the test's own fullReindexAbort() in finally, not a switchover. Cause: ReindexThread.startThread() delegates to unpause(), which only registers a Hibernate commit listener unless ALLOW_MANUAL_REINDEX_UNPAUSE is set. With no committing transaction in the harness bootstrap the listener never fires and the worker never starts. Sibling tests in this class pass only because saving a contentlet commits a transaction, which fires the listener as a side effect. Also rules out #37281/#37282: both concern the switchover being deferred by the minimum-runtime guard, whose signature is "Running Reindex Switchover" every 3s. This run logs none - the switchover is never attempted. Tracked as #37302; removing the @ignore is the verification for that fix. Refs: #36922, #37302 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proposed Changes
ThreadState.SHUTDOWN, distinct from the restartableSTOPPED, so the worker exits shutdown permanently instead of spinning between the inner and outer loops.ShutdownCoordinator.isShutdownStarted()instead of the transientisRequestDraining(), and log the transition at most once per shutdown.AtomicBoolean, claimed by compare-and-set before submit and cleared in afinallythat also coversError, so a dead runnable can no longer be mistaken for a paused one.unpauseImpl()restart a dead worker (reported atERROR) rather than flipping a flag nobody is listening to — the silent stall that left push-published content unindexed.ReindexThreadinstead ofThreadUtils.sleep, which swallows bothInterruptedExceptionand the interrupt flag.instancefield.Deviations from the issue's acceptance criteria
Two ACs are intentionally not implemented literally, because doing so would reintroduce the bug in another form. Flagging here so the mismatch against the issue's checkboxes is not a surprise.
catch (Exception e)to be widened tocatch (Throwable e). Doing exactly that would retry anOutOfMemoryErrorforever atSLEEP_ON_ERROR— turning Bug 2 into a second hot-loop. Thefinally, not the catch width, is what guarantees the liveness clear. Implemented as innercatch (Exception)for retry + outercatch (Throwable)that terminates the worker +finallythat clears liveness, withrunReindexLoop()rethrowingErrorso it reaches that outer catch. Net effect matches the AC's intent.isRequestDraining()andSTOPPED. Implementing that literally would make the Bug 2 fix dangerous:isRequestDraining()is cleared at the end of shutdown Phase 1, after which a late commit listener would hit theSTOPPEDbranch — the oneunpauseImpl()treats as "safe to restart" — and resurrect the worker mid-shutdown. Uses the monotonicisShutdownStarted()and a distinct terminal state instead.Checklist
Logger's static throttle map cannot grow unboundedAdditional Info
Backend-only and rollback-safe: no DB schema, ES mapping, REST contract,
bom/, or configuration changes.Root-cause analysis, full test evidence (manual shutdown, full-reindex switchover, 2-node cluster, idle-CPU measurements) and the remaining reviewer notes are in the comments below.
Fixes #36922