use crate::crdt::{Result, TaskList, TaskListDelta};
use crate::gossip::wire::{decode_delta, encode_delta};
use crate::gossip::PubSubManager;
use saorsa_gossip_types::PeerId;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
const STATE_SYNC_TOPIC_SUFFIX: &str = "/state-sync";
const STATE_REQUEST_RETRY_SECS: [u64; 4] = [1, 5, 15, 30];
const STATE_REQUEST_TAIL_START_SECS: u64 = 30;
const STATE_REQUEST_TAIL_CAP_SECS: u64 = 300;
fn state_request_delays() -> impl Iterator<Item = u64> {
let tail = std::iter::successors(Some(STATE_REQUEST_TAIL_START_SECS), |d| {
Some(d.saturating_mul(2).min(STATE_REQUEST_TAIL_CAP_SECS))
});
STATE_REQUEST_RETRY_SECS.into_iter().chain(tail)
}
const STATE_RESPONSE_COOLDOWN_SECS: u64 = 15;
fn jittered_secs(secs: u64) -> std::time::Duration {
let factor = 0.8 + rand::random::<f64>() * 0.4;
std::time::Duration::from_secs_f64(secs as f64 * factor)
}
#[derive(Debug, Serialize, Deserialize)]
enum TaskListSyncMessage {
StateRequest { requester: PeerId },
StateServed {
responder: PeerId,
},
}
pub struct TaskListSync {
task_list: Arc<RwLock<TaskList>>,
pubsub: Arc<PubSubManager>,
topic: String,
local_peer_id: PeerId,
stopped: Arc<std::sync::atomic::AtomicBool>,
cancel: tokio_util::sync::CancellationToken,
}
impl Drop for TaskListSync {
fn drop(&mut self) {
self.cancel.cancel();
}
}
impl TaskListSync {
pub fn new(
task_list: TaskList,
pubsub: Arc<PubSubManager>,
topic: String,
local_peer_id: PeerId,
) -> Result<Self> {
let task_list = Arc::new(RwLock::new(task_list));
Ok(Self {
task_list,
pubsub,
topic,
local_peer_id,
stopped: Arc::new(std::sync::atomic::AtomicBool::new(false)),
cancel: tokio_util::sync::CancellationToken::new(),
})
}
fn state_sync_topic(&self) -> String {
format!("{}{}", self.topic, STATE_SYNC_TOPIC_SUFFIX)
}
pub async fn start(&self) -> Result<()> {
self.start_with_spawner(|fut| {
tokio::spawn(fut);
})
.await
}
pub async fn start_with_spawner<S>(&self, spawn: S) -> Result<()>
where
S: Fn(std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'static>>)
+ Send
+ Sync,
{
let mut sub = self.pubsub.subscribe(self.topic.clone()).await;
let task_list = Arc::clone(&self.task_list);
let listener_cancel = self.cancel.clone();
spawn(Box::pin(async move {
loop {
let msg = tokio::select! {
() = listener_cancel.cancelled() => return,
msg = sub.recv() => msg,
};
let Some(msg) = msg else {
listener_cancel.cancel();
return;
};
match decode_delta::<TaskListDelta>(&msg.payload) {
Ok((peer_id, delta)) => {
let mut list = task_list.write().await;
if let Err(e) = list.merge_delta(&delta, peer_id) {
tracing::warn!("Failed to merge remote delta: {}", e);
}
}
Err(e) => {
tracing::warn!("Failed to deserialize delta from topic: {}", e);
}
}
}
}));
let mut sync_sub = self.pubsub.subscribe(self.state_sync_topic()).await;
let responder_list = Arc::clone(&self.task_list);
let responder_pubsub = Arc::clone(&self.pubsub);
let responder_topic = self.topic.clone();
let sync_topic = self.state_sync_topic();
let served_evidence = Arc::new(std::sync::atomic::AtomicBool::new(false));
let responder_served = Arc::clone(&served_evidence);
let responder_cancel = self.cancel.clone();
let local_peer_id = self.local_peer_id;
spawn(Box::pin(async move {
let mut last_full_response: Option<tokio::time::Instant> = None;
loop {
let msg = tokio::select! {
() = responder_cancel.cancelled() => return,
msg = sync_sub.recv() => msg,
};
let Some(msg) = msg else {
responder_cancel.cancel();
return;
};
let Ok(sync_msg) = bincode::deserialize::<TaskListSyncMessage>(&msg.payload) else {
continue;
};
match sync_msg {
TaskListSyncMessage::StateRequest { requester } => {
if requester == local_peer_id {
continue;
}
let cooled_down = last_full_response.is_some_and(|t| {
t.elapsed()
< std::time::Duration::from_secs(STATE_RESPONSE_COOLDOWN_SECS)
});
if cooled_down {
continue;
}
let full = {
let list = responder_list.read().await;
if list.task_count() == 0 {
continue;
}
list.full_delta()
};
let Ok(serialized) = encode_delta(local_peer_id, &full) else {
continue;
};
if let Err(e) = responder_pubsub
.publish(responder_topic.clone(), bytes::Bytes::from(serialized))
.await
{
tracing::warn!("TaskList state-response publish failed: {e}");
continue;
}
last_full_response = Some(tokio::time::Instant::now());
let marker = TaskListSyncMessage::StateServed {
responder: local_peer_id,
};
match bincode::serialize(&marker) {
Ok(serialized) => {
if let Err(e) = responder_pubsub
.publish(sync_topic.clone(), bytes::Bytes::from(serialized))
.await
{
tracing::warn!(
"TaskList state-served marker publish failed: {e}"
);
}
}
Err(e) => {
tracing::warn!(
"TaskList state-served marker serialize failed: {e}"
);
}
}
}
TaskListSyncMessage::StateServed { responder } => {
if responder == local_peer_id {
continue; }
responder_served.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
}
}));
if self.task_list.read().await.task_count() == 0 {
let requester_pubsub = Arc::clone(&self.pubsub);
let requester_list = Arc::downgrade(&self.task_list);
let sync_topic = self.state_sync_topic();
let stopped = Arc::clone(&self.stopped);
let requester_cancel = self.cancel.clone();
let requester_served = Arc::clone(&served_evidence);
spawn(Box::pin(async move {
for (attempt, delay_secs) in state_request_delays().enumerate() {
tokio::select! {
() = requester_cancel.cancelled() => return,
() = tokio::time::sleep(jittered_secs(delay_secs)) => {}
}
if stopped.load(std::sync::atomic::Ordering::Relaxed) {
return; }
if attempt >= STATE_REQUEST_RETRY_SECS.len() {
let Some(list) = requester_list.upgrade() else {
return; };
if requester_served.load(std::sync::atomic::Ordering::Relaxed)
&& list.read().await.task_count() > 0
{
return; }
}
let request = TaskListSyncMessage::StateRequest {
requester: local_peer_id,
};
let Ok(serialized) = bincode::serialize(&request) else {
return;
};
if let Err(e) = requester_pubsub
.publish(sync_topic.clone(), bytes::Bytes::from(serialized))
.await
{
tracing::debug!("TaskList state-request publish failed: {e}");
}
}
}));
}
Ok(())
}
pub async fn stop(&self) -> Result<()> {
self.cancel_sync();
self.pubsub.unsubscribe(&self.topic).await;
self.pubsub.unsubscribe(&self.state_sync_topic()).await;
Ok(())
}
pub fn silence_bootstrap(&self) {
self.stopped
.store(true, std::sync::atomic::Ordering::Relaxed);
}
pub fn cancel_sync(&self) {
self.cancel.cancel();
}
pub async fn apply_remote_delta(&self, peer_id: PeerId, delta: TaskListDelta) -> Result<()> {
let mut task_list = self.task_list.write().await;
task_list.merge_delta(&delta, peer_id)?;
Ok(())
}
pub async fn publish_delta(&self, local_peer_id: PeerId, delta: TaskListDelta) -> Result<()> {
let serialized = encode_delta(local_peer_id, &delta).map_err(|e| {
crate::crdt::CrdtError::Gossip(format!("failed to serialize delta: {e}"))
})?;
self.pubsub
.publish(self.topic.clone(), bytes::Bytes::from(serialized))
.await
.map_err(|e| crate::crdt::CrdtError::Gossip(format!("failed to publish delta: {e}")))?;
Ok(())
}
pub async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, TaskList> {
self.task_list.read().await
}
pub async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, TaskList> {
self.task_list.write().await
}
#[must_use]
pub fn topic(&self) -> &str {
&self.topic
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crdt::{TaskId, TaskItem, TaskListId, TaskMetadata};
use crate::identity::AgentId;
use crate::network::{NetworkConfig, NetworkNode};
use std::time::Duration;
fn agent(n: u8) -> AgentId {
AgentId([n; 32])
}
fn peer(n: u8) -> PeerId {
PeerId::new([n; 32])
}
fn list_id(n: u8) -> TaskListId {
TaskListId::new([n; 32])
}
fn make_task(id_byte: u8, peer: PeerId) -> TaskItem {
let agent = agent(1);
let task_id = TaskId::from_bytes([id_byte; 32]);
let metadata = TaskMetadata::new(
format!("Task {}", id_byte),
format!("Description {}", id_byte),
128,
agent,
1000,
);
TaskItem::new(task_id, metadata, peer)
}
async fn make_node() -> Arc<NetworkNode> {
Arc::new(
NetworkNode::new(NetworkConfig::default(), None, None)
.await
.expect("network node"),
)
}
async fn make_sync(topic: &str) -> TaskListSync {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let list = TaskList::new(list_id(1), "Test List".to_string(), peer(1));
TaskListSync::new(list, pubsub, topic.to_string(), peer(1)).expect("task list sync")
}
async fn make_sync_with_pubsub(topic: &str) -> (TaskListSync, Arc<PubSubManager>) {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let list = TaskList::new(list_id(1), "Test List".to_string(), peer(1));
let sync = TaskListSync::new(list, Arc::clone(&pubsub), topic.to_string(), peer(1))
.expect("task list sync");
(sync, pubsub)
}
#[tokio::test]
async fn test_task_list_sync_creation() {
let peer = peer(1);
let id = list_id(1);
let task_list = TaskList::new(id, "Test List".to_string(), peer);
let _list_for_sync = task_list;
}
#[tokio::test]
async fn test_apply_delta() {
let peer1 = peer(1);
let peer2 = peer(2);
let id = list_id(1);
let task_list = TaskList::new(id, "Test".to_string(), peer1);
let task_list_arc = Arc::new(RwLock::new(task_list));
let mut delta = TaskListDelta::new(1);
let task = make_task(1, peer2);
let task_id = *task.id();
let tag = (peer2, 1);
delta.added_tasks.insert(task_id, (task, tag));
{
let mut list = task_list_arc.write().await;
let result = list.merge_delta(&delta, peer2);
assert!(result.is_ok());
}
{
let list = task_list_arc.read().await;
assert_eq!(list.task_count(), 1);
}
}
#[tokio::test]
async fn test_concurrent_access() {
let peer = peer(1);
let id = list_id(1);
let task_list = TaskList::new(id, "Test".to_string(), peer);
let task_list_arc = Arc::new(RwLock::new(task_list));
let list1 = task_list_arc.read().await;
let list2 = task_list_arc.read().await;
assert_eq!(list1.name(), "Test");
assert_eq!(list2.name(), "Test");
drop(list1);
drop(list2);
{
let mut list = task_list_arc.write().await;
list.update_name("Updated".to_string(), peer);
}
let list = task_list_arc.read().await;
assert_eq!(list.name(), "Updated");
}
#[tokio::test]
async fn new_sets_topic_and_yields_accessible_guards() {
let sync = make_sync("tasks/A").await;
assert_eq!(sync.topic(), "tasks/A");
{
let list = sync.read().await;
assert_eq!(list.name(), "Test List");
assert_eq!(list.task_count(), 0);
}
{
let mut list = sync.write().await;
list.update_name("Renamed".to_string(), peer(1));
}
let list = sync.read().await;
assert_eq!(list.name(), "Renamed", "write-guard rename must be visible");
}
#[tokio::test]
async fn state_sync_topic_appends_side_channel_suffix() {
let sync = make_sync("tasks/B").await;
assert_eq!(sync.state_sync_topic(), "tasks/B/state-sync");
let sync2 = make_sync("tasks/B/nested").await;
assert_eq!(sync2.state_sync_topic(), "tasks/B/nested/state-sync");
}
#[tokio::test]
async fn apply_remote_delta_merges_task_into_list() {
let sync = make_sync("tasks/C").await;
assert_eq!(sync.read().await.task_count(), 0);
let remote = peer(2);
let task = make_task(7, remote);
let task_id = *task.id();
let mut delta = TaskListDelta::new(1);
delta.added_tasks.insert(task_id, (task, (remote, 1)));
sync.apply_remote_delta(remote, delta)
.await
.expect("apply_remote_delta");
let list = sync.read().await;
assert_eq!(list.task_count(), 1, "merged task must bump the count");
assert!(
list.get_task(&task_id).is_some(),
"merged task must be retrievable by id"
);
}
#[tokio::test]
async fn publish_delta_delivers_encoded_pair_to_subscriber() {
let (sync, pubsub) = make_sync_with_pubsub("tasks/D").await;
let mut sub = pubsub.subscribe("tasks/D".to_string()).await;
let sender = peer(7);
let task = make_task(3, sender);
let task_id = *task.id();
let mut delta = TaskListDelta::new(9);
delta.added_tasks.insert(task_id, (task, (sender, 3)));
sync.publish_delta(sender, delta)
.await
.expect("publish_delta");
let msg = tokio::time::timeout(Duration::from_secs(2), sub.recv())
.await
.expect("timed out waiting for published delta")
.expect("subscriber stream closed");
let (observed_sender, observed_delta) =
decode_delta::<TaskListDelta>(&msg.payload).expect("wire decode");
assert_eq!(observed_sender, sender);
assert_eq!(observed_delta.version, 9);
assert!(
observed_delta.added_tasks.contains_key(&task_id),
"published delta must carry the task"
);
assert_eq!(msg.topic, "tasks/D");
}
#[tokio::test]
async fn start_with_spawner_accepts_custom_spawner_and_returns_ok() {
let sync = make_sync("tasks/E").await;
sync.start_with_spawner(|_fut| {
})
.await
.expect("start_with_spawner");
}
#[tokio::test]
async fn start_default_spawner_merges_remote_delta() {
let sync = make_sync("tasks/F").await;
sync.start().await.expect("start");
tokio::time::sleep(Duration::from_millis(100)).await;
let remote = peer(2);
let task = make_task(5, remote);
let task_id = *task.id();
let mut delta = TaskListDelta::new(1);
delta.added_tasks.insert(task_id, (task, (remote, 1)));
sync.publish_delta(remote, delta).await.expect("publish");
let landed = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let count = sync.read().await.task_count();
if count == 1 {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await;
assert!(
landed.is_ok(),
"remote delta was not merged by start() loop"
);
assert!(
sync.read().await.get_task(&task_id).is_some(),
"merged task must be retrievable by id"
);
}
#[tokio::test]
async fn stop_returns_ok_and_is_idempotent() {
let sync = make_sync("tasks/G").await;
sync.stop().await.expect("first stop");
sync.stop().await.expect("second stop (idempotent)");
}
#[tokio::test]
async fn stop_and_silence_arm_their_kill_switches() {
let sync = make_sync("tasks/stopflag").await;
assert!(
!sync.cancel.is_cancelled() && !sync.stopped.load(std::sync::atomic::Ordering::Relaxed),
"both switches must start disarmed"
);
sync.silence_bootstrap();
assert!(
sync.stopped.load(std::sync::atomic::Ordering::Relaxed),
"silence_bootstrap() arms the requester-only flag"
);
assert!(
!sync.cancel.is_cancelled(),
"silence_bootstrap() must NOT cancel the listener/responder loops"
);
sync.stop().await.expect("stop");
assert!(
sync.cancel.is_cancelled(),
"stop() must cancel ALL background loops via the token"
);
}
#[tokio::test]
async fn dropping_the_sync_cancels_all_loops() {
let sync = make_sync("tasks/dropcancel").await;
let token = sync.cancel.clone();
assert!(!token.is_cancelled());
drop(sync);
assert!(
token.is_cancelled(),
"dropping the last sync reference must cancel every loop"
);
}
#[test]
fn state_request_schedule_never_terminates_while_unconverged() {
let front: Vec<u64> = state_request_delays().take(4).collect();
assert_eq!(front, STATE_REQUEST_RETRY_SECS, "front burst unchanged");
let tail: Vec<u64> = state_request_delays().skip(4).take(8).collect();
assert_eq!(
tail,
[30, 60, 120, 240, 300, 300, 300, 300],
"tail doubles to the cap, then holds it"
);
assert_eq!(
state_request_delays().nth(10_000),
Some(STATE_REQUEST_TAIL_CAP_SECS),
"the schedule is infinite — convergence, not the schedule, \
is what ends the requester"
);
}
#[tokio::test(start_paused = true)]
async fn single_incremental_delta_does_not_stop_recovery() {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let topic = "tasks-238-bait";
let joiner_list = TaskList::new(list_id(1), "Test List".to_string(), peer(2));
let joiner =
TaskListSync::new(joiner_list, Arc::clone(&pubsub), topic.to_string(), peer(2))
.expect("joiner sync");
joiner.start().await.expect("start joiner");
tokio::time::sleep(Duration::from_secs(70)).await;
let bait_task = make_task(2, peer(3));
let bait_id = *bait_task.id();
let mut bait = TaskListDelta::new(1);
bait.added_tasks.insert(bait_id, (bait_task, (peer(3), 1)));
let encoded = encode_delta(peer(3), &bait).expect("encode bait");
pubsub
.publish(topic.to_string(), bytes::Bytes::from(encoded))
.await
.expect("publish bait");
tokio::time::sleep(Duration::from_secs(30)).await;
assert_eq!(
joiner.read().await.task_count(),
1,
"bait delta merged — the false-convergence precondition holds"
);
let mut holder_list = TaskList::new(list_id(1), "Test List".to_string(), peer(1));
holder_list
.add_task(make_task(1, peer(1)), peer(1), 1)
.expect("holder task 1");
holder_list
.add_task(make_task(2, peer(1)), peer(1), 2)
.expect("holder task 2");
let holder =
TaskListSync::new(holder_list, Arc::clone(&pubsub), topic.to_string(), peer(1))
.expect("holder sync");
holder.start().await.expect("start holder");
let historical = TaskId::from_bytes([1; 32]);
let mut converged = false;
for _ in 0..200 {
tokio::time::sleep(Duration::from_secs(5)).await;
if joiner.read().await.get_task(&historical).is_some() {
converged = true;
break;
}
}
assert!(
converged,
"the requester must survive the bait delta and recover the \
historical task from the late holder"
);
}
#[tokio::test(start_paused = true)]
async fn requester_recovers_when_holder_returns_after_old_hard_cap() {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let topic = "tasks-238-zombie";
let joiner_list = TaskList::new(list_id(1), "Test List".to_string(), peer(2));
let joiner =
TaskListSync::new(joiner_list, Arc::clone(&pubsub), topic.to_string(), peer(2))
.expect("joiner sync");
joiner.start().await.expect("start joiner");
tokio::time::sleep(Duration::from_secs(700)).await;
assert_eq!(
joiner.read().await.task_count(),
0,
"nobody was online to answer"
);
let mut holder_list = TaskList::new(list_id(1), "Test List".to_string(), peer(1));
holder_list
.add_task(make_task(1, peer(1)), peer(1), 1)
.expect("add task");
let holder =
TaskListSync::new(holder_list, Arc::clone(&pubsub), topic.to_string(), peer(1))
.expect("holder sync");
holder.start().await.expect("start holder");
let mut converged = false;
for _ in 0..200 {
tokio::time::sleep(Duration::from_secs(5)).await;
if joiner.read().await.task_count() > 0 {
converged = true;
break;
}
}
assert!(
converged,
"the infinite tail must recover state from a holder that \
returns after the old hard cap (zombie subscription, issue #238)"
);
}
}