Siyao Meng created HDDS-17395:
---------------------------------

             Summary: Restarted BackgroundService can stop for good or run 
batches from its old executor after a forced shutdown
                 Key: HDDS-17395
                 URL: https://issues.apache.org/jira/browse/HDDS-17395
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng


h3. Mechanism
{{BackgroundService.PeriodicalTask.run}} waits for the previous batch with 
{{future.join()}} on the instance field {{future}}, then calls {{getTasks()}} 
and submits every task with {{CompletableFuture.runAsync(..., exec)}}, folding 
each task's future into {{future}}. {{join()}} ignores interrupts. The submit 
reads the field {{exec}}, not the executor the run was scheduled on.

{{shutdown()}} calls {{exec.shutdown()}}, waits 60 seconds in 
{{awaitTermination}}, and calls {{shutdownNow()}} when that wait runs out or 
the calling thread is interrupted. {{start()}} creates a new executor when the 
old one is shut down, but keeps {{future}}. Three things follow:
* {{shutdownNow()}} removes the queued {{runAsync}} wrappers from the executor 
without running them, so their futures never complete, and neither does 
{{future}}. The first run of the restarted service waits on it forever.
* A run that was waiting in {{join()}} during the restart is not stopped by 
{{shutdownNow()}}. When the slow task ends, it calls {{getTasks()}} and submits 
into {{exec}}, which is now the new executor, while the new executor's own run 
does the same.
* Since HDDS-14645, {{shutdown()}} calls {{exec.shutdown()}} outside the 
monitor that the submit loop holds, so it can land between {{getTasks()}} and 
the submit. {{runAsync}} then throws {{RejectedExecutionException}}, which ends 
the run with no log line; the polled task is lost and the rest of the batch is 
abandoned.

h3. Trigger
{{BlockDeletingService.updateAndRestart}} and 
{{DirectoryDeletingService.updateAndRestart}} are the only callers that restart 
a service instance in place. They run when {{ozone admin reconfig}} changes 
{{hdds.datanode.block.deleting.service.interval}} 
({{ozone.block.deleting.service.interval}} in 2.1 and 2.2), 
{{ozone.block.deleting.service.timeout}} or 
{{ozone.block.deleting.service.workers}} on a datanode, or 
{{ozone.directory.deleting.service.interval}} or 
{{ozone.thread.number.dir.deletion}} on an OM. The restart reaches 
{{shutdownNow()}} when the tasks still running or queued at that moment do not 
finish within 60 seconds. The interrupt path of {{shutdown()}} exists in the 
code, but nothing in Ozone interrupts the reconfiguration thread. After a plain 
{{exec.shutdown()}} the queued tasks still run, so a task is dropped only if it 
has not started when the 60 seconds run out.

For {{BlockDeletingService}}, a batch has one task per container with pending 
deletes, up to {{hdds.datanode.block.deleting.limit.per.interval}} (20000 
blocks by default) in total, so on a datanode with a deletion backlog it often 
has more tasks than the 10 default workers. {{BlockDeletingTask}} does not 
check for interrupts. Tasks are dropped when such a batch is still running 60 
seconds after the reconfiguration started, which is also when an operator is 
likely to retune deletion. This is argued from the code, not measured on a 
datanode. {{DirectoryDeletingService}} runs one worker since HDDS-16164, but 
its own {{shutdown()}} first shuts its deletion pool down, after which queued 
tasks return quickly, so it reaches {{shutdownNow()}} only when a task outlives 
that as well (argued from the code).

h3. Impact
All of the following were run on unmodified source against 
{{BackgroundService}} through its public methods, with a test subclass that 
restarts in the same order as {{updateAndRestart}}. No production service was 
run.
* *Restarted service never runs again.* Pool size 1, first batch of two tasks, 
one running and one queued. After a restart whose {{shutdown()}} was 
interrupted, and after one whose 60 second wait really ran out, the restarted 
service called {{getTasks()}} zero times in 3 seconds at a 50 ms interval, with 
its only worker parked in {{CompletableFuture.join}} from 
{{PeriodicalTask.run}}. Nothing reassigns {{future}}, so this lasts until the 
process restarts, and by code reading every later {{shutdown()}} waits the full 
60 seconds for the parked thread. For {{BlockDeletingService}} this means block 
deletion on that datanode stops while the service still counts as started. 
[^TestBugMC1RestartAfterForcedShutdown.java]
* *Two batches at once.* Pool size 2, one task holding a worker through the 
restart and the next run waiting for it. After the task ended, both the old 
executor's run and the new executor's run fetched a batch, and the two batches 
ran at the same time on the new executor, against the class contract of one 
batch per interval. In {{BlockDeletingService}} the duplicate batch is absorbed 
({{getTasks()}} only reads, and each task re-reads the container under its 
write lock), so this is a latent defect today (argued from the code). 
[^TestBugMC2StaleLoopOverlapsRestartedBatch.java]
* *Single worker deadlock.* Pool size 1 and two restarts that both reach 
{{shutdownNow()}}: the run that survived the second restart submitted its task 
into the third executor, behind that executor's next run, which then waited in 
{{join()}} for a task that could only run on its own thread. {{getTasks()}} was 
not called again. The test orders the steps with latches, so it shows that the 
interleaving exists, not how often it occurs. 
[^TestBugMC3SingleWorkerSelfDeadlock.java]
* *Batch dropped without a log line.* A {{shutdown()}} that landed after 
{{getTasks()}} had returned four tasks: one task was polled and neither ran nor 
was handed back, three were abandoned, and only the start and shutdown INFO 
lines were logged. The services restarted in place rebuild their batch from 
durable state on the next run, and the services whose {{getTasks()}} reserves 
state ({{KeyLifecycleService}}, {{DiskBalancerService}}) are only stopped 
together with their owner and then discarded, so no wrong result follows today 
(argued from the code). [^TestBugMC4ShutdownDuringSubmit.java]

