[ 
https://issues.apache.org/jira/browse/HDDS-16092?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Ritesh Shukla updated HDDS-16092:
---------------------------------
    Description: 
h2. Summary

Two code paths write the OM's persisted transaction index, 
{{TRANSACTION_INFO_KEY}}, and they are not ordered against each other. A 
snapshot can overwrite a higher index that a batch commit just stored, leaving 
the DB holding transactions that its own index disclaims.

h2. The two writers

The double buffer writes the index *inside* the same RocksDB batch as the 
transaction data ({{OzoneManagerDoubleBuffer.java:373-376}}), so data and index 
become durable together at the commit ({{:379-381}}). It advances the state 
machine's in-memory applied index only *afterwards* ({{:396}}).

{{OzoneManagerStateMachine.takeSnapshotImpl}} ({{:596-610}}) computes its index 
from that in-memory applied value and writes the key with a direct, unbatched 
{{put}} ({{:604}}), then forces it to disk with {{flushDB()}} ({{:605}}).

h2. The race

# *OMDoubleBufferFlushThread* commits a batch through index N. The DB now holds 
that data and {{TransactionInfo = N}}, atomically.
# Before it reaches {{:396}}, the *Ratis StateMachineUpdater* enters 
{{takeSnapshotImpl}} and reads the still-unadvanced applied index M, where M < 
N.
# It writes {{TransactionInfo = M}}. That put is sequenced after the committed 
batch, so *M overwrites N*, and {{flushDB()}} makes it durable.

The gap at step 2 is not a single instruction: between the commit returning and 
the index advancing, the flush thread closes the batch, updates counters, runs 
{{cleanupCache}} ({{:392}}) and releases semaphore permits ({{:394}}).

Two guards that look like they would prevent this do not:

* The wait loop in {{takeSnapshot()}} ({{:582-591}}) only runs while {{applied 
< lastSkippedIndex}}. {{lastSkippedIndex}} advances solely in 
{{notifyTermIndexUpdated}}, which Ratis calls only for non-state-machine 
entries ({{RaftServerImpl.java:1884-1886}}) -- and the OM disables Ratis 
log-metadata entries ({{OzoneManagerRatisServer.java:810}}). In steady state 
the only such entry is the configuration entry written at leader election, so 
the condition is permanently false and the loop never executes.
* {{max(applied, notified)}} at {{:599}} is, for the same reason, just 
{{applied}}: {{lastNotifiedTermIndex}} is frozen at that configuration entry, 
far below applied.

Ratis does not detect it either. Its only assertion is {{snapshot index > 
appliedIndex}} ({{StateMachineUpdater.java:294-299}}), which is the opposite 
direction. A too-low index is accepted silently, and the log purge simply stops 
earlier -- which is safe in itself, and is why the condition leaves no trace.

h2. When it matters

Under sustained write load the next batch commit rewrites the key with a 
correct higher index within milliseconds, so most occurrences are invisible and 
self-repairing.

The case that does not self-repair is *graceful shutdown*. 
{{TRIGGER_WHEN_STOP_ENABLED_DEFAULT}} is true in Ratis 3.2.1, so a snapshot is 
taken on the way down ({{StateMachineUpdater.java:334-335}}), and the OM stops 
the double buffer immediately afterwards 
({{OzoneManagerStateMachine.java:752-755}}). If that stop-snapshot loses the 
race, the regressed index is the *final* persisted value. The node then 
restarts, {{loadSnapshotInfoFromDB}} ({{:709-724}}) seeds the applied index at 
M, and Ratis replays M+1..N against a DB that already contains them.

That makes rolling restarts the realistic exposure: every OM shutdown is one 
draw, and a rolling upgrade of a 3-OM cluster is three. The auto-snapshot 
trigger (every 400000 applied indices, {{OzoneManagerRatisServer.java:869}}) 
fires far more often but is the self-repairing case.

h2. Consequence

Only the restarting OM replays; its peers do not. The OM has no 
replay-idempotency guard -- searching for "replay" across 
{{ozone-manager/src/main}} returns only comments -- and quota accounting is 
read-modify-write ({{OMKeyCommitRequest.java:375,407}}), so replicas can drift 
apart with no exception, no warning and no checksum.

*The severity of that drift is inferred, not demonstrated.* An experiment on a 
3-OM cluster -- regress a follower's persisted index from (t:1, i:41) to (t:1, 
i:33), restart only that OM, poll -- showed the follower restart without error 
and catch its index up to (t:1, i:42) within a second. Replay ran and 
succeeded. That experiment did not check whether the replayed transactions 
caused damage, so the drift described above is traced structurally and has not 
been reproduced.

A prior version of this description claimed a deterministic startup crash-loop 
through the updateID guard in {{WithObjectID.Builder.validate}}. That was wrong 
and is retracted; the comments carry both experiments.

h2. Fix

Order the two writers on a lock owned by the double buffer, and route the 
snapshot's write through a monotonic {{persistIfNewer}} so it can never lower 
the stored index. The snapshot then reports whatever value is actually stored, 
so the DB row, the in-memory copy Ratis reads via {{getLatestSnapshot}}, and 
the returned index cannot disagree.

A read-then-write check alone is insufficient: holding the state machine 
monitor blocks the applied-index update, but a commit already in flight can 
still land between the check and the write. The read must also bypass the table 
cache ({{getSkipCache}}, as {{TransactionInfo.readTransactionInfo}} already 
does for this key), because the value being compared against is written by a 
batch commit that does not populate that cache.

  was:
h2. Summary

{{OzoneManagerStateMachine.takeSnapshotImpl}} writes {{TRANSACTION_INFO_KEY}} 
with a direct, unbatched {{put}} while the double buffer writes the same key 
inside its atomic batch. A snapshot that lands in the gap between the batch 
commit and the applied-index advance computes its index from a stale value and 
overwrites the batch's higher {{TransactionInfo}} with a lower one, leaving the 
DB holding data for transactions its own watermark disclaims.

h2. The two writers

* Double buffer: {{putWithBatch}} at {{OzoneManagerDoubleBuffer.java:375-376}}, 
committed together with the transaction data at {{:379-381}}. The state 
machine's applied index advances only *afterwards*, at {{:396}} 
({{updateLastAppliedIndex.accept}}, wired to 
{{OzoneManagerStateMachine::updateLastAppliedTermIndex}} at 
{{OzoneManagerStateMachine.java:562}}).
* Snapshot: direct {{put}} at {{OzoneManagerStateMachine.java:604}}, using the 
index computed at {{:599}} as {{max(applied, notified)}}.

h2. Interleaving

# *OMDoubleBufferFlushThread* commits transactions 101..105. The DB now 
atomically holds their data and {{TransactionInfo = 105}}. It proceeds toward 
{{accept(105)}}.
# *Ratis StateMachineUpdater* enters {{takeSnapshotImpl}} first. That method is 
{{synchronized}} ({{:596}}) and so is {{updateLastAppliedTermIndex}} ({{:262}}) 
-- the same monitor -- so step 1's {{accept(105)}} blocks. The window is 
therefore held open across the put and the {{flushDB}} that follows, rather 
than being a momentary read blip.
# It reads {{applied = 100}}, computes {{snapshot = 100}}, and at {{:604}} puts 
{{TransactionInfo = 100}}. That write is sequenced after the already-committed 
batch, so *100 overwrites 105*.

End state: the DB contains the effects of 101..105 under a watermark of 100. 
Ratis's snapshot index is also 100, so the log purge stops there and entries 
101..105 survive in the raft log. On an idle cluster the inconsistency persists 
until the next flush.

Note the asymmetry: {{updateLastAppliedTermIndex}} guards the *in-memory* index 
against moving backwards ({{assertUpdateIncreasingly}}, {{:264}}). The 
persisted twin has no equivalent guard, which is exactly the direction this 
moves.

h2. Consequence

*Not established -- see the correction comment on this issue.* The original 
text here claimed a deterministic startup crash-loop via the updateID guard in 
{{WithObjectID.Builder.validate}}. An end-to-end experiment (stage a regressed 
watermark on a real OM, restart it) did not reproduce that: the OM restarted 
cleanly with data intact. The guard exists, but nothing verifies a restart 
reaches it.

What is established is the state itself: the DB ends up holding transactions 
that its own persisted index disclaims, so the record of what the DB contains 
is wrong. That is worth fixing regardless, but no specific failure mode should 
be used to argue severity or backport scope until someone establishes one.

h2. Trigger

The auto-snapshot path, enabled unconditionally at 
{{OzoneManagerRatisServer.java:869}} with the threshold from 
{{ozone.om.ratis.snapshot.auto.trigger.threshold}}.

h2. Suggested fix

Make the snapshot's write unable to lower the stored value: either skip the put 
when the persisted index already exceeds the snapshot index, or route it 
through the same batch/commit path the flush uses so the two writers are 
ordered rather than racing.

h2. Provenance and caveats

Found while reviewing HDDS-16086 / [PR 
#10943|https://github.com/apache/ozone/pull/10943], which touches the other 
writer of this field. Line references are against master at commit 
{{8415f3b876}}.

Every load-bearing citation above was read directly. The one assumption not 
re-derived from source is standard RocksDB write ordering in step 3 -- that a 
{{put}} issued after {{commitBatchOperation}} returns is sequenced after that 
batch. No reproducer has been written yet; the interleaving is derived from the 
code, not observed.


> takeSnapshotImpl's unbatched TransactionInfo put can regress the persisted 
> transaction index below the DB's content
> -------------------------------------------------------------------------------------------------------------------
>
>                 Key: HDDS-16092
>                 URL: https://issues.apache.org/jira/browse/HDDS-16092
>             Project: Apache Ozone
>          Issue Type: Bug
>          Components: Ozone Manager
>            Reporter: Ritesh Shukla
>            Priority: Major
>              Labels: pull-request-available
>
> h2. Summary
> Two code paths write the OM's persisted transaction index, 
> {{TRANSACTION_INFO_KEY}}, and they are not ordered against each other. A 
> snapshot can overwrite a higher index that a batch commit just stored, 
> leaving the DB holding transactions that its own index disclaims.
> h2. The two writers
> The double buffer writes the index *inside* the same RocksDB batch as the 
> transaction data ({{OzoneManagerDoubleBuffer.java:373-376}}), so data and 
> index become durable together at the commit ({{:379-381}}). It advances the 
> state machine's in-memory applied index only *afterwards* ({{:396}}).
> {{OzoneManagerStateMachine.takeSnapshotImpl}} ({{:596-610}}) computes its 
> index from that in-memory applied value and writes the key with a direct, 
> unbatched {{put}} ({{:604}}), then forces it to disk with {{flushDB()}} 
> ({{:605}}).
> h2. The race
> # *OMDoubleBufferFlushThread* commits a batch through index N. The DB now 
> holds that data and {{TransactionInfo = N}}, atomically.
> # Before it reaches {{:396}}, the *Ratis StateMachineUpdater* enters 
> {{takeSnapshotImpl}} and reads the still-unadvanced applied index M, where M 
> < N.
> # It writes {{TransactionInfo = M}}. That put is sequenced after the 
> committed batch, so *M overwrites N*, and {{flushDB()}} makes it durable.
> The gap at step 2 is not a single instruction: between the commit returning 
> and the index advancing, the flush thread closes the batch, updates counters, 
> runs {{cleanupCache}} ({{:392}}) and releases semaphore permits ({{:394}}).
> Two guards that look like they would prevent this do not:
> * The wait loop in {{takeSnapshot()}} ({{:582-591}}) only runs while 
> {{applied < lastSkippedIndex}}. {{lastSkippedIndex}} advances solely in 
> {{notifyTermIndexUpdated}}, which Ratis calls only for non-state-machine 
> entries ({{RaftServerImpl.java:1884-1886}}) -- and the OM disables Ratis 
> log-metadata entries ({{OzoneManagerRatisServer.java:810}}). In steady state 
> the only such entry is the configuration entry written at leader election, so 
> the condition is permanently false and the loop never executes.
> * {{max(applied, notified)}} at {{:599}} is, for the same reason, just 
> {{applied}}: {{lastNotifiedTermIndex}} is frozen at that configuration entry, 
> far below applied.
> Ratis does not detect it either. Its only assertion is {{snapshot index > 
> appliedIndex}} ({{StateMachineUpdater.java:294-299}}), which is the opposite 
> direction. A too-low index is accepted silently, and the log purge simply 
> stops earlier -- which is safe in itself, and is why the condition leaves no 
> trace.
> h2. When it matters
> Under sustained write load the next batch commit rewrites the key with a 
> correct higher index within milliseconds, so most occurrences are invisible 
> and self-repairing.
> The case that does not self-repair is *graceful shutdown*. 
> {{TRIGGER_WHEN_STOP_ENABLED_DEFAULT}} is true in Ratis 3.2.1, so a snapshot 
> is taken on the way down ({{StateMachineUpdater.java:334-335}}), and the OM 
> stops the double buffer immediately afterwards 
> ({{OzoneManagerStateMachine.java:752-755}}). If that stop-snapshot loses the 
> race, the regressed index is the *final* persisted value. The node then 
> restarts, {{loadSnapshotInfoFromDB}} ({{:709-724}}) seeds the applied index 
> at M, and Ratis replays M+1..N against a DB that already contains them.
> That makes rolling restarts the realistic exposure: every OM shutdown is one 
> draw, and a rolling upgrade of a 3-OM cluster is three. The auto-snapshot 
> trigger (every 400000 applied indices, {{OzoneManagerRatisServer.java:869}}) 
> fires far more often but is the self-repairing case.
> h2. Consequence
> Only the restarting OM replays; its peers do not. The OM has no 
> replay-idempotency guard -- searching for "replay" across 
> {{ozone-manager/src/main}} returns only comments -- and quota accounting is 
> read-modify-write ({{OMKeyCommitRequest.java:375,407}}), so replicas can 
> drift apart with no exception, no warning and no checksum.
> *The severity of that drift is inferred, not demonstrated.* An experiment on 
> a 3-OM cluster -- regress a follower's persisted index from (t:1, i:41) to 
> (t:1, i:33), restart only that OM, poll -- showed the follower restart 
> without error and catch its index up to (t:1, i:42) within a second. Replay 
> ran and succeeded. That experiment did not check whether the replayed 
> transactions caused damage, so the drift described above is traced 
> structurally and has not been reproduced.
> A prior version of this description claimed a deterministic startup 
> crash-loop through the updateID guard in {{WithObjectID.Builder.validate}}. 
> That was wrong and is retracted; the comments carry both experiments.
> h2. Fix
> Order the two writers on a lock owned by the double buffer, and route the 
> snapshot's write through a monotonic {{persistIfNewer}} so it can never lower 
> the stored index. The snapshot then reports whatever value is actually 
> stored, so the DB row, the in-memory copy Ratis reads via 
> {{getLatestSnapshot}}, and the returned index cannot disagree.
> A read-then-write check alone is insufficient: holding the state machine 
> monitor blocks the applied-index update, but a commit already in flight can 
> still land between the check and the write. The read must also bypass the 
> table cache ({{getSkipCache}}, as {{TransactionInfo.readTransactionInfo}} 
> already does for this key), because the value being compared against is 
> written by a batch commit that does not populate that cache.



--
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