feat(connect): capture bounded local CPU profiles (#7811)

This commit is contained in:
Chris
2026-09-14 10:28:39 +08:00
committed by GitHub
parent a0bbcba2b9
commit 97ae2ca39a
5 changed files with 338 additions and 31 deletions
Generated
+1
View File
@@ -9534,6 +9534,7 @@ dependencies = [
"pin-project-lite",
"proptest",
"prost 0.14.4",
"pyroscope",
"quick-xml",
"rand 0.10.2",
"rcgen",
+4 -1
View File
@@ -74,7 +74,7 @@ connect-e2e-short-credentials = []
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
offline-enrollment-e2e-root = []
rio-v2 = ["rustfs-ecstore/rio-v2"]
pyroscope = ["rustfs-obs/pyroscope"]
pyroscope = ["rustfs-obs/pyroscope", "dep:pyroscope"]
# Tokio runtime telemetry. Requires `--cfg tokio_unstable`; use `make build-profiling`.
dial9 = ["rustfs-obs/dial9"]
hotpath = [
@@ -374,6 +374,9 @@ rustfs-mimalloc = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
libsystemd.workspace = true
[target.'cfg(any(all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")), all(target_os = "linux", target_env = "gnu", any(target_arch = "x86_64", target_arch = "aarch64"))))'.dependencies]
pyroscope = { workspace = true, optional = true, features = ["backend-pprof-rs"] }
[dev-dependencies]
uuid = { workspace = true, features = ["v4", "v5", "fast-rng", "macro-diagnostics"] }
serial_test = { workspace = true }
+295 -10
View File
@@ -14,10 +14,9 @@
//! Bounded profile result and signed local export primitives.
//!
//! RustFS currently has no reviewed local CPU symbol catalogue or in-process
//! sampler. [`capture_cpu_profile`] therefore returns an explicit, typed
//! unsupported result. It never substitutes Pyroscope delivery state, raw
//! symbol strings, or zero samples for a local CPU measurement.
//! The optional Pyroscope pprof backend supplies an on-demand process sampler.
//! Raw frames stay local: the exported summary contains nonce-bound symbol IDs
//! and counts only, never symbol text, paths, addresses, or thread metadata.
use std::fs::{self, File, OpenOptions};
use std::io::{Cursor, Write as _};
@@ -396,6 +395,21 @@ impl ProfileResult {
}
}
fn partial(request: &ProfileCaptureRequest, tool: ProfileTool, duration: Duration, data: ProfileData) -> Self {
Self {
schema_version: PROFILE_SCHEMA_VERSION,
run_uid: request.run_uid.clone(),
tool_id: tool,
capability: tool.capability(),
outcome: ProfileOutcome::Partial,
reason_code: ProfileReasonCode::LimitExceeded,
duration_millis: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX).min(30_000),
provenance: request.provenance.clone(),
coverage: ProfileCoverage::complete_window(),
data: Some(data),
}
}
pub(super) fn unsupported(request: &ProfileCaptureRequest, tool: ProfileTool, reason_code: ProfileReasonCode) -> Self {
Self {
schema_version: PROFILE_SCHEMA_VERSION,
@@ -469,25 +483,296 @@ pub enum ProfileError {
DurabilityAfterCommit(#[source] std::io::Error),
}
pub fn capture_cpu_profile(request: &ProfileCaptureRequest, cancel: &CancellationToken) -> Result<ProfileResult, ProfileError> {
pub async fn capture_cpu_profile(
request: &ProfileCaptureRequest,
cancel: &CancellationToken,
) -> Result<ProfileResult, ProfileError> {
request.validate(ProfileTool::Cpu, unix_now()?)?;
check_cancel(cancel)?;
// The only existing CPU profiler exports to Pyroscope and does not provide
// a reviewed local symbol-id catalogue. Publishing samples here would turn
// unreviewed process symbols into L3 evidence or invent their mapping.
#[cfg(all(
feature = "pyroscope",
any(
all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")),
all(
target_os = "linux",
target_env = "gnu",
any(target_arch = "x86_64", target_arch = "aarch64")
)
)
))]
{
let _lease = CollectorLease::acquire()?;
let owned_request = request.clone();
let owned_cancel = cancel.clone();
let (data, elapsed) = tokio::task::spawn_blocking(move || local_cpu::collect(&owned_request, &owned_cancel))
.await
.map_err(|_| ProfileError::CollectionFailed)??;
check_cancel(cancel)?;
let outcome = if data.dropped_sample_count == 0 {
ProfileResult::succeeded(request, ProfileTool::Cpu, elapsed, ProfileData::Cpu(data))
} else {
ProfileResult::partial(request, ProfileTool::Cpu, elapsed, ProfileData::Cpu(data))
};
return Ok(outcome);
}
#[cfg(not(all(
feature = "pyroscope",
any(
all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")),
all(
target_os = "linux",
target_env = "gnu",
any(target_arch = "x86_64", target_arch = "aarch64")
)
)
)))]
Ok(ProfileResult::unsupported(request, ProfileTool::Cpu, ProfileReasonCode::UnsupportedTool))
}
pub fn export_cpu_profile(
pub async fn export_cpu_profile(
request: &ProfileCaptureRequest,
key: &DeviceIdentity,
cancel: &CancellationToken,
) -> Result<SignedProfileExport, ProfileError> {
let result = capture_cpu_profile(request, cancel)?;
let result = capture_cpu_profile(request, cancel).await?;
encode_signed_profile_export(request, &result, key, cancel)
}
#[cfg(all(
feature = "pyroscope",
any(
all(target_os = "macos", any(target_arch = "x86_64", target_arch = "aarch64")),
all(
target_os = "linux",
target_env = "gnu",
any(target_arch = "x86_64", target_arch = "aarch64")
)
)
))]
mod local_cpu {
use std::collections::HashMap;
use std::time::{Duration, Instant};
use pyroscope::backend::{BackendConfig, PprofConfig, ReportData, pprof_backend};
use sha2::{Digest as _, Sha256};
use tokio_util::sync::CancellationToken;
use super::{CpuProfileData, CpuProfileSample, ProfileCaptureRequest, ProfileError, check_cancel, hex_lower};
const SYMBOL_DOMAIN: &[u8] = b"rustfs-connect-cpu-symbol-v1\0";
const MAX_SAMPLE_RATE_HZ: u32 = 100;
const MAX_STACK_RECORDS: usize = 65_536;
const MAX_SAMPLES: u64 = 65_536;
const MAX_UNIQUE_SYMBOLS: usize = 4_096;
const MAX_OUTPUT_SYMBOLS: usize = 256;
const MAX_SYMBOL_BYTES: usize = 4_096;
pub(super) fn collect(
request: &ProfileCaptureRequest,
cancel: &CancellationToken,
) -> Result<(CpuProfileData, Duration), ProfileError> {
let sample_period_micros = u64::try_from(request.sample_period.as_micros()).map_err(|_| ProfileError::LimitExceeded)?;
let sample_rate = 1_000_000_u64
.checked_div(sample_period_micros)
.ok_or(ProfileError::LimitExceeded)?;
let sample_rate = u32::try_from(sample_rate).map_err(|_| ProfileError::LimitExceeded)?;
if sample_rate == 0 || sample_rate > MAX_SAMPLE_RATE_HZ {
return Err(ProfileError::LimitExceeded);
}
let started = Instant::now();
let deadline = started.checked_add(request.duration).ok_or(ProfileError::LimitExceeded)?;
let mut backend = pprof_backend(PprofConfig { sample_rate }, BackendConfig::default())
.initialize()
.map_err(|_| ProfileError::SourceUnavailable)?;
let report_result =
wait_for_window(deadline, cancel).and_then(|()| backend.report().map_err(|_| ProfileError::CollectionFailed));
let shutdown_result = backend.shutdown().map_err(|_| ProfileError::CollectionFailed);
let batch = report_result?;
shutdown_result?;
let ReportData::Reports(reports) = batch.data else {
return Err(ProfileError::SourceUnavailable);
};
let mut accumulator = Accumulator::new(request.nonce);
for report in reports {
for (stack, count) in report.data {
accumulator.record_stack(stack.frames.iter().filter_map(|frame| frame.name.as_deref()), count)?;
}
}
let actual_period_micros = 1_000_000_u64
.checked_div(u64::from(sample_rate))
.ok_or(ProfileError::LimitExceeded)?;
Ok((accumulator.finish(actual_period_micros)?, started.elapsed()))
}
fn wait_for_window(deadline: Instant, cancel: &CancellationToken) -> Result<(), ProfileError> {
const POLL_INTERVAL: Duration = Duration::from_millis(10);
loop {
check_cancel(cancel)?;
let now = Instant::now();
if now >= deadline {
return Ok(());
}
std::thread::sleep(deadline.saturating_duration_since(now).min(POLL_INTERVAL));
}
}
struct Accumulator {
nonce: [u8; 32],
samples: HashMap<String, u64>,
stack_records: usize,
accepted_sample_count: u64,
dropped_sample_count: u64,
}
impl Accumulator {
fn new(nonce: [u8; 32]) -> Self {
Self {
nonce,
samples: HashMap::new(),
stack_records: 0,
accepted_sample_count: 0,
dropped_sample_count: 0,
}
}
fn record_stack<'a>(&mut self, symbols: impl Iterator<Item = &'a str>, count: usize) -> Result<(), ProfileError> {
self.stack_records = self.stack_records.checked_add(1).ok_or(ProfileError::LimitExceeded)?;
let count = u64::try_from(count).map_err(|_| ProfileError::LimitExceeded)?;
if self.stack_records > MAX_STACK_RECORDS {
return self.drop_samples(count);
}
let accepted_count = count.min(MAX_SAMPLES.saturating_sub(self.accepted_sample_count));
self.drop_samples(count.saturating_sub(accepted_count))?;
if accepted_count == 0 {
return Ok(());
}
self.accepted_sample_count = self
.accepted_sample_count
.checked_add(accepted_count)
.ok_or(ProfileError::LimitExceeded)?;
let symbol = symbols
.into_iter()
.find(|symbol| !symbol.is_empty() && symbol.len() <= MAX_SYMBOL_BYTES)
.unwrap_or("<unresolved>");
let symbol_id = symbol_id(&self.nonce, symbol);
if !self.samples.contains_key(&symbol_id) && self.samples.len() >= MAX_UNIQUE_SYMBOLS {
return self.drop_samples(accepted_count);
}
let samples = self.samples.entry(symbol_id).or_default();
*samples = samples.checked_add(accepted_count).ok_or(ProfileError::LimitExceeded)?;
Ok(())
}
fn drop_samples(&mut self, count: u64) -> Result<(), ProfileError> {
self.dropped_sample_count = self
.dropped_sample_count
.checked_add(count)
.ok_or(ProfileError::LimitExceeded)?;
Ok(())
}
fn finish(self, sample_period_micros: u64) -> Result<CpuProfileData, ProfileError> {
let mut samples = self
.samples
.into_iter()
.map(|(symbol_id, sample_count)| CpuProfileSample { symbol_id, sample_count })
.collect::<Vec<_>>();
samples.sort_unstable_by(|left, right| {
right
.sample_count
.cmp(&left.sample_count)
.then_with(|| left.symbol_id.cmp(&right.symbol_id))
});
let mut dropped_sample_count = self.dropped_sample_count;
if samples.len() > MAX_OUTPUT_SYMBOLS {
dropped_sample_count = samples[MAX_OUTPUT_SYMBOLS..]
.iter()
.try_fold(dropped_sample_count, |total, sample| {
total.checked_add(sample.sample_count).ok_or(ProfileError::LimitExceeded)
})?;
samples.truncate(MAX_OUTPUT_SYMBOLS);
}
if samples.is_empty() {
return Err(ProfileError::SourceUnavailable);
}
Ok(CpuProfileData {
sample_period_micros,
samples,
dropped_sample_count,
})
}
}
fn symbol_id(nonce: &[u8; 32], symbol: &str) -> String {
let mut digest = Sha256::new();
digest.update(SYMBOL_DOMAIN);
digest.update(nonce);
digest.update(symbol.as_bytes());
format!("sha256:{}", hex_lower(&digest.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn summary_uses_nonce_bound_ids_and_excludes_raw_symbols() {
let raw_symbol = "rustfs_ecstore::disk::read_object";
let mut first = Accumulator::new([7; 32]);
first
.record_stack([raw_symbol].into_iter(), 9)
.expect("first stack should be recorded");
let first = first.finish(10_000).expect("first summary should be produced");
let mut second = Accumulator::new([8; 32]);
second
.record_stack([raw_symbol].into_iter(), 9)
.expect("second stack should be recorded");
let second = second.finish(10_000).expect("second summary should be produced");
assert_ne!(first.samples[0].symbol_id, second.samples[0].symbol_id);
assert_eq!(first.samples[0].sample_count, 9);
let encoded = serde_json::to_string(&first).expect("CPU summary should serialize");
assert!(!encoded.contains(raw_symbol));
assert!(!encoded.contains("read_object"));
assert!(!encoded.contains('/'));
}
#[test]
fn summary_bounds_samples_and_output_symbols() {
let mut accumulator = Accumulator::new([3; 32]);
for index in 0..=MAX_OUTPUT_SYMBOLS {
let symbol = format!("rustfs::bounded::{index}");
accumulator
.record_stack([symbol.as_str()].into_iter(), 1)
.expect("bounded stack should be recorded");
}
accumulator
.record_stack(["rustfs::large_count"].into_iter(), usize::MAX)
.expect("large count should be bounded");
let summary = accumulator.finish(10_000).expect("bounded summary should be produced");
assert_eq!(summary.samples.len(), MAX_OUTPUT_SYMBOLS);
assert!(summary.dropped_sample_count > 0);
assert!(summary.samples.iter().map(|sample| sample.sample_count).sum::<u64>() <= MAX_SAMPLES);
}
#[test]
fn window_observes_cancellation() {
let cancel = CancellationToken::new();
cancel.cancel();
assert!(matches!(
wait_for_window(Instant::now() + Duration::from_secs(1), &cancel),
Err(ProfileError::Cancelled)
));
}
}
}
pub fn encode_signed_profile_export(
request: &ProfileCaptureRequest,
result: &ProfileResult,
+1 -1
View File
@@ -1236,7 +1236,7 @@ async fn execute_connect_profile(options: ConnectProfileOpts) -> Result<()> {
if options.thread_scope.is_some() {
return Err(Error::other("--thread-scope is valid only for the threads profile"));
}
export_cpu_profile(&request, &key, &cancel).map_err(Error::other)
export_cpu_profile(&request, &key, &cancel).await.map_err(Error::other)
}
ConnectProfileTool::Memory => {
if options.thread_scope.is_some() {
+37 -19
View File
@@ -59,9 +59,12 @@ fn request() -> ProfileCaptureRequest {
}
}
#[test]
fn cpu_without_reviewed_symbol_catalog_is_explicitly_unsupported() {
let result = capture_cpu_profile(&request(), &CancellationToken::new()).expect("unsupported is a typed result");
#[cfg(not(feature = "pyroscope"))]
#[tokio::test]
async fn cpu_without_local_sampler_is_explicitly_unsupported() {
let result = capture_cpu_profile(&request(), &CancellationToken::new())
.await
.expect("unsupported is a typed result");
assert_eq!(result.outcome(), ProfileOutcome::Unsupported);
assert_eq!(result.reason_code(), ProfileReasonCode::UnsupportedTool);
assert!(result.data().is_none(), "unsupported CPU must not fabricate zero samples");
@@ -74,48 +77,63 @@ fn cpu_without_reviewed_symbol_catalog_is_explicitly_unsupported() {
assert!(json["data"].is_null());
}
#[test]
fn cpu_refuses_missing_or_expired_local_consent_before_capability_disclosure() {
#[cfg(feature = "pyroscope")]
#[tokio::test]
async fn cpu_with_local_sampler_returns_bounded_redacted_samples() {
let result = capture_cpu_profile(&request(), &CancellationToken::new())
.await
.expect("local CPU capture should complete");
assert!(matches!(result.outcome(), ProfileOutcome::Succeeded | ProfileOutcome::Partial));
let json = serde_json::to_string(&result).expect("result JSON");
assert!(json.contains("sha256:"));
assert!(!json.contains("rustfs::"));
assert!(!json.contains("/Users/"));
assert!(!json.contains("threadName"));
}
#[tokio::test]
async fn cpu_refuses_missing_or_expired_local_consent_before_capability_disclosure() {
let mut missing = request();
missing.consent.confirmed = false;
assert!(matches!(
capture_cpu_profile(&missing, &CancellationToken::new()),
capture_cpu_profile(&missing, &CancellationToken::new()).await,
Err(ProfileError::ConsentRequired)
));
let mut expired = request();
expired.consent.expires_at_unix = now() - 1;
assert!(matches!(
capture_cpu_profile(&expired, &CancellationToken::new()),
capture_cpu_profile(&expired, &CancellationToken::new()).await,
Err(ProfileError::ConsentExpired)
));
}
#[test]
fn cpu_negotiation_and_resource_limits_are_closed_at_the_boundary() {
#[tokio::test]
async fn cpu_negotiation_and_resource_limits_are_closed_at_the_boundary() {
let mut invalid = request();
invalid.schema_version = 2;
assert!(matches!(
capture_cpu_profile(&invalid, &CancellationToken::new()),
capture_cpu_profile(&invalid, &CancellationToken::new()).await,
Err(ProfileError::UnsupportedVersion)
));
let mut invalid = request();
invalid.capability = "profile.cpu@2".to_string();
assert!(matches!(
capture_cpu_profile(&invalid, &CancellationToken::new()),
capture_cpu_profile(&invalid, &CancellationToken::new()).await,
Err(ProfileError::UnsupportedCapability)
));
let mut boundary = request();
boundary.duration = MAX_PROFILE_DURATION;
boundary.sample_period = MAX_PROFILE_DURATION;
assert!(capture_cpu_profile(&boundary, &CancellationToken::new()).is_ok());
#[cfg(not(feature = "pyroscope"))]
assert!(capture_cpu_profile(&boundary, &CancellationToken::new()).await.is_ok());
let mut over = request();
over.duration = MAX_PROFILE_DURATION + Duration::from_nanos(1);
assert!(matches!(
capture_cpu_profile(&over, &CancellationToken::new()),
capture_cpu_profile(&over, &CancellationToken::new()).await,
Err(ProfileError::LimitExceeded)
));
@@ -127,7 +145,7 @@ fn cpu_negotiation_and_resource_limits_are_closed_at_the_boundary() {
(0..65).map(|index| format!("feature_{index}")).collect(),
);
assert!(matches!(
capture_cpu_profile(&features, &CancellationToken::new()),
capture_cpu_profile(&features, &CancellationToken::new()).await,
Err(ProfileError::InvalidRequest)
));
@@ -135,7 +153,7 @@ fn cpu_negotiation_and_resource_limits_are_closed_at_the_boundary() {
empty_version_suffix.provenance =
ProfileProvenance::new("a".repeat(40), "b".repeat(64), "1.0.0-", vec!["default".to_string()]);
assert!(matches!(
capture_cpu_profile(&empty_version_suffix, &CancellationToken::new()),
capture_cpu_profile(&empty_version_suffix, &CancellationToken::new()).await,
Err(ProfileError::InvalidRequest)
));
@@ -144,14 +162,14 @@ fn cpu_negotiation_and_resource_limits_are_closed_at_the_boundary() {
overflowing_window.expires_at_unix = i64::MAX;
overflowing_window.consent.expires_at_unix = i64::MAX;
assert!(matches!(
capture_cpu_profile(&overflowing_window, &CancellationToken::new()),
capture_cpu_profile(&overflowing_window, &CancellationToken::new()).await,
Err(ProfileError::Expired)
));
}
#[test]
fn cpu_honors_pre_cancelled_capture() {
#[tokio::test]
async fn cpu_honors_pre_cancelled_capture() {
let cancel = CancellationToken::new();
cancel.cancel();
assert!(matches!(capture_cpu_profile(&request(), &cancel), Err(ProfileError::Cancelled)));
assert!(matches!(capture_cpu_profile(&request(), &cancel).await, Err(ProfileError::Cancelled)));
}