Files
seaweedfs/weed/storage/blockvol/testrunner/regression.go
T
Ping QiuandClaude Opus 4.6 da1b81d1c9 feat: CP8-3-1 durability modes + testrunner platform + 21 adversarial tests
Durability mode implementation (sync_all, sync_quorum, best_effort):
- DurabilityMode type with superblock persistence, parse/validate/string
- MakeDistributedSync mode-aware barrier enforcement in dist_group_commit
- blockerr sentinel package (ErrDurabilityBarrierFailed, ErrDurabilityQuorumLost)
- gRPC create path: mode validation, idempotent create consistency, partial cleanup
- F1: strict mode rejects partial replica provisioning with cleanup
- F3: empty heartbeat does not overwrite persisted strict mode
- F4: SCSI error mapping uses errors.Is sentinels (not string matching)
- Proto/wire/blockapi/CLI/UI plumbing for durability_mode field
- Observability dashboard: cluster health cards + per-volume columns

Testrunner platform (YAML-driven integration test framework):
- Engine, parser, registry, reporter (JUnit XML + HTML), metrics scraping
- 52 registered actions: block, iSCSI, I/O, fault injection, assertions
- Baseline regression framework with 7 hard-fail conditions
- 15 YAML scenarios (smoke, crash, HA, fault, consistency, snapshot)
- 49 unit tests for testrunner internals

QA adversarial suite (21 tests, all PASS):
- Idempotent create mode/RF mismatch detection
- Heartbeat mode downgrade prevention (F3)
- sync_all/sync_quorum partial replica enforcement (F1)
- Concurrent create race safety
- Failover/expand mode preservation
- Cleanup resilience when delete fails
- Master restart auto-register mode handling
- Superblock roundtrip all 3 modes
- Validate edge cases (mode×RF matrix)
- RequiredReplicas quorum math verification
- Sentinel error categorization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 01:06:51 -08:00

201 lines
5.7 KiB
Go

