Siyao Meng created HDDS-17227:
---------------------------------
Summary: A failed rewrite of datanode.id leaves it empty, so the
datanode loses its operational state and initial version on the next start
Key: HDDS-17227
URL: https://issues.apache.org/jira/browse/HDDS-17227
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments: CR-6-replace-datanode-id-atomically.patch,
TestBugCR6DatanodeIdLostOnFailedWrite.java
h3. Mechanism
{{ContainerUtils.writeDatanodeDetailsTo}} deletes an existing {{datanode.id}}
and creates it again empty before {{DatanodeIdYaml.createDatanodeIdFile}}
writes the new content through {{YamlUtils.dump}}. That helper writes to a
temporary file with Ratis {{AtomicFileOutputStream}} and moves it over the
target when the stream is closed. Two things defeat the atomic write:
* the delete: any write that does not complete leaves an empty file, or no file
if the process dies between the delete and the create;
* {{YamlUtils.dump}} closes the stream in a try with resources also when
{{yaml.dump}} throws, so after a failed write, for example on a full disk, the
empty or partial temporary file is still moved over the target. The stream's
{{abort()}} is never called.
On the next start {{ContainerUtils.readDatanodeDetailsFrom}} finds the empty
file and recovers only the UUID from the volume VERSION file (HDDS-13955). The
operational state becomes IN_SERVICE, its expiry 0, the certificate serial ID
null and the initial version {{DEFAULT_VERSION}}. Hostname and software version
are set again on every start, so their loss has no effect.
h3. Trigger
No crash is needed, a write that fails while the datanode keeps running is
enough:
* an I/O error while the content is written, for example a full metadata disk
({{File too large}} under a file size limit was run as well);
* an I/O error when the temporary file is created;
* an interrupted command thread: {{DatanodeStateMachine.close}} interrupts the
thread that runs {{SetNodeOperationalStateCommandHandler}}, and the write fails
with {{ClosedByInterruptException}}.
The file is rewritten through this method for every
{{SetNodeOperationalStateCommand}}, on every datanode start
({{InitDatanodeState}}) and on certificate rotation in secure mode
({{HddsDatanodeService.saveNewCertId}}). The missing file case needs a crash
between two consecutive system calls and was not run.
h3. Impact
>From 2.2.0 the datanode starts after an empty {{datanode.id}}, with these
>losses:
* The initial version does not heal: it is only set when {{datanode.id}} is
created, and {{XceiverServerRatis.assignPorts}} uses the separate Ratis server
and admin ports only for an initial version that supports them. After one such
start the datanode serves Raft on its client port (9858 by default) instead of
the server port (9856) for good. By code reading, existing pipelines whose Raft
groups hold the old server address then cannot reach this datanode until SCM
closes them; not run on a cluster.
* The persisted operational state is IN_SERVICE with no maintenance end time. A
stable leader SCM still holds the state and sends it again on the next
heartbeat. By code reading the state is lost for good when no SCM holds it at
that point, for example after all SCMs restarted, or when leadership moves to a
follower that adopted the reported IN_SERVICE before the correction reached the
datanode; not run.
* The certificate serial ID in the file is lost.
In 2.0.x and 2.1.x, which have the atomic write (HDDS-12531, first released in
2.0.0) but not the recovery (HDDS-13955), an empty {{datanode.id}} stops the
datanode from starting with the {{NullPointerException}} quoted in HDDS-13955
until an operator repairs the file. Releases before 2.0.0 wrote the file in
place and emptied it on a failed write as well. No container data is lost.
h3. Reproduction
PASS on unmodified source, deterministic.
[^TestBugCR6DatanodeIdLostOnFailedWrite.java]
({{hadoop-hdds/container-service}}) writes {{datanode.id}} with
ENTERING_MAINTENANCE, an end time, a certificate serial ID and the current
software version as initial version, next to a VERSION file. It then makes one
write fail in three ways: the real {{SetNodeOperationalStateCommandHandler}}
with an interrupted thread, the same handler with an I/O error when the
temporary file is created, and {{YamlUtils.dump}} called directly with a
{{Yaml}} whose writer fails every write, as a full disk does. After each
failure the file has length 0, and reading it with
{{ContainerUtils.readDatanodeDetailsFrom}}, as a restart does, gives the right
UUID with {{persistedOpState=IN_SERVICE}}, expiry 0, null certificate serial ID
and {{initialVersion=DEFAULT_VERSION}}. A passing test means the defect is
present.
h3. Patch
[^CR-6-replace-datanode-id-atomically.patch], against
b0aa6475b7856d2c7f467dd3db12b9a1dd116f63. It also applies to master at
20d95ed37fe.
{{writeDatanodeDetailsTo}} no longer deletes and recreates an existing file; it
only creates the parent directory when the file does not exist yet.
{{YamlUtils.dump}} now flushes and closes the stream only after {{yaml.dump}}
succeeded, which moves the temporary file over the target, and calls
{{abort()}} on any failure, which deletes the temporary file and leaves the
target alone. A failed write now keeps the previous content and leaves no
temporary file. A successful write produces the same file as before. The change
is in the shared {{YamlUtils.dump}}, so its other callers get the same
behaviour.
Covered by the new {{TestYamlUtils}} ({{hadoop-hdds/framework}}): a write error
inside {{yaml.dump}} must keep the previous content and leave no temporary
file, and a later write must succeed; a second case covers an {{Error}} during
serialization. Also by {{testFailedDatanodeIdWriteKeepsPreviousFile}} in the
existing {{TestContainerUtils}}, for an error when the temporary file is
created. Both fail without the change because the file is empty. With the
change the tests of {{YamlUtils}}, {{ContainerUtils}}, {{DatanodeIdYaml}} and
the other {{YamlUtils.dump}} callers, {{TestDecommissionAndMaintenance}} on a
mini cluster (9 tests) and checkstyle pass. With the patch all three
reproduction tests fail because the file keeps its 346 bytes and the old state.
Under a file size limit of 0, a real {{File too large}} write error left the
file at 0 bytes before the change and kept its 35 bytes after it.
Not changed: the parent directory is not synced after the rename, so after a
power loss some file systems may show the old file, never an empty one. A write
error inside {{yaml.dump}} surfaces as an unchecked {{YAMLException}} that gets
past the callers' {{catch (IOException)}}, so their handling of a failed write,
such as the shutdown in {{InitDatanodeState}}, is skipped.
Found by code review of the SCM datanode decommission and maintenance admin
paths, as part of the TLA+ verification effort under HDDS-15926, on commit
b0aa6475b7856d2c7f467dd3db12b9a1dd116f63. Checked against HDDS issues and
apache/ozone pull requests for duplicates before filing. Related but not
duplicates: HDDS-13955 (recovers the UUID from an empty file, the symptom),
HDDS-5309 (empty {{datanode.id}} after a restart, closed without a cause),
HDDS-12843 (empty {{datanode.id}} in {{TestDecommissionAndMaintenance}}, fixed
the in memory update order) and HDDS-12608 (VERSION file creation race). The
attached patch is a proposal for review. Generated with Specula (Claude Opus
5.5 and Claude Opus 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]