Siyao Meng created HDDS-17259:
---------------------------------

             Summary: Compaction DAG is populated without the lock its readers 
take, so snapshot diff can miss keys and backup SST pruning can silently stop
                 Key: HDDS-17259
                 URL: https://issues.apache.org/jira/browse/HDDS-17259
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-1-synchronize-compaction-dag-population-with-dag-walks.patch, 
TestBugMC1DagDiffDuringCompactionListener.java

h3. Mechanism
{{RocksDBCheckpointDiffer.newCompactionCompletedListener}} calls 
{{CompactionDag.populateCompactionDAG}} inside {{synchronized (this)}}, but the 
block is in an anonymous {{AbstractEventListener}}, so it locks the listener 
object. Every DAG walk ({{getSSTDiffList}}, {{internalGetSSTDiffList}}) and the 
DAG pruning tasks ({{pruneSstFiles}}, {{pruneSstFileNodesFromDag}}) lock the 
differ instead. Nothing keeps them from running while the listener is adding a 
compaction to the DAG, a Guava {{MutableGraph}} that is not thread safe. The 
comment in {{pruneSstFiles}} shows the reader lock was meant to exclude the 
compaction thread.

Two consequences follow.

*SST file pruning.* {{pruneSstFiles}} streams over the forward DAG's nodes 
while the listener can add nodes. The scan can then throw 
{{ConcurrentModificationException}}. The task is scheduled with 
{{Scheduler.scheduleWithFixedDelay}} and has no exception handling, so the 
first such exception cancels it for good, and nothing is logged.

*Snapshot diff.* {{populateCompactionDAG}} first adds the output node O, then 
the edges from O to each input, one at a time. If a walk runs after the edge to 
an input A that is in the older snapshot but before the edge to a newer input 
B, A counts as unchanged, B is never visited, the reachability check passes, 
and the result is a present but empty delta. {{CompositeDeltaDiffComputer}} 
falls back to a full diff only when the DAG walk returns no result (an empty 
{{Optional}}) or throws, so the keys that exist only in B are missing from the 
diff. If B's edge comes first, A is unreachable and the diff falls back to a 
full diff (safe). The edge order follows the {{HashMap}} order of the SST paths.

h3. Trigger
SST file pruning: a tracked compaction (an OM with at least one snapshot) 
completes while the {{pruneSstFiles}} scan runs. The scan runs every 
{{ozone.om.snapshot.compaction.dag.prune.daemon.run.interval}} (default 10m) 
and takes time proportional to the DAG size. No unusual scheduling is needed, 
only that overlap.

Snapshot diff:
# The older snapshot contains SST A.
# A key k2 is written and flushed into SST B.
# RocksDB compacts A and B into O; the completed listener starts adding O to 
the DAG.
# A newer snapshot is created while the listener is between the edge to A and 
the edge to B.
# A snapshot diff between the two reaches the DAG walk before the listener 
finishes.

The unsafe window for the diff is only the in-memory edge loop of 
{{populateCompactionDAG}}. The part of the listener before it is safe (a node 
not yet in the DAG is read whole). So in practice the diff omission needs the 
compaction thread to be descheduled inside that loop for at least as long as 
creating a snapshot and starting a diff job. It was not observed without an 
injected pause.

h3. Impact
* Once the {{pruneSstFiles}} task is cancelled, backup SST files of compacted 
outputs are no longer removed from the compaction SST backup directory until 
the OM restarts or reopens its DB. Only the DAG pruning of entries older than 
{{ozone.om.snapshot.compaction.dag.max.time.allowed}} (default 30d) still 
removes them, so the directory holds up to that much compaction history on 
disk. Shown with a unit level probe against the real differ and its scheduler 
(prune interval 20 ms, 20000 compactions added to the DAG under a separate 
monitor in about 3 s): the task stopped on unmodified source and kept running 
with the patch. Not run on a cluster.
* When the diff window is hit, the snapshot diff report silently omits the keys 
that were only in the newer input (CREATE k2 above). The finished job serves 
the stored report; nothing revalidates it. Exercised at the differ level only, 
not with an end to end report on a cluster.
* Argued from code, not run: 
{{SnapshotDefragService.performIncrementalDefragmentation}} uses the same 
{{CompositeDeltaDiffComputer}}, so a defragmentation that hit the same window 
would build the snapshot without those keys.

