-
Notifications
You must be signed in to change notification settings - Fork 10
Add cassandra profile command with async-profiler pass-through to Pyroscope
#565
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
Open
rustyrazorblade
wants to merge
10
commits into
main
Choose a base branch
from
claude/issue-159-20260313-0357
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
18ff8a2
Add profile cassandra command with async-profiler pass-through to Pyr…
github-actions[bot] d65608a
Move profile to cassandra subcommand: cassandra profile
github-actions[bot] 9c573c7
Address cassandra profile review feedback
github-actions[bot] 74b9c10
Redesign cassandra profile for continuous profiling with per-minute P…
github-actions[bot] 94c6b79
Address cassandra profile review feedback
github-actions[bot] c33db2d
Use async-profiler --loop for native continuous profiling
github-actions[bot] a4e0e0f
Use -e alloc and reduce sleep in cassandra profile E2E test
github-actions[bot] 9136d83
fix: use JFR format and rename --interval to --loop-interval
rustyrazorblade 39b8d6f
refactor: profiling uses systemd-run with CLI args, no env file
rustyrazorblade 217c359
fix: address review feedback on profiling PR
rustyrazorblade File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| #!/bin/bash | ||
|
|
||
| # flamegraph-to-pyroscope: Continuous profiling using async-profiler --loop, uploading to Pyroscope. | ||
| # | ||
| # Uses asprof's native --loop mode: profiles continuously, writing one JFR file per interval. | ||
| # Each completed file is uploaded to Pyroscope and removed. Runs until stopped (SIGTERM). | ||
| # | ||
| # Usage: flamegraph-to-pyroscope [--interval N] [-- asprof-args...] | ||
| # --interval N Loop duration in seconds per upload cycle (default: 60) | ||
| # -- Everything after is passed to asprof | ||
| # | ||
| # Examples: | ||
| # flamegraph-to-pyroscope | ||
| # flamegraph-to-pyroscope --interval 30 | ||
| # flamegraph-to-pyroscope --interval 5 -- -e alloc -t | ||
| # | ||
| # The Cassandra PID is auto-detected via cassandra-pid. | ||
| # PYROSCOPE_SERVER_ADDRESS is sourced from /etc/default/cassandra. | ||
|
|
||
| set -e | ||
|
|
||
| ASPROF=/usr/local/async-profiler/bin/asprof | ||
| APP_NAME="cassandra.$(hostname)" | ||
| OUTDIR=$(mktemp -d /tmp/flamegraph-XXXXXX) | ||
| ASPROF_PID="" | ||
| INTERVAL=60 | ||
| EXTRA_ARGS=() | ||
|
|
||
| # Parse CLI args | ||
| while [ $# -gt 0 ]; do | ||
| case "$1" in | ||
| --interval) | ||
| INTERVAL="$2" | ||
| shift 2 | ||
| ;; | ||
| --) | ||
| shift | ||
| EXTRA_ARGS=("$@") | ||
| break | ||
| ;; | ||
| *) | ||
| echo "Unknown option: $1" >&2 | ||
| echo "Usage: flamegraph-to-pyroscope [--interval N] [-- asprof-args...]" >&2 | ||
| exit 1 | ||
| ;; | ||
| esac | ||
| done | ||
|
|
||
| # Source environment for Pyroscope address | ||
| # shellcheck source=/dev/null | ||
| [ -f /etc/default/cassandra ] && source /etc/default/cassandra | ||
|
|
||
| PYROSCOPE_ADDR="${PYROSCOPE_SERVER_ADDRESS:-http://localhost:4040}" | ||
|
|
||
| # Returns 0 if the file hasn't been modified in the last 2 seconds (considered complete). | ||
| # Prevents uploading a file that asprof is still writing. | ||
| file_is_stable() { | ||
| local f="$1" | ||
| local now mtime | ||
| now=$(date +%s) | ||
| mtime=$(stat -c %Y "$f") | ||
| [ $((now - mtime)) -ge 2 ] | ||
| } | ||
|
|
||
| upload_file() { | ||
| local f="$1" | ||
| local until from | ||
| # Use file mtime as the end of the profiling interval. | ||
| until=$(stat -c %Y "$f") | ||
| from=$((until - INTERVAL)) | ||
| curl -sf -X POST \ | ||
| "${PYROSCOPE_ADDR}/ingest?name=${APP_NAME}&spyName=asyncprofiler&format=jfr&from=${from}&until=${until}" \ | ||
| -H "Content-Type: application/octet-stream" \ | ||
| --data-binary @"$f" \ | ||
| || echo "Upload failed at $(date -Iseconds), continuing..." | ||
| rm -f "$f" | ||
| echo "Profile sent to Pyroscope ($(date -d @"${from}" -Iseconds) to $(date -d @"${until}" -Iseconds))" | ||
| } | ||
|
|
||
| cleanup() { | ||
| echo "Stopping profiler..." | ||
| if [ -n "$ASPROF_PID" ]; then | ||
| kill "$ASPROF_PID" 2>/dev/null || true | ||
| wait "$ASPROF_PID" 2>/dev/null || true | ||
| fi | ||
| # Upload any remaining completed files before exiting. | ||
| for f in "${OUTDIR}"/profile-*.jfr; do | ||
| [ -f "$f" ] && upload_file "$f" || true | ||
| done | ||
| rm -rf "$OUTDIR" | ||
| } | ||
| trap cleanup EXIT | ||
|
|
||
| # Get the Cassandra PID | ||
| PID=$(cassandra-pid) | ||
|
|
||
| if [ -z "$PID" ] || [ "$PID" = "0" ]; then | ||
| echo "Error: Could not determine Cassandra PID" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "Starting continuous profiling (interval: ${INTERVAL}s, PID: $PID, app: $APP_NAME)..." | ||
|
|
||
| # Start async-profiler with --loop for native continuous profiling. | ||
| # --loop <N>s: profile in a loop, writing one output file per interval, then reset. | ||
| # %t in the filename is replaced with a timestamp; .jfr extension sets the output format. | ||
| sudo "$ASPROF" --loop "${INTERVAL}s" \ | ||
| -f "${OUTDIR}/profile-%t.jfr" \ | ||
| "${EXTRA_ARGS[@]}" "$PID" & | ||
| ASPROF_PID=$! | ||
|
|
||
| # Poll for completed profile files and upload them. Skip files modified in the last | ||
| # 2 seconds so we never upload a JFR that asprof is still writing. | ||
| # Files are removed after upload so only unprocessed files remain at any time. | ||
| while kill -0 "$ASPROF_PID" 2>/dev/null; do | ||
| for f in "${OUTDIR}"/profile-*.jfr; do | ||
| [ -f "$f" ] && file_is_stable "$f" && upload_file "$f" || true | ||
| done | ||
| sleep 5 | ||
| done | ||
|
|
||
| wait "$ASPROF_PID" 2>/dev/null || true |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
33 changes: 33 additions & 0 deletions
33
src/main/kotlin/com/rustyrazorblade/easydblab/commands/cassandra/Profile.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.rustyrazorblade.easydblab.commands.cassandra | ||
|
|
||
| import picocli.CommandLine.Command | ||
| import picocli.CommandLine.Model.CommandSpec | ||
| import picocli.CommandLine.Spec | ||
|
|
||
| /** | ||
| * Parent command for async-profiler flamegraph operations on Cassandra nodes. | ||
| * | ||
| * Profiling runs as a long-lived systemd service on each node, uploading flamegraph | ||
| * data to Pyroscope once per cycle (default: every 60 seconds). | ||
| * | ||
| * Sub-commands: | ||
| * - start: Start continuous profiling on all Cassandra nodes | ||
| * - stop: Stop continuous profiling on all Cassandra nodes | ||
| */ | ||
| @Command( | ||
| name = "profile", | ||
| description = ["Manage async-profiler flamegraph collection on Cassandra nodes."], | ||
| mixinStandardHelpOptions = true, | ||
| subcommands = [ | ||
| ProfileStart::class, | ||
| ProfileStop::class, | ||
| ], | ||
| ) | ||
| class Profile : Runnable { | ||
| @Spec | ||
| lateinit var spec: CommandSpec | ||
|
|
||
| override fun run() { | ||
| spec.commandLine().usage(System.out) | ||
| } | ||
| } |
77 changes: 77 additions & 0 deletions
77
src/main/kotlin/com/rustyrazorblade/easydblab/commands/cassandra/ProfileStart.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| package com.rustyrazorblade.easydblab.commands.cassandra | ||
|
|
||
| import com.rustyrazorblade.easydblab.annotations.RequireProfileSetup | ||
| import com.rustyrazorblade.easydblab.annotations.RequireSSHKey | ||
| import com.rustyrazorblade.easydblab.commands.PicoBaseCommand | ||
| import com.rustyrazorblade.easydblab.configuration.ServerType | ||
| import com.rustyrazorblade.easydblab.configuration.toHost | ||
| import com.rustyrazorblade.easydblab.events.Event | ||
| import com.rustyrazorblade.easydblab.services.HostOperationsService | ||
| import com.rustyrazorblade.easydblab.services.ProfilingService | ||
| import org.koin.core.component.inject | ||
| import picocli.CommandLine.Command | ||
| import picocli.CommandLine.Option | ||
| import picocli.CommandLine.Unmatched | ||
|
|
||
| /** | ||
| * Start continuous async-profiler flamegraph collection on all Cassandra nodes. | ||
| * | ||
| * Starts the `flamegraph-cassandra` systemd service on each node. The service loops | ||
| * indefinitely: profile for --loop-interval seconds (default: 60), upload to | ||
| * Pyroscope, repeat — until stopped with `cassandra profile stop`. | ||
| * | ||
| * Unrecognized options are passed directly to asprof. Common options: | ||
| * -e <event> Profiling event: cpu, alloc, lock, wall, nativemem (default: cpu) | ||
| * -e cpu,alloc,lock Multiple events (JFR format required, used automatically) | ||
| * --alloc <size> Allocation sampling interval (e.g. 500k) | ||
| * --lock <duration> Lock profiling threshold (e.g. 10ms) | ||
| * --nativemem <size> Native memory leak profiling | ||
| * --all Enable cpu, wall, alloc, live, lock, nativemem simultaneously | ||
| * -i <interval> Sampling interval (e.g. 50ms) | ||
| * -t Split stack traces by thread | ||
| * | ||
| * Examples: | ||
| * cassandra profile start | ||
| * cassandra profile start -e alloc | ||
| * cassandra profile start --loop-interval 30 -e cpu,alloc,lock | ||
| * cassandra profile start --all --alloc 2m | ||
| */ | ||
| @RequireProfileSetup | ||
| @RequireSSHKey | ||
| @Command( | ||
| name = "start", | ||
| description = [ | ||
| "Start continuous profiling on all Cassandra nodes.", | ||
| "Data is uploaded to Pyroscope every --loop-interval seconds (default: 60).", | ||
| "Unrecognized options are passed directly to asprof.", | ||
| ], | ||
| mixinStandardHelpOptions = true, | ||
| ) | ||
| class ProfileStart : PicoBaseCommand() { | ||
| private val profilingService: ProfilingService by inject() | ||
| private val hostOperationsService: HostOperationsService by inject() | ||
|
|
||
| @Option( | ||
| names = ["--loop-interval"], | ||
| description = ["Loop duration in seconds: profile this long, upload, repeat (default: 60)."], | ||
| ) | ||
| var loopInterval: Int? = null | ||
|
|
||
| @Unmatched | ||
| var profilerArgs: MutableList<String> = mutableListOf() | ||
|
|
||
| override fun execute() { | ||
| val cassandraHosts = clusterState.hosts[ServerType.Cassandra] | ||
| if (cassandraHosts.isNullOrEmpty()) { | ||
| eventBus.emit(Event.Profiling.Error("cluster", "No Cassandra nodes found. Is the cluster running?")) | ||
| return | ||
| } | ||
|
|
||
| // Best-effort: failures on individual nodes emit Event.Profiling.Error | ||
| // but don't abort profiling on remaining nodes. | ||
| val args = profilerArgs.toList() | ||
| hostOperationsService.withHosts(clusterState.hosts, ServerType.Cassandra, parallel = true) { host -> | ||
| profilingService.startProfiling(host.toHost(), args, loopInterval) | ||
| } | ||
| } | ||
| } |
43 changes: 43 additions & 0 deletions
43
src/main/kotlin/com/rustyrazorblade/easydblab/commands/cassandra/ProfileStop.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.rustyrazorblade.easydblab.commands.cassandra | ||
|
|
||
| import com.rustyrazorblade.easydblab.annotations.RequireProfileSetup | ||
| import com.rustyrazorblade.easydblab.annotations.RequireSSHKey | ||
| import com.rustyrazorblade.easydblab.commands.PicoBaseCommand | ||
| import com.rustyrazorblade.easydblab.configuration.ServerType | ||
| import com.rustyrazorblade.easydblab.configuration.toHost | ||
| import com.rustyrazorblade.easydblab.events.Event | ||
| import com.rustyrazorblade.easydblab.services.HostOperationsService | ||
| import com.rustyrazorblade.easydblab.services.ProfilingService | ||
| import org.koin.core.component.inject | ||
| import picocli.CommandLine.Command | ||
|
|
||
| /** | ||
| * Stop continuous async-profiler flamegraph collection on all Cassandra nodes. | ||
| * | ||
| * Stops the `flamegraph-cassandra` systemd service on each node in parallel. | ||
| */ | ||
| @RequireProfileSetup | ||
| @RequireSSHKey | ||
| @Command( | ||
| name = "stop", | ||
| description = ["Stop continuous profiling on all Cassandra nodes."], | ||
| mixinStandardHelpOptions = true, | ||
| ) | ||
| class ProfileStop : PicoBaseCommand() { | ||
| private val profilingService: ProfilingService by inject() | ||
| private val hostOperationsService: HostOperationsService by inject() | ||
|
|
||
| override fun execute() { | ||
| val cassandraHosts = clusterState.hosts[ServerType.Cassandra] | ||
| if (cassandraHosts.isNullOrEmpty()) { | ||
| eventBus.emit(Event.Profiling.Error("cluster", "No Cassandra nodes found. Is the cluster running?")) | ||
| return | ||
| } | ||
|
|
||
| // Best-effort: failures on individual nodes emit Event.Profiling.Error | ||
| // but don't abort stopping on remaining nodes. | ||
| hostOperationsService.withHosts(clusterState.hosts, ServerType.Cassandra, parallel = true) { host -> | ||
| profilingService.stop(host.toHost()) | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not using continuous mode correctly. It should call async profiler with --loop as per the doc:
https://github.com/async-profiler/async-profiler/blob/master/docs/ProfilerOptions.md
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@claude see my comment ^^
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Claude finished @rustyrazorblade's task in 4m 23s —— View job
Fixed in commit
c33db2d. Here's what changed inflamegraph-to-pyroscope:Before: The script called
asprof -d $INTERVALin a shellwhile trueloop. This blocked for the full interval each time and didn't use async-profiler's continuous profiling support.After: asprof is started once with
--loop ${INTERVAL}s, which is async-profiler's native continuous profiling mode — it profiles for the interval, writes the output file, resets, and immediately starts the next interval without stopping. The script runs a polling loop to pick up completed files and upload them to Pyroscope.Key design points:
--loop ${INTERVAL}s -o collapsed -f ${OUTDIR}/profile-%t.collapsed— async-profiler writes one file per interval, each with a unique%ttimestamp in the nameuntiltimestamp for Pyroscope;from = mtime - INTERVALcleanup()sends SIGTERM to asprof and uploads any remaining files before the service exitsEXTRA_ARGSarray now safely handles the case whereFLAMEGRAPH_EXTRA_ARGSis unset (avoids empty element in previousEXTRA_ARGS=($VAR)form)| Branch