diff --git a/nym-api/nym-api-requests/src/lib.rs b/nym-api/nym-api-requests/src/lib.rs index cb0bc32f63..c627ef5a79 100644 --- a/nym-api/nym-api-requests/src/lib.rs +++ b/nym-api/nym-api-requests/src/lib.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; pub mod coconut; pub mod models; +pub mod pagination; pub trait Deprecatable { fn deprecate(self) -> Deprecated diff --git a/nym-api/nym-api-requests/src/models.rs b/nym-api/nym-api-requests/src/models.rs index a853684bb0..78e418fb6c 100644 --- a/nym-api/nym-api-requests/src/models.rs +++ b/nym-api/nym-api-requests/src/models.rs @@ -1,6 +1,7 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +use crate::pagination::PaginatedResponse; use cosmwasm_std::{Addr, Coin, Decimal}; use nym_mixnet_contract_common::families::FamilyHead; use nym_mixnet_contract_common::mixnode::MixNodeDetails; @@ -569,3 +570,28 @@ pub struct SignerInformationResponse { pub verification_key: Option, } + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema, Default)] +pub struct TestNode { + pub node_id: Option, + pub identity_key: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TestRoute { + pub gateway: TestNode, + pub layer1: TestNode, + pub layer2: TestNode, + pub layer3: TestNode, +} + +#[derive(Clone, Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TestResult { + pub monitor_run_id: i64, + pub timestamp: i64, + pub reliability: Option, + pub test_routes: Vec, +} + +pub type MixnodeTestResultResponse = PaginatedResponse; +pub type GatewayTestResultResponse = PaginatedResponse; diff --git a/nym-api/nym-api-requests/src/pagination.rs b/nym-api/nym-api-requests/src/pagination.rs new file mode 100644 index 0000000000..20ab30e7a6 --- /dev/null +++ b/nym-api/nym-api-requests/src/pagination.rs @@ -0,0 +1,18 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: Apache-2.0 + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct Pagination { + pub total: usize, + pub page: u32, + pub size: usize, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct PaginatedResponse { + pub pagination: Pagination, + pub data: Vec, +} diff --git a/nym-api/src/node_status_api/mod.rs b/nym-api/src/node_status_api/mod.rs index dc6e34b779..91967ebcb8 100644 --- a/nym-api/src/node_status_api/mod.rs +++ b/nym-api/src/node_status_api/mod.rs @@ -53,6 +53,8 @@ pub(crate) fn node_status_routes( routes::get_active_set_detailed, routes::get_gateways_detailed, routes::get_gateways_detailed_unfiltered, + routes::unstable::mixnode_test_results, + routes::unstable::gateway_test_results, ] } else { // in the minimal variant we would not have access to endpoints relying on existence diff --git a/nym-api/src/node_status_api/routes.rs b/nym-api/src/node_status_api/routes.rs index 466b10f883..e8dabbef70 100644 --- a/nym-api/src/node_status_api/routes.rs +++ b/nym-api/src/node_status_api/routes.rs @@ -1,6 +1,19 @@ -// Copyright 2021-2023 - Nym Technologies SA +// Copyright 2021-2024 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use nym_api_requests::models::{ + AllInclusionProbabilitiesResponse, ComputeRewardEstParam, GatewayBondAnnotated, + GatewayCoreStatusResponse, GatewayStatusReportResponse, GatewayUptimeHistoryResponse, + GatewayUptimeResponse, InclusionProbabilityResponse, MixNodeBondAnnotated, + MixnodeCoreStatusResponse, MixnodeStatusReportResponse, MixnodeStatusResponse, + MixnodeUptimeHistoryResponse, RewardEstimationResponse, StakeSaturationResponse, + UptimeResponse, +}; +use nym_mixnet_contract_common::MixId; +use rocket::serde::json::Json; +use rocket::State; +use rocket_okapi::openapi; + use super::helpers::_get_gateways_detailed; use super::NodeStatusCache; use crate::node_status_api::helpers::{ @@ -15,18 +28,6 @@ use crate::node_status_api::helpers::{ use crate::node_status_api::models::ErrorResponse; use crate::storage::NymApiStorage; use crate::NymContractCache; -use nym_api_requests::models::{ - AllInclusionProbabilitiesResponse, ComputeRewardEstParam, GatewayBondAnnotated, - GatewayCoreStatusResponse, GatewayStatusReportResponse, GatewayUptimeHistoryResponse, - GatewayUptimeResponse, InclusionProbabilityResponse, MixNodeBondAnnotated, - MixnodeCoreStatusResponse, MixnodeStatusReportResponse, MixnodeStatusResponse, - MixnodeUptimeHistoryResponse, RewardEstimationResponse, StakeSaturationResponse, - UptimeResponse, -}; -use nym_mixnet_contract_common::MixId; -use rocket::serde::json::Json; -use rocket::State; -use rocket_okapi::openapi; #[openapi(tag = "status")] #[get("/gateway//report")] @@ -227,3 +228,287 @@ pub async fn get_gateways_detailed_unfiltered( ) -> Json> { Json(_get_gateways_detailed_unfiltered(cache).await) } + +pub mod unstable { + use crate::node_status_api::models::ErrorResponse; + use crate::support::http::helpers::PaginationRequest; + use crate::support::storage::NymApiStorage; + use nym_api_requests::models::{ + GatewayTestResultResponse, MixnodeTestResultResponse, TestNode, TestResult, TestRoute, + }; + use nym_api_requests::pagination::Pagination; + use nym_mixnet_contract_common::MixId; + use rocket::http::Status; + use rocket::serde::json::Json; + use rocket::State; + use rocket_okapi::openapi; + use std::cmp::min; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::RwLock; + + pub type DbId = i64; + + // a simply in-memory cache of node details + #[derive(Debug, Default)] + pub struct NodeInfoCache { + inner: Arc>, + } + + impl NodeInfoCache { + async fn get_mix_node_details(&self, db_id: DbId, storage: &NymApiStorage) -> TestNode { + { + let read_guard = self.inner.read().await; + if let Some(cached) = read_guard.mixnodes.get(&db_id) { + trace!("cache hit for mixnode {db_id}"); + return cached.clone(); + } + } + trace!("cache miss for mixnode {db_id}"); + + let mut write_guard = self.inner.write().await; + // double-check the cache in case somebody already updated it while we were waiting for the lock + if let Some(cached) = write_guard.mixnodes.get(&db_id) { + return cached.clone(); + } + + let details = match storage.get_mixnode_details_by_db_id(db_id).await { + Ok(Some(details)) => details.into(), + Ok(None) => { + error!("somebody has been messing with the database! details for mixnode with database id {db_id} have been removed!"); + TestNode::default() + } + Err(err) => { + // don't insert into the cache in case another request is successful + error!("failed to retrieve details for mixnode {db_id}: {err}"); + return TestNode::default(); + } + }; + + write_guard.mixnodes.insert(db_id, details.clone()); + details + } + + async fn get_gateway_details(&self, db_id: DbId, storage: &NymApiStorage) -> TestNode { + { + let read_guard = self.inner.read().await; + if let Some(cached) = read_guard.gateways.get(&db_id) { + trace!("cache hit for gateway {db_id}"); + return cached.clone(); + } + } + trace!("cache miss for gateway {db_id}"); + + let mut write_guard = self.inner.write().await; + // double-check the cache in case somebody already updated it while we were waiting for the lock + if let Some(cached) = write_guard.gateways.get(&db_id) { + return cached.clone(); + } + + let details = match storage.get_gateway_details_by_db_id(db_id).await { + Ok(Some(details)) => details.into(), + Ok(None) => { + error!("somebody has been messing with the database! details for gateway with database id {db_id} have been removed!"); + TestNode::default() + } + Err(err) => { + // don't insert into the cache in case another request is successful + error!("failed to retrieve details for gateway {db_id}: {err}"); + return TestNode::default(); + } + }; + + write_guard.gateways.insert(db_id, details.clone()); + details + } + } + + #[derive(Debug, Default)] + struct NodeInfoCacheInner { + mixnodes: HashMap, + gateways: HashMap, + } + + const MAX_TEST_RESULTS_PAGE_SIZE: u32 = 100; + const DEFAULT_TEST_RESULTS_PAGE_SIZE: u32 = 50; + + async fn _mixnode_test_results( + mix_id: MixId, + page: u32, + per_page: u32, + info_cache: &State, + storage: &State, + ) -> anyhow::Result { + // convert to db offset + // we're paging from page 0 like civilised people, + // so we have to skip (page * per_page) results + let offset = page * per_page; + let limit = per_page; + + let raw_results = storage + .get_mixnode_detailed_statuses(mix_id, limit, offset) + .await?; + let total = match raw_results.first() { + None => 0, + Some(r) => storage.get_mixnode_detailed_statuses_count(r.db_id).await?, + }; + + let mut test_results = HashMap::new(); + + for result in raw_results { + let entry = test_results + .entry(result.monitor_run_id) + .or_insert(TestResult { + monitor_run_id: result.monitor_run_id, + timestamp: result.timestamp, + reliability: result.reliability, + test_routes: vec![], + }); + + let gateway = info_cache + .get_gateway_details(result.gateway_id, storage) + .await; + let layer1 = info_cache + .get_mix_node_details(result.layer1_mix_id, storage) + .await; + let layer2 = info_cache + .get_mix_node_details(result.layer2_mix_id, storage) + .await; + let layer3 = info_cache + .get_mix_node_details(result.layer3_mix_id, storage) + .await; + + entry.test_routes.push(TestRoute { + gateway, + layer1, + layer2, + layer3, + }) + } + + Ok(MixnodeTestResultResponse { + pagination: Pagination { + total, + page, + size: test_results.len(), + }, + data: test_results.into_values().collect(), + }) + } + + #[openapi(tag = "UNSTABLE - DO **NOT** USE")] + #[get("/mixnodes//test-results?")] + pub async fn mixnode_test_results( + mix_id: MixId, + pagination: PaginationRequest, + info_cache: &State, + storage: &State, + ) -> Result, ErrorResponse> { + let page = pagination.page.unwrap_or_default(); + let per_page = min( + pagination + .per_page + .unwrap_or(DEFAULT_TEST_RESULTS_PAGE_SIZE), + MAX_TEST_RESULTS_PAGE_SIZE, + ); + + match _mixnode_test_results(mix_id, page, per_page, info_cache, storage).await { + Ok(res) => Ok(Json(res)), + Err(err) => Err(ErrorResponse::new( + format!("failed to retrieve mixnode test results for node {mix_id}: {err}"), + Status::InternalServerError, + )), + } + } + + async fn _gateway_test_results( + gateway_identity: &str, + page: u32, + per_page: u32, + info_cache: &State, + storage: &State, + ) -> anyhow::Result { + // convert to db offset + // we're paging from page 0 like civilised people, + // so we have to skip (page * per_page) results + let offset = page * per_page; + let limit = per_page; + + let raw_results = storage + .get_gateway_detailed_statuses(gateway_identity, limit, offset) + .await?; + let total = match raw_results.first() { + None => 0, + Some(r) => storage.get_gateway_detailed_statuses_count(r.db_id).await?, + }; + + let mut test_results = HashMap::new(); + + for result in raw_results { + let entry = test_results + .entry(result.monitor_run_id) + .or_insert(TestResult { + monitor_run_id: result.monitor_run_id, + timestamp: result.timestamp, + reliability: result.reliability, + test_routes: vec![], + }); + + let gateway = info_cache + .get_gateway_details(result.gateway_id, storage) + .await; + let layer1 = info_cache + .get_mix_node_details(result.layer1_mix_id, storage) + .await; + let layer2 = info_cache + .get_mix_node_details(result.layer2_mix_id, storage) + .await; + let layer3 = info_cache + .get_mix_node_details(result.layer3_mix_id, storage) + .await; + + entry.test_routes.push(TestRoute { + gateway, + layer1, + layer2, + layer3, + }) + } + + Ok(GatewayTestResultResponse { + pagination: Pagination { + total, + page, + size: test_results.len(), + }, + data: test_results.into_values().collect(), + }) + } + + #[openapi(tag = "UNSTABLE - DO **NOT** USE")] + #[get("/gateways//test-results?")] + pub async fn gateway_test_results( + gateway_identity: &str, + pagination: PaginationRequest, + info_cache: &State, + storage: &State, + ) -> Result, ErrorResponse> { + let page = pagination.page.unwrap_or_default(); + let per_page = min( + pagination + .per_page + .unwrap_or(DEFAULT_TEST_RESULTS_PAGE_SIZE), + MAX_TEST_RESULTS_PAGE_SIZE, + ); + + match _gateway_test_results(gateway_identity, page, per_page, info_cache, storage).await { + Ok(res) => Ok(Json(res)), + Err(err) => Err(ErrorResponse::new( + format!( + "failed to retrieve mixnode test results for gateway {gateway_identity}: {err}" + ), + Status::InternalServerError, + )), + } + } +} diff --git a/nym-api/src/support/http/helpers.rs b/nym-api/src/support/http/helpers.rs new file mode 100644 index 0000000000..45efa8f586 --- /dev/null +++ b/nym-api/src/support/http/helpers.rs @@ -0,0 +1,11 @@ +// Copyright 2024 - Nym Technologies SA +// SPDX-License-Identifier: GPL-3.0-only + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, FromForm, Debug, JsonSchema)] +pub struct PaginationRequest { + pub page: Option, + pub per_page: Option, +} diff --git a/nym-api/src/support/http/mod.rs b/nym-api/src/support/http/mod.rs index 9bf7d9b6aa..5d6493239c 100644 --- a/nym-api/src/support/http/mod.rs +++ b/nym-api/src/support/http/mod.rs @@ -7,6 +7,7 @@ use crate::coconut::{self, comm::QueryCommunicationChannel}; use crate::network::models::NetworkDetails; use crate::network::network_routes; use crate::node_describe_cache::DescribedNodes; +use crate::node_status_api::routes::unstable; use crate::node_status_api::{self, NodeStatusCache}; use crate::nym_contract_cache::cache::NymContractCache; use crate::status::{api_status_routes, ApiStatusState, SignerState}; @@ -23,6 +24,7 @@ use rocket_cors::{AllowedHeaders, AllowedOrigins, Cors}; use rocket_okapi::mount_endpoints_and_merged_docs; use rocket_okapi::swagger_ui::make_swagger_ui; +pub(crate) mod helpers; pub(crate) mod openapi; pub(crate) async fn setup_rocket( @@ -57,7 +59,8 @@ pub(crate) async fn setup_rocket( .attach(setup_cors()?) .attach(NymContractCache::stage()) .attach(NodeStatusCache::stage()) - .attach(CirculatingSupplyCache::stage(mix_denom.clone())); + .attach(CirculatingSupplyCache::stage(mix_denom.clone())) + .manage(unstable::NodeInfoCache::default()); // This is not a very nice approach. A lazy value would be more suitable, but that's still // a nightly feature: https://github.com/rust-lang/rust/issues/74465 diff --git a/nym-api/src/support/storage/manager.rs b/nym-api/src/support/storage/manager.rs index cf0ec15f79..29fab1431b 100644 --- a/nym-api/src/support/storage/manager.rs +++ b/nym-api/src/support/storage/manager.rs @@ -4,7 +4,8 @@ use crate::network_monitor::monitor::summary_producer::{GatewayResult, MixnodeRe use crate::node_status_api::models::{HistoricalUptime, Uptime}; use crate::node_status_api::utils::{ActiveGatewayStatuses, ActiveMixnodeStatuses}; use crate::support::storage::models::{ - ActiveGateway, ActiveMixnode, NodeStatus, RewardingReport, TestingRoute, + ActiveGateway, ActiveMixnode, GatewayDetails, MixnodeDetails, NodeStatus, RewardingReport, + TestedGatewayStatus, TestedMixnodeStatus, TestingRoute, }; use nym_mixnet_contract_common::{EpochId, IdentityKey, MixId}; @@ -971,4 +972,129 @@ impl StorageManager { Ok(active_day_statuses) } + + pub(crate) async fn get_mixnode_details_by_db_id( + &self, + id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + MixnodeDetails, + "SELECT * FROM mixnode_details WHERE id = ?", + id + ) + .fetch_optional(&self.connection_pool) + .await + } + + pub(crate) async fn get_gateway_details_by_db_id( + &self, + id: i64, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + GatewayDetails, + "SELECT * FROM gateway_details WHERE id = ?", + id + ) + .fetch_optional(&self.connection_pool) + .await + } + + pub(crate) async fn get_mixnode_statuses_count(&self, db_id: i64) -> Result { + sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM mixnode_status + JOIN monitor_run ON mixnode_status.timestamp = monitor_run.timestamp + WHERE mixnode_details_id = ? + "#, + db_id + ) + .fetch_one(&self.connection_pool) + .await + .map(|record| record.count) + } + + pub(crate) async fn get_mixnode_statuses( + &self, + mix_id: MixId, + limit: u32, + offset: u32, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + TestedMixnodeStatus, + r#" + SELECT + mixnode_details.id as "db_id", + mix_id as "mix_id!", + identity_key, + reliability as "reliability: u8", + monitor_run.timestamp as "timestamp!", + gateway_id as "gateway_id!", + layer1_mix_id as "layer1_mix_id!", + layer2_mix_id as "layer2_mix_id!", + layer3_mix_id as "layer3_mix_id!", + monitor_run_id as "monitor_run_id!" + FROM mixnode_status + JOIN mixnode_details ON mixnode_status.mixnode_details_id = mixnode_details.id + JOIN monitor_run ON mixnode_status.timestamp = monitor_run.timestamp + JOIN testing_route ON monitor_run.id = testing_route.monitor_run_id + WHERE mix_id = ? + LIMIT ? OFFSET ? + "#, + mix_id, + limit, + offset + ) + .fetch_all(&self.connection_pool) + .await + } + + pub(crate) async fn get_gateway_statuses_count(&self, db_id: i64) -> Result { + sqlx::query!( + r#" + SELECT COUNT(*) as count + FROM gateway_status + JOIN monitor_run ON gateway_status.timestamp = monitor_run.timestamp + WHERE gateway_details_id = ? + "#, + db_id + ) + .fetch_one(&self.connection_pool) + .await + .map(|record| record.count) + } + + pub(crate) async fn get_gateway_statuses( + &self, + gateway_identity: &str, + limit: u32, + offset: u32, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + TestedGatewayStatus, + r#" + SELECT + gateway_details.id as "db_id", + identity as "identity_key", + reliability as "reliability: u8", + monitor_run.timestamp as "timestamp!", + gateway_id as "gateway_id!", + layer1_mix_id as "layer1_mix_id!", + layer2_mix_id as "layer2_mix_id!", + layer3_mix_id as "layer3_mix_id!", + monitor_run_id as "monitor_run_id!" + FROM gateway_status + JOIN gateway_details ON gateway_status.gateway_details_id = gateway_details.id + JOIN monitor_run ON gateway_status.timestamp = monitor_run.timestamp + JOIN testing_route ON monitor_run.id = testing_route.monitor_run_id + WHERE identity = ? + LIMIT ? OFFSET ? + "#, + gateway_identity, + limit, + offset + ) + .fetch_all(&self.connection_pool) + .await + } } diff --git a/nym-api/src/support/storage/mod.rs b/nym-api/src/support/storage/mod.rs index 3cd1e350cd..201dfec366 100644 --- a/nym-api/src/support/storage/mod.rs +++ b/nym-api/src/support/storage/mod.rs @@ -10,6 +10,9 @@ use crate::node_status_api::models::{ use crate::node_status_api::{ONE_DAY, ONE_HOUR}; use crate::storage::manager::StorageManager; use crate::storage::models::{NodeStatus, TestingRoute}; +use crate::support::storage::models::{ + GatewayDetails, MixnodeDetails, TestedGatewayStatus, TestedMixnodeStatus, +}; use nym_mixnet_contract_common::MixId; use rocket::fairing::AdHoc; use sqlx::ConnectOptions; @@ -716,4 +719,66 @@ impl NymApiStorage { .await .map_err(|err| err.into()) } + + pub(crate) async fn get_mixnode_details_by_db_id( + &self, + id: i64, + ) -> Result, NymApiStorageError> { + Ok(self.manager.get_mixnode_details_by_db_id(id).await?) + } + + pub(crate) async fn get_gateway_details_by_db_id( + &self, + id: i64, + ) -> Result, NymApiStorageError> { + Ok(self.manager.get_gateway_details_by_db_id(id).await?) + } + + pub(crate) async fn get_mixnode_detailed_statuses_count( + &self, + db_id: i64, + ) -> Result { + Ok(self + .manager + .get_mixnode_statuses_count(db_id) + .await? + .try_into() + .unwrap_or(usize::MAX)) + } + + pub(crate) async fn get_mixnode_detailed_statuses( + &self, + mix_id: MixId, + limit: u32, + offset: u32, + ) -> Result, NymApiStorageError> { + Ok(self + .manager + .get_mixnode_statuses(mix_id, limit, offset) + .await?) + } + + pub(crate) async fn get_gateway_detailed_statuses_count( + &self, + db_id: i64, + ) -> Result { + Ok(self + .manager + .get_gateway_statuses_count(db_id) + .await? + .try_into() + .unwrap_or(usize::MAX)) + } + + pub(crate) async fn get_gateway_detailed_statuses( + &self, + gateway_identity: &str, + limit: u32, + offset: u32, + ) -> Result, NymApiStorageError> { + Ok(self + .manager + .get_gateway_statuses(gateway_identity, limit, offset) + .await?) + } } diff --git a/nym-api/src/support/storage/models.rs b/nym-api/src/support/storage/models.rs index a07a11176c..cc3f11ce26 100644 --- a/nym-api/src/support/storage/models.rs +++ b/nym-api/src/support/storage/models.rs @@ -1,6 +1,7 @@ // Copyright 2021 - Nym Technologies SA // SPDX-License-Identifier: GPL-3.0-only +use nym_api_requests::models::TestNode; use nym_mixnet_contract_common::MixId; // Internally used struct to catch results from the database to calculate uptimes for given mixnode/gateway @@ -49,3 +50,61 @@ pub(crate) struct RewardingReport { pub(crate) eligible_mixnodes: u32, } + +pub struct MixnodeDetails { + pub id: i64, + pub mix_id: i64, + pub owner: String, + pub identity_key: String, +} + +impl From for TestNode { + fn from(value: MixnodeDetails) -> Self { + TestNode { + node_id: Some(value.mix_id.try_into().unwrap_or(u32::MAX)), + identity_key: Some(value.identity_key), + } + } +} + +pub struct GatewayDetails { + pub id: i64, + pub owner: String, + pub identity: String, +} + +impl From for TestNode { + fn from(value: GatewayDetails) -> Self { + TestNode { + node_id: None, + identity_key: Some(value.identity), + } + } +} + +pub struct TestedMixnodeStatus { + pub db_id: i64, + pub mix_id: i64, + pub identity_key: String, + pub reliability: Option, + pub timestamp: i64, + + pub gateway_id: i64, + pub layer1_mix_id: i64, + pub layer2_mix_id: i64, + pub layer3_mix_id: i64, + pub monitor_run_id: i64, +} + +pub struct TestedGatewayStatus { + pub db_id: i64, + pub identity_key: String, + pub reliability: Option, + pub timestamp: i64, + + pub gateway_id: i64, + pub layer1_mix_id: i64, + pub layer2_mix_id: i64, + pub layer3_mix_id: i64, + pub monitor_run_id: i64, +}