h3. Reproduction
The four attached JUnit classes belong in 
{{hadoop-hdds/framework/src/test/java/org/apache/hadoop/util/}} (Maven command 
in each class comment) and pass on unmodified source while the bug is present. 
They only use {{start()}}, {{shutdown()}}, {{setInterval()}} and 
{{getExecutorService()}}. Except for one case of the first class, the forced 
shutdown is reached by interrupting the restarting thread, which takes the same 
{{shutdownNow()}} path as a 60 second wait that runs out, without waiting for 
it.

h3. Patch
[^MC-1-complete-dropped-tasks-and-stop-stale-background-service-loops.patch], 
against 9c3e5477ffb9f41da639051aaa5e9d7bd8b53fe4. It also applies to master at 
c521a16af75.

All changes are in {{BackgroundService}}:
* {{start()}} schedules a new {{PeriodicalTask}}, which keeps the executor it 
was scheduled on. A run whose executor has been shut down returns after 
{{join()}} without calling {{getTasks()}}, and the submit loop stops when its 
executor is shut down. So no run of an old executor reaches the new one.
* Each submitted task registers its future in a set kept per executor and 
removes it when it starts. After {{shutdownNow()}}, {{shutdown()}} completes 
the futures still in the set, which belong to the tasks that were dropped and 
will never run. A task that had started is still waited for, so the restarted 
service does not run its first batch next to a task of the old executor that is 
still running.
* {{shutdown()}} calls {{exec.shutdown()}} under the monitor, so it cannot land 
halfway through the submit loop. {{awaitTermination}} stays outside the 
monitor, as HDDS-15429 requires. {{exec.shutdown()}} does not wait for workers, 
and the submit loop already takes the executor's lock while holding the 
monitor, so this adds no new lock order. A batch fetched before the shutdown 
and not submitted is logged at INFO with its size.

A service that is never restarted and whose shutdown finishes in time behaves 
as before. Resetting {{future}} in {{start()}} was not chosen: the new batch 
would then run next to tasks of the old executor that are still running, and a 
run of the old executor parked on a dropped task would stay parked.

Covered by three new cases in the existing {{TestBackgroundService}}: 
{{testRestartAfterForcedShutdownSkipsDroppedTasks}}, 
{{testLoopOfReplacedExecutorDoesNotFetchTasks}} and 
{{testShutdownWhileFetchingTasksDropsWholeBatch}}. Without the change they fail 
with a {{TimeoutException}} (the restarted run parked in {{join()}}), 
"Expecting AtomicInteger(2) to have value: 1" and "expected: 4 but was: 3". 
With it they pass, the existing suites of the {{BackgroundService}} subclasses 
in {{hadoop-hdds/container-service}} and {{hadoop-ozone/ozone-manager}} pass, 
checkstyle is clean, and the four attached classes no longer show the bug.

Found by TLA+ model checking and code review of the BackgroundService restart 
and shutdown paths and the reconfiguration callback handling under HDDS-15926, 
on commit 9c3e5477ffb9f41da639051aaa5e9d7bd8b53fe4. Checked against HDDS issues 
and apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-15429 (monitor deadlock in {{updateAndRestart}}, whose fix 
stopped calling {{shutdown()}} under the monitor), HDDS-14645 (moved 
{{exec.shutdown()}} and {{awaitTermination}} out of the monitor), HDDS-14020 
(open, the same lock shared by shutdown and the submit loop), HDDS-13773 (open, 
timeout and interrupt for background tasks), HDDS-14236 (fixed delay scheduling 
of {{PeriodicalTask}}, in progress, its PR closed), HDDS-16949 (an exception 
from {{getTasks()}} cancelled the periodic task) and HDDS-17296 (a replaced 
{{DirectoryDeletingService}} is restarted by its reconfiguration callback). 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