Files
seaweedfs/sw-block/design/bugs/005_backend_close_cross_session.md
T
pingqiuandClaude Opus 4.7 4127e5136c T3 closure: finalize sign-ready state + BUG-006/007 + catalogue retrofill
Closure report (v3-phase-15-t3-closure-report.md):
- §E rewritten as FINAL A–F m01 verification table with per-impl
  status + G4 pass criterion = smartwal (production default) full
  A–F green; walstore non-default fallback with Matrix D failure
  tracked via BUG-007
- §E sign table: QA re-sign 2026-04-22 with evidence basis
  (seaweed_block@313dd52 + BUG-005 fix 42b045a); prior RETRACTED
  row superseded
- §D INV-DURABLE-001: conditional "Path B pending" wording
  removed; scoped to smartwal; canonical row name stands
- §B non-claims: stale _TBD_ perf wording replaced with
  first-light scope statement; new non-claim "G4 pass =
  smartwal only; walstore deferred via BUG-007" added
- §G.3 finalized: FINAL resolution with smartwal A–F PASS;
  walstore deferred
- §H Phase 2 narrative updated to match final matrix outcome
  (Matrix E smartwal-only; walstore E skipped pending BUG-007)
- §H Phase 4: T3-DEF-6 test wording downgraded from
  "pins cleanup contract" to "smoke + goroutine-leak guard"
  per PM feedback (no test-only introspection of Target.ctrls/
  AER/KATO internals; follow-up deferred)
- §H Phase 5: BUG-007 filed and scoped; non-blocking basis
  spelled out

Contract Bridge Catalogue (v2-v3-contract-bridge-catalogue.md):
- §2.2.14 C1-NVME-SESSION-KATO reclassified PRESERVE-partial
  → VIOLATED with BUG-006 anchor + m01 Matrix D evidence
- §2.2.14 C5-NVME-SESSION-STATE-CLEANUP-ON-CLOSE added
  (T3-DEF-6 retrofit, pinned by QA L1 addendum)
- §2.3 drift-event audit table expanded with BUG-006, BUG-007,
  T3-DEF-5, T3-DEF-6

BUG-006 (006_nvme_kato_timer_not_enforced.md):
- Unified contract ID to catalogue name
  C1-NVME-SESSION-KATO-STORED-NOT-ENFORCED (was drifting as
  C3-NVME-KATO-ENFORCEMENT, PM Low catch)
- §7 reframed as "existing row reclassified VIOLATED"
  rather than "add new row"

BUG-007 (007_walstore_umount_remount_data_loss.md): filed as
pre-existing walstore-specific durability bug surfaced by
Matrix D re-verify; explicitly non-blocking for T3 since
smartwal is production default.

BUG-005 (005_backend_close_cross_session.md): committed for
HEAD-reproducibility (referenced by closure §H Phase 3).

Inventory (bugs/inventory/nvme-test-coverage-deferred.md):
T3-DEF-5/6/7 struck through with per-row resolution pointers;
zero open T3-scope inventory rows remaining.

Evidence artifacts committed in seaweed_block@313dd52
(scripts/iterate-m01-nvme.sh Matrix F robustness +
t3_qa_session_cleanup_addendum_test.go).

Awaiting architect + PM three-sign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 22:08:30 -07:00

7.2 KiB
Raw Blame History

BUG-005 — Backend.Close in session lifecycle breaks cached DurableProvider Backend across sessions

Status: FIX-LANDED — seaweed_block commit <TBD>; unit regression green; awaiting m01 re-verification Severity: BLOCKER (G4 partial-pass gate — without this fix, cross-session reconnect fails 100%) Component: core/frontend/nvme/target.go + core/frontend/iscsi/session.go (session-layer Backend close) Discovered-by: QA Owner during Path B m01 Matrix A cycle-2 reconnect test Discovered-when: 2026-04-22, during T3c three-sign prep Owner: sw Blocks: T3-end three-sign (G4 durable-data-path pass)


1. One-line symptom

After a full NVMe session (connect → mkfs → IO → disconnect) completes cleanly, the NEXT nvme connect on the same target returns I/O Error (sct 0x3 / sc 0x2) DNR on the very first Read. The kernel treats it as ANA "Asymmetric Access Inaccessible" and drops the namespace.

Not a data-integrity bug — the bytes on disk are intact. A lifecycle bookkeeping bug: the cached Backend handle gets marked closed by session 1's teardown, so session 2 receives the same (closed) handle from the Provider cache.


2. Environment

  • Host: m01 (Linux 6.17, nvme-cli)
  • Target: V3 blockvolume with --durable-root + --durable-impl smartwal
  • Phase: Path B m01 reconnect matrix during T3c three-sign prep

3. Symptom — verbatim

Kernel dmesg

nvme nvme1: creating 8 I/O queues.  (cycle 1)
... mkfs / mount / write / sync / umount / disconnect succeed ...
nvme nvme1: creating 8 I/O queues.  (cycle 2)
nvme1n1: I/O Cmd(0x2) @ LBA 0, 8 blocks, I/O Error (sct 0x3 / sc 0x2) DNR

V3 status mapping

core/frontend/nvme/errors.go:

SCTPathRelated                = 0x3
SCPathAsymAccessInaccessible  = 0x02

