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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ curl localhost:9002/admin/run_command -H 'Content-Type: application/json' -d '{"
curl localhost:9002/admin/run_command -H 'Content-Type: application/json' -d '{"commandName": "set-config", "data": {"profiler-trigger": "1m"}}'
```

### Ledger service profiler
The standalone ledger service (`cmd/ledger`) can expose the same profiler configuration through its admin server. The admin server is disabled by default because `--admin-addr` defaults to an empty value; start the service with `--admin-addr=127.0.0.1:9003` to enable it. The `net/http/pprof` endpoints are registered on the admin server for on-demand profiling and are restricted to loopback addresses.

```bash
# Start the ledger service with the admin server enabled
./ledger --triedir=/path/to/trie --admin-addr=127.0.0.1:9003

# Enable the auto-profiler
curl localhost:9003/admin/run_command -H 'Content-Type: application/json' -d '{"commandName": "set-config", "data": {"profiler-enabled": true}}'

# Trigger a profile run
curl localhost:9003/admin/run_command -H 'Content-Type: application/json' -d '{"commandName": "set-config", "data": {"profiler-trigger": "1m"}}'

# Get a heap profile via pprof (only accessible from loopback)
curl -o heap.prof localhost:9003/debug/pprof/heap
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

### Set a stop height
```
curl localhost:9002/admin/run_command -H 'Content-Type: application/json' -d '{"commandName": "stop-at-height", "data": { "height": 1111, "crash": false }}'
Expand Down
24 changes: 24 additions & 0 deletions cmd/ledger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ go build -o flow-ledger-service ./cmd/ledger
- `-max-request-size`: Maximum request message size in bytes (default: 1 GiB)
- `-max-response-size`: Maximum response message size in bytes (default: 1 GiB)
- `-loglevel`: Log level (panic, fatal, error, warn, info, debug) (default: info)
- `-profiler-enabled`: Whether to enable the auto-profiler (default: false)
- `-profiler-dir`: Directory to create auto-profiler profiles (default: `profiler`)
- `-profiler-interval`: Interval between auto-profiler runs (default: 15m)
- `-profiler-duration`: Duration of each auto-profiler run (default: 10s)

## Admin Commands

Expand All @@ -69,6 +73,8 @@ When `-admin-addr` is provided, the service exposes an HTTP admin API for managi
- `trigger-checkpoint`: Triggers a checkpoint to be created as soon as the current WAL segment file is finished writing. This is useful for manually creating checkpoints without waiting for the automatic checkpoint distance.
- `ping`: Simple health check command to verify the admin server is responsive.
- `list-commands`: Lists all available admin commands.
- `get-config`: Returns the current value of a registered runtime config.
- `set-config`: Updates the value of a registered runtime config.

**Examples:**
```bash
Expand All @@ -86,6 +92,24 @@ curl -X POST http://localhost:9003/admin/run_command \
curl -X POST http://localhost:9003/admin/run_command \
-H "Content-Type: application/json" \
-d '{"commandName": "list-commands", "data": {}}'

# Get the current auto-profiler enabled state
curl -X POST http://localhost:9003/admin/run_command \
-H "Content-Type: application/json" \
-d '{"commandName": "get-config", "data": "profiler-enabled"}'

# Enable the auto-profiler
curl -X POST http://localhost:9003/admin/run_command \
-H "Content-Type: application/json" \
-d '{"commandName": "set-config", "data": {"profiler-enabled": true}}'

# Trigger a profile run manually
curl -X POST http://localhost:9003/admin/run_command \
-H "Content-Type: application/json" \
-d '{"commandName": "set-config", "data": {"profiler-trigger": "1m"}}'

# Get a heap profile via pprof (only accessible from loopback)
curl -o heap.prof http://localhost:9003/debug/pprof/heap
```

**Note:** When running an execution node with a remote ledger service (using `--ledger-service-addr`), the `trigger-checkpoint` command on the execution node is disabled. You must use the ledger service's admin endpoint to trigger checkpoints.
Expand Down
87 changes: 85 additions & 2 deletions cmd/ledger/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Field.Set can fail with non-validation errors, e.g. TriggerRun returns "profiling is already in progress", which is server state, not a bad request. Check updatable_configs.IsValidationError(err) and return 400 only for validation errors, 500 otherwise, matching admin/commands/common/set_config.go.

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
Expand Down
93 changes: 90 additions & 3 deletions cmd/ledger/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"

Expand All @@ -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 (
Expand All @@ -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() {
Expand All @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: profiler.New creates the profile directory and starts a goroutine before the required-address validation below can fatal. Move this block after that validation so misconfigured invocations have no side effects.

profilerConfig := profiler.ProfilerConfig{
Enabled: *profilerEnabled,
UploaderEnabled: false, // ledger service does not support remote profile upload
Dir: *profilerDir,
Interval: *profilerInterval,
Duration: *profilerDuration,
Comment thread
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)
Expand Down Expand Up @@ -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)
Comment thread
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() {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 -200

Repository: 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
done

Repository: 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 -S

Repository: 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 -250

Repository: 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 -250

Repository: 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
PY

Repository: onflow/flow-go

Length of output: 5084


Track the profiler worker in the unit lifecycle.

AutoProfiler.Done() only waits on the profile directory creation in New() because p.runForever() is started with a bare go before that WaitGroup action is added. Launch the worker with p.unit.Launch(p.runForever) so await in cmd/ledger/main.go waits for the running worker to stop and any active profile trace to complete.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/ledger/main.go` around lines 384 - 386, Update the auto-profiler startup
in New to launch runForever through p.unit.Launch instead of a bare goroutine,
ensuring AutoProfiler.Done waits for the worker and any active trace to finish;
leave the shutdown await in cmd/ledger unchanged.

Source: Coding guidelines


logger.Info().Msg("waiting for ledger to stop...")
<-ledgerStorage.Done()

Expand Down
Loading