diff --git a/cmd/benchmarkoor/db.go b/cmd/benchmarkoor/db.go new file mode 100644 index 000000000..a98d1efb4 --- /dev/null +++ b/cmd/benchmarkoor/db.go @@ -0,0 +1,106 @@ +package main + +import ( + "fmt" + "os/signal" + "syscall" + "time" + + "github.com/ethpandaops/benchmarkoor/pkg/client" + "github.com/ethpandaops/benchmarkoor/pkg/config" + "github.com/ethpandaops/benchmarkoor/pkg/docker" + "github.com/ethpandaops/benchmarkoor/pkg/runner" + "github.com/spf13/cobra" +) + +var dbCompactFlags struct { + client string + datadir string + prepare []string + image string + extraArgs []string + timeout string + noInspect bool +} + +var dbCmd = &cobra.Command{ + Use: "db", + Short: "Offline client database maintenance", +} + +var dbCompactCmd = &cobra.Command{ + Use: "compact", + Short: "Compact a stopped client's datadir, as db_compaction does in a run", + Long: `Compact a stopped client's datadir using the client's own offline command. +Supported clients are geth and erigon. Use --image to select the same client +version that wrote the datadir. Other clients return an unsupported error.`, + Args: cobra.NoArgs, + RunE: runDBCompact, +} + +func init() { + rootCmd.AddCommand(dbCmd) + dbCmd.AddCommand(dbCompactCmd) + + f := dbCompactCmd.Flags() + f.StringVar(&dbCompactFlags.client, "client", "", "client that wrote the datadir (geth or erigon)") + f.StringVar(&dbCompactFlags.datadir, "datadir", "", "host path of the datadir; the client must be stopped") + f.StringArrayVar(&dbCompactFlags.prepare, "prepare", nil, "preparation step to run before the compaction (repeatable, e.g. erigon's seg-retire)") + f.StringVar(&dbCompactFlags.image, "image", "", "client image (default: the client's default image)") + f.StringArrayVar(&dbCompactFlags.extraArgs, "extra-arg", nil, "argument appended to the compaction command (repeatable)") + f.StringVar(&dbCompactFlags.timeout, "timeout", config.DefaultDBCompactionTimeout, "time limit for the whole compaction") + f.BoolVar(&dbCompactFlags.noInspect, "no-inspect", false, "skip the database inspection before and after") + + _ = dbCompactCmd.MarkFlagRequired("client") + _ = dbCompactCmd.MarkFlagRequired("datadir") +} + +func runDBCompact(cmd *cobra.Command, _ []string) error { + ct := client.ClientType(dbCompactFlags.client) + if !client.SupportsDBCompaction(ct) { + return fmt.Errorf("client %q has no offline database compaction", ct) + } + + if dbCompactFlags.datadir == "" { + return fmt.Errorf("--datadir must not be empty") + } + + timeout, err := time.ParseDuration(dbCompactFlags.timeout) + if err != nil { + return fmt.Errorf("--timeout: %w", err) + } + + if timeout <= 0 { + return fmt.Errorf("--timeout must be positive") + } + + inspect := !dbCompactFlags.noInspect + cfg := &config.DBCompactionConfig{ + Enabled: true, + Prepare: dbCompactFlags.prepare, + ExtraArgs: dbCompactFlags.extraArgs, + Timeout: dbCompactFlags.timeout, + Inspect: &inspect, + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + mgr, err := docker.NewManager(log) + if err != nil { + return err + } + + if err := mgr.Start(ctx); err != nil { + return err + } + + defer func() { _ = mgr.Stop() }() + + return runner.CompactDatadir(ctx, log, mgr, &runner.DatadirCompaction{ + Client: ct, + DataDir: dbCompactFlags.datadir, + Image: dbCompactFlags.image, + Cfg: cfg, + }) +} diff --git a/cmd/benchmarkoor/db_test.go b/cmd/benchmarkoor/db_test.go new file mode 100644 index 000000000..921e21280 --- /dev/null +++ b/cmd/benchmarkoor/db_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestDBCompactRejectsInvalidInputs(t *testing.T) { + original := dbCompactFlags + t.Cleanup(func() { dbCompactFlags = original }) + + // Invalid input must be rejected without a reachable container engine. + t.Setenv("DOCKER_HOST", "unix://"+filepath.Join(t.TempDir(), "missing.sock")) + + for _, tc := range []struct { + name string + datadir string + timeout string + want string + }{ + {"empty datadir", "", "1m", "--datadir must not be empty"}, + {"zero timeout", t.TempDir(), "0", "--timeout must be positive"}, + {"negative timeout", t.TempDir(), "-1s", "--timeout must be positive"}, + {"malformed timeout", t.TempDir(), "invalid", "--timeout:"}, + } { + t.Run(tc.name, func(t *testing.T) { + dbCompactFlags.client = "geth" + dbCompactFlags.datadir = tc.datadir + dbCompactFlags.timeout = tc.timeout + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + require.ErrorContains(t, runDBCompact(cmd, nil), tc.want) + }) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index b3014cfd5..eb48c4b1c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1148,6 +1148,18 @@ Only clients that ship an offline compaction command support this. Today that is > **On erigon, `before_benchmarks` needs `--exec.no-prune` on the instance.** That phase stops and restarts the client, and erigon refuses to reopen a datadir whose receipt domain it pruned past the snapshot files: `[snapshots] gap between snapshot files and DB for domain receipt: files end at txNum 0 but the DB was pruned up to 390625`. A synthetic snapshot has no snapshot files, so the client's own pruning is enough to create the gap. `--exec.no-prune` disables the state-aggregator pruning that advances the marker. Erigon documents the flag for diagnostic and perf-comparison use, which is what a benchmark is, and it removes housekeeping the benchmark does not want anyway. `before_pre_runs` needs no flag: it compacts before the client ever boots, so nothing restarts. +###### Outside a run: `benchmarkoor db compact` + +Run the same client-provided maintenance commands against a stopped host datadir without a benchmark config: + +```bash +benchmarkoor db compact --client geth --datadir /path/to/datadir --image ethereum/client-go:v1.17.7 +``` + +Only Geth and Erigon are supported. Other clients return an error; benchmarkoor does not use generic database tools or replace database files itself. Select the image version that wrote the datadir with `--image`; the default is the client's default image. The client must be stopped before running this command. + +`--prepare`, `--extra-arg`, `--timeout` and `--no-inspect` use the same settings as a run's compaction phase. Container output goes to stdout. Preparation or compaction failures return a non-zero exit status; inspection failures remain warnings, as in a run. No run marker or report is written. + ###### Preparation steps (`prepare`) A client can offer steps that run before the compaction to make it reclaim more. None run unless `prepare` names them, because a step that helps one datadir can ruin another. diff --git a/pkg/runner/db_compaction.go b/pkg/runner/db_compaction.go index a2a06f487..9914f16a8 100644 --- a/pkg/runner/db_compaction.go +++ b/pkg/runner/db_compaction.go @@ -950,3 +950,98 @@ func datadirMountFor( return docker.Mount{}, false } + +// DatadirCompaction is one compaction of a stopped client's host datadir +// outside a run, as `benchmarkoor db compact` does it. +type DatadirCompaction struct { + Client client.ClientType + DataDir string + + // Image is the client image; empty uses the client's default. + Image string + + // Cfg carries the db_compaction settings that apply outside a run: + // prepare, extra_args, image, inspect and timeout. + Cfg *config.DBCompactionConfig +} + +// CompactDatadir runs what a db_compaction phase runs — the inspections, the +// preparation steps and the compaction — against a host datadir, using the +// client image. Container output goes to stdout. No marker or report is +// written: those belong to a run. +func CompactDatadir( + ctx context.Context, log *logrus.Logger, mgr docker.ContainerManager, c *DatadirCompaction, +) error { + spec, err := client.NewRegistry().Get(c.Client) + if err != nil { + return err + } + + cmds := spec.DBMaintenanceCommands(spec.DataDir()) + if cmds == nil || len(cmds.Compact) == 0 { + return fmt.Errorf("client %s has no database compaction command", c.Client) + } + + steps, err := dbCompactionSelectedSteps(cmds, c.Cfg) + if err != nil { + return err + } + + if c.DataDir == "" { + return fmt.Errorf("datadir must not be empty") + } + + dataDir, err := filepath.Abs(c.DataDir) + if err != nil { + return err + } + + if info, err := os.Stat(dataDir); err != nil { + return err + } else if !info.IsDir() { + return fmt.Errorf("%s is not a directory", dataDir) + } + + image := c.Image + if image == "" { + image = spec.DefaultImage() + } + + logDir, err := os.MkdirTemp("", "benchmarkoor-db-compact-") + if err != nil { + return err + } + + defer func() { _ = os.RemoveAll(logDir) }() + + r := &runner{ + logger: log, + log: log.WithField("component", "db-compact"), + cfg: &Config{ClientLogsToStdout: true}, + containerMgr: mgr, + } + + req := &dbCompactionRequest{ + Instance: &config.ClientInstance{ID: string(c.Client), Client: string(c.Client)}, + Spec: spec, + Cfg: c.Cfg, + Phase: "cli", + ImageName: image, + RunID: time.Now().UTC().Format("20060102T150405Z"), + Mount: docker.Mount{Type: "bind", Source: dataDir, Target: spec.DataDir()}, + } + + if err := mgr.PullImage(ctx, req.image(), "if-not-present"); err != nil { + return fmt.Errorf("pulling client image: %w", err) + } + + started := time.Now() + + if err := r.runDBCompactionContainers(ctx, req, cmds, steps, logDir, r.log); err != nil { + return err + } + + r.log.WithField("duration", time.Since(started).Round(time.Second)).Info("Database compaction completed") + + return nil +} diff --git a/pkg/runner/db_compaction_cli_test.go b/pkg/runner/db_compaction_cli_test.go new file mode 100644 index 000000000..aa68b7784 --- /dev/null +++ b/pkg/runner/db_compaction_cli_test.go @@ -0,0 +1,113 @@ +package runner + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/ethpandaops/benchmarkoor/pkg/client" + "github.com/ethpandaops/benchmarkoor/pkg/config" + "github.com/ethpandaops/benchmarkoor/pkg/docker" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type cliCompactionMgr struct { + fakeDBMaintenanceMgr + pulled []string + specs []*docker.ContainerSpec + pullErr error +} + +func (m *cliCompactionMgr) PullImage(_ context.Context, image, _ string) error { + m.pulled = append(m.pulled, image) + + return m.pullErr +} + +func (m *cliCompactionMgr) RunInitContainer( + ctx context.Context, spec *docker.ContainerSpec, stdout, stderr io.Writer, +) error { + m.specs = append(m.specs, spec) + + return m.fakeDBMaintenanceMgr.RunInitContainer(ctx, spec, stdout, stderr) +} + +func TestCompactDatadir_ClientCommands(t *testing.T) { + for _, ct := range []client.ClientType{client.ClientGeth, client.ClientErigon} { + t.Run(string(ct), func(t *testing.T) { + dir := t.TempDir() + mgr := &cliCompactionMgr{} + cfg := &config.DBCompactionConfig{ExtraArgs: []string{"--example-option"}} + require.NoError(t, CompactDatadir(context.Background(), logrus.New(), mgr, &DatadirCompaction{ + Client: ct, DataDir: dir, Image: "client:pinned", Cfg: cfg, + })) + assert.Equal(t, []string{"client:pinned"}, mgr.pulled) + require.Len(t, mgr.specs, 3) + + for _, spec := range mgr.specs { + assert.Equal(t, "client:pinned", spec.Image) + assert.Empty(t, spec.Entrypoint, "use the client image's entrypoint") + require.Len(t, spec.Mounts, 1) + assert.Equal(t, docker.Mount{Type: "bind", Source: dir, Target: "/data"}, spec.Mounts[0]) + } + + assert.Equal(t, []string{"db", "compact", "--datadir=/data", "--example-option"}, mgr.specs[1].Command) + assert.NotContains(t, mgr.specs[0].Command, "--example-option") + assert.NotContains(t, mgr.specs[2].Command, "--example-option") + }) + } +} + +func TestCompactDatadir_RejectsUnsupportedClients(t *testing.T) { + for _, ct := range []client.ClientType{client.ClientBesu, client.ClientNethermind, client.ClientReth, client.ClientEthrex} { + t.Run(string(ct), func(t *testing.T) { + mgr := &cliCompactionMgr{} + err := CompactDatadir(context.Background(), logrus.New(), mgr, &DatadirCompaction{Client: ct}) + require.ErrorContains(t, err, "no database compaction command") + assert.Empty(t, mgr.pulled) + assert.Empty(t, mgr.specs) + }) + } +} + +func TestCompactDatadir_Failures(t *testing.T) { + for _, stage := range []string{"pull", "compact"} { + t.Run(stage, func(t *testing.T) { + mgr := &cliCompactionMgr{} + if stage == "pull" { + mgr.pullErr = fmt.Errorf("image unavailable") + } else { + mgr.failCommand = "db" + } + + inspect := false + err := CompactDatadir(context.Background(), logrus.New(), mgr, &DatadirCompaction{ + Client: client.ClientGeth, DataDir: t.TempDir(), Image: "client:pinned", + Cfg: &config.DBCompactionConfig{Inspect: &inspect}, + }) + require.Error(t, err) + + if stage == "pull" { + require.ErrorContains(t, err, "image unavailable") + assert.Empty(t, mgr.specs) + } else { + require.ErrorContains(t, err, "exit status 1") + require.Len(t, mgr.specs, 1) + } + }) + } +} + +func TestCompactDatadir_RejectsEmptyDatadir(t *testing.T) { + mgr := &cliCompactionMgr{} + err := CompactDatadir(context.Background(), logrus.New(), mgr, &DatadirCompaction{ + Client: client.ClientGeth, Image: "client:pinned", + Cfg: &config.DBCompactionConfig{}, + }) + require.ErrorContains(t, err, "datadir must not be empty") + assert.Empty(t, mgr.pulled) + assert.Empty(t, mgr.specs) +}