HDDS-16092. Stop takeSnapshot from lowering the persisted transaction index - #10953
HDDS-16092. Stop takeSnapshot from lowering the persisted transaction index#10953kerneltime wants to merge 3 commits into
Conversation
… index The double buffer commits a batch of transactions and the TRANSACTION_INFO_KEY describing them together, then advances the state machine's applied index once that commit returns. A snapshot taken in between derives its index from the not-yet-advanced value and wrote it unconditionally, so it could overwrite a higher stored index with a lower one. The DB was then left holding transactions its own watermark disclaims -- its own record of what it contains is wrong. Order the two writers on a lock owned by the double buffer, and route the snapshot's write through persistIfNewer so it can only move the stored index forward. The snapshot then reports whichever value is stored, keeping the in-memory copy Ratis reads from disagreeing with the DB. The lock covers the batch commit and the read-compare-write, not the snapshot's flushDB: only the flush daemon commits and snapshots are rare, so it is all but uncontended. Generated-by: Claude Code (Fable 5)
…hot write The tests added with the fix pinned only its comparison: they passed with both locks removed, so they could not tell the fix apart from a read-then-write guard that still loses the race against an in-flight commit. Add a staged interleaving test that holds the snapshot between its read and its write while a real batch commit is attempted, which fails when the lock is gone and is the only test that does. Add a concurrent test asserting a reader never sees the stored index move backwards, which fails within a few rounds when the comparison is gone. Each covers the half the other misses; both javadocs say so. They live in their own class because the existing double-buffer tests assert on cumulative flush counters that any added commit would inflate, and they keep the flush daemon stopped so the test thread is the only flusher, as in production -- two concurrent flushers write indexes out of order regardless of this fix. persistIfNewer gets a no-op hook between its read and its write for the staged test to drive. Generated-by: Claude Code (Fable 5)
persistIfNewer compared against a value read with Table.get, which consults the table cache first. The value it must compare against is written by a batch commit, which does not populate that cache, and on a full-cache metadata manager a miss returns null rather than falling through to the DB -- so the guard could conclude nothing was stored and write the lower index anyway, which is the defect it exists to prevent. Use getSkipCache, as TransactionInfo.readTransactionInfo already does for this same key. Generated-by: Claude Code (Fable 5)
Reproduction you can run yourselfThe analysis above is a code trace, so here is the race actually happening. Branch, on my fork, not for merge: https://github.com/kerneltime/ozone/tree/HDDS-16092-repro It is unmodified On that branch it fails: Cherry-pick this PR's commits on top and it passes — 0 regressions across 21,635,614 samples in a full 90-second run, versus a hit within ~30 seconds without them. Why the threshold is loweredThe one setting changed from production is Why it is on a branch and not in this PRIt depends on a non-production setting and on thread timing, so a green run proves nothing — it would be a CI gate that passes whether or not the bug is present. The committed tests in this PR are the deterministic equivalent, each verified by mutation to fail when the half of the fix it covers is removed. This branch is for seeing the real thing. The detectorA watcher per OM polls |
There was a problem hiding this comment.
Pull request overview
Prevents OM snapshots from lowering the persisted transaction index during concurrent double-buffer commits.
Changes:
- Serializes transaction-info writes and persists only newer indexes.
- Reports the actual persisted snapshot index.
- Adds deterministic concurrency and state-machine tests.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
OzoneManagerDoubleBuffer.java |
Adds locking and monotonic persistence. |
OzoneManagerStateMachine.java |
Uses the persisted index for snapshots. |
TestOzoneManagerDoubleBufferTransactionInfo.java |
Tests concurrent index writes. |
TestOzoneManagerStateMachine.java |
Tests snapshot persistence behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| when(om.getMetadataManager()).thenReturn(metaMgr); | ||
| when(metaMgr.getTransactionInfoTable()).thenReturn(txnTable); | ||
| when(metaMgr.getStore()).thenReturn(store); | ||
| stubPersistIfNewerAsAccepting(); |
Generated-by: Claude Code (Fable 5)
What changes were proposed in this pull request?
The OM records how far it has applied in a single RocksDB key,
TRANSACTION_INFO_KEY. Two paths write it and they are not ordered against each other, so one can overwrite the other with an older value.The double buffer writes that key inside the same batch as the transaction data, so the two commit together. It updates the state machine's in-memory counter only after the commit returns. A snapshot taken in that gap reads the not-yet-updated counter and writes it over the newer value the commit just stored — and forces it to disk.
The DB is then in a state where it physically contains transactions that its own index says it does not.
Under load this is self-repairing: the next commit rewrites the key correctly a few milliseconds later. It matters on graceful shutdown, where Ratis takes a snapshot on the way down and the double buffer stops right after, so nothing repairs it. That OM restarts believing it is behind its own data and replays a batch its peers never replay. Rolling restarts are the realistic exposure.
Two guards that look like they would prevent the race do not, both for the same reason. The wait loop in
takeSnapshot()only runs whileapplied < lastSkippedIndex, andmax(applied, notified)only helps ifnotifiedis ahead — but both advance solely innotifyTermIndexUpdated, which Ratis calls only for non-state-machine entries, and the OM disables Ratis log-metadata entries (OzoneManagerRatisServer.java:810). In steady state the loop never executes and themaxis justapplied.HDDS-16092 has the detailed analysis, including which parts are verified and which are inferred.
The fix
A lock in the double buffer orders its batch commit against the snapshot's write, and
persistIfNewermakes that write monotonic — it will not lower the stored index. The snapshot then reports whatever value is actually stored, so the DB row, the in-memory copy Ratis reads, and the returned index all agree.A read-then-write check alone is not enough: a commit already in flight can land between the check and the write. The read deliberately uses
getSkipCache, matchingTransactionInfo.readTransactionInfo, because the value it compares against is written by a batch commit that does not populate the table cache.The lock covers the batch commit and the read-compare-write, but not the snapshot's
flushDB. Only the flush daemon commits and snapshots are rare, so it is effectively uncontended. Lock ordering is one-directional — the commit block releases before the applied-index update takes the state machine monitor — so there is no cycle.What is the link to the Apache JIRA
https://issues.apache.org/jira/browse/HDDS-16092
How was this patch tested?
New unit tests, each checked by mutation — remove one half of the fix and confirm which test fails:
TestOzoneManagerDoubleBufferTransactionInfo(new) holds a snapshot between its read and its write while a real batch commit is attempted. It is the only test that catches a missing lock — the window is far too narrow to lose by chance. Its sibling runs commits and snapshots concurrently and asserts a reader never sees the stored index move backwards.TestOzoneManagerStateMachinegains three cases for the state machine's use of it. The first fails on unfixed master withexpected: <1#105> but was: <1#100>.71 tests pass across
TestOzoneManagerStateMachineand the double-buffer suites, plusTestOMRatisSnapshots(7/7, real snapshots and checkpoint installs under load) andTestOzoneManagerRestart(3/3) — those two cover the risk this change actually adds, which is a lock on every batch commit on the write path.The race is also reproduced end to end on a 3-OM HA cluster, with the real Ratis snapshot trigger racing the real flush under real client write load. The only setting changed from production is the snapshot trigger threshold, dropped from 400000 to 50 — that does not create the race, it just samples the existing window thousands of times instead of once. Detector: poll each OM's persisted index and record any move backwards.
That reproduction is not committed here — it depends on a non-production threshold and on timing, so it would be a CI gate that passes whether or not the bug is present. It lives on a branch you can run yourself instead: kerneltime/ozone@HDDS-16092-repro — unmodified
masterplus one test, which fails there and passes with these commits cherry-picked. See the comment below for how to run it. The staged interleaving test above is the deterministic equivalent.