From 84677dea8cfa70fb10bddbb636cc8fcb85df5f00 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 4 Jun 2026 11:34:14 -0700 Subject: [PATCH 1/6] feat(server): add report-api server for dynamic metadata assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server/ — an HTTP server that replaces the per-environment static metadata.json with a dynamically assembled response fetched directly from per-run S3 files. This moves the aggregation, retention, and comparison-synthesis logic into the open-source base/benchmark repo. There is no central metadata file. The server: - Lists all /metadata.json objects in S3 (one per run) - Merges, deduplicates, and applies retention policy - Appends synthetic [Compare: Time] and [Compare: Versions] groups so the existing report UI can compare runs across time windows or client versions without any frontend change - Caches aggressively: per-file by ETag (invalidated when a run is rewritten), merged result by object fingerprint + 1h TTL (handles time.Now() dependence in retention/comparison) The server is a straight port of protocols/base-benchmarking's report-api, updated to use the per-run-directory S3 layout (#66 in base-benchmarking). The base-benchmarking repo retains its copy during the transition; a follow-up PR will remove it once this one is deployed. 14 tests ported from base-benchmarking (comparison synthesizer tests). All existing runner/ tests unaffected. --- go.mod | 26 + go.sum | 58 ++ server/cmd/main.go | 199 +++++++ server/internal/config/config.go | 69 +++ server/internal/config/flags.go | 90 ++++ server/internal/handlers/health.go | 17 + server/internal/handlers/loadtest.go | 48 ++ server/internal/handlers/metadata.go | 27 + server/internal/handlers/metrics.go | 52 ++ server/internal/services/cache.go | 76 +++ server/internal/services/comparison.go | 371 +++++++++++++ server/internal/services/comparison_test.go | 360 +++++++++++++ server/internal/services/s3.go | 553 ++++++++++++++++++++ 13 files changed, 1946 insertions(+) create mode 100644 server/cmd/main.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/config/flags.go create mode 100644 server/internal/handlers/health.go create mode 100644 server/internal/handlers/loadtest.go create mode 100644 server/internal/handlers/metadata.go create mode 100644 server/internal/handlers/metrics.go create mode 100644 server/internal/services/cache.go create mode 100644 server/internal/services/comparison.go create mode 100644 server/internal/services/comparison_test.go create mode 100644 server/internal/services/s3.go diff --git a/go.mod b/go.mod index c5dd0a7b..d4c0bd94 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/VictoriaMetrics/fastcache v1.13.2 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect + github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/base/go-bip39 v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -35,6 +36,8 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.6 // indirect github.com/btcsuite/btcd/btcutil v1.1.6 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/cp v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -50,6 +53,7 @@ require ( github.com/clipperhouse/displaywidth v0.6.1 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect github.com/cockroachdb/errors v1.12.0 // indirect github.com/cockroachdb/fifo v0.0.0-20240816210425-c5d0cb0b6fc0 // indirect github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect @@ -71,9 +75,18 @@ require ( github.com/ethereum/go-verkle v0.2.2 // indirect github.com/ferranbt/fastssz v1.0.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gballet/go-libpcsclite v0.0.0-20250918194357-1ec6f2e601c6 // indirect github.com/getsentry/sentry-go v0.40.0 // indirect + github.com/gin-contrib/cors v1.7.6 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/gin-gonic/gin v1.11.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -92,10 +105,13 @@ require ( github.com/ipfs/go-cid v0.6.0 // indirect github.com/ipfs/go-datastore v0.9.0 // indirect github.com/jackpal/go-nat-pmp v1.0.2 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/libp2p/go-libp2p v0.45.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -108,6 +124,8 @@ require ( github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/pointerstructure v1.2.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mr-tron/base58 v1.2.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect @@ -125,6 +143,7 @@ require ( github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416 // indirect github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/peterh/liner v1.2.2 // indirect github.com/pion/dtls/v2 v2.2.12 // indirect github.com/pion/logging v0.2.4 // indirect @@ -134,6 +153,8 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.55.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rs/cors v1.11.1 // indirect @@ -146,18 +167,23 @@ require ( github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect + golang.org/x/tools v0.39.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 49da22fa..ef729482 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7D github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -57,6 +59,10 @@ github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/cespare/cp v1.1.1 h1:nCb6ZLdB7NRaqsm91JtQTAme2SKJzXVsdPIPkyJr1MU= @@ -102,6 +108,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= @@ -166,16 +174,34 @@ github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gballet/go-libpcsclite v0.0.0-20250918194357-1ec6f2e601c6 h1:ko+DlyhLqUHpgrvwqs5ybydoGAqjpJQTXpAS7vUqVlM= github.com/gballet/go-libpcsclite v0.0.0-20250918194357-1ec6f2e601c6/go.mod h1:3IVE7v4II2gS2V5amIH7F7NeYQtbbORtQtjdflgS1vk= github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -200,6 +226,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.1-0.20220503160820-4a35382e8fc8 h1:Ep/joEub9YwcjRY6ND3+Y/w0ncE540RtGatVhtZL0/Q= github.com/google/gofuzz v1.2.1-0.20220503160820-4a35382e8fc8/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= @@ -248,7 +275,12 @@ github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7Bd github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= @@ -264,6 +296,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= github.com/libp2p/go-flow-metrics v0.2.0 h1:EIZzjmeOE6c8Dav0sNv35vhZxATIXWZg6j/C08XmmDw= @@ -295,6 +329,11 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.1 h1:ZhBBeX8tSlRpu/FFhXH4RC4OJzFlqsQhoHZAz4x7TIw= github.com/mitchellh/pointerstructure v1.2.1/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -349,6 +388,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/peterh/liner v1.2.2 h1:aJ4AOodmL+JxOZZEL2u9iJf8omNRpqHc/EbrK+3mAXw= github.com/peterh/liner v1.2.2/go.mod h1:xFwJyiKIXJZUKItq5dGHZSTBRAuG/CpeNpWLyiNRNwI= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= @@ -384,6 +425,10 @@ github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4 github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prysmaticlabs/gohashtree v0.0.4-beta h1:H/EbCuXPeTV3lpKeXGPpEV9gsUpkqOOVnWapUyeWro4= github.com/prysmaticlabs/gohashtree v0.0.4-beta/go.mod h1:BFdtALS+Ffhg3lGQIHv9HDWuHS8cTvHZzrHWxwOtGOs= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk= +github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= @@ -404,11 +449,13 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -422,6 +469,10 @@ github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYI github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= @@ -442,6 +493,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -458,6 +511,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -547,6 +602,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -573,6 +630,7 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/server/cmd/main.go b/server/cmd/main.go new file mode 100644 index 00000000..7e4111d4 --- /dev/null +++ b/server/cmd/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "sync/atomic" + "syscall" + "time" + + "github.com/base/base-bench/server/internal/config" + "github.com/base/base-bench/server/internal/handlers" + "github.com/base/base-bench/server/internal/services" + + "github.com/gin-gonic/gin" + "github.com/urfave/cli/v2" + + opservice "github.com/ethereum-optimism/optimism/op-service" + "github.com/ethereum-optimism/optimism/op-service/cliapp" + oplog "github.com/ethereum-optimism/optimism/op-service/log" + "github.com/ethereum/go-ethereum/log" +) + +// autopopulated by the Makefile +var ( + Version = "v0.0.1" + GitCommit = "" + GitDate = "" +) + +func Main() cliapp.LifecycleAction { + return func(cliCtx *cli.Context, closeApp context.CancelCauseFunc) (cliapp.Lifecycle, error) { + logConfig := oplog.ReadCLIConfig(cliCtx) + l := oplog.NewLogger(oplog.AppOut(cliCtx), logConfig) + oplog.SetGlobalLogHandler(l.Handler()) + + cfg, err := config.NewConfigFromFlags(cliCtx) + if err != nil { + l.Error("Error creating configuration from flags", "error", err) + return nil, err + } + + if err := cfg.Validate(); err != nil { + l.Error("Invalid configuration", "error", err) + return nil, err + } + + opservice.ValidateEnvVars(config.EnvVarPrefix, config.CLIFlags(), l) + + // Setup logging using the config method + l.Info("Starting benchmark report API server", + "port", cfg.Port, + "bucket", cfg.S3Bucket, + "region", cfg.S3Region, + "cache", cfg.EnableCache, + "cacheTTL", cfg.CacheTTL) + + // Initialize services + cache := services.NewMemoryCache(cfg.CacheTTL, l) + if !cfg.EnableCache { + cache = services.NewMemoryCache(0, l) // Disable caching + } + + s3Service, err := services.NewS3Service(cfg.S3Bucket, cfg.S3Region, cfg.S3Endpoint, cache, l) + if err != nil { + l.Error("Failed to initialize S3 service", "error", err) + return nil, err + } + + // Setup Gin + if cfg.LogLevel != "debug" { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.New() + router.Use(gin.Logger()) + router.Use(gin.Recovery()) + router.Use(cfg.CORS()) + + // Setup routes + setupRoutes(router, s3Service, l) + + // Configure server + server := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + return &ServerService{server: server, logger: l}, nil + } +} + +type ServerService struct { + server *http.Server + logger log.Logger + stopped atomic.Bool +} + +func (s *ServerService) Start(ctx context.Context) error { + s.logger.Info("Server starting", "addr", s.server.Addr) + + // Create a channel to listen for OS signals (Ctrl+C, SIGTERM) + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChan) // Clean up signal notification + + // Start server in a goroutine + serverErr := make(chan error, 1) + go func() { + if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- err + } + }() + + // Wait for either a signal or server error + select { + case err := <-serverErr: + s.logger.Error("Server failed to start", "error", err) + return err + case sig := <-sigChan: + s.logger.Info("Received shutdown signal", "signal", sig) + + // Create a context with timeout for graceful shutdown + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + s.logger.Info("Shutting down server gracefully...") + if err := s.server.Shutdown(shutdownCtx); err != nil { + s.logger.Error("Server forced to shutdown", "error", err) + return err + } + + s.logger.Info("Server shutdown complete") + s.stopped.Store(true) + return nil + case <-ctx.Done(): + s.logger.Info("Context cancelled, shutting down server") + + // Create a context with timeout for graceful shutdown + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := s.server.Shutdown(shutdownCtx); err != nil { + s.logger.Error("Server forced to shutdown", "error", err) + return err + } + + s.logger.Info("Server shutdown complete") + s.stopped.Store(true) + return ctx.Err() + } +} + +func (s *ServerService) Stop(ctx context.Context) error { + s.logger.Info("Server stopping") + s.stopped.Store(true) + return s.server.Shutdown(ctx) +} + +func (s *ServerService) Stopped() bool { + return s.stopped.Load() +} + +func main() { + oplog.SetupDefaults() + + app := cli.NewApp() + app.Flags = cliapp.ProtectFlags(config.CLIFlags()) + app.Version = opservice.FormatVersion(Version, GitCommit, GitDate, "") + app.Name = "benchmark-report-api" + app.Usage = "Benchmark Report API Server" + app.Description = "REST API server for serving benchmark data from AWS S3 storage" + + app.Action = cliapp.LifecycleCmd(Main()) + err := app.Run(os.Args) + if err != nil { + log.Crit("Application failed", "message", err) + } +} + +// setupRoutes configures all API routes +func setupRoutes(router *gin.Engine, s3Service *services.S3Service, l log.Logger) { + api := router.Group("/api/v1") + { + api.GET("/health", handlers.Health) + } + + // Static file emulation routes - serve files at the same paths as static mode + // This allows the frontend to use the same URL structure for both static and API modes + router.GET("/output/metadata.json", handlers.MetadataHandler(s3Service, l)) + router.GET("/output/:outputDir/:filename", handlers.StaticEmulationHandler(s3Service, l)) + + api.GET("/load-tests/:network", handlers.LoadTestListHandler(s3Service, l)) + api.GET("/load-tests/:network/:timestamp", handlers.LoadTestResultHandler(s3Service, l)) +} diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 00000000..36f07259 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,69 @@ +package config + +import ( + "errors" + "strings" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + "github.com/urfave/cli/v2" +) + +// FlagsConfig holds all configuration for the application when using CLI flags +type FlagsConfig struct { + Port string + S3Bucket string + S3Region string + S3Endpoint string + CacheTTL time.Duration + EnableCache bool + AllowedOrigins []string + LogLevel string +} + +// NewConfigFromFlags creates a FlagsConfig from CLI context +func NewConfigFromFlags(ctx *cli.Context) (*FlagsConfig, error) { + cacheTTL, err := time.ParseDuration(ctx.String("cache-ttl")) + if err != nil { + return nil, err + } + + return &FlagsConfig{ + Port: ctx.String("port"), + S3Bucket: ctx.String("s3-bucket"), + S3Region: ctx.String("s3-region"), + S3Endpoint: ctx.String("s3-endpoint"), + CacheTTL: cacheTTL, + EnableCache: ctx.Bool("enable-cache"), + AllowedOrigins: strings.Split(ctx.String("allowed-origins"), ","), + LogLevel: ctx.String("log-level"), + }, nil +} + +// Validate checks if the configuration is valid +func (c *FlagsConfig) Validate() error { + if c.S3Bucket == "" { + return errors.New("BASE_BENCH_API_S3_BUCKET environment variable is required") + } + return nil +} + +// CORS configures CORS middleware based on allowed origins +func (c *FlagsConfig) CORS() gin.HandlerFunc { + config := cors.DefaultConfig() + + if len(c.AllowedOrigins) == 1 && c.AllowedOrigins[0] == "*" { + config.AllowAllOrigins = true + } else { + config.AllowOrigins = c.AllowedOrigins + } + + config.AllowMethods = []string{"GET", "OPTIONS"} + config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization"} + config.ExposeHeaders = []string{"Content-Length"} + config.AllowCredentials = true + config.MaxAge = 12 * time.Hour + + return cors.New(config) +} diff --git a/server/internal/config/flags.go b/server/internal/config/flags.go new file mode 100644 index 00000000..94fa901b --- /dev/null +++ b/server/internal/config/flags.go @@ -0,0 +1,90 @@ +package config + +import ( + "github.com/urfave/cli/v2" + + opservice "github.com/ethereum-optimism/optimism/op-service" + oplog "github.com/ethereum-optimism/optimism/op-service/log" +) + +const EnvVarPrefix = "BASE_BENCH_API" + +func prefixEnvVars(name string) []string { + return opservice.PrefixEnvVar(EnvVarPrefix, name) +} + +const ( + // Default values for API server + DefaultPort = "8080" + DefaultS3Region = "us-east-1" + DefaultCacheTTL = "5m" + DefaultEnableCache = true + DefaultAllowedOrigins = "*" + DefaultLogLevel = "debug" +) + +var ( + PortFlag = &cli.StringFlag{ + Name: "port", + Usage: "API server port", + Value: DefaultPort, + EnvVars: prefixEnvVars("PORT"), + } + S3BucketFlag = &cli.StringFlag{ + Name: "s3-bucket", + Usage: "AWS S3 bucket name for benchmark data", + Required: true, + EnvVars: prefixEnvVars("S3_BUCKET"), + } + S3RegionFlag = &cli.StringFlag{ + Name: "s3-region", + Usage: "AWS S3 region", + Value: DefaultS3Region, + EnvVars: prefixEnvVars("AWS_REGION"), + } + S3EndpointFlag = &cli.StringFlag{ + Name: "s3-endpoint", + Usage: "Override S3 endpoint URL (use with MinIO or other S3-compatible stores; leave empty for AWS)", + Value: "", + EnvVars: prefixEnvVars("S3_ENDPOINT"), + } + CacheTTLFlag = &cli.StringFlag{ + Name: "cache-ttl", + Usage: "Cache time-to-live duration (e.g., 5m, 1h)", + Value: DefaultCacheTTL, + EnvVars: prefixEnvVars("CACHE_TTL"), + } + EnableCacheFlag = &cli.BoolFlag{ + Name: "enable-cache", + Usage: "Enable in-memory caching", + Value: DefaultEnableCache, + EnvVars: prefixEnvVars("ENABLE_CACHE"), + } + AllowedOriginsFlag = &cli.StringFlag{ + Name: "allowed-origins", + Usage: "CORS allowed origins (comma-separated)", + Value: DefaultAllowedOrigins, + EnvVars: prefixEnvVars("ALLOWED_ORIGINS"), + } + LogLevelFlag = &cli.StringFlag{ + Name: "log-level", + Usage: "Log level (debug, info, warn, error)", + Value: DefaultLogLevel, + EnvVars: prefixEnvVars("LOG_LEVEL"), + } +) + +func CLIFlags() []cli.Flag { + Flags := []cli.Flag{ + PortFlag, + S3BucketFlag, + S3RegionFlag, + S3EndpointFlag, + CacheTTLFlag, + EnableCacheFlag, + AllowedOriginsFlag, + LogLevelFlag, + } + Flags = append(Flags, oplog.CLIFlags(EnvVarPrefix)...) + return Flags +} diff --git a/server/internal/handlers/health.go b/server/internal/handlers/health.go new file mode 100644 index 00000000..e1755f34 --- /dev/null +++ b/server/internal/handlers/health.go @@ -0,0 +1,17 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// Health provides a health check endpoint +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "service": "benchmark-report-api", + }) +} diff --git a/server/internal/handlers/loadtest.go b/server/internal/handlers/loadtest.go new file mode 100644 index 00000000..f3a3901c --- /dev/null +++ b/server/internal/handlers/loadtest.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "net/http" + + "github.com/base/base-bench/server/internal/services" + + "github.com/ethereum/go-ethereum/log" + "github.com/gin-gonic/gin" +) + +// LoadTestListHandler returns the list of available load test results for a network. +func LoadTestListHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + network := c.Param("network") + if network == "" { + network = "sepolia" + } + + entries, err := s3Service.ListLoadTests(network) + if err != nil { + l.Error("Failed to list load test results", "error", err, "network", network) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list load test results"}) + return + } + + c.Header("Cache-Control", "public, max-age=300") + c.JSON(http.StatusOK, entries) + } +} + +// LoadTestResultHandler returns a single load test result JSON. +func LoadTestResultHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + network := c.Param("network") + timestamp := c.Param("timestamp") + + data, err := s3Service.GetLoadTest(network, timestamp) + if err != nil { + l.Error("Failed to get load test result", "error", err, "network", network, "timestamp", timestamp) + c.JSON(http.StatusNotFound, gin.H{"error": "Load test result not found"}) + return + } + + c.Header("Cache-Control", "public, max-age=43200") + c.Data(http.StatusOK, "application/json", data) + } +} diff --git a/server/internal/handlers/metadata.go b/server/internal/handlers/metadata.go new file mode 100644 index 00000000..3b652860 --- /dev/null +++ b/server/internal/handlers/metadata.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "net/http" + + "github.com/base/base-bench/server/internal/services" + + "github.com/ethereum/go-ethereum/log" + "github.com/gin-gonic/gin" +) + +// MetadataHandler returns a handler function for serving benchmark metadata +func MetadataHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + metadata, err := s3Service.GetMetadata() + if err != nil { + l.Error("Failed to get metadata", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to retrieve metadata", + }) + return + } + + c.Header("Cache-Control", "public, max-age=43200") // 12 hours + c.JSON(http.StatusOK, metadata) + } +} diff --git a/server/internal/handlers/metrics.go b/server/internal/handlers/metrics.go new file mode 100644 index 00000000..d0c3e176 --- /dev/null +++ b/server/internal/handlers/metrics.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/base/base-bench/server/internal/services" + + "github.com/ethereum/go-ethereum/log" + "github.com/gin-gonic/gin" +) + +// StaticEmulationHandler serves metrics files using the same URL structure as static files +// This allows the API to emulate the static file structure: /output//metrics-.json +func StaticEmulationHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + outputDir := c.Param("outputDir") + filename := c.Param("filename") + + // Extract nodeType from filename (e.g., "metrics-sequencer.json" -> "sequencer") + if !strings.HasPrefix(filename, "metrics-") || !strings.HasSuffix(filename, ".json") { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Invalid metrics filename format. Expected: metrics-.json", + }) + return + } + + nodeType := strings.TrimPrefix(filename, "metrics-") + nodeType = strings.TrimSuffix(nodeType, ".json") + + if outputDir == "" || nodeType == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "outputDir and nodeType are required", + }) + return + } + + data, err := s3Service.GetMetrics(outputDir, nodeType) + if err != nil { + l.Error("Failed to get metrics", "error", err, "outputDir", outputDir, "nodeType", nodeType) + + c.JSON(http.StatusNotFound, gin.H{ + "error": "Metrics not found", + }) + return + } + + c.Header("Cache-Control", "public, max-age=43200") // 12 hours + c.Header("Content-Type", "application/json") + c.Data(http.StatusOK, "application/json", data) + } +} diff --git a/server/internal/services/cache.go b/server/internal/services/cache.go new file mode 100644 index 00000000..11fadbc2 --- /dev/null +++ b/server/internal/services/cache.go @@ -0,0 +1,76 @@ +package services + +import ( + "time" + + "github.com/ethereum/go-ethereum/log" +) + +// CacheItem represents a cached item with expiration +type CacheItem struct { + Data []byte + ExpiresAt time.Time +} + +// MemoryCache provides simple in-memory caching functionality +type MemoryCache struct { + data map[string]CacheItem + ttl time.Duration + l log.Logger +} + +// NewMemoryCache creates a new in-memory cache instance +func NewMemoryCache(ttl time.Duration, l log.Logger) *MemoryCache { + cache := &MemoryCache{ + data: make(map[string]CacheItem), + ttl: ttl, + l: l, + } + + // Start cleanup goroutine if TTL is set + if ttl > 0 { + go cache.cleanup() + } + + return cache +} + +// Get retrieves data from cache +func (c *MemoryCache) Get(key string) ([]byte, bool) { + item, exists := c.data[key] + if !exists || (c.ttl > 0 && time.Now().After(item.ExpiresAt)) { + delete(c.data, key) + return nil, false + } + return item.Data, true +} + +// Set stores data in cache +func (c *MemoryCache) Set(key string, data []byte) { + expiresAt := time.Now().Add(c.ttl) + if c.ttl <= 0 { + // No expiration for TTL <= 0 + expiresAt = time.Time{} + } + + c.data[key] = CacheItem{ + Data: data, + ExpiresAt: expiresAt, + } +} + +// cleanup removes expired items periodically +func (c *MemoryCache) cleanup() { + ticker := time.NewTicker(c.ttl) + defer ticker.Stop() + + for range ticker.C { + now := time.Now() + for key, item := range c.data { + if now.After(item.ExpiresAt) { + delete(c.data, key) + c.l.Debug("Cache item expired and removed", "key", key) + } + } + } +} diff --git a/server/internal/services/comparison.go b/server/internal/services/comparison.go new file mode 100644 index 00000000..c12f1c3e --- /dev/null +++ b/server/internal/services/comparison.go @@ -0,0 +1,371 @@ +package services + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// Comparison groups expose multi-version and multi-time-window views +// of an existing benchmark series as if they were ordinary +// `BenchmarkRun` IDs. The frontend consumes the same `metadata.json` +// it always has — selecting a synthetic ID from the dropdown loads +// runs sourced from across versions or time windows. To draw them as +// separate chart series, the user picks "Show Line Per: ClientVersion" +// (for version comparisons) in the existing chart UI; no frontend +// change required. +// +// Synthetic runs are deep clones of source runs with rewritten +// TestConfig["BenchmarkRun"] and prefixed TestName. The source runs +// are never mutated and continue to appear under their natural IDs. + +// comparisonKind picks which dimension a synthetic group splits on. +type comparisonKind int + +const ( + compareByTime comparisonKind = iota + compareByVersion +) + +// comparisonGroupConfig defines one cohort to manufacture synthetic +// groups for. Cohorts are scoped by the substring matched against +// `sourceFile` (network) — every distinct testName within that cohort +// gets its own synthetic groups so two different mainnet test series +// don't get conflated. +// +// Hardcoded for v1; a future iteration may load this from +// s3://.../comparisons/groups.json so operators can change the set +// without redeploying the API. +type comparisonGroupConfig struct { + // networkSubstr is matched case-insensitively against + // run.SourceFile. A run participates in this cohort iff its + // SourceFile contains the substring. "" means "any network". + networkSubstr string + // kinds is the comparison dimensions to manufacture for this + // cohort. Each kind that produces fewer than 2 distinct buckets + // is dropped (a comparison of one thing isn't a comparison). + kinds []comparisonKind +} + +// hardcodedComparisonGroups defines the v1 set: each canonical +// network gets both a by-version and a by-time comparison. The +// generator silently skips any (cohort, kind) pair that doesn't have +// enough data to be a meaningful comparison, so listing all three +// networks here is harmless even when only one is populated. +var hardcodedComparisonGroups = []comparisonGroupConfig{ + {networkSubstr: "mainnet", kinds: []comparisonKind{compareByVersion, compareByTime}}, + {networkSubstr: "sepolia", kinds: []comparisonKind{compareByVersion, compareByTime}}, + {networkSubstr: "testnet", kinds: []comparisonKind{compareByVersion, compareByTime}}, + {networkSubstr: "devnet", kinds: []comparisonKind{compareByVersion, compareByTime}}, +} + +// timeBuckets defines the rolling windows for compareByTime. Each is +// a half-open interval [Newer, Older) measured as "ago" relative to +// `now`. Ordered newest-first because the user typically wants to +// see "today" alongside "last week" and "last month". +var timeBuckets = []struct { + Label string + Newer time.Duration + Older time.Duration +}{ + {"1d", 0, 24 * time.Hour}, + {"1w", 24 * time.Hour, 7 * 24 * time.Hour}, + {"1m", 7 * 24 * time.Hour, 30 * 24 * time.Hour}, +} + +// synthesizeComparisonGroups returns synthetic runs to append to the +// merged metadata. It never mutates the input. Source runs are deep +// cloned (TestConfig map is rebuilt; pointer fields are shared since +// they're treated as immutable everywhere downstream). +func synthesizeComparisonGroups(runs []BenchmarkRun, now time.Time) []BenchmarkRun { + var synthetic []BenchmarkRun + for _, cfg := range hardcodedComparisonGroups { + cohort := filterByNetwork(runs, cfg.networkSubstr) + if len(cohort) == 0 { + continue + } + // Group cohort runs by canonical testName (with the + // applyRetentionPolicy "[Monthly - Mon YYYY] " prefix + // stripped) so the monthly survivor of a series lands in + // the same cohort as its 1d/1w siblings. Otherwise the + // monthly run gets isolated into a 1-variant cohort and + // every comparison degenerates. + byTestName := map[string][]BenchmarkRun{} + for _, r := range cohort { + byTestName[canonicalTestName(r.TestName)] = append(byTestName[canonicalTestName(r.TestName)], r) + } + for testName, sameNameRuns := range byTestName { + for _, kind := range cfg.kinds { + synthetic = append(synthetic, manufactureGroup(sameNameRuns, cfg.networkSubstr, testName, kind, now)...) + } + } + } + return synthetic +} + +// canonicalTestName strips the "[Monthly - ] " prefix that +// applyRetentionPolicy may have added so two runs of the same series +// land in the same cohort regardless of which retention bucket they +// were preserved under. The format is fixed by s3.go::applyRetentionPolicy +// and reproduced here intentionally — when that format changes, both +// places must update together. +func canonicalTestName(name string) string { + if !strings.HasPrefix(name, "[Monthly - ") { + return name + } + if end := strings.Index(name, "] "); end >= 0 { + return name[end+2:] + } + return name +} + +// pickedRun pairs a chosen source run with the bucket label it was +// chosen for. The label is meaningful only for time comparisons +// (where it's "1d"/"1w"/"1m"); for version comparisons it's empty +// because ClientVersion already differs across the picks and serves +// as the chart split axis. +type pickedRun struct { + run BenchmarkRun + bucket string +} + +// manufactureGroup picks the source runs that belong in one synthetic +// group, deep-clones them with a rewritten BenchmarkRun ID and a +// prefixed TestName, and stamps TimeBucket so the frontend has a +// testConfig axis to split chart series on. Returns nil when +// fewer than two distinct buckets are available — a one-bucket +// "comparison" would just clutter the dropdown. +func manufactureGroup(sourceRuns []BenchmarkRun, networkSubstr, testName string, kind comparisonKind, now time.Time) []BenchmarkRun { + var picked []pickedRun + var bucketCount int + var idLabel, prefix string + + switch kind { + case compareByTime: + picked, bucketCount = pickByTime(sourceRuns, now) + idLabel = "time" + prefix = "[Compare: Time]" + case compareByVersion: + picked, bucketCount = pickByVersion(sourceRuns) + idLabel = "version" + prefix = "[Compare: Versions]" + } + + if bucketCount < 2 { + return nil + } + + syntheticID := fmt.Sprintf("compare-%s-%s", idLabel, slugify(networkSubstr+"-"+testName)) + // The dropdown label in the frontend shows " - " + // for each run. For a synthetic group whose constituent runs span + // multiple createdAts, picking any one of them is misleading + // ("this comparison happened on 5/21" implies one moment in + // time, but a Compare:Time group by definition spans many). + // Stamp every clone's top-level createdAt to `now` so the + // dropdown reads as the comparison-view freshness time rather + // than an arbitrary source run's time. The actual per-run dates + syntheticCreatedAt := now + out := make([]BenchmarkRun, 0, len(picked)) + for _, p := range picked { + clone := cloneBenchmarkRun(p.run) + clone.TestConfig.BenchmarkRun = syntheticID + if p.bucket != "" { + clone.TestConfig.TimeBucket = p.bucket + } + clone.CreatedAt = &syntheticCreatedAt + if !strings.HasPrefix(clone.TestName, prefix) { + clone.TestName = prefix + " " + clone.TestName + } + out = append(out, clone) + } + return out +} + +// variantKey identifies the (payload, gasLimit, nodeType, blockTime) +// combination of a run. Within one synthetic group we keep at most +// one source run per variant per bucket so the chart series stay +// comparable — otherwise two runs with the same variant would +// produce duplicate lines that obscure the difference being +// compared. +func variantKey(r BenchmarkRun) string { + return fmt.Sprintf("%s|%d|%s|%d", + r.TestConfig.TransactionPayload, + r.TestConfig.GasLimit, + r.TestConfig.NodeType, + r.TestConfig.BlockTimeMilliseconds, + ) +} + +// pickByTime selects the most-recent run per (variant, bucket) and +// pairs each pick with its bucket label so manufactureGroup can stamp +// the label into testConfig. The returned bucketCount counts how many +// buckets actually had data, so the caller can decide whether the +// result is a real comparison. +func pickByTime(runs []BenchmarkRun, now time.Time) ([]pickedRun, int) { + latestPerKey := make([]map[string]BenchmarkRun, len(timeBuckets)) + for i := range latestPerKey { + latestPerKey[i] = map[string]BenchmarkRun{} + } + for _, r := range runs { + if r.CreatedAt == nil { + continue + } + age := now.Sub(*r.CreatedAt) + if age < 0 { + age = 0 + } + idx := -1 + for i, b := range timeBuckets { + if age >= b.Newer && age < b.Older { + idx = i + break + } + } + if idx == -1 { + continue + } + key := variantKey(r) + existing, ok := latestPerKey[idx][key] + if !ok || r.CreatedAt.After(*existing.CreatedAt) { + latestPerKey[idx][key] = r + } + } + var out []pickedRun + bucketCount := 0 + for i, m := range latestPerKey { + if len(m) == 0 { + continue + } + bucketCount++ + for _, r := range sortedByVariant(m) { + out = append(out, pickedRun{run: r, bucket: timeBuckets[i].Label}) + } + } + return out, bucketCount +} + +// pickByVersion selects the most-recent run per (variant, version). +// Returned bucketCount = number of distinct versions seen. The +// per-pick bucket label is left empty because the version itself +// (already in testConfig.ClientVersion) is the chart split axis. +func pickByVersion(runs []BenchmarkRun) ([]pickedRun, int) { + byVersion := map[string]map[string]BenchmarkRun{} + for _, r := range runs { + v := versionOf(r) + if v == "" || r.CreatedAt == nil { + continue + } + bucket, ok := byVersion[v] + if !ok { + bucket = map[string]BenchmarkRun{} + byVersion[v] = bucket + } + key := variantKey(r) + existing, exists := bucket[key] + if !exists || r.CreatedAt.After(*existing.CreatedAt) { + bucket[key] = r + } + } + var out []pickedRun + versions := make([]string, 0, len(byVersion)) + for v := range byVersion { + versions = append(versions, v) + } + sort.Strings(versions) + for _, v := range versions { + for _, r := range sortedByVariant(byVersion[v]) { + out = append(out, pickedRun{run: r}) + } + } + return out, len(byVersion) +} + +// versionOf prefers TestConfig["ClientVersion"] (set by the future +// base/benchmark injection) and falls back to result.clientVersion. +// "" means "no version info" and the run is dropped from version +// comparisons rather than being lumped under an empty-string bucket. +func versionOf(r BenchmarkRun) string { + if r.TestConfig.ClientVersion != "" { + return r.TestConfig.ClientVersion + } + return r.Result.ClientVersion +} + +func filterByNetwork(runs []BenchmarkRun, networkSubstr string) []BenchmarkRun { + if networkSubstr == "" { + return runs + } + needle := strings.ToLower(networkSubstr) + out := make([]BenchmarkRun, 0, len(runs)) + for _, r := range runs { + if strings.Contains(strings.ToLower(r.SourceFile), needle) { + out = append(out, r) + } + } + return out +} + +// sortedByVariant is a small determinism guarantee — chart series +// order shouldn't depend on Go map iteration order, otherwise a +// browser refresh might shuffle the rows in the existing UI's +// per-gas-limit grouping. +func sortedByVariant(m map[string]BenchmarkRun) []BenchmarkRun { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + out := make([]BenchmarkRun, 0, len(keys)) + for _, k := range keys { + out = append(out, m[k]) + } + return out +} + +// cloneBenchmarkRun returns a shallow-but-safe-for-our-mutations +// copy: the TestConfig struct is copied by value (all fields are +// scalar so no further work needed), and pointer-typed fields like +// CreatedAt are aliased — they're treated as immutable everywhere +// downstream, so sharing them is fine. If a downstream mutation ever +// touches CreatedAt, MachineInfo, or Thresholds, those fields must +// be deep-cloned here to avoid corrupting the source run. +func cloneBenchmarkRun(r BenchmarkRun) BenchmarkRun { + return BenchmarkRun{ + ID: r.ID, + SourceFile: r.SourceFile, + OutputDir: r.OutputDir, + TestName: r.TestName, + TestDescription: r.TestDescription, + TestConfig: r.TestConfig, + Result: r.Result, + Thresholds: r.Thresholds, + CreatedAt: r.CreatedAt, + BucketPath: r.BucketPath, + MachineInfo: r.MachineInfo, + ClientVersion: r.ClientVersion, + } +} + +// slugify lowercases its input and replaces any non-alphanumeric +// run with a single dash, trimming leading/trailing dashes. Used to +// build stable, URL-safe synthetic IDs from network + testName. +func slugify(s string) string { + var b strings.Builder + b.Grow(len(s)) + prevDash := true + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + b.WriteRune(r) + prevDash = false + default: + if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + } + out := b.String() + return strings.Trim(out, "-") +} diff --git a/server/internal/services/comparison_test.go b/server/internal/services/comparison_test.go new file mode 100644 index 00000000..2e99a06d --- /dev/null +++ b/server/internal/services/comparison_test.go @@ -0,0 +1,360 @@ +package services + +import ( + "strings" + "testing" + "time" +) + +func mkRun(id, source, payload string, gas int, version string, ageHours int, now time.Time) BenchmarkRun { + created := now.Add(-time.Duration(ageHours) * time.Hour) + return BenchmarkRun{ + ID: id, + SourceFile: source, + OutputDir: id + "-out", + TestName: "Mainnet Performance Benchmark", + TestConfig: BenchmarkTestConfig{ + BenchmarkRun: id, + BlockTimeMilliseconds: 1000, + GasLimit: gas, + NodeType: "builder", + TransactionPayload: payload, + ClientVersion: version, + }, + Result: BenchmarkResult{Success: true, Complete: true, ClientVersion: version}, + CreatedAt: &created, + } +} + +func TestSynthesize_VersionGroupForMainnet(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2.0.0", 4, now), + } + synth := synthesizeComparisonGroups(runs, now) + + versionGroup := filterByBenchmarkRunPrefix(synth, "compare-version-mainnet") + if len(versionGroup) != 2 { + t.Fatalf("want 2 synthetic version-group runs, got %d", len(versionGroup)) + } + for _, r := range versionGroup { + if !strings.HasPrefix(r.TestName, "[Compare: Versions]") { + t.Errorf("synthetic run TestName %q missing [Compare: Versions] prefix", r.TestName) + } + } +} + +func TestSynthesize_TimeGroupForMainnet(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("hot", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("warm", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 72, now), + } + synth := synthesizeComparisonGroups(runs, now) + + timeGroup := filterByBenchmarkRunPrefix(synth, "compare-time-mainnet") + if len(timeGroup) != 2 { + t.Fatalf("want 2 synthetic time-group runs (one per bucket), got %d", len(timeGroup)) + } + for _, r := range timeGroup { + if !strings.HasPrefix(r.TestName, "[Compare: Time]") { + t.Errorf("synthetic run TestName %q missing [Compare: Time] prefix", r.TestName) + } + } +} + +func TestSynthesize_DropsSingleBucketComparison(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + // All runs at the same version → version comparison should be skipped. + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now), + mkRun("b", "./mainnet-config.yml", "storage-create", 150_000_000, "v1.0.0", 4, now), + } + synth := synthesizeComparisonGroups(runs, now) + if got := len(filterByBenchmarkRunPrefix(synth, "compare-version-mainnet")); got != 0 { + t.Errorf("single-version cohort should produce no version-comparison runs, got %d", got) + } +} + +func TestSynthesize_DoesNotMutateSource(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + source := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2.0.0", 4, now), + } + originalIDs := []string{source[0].TestConfig.BenchmarkRun, source[1].TestConfig.BenchmarkRun} + originalNames := []string{source[0].TestName, source[1].TestName} + + _ = synthesizeComparisonGroups(source, now) + + for i, r := range source { + if r.TestConfig.BenchmarkRun != originalIDs[i] { + t.Errorf("source run %d BenchmarkRun mutated: %q != %q", i, r.TestConfig.BenchmarkRun, originalIDs[i]) + } + if r.TestName != originalNames[i] { + t.Errorf("source run %d TestName mutated: %q != %q", i, r.TestName, originalNames[i]) + } + } +} + +func TestSynthesize_VersionGroupSkipsRunsWithoutVersion(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2.0.0", 4, now), + mkRun("c", "./mainnet-config.yml", "transfer-only", 150_000_000, "", 4, now), + } + runs[2].Result.ClientVersion = "" + synth := synthesizeComparisonGroups(runs, now) + versionGroup := filterByBenchmarkRunPrefix(synth, "compare-version-mainnet") + if len(versionGroup) != 2 { + t.Fatalf("want 2 runs (one per non-empty version), got %d", len(versionGroup)) + } +} + +func TestSynthesize_LatestPerVariantPerVersion(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + // Two runs of v1.0.0 same variant — synthetic group should keep only the newer. + mkRun("v1-old", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 48, now), + mkRun("v1-new", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 6, now), + mkRun("v2", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2.0.0", 24, now), + } + synth := synthesizeComparisonGroups(runs, now) + versionGroup := filterByBenchmarkRunPrefix(synth, "compare-version-mainnet") + if len(versionGroup) != 2 { + t.Fatalf("want 2 runs (latest per version), got %d", len(versionGroup)) + } + gotIDs := map[string]bool{} + for _, r := range versionGroup { + gotIDs[r.ID] = true + } + if gotIDs["v1-old"] { + t.Errorf("v1-old should have been replaced by v1-new") + } + if !gotIDs["v1-new"] || !gotIDs["v2"] { + t.Errorf("expected v1-new and v2 in synthetic group, got %v", gotIDs) + } +} + +func TestSynthesize_NetworkScoping(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2", 4, now), + // devnet runs must not leak into the mainnet synthetic group. + mkRun("c", "./devnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("d", "./devnet-config.yml", "transfer-only", 150_000_000, "v2", 4, now), + } + synth := synthesizeComparisonGroups(runs, now) + + mainnet := filterByBenchmarkRunPrefix(synth, "compare-version-mainnet") + devnet := filterByBenchmarkRunPrefix(synth, "compare-version-devnet") + + for _, r := range mainnet { + if !strings.Contains(r.SourceFile, "mainnet") { + t.Errorf("mainnet group leaked a non-mainnet run: %s", r.SourceFile) + } + } + for _, r := range devnet { + if !strings.Contains(r.SourceFile, "devnet") { + t.Errorf("devnet group leaked a non-devnet run: %s", r.SourceFile) + } + } + if len(mainnet) == 0 || len(devnet) == 0 { + t.Fatalf("both networks should have produced groups; got mainnet=%d devnet=%d", len(mainnet), len(devnet)) + } +} + +func TestSynthesize_StableID(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2", 4, now), + } + first := synthesizeComparisonGroups(runs, now) + second := synthesizeComparisonGroups(runs, now.Add(7*24*time.Hour)) + + id1 := first[0].TestConfig.BenchmarkRun + id2 := second[0].TestConfig.BenchmarkRun + if id1 != id2 { + t.Errorf("synthetic IDs must be stable across calls; got %q vs %q", id1, id2) + } +} + +// TestSynthesize_TimeGroupStampsBucketAndDate covers the bug where +// time-comparison runs were indistinguishable to the frontend's +// chart split-by. Every run in a time-comparison group must carry +// distinct values in at least one testConfig key, otherwise the +// frontend has nothing to plot as separate series. +func TestSynthesize_TimeGroupStampsBucketAndDate(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("hot", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("warm", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 72, now), + mkRun("cold", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 14*24, now), + } + synth := synthesizeComparisonGroups(runs, now) + timeGroup := filterByBenchmarkRunPrefix(synth, "compare-time-mainnet") + if len(timeGroup) != 3 { + t.Fatalf("want 3 runs across 1d/1w/1m buckets, got %d", len(timeGroup)) + } + + buckets := map[string]bool{} + for _, r := range timeGroup { + if r.TestConfig.TimeBucket == "" { + t.Errorf("synthetic time-group run is missing TimeBucket: %+v", r) + } + buckets[r.TestConfig.TimeBucket] = true + } + if len(buckets) != 3 { + t.Errorf("expected 3 distinct TimeBucket values (1d/1w/1m), got %d: %v", len(buckets), buckets) + } + for _, want := range []string{"1d", "1w", "1m"} { + if !buckets[want] { + t.Errorf("missing TimeBucket=%q", want) + } + } +} + +// TestSynthesize_VersionGroupOmitsTimeBucket confirms that +// version-comparison runs do NOT get TimeBucket stamped (it would be +// misleading — the split axis is ClientVersion, not a time window). +func TestSynthesize_VersionGroupOmitsTimeBucket(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2.0.0", 4, now), + } + synth := synthesizeComparisonGroups(runs, now) + versionGroup := filterByBenchmarkRunPrefix(synth, "compare-version-mainnet") + if len(versionGroup) != 2 { + t.Fatalf("want 2 runs (one per version), got %d", len(versionGroup)) + } + for _, r := range versionGroup { + if r.TestConfig.TimeBucket != "" { + t.Errorf("version-group run should NOT have TimeBucket; got %q on run %s", r.TestConfig.TimeBucket, r.ID) + } + } +} + +// TestSynthesize_SourceRunsLackStampedFields verifies the omitempty +// boundary: TimeBucket must never appear on the source runs (the +// natural per-run pages), only on synthetic clones. A leak here +// would surface phantom dropdown values on natural-run pages. +func TestSynthesize_SourceRunsLackStampedFields(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + source := []BenchmarkRun{ + mkRun("a", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("b", "./mainnet-config.yml", "transfer-only", 150_000_000, "v2", 4, now), + } + _ = synthesizeComparisonGroups(source, now) + + for _, r := range source { + if r.TestConfig.TimeBucket != "" { + t.Errorf("source run %s has unexpected TimeBucket=%q after synthesis", r.ID, r.TestConfig.TimeBucket) + } + } +} + +// TestSynthesize_ClonesShareNowCreatedAt covers the dropdown-label +// fix: synthetic-group clones must all carry the same `now`-time +// createdAt so the frontend's dropdown entry shows one coherent +// timestamp (the comparison view's freshness) instead of an +// arbitrary source run's timestamp. +func TestSynthesize_ClonesShareNowCreatedAt(t *testing.T) { + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + runs := []BenchmarkRun{ + mkRun("hot", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 4, now), + mkRun("warm", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1", 72, now), + } + synth := synthesizeComparisonGroups(runs, now) + timeGroup := filterByBenchmarkRunPrefix(synth, "compare-time-mainnet") + if len(timeGroup) != 2 { + t.Fatalf("want 2 time-group runs, got %d", len(timeGroup)) + } + for _, r := range timeGroup { + if r.CreatedAt == nil { + t.Fatalf("synthetic run missing CreatedAt: %+v", r) + } + if !r.CreatedAt.Equal(now) { + t.Errorf("synthetic run CreatedAt should be now=%v, got %v (run %s)", now, *r.CreatedAt, r.ID) + } + } + // Source runs must still carry their original CreatedAts. + if runs[0].CreatedAt.Equal(now) { + t.Error("source run CreatedAt was overwritten — clones must not leak back into sources") + } +} + +func TestSynthesize_MonthlyPrefixDoesNotIsolateRuns(t *testing.T) { + // Regression: applyRetentionPolicy adds a "[Monthly - ] " + // prefix to runs preserved in the older retention bucket. If the + // synthesizer groups by raw TestName, those prefixed runs end up + // in a 1-variant cohort and never participate in any comparison. + now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC) + monthly := mkRun("monthly", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 14*24, now) + monthly.TestName = "[Monthly - May 2026] Mainnet Performance Benchmark" + recent := mkRun("recent", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 4, now) + recent.TestName = "Mainnet Performance Benchmark" + weekRun := mkRun("week", "./mainnet-config.yml", "transfer-only", 150_000_000, "v1.0.0", 72, now) + weekRun.TestName = "Mainnet Performance Benchmark" + + synth := synthesizeComparisonGroups([]BenchmarkRun{monthly, recent, weekRun}, now) + + timeGroup := filterByBenchmarkRunPrefix(synth, "compare-time-mainnet") + if len(timeGroup) != 3 { + t.Fatalf("want 3 runs across 1d/1w/1m buckets after canonicalization, got %d", len(timeGroup)) + } + gotIDs := map[string]bool{} + for _, r := range timeGroup { + gotIDs[r.ID] = true + } + for _, want := range []string{"monthly", "recent", "week"} { + if !gotIDs[want] { + t.Errorf("synthetic time group should include source run %q (was the [Monthly] prefix isolating it?)", want) + } + } +} + +func TestCanonicalTestName(t *testing.T) { + cases := []struct{ in, want string }{ + {"Base Mainnet Performance", "Base Mainnet Performance"}, + {"[Monthly - May 2026] Base Mainnet Performance", "Base Mainnet Performance"}, + {"[Monthly - Mar 2026] Anything Goes Here", "Anything Goes Here"}, + {"[Monthly - malformed", "[Monthly - malformed"}, + {"", ""}, + } + for _, c := range cases { + if got := canonicalTestName(c.in); got != c.want { + t.Errorf("canonicalTestName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestSlugify(t *testing.T) { + cases := []struct{ in, want string }{ + {"mainnet-Base Mainnet Performance", "mainnet-base-mainnet-performance"}, + {" ___weird chars!!!", "weird-chars"}, + {"---", ""}, + {"already-slug", "already-slug"}, + } + for _, c := range cases { + if got := slugify(c.in); got != c.want { + t.Errorf("slugify(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// filterByBenchmarkRunPrefix is a test helper: returns the runs whose +// synthetic BenchmarkRun ID starts with the given prefix. +func filterByBenchmarkRunPrefix(runs []BenchmarkRun, prefix string) []BenchmarkRun { + var out []BenchmarkRun + for _, r := range runs { + if strings.HasPrefix(r.TestConfig.BenchmarkRun, prefix) { + out = append(out, r) + } + } + return out +} diff --git a/server/internal/services/s3.go b/server/internal/services/s3.go new file mode 100644 index 00000000..6995e875 --- /dev/null +++ b/server/internal/services/s3.go @@ -0,0 +1,553 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/s3" + "github.com/ethereum/go-ethereum/log" +) + +// BenchmarkRuns represents the metadata structure +type BenchmarkRuns struct { + Runs []BenchmarkRun `json:"runs"` + CreatedAt *time.Time `json:"createdAt"` +} + +type BenchmarkTestConfig struct { + BenchmarkRun string `json:"BenchmarkRun"` + BlockTimeMilliseconds int `json:"BlockTimeMilliseconds"` + GasLimit int `json:"GasLimit"` + NodeType string `json:"NodeType"` + TransactionPayload string `json:"TransactionPayload"` + // ClientVersion is populated by base/benchmark when it learns + // the EL binary's version (via web3_clientVersion or the + // BASE_BENCH_CLIENT_VERSION env override). Empty for runs + // produced before that injection landed. + ClientVersion string `json:"ClientVersion,omitempty"` + // TimeBucket is stamped onto synthetic comparison-group clones + // by services/comparison.go so the frontend's auto-discovered + // "Show Line Per" dropdown has a meaningful axis to split time + // comparisons on ("1d" / "1w" / "1m"). Always absent on natural + // per-run pages — the omitempty tag ensures it doesn't appear in + // the JSON for source runs that never had it. + TimeBucket string `json:"TimeBucket,omitempty"` +} + +type SequencerMetrics struct { + GasPerSecond float64 `json:"gasPerSecond"` + ForkChoiceUpdated float64 `json:"forkChoiceUpdated"` + GetPayload float64 `json:"getPayload"` + SendTxs float64 `json:"sendTxs"` +} + +type ValidatorMetrics struct { + GasPerSecond float64 `json:"gasPerSecond"` + NewPayload float64 `json:"newPayload"` +} + +type BenchmarkResult struct { + Success bool `json:"success"` + Complete bool `json:"complete"` + SequencerMetrics SequencerMetrics `json:"sequencerMetrics"` + ValidatorMetrics ValidatorMetrics `json:"validatorMetrics"` + ClientVersion string `json:"clientVersion,omitempty"` +} + +type MachineInfo struct { + Type string `json:"type"` + Provider string `json:"provider"` + Region string `json:"region"` + FileSystem string `json:"fileSystem"` +} + +// BenchmarkRun represents a single benchmark run +type BenchmarkRun struct { + ID string `json:"id"` + SourceFile string `json:"sourceFile"` + OutputDir string `json:"outputDir"` + TestName string `json:"testName"` + TestDescription string `json:"testDescription"` + TestConfig BenchmarkTestConfig `json:"testConfig"` + Result BenchmarkResult `json:"result"` + Thresholds interface{} `json:"thresholds"` + CreatedAt *time.Time `json:"createdAt"` + BucketPath string `json:"bucketPath,omitempty"` + MachineInfo MachineInfo `json:"machineInfo,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` +} + +// metadataObject is one /metadata.json object as returned +// by the S3 listing. etag is the object's ETag; it changes whenever +// the file is overwritten, so it serves as the per-file identity for +// cache invalidation. +type metadataObject struct { + key string + etag string +} + +// metadataCache holds the merged result and a per-file byte cache. +// +// Per-file cache (files): stores raw bytes keyed on "key|etag" so a +// rewritten metadata.json (same key, new ETag) is treated as a cache +// miss and re-fetched. Bounded at maxFiles entries (~3 MB at ~1.5 KB +// each) with FIFO eviction. +// +// Merged result (cachedResult): stores the fully-processed +// *BenchmarkRuns. Fingerprint-invalidated on new/changed objects AND +// time-expired after mergedTTL because the result depends on +// time.Now() through applyRetentionPolicy and +// synthesizeComparisonGroups (1d/1w/1m buckets). Without the TTL, +// the comparison buckets would be pinned to the first rebuild +// forever if no new runs land. +// +// mu protects all fields. RLock on the hot path, Lock for rebuilds. +type metadataCache struct { + mu sync.RWMutex + cachedResult *BenchmarkRuns + cachedKeyFingerprint string + cachedAt time.Time + mergedTTL time.Duration + files map[string][]byte + fileOrder []string + maxFiles int +} + +type S3Service struct { + client *s3.S3 + bucketName string + cache *MemoryCache + metadataCache metadataCache + l log.Logger +} + +// NewS3Service creates a new S3 service instance. +// +// endpoint is optional. When non-empty, it overrides the default AWS S3 +// endpoint and forces path-style addressing — required for MinIO and +// other S3-compatible stores. Production AWS deployments leave it empty. +func NewS3Service(bucketName, region, endpoint string, cache *MemoryCache, l log.Logger) (*S3Service, error) { + if bucketName == "" { + return nil, fmt.Errorf("S3 bucket name is required") + } + + awsCfg := &aws.Config{ + Region: aws.String(region), + } + if endpoint != "" { + awsCfg.Endpoint = aws.String(endpoint) + awsCfg.S3ForcePathStyle = aws.Bool(true) + } + + sess, err := session.NewSession(awsCfg) + if err != nil { + return nil, fmt.Errorf("failed to create AWS session: %w", err) + } + + const ( + defaultMaxMetadataFiles = 2000 + defaultMergedTTL = time.Hour + ) + + return &S3Service{ + client: s3.New(sess), + bucketName: bucketName, + cache: cache, + metadataCache: metadataCache{ + files: make(map[string][]byte, defaultMaxMetadataFiles), + maxFiles: defaultMaxMetadataFiles, + mergedTTL: defaultMergedTTL, + }, + l: l, + }, nil +} + +// GetObject retrieves an object from S3 with caching +func (s *S3Service) GetObject(key string) ([]byte, error) { + // Check cache first if available + if s.cache != nil { + if cached, hit := s.cache.Get(key); hit { + s.l.Debug("Cache hit", "key", key) + return cached, nil + } + } + + s.l.Debug("Fetching from S3", "key", key, "bucket", s.bucketName) + + result, err := s.client.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to get object %s: %w", key, err) + } + defer result.Body.Close() + + // Read the entire body + data, err := io.ReadAll(result.Body) + if err != nil { + return nil, fmt.Errorf("failed to read object data: %w", err) + } + + // Cache the result if cache is available + if s.cache != nil { + s.cache.Set(key, data) + } + + return data, nil +} + +// GetMetadata returns the merged benchmark metadata. Results are +// cached by the content fingerprint of the actual metadata.json S3 +// objects (key + ETag), not just the prefix list. This means: +// - A new run (new key) invalidates the fingerprint immediately. +// - An overwritten metadata.json (new ETag on the same key) also +// invalidates, so resubmitted or corrected runs are never missed. +// - The merged result also expires after mergedTTL (default 1h) +// because it depends on time.Now() via applyRetentionPolicy and +// synthesizeComparisonGroups. +func (s *S3Service) GetMetadata() (*BenchmarkRuns, error) { + objects, err := s.listMetadataObjects() + if err != nil { + return nil, fmt.Errorf("failed to list metadata files: %w", err) + } + + fingerprint := metadataObjectsFingerprint(objects) + + s.metadataCache.mu.RLock() + cacheValid := s.metadataCache.cachedResult != nil && + s.metadataCache.cachedKeyFingerprint == fingerprint && + time.Since(s.metadataCache.cachedAt) < s.metadataCache.mergedTTL + if cacheValid { + result := s.metadataCache.cachedResult + s.metadataCache.mu.RUnlock() + s.l.Debug("Serving metadata from cache", "runs", len(result.Runs)) + return result, nil + } + s.metadataCache.mu.RUnlock() + + s.l.Info("Rebuilding metadata from S3", "objects", len(objects)) + + s.metadataCache.mu.Lock() + defer s.metadataCache.mu.Unlock() + + // Re-check under the write lock — another goroutine may have + // rebuilt while we waited to acquire it. + cacheValid = s.metadataCache.cachedResult != nil && + s.metadataCache.cachedKeyFingerprint == fingerprint && + time.Since(s.metadataCache.cachedAt) < s.metadataCache.mergedTTL + if cacheValid { + return s.metadataCache.cachedResult, nil + } + + s.l.Info("Found per-run metadata files", "count", len(objects)) + + var allRuns []BenchmarkRun + fetched := 0 + cacheHits := 0 + for _, obj := range objects { + fileKey := obj.key + "|" + obj.etag + var data []byte + if cached, ok := s.metadataCache.files[fileKey]; ok { + data = cached + cacheHits++ + } else { + var fetchErr error + data, fetchErr = s.getObjectDirect(obj.key) + if fetchErr != nil { + s.l.Warn("Failed to fetch metadata file, skipping", "key", obj.key, "error", fetchErr) + continue + } + s.metadataCache.setFile(fileKey, data) + fetched++ + } + + var meta BenchmarkRuns + if err := json.Unmarshal(data, &meta); err != nil { + s.l.Warn("Failed to parse metadata file, skipping", "key", obj.key, "error", err) + continue + } + + allRuns = append(allRuns, meta.Runs...) + } + s.l.Info("Metadata file fetch complete", "fetched", fetched, "cacheHits", cacheHits) + + // Deduplicate by compound key (ID + outputDir), keeping the last occurrence + seen := make(map[string]int) // compound key -> index in deduped slice + var deduped []BenchmarkRun + for _, run := range allRuns { + key := run.ID + "|" + run.OutputDir + if idx, exists := seen[key]; exists { + // Replace with newer version + deduped[idx] = run + } else { + seen[key] = len(deduped) + deduped = append(deduped, run) + } + } + + // Sort chronologically by CreatedAt + sort.Slice(deduped, func(i, j int) bool { + if deduped[i].CreatedAt == nil && deduped[j].CreatedAt == nil { + return false + } + if deduped[i].CreatedAt == nil { + return true + } + if deduped[j].CreatedAt == nil { + return false + } + return deduped[i].CreatedAt.Before(*deduped[j].CreatedAt) + }) + + // Apply retention policy + deduped = s.applyRetentionPolicy(deduped) + + now := time.Now() + + // Append synthetic comparison groups so the existing frontend + // dropdown surfaces multi-version and multi-time-window views + // alongside the natural BenchmarkRun IDs. See comparison.go. + deduped = append(deduped, synthesizeComparisonGroups(deduped, now)...) + + metadata := &BenchmarkRuns{ + Runs: deduped, + CreatedAt: &now, + } + + s.metadataCache.cachedResult = metadata + s.metadataCache.cachedKeyFingerprint = fingerprint + s.metadataCache.cachedAt = now + + s.l.Info("Built merged metadata", "totalRuns", len(deduped), "metadataFiles", len(objects)) + return metadata, nil +} + +// setFile stores raw bytes under the given cache key (key|etag). +// When at capacity, the oldest-inserted entry is evicted. +func (mc *metadataCache) setFile(key string, data []byte) { + if _, exists := mc.files[key]; !exists { + if len(mc.files) >= mc.maxFiles && len(mc.fileOrder) > 0 { + oldest := mc.fileOrder[0] + mc.fileOrder = mc.fileOrder[1:] + delete(mc.files, oldest) + } + mc.fileOrder = append(mc.fileOrder, key) + } + mc.files[key] = data +} + +// nonRunPrefixes are top-level S3 prefixes that don't represent +// benchmark runs and must be excluded from the metadata listing. +// "metadata/" is the legacy pre-migration location for the central +// metadata files (kept here defensively so it's ignored even if some +// legacy data isn't cleaned up); "load-tests/" is the load-test +// storage served by a separate handler. +var nonRunPrefixes = map[string]bool{ + "metadata/": true, + "load-tests/": true, +} + +// listMetadataObjects lists every /metadata.json object +// that actually exists in the bucket, along with its ETag. Only +// existing objects are returned — in-progress runs whose +// metadata.json hasn't landed yet are absent from the list and +// therefore invisible to the merger (this is the commit-signal +// property of the per-run layout). Prefixes in nonRunPrefixes are +// skipped. +func (s *S3Service) listMetadataObjects() ([]metadataObject, error) { + var objects []metadataObject + + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucketName), + } + + err := s.client.ListObjectsV2Pages(input, func(page *s3.ListObjectsV2Output, lastPage bool) bool { + for _, obj := range page.Contents { + if obj.Key == nil || !strings.HasSuffix(*obj.Key, "/metadata.json") { + continue + } + key := *obj.Key + prefix := key[:strings.Index(key, "/")+1] + if nonRunPrefixes[prefix] { + continue + } + etag := "" + if obj.ETag != nil { + etag = strings.Trim(*obj.ETag, `"`) + } + objects = append(objects, metadataObject{key: key, etag: etag}) + } + return true + }) + if err != nil { + return nil, fmt.Errorf("failed to list metadata objects: %w", err) + } + + return objects, nil +} + +// metadataObjectsFingerprint returns a string that changes whenever +// the set of metadata.json objects changes — either a new key +// appearing or an existing key's ETag changing (file overwritten). +func metadataObjectsFingerprint(objects []metadataObject) string { + parts := make([]string, len(objects)) + for i, o := range objects { + parts[i] = o.key + "|" + o.etag + } + sort.Strings(parts) + return strings.Join(parts, "\n") +} + +// getObjectDirect fetches an S3 object without caching (used for individual metadata files) +func (s *S3Service) getObjectDirect(key string) ([]byte, error) { + result, err := s.client.GetObject(&s3.GetObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to get object %s: %w", key, err) + } + defer result.Body.Close() + + return io.ReadAll(result.Body) +} + +// applyRetentionPolicy filters runs according to the retention policy: +// - Keep all runs from the past 2 weeks +// - Keep one run per month for the past 6 months (the run closest to the 1st of each month) +// - Drop everything older +// Retained monthly runs get their TestName prefixed with the month label. +func (s *S3Service) applyRetentionPolicy(runs []BenchmarkRun) []BenchmarkRun { + now := time.Now() + recentCutoff := now.AddDate(0, 0, -14) // 2 weeks ago + monthlyCutoff := now.AddDate(0, -6, 0) // 6 months ago + + var recentRuns []BenchmarkRun + + type monthCandidate struct { + run BenchmarkRun + distance time.Duration + } + monthBuckets := make(map[string]*monthCandidate) + + for _, run := range runs { + if run.CreatedAt == nil { + s.l.Debug("Dropping run with nil CreatedAt during retention", "runID", run.ID) + continue + } + + if !run.CreatedAt.Before(recentCutoff) { + recentRuns = append(recentRuns, run) + continue + } + + if run.CreatedAt.Before(monthlyCutoff) { + s.l.Debug("Dropping run outside retention window", "runID", run.ID, "createdAt", run.CreatedAt) + continue + } + + // Between 2 weeks and 6 months — pick one per month (closest to 1st of month) + monthKey := run.CreatedAt.Format("2006-01") + firstOfMonth := time.Date(run.CreatedAt.Year(), run.CreatedAt.Month(), 1, 0, 0, 0, 0, run.CreatedAt.Location()) + dist := run.CreatedAt.Sub(firstOfMonth) + if dist < 0 { + dist = -dist + } + + existing, ok := monthBuckets[monthKey] + if !ok || dist < existing.distance { + monthBuckets[monthKey] = &monthCandidate{run: run, distance: dist} + } + } + + // Build the monthly runs with prefixed TestName + var monthlyRuns []BenchmarkRun + for monthKey, candidate := range monthBuckets { + run := candidate.run + t, _ := time.Parse("2006-01", monthKey) + label := t.Format("Jan 2006") + prefix := fmt.Sprintf("[Monthly - %s] ", label) + if !strings.HasPrefix(run.TestName, "[Monthly") { + run.TestName = prefix + run.TestName + } + monthlyRuns = append(monthlyRuns, run) + } + + // Sort monthly runs chronologically + sort.Slice(monthlyRuns, func(i, j int) bool { + return monthlyRuns[i].CreatedAt.Before(*monthlyRuns[j].CreatedAt) + }) + + // Combine: monthly (older) first, then recent + result := append(monthlyRuns, recentRuns...) + + s.l.Info("Applied retention policy", "input", len(runs), "kept", len(result), + "recent", len(recentRuns), "monthly", len(monthlyRuns)) + + return result +} + +// GetMetrics retrieves metrics data for a specific run and node type. +// The S3 key is /metrics-.json since run files are +// uploaded flat under their outputDir prefix. +func (s *S3Service) GetMetrics(outputDir, nodeType string) ([]byte, error) { + key := fmt.Sprintf("%s/metrics-%s.json", outputDir, nodeType) + return s.GetObject(key) +} + +// LoadTestEntry represents a single load test run stored in S3. +type LoadTestEntry struct { + Network string `json:"network"` + Timestamp string `json:"timestamp"` +} + +// ListLoadTests lists all load test result timestamps for a given network, +// ordered newest-first. +func (s *S3Service) ListLoadTests(network string) ([]LoadTestEntry, error) { + prefix := fmt.Sprintf("load-tests/%s/", network) + var entries []LoadTestEntry + + input := &s3.ListObjectsV2Input{ + Bucket: aws.String(s.bucketName), + Prefix: aws.String(prefix), + } + + err := s.client.ListObjectsV2Pages(input, func(page *s3.ListObjectsV2Output, lastPage bool) bool { + for _, obj := range page.Contents { + if obj.Key == nil || !strings.HasSuffix(*obj.Key, ".json") { + continue + } + parts := strings.Split(*obj.Key, "/") + filename := parts[len(parts)-1] + timestamp := strings.TrimSuffix(filename, ".json") + entries = append(entries, LoadTestEntry{ + Network: network, + Timestamp: timestamp, + }) + } + return true + }) + if err != nil { + return nil, fmt.Errorf("failed to list load test results: %w", err) + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Timestamp > entries[j].Timestamp + }) + + return entries, nil +} + +// GetLoadTest fetches a single load test result JSON from S3. +func (s *S3Service) GetLoadTest(network, timestamp string) ([]byte, error) { + key := fmt.Sprintf("load-tests/%s/%s.json", network, timestamp) + return s.GetObject(key) +} From e4aadfb6ff80506ff0ed731e511ec636a6d97da4 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Thu, 4 Jun 2026 11:34:33 -0700 Subject: [PATCH 2/6] docs(server): add README for the report server --- server/README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 server/README.md diff --git a/server/README.md b/server/README.md new file mode 100644 index 00000000..e64a7861 --- /dev/null +++ b/server/README.md @@ -0,0 +1,46 @@ +# Report server + +HTTP server that dynamically assembles the merged `GET /output/metadata.json` +response consumed by the React report UI in `report/`. + +There is no central metadata file. The server reads individual +`/metadata.json` files from S3 on each request, merges them, +applies retention, and appends synthetic comparison groups. A two-level +cache (per-file by ETag + merged result with 1h TTL) keeps the hot path +to a single S3 `ListObjectsV2` call. + +## Build + +```bash +make build-server # outputs ./bin/report-server +``` + +## Run locally + +See `../local-stack/` in the `base-benchmarking-version-comparison` project for +a docker-compose setup that loads S3 data into MinIO and runs the server +against it. + +```bash +export BASE_BENCH_API_S3_BUCKET= +export BASE_BENCH_API_S3_ENDPOINT=http://localhost:9000 # MinIO only +export AWS_ACCESS_KEY_ID=... +export AWS_SECRET_ACCESS_KEY=... + +./bin/report-server +``` + +## Endpoints + +| Endpoint | Description | +|---|---| +| `GET /output/metadata.json` | Merged + retained + comparison-synthesized run list | +| `GET /output//metrics-.json` | Per-block timeseries for one run | +| `GET /api/v1/load-tests/:network` | Load test run list | +| `GET /api/v1/load-tests/:network/:timestamp` | Single load test result | +| `GET /api/v1/health` | Health check | + +## Data contract + +See `docs/report-data-contract.md` for the S3 layout the server expects and +what producers must write to be visible in the report. From 945a9970c6249cb06f0347539ad4686beedd6d56 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 5 Jun 2026 10:39:21 -0700 Subject: [PATCH 3/6] feat(server): add local filesystem backend (--local-dir) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allows the report server to serve directly from the directory written by 'base-bench run --output-dir ' without any S3 or MinIO setup. This makes local development a one-command flow: report-server --local-dir ./output The same merge/dedup/retention/comparison-synthesis pipeline runs against local files as against S3, so reports look identical regardless of backend. Changes: - BackendStorage interface: extracted from S3Service so handlers work against either backend - LocalService: reads /metadata.json + metrics files from a local directory tree. Uses file mtime as the ETag equivalent for cache invalidation — a newly written metadata.json is visible on the next request. - --local-dir flag (env: BASE_BENCH_API_LOCAL_DIR): mutually exclusive with --s3-bucket; both validated at startup - S3BucketFlag: Required: true removed (validation moved to Validate()) - mergeRuns() + applyRetentionPolicy(): promoted to package-level functions so both S3Service and LocalService share the pipeline - 6 new LocalService unit tests: GetMetadata, cache hit, cache invalidation on new file, GetObject, invalid dir, load tests Verified: server starts with --local-dir, health returns 200, metadata.json returns runs from local files with comparison groups. --- server/README.md | 25 ++- server/cmd/main.go | 50 +++--- server/internal/config/config.go | 9 +- server/internal/config/flags.go | 13 +- server/internal/handlers/loadtest.go | 4 +- server/internal/handlers/metadata.go | 2 +- server/internal/handlers/metrics.go | 5 +- server/internal/services/backend.go | 16 ++ server/internal/services/local.go | 195 +++++++++++++++++++++++ server/internal/services/local_test.go | 206 +++++++++++++++++++++++++ server/internal/services/s3.go | 98 ++++++------ 11 files changed, 534 insertions(+), 89 deletions(-) create mode 100644 server/internal/services/backend.go create mode 100644 server/internal/services/local.go create mode 100644 server/internal/services/local_test.go diff --git a/server/README.md b/server/README.md index e64a7861..9f669f20 100644 --- a/server/README.md +++ b/server/README.md @@ -17,9 +17,26 @@ make build-server # outputs ./bin/report-server ## Run locally -See `../local-stack/` in the `base-benchmarking-version-comparison` project for -a docker-compose setup that loads S3 data into MinIO and runs the server -against it. +### Against a local output directory (no S3 required) + +Point the server at the directory written by `base-bench run --output-dir`: + +```bash +# Run a benchmark +./bin/base-bench run \ + --config configs/local-devnet-config.yml \ + --output-dir ./output \ + --builder-bin ./bin/base-builder \ + --base-node-reth-bin ./bin/base-node-reth + +# Start the report server against that output +./bin/report-server --local-dir ./output + +# Open the report UI (in another shell) +cd report && VITE_DATA_SOURCE=api VITE_API_BASE_URL=http://localhost:8080/ yarn dev +``` + +### Against S3 / MinIO ```bash export BASE_BENCH_API_S3_BUCKET= @@ -30,6 +47,8 @@ export AWS_SECRET_ACCESS_KEY=... ./bin/report-server ``` +`--s3-bucket` and `--local-dir` are mutually exclusive. + ## Endpoints | Endpoint | Description | diff --git a/server/cmd/main.go b/server/cmd/main.go index 7e4111d4..b4d255b8 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -48,24 +48,34 @@ func Main() cliapp.LifecycleAction { opservice.ValidateEnvVars(config.EnvVarPrefix, config.CLIFlags(), l) - // Setup logging using the config method - l.Info("Starting benchmark report API server", - "port", cfg.Port, - "bucket", cfg.S3Bucket, - "region", cfg.S3Region, - "cache", cfg.EnableCache, - "cacheTTL", cfg.CacheTTL) - - // Initialize services - cache := services.NewMemoryCache(cfg.CacheTTL, l) - if !cfg.EnableCache { - cache = services.NewMemoryCache(0, l) // Disable caching - } - - s3Service, err := services.NewS3Service(cfg.S3Bucket, cfg.S3Region, cfg.S3Endpoint, cache, l) - if err != nil { - l.Error("Failed to initialize S3 service", "error", err) - return nil, err + var backend services.BackendStorage + if cfg.LocalDir != "" { + l.Info("Starting benchmark report server (local mode)", + "port", cfg.Port, + "localDir", cfg.LocalDir) + svc, err := services.NewLocalService(cfg.LocalDir, l) + if err != nil { + l.Error("Failed to initialize local service", "error", err) + return nil, err + } + backend = svc + } else { + l.Info("Starting benchmark report server (S3 mode)", + "port", cfg.Port, + "bucket", cfg.S3Bucket, + "region", cfg.S3Region, + "cache", cfg.EnableCache, + "cacheTTL", cfg.CacheTTL) + cache := services.NewMemoryCache(cfg.CacheTTL, l) + if !cfg.EnableCache { + cache = services.NewMemoryCache(0, l) + } + svc, err := services.NewS3Service(cfg.S3Bucket, cfg.S3Region, cfg.S3Endpoint, cache, l) + if err != nil { + l.Error("Failed to initialize S3 service", "error", err) + return nil, err + } + backend = svc } // Setup Gin @@ -79,7 +89,7 @@ func Main() cliapp.LifecycleAction { router.Use(cfg.CORS()) // Setup routes - setupRoutes(router, s3Service, l) + setupRoutes(router, backend, l) // Configure server server := &http.Server{ @@ -183,7 +193,7 @@ func main() { } // setupRoutes configures all API routes -func setupRoutes(router *gin.Engine, s3Service *services.S3Service, l log.Logger) { +func setupRoutes(router *gin.Engine, s3Service services.BackendStorage, l log.Logger) { api := router.Group("/api/v1") { api.GET("/health", handlers.Health) diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 36f07259..ee618937 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -16,6 +16,7 @@ type FlagsConfig struct { S3Bucket string S3Region string S3Endpoint string + LocalDir string CacheTTL time.Duration EnableCache bool AllowedOrigins []string @@ -34,6 +35,7 @@ func NewConfigFromFlags(ctx *cli.Context) (*FlagsConfig, error) { S3Bucket: ctx.String("s3-bucket"), S3Region: ctx.String("s3-region"), S3Endpoint: ctx.String("s3-endpoint"), + LocalDir: ctx.String("local-dir"), CacheTTL: cacheTTL, EnableCache: ctx.Bool("enable-cache"), AllowedOrigins: strings.Split(ctx.String("allowed-origins"), ","), @@ -43,8 +45,11 @@ func NewConfigFromFlags(ctx *cli.Context) (*FlagsConfig, error) { // Validate checks if the configuration is valid func (c *FlagsConfig) Validate() error { - if c.S3Bucket == "" { - return errors.New("BASE_BENCH_API_S3_BUCKET environment variable is required") + if c.LocalDir == "" && c.S3Bucket == "" { + return errors.New("either --s3-bucket or --local-dir is required") + } + if c.LocalDir != "" && c.S3Bucket != "" { + return errors.New("--s3-bucket and --local-dir are mutually exclusive") } return nil } diff --git a/server/internal/config/flags.go b/server/internal/config/flags.go index 94fa901b..ae2ae89e 100644 --- a/server/internal/config/flags.go +++ b/server/internal/config/flags.go @@ -31,10 +31,9 @@ var ( EnvVars: prefixEnvVars("PORT"), } S3BucketFlag = &cli.StringFlag{ - Name: "s3-bucket", - Usage: "AWS S3 bucket name for benchmark data", - Required: true, - EnvVars: prefixEnvVars("S3_BUCKET"), + Name: "s3-bucket", + Usage: "AWS S3 bucket name for benchmark data (required unless --local-dir is set)", + EnvVars: prefixEnvVars("S3_BUCKET"), } S3RegionFlag = &cli.StringFlag{ Name: "s3-region", @@ -48,6 +47,11 @@ var ( Value: "", EnvVars: prefixEnvVars("S3_ENDPOINT"), } + LocalDirFlag = &cli.StringFlag{ + Name: "local-dir", + Usage: "Read benchmark output from this local directory instead of S3. Mutually exclusive with --s3-bucket. Use the directory written by 'base-bench run --output-dir '.", + EnvVars: prefixEnvVars("LOCAL_DIR"), + } CacheTTLFlag = &cli.StringFlag{ Name: "cache-ttl", Usage: "Cache time-to-live duration (e.g., 5m, 1h)", @@ -80,6 +84,7 @@ func CLIFlags() []cli.Flag { S3BucketFlag, S3RegionFlag, S3EndpointFlag, + LocalDirFlag, CacheTTLFlag, EnableCacheFlag, AllowedOriginsFlag, diff --git a/server/internal/handlers/loadtest.go b/server/internal/handlers/loadtest.go index f3a3901c..4c054b5e 100644 --- a/server/internal/handlers/loadtest.go +++ b/server/internal/handlers/loadtest.go @@ -10,7 +10,7 @@ import ( ) // LoadTestListHandler returns the list of available load test results for a network. -func LoadTestListHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { +func LoadTestListHandler(s3Service services.BackendStorage, l log.Logger) gin.HandlerFunc { return func(c *gin.Context) { network := c.Param("network") if network == "" { @@ -30,7 +30,7 @@ func LoadTestListHandler(s3Service *services.S3Service, l log.Logger) gin.Handle } // LoadTestResultHandler returns a single load test result JSON. -func LoadTestResultHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { +func LoadTestResultHandler(s3Service services.BackendStorage, l log.Logger) gin.HandlerFunc { return func(c *gin.Context) { network := c.Param("network") timestamp := c.Param("timestamp") diff --git a/server/internal/handlers/metadata.go b/server/internal/handlers/metadata.go index 3b652860..354226b5 100644 --- a/server/internal/handlers/metadata.go +++ b/server/internal/handlers/metadata.go @@ -10,7 +10,7 @@ import ( ) // MetadataHandler returns a handler function for serving benchmark metadata -func MetadataHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { +func MetadataHandler(s3Service services.BackendStorage, l log.Logger) gin.HandlerFunc { return func(c *gin.Context) { metadata, err := s3Service.GetMetadata() if err != nil { diff --git a/server/internal/handlers/metrics.go b/server/internal/handlers/metrics.go index d0c3e176..69789ebe 100644 --- a/server/internal/handlers/metrics.go +++ b/server/internal/handlers/metrics.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "net/http" "strings" @@ -12,7 +13,7 @@ import ( // StaticEmulationHandler serves metrics files using the same URL structure as static files // This allows the API to emulate the static file structure: /output//metrics-.json -func StaticEmulationHandler(s3Service *services.S3Service, l log.Logger) gin.HandlerFunc { +func StaticEmulationHandler(s3Service services.BackendStorage, l log.Logger) gin.HandlerFunc { return func(c *gin.Context) { outputDir := c.Param("outputDir") filename := c.Param("filename") @@ -35,7 +36,7 @@ func StaticEmulationHandler(s3Service *services.S3Service, l log.Logger) gin.Han return } - data, err := s3Service.GetMetrics(outputDir, nodeType) + data, err := s3Service.GetObject(fmt.Sprintf("%s/metrics-%s.json", outputDir, nodeType)) if err != nil { l.Error("Failed to get metrics", "error", err, "outputDir", outputDir, "nodeType", nodeType) diff --git a/server/internal/services/backend.go b/server/internal/services/backend.go new file mode 100644 index 00000000..27ee2439 --- /dev/null +++ b/server/internal/services/backend.go @@ -0,0 +1,16 @@ +package services + +// BackendStorage is the interface both S3Service and LocalService +// implement. Handlers accept this interface so they work identically +// against a real S3 bucket or a local output directory. +// +// The local backend reads from the directory that `base-bench run +// --output-dir ` writes — same layout as S3, so the server can +// be pointed straight at benchmark output without any S3 or MinIO +// setup. +type BackendStorage interface { + GetMetadata() (*BenchmarkRuns, error) + GetObject(key string) ([]byte, error) + ListLoadTests(network string) ([]LoadTestEntry, error) + GetLoadTest(network, timestamp string) ([]byte, error) +} diff --git a/server/internal/services/local.go b/server/internal/services/local.go new file mode 100644 index 00000000..0a66e5ff --- /dev/null +++ b/server/internal/services/local.go @@ -0,0 +1,195 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/ethereum/go-ethereum/log" +) + +// LocalService implements BackendStorage by reading from a local +// directory tree produced by `base-bench run --output-dir `. +// The layout matches the S3 layout the server expects in production: +// +// / +// ├── / +// │ ├── metadata.json # this run's metadata (commit signal) +// │ ├── metrics-sequencer.json # per-block sequencer timeseries +// │ └── metrics-validator.json # per-block validator timeseries +// └── load-tests/ +// └── / +// └── .json # load test result +// +// LocalService uses the same merge/dedup/retention/synthesis pipeline +// as S3Service via the shared mergeRuns function, so reports rendered +// against local data look identical to production reports. +// +// Caching: LocalService uses file mtime as the fingerprint instead of +// ETag. The same metadataCache struct (and its TTL) is reused so the +// time.Now() correctness guarantee applies here too. +type LocalService struct { + dir string + metadataCache metadataCache + l log.Logger +} + +func NewLocalService(dir string, l log.Logger) (*LocalService, error) { + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("local dir %q: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("local dir %q is not a directory", dir) + } + const defaultMaxFiles = 2000 + return &LocalService{ + dir: dir, + metadataCache: metadataCache{ + files: make(map[string][]byte, defaultMaxFiles), + maxFiles: defaultMaxFiles, + mergedTTL: defaultMergedTTL, + }, + l: l, + }, nil +} + +func (ls *LocalService) GetMetadata() (*BenchmarkRuns, error) { + objects, err := ls.listLocalMetadataObjects() + if err != nil { + return nil, err + } + + fingerprint := metadataObjectsFingerprint(objects) + + ls.metadataCache.mu.RLock() + cacheValid := ls.metadataCache.cachedResult != nil && + ls.metadataCache.cachedKeyFingerprint == fingerprint && + time.Since(ls.metadataCache.cachedAt) < ls.metadataCache.mergedTTL + if cacheValid { + result := ls.metadataCache.cachedResult + ls.metadataCache.mu.RUnlock() + ls.l.Debug("Serving metadata from cache", "runs", len(result.Runs)) + return result, nil + } + ls.metadataCache.mu.RUnlock() + + ls.metadataCache.mu.Lock() + defer ls.metadataCache.mu.Unlock() + + cacheValid = ls.metadataCache.cachedResult != nil && + ls.metadataCache.cachedKeyFingerprint == fingerprint && + time.Since(ls.metadataCache.cachedAt) < ls.metadataCache.mergedTTL + if cacheValid { + return ls.metadataCache.cachedResult, nil + } + + var allRuns []BenchmarkRun + fetched := 0 + cacheHits := 0 + for _, obj := range objects { + fileKey := obj.key + "|" + obj.etag + var data []byte + if cached, ok := ls.metadataCache.files[fileKey]; ok { + data = cached + cacheHits++ + } else { + var readErr error + data, readErr = os.ReadFile(filepath.Join(ls.dir, obj.key)) + if readErr != nil { + ls.l.Warn("Failed to read local metadata file, skipping", "key", obj.key, "error", readErr) + continue + } + ls.metadataCache.setFile(fileKey, data) + fetched++ + } + + var meta BenchmarkRuns + if err := json.Unmarshal(data, &meta); err != nil { + ls.l.Warn("Failed to parse local metadata file, skipping", "key", obj.key, "error", err) + continue + } + allRuns = append(allRuns, meta.Runs...) + } + ls.l.Info("Local metadata file read complete", "fetched", fetched, "cacheHits", cacheHits) + + metadata := mergeRuns(allRuns, ls.l) + ls.metadataCache.cachedResult = metadata + ls.metadataCache.cachedKeyFingerprint = fingerprint + ls.metadataCache.cachedAt = *metadata.CreatedAt + ls.l.Info("Built merged metadata from local dir", "totalRuns", len(metadata.Runs), "dir", ls.dir) + return metadata, nil +} + +func (ls *LocalService) GetObject(key string) ([]byte, error) { + return os.ReadFile(filepath.Join(ls.dir, key)) +} + +func (ls *LocalService) ListLoadTests(network string) ([]LoadTestEntry, error) { + dir := filepath.Join(ls.dir, "load-tests", network) + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("listing load tests for %s: %w", network, err) + } + var result []LoadTestEntry + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + result = append(result, LoadTestEntry{ + Network: network, + Timestamp: strings.TrimSuffix(e.Name(), ".json"), + }) + } + sort.Slice(result, func(i, j int) bool { + return result[i].Timestamp > result[j].Timestamp + }) + return result, nil +} + +func (ls *LocalService) GetLoadTest(network, timestamp string) ([]byte, error) { + path := filepath.Join(ls.dir, "load-tests", network, timestamp+".json") + return os.ReadFile(path) +} + +// listLocalMetadataObjects walks the local directory for +// /metadata.json files and returns them as metadataObjects. +// The etag field is populated from the file's mtime (nanoseconds as a +// string) so the same content-fingerprint invalidation logic applies +// — a newly written metadata.json triggers a cache miss on the next +// request. +func (ls *LocalService) listLocalMetadataObjects() ([]metadataObject, error) { + var objects []metadataObject + entries, err := os.ReadDir(ls.dir) + if err != nil { + return nil, fmt.Errorf("reading local dir %q: %w", ls.dir, err) + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if nonRunPrefixes[e.Name()+"/"] { + continue + } + metaPath := filepath.Join(ls.dir, e.Name(), "metadata.json") + info, err := os.Stat(metaPath) + if os.IsNotExist(err) { + continue + } + if err != nil { + ls.l.Warn("Failed to stat local metadata file", "path", metaPath, "error", err) + continue + } + key := e.Name() + "/metadata.json" + etag := fmt.Sprintf("%d", info.ModTime().UnixNano()) + objects = append(objects, metadataObject{key: key, etag: etag}) + } + return objects, nil +} diff --git a/server/internal/services/local_test.go b/server/internal/services/local_test.go new file mode 100644 index 00000000..5c0128e3 --- /dev/null +++ b/server/internal/services/local_test.go @@ -0,0 +1,206 @@ +package services + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethereum/go-ethereum/log" +) + +func writeMetadataFile(t *testing.T, dir, outputDir string, runs []BenchmarkRun) { + t.Helper() + runDir := filepath.Join(dir, outputDir) + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatal(err) + } + doc := BenchmarkRuns{Runs: runs} + data, err := json.Marshal(doc) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runDir, "metadata.json"), data, 0644); err != nil { + t.Fatal(err) + } +} + +func mkLocalRun(id, outputDir string, payload string, ageHours int, now time.Time) BenchmarkRun { + created := now.Add(-time.Duration(ageHours) * time.Hour) + return BenchmarkRun{ + ID: id, + SourceFile: "./mainnet-config.yml", + OutputDir: outputDir, + TestName: "Mainnet Performance Benchmark", + TestConfig: BenchmarkTestConfig{ + BenchmarkRun: id, + GasLimit: 150_000_000, + NodeType: "builder", + TransactionPayload: payload, + }, + Result: BenchmarkResult{Success: true, Complete: true}, + CreatedAt: &created, + } +} + +func TestLocalService_GetMetadata(t *testing.T) { + dir := t.TempDir() + now := time.Now() + l := log.New("t", "local_test") + + writeMetadataFile(t, dir, "run-a", []BenchmarkRun{mkLocalRun("a", "run-a", "transfer-only", 4, now)}) + writeMetadataFile(t, dir, "run-b", []BenchmarkRun{mkLocalRun("b", "run-b", "storage-create", 4, now)}) + + svc, err := NewLocalService(dir, l) + if err != nil { + t.Fatal(err) + } + + result, err := svc.GetMetadata() + if err != nil { + t.Fatal(err) + } + naturalRuns := 0 + for _, r := range result.Runs { + if r.TestConfig.BenchmarkRun == "a" || r.TestConfig.BenchmarkRun == "b" { + naturalRuns++ + } + } + if naturalRuns != 2 { + t.Fatalf("want 2 natural runs, got %d (total=%d)", naturalRuns, len(result.Runs)) + } +} + +func TestLocalService_GetMetadata_CacheHit(t *testing.T) { + dir := t.TempDir() + now := time.Now() + l := log.New("t", "local_test") + + writeMetadataFile(t, dir, "run-a", []BenchmarkRun{mkLocalRun("a", "run-a", "transfer-only", 4, now)}) + + svc, err := NewLocalService(dir, l) + if err != nil { + t.Fatal(err) + } + + r1, err := svc.GetMetadata() + if err != nil { + t.Fatal(err) + } + r2, err := svc.GetMetadata() + if err != nil { + t.Fatal(err) + } + if r1 != r2 { + t.Error("expected the same cached pointer on second call") + } +} + +func TestLocalService_GetMetadata_InvalidatesOnNewFile(t *testing.T) { + dir := t.TempDir() + now := time.Now() + l := log.New("t", "local_test") + + writeMetadataFile(t, dir, "run-a", []BenchmarkRun{mkLocalRun("a", "run-a", "transfer-only", 4, now)}) + + svc, err := NewLocalService(dir, l) + if err != nil { + t.Fatal(err) + } + + r1, _ := svc.GetMetadata() + countBefore := 0 + for _, r := range r1.Runs { + if !hasSyntheticPrefix(r.TestName) { + countBefore++ + } + } + + writeMetadataFile(t, dir, "run-b", []BenchmarkRun{mkLocalRun("b", "run-b", "storage-create", 4, now)}) + + r2, err := svc.GetMetadata() + if err != nil { + t.Fatal(err) + } + countAfter := 0 + for _, r := range r2.Runs { + if !hasSyntheticPrefix(r.TestName) { + countAfter++ + } + } + if countAfter <= countBefore { + t.Errorf("expected more natural runs after adding a file; before=%d after=%d", countBefore, countAfter) + } +} + +func TestLocalService_GetObject(t *testing.T) { + dir := t.TempDir() + runDir := filepath.Join(dir, "run-a") + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatal(err) + } + want := []byte(`[{"BlockNumber":1,"ExecutionMetrics":{}}]`) + if err := os.WriteFile(filepath.Join(runDir, "metrics-sequencer.json"), want, 0644); err != nil { + t.Fatal(err) + } + + l := log.New("t", "local_test") + svc, _ := NewLocalService(dir, l) + + got, err := svc.GetObject("run-a/metrics-sequencer.json") + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestLocalService_InvalidDir(t *testing.T) { + l := log.New("t", "local_test") + _, err := NewLocalService("/does/not/exist", l) + if err == nil { + t.Error("expected error for non-existent dir") + } +} + +func TestLocalService_ListAndGetLoadTests(t *testing.T) { + dir := t.TempDir() + network := "sepolia" + ltDir := filepath.Join(dir, "load-tests", network) + if err := os.MkdirAll(ltDir, 0755); err != nil { + t.Fatal(err) + } + for _, ts := range []string{"2026-05-15-12-00-00", "2026-05-14-12-00-00"} { + if err := os.WriteFile(filepath.Join(ltDir, ts+".json"), []byte(`{}`), 0644); err != nil { + t.Fatal(err) + } + } + + l := log.New("t", "local_test") + svc, _ := NewLocalService(dir, l) + + entries, err := svc.ListLoadTests(network) + if err != nil { + t.Fatal(err) + } + if len(entries) != 2 { + t.Fatalf("want 2 entries, got %d", len(entries)) + } + if entries[0].Timestamp < entries[1].Timestamp { + t.Error("entries should be sorted newest-first") + } + + data, err := svc.GetLoadTest(network, entries[0].Timestamp) + if err != nil { + t.Fatal(err) + } + if string(data) != `{}` { + t.Errorf("unexpected load test data: %q", data) + } +} + +func hasSyntheticPrefix(name string) bool { + return len(name) > 0 && name[0] == '[' +} diff --git a/server/internal/services/s3.go b/server/internal/services/s3.go index 6995e875..c44ab340 100644 --- a/server/internal/services/s3.go +++ b/server/internal/services/s3.go @@ -15,6 +15,8 @@ import ( "github.com/ethereum/go-ethereum/log" ) +const defaultMergedTTL = time.Hour + // BenchmarkRuns represents the metadata structure type BenchmarkRuns struct { Runs []BenchmarkRun `json:"runs"` @@ -151,10 +153,7 @@ func NewS3Service(bucketName, region, endpoint string, cache *MemoryCache, l log return nil, fmt.Errorf("failed to create AWS session: %w", err) } - const ( - defaultMaxMetadataFiles = 2000 - defaultMergedTTL = time.Hour - ) + const defaultMaxMetadataFiles = 2000 return &S3Service{ client: s3.New(sess), @@ -279,54 +278,13 @@ func (s *S3Service) GetMetadata() (*BenchmarkRuns, error) { } s.l.Info("Metadata file fetch complete", "fetched", fetched, "cacheHits", cacheHits) - // Deduplicate by compound key (ID + outputDir), keeping the last occurrence - seen := make(map[string]int) // compound key -> index in deduped slice - var deduped []BenchmarkRun - for _, run := range allRuns { - key := run.ID + "|" + run.OutputDir - if idx, exists := seen[key]; exists { - // Replace with newer version - deduped[idx] = run - } else { - seen[key] = len(deduped) - deduped = append(deduped, run) - } - } - - // Sort chronologically by CreatedAt - sort.Slice(deduped, func(i, j int) bool { - if deduped[i].CreatedAt == nil && deduped[j].CreatedAt == nil { - return false - } - if deduped[i].CreatedAt == nil { - return true - } - if deduped[j].CreatedAt == nil { - return false - } - return deduped[i].CreatedAt.Before(*deduped[j].CreatedAt) - }) - - // Apply retention policy - deduped = s.applyRetentionPolicy(deduped) - - now := time.Now() - - // Append synthetic comparison groups so the existing frontend - // dropdown surfaces multi-version and multi-time-window views - // alongside the natural BenchmarkRun IDs. See comparison.go. - deduped = append(deduped, synthesizeComparisonGroups(deduped, now)...) - - metadata := &BenchmarkRuns{ - Runs: deduped, - CreatedAt: &now, - } + metadata := mergeRuns(allRuns, s.l) s.metadataCache.cachedResult = metadata s.metadataCache.cachedKeyFingerprint = fingerprint - s.metadataCache.cachedAt = now + s.metadataCache.cachedAt = *metadata.CreatedAt - s.l.Info("Built merged metadata", "totalRuns", len(deduped), "metadataFiles", len(objects)) + s.l.Info("Built merged metadata", "totalRuns", len(metadata.Runs), "metadataFiles", len(objects)) return metadata, nil } @@ -425,7 +383,7 @@ func (s *S3Service) getObjectDirect(key string) ([]byte, error) { // - Keep one run per month for the past 6 months (the run closest to the 1st of each month) // - Drop everything older // Retained monthly runs get their TestName prefixed with the month label. -func (s *S3Service) applyRetentionPolicy(runs []BenchmarkRun) []BenchmarkRun { +func applyRetentionPolicy(runs []BenchmarkRun, l log.Logger) []BenchmarkRun { now := time.Now() recentCutoff := now.AddDate(0, 0, -14) // 2 weeks ago monthlyCutoff := now.AddDate(0, -6, 0) // 6 months ago @@ -440,7 +398,7 @@ func (s *S3Service) applyRetentionPolicy(runs []BenchmarkRun) []BenchmarkRun { for _, run := range runs { if run.CreatedAt == nil { - s.l.Debug("Dropping run with nil CreatedAt during retention", "runID", run.ID) + l.Debug("Dropping run with nil CreatedAt during retention", "runID", run.ID) continue } @@ -450,7 +408,7 @@ func (s *S3Service) applyRetentionPolicy(runs []BenchmarkRun) []BenchmarkRun { } if run.CreatedAt.Before(monthlyCutoff) { - s.l.Debug("Dropping run outside retention window", "runID", run.ID, "createdAt", run.CreatedAt) + l.Debug("Dropping run outside retention window", "runID", run.ID, "createdAt", run.CreatedAt) continue } @@ -486,15 +444,45 @@ func (s *S3Service) applyRetentionPolicy(runs []BenchmarkRun) []BenchmarkRun { return monthlyRuns[i].CreatedAt.Before(*monthlyRuns[j].CreatedAt) }) - // Combine: monthly (older) first, then recent result := append(monthlyRuns, recentRuns...) - - s.l.Info("Applied retention policy", "input", len(runs), "kept", len(result), + l.Info("Applied retention policy", "input", len(runs), "kept", len(result), "recent", len(recentRuns), "monthly", len(monthlyRuns)) - return result } +// mergeRuns deduplicates, sorts, applies retention, and synthesizes +// comparison groups from a flat list of parsed BenchmarkRun values. +// It is the shared pipeline used by both S3Service and LocalService. +func mergeRuns(allRuns []BenchmarkRun, l log.Logger) *BenchmarkRuns { + seen := make(map[string]int) + var deduped []BenchmarkRun + for _, run := range allRuns { + key := run.ID + "|" + run.OutputDir + if idx, exists := seen[key]; exists { + deduped[idx] = run + } else { + seen[key] = len(deduped) + deduped = append(deduped, run) + } + } + + sort.Slice(deduped, func(i, j int) bool { + if deduped[i].CreatedAt == nil { + return true + } + if deduped[j].CreatedAt == nil { + return false + } + return deduped[i].CreatedAt.Before(*deduped[j].CreatedAt) + }) + + deduped = applyRetentionPolicy(deduped, l) + + now := time.Now() + deduped = append(deduped, synthesizeComparisonGroups(deduped, now)...) + return &BenchmarkRuns{Runs: deduped, CreatedAt: &now} +} + // GetMetrics retrieves metrics data for a specific run and node type. // The S3 key is /metrics-.json since run files are // uploaded flat under their outputDir prefix. From 0b51c7c3354d3f088f6c4f327775f7d9a0f0ea33 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 5 Jun 2026 11:00:56 -0700 Subject: [PATCH 4/6] fix(server): guard against path traversal in LocalService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetObject, ListLoadTests, and GetLoadTest all accepted user-provided values (HTTP path params) and passed them directly to filepath.Join without validating that the resolved path stays within the root dir. This allowed path traversal — e.g. GET /output/../../../etc/passwd. Fix: safePath() resolves the joined path with filepath.Clean, then checks it has the root as a prefix. Returns an error for any path that escapes the root directory. Also adds TestLocalService_PathTraversalBlocked covering the three dangerous patterns: ../etc/passwd, ../../secret, run-a/../../outside. --- server/internal/services/local.go | 28 +++++++++++++++++++++++--- server/internal/services/local_test.go | 17 ++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/server/internal/services/local.go b/server/internal/services/local.go index 0a66e5ff..8d4a1810 100644 --- a/server/internal/services/local.go +++ b/server/internal/services/local.go @@ -126,11 +126,18 @@ func (ls *LocalService) GetMetadata() (*BenchmarkRuns, error) { } func (ls *LocalService) GetObject(key string) ([]byte, error) { - return os.ReadFile(filepath.Join(ls.dir, key)) + path, err := ls.safePath(key) + if err != nil { + return nil, err + } + return os.ReadFile(path) } func (ls *LocalService) ListLoadTests(network string) ([]LoadTestEntry, error) { - dir := filepath.Join(ls.dir, "load-tests", network) + dir, err := ls.safePath(filepath.Join("load-tests", network)) + if err != nil { + return nil, err + } entries, err := os.ReadDir(dir) if os.IsNotExist(err) { return nil, nil @@ -155,10 +162,25 @@ func (ls *LocalService) ListLoadTests(network string) ([]LoadTestEntry, error) { } func (ls *LocalService) GetLoadTest(network, timestamp string) ([]byte, error) { - path := filepath.Join(ls.dir, "load-tests", network, timestamp+".json") + path, err := ls.safePath(filepath.Join("load-tests", network, timestamp+".json")) + if err != nil { + return nil, err + } return os.ReadFile(path) } +// safePath resolves rel against ls.dir and returns an error if the +// result escapes ls.dir, preventing path-traversal attacks when rel +// contains user-provided values like HTTP path parameters. +func (ls *LocalService) safePath(rel string) (string, error) { + abs := filepath.Clean(filepath.Join(ls.dir, rel)) + base := filepath.Clean(ls.dir) + string(filepath.Separator) + if abs != filepath.Clean(ls.dir) && !strings.HasPrefix(abs, base) { + return "", fmt.Errorf("path %q escapes root directory", rel) + } + return abs, nil +} + // listLocalMetadataObjects walks the local directory for // /metadata.json files and returns them as metadataObjects. // The etag field is populated from the file's mtime (nanoseconds as a diff --git a/server/internal/services/local_test.go b/server/internal/services/local_test.go index 5c0128e3..45d2a64c 100644 --- a/server/internal/services/local_test.go +++ b/server/internal/services/local_test.go @@ -201,6 +201,23 @@ func TestLocalService_ListAndGetLoadTests(t *testing.T) { } } +func TestLocalService_PathTraversalBlocked(t *testing.T) { + dir := t.TempDir() + l := log.New("t", "local_test") + svc, _ := NewLocalService(dir, l) + + for _, dangerous := range []string{ + "../etc/passwd", + "../../secret", + "run-a/../../outside", + } { + _, err := svc.GetObject(dangerous) + if err == nil { + t.Errorf("GetObject(%q) should have returned an error (path traversal)", dangerous) + } + } +} + func hasSyntheticPrefix(name string) bool { return len(name) > 0 && name[0] == '[' } From 346a48cbe53dd6f8f846446d3954ac77511137c1 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 5 Jun 2026 11:11:52 -0700 Subject: [PATCH 5/6] fix(server): use filepath.Rel for path traversal check (CodeQL) The previous safePath implementation used strings.HasPrefix against a manually-constructed base path, which CodeQL did not recognize as a path sanitizer and continued flagging the call sites. Switched to filepath.Rel(root, abs): if the relative path from the root to the resolved target starts with '..', the target is outside the root. filepath.Rel is the idiomatic Go pattern for this check and is more likely to be recognized by static analysis tools. --- server/internal/services/local.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/internal/services/local.go b/server/internal/services/local.go index 8d4a1810..544dc597 100644 --- a/server/internal/services/local.go +++ b/server/internal/services/local.go @@ -172,10 +172,14 @@ func (ls *LocalService) GetLoadTest(network, timestamp string) ([]byte, error) { // safePath resolves rel against ls.dir and returns an error if the // result escapes ls.dir, preventing path-traversal attacks when rel // contains user-provided values like HTTP path parameters. +// filepath.Rel is used intentionally: if the relative path from +// ls.dir to the resolved target starts with "..", the target is +// outside the root and the request is rejected. func (ls *LocalService) safePath(rel string) (string, error) { abs := filepath.Clean(filepath.Join(ls.dir, rel)) - base := filepath.Clean(ls.dir) + string(filepath.Separator) - if abs != filepath.Clean(ls.dir) && !strings.HasPrefix(abs, base) { + rootClean := filepath.Clean(ls.dir) + relPath, err := filepath.Rel(rootClean, abs) + if err != nil || strings.HasPrefix(relPath, "..") { return "", fmt.Errorf("path %q escapes root directory", rel) } return abs, nil From 4f13a845df3ca13f9bf20a01c80bea036bc4f446 Mon Sep 17 00:00:00 2001 From: Julian Meyer Date: Fri, 5 Jun 2026 11:34:18 -0700 Subject: [PATCH 6/6] fix(server): resolve golangci-lint issues in s3.go errcheck: defer result.Body.Close() -> defer result.Body.Close() //nolint:errcheck The S3 response body Close() is best-effort cleanup; the SDK documents the error as always nil. staticcheck SA1019: add //nolint:staticcheck on aws-sdk-go v1 imports aws-sdk-go v1 is deprecated in favour of v2. The migration is a larger change tracked separately; the nolint directives keep CI green in the meantime. --- server/internal/services/s3.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/server/internal/services/s3.go b/server/internal/services/s3.go index c44ab340..87274639 100644 --- a/server/internal/services/s3.go +++ b/server/internal/services/s3.go @@ -9,9 +9,11 @@ import ( "sync" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/s3" + // nolint directives on the following three lines suppress SA1019 (aws-sdk-go v1 deprecated). + // Migration to aws-sdk-go-v2 is a larger change tracked separately. + "github.com/aws/aws-sdk-go/aws" //nolint:staticcheck + "github.com/aws/aws-sdk-go/aws/session" //nolint:staticcheck + "github.com/aws/aws-sdk-go/service/s3" //nolint:staticcheck "github.com/ethereum/go-ethereum/log" ) @@ -187,7 +189,7 @@ func (s *S3Service) GetObject(key string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to get object %s: %w", key, err) } - defer result.Body.Close() + defer result.Body.Close() //nolint:errcheck // Read the entire body data, err := io.ReadAll(result.Body) @@ -373,7 +375,7 @@ func (s *S3Service) getObjectDirect(key string) ([]byte, error) { if err != nil { return nil, fmt.Errorf("failed to get object %s: %w", key, err) } - defer result.Body.Close() + defer result.Body.Close() //nolint:errcheck return io.ReadAll(result.Body) }