Siyao Meng created HDDS-17435:
---------------------------------
Summary: Replaying a stream init log entry after a datanode
restart recreates a deleted container replica empty, which SCM can count as
healthy
Key: HDDS-17435
URL: https://issues.apache.org/jira/browse/HDDS-17435
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments:
CR-11-do-not-recreate-missing-container-on-stream-init-replay.patch,
TestBugCR11StreamInitReplayRecreatesDeletedContainer.java
h3. Mechanism
{{HddsDispatcher.dispatchRequest}} treats StreamInit and StreamInitWithPutBlock
as a write stage in every context, so they auto-create a missing container also
when their log entry is applied by {{ContainerStateMachine.applyTransaction}}.
For WriteChunk the write stage is only WRITE_DATA, and the applied stage
(COMMIT_DATA) returns CONTAINER_NOT_FOUND for a missing container, as HDDS-935
intended ("ensure container creation does not happen as part of
applyTransaction of writeChunk request in Ratis"). HDDS-935's missing container
set only covers containers recorded in the last Ratis snapshot, and a replica
deleted by SCM is also removed from the datanode's container table, so a
container created after the last snapshot and deleted since is recreated,
empty, when the stream entry is replayed. For StreamInit, a replayed
CloseContainer entry then closes it.
With PutBlock through Raft the replay then reaches the block's PutBlock entry,
which fails on the missing chunk file and marks the recreated replica
UNHEALTHY, so SCM discards it. With
{{ozone.client.datastream.putblock.without.raft.enabled}} (HDDS-15758,
apache/ozone#10823, master only) the entry is StreamInitWithPutBlock and there
is no PutBlock entry. Its replay recreates the replica and then fails
{{Preconditions.checkArgument}} in {{HddsDispatcher.dispatchRequest}} with an
IllegalArgumentException, because {{applyTransaction}} does not pass
{{container2BCSIDMap}} for that type, so the pipeline is closed and the replica
stays OPEN and empty. After SCM removes the closed pipeline, its close handling
for the mismatched replica quasi closes it and, since every BCSID is 0, force
closes it, so the empty replica ends CLOSED. SCM cannot tell it apart from the
real replicas: {{RatisContainerReplicaCount}} counts any CLOSED replica as
healthy, and {{RatisOverReplicationHandler}} picks excess CLOSED replicas by
{{ContainerReplica.hashCode}}.
h3. Trigger
# Ratis data stream is enabled ({{hdds.container.ratis.datastream.enabled}})
and keys are written with
{{ozone.client.datastream.putblock.without.raft.enabled}}, both off by default.
# A container is created and closed on a pipeline after that pipeline's last
Ratis snapshot.
# SCM deletes the closed replica on one pipeline member while the pipeline
stays open, for example the source of a balancer move, or an excess replica.
# That datanode stops without taking a snapshot (crash or kill) and restarts,
so it replays the stream entries for that container.
h3. Impact
The datanode ends up with an empty CLOSED replica next to the real ones. SCM
counts it as a healthy copy, so redundancy is silently lower than reported, and
when the container is over replicated (for example right after a balancer move)
SCM may delete a real replica instead of the empty one. A mini cluster run of
this sequence on the unfixed commit showed exactly that: after the restart the
replica was OPEN with 0 blocks, after the pipeline was removed it was quasi
closed and then CLOSED with 0 keys and BCSID 0, and ReplicationManager deleted
a real replica and kept the empty one (the key stayed readable from the other
two). That run is not attached; it emulated the balancer move with a replicate
command and a force delete, emulated the crash by restarting the datanode with
the stop snapshot disabled, shortened the pipeline destroy timeout, the scrub,
replication and report intervals to seconds, and chose the victim so that a
real replica sorts first by {{hashCode}}. In released versions (StreamInit with
PutBlock through Raft) the replay also recreates the container, but the replica
ends UNHEALTHY, so they are not affected in the same way.
h3. Reproduction
[^TestBugCR11StreamInitReplayRecreatesDeletedContainer.java]
({{hadoop-hdds/container-service}}) uses a real {{HddsDispatcher}} and
{{ContainerStateMachine}} with a mocked Ratis server. {{stream}} creates the
container, {{KeyValueHandler.deleteContainer}} removes it, a new state machine
with an empty {{container2BCSIDMap}} stands in for a restart from an older
snapshot, and the stream entry and a CloseContainer entry are replayed through
{{applyTransaction}}. On the unfixed commit both cases fail: for StreamInit the
container comes back CLOSED with 0 blocks and the state machine stays healthy;
for StreamInitWithPutBlock it comes back OPEN with 0 blocks and the apply fails
with the IllegalArgumentException described above. With the patch both pass: no
replica is recreated and the apply fails with "ContainerID 1 does not exist". A
failing test means the defect is present. It does not run an SCM delete, a real
restart or ReplicationManager.
h3. Patch
[^CR-11-do-not-recreate-missing-container-on-stream-init-replay.patch], against
7212465c0bb19615708c144238317db51c2a696d. It also applies to master at
ae71e2dd50c. If the patch of HDDS-17430 lands first, this one needs a trivial
rebase in {{TestHddsDispatcher}} (adjacent imports and test method).
The patch counts StreamInit and StreamInitWithPutBlock as a write stage only
when the dispatcher context is not APPLY_TRANSACTION. The stream setup still
creates the container as before, applying the entry for an existing container
is unchanged, and applying it for a missing container now returns
CONTAINER_NOT_FOUND like a replayed WriteChunk or CloseContainer: the state
machine becomes unhealthy and the pipeline is closed, with no replica
recreated. This also removes the IllegalArgumentException on a
StreamInitWithPutBlock replay for a missing container: the precondition is only
reached after an auto create, which no longer happens when the entry is applied.
A stream init entry is written to a peer's Raft log only after that peer's own
{{stream()}} has completed, and {{stream()}} creates the container: Ratis links
the entry to the local stream before writing it ({{ContainerStateMachine.link}}
fails when the stream is missing) and Ozone syncs state machine data before the
log write. So the entry can only be applied for a missing container after the
replica was removed, as for a replayed WriteChunk. The patch reverses the
effect of HDDS-6592, which let the StreamInit replay for a missing container
pass by recreating it. SCM is not changed: once every replica is CLOSED with
BCSID 0 it has no sound signal to prefer one.
Covered by
{{TestHddsDispatcher#testStreamInitCreatesContainerOnlyWhenStreamStarts}} for
both types, with and without {{container2BCSIDMap}} in the apply context.
Without the change it errors with the IllegalArgumentException; with it the
{{HddsDispatcher}}, {{ContainerStateMachine}}, {{KeyValueHandler}} and
{{ContainerSet}} unit suites pass, {{TestBlockDataStreamOutput}} passes, and
checkstyle is clean. The mini cluster sequence above, rerun with the patch,
leaves no replica on the restarted datanode and the three real replicas in
place.
Related: HDDS-17430 (a replayed PutBlock for a container created by a data
stream fails {{updateBCSID}}) is on the same replay path; its fix does not
interact with this one.
Found by code review of the datanode streaming PutBlock paths, as part of the
TLA+ verification effort under HDDS-15926, on commit
7212465c0bb19615708c144238317db51c2a696d. 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]