Fix storage lock contention: reduce lock holds across 6 hot paths

- get_blob_for_post: 3 sequential locks → 1 combined acquisition
- prefetch_blobs_from_peer: lock only for DB reads, blob checks outside lock
- fetch_engagement_from_peer: explicit lock release before next network I/O
- serve_post: 4 locks (2 redundant) → 2
- run_replication_check: 2 locks → 1 combined
- Badge cycle: N+2 IPC calls → 1 (new get_badge_counts command)
- Follow timeout: 15s cap on auto-sync-on-follow to prevent UI hang
- Notification clearing: clear system notifications on conversation read

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Scott Reimers 2026-03-21 13:02:30 -04:00
parent 3cc39590a7
commit 89d6a853f5
5 changed files with 152 additions and 106 deletions

View file

@ -116,6 +116,13 @@ struct StatsDto {
follow_count: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BadgeCountsDto {
new_feed: usize,
new_engagement: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RedundancyDto {
@ -666,11 +673,16 @@ async fn follow_node(state: State<'_, AppState>, node_id_hex: String) -> Result<
let node = state.inner();
let nid = parse_node_id(&node_id_hex)?;
node.follow(&nid).await.map_err(|e| e.to_string())?;
// Auto-sync: pull posts from the followed peer in the background
// Auto-sync: pull posts from the followed peer in the background (15s timeout)
let node_clone = state.inner().clone();
tokio::spawn(async move {
if let Err(e) = node_clone.sync_with(nid).await {
tracing::debug!(error = %e, "Auto-sync after follow failed (peer may not be connected)");
match tokio::time::timeout(
std::time::Duration::from_secs(15),
node_clone.sync_with(nid),
).await {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::debug!(error = %e, "Auto-sync after follow failed"),
Err(_) => tracing::debug!("Auto-sync after follow timed out (15s)"),
}
});
Ok(())
@ -1399,6 +1411,54 @@ async fn get_seen_engagement(
}))
}
#[tauri::command]
async fn get_badge_counts(
state: State<'_, AppState>,
last_feed_view_ms: u64,
) -> Result<BadgeCountsDto, String> {
let node = state.inner();
let storage = node.storage.lock().await;
// Feed badge: count non-DM posts from others newer than last_feed_view_ms
let feed_posts = storage.get_feed().map_err(|e| e.to_string())?;
let new_feed = feed_posts.iter()
.filter(|(id, p, _vis)| {
p.author != node.node_id
&& p.timestamp_ms > last_feed_view_ms
&& !matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
)
})
.count();
// My Posts badge: count own non-DM posts with unseen engagement
let all_posts = storage.list_posts_reverse_chron().map_err(|e| e.to_string())?;
let mut new_engagement = 0usize;
for (id, post, _vis) in &all_posts {
if post.author != node.node_id { continue; }
// Skip DMs
if matches!(
storage.get_post_intent(id).ok().flatten(),
Some(VisibilityIntent::Direct(_))
) { continue; }
let total_reacts: u64 = storage.get_reaction_counts(id, &node.node_id)
.unwrap_or_default()
.iter()
.map(|(_, count, _)| *count)
.sum();
let total_comments = storage.get_comment_count(id).unwrap_or(0);
if total_reacts > 0 || total_comments > 0 {
let (seen_r, seen_c) = storage.get_seen_engagement(id).unwrap_or((0, 0));
if total_reacts > seen_r as u64 || total_comments > seen_c as u64 {
new_engagement += 1;
}
}
}
Ok(BadgeCountsDto { new_feed, new_engagement })
}
#[tauri::command]
async fn get_last_read_message(
state: State<'_, AppState>,
@ -2057,6 +2117,7 @@ pub fn run() {
mark_post_seen,
mark_conversation_read,
get_seen_engagement,
get_badge_counts,
get_last_read_message,
generate_share_link,
])