iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 0ab272120415c0ecff82d00f7ad7e4fd66547459
parent 1ae1f0259835f81f8de4185354c54ab925fe28a1
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 23 Aug 2026 22:47:35 +0200

Project UI data outside requests

Diffstat:
Msrc/adapters/http.rs | 56++++----------------------------------------------------
Msrc/adapters/http/actions.rs | 48++++++------------------------------------------
Msrc/adapters/http/api.rs | 122++++++++++++++++---------------------------------------------------------------
Msrc/adapters/http/index_html.rs | 5+++--
Msrc/adapters/http/metrics.rs | 1+
Msrc/adapters/http/request_auth.rs | 4+---
Msrc/adapters/http/state.rs | 16----------------
Msrc/adapters/http/types.rs | 1+
Msrc/adapters/http/ui.rs | 75+++++----------------------------------------------------------------------
Msrc/adapters/ui_data_store.rs | 15+++++++++++++++
Msrc/main.rs | 26++++++++++++++++++++------
Msrc/main_tests.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 6+++++-
13 files changed, 137 insertions(+), 289 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -19,7 +19,7 @@ use axum::{ use tokio::{net::TcpListener, sync::Mutex}; use crate::{ - adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork, ui_index::UiChainIndex}, + adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork}, app::{SharedNode, SharedPeerBook}, domain::validate_address, }; @@ -55,11 +55,11 @@ use index_html::INDEX_HTML; use metrics::{metrics_response, network_health}; use request_auth::wallet_password_for_request; pub use state::ServeOptions; -use state::{AuthClientKey, AuthSession, HttpState, UiChainCache, UiChainView}; +use state::{AuthClientKey, AuthSession, HttpState}; use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ - add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, ui_blocks_from_indexes, - ui_transaction, wallet_transaction_row, wallet_transaction_rows, + add_pending_outputs, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, + wallet_transaction_rows, }; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, @@ -119,24 +119,7 @@ pub async fn serve( stratum: options.stratum, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), - ui_cache: Arc::new(Mutex::new(UiChainCache::default())), - ui_data_refresh: Arc::new(Mutex::new(())), }; - println!( - "warming UI data cache from {}...", - state.ui_data_store.path().display() - ); - let ui_data_started = Instant::now(); - prewarm_chain_view_cache(state.clone()).await?; - { - let cache = state.ui_cache.lock().await; - println!( - "UI data cache ready in {:.2}s (outputs: {}, burn-rank blocks: {})", - ui_data_started.elapsed().as_secs_f64(), - cache.outputs.len(), - cache.burn_leader_ranks_by_hash.len() - ); - } let app = Router::new() .route("/", get(index)) .route("/favicon.ico", get(favicon)) @@ -234,37 +217,6 @@ async fn log_slow_api_request(request: Request<Body>, next: Next) -> Response { response } -async fn prewarm_chain_view_cache(state: HttpState) -> Result<()> { - let tip_hash = { - let node = state.node.lock().await; - node.chain_tip_hash() - }; - if let Some(index) = load_persisted_ui_chain_index(&state, tip_hash.clone()).await? { - let mut cache = state.ui_cache.lock().await; - cache.tip_hash = index.tip_hash; - cache.outputs = index.outputs; - cache.burn_leader_ranks_by_hash = index.burn_leader_ranks_by_hash; - return Ok(()); - } - - let snapshot = { - let node = state.node.lock().await; - node.chain_snapshot() - }; - let _ = cached_chain_view(&state, &snapshot).await?; - Ok(()) -} - -async fn load_persisted_ui_chain_index( - state: &HttpState, - tip_hash: String, -) -> Result<Option<UiChainIndex>> { - let store = state.ui_data_store.clone(); - tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash)) - .await - .context("UI chain index loader failed")? -} - async fn api_config_form( State(state): State<HttpState>, Form(form): Form<ConfigForm>, diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -306,23 +306,12 @@ pub(super) async fn set_recovery_vdf_top_rank_percent( } pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> { - if enabled { - let snapshot = { - let node = state.node.lock().await; - node.has_real_chain().then(|| node.chain_snapshot()) - }; - if let Some(snapshot) = snapshot { - replace_metrics_for_snapshot(&state.ui_data_store, snapshot).await?; - } else { - clear_metrics(&state.ui_data_store).await?; - } - } else { - clear_metrics(&state.ui_data_store).await?; - } - let mut config = state.ui_config.lock().await; - config.keep_track_of_metrics = enabled; - config_store::save(&state.config_path, &config) + let mut next_config = config.clone(); + next_config.keep_track_of_metrics = enabled; + config_store::save(&state.config_path, &next_config)?; + *config = next_config; + Ok(()) } pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> Result<()> { @@ -334,10 +323,6 @@ pub(super) async fn reset_local_chain(state: &HttpState, confirmation: &str) -> let mut node = state.node.lock().await; node.reset_chain_to_setup_placeholder(); } - { - let mut cache = state.ui_cache.lock().await; - *cache = super::UiChainCache::default(); - } clear_chain(&state.chain_store).await?; clear_ui_data(&state.ui_data_store).await?; state @@ -420,25 +405,6 @@ pub(super) async fn set_stratum_settings( Ok(()) } -async fn replace_metrics_for_snapshot( - store: &SqliteUiDataStore, - snapshot: crate::domain::ChainSnapshot, -) -> Result<()> { - let store = store.clone(); - tokio::task::spawn_blocking(move || store.replace_metrics_for_snapshot(&snapshot)) - .await - .context("metrics worker failed")??; - Ok(()) -} - -async fn clear_metrics(store: &SqliteUiDataStore) -> Result<()> { - let store = store.clone(); - tokio::task::spawn_blocking(move || store.clear_metrics()) - .await - .context("metrics cleanup worker failed")??; - Ok(()) -} - async fn clear_ui_data(store: &SqliteUiDataStore) -> Result<()> { let store = store.clone(); tokio::task::spawn_blocking(move || store.clear_all()) @@ -557,7 +523,7 @@ mod tests { domain::{GenesisBurn, Ledger, MICRO_IUNA}, }; - use super::super::{AuthSession, HttpState, UiChainCache, state::AuthBackoff}; + use super::super::{AuthSession, HttpState, state::AuthBackoff}; use super::{CHAIN_RESET_CONFIRMATION, reset_local_chain}; fn socket() -> SocketAddr { @@ -617,8 +583,6 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::<String, AuthSession>::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::<String, AuthBackoff>::new())), - ui_cache: Arc::new(Mutex::new(UiChainCache::default())), - ui_data_refresh: Arc::new(Mutex::new(())), }; reset_local_chain(&state, CHAIN_RESET_CONFIRMATION) diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet}; -use anyhow::{Context, Result}; +use anyhow::Result; use axum::{ Json, extract::{Query, State}, @@ -21,9 +21,8 @@ use super::{ }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, - add_pending_outputs, cached_chain_view, cached_ui_blocks_for_tip, metrics_response, - network_health, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, - wallet_transaction_rows, + add_pending_outputs, metrics_response, network_health, ui_blocks_from_indexes, ui_transaction, + wallet_transaction_row, wallet_transaction_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -48,21 +47,12 @@ pub(super) async fn api_blocks( }; (node.chain_tip_hash(), blocks) }; - if let Some(blocks) = cached_ui_blocks_for_tip(&state, Some(tip_hash.as_str()), blocks).await { - return Json(blocks); - } - - let (snapshot, blocks) = { - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let blocks = match query.before_height { - Some(before_height) => node.blocks_before(before_height, limit), - None => node.recent_blocks(limit), - }; - (snapshot, blocks) - }; - let view = cached_chain_view(&state, &snapshot) + let store = state.ui_data_store.clone(); + let view = tokio::task::spawn_blocking(move || store.load_ui_chain_index(&tip_hash)) .await + .ok() + .and_then(Result::ok) + .flatten() .unwrap_or_default(); Json(ui_blocks_from_indexes( blocks, @@ -85,20 +75,15 @@ pub(super) async fn api_mempool( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<UiTransaction>> { - let ui_data_ready = ensure_ui_data_current(&state).await.is_ok(); let pending = { let node = state.node.lock().await; node.pending_transactions() }; let mut required_outputs = BTreeSet::new(); collect_transaction_input_outpoints(pending.iter(), &mut required_outputs); - let mut outputs = if ui_data_ready { - load_outputs_for_outpoints(&state, required_outputs) - .await - .unwrap_or_default() - } else { - BTreeMap::new() - }; + let mut outputs = load_outputs_for_outpoints(&state, required_outputs) + .await + .unwrap_or_default(); add_pending_outputs(&mut outputs, &pending); let mut items = pending .iter() @@ -119,16 +104,6 @@ pub(super) async fn api_wallet_transactions( .unwrap_or(DATASET_PAGE_LIMIT) .clamp(1, DATASET_LIMIT); let filters = WalletTransactionFilters::from_query(query); - if ensure_ui_data_current(&state).await.is_err() { - return Json(Page { - items: Vec::new(), - offset, - limit, - total: 0, - has_more: false, - next_offset: None, - }); - } let (wallet, pending) = { let node = state.node.lock().await; ( @@ -218,62 +193,11 @@ async fn load_outputs_for_outpoints( .unwrap_or_else(|_| Ok(BTreeMap::new())) } -async fn ensure_ui_data_current(state: &HttpState) -> Result<()> { - let Some(tip_hash) = current_real_chain_tip(state).await else { - return Ok(()); - }; - if ui_data_matches_tip(state, tip_hash).await? { - return Ok(()); - } - - let _refresh_guard = state.ui_data_refresh.lock().await; - let Some(tip_hash) = current_real_chain_tip(state).await else { - return Ok(()); - }; - if ui_data_matches_tip(state, tip_hash).await? { - return Ok(()); - } - - let (snapshot, tip_hash) = { - let node = state.node.lock().await; - if !node.has_real_chain() { - return Ok(()); - } - (node.chain_snapshot(), node.chain_tip_hash()) - }; - let keep_metrics = state.ui_config.lock().await.keep_track_of_metrics; - let chain_store = state.chain_store.clone(); - let ui_data_store = state.ui_data_store.clone(); - tokio::task::spawn_blocking(move || { - chain_store - .save(&snapshot) - .context("failed to persist chain before UI data catch-up")?; - ui_data_store - .project_snapshot(&snapshot, keep_metrics) - .context("failed to project UI data catch-up")?; - Ok::<(), anyhow::Error>(()) - }) - .await - .context("UI data catch-up worker failed")??; - - ui_data_matches_tip(state, tip_hash) - .await? - .then_some(()) - .context("UI data catch-up completed but projection tip does not match the chain tip") -} - async fn current_real_chain_tip(state: &HttpState) -> Option<String> { let node = state.node.lock().await; node.has_real_chain().then(|| node.chain_tip_hash()) } -async fn ui_data_matches_tip(state: &HttpState, tip_hash: String) -> Result<bool> { - let store = state.ui_data_store.clone(); - tokio::task::spawn_blocking(move || store.is_projected_to(&tip_hash)) - .await - .context("UI data projection metadata worker failed")? -} - fn collect_transaction_input_outpoints<'a>( transactions: impl IntoIterator<Item = &'a Transaction>, outpoints: &mut BTreeSet<OutPoint>, @@ -292,9 +216,6 @@ pub(super) async fn api_wallet_utxos( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<WalletUtxoRow>> { - if ensure_ui_data_current(&state).await.is_err() { - return Json(page_items(Vec::new(), query)); - } let (wallet, pending_spent) = { let node = state.node.lock().await; ( @@ -317,9 +238,6 @@ pub(super) async fn api_wallet_utxos( pub(super) async fn api_wallet_selectable_utxos( State(state): State<HttpState>, ) -> Json<Vec<WalletUtxoRow>> { - if ensure_ui_data_current(&state).await.is_err() { - return Json(Vec::new()); - } let (wallet, pending_spent) = { let node = state.node.lock().await; ( @@ -413,10 +331,19 @@ pub(super) async fn api_metrics( ) -> Json<MetricsResponse> { let enabled = state.ui_config.lock().await.keep_track_of_metrics; if !enabled { - return Json(empty_metrics_response(enabled)); + return Json(empty_metrics_response(enabled, false)); } - if ensure_ui_data_current(&state).await.is_err() { - return Json(empty_metrics_response(enabled)); + let Some(tip_hash) = current_real_chain_tip(&state).await else { + return Json(empty_metrics_response(enabled, false)); + }; + let store = state.ui_data_store.clone(); + let ready = tokio::task::spawn_blocking(move || store.metrics_are_projected_to(&tip_hash)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(false); + if !ready { + return Json(empty_metrics_response(enabled, true)); } let store = state.ui_data_store.clone(); let rows = tokio::task::spawn_blocking(move || match query.limit { @@ -437,9 +364,10 @@ pub(super) async fn api_metrics( Json(metrics_response(enabled, rows, leaderboards)) } -fn empty_metrics_response(enabled: bool) -> MetricsResponse { +fn empty_metrics_response(enabled: bool, preparing: bool) -> MetricsResponse { MetricsResponse { enabled, + preparing, latest: None, charts: Vec::new(), leaderboards: MetricsLeaderboards::default(), diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -1070,7 +1070,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="metric"><div class="label">Total burned</div><div class="value" x-text="metricAmountLabel(metricsLatest().totalBurnedAmount)"></div></div> <div class="metric"><div class="label">Difficulty</div><div class="value" x-text="metricsLatest().mineDifficultyBits ?? '-'"></div></div> </div> - <div class="metrics-grid" x-show="loadingMetrics && metricsCharts().length === 0"> + <div class="metrics-grid" x-show="(loadingMetrics || metricsPreparing()) && metricsCharts().length === 0"> <article class="metric-chart-card skeleton-card" aria-hidden="true"> <div class="metric-chart-head"><div class="skeleton-line medium"></div><div class="skeleton-line short"></div></div> <div class="metric-chart-frame"><div class="skeleton-line long"></div></div> @@ -1080,7 +1080,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> <div class="metric-chart-frame"><div class="skeleton-line long"></div></div> </article> </div> - <div class="metrics-empty" x-show="metricsCharts().length === 0 && !loadingMetrics">No metrics collected yet</div> + <div class="metrics-empty" x-show="metricsPreparing() && !loadingMetrics">Preparing development mode from the current chain. Metrics will stay updated in the background.</div> + <div class="metrics-empty" x-show="metricsCharts().length === 0 && !loadingMetrics && !metricsPreparing()">No metrics collected yet</div> <div class="metrics-grid"> <template x-for="chart in metricsCharts()" :key="chart.id"> <article class="metric-chart-card"> diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -28,6 +28,7 @@ pub(super) fn metrics_response( let latest = rows.last().cloned(); MetricsResponse { enabled, + preparing: false, latest, leaderboards, charts: vec![ diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs @@ -323,7 +323,7 @@ mod tests { domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, }; - use super::super::state::{AuthSession, HttpState, UiChainCache}; + use super::super::state::{AuthSession, HttpState}; use super::{AUTH_COOKIE_NAME, now_ms}; use super::{ auth_client_key, check_auth_backoff, record_auth_failure, request_is_authenticated, @@ -380,8 +380,6 @@ mod tests { }, auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), - ui_cache: Arc::new(Mutex::new(UiChainCache::default())), - ui_data_refresh: Arc::new(Mutex::new(())), } } diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -8,7 +8,6 @@ use crate::{ ui_data_store::SqliteUiDataStore, }, app::{SharedNode, SharedPeerBook, StratumStatus}, - domain::{BurnLeaderRank, OutPoint, TxOutput}, }; #[derive(Clone)] @@ -24,8 +23,6 @@ pub(super) struct HttpState { pub(super) stratum: StratumStatus, pub(super) auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, - pub(super) ui_cache: Arc<Mutex<UiChainCache>>, - pub(super) ui_data_refresh: Arc<Mutex<()>>, } #[derive(Clone)] @@ -43,19 +40,6 @@ pub(super) struct AuthBackoff { pub(super) locked_until_ms: Option<u64>, } -#[derive(Clone, Debug, Default)] -pub(super) struct UiChainCache { - pub(super) tip_hash: Option<String>, - pub(super) outputs: BTreeMap<OutPoint, TxOutput>, - pub(super) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, -} - -#[derive(Clone, Debug, Default)] -pub(super) struct UiChainView { - pub(super) outputs: BTreeMap<OutPoint, TxOutput>, - pub(super) burn_leader_ranks_by_hash: BTreeMap<String, Vec<BurnLeaderRank>>, -} - pub struct ServeOptions { pub config_path: PathBuf, pub chain_store: SqliteChainStore, diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -273,6 +273,7 @@ pub(super) struct Page<T> { #[serde(rename_all = "camelCase")] pub(super) struct MetricsResponse { pub(super) enabled: bool, + pub(super) preparing: bool, pub(super) latest: Option<BlockMetricRow>, pub(super) charts: Vec<MetricsChart>, pub(super) leaderboards: MetricsLeaderboards, diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,18 +1,13 @@ use std::collections::BTreeMap; use crate::domain::{ - Amount, Block, BurnLeaderRank, ChainSnapshot, FinalizerMode, MINE_REWARD, OutPoint, - Transaction, TxInput, TxOutput, + Amount, Block, BurnLeaderRank, FinalizerMode, MINE_REWARD, OutPoint, Transaction, TxInput, + TxOutput, }; -use crate::adapters::ui_index::build_ui_chain_index; - -use super::{ - HttpState, UiChainView, - types::{ - UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiTransaction, UiTxInput, - WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, - }, +use super::types::{ + UiBlock, UiBurnBundle, UiBurnBundleQuorum, UiByteBreakdown, UiTransaction, UiTxInput, + WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, }; pub(super) fn wallet_transaction_rows( @@ -414,66 +409,6 @@ fn hex_nibble(byte: u8) -> Option<u8> { } } -pub(super) async fn cached_chain_view( - state: &HttpState, - snapshot: &ChainSnapshot, -) -> anyhow::Result<UiChainView> { - let tip_hash = snapshot.blocks.last().map(|block| block.hash.clone()); - { - let cache = state.ui_cache.lock().await; - if cache.tip_hash == tip_hash { - return Ok(ui_chain_view_from_cache(&cache)); - } - } - - let (computed_tip_hash, view) = tokio::task::spawn_blocking({ - let snapshot = snapshot.clone(); - move || build_chain_view(&snapshot) - }) - .await?; - - let mut cache = state.ui_cache.lock().await; - if cache.tip_hash == tip_hash { - return Ok(ui_chain_view_from_cache(&cache)); - } - - cache.tip_hash = computed_tip_hash; - cache.outputs = view.outputs.clone(); - cache.burn_leader_ranks_by_hash = view.burn_leader_ranks_by_hash.clone(); - Ok(UiChainView { - outputs: view.outputs, - burn_leader_ranks_by_hash: view.burn_leader_ranks_by_hash, - }) -} - -pub(super) async fn cached_ui_blocks_for_tip( - state: &HttpState, - tip_hash: Option<&str>, - blocks: Vec<Block>, -) -> Option<Vec<UiBlock>> { - let cache = state.ui_cache.lock().await; - (cache.tip_hash.as_deref() == tip_hash) - .then(|| ui_blocks_from_indexes(blocks, &cache.outputs, &cache.burn_leader_ranks_by_hash)) -} - -fn ui_chain_view_from_cache(cache: &super::UiChainCache) -> UiChainView { - UiChainView { - outputs: cache.outputs.clone(), - burn_leader_ranks_by_hash: cache.burn_leader_ranks_by_hash.clone(), - } -} - -fn build_chain_view(snapshot: &ChainSnapshot) -> (Option<String>, UiChainView) { - let index = build_ui_chain_index(snapshot); - ( - index.tip_hash.clone(), - UiChainView { - outputs: index.outputs, - burn_leader_ranks_by_hash: index.burn_leader_ranks_by_hash, - }, - ) -} - pub(super) fn add_pending_outputs( outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[Transaction], diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -201,6 +201,7 @@ impl SqliteUiDataStore { }) } + #[cfg(test)] pub(crate) fn is_projected_to(&self, tip_hash: &str) -> Result<bool> { self.with_connection(|connection| { let projected = connection @@ -218,6 +219,18 @@ impl SqliteUiDataStore { }) } + pub(crate) fn metrics_are_projected_to(&self, tip_hash: &str) -> Result<bool> { + self.with_connection(|connection| { + connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM block_metrics WHERE block_hash = ?1)", + [tip_hash], + |row| row.get(0), + ) + .context("failed to inspect metrics projection") + }) + } + pub fn project_snapshot(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> { let updated_at_ms = unix_ms(); let ui_index = build_ui_chain_index(snapshot); @@ -1367,6 +1380,7 @@ mod tests { let tip = snapshot.blocks.last().unwrap().hash.clone(); store.project_snapshot(&snapshot, true).unwrap(); assert!(store.is_projected_to(&tip).unwrap()); + assert!(store.metrics_are_projected_to(&tip).unwrap()); assert!(!store.load_metrics().unwrap().is_empty()); let mut invalid = snapshot.clone(); @@ -1378,6 +1392,7 @@ mod tests { "{error:#}" ); assert!(store.is_projected_to(&tip).unwrap()); + assert!(store.metrics_are_projected_to(&tip).unwrap()); assert!(!store.load_metrics().unwrap().is_empty()); } } diff --git a/src/main.rs b/src/main.rs @@ -225,6 +225,7 @@ async fn main() -> Result<()> { None } }; + let persistence_initial_keep_metrics = ui_config.lock().await.keep_track_of_metrics; tokio::spawn(async move { run_chain_persistence( persistence_node, @@ -232,6 +233,7 @@ async fn main() -> Result<()> { persistence_ui_data_store, persistence_config, persistence_initial_tip, + persistence_initial_keep_metrics, ) .await; }); @@ -913,6 +915,7 @@ async fn run_chain_persistence( ui_data_store: SqliteUiDataStore, ui_config: Arc<Mutex<config_store::UiConfig>>, initial_saved_tip: Option<String>, + initial_projected_keep_metrics: bool, ) { run_chain_persistence_with_interval( node, @@ -921,6 +924,7 @@ async fn run_chain_persistence( ui_config, Duration::from_secs(2), initial_saved_tip, + initial_projected_keep_metrics, ) .await; } @@ -932,8 +936,10 @@ async fn run_chain_persistence_with_interval( ui_config: Arc<Mutex<config_store::UiConfig>>, interval: Duration, initial_saved_tip: Option<String>, + initial_projected_keep_metrics: bool, ) { let mut last_saved_tip = initial_saved_tip; + let mut last_projected_keep_metrics = initial_projected_keep_metrics; loop { tokio::time::sleep(interval).await; let snapshot = { @@ -946,15 +952,23 @@ async fn run_chain_persistence_with_interval( let Some(tip_hash) = snapshot.blocks.last().map(|block| block.hash.clone()) else { continue; }; - if last_saved_tip.as_deref() == Some(tip_hash.as_str()) { + let keep_metrics = ui_config.lock().await.keep_track_of_metrics; + let tip_changed = last_saved_tip.as_deref() != Some(tip_hash.as_str()); + let metrics_mode_changed = last_projected_keep_metrics != keep_metrics; + if !tip_changed && !metrics_mode_changed { continue; } - let keep_metrics = ui_config.lock().await.keep_track_of_metrics; - match persist_chain_and_project_ui_data(&store, &ui_data_store, snapshot, keep_metrics) - .await - { - Ok(()) => last_saved_tip = Some(tip_hash), + let result = if tip_changed { + persist_chain_and_project_ui_data(&store, &ui_data_store, snapshot, keep_metrics).await + } else { + project_ui_data_store(&ui_data_store, snapshot, keep_metrics).await + }; + match result { + Ok(()) => { + last_saved_tip = Some(tip_hash); + last_projected_keep_metrics = keep_metrics; + } Err(error) if debug_logging_enabled() => { eprintln!("chain persistence failed: {error:#}") } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -1078,6 +1078,7 @@ async fn persistence_loop_saves_new_tip_after_node_changes() { ui_config, Duration::from_millis(10), initial_tip, + false, )); { let mut node = node.lock().await; @@ -1172,6 +1173,7 @@ async fn persistence_loop_skips_tip_already_projected_at_startup() { ui_config, Duration::from_millis(10), initial_tip, + false, )); tokio::time::sleep(Duration::from_millis(50)).await; persistence_task.abort(); @@ -1188,6 +1190,54 @@ async fn persistence_loop_skips_tip_already_projected_at_startup() { } #[tokio::test] +async fn persistence_loop_projects_metrics_when_mode_changes_without_a_new_tip() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let ui_data_store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("background-metrics-mode-change"); + let ledger = ledger_with_one_spendable_iuna(&wallet); + let node = Arc::new(Mutex::new(NodeCore::from_ledger( + wallet, + ledger, + DEFAULT_BURN_PER_BLOCK, + ))); + let initial_snapshot = { node.lock().await.chain_snapshot() }; + let initial_tip = initial_snapshot + .blocks + .last() + .map(|block| block.hash.clone()); + persist_chain_snapshot(&store, initial_snapshot.clone()) + .await + .unwrap(); + project_ui_data_store(&ui_data_store, initial_snapshot, false) + .await + .unwrap(); + let ui_config = Arc::new(Mutex::new(UiConfig::default())); + + let persistence_task = tokio::spawn(run_chain_persistence_with_interval( + Arc::clone(&node), + store, + ui_data_store.clone(), + Arc::clone(&ui_config), + Duration::from_millis(10), + initial_tip, + false, + )); + ui_config.lock().await.keep_track_of_metrics = true; + + tokio::time::timeout(Duration::from_secs(5), async { + while ui_data_store.load_metrics().unwrap().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + persistence_task.abort(); + + assert!(!ui_data_store.load_metrics().unwrap().is_empty()); +} + +#[tokio::test] async fn persistence_loop_skips_setup_placeholder_chain() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); @@ -1204,6 +1254,7 @@ async fn persistence_loop_skips_setup_placeholder_chain() { ui_config, Duration::from_millis(10), None, + false, )); tokio::time::sleep(Duration::from_millis(50)).await; persistence_task.abort(); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -1551,7 +1551,7 @@ window.iunaApp = function iunaApp() { await this.postForm( "/api/settings/metrics", { enabled }, - enabled ? "Development mode turned on" : "Development mode turned off" + enabled ? "Development mode is preparing" : "Development mode turned off" ); await this.refreshConfig(); if (!enabled && this.tab === "metrics") { @@ -1916,6 +1916,10 @@ window.iunaApp = function iunaApp() { return Array.isArray(this.blockchainMetrics?.charts) ? this.blockchainMetrics.charts : []; }, + metricsPreparing() { + return this.blockchainMetrics?.preparing === true; + }, + metricsLatest() { return this.blockchainMetrics?.latest || {}; },