Add atomic bulk channel subscription updates (#59998)

## What changed

Add `LocalAgentMessageBoard::set_subscriptions` to apply a caller's subscribe and unsubscribe choices for multiple channels in one transaction. Validate all supplied channels before applying changes, and preserve subscriptions for other channels, threads, and agents.

Allow this self-only operation on read-only live connections, reject fixed views, and emit no notifications. Share subscription and opt-out updates with the existing single-subscription API.

## Testing

Add coverage for bulk updates through a read-only connection, atomic failure when a channel is missing, preservation of other channels and agents, and the absence of notifications.

GitOrigin-RevId: 83ed587f9c5deaa3e9de87adb6319f9de0c804b1
This commit is contained in:
felixxia-oai
2026-11-06 22:09:04 +00:00
committed by copyberry
parent 8d0098930e
commit eb485f57ba
3 changed files with 181 additions and 21 deletions
@@ -1939,6 +1939,89 @@ async fn silent_appends_preserve_evidence_and_explicit_subscriptions()
Ok(())
}
#[tokio::test]
async fn bulk_subscriptions_are_atomic_and_scoped_to_the_caller_and_supplied_channels() -> Result<()>
{
use SubscriptionChange::Subscribe;
use SubscriptionChange::Unsubscribe;
use std::collections::BTreeMap;
let dir = tempfile::tempdir()?;
let sqlite = SqliteConfig::new_for_testing(dir.path().to_path_buf().try_into().unwrap());
let owner = ThreadId::new();
let reviewer = ThreadId::new();
let host = Arc::new(Host {
clock: AtomicI64::default(),
fail_clock: AtomicBool::default(),
agent_path_calls: AtomicUsize::default(),
members: [
(owner, AgentPath::root()),
(reviewer, AgentPath::root().join("reviewer").unwrap()),
]
.into(),
active: AtomicBool::new(true),
fail_notifications: AtomicBool::default(),
notifications: Mutex::default(),
});
let board = LocalBoard::new(sqlite, owner.into());
let writer = board.read_write().open(host.clone()).await?;
let reader = board.read_only().open(host.clone()).await?;
for name in ["build", "release"] {
writer
.create_channel(
owner,
CreateChannelRequest {
channel_name: name.into(),
description: None,
subscription: Subscribe,
},
)
.await?;
}
reader
.set_subscriptions(
reviewer,
&BTreeMap::from([("build", Subscribe), ("release", Subscribe)]),
)
.await?;
let both = vec!["build".to_owned(), "release".to_owned()];
assert!(
reader
.set_subscriptions(
reviewer,
&BTreeMap::from([("build", Unsubscribe), ("missing", Subscribe)])
)
.await
.is_err()
);
assert_eq!(
reader
.subscribed_channels(reviewer, PageRequest::default())
.await?
.results,
both
);
reader
.set_subscriptions(reviewer, &BTreeMap::from([("build", Unsubscribe)]))
.await?;
assert_eq!(
reader
.subscribed_channels(reviewer, PageRequest::default())
.await?
.results,
vec!["release".to_owned()]
);
assert_eq!(
reader
.subscribed_channels(owner, PageRequest::default())
.await?
.results,
both
);
assert!(host.notifications.lock().unwrap().is_empty());
Ok(())
}
#[tokio::test]
async fn local_board_setup_survives_reopen() -> Result<()> {
use serde_json::json;