commit 6a56161b91962b980758ddcd1a89b305a0c73145
parent bb15b074c2d40023cfafd90e163f7850f9e5e220
Author: William Casarin <jb55@jb55.com>
Date: Thu, 18 Dec 2025 11:01:46 -0800
Merge don't block profile page loading by elsat #43
alltheseas (3):
perf: skip blocking profile feed fetch when notes are cached
perf: rate-limit background profile refreshes
perf: add concurrency safety with DashMap and atomic operations
Diffstat:
3 files changed, 151 insertions(+), 5 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -572,6 +572,20 @@ dependencies = [
]
[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1909,6 +1923,7 @@ version = "0.1.0"
dependencies = [
"ammonia",
"bytes",
+ "dashmap",
"egui",
"egui_extras",
"egui_skia",
diff --git a/Cargo.toml b/Cargo.toml
@@ -34,3 +34,4 @@ pulldown-cmark = "0.9"
serde_json = "*"
metrics = "0.21"
metrics-exporter-prometheus = "0.13"
+dashmap = "6"
diff --git a/src/main.rs b/src/main.rs
@@ -1,4 +1,8 @@
use std::net::SocketAddr;
+use std::time::Instant;
+
+use dashmap::DashMap;
+use tokio::task::AbortHandle;
use http_body_util::Full;
use hyper::body::Bytes;
@@ -17,7 +21,7 @@ use crate::{
render::{ProfileRenderData, RenderData},
};
use nostr_sdk::prelude::*;
-use nostrdb::{Config, Ndb, NoteKey, Transaction};
+use nostrdb::{Config, Filter, Ndb, NoteKey, Transaction};
use std::time::Duration;
mod abbrev;
@@ -37,6 +41,20 @@ const POETSEN_FONT: &[u8] = include_bytes!("../fonts/PoetsenOne-Regular.ttf");
const DEFAULT_PFP_IMAGE: &[u8] = include_bytes!("../assets/default_pfp.jpg");
const DAMUS_LOGO_ICON: &[u8] = include_bytes!("../assets/logo_icon.png");
+/// Minimum interval between background profile feed refreshes for the same pubkey
+const PROFILE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
+
+/// Prune refresh tracking map when it exceeds this size (deliberate limit, ~40KB max memory)
+const PROFILE_REFRESH_MAP_PRUNE_THRESHOLD: usize = 1000;
+
+/// Tracks the state of a background profile refresh
+enum ProfileRefreshState {
+ /// Refresh currently in progress with handle to abort if stuck
+ InProgress { started: Instant, handle: AbortHandle },
+ /// Last successful refresh completed at this time
+ Completed(Instant),
+}
+
#[derive(Clone)]
pub struct Notecrumbs {
pub ndb: Ndb,
@@ -49,6 +67,9 @@ pub struct Notecrumbs {
/// How long do we wait for remote note requests
_timeout: Duration,
+
+ /// Tracks refresh state per pubkey - prevents excessive relay queries and concurrent fetches
+ profile_refresh_state: Arc<DashMap<[u8; 32], ProfileRefreshState>>,
}
#[inline]
@@ -185,10 +206,118 @@ async fn serve(
};
if let Some(pubkey) = maybe_pubkey {
- if let Err(err) =
- render::fetch_profile_feed(app.relay_pool.clone(), app.ndb.clone(), pubkey).await
- {
- error!("Error fetching profile feed: {err}");
+ // Check if we have cached notes for this profile
+ let has_cached_notes = {
+ let txn = Transaction::new(&app.ndb)?;
+ let notes_filter = Filter::new()
+ .authors([&pubkey])
+ .kinds([1])
+ .limit(1)
+ .build();
+ app.ndb
+ .query(&txn, &[notes_filter], 1)
+ .map(|results| !results.is_empty())
+ .unwrap_or(false)
+ };
+
+ let pool = app.relay_pool.clone();
+ let ndb = app.ndb.clone();
+
+ if has_cached_notes {
+ // Cached data exists: spawn background refresh so we don't block response.
+ // Rate-limit refreshes per pubkey to avoid hammering relays on hot profiles.
+ let now = Instant::now();
+ let state_map = &app.profile_refresh_state;
+
+ // Prune stale completed entries to bound memory growth
+ if state_map.len() > PROFILE_REFRESH_MAP_PRUNE_THRESHOLD {
+ state_map.retain(|_, state| match state {
+ ProfileRefreshState::InProgress { .. } => true,
+ ProfileRefreshState::Completed(t) => {
+ now.duration_since(*t) < PROFILE_REFRESH_INTERVAL
+ }
+ });
+ }
+
+ // Use entry API for atomic check-and-insert to prevent race conditions
+ // where concurrent requests could each spawn a refresh
+ use dashmap::mapref::entry::Entry;
+ match state_map.entry(pubkey) {
+ Entry::Occupied(mut occupied) => {
+ let should_refresh = match occupied.get() {
+ // Already refreshing - skip unless stuck (>10 min)
+ ProfileRefreshState::InProgress { started, .. }
+ if now.duration_since(*started) < Duration::from_secs(10 * 60) =>
+ {
+ false
+ }
+ // Recently completed - skip refresh
+ ProfileRefreshState::Completed(t)
+ if now.duration_since(*t) < PROFILE_REFRESH_INTERVAL =>
+ {
+ false
+ }
+ // Stuck fetch - abort and restart
+ ProfileRefreshState::InProgress { handle, .. } => {
+ handle.abort();
+ true
+ }
+ // Stale completion - refresh
+ ProfileRefreshState::Completed(_) => true,
+ };
+
+ if should_refresh {
+ let refresh_state = app.profile_refresh_state.clone();
+ let handle = tokio::spawn(async move {
+ let result = render::fetch_profile_feed(pool, ndb, pubkey).await;
+ match result {
+ Ok(()) => {
+ refresh_state.insert(
+ pubkey,
+ ProfileRefreshState::Completed(Instant::now()),
+ );
+ }
+ Err(err) => {
+ error!("Background profile feed refresh failed: {err}");
+ refresh_state.remove(&pubkey);
+ }
+ }
+ });
+ occupied.insert(ProfileRefreshState::InProgress {
+ started: now,
+ handle: handle.abort_handle(),
+ });
+ }
+ }
+ Entry::Vacant(vacant) => {
+ // No existing state - start refresh
+ let refresh_state = app.profile_refresh_state.clone();
+ let handle = tokio::spawn(async move {
+ let result = render::fetch_profile_feed(pool, ndb, pubkey).await;
+ match result {
+ Ok(()) => {
+ refresh_state.insert(
+ pubkey,
+ ProfileRefreshState::Completed(Instant::now()),
+ );
+ }
+ Err(err) => {
+ error!("Background profile feed refresh failed: {err}");
+ refresh_state.remove(&pubkey);
+ }
+ }
+ });
+ vacant.insert(ProfileRefreshState::InProgress {
+ started: now,
+ handle: handle.abort_handle(),
+ });
+ }
+ }
+ } else {
+ // No cached data: must wait for relay fetch before rendering
+ if let Err(err) = render::fetch_profile_feed(pool, ndb, pubkey).await {
+ error!("Error fetching profile feed: {err}");
+ }
}
}
}
@@ -304,6 +433,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
font_data,
default_pfp,
prometheus_handle,
+ profile_refresh_state: Arc::new(DashMap::new()),
};
// We start a loop to continuously accept incoming connections