Hardware scenarios (all PASS on m01/m02, 25Gbps RoCE): - I-V3 auto-failover: 43/43 (create→write→kill→promote→verify IO) - I-R8 rebuild-rejoin: 58/58 (failover→write→restart→1GB rebuild in 2s→verify data) - Fast rejoin: 43/43 (kill replica→3s→restart→recovery→data verified) Performance: V2 RF=1 = 46,666 IOPS vs V1.5 RF=1 = 47,233 IOPS (-1.2%, noise) New test scenarios: - v2-rebuild-rejoin.yaml: full failover→rebuild→second failover→data integrity - v2-fast-rejoin-catchup.yaml: replica kill→fast restart→recovery - v2-rebuild-failure-retry.yaml: kill during rebuild→restart→data verified - rf1-perf-compare.yaml: RF=1 perf baseline for V1.5 vs V2 comparison Design documents: - protocol-anti-patterns.md: 7 anti-patterns with cases from SeaweedFS/Ceph/DRBD - smartwal-design-memo.md: extent-first write algorithm research (BlueStore/ZFS/DRBD) - smartwal-prototype-spec.md: prototype spec with 16/16 crash tests PASS - v3-clean-recovery-draft.md: V3 semantic cleanup principles - v2-integration-matrix.md: 25-row integration coverage map - v2-acceptance-evidence.md: gap analysis for remaining work SmartWAL prototype (16/16 tests PASS): - smartwal.go, smartwal_record.go, smartwal_recovery.go: core implementation - smartwal_test.go: 9 single-node crash tests - smartwal_repl_test.go: 7 two-node replication crash tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
20 KiB
Protocol Anti-Patterns in Distributed Storage Systems
Date: 2026-04-09 Status: reference document
Purpose
This document catalogs recurring protocol anti-patterns found in distributed storage systems. Each anti-pattern is defined, then illustrated with concrete cases from real codebases: SeaweedFS (upstream object store), SeaweedFS sw-block (block storage extension), Ceph RADOS, and DRBD.
The goal is to provide a reusable reference for protocol review and design decisions. When a new protocol change is proposed, check it against this list.
A1: Heartbeat Timing Defines Recovery Semantics
Definition: A timer or heartbeat interval directly determines a semantic outcome (node death, volume reassignment, recovery type) without collecting independent facts.
Why it is wrong: Heartbeat is transport-layer liveness observation. It measures "when did I last hear from you," not "what is your data state." Using it as the sole input for semantic decisions conflates observation latency with data truth.
Correct principle: Timers trigger observation. Facts determine semantics.
Case: SeaweedFS upstream — dead node detection
topology/volume_location_list.go:69-86
When LastSeen < freshThreshHold (hardcoded 60 seconds), the node is removed
from volume location lists. When the heartbeat gRPC stream drops
(master_grpc_server.go:67-101), the defer block immediately unregisters the
node and marks all volumes unavailable.
A 60-second network hiccup causes immediate volume unavailability. No probe, no corroboration, no fact collection. One missed heartbeat cycle = topology change.
Case: SeaweedFS sw-block — fast replica rejoin missed
When a replica restarts faster than the master's heartbeat detection (~5
seconds), the master never detects the disconnect. No failoverBlockVolumes
fires, no recoverBlockVolumes fires, no primary refresh is sent. The
primary's shipper holds a dead TCP connection and the volume stays degraded
indefinitely.
The system's recovery depends on the timing of the restart relative to the heartbeat cycle, not on the fact of the restart.
Case: Ceph — OSD flapping storms
Early Ceph used OSD heartbeat timeout (20s default) as the trigger for PG peering. Network congestion caused heartbeat delays without actual OSD failure, leading to "flapping" — OSDs bouncing between up and down, triggering unnecessary recovery storms that made congestion worse.
Fix: Ceph separated heartbeat (liveness signal) from PG peering (data-plane
fact collection). The monitor marks a node down, but PG peering independently
collects pg_info from all OSDs before deciding recovery. The
osd_heartbeat_grace and mon_osd_min_down_reporters tuning knobs are
artifacts of this evolution.
Case: DRBD — ping timeout triggers resync
DRBD's ping-timeout and connect-int timers originally drove state
transitions directly. A slow network caused DRBD to declare the peer dead and
start resync — but the peer was alive, just slow.
Fix: DRBD 8.4+ added fencing — you need an external fencing agent to confirm death before split-brain resolution proceeds. The timer triggers the check; the fencing agent provides the fact.
Fix pattern
Timer fires → trigger probe/observation
Probe collects bounded facts (R, S, H, epoch, endpoint)
Facts determine semantic action (no-op, catchup, rebuild, promote)
A2: Transport Failure Directly Defines Recovery Type
Definition: A single transport-layer error (TCP reset, write timeout, dial failure) directly triggers a semantic state change (rebuild required, volume degraded, write failed) without distinguishing transient from permanent failure.
Why it is wrong: Transport failure and data-plane recovery are different truth domains. A TCP reset means "I cannot reach you right now," not "your data is divergent and you need a full rebuild."
Correct principle: Transport failure changes reachability truth. Recovery type requires bounded data-plane facts.
Case: SeaweedFS upstream — one replica failure fails entire write
topology/store_replicate.go:73-137
DistributedOperation fans out writes to all replicas in parallel. If ANY
replica returns an error, the entire write fails and returns error to the
client. No distinction between:
- Transient network hiccup (retry would succeed)
- Replica disk full (permanent, needs different handling)
- Replica truly unreachable (degrade and continue)
A single TCP error from one replica out of three fails the write for the client.
Case: SeaweedFS sw-block — Ship() 3s deadline causes NeedsRebuild
The WAL shipper's Ship() sets a 3-second write deadline on the TCP
connection. Under sustained qd=16 writes, the replica couldn't drain the TCP
buffer fast enough. The deadline fired, markDegraded() was called, and
subsequent WAL recycling made the gap unrecoverable — escalating to
NeedsRebuild.
A 3-second TCP write timeout caused a full 1GB rebuild. The transport timeout was over-interpreted as a semantic failure.
Case: DRBD — TCP drop triggers full resync
In DRBD 8.x, a single TCP connection failure could trigger a full resync. The activity log bitmap was supposed to track only changed blocks, but connection drops during heavy I/O could corrupt the bitmap tracking, causing a full resync of the entire device.
Fix: DRBD 9 introduced quorum — transport failure alone doesn't trigger resync. You need quorum loss (confirmed by multiple paths) before the system decides the peer needs rebuilding.
Case: Ceph — transport loss vs acting set change
Ceph's PG peering distinguishes transport loss from acting set changes. A transport failure moves the OSD out of the acting set, but the PG doesn't start recovery until peering collects actual data-plane facts (pg_info exchange). Transport loss changes reachability; recovery requires facts.
Fix pattern
Transport error → mark reachability lost
Keep probing last known endpoint (harmless retries)
When reachable again → collect facts (R, S, H)
Facts determine: no recovery, catchup, or rebuild
A3: Ack Arrival Defines Terminal Success
Definition: An acknowledgment message (ack, barrier response, completion signal) is treated as terminal success before the full protocol round-trip closes. The system announces success, then discovers the operation is not actually complete.
Why it is wrong: Observed completion is not the same as closed recovery authority. An ack means "the remote side received and processed your request." It does not mean "all side effects are complete, all cleanup is done, and the system is in a consistent terminal state."
Correct principle: Terminal success is emitted only after executor/session closure completes, not on ack arrival.
Case: SeaweedFS sw-block — double event on rebuild completion
The remote rebuild ack callback emitted SessionCompleted immediately on
receiving SessionAckCompleted from the replica. The V2 core transitioned to
publish_healthy. Then the executor's cleanup path encountered a "sender
stopped" error and emitted SessionFailed, knocking the mode back to
degraded. Two contradictory terminal events from the same operation.
Root cause: ack arrival was treated as terminal truth, but the executor was still unwinding.
Fix: Ack updates observed facts only. Terminal success is emitted after executor/session closure succeeds.
Case: SeaweedFS upstream — local write before replica ack
topology/store_replicate.go:49-64
The primary writes locally first, then replicates to remote locations. If local write succeeds but remote replication fails, the primary has data the replicas don't. There is no reconciliation mechanism. The write is partially committed.
If the client connection drops after local write but before replication error is returned, the client may see success (from a previous response) while the replicas don't have the data.
Case: Ceph — committed before fsync
Ceph had a bug where the primary OSD declared a write "committed" after
receiving replica acks, but the replica crashed before fsyncing. The fix was
min_size enforcement — the write isn't committed until the required number
of replicas confirm durable persistence via journal/WAL fsync.
Fix pattern
Ack arrives → update observed facts (progress, achieved LSN)
Do NOT emit terminal success yet
Executor completes cleanup (sender close, session close)
Then emit terminal success
A4: Event Ordering Determines Semantics
Definition: The same set of facts produces different semantic outcomes depending on which event arrives first. Two orderings of the same events lead to different recovery paths or different final states.
Why it is wrong: If the system has the same facts, it should make the same decision. Event order should affect convergence latency, not semantic result.
Correct principle: Same facts, same decision, regardless of arrival order.
Case: SeaweedFS sw-block — NeedsRebuildObserved removes sender before rebuild starts
handleReplicaProbeResult calls applyCoreEvent(NeedsRebuildObserved) which
emits InvalidateSessionCommand. The command removes the sender's session
from the orchestrator registry. Then installSession tries to re-create it,
and RebuildStarted fires. But by the time the first ack arrives, a
registry reconciliation (triggered by syncProtocolExecutionState inside
applyCoreEvent) has removed the sender again.
The sender survives or dies depending on the exact ordering of:
- InvalidateSessionCommand dispatch
- installSession ProcessAssignment
- syncProtocolExecutionState reconciliation
- First ack arrival
Different timings of these four events produce different outcomes (rebuild succeeds or fails with "sender not found").
Case: SeaweedFS upstream — volume growth race
topology/volume_growth.go:288-349
Concurrent Assign requests race on volume slot reservation. Thread A
reserves node1+node2+node3. Thread B sees node3 full (A got it), fails, and
releases its reservations. Under high concurrency, different request ordering
produces different capacity decisions.
Case: Ceph — PG peering collects all facts first
Ceph's solution to this anti-pattern: the PG peering state machine has
GetInfo and GetLog states that wait for responses from ALL peers before
advancing to Active. Event order within the collection phase doesn't matter
because no decision is made until all facts are present.
Case: Raft — log position, not arrival order
The Raft consensus algorithm is the canonical solution. All decisions are based on log position (term + index), not arrival order. If two messages carry the same log position information, the same decision is made regardless of which arrives first.
Fix pattern
Collect all required facts before making decisions
Use monotonic identifiers (epoch, LSN, sessionID) to order facts
Decision function: f(facts) → action (deterministic, no ordering dependency)
R >= H → no recovery
R >= S → catchup
R < S → rebuild
A5: Projection Drives Control Truth
Definition: A derived status field (mode, health, state label) is read back as input to control decisions. The projection is supposed to be a report of truth, but it becomes a hidden control input that creates feedback loops.
Why it is wrong: Projection must be derived only. When a derived field is used as input, the system creates a circular dependency: state determines projection, projection determines state.
Correct principle: Projection reports truth. It must never create truth.
Case: SeaweedFS upstream — ReadOnly status drives assignment
topology/volume_layout.go:267-271
Volume ReadOnly status (self-reported by the volume server) directly controls
whether the master adds the volume to the writable list and whether it is
eligible for new write assignments. The master has no independent verification.
If a volume server incorrectly marks itself readonly (bug, misconfiguration), the master stops all writes to that volume. The volume server's self-reported status becomes authoritative for the master's assignment decisions.
Case: SeaweedFS upstream — volume compacting status
topology/topology_vacuum.go:347-349
The master skips vacuum for volumes marked readonly — but readonly is itself derived from the volume server's report. A volume server can prevent vacuuming by claiming readonly, even if the volume should be vacuumed.
Case: Kubernetes — Pod phase feedback loop
Pod status.phase (Running, Pending, Failed) is supposed to be a derived
report. But many controllers use status.phase as input for reconciliation
loops, creating feedback cycles. The Kubernetes community has been migrating
toward conditions (explicit fact-based fields) instead of phase (derived
summary).
Case: Ceph — PG state as recovery scheduler input
Ceph's PG state (active+clean, active+degraded) was used internally to
prioritize recovery scheduling. But degraded was itself derived from the
recovery state — creating a loop. Fix: the recovery scheduler works from
pg_missing (actual missing objects), not the PG state label.
Fix pattern
Projection = f(facts) — pure derivation, no side effects
Control decisions read facts directly, not projections
If you find a control path reading a projection, refactor to read the
underlying fact instead
A6: Timing Workaround States Accumulate in the Engine
Definition: New states, flags, or enum values are added to the semantic engine to survive a specific race condition or timing issue. Over time, the engine becomes a patch history rather than a semantic model.
Why it is wrong: Each workaround state increases the state machine's surface area without adding semantic meaning. The state exists for timing reasons, not because the problem space has a new distinction. Future developers cannot tell which states are essential and which are workarounds.
Correct principle: New state enters the engine only if it expresses a new semantic distinction that cannot be represented by existing dimensions.
Case: SeaweedFS upstream — phantom volume server Counter
data_node.go:24
Counter int // in race condition, the previous dataNode was not dead
When a VS reconnects before the old heartbeat stream's defer runs, two streams
exist for the same node. The Counter field tracks active streams and only
unregisters when it reaches 0. This is a timing workaround for the race
between stream closure and new stream registration. The comment explicitly
says "in race condition."
Case: SeaweedFS upstream — reservation timeout
topology/node.go:294
const reservationTimeout = 5 * time.Minute // TODO: make this configurable
Reservations are cleaned up after an arbitrary 5-minute timeout. This is a workaround for failed volume creation that doesn't release its reservation. The timeout exists because there is no proper completion/failure signal for the reservation lifecycle.
Case: Ceph — PG peering state explosion
Ceph's PG peering state machine grew from ~8 states in early versions to ~20+
states. Many intermediate states (Stray, GetMissing, WaitUpThru,
Incomplete) were introduced for specific race conditions between OSD restart
timing and monitor OSDMap propagation.
WaitUpThru exists solely because the monitor's OSDMap epoch update can
arrive before or after PG peering completes — it is a timing workaround state
that has persisted for over a decade.
Case: DRBD — connection state proliferation
DRBD's original 4 connection states (StandAlone, Connecting, Connected,
Disconnecting) grew to include NetworkFailure, Unconnected, Timeout,
BrokenPipe, TearDown — many of which handle specific TCP failure modes
rather than semantic differences.
Fix pattern
Before adding a new state, ask:
1. Does this express a new semantic distinction? (keep)
2. Or does it exist to survive a specific timing race? (reject)
If the answer is (2), fix the timing issue in the runtime layer
(retry, epoch comparison, idempotent processing) rather than adding
a new state to the semantic engine.
A7: Transport Mechanics Leak Into the Semantic Engine
Definition: TCP connection management, retry logic, goroutine scheduling, or wire protocol details appear inside the semantic state machine instead of being handled by runtime adapters.
Why it is wrong: The semantic engine should process facts and produce decisions. It should not know or care about TCP, gRPC, goroutines, or wire formats. When transport mechanics leak in, the engine becomes coupled to a specific runtime implementation and cannot be tested or reasoned about independently.
Correct principle: The engine processes facts. Runtime adapters handle transport.
Case: SeaweedFS sw-block — sender registry as transport gate
The V2 engine's sender registry (Orchestrator.Registry) serves dual purpose:
semantic session tracking AND transport connection management. When
InvalidateSessionCommand fires, it nulls the sender's session (semantic)
and the same sender is used by the ack observation path to validate incoming
TCP frames (transport). The semantic invalidation kills the transport path.
This coupling caused the "sender not found" rebuild failure: the engine's semantic state change (invalidate session) removed the transport path needed by the rebuild coordinator.
Case: Ceph — messenger abstraction
Ceph explicitly separates the semantic layer (PG peering state machine) from the transport layer (msgr2 messenger). The PG state machine never directly touches TCP connections. It produces messages that the messenger delivers. Connection failures are reported to the state machine as facts, not as transport errors.
Fix pattern
Engine interface:
Input: facts (epoch, R, S, H, ack kind, session ID)
Output: decisions (no-op, catchup, rebuild, promote)
Runtime adapter:
Translates TCP events → facts
Translates decisions → TCP actions
Handles retry, timeout, connection lifecycle
The engine never imports net, io, or sync packages.
Summary Table
| Anti-Pattern | SeaweedFS upstream | sw-block V2 | Ceph | DRBD |
|---|---|---|---|---|
| A1: Timer defines semantics | Dead node detection (60s) | Fast rejoin missed | OSD flapping storms | Ping timeout resync |
| A2: Transport = recovery | One replica fails all | Ship() 3s deadline | — | TCP drop = full resync |
| A3: Ack = terminal success | Local write before ack | Double SessionCompleted/Failed | Committed before fsync | — |
| A4: Ordering determines result | Volume growth race | Sender removed before rebuild | Solved: collect all facts first | — |
| A5: Projection as control | ReadOnly drives assignment | — | PG state as scheduler input | — |
| A6: Workaround states | Counter field, reservation timeout | — | WaitUpThru, 20+ PG states | 9+ connection states |
| A7: Transport in engine | — | Sender registry dual-purpose | Solved: messenger abstraction | — |
Using This Document
When reviewing a protocol change:
- Check if the change matches any anti-pattern above
- If it does, ask: can the same goal be achieved without the anti-pattern?
- Apply the "fix pattern" suggested for that anti-pattern
- If no clean alternative exists, document why the exception is necessary
When designing a new protocol:
- Separate identity truth (who), reachability truth (can I reach), recovery truth (what data action), and execution (transport mechanics)
- Ensure each truth domain has a single authority
- Ensure timers trigger observations, never semantic decisions
- Ensure same facts produce same decisions regardless of event order
- Keep transport mechanics in runtime adapters, not the semantic engine
This document is a living reference. Add new cases as they are discovered.