commit e727d4c37af14fd0da45b62dca575dff37de9cdb
parent 7c7bd5afdaf76f93acd546ed79ed438546e92d03
Author: kernelkind <kernelkind@gmail.com>
Date: Tue, 24 Feb 2026 23:00:22 -0500
refactor(outbox-int): manage scoped owners per thread scope depth
Replace Remote/dependers bookkeeping with scope-owner lifecycle tracking in thread_subs.
Each newly opened thread scope now declares a scoped remote reply subscription using an owner derived from (account, column, root, scope_depth). Unsubscribe paths report whether a close action removed a whole scope or only a local frame, and only drop the scoped owner when the scope is fully removed.
This keeps remote lifetime tied to thread scope lifetimes instead of manual depender counts, and preserves the local unsubscribe rollback behavior added earlier.
Signed-off-by: kernelkind <kernelkind@gmail.com>
Diffstat:
1 file changed, 81 insertions(+), 110 deletions(-)
diff --git a/crates/notedeck_columns/src/timeline/sub/thread_sub.rs b/crates/notedeck_columns/src/timeline/sub/thread_sub.rs
@@ -1,9 +1,10 @@
use egui_nav::ReturnType;
-use enostr::NoteId;
+use enostr::{NoteId, Pubkey};
use hashbrown::HashMap;
use nostrdb::{Filter, Ndb, Subscription};
use notedeck::{RelaySelection, ScopedSubApi, ScopedSubIdentity, SubConfig, SubKey, SubOwnerKey};
+use crate::scoped_sub_owner_keys::thread_scope_owner_key;
use crate::timeline::{
sub::{ndb_sub, ndb_unsub},
ThreadSelection,
@@ -13,26 +14,21 @@ type RootNoteId = NoteId;
#[derive(Default)]
pub struct ThreadSubs {
- pub remotes: HashMap<RootNoteId, Remote>,
scopes: HashMap<MetaId, Vec<Scope>>,
}
// column id
type MetaId = usize;
-pub struct Remote {
- pub filter: Vec<Filter>,
- owner: SubOwnerKey,
- dependers: usize,
-}
-
-impl std::fmt::Debug for Remote {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.debug_struct("Remote")
- .field("owner", &self.owner)
- .field("dependers", &self.dependers)
- .finish()
- }
+/// Outcome of removing local thread subscriptions for a close action.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum UnsubscribeOutcome {
+ /// Local NDB sub(s) were removed, but the scope still has stack entries so the
+ /// remote scoped-sub owner should remain.
+ KeepOwner,
+ /// The thread scope was fully removed and the remote scoped-sub owner should
+ /// be released using the returned root note id.
+ DropOwner(RootNoteId),
}
struct Scope {
@@ -58,40 +54,28 @@ impl ThreadSubs {
new_scope: bool,
remote_sub_filter: Vec<Filter>,
) {
+ let account_pk = scoped_subs.selected_account_pubkey();
let cur_scopes = self.scopes.entry(meta_id).or_default();
- let new_subs = if new_scope || cur_scopes.is_empty() {
- local_sub_new_scope(ndb, id, local_sub_filter, cur_scopes)
+ let added_local = if new_scope || cur_scopes.is_empty() {
+ local_sub_new_scope(
+ ndb,
+ scoped_subs,
+ account_pk,
+ meta_id,
+ id,
+ local_sub_filter,
+ remote_sub_filter,
+ cur_scopes,
+ )
} else {
let cur_scope = cur_scopes.last_mut().expect("can't be empty");
sub_current_scope(ndb, id, local_sub_filter, cur_scope)
};
- let remote = match self.remotes.raw_entry_mut().from_key(&id.root_id.bytes()) {
- hashbrown::hash_map::RawEntryMut::Occupied(entry) => entry.into_mut(),
- hashbrown::hash_map::RawEntryMut::Vacant(entry) => {
- let (_, res) = entry.insert(
- NoteId::new(*id.root_id.bytes()),
- sub_remote(
- scoped_subs,
- &NoteId::new(*id.root_id.bytes()),
- remote_sub_filter,
- id,
- ),
- );
-
- res
- }
- };
-
- remote.dependers = remote.dependers.saturating_add_signed(new_subs);
- let num_dependers = remote.dependers;
- tracing::debug!(
- "Sub stats: num remotes: {}, num locals: {}, num remote dependers: {:?}",
- self.remotes.len(),
- self.scopes.len(),
- num_dependers,
- );
+ if added_local {
+ tracing::debug!("Sub stats: num locals: {}", self.scopes.len());
+ }
}
pub fn unsubscribe(
@@ -102,44 +86,32 @@ impl ThreadSubs {
id: &ThreadSelection,
return_type: ReturnType,
) {
+ let account_pk = scoped_subs.selected_account_pubkey();
let Some(scopes) = self.scopes.get_mut(&meta_id) else {
return;
};
- let Some(remote) = self.remotes.get_mut(&id.root_id.bytes()) else {
- tracing::error!("somehow we're unsubscribing but we don't have a remote");
+ let scope_depth = scopes.len().saturating_sub(1);
+ let Some(unsub_outcome) = (match return_type {
+ ReturnType::Drag => unsubscribe_drag(scopes, ndb, id),
+ ReturnType::Click => unsubscribe_click(scopes, ndb, id),
+ }) else {
return;
};
- let unsubscribed = match return_type {
- ReturnType::Drag => unsubscribe_drag(scopes, ndb, id, remote),
- ReturnType::Click => unsubscribe_click(scopes, ndb, id, remote),
- };
-
- if !unsubscribed {
- return;
- }
-
if scopes.is_empty() {
self.scopes.remove(&meta_id);
}
- let num_dependers = remote.dependers;
-
- if remote.dependers == 0 {
- let remote = self
- .remotes
- .remove(&id.root_id.bytes())
- .expect("code above should guarentee existence");
- tracing::debug!("Remotely unsubscribed: {:?}", remote.owner);
- let _ = scoped_subs.drop_owner(remote.owner);
+ if let UnsubscribeOutcome::DropOwner(root_id) = unsub_outcome {
+ let owner = thread_scope_owner_key(account_pk, meta_id, &root_id, scope_depth);
+ let _ = scoped_subs.drop_owner(owner);
}
tracing::debug!(
- "unsub stats: num remotes: {}, num locals: {}, num remote dependers: {:?}",
- self.remotes.len(),
+ "unsub stats: num locals: {}, released owner: {}",
self.scopes.len(),
- num_dependers,
+ matches!(unsub_outcome, UnsubscribeOutcome::DropOwner(_)),
);
}
@@ -156,16 +128,15 @@ fn unsubscribe_drag(
scopes: &mut Vec<Scope>,
ndb: &mut Ndb,
id: &ThreadSelection,
- remote: &mut Remote,
-) -> bool {
+) -> Option<UnsubscribeOutcome> {
let Some(scope) = scopes.last_mut() else {
tracing::error!("called drag unsubscribe but there aren't any scopes left");
- return false;
+ return None;
};
let Some(cur_sub) = scope.stack.pop() else {
tracing::error!("expected a scope to be left");
- return false;
+ return None;
};
log_scope_root_mismatch(scope, id);
@@ -173,33 +144,30 @@ fn unsubscribe_drag(
if !ndb_unsub(ndb, cur_sub.sub, id) {
// Keep local bookkeeping aligned with NDB when unsubscribe fails.
scope.stack.push(cur_sub);
- return false;
+ return None;
}
- remote.dependers = remote.dependers.saturating_sub(1);
-
if scope.stack.is_empty() {
- scopes.pop();
+ let removed_scope = scopes.pop().expect("checked empty above");
+ return Some(UnsubscribeOutcome::DropOwner(removed_scope.root_id));
}
- true
+ Some(UnsubscribeOutcome::KeepOwner)
}
fn unsubscribe_click(
scopes: &mut Vec<Scope>,
ndb: &mut Ndb,
id: &ThreadSelection,
- remote: &mut Remote,
-) -> bool {
+) -> Option<UnsubscribeOutcome> {
let Some(mut scope) = scopes.pop() else {
tracing::error!("called unsubscribe but there aren't any scopes left");
- return false;
+ return None;
};
log_scope_root_mismatch(&scope, id);
while let Some(sub) = scope.stack.pop() {
if ndb_unsub(ndb, sub.sub, id) {
- remote.dependers = remote.dependers.saturating_sub(1);
continue;
}
@@ -207,10 +175,10 @@ fn unsubscribe_click(
// to thread bookkeeping and keep the remote owner alive.
scope.stack.push(sub);
scopes.push(scope);
- return false;
+ return None;
}
- true
+ Some(UnsubscribeOutcome::DropOwner(scope.root_id))
}
fn log_scope_root_mismatch(scope: &Scope, id: &ThreadSelection) {
@@ -234,20 +202,12 @@ fn thread_remote_sub_key(root_id: &RootNoteId) -> SubKey {
.finish()
}
-fn thread_remote_owner_key(root_id: &RootNoteId) -> SubOwnerKey {
- SubOwnerKey::builder(ThreadScopedSub::RepliesByRoot)
- .with(*root_id.bytes())
- .finish()
-}
-
fn sub_current_scope(
ndb: &mut Ndb,
selection: &ThreadSelection,
local_sub_filter: Vec<Filter>,
cur_scope: &mut Scope,
-) -> isize {
- let mut new_subs = 0;
-
+) -> bool {
if selection.root_id.bytes() != cur_scope.root_id.bytes() {
tracing::error!(
"Somehow the current scope's root is not equal to the selected note's root"
@@ -260,51 +220,62 @@ fn sub_current_scope(
sub,
filter: local_sub_filter,
});
- new_subs += 1;
+ return true;
}
- new_subs
+ false
}
fn sub_remote(
scoped_subs: &mut ScopedSubApi<'_, '_>,
- root_id: &RootNoteId,
- remote_sub_filter: Vec<Filter>,
+ owner: SubOwnerKey,
+ key: SubKey,
+ filter: Vec<Filter>,
id: impl std::fmt::Debug,
-) -> Remote {
- let filter = remote_sub_filter;
- let owner = thread_remote_owner_key(root_id);
- let key = thread_remote_sub_key(root_id);
-
+) {
tracing::debug!("Remote subscribe for {:?}", id);
- let identity = ScopedSubIdentity::global(owner, key);
+ let identity = ScopedSubIdentity::account(owner, key);
let config = SubConfig {
relays: RelaySelection::AccountsRead,
- filters: filter.clone(),
+ filters: filter,
use_transparent: false,
};
let _ = scoped_subs.ensure_sub(identity, config);
-
- Remote {
- filter,
- owner,
- dependers: 0,
- }
}
+#[allow(clippy::too_many_arguments)]
fn local_sub_new_scope(
ndb: &mut Ndb,
+ scoped_subs: &mut ScopedSubApi<'_, '_>,
+ account_pk: Pubkey,
+ meta_id: usize,
id: &ThreadSelection,
local_sub_filter: Vec<Filter>,
+ remote_sub_filter: Vec<Filter>,
scopes: &mut Vec<Scope>,
-) -> isize {
+) -> bool {
+ let root_id = id.root_id.to_note_id();
+ let scope_depth = scopes.len();
+ let owner = thread_scope_owner_key(account_pk, meta_id, &root_id, scope_depth);
+ tracing::info!(
+ "thread sub with owner: pk: {account_pk:?}, col: {meta_id}, rootid: {root_id:?}, depth: {scope_depth}"
+ );
+ sub_remote(
+ scoped_subs,
+ owner,
+ thread_remote_sub_key(&root_id),
+ remote_sub_filter,
+ id,
+ );
+
let Some(sub) = ndb_sub(ndb, &local_sub_filter, id) else {
- return 0;
+ let _ = scoped_subs.drop_owner(owner);
+ return false;
};
scopes.push(Scope {
- root_id: id.root_id.to_note_id(),
+ root_id,
stack: vec![Sub {
selected_id: NoteId::new(*id.selected_or_root()),
sub,
@@ -312,5 +283,5 @@ fn local_sub_new_scope(
}],
});
- 1
+ true
}