-
Notifications
You must be signed in to change notification settings - Fork 214
Add pprof to ledger service #8631
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,10 +3,14 @@ package main | |
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
| "net" | ||
| "net/http" | ||
| "net/http/pprof" | ||
|
|
||
| "github.com/rs/zerolog" | ||
| "go.uber.org/atomic" | ||
|
|
||
| "github.com/onflow/flow-go/module/updatable_configs" | ||
| ) | ||
|
|
||
| // adminRequest represents the JSON request body for admin commands. | ||
|
|
@@ -29,19 +33,50 @@ type adminResponse struct { | |
| type adminHandler struct { | ||
| logger zerolog.Logger | ||
| triggerCheckpoint *atomic.Bool | ||
| configManager *updatable_configs.Manager | ||
| commands []string | ||
| } | ||
|
|
||
| // requireLoopback returns an http.HandlerFunc that only serves requests originating | ||
| // from the loopback interface. It is used to prevent sensitive profiling endpoints | ||
| // from being exposed when the admin server is bound to a publicly reachable address. | ||
| func requireLoopback(next http.HandlerFunc) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| host, _, err := net.SplitHostPort(r.RemoteAddr) | ||
| if err != nil { | ||
| http.Error(w, "forbidden", http.StatusForbidden) | ||
| return | ||
| } | ||
| ip := net.ParseIP(host) | ||
| if ip == nil || !ip.IsLoopback() { | ||
| http.Error(w, "forbidden", http.StatusForbidden) | ||
| return | ||
| } | ||
| next(w, r) | ||
| } | ||
| } | ||
|
|
||
| // newAdminHandler creates a new admin HTTP handler. | ||
| func newAdminHandler(logger zerolog.Logger, triggerCheckpoint *atomic.Bool) http.Handler { | ||
| func newAdminHandler(logger zerolog.Logger, triggerCheckpoint *atomic.Bool, configManager *updatable_configs.Manager) http.Handler { | ||
| h := &adminHandler{ | ||
| logger: logger.With().Str("component", "admin").Logger(), | ||
| triggerCheckpoint: triggerCheckpoint, | ||
| commands: []string{"ping", "list-commands", "trigger-checkpoint"}, | ||
| configManager: configManager, | ||
| commands: []string{"ping", "list-commands", "trigger-checkpoint", "get-config", "set-config"}, | ||
| } | ||
|
|
||
| mux := http.NewServeMux() | ||
| mux.HandleFunc("/admin/run_command", h.handleCommand) | ||
|
|
||
| // Register pprof handlers for profiling (CPU, heap, goroutine, etc.). | ||
| // These endpoints are restricted to loopback to avoid exposing profiling | ||
| // data when the admin server is bound to a public address. | ||
| mux.HandleFunc("/debug/pprof/", requireLoopback(pprof.Index)) | ||
| mux.HandleFunc("/debug/pprof/cmdline", requireLoopback(pprof.Cmdline)) | ||
| mux.HandleFunc("/debug/pprof/profile", requireLoopback(pprof.Profile)) | ||
| mux.HandleFunc("/debug/pprof/symbol", requireLoopback(pprof.Symbol)) | ||
| mux.HandleFunc("/debug/pprof/trace", requireLoopback(pprof.Trace)) | ||
|
|
||
| return mux | ||
| } | ||
|
|
||
|
|
@@ -78,6 +113,54 @@ func (h *adminHandler) handleCommand(w http.ResponseWriter, r *http.Request) { | |
| result = "checkpoint already triggered" | ||
| } | ||
|
|
||
| case "get-config": | ||
| var configName string | ||
| if err := json.Unmarshal(req.Data, &configName); err != nil { | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("get-config data must be a string config name: %v", err)) | ||
| return | ||
| } | ||
| field, ok := h.configManager.GetField(configName) | ||
| if !ok { | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown config field: %s", configName)) | ||
| return | ||
| } | ||
| result = field.Get() | ||
|
|
||
| case "set-config": | ||
| var data map[string]any | ||
| if err := json.Unmarshal(req.Data, &data); err != nil { | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("set-config data must be a JSON object: %v", err)) | ||
| return | ||
| } | ||
| if len(data) != 1 { | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("set-config data must have exactly one entry, got %d", len(data))) | ||
| return | ||
| } | ||
| var configName string | ||
| var configValue any | ||
| for k, v := range data { | ||
| configName = k | ||
| configValue = v | ||
| } | ||
| field, ok := h.configManager.GetField(configName) | ||
| if !ok { | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown config field: %s", configName)) | ||
| return | ||
| } | ||
| oldValue := field.Get() | ||
| if err := field.Set(configValue); err != nil { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| status := http.StatusInternalServerError | ||
| if updatable_configs.IsValidationError(err) { | ||
| status = http.StatusBadRequest | ||
| } | ||
| h.writeError(w, status, fmt.Sprintf("failed to set config %s: %v", configName, err)) | ||
| return | ||
| } | ||
| result = map[string]any{ | ||
| "oldValue": oldValue, | ||
| "newValue": configValue, | ||
| } | ||
|
|
||
| default: | ||
| h.writeError(w, http.StatusBadRequest, fmt.Sprintf("unknown command: %s", req.CommandName)) | ||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,9 @@ import ( | |
| "os" | ||
| "os/signal" | ||
| "path/filepath" | ||
| "runtime" | ||
| "strings" | ||
| "sync" | ||
| "syscall" | ||
| "time" | ||
|
|
||
|
|
@@ -23,6 +25,8 @@ import ( | |
| "github.com/onflow/flow-go/ledger/remote" | ||
| "github.com/onflow/flow-go/module/irrecoverable" | ||
| "github.com/onflow/flow-go/module/metrics" | ||
| "github.com/onflow/flow-go/module/profiler" | ||
| "github.com/onflow/flow-go/module/updatable_configs" | ||
| ) | ||
|
|
||
| var ( | ||
|
|
@@ -37,6 +41,11 @@ var ( | |
| logLevel = flag.String("loglevel", "info", "Log level (panic, fatal, error, warn, info, debug)") | ||
| maxRequestSize = flag.Uint("max-request-size", 1<<30, "Maximum request message size in bytes (default: 1 GiB)") | ||
| maxResponseSize = flag.Uint("max-response-size", 1<<30, "Maximum response message size in bytes (default: 1 GiB)") | ||
|
|
||
| profilerEnabled = flag.Bool("profiler-enabled", false, "Whether to enable the auto-profiler") | ||
| profilerDir = flag.String("profiler-dir", "profiler", "Directory to create auto-profiler profiles") | ||
| profilerInterval = flag.Duration("profiler-interval", 15*time.Minute, "Interval between auto-profiler runs") | ||
| profilerDuration = flag.Duration("profiler-duration", 10*time.Second, "Duration of each auto-profiler run") | ||
| ) | ||
|
|
||
| func main() { | ||
|
|
@@ -60,18 +69,88 @@ func main() { | |
| Str("service", "ledger"). | ||
| Logger() | ||
|
|
||
| if *profilerInterval <= 0 { | ||
| logger.Fatal().Dur("profiler_interval", *profilerInterval).Msg("profiler-interval must be positive") | ||
| } | ||
|
|
||
| // Validate that at least one address is provided | ||
| if *ledgerServiceTCP == "" && *ledgerServiceSocket == "" { | ||
| logger.Fatal().Msg("at least one of --ledger-service-tcp or --ledger-service-socket must be provided") | ||
| } | ||
|
|
||
| // Initialize updatable config manager and auto-profiler. | ||
| // The profiler is configured via admin get-config/set-config commands. | ||
| configManager := updatable_configs.NewManager() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
| profilerConfig := profiler.ProfilerConfig{ | ||
| Enabled: *profilerEnabled, | ||
| UploaderEnabled: false, // ledger service does not support remote profile upload | ||
| Dir: *profilerDir, | ||
| Interval: *profilerInterval, | ||
| Duration: *profilerDuration, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| autoProfiler, err := profiler.New(logger, &profiler.NoopUploader{}, profilerConfig) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to create auto-profiler") | ||
| } | ||
|
|
||
| err = configManager.RegisterBoolConfig("profiler-enabled", autoProfiler.Enabled, autoProfiler.SetEnabled) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to register profiler-enabled config") | ||
| } | ||
| err = configManager.RegisterDurationConfig( | ||
| "profiler-trigger", | ||
| func() time.Duration { return profilerConfig.Duration }, | ||
| func(d time.Duration) error { return autoProfiler.TriggerRun(d) }, | ||
| ) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to register profiler-trigger config") | ||
| } | ||
| err = configManager.RegisterUintConfig( | ||
| "profiler-set-mem-profile-rate", | ||
| func() uint { return uint(runtime.MemProfileRate) }, | ||
| func(r uint) error { runtime.MemProfileRate = int(r); return nil }, | ||
| ) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to register profiler-set-mem-profile-rate config") | ||
| } | ||
| var currentBlockRateMu sync.Mutex | ||
| var currentBlockRate uint | ||
| err = configManager.RegisterUintConfig( | ||
| "profiler-set-block-profile-rate", | ||
| func() uint { | ||
| currentBlockRateMu.Lock() | ||
| defer currentBlockRateMu.Unlock() | ||
| return currentBlockRate | ||
| }, | ||
| func(r uint) error { | ||
| currentBlockRateMu.Lock() | ||
| defer currentBlockRateMu.Unlock() | ||
| runtime.SetBlockProfileRate(int(r)) | ||
| currentBlockRate = r | ||
| return nil | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to register profiler-set-block-profile-rate config") | ||
| } | ||
| err = configManager.RegisterUintConfig( | ||
| "profiler-set-mutex-profile-fraction", | ||
| func() uint { return uint(runtime.SetMutexProfileFraction(-1)) }, | ||
| func(r uint) error { _ = runtime.SetMutexProfileFraction(int(r)); return nil }, | ||
| ) | ||
| if err != nil { | ||
| logger.Fatal().Err(err).Msg("failed to register profiler-set-mutex-profile-fraction config") | ||
| } | ||
|
|
||
| logger.Info(). | ||
| Str("triedir", *triedir). | ||
| Str("ledger_service_tcp", *ledgerServiceTCP). | ||
| Str("ledger_service_socket", *ledgerServiceSocket). | ||
| Str("admin_addr", *adminAddr). | ||
| Uint("metrics_port", *metricsPort). | ||
| Int("mtrie_cache_size", *mtrieCacheSize). | ||
| Bool("profiler_enabled", *profilerEnabled). | ||
| Msg("starting ledger service") | ||
|
|
||
| // Create trigger for manual checkpointing (used by admin command) | ||
|
|
@@ -229,10 +308,14 @@ func main() { | |
| // This is a lightweight HTTP-only server (no gRPC proxy layer) | ||
| var adminServer *http.Server | ||
| if *adminAddr != "" { | ||
| adminHandler := newAdminHandler(logger, triggerCheckpointOnNextSegmentFinish) | ||
| adminHandler := newAdminHandler(logger, triggerCheckpointOnNextSegmentFinish, configManager) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| adminServer = &http.Server{ | ||
| Addr: *adminAddr, | ||
| Handler: adminHandler, | ||
| Addr: *adminAddr, | ||
| Handler: adminHandler, | ||
| ReadHeaderTimeout: 10 * time.Second, | ||
| ReadTimeout: 10 * time.Second, | ||
| IdleTimeout: 60 * time.Second, | ||
| WriteTimeout: 2 * time.Minute, | ||
| } | ||
|
|
||
| go func() { | ||
|
|
@@ -298,6 +381,10 @@ func main() { | |
| logger.Info().Msg("metrics server stopped") | ||
| } | ||
|
|
||
| logger.Info().Msg("shutting down auto-profiler...") | ||
| <-autoProfiler.Done() | ||
| logger.Info().Msg("auto-profiler stopped") | ||
|
Comment on lines
+384
to
+386
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== locate files =="
fd -a 'main.go|component.go|profiler.go|profiler' . | sed 's#^\./##' | head -80
echo "== cmd/ledger/main.go outline around shutdown =="
if [ -f cmd/ledger/main.go ]; then
wc -l cmd/ledger/main.go
sed -n '340,410p' cmd/ledger/main.go | nl -ba -v340
fi
echo "== locate AutoProfiler and profiler implementation =="
rg -n "type .*AutoProfiler|func .*AutoProfiler|AutoProfiler\.Done|runForever|runOnce|Launch|unit\.Launch|func .*Done" . -S --glob '*.go' | head -200Repository: onflow/flow-go Length of output: 1959 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== candidate files =="
git ls-files | rg 'module/component/component\.go|cmd/ledger/main\.go|.*profiler\.go$' | sort
echo "== component interface =="
if [ -f module/component/component.go ]; then
sed -n '1,220p' module/component/component.go | nl -ba
fi
echo "== auto-profile lifecycle in cmd/ledger/main.go =="
python3 - <<'PY'
from pathlib import Path
p=Path('cmd/ledger/main.go')
if p.exists():
lines=p.read_text().splitlines()
targets=['NewAutoProfiler','StartAutoProfiler','AutoProfiler','Done()']
for needle in targets:
for i,l in enumerate(lines,1):
if needle in l:
start=max(1,i-20); end=min(len(lines),i+40)
print(f'\n-- {p}:{i} containing {needle} --')
for n in range(start,end+1):
print(f'{n:4}: {lines[n-1]}')
PY
echo "== profiler source =="
for f in $(git ls-files | rg '(^|/)profiler\.go$|auto.*profiler\.go$' | head -20); do
echo "\n== $f =="
wc -l "$f"
ast-grep outline "$f" --view expanded 2>/dev/null || true
sed -n '1,260p' "$f" | nl -ba
doneRepository: onflow/flow-go Length of output: 317 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== cmd/ledger/main.go relevant sections =="
awk 'NR>=340 && NR<=410 { printf("%4d: %s\n", NR, $0) }' cmd/ledger/main.go
echo "== module/component/component.go =="
awk '{ printf("%4d: %s\n", NR, $0) }' module/component/component.go
echo "== profiler implementation =="
awk '{ printf("%4d: %s\n", NR, $0) }' module/profiler/profiler.go
echo "== references to NewAutoProfiler, StartAutoProfiler, AutoProfiler.Done =="
rg -n "NewAutoProfiler|StartAutoProfiler|AutoProfiler\.Done|runForever|runOnce|Launch\(.*run|unit\.Launch|Done \(\)" cmd/ledger/main.go module/profiler/profiler.go module/component/component.go -SRepository: onflow/flow-go Length of output: 27351 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== unit implementation =="
git ls-files 'module/unit/*.go' 'module/engine/*.go' | rg 'unit|engine' | sort
for f in $(git ls-files 'module/unit/*.go' 'module/engine/*.go' | rg 'unit|engine' | sort); do
echo "\n== $f =="
ast-grep outline "$f" --view expanded 2>/dev/null || true
sed -n '1,260p' "$f" | awk '{ printf("%4d: %s\n", NR, $0) }'
done
echo "== NewAutoProfiler and AutoProfiler usages =="
rg -n "NewAutoProfiler|AutoProfiler|RunComponent|ComponentManagerBuilder|Component" cmd module engine -S --glob '*.go' | head -250Repository: onflow/flow-go Length of output: 178 🏁 Script executed: #!/bin/bash
set EUo pipefail
echo "== files containing unit implementation/imports =="
rg -n "type Unit|func .*Unit|func \\(.*\\) Launch|func NewUnit|Shutdown\\(\\).*Done|Done\\(\\).*Quit|Quit\\(\\)" . -S --glob '*.go' | head -200
echo "== go.mod dependencies for unit definitions (if present) =="
rg -n "module|go " go.mod | head -20
echo "== focused grep for NewUnit definitions =="
rg -n "func NewUnit\\(" . -S --glob '*.go'
rg -n "type Unit struct" . -S --glob '*.go'
rg -n "func \\(.*Unit.*\\) Launch|func \\([^ ]*\\) Launch\\(" . -S --glob '*.go'
echo "== NewAutoProfiler and component usages =="
rg -n "NewAutoProfiler|AutoProfiler|RunComponent|ComponentManagerBuilder|ComponentInterface|Component" cmd module engine -S --glob '*.go' | head -250Repository: onflow/flow-go Length of output: 27908 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== engine/unit.go =="
awk '{ printf("%4d: %s\n", NR, $0) }' engine/unit.go
echo "== AutoProfiler construction context =="
python3 - <<'PY'
from pathlib import Path
for p in Path('.').rglob('*.go'):
s=str(p)
if 'go' not in s and not s.endswith('.go'):
continue
txt=p.read_text(errors='ignore')
if 'NewAutoProfiler' in txt or 'AutoProfiler' in txt:
lines=txt.splitlines()
for i,l in enumerate(lines,1):
if 'NewAutoProfiler' in l:
for n in range(max(1,i-35), min(len(lines), i+80)+1):
print(f'{p}:{n}: {lines[n-1]}')
break
PYRepository: onflow/flow-go Length of output: 5084 Track the profiler worker in the unit lifecycle.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| logger.Info().Msg("waiting for ledger to stop...") | ||
| <-ledgerStorage.Done() | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.