package testrunner
import (
"fmt"
"math"
"strings"
)
// RegressionResult holds the comparison of one metric against baseline.
type RegressionResult struct {
Metric string
BaselineVal float64
CurrentVal float64
ThresholdPct float64 // positive = allowed increase, negative = allowed decrease
Pass bool
Reason string
}
// RegressionReport is the full regression comparison output.
type RegressionReport struct {
Results []RegressionResult
HardFails []HardFailResult
OverallPass bool
BaselineGitSHA string
CurrentGitSHA string
}
// HardFailResult is the result of a hard fail condition check.
type HardFailResult struct {
Condition string
Pass bool
Detail string
}
// RegressionThresholds defines the allowed deviation per metric.
var RegressionThresholds = map[string]float64{
"p99_write_latency_seconds": 0.10, // fail if >10% increase
"p99_read_latency_seconds": 0.10, // fail if >10% increase
"write_iops": -0.05, // fail if >5% decrease
"read_iops": -0.05, // fail if >5% decrease
}
// CompareBaseline runs regression checks against a baseline.
func CompareBaseline(baseline, current map[string]float64) []RegressionResult {
var results []RegressionResult
for metric, threshold := range RegressionThresholds {
bv, bOk := baseline[metric]
cv, cOk := current[metric]
if !bOk || !cOk {
continue
}
if bv == 0 {
results = append(results, RegressionResult{
Metric: metric, BaselineVal: bv, CurrentVal: cv,
ThresholdPct: threshold, Pass: true, Reason: "baseline is 0, skipped",
})
continue
}
r := RegressionResult{
Metric: metric, BaselineVal: bv, CurrentVal: cv,
ThresholdPct: threshold,
}
pctChange := (cv - bv) / math.Abs(bv)
if threshold >= 0 {
// Positive threshold: fail if increase exceeds threshold.
r.Pass = pctChange <= threshold
r.Reason = fmt.Sprintf("change=%.1f%% (limit=+%.1f%%)", pctChange*100, threshold*100)
} else {
// Negative threshold: fail if decrease exceeds |threshold|.
r.Pass = pctChange >= threshold
r.Reason = fmt.Sprintf("change=%.1f%% (limit=%.1f%%)", pctChange*100, threshold*100)
}
results = append(results, r)
}
return results
}
// HardFailChecks are immediate test failure conditions (no comparison needed).
// Each check takes the current metrics map and returns pass/fail.
var HardFailChecks = []struct {
Name string
Check func(m map[string]float64) (bool, string)
}{
{
Name: "data_mismatch",
Check: func(m map[string]float64) (bool, string) {
if v, ok := m["data_mismatch_count"]; ok && v > 0 {
return false, fmt.Sprintf("%.0f data mismatches detected", v)
}
return true, ""
},
},
{
Name: "promotion_panic",
Check: func(m map[string]float64) (bool, string) {
if v, ok := m["promotion_panic_count"]; ok && v > 0 {
return false, fmt.Sprintf("%.0f promotion panics", v)
}
return true, ""
},
},
{
Name: "scrub_false_positive_burst",
Check: func(m map[string]float64) (bool, string) {
if v, ok := m["scrub_false_positives_per_pass"]; ok && v > 3 {
return false, fmt.Sprintf("%.0f false positives in one scrub pass (limit=3)", v)
}
return true, ""
},
},
{
Name: "barrier_lag_lsn_unbounded",
Check: func(m map[string]float64) (bool, string) {
if v, ok := m["barrier_lag_lsn_max"]; ok && v > 1000 {
return false, fmt.Sprintf("barrier_lag_lsn=%.0f (limit=1000)", v)
}
return true, ""
},
},
{
Name: "barrier_error_rate",
Check: func(m map[string]float64) (bool, string) {
total, tOk := m["barrier_requests_total"]
failed, fOk := m["barrier_failures_total"]
if tOk && fOk && total > 0 && (failed/total) > 0.05 {
return false, fmt.Sprintf("barrier error rate=%.1f%% (limit=5%%)", (failed/total)*100)
}
return true, ""
},
},
{
Name: "health_zero_without_fault",
Check: func(m map[string]float64) (bool, string) {
health, hOk := m["health_score"]
faultActive, fOk := m["fault_active"]
if hOk && health == 0.0 && (!fOk || faultActive == 0) {
return false, "health score dropped to 0.0 without injected fault"
}
return true, ""
},
},
{
Name: "wal_full_stall",
Check: func(m map[string]float64) (bool, string) {
if v, ok := m["wal_full_duration_seconds"]; ok && v > 10 {
return false, fmt.Sprintf("WAL full for %.1fs (limit=10s)", v)
}
return true, ""
},
},
}
// RunHardFailChecks evaluates all hard fail conditions against current metrics.
func RunHardFailChecks(metrics map[string]float64) []HardFailResult {
results := make([]HardFailResult, 0, len(HardFailChecks))
for _, hf := range HardFailChecks {
pass, detail := hf.Check(metrics)
results = append(results, HardFailResult{
Condition: hf.Name,
Pass: pass,
Detail: detail,
})
}
return results
}
// FormatRegressionReport produces a human-readable report string.
func FormatRegressionReport(r *RegressionReport) string {
var sb strings.Builder
sb.WriteString("=== SLO Regression Report ===\n")
sb.WriteString(fmt.Sprintf("Baseline: %s Current: %s\n\n", r.BaselineGitSHA, r.CurrentGitSHA))
sb.WriteString("--- Metric Comparisons ---\n")
for _, res := range r.Results {
status := "PASS"
if !res.Pass {
status = "FAIL"
}
sb.WriteString(fmt.Sprintf(" [%s] %s: baseline=%.4f current=%.4f %s\n",
status, res.Metric, res.BaselineVal, res.CurrentVal, res.Reason))
}
sb.WriteString("\n--- Hard Fail Conditions ---\n")
for _, hf := range r.HardFails {
status := "PASS"
if !hf.Pass {
status = "FAIL"
}
detail := hf.Detail
if detail == "" {
detail = "ok"
}
sb.WriteString(fmt.Sprintf(" [%s] %s: %s\n", status, hf.Condition, detail))
}
sb.WriteString(fmt.Sprintf("\nOverall: %s\n", map[bool]string{true: "PASS", false: "FAIL"}[r.OverallPass]))
return sb.String()
}