diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c2c7d3..1e29efd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +* add `caching-proxy clean "error:"` subcommand to remove wrongfully-cached error entries whose message matches the regex, with `--batch-size` (1 deletes serially) and `--dry-run` flags; prints a styled summary report (auto-plain when not a TTY) * add fallback endpoint support with `--fallback` flag, with `--request-timeout` and `--timeout-before-fallback` ### Changed diff --git a/cmd/evmx/caching_proxy.go b/cmd/evmx/caching_proxy.go index df11aea..3ea74a6 100644 --- a/cmd/evmx/caching_proxy.go +++ b/cmd/evmx/caching_proxy.go @@ -31,7 +31,7 @@ import ( "go.uber.org/zap" ) -var CachingProxyCmd = cli.Command(cachingProxyE, +var CachingProxyCmd = cli.Group( "caching-proxy", "Starts the JSON-RPC caching proxy", cli.Description(` @@ -52,7 +52,9 @@ var CachingProxyCmd = cli.Command(cachingProxyE, flags.Duration("shutdown-signal-delay", 0, "Upon shutdown signal, the server will return errors to the healthchecks endpoint, but still serve traffic for that time duration") flags.StringSliceP("headers", "H", []string{}, "Headers to add to upstream eth_call requests (format: 'Header-Name: value')") }), + cli.Execute(cachingProxyE), cli.ExactArgs(2), + CachingProxyCleanCmd, ) func cachingProxyE(cmd *cobra.Command, args []string) error { diff --git a/cmd/evmx/caching_proxy_clean.go b/cmd/evmx/caching_proxy_clean.go new file mode 100644 index 0000000..7e0f40e --- /dev/null +++ b/cmd/evmx/caching_proxy_clean.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "regexp" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/streamingfast/cli" + "github.com/streamingfast/cli/sflags" + "github.com/streamingfast/evmx/cmd/evmx/stylex" + "github.com/streamingfast/evmx/executor" + "github.com/streamingfast/kvdb/store" + "go.uber.org/zap" +) + +const errorMatcherPrefix = "error:" + +var CachingProxyCleanCmd = cli.Command(cachingProxyCleanE, + "clean", + "Removes wrongfully-cached entries from the caching proxy store", + cli.Description(` + Scans every entry in the caching proxy KV store and removes cached error entries whose + message matches the provided matcher. + + The matcher must be of the form 'error:' where is a Go regular expression + tested against the cached error message. The 'error:' prefix is reserved so that other + matcher kinds can be added later. + + ex: 'evmx caching-proxy clean bigkv://my-proj.my-instance/my-table "error:execution reverted"' + `), + cli.Flags(func(flags *pflag.FlagSet) { + flags.Int("batch-size", 100, "Number of keys to delete per batch, a value of 1 deletes serially (one key per call), bypassing batching") + flags.Bool("dry-run", false, "When true, scans and reports matching entries but deletes nothing") + }), + cli.ExactArgs(2), +) + +func cachingProxyCleanE(cmd *cobra.Command, args []string) error { + dsn := args[0] + matcher := args[1] + + regex, err := parseErrorMatcher(matcher) + if err != nil { + return err + } + + batchSize := sflags.MustGetInt(cmd, "batch-size") + if batchSize < 1 { + return fmt.Errorf("batch-size must be at least 1, got %d", batchSize) + } + dryRun := sflags.MustGetBool(cmd, "dry-run") + + kv, err := store.New(dsn) + if err != nil { + return fmt.Errorf("open store %q: %w", dsn, err) + } + defer kv.Close() + + zlog.Info("cleaning caching proxy error entries", + zap.String("dsn", dsn), + zap.String("message_regex", regex.String()), + zap.Int("batch_size", batchSize), + zap.Bool("dry_run", dryRun), + ) + + summary, err := executor.CleanErrors(cmd.Context(), kv, regex, batchSize, dryRun, zlog) + if err != nil { + return fmt.Errorf("clean errors: %w", err) + } + + fmt.Println(renderCleanReport(summary, dryRun)) + + return nil +} + +// renderCleanReport formats a [executor.CleanErrorsSummary] into a styled, human-friendly +// report. Styling is automatically disabled when standard output is not a TTY. +func renderCleanReport(summary *executor.CleanErrorsSummary, dryRun bool) string { + var b strings.Builder + + title := "Caching Proxy Clean Report" + b.WriteString(stylex.Title(title)) + b.WriteByte('\n') + b.WriteString(stylex.Dim(strings.Repeat("─", len(title)))) + b.WriteByte('\n') + + row := func(label string, value string) { + fmt.Fprintf(&b, " %s %s\n", stylex.Label(fmt.Sprintf("%-14s", label)), value) + } + + row("Scanned", stylex.Valuef("%d", summary.Scanned)) + row("Error entries", stylex.Valuef("%d", summary.Errors)) + row("Matched", stylex.Valuef("%d", summary.Matched)) + + deleted := stylex.Successf("%d", summary.Deleted) + if dryRun { + deleted = stylex.Warnf("%d", summary.Deleted) + " " + stylex.Note("(dry-run, nothing deleted)") + } + row("Deleted", deleted) + + return b.String() +} + +// parseErrorMatcher parses a matcher of the form 'error:' into a compiled regular +// expression tested against cached error messages. +func parseErrorMatcher(matcher string) (*regexp.Regexp, error) { + if !strings.HasPrefix(matcher, errorMatcherPrefix) { + return nil, fmt.Errorf("invalid matcher %q, expected form 'error:'", matcher) + } + + pattern := strings.TrimPrefix(matcher, errorMatcherPrefix) + if pattern == "" { + return nil, fmt.Errorf("invalid matcher %q, the regex after 'error:' must not be empty", matcher) + } + + regex, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid regex %q: %w", pattern, err) + } + + return regex, nil +} diff --git a/cmd/evmx/caching_proxy_clean_test.go b/cmd/evmx/caching_proxy_clean_test.go new file mode 100644 index 0000000..bb689e0 --- /dev/null +++ b/cmd/evmx/caching_proxy_clean_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/streamingfast/evmx/executor" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderCleanReport(t *testing.T) { + dryRun := renderCleanReport(&executor.CleanErrorsSummary{Scanned: 9508, Errors: 7736, Matched: 7727, Deleted: 0}, true) + assert.Contains(t, dryRun, "9508") + assert.Contains(t, dryRun, "7736") + assert.Contains(t, dryRun, "7727") + assert.Contains(t, dryRun, "Scanned") + assert.Contains(t, dryRun, "Matched") + assert.Contains(t, dryRun, "Deleted") + assert.Contains(t, dryRun, "dry-run", "dry-run report must flag that nothing was deleted") + + real := renderCleanReport(&executor.CleanErrorsSummary{Scanned: 10, Errors: 4, Matched: 3, Deleted: 3}, false) + assert.Contains(t, real, "Deleted") + assert.NotContains(t, real, "dry-run") +} + +func TestParseErrorMatcher(t *testing.T) { + tests := []struct { + name string + matcher string + expectErr bool + matchInput string + matches bool + }{ + {"valid regex", "error:execution reverted", false, "execution reverted: boom", true}, + {"valid regex no match", "error:execution reverted", false, "insufficient funds", false}, + {"missing prefix", "execution reverted", true, "", false}, + {"wrong prefix", "data:foo", true, "", false}, + {"empty regex", "error:", true, "", false}, + {"invalid regex", "error:[unterminated", true, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + regex, err := parseErrorMatcher(tt.matcher) + if tt.expectErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.matches, regex.MatchString(tt.matchInput)) + }) + } +} diff --git a/cmd/evmx/stylex/doc.go b/cmd/evmx/stylex/doc.go new file mode 100644 index 0000000..7461f12 --- /dev/null +++ b/cmd/evmx/stylex/doc.go @@ -0,0 +1,9 @@ +// Package stylex provides lipgloss styles for the evmx CLI commands. +// +// It defines a small set of lipgloss styles that can be used across commands to +// ensure a consistent look and feel. Styling is automatically disabled when the +// output is not a TTY, so commands can be used in scripts without worrying about +// ANSI escape codes. +// +// You have nothing to do to activate that. +package stylex diff --git a/cmd/evmx/stylex/stylex.go b/cmd/evmx/stylex/stylex.go new file mode 100644 index 0000000..6a41f87 --- /dev/null +++ b/cmd/evmx/stylex/stylex.go @@ -0,0 +1,65 @@ +package stylex + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-isatty" +) + +var ( + // IsTTY is true when standard output is a terminal, in which case styling is enabled. + IsTTY = isatty.IsTerminal(os.Stdout.Fd()) + + TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + HeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("14")) + NoteStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")).Italic(true) + DimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) + + LabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + ValueStyle = lipgloss.NewStyle().Bold(true) + + SuccessStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("10")) // Green + WarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("208")) // Orange + ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")) // Red +) + +func init() { + // Disable styling if not a TTY + if !IsTTY { + DisableColors() + } +} + +// DisableColors resets every style to a no-op style, producing plain unstyled output. +func DisableColors() { + TitleStyle = lipgloss.NewStyle() + HeaderStyle = lipgloss.NewStyle() + NoteStyle = lipgloss.NewStyle() + DimStyle = lipgloss.NewStyle() + LabelStyle = lipgloss.NewStyle() + ValueStyle = lipgloss.NewStyle() + SuccessStyle = lipgloss.NewStyle() + WarnStyle = lipgloss.NewStyle() + ErrorStyle = lipgloss.NewStyle() +} + +func Titlef(format string, a ...any) string { return TitleStyle.Render(fmt.Sprintf(format, a...)) } +func Title(str string) string { return TitleStyle.Render(str) } +func Headerf(format string, a ...any) string { return HeaderStyle.Render(fmt.Sprintf(format, a...)) } +func Header(str string) string { return HeaderStyle.Render(str) } +func Notef(format string, a ...any) string { return NoteStyle.Render(fmt.Sprintf(format, a...)) } +func Note(str string) string { return NoteStyle.Render(str) } +func Dimf(format string, a ...any) string { return DimStyle.Render(fmt.Sprintf(format, a...)) } +func Dim(str string) string { return DimStyle.Render(str) } +func Labelf(format string, a ...any) string { return LabelStyle.Render(fmt.Sprintf(format, a...)) } +func Label(str string) string { return LabelStyle.Render(str) } +func Valuef(format string, a ...any) string { return ValueStyle.Render(fmt.Sprintf(format, a...)) } +func Value(str string) string { return ValueStyle.Render(str) } +func Successf(format string, a ...any) string { return SuccessStyle.Render(fmt.Sprintf(format, a...)) } +func Success(str string) string { return SuccessStyle.Render(str) } +func Warnf(format string, a ...any) string { return WarnStyle.Render(fmt.Sprintf(format, a...)) } +func Warn(str string) string { return WarnStyle.Render(str) } +func Errorf(format string, a ...any) string { return ErrorStyle.Render(fmt.Sprintf(format, a...)) } +func Error(str string) string { return ErrorStyle.Render(str) } diff --git a/executor/caching_proxy_clean.go b/executor/caching_proxy_clean.go new file mode 100644 index 0000000..6e06d55 --- /dev/null +++ b/executor/caching_proxy_clean.go @@ -0,0 +1,107 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + + "github.com/streamingfast/eth-go/rpc" + "github.com/streamingfast/kvdb/store" + "go.uber.org/zap" +) + +// CleanErrorsSummary reports the outcome of a [CleanErrors] run. +type CleanErrorsSummary struct { + Scanned int // Total number of entries scanned in the store + Errors int // Number of error entries seen (value prefixed with ErrorPrefix) + Matched int // Number of error entries whose message matched the regex + Deleted int // Number of entries actually deleted (always 0 when dryRun) +} + +// CleanErrors scans every entry in the store and deletes error entries (those whose +// value is prefixed with [ErrorPrefix]) whose rpc error message matches messageRegex. +// +// Deletions are batched in groups of batchSize; a batchSize of 1 deletes serially, +// one key per [store.KVStore.BatchDelete] call, bypassing batching. When dryRun is +// true, matches are counted but nothing is deleted. +func CleanErrors( + ctx context.Context, + kv store.KVStore, + messageRegex *regexp.Regexp, + batchSize int, + dryRun bool, + logger *zap.Logger, +) (*CleanErrorsSummary, error) { + if batchSize < 1 { + return nil, fmt.Errorf("batch size must be at least 1, got %d", batchSize) + } + + summary := &CleanErrorsSummary{} + batch := make([][]byte, 0, batchSize) + + flush := func() error { + if len(batch) == 0 { + return nil + } + if err := kv.BatchDelete(ctx, batch); err != nil { + return fmt.Errorf("batch delete: %w", err) + } + summary.Deleted += len(batch) + batch = batch[:0] + return nil + } + + // A nil prefix matches every key, giving us a full scan regardless of key length. + itr := kv.Prefix(ctx, nil, store.Unlimited) + for itr.Next() { + item := itr.Item() + summary.Scanned++ + + value := item.Value + if len(value) == 0 || value[0] != ErrorPrefix { + continue + } + summary.Errors++ + + rpcErr := &rpc.ErrResponse{} + if err := json.Unmarshal(value[1:], rpcErr); err != nil { + logger.Debug("skipping error entry with unparseable payload", zap.Error(err)) + continue + } + + if !messageRegex.MatchString(rpcErr.Message) { + continue + } + summary.Matched++ + + logger.Debug("matched error entry to clean", + zap.Int("code", int(rpcErr.Code)), + zap.String("message", rpcErr.Message), + ) + + if dryRun { + continue + } + + // The iterator may reuse the key slice, copy it before retaining it in the batch. + key := make([]byte, len(item.Key)) + copy(key, item.Key) + batch = append(batch, key) + + if len(batch) >= batchSize { + if err := flush(); err != nil { + return summary, err + } + } + } + if err := itr.Err(); err != nil { + return summary, fmt.Errorf("scanning store: %w", err) + } + + if err := flush(); err != nil { + return summary, err + } + + return summary, nil +} diff --git a/executor/caching_proxy_clean_test.go b/executor/caching_proxy_clean_test.go new file mode 100644 index 0000000..2fd1e11 --- /dev/null +++ b/executor/caching_proxy_clean_test.go @@ -0,0 +1,133 @@ +package executor + +import ( + "context" + "encoding/json" + "path/filepath" + "regexp" + "testing" + + "github.com/streamingfast/eth-go/rpc" + "github.com/streamingfast/kvdb/store" + _ "github.com/streamingfast/kvdb/store/badger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestStore(t *testing.T) store.KVStore { + t.Helper() + + dsn := "badger://" + filepath.Join(t.TempDir(), "clean.db") + kv, err := store.New(dsn) + require.NoError(t, err) + t.Cleanup(func() { kv.Close() }) + + return kv +} + +func putData(t *testing.T, kv store.KVStore, key string, data []byte) { + t.Helper() + require.NoError(t, kv.Put(context.Background(), []byte(key), append([]byte{DataPrefix}, data...))) +} + +func putError(t *testing.T, kv store.KVStore, key string, code rpc.ErrorCode, message string) { + t.Helper() + payload, err := json.Marshal(&rpc.ErrResponse{Code: code, Message: message}) + require.NoError(t, err) + require.NoError(t, kv.Put(context.Background(), []byte(key), append([]byte{ErrorPrefix}, payload...))) +} + +func flush(t *testing.T, kv store.KVStore) { + t.Helper() + require.NoError(t, kv.FlushPuts(context.Background())) +} + +func exists(t *testing.T, kv store.KVStore, key string) bool { + t.Helper() + _, err := kv.Get(context.Background(), []byte(key)) + if err == nil { + return true + } + require.EqualError(t, err, "not found") + return false +} + +func TestCleanErrors_DeletesOnlyMatchingErrors(t *testing.T) { + kv := newTestStore(t) + + putData(t, kv, "data-1", []byte("some-return-data")) + putError(t, kv, "err-match-1", 3, "execution reverted: bad thing") + putError(t, kv, "err-match-2", 3, "execution reverted: other thing") + putError(t, kv, "err-nomatch", -32000, "insufficient funds for gas") + flush(t, kv) + + summary, err := CleanErrors(context.Background(), kv, regexp.MustCompile("execution reverted"), 100, false, zlog) + require.NoError(t, err) + + assert.Equal(t, 4, summary.Scanned) + assert.Equal(t, 3, summary.Errors) + assert.Equal(t, 2, summary.Matched) + assert.Equal(t, 2, summary.Deleted) + + assert.True(t, exists(t, kv, "data-1"), "data entry must be kept") + assert.True(t, exists(t, kv, "err-nomatch"), "non-matching error must be kept") + assert.False(t, exists(t, kv, "err-match-1"), "matching error must be deleted") + assert.False(t, exists(t, kv, "err-match-2"), "matching error must be deleted") +} + +func TestCleanErrors_DryRunDeletesNothing(t *testing.T) { + kv := newTestStore(t) + + putError(t, kv, "err-match", 3, "execution reverted") + flush(t, kv) + + summary, err := CleanErrors(context.Background(), kv, regexp.MustCompile("execution reverted"), 100, true, zlog) + require.NoError(t, err) + + assert.Equal(t, 1, summary.Matched) + assert.Equal(t, 0, summary.Deleted) + assert.True(t, exists(t, kv, "err-match"), "dry-run must not delete") +} + +func TestCleanErrors_BatchSizeOneDeletesSerially(t *testing.T) { + kv := newTestStore(t) + + putError(t, kv, "err-1", 3, "execution reverted a") + putError(t, kv, "err-2", 3, "execution reverted b") + putError(t, kv, "err-3", 3, "execution reverted c") + flush(t, kv) + + summary, err := CleanErrors(context.Background(), kv, regexp.MustCompile("execution reverted"), 1, false, zlog) + require.NoError(t, err) + + assert.Equal(t, 3, summary.Matched) + assert.Equal(t, 3, summary.Deleted) + assert.False(t, exists(t, kv, "err-1")) + assert.False(t, exists(t, kv, "err-2")) + assert.False(t, exists(t, kv, "err-3")) +} + +func TestCleanErrors_SkipsMalformedErrorPayload(t *testing.T) { + kv := newTestStore(t) + + // Error-prefixed value with a non-JSON payload must be skipped, not deleted. + require.NoError(t, kv.Put(context.Background(), []byte("err-bad"), []byte{ErrorPrefix, 'n', 'o', 't'})) + putError(t, kv, "err-match", 3, "execution reverted") + flush(t, kv) + + summary, err := CleanErrors(context.Background(), kv, regexp.MustCompile("execution reverted"), 100, false, zlog) + require.NoError(t, err) + + assert.Equal(t, 2, summary.Errors) + assert.Equal(t, 1, summary.Matched) + assert.Equal(t, 1, summary.Deleted) + assert.True(t, exists(t, kv, "err-bad"), "malformed error entry must be kept") + assert.False(t, exists(t, kv, "err-match")) +} + +func TestCleanErrors_RejectsInvalidBatchSize(t *testing.T) { + kv := newTestStore(t) + + _, err := CleanErrors(context.Background(), kv, regexp.MustCompile("x"), 0, false, zlog) + require.Error(t, err) +} diff --git a/go.mod b/go.mod index 3f4aaad..5e43f58 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.24.0 require ( github.com/KimMachineGun/automemlimit v0.3.0 github.com/bobg/go-generics/v2 v2.2.2 + github.com/charmbracelet/lipgloss v1.0.0 github.com/ethereum/go-ethereum v1.16.8 github.com/gorilla/mux v1.8.0 github.com/gorilla/rpc v1.2.0 github.com/holiman/uint256 v1.3.2 + github.com/mattn/go-isatty v0.0.20 github.com/remeh/sizedwaitgroup v1.0.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.6 @@ -81,7 +83,6 @@ require ( github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/lipgloss v1.0.0 // indirect github.com/charmbracelet/x/ansi v0.4.2 // indirect github.com/chzyer/readline v1.5.0 // indirect github.com/cilium/ebpf v0.9.1 // indirect @@ -143,7 +144,6 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-ieproxy v0.0.1 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/minio/highwayhash v1.0.2 // indirect