增加必要的数据记录
This commit is contained in:
565
src/rtr/report.rs
Normal file
565
src/rtr/report.rs
Normal file
@@ -0,0 +1,565 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use serde::Serialize;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::rtr::cache::{CacheAvailability, SharedRtrCache, VersionReportStats};
|
||||
use crate::rtr::config::format_timezone;
|
||||
use crate::rtr::server::{RtrNotifier, RtrServiceStats, RtrTransportConnectionCounts};
|
||||
use crate::source::pipeline::{DataQualityReport, SourceLoadReport};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ReportConfiguration {
|
||||
source_refresh_interval_seconds: u64,
|
||||
report_interval_seconds: u64,
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
strict_ccr_validation: bool,
|
||||
timezone: String,
|
||||
timing: TimingReport,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize)]
|
||||
struct TimingReport {
|
||||
refresh: u32,
|
||||
retry: u32,
|
||||
expire: u32,
|
||||
}
|
||||
|
||||
impl ReportConfiguration {
|
||||
pub fn new(
|
||||
source_refresh_interval_seconds: u64,
|
||||
report_interval_seconds: u64,
|
||||
max_delta: u8,
|
||||
prune_delta_by_snapshot_size: bool,
|
||||
strict_ccr_validation: bool,
|
||||
timezone: Tz,
|
||||
timing: (u32, u32, u32),
|
||||
) -> Self {
|
||||
Self {
|
||||
source_refresh_interval_seconds,
|
||||
report_interval_seconds,
|
||||
max_delta,
|
||||
prune_delta_by_snapshot_size,
|
||||
strict_ccr_validation,
|
||||
timezone: format_timezone(timezone),
|
||||
timing: TimingReport {
|
||||
refresh: timing.0,
|
||||
retry: timing.1,
|
||||
expire: timing.2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timezone(&self) -> Tz {
|
||||
self.timezone
|
||||
.parse::<Tz>()
|
||||
.expect("serialized timezone should be a valid IANA timezone")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ReportContext {
|
||||
started_at: DateTime<Utc>,
|
||||
started_instant: Instant,
|
||||
timezone: Tz,
|
||||
configuration: ReportConfiguration,
|
||||
runtime: Arc<RwLock<RuntimeReportState>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct RuntimeReportState {
|
||||
source: Option<SourceLoadReport>,
|
||||
data_quality: Option<DataQualityReport>,
|
||||
refresh: RefreshReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct RefreshReport {
|
||||
last_attempt_at: Option<DateTime<Utc>>,
|
||||
last_success_at: Option<DateTime<Utc>>,
|
||||
last_changed_at: Option<DateTime<Utc>>,
|
||||
status: &'static str,
|
||||
changed: Option<bool>,
|
||||
duration_ms: Option<u128>,
|
||||
consecutive_failures: u64,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshReportView {
|
||||
last_attempt_at: Option<DateTime<Tz>>,
|
||||
last_success_at: Option<DateTime<Tz>>,
|
||||
last_changed_at: Option<DateTime<Tz>>,
|
||||
status: &'static str,
|
||||
changed: Option<bool>,
|
||||
duration_ms: Option<u128>,
|
||||
consecutive_failures: u64,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshReportView {
|
||||
fn from_report(report: RefreshReport, timezone: Tz) -> Self {
|
||||
Self {
|
||||
last_attempt_at: report
|
||||
.last_attempt_at
|
||||
.map(|time| to_report_time(time, timezone)),
|
||||
last_success_at: report
|
||||
.last_success_at
|
||||
.map(|time| to_report_time(time, timezone)),
|
||||
last_changed_at: report
|
||||
.last_changed_at
|
||||
.map(|time| to_report_time(time, timezone)),
|
||||
status: report.status,
|
||||
changed: report.changed,
|
||||
duration_ms: report.duration_ms,
|
||||
consecutive_failures: report.consecutive_failures,
|
||||
last_error: report.last_error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RefreshReport {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_attempt_at: None,
|
||||
last_success_at: None,
|
||||
last_changed_at: None,
|
||||
status: "not_attempted",
|
||||
changed: None,
|
||||
duration_ms: None,
|
||||
consecutive_failures: 0,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReportContext {
|
||||
pub fn new(configuration: ReportConfiguration) -> Self {
|
||||
let timezone = configuration.timezone();
|
||||
Self {
|
||||
started_at: Utc::now(),
|
||||
started_instant: Instant::now(),
|
||||
timezone,
|
||||
configuration,
|
||||
runtime: Arc::new(RwLock::new(RuntimeReportState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_refresh_success(
|
||||
&self,
|
||||
attempted_at: DateTime<Utc>,
|
||||
duration_ms: u128,
|
||||
changed: bool,
|
||||
source: SourceLoadReport,
|
||||
data_quality: DataQualityReport,
|
||||
) {
|
||||
let mut state = self
|
||||
.runtime
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.source = Some(source);
|
||||
state.data_quality = Some(data_quality);
|
||||
state.refresh.last_attempt_at = Some(attempted_at);
|
||||
state.refresh.last_success_at = Some(Utc::now());
|
||||
if changed {
|
||||
state.refresh.last_changed_at = Some(Utc::now());
|
||||
}
|
||||
state.refresh.status = "success";
|
||||
state.refresh.changed = Some(changed);
|
||||
state.refresh.duration_ms = Some(duration_ms);
|
||||
state.refresh.consecutive_failures = 0;
|
||||
state.refresh.last_error = None;
|
||||
}
|
||||
|
||||
pub fn record_refresh_unchanged(&self, attempted_at: DateTime<Utc>, duration_ms: u128) {
|
||||
let mut state = self
|
||||
.runtime
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.refresh.last_attempt_at = Some(attempted_at);
|
||||
state.refresh.last_success_at = Some(Utc::now());
|
||||
state.refresh.status = "success";
|
||||
state.refresh.changed = Some(false);
|
||||
state.refresh.duration_ms = Some(duration_ms);
|
||||
state.refresh.consecutive_failures = 0;
|
||||
state.refresh.last_error = None;
|
||||
}
|
||||
|
||||
pub fn record_refresh_failure(
|
||||
&self,
|
||||
attempted_at: DateTime<Utc>,
|
||||
duration_ms: u128,
|
||||
error: &anyhow::Error,
|
||||
) {
|
||||
let mut state = self
|
||||
.runtime
|
||||
.write()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.refresh.last_attempt_at = Some(attempted_at);
|
||||
state.refresh.status = "failed";
|
||||
state.refresh.changed = None;
|
||||
state.refresh.duration_ms = Some(duration_ms);
|
||||
state.refresh.consecutive_failures = state.refresh.consecutive_failures.saturating_add(1);
|
||||
state.refresh.last_error = Some(error.to_string());
|
||||
}
|
||||
|
||||
pub fn write_or_warn(
|
||||
&self,
|
||||
report_dir: &Path,
|
||||
phase: &str,
|
||||
shared_cache: &SharedRtrCache,
|
||||
notifier: &RtrNotifier,
|
||||
service_stats: &RtrServiceStats,
|
||||
) {
|
||||
if let Err(err) = self.write(report_dir, phase, shared_cache, notifier, service_stats) {
|
||||
warn!(
|
||||
"failed to write RTR report to {}: {:?}",
|
||||
report_dir.display(),
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write(
|
||||
&self,
|
||||
report_dir: &Path,
|
||||
phase: &str,
|
||||
shared_cache: &SharedRtrCache,
|
||||
notifier: &RtrNotifier,
|
||||
service_stats: &RtrServiceStats,
|
||||
) -> Result<()> {
|
||||
let cache = shared_cache.load_full();
|
||||
let runtime = self
|
||||
.runtime
|
||||
.read()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone();
|
||||
let availability = match cache.availability() {
|
||||
CacheAvailability::Ready => "ready",
|
||||
CacheAvailability::NoDataAvailable => "no_data_available",
|
||||
};
|
||||
let active_connections = service_stats.active_connections();
|
||||
let connections_by_transport = service_stats.transport_connections();
|
||||
let max_connections = service_stats.max_connections();
|
||||
let report = RtrReport {
|
||||
schema_version: 2,
|
||||
generated_at: self.report_now(),
|
||||
phase: phase.to_string(),
|
||||
service: ServiceReport {
|
||||
started_at: self.to_report_time(self.started_at),
|
||||
uptime_seconds: self.started_instant.elapsed().as_secs(),
|
||||
active_connections,
|
||||
connections_by_transport: TransportConnectionReport::from(connections_by_transport),
|
||||
session_listeners: notifier.listener_count(),
|
||||
max_connections,
|
||||
connection_utilization: active_connections as f64 / max_connections as f64,
|
||||
},
|
||||
process: ProcessReport {
|
||||
rss_mib: current_rss_mib(),
|
||||
},
|
||||
source: runtime
|
||||
.source
|
||||
.map(|source| SourceLoadReportView::from_report(source, self.timezone)),
|
||||
refresh: RefreshReportView::from_report(runtime.refresh, self.timezone),
|
||||
data_quality: runtime.data_quality,
|
||||
configuration: self.configuration.clone(),
|
||||
cache: CacheReport {
|
||||
availability,
|
||||
created_at: self.to_report_time(cache.created_at().utc()),
|
||||
last_update_begin: self.to_report_time(cache.last_update_begin().utc()),
|
||||
last_update_end: self.to_report_time(cache.last_update_end().utc()),
|
||||
versions: cache.version_report_stats(),
|
||||
},
|
||||
};
|
||||
|
||||
fs::create_dir_all(report_dir)
|
||||
.with_context(|| format!("create report directory {}", report_dir.display()))?;
|
||||
let target = report_dir.join("rtr-server.json");
|
||||
let temporary = report_dir.join(".rtr-server.json.tmp");
|
||||
let json = serde_json::to_vec_pretty(&report).context("serialize RTR report")?;
|
||||
fs::write(&temporary, json)
|
||||
.with_context(|| format!("write temporary report {}", temporary.display()))?;
|
||||
|
||||
if let Err(err) = fs::rename(&temporary, &target) {
|
||||
if target.exists() {
|
||||
fs::remove_file(&target)
|
||||
.with_context(|| format!("replace existing report {}", target.display()))?;
|
||||
fs::rename(&temporary, &target)
|
||||
.with_context(|| format!("move report into {}", target.display()))?;
|
||||
} else {
|
||||
return Err(err).with_context(|| {
|
||||
format!(
|
||||
"move temporary report {} into {}",
|
||||
temporary.display(),
|
||||
target.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report_now(&self) -> DateTime<Tz> {
|
||||
self.to_report_time(Utc::now())
|
||||
}
|
||||
|
||||
fn to_report_time(&self, time: DateTime<Utc>) -> DateTime<Tz> {
|
||||
time.with_timezone(&self.timezone)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RtrReport {
|
||||
schema_version: u16,
|
||||
generated_at: DateTime<Tz>,
|
||||
phase: String,
|
||||
service: ServiceReport,
|
||||
process: ProcessReport,
|
||||
source: Option<SourceLoadReportView>,
|
||||
refresh: RefreshReportView,
|
||||
data_quality: Option<DataQualityReport>,
|
||||
configuration: ReportConfiguration,
|
||||
cache: CacheReport,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServiceReport {
|
||||
started_at: DateTime<Tz>,
|
||||
uptime_seconds: u64,
|
||||
active_connections: usize,
|
||||
connections_by_transport: TransportConnectionReport,
|
||||
session_listeners: usize,
|
||||
max_connections: usize,
|
||||
connection_utilization: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SourceLoadReportView {
|
||||
ccr_file: String,
|
||||
ccr_file_size_bytes: u64,
|
||||
ccr_modified_at: Option<DateTime<Tz>>,
|
||||
ccr_produced_at: Option<String>,
|
||||
slurm_enabled: bool,
|
||||
slurm_file_count: usize,
|
||||
slurm_files: Vec<String>,
|
||||
slurm_version: Option<u32>,
|
||||
}
|
||||
|
||||
impl SourceLoadReportView {
|
||||
fn from_report(report: SourceLoadReport, timezone: Tz) -> Self {
|
||||
Self {
|
||||
ccr_file: report.ccr_file,
|
||||
ccr_file_size_bytes: report.ccr_file_size_bytes,
|
||||
ccr_modified_at: report
|
||||
.ccr_modified_at
|
||||
.map(|time| to_report_time(time, timezone)),
|
||||
ccr_produced_at: report.ccr_produced_at,
|
||||
slurm_enabled: report.slurm_enabled,
|
||||
slurm_file_count: report.slurm_file_count,
|
||||
slurm_files: report.slurm_files,
|
||||
slurm_version: report.slurm_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TransportConnectionReport {
|
||||
tcp: usize,
|
||||
tls: usize,
|
||||
ssh: usize,
|
||||
}
|
||||
|
||||
impl From<RtrTransportConnectionCounts> for TransportConnectionReport {
|
||||
fn from(counts: RtrTransportConnectionCounts) -> Self {
|
||||
Self {
|
||||
tcp: counts.tcp,
|
||||
tls: counts.tls,
|
||||
ssh: counts.ssh,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProcessReport {
|
||||
rss_mib: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CacheReport {
|
||||
availability: &'static str,
|
||||
created_at: DateTime<Tz>,
|
||||
last_update_begin: DateTime<Tz>,
|
||||
last_update_end: DateTime<Tz>,
|
||||
versions: [VersionReportStats; 3],
|
||||
}
|
||||
|
||||
pub fn to_report_time(time: DateTime<Utc>, timezone: Tz) -> DateTime<Tz> {
|
||||
time.with_timezone(&timezone)
|
||||
}
|
||||
|
||||
pub fn current_rss_mib() -> Option<u64> {
|
||||
let status = fs::read_to_string("/proc/self/status").ok()?;
|
||||
let vmrss_line = status.lines().find(|line| line.starts_with("VmRSS:"))?;
|
||||
let kb = vmrss_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|value| value.parse::<u64>().ok())?;
|
||||
Some(kb / 1024)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::rtr::cache::RtrCache;
|
||||
use crate::rtr::server::RtrService;
|
||||
use crate::source::pipeline::{
|
||||
DataQualityReport, PayloadTypeCounts, SlurmRuleCounts, SourceLoadReport,
|
||||
};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn test_configuration() -> ReportConfiguration {
|
||||
ReportConfiguration::new(
|
||||
300,
|
||||
60,
|
||||
100,
|
||||
false,
|
||||
false,
|
||||
chrono_tz::Asia::Shanghai,
|
||||
(3600, 600, 7200),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_report_creates_parseable_json() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"test",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report: Value =
|
||||
serde_json::from_slice(&fs::read(report_dir.join("rtr-server.json")).unwrap()).unwrap();
|
||||
assert_eq!(report["schema_version"], 2);
|
||||
assert_eq!(report["phase"], "test");
|
||||
assert_report_time_offset(&report["generated_at"]);
|
||||
assert_report_time_offset(&report["service"]["started_at"]);
|
||||
assert_report_time_offset(&report["cache"]["created_at"]);
|
||||
assert_eq!(report["cache"]["availability"], "ready");
|
||||
assert_eq!(report["refresh"]["status"], "not_attempted");
|
||||
assert!(report["source"].is_null());
|
||||
assert!(report["data_quality"].is_null());
|
||||
assert_eq!(
|
||||
report["configuration"]["source_refresh_interval_seconds"],
|
||||
300
|
||||
);
|
||||
assert_eq!(report["service"]["max_connections"], 1024);
|
||||
assert_eq!(report["service"]["active_connections"], 0);
|
||||
assert_eq!(report["service"]["connections_by_transport"]["tcp"], 0);
|
||||
assert_eq!(report["service"]["connections_by_transport"]["tls"], 0);
|
||||
assert_eq!(report["service"]["connections_by_transport"]["ssh"], 0);
|
||||
assert_eq!(report["cache"]["versions"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(report["cache"]["versions"][2]["snapshot"]["total"], 0);
|
||||
assert!(!report_dir.join(".rtr-server.json.tmp").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_failure_preserves_last_successful_source_data() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let report_dir = temp.path().join("report");
|
||||
let shared_cache = Arc::new(ArcSwap::from_pointee(RtrCache::default()));
|
||||
let service = RtrService::new(shared_cache.clone());
|
||||
let notifier = service.notifier();
|
||||
let context = ReportContext::new(test_configuration());
|
||||
let source = SourceLoadReport {
|
||||
ccr_file: "data/example.ccr".to_string(),
|
||||
ccr_file_size_bytes: 123,
|
||||
ccr_modified_at: Some(Utc::now()),
|
||||
ccr_produced_at: Some("20260615000000Z".to_string()),
|
||||
slurm_enabled: true,
|
||||
slurm_file_count: 1,
|
||||
slurm_files: vec!["policy.slurm".to_string()],
|
||||
slurm_version: Some(2),
|
||||
};
|
||||
let quality = DataQualityReport {
|
||||
ccr_input: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
invalid: PayloadTypeCounts::default(),
|
||||
before_slurm: PayloadTypeCounts {
|
||||
total: 11,
|
||||
vrp: 10,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
after_slurm: PayloadTypeCounts {
|
||||
total: 10,
|
||||
vrp: 9,
|
||||
router_key: 0,
|
||||
aspa: 1,
|
||||
},
|
||||
slurm_filters: SlurmRuleCounts {
|
||||
prefix: 1,
|
||||
router_key: 0,
|
||||
aspa: 0,
|
||||
},
|
||||
slurm_assertions: SlurmRuleCounts::default(),
|
||||
};
|
||||
|
||||
context.record_refresh_success(Utc::now(), 12, true, source, quality);
|
||||
context.record_refresh_failure(Utc::now(), 5, &anyhow::anyhow!("source unavailable"));
|
||||
context
|
||||
.write(
|
||||
&report_dir,
|
||||
"refresh_failed",
|
||||
&shared_cache,
|
||||
¬ifier,
|
||||
&service.stats(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report: Value =
|
||||
serde_json::from_slice(&fs::read(report_dir.join("rtr-server.json")).unwrap()).unwrap();
|
||||
assert_eq!(report["source"]["ccr_file"], "data/example.ccr");
|
||||
assert_report_time_offset(&report["source"]["ccr_modified_at"]);
|
||||
assert_eq!(report["data_quality"]["after_slurm"]["total"], 10);
|
||||
assert_eq!(report["refresh"]["status"], "failed");
|
||||
assert_eq!(report["refresh"]["consecutive_failures"], 1);
|
||||
assert_eq!(report["refresh"]["last_error"], "source unavailable");
|
||||
assert!(!report["refresh"]["last_success_at"].is_null());
|
||||
assert_report_time_offset(&report["refresh"]["last_success_at"]);
|
||||
}
|
||||
|
||||
fn assert_report_time_offset(value: &Value) {
|
||||
let value = value.as_str().expect("report time should be a string");
|
||||
assert!(
|
||||
value.ends_with("+08:00"),
|
||||
"report time should use +08:00 offset, got {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user