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_SECS: u64 = 30;
const STATE_REQUEST_MAX_TAIL_ATTEMPTS: usize = 20;
#[derive(Debug, Serialize, Deserialize)]
enum TaskListSyncMessage {
StateRequest { requester: PeerId },
}
pub struct TaskListSync {
task_list: Arc<RwLock<TaskList>>,
pubsub: Arc<PubSubManager>,
topic: String,
local_peer_id: PeerId,
}
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,
})
}
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);
spawn(Box::pin(async move {
while let Some(msg) = sub.recv().await {
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 local_peer_id = self.local_peer_id;
spawn(Box::pin(async move {
while let Some(msg) = sync_sub.recv().await {
let Ok(TaskListSyncMessage::StateRequest { requester }) =
bincode::deserialize::<TaskListSyncMessage>(&msg.payload)
else {
continue;
};
if requester == local_peer_id {
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}");
}
}
}));
if self.task_list.read().await.task_count() == 0 {
let requester_pubsub = Arc::clone(&self.pubsub);
let requester_list = Arc::clone(&self.task_list);
let sync_topic = self.state_sync_topic();
spawn(Box::pin(async move {
for delay_secs in STATE_REQUEST_RETRY_SECS {
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
if requester_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}");
}
}
for _ in 0..STATE_REQUEST_MAX_TAIL_ATTEMPTS {
tokio::time::sleep(std::time::Duration::from_secs(STATE_REQUEST_TAIL_SECS))
.await;
if requester_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.pubsub.unsubscribe(&self.topic).await;
self.pubsub.unsubscribe(&self.state_sync_topic()).await;
Ok(())
}
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)");
}
}