Siyao Meng created HDDS-16890:
---------------------------------
Summary: SCM closePipeline leaves a container allocated during the
close OPEN on the CLOSED pipeline until the pipeline is deleted
Key: HDDS-16890
URL: https://issues.apache.org/jira/browse/HDDS-16890
Project: Apache Ozone
Issue Type: Bug
Reporter: Siyao Meng
Attachments:
MC-2-close-containers-allocated-during-pipeline-close.patch,
TestBugMC2ClosePipelineOrphanContainer.java
h3. Mechanism
{{PipelineManagerImpl.closePipeline}} first calls
{{closeContainersForPipeline}}, which reads the pipeline's containers and
FINALIZEs each OPEN one (FINALIZE moves it to CLOSING and removes it from the
pipeline, then a CLOSE_CONTAINER event is fired). Only after that does it take
the pipeline manager write lock and move the pipeline to CLOSED. No lock is
held between the read and the CLOSED state. Allocation holds only the pipeline
manager read lock ({{WritableRatisContainerProvider.getContainer}}, used by
{{BlockManagerImpl.allocateBlock}}, and
{{ContainerManagerImpl.allocateContainer}}, used by the admin
{{allocateContainer}} RPC), and {{PipelineStateMap.addContainerToOpenPipeline}}
rejects only CLOSED pipelines. A container allocated in that window is
therefore added to the pipeline and never FINALIZEd. It stays OPEN on the
CLOSED pipeline, or on a deleted one when the caller deletes right after
closing ({{DeadNodeHandler}}, {{closeStalePipelines}}, the datastream port
close).
HDDS-5973 ordered the close as containers first, then pipeline, so that SCM
never has an OPEN container on a closed pipeline; its review states the goal as
"containers are in closing before pipeline remove/close log in ratis".
Concurrent allocation was not discussed there. HDDS-5655 added the read lock,
which keeps allocation away from the CLOSED transition itself but not from the
window before it.
h3. Trigger
Any pipeline close (pipeline action from a datanode, stale or dead node,
decommission or maintenance start, admin close, scrubber, full EC pipeline)
while an allocation that creates a new container runs on the same pipeline. For
Ratis this is ordinary block allocation. A Ratis pipeline in steady state is at
its open container limit, and each FINALIZE in the first pass removes a
container from the pipeline, so a block allocation that picks this still OPEN
pipeline during the close finds it below the limit and creates a new container
({{ContainerManagerImpl.getMatchingContainer}}). The close itself makes
allocation create the late container. The window lasts as long as the FINALIZE
calls of the first pass. For EC, block allocation adds a container only to a
pipeline it has just created and not yet opened
({{WritableECContainerProvider}}). That pipeline is already registered with the
node manager, so a stale node, dead node or decommission handler that closes it
in that moment leaves the container OPEN on it, and the following
{{openPipeline}} fails with "Closed pipeline can not be opened". That is rare;
otherwise only the admin {{allocateContainer}} RPC hits the EC route.
h3. Impact
* The late container gets no FINALIZE and no close command. Allocation offers
only OPEN pipelines, so no later block goes into it.
* {{OpenContainerHandler}} in {{ReplicationManager}} closes an OPEN container
when {{ReplicationManager.hasHealthyPipeline}} is false or a replica is
reported in another state. {{hasHealthyPipeline}} only checks that the pipeline
exists, so a late container whose replicas are OPEN (or that has none yet) is
closed only after the scrubber deletes the CLOSED pipeline. With the default
values ({{ozone.scm.pipeline.destroy.timeout}} 66s,
{{ozone.scm.pipeline.scrub.interval}} 5m,
{{hdds.scm.replication.thread.interval}} 300s) that is up to about 11 minutes
after the close. For callers that delete right away it is the next
ReplicationManager run. If ReplicationManager or the scrubber is stopped, the
container stays OPEN.
* When the close command finally arrives the Ratis group is gone, so the
datanode quasi closes a Ratis container ({{CloseContainerCommandHandler}}, no
write channel and no force flag) instead of closing it through Ratis, and
ReplicationManager has to resolve the quasi closed replicas later.
* What the writer sees: {{BlockManagerImpl.newBlock}} reads the pipeline again
after the container was chosen, so {{allocateBlock}} can return the CLOSED
pipeline with a block in a container SCM still has as OPEN. After a close
without delete the Ratis group stays up until the scrubber deletes the
pipeline, so writes of that block into the OPEN container go to a live group
and succeed. After a close with delete the group is removed, the write fails
and the client excludes the pipeline and asks for a new block, as it does for
the other containers of that pipeline. No path that acknowledges a write on a
removed group was found.
The ReplicationManager, scrubber, datanode and client steps above are argued
from code. The reproduction stops after {{closePipeline}} returns.
h3. Reproduction
[^TestBugMC2ClosePipelineOrphanContainer.java] ({{hadoop-hdds/server-scm}},
target path and Maven command in the class comment) runs a real
{{PipelineManagerImpl}} and {{ContainerManagerImpl}} on RocksDB with
{{MockNodeManager}} and two real threads. A latch in the container manager that
the closer uses holds {{closePipeline}} just before it FINALIZEs the last
container it read, which only fixes the timing. The test thread then allocates
through {{WritableRatisContainerProvider.getContainer}} (Ratis) or
{{ContainerManager.allocateContainer}} (EC 3+2, as the admin RPC does). Output
on unmodified source, the same for both routes:
{noformat}
close race: closePipeline read [#1], pipeline state while it runs = OPEN
close race: after closePipeline: #1 (read by closePipeline) state = CLOSING
close race: allocation during close handed out #2 on Pipeline-...
close race: after closePipeline: pipeline state = CLOSED, containers still in
pipeline = [#2], #2 state = OPEN
close race: OPEN pipelines offered to new allocations = []
{noformat}
No CLOSE_CONTAINER event was fired for #2. A passing test means the defect is
present.
h3. Suggested fix
The patch removes the cause. {{ReplicationManager.hasHealthyPipeline}} could in
addition treat a CLOSED pipeline (not a DORMANT one, whose containers are meant
to stay OPEN) as unhealthy, so that any OPEN container left on a CLOSED
pipeline is closed on the next ReplicationManager run while its Ratis group
still exists. With the patch the only remaining way I found to get there is an
SCM crash or leader change between the CLOSED transition and the second
container pass (argued), so this is not part of the patch.
h3. Patch
[^MC-2-close-containers-allocated-during-pipeline-close.patch], against
b0aa6475b7856d2c7f467dd3db12b9a1dd116f63. It also applies to master at
6818707698c.
{{closePipeline}} calls {{closeContainersForPipeline}} a second time after the
pipeline is CLOSED. FINALIZE removes a container from its pipeline, so the
second call only finds containers added while the first one ran, and normally
reads an empty set. {{addContainerToOpenPipeline}} checks for CLOSED under the
same state manager lock as the CLOSED transition, so once the pipeline is
CLOSED no container can be added and the second call finds every late one. The
allocation path is unchanged and takes no new lock, and the HDDS-5973 order is
kept for every container that existed when the close started. Holding the write
lock from the read to the CLOSED transition would also work but blocks all
allocation while the FINALIZE calls go through Ratis; moving the pipeline to a
new non OPEN state before the read would change the pipeline state machine. The
second call relies on CLOSED being final. At this commit {{activatePipeline}}
can move a CLOSED pipeline back to OPEN, which is reported and fixed separately
in HDDS-16889.
Covered by {{testClosePipelineClosesContainerAllocatedDuringClose}} in the
existing {{TestPipelineManagerImpl}}, which allocates a container from inside
the first FINALIZE and checks that both containers are CLOSING, none is left in
the pipeline and each got exactly one CLOSE_CONTAINER event. Without the change
it fails with "expected: CLOSING but was: OPEN". With it the SCM pipeline,
block, container manager, close container, dead node and client protocol suites
pass (230 tests), as do {{TestPipelineClose}} on a mini cluster (5 tests) and
the Recon pipeline manager tests (5 tests), and checkstyle is clean. With the
patch both tests of the reproduction fail, because the late container is
CLOSING.
Found by TLA+ model checking and code review of the SCM pipeline lifecycle
paths under HDDS-15926, on commit b0aa6475b7856d2c7f467dd3db12b9a1dd116f63.
Checked against HDDS issues and apache/ozone pull requests for duplicates
before filing. Related but not duplicates: HDDS-5973 (resolved, close order for
SCM restarts), HDDS-5655 (resolved, read lock around allocation), HDDS-7829
(resolved, rejects adding a container to a CLOSED pipeline), HDDS-10231
(resolved, ReplicationManager closes OPEN containers whose pipeline is missing)
and HDDS-11521 (Not A Problem, client wait on pipeline creation). 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]