Siyao Meng created HDDS-17396:
---------------------------------

             Summary: BackgroundService.start on a running service schedules a 
second loop, which can deadlock a new DirectoryDeletingService started by a 
reconfiguration
                 Key: HDDS-17396
                 URL: https://issues.apache.org/jira/browse/HDDS-17396
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: CR-7-ignore-start-on-running-background-service.patch, 
TestBugCR7DirDeletingServiceStartedTwice.java

h3. Mechanism
{{BackgroundService.start}} creates a new executor only when the current one is 
shut down, and otherwise always calls {{scheduleWithFixedDelay}} again. It 
keeps no handle to the loop it already scheduled, so a second {{start()}} on a 
running service adds a second periodic loop on the same executor.

Services that register a reconfiguration restart callback in their constructor 
can be started by that callback before their owner calls {{start()}}. 
{{KeyManagerImpl.start}} builds a new {{DirectoryDeletingService}} and calls 
{{start()}} on it in the next statement. The constructor has already registered 
the complete callback ({{registerReconfigCallbacks}}), and that callback calls 
{{updateAndRestart}}, which does {{shutdown()}} and then {{start()}}. If the 
callback runs on the new instance after construction and before 
{{KeyManagerImpl.start}} reaches {{start()}}, the callback starts the service, 
and the following {{start()}} from {{KeyManagerImpl}} sees a live executor and 
schedules a second loop.

Since HDDS-16164 the {{DirectoryDeletingService}} executor has one thread. 
{{PeriodicalTask.run}} submits its batch to that same executor and returns, and 
the next run first calls {{future.join()}} on that batch. When the second loop 
is scheduled before the first loop has submitted its batch, it is ahead of the 
batch in the delay queue. It then joins a batch that can only run on the thread 
it is blocking. {{join()}} cannot be interrupted, so the thread waits forever 
and no batch runs again.

h3. Trigger
The worst case is the {{DirectoryDeletingService}} race during a checkpoint 
install:
# A reconfiguration of {{ozone.directory.deleting.service.interval}} or 
{{ozone.thread.number.dir.deletion}} ({{ozone admin reconfig --service=OM 
--address=<om> start}}, admin only) reaches its complete callbacks just after 
the new instance registered its own. A callback registered while the callbacks 
are already running is skipped, see HDDS-17394.
# At that moment the same OM is in {{OzoneManager.installCheckpoint}} and calls 
{{KeyManager.start}}: after a checkpoint that is not newer, after a failure to 
stop the services, or after reloading the state. On the reload outcome the RPC 
server is stopped, so only a reconfiguration started earlier can reach it; the 
other two outcomes keep it running.
# The new callback is the last one in the list, so it runs after the OM's own 
callbacks and after the callbacks of every instance replaced by an earlier 
install (HDDS-17296), each of which does a full shutdown and start. Its 
{{shutdown()}} must still read the executor before {{KeyManagerImpl.start}} 
calls {{start()}}, a few statements later, so the install thread has to stall 
at exactly that point. The race is very rare. It is not reachable at OM 
startup, because the OM RPC server, which takes the reconfiguration request, 
starts after {{KeyManager.start}}.

h3. Impact
* On master: the new {{DirectoryDeletingService}} stops for good. Deleted 
directories are not purged on that OM while it is leader, so their space and 
metadata are not reclaimed. Shown in the reproduction.
* A later reconfiguration restart does not recover it at this commit, because 
{{shutdownNow()}} drops the batch without completing it and the shared 
{{future}} still waits for it; only the next install or an OM restart replaces 
the instance (code reading, not run). Once the fix proposed on HDDS-17395 
lands, such a restart recovers after its 60 second wait (code reading, not 
run). Each {{shutdown()}} of the stuck instance waits 60 seconds before it 
gives up, which delays the next install or OM stop (the 60 second wait is 
visible in the reproduction).
* If the first loop does submit its batch before the second loop is scheduled, 
every round runs twice per interval, and a later round deadlocks the same way 
once a round takes longer than the interval (argued, not run).
* In 2.1.0 to 2.2.1 the executor has {{ozone.thread.number.dir.deletion}} 
threads (default 10), so the result there is two loops and rounds twice per 
interval, with a deadlock only when that key is 1 (code reading, not run).

h3. Reproduction
PASS, deterministic, unmodified source. 
[^TestBugCR7DirDeletingServiceStartedTwice.java] 
({{hadoop-ozone/ozone-manager}}) starts one OM through {{OmTestManagers}}, 
builds a {{DirectoryDeletingService}} as {{KeyManagerImpl.start}} does, calls 
{{updateAndRestart}} on it (what the callback does) and then {{start()}} (what 
{{KeyManagerImpl.start}} does next). The only timing control is that the test 
holds the service monitor across both calls, so the first loop submits its 
batch after the second loop is scheduled. That is also the likely order in 
production, since the worker thread must start and build its task queue first. 
While the OM's own {{DirectoryDeletingService}} runs three rounds, the new one 
runs no task and its only worker stays busy, blocked in {{PeriodicalTask.run}} 
at {{future.join()}}. A passing test means the defect is present. The 
reconfiguration task itself and a concurrent install were not run.

h3. Patch
[^CR-7-ignore-start-on-running-background-service.patch], against 
9c3e5477ffb9f41da639051aaa5e9d7bd8b53fe4. It also applies to master at 
c521a16af75.

{{BackgroundService.start}} keeps the {{ScheduledFuture}} of its loop and 
returns with a warning when that loop is still scheduled on the current 
executor. A {{start()}} after {{shutdown()}} still creates a new executor and 
schedules a loop, so {{updateAndRestart}} and the other restart paths behave as 
before. The guard is in the base class, so it covers every subclass. The 
datanode {{BlockDeletingService}} has the same pattern: its constructor 
registers a restart callback, the datanode RPC server that serves 
reconfiguration starts before the first SCM handshake, and 
{{OzoneContainer.start}} calls {{start()}} only after that handshake, so a 
reconfiguration of the block deleting interval, timeout or workers in that 
window adds a second loop; the guard covers it too (code reading, not run).

Covered by {{testStartOnRunningServiceIsIgnored}} in the existing 
{{TestBackgroundService}}: it starts a one thread service twice while holding 
its monitor, then checks that tasks keep running, that the second {{start()}} 
logged that the service is already started, and that a {{start()}} after 
{{shutdown()}} restarts the service. Without the change it times out with the 
only worker blocked in {{future.join()}}. With the change 
{{TestBackgroundService}}, {{TestDirectoryDeletingService}} and the other OM 
deleting service suites pass, and checkstyle is clean. With the patch the 
reproduction fails at its first assertion, because the new service runs its 
tasks. The patch and the one proposed on HDDS-17395 change the same line of 
{{start()}}, so whichever lands second needs a one line rebase.

Found by code review of the BackgroundService restart and shutdown paths and 
the reconfiguration callback handling, as part of the TLA+ verification effort 
under HDDS-15926, on commit 9c3e5477ffb9f41da639051aaa5e9d7bd8b53fe4. Checked 
against HDDS issues and apache/ozone pull requests for duplicates before 
filing. Related but not duplicates: HDDS-17296 (a replaced service's callback 
restarts it after an install; its patch moves the callback to {{OzoneManager}}, 
which reads the service field that {{KeyManagerImpl.start}} assigns one 
statement before {{start()}}, so this window stays open), HDDS-17394 (a 
callback registered while callbacks run is skipped, which narrows the trigger), 
HDDS-17395 (a restarted service can stop for good after a forced shutdown; its 
fix lets a later restart recover from this deadlock), HDDS-16164 (made the 
executor one thread, which turns the second loop into a deadlock), HDDS-11513 
(registered the callbacks in the constructors), HDDS-14236 and HDDS-6696 (how a 
single loop is scheduled). 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