Skip to content
Open
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
2 changes: 2 additions & 0 deletions pkg/store/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ The `store` package provides a persistent storage solution for Evolve, designed

The storage system consists of a key-value store interface that allows for the persistence of blockchain data. It leverages the IPFS Datastore interface (`go-datastore`) with a Badger database implementation by default.

Badger options are tuned for the ev-node write pattern (append-heavy with periodic overwrites) via `store.BadgerOptions()`. Use `tools/db-bench` to validate performance against Badger defaults.

## Core Components

### Storage Interface
Expand Down
37 changes: 37 additions & 0 deletions pkg/store/badger_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package store

import (
"runtime"

badger4 "github.com/ipfs/go-ds-badger4"
)

// BadgerOptions returns ev-node tuned Badger options for the node workload.
// These defaults favor write throughput for append-heavy usage.
func BadgerOptions() *badger4.Options {
opts := badger4.DefaultOptions

// Disable conflict detection to reduce write overhead; ev-node does not rely
// on Badger's multi-writer conflict checks for correctness.
opts.Options = opts.Options.WithDetectConflicts(false)

Check failure on line 16 in pkg/store/badger_options.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint

QF1008: could remove embedded field "Options" from selector (staticcheck)
// Allow more L0 tables before compaction kicks in to smooth bursty ingest.
opts.Options = opts.Options.WithNumLevelZeroTables(10)

Check failure on line 18 in pkg/store/badger_options.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint

QF1008: could remove embedded field "Options" from selector (staticcheck)
// Stall threshold is raised to avoid write throttling under heavy load.
opts.Options = opts.Options.WithNumLevelZeroTablesStall(20)

Check failure on line 20 in pkg/store/badger_options.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint

QF1008: could remove embedded field "Options" from selector (staticcheck)
// Scale compaction workers to available CPUs without over-saturating.
opts.Options = opts.Options.WithNumCompactors(compactorCount())

return &opts
}

func compactorCount() int {
// Badger defaults to 4; keep a modest range to avoid compaction thrash.
count := runtime.NumCPU()
if count < 4 {
return 4
}
if count > 8 {
return 8
}
return count
}
7 changes: 3 additions & 4 deletions pkg/store/kv.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const EvPrefix = "0"
// NewDefaultKVStore creates instance of default key-value store.
func NewDefaultKVStore(rootDir, dbPath, dbName string) (ds.Batching, error) {
path := filepath.Join(rootify(rootDir, dbPath), dbName)
return badger4.NewDatastore(path, nil)
return badger4.NewDatastore(path, BadgerOptions())
}

