mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-16 20:26:45 +00:00
* ec: bounded-exhaustive model check of the volume lifecycle The randomized chaos harness samples the state space; this enumerates it. The lifecycle is a state machine whose steps mirror the pipelines in this package, and the checker explores every schedule within the bound: a crash at every step boundary, an error return running the rollback (itself crashable at every step), a volume-server restart applying the startup reconciliation rules in every quiescent state, and the prescribed restart-based recovery from every crashed state. Checked in every reachable state: durability (a readable copy always exists), at most one generation mounted, and — a property the sweep discipline turns out to guarantee — at most one generation's files on disk. From every quiescent state the recovery must converge to a clean volume. Runs in well under a second. * test: deterministic EC interruption matrix Enumerate every phase of every interruptible EC operation and kill a real weed shell exactly when the phase announces itself on the command output, instead of at a random moment: four encode phases, four decode phases, and the balance's move phase (set up with -rebalance=false so a move is guaranteed). Each scenario prepares its precondition, kills at the marker, runs the prescribed recovery, and verifies every stored byte still reads back identical. The interruption recoveries move out of the randomized ops into shared chaosRun helpers both drivers use. * test: make the randomized EC chaos walk opt-in The systematic layers — the interruption matrix and the lifecycle model check — carry the CI coverage deterministically; the randomized walk stays for exploratory runs, behind EC_CHAOS_SEED. * ci: bound the EC integration suite by the job budget, not go test's default The suite with the interruption matrix runs close to the default 10m binary timeout on slower runners. * test: require every interruption-matrix marker to appear A marker that never prints means a pipeline refactor renamed or dropped the progress line; silently degenerating into a no-interruption run would let CI pass without exercising the boundary the scenario names. Also recheck the marker channel after the wait: a shell that prints and exits at once makes both channels ready, and select picking the exit case must not report a printed marker as missed.
521 lines
18 KiB
Go
521 lines
18 KiB
Go
package ec
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Bounded-exhaustive model check of the EC volume lifecycle.
|
|
//
|
|
// The randomized chaos harness samples the state space; this test enumerates
|
|
// it. The lifecycle of one volume is modeled as a state machine whose
|
|
// transitions mirror, step by step, the real pipelines in this package, and
|
|
// the checker explores EVERY schedule within the bound: every operation, a
|
|
// crash (kill, no cleanup) at every step boundary, an error return (which
|
|
// runs the rollback, itself crashable at every step) at every step boundary,
|
|
// a volume-server restart (running the startup reconciliation rules) in every
|
|
// quiescent state, and every recovery re-run from every crashed state.
|
|
//
|
|
// Two safety invariants are asserted in every reachable state, including
|
|
// mid-operation and post-crash:
|
|
//
|
|
// I1 durability: the volume's data is always recoverable — the regular
|
|
// replica exists, or some single generation holds at least dataShards
|
|
// shard files.
|
|
// I2 single serving generation: at most one generation is ever mounted.
|
|
// I3 single generation on disk: the sweep-before-generate discipline keeps
|
|
// at most one generation's files on disk through every schedule. (An
|
|
// externally planted file breaks this by construction; that path is the
|
|
// chaos harness's, not the orchestration's.)
|
|
//
|
|
// And one convergence property: from every quiescent state, the prescribed
|
|
// recovery — re-running the interrupted operation, per the restart-not-resume
|
|
// model — terminates in a clean state (a writable regular volume with no EC
|
|
// leftovers, or exactly one complete mounted generation with no source
|
|
// replica and no surplus copies).
|
|
//
|
|
// The model's fidelity contract: each step below names the code it stands
|
|
// for. When the pipeline order changes, this file must change with it.
|
|
//
|
|
// encode = ProcessEcEncodeBatch/doEcEncode: markReadonly
|
|
// (markVolumeReplicaWritable false) → sweep
|
|
// (clearPreexistingEcShards, removes every prior generation) →
|
|
// generate (VolumeEcShardsGenerate, a new generation's files) →
|
|
// mount (VolumeEcShardsMount, registers it) → balance
|
|
// (EcBalance; a crash mid-move leaves a surplus copy, never a
|
|
// loss, because moves copy before they delete) → verify
|
|
// (verifyEcShardsBeforeDelete) → deleteSource
|
|
// (doDeleteVolumesWithLocations, the commit point).
|
|
// rollback = rollbackFailedEcEncode, run only on an error return before
|
|
// the commit: rollbackSweep (clearPreexistingEcShards again) →
|
|
// restoreWritable. A kill runs nothing.
|
|
// decode = DoEcDecode: collect (collectEcShards) → generateVolume
|
|
// (VolumeEcShardsToVolume) → mountVolume (VolumeMount) →
|
|
// verifyDecoded (verifyDecodedVolumeBeforeDelete) → three
|
|
// per-location shard deletions (unmountAndDeleteEcShards).
|
|
// Forward-only: no rollback.
|
|
// reconcile = the volume server startup rules (weed/storage
|
|
// disk_location_ec.go): with the source .dat present, a
|
|
// partial generation fails validation and is deleted
|
|
// (handleFoundEcxFile/validateEcVolume); surviving shard files
|
|
// with their sidecars re-register (the #9212 adoption).
|
|
// dedup = the balance's dedup phase removing surplus copies
|
|
// (ecbalancer "dedup" moves + verifyEcShardOnKeepNode).
|
|
//
|
|
// Deliberately outside the model: multi-replica sources (the pipeline syncs
|
|
// and reduces to one best replica before encoding), per-node shard placement
|
|
// (I1/I2 do not depend on it), and externally planted files (the chaos
|
|
// harness covers adoption of foreign shards).
|
|
|
|
const (
|
|
modelDataShards = 10
|
|
modelTotalShards = 14
|
|
)
|
|
|
|
type modelGen struct {
|
|
files int // shard files on disk, whether or not registered
|
|
mounted bool // registered and serving
|
|
surplus int // duplicate copies left by an interrupted balance move
|
|
}
|
|
|
|
type modelState struct {
|
|
replica bool
|
|
replicaReadonly bool
|
|
gens []modelGen // oldest first; a new encode appends
|
|
}
|
|
|
|
func (s modelState) clone() modelState {
|
|
c := s
|
|
c.gens = append([]modelGen(nil), s.gens...)
|
|
return c
|
|
}
|
|
|
|
func (s modelState) key() string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "r%v,ro%v", s.replica, s.replicaReadonly)
|
|
for _, g := range s.gens {
|
|
fmt.Fprintf(&b, "|f%d,m%v,s%d", g.files, g.mounted, g.surplus)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// dropEmptyGens removes generations with no files left.
|
|
func (s *modelState) dropEmptyGens() {
|
|
kept := s.gens[:0]
|
|
for _, g := range s.gens {
|
|
if g.files > 0 {
|
|
kept = append(kept, g)
|
|
}
|
|
}
|
|
s.gens = kept
|
|
}
|
|
|
|
// ── invariants ──────────────────────────────────────────────────────────────
|
|
|
|
func checkInvariants(s modelState) error {
|
|
// I1: durability.
|
|
recoverable := s.replica
|
|
for _, g := range s.gens {
|
|
if g.files >= modelDataShards {
|
|
recoverable = true
|
|
}
|
|
}
|
|
if !recoverable {
|
|
return fmt.Errorf("I1 violated: no replica and no generation with >= %d shard files", modelDataShards)
|
|
}
|
|
// I2: at most one mounted generation.
|
|
mounted := 0
|
|
for _, g := range s.gens {
|
|
if g.mounted {
|
|
mounted++
|
|
}
|
|
}
|
|
if mounted > 1 {
|
|
return fmt.Errorf("I2 violated: %d generations mounted", mounted)
|
|
}
|
|
// I3: at most one generation's files on disk.
|
|
withFiles := 0
|
|
for _, g := range s.gens {
|
|
if g.files > 0 {
|
|
withFiles++
|
|
}
|
|
}
|
|
if withFiles > 1 {
|
|
return fmt.Errorf("I3 violated: %d generations have files on disk", withFiles)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isCleanEc(s modelState) bool {
|
|
if s.replica || len(s.gens) != 1 {
|
|
return false
|
|
}
|
|
g := s.gens[0]
|
|
return g.files == modelTotalShards && g.mounted && g.surplus == 0
|
|
}
|
|
|
|
// ── operations as step lists ────────────────────────────────────────────────
|
|
|
|
// A step mutates the state; ok=false means the step's precondition failed
|
|
// (the real command would return an error at this point).
|
|
type modelStep struct {
|
|
name string
|
|
apply func(*modelState) (ok bool)
|
|
}
|
|
|
|
func encodeSteps() []modelStep {
|
|
return []modelStep{
|
|
{"markReadonly", func(s *modelState) bool {
|
|
if !s.replica {
|
|
return false
|
|
}
|
|
s.replicaReadonly = true
|
|
return true
|
|
}},
|
|
// The orphan sweep tears down per (node, volume); a crash can land
|
|
// between nodes, leaving a partially removed generation.
|
|
{"sweepLoc0", func(s *modelState) bool { sweepLocation(s, 0); return true }},
|
|
{"sweepLoc1", func(s *modelState) bool { sweepLocation(s, 1); return true }},
|
|
{"sweepLoc2", func(s *modelState) bool { sweepLocation(s, 2); return true }},
|
|
// Generation writes the shard files on the generation host; a crash
|
|
// mid-write leaves a partial, unregistered set beside the .dat, which
|
|
// the startup validation removes and the next sweep also covers.
|
|
{"generateHalf", func(s *modelState) bool {
|
|
if !s.replica {
|
|
return false
|
|
}
|
|
s.gens = append(s.gens, modelGen{files: modelTotalShards / 2})
|
|
return true
|
|
}},
|
|
{"generateRest", func(s *modelState) bool {
|
|
s.gens[len(s.gens)-1].files = modelTotalShards
|
|
return true
|
|
}},
|
|
{"mount", func(s *modelState) bool {
|
|
s.gens[len(s.gens)-1].mounted = true
|
|
return true
|
|
}},
|
|
// balance: a completed run changes placement only. Its crash variant
|
|
// is modeled by crashing between "balanceCopy" and "balanceDelete":
|
|
// the copy landed, the source deletion did not.
|
|
{"balanceCopy", func(s *modelState) bool {
|
|
s.gens[len(s.gens)-1].surplus++
|
|
return true
|
|
}},
|
|
{"balanceDelete", func(s *modelState) bool {
|
|
s.gens[len(s.gens)-1].surplus--
|
|
return true
|
|
}},
|
|
{"verify", func(s *modelState) bool {
|
|
return s.gens[len(s.gens)-1].files >= modelDataShards
|
|
}},
|
|
{"deleteSource", func(s *modelState) bool {
|
|
s.replica = false
|
|
s.replicaReadonly = false
|
|
return true
|
|
}},
|
|
}
|
|
}
|
|
|
|
// encodeCommitIndex is the index of deleteSource: an error return at or after
|
|
// it must not roll back (the shards are the only copy once it runs).
|
|
func encodeCommitIndex() int { return len(encodeSteps()) - 1 }
|
|
|
|
// sweepLocation removes one location's share of every generation's files —
|
|
// the per-(node, volume) teardown of clearPreexistingEcShards. The last
|
|
// location clears the remainder, sidecars included.
|
|
func sweepLocation(s *modelState, loc int) {
|
|
for gi := range s.gens {
|
|
g := &s.gens[gi]
|
|
chunk := modelTotalShards / 3
|
|
if loc == 2 {
|
|
g.files, g.mounted, g.surplus = 0, false, 0
|
|
} else if g.files > chunk {
|
|
g.files -= chunk
|
|
} else {
|
|
g.files = 0
|
|
}
|
|
}
|
|
s.dropEmptyGens()
|
|
}
|
|
|
|
func rollbackSteps() []modelStep {
|
|
return []modelStep{
|
|
{"rollbackSweep", func(s *modelState) bool {
|
|
s.gens = nil
|
|
return true
|
|
}},
|
|
{"restoreWritable", func(s *modelState) bool {
|
|
if s.replica {
|
|
s.replicaReadonly = false
|
|
}
|
|
return true
|
|
}},
|
|
}
|
|
}
|
|
|
|
func decodeSteps() []modelStep {
|
|
steps := []modelStep{
|
|
{"collect", func(s *modelState) bool {
|
|
for _, g := range s.gens {
|
|
if g.mounted && g.files >= modelDataShards {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}},
|
|
{"generateVolume", func(s *modelState) bool {
|
|
s.replica = true
|
|
return true
|
|
}},
|
|
{"mountVolume", func(s *modelState) bool { return true }},
|
|
{"verifyDecoded", func(s *modelState) bool { return s.replica }},
|
|
}
|
|
// Shard deletion is per holder location; three locations model a spread
|
|
// volume, so a crash can land between any two of them.
|
|
for i := 0; i < 3; i++ {
|
|
i := i
|
|
steps = append(steps, modelStep{fmt.Sprintf("deleteShardsLoc%d", i), func(s *modelState) bool {
|
|
for gi := range s.gens {
|
|
g := &s.gens[gi]
|
|
if !g.mounted && g.files == 0 {
|
|
continue
|
|
}
|
|
// Each location holds roughly a third of the shards; the last
|
|
// pass clears the remainder and the generation with it.
|
|
chunk := modelTotalShards / 3
|
|
if i == 2 {
|
|
g.files, g.surplus, g.mounted = 0, 0, false
|
|
} else if g.files > chunk {
|
|
g.files -= chunk
|
|
}
|
|
}
|
|
s.dropEmptyGens()
|
|
return true
|
|
}})
|
|
}
|
|
return steps
|
|
}
|
|
|
|
// reconcile applies the volume-server startup rules after a crash-restart.
|
|
func reconcile(s *modelState) {
|
|
for gi := range s.gens {
|
|
g := &s.gens[gi]
|
|
if s.replica && g.files < modelTotalShards {
|
|
// .dat present and the local generation is partial: validation
|
|
// fails and the files are removed (disk_location_ec.go).
|
|
g.files, g.mounted, g.surplus = 0, false, 0
|
|
continue
|
|
}
|
|
if g.files > 0 {
|
|
// Surviving files re-register through their sidecars (#9212).
|
|
g.mounted = true
|
|
}
|
|
}
|
|
s.dropEmptyGens()
|
|
}
|
|
|
|
// dedupSurplus is the balance dedup phase.
|
|
func dedupSurplus(s *modelState) {
|
|
for gi := range s.gens {
|
|
s.gens[gi].surplus = 0
|
|
}
|
|
}
|
|
|
|
// ── recovery convergence ────────────────────────────────────────────────────
|
|
|
|
// runToCompletion applies steps without interruption; false when a
|
|
// precondition fails (the real re-run would error out).
|
|
func runToCompletion(s *modelState, steps []modelStep) bool {
|
|
for _, st := range steps {
|
|
if !st.apply(s) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// recover applies the prescribed recovery for a quiescent state and reports
|
|
// whether it converges to a clean state. Per the restart-not-resume model:
|
|
// a surviving replica means the encode restarts from scratch; no replica
|
|
// means the encode had committed and only surplus cleanup can remain; a
|
|
// mounted generation beside a replica can also be decoded away.
|
|
func recoverModel(s modelState) (modelState, error) {
|
|
// A crash-restart happens before any operator action; its reconciliation
|
|
// must itself keep the invariants.
|
|
reconcile(&s)
|
|
if err := checkInvariants(s); err != nil {
|
|
return s, fmt.Errorf("after reconcile: %w", err)
|
|
}
|
|
|
|
if !s.replica {
|
|
// The encode committed: shards are the volume. Dedup cleans surplus.
|
|
dedupSurplus(&s)
|
|
if !isCleanEc(s) {
|
|
return s, fmt.Errorf("no replica and not clean EC")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Replica survives: re-run the encode from scratch (sweep + regenerate),
|
|
// then the volume is EC; this is the restart path every interrupted
|
|
// encode and every leftover-orphan state takes.
|
|
if !runToCompletion(&s, encodeSteps()) {
|
|
return s, fmt.Errorf("recovery encode did not complete")
|
|
}
|
|
dedupSurplus(&s)
|
|
if err := checkInvariants(s); err != nil {
|
|
return s, fmt.Errorf("after recovery encode: %w", err)
|
|
}
|
|
if !isCleanEc(s) {
|
|
return s, fmt.Errorf("recovery encode did not converge to clean EC")
|
|
}
|
|
// And a decode must be able to bring it back to a regular volume.
|
|
if !runToCompletion(&s, decodeSteps()) {
|
|
return s, fmt.Errorf("decode after recovery did not complete")
|
|
}
|
|
if err := checkInvariants(s); err != nil {
|
|
return s, fmt.Errorf("after decode: %w", err)
|
|
}
|
|
if !s.replica || len(s.gens) != 0 {
|
|
return s, fmt.Errorf("decode after recovery did not converge to a bare regular volume")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// ── exhaustive exploration ──────────────────────────────────────────────────
|
|
|
|
type trace []string
|
|
|
|
// explore runs every schedule of one operation from state s: after every
|
|
// step, the schedule may continue, crash (kill: stop, nothing else runs), or
|
|
// error out (rollback runs, itself crashable at every step). Every resulting
|
|
// quiescent state is handed to onQuiescent together with the trace that
|
|
// produced it.
|
|
func exploreOp(t *testing.T, s modelState, opName string, steps []modelStep, commitIndex int, withRollback bool, tr trace, onQuiescent func(modelState, trace)) {
|
|
t.Helper()
|
|
// Crash before the first step is the same as never starting.
|
|
for prefix := 1; prefix <= len(steps); prefix++ {
|
|
run := s.clone()
|
|
ok := true
|
|
for i := 0; i < prefix; i++ {
|
|
if !steps[i].apply(&run) {
|
|
ok = false
|
|
break
|
|
}
|
|
if err := checkInvariants(run); err != nil {
|
|
t.Fatalf("%s after %s: %v\ntrace: %v", opName, steps[i].name, err, append(tr, opName+"/"+steps[i].name))
|
|
}
|
|
}
|
|
if !ok {
|
|
continue // precondition stopped the op; same as an early error
|
|
}
|
|
stepTrace := append(append(trace{}, tr...), fmt.Sprintf("%s..%s", opName, steps[prefix-1].name))
|
|
|
|
// Kill here: nothing else runs.
|
|
onQuiescent(run.clone(), append(append(trace{}, stepTrace...), "KILL"))
|
|
|
|
// Error return here: the rollback runs, unless the op has committed;
|
|
// the rollback itself can be killed at each of its step boundaries.
|
|
if withRollback && prefix <= commitIndex {
|
|
rb := rollbackSteps()
|
|
for rbPrefix := 0; rbPrefix <= len(rb); rbPrefix++ {
|
|
rbRun := run.clone()
|
|
for i := 0; i < rbPrefix; i++ {
|
|
rb[i].apply(&rbRun)
|
|
if err := checkInvariants(rbRun); err != nil {
|
|
t.Fatalf("%s rollback after %s: %v\ntrace: %v", opName, rb[i].name, err, stepTrace)
|
|
}
|
|
}
|
|
suffix := "FAIL+rollback-killed"
|
|
if rbPrefix == len(rb) {
|
|
suffix = "FAIL+rollback-complete"
|
|
}
|
|
onQuiescent(rbRun.clone(), append(append(trace{}, stepTrace...), fmt.Sprintf("%s@%d", suffix, rbPrefix)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestECLifecycleModelExhaustive enumerates every crash and failure schedule
|
|
// of encode and decode up to two chained interrupted operations, checks the
|
|
// safety invariants in every intermediate state, and requires the prescribed
|
|
// recovery to converge from every quiescent state.
|
|
func TestECLifecycleModelExhaustive(t *testing.T) {
|
|
start := modelState{replica: true}
|
|
|
|
visited := map[string]trace{}
|
|
quiescent := 0
|
|
schedules := 0
|
|
var enqueue func(s modelState, depth int, tr trace)
|
|
|
|
checkRecovery := func(s modelState, tr trace) {
|
|
schedules++
|
|
if _, seen := visited["Q"+s.key()]; seen {
|
|
return
|
|
}
|
|
visited["Q"+s.key()] = tr
|
|
quiescent++
|
|
if _, err := recoverModel(s.clone()); err != nil {
|
|
t.Fatalf("recovery does not converge: %v\nstate: %s\ntrace: %v", err, s.key(), tr)
|
|
}
|
|
}
|
|
|
|
enqueue = func(s modelState, depth int, tr trace) {
|
|
checkRecovery(s, tr)
|
|
if depth == 0 {
|
|
return
|
|
}
|
|
// A crash-restart may happen in any quiescent state before the next
|
|
// operation; explore both with and without it.
|
|
restarted := s.clone()
|
|
reconcile(&restarted)
|
|
if err := checkInvariants(restarted); err != nil {
|
|
t.Fatalf("reconcile violated invariants: %v\nstate: %s\ntrace: %v", err, s.key(), tr)
|
|
}
|
|
for _, variant := range []struct {
|
|
st modelState
|
|
tag string
|
|
}{{s, ""}, {restarted, "+restart"}} {
|
|
st, tag := variant.st, variant.tag
|
|
if st.replica {
|
|
exploreOp(t, st, "encode"+tag, encodeSteps(), encodeCommitIndex(), true, tr, func(q modelState, qtr trace) {
|
|
enqueue(q, depth-1, qtr)
|
|
})
|
|
}
|
|
canDecode := false
|
|
for _, g := range st.gens {
|
|
if g.mounted && g.files >= modelDataShards {
|
|
canDecode = true
|
|
}
|
|
}
|
|
if canDecode {
|
|
exploreOp(t, st, "decode"+tag, decodeSteps(), len(decodeSteps()), false, tr, func(q modelState, qtr trace) {
|
|
enqueue(q, depth-1, qtr)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Depth 2: an interrupted operation followed by another interrupted
|
|
// operation, recovery checked at every quiescent point in between and
|
|
// after. Deeper chains only revisit already-explored states (the state
|
|
// space is finite and memoized), which the visited count makes apparent.
|
|
enqueue(start, 2, trace{"start"})
|
|
|
|
// Not spec constants — collapse guards: if a refactor of the model or the
|
|
// explorer accidentally prunes schedules, these counts crater and this
|
|
// catches it. Update deliberately when the model changes fidelity.
|
|
if quiescent < 15 || schedules < 500 {
|
|
t.Fatalf("suspiciously small exploration: %d distinct quiescent states from %d schedules", quiescent, schedules)
|
|
}
|
|
keys := make([]string, 0, len(visited))
|
|
for k := range visited {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
t.Logf("explored %d schedules reaching %d distinct quiescent states, all recoverable", schedules, quiescent)
|
|
}
|