Files
seaweedfs/weed/ec/ec_lifecycle_model_test.go
Chris LuandGitHub 1c926e8fac test: systematic EC interruption verification — exhaustive model check + deterministic kill matrix (#10764)
* 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.
2026-08-14 17:45:11 -07:00

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)
}