// NewPrefixKVStore creates a new key-value store with a prefix applied to all keys.
Expand Down Expand Up @@ -56,8 +56,7 @@ func rootify(rootDir, dbPath string) string {

// NewTestInMemoryKVStore builds KVStore that works in-memory (without accessing disk).
func NewTestInMemoryKVStore() (ds.Batching, error) {
inMemoryOptions := &badger4.Options{
Options: badger4.DefaultOptions.WithInMemory(true),
}
inMemoryOptions := BadgerOptions()
inMemoryOptions.Options = inMemoryOptions.Options.WithInMemory(true)
return badger4.NewDatastore("", inMemoryOptions)
}
22 changes: 22 additions & 0 deletions tools/db-bench/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# db-bench

Local BadgerDB benchmark for ev-node write patterns (append + overwrite).

## Usage

Run the tuned defaults:

```sh
go run ./tools/db-bench -bytes 536870912 -value-size 4096 -batch-size 1000 -overwrite-ratio 0.1
```

Compare against Badger defaults:

```sh
go run ./tools/db-bench -profile all -bytes 1073741824 -value-size 4096 -batch-size 1000 -overwrite-ratio 0.1
```

Notes:

- `-bytes` is the total data volume; the tool rounds down to full `-value-size` writes.
- `-profile all` runs `evnode` and `default` in separate subdirectories under a temp base dir.
297 changes: 297 additions & 0 deletions tools/db-bench/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package main

import (
"context"
"flag"
"fmt"
"io/fs"
"math"
"math/rand"
"os"
"path/filepath"
"strconv"
"time"

ds "github.com/ipfs/go-datastore"
badger4 "github.com/ipfs/go-ds-badger4"

"github.com/evstack/ev-node/pkg/store"
)

type config struct {
baseDir string
reset bool
keepTemp bool
totalBytes int64
valueSize int
batchSize int
overwriteRatio float64
profile string
}

type profile struct {
name string
open func(path string) (ds.Batching, error)
}

type result struct {
profile string
writes int
bytes int64
duration time.Duration
mbPerSec float64
writesPerS float64
dbSize int64
}

func main() {
cfg := parseFlags()

profiles := []profile{
{name: "evnode", open: openEvnode},
{name: "default", open: openDefault},
}

baseDir, cleanup := ensureBaseDir(cfg.baseDir, cfg.keepTemp)
defer cleanup()

selected := selectProfiles(profiles, cfg.profile)
if len(selected) == 0 {
fmt.Fprintf(os.Stderr, "Unknown profile %q (valid: evnode, default, all)\n", cfg.profile)
os.Exit(1)
}

for _, p := range selected {
profileDir := filepath.Join(baseDir, p.name)
if cfg.reset {
_ = os.RemoveAll(profileDir)
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The error returned by os.RemoveAll is ignored. If removing the directory fails (e.g., due to permissions), the benchmark might run with old data or fail at a later stage, leading to confusing results. It would be better to handle this error, for instance by logging a warning to the user.

Suggested change
_ = os.RemoveAll(profileDir)
if err := os.RemoveAll(profileDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to remove profile directory %s: %v\n", profileDir, err)
}

}
if err := os.MkdirAll(profileDir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create db dir %s: %v\n", profileDir, err)
os.Exit(1)
}

res, err := runProfile(p, profileDir, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Profile %s failed: %v\n", p.name, err)
os.Exit(1)
}
printResult(res)
}
}

func parseFlags() config {
cfg := config{}
flag.StringVar(&cfg.baseDir, "dir", "", "DB base directory (default: temp dir)")
flag.BoolVar(&cfg.reset, "reset", false, "remove existing DB directory before running")
flag.BoolVar(&cfg.keepTemp, "keep", false, "keep temp directory (only used when -dir is empty)")
flag.Int64Var(&cfg.totalBytes, "bytes", 512<<20, "total bytes to write")
flag.IntVar(&cfg.valueSize, "value-size", 1024, "value size in bytes")
flag.IntVar(&cfg.batchSize, "batch-size", 1000, "writes per batch commit")
flag.Float64Var(&cfg.overwriteRatio, "overwrite-ratio", 0.1, "fraction of writes that overwrite existing keys (0..1)")
flag.StringVar(&cfg.profile, "profile", "evnode", "profile to run: evnode, default, all")
flag.Parse()

if cfg.totalBytes <= 0 {
exitError("bytes must be > 0")
}
if cfg.valueSize <= 0 {
exitError("value-size must be > 0")
}
if cfg.batchSize <= 0 {
exitError("batch-size must be > 0")
}
if cfg.overwriteRatio < 0 || cfg.overwriteRatio > 1 {
exitError("overwrite-ratio must be between 0 and 1")
}

return cfg
}

func runProfile(p profile, dir string, cfg config) (result, error) {
totalWrites := int(cfg.totalBytes / int64(cfg.valueSize))
if totalWrites == 0 {
return result{}, fmt.Errorf("total bytes (%d) smaller than value size (%d)", cfg.totalBytes, cfg.valueSize)
}
actualBytes := int64(totalWrites) * int64(cfg.valueSize)

rng := rand.New(rand.NewSource(1)) // Deterministic data for comparable runs.

Check failure on line 118 in tools/db-bench/main.go

View workflow job for this annotation

GitHub Actions / lint / golangci-lint

G404: Use of weak random number generator (math/rand or math/rand/v2 instead of crypto/rand) (gosec)
value := make([]byte, cfg.valueSize)
if _, err := rng.Read(value); err != nil {
return result{}, fmt.Errorf("failed to seed value bytes: %w", err)
}

overwriteEvery := 0
if cfg.overwriteRatio > 0 {
overwriteEvery = int(math.Round(1.0 / cfg.overwriteRatio))
if overwriteEvery < 1 {
overwriteEvery = 1
}
}

kv, err := p.open(dir)
if err != nil {
return result{}, fmt.Errorf("failed to open db: %w", err)
}
defer kv.Close()

ctx := context.Background()
start := time.Now()

batch, err := kv.Batch(ctx)
if err != nil {
return result{}, fmt.Errorf("failed to create batch: %w", err)
}

pending := 0
keysWritten := 0
for i := 0; i < totalWrites; i++ {
keyIndex := keysWritten
if overwriteEvery > 0 && i%overwriteEvery == 0 && keysWritten > 0 {
keyIndex = i % keysWritten
} else {
keysWritten++
}

if err := batch.Put(ctx, keyForIndex(keyIndex), value); err != nil {
_ = kv.Close()
return result{}, fmt.Errorf("batch put failed: %w", err)
}

pending++
if pending == cfg.batchSize {
if err := batch.Commit(ctx); err != nil {
_ = kv.Close()
return result{}, fmt.Errorf("batch commit failed: %w", err)
}
batch, err = kv.Batch(ctx)
if err != nil {
_ = kv.Close()
return result{}, fmt.Errorf("failed to create batch: %w", err)
}
pending = 0
}
}

if pending > 0 {
if err := batch.Commit(ctx); err != nil {
_ = kv.Close()
return result{}, fmt.Errorf("final batch commit failed: %w", err)
}
}

if err := kv.Sync(ctx, ds.NewKey("/")); err != nil {
_ = kv.Close()
return result{}, fmt.Errorf("sync failed: %w", err)
}

if err := kv.Close(); err != nil {
return result{}, fmt.Errorf("close failed: %w", err)
}

duration := time.Since(start)
mbPerSec := (float64(actualBytes) / (1024 * 1024)) / duration.Seconds()
writesPerSec := float64(totalWrites) / duration.Seconds()
dbSize, err := dirSize(dir)
if err != nil {
return result{}, fmt.Errorf("failed to compute db size: %w", err)
}

return result{
profile: p.name,
writes: totalWrites,
bytes: actualBytes,
duration: duration,
mbPerSec: mbPerSec,
writesPerS: writesPerSec,
dbSize: dbSize,
}, nil
}

func openEvnode(path string) (ds.Batching, error) {
return badger4.NewDatastore(path, store.BadgerOptions())
}

func openDefault(path string) (ds.Batching, error) {
return badger4.NewDatastore(path, nil)
}

func keyForIndex(i int) ds.Key {
return ds.NewKey("k/" + strconv.Itoa(i))
}

func dirSize(root string) (int64, error) {
var size int64
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type().IsRegular() {
info, err := d.Info()
if err != nil {
return err
}
size += info.Size()
}
return nil
})
return size, err
}

