[ 
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 race itself is reproduced; the drift is not.* The mechanism has been 
demonstrated end to end on a
3-OM HA cluster with the real snapshot trigger and real write load -- 24 
occurrences of the persisted
index moving backwards in ~30 seconds on unfixed code, and zero across 21.6 
million samples with the
fix. See the reproduction comment. What remains inferred is what damage follows 
from that state. 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

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.


> 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 race itself is reproduced; the drift is not.* The mechanism has been 
> demonstrated end to end on a
> 3-OM HA cluster with the real snapshot trigger and real write load -- 24 
> occurrences of the persisted
> index moving backwards in ~30 seconds on unfixed code, and zero across 21.6 
> million samples with the
> fix. See the reproduction comment. What remains inferred is what damage 
> follows from that state. 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