doc: external failure taxonomy — 20 real bugs from Ceph/DRBD/Mayastor/Longhorn

Catalogs production failures organized by semantic class:
- Membership/liveness misjudgment (4 cases)
- Recovery decision error (3 cases)
- Completion/durability illusion (4 cases)
- Ordering/race conditions (4 cases)
- Background work corrupts semantics (3 cases)

Each entry maps to V2 exposure and V3 prevention rules.
Includes "Would V2 Have This Bug?" self-audit checklist.

Sources: Ceph tracker, DRBD changelogs, Longhorn/Mayastor GitHub issues.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pingqiu
2026-04-11 01:21:08 -07:00
co-authored by Claude Opus 4.6
parent 8ecc506452
commit 25ede892b4
@@ -0,0 +1,420 @@
# External Failure Taxonomy
Date: 2026-04-11
Status: reference
Purpose: Catalog real production bugs from Ceph/DRBD/Mayastor/Longhorn,
extract semantic lessons, and map to V2 exposure and V3 prevention rules.
## How to read this document
Each entry answers:
1. What happened (the failure)
2. Why it happened (the root semantic mistake)
3. Did V2 hit this? (or could it)
4. What V3 rule prevents it
The value is not the bugfix — it is the semantic lesson.
---
## Category 1: Membership / Liveness Misjudgment
### 1.1 Ceph: Asymmetric heartbeat flapping
**Source**: [Ceph #14181](https://tracker.ceph.com/issues/14181)
**What**: OSDs flapped up/down continuously. Monitors saw them as dead
(cluster network heartbeat failed), but OSDs saw themselves as alive
(public network to monitor worked). Flap loop: mark down → mark up → repeat.
**Root semantic mistake**: Heartbeat and control-plane used different
network paths. A partial network failure created contradictory liveness
views. Timer-based death declaration with no corroboration.
**V2 exposed?**: Yes. Our fast-rejoin test showed: master didn't detect
3-second replica disconnect because heartbeat cycle was 5 seconds.
Recovery depended on timing, not facts.
**V3 rule**: P1 (timers trigger, facts decide). Liveness observation
on one path must not contradict identity truth on another. Primary
probes independently of master heartbeat.
---
### 1.2 Ceph: Stale epoch checked before sync
**Source**: [Ceph #22673](https://tracker.ceph.com/issues/22673)
**What**: After OSD destroy + recreate, OSD refused to start — checked
DESTROYED flag against locally-cached stale OSDMap epoch instead of
syncing to current epoch first.
**Root semantic mistake**: Membership decision made against stale local
state. The check ran before the sync.
**V2 exposed?**: Similar pattern. Our stale-cleanup race: the master's
heartbeat handler checked the block registry (stale) before the VS had
time to report its volumes. Entry deleted prematurely.
**V3 rule**: P5 (monotonic facts win). Never validate identity against
stale local epoch. Sync to authoritative source first.
---
### 1.3 DRBD: Split brain from asymmetric UUID rules
**Source**: [DRBD 9.0.20-1 release notes](https://github.com/LINBIT/drbd/releases/tag/drbd-9.0.20-1)
**What**: Split brain detected despite quorum, after resize operation.
UUID propagation differed between diskless and disk-backed nodes.
**Root semantic mistake**: Generation identifier (UUID) rules were
asymmetric across node types. Same logical operation produced different
identity artifacts depending on node role.
**V2 exposed?**: Yes. Our replicaID format mismatch: shipper used
`serverID`, engine used `path/serverID`, orchestrator used yet another
format. Same identity, three representations.
**V3 rule**: Single canonical identity format. `MakeReplicaID(path, serverID)`
is the only constructor. No format conversion in hot paths.
---
### 1.4 Longhorn: Instance manager restart blindspot
**Source**: [Longhorn #5809](https://github.com/longhorn/longhorn/issues/5809)
**What**: After instance manager pod restart, Longhorn was unaware
replica processes were dead for 60 seconds. Replicas appeared alive
but were actually killed.
**Root semantic mistake**: The liveness monitor died with the component
it was monitoring. No watchdog survived the restart.
**V2 exposed?**: Yes. When the primary's shipper held a dead TCP
connection after replica restart, it didn't detect the failure until
the next barrier attempt (~30s later).
**V3 rule**: Liveness detection must be independent of the transport
being monitored. The watchdog recheck fires on a timer regardless of
whether Ship/Barrier calls are happening.
---
## Category 2: Recovery Decision Error
### 2.1 DRBD: Full resync when partial would suffice
**Source**: [DRBD 9.1.19 ChangeLog](https://github.com/LINBIT/drbd/blob/drbd-9.1/ChangeLog)
**What**: After brief disconnect + reconnect, DRBD performed full
resync of all data instead of bitmap-tracked partial resync. Multiple
root causes across versions: bitmap loss, UUID mismatch during resync,
UUID history slot corruption.
**Root semantic mistake**: The UUID comparison that decides partial vs
full resync was fragile against transient disconnects. A UUID change
during active resync was misinterpreted as data divergence.
**V2 exposed?**: Yes. Our "no WAL retention" caused every disconnect
(even 3 seconds) to force full 1GB rebuild instead of catchup. The
decision boundary (R < S → rebuild) was too coarse.
**V3 rule**: P4 (deterministic recovery choice). Recovery decision
from bounded facts only: `R >= S → catchup, R < S → rebuild`.
SmartWAL + LBA dirty map eliminates the S boundary entirely — recovery
is always "send dirty blocks from extent."
---
### 2.2 Longhorn: Rebuild sent to wrong replica (address reuse)
**Source**: [Longhorn #5709](https://github.com/longhorn/longhorn/issues/5709)
**What**: After instance-manager restart, rebuild command was sent to
wrong replica. A new volume's replica received the same IP:port as the
old failed replica. Rebuild targeted wrong volume.
**Root semantic mistake**: Recovery command routed by network address,
not by stable replica identity. Address reuse after restart caused
identity collision.
**V2 exposed?**: Yes. Our nil-interface panic in `senderByID` was
caused by a sender lookup returning nil `*Sender` wrapped in a non-nil
interface. The registry keyed by `replicaID` strings, and format
mismatch caused lookup failures.
**V3 rule**: P2 (single authority per truth domain). Replica identity
must use stable, unique IDs (epoch + path + serverID). Never derive
identity from reusable network addresses.
---
### 2.3 Ceph: Backfill cancellation crashes target OSD
**Source**: [Ceph #21613](https://tracker.ceph.com/issues/21613)
**What**: Primary sent REJECT message to backfill target to cancel
backfill. Target crashed — REJECT is a message the target sends to the
primary, not the other way around.
**Root semantic mistake**: Protocol message repurposed in wrong
direction. The state machine on the receiving end never expected this
message from that sender.
**V2 exposed?**: Similar pattern. Our `InvalidateSessionCommand`
dispatch path invalidated the sender's session (semantic cleanup) which
killed the transport path needed by the rebuild coordinator. The
command was semantically valid but operationally destructive in context.
**V3 rule**: Protocol messages have fixed sender/receiver roles.
Recovery commands flow from primary to replica. Acknowledgments flow
from replica to primary. Never reverse the direction.
---
## Category 3: Completion / Durability Illusion
### 3.1 Longhorn: Corrupted replica kept, healthy one discarded
**Source**: [Longhorn #7425](https://github.com/longhorn/longhorn/issues/7425)
**What**: After rebuild timeout + node failure, corrupted replica was
kept and healthy replica was discarded. Autosalvage selected replicas
by `healthyAt` timestamp — corrupted replica had a valid timestamp from
before the rebuild started.
**Root semantic mistake**: Health metadata was not invalidated when
rebuild started. The metadata said "healthy" but the data was corrupt
from a partial rebuild.
**V2 exposed?**: Yes. Our double-event bug: `SessionCompleted` emitted
on ack arrival, then `SessionFailed("sender stopped")` emitted during
executor cleanup. The system briefly declared success then reverted to
failure.
**V3 rule**: A3 (ack arrival ≠ terminal success). Health metadata must
be invalidated at rebuild START, not just at rebuild FAILURE. Terminal
success is emitted only after executor/session closure completes.
---
### 3.2 Longhorn: Healthy replica deleted due to address collision
**Source**: [Longhorn #9216](https://github.com/longhorn/longhorn/issues/9216)
**What**: In a 3-replica degraded volume, the last healthy replica was
deleted. A stopped replica was assigned the same port as a previously
deleted one. The engine saw two replicas at the same address and
deleted the "redundant" one — which was the only healthy copy.
**Root semantic mistake**: Replica identity derived from network address.
Port reuse after restart created identity collision. Two physically
different replicas appeared as the same entity.
**V2 exposed?**: Yes. The `Counter` field in SeaweedFS upstream's
`data_node.go` is a workaround for the same problem — two heartbeat
streams for the same IP:port after fast restart.
**V3 rule**: Replica identity = `(epoch, volumePath, serverID)`.
Immutable, never derived from ports or transport addresses.
---
### 3.3 Ceph: Rolling restarts orphan objects
**Source**: [Ceph #52385](https://tracker.ceph.com/issues/52385)
**What**: After restarting all nodes sequentially without waiting for
`HEALTH_OK`, PGs entered `recovery_unfound` with permanently lost
objects. Objects existed on stray OSDs excluded from the acting set.
**Root semantic mistake**: Peering completion was not waited for before
proceeding with the next restart. Partial membership view during
peering excluded OSDs that had the data.
**V2 exposed?**: Similar risk. If the master's primary refresh isn't
delivered before the next heartbeat cycle, the primary operates with a
partial roster. The rebuild may target the wrong set of replicas.
**V3 rule**: Recovery operations must wait for complete membership view.
Never start recovery with a partial roster.
---
## Category 4: Ordering / Race Conditions
### 4.1 DRBD: Write races with very short resync
**Source**: [DRBD 9.2.14 ChangeLog](https://github.com/LINBIT/drbd/blob/drbd-9.1/ChangeLog)
**What**: A write during a very short resync was not resynced, leaving
replicas divergent. The resync marked the region "clean" while the write
was in-flight.
**Root semantic mistake**: Region bitmap clear was not ordered after
concurrent write drain. The bitmap said "synced" but an in-flight write
had not been applied to the peer.
**V2 exposed?**: Our rebuild bitmap has the same structure. The rule
"WAL-applied wins over base-applied" handles this: the bitmap is SET
by the live lane, and base lane checks before installing. But a race
between bitmap check and base install could produce the same bug if
the lock granularity is wrong.
**V3 rule**: Recovery completion must not mark a region clean while
writes are in-flight to that region. Drain before clear.
---
### 4.2 DRBD: Parallel connection handshake interference
**Source**: [DRBD 9.1.23 ChangeLog](https://github.com/LINBIT/drbd/blob/drbd-9.1/ChangeLog)
**What**: In 3+ node setups, parallel connection establishment left one
connection in `WFBitMapT/Established` — an inconsistent half-syncing
state.
**Root semantic mistake**: Per-connection state transitions were not
isolated. One connection's handshake interfered with another's.
**V2 exposed?**: Our `ShipperGroup` manages multiple shippers. If
`ProbeReconnectAll` probes all replicas in parallel, one probe's state
change could interfere with another's if they share state.
**V3 rule**: Per-replica state must be fully isolated. No shared
mutable state between recovery sessions for different replicas.
---
### 4.3 Longhorn: Zombie engine from rapid attach/detach
**Source**: [Longhorn #11605](https://github.com/longhorn/longhorn/issues/11605)
**What**: Attach + detach within 1 second left an orphaned engine
process. Detach happened before the instance appeared in the CR,
so nothing knew to clean it up.
**Root semantic mistake**: Deletion checked once and gave up.
The target wasn't visible during that single check window because
of CR sync delay.
**V2 exposed?**: Our `cancelAndDrain` with 5-second timeout is the
same pattern. If the catch-up goroutine doesn't exit within 5s, we
abandon it. The zombie goroutine may hold resources indefinitely.
**V3 rule**: Cleanup must retry until confirmed absent. A single
check-miss is not proof of absence.
---
### 4.4 Mayastor: Internal/external state divergence on fault
**Source**: [Mayastor #549](https://github.com/openebs/mayastor/issues/549)
**What**: Faulting a nexus child removed it from internal structure but
NOT from external child list. Destroy caused state machine assertion
failure because internal and external representations disagreed.
**Root semantic mistake**: Partial cleanup. Internal state changed but
external-facing state was not updated atomically.
**V2 exposed?**: Our sender registry vs shipper state divergence.
`InvalidateSessionCommand` nulled the sender's session but the shipper's
transport state was unchanged. The registry said "no session" but the
shipper still had a live TCP connection.
**V3 rule**: State changes must be atomic across all representations.
If you invalidate a session, both the engine state AND the transport
state must reflect it.
---
## Category 5: Background Work Corrupts Semantics
### 5.1 Ceph: Recovery priority starvation
**Source**: [Ceph #62811](https://tracker.ceph.com/issues/62811)
**What**: PGs stuck in `backfilling` indefinitely. Recovery work was
continuously preempted by peering events and sub-op reads from other
PGs. Recovery could never make forward progress.
**Root semantic mistake**: No guaranteed forward-progress for recovery.
Each individual preemption was "correct" but infinite preemption = no
recovery = semantic failure.
**V2 exposed?**: Our recovery goroutine competes with heartbeat
processing on the same thread. If heartbeat processing takes too long
(e.g., many volumes), recovery stalls.
**V3 rule**: Recovery must have guaranteed forward-progress slots that
cannot be infinitely preempted. Rate-limit interruptions.
---
### 5.2 Mayastor: Rebuild thundering herd after node loss
**Source**: [Mayastor #1714](https://github.com/openebs/mayastor/issues/1714)
**What**: When 1 node went down in a 3-node cluster, all volumes
attempted rebuild simultaneously. Connection failures cascaded because
rebuild descriptors couldn't acquire IO channels. Each failed rebuild
faulted its child, and retry created a thundering herd.
**Root semantic mistake**: No admission control for concurrent rebuilds.
Automated remediation without backoff worsened the overload.
**V2 exposed?**: Currently only 1 volume in tests. With 100+ volumes,
simultaneous rebuild after a node failure would create the same
thundering herd on our rebuild server.
**V3 rule**: Rebuild scheduling after node failure must be staggered.
Maximum concurrent rebuilds per node. Exponential backoff on failure.
---
### 5.3 Mayastor: HA feedback loop under shared pool load
**Source**: [Mayastor #1331](https://github.com/openebs/mayastor/issues/1331)
**What**: Shared DiskPool under concurrent IO from 6 pods triggered
timeouts. HA interpreted timeouts as node failure, initiated target
replacement, generating more IO, causing more timeouts. Positive
feedback loop.
**Root semantic mistake**: HA reaction to resource exhaustion did not
include backoff. The "fix" (replace target) consumed more resources
than the failure it was trying to fix.
**V2 exposed?**: If the rebuild server saturates the disk with base
block reads while the primary is also serving live writes, the same
resource exhaustion → timeout → retry loop could occur.
**V3 rule**: HA/rebuild reactions must detect resource exhaustion and
back off. Automated remediation without admission control cascades.
---
## Mapping to V3 Anti-Patterns
| External Lesson | V3 Anti-Pattern | V3 Principle |
|---|---|---|
| Timer-based death without corroboration | A1 | P1: timers trigger, facts decide |
| Stale epoch used for membership decision | A1 | P5: monotonic facts win |
| Transport error → full rebuild | A2 | P6: transport ≠ semantic overreach |
| Ack → success before close | A3 | P2: terminal authority = executor close |
| Address reuse → identity collision | A4 | Stable IDs, never address-derived |
| Health metadata survives rebuild | A5 | P3: projection is derived only |
| Partial cleanup leaves split state | A4 | Atomic state changes across all representations |
| Priority starvation blocks recovery | A6 | Guaranteed forward progress |
| Thundering herd of rebuilds | A6 | Admission control + stagger |
---
## "Would V2 Have This Bug?" Checklist
Use this for self-audit. For each external bug, ask: could our system
hit the same failure class?
| # | Failure Class | V2 Exposed? | Status |
|---|---|---|---|
| 1 | Heartbeat timing = semantic death | Yes (fast rejoin) | Fixed: primary probes independently |
| 2 | Stale local state for membership check | Yes (stale-cleanup race) | Fixed: RegisteredAt grace period |
| 3 | Asymmetric identity rules | Yes (replicaID format mismatch) | Fixed: resolveEngineReplicaID |
| 4 | Liveness monitor dies with target | Yes (shipper holds dead conn) | Partially fixed: watchdog recheck |
| 5 | Transport error → full rebuild | Yes (no WAL retention) | Designed: SmartWAL + LBA dirty map |
| 6 | Address reuse → wrong target | Not yet (single volume tests) | Risk at scale: need stable IDs |
| 7 | Ack = terminal success | Yes (double event) | Fixed: suppression + manual OnRebuildCompleted |
| 8 | Health metadata survives rebuild | Not yet | Risk: need to track |
| 9 | Partial cleanup (sender vs session) | Yes (sender registry) | Fixed: emitTerminal=false |
| 10 | Recovery starvation | Not yet | Risk at scale with many volumes |
| 11 | Rebuild thundering herd | Not yet | Risk at scale: need admission control |
---
*This document is updated as new external cases are discovered.*