Compare commits

...

6 Commits

Author SHA1 Message Date
durch 8ad641f8f8 fix(nym-api): allow simulation mode to run when another API is advancing epoch
In simulation mode, the nym-api should be able to run performance
simulations regardless of which instance is handling epoch advancement,
since simulations don't perform any blockchain transactions.

This fix:
- Allows simulation mode to proceed when another API is advancing the epoch
- Skips epoch transition attempts in simulation mode
- Skips actual reward distribution blockchain operations in simulation mode
2025-06-24 17:44:25 +02:00
durch b45bb9e7e9 Add socket-level websocket connection health checking
Implements proper socket-level health checking for gateway websocket connections:

- Add is_connection_alive() method to GatewayTransceiver trait
- Implement socket-level health check using TcpStream::peek() on Unix systems
- Add is_gateway_connection_alive() method to MixnetClient for easy access
- Update network monitor to use connection health checks before sending
- Fix axum router state configuration in network monitor

The health check performs actual OS-level socket validation rather than just
checking file descriptor existence, providing reliable connection status.
2025-06-06 12:15:58 +02:00
durch cab072f2d0 Fix client drop loop hanging in network monitor
Replace infinite busy-wait loop with timeout-based client cleanup to prevent potential system hangs during client lifecycle management.
2025-06-06 11:02:06 +02:00
durch c389f43dd0 Put client factory back 2025-06-06 10:51:32 +02:00
durch 71c24d8c81 Respond with 504 to timeouts 2025-06-06 10:49:03 +02:00
durch e336e02df2 Dont use hickory dns for gateway client 2025-06-06 10:42:43 +02:00
24 changed files with 799 additions and 443 deletions
Generated
+1 -2
View File
@@ -4731,7 +4731,7 @@ checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "nym-api"
version = "1.1.63"
version = "1.1.64"
dependencies = [
"anyhow",
"async-trait",
@@ -5666,7 +5666,6 @@ dependencies = [
"nym-credentials-interface",
"nym-crypto",
"nym-gateway-requests",
"nym-http-api-client",
"nym-network-defaults",
"nym-pemstore",
"nym-sphinx",
@@ -36,6 +36,9 @@ pub trait GatewayTransceiver: GatewaySender + GatewayReceiver {
&mut self,
message: ClientRequest,
) -> Result<(), GatewayClientError>;
/// Check if the websocket connection to the gateway is alive
fn is_connection_alive(&self) -> bool;
}
/// This trait defines the functionality of sending `MixPacket` into the mixnet,
@@ -90,6 +93,11 @@ impl<G: GatewayTransceiver + ?Sized + Send> GatewayTransceiver for Box<G> {
log::debug!("Sent client request: {:?}", message);
Ok(())
}
#[inline]
fn is_connection_alive(&self) -> bool {
(**self).is_connection_alive()
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -147,6 +155,10 @@ where
) -> Result<(), GatewayClientError> {
self.gateway_client.send_client_request(message).await
}
fn is_connection_alive(&self) -> bool {
self.gateway_client.is_connection_alive()
}
}
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -234,6 +246,11 @@ mod nonwasm_sealed {
) -> Result<(), GatewayClientError> {
Ok(())
}
fn is_connection_alive(&self) -> bool {
// LocalGateway is always "connected" since it's in-process
true
}
}
#[async_trait]
@@ -316,4 +333,9 @@ impl GatewayTransceiver for MockGateway {
) -> Result<(), GatewayClientError> {
Ok(())
}
fn is_connection_alive(&self) -> bool {
// MockGateway is always "connected" for testing purposes
true
}
}
@@ -27,7 +27,6 @@ nym-credential-storage = { path = "../../credential-storage" }
nym-credentials-interface = { path = "../../credentials-interface" }
nym-crypto = { path = "../../crypto" }
nym-gateway-requests = { path = "../../gateway-requests" }
nym-http-api-client = { path = "../../http-api-client" }
nym-network-defaults = { path = "../../network-defaults" }
nym-sphinx = { path = "../../nymsphinx" }
nym-statistics-common = { path = "../../statistics" }
@@ -165,6 +165,24 @@ impl<C, St> GatewayClient<C, St> {
self.bandwidth.remaining()
}
pub fn is_connection_established(&self) -> bool {
self.connection.is_established()
}
/// Check if the websocket connection is actually alive at the socket level
pub fn is_connection_alive(&self) -> bool {
// First check if we have an established connection
if !self.connection.is_established() {
return false;
}
// Get the file descriptor and check if the socket is alive
match self.ws_fd() {
Some(fd) => socket_is_alive(fd),
None => false,
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn _close_connection(&mut self) -> Result<(), GatewayClientError> {
match std::mem::replace(&mut self.connection, SocketState::NotConnected) {
@@ -1128,3 +1146,49 @@ impl GatewayClient<InitOnly, EphemeralCredentialStorage> {
}
}
}
/// Check if a socket file descriptor is alive and responsive
///
/// This function performs socket-level checks to determine if the connection is actually alive.
/// It's cross-platform compatible and works on both Unix and non-Unix systems.
fn socket_is_alive(fd: RawFd) -> bool {
#[cfg(unix)]
{
use std::io::ErrorKind;
use std::net::TcpStream;
use std::os::unix::io::FromRawFd;
unsafe {
// Create a TcpStream from the raw fd to perform socket operations
let stream = TcpStream::from_raw_fd(fd);
// Try to peek at the socket to see if it's still connected
// We peek with a zero-length buffer to avoid consuming data
let mut buf = [0u8; 0];
let result = match stream.peek(&mut buf) {
Ok(_) => true, // Socket is alive and readable
Err(e) => match e.kind() {
ErrorKind::WouldBlock => true, // Socket is alive but no data available
ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::NotConnected => false, // Socket is clearly dead
_ => true, // Other errors might be temporary, assume alive
},
};
// Prevent the TcpStream from closing the fd when it's dropped
// since we don't own the fd
std::mem::forget(stream);
result
}
}
#[cfg(not(unix))]
{
// On non-Unix systems, we can't easily check socket state
// Fall back to assuming the connection is alive if we have an fd
fd != 0
}
}
@@ -1,6 +1,5 @@
use crate::error::GatewayClientError;
use nym_http_api_client::HickoryDnsResolver;
#[cfg(unix)]
use std::{
os::fd::{AsRawFd, RawFd},
@@ -20,7 +19,6 @@ pub(crate) async fn connect_async(
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), GatewayClientError> {
use tokio::net::TcpSocket;
let resolver = HickoryDnsResolver::default();
let uri =
Url::parse(endpoint).map_err(|_| GatewayClientError::InvalidUrl(endpoint.to_owned()))?;
let port: u16 = uri.port_or_known_default().unwrap_or(443);
@@ -29,18 +27,18 @@ pub(crate) async fn connect_async(
.host()
.ok_or(GatewayClientError::InvalidUrl(endpoint.to_owned()))?;
// Get address for tcp connection, if a domain is provided use our preferred resolver rather than
// the default std resolve
// Get address for tcp connection, using system DNS resolver
let sock_addrs: Vec<SocketAddr> = match host {
Host::Ipv4(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Ipv6(addr) => vec![SocketAddr::new(addr.into(), port)],
Host::Domain(domain) => {
// Do a DNS lookup for the domain using our custom DNS resolver
resolver
.resolve_str(domain)
.await?
.into_iter()
.map(|a| SocketAddr::new(a, port))
// Do a DNS lookup for the domain using system DNS resolver
tokio::net::lookup_host((domain, port))
.await
.map_err(|err| GatewayClientError::NetworkConnectionFailed {
address: endpoint.to_owned(),
source: err.into(),
})?
.collect()
}
};
@@ -49,10 +49,6 @@ pub enum GatewayClientError {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[cfg(not(target_arch = "wasm32"))]
#[error("resolution failed: {0}")]
ResolutionFailed(#[from] nym_http_api_client::HickoryDnsError),
#[error("No shared key was provided or obtained")]
NoSharedKeyAvailable,
+1 -1
View File
@@ -4,7 +4,7 @@
[package]
name = "nym-api"
license = "GPL-3.0"
version = "1.1.63"
version = "1.1.64"
authors.workspace = true
edition = "2021"
rust-version.workspace = true
+1 -3
View File
@@ -60,9 +60,7 @@ pub enum RewardingError {
RewardingParamsRetrievalFailure,
#[error("Database operation failed: {source}")]
DatabaseError {
source: anyhow::Error,
},
DatabaseError { source: anyhow::Error },
#[error("{0}")]
GenericError(#[from] anyhow::Error),
+40 -20
View File
@@ -141,21 +141,34 @@ impl EpochAdvancer {
if epoch_status.being_advanced_by.as_str() != address.as_ref() {
// another nym-api is already handling
error!("another nym-api ({}) is already advancing the epoch... but we shouldn't have other nym-apis yet!", epoch_status.being_advanced_by);
return Ok(());
// In simulation mode, we want to proceed anyway since we're not actually advancing the epoch
if self.simulation_config.is_some() {
info!("Another nym-api ({}) is advancing the epoch, but we're in simulation mode so proceeding anyway", epoch_status.being_advanced_by);
} else {
error!("another nym-api ({}) is already advancing the epoch... but we shouldn't have other nym-apis yet!", epoch_status.being_advanced_by);
return Ok(());
}
} else {
warn!("we seem to have crashed mid-epoch advancement...");
}
} else {
let should_continue = self.begin_epoch_transition().await?;
if !should_continue {
return Ok(());
// In simulation mode, skip trying to begin epoch transition
if self.simulation_config.is_none() {
let should_continue = self.begin_epoch_transition().await?;
if !should_continue {
return Ok(());
}
} else {
info!("Skipping epoch transition in simulation mode - proceeding to performance calculations");
}
}
// Run simulation if enabled (before actual rewarding)
if let Some(simulation_config) = &self.simulation_config {
info!("Running reward simulation for epoch {}", interval.current_epoch_absolute_id());
info!(
"Running reward simulation for epoch {}",
interval.current_epoch_absolute_id()
);
let rewarded_set = match self.nyxd_client.get_rewarded_set_nodes().await {
Ok(rewarded_set) => rewarded_set,
Err(err) => {
@@ -174,26 +187,33 @@ impl EpochAdvancer {
.await
.into_inner()
{
let _ = self.run_simulation_if_enabled(
&rewarded_set,
reward_params,
interval.current_epoch_absolute_id(),
simulation_config.clone(),
).await;
let _ = self
.run_simulation_if_enabled(
&rewarded_set,
reward_params,
interval.current_epoch_absolute_id(),
simulation_config.clone(),
)
.await;
} else {
warn!("Could not obtain reward parameters for simulation");
}
}
// Reward all the nodes in the still current, soon to be previous rewarded set
info!("Rewarding the current rewarded set...");
self.reward_current_rewarded_set(rewards, interval).await?;
// Skip actual rewarding operations in simulation mode
if self.simulation_config.is_none() {
// Reward all the nodes in the still current, soon to be previous rewarded set
info!("Rewarding the current rewarded set...");
self.reward_current_rewarded_set(rewards, interval).await?;
// note: those operations don't really have to be atomic, so it's fine to send them
// as separate transactions
self.reconcile_epoch_events().await?;
self.update_rewarded_set_and_advance_epoch(&nym_nodes)
.await?;
// note: those operations don't really have to be atomic, so it's fine to send them
// as separate transactions
self.reconcile_epoch_events().await?;
self.update_rewarded_set_and_advance_epoch(&nym_nodes)
.await?;
} else {
info!("Skipping actual reward distribution in simulation mode");
}
info!("Purging old data (node statuses and routes) from the storage...");
let cutoff = (epoch_end - 2 * ONE_DAY).unix_timestamp();
+178 -121
View File
@@ -2,18 +2,21 @@
// SPDX-License-Identifier: GPL-3.0-only
//! Performance methodology comparison system
//!
//!
//! This module provides functionality to compare different performance calculation
//! methodologies without affecting actual rewards, enabling analysis of:
//! - Old method: 24-hour cache-based performance calculation
//! - New method: 1-hour route-based performance calculation
//!
//!
//! The system focuses on performance metrics and rankings rather than reward amounts,
//! as actual rewards are calculated on-chain based on factors not available to the API.
use crate::epoch_operations::error::RewardingError;
use crate::epoch_operations::helpers::RewardedNodeWithParams;
use crate::storage::models::{SimulatedNodePerformance, SimulatedPerformanceComparison, SimulatedPerformanceRanking, SimulatedRouteAnalysis};
use crate::storage::models::{
SimulatedNodePerformance, SimulatedPerformanceComparison, SimulatedPerformanceRanking,
SimulatedRouteAnalysis,
};
use crate::support::storage::NymApiStorage;
use crate::EpochAdvancer;
use nym_contracts_common::types::NaiveFloat;
@@ -44,7 +47,6 @@ impl Default for SimulationConfig {
}
}
/// Main simulation coordinator
pub struct SimulationCoordinator<'a> {
storage: &'a NymApiStorage,
@@ -66,14 +68,16 @@ impl<'a> SimulationCoordinator<'a> {
let now = OffsetDateTime::now_utc();
let end_timestamp = now.unix_timestamp();
let start_timestamp = end_timestamp - (24 * 3600); // 24 hours ago for baseline
info!(
"Starting simulation for epoch {} with time window {}h",
current_epoch_id, self.config.new_method_time_window_hours
);
// Create simulation epoch record
let epoch_db_id = self.storage.manager
let epoch_db_id = self
.storage
.manager
.create_simulated_reward_epoch(
current_epoch_id,
"comparison",
@@ -86,12 +90,10 @@ impl<'a> SimulationCoordinator<'a> {
// Run old method simulation (24h cache-based)
if self.config.run_both_methods {
match self.run_old_method_simulation(
rewarded_set,
reward_params,
epoch_db_id,
end_timestamp,
).await {
match self
.run_old_method_simulation(rewarded_set, reward_params, epoch_db_id, end_timestamp)
.await
{
Ok(_) => {
info!("Old method simulation completed successfully");
}
@@ -103,12 +105,10 @@ impl<'a> SimulationCoordinator<'a> {
}
// Run new method simulation (1h route-based)
match self.run_new_method_simulation(
rewarded_set,
reward_params,
epoch_db_id,
end_timestamp,
).await {
match self
.run_new_method_simulation(rewarded_set, reward_params, epoch_db_id, end_timestamp)
.await
{
Ok(_) => {
info!("New method simulation completed successfully");
}
@@ -132,57 +132,62 @@ impl<'a> SimulationCoordinator<'a> {
debug!("Running old method simulation (24h cache-based)");
// Get 24h performance data using existing cache-based method
let mixnode_reliabilities = self.storage
let mixnode_reliabilities = self
.storage
.get_all_avg_mix_reliability_in_last_24hr(end_timestamp)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
let gateway_reliabilities = self.storage
let gateway_reliabilities = self
.storage
.get_all_avg_gateway_reliability_in_last_24hr(end_timestamp)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
// Convert to performance map
let mut performance_map = HashMap::new();
for mix_reliability in mixnode_reliabilities {
performance_map.insert(
mix_reliability.mix_id(),
Performance::from_percentage_value(mix_reliability.value() as u64).unwrap_or_default()
mix_reliability.mix_id(),
Performance::from_percentage_value(mix_reliability.value() as u64)
.unwrap_or_default(),
);
}
for gateway_reliability in gateway_reliabilities {
performance_map.insert(
gateway_reliability.node_id(),
Performance::from_percentage_value(gateway_reliability.value() as u64).unwrap_or_default()
Performance::from_percentage_value(gateway_reliability.value() as u64)
.unwrap_or_default(),
);
}
// Calculate rewards using old method logic
let rewarded_nodes = self.calculate_rewards_for_nodes(
rewarded_set,
reward_params,
&performance_map,
);
let rewarded_nodes =
self.calculate_rewards_for_nodes(rewarded_set, reward_params, &performance_map);
// Convert to simulation data structures
let node_performance = self.convert_to_simulated_performance(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"old",
None, // Old method doesn't have route sample data
).await;
let node_performance = self
.convert_to_simulated_performance(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"old",
None, // Old method doesn't have route sample data
)
.await;
let performance_comparisons = self.convert_to_performance_comparisons(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"old",
).await;
let performance_comparisons = self
.convert_to_performance_comparisons(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"old",
)
.await;
// Create route analysis for old method
let route_analysis = SimulatedRouteAnalysis {
@@ -194,29 +199,36 @@ impl<'a> SimulationCoordinator<'a> {
failed_routes: 0,
average_route_reliability: None,
time_window_hours: 24, // Old method uses 24h
analysis_parameters: Some("{\"method\":\"cache_based\",\"data_source\":\"status_cache\"}".to_string()),
analysis_parameters: Some(
"{\"method\":\"cache_based\",\"data_source\":\"status_cache\"}".to_string(),
),
calculated_at: OffsetDateTime::now_utc().unix_timestamp(),
};
// Store results in database
self.storage.manager
self.storage
.manager
.insert_simulated_node_performance(&node_performance)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
self.storage.manager
self.storage
.manager
.insert_simulated_performance_comparisons(&performance_comparisons)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
// Calculate and store performance rankings
let rankings = self.calculate_performance_rankings(&performance_comparisons, epoch_db_id, "old");
self.storage.manager
let rankings =
self.calculate_performance_rankings(&performance_comparisons, epoch_db_id, "old");
self.storage
.manager
.insert_simulated_performance_rankings(&rankings)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
self.storage.manager
self.storage
.manager
.insert_simulated_route_analysis(&route_analysis)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
@@ -232,13 +244,18 @@ impl<'a> SimulationCoordinator<'a> {
epoch_db_id: i64,
end_timestamp: i64,
) -> Result<(), RewardingError> {
debug!("Running new method simulation ({}h route-based)", self.config.new_method_time_window_hours);
debug!(
"Running new method simulation ({}h route-based)",
self.config.new_method_time_window_hours
);
let time_window_secs = (self.config.new_method_time_window_hours as i64) * 3600;
let start_timestamp = end_timestamp - time_window_secs;
// Get route-based performance data using new method
let corrected_reliabilities = self.storage.manager
let corrected_reliabilities = self
.storage
.manager
.calculate_corrected_node_reliabilities_for_interval(start_timestamp, end_timestamp)
.await
.map_err(|e| RewardingError::DatabaseError { source: e })?;
@@ -252,10 +269,11 @@ impl<'a> SimulationCoordinator<'a> {
let mut reliability_count = 0u32;
for node_reliability in &corrected_reliabilities {
let total_samples = node_reliability.pos_samples_in_interval + node_reliability.neg_samples_in_interval;
let total_samples =
node_reliability.pos_samples_in_interval + node_reliability.neg_samples_in_interval;
total_routes += total_samples;
successful_routes += node_reliability.pos_samples_in_interval;
if total_samples > 0 {
reliability_sum += node_reliability.reliability;
reliability_count += 1;
@@ -263,40 +281,45 @@ impl<'a> SimulationCoordinator<'a> {
performance_map.insert(
node_reliability.node_id,
Performance::naive_try_from_f64(node_reliability.reliability / 100.0).unwrap_or_default()
Performance::naive_try_from_f64(node_reliability.reliability / 100.0)
.unwrap_or_default(),
);
// Store sample counts for detailed performance records
route_reliability_map.insert(
node_reliability.node_id,
(node_reliability.pos_samples_in_interval, node_reliability.neg_samples_in_interval)
(
node_reliability.pos_samples_in_interval,
node_reliability.neg_samples_in_interval,
),
);
}
// Calculate rewards using new method logic
let rewarded_nodes = self.calculate_rewards_for_nodes(
rewarded_set,
reward_params,
&performance_map,
);
let rewarded_nodes =
self.calculate_rewards_for_nodes(rewarded_set, reward_params, &performance_map);
// Convert to simulation data structures
let node_performance = self.convert_to_simulated_performance(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"new",
Some(&route_reliability_map), // Pass route sample data for new method
).await;
// Convert to simulation data structures
let node_performance = self
.convert_to_simulated_performance(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"new",
Some(&route_reliability_map), // Pass route sample data for new method
)
.await;
let performance_comparisons = self.convert_to_performance_comparisons(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"new",
).await;
let performance_comparisons = self
.convert_to_performance_comparisons(
&rewarded_nodes,
rewarded_set,
reward_params,
epoch_db_id,
"new",
)
.await;
// Create route analysis for new method
let route_analysis = SimulatedRouteAnalysis {
@@ -321,24 +344,29 @@ impl<'a> SimulationCoordinator<'a> {
};
// Store results in database
self.storage.manager
self.storage
.manager
.insert_simulated_node_performance(&node_performance)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
self.storage.manager
self.storage
.manager
.insert_simulated_performance_comparisons(&performance_comparisons)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
// Calculate and store performance rankings
let rankings = self.calculate_performance_rankings(&performance_comparisons, epoch_db_id, "new");
self.storage.manager
let rankings =
self.calculate_performance_rankings(&performance_comparisons, epoch_db_id, "new");
self.storage
.manager
.insert_simulated_performance_rankings(&rankings)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
self.storage.manager
self.storage
.manager
.insert_simulated_route_analysis(&route_analysis)
.await
.map_err(|e| RewardingError::DatabaseError { source: e.into() })?;
@@ -409,19 +437,33 @@ impl<'a> SimulationCoordinator<'a> {
}
/// Determine node type and work factor from rewarded set position
fn determine_node_info(&self, node_id: NodeId, rewarded_set: &EpochRewardedSet, reward_params: RewardingParams) -> (String, f64) {
fn determine_node_info(
&self,
node_id: NodeId,
rewarded_set: &EpochRewardedSet,
reward_params: RewardingParams,
) -> (String, f64) {
let nodes = &rewarded_set.assignment;
// Check if node is in active mixnode layers
if nodes.layer1.contains(&node_id) || nodes.layer2.contains(&node_id) || nodes.layer3.contains(&node_id) {
return ("mixnode".to_string(), reward_params.active_node_work().naive_to_f64());
if nodes.layer1.contains(&node_id)
|| nodes.layer2.contains(&node_id)
|| nodes.layer3.contains(&node_id)
{
return (
"mixnode".to_string(),
reward_params.active_node_work().naive_to_f64(),
);
}
// Check if node is in active gateways
if nodes.entry_gateways.contains(&node_id) || nodes.exit_gateways.contains(&node_id) {
return ("gateway".to_string(), reward_params.active_node_work().naive_to_f64());
return (
"gateway".to_string(),
reward_params.active_node_work().naive_to_f64(),
);
}
// Check if node is in standby (could be mixnode or gateway)
if nodes.standby.contains(&node_id) {
// Note: We cannot determine if standby nodes are mixnodes or gateways from the
@@ -430,9 +472,12 @@ impl<'a> SimulationCoordinator<'a> {
// 1. All standby nodes receive the same work factor regardless of type
// 2. The gateway 3-sample rule can't be applied to standby nodes in either method
// For consistency, we label all standby nodes as "mixnode" in the simulation data
return ("mixnode".to_string(), reward_params.standby_node_work().naive_to_f64());
return (
"mixnode".to_string(),
reward_params.standby_node_work().naive_to_f64(),
);
}
// Default case (shouldn't happen)
("unknown".to_string(), 0.0)
}
@@ -448,40 +493,43 @@ impl<'a> SimulationCoordinator<'a> {
route_reliability_map: Option<&HashMap<NodeId, (u32, u32)>>, // (positive_samples, negative_samples)
) -> Vec<SimulatedNodePerformance> {
let now = OffsetDateTime::now_utc().unix_timestamp();
let mut performance_records = Vec::with_capacity(rewarded_nodes.len());
// First collect all node IDs for batch identity key lookups
let all_node_ids: Vec<NodeId> = rewarded_nodes.iter().map(|node| node.node_id).collect();
// Batch fetch identity keys for both mixnodes and gateways
let mixnode_identities = self.storage
let mixnode_identities = self
.storage
.manager
.get_mixnode_identity_keys_batch(&all_node_ids)
.await
.unwrap_or_default();
let gateway_identities = self.storage
let gateway_identities = self
.storage
.manager
.get_gateway_identity_keys_batch(&all_node_ids)
.await
.unwrap_or_default();
for node in rewarded_nodes {
let (node_type, _) = self.determine_node_info(node.node_id, rewarded_set, reward_params);
let (node_type, _) =
self.determine_node_info(node.node_id, rewarded_set, reward_params);
// Get identity key from our batch results
let identity_key = match node_type.as_str() {
"mixnode" => mixnode_identities.get(&node.node_id).cloned(),
"gateway" => gateway_identities.get(&node.node_id).cloned(),
_ => None,
};
// Extract sample counts from route reliability if available
let (positive_samples, negative_samples) = route_reliability_map
.and_then(|map| map.get(&node.node_id))
.copied()
.unwrap_or((0, 0));
performance_records.push(SimulatedNodePerformance {
id: 0, // Will be set by database
simulated_epoch_id: epoch_db_id,
@@ -496,7 +544,7 @@ impl<'a> SimulationCoordinator<'a> {
calculated_at: now,
});
}
performance_records
}
@@ -510,12 +558,13 @@ impl<'a> SimulationCoordinator<'a> {
method: &str,
) -> Vec<SimulatedPerformanceComparison> {
let now = OffsetDateTime::now_utc().unix_timestamp();
let mut performance_records = Vec::with_capacity(rewarded_nodes.len());
for node in rewarded_nodes {
let (node_type, _) = self.determine_node_info(node.node_id, rewarded_set, reward_params);
let (node_type, _) =
self.determine_node_info(node.node_id, rewarded_set, reward_params);
performance_records.push(SimulatedPerformanceComparison {
id: 0, // Will be set by database
simulated_epoch_id: epoch_db_id,
@@ -530,10 +579,10 @@ impl<'a> SimulationCoordinator<'a> {
calculated_at: now,
});
}
performance_records
}
/// Calculate performance rankings for a set of performance comparisons
fn calculate_performance_rankings(
&self,
@@ -543,20 +592,22 @@ impl<'a> SimulationCoordinator<'a> {
) -> Vec<SimulatedPerformanceRanking> {
let now = OffsetDateTime::now_utc().unix_timestamp();
let mut rankings = Vec::with_capacity(performance_comparisons.len());
// Sort by performance score descending
let mut sorted_comparisons: Vec<_> = performance_comparisons.iter()
let mut sorted_comparisons: Vec<_> = performance_comparisons
.iter()
.enumerate()
.map(|(idx, comp)| (idx, comp.performance_score))
.collect();
sorted_comparisons.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
sorted_comparisons
.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let total_nodes = sorted_comparisons.len() as f64;
for (rank, (original_idx, _score)) in sorted_comparisons.iter().enumerate() {
let comparison = &performance_comparisons[*original_idx];
let percentile = ((total_nodes - rank as f64 - 1.0) / total_nodes) * 100.0;
rankings.push(SimulatedPerformanceRanking {
id: 0, // Will be set by database
simulated_epoch_id: epoch_db_id,
@@ -567,7 +618,7 @@ impl<'a> SimulationCoordinator<'a> {
calculated_at: now,
});
}
rankings
}
}
@@ -582,10 +633,16 @@ impl EpochAdvancer {
simulation_config: SimulationConfig,
) -> Result<(), RewardingError> {
let coordinator = SimulationCoordinator::new(&self.storage, simulation_config);
match coordinator.run_simulation(rewarded_set, reward_params, current_epoch_id).await {
match coordinator
.run_simulation(rewarded_set, reward_params, current_epoch_id)
.await
{
Ok(()) => {
info!("Simulation completed successfully for epoch {}", current_epoch_id);
info!(
"Simulation completed successfully for epoch {}",
current_epoch_id
);
Ok(())
}
Err(e) => {
@@ -595,4 +652,4 @@ impl EpochAdvancer {
}
}
}
}
}
+283 -179
View File
@@ -4,10 +4,10 @@
//! Handlers for simulation API endpoints
use crate::simulation_api::models::{
SimulationApiError, SimulationEpochDetails, SimulationEpochSummary, SimulationEpochsResponse,
SimulationListQuery, NodeComparisonQuery, MethodComparisonResponse, NodeMethodComparison,
ComparisonSummaryStats, RouteAnalysisComparison, NodePerformanceData, PerformanceComparisonData,
RouteAnalysisData, ExportFormat,
ComparisonSummaryStats, ExportFormat, MethodComparisonResponse, NodeComparisonQuery,
NodeMethodComparison, NodePerformanceData, PerformanceComparisonData, RouteAnalysisComparison,
RouteAnalysisData, SimulationApiError, SimulationEpochDetails, SimulationEpochSummary,
SimulationEpochsResponse, SimulationListQuery,
};
use crate::storage::models::SimulatedPerformanceRanking;
use crate::support::http::state::AppState;
@@ -33,7 +33,10 @@ pub(crate) fn simulation_routes() -> Router<AppState> {
.route("/epochs/:epoch_id", get(get_simulation_epoch_details))
.route("/epochs/:epoch_id/comparison", get(compare_methods))
.route("/epochs/:epoch_id/export", get(export_simulation_data))
.route("/nodes/:node_id/performance", get(get_node_performance_history))
.route(
"/nodes/:node_id/performance",
get(get_node_performance_history),
)
}
#[derive(Deserialize, IntoParams)]
@@ -73,48 +76,61 @@ async fn list_simulation_epochs(
) -> AxumResult<FormattedResponse<SimulationEpochsResponse>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
// Apply defaults and validation
let limit = params.limit.unwrap_or(50).min(1000);
let offset = params.offset.unwrap_or(0);
let epochs = get_simulation_epochs_with_filters(storage, &params, limit, offset)
.await
.map_err(to_axum_error)?;
// Enhance epochs with additional metadata using batch operations
let epoch_db_ids: Vec<i64> = epochs.iter().map(|e| e.id).collect();
let epoch_ids: Vec<u32> = epochs.iter().map(|e| e.epoch_id).collect();
// Batch fetch node counts and available methods for all epochs
let node_counts = storage
.manager
.count_simulated_node_performance_for_epochs_batch(&epoch_db_ids)
.await
.map_err(|e| to_axum_error(SimulationApiError::with_details("Database error", &e.to_string())))?;
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Database error",
&e.to_string(),
))
})?;
let available_methods = storage
.manager
.get_available_calculation_methods_for_epochs_batch(&epoch_ids)
.await
.map_err(|e| to_axum_error(SimulationApiError::with_details("Database error", &e.to_string())))?;
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Database error",
&e.to_string(),
))
})?;
let mut enhanced_epochs = Vec::new();
for mut epoch in epochs {
// Get metadata from our batch results
epoch.nodes_analyzed = node_counts.get(&epoch.id).copied().unwrap_or(0);
epoch.available_methods = available_methods.get(&epoch.epoch_id).cloned().unwrap_or_default();
epoch.available_methods = available_methods
.get(&epoch.epoch_id)
.cloned()
.unwrap_or_default();
enhanced_epochs.push(epoch);
}
let total_count = count_total_simulation_epochs(storage, &params)
.await
.map_err(to_axum_error)?;
let response = SimulationEpochsResponse {
epochs: enhanced_epochs,
total_count,
};
Ok(output.to_response(response))
}
@@ -141,39 +157,40 @@ async fn get_simulation_epoch_details(
) -> AxumResult<FormattedResponse<SimulationEpochDetails>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
let epoch = get_simulation_epoch_by_id(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
let mut epoch_summary = SimulationEpochSummary::from(epoch);
epoch_summary.nodes_analyzed = count_nodes_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
epoch_summary.available_methods = get_available_methods_for_epoch(storage, epoch_summary.epoch_id)
.await
.map_err(to_axum_error)?;
epoch_summary.available_methods =
get_available_methods_for_epoch(storage, epoch_summary.epoch_id)
.await
.map_err(to_axum_error)?;
let node_performance = get_node_performance_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let performance_comparisons = get_performance_comparisons_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let route_analysis = get_route_analysis_for_epoch(storage, params.epoch_id)
.await
.map_err(to_axum_error)?;
let details = SimulationEpochDetails {
epoch: epoch_summary,
node_performance,
performance_comparisons,
route_analysis,
};
Ok(output.to_response(details))
}
@@ -202,13 +219,13 @@ async fn compare_methods(
) -> AxumResult<FormattedResponse<MethodComparisonResponse>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
// Get simulation epoch to extract actual epoch_id
let sim_epoch = get_simulation_epoch_by_id(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
// Get performance data for both methods
let old_performance = get_performance_by_method(storage, sim_epoch.epoch_id, "old")
.await
@@ -216,34 +233,46 @@ async fn compare_methods(
let new_performance = get_performance_by_method(storage, sim_epoch.epoch_id, "new")
.await
.map_err(to_axum_error)?;
// Get performance rankings for both methods
let old_rankings = storage.manager
let old_rankings = storage
.manager
.get_simulated_performance_rankings(params.epoch_id, Some("old"))
.await
.map_err(|e| to_axum_error(SimulationApiError::with_details("Database error", &e.to_string())))?;
let new_rankings = storage.manager
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Database error",
&e.to_string(),
))
})?;
let new_rankings = storage
.manager
.get_simulated_performance_rankings(params.epoch_id, Some("new"))
.await
.map_err(|e| to_axum_error(SimulationApiError::with_details("Database error", &e.to_string())))?;
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Database error",
&e.to_string(),
))
})?;
// Build node comparisons
let node_comparisons = build_node_comparisons_with_rankings(
old_performance,
new_performance,
old_performance,
new_performance,
old_rankings,
new_rankings,
&query
&query,
);
// Calculate summary statistics
let summary_statistics = calculate_summary_statistics(&node_comparisons);
// Get route analysis comparison
let route_analysis_comparison = get_route_analysis_comparison(storage, sim_epoch.epoch_id)
.await
.map_err(to_axum_error)?;
let comparison = MethodComparisonResponse {
epoch_id: sim_epoch.epoch_id,
simulation_epoch_id: params.epoch_id,
@@ -251,7 +280,7 @@ async fn compare_methods(
summary_statistics,
route_analysis_comparison,
};
Ok(output.to_response(comparison))
}
@@ -278,35 +307,65 @@ async fn export_simulation_data(
) -> Result<Response, (StatusCode, Json<SimulationApiError>)> {
let storage = state.storage();
let format = query.format.unwrap_or(ExportFormat::Json);
// Get detailed simulation data
let epoch_details = get_simulation_epoch_details_internal(storage, params.epoch_id)
.await
.map_err(to_axum_error)?
.ok_or_else(|| to_axum_error(SimulationApiError::new("Simulation epoch not found")))?;
match format {
ExportFormat::Json => {
let json_data = serde_json::to_string_pretty(&epoch_details)
.map_err(|e| to_axum_error(SimulationApiError::with_details("JSON serialization failed", &e.to_string())))?;
let json_data = serde_json::to_string_pretty(&epoch_details).map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"JSON serialization failed",
&e.to_string(),
))
})?;
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Content-Disposition", format!("attachment; filename=\"simulation_epoch_{}.json\"", params.epoch_id))
.header(
"Content-Disposition",
format!(
"attachment; filename=\"simulation_epoch_{}.json\"",
params.epoch_id
),
)
.body(json_data.into())
.map_err(|e| to_axum_error(SimulationApiError::with_details("Response building failed", &e.to_string())))
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Response building failed",
&e.to_string(),
))
})
}
ExportFormat::Csv => {
let csv_data = convert_to_csv(&epoch_details)
.map_err(|e| to_axum_error(SimulationApiError::with_details("CSV conversion failed", &e.to_string())))?;
let csv_data = convert_to_csv(&epoch_details).map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"CSV conversion failed",
&e.to_string(),
))
})?;
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "text/csv")
.header("Content-Disposition", format!("attachment; filename=\"simulation_epoch_{}.csv\"", params.epoch_id))
.header(
"Content-Disposition",
format!(
"attachment; filename=\"simulation_epoch_{}.csv\"",
params.epoch_id
),
)
.body(csv_data.into())
.map_err(|e| to_axum_error(SimulationApiError::with_details("Response building failed", &e.to_string())))
.map_err(|e| {
to_axum_error(SimulationApiError::with_details(
"Response building failed",
&e.to_string(),
))
})
}
}
}
@@ -333,11 +392,11 @@ async fn get_node_performance_history(
) -> AxumResult<FormattedResponse<Vec<NodePerformanceData>>> {
let storage = state.storage();
let output = output.output.unwrap_or_default();
let performance_history = get_node_performance_history_internal(storage, params.node_id)
.await
.map_err(to_axum_error)?;
Ok(output.to_response(performance_history))
}
@@ -351,7 +410,7 @@ async fn get_simulation_epochs_with_filters(
) -> SimulationResult<Vec<SimulationEpochSummary>> {
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let epochs = sqlx::query_as!(
crate::support::storage::models::SimulatedRewardEpoch,
"SELECT id as \"id!\", epoch_id as \"epoch_id!: u32\", calculation_method as \"calculation_method!\",
@@ -366,8 +425,11 @@ async fn get_simulation_epochs_with_filters(
.fetch_all(&storage.manager.connection_pool)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(epochs.into_iter().map(SimulationEpochSummary::from).collect())
Ok(epochs
.into_iter()
.map(SimulationEpochSummary::from)
.collect())
}
async fn count_nodes_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<usize> {
@@ -378,7 +440,10 @@ async fn count_nodes_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> Simula
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn get_available_methods_for_epoch(storage: &NymApiStorage, epoch_id: u32) -> SimulationResult<Vec<String>> {
async fn get_available_methods_for_epoch(
storage: &NymApiStorage,
epoch_id: u32,
) -> SimulationResult<Vec<String>> {
storage
.manager
.get_available_calculation_methods_for_epoch(epoch_id)
@@ -386,18 +451,22 @@ async fn get_available_methods_for_epoch(storage: &NymApiStorage, epoch_id: u32)
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn count_total_simulation_epochs(storage: &NymApiStorage, _params: &SimulationListQuery) -> SimulationResult<usize> {
let result = sqlx::query!(
"SELECT COUNT(*) as count FROM simulated_reward_epochs"
)
.fetch_one(&storage.manager.connection_pool)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
async fn count_total_simulation_epochs(
storage: &NymApiStorage,
_params: &SimulationListQuery,
) -> SimulationResult<usize> {
let result = sqlx::query!("SELECT COUNT(*) as count FROM simulated_reward_epochs")
.fetch_one(&storage.manager.connection_pool)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(result.count as usize)
}
async fn get_simulation_epoch_by_id(storage: &NymApiStorage, id: i64) -> SimulationResult<Option<crate::support::storage::models::SimulatedRewardEpoch>> {
async fn get_simulation_epoch_by_id(
storage: &NymApiStorage,
id: i64,
) -> SimulationResult<Option<crate::support::storage::models::SimulatedRewardEpoch>> {
storage
.manager
.get_simulated_reward_epoch(id)
@@ -405,48 +474,66 @@ async fn get_simulation_epoch_by_id(storage: &NymApiStorage, id: i64) -> Simulat
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))
}
async fn get_node_performance_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<NodePerformanceData>> {
async fn get_node_performance_for_epoch(
storage: &NymApiStorage,
epoch_id: i64,
) -> SimulationResult<Vec<NodePerformanceData>> {
let performance = storage
.manager
.get_simulated_node_performance_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
Ok(performance
.into_iter()
.map(NodePerformanceData::from)
.collect())
}
async fn get_performance_comparisons_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<PerformanceComparisonData>> {
async fn get_performance_comparisons_for_epoch(
storage: &NymApiStorage,
epoch_id: i64,
) -> SimulationResult<Vec<PerformanceComparisonData>> {
let comparisons = storage
.manager
.get_simulated_performance_comparisons_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(comparisons.into_iter().map(PerformanceComparisonData::from).collect())
Ok(comparisons
.into_iter()
.map(PerformanceComparisonData::from)
.collect())
}
async fn get_route_analysis_for_epoch(storage: &NymApiStorage, epoch_id: i64) -> SimulationResult<Vec<RouteAnalysisData>> {
async fn get_route_analysis_for_epoch(
storage: &NymApiStorage,
epoch_id: i64,
) -> SimulationResult<Vec<RouteAnalysisData>> {
let analysis = storage
.manager
.get_simulated_route_analysis_for_epoch(epoch_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(analysis.into_iter().map(RouteAnalysisData::from).collect())
}
async fn get_performance_by_method(
storage: &NymApiStorage,
epoch_id: u32,
method: &str
storage: &NymApiStorage,
epoch_id: u32,
method: &str,
) -> SimulationResult<Vec<NodePerformanceData>> {
let performance = storage
.manager
.get_simulated_node_performance_by_method(epoch_id, method)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
Ok(performance
.into_iter()
.map(NodePerformanceData::from)
.collect())
}
fn build_node_comparisons_with_rankings(
@@ -460,85 +547,88 @@ fn build_node_comparisons_with_rankings(
.into_iter()
.map(|p| (p.node_id, p))
.collect();
let mut new_map: HashMap<NodeId, NodePerformanceData> = new_performance
.into_iter()
.map(|p| (p.node_id, p))
.collect();
// Create ranking maps
let old_ranking_map: HashMap<NodeId, i64> = old_rankings
.into_iter()
.map(|r| (r.node_id, r.performance_rank))
.collect();
let new_ranking_map: HashMap<NodeId, i64> = new_rankings
.into_iter()
.map(|r| (r.node_id, r.performance_rank))
.collect();
let mut comparisons = Vec::new();
// Get all unique node IDs from both methods
let mut all_node_ids: Vec<_> = old_map.keys().chain(new_map.keys()).cloned().collect();
all_node_ids.sort();
all_node_ids.dedup();
for node_id in all_node_ids {
let old_perf = old_map.remove(&node_id);
let new_perf = new_map.remove(&node_id);
// Apply filters
if let Some(filter_node_id) = query.node_id {
if node_id != filter_node_id {
continue;
}
}
if let Some(ref filter_node_type) = query.node_type {
let node_type = old_perf.as_ref()
let node_type = old_perf
.as_ref()
.or(new_perf.as_ref())
.map(|p| &p.node_type);
if node_type != Some(filter_node_type) {
continue;
}
}
// Calculate differences
let reliability_difference = match (&old_perf, &new_perf) {
(Some(old), Some(new)) => Some(new.reliability_score - old.reliability_score),
_ => None,
};
let performance_delta_percentage = match (&old_perf, &new_perf) {
(Some(old), Some(new)) if old.reliability_score != 0.0 => {
Some((new.reliability_score - old.reliability_score) / old.reliability_score * 100.0)
}
(Some(old), Some(new)) if old.reliability_score != 0.0 => Some(
(new.reliability_score - old.reliability_score) / old.reliability_score * 100.0,
),
_ => None,
};
// Apply delta filters
if let Some(min_delta) = query.min_delta {
if reliability_difference.map_or(true, |d| d < min_delta) {
continue;
}
}
if let Some(max_delta) = query.max_delta {
if reliability_difference.map_or(true, |d| d > max_delta) {
continue;
}
}
let node_type = old_perf.as_ref()
let node_type = old_perf
.as_ref()
.or(new_perf.as_ref())
.map(|p| p.node_type.clone())
.unwrap_or_else(|| "unknown".to_string());
let identity_key = old_perf.as_ref()
let identity_key = old_perf
.as_ref()
.or(new_perf.as_ref())
.and_then(|p| p.identity_key.clone());
// Get rankings for this node
let ranking_old = old_ranking_map.get(&node_id).copied();
let ranking_new = new_ranking_map.get(&node_id).copied();
@@ -546,7 +636,7 @@ fn build_node_comparisons_with_rankings(
(Some(old), Some(new)) => Some(new - old),
_ => None,
};
comparisons.push(NodeMethodComparison {
node_id,
node_type,
@@ -560,7 +650,7 @@ fn build_node_comparisons_with_rankings(
ranking_delta,
});
}
comparisons
}
@@ -572,7 +662,7 @@ fn calculate_summary_statistics(comparisons: &[NodeMethodComparison]) -> Compari
let mut unchanged = 0;
let mut max_improvement: f64 = 0.0;
let mut max_degradation: f64 = 0.0;
for comparison in comparisons {
if let Some(old) = &comparison.old_method {
reliabilities_old.push(old.reliability_score);
@@ -580,7 +670,7 @@ fn calculate_summary_statistics(comparisons: &[NodeMethodComparison]) -> Compari
if let Some(new) = &comparison.new_method {
reliabilities_new.push(new.reliability_score);
}
if let Some(diff) = comparison.reliability_difference {
if diff > 0.001 {
improvements += 1;
@@ -593,23 +683,25 @@ fn calculate_summary_statistics(comparisons: &[NodeMethodComparison]) -> Compari
}
}
}
let average_reliability_old = if reliabilities_old.is_empty() {
0.0
} else {
reliabilities_old.iter().sum::<f64>() / reliabilities_old.len() as f64
};
let average_reliability_new = if reliabilities_new.is_empty() {
0.0
} else {
reliabilities_new.iter().sum::<f64>() / reliabilities_new.len() as f64
};
// Calculate medians and standard deviations
let (median_reliability_old, reliability_std_dev_old) = calculate_median_and_std(&reliabilities_old);
let (median_reliability_new, reliability_std_dev_new) = calculate_median_and_std(&reliabilities_new);
let (median_reliability_old, reliability_std_dev_old) =
calculate_median_and_std(&reliabilities_old);
let (median_reliability_new, reliability_std_dev_new) =
calculate_median_and_std(&reliabilities_new);
ComparisonSummaryStats {
total_nodes_compared: comparisons.len(),
nodes_improved: improvements,
@@ -630,22 +722,20 @@ fn calculate_median_and_std(values: &[f64]) -> (f64, f64) {
if values.is_empty() {
return (0.0, 0.0);
}
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if sorted.len() % 2 == 0 {
(sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
} else {
sorted[sorted.len() / 2]
};
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter()
.map(|x| (x - mean).powi(2))
.sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;
let std_dev = variance.sqrt();
(median, std_dev)
}
@@ -659,24 +749,26 @@ async fn get_route_analysis_comparison(
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?
.map(RouteAnalysisData::from);
let new_analysis = storage
.manager
.get_simulated_route_analysis_by_method(epoch_id, "new")
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?
.map(RouteAnalysisData::from);
let time_window_difference_hours = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => new.time_window_hours as i32 - old.time_window_hours as i32,
_ => 0,
};
let route_coverage_difference = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => new.total_routes_analyzed as i32 - old.total_routes_analyzed as i32,
(Some(old), Some(new)) => {
new.total_routes_analyzed as i32 - old.total_routes_analyzed as i32
}
_ => 0,
};
let success_rate_difference = match (&old_analysis, &new_analysis) {
(Some(old), Some(new)) => {
let old_rate = old.successful_routes as f64 / old.total_routes_analyzed as f64;
@@ -685,7 +777,7 @@ async fn get_route_analysis_comparison(
}
_ => None,
};
Ok(RouteAnalysisComparison {
old_method: old_analysis,
new_method: new_analysis,
@@ -703,15 +795,16 @@ async fn get_simulation_epoch_details_internal(
Some(epoch) => epoch,
None => return Ok(None),
};
let mut epoch_summary = SimulationEpochSummary::from(epoch);
epoch_summary.nodes_analyzed = count_nodes_for_epoch(storage, epoch_id).await?;
epoch_summary.available_methods = get_available_methods_for_epoch(storage, epoch_summary.epoch_id).await?;
epoch_summary.available_methods =
get_available_methods_for_epoch(storage, epoch_summary.epoch_id).await?;
let node_performance = get_node_performance_for_epoch(storage, epoch_id).await?;
let performance_comparisons = get_performance_comparisons_for_epoch(storage, epoch_id).await?;
let route_analysis = get_route_analysis_for_epoch(storage, epoch_id).await?;
Ok(Some(SimulationEpochDetails {
epoch: epoch_summary,
node_performance,
@@ -723,10 +816,12 @@ async fn get_simulation_epoch_details_internal(
fn convert_to_csv(details: &SimulationEpochDetails) -> Result<String, Box<dyn std::error::Error>> {
// Simple CSV conversion
let mut csv = String::new();
// Header
csv.push_str("data_type,node_id,node_type,reliability_score,reward_amount,calculation_method\n");
csv.push_str(
"data_type,node_id,node_type,reliability_score,reward_amount,calculation_method\n",
);
// Performance data
for perf in &details.node_performance {
csv.push_str(&format!(
@@ -734,15 +829,19 @@ fn convert_to_csv(details: &SimulationEpochDetails) -> Result<String, Box<dyn st
perf.node_id, perf.node_type, perf.reliability_score, "", perf.calculation_method
));
}
// Performance comparison data
for comparison in &details.performance_comparisons {
csv.push_str(&format!(
"performance_comparison,{},{},{},{},{}\n",
comparison.node_id, comparison.node_type, "", comparison.performance_score, comparison.calculation_method
comparison.node_id,
comparison.node_type,
"",
comparison.performance_score,
comparison.calculation_method
));
}
Ok(csv)
}
@@ -755,8 +854,11 @@ async fn get_node_performance_history_internal(
.get_simulated_node_performance_history(node_id)
.await
.map_err(|e| SimulationApiError::with_details("Database error", &e.to_string()))?;
Ok(performance.into_iter().map(NodePerformanceData::from).collect())
Ok(performance
.into_iter()
.map(NodePerformanceData::from)
.collect())
}
fn to_axum_error(error: SimulationApiError) -> (StatusCode, Json<SimulationApiError>) {
@@ -766,9 +868,13 @@ fn to_axum_error(error: SimulationApiError) -> (StatusCode, Json<SimulationApiEr
#[cfg(test)]
mod tests {
use super::*;
use crate::simulation_api::models::{NodePerformanceData, NodeMethodComparison};
fn create_test_performance_data(node_id: NodeId, reliability: f64, method: &str) -> NodePerformanceData {
use crate::simulation_api::models::{NodeMethodComparison, NodePerformanceData};
fn create_test_performance_data(
node_id: NodeId,
reliability: f64,
method: &str,
) -> NodePerformanceData {
NodePerformanceData {
node_id,
node_type: "mixnode".to_string(),
@@ -862,7 +968,7 @@ mod tests {
create_test_performance_data(1, 80.0, "old"),
create_test_performance_data(2, 70.0, "old"),
];
let new_performance = vec![
create_test_performance_data(1, 90.0, "new"),
create_test_performance_data(3, 85.0, "new"), // New node not in old
@@ -896,7 +1002,7 @@ mod tests {
calculated_at: 1234567890,
},
];
let new_rankings = vec![
SimulatedPerformanceRanking {
id: 3,
@@ -928,15 +1034,15 @@ mod tests {
];
let comparisons = build_node_comparisons_with_rankings(
old_performance,
new_performance,
old_performance,
new_performance,
old_rankings,
new_rankings,
&query
&query,
);
assert_eq!(comparisons.len(), 3); // Nodes 1, 2, 3
// Find node 1 comparison
let node1_comparison = comparisons.iter().find(|c| c.node_id == 1).unwrap();
assert!(node1_comparison.old_method.is_some());
@@ -963,7 +1069,7 @@ mod tests {
create_test_performance_data(1, 80.0, "old"),
create_test_performance_data(2, 70.0, "old"),
];
let new_performance = vec![
create_test_performance_data(1, 90.0, "new"),
create_test_performance_data(2, 75.0, "new"),
@@ -978,24 +1084,22 @@ mod tests {
};
// Create test rankings for filter test
let rankings = vec![
SimulatedPerformanceRanking {
id: 1,
simulated_epoch_id: 1,
node_id: 1,
calculation_method: "old".to_string(),
performance_rank: 1,
performance_percentile: 100.0,
calculated_at: 1234567890,
},
];
let rankings = vec![SimulatedPerformanceRanking {
id: 1,
simulated_epoch_id: 1,
node_id: 1,
calculation_method: "old".to_string(),
performance_rank: 1,
performance_percentile: 100.0,
calculated_at: 1234567890,
}];
let comparisons = build_node_comparisons_with_rankings(
old_performance.clone(),
new_performance.clone(),
old_performance.clone(),
new_performance.clone(),
rankings.clone(),
rankings.clone(),
&query
&query,
);
assert_eq!(comparisons.len(), 1);
assert_eq!(comparisons[0].node_id, 1);
@@ -1009,11 +1113,11 @@ mod tests {
};
let comparisons = build_node_comparisons_with_rankings(
old_performance,
old_performance,
new_performance,
rankings.clone(),
rankings,
&query
&query,
);
assert_eq!(comparisons.len(), 1);
assert_eq!(comparisons[0].node_id, 1);
@@ -1037,28 +1141,28 @@ mod tests {
create_test_performance_data(1, 80.0, "old"),
create_test_performance_data(1, 90.0, "new"),
],
performance_comparisons: vec![
PerformanceComparisonData {
node_id: 1,
node_type: "mixnode".to_string(),
performance_score: 80.0,
work_factor: 10.0,
calculation_method: "old".to_string(),
positive_samples: Some(100),
negative_samples: Some(20),
route_success_rate: Some(80.0),
calculated_at: 1234567890,
},
],
performance_comparisons: vec![PerformanceComparisonData {
node_id: 1,
node_type: "mixnode".to_string(),
performance_score: 80.0,
work_factor: 10.0,
calculation_method: "old".to_string(),
positive_samples: Some(100),
negative_samples: Some(20),
route_success_rate: Some(80.0),
calculated_at: 1234567890,
}],
route_analysis: vec![],
};
let csv = convert_to_csv(&details).unwrap();
println!("CSV: {}", csv);
assert!(csv.contains("data_type,node_id,node_type,reliability_score,reward_amount,calculation_method"));
assert!(csv.contains(
"data_type,node_id,node_type,reliability_score,reward_amount,calculation_method"
));
assert!(csv.contains("performance,1,mixnode,80,"));
assert!(csv.contains("performance,1,mixnode,90,"));
}
}
}
+2 -2
View File
@@ -2,9 +2,9 @@
// SPDX-License-Identifier: GPL-3.0-only
//! Simulation API for reward calculation analysis
//!
//!
//! This module provides REST endpoints for accessing and analyzing
//! simulated reward calculation data comparing old vs new methodologies.
pub(crate) mod handlers;
pub(crate) mod models;
pub(crate) mod models;
+13 -10
View File
@@ -3,7 +3,10 @@
//! API models for simulation data responses
use crate::storage::models::{SimulatedNodePerformance, SimulatedPerformanceComparison, SimulatedRewardEpoch, SimulatedRouteAnalysis};
use crate::storage::models::{
SimulatedNodePerformance, SimulatedPerformanceComparison, SimulatedRewardEpoch,
SimulatedRouteAnalysis,
};
use nym_mixnet_contract_common::NodeId;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
@@ -42,7 +45,7 @@ impl From<SimulatedRewardEpoch> for SimulationEpochSummary {
end_timestamp: epoch.end_timestamp,
description: epoch.description,
created_at: epoch.created_at,
nodes_analyzed: 0, // Will be populated by handler
nodes_analyzed: 0, // Will be populated by handler
available_methods: vec![], // Will be populated by handler
}
}
@@ -174,17 +177,17 @@ pub struct NodeMethodComparison {
#[derive(Serialize, Deserialize, ToSchema, Debug, Clone)]
pub struct ComparisonSummaryStats {
pub total_nodes_compared: usize,
pub nodes_improved: usize, // nodes with better performance in new method
pub nodes_degraded: usize, // nodes with worse performance in new method
pub nodes_unchanged: usize, // nodes with same performance
pub nodes_improved: usize, // nodes with better performance in new method
pub nodes_degraded: usize, // nodes with worse performance in new method
pub nodes_unchanged: usize, // nodes with same performance
pub average_reliability_old: f64,
pub average_reliability_new: f64,
pub median_reliability_old: f64,
pub median_reliability_new: f64,
pub reliability_std_dev_old: f64,
pub reliability_std_dev_new: f64,
pub max_improvement: f64, // highest positive delta
pub max_degradation: f64, // highest negative delta
pub max_improvement: f64, // highest positive delta
pub max_degradation: f64, // highest negative delta
}
/// Comparison of route analysis between methods
@@ -192,8 +195,8 @@ pub struct ComparisonSummaryStats {
pub struct RouteAnalysisComparison {
pub old_method: Option<RouteAnalysisData>,
pub new_method: Option<RouteAnalysisData>,
pub time_window_difference_hours: i32, // new - old
pub route_coverage_difference: i32, // new total routes - old total routes
pub time_window_difference_hours: i32, // new - old
pub route_coverage_difference: i32, // new total routes - old total routes
pub success_rate_difference: Option<f64>, // new success rate - old success rate
}
@@ -252,4 +255,4 @@ impl SimulationApiError {
timestamp: OffsetDateTime::now_utc().unix_timestamp(),
}
}
}
}
+5 -2
View File
@@ -9,7 +9,7 @@ use crate::ecash::dkg::controller::keys::{
};
use crate::ecash::dkg::controller::DkgController;
use crate::ecash::state::EcashState;
use crate::epoch_operations::{EpochAdvancer, simulation::SimulationConfig};
use crate::epoch_operations::{simulation::SimulationConfig, EpochAdvancer};
use crate::network::models::NetworkDetails;
use crate::node_describe_cache::DescribedNodes;
use crate::node_status_api::handlers::unstable;
@@ -298,7 +298,10 @@ async fn start_nym_api_tasks_axum(config: &Config) -> anyhow::Result<ShutdownHan
// Create simulation config if simulation mode is enabled
let simulation_config = if config.rewarding.simulation_mode {
Some(SimulationConfig {
new_method_time_window_hours: config.rewarding.debug.simulation_new_method_time_window_hours,
new_method_time_window_hours: config
.rewarding
.debug
.simulation_new_method_time_window_hours,
run_both_methods: config.rewarding.debug.simulation_run_both_methods,
description: Some("Simulation run at epoch advancement".to_string()),
})
+1 -1
View File
@@ -559,7 +559,7 @@ impl Default for RewardingDebug {
RewardingDebug {
minimum_interval_monitor_threshold: DEFAULT_MONITOR_THRESHOLD,
simulation_new_method_time_window_hours: 1, // New method uses 1 hour
simulation_run_both_methods: true, // Default to running both for comparison
simulation_run_both_methods: true, // Default to running both for comparison
}
}
}
+1 -5
View File
@@ -1204,10 +1204,7 @@ impl StorageManager {
/// # Arguments
///
/// * `timestamp`: timestamp specifying the purge cutoff.
pub(super) async fn purge_old_routes(
&self,
timestamp: i64,
) -> Result<(), sqlx::Error> {
pub(super) async fn purge_old_routes(&self, timestamp: i64) -> Result<(), sqlx::Error> {
sqlx::query!("DELETE FROM routes WHERE timestamp < ?", timestamp)
.execute(&self.connection_pool)
.await?;
@@ -2027,7 +2024,6 @@ impl StorageManager {
&self,
simulated_epoch_ids: &[i64],
) -> Result<std::collections::HashMap<i64, usize>, sqlx::Error> {
if simulated_epoch_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
+6 -6
View File
@@ -174,7 +174,7 @@ pub struct SimulatedNodePerformance {
pub reliability_score: f64, // 0.0 to 100.0
pub positive_samples: u32,
pub negative_samples: u32,
pub work_factor: Option<f64>, // 0.0 to 1.0
pub work_factor: Option<f64>, // 0.0 to 1.0
pub calculation_method: String, // 'old' or 'new'
pub calculated_at: i64,
}
@@ -186,9 +186,9 @@ pub struct SimulatedPerformanceComparison {
pub id: i64,
pub simulated_epoch_id: i64,
pub node_id: NodeId,
pub node_type: String, // 'mixnode' or 'gateway'
pub performance_score: f64, // 0.0 to 100.0
pub work_factor: f64, // Work factor applied (e.g., 10.0 for active, 1.0 for standby)
pub node_type: String, // 'mixnode' or 'gateway'
pub performance_score: f64, // 0.0 to 100.0
pub work_factor: f64, // Work factor applied (e.g., 10.0 for active, 1.0 for standby)
pub calculation_method: String, // 'old' or 'new'
pub positive_samples: Option<i64>,
pub negative_samples: Option<i64>,
@@ -221,7 +221,7 @@ pub struct SimulatedRouteAnalysis {
pub successful_routes: u32,
pub failed_routes: u32,
pub average_route_reliability: Option<f64>, // 0.0 to 100.0
pub time_window_hours: u32, // 1 for new method, 24 for old method
pub analysis_parameters: Option<String>, // JSON with analysis config
pub time_window_hours: u32, // 1 for new method, 24 for old method
pub analysis_parameters: Option<String>, // JSON with analysis config
pub calculated_at: i64,
}
+42 -33
View File
@@ -13,7 +13,7 @@ async fn test_simulation_epochs_endpoint() {
let url = format!("{}/v1/simulation/epochs", base);
let response = make_request(&url).await;
match response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -23,10 +23,10 @@ async fn test_simulation_epochs_endpoint() {
assert!(data.is_object());
assert!(data.get("epochs").is_some());
assert!(data.get("total_count").is_some());
let epochs = data.get("epochs").unwrap();
assert!(epochs.is_array());
// If there are epochs, verify their structure
if let Some(epoch_array) = epochs.as_array() {
if !epoch_array.is_empty() {
@@ -34,7 +34,7 @@ async fn test_simulation_epochs_endpoint() {
verify_epoch_summary_structure(first_epoch);
}
}
println!("✓ Simulation epochs endpoint returned valid response");
}
Err(e) => {
@@ -58,7 +58,7 @@ async fn test_simulation_epochs_with_pagination() {
let url = format!("{}/v1/simulation/epochs?limit=5&offset=0", base);
let response = make_request(&url).await;
match response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -67,10 +67,10 @@ async fn test_simulation_epochs_with_pagination() {
assert!(data.is_object());
assert!(data.get("epochs").is_some());
assert!(data.get("total_count").is_some());
let epochs = data.get("epochs").unwrap().as_array().unwrap();
assert!(epochs.len() <= 5); // Should respect limit
println!("✓ Simulation epochs pagination works correctly");
}
Err(_) => {
@@ -94,7 +94,7 @@ async fn test_simulation_epoch_details_structure() {
// First, get a list of epochs to find a valid ID
let epochs_url = format!("{}/v1/simulation/epochs?limit=1", base);
let epochs_response = make_request(&epochs_url).await;
match epochs_response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -103,11 +103,11 @@ async fn test_simulation_epoch_details_structure() {
if !epochs.is_empty() {
let first_epoch = &epochs[0];
let epoch_id = first_epoch.get("id").unwrap().as_i64().unwrap();
// Now test the details endpoint
let details_url = format!("{}/v1/simulation/epochs/{}", base, epoch_id);
let details_response = make_request(&details_url).await;
match details_response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -144,7 +144,7 @@ async fn test_simulation_comparison_endpoint() {
// First, get a list of epochs to find a valid ID
let epochs_url = format!("{}/v1/simulation/epochs?limit=1", base);
let epochs_response = make_request(&epochs_url).await;
match epochs_response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -153,18 +153,21 @@ async fn test_simulation_comparison_endpoint() {
if !epochs.is_empty() {
let first_epoch = &epochs[0];
let epoch_id = first_epoch.get("id").unwrap().as_i64().unwrap();
// Test the comparison endpoint
let comparison_url = format!("{}/v1/simulation/epochs/{}/comparison", base, epoch_id);
let comparison_url =
format!("{}/v1/simulation/epochs/{}/comparison", base, epoch_id);
let comparison_response = make_request(&comparison_url).await;
match comparison_response {
Ok(res) => {
let json = validate_json_response(res).await;
match json {
Ok(data) => {
verify_comparison_structure(&data);
println!("✓ Simulation comparison endpoint returned valid structure");
println!(
"✓ Simulation comparison endpoint returned valid structure"
);
}
Err(e) => {
println!("✗ Failed to parse comparison data: {}", e);
@@ -194,7 +197,7 @@ async fn test_simulation_export_endpoints() {
// First, get a list of epochs to find a valid ID
let epochs_url = format!("{}/v1/simulation/epochs?limit=1", base);
let epochs_response = make_request(&epochs_url).await;
match epochs_response {
Ok(res) => {
let json = validate_json_response(res).await;
@@ -203,11 +206,14 @@ async fn test_simulation_export_endpoints() {
if !epochs.is_empty() {
let first_epoch = &epochs[0];
let epoch_id = first_epoch.get("id").unwrap().as_i64().unwrap();
// Test JSON export
let json_export_url = format!("{}/v1/simulation/epochs/{}/export?format=json", base, epoch_id);
let json_export_url = format!(
"{}/v1/simulation/epochs/{}/export?format=json",
base, epoch_id
);
let json_response = make_request(&json_export_url).await;
match json_response {
Ok(res) => {
assert!(res.status().is_success());
@@ -221,11 +227,14 @@ async fn test_simulation_export_endpoints() {
println!("✗ JSON export failed: {}", e);
}
}
// Test CSV export
let csv_export_url = format!("{}/v1/simulation/epochs/{}/export?format=csv", base, epoch_id);
let csv_export_url = format!(
"{}/v1/simulation/epochs/{}/export?format=csv",
base, epoch_id
);
let csv_response = make_request(&csv_export_url).await;
match csv_response {
Ok(res) => {
assert!(res.status().is_success());
@@ -258,7 +267,7 @@ async fn test_simulation_error_handling() {
// Test 404 for non-existent epoch
let invalid_url = format!("{}/v1/simulation/epochs/999999", base);
let response = make_request(&invalid_url).await;
match response {
Ok(res) => {
// Should get a successful response (empty or error structure)
@@ -284,7 +293,7 @@ fn verify_epoch_summary_structure(epoch: &Value) {
assert!(epoch.get("created_at").is_some());
assert!(epoch.get("nodes_analyzed").is_some());
assert!(epoch.get("available_methods").is_some());
// Verify types
assert!(epoch.get("id").unwrap().is_i64());
assert!(epoch.get("epoch_id").unwrap().is_u64());
@@ -299,22 +308,22 @@ fn verify_epoch_details_structure(data: &Value) {
assert!(data.get("node_performance").is_some());
assert!(data.get("rewards").is_some());
assert!(data.get("route_analysis").is_some());
// Verify epoch summary structure
verify_epoch_summary_structure(data.get("epoch").unwrap());
// Verify arrays
assert!(data.get("node_performance").unwrap().is_array());
assert!(data.get("rewards").unwrap().is_array());
assert!(data.get("route_analysis").unwrap().is_array());
// If there's performance data, verify its structure
let performance_array = data.get("node_performance").unwrap().as_array().unwrap();
if !performance_array.is_empty() {
let first_performance = &performance_array[0];
verify_performance_data_structure(first_performance);
}
// If there's reward data, verify its structure
let rewards_array = data.get("rewards").unwrap().as_array().unwrap();
if !rewards_array.is_empty() {
@@ -332,7 +341,7 @@ fn verify_performance_data_structure(performance: &Value) {
assert!(performance.get("negative_samples").is_some());
assert!(performance.get("calculation_method").is_some());
assert!(performance.get("calculated_at").is_some());
// Verify types
assert!(performance.get("node_id").unwrap().is_u64());
assert!(performance.get("node_type").unwrap().is_string());
@@ -350,7 +359,7 @@ fn verify_reward_data_structure(reward: &Value) {
assert!(reward.get("calculated_reward_amount").is_some());
assert!(reward.get("reward_currency").is_some());
assert!(reward.get("calculation_method").is_some());
// Verify types
assert!(reward.get("node_id").unwrap().is_u64());
assert!(reward.get("node_type").unwrap().is_string());
@@ -366,14 +375,14 @@ fn verify_comparison_structure(data: &Value) {
assert!(data.get("node_comparisons").is_some());
assert!(data.get("summary_statistics").is_some());
assert!(data.get("route_analysis_comparison").is_some());
// Verify types
assert!(data.get("epoch_id").unwrap().is_u64());
assert!(data.get("simulation_epoch_id").unwrap().is_i64());
assert!(data.get("node_comparisons").unwrap().is_array());
assert!(data.get("summary_statistics").unwrap().is_object());
assert!(data.get("route_analysis_comparison").unwrap().is_object());
// Verify summary statistics structure
let stats = data.get("summary_statistics").unwrap();
assert!(stats.get("total_nodes_compared").is_some());
@@ -381,4 +390,4 @@ fn verify_comparison_structure(data: &Value) {
assert!(stats.get("nodes_degraded").is_some());
assert!(stats.get("average_reliability_old").is_some());
assert!(stats.get("average_reliability_new").is_some());
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ license.workspace = true
[dependencies]
anyhow = { workspace = true }
axum = { workspace = true, features = ["json"] }
axum = { workspace = true, features = ["json", "macros"] }
clap = { workspace = true, features = ["derive", "env"] }
dashmap = { workspace = true }
futures = { workspace = true }
+2 -4
View File
@@ -504,10 +504,8 @@ pub async fn submit_metrics(database_url: Option<&String>) -> anyhow::Result<()>
let node_submit_url =
format!("{}/{API_VERSION}/{STATUS}/{SUBMIT_NODE}", nym_api_url);
let gateway_submit_url = format!(
"{}/{API_VERSION}/{STATUS}/{SUBMIT_GATEWAY}",
nym_api_url
);
let gateway_submit_url =
format!("{}/{API_VERSION}/{STATUS}/{SUBMIT_GATEWAY}", nym_api_url);
let route_submit_url =
format!("{}/{API_VERSION}/{STATUS}/{SUBMIT_ROUTE}", nym_api_url);
+23 -18
View File
@@ -15,13 +15,13 @@ use std::{
sync::Arc,
time::Duration,
};
use tokio::{sync::RwLock, time::timeout};
use tokio::time::timeout;
use utoipa::ToSchema;
use crate::{
accounting::{all_node_stats, NetworkAccount, NetworkAccountStats, NodeStats},
http::AppState,
make_client, MIXNET_TIMEOUT, TOPOLOGY,
MIXNET_TIMEOUT,
};
#[derive(ToSchema, Serialize)]
@@ -183,12 +183,12 @@ pub async fn mermaid_handler() -> Result<String, StatusCode> {
Ok(mermaid)
}
async fn send_receive_mixnet(_state: AppState) -> Result<String, StatusCode> {
let client = Arc::new(RwLock::new(
make_client(TOPOLOGY.get().expect("Set at the begining").clone())
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
));
async fn send_receive_mixnet(state: AppState) -> Result<String, StatusCode> {
// let client = Arc::new(RwLock::new(
// make_client(TOPOLOGY.get().expect("Set at the begining").clone())
// .await
// .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
// ));
let msg: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
@@ -197,15 +197,20 @@ async fn send_receive_mixnet(_state: AppState) -> Result<String, StatusCode> {
.collect();
let sent_msg = msg.clone();
// let client = {
// let mut clients = state.clients().write().await;
// if let Some(client) = clients.make_contiguous().choose(&mut rand::thread_rng()) {
// Arc::clone(client)
// } else {
// error!("No clients currently available");
// return Err(StatusCode::SERVICE_UNAVAILABLE);
// }
// };
let client = {
let mut clients = state.clients().write().await;
if let Some(client) = clients.make_contiguous().choose(&mut rand::thread_rng()) {
Arc::clone(client)
} else {
error!("No clients currently available");
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
};
if !client.read().await.is_gateway_connection_alive() {
warn!("Client is not connected, waiting for it to connect, trying another one");
return Err(StatusCode::SERVICE_UNAVAILABLE);
}
let recv = Arc::clone(&client);
let sender = Arc::clone(&client);
@@ -245,7 +250,7 @@ async fn send_receive_mixnet(_state: AppState) -> Result<String, StatusCode> {
Ok(_) => {}
Err(e) => {
error!("Failed to send/receive message: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
return Err(StatusCode::GATEWAY_TIMEOUT);
}
}
}
+3 -2
View File
@@ -67,7 +67,7 @@ impl HttpServer {
let n_clients = clients.read().await.len();
let state = AppState { clients };
let app = Router::new()
.route("/v1/send", post(send_handler).with_state(state))
.route("/v1/send", post(send_handler))
.merge(SwaggerUi::new("/v1/ui").url("/v1/docs/openapi.json", ApiDoc::openapi()))
.route("/v1/accounting", get(accounting_handler))
.route("/v1/sent", get(sent_handler))
@@ -77,7 +77,8 @@ impl HttpServer {
.route("/v1/stats", get(stats_handler))
.route("/v1/node_stats/:mix_id", get(node_stats_handler))
.route("/v1/node_stats", get(all_nodes_stats_handler))
.route("/v1/received", get(recv_handler));
.route("/v1/received", get(recv_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind(self.listener).await?;
let server_future =
+45 -18
View File
@@ -52,18 +52,45 @@ async fn make_clients(
tokio::time::sleep(tokio::time::Duration::from_secs(lifetime)).await;
info!("Removing oldest client");
if let Some(dropped_client) = clients.write().await.pop_front() {
loop {
if Arc::strong_count(&dropped_client) == 1 {
if let Some(client) = Arc::into_inner(dropped_client) {
let client_handle = client.into_inner();
client_handle.disconnect().await;
} else {
warn!("Failed to drop client, client had more then one strong ref")
}
break;
const CLIENT_DROP_TIMEOUT: Duration = Duration::from_secs(30);
let start = tokio::time::Instant::now();
// Try immediate unwrap first
match Arc::try_unwrap(dropped_client) {
Ok(client) => {
let client_handle = client.into_inner();
client_handle.disconnect().await;
info!("Successfully disconnected client immediately");
}
Err(dropped_client) => {
// Fallback: wait with timeout for references to drop
info!("Client still has references, waiting for cleanup with timeout");
while start.elapsed() < CLIENT_DROP_TIMEOUT {
if Arc::strong_count(&dropped_client) == 1 {
match Arc::try_unwrap(dropped_client) {
Ok(client) => {
let client_handle = client.into_inner();
client_handle.disconnect().await;
info!("Successfully disconnected client after waiting");
break;
}
Err(_) => {
warn!("Failed to unwrap client despite reference count being 1");
break;
}
}
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if start.elapsed() >= CLIENT_DROP_TIMEOUT {
warn!(
"Client drop timed out after {:?}, forcing drop",
CLIENT_DROP_TIMEOUT
);
// Client will be dropped when Arc goes out of scope
}
}
info!("Client still in use, waiting 2 seconds");
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
}
}
}
@@ -236,13 +263,13 @@ async fn main() -> Result<()> {
MIXNET_TIMEOUT.set(args.mixnet_timeout).ok();
// let spawn_clients = Arc::clone(&clients);
// tokio::spawn(make_clients(
// spawn_clients,
// args.n_clients,
// args.client_lifetime,
// TOPOLOGY.get().expect("Topology not set yet!").clone(),
// ));
let spawn_clients = Arc::clone(&clients);
tokio::spawn(make_clients(
spawn_clients,
args.n_clients,
args.client_lifetime,
TOPOLOGY.get().expect("Topology not set yet!").clone(),
));
let clients_server = clients.clone();
@@ -149,6 +149,17 @@ impl MixnetClient {
self.client_state.gateway_connection
}
/// Check if the websocket connection to the gateway is alive at the socket level
///
/// This performs a real socket-level health check to determine if the connection
/// is actually alive and responsive, not just whether we have a file descriptor.
pub fn is_gateway_connection_alive(&self) -> bool {
match self.client_state.gateway_connection.gateway_ws_fd {
Some(fd) => socket_is_alive(fd),
None => false,
}
}
/// Get a shallow clone of [`MixnetClientSender`]. Useful if you want split the send and
/// receive logic in different locations.
pub fn split_sender(&self) -> MixnetClientSender {
@@ -340,3 +351,49 @@ impl MixnetMessageSender for MixnetClientSender {
.map_err(|_| Error::MessageSendingFailure)
}
}
/// Check if a socket file descriptor is alive and responsive
///
/// This function performs socket-level checks to determine if the connection is actually alive.
/// It's cross-platform compatible and works on both Unix and non-Unix systems.
fn socket_is_alive(fd: std::os::raw::c_int) -> bool {
#[cfg(unix)]
{
use std::io::ErrorKind;
use std::net::TcpStream;
use std::os::unix::io::FromRawFd;
unsafe {
// Create a TcpStream from the raw fd to perform socket operations
let stream = TcpStream::from_raw_fd(fd);
// Try to peek at the socket to see if it's still connected
// We peek with a zero-length buffer to avoid consuming data
let mut buf = [0u8; 0];
let result = match stream.peek(&mut buf) {
Ok(_) => true, // Socket is alive and readable
Err(e) => match e.kind() {
ErrorKind::WouldBlock => true, // Socket is alive but no data available
ErrorKind::ConnectionReset
| ErrorKind::ConnectionAborted
| ErrorKind::BrokenPipe
| ErrorKind::NotConnected => false, // Socket is clearly dead
_ => true, // Other errors might be temporary, assume alive
},
};
// Prevent the TcpStream from closing the fd when it's dropped
// since we don't own the fd
std::mem::forget(stream);
result
}
}
#[cfg(not(unix))]
{
// On non-Unix systems, we can't easily check socket state
// Fall back to assuming the connection is alive if we have an fd
fd != 0
}
}