Result of mapping frontend.ErrBackendClosed through NewBackendClosed().


4. Root cause

Two design elements combined into a bug:

Element A — per-volumeID Backend cache in DurableProvider. Required because LogicalStorage is an exclusive resource: cannot have two open WAL writers on the same file. So Open returns the same StorageBackend for repeated Opens of the same volumeID.

Element B — session-layer calls to Backend.Close():

  • core/frontend/nvme/target.go handleConn: defer backend.Close()
  • core/frontend/iscsi/session.go close(): _ = s.backend.Close()

These were memback-era patterns that worked because memback's Provider.Open creates a NEW backend struct per call (fresh closed=false); only the underlying volumeStore is cached. Session-level Close marked just that session's handle closed.

When T3a DurableProvider introduced a per-volumeID cache, the two elements became incompatible: session 1's Close flipped the CACHED handle closed. Session 2's Open returned the same (closed) handle. Every I/O hit StorageBackend.gate()'s closed=true branch and returned ErrBackendClosed, which the NVMe layer faithfully reported as SCT=3 SC=2.

Why V2 didn't have this

V2's architecture structurally avoided the conflict:

  • Subsystem holds the BlockDevice; registered via AddVolume / unregistered via RemoveVolume (admin-level, not per-session)
  • Controller (per-session) holds a *Subsystem POINTER — never closes the underlying Dev
  • Controller.shutdown() closes conn, stops KATO, clears pendingCapsules, unregisters CNTLID from admin map — explicit list, no Dev.Close()

V3 introduced the frontend.Backend abstraction without carrying V2's implicit ownership contract ("device outlives sessions") into the new interface's godoc. The result: T2 target authors saw Backend.Close() and treated it as session-owned, matching memback's accidental compatibility. DurableProvider's arrival broke that.


5. Fix

Minimal 2-line delete + godoc formalization + regression test:

  1. Remove defer backend.Close() in core/frontend/nvme/target.go handleConn (replaced with a BUG-005 comment).
  2. Remove _ = s.backend.Close() in core/frontend/iscsi/session.go close() (replaced with a BUG-005 comment).
  3. Add Provider-ownership clause to frontend.Backend interface godoc in core/frontend/types.go:

    "Backend.Close() is OWNED BY THE PROVIDER. Session-layer consumers MUST NOT call Close on a Backend obtained from Provider.Open — treat the returned Backend as a BORROWED reference..."

  4. Regression test TestT3_Bug005_ProviderCachedBackend_ReusableAcrossSessions matrix-parameterized over walstore + smartwal.

No changes to StorageBackend itself; the invariant is enforced at the call-site level. Backend.Close() remains a legal operation for callers who OWN the handle (Provider.Close, explicit tests).


6. Why existing tests missed it

All T3a/T3b/T3c tests either:

  • Single-session (scenario_crash_recovery_test.go etc.) — no session-close → no cache poisoning
  • Direct-handler (integration_iscsi_test.go etc.) — call SCSIHandler.HandleCommand / IOHandler.Handle directly, bypassing target.handleConn's defer

Only "real target + session 1 + disconnect + session 2" exercises the cross-session cache-poisoning path. m01 is the first environment that drove this pattern end-to-end.

Same structural lesson as BUG-001: L1 coverage of individual layers (adapter, provider, handler) doesn't catch bugs that emerge only when the target's session-lifecycle × Provider's handle-cache interaction runs in a real multi-session workload.


7. Fix verification

  • Unit regression: TestT3_Bug005_ProviderCachedBackend_ReusableAcrossSessions passes for both impls.
  • Full tree: go test ./core/... -count=1 — all packages green.
  • m01 re-run post-fix: pending QA cycle with Path B (Matrix A / B / C / D already green pre-BUG-005; re-verify they stay green, then Matrix E attempted).

8. Exit criteria

  • Session-layer Backend.Close removed (nvme/target.go + iscsi/session.go)
  • Backend godoc documents Provider ownership
  • Regression test matrix pins the invariant
  • m01 reconnect cycle green (QA re-run)
  • Ledger row INV-DURABLE-SESSION-LIFECYCLE-001 ACTIVE

9. New ledger row

INV-DURABLE-SESSION-LIFECYCLE-001: Across NVMe / iSCSI session disconnect + reconnect, the Provider-cached Backend remains operational. I/O on the reconnected session MUST NOT receive ErrBackendClosed caused by prior session teardown.

  • V2 reference (correct pattern): weed/storage/blockvol/nvme/controller.go shutdown() + weed/storage/blockvol/nvme/server.go AddVolume / RemoveVolume
  • BUG-001 (precedent): same L1-passes-L2-fails pattern; storage-level drift vs session-level drift
  • T3a design note §3 (pre-existing contract): "Backend.Close marks closed but does NOT tear down storage; Provider retains storage handle"
  • Memory: feedback_porting_discipline.md (update: new-abstraction-layer ownership discipline)

11. Log

Date Actor Event
2026-04-22 QA Path B cycle-2 reconnect fails; dmesg sct 0x3/sc 0x2 captured
2026-04-22 sw Root cause identified: session-layer Backend.Close × Provider cache interaction
2026-04-22 sw Fix Option 1 landed (2-line delete + godoc + regression test)
2026-04-22 sw Regression test green both impls; awaiting m01 re-verify