[
https://issues.apache.org/jira/browse/HDDS-16980?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Siyao Meng updated HDDS-16980:
------------------------------
Attachment: TestBugCR7HsyncReturnsAfterStreamFailed.java
> Concurrent hsync on a Ratis key returns normally after the stream failed, and
> replaces the key's block list in OM with an empty one
> -----------------------------------------------------------------------------------------------------------------------------------
>
> Key: HDDS-16980
> URL: https://issues.apache.org/jira/browse/HDDS-16980
> Project: Apache Ozone
> Issue Type: Bug
> Reporter: Siyao Meng
> Priority: Critical
> Attachments: CR-7-fail-calls-in-progress-when-key-stream-fails.patch,
> TestBugCR7HsyncReturnsAfterStreamFailed.java
>
>
> h3. Mechanism
> When a call on {{KeyOutputStream}} cannot recover from a block failure,
> {{markStreamClosed}} drops the buffered data and the block list
> ({{BlockOutputStreamEntryPool.cleanup}}) and sets {{closed}}. {{write}},
> {{flush}} and {{hsync}} check {{closed}} only when they are entered. With
> concurrent calls allowed, a second call that passed the check before still
> runs to its end, in one of two ways:
> # Its own flush of the same block failed as well, and its {{handleException}}
> gets the write lock after the first caller's. {{handleExceptionInternal}}
> finds no buffered data, because the buffer pool was cleared, so it retries
> nothing and returns. {{handleFlushOrClose}} loops, finds no current block and
> returns. This is the wide window: the second call only has to be in its flush
> of the block when the block fails.
> # It read the current block and then waited for the write lock, which the
> first caller's failure handling holds. That handling has cleaned up the block
> stream, and {{RatisBlockOutputStream.hsync}} and {{BlockOutputStream.flush}}
> do nothing on a cleaned up stream.
> In both cases {{KeyOutputStream.hsync}} goes on to
> {{BlockOutputStreamEntryPool.hsyncKey}}, which builds the block list from the
> cleared entries. The list is empty, so it sends OM an hsync with the length
> of the stream and no blocks. The key commit request ({{OMKeyCommitRequest}},
> {{OMKeyCommitRequestWithFSO}}) stores that in the key table, where
> {{OmKeyInfo.updateLocationInfoList}} replaces the block list. Then {{hsync}}
> returns without an exception.
> h3. Trigger
> # {{ozone.client.hbase.enhancements.allowed=true}} and
> {{ozone.client.key.write.concurrency}} greater than 1 or negative (run with
> -1), so that calls on one key stream can overlap. The default configuration
> is not affected. {{ozone.fs.hsync.enabled=true}} is needed for the OM half: a
> second {{flush}} returns normally in the same way and sends nothing to OM.
> # A block of a Ratis key fails in a way the client cannot recover from. Run
> with every write answered with {{CLOSED_CONTAINER_IO}}, so that the
> replacement blocks fail too and the default 5 retries are used up, and with
> {{ozone.client.max.retries=0}}. Other ends of the failure handling (OM cannot
> allocate another block, the thread is interrupted, the exception of
> HDDS-16975) lead to the same {{markStreamClosed}} and are argued, not run.
> # Two or more calls are in progress on that block, one of them an {{hsync}}.
> A single thread always gets the exception (run).
> HBase WAL sync threads call {{hflush}} on one stream concurrently (the stack
> in HDDS-11374 shows {{FSHLog$SyncRunner}} in {{KeyOutputStream.hsync}} with
> write concurrency -1), so there the third condition is the normal state. Not
> run with HBase. Which cluster events use up the retries in practice (for
> example a client that reaches OM but no datanode) is argued, not run.
> The same code is in the release tags ozone-2.0.0 to ozone-2.2.1 (compared in
> the tagged source, nothing run on a release).
> h3. Impact
> Run on a mini cluster with 5 datanodes, a real OM, an FSO bucket and the
> default retry count, unmodified source. The write errors came from the client
> side test hook {{XceiverClientManager.enableErrorInjection}}, which answered
> every WriteChunk and PutBlock with {{CLOSED_CONTAINER_IO}}. The datanodes
> were healthy.
> * One call held: a file with 1000 bytes that were synced earlier and 500 more
> bytes written, then two {{FSDataOutputStream.hsync}} calls, the first held in
> that hook until the second was in the same flush. One call throws "Retry
> request failed. retries get failed due to exceeded maximum allowed retries
> number: 5", the other one returns normally. The 500 bytes are stored nowhere.
> * After that the key in OM has length 1500 and no blocks, and a reader gets 0
> bytes, so the 1000 bytes that an earlier {{hsync}} had acknowledged are not
> readable either. The block is still listed in the writer's open key entry and
> its data is still on the datanodes, but nothing reads through that entry.
> {{recoverLease}} returns true, closes the file with length 0 and removes that
> entry. The blocks were not queued for deletion in that run.
> * The caller that got no exception finds out only with its next call ("Stream
> is closed"). {{close}} on the failed stream returns normally.
> * No call held: one writer thread and 5 threads calling {{hsync}} in a loop,
> errors from second 1, 8 rounds. In every round the {{hsync}} calls that were
> in progress (4 or 5) returned normally after the first call had failed, the
> key was left with no blocks, and {{recoverLease}} closed the file with length
> 0. Between 229632 and 393216 bytes had been acknowledged before the errors
> started.
> For an application that treats a returned {{hsync}} as durable, such as the
> HBase WAL, the data acknowledged by this and by earlier {{hsync}} calls is
> silently lost to readers and to lease recovery.
> h3. Reproduction
> PASS, deterministic, unmodified source.
> [^TestBugCR7HsyncReturnsAfterStreamFailed.java] ({{hadoop-ozone/client}},
> about one second) drives the unmodified write path through
> {{OzoneBucket.createKey}} and {{OzoneOutputStream}} with mock datanodes and
> the mock OM. A subclass of {{MockXceiverClientFactory}} answers every write
> with {{CLOSED_CONTAINER_IO}} and holds the first caller, either in its flush
> or in its failure handling, until the second caller is blocked behind it. No
> fixed sleeps. Cases: both ways described above with the default retry count
> (one of the two {{hsync}} calls returns normally, the key in the mock OM has
> the full length and reads back empty), the second way without retries,
> {{flush}} and {{write}} as the second call, and a control with a single
> caller, which gets the exception. A passing test means the defect is present.
> The mini cluster runs used temporary tests and are not attached.
> h3. Suggested fix
> The attached patch fixes the client. A check on the OM side could be a second
> line of defence, for example against an hsync with a data size and no blocks.
> It needs its own discussion and is not part of the patch.
> h3. Patch
> [^CR-7-fail-calls-in-progress-when-key-stream-fails.patch], against
> 031e76aa2c9a60b67185390b5b33499cbbb82e43. It also applies to master at
> 5181ff8ae81 (not built there).
> * {{KeyOutputStream}} records when {{close}} has committed the key. A stream
> that was closed without that has failed. {{write}}, {{flush}} and {{hsync}}
> check for it again with the write lock held: {{write}} before it touches the
> block pool, {{flush}} and {{hsync}} before they report success. {{hsync}}
> does it in the same critical section as {{hsyncKey}}, so no block list is
> sent to OM for a failed stream.
> * To make that check exact, the stream is now always closed with the write
> lock held: {{handleException}} closes it before it releases the lock when the
> handling fails, and {{handleFlushOrClose}} takes the lock for
> {{markStreamClosed}}.
> * Behaviour: a call that is in progress when the stream fails now gets
> "Stream is closed", also when its own data had already reached the datanodes.
> Nothing changes without a failure. In particular a call that is in progress
> when another thread's {{close}} commits the key does what it did before, for
> example an {{hflush}} with the default configuration returns normally. Calls
> that returned before the failure and {{close}} itself are not affected.
> * Tests, in the existing {{TestKeyOutputStream}}:
> {{testCallInProgressFailsWhenStreamFails}} (5 cases: {{hsync}} in both ways,
> without retries, {{flush}}, {{write}}) also checks that an {{hsync}} before
> the failure succeeds and that its data stays readable. Without the change all
> 5 cases fail with "Expecting code to raise a throwable".
> {{testFlushInProgressSucceedsWhenStreamIsClosed}} (default configuration,
> {{hflush}} against {{close}}) passes with and without the change.
> * The tests do not fail without the two lock changes. Without the first one,
> a loop of the case without retries still let the second {{hsync}} return
> normally in 4 of 2000 runs (0 of 2000 with it). The second one is by
> reasoning.
> * With the patch: the tests of {{hadoop-ozone/client}} (165) pass, checkstyle
> is clean, and these mini cluster suites pass: {{TestHSync}} (42),
> {{TestBlockOutputStream}} (24), {{TestBlockOutputStreamWithFailures}} (20),
> {{TestFailureHandlingByClient}} (6),
> {{TestFailureHandlingByClientFlushDelay}} (1),
> {{TestOzoneClientRetriesOnExceptions}} (2),
> {{TestOzoneClientRetriesOnExceptionFlushDelay}} (1),
> {{TestMultiBlockWritesWithDnFailures}} (2), {{TestClientRetryTimeout}} (4). 5
> of the 6 cases of the reproduction then fail, because the second call throws.
> The control still passes.
> * Mini cluster with the patch: with one call held, the second {{hsync}} fails
> with "Stream is closed", the key keeps its block and the 1000 synced bytes
> stay readable. With no call held, no {{hsync}} returned normally after a
> failure in 3 rounds, and all bytes acknowledged before the failure stayed
> readable.
> * Not repaired by the patch: {{recoverLease}} on such a file failed in the
> same runs, for example with "ContainerID 6 does not exist", for a block that
> was allocated during the failed retries and never written. The open key entry
> lists the same blocks with and without the patch. Without the patch the key
> table has no blocks left for the recovery to check. Lease recovery failing on
> never written blocks of the open key is described in HDDS-16830.
> The patch is independent of HDDS-16975 and applies before or after its patch.
> The main changes are also independent of the patch of HDDS-16979, but both
> patches add imports and a test at the end of {{TestKeyOutputStream}}, so the
> one merged second needs a rebase of that file.
> Found by code review of the Ratis key write path while validating fixes from
> the TLA+ verification effort under HDDS-15926, on commit
> 031e76aa2c9a60b67185390b5b33499cbbb82e43. 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]