func printResult(res result) {
fmt.Printf("\nProfile: %s\n", res.profile)
fmt.Printf("Writes: %d\n", res.writes)
fmt.Printf("Data: %s\n", humanBytes(res.bytes))
fmt.Printf("Duration: %s\n", res.duration)
fmt.Printf("Throughput: %.2f MB/s\n", res.mbPerSec)
fmt.Printf("Writes/sec: %.2f\n", res.writesPerS)
fmt.Printf("DB size: %s\n", humanBytes(res.dbSize))
}

func selectProfiles(profiles []profile, name string) []profile {
if name == "all" {
return profiles
}
for _, p := range profiles {
if p.name == name {
return []profile{p}
}
}
return nil
}

func ensureBaseDir(dir string, keep bool) (string, func()) {
if dir != "" {
return dir, func() {}
}

tempDir, err := os.MkdirTemp("", "evnode-db-bench-*")
if err != nil {
exitError(fmt.Sprintf("failed to create temp dir: %v", err))
}

if keep {
fmt.Printf("Using temp dir: %s\n", tempDir)
return tempDir, func() {}
}

return tempDir, func() { _ = os.RemoveAll(tempDir) }
}

func humanBytes(size int64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(size)/float64(div), "KMGTPE"[exp])
}

func exitError(msg string) {
fmt.Fprintln(os.Stderr, msg)
os.Exit(1)
}
Loading