Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions cmd/benchmarkoor/db.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
40 changes: 40 additions & 0 deletions cmd/benchmarkoor/db_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 95 additions & 0 deletions pkg/runner/db_compaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 empty --datadir silently compacts the working directory

--datadir= still sets the flag, so Cobra's MarkFlagRequired("datadir") passes, and filepath.Abs("") returns the process working directory; CompactDatadir then bind-mounts that directory at /data and runs the client's compaction against it. Reject an empty DataDir before Abs — the run path always supplies a non-empty mount source, so only the CLI can hit this.

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
}
113 changes: 113 additions & 0 deletions pkg/runner/db_compaction_cli_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading