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:
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 || {};
},