Siyao Meng created HDDS-17670:
---------------------------------

             Summary: DirectoryDeletingService run that outlives a lost and 
regained OM leadership purges directories again and reduces bucket usage twice
                 Key: HDDS-17670
                 URL: https://issues.apache.org/jira/browse/HDDS-17670
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-1-do-not-submit-dir-purge-after-term-change-during-run.patch, 
TestBugMC1MC2StaleDirPurge.java

h3. Mechanism
{{DirectoryDeletingService.DirDeletingTask.call}} checks {{shouldRun}} (leader 
ready) once, at the start of a run, for the active object store task and for 
each snapshot deep clean task. The task then scans {{deletedDirTable}} with its 
worker threads, each worker submits {{PurgeDirectories}} requests, and the task 
submits a {{SetSnapshotProperty}} request at the end. Nothing ties these 
requests to the leadership under which the scan was made, and Ratis accepts 
them whenever this OM is the leader at the time of the submit.

{{OMDirectoriesPurgeRequestWithFSO}} only checks that the bucket still has the 
same object ID (and, for a purge from a snapshot, that the snapshot chain has 
not changed). It does not check that the directories and files of the request 
are still pending, and applies the usage change for every entry again: a 
sub-file moves its size and one namespace from {{usedBytes}} and 
{{usedNamespace}} to {{snapshotUsedBytes}} and {{snapshotUsedNamespace}}, a 
sub-directory moves one namespace, and a purged directory subtracts one from 
{{snapshotUsedNamespace}}.

If the OM loses the leadership and gets it back while a run is between its 
check and its submit, and the leader in between has purged the same 
directories, the request is applied a second time on every OM. HDDS-16794 
describes this window for {{KeyDeletingService}} and leaves this service out of 
its patch.

h3. Trigger
# OM1 is leader and directories of an FSO bucket are deleted (for the snapshot 
path, a snapshot of the bucket is taken after the delete, so the deleted 
directories belong to the snapshot).
# A DirectoryDeletingService run on OM1 passes its checks, scans the deleted 
directories and builds its request.
# The run stalls before it submits. OM1 loses the leadership, OM2 becomes 
leader and its own run purges the same directories.
# OM1 becomes leader again. Its log holds the purge of OM2, otherwise it could 
not be elected, so the stale request is appended after it.
# The stalled run continues and submits its request, which is applied in the 
new term on every OM.

The stall has to cover both leadership changes and the run of the other leader, 
which comes on that leader's own schedule (default interval 60s), so it has to 
last for tens of seconds up to about a minute. It also has to hold the thread 
of the run and not the whole OM, because an OM that is paused as a whole cannot 
be elected again before the run continues. Waits for snapshot locks or a slow 
opening of snapshot DBs in the reclaim filters are possible causes. I expect 
this to be rare (argued, not measured). Snapshot deep cleaning is enabled by 
default.

By reading the release tags (not run on released code): the purge has released 
the usage of the sub-files and sub-directories it moves since {{ozone-1.3.0}} 
(HDDS-4565), so a repeated purge of a directory with content reduces 
{{usedBytes}} and {{usedNamespace}} again since then. The reduction of 
{{snapshotUsedNamespace}} for purged directories (HDDS-13764) and the snapshot 
deep clean tasks of this service (HDDS-13160) are in {{ozone-2.1.0}} and later.

h3. Impact
Reproduced on unmodified source:
* *Active object store.* An empty directory and a directory with one 1024 byte 
file are deleted, OM2 purges both, then the stale run of OM1 purges them again. 
On all three OMs the bucket row in RocksDB holds {{usedBytes = -1024}}, 
{{usedNamespace = -1}}, {{snapshotUsedBytes = 2048}} and 
{{snapshotUsedNamespace = 0}} (correct: 0, 0, 1024, 1). After 
{{KeyDeletingService}} has purged the file and {{deletedTable}} is empty, the 
row stays at -1024, -1, 1024 and -1 (correct: all 0), the client reads 
{{usedNamespace = -2}} for the empty bucket, and with a namespace quota of 1 
the bucket accepts three keys and rejects the fourth with {{QUOTA_EXCEEDED}}.
* *Snapshot deep clean.* An empty directory is deleted and a snapshot is taken, 
the deep clean of OM2 purges the directory from the snapshot, then the stale 
run of OM1 purges it again. On all three OMs {{snapshotUsedNamespace = -1}} 
(correct: 0), the client reads {{usedNamespace = -1}}, and with a namespace 
quota of 1 the bucket accepts two directories and rejects the third.
* The stale request repeats the whole batch of the stalled worker, not a single 
directory.
* Total bytes, which the client and the space quota check read 
({{OmBucketInfo.getTotalBucketSpace}}), are not changed by the repeated move of 
a file: it lowers {{usedBytes}} and raises {{snapshotUsedBytes}} by the same 
amount. In the reproduction the client reads {{usedBytes = 0}} after the file 
is purged, which is correct. By code reading, not run: the total bytes drop 
when {{KeyDeletingService}} of the other leader has already purged the file, 
because the stale request puts it back into {{deletedTable}} and it is purged a 
second time, and in 1.3.0 to 2.0.x, where the purge subtracted {{usedBytes}} 
directly.
* The wrong values are in the bucket table on all OMs and nothing corrects them 
automatically. The quota repair recomputes these counters when an operator 
starts it (code reading, not run).

