mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 21:37:50 +08:00
343 lines
9.2 KiB
Go
343 lines
9.2 KiB
Go
package resticbench
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Agent executes the benchmark phases on the management host and streams
|
|
// Events back to the orchestrator.
|
|
type Agent struct {
|
|
cfg Config
|
|
emit func(Event)
|
|
}
|
|
|
|
func RunAgent(ctx context.Context, cfg Config, emit func(Event)) error {
|
|
if len(cfg.Connections) == 0 {
|
|
cfg.Connections = []int{5}
|
|
}
|
|
if len(cfg.Phases) == 0 {
|
|
cfg.Phases = DefaultPhases
|
|
}
|
|
if cfg.ResticBin == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.ResticBin = filepath.Join(home, remoteDir, "restic")
|
|
}
|
|
if err := os.MkdirAll(cfg.Workdir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
a := &Agent{cfg: cfg, emit: emit}
|
|
r := &Restic{
|
|
Bin: cfg.ResticBin,
|
|
Repo: cfg.Repo,
|
|
Password: cfg.Password,
|
|
CacheDir: filepath.Join(cfg.Workdir, "cache"),
|
|
}
|
|
|
|
version, err := r.Version(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := a.preflight(); err != nil {
|
|
return err
|
|
}
|
|
|
|
host, _ := os.Hostname()
|
|
result := &RunResult{
|
|
Label: cfg.Label,
|
|
Host: host,
|
|
RepoHost: scrubRepo(cfg.Repo),
|
|
ResticVersion: version,
|
|
Created: time.Now().UTC(),
|
|
Config: publicConfig(cfg),
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseBackup) {
|
|
created, err := r.EnsureInit(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !created {
|
|
if ids, err := r.SnapshotIDs(ctx, ""); err == nil && len(ids) > 0 {
|
|
a.emit(Event{Type: "warning", Message: fmt.Sprintf(
|
|
"repository already holds %d snapshots; dedup against them can skew write results", len(ids))})
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, conns := range cfg.Connections {
|
|
cell, err := a.runCell(ctx, r, conns)
|
|
if err != nil {
|
|
return fmt.Errorf("cell connections=%d: %w", conns, err)
|
|
}
|
|
result.Cells = append(result.Cells, *cell)
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseCleanup) {
|
|
pr, err := a.cleanupPhase(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.Cleanup = pr
|
|
}
|
|
|
|
a.emit(Event{Type: "result", Result: result})
|
|
return nil
|
|
}
|
|
|
|
// preflight fails fast when the workdir volume clearly cannot hold the dataset.
|
|
func (a *Agent) preflight() error {
|
|
if !a.cfg.HasPhase(PhaseGenerate) {
|
|
return nil
|
|
}
|
|
var st syscall.Statfs_t
|
|
if err := syscall.Statfs(a.cfg.Workdir, &st); err != nil {
|
|
return nil
|
|
}
|
|
free := int64(st.Bavail) * int64(st.Bsize) //nolint:unconvert // types differ per GOOS
|
|
need := a.cfg.Size + a.cfg.Size/5
|
|
if a.cfg.KeepData && a.cfg.HasPhase(PhaseRestore) {
|
|
need = 2*a.cfg.Size + a.cfg.Size/5
|
|
}
|
|
if free < need {
|
|
return fmt.Errorf("workdir %s has %s free, need ~%s (size=%s); use a bigger volume or smaller --size",
|
|
a.cfg.Workdir, FormatBytes(free), FormatBytes(need), FormatBytes(a.cfg.Size))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *Agent) runCell(ctx context.Context, r *Restic, conns int) (*CellResult, error) {
|
|
cfg := a.cfg
|
|
cell := &CellResult{Connections: conns}
|
|
dataDir := filepath.Join(cfg.Workdir, "data")
|
|
restoreDir := filepath.Join(cfg.Workdir, "restore")
|
|
// Per-cell seed: each cell uploads unique content, otherwise cell N>1
|
|
// would dedup against cell 1's snapshots and measure nothing.
|
|
seed := cfg.Seed ^ (0x9e3779b97f4a7c15 * uint64(conns+1))
|
|
cellTag := fmt.Sprintf("%s-c%d", cfg.Tag, conns)
|
|
var manifest *Manifest
|
|
|
|
backupArgs := func() []string {
|
|
args := []string{
|
|
"backup", dataDir,
|
|
"--tag", cfg.Tag + "," + cellTag,
|
|
"-o", "rest.connections=" + strconv.Itoa(conns),
|
|
}
|
|
if cfg.Compression != "" {
|
|
args = append(args, "--compression", cfg.Compression)
|
|
}
|
|
if cfg.PackSizeMiB > 0 {
|
|
args = append(args, "--pack-size", strconv.Itoa(cfg.PackSizeMiB))
|
|
}
|
|
if cfg.ReadConcurrency > 0 {
|
|
args = append(args, "--read-concurrency", strconv.Itoa(cfg.ReadConcurrency))
|
|
}
|
|
return args
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseGenerate) {
|
|
pr, err := a.phase(PhaseGenerate, conns, func(pr *PhaseResult) error {
|
|
progress := a.progressFn(PhaseGenerate, conns, cfg.Size)
|
|
m, err := Generate(dataDir, cfg.Size, cfg.FileSize, seed, progress)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
manifest = m
|
|
pr.Bytes = cfg.Size
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cell.Phases = append(cell.Phases, *pr)
|
|
}
|
|
|
|
runBackup := func(name string) error {
|
|
pr, err := a.phase(name, conns, func(pr *PhaseResult) error {
|
|
summary, err := r.runJSON(ctx, a.statusFn(name, conns, "bytes_done", "total_bytes"), backupArgs()...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if summary != nil {
|
|
pr.Bytes = int64(num(summary, "total_bytes_processed"))
|
|
pr.BytesAdded = int64(num(summary, "data_added"))
|
|
pr.SnapshotID = str(summary, "snapshot_id")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Throughput = payload the wire saw: full size initially, delta after.
|
|
if pr.BytesAdded > 0 && pr.Seconds > 0 {
|
|
pr.Throughput = float64(pr.BytesAdded) / pr.Seconds
|
|
}
|
|
cell.Phases = append(cell.Phases, *pr)
|
|
return nil
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseBackup) {
|
|
if err := runBackup(PhaseBackup); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseIncremental) && cfg.Incrementals > 0 {
|
|
if manifest == nil {
|
|
return nil, fmt.Errorf("incremental phase needs the generate phase")
|
|
}
|
|
for i := 1; i <= cfg.Incrementals; i++ {
|
|
mutName := fmt.Sprintf("mutate-%d", i)
|
|
pr, err := a.phase(mutName, conns, func(pr *PhaseResult) error {
|
|
written, err := Mutate(dataDir, manifest, cfg.MutatePercent, nil)
|
|
pr.Bytes = written
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cell.Phases = append(cell.Phases, *pr)
|
|
if err := runBackup(fmt.Sprintf("incremental-%d", i)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseCheck) {
|
|
pr, err := a.phase(PhaseCheck, conns, func(pr *PhaseResult) error {
|
|
_, err := r.runJSON(ctx, nil,
|
|
"check", "-o", "rest.connections="+strconv.Itoa(conns))
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cell.Phases = append(cell.Phases, *pr)
|
|
}
|
|
|
|
if cfg.HasPhase(PhaseRestore) {
|
|
if !cfg.KeepData {
|
|
// Reclaim the dataset's space before restoring; not timed.
|
|
if err := os.RemoveAll(dataDir); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := os.RemoveAll(restoreDir); err != nil {
|
|
return nil, err
|
|
}
|
|
pr, err := a.phase(PhaseRestore, conns, func(pr *PhaseResult) error {
|
|
summary, err := r.runJSON(ctx, a.statusFn(PhaseRestore, conns, "bytes_restored", "total_bytes"),
|
|
"restore", "latest", "--tag", cellTag,
|
|
"--target", restoreDir,
|
|
"-o", "rest.connections="+strconv.Itoa(conns))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if summary != nil {
|
|
pr.Bytes = int64(num(summary, "bytes_restored"))
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cell.Phases = append(cell.Phases, *pr)
|
|
if !cfg.KeepData {
|
|
if err := os.RemoveAll(restoreDir); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return cell, nil
|
|
}
|
|
|
|
// cleanupPhase forgets every snapshot this tool ever tagged and prunes — a
|
|
// heavy listing+delete workload, timed as its own result.
|
|
func (a *Agent) cleanupPhase(ctx context.Context, r *Restic) (*PhaseResult, error) {
|
|
ids, err := r.SnapshotIDs(ctx, a.cfg.Tag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(ids) == 0 {
|
|
a.emit(Event{Type: "warning", Message: "cleanup: no snapshots tagged " + a.cfg.Tag})
|
|
return nil, nil
|
|
}
|
|
return a.phase(PhaseCleanup, 0, func(pr *PhaseResult) error {
|
|
if _, err := r.runJSON(ctx, nil, append([]string{"forget"}, ids...)...); err != nil {
|
|
return err
|
|
}
|
|
_, err := r.runJSON(ctx, nil, "prune")
|
|
return err
|
|
})
|
|
}
|
|
|
|
// phase wraps fn with wall-clock timing and start/done events. fn fills the
|
|
// PhaseResult's byte counts; Seconds and default Throughput are set here.
|
|
func (a *Agent) phase(name string, conns int, fn func(*PhaseResult) error) (*PhaseResult, error) {
|
|
a.emit(Event{Type: "phase_start", Phase: name, Connections: conns})
|
|
pr := &PhaseResult{Name: name}
|
|
start := time.Now()
|
|
if err := fn(pr); err != nil {
|
|
return nil, err
|
|
}
|
|
pr.Seconds = time.Since(start).Seconds()
|
|
if pr.Throughput == 0 && pr.Bytes > 0 && pr.Seconds > 0 {
|
|
pr.Throughput = float64(pr.Bytes) / pr.Seconds
|
|
}
|
|
a.emit(Event{Type: "phase_done", Phase: name, Connections: conns, PhaseResult: pr})
|
|
return pr, nil
|
|
}
|
|
|
|
// progressFn returns a delta-accumulating, 10s-throttled progress emitter for
|
|
// locally generated work.
|
|
func (a *Agent) progressFn(phase string, conns int, total int64) func(delta int64) {
|
|
var done atomic.Int64
|
|
var last atomic.Int64
|
|
start := time.Now()
|
|
return func(delta int64) {
|
|
d := done.Add(delta)
|
|
now := time.Now().UnixNano()
|
|
prev := last.Load()
|
|
if now-prev < 10*int64(time.Second) || !last.CompareAndSwap(prev, now) {
|
|
return
|
|
}
|
|
elapsed := time.Since(start).Seconds()
|
|
a.emit(Event{Type: "progress", Phase: phase, Connections: conns, Done: d, Total: total,
|
|
BPS: float64(d) / elapsed})
|
|
}
|
|
}
|
|
|
|
// statusFn adapts restic --json status messages into throttled progress events.
|
|
func (a *Agent) statusFn(phase string, conns int, doneKey, totalKey string) func(map[string]any) {
|
|
var last time.Time
|
|
start := time.Now()
|
|
return func(m map[string]any) {
|
|
if m["message_type"] != "status" {
|
|
return
|
|
}
|
|
if time.Since(last) < 10*time.Second {
|
|
return
|
|
}
|
|
last = time.Now()
|
|
done := int64(num(m, doneKey))
|
|
elapsed := time.Since(start).Seconds()
|
|
var bps float64
|
|
if elapsed > 0 {
|
|
bps = float64(done) / elapsed
|
|
}
|
|
a.emit(Event{Type: "progress", Phase: phase, Connections: conns,
|
|
Done: done, Total: int64(num(m, totalKey)), BPS: bps})
|
|
}
|
|
}
|