Siyao Meng created HDDS-17291:
---------------------------------

             Summary: SST value pruner waits for the bootstrap lock while 
holding the differ monitor, so OM bootstrap can deadlock with snapshot diff or 
defrag
                 Key: HDDS-17291
                 URL: https://issues.apache.org/jira/browse/HDDS-17291
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-1-take-bootstrap-lock-before-differ-monitor-in-sst-pruner.patch, 
TestBugMC1PrunerSnapshotDiffCheckpointDeadlock.java, 
TestBugMC2DefragPrunerCheckpointDeadlock.java

h3. Mechanism
{{RocksDBCheckpointDiffer.pruneSstFileValues}} enters {{synchronized (this)}} 
for each compaction log entry, rewrites the entry's backup SST files and, still 
inside the monitor, takes the BOOTSTRAP read lock (key 
{{rocksdbCheckpointDifferAOS}}, supplied by {{OmMetadataManagerImpl.loadDB}}) 
around the {{Files.move}} that replaces each file. Apart from 
{{loadAllCompactionLogs}}, which runs once while the DB is opened and removes 
orphaned backup files under the bootstrap read lock inside the monitor, every 
other user of the two locks takes them in the opposite order: 
{{pruneOlderSnapshotsWithCompactionHistory}} and {{SnapshotDefragService}} take 
the bootstrap lock first, and a snapshot diff job holds SNAPSHOT_DB read locks 
on both snapshots ({{SnapshotCache.get}}) when 
{{RDBDifferComputer.computeDeltaFiles}} enters {{synchronized (differ)}}. The 
differ monitor is not a tracked lock resource, so the DAG lock tracker does not 
catch this.

The checkpoint servlet ({{OMDBCheckpointServletInodeBasedXfer}}) write-locks 
every BOOTSTRAP stripe through {{OMDBCheckpointServlet.Lock}} and holds them 
while it collects the files. A follower bootstrap always requests snapshot 
data, so in its last batch the servlet also write-locks every SNAPSHOT_DB 
stripe through {{SnapshotCache.lock()}}. That gives two cycles:
* *Snapshot diff (default configuration).* The pruner holds the monitor and 
waits for BOOTSTRAP read, which the servlet holds. The snapshot diff job holds 
SNAPSHOT_DB read and waits for the monitor. The servlet holds BOOTSTRAP write 
and waits for SNAPSHOT_DB write.
* *Defrag (non default stripe count).* {{SnapshotDefragService}} holds 
BOOTSTRAP read with key {{SnapshotDefragService}} and waits for the monitor in 
{{RDBDifferComputer.computeDeltaFiles}}. The pruner holds the monitor and waits 
for BOOTSTRAP read on its own stripe. The servlet, which takes the stripes in 
ascending order, has already taken the pruner's stripe (or is queued on it) and 
waits for the defrag stripe. This needs the pruner's stripe at or below the 
defrag stripe. Both keys are constants, so the outcome is fixed by 
{{ozone.om.lock.stripes.bootstrap_lock}}: no cycle at the default 512 (or 32, 
1024), a cycle at 1, 2, 4, 8, 16, 64, 128, 256 and 2048 to 32768 (computed from 
the Guava stripe mapping; other values round up to the next power of two, so 
100 behaves like 128). That key is not documented.

h3. Trigger
Both cycles need the rocks-tools native library, which Linux release builds 
include (built with {{-Drocks_tools_native}}) and the OM loads by default; 
without it the pruner is not scheduled. The 2.2.1 library does not load on RHEL 
or Rocky Linux 9, including the {{apache/ozone:2.2.1}} image (HDDS-17153, fixed 
in 2.3.0), so neither cycle forms on those hosts. Snapshots must exist and a 
tracked column family must have been compacted since, so that the prune queue 
is not empty.

For the snapshot diff cycle a follower must be bootstrapping from the leader, 
because only a follower bootstrap requests snapshot data and so makes the 
servlet take {{SnapshotCache.lock()}}. The pruner's periodic run (every 10 
minutes by default) has to start while the servlet holds the bootstrap lock, or 
be in its rewrite when the servlet arrives. Either way the pruner ends up 
parked on the bootstrap lock while holding the monitor. A snapshot diff job 
that reaches its delta file computation before the servlet reaches 
{{SnapshotCache.lock()}} then closes the cycle. The servlet holds the bootstrap 
lock for its whole collection phase, so this window is as long as copying the 
snapshot directories (argued from the code; the reproduction orders the steps 
with a latch).

For the defrag cycle, {{ozone.snapshot.defrag.service.interval}} must be 
enabled (it is off by default) and the stripe count must be one of the values 
above. Any request that takes the BOOTSTRAP write lock is enough, not only a 
follower bootstrap: Recon's OM DB fetch (which does not request snapshot data) 
and the v1 {{/dbCheckpoint}} servlet take the same lock.

h3. Impact
All waits are untimed lock or monitor waits, and the JVM's deadlock detection 
does not report them because read locks have no owner. Only an OM restart 
recovers. On the leader:
* the checkpoint request never returns, so the follower cannot finish its 
bootstrap; a retry queues behind the same lock;
* every later user of the BOOTSTRAP read lock blocks behind the servlet: the 
key, directory and snapshot deleting services when they submit purges, 
{{SstFilteringService}}, {{SnapshotDefragService}} and the differ's pruning 
tasks, so space is no longer reclaimed;
* later snapshot diff jobs block on the differ monitor. Snapshot reads and 
diffs whose snapshots map to the SNAPSHOT_DB stripes the servlet has already 
taken also block (argued, not run).

In the defrag cycle the servlet waits at the defrag stripe, so only bootstrap 
readers on that stripe and the stripes below it block, and snapshot reads are 
not affected. The requester (follower or Recon) never gets its checkpoint, and 
defrag, the differ's pruning tasks (they share the pruner's thread) and later 
snapshot diff jobs hang as above.

Released versions 2.2.0 and 2.2.1 have the same code (read from the tags). 
2.1.x has the nesting, but its servlet does not take {{SnapshotCache.lock()}}, 
so the snapshot diff cycle does not form there.

h3. Reproduction
Two self contained classes for {{hadoop-ozone/ozone-manager}} (target path and 
Maven command in the class comment). Both pass on unmodified source when the 
bug is present. They use the real {{OzoneManagerLock}}, the OM DB and differ 
built by {{OmMetadataManagerImpl.loadDB}} (so the production lock supplier), a 
prune queue entry produced by a real compaction of keyTable with a 
snapshotInfoTable row present, {{pruneSstFileValues}}, 
{{RDBDifferComputer.computeDeltaFiles}} and the servlet's 
{{OMDBCheckpointServlet.Lock}}. The native SST reader is mocked as in 
{{TestRocksDBCheckpointDiffer}}, so they run without the native library, and 
the snapshot local data provider is mocked. The servlet request, the snapshot 
diff job and the defrag task are replayed at their lock steps, not run end to 
end through HTTP or the services.
* [^TestBugMC1PrunerSnapshotDiffCheckpointDeadlock.java]: default 
configuration. A latch in the mocked SST scan keeps the pruner in its rewrite 
until the snapshot diff thread is blocked on the monitor and the servlet thread 
waits in {{SnapshotCache.lock()}}. After the latch opens none of the three 
finishes; the pruner waits on the lock owned by the servlet thread, the 
snapshot diff thread on the monitor owned by the pruner, and a later bootstrap 
read (key {{KeyDeletingService}}) blocks too.
* [^TestBugMC2DefragPrunerCheckpointDeadlock.java]: defrag cycle with the 
bootstrap lock of a real {{SnapshotDefragService}}, parameterized over 2, 256 
and 2048 stripes (all three threads stuck, defrag blocked on the monitor owned 
by the pruner) and a control at the default 512 (all three complete).

Not exercised: a real follower bootstrap over HTTP, a real snapshot diff or 
defrag run, and the real native library.

h3. Patch
[^MC-1-take-bootstrap-lock-before-differ-monitor-in-sst-pruner.patch], against 
e0de543cbe9d73f3bf5473a45e1c59cd6eae7173. It also applies to master at 
1ff29ee6b86.

{{pruneSstFileValues}} now takes the BOOTSTRAP read lock for each compaction 
log entry before entering {{synchronized (this)}}, instead of around 
{{Files.move}} inside it. The pruner then never waits for the bootstrap lock 
while holding the monitor, which removes the edge both cycles need, at any 
stripe count. The move is still done under the bootstrap read lock, so a 
checkpoint that holds the write lock still cannot see a backup SST being 
replaced (the protection added by HDDS-12501 and HDDS-13905). The rest of the 
method is unchanged apart from indentation ({{git diff -w}} shows only the real 
change).

The cost is a longer wait for a bootstrap. The lock now covers one compaction 
log entry, whose input file list can be long after a large or manual 
compaction, and the pruner holds it while it waits for the differ monitor, so a 
bootstrap that arrives then also waits for any snapshot diff or defrag already 
in the monitor. While the servlet waits on the pruner's stripe it holds the 
stripes below it, so bootstrap readers on those stripes wait as well. The 
servlet is not starved: the pruner releases the lock between entries, and with 
the default nonfair lock its next read waits behind the queued writer.

After the patch {{loadAllCompactionLogs}} is the only path left that takes the 
monitor and then the bootstrap lock (through {{cleanupOrphanedSstBackupFiles}}, 
added by HDDS-14742 after 2.2.1). It cannot deadlock: it runs once in the 
{{RDBStore}} constructor, before the pruner's first run (scheduled one interval 
after start) and before this OM serves checkpoint requests. The patch leaves it 
unchanged.

New test 
{{TestRocksDBCheckpointDiffer#testPruneSstFileValuesWaitsForBootstrapLockOutsideDifferMonitor}}:
 while another thread holds the bootstrap write lock it checks that the backup 
SST file is unchanged and that a snapshot diff style {{synchronized (differ)}} 
can proceed, then that the file is pruned once the lock is released. It fails 
without the change. With it the {{rocksdb-checkpoint-differ}} module tests 
(121), the related {{ozone-manager}} unit tests (141, including 
{{TestSnapshotDefragService}}, {{TestSnapshotDiffManager}}, 
{{TestRDBDifferComputer}}) and {{TestOMDbCheckpointServletInodeBasedXfer}} (15) 
pass, and both attached reproductions no longer show the deadlock.

Found by TLA+ model checking and code review of the Ozone Manager lock ordering 
paths under HDDS-15926, on commit e0de543cbe9d73f3bf5473a45e1c59cd6eae7173. 
Checked against HDDS issues and apache/ozone pull requests for duplicates 
before filing. Related but not duplicates: HDDS-13905 (resolved, made 
background bootstrap locks read locks and kept this nesting), HDDS-9486 
(resolved, an earlier pruner and checkpoint deadlock), HDDS-16685 (snapshot 
diff takes SNAPSHOT_DB stripes out of order against the same servlet step) and 
HDDS-17259 (the compaction listener synchronizes on the wrong object in the 
same class). The attached patch is a proposal for review. Generated with 
Specula (Claude Opus 5.5).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to