h3. Reproduction
PASS on unmodified source, deterministic up to the edge order. 
[^TestBugMC1DagDiffDuringCompactionListener.java] 
({{hadoop-hdds/rocksdb-checkpoint-differ}}) shows the diff omission. It uses a 
real RocksDB with the differ's real listeners, a real {{compactRange}} and real 
checkpoints, and issues the diff as {{RDBDifferComputer}} does. The only 
intervention is timing: a delegating proxy on the forward DAG holds the 
listener after its first edge until the diff has finished or is waiting. Every 
graph call is passed through unchanged. It repeats the scenario with shifted 
SST numbers until the omission order occurs. Output of the run on the 
unmodified source, round 1: {{diff during listener: present, files=[] 
keys=[]}}, then {{diff after listener: present, files=[000013] keys=[k2]}}. A 
passing test means the defect is present. The pruning consequence is covered by 
the regression test in the patch and the probe described above, not by this 
attachment.

h3. Suggested fix
Locking the differ in the listeners ({{synchronized 
(RocksDBCheckpointDiffer.this)}}) would also close both windows, but the differ 
monitor is held across blocking work: {{pruneSstFileValues}} rewrites backup 
SST files and acquires the bootstrap lock inside it, and RocksDB writes to the 
compaction log table happen under it. RocksDB compaction threads would then 
wait for that work. The attached patch uses a lock that only guards in-memory 
DAG operations instead.

h3. Patch
[^MC-1-synchronize-compaction-dag-population-with-dag-walks.patch], against 
b10a7d427bdf83807a7e48a1be507d3b02d5a503. It also applies to master at 
d3b5f518556.

{{CompactionDag.populateCompactionDAG}} and {{CompactionDag.pruneNodesFromDag}} 
become synchronized, {{RocksDBCheckpointDiffer.getSSTDiffList}} walks the DAG 
inside {{synchronized (compactionDag)}}, and the node scan in {{pruneSstFiles}} 
takes the same lock instead of the differ, which removes the 
{{ConcurrentModificationException}} that cancels the pruning task. A walk or 
scan then sees a compaction either not yet added or with all its edges. Holders 
of this lock only touch in-memory DAG structures, so the compaction thread 
waits at most for a walk or a prune in progress. No config, protocol or on disk 
change.

Covered by two tests in the existing {{TestRocksDBCheckpointDiffer}} that hold 
{{populateCompactionDAG}} after the first edge. 
{{testGetSSTDiffListDuringCompactionDagPopulation}} runs {{getSSTDiffList}} 
meanwhile, for both edge orders: without the change the omission order returns 
an empty delta and the other order an empty {{Optional}}; with it both return 
the newer input. {{testPruneSstFilesDuringCompactionDagPopulation}} runs 
{{pruneSstFiles}} meanwhile: without the change (or with only the 
{{pruneSstFiles}} line reverted) the scan runs during the population; with it 
the scan waits and then prunes the backup file of the new output. With the 
change the tests of {{rocksdb-checkpoint-differ}} (123 run, 2 native library 
tests skipped), the OM differ users {{TestRDBDifferComputer}}, 
{{TestCompositeDeltaDiffComputer}}, {{TestSnapshotDiffManager}} and 
{{TestOMSnapshotDirectoryMetrics}} (89), and the mini cluster suites 
{{TestOMSnapshotDAG}} and {{TestSnapshotBackgroundServices}} (6) pass, and 
checkstyle is clean. With the patch the reproduction fails, because the diff 
waits for the listener in every round and always contains k2.

Found by TLA+ model checking and code review of the OM snapshot diff paths 
under HDDS-15926, on commit b10a7d427bdf83807a7e48a1be507d3b02d5a503. 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
(v8.20.10#820010)

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

Reply via email to