h3. Reproduction
PASS, unmodified source. [^TestBugMC1MC2StaleDirPurge.java] 
({{hadoop-ozone/integration-test}}) starts a mini cluster with three OMs and 
one datanode, shared by two tests (active object store and snapshot deep 
clean). The deleting services get a long interval and are run by hand with 
{{runPeriodicalTaskNow}}, and the leadership is moved with 
{{OzoneManager.transferLeadership}}. The stale run is made by a second 
{{DirectoryDeletingService}} on the first leader whose {{submitPurgeRequest}} 
waits on a latch and then calls the unmodified implementation, so the request 
is built by the real service from a real scan. All state is created through the 
client API. A passing test means the defect is present. With the patch both 
tests fail at their first assertion of the buggy outcome, because the stale run 
does not submit and all OMs keep the correct counters.

h3. Patch
[^MC-1-do-not-submit-dir-purge-after-term-change-during-run.patch], against 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. It also applies to master at 
6ab9ed82a81.

Same approach as the patch on HDDS-16794 for {{KeyDeletingService}}. A run of 
{{DirectoryDeletingService}} records the Raft term right before the leader 
check, and the service compares it with the current term immediately before 
each submit of the run ({{PurgeDirectories}} from the worker threads and 
{{SetSnapshotProperty}} from the task, for the active object store and for the 
snapshot tasks). Reading it before the check means that a leader change between 
the two fails the comparison instead of being recorded as the term of the run. 
The HDDS-16794 patch reads the term after its leader check, and the same order 
applies there. An OM without a Ratis server yet (as in 
{{OzoneManager.restart}}) reads -1, which never matches. The term is kept per 
thread, as in the HDDS-16794 patch, so that the helpers between the task and 
the submit keep their signatures; it is handed to the worker threads of the run 
when they start and cleared when the task and each worker end. If the term is 
different the request is not submitted and the worker stops as after a failed 
submit. The directories stay in {{deletedDirTable}} and the next run, which 
starts in the new term, purges them once. There is no proto change and nothing 
changes at apply time. Stopping after a failed submit already exists in this 
service and leaves no partial accounting: every {{PurgePathRequest}} carries 
the usage of its own entries, and a sub-directory marked by an applied request 
is processed by a later run.

This narrows the window, it does not close it. The comparison is not atomic 
with the append to the Ratis log, so a run that stalls between the comparison 
and the append for as long as described under Trigger still purges twice. The 
stricter alternative is an optional field in {{PurgeDirectoriesRequest}} with 
the term of the run, checked in {{OMDirectoriesPurgeRequestWithFSO}} against 
the term of the log entry. It is exact and decided identically on every OM, but 
it adds a field without a layout feature gate to an existing request, and OMs 
of two versions applying the same log would disagree. Applying the usage only 
for entries that are still pending was not taken for the reason given in 
HDDS-16794: the deleted tables have no table cache entries and the DB lags the 
apply by the double buffer, so OMs could decide differently.

Covered by {{testNoPurgeAfterTermChangeDuringRun}} in the existing 
{{TestDirectoryDeletingService}}, for the active object store and for a 
snapshot, with two worker threads: the term changes after the run has started, 
the run does not purge and {{snapshotUsedNamespace}} stays 1, and the next run 
purges the directory once. It fails when the comparison is removed, and also 
when the term is not handed to the worker threads, because the first run then 
purges the directory. With the patch {{TestDirectoryDeletingService}}, 
{{TestKeyDeletingService}}, {{TestSnapshotDeletingService}} and 
{{TestOMDirectoriesPurgeRequestAndResponse}} (65 tests) and the mini cluster 
suites {{TestDirectoryDeletingServiceWithFSO}} and {{TestRootedDDSWithFSO}} (9 
tests) pass, and checkstyle is clean. {{TestSnapshotDirectoryCleaningService}} 
timed out in my environment with and without the patch, before the service is 
involved, so it is not counted.

The patch and the one on HDDS-16794 change different files and apply together. 
If both are taken, the term check can move into {{AbstractKeyDeletingService}}. 
HDDS-16795 (a run that starts after a leader change, before an earlier purge is 
applied) is a different window: neither patch covers the other. Its patch 
covers the active object store of this service through the shared flush check, 
not the snapshot tasks.

Found by TLA+ model checking and code review of the OM directory and key 
deleting services and their bucket usage accounting under HDDS-15926, on commit 
ec5ed5f7ea0e4b406aee53aae4906616819cba2b. Checked against HDDS issues and 
apache/ozone pull requests for duplicates before filing. Related but not 
duplicates: HDDS-16794 (the same window for {{KeyDeletingService}}, names this 
service as not covered), HDDS-16795 (a run that starts after a leader change), 
HDDS-16790 ({{KeyDeletingService}} request split) and HDDS-13760 (the flush 
check at the start of a run). 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