Skip to content

fix(ReindexThread): shutdown hot-loop and dead-runnable silent queue failure #36922

Description

@freddyDOTCMS

Problem Statement

Two bugs in ReindexThread.java that together caused push-published content to remain invisible on Production for ~2 days (observed on 26.07.06-3 Evergreen in a dotCMS Cloud Production environment).

Since PR #33885, push-published content is indexed asynchronously via the reindex queue on the receiver. Both bugs prevent that queue from draining reliably.

Bug 1 — Shutdown hot-loop

During JVM shutdown, ShutdownCoordinator.isRequestDraining() becomes true before stopThread() sets state to STOPPED. The inner loop in runReindexLoop() detects shutdown and breaks — but this only exits the inner loop and returns control to the outer loop in ReindexThreadRunnable:

// Outer loop — ReindexThreadRunnable (line 155)
while (state.get() != ThreadState.STOPPED) {
    try {
        runReindexLoop();   // breaks due to shutdown, returns here
    } catch (Exception e) { ... }
    // state is still RUNNING → outer loop calls runReindexLoop() again → immediate log + break → repeat
}

Observed impact: ~1.2 million "Shutdown detected, stopping reindex operations" log lines in ~2 minutes — putting severe pressure on I/O and contributing to JVM instability/crash.

Bug 2 — Dead runnable not detected in unpauseImpl()

unpauseImpl() assumes that if state == PAUSED, the ReindexThreadRunnable is alive in its sleep() loop:

if (state == ThreadState.PAUSED) {
    Logger.info(ReindexThread.class, "--- Unpausing reindex thread ");
    cache.get().remove(REINDEX_THREAD_PAUSED);
    getInstance().state(ThreadState.RUNNING);  // flips flag — but nobody is listening
}

After a crash, the runnable may exit (uncaught Throwable, executor shutdown) while leaving state at PAUSED. When content is push-published, unpauseImpl() logs "Unpausing reindex thread", sets state to RUNNING — but there is no live thread to act on it. The queue never drains. No error is logged.

The STOPPED branch correctly re-submits the runnable, but it is never reached.


Steps to Reproduce

Bug 1:

  1. Start dotCMS
  2. Trigger a JVM shutdown while ReindexThread is in RUNNING state
  3. Observe millions of "Shutdown detected, stopping reindex operations" log lines before the process exits

The hot-loop only runs during the window between ShutdownCoordinator.isRequestDraining() becoming true and stopThread() being called. In production this window was long due to a JVM stall. To reproduce locally, use one of these approaches:

  • Debugger breakpoint (no code change): Set a breakpoint on the first line of stopThread() (line 299 of ReindexThread.java). Trigger a server shutdown — the breakpoint pauses stopThread() while ReindexThread hot-loops on its own thread. Watch the log accumulate lines rapidly, then resume to complete shutdown.

  • Temporary sleep in ReindexThreadShutdownTask: Add a Thread.sleep(5000) before the stopThread() call to create a 5-second window. Start dotCMS, wait for ReindexThread to reach RUNNING state, shut down, and observe the dense burst of log lines.

  • Unit test: Mock ShutdownCoordinator.isRequestDraining() to return true, start ReindexThreadRunnable in a thread without calling stopThread(), let it run for ~200 ms, then assert "Shutdown detected" was logged more than once — proving the hot-loop. Use mockStatic if isRequestDraining() is a static method.

Bug 2:

  1. Start dotCMS — ReindexThread starts, queue is empty, thread enters PAUSED state
  2. Pod/JVM becomes unstable (thread stall, executor shutdown) — runnable exits but state remains PAUSED
  3. Push-publish content from a remote environment
  4. Observe: "Unpausing reindex thread" is logged (transaction committed), state is set to RUNNING
  5. Observe: content does NOT appear on the target site — queue is never drained
  6. Manual reindex makes content visible immediately (bypasses the queue)

Acceptance Criteria

  • When ShutdownCoordinator.isRequestDraining() returns true inside runReindexLoop(), state is set to STOPPED before breaking, so the outer loop in ReindexThreadRunnable exits without re-entering runReindexLoop()
  • "Shutdown detected, stopping reindex operations" is logged at most once per shutdown event — not repeated in a hot-loop
  • ReindexThreadRunnable sets a liveness flag (AtomicBoolean) to true on start and clears it in a finally block on exit, including when an uncaught Throwable causes the runnable to exit unexpectedly
  • When unpauseImpl() is called and state == PAUSED but the liveness flag is false (runnable has exited), the method re-submits the ReindexThreadRunnable rather than only flipping state to RUNNING
  • After a pod restart that leaves ReindexThread in a dead-but-PAUSED state, push-published content becomes visible on Production without requiring a manual reindex
  • The catch (Exception e) in ReindexThreadRunnable is widened to catch (Throwable e) so JVM-level errors (e.g., OutOfMemoryError) do not bypass the finally liveness-clear
  • Normal pause/unpause cycle (empty queue → pause → content pushed → unpause → indexed) continues to work correctly after both fixes
  • Full reindex from admin UI completes and performs index switchover successfully after both fixes

dotCMS Version

26.07.06-3 Evergreen. Likely affects all Evergreen builds containing PR #33885 (async receiver reindex).


Severity

High - Major functionality broken


Links

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions