Siyao Meng created HDDS-17603:
---------------------------------

             Summary: OM persists the index of a write that fails with 
INTERNAL_ERROR before it terminates, so after restart it never applies that 
write
                 Key: HDDS-17603
                 URL: https://issues.apache.org/jira/browse/HDDS-17603
             Project: Apache Ozone
          Issue Type: Bug
            Reporter: Siyao Meng
         Attachments: 
MC-2-do-not-flush-index-of-entry-that-terminates-om.patch, 
TestFailedEntryIndexFlushedBeforeTerminate.java

h3. Mechanism
{{RequestHandler.handleWriteRequest}} adds every response to the double buffer, 
including INTERNAL_ERROR and METADATA_ERROR responses, and 
{{OzoneManagerStateMachine.createErrorResponse}} does the same for an 
{{IOException}} thrown out of the request. {{OzoneManagerDoubleBuffer.add}} 
wakes the flush thread at once. Only after that does 
{{OzoneManagerStateMachine.processResponse}} call {{ExitUtils.terminate}} for 
these statuses. The flush thread skips the DB batch of a non OK response 
({{OMClientResponse.checkAndUpdateDB}}), but {{flushBatch}} still writes 
TRANSACTION_INFO as the highest index in the batch, failed entry included. On 
restart {{OzoneManagerStateMachine}} reports that index to Ratis, which applies 
the log from the next index, so the failed entry is never applied again on that 
OM.

The comment in {{processResponse}} says OM is terminated so that it does not 
"continue applying transactions which leads to an inconsistent state", and 
HDDS-17270 relies on the same thing for a partially applied snapshot purge ("On 
restart the chain is rebuilt from snapshotInfoTable"). That only holds if the 
failed entry is applied again after restart. A {{RuntimeException}} from a 
request already behaves that way, because {{runCommand}} terminates before 
anything is added to the buffer.

HDDS-16860 reports the same mechanism through a different trigger 
({{CompleteFinalizeUpgrade}} while a quota repair runs). Its attached patch 
removes that trigger only and states that the general mechanism remains. This 
issue is about the mechanism itself.

h3. Trigger
Any fault local to one OM that makes a write fail with INTERNAL_ERROR or 
METADATA_ERROR on that OM only, while the other OMs apply the entry. Two 
examples in the snapshot code, both reproduced:
# A snapshot purge for [s2, s1]. On one OM, s2 is purged in cache and chain, 
then reading s1 from {{snapshotInfoTable}} fails (a {{CodecException}} or 
RocksDB read error on that OM's disk; the table is not fully cached, so the 
read reaches RocksDB). {{OMSnapshotPurgeRequest}} returns INTERNAL_ERROR (or 
METADATA_ERROR for a RocksDB error); both terminate.
# A snapshot chain load that hits a read error on OM start marks the chain 
corrupted on that OM ({{SnapshotChainManager}}). The next snapshot create fails 
there with "Snapshot chain is corrupted." (INTERNAL_ERROR).

The flush thread is woken before {{processResponse}} runs, and the double 
buffer is only stopped late in the shutdown hook ({{OzoneManager.stop}} stops 
the RPC servers first), so the failed index is normally persisted before the 
process exits. With a real process exit this is a race. In a unit level run not 
attached here, with the real flush thread and the buffer stopped right after 
the terminate (earlier than the shutdown hook does), the failed index was 
already persisted in 20 of 20 runs. HDDS-16860 reports that on a mini cluster 
the double buffer always flushed the failed entry before the OM was stopped.

h3. Impact
* The OM that hit the fault restarts after the failed entry and never applies 
it, while its peers did. Its snapshot table and chain then permanently differ 
from theirs. In the purge case it keeps s1 and s2, so a later create of a 
snapshot with one of those names fails on that OM only (FILE_ALREADY_EXISTS) 
while the others succeed. In the create case it never has the new snapshot, so 
a later delete of it fails there (FILE_NOT_FOUND) and succeeds elsewhere. This 
was run with three OMs in one JVM (each with its own DB, chain and double 
buffer, the log replayed by hand, no Ratis) in a test not attached here; the 
attached reproduction keeps the single OM part. With the patch the same three 
OM run converges: the restarted OM applies the failed entry again and the later 
create or delete gives the same result on all three.
* Nothing repairs it later short of a Ratis snapshot install from another OM 
(argued, not run). Reads served by that OM, and its behaviour if it becomes 
leader, follow its own diverged state.
* The same applies to any other request that fails with INTERNAL_ERROR or 
METADATA_ERROR because of a local fault.
* Affects 1.0.0 onward: TRANSACTION_INFO has been written by the double buffer 
flush since HDDS-3475, and error status responses have always been added to the 
buffer before the terminate.

h3. Reproduction
PASS on the unmodified source. 
[^TestFailedEntryIndexFlushedBeforeTerminate.java] 
({{hadoop-ozone/ozone-manager}}): one OM with a real metadata DB, snapshot 
chain, {{OzoneManagerRequestHandler}} and a running double buffer flush thread. 
Each entry goes through {{OzoneManagerStateMachine.runCommand}} and 
{{processResponse}}, with system exit disabled so the terminate shows as an 
{{ExitException}}. Ratis is not run: the restart is modelled by reading 
TRANSACTION_INFO from the DB. The test waits for the flush the failed entry 
woke before it stops the buffer. The only faults are a read error injected on 
one {{snapshotInfoTable}} row during a purge, and on the table iterator while 
the chain is loaded. Both tests show TRANSACTION_INFO at the failed entry's 
index while the DB holds none of its effect. A passing test means the defect is 
present.

h3. Suggested fix
Do not put a response that terminates OM into the double buffer. Entries 
applied before it in the same batch are still flushed with their own index. No 
later entry can be in the buffer, because the apply thread is the one calling 
{{System.exit}}. The alternative of cutting the batch at the failed entry in 
{{flushBatch}} would have to stop every later flush as well, so it amounts to 
the same thing with more code.

h3. Patch
[^MC-2-do-not-flush-index-of-entry-that-terminates-om.patch], against 
284fad307bfd129fca3f4966d854ac45ea3b0d11. It also applies to master at 
6135e81a09a.

{{OzoneManagerDoubleBuffer.add}} now drops a response on which OM terminates, 
that is a response that is not successful and has status INTERNAL_ERROR or 
METADATA_ERROR. The check is a new 
{{OzoneManagerStateMachine.isCriticalError}}, which {{processResponse}} now 
uses as well, so the two cannot drift apart. That covers both callers of 
{{add}}. The restarted OM then applies the failed entry again, which is what 
the terminate is meant to achieve. Argued from the code, not run: this also 
removes the lasting part of HDDS-16860, because the restarted OM replays 
{{CompleteFinalizeUpgrade}} before it can be the ready leader, so 
{{QuotaRepairUpgradeAction}} is skipped and that OM finalizes. Covered by 
{{testCriticalErrorDoesNotAdvanceTransactionInfo}} (both statuses, including a 
flush that holds only the failed entry) and 
{{testNonCriticalErrorAdvancesTransactionInfo}} in the existing 
{{TestOzoneManagerDoubleBufferTransactionInfo}}. Without the change the first 
fails with {{expected: <(t:~, i:1)> but was: <(t:~, i:2)>}}. With it, 
{{TestOzoneManagerDoubleBuffer*}}, {{TestOzoneManagerStateMachine}}, 
{{TestOzoneManagerRatisServer}}, {{TestOMSnapshotPurgeRequestAndResponse}} and 
{{TestOMSnapshotCreateRequest}} pass (144 tests), and checkstyle is clean. With 
the patch both tests of the reproduction fail ({{expected: 2L but was: 1L}}): 
the failed entry's index is no longer persisted, so a restart applies it again.

Behaviour change: an INTERNAL_ERROR that is deterministic on every OM (a bug 
rather than a local fault) is today applied as a no-op after one terminate per 
OM, if the flush wins the race. With the patch every OM terminates again when 
it replays the entry, until the cause is fixed. That is the fail stop behaviour 
{{processResponse}} documents, and the same as today for an unchecked exception 
or a failure while the flush thread builds the batch, both of which already 
fail again on every restart. It turns such a failure into an outage that lasts 
until the cause is fixed or the operator, with each OM stopped, moves 
TRANSACTION_INFO past the entry with {{ozone repair om update-transaction --db 
<om.db> --term <term> --index <index>}}, instead of a silent skip.

Found by TLA+ model checking and code review of the OM snapshot purge, set 
property and double buffer apply paths under HDDS-15926, on commit 
284fad307bfd129fca3f4966d854ac45ea3b0d11. 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
(v10.3.26#10030026)

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

Reply via email to