Detect and repair stale NVMe-oF subsystems and controllers - #429
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Introduces a generalized NVMe-oF diagnosis + repair layer in atlas-lib/nvmeof (sysfs-based defect detection plus controller/subsystem teardown), and wires the CSI node plugin to use it to break “already connected but unusable” retry loops (both at NodeStage attach time and in the connection monitor).
Changes:
- Add
nvmeof.Inspect(typed defect detection from sysfs) andnvmeof.Repair/nvmeof.Repairer(teardown mechanism + policy/cooldown orchestration). - Add CSI-side policy/adapter (
nvmerepair.go) and integrate attach/monitor hooks ininitiator.go. - Add snapshot-based sysfs tests + nvmet lab tooling and sysfs fixtures to make defect states replayable in CI.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| csi-driver/pkg/util/nvmerepair.go | CSI-side repair policy wrapper around nvmeof.Inspect/nvmeof.Repair, with cooldown and safety gates. |
| csi-driver/pkg/util/nvmerepair_test.go | Unit tests for CSI-side policy selection, cooldown keying, and teardown ordering. |
| csi-driver/pkg/util/initiator.go | Split connect into connectOnce + repair retry; add monitor hook after ANA reconciliation; minor ANA constant cleanup. |
| csi-driver/e2e/nvmet/nvmet-lab.sh | nvmet-based lab script to force/verify defect states against a real kernel NVMe-oF target. |
| csi-driver/e2e/nvmet/capture-sysfs.sh | Script to dump/sanitize/reconstruct NVMe sysfs state into replayable snapshots. |
| atlas-lib/nvmeof/testdata/sysfs/no-namespace.tsv | Replayable sysfs snapshot fixture for “live controllers, zero namespaces” defect. |
| atlas-lib/nvmeof/testdata/sysfs/namespace-missing.tsv | Replayable sysfs snapshot fixture for “namespace missing but others present” defect. |
| atlas-lib/nvmeof/testdata/sysfs/controller-not-contributing.tsv | Replayable sysfs snapshot fixture for “live controller contributes no ANA leg” defect. |
| atlas-lib/nvmeof/inspect.go | Sysfs-based defect detection (NoNamespace, NamespaceMissing, ControllerNotContributing, AmbiguousHead, StaleEndpoint). |
| atlas-lib/nvmeof/inspect_test.go | Unit tests for defect detection, including blast-radius/co-tenant calculations. |
| atlas-lib/nvmeof/repair.go | Repair mechanism + Repairer.Attach policy loop with cooldown, scope caps, and disruption protection. |
| atlas-lib/nvmeof/repair_test.go | Unit tests for repair ordering, cooldown behavior, refusal gates, and attach/repair loop semantics. |
| atlas-lib/nvmeof/realsysfs_test.go | Snapshot replay tests to guard against false positives and to assert forced-defect verdicts. |
| atlas-lib/nvmeof/fabrics.go | Add DisconnectController to support controller-scope teardowns. |
| atlas-lib/nvmeof/connector.go | Extend Connector interface with DisconnectController. |
| atlas-lib/nvmeof/detach_test.go | Update connector test double to satisfy the extended interface. |
| atlas-lib/nvmeof/doc.go | Package documentation update describing the new inspect/repair layering and rationale. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
noctarius
force-pushed
the
fix/stale-controller-detection
branch
from
August 17, 2026 08:56
633dfab to
99143cb
Compare
Generalises three separate stale-controller fixes — f99b5b5, 498c2c5 and the device-discovery rework in #399 — into one diagnosis/repair layer, and rewires the CSI node plugin onto it. All three were chasing the same shape of bug: a connect succeeds at one layer of the NVMe object tree while the layer below it is unusable, and the check that gates the retry sits at the higher layer, so nothing looks missing and the retry spins. A subsystem attached with live controllers that exports no namespace at all, so no block device ever appears and kubelet retries NodeStageVolume forever. A controller that is live and contributes no path to the namespace, so the volume runs a path short while every connect answers "already connected" — in one 42-hour run that left volumes routinely below their configured redundancy with no operator-visible signal. Inspect diagnoses, read-only, and names each defect positively from state the kernel already publishes rather than inferring it from a wait that ran out or from the text of an nvme-cli error. A timeout cannot tell "slow" from "broken"; "live controller, zero namespaces" is decidable on the first look. Each Defect carries the teardown scope, the controllers to release in teardown order, and the co-tenant namespaces a repair would leave with no usable path. Repair acts on one defect; Repairer.Attach adds the policy — narrowest scope first, and never at the cost of another volume's block device, the caller's own device, or a repair that just ran. The cooldown key is what makes the last of those work: it must be narrow enough that two outstanding repairs do not collide and stable enough to survive a repair that did not stick, and neither the controller id nor the kernel subsystem id qualifies in general because a repair re-creates both. The CSI driver diagnoses through Inspect and tears down through Repair over the nvme-cli connector, so detection, teardown ordering and the disconnect itself are shared; what stays local is policy alone. Reading sysfs instead of shelling out also removes a process per monitor tick and the reverse-engineering of udev's link naming. testdata/sysfs replays kernel state captured from a live cluster: a healthy node with four subsystems and eight namespaces, and the three defects that can be forced with nvmet. The healthy one is the false-positive guard, and it is the assertion that matters most — a repair tears down live data paths, so a diagnosis that fires on a healthy fabric is worse than no diagnosis, and hand-built fixtures cannot rule that out because they encode the belief under test. Two things are deliberately not covered. The auto-repair path has no automated end-to-end test: forcing a non-contributing controller at a *published* endpoint needs a storage node to stop exporting a namespace on one of its published listeners, which the real control plane will not do on request. And defects are logged but not otherwise surfaced; counters would fix that and would also be the natural assertion surface for an e2e suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
noctarius
force-pushed
the
fix/stale-controller-detection
branch
from
August 17, 2026 13:20
99143cb to
9f7ab6d
Compare
geoffrey1330
approved these changes
Aug 18, 2026
boddumanohar
approved these changes
Aug 18, 2026
noctarius
added a commit
that referenced
this pull request
Aug 19, 2026
* Detect and repair stale NVMe-oF subsystems and controllers Generalises three separate stale-controller fixes — f99b5b5, 498c2c5 and the device-discovery rework in #399 — into one diagnosis/repair layer, and rewires the CSI node plugin onto it. All three were chasing the same shape of bug: a connect succeeds at one layer of the NVMe object tree while the layer below it is unusable, and the check that gates the retry sits at the higher layer, so nothing looks missing and the retry spins. A subsystem attached with live controllers that exports no namespace at all, so no block device ever appears and kubelet retries NodeStageVolume forever. A controller that is live and contributes no path to the namespace, so the volume runs a path short while every connect answers "already connected" — in one 42-hour run that left volumes routinely below their configured redundancy with no operator-visible signal. Inspect diagnoses, read-only, and names each defect positively from state the kernel already publishes rather than inferring it from a wait that ran out or from the text of an nvme-cli error. A timeout cannot tell "slow" from "broken"; "live controller, zero namespaces" is decidable on the first look. Each Defect carries the teardown scope, the controllers to release in teardown order, and the co-tenant namespaces a repair would leave with no usable path. Repair acts on one defect; Repairer.Attach adds the policy — narrowest scope first, and never at the cost of another volume's block device, the caller's own device, or a repair that just ran. The cooldown key is what makes the last of those work: it must be narrow enough that two outstanding repairs do not collide and stable enough to survive a repair that did not stick, and neither the controller id nor the kernel subsystem id qualifies in general because a repair re-creates both. The CSI driver diagnoses through Inspect and tears down through Repair over the nvme-cli connector, so detection, teardown ordering and the disconnect itself are shared; what stays local is policy alone. Reading sysfs instead of shelling out also removes a process per monitor tick and the reverse-engineering of udev's link naming. testdata/sysfs replays kernel state captured from a live cluster: a healthy node with four subsystems and eight namespaces, and the three defects that can be forced with nvmet. The healthy one is the false-positive guard, and it is the assertion that matters most — a repair tears down live data paths, so a diagnosis that fires on a healthy fabric is worse than no diagnosis, and hand-built fixtures cannot rule that out because they encode the belief under test. Two things are deliberately not covered. The auto-repair path has no automated end-to-end test: forcing a non-contributing controller at a *published* endpoint needs a storage node to stop exporting a namespace on one of its published listeners, which the real control plane will not do on request. And defects are logged but not otherwise surfaced; counters would fix that and would also be the natural assertion surface for an e2e suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Integrated dhchap into the atlas-based connector --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit 6df11b0)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Detect and repair stale NVMe-oF subsystems and controllers
Generalises three separate stale-controller fixes —
f99b5b57,498c2c5f, and thedevice-discovery rework in #399 — into one diagnosis/repair layer in
atlas-lib/nvmeof,and rewires the CSI node plugin onto it.
Stacked on #430, now merged: the shared connector machinery and
CLIConnectorcame fromthere, and this PR only consumes them.
The problem
All three fixes were chasing the same shape of bug: a connect succeeds at one layer of
the NVMe object tree while the layer below it is unusable, and the check that gates the
retry sits at the higher layer — so nothing looks missing and the retry spins forever.
nvme connectis satisfied,nvme list-subsysshows the NQN, and no block device everappears — so every
NodeStageVolumeskips the connect and times out on devicediscovery, which kubelet retries forever. That is the multi-hour FIO pod hang.
device-scoped view omits it, so the volume runs a path short, but
nvme connectrefuseswith "already connected" because nothing is missing at the controller level. In
K8sNativeResilientFailoverTestiteration 28 that produced 106 retries over 11 minutesfor one volume, not one of which reached the target; the volume ran at 2 of 3 paths and
lost all I/O when the outage took the other two.
block device.
What's in it
Inspect(inspect.go) — diagnosis, read-only, no NVMe commands issued. Returns typedDefects:NoNamespace,NamespaceMissing,ControllerNotContributing,AmbiguousHead,StaleEndpoint. Each carries the teardownScope, the controllers to release already inteardown order, and
CoTenants— the namespaces that would be left with no usable path.Detection is positive, from state the kernel publishes, not inferred from a wait that
ran out or from the text of an nvme-cli error.
498c2c5fused a 30s timeout as its stalesignal, which cannot tell "slow" from "broken"; "live controller, zero namespaces" is
decidable on the first look.
Repair+Repairer(repair.go) —Repairis the bare mechanism, taking aone-method
ControllerDetacherso a caller whose connect path lives elsewhere need notsupply a whole
Connector.Repairer.Attachadds policy: narrowest scope first, and neverat the cost of another volume's block device, the caller's own device, or a repair that
just ran.
CSI driver —
nvmerepair.godiagnoses throughInspectand tears down throughRepairover #430'sCLIConnector, so detection, teardown ordering and the disconnectitself are all shared; what stays local is policy alone. Two seams in
initiator.go:Connectsplits intoconnectOnceplus a repair retry, andrecoverPathsWithANAcallsthe monitor hook after its reconciles.
Reading sysfs instead of shelling out also removes a process per monitor tick (the previous
approach forked
nvme list-subsystwice per volume plus once per co-tenant, at a 3scadence) and removes the reverse-engineering of udev's
_<nsid>link naming, which was themost fragile part.
Verified against a live cluster
Identity and the two premises the detectors rest on were checked on a running cluster
(kernel 5.14.0-503.14.1.el9_5, nvme-cli 2.16), not assumed:
modelis the master lvol UUID space-padded to 40 bytes;serialisha; ANA legs liveat
/sys/class/nvme/<ctrl>/nvme<subsys>c<ctrl>n<nsid>/ana_state; cntlid is 1..N on theprimary storage node and 1000+ on the secondary.
nvme list-subsyscarries noANAStateat all — only the device-scopedform does. The code uses each accordingly.
All four defects that can be forced were then reproduced with kernel NVMe-oF targets
(
nvmet, tooling inatlas-lib/hack/nvmetfrom #430), includingControllerNotContributingvia two targets sharing an NQN with disjoint cntlid ranges —the host merges them and one controller ends up with an empty leg set. Each state was
captured and is committed as a replayable snapshot.
Tests
atlas-lib/nvmeof(41 specific to this PR), 92% statement coverage;17 in
csi-driver/pkg/utilfor the policy layer.testdata/sysfs/*.tsv— four sysfs snapshots replayed through the real resolver: ahealthy production node (4 subsystems, 8 namespaces, one a 5-namespace shared subsystem)
plus the three forced defects. Sanitized with consistent substitution so the
relationships under test survive.
matters most: a repair tears down live data paths, so a diagnosis that fires on a healthy
fabric is worse than no diagnosis. Hand-built fixtures cannot rule that out — they encode
what we believe sysfs looks like, which is the belief under test.
Two decisions worth a second opinion
The
NoNamespaceco-tenant gate departs fromdetach.go's precedent.DetachDevicegates on
IsMultiNamespace(can the subsystem be shared) rather than on currentco-tenants, because a namespace can join between check and disconnect. This gates on the
actual blast radius instead: for
NoNamespacethe subsystem exports zero namespaces, sothere is no co-tenant device to destroy, and gating on "shareable" would leave that volume
permanently stuck — the exact bug being fixed. The race
detach.gowarns about is benignhere, since a co-tenant joining a subsystem that exports nothing wants it torn down too.
This is the one place the two files disagree.
The cooldown key is constrained from both sides. It must be narrow enough that two
outstanding repairs do not collide — applying one would otherwise mask the other — and
stable enough to survive a repair that did not stick, since a key the teardown itself
changes sees a brand-new repair every time. Neither the controller id nor the kernel
subsystem id qualifies in general, because a repair re-creates both:
NoNamespace/NamespaceMissingAmbiguousHeadThe
AmbiguousHeadrow was a bug Copilot caught on the earlier combined PR: without it,repairing the first stale head marked the rest as handled and left them attached.
Known limitations
ControllerNotContributingat a published endpoint needs a storage node to stopexporting a namespace on one of its published listeners, which the real control plane
will not do on request. Detection of that state is pinned by the committed snapshot; the
repair mechanism is shared with the defects that are covered. Closing the gap needs a
simulated control plane — see follow-ups.
f99b5b57's incident report names "nooperator-visible signal" as part of the damage; counters would fix that and would also be
the natural e2e assertion surface. Not in this PR.
AmbiguousHeadis reported but cannot be forced deterministically — the kernel rejects aduplicate-cntlid controller rather than creating a second subsystem. Unit tests only,
which is also where Copilot found the bug above; that correlation is not a coincidence.
Follow-ups (separate branches)
feat/csi-e2e-stale-controller— Ginkgo specs for the two properties reachable againstreal simplyblock: a healthy fabric is never disturbed, and an unpublished controller is
never torn down while a lost path still recovers.
auto-repair path, the cooldown and the co-tenant refusal testable end to end.
ConnectPaths. Held for fix: forward hostNQN/DHCHAP secrets on connect and fix allowed-node scheduling #417, which forwardsDHCHAP secrets on connect —
nvmeof.Targethas no DHCHAP fields, so that is aprerequisite rather than just a conflict.