Siyao Meng created HDDS-17402:
---------------------------------

             Summary: LeaseManager stops expiring leases for good after one 
unexpected error in its monitor thread, because the restart calls start() on 
the dead thread
                 Key: HDDS-17402
                 URL: https://issues.apache.org/jira/browse/HDDS-17402
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
CR-1-keep-lease-monitor-running-after-unexpected-error.patch, 
TestBugCR1LeaseMonitorDeath.java

h3. Mechanism
{{LeaseManager}} expires leases on a single {{LeaseMonitor}} thread. The loop 
in {{LeaseMonitor.run}} only catches {{LeaseNotFoundException}} and 
{{LeaseExpiredException}}, so any other Throwable ends the thread. The uncaught 
exception handler installed in {{LeaseManager.start}} logs the error and calls 
{{leaseMonitorThread.start()}} on the same {{Thread}} object, which always 
throws {{IllegalThreadStateException}}. The JVM only prints it to stderr, and 
no thread is left to expire leases. The handler comment ("Let us just restart 
this thread after logging an error") shows the restart was meant to keep expiry 
alive; it can never work.

The lease being expired at that moment also loses its callback when the failure 
comes from {{executorService.execute}}: {{release(resource)}} has already 
removed and invalidated it one line earlier.

h3. Trigger
On a running {{LeaseManager}} the loop body cannot throw a {{RuntimeException}} 
by itself: the {{Lease}} getters only throw the checked 
{{LeaseExpiredException}}, {{get}} and {{release}} only throw 
{{LeaseManagerNotRunningException}} after {{shutdown()}}, callbacks run on the 
pool and their exceptions are caught there, and the cached pool is never shut 
down. What remains is a JVM {{Error}}, in practice {{OutOfMemoryError: unable 
to create native thread}} from {{executorService.execute}} when the process 
hits its thread limit, or an exception from the {{hashCode}}/{{equals}} of a 
caller supplied resource. SCM's resource ({{CloseContainerCommand}}) cannot 
throw there, so in SCM it takes thread exhaustion caused elsewhere in the 
process.

h3. Impact
* From the failure on, no lease expires and no callback runs for the life of 
the process. Every timed out resource stays in {{activeLeases}} and acquiring 
it again throws {{LeaseAlreadyExistException}} (both run, see Reproduction).
* In SCM, {{CloseContainerEventHandler}} uses the lease to delay the close 
command for a CLOSING container. After the monitor dies those delayed close 
commands are never sent, repeat close events only log 
{{LeaseAlreadyExistException}} at DEBUG, and {{activeLeases}} grows by one 
entry per closed container. ReplicationManager still sends close commands to 
the replicas of CLOSING containers on each pass 
({{hdds.scm.replication.thread.interval}}, 300s by default), so containers 
still close, later. This SCM part is argued from the code, not run on a cluster.
* An outside interrupt of the monitor while it is enabled would make the loop 
spin, but no code path does that: the thread is private and the only 
{{interrupt()}} is in {{shutdown()}}, after the loop is disabled.

h3. Reproduction
[^TestBugCR1LeaseMonitorDeath.java] ({{hadoop-hdds/framework}}, package 
{{org.apache.hadoop.ozone.lease}}), deterministic, unmodified source. A lease 
resource whose {{hashCode}} throws {{OutOfMemoryError("unable to create native 
thread")}} the first time the monitor thread looks it up stands in for the 
thread exhaustion. The test shows the monitor thread TERMINATED, the stderr 
line {{Exception: java.lang.IllegalThreadStateException thrown from the 
UncaughtExceptionHandler}}, a later 100 ms lease whose callback does not run 
within 2 s and whose resource cannot be acquired again, and that {{start()}} on 
the dead thread throws {{IllegalThreadStateException}}. The private thread 
field is read by reflection for observation only. A passing test means the 
defect is present.

The real trigger was also run outside the test suite: lease callbacks that 
block until released, until {{executorService.execute}} on the monitor thread 
failed with {{OutOfMemoryError: unable to create native thread}} at the per 
process thread limit. On the unmodified code the monitor died with the same 
{{IllegalThreadStateException}}, and after the pressure was removed a 100 ms 
lease never expired. With the patch the same run logged each failed submission, 
the monitor stayed alive, and the probe lease expired and could be acquired 
again.

h3. Patch
[^CR-1-keep-lease-monitor-running-after-unexpected-error.patch], against 
f804bf26867246067017fdd9e5319c2cbdbd5db8. It also applies to master at 
a7da4f70195.

{{LeaseMonitor.run}} now catches any other Throwable per lease, logs it at 
ERROR with the manager name and the resource, and continues with the next 
lease, so one failure cannot stop expiry for all of them. It also bounds the 
next wait to the default lease timeout, but at least one second, so a lease 
whose check failed before {{release}} is retried by the monitor itself even if 
no other acquire or expiry wakes it, and a persistent failure logs at most once 
per that interval instead of spinning. A failed callback submission after 
{{release}} is logged, and that one callback still does not run. Catching 
{{Throwable}} rather than only {{RuntimeException}} is deliberate: the 
realistic trigger is an {{Error}}, and surviving it is what the existing 
handler already tried to do. The uncaught exception handler no longer pretends 
to restart the thread; it only logs. Rebuilding a new thread in the handler was 
not chosen: it needs more code in {{start()}}, must not restart after 
{{shutdown()}}, restarts in a tight loop on a persistent failure, and still 
loses the lease in flight.

Covered by {{testLeaseMonitorSurvivesUnexpectedError}} in the existing 
{{TestLeaseManager}}: the monitor's first lookup of the only lease's resource 
throws an {{Error}}, and that lease must still expire and run its callback with 
nothing else waking the monitor. It times out on the unmodified code (dead 
monitor) and also with the catch but without the bounded wait. With the patch 
{{TestLeaseManager}}, {{TestEventWatcher}}, {{TestCloseContainerEventHandler}} 
and {{TestBlockManager}} (31 tests) pass and checkstyle is clean. With the 
patch the reproduction fails at its first assertion, because the monitor thread 
is still alive.

Found by code review of the HDDS LeaseManager expiry and shutdown paths, as 
part of the TLA+ verification effort under HDDS-15926, on commit 
f804bf26867246067017fdd9e5319c2cbdbd5db8. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. The attached patch is 
a proposal for review. Generated with Specula (Claude Opus 5.5).



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to