use crate::gossip::wire::{decode_delta, encode_delta};
use crate::gossip::PubSubManager;
use crate::identity::AgentId;
use crate::kv::store::AccessPolicy;
use crate::kv::{KvStore, KvStoreDelta, Result};
use saorsa_gossip_types::PeerId;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
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];
#[derive(Debug, Serialize, Deserialize)]
enum KvSyncMessage {
StateRequest { requester: PeerId },
OwnerAnnounce {
owner: AgentId,
policy: AccessPolicy,
policy_version: u64,
},
}
pub struct KvStoreSync {
store: Arc<RwLock<KvStore>>,
pubsub: Arc<PubSubManager>,
topic: String,
local_peer_id: PeerId,
local_agent_id: Option<AgentId>,
persist: std::sync::Mutex<Option<Arc<PersistCtx>>>,
}
struct PersistCtx {
path: PathBuf,
gate: tokio::sync::Mutex<Option<u64>>,
degraded: std::sync::atomic::AtomicBool,
}
impl KvStoreSync {
pub fn new(
store: KvStore,
pubsub: Arc<PubSubManager>,
topic: String,
local_peer_id: PeerId,
local_agent_id: Option<AgentId>,
) -> Result<Self> {
let store = Arc::new(RwLock::new(store));
Ok(Self {
store,
pubsub,
topic,
local_peer_id,
local_agent_id,
persist: std::sync::Mutex::new(None),
})
}
pub fn set_persist_path(&self, path: PathBuf) {
if let Ok(mut guard) = self.persist.lock() {
*guard = Some(Arc::new(PersistCtx {
path,
gate: tokio::sync::Mutex::new(None),
degraded: std::sync::atomic::AtomicBool::new(false),
}));
}
}
fn persist_ctx(&self) -> Option<Arc<PersistCtx>> {
self.persist.lock().ok().and_then(|g| g.clone())
}
pub async fn persist(&self) -> Result<()> {
match self.persist_ctx() {
Some(ctx) => persist_snapshot(&self.store, &ctx).await,
None => Ok(()),
}
}
pub fn durability_degraded(&self) -> bool {
self.persist_ctx()
.is_some_and(|c| c.degraded.load(std::sync::atomic::Ordering::Relaxed))
}
pub async fn ensure_durable(&self) -> Result<()> {
match self.persist_ctx() {
Some(ctx) if ctx.degraded.load(std::sync::atomic::Ordering::Relaxed) => {
persist_snapshot(&self.store, &ctx).await
}
_ => Ok(()),
}
}
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 store = Arc::clone(&self.store);
let bootstrap_needed = store.read().await.is_empty();
let main_topic = self.topic.clone();
let persist_ctx = self.persist_ctx();
let loop_persist_ctx = persist_ctx.clone();
spawn(Box::pin(async move {
while let Some(msg) = sub.recv().await {
if msg.topic != main_topic {
continue;
}
let decoded = decode_delta::<KvStoreDelta>(&msg.payload);
match decoded {
Ok((peer_id, delta)) => {
let merged = {
let mut s = store.write().await;
let writer = msg.sender.as_ref();
match s.merge_delta(&delta, peer_id, writer) {
Ok(()) => true,
Err(e) => {
tracing::warn!("Failed to merge KvStore delta: {e}");
false
}
}
};
if merged {
if let Some(ctx) = loop_persist_ctx.as_ref() {
let _ = persist_snapshot(&store, ctx).await;
}
}
}
Err(e) => {
tracing::warn!("Failed to deserialize KvStore delta: {e}");
}
}
}
}));
let mut sync_sub = self.pubsub.subscribe(self.state_sync_topic()).await;
let responder_store = Arc::clone(&self.store);
let responder_persist_ctx = persist_ctx.clone();
let responder_pubsub = Arc::clone(&self.pubsub);
let responder_topic = self.topic.clone();
let sync_topic = self.state_sync_topic();
let local_peer_id = self.local_peer_id;
let local_agent_id = self.local_agent_id;
spawn(Box::pin(async move {
while let Some(msg) = sync_sub.recv().await {
if msg.topic != sync_topic {
continue;
}
let Ok(sync_msg) = bincode::deserialize::<KvSyncMessage>(&msg.payload) else {
continue;
};
match sync_msg {
KvSyncMessage::StateRequest { requester } => {
if requester == local_peer_id {
continue;
}
let announce = {
let s = responder_store.read().await;
match (local_agent_id, s.owner()) {
(Some(me), Some(owner)) if me == *owner => {
Some(KvSyncMessage::OwnerAnnounce {
owner: me,
policy: s.policy().clone(),
policy_version: s.policy_version(),
})
}
_ => None,
}
};
if let Some(announce) = announce {
match bincode::serialize(&announce) {
Ok(serialized) => {
if let Err(e) = responder_pubsub
.publish(sync_topic.clone(), bytes::Bytes::from(serialized))
.await
{
tracing::warn!(
"KvStore owner-announce publish failed: {e}"
);
}
}
Err(e) => {
tracing::warn!("KvStore owner-announce serialize failed: {e}");
}
}
}
let full = {
let s = responder_store.read().await;
if s.is_empty() {
continue;
}
s.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!("KvStore state-response publish failed: {e}");
}
}
KvSyncMessage::OwnerAnnounce {
owner,
policy,
policy_version,
} => {
let Some(sender) = msg.sender else {
tracing::warn!(
"ignoring unsigned KvStore ownership announcement on {}",
msg.topic
);
continue;
};
if local_agent_id.is_some_and(|me| me == sender) {
continue; }
let learned = {
let mut s = responder_store.write().await;
match s.learn_ownership(owner, policy, policy_version, &sender) {
Ok(()) => {
tracing::info!(
"KvStore {} processed owner announce from {} (policy {}, version {})",
s.id(),
hex::encode(owner.as_bytes()),
s.policy(),
s.policy_version()
);
true
}
Err(e) => {
tracing::warn!(
"rejected KvStore ownership announcement from {}: {e}",
hex::encode(sender.as_bytes())
);
false
}
}
};
if learned {
if let Some(ctx) = responder_persist_ctx.as_ref() {
let _ = persist_snapshot(&responder_store, ctx).await;
}
}
}
}
}
}));
if bootstrap_needed {
let requester_pubsub = Arc::clone(&self.pubsub);
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;
let request = KvSyncMessage::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!("KvStore 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 publish_delta(&self, local_peer_id: PeerId, delta: KvStoreDelta) -> Result<()> {
let serialized = encode_delta(local_peer_id, &delta)
.map_err(|e| crate::kv::KvError::Gossip(format!("serialize delta failed: {e}")))?;
self.pubsub
.publish(self.topic.clone(), bytes::Bytes::from(serialized))
.await
.map_err(|e| crate::kv::KvError::Gossip(format!("publish delta failed: {e}")))?;
Ok(())
}
pub async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, KvStore> {
self.store.read().await
}
pub async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, KvStore> {
self.store.write().await
}
#[must_use]
pub fn topic(&self) -> &str {
&self.topic
}
}
static SNAPSHOT_TMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
const SNAPSHOT_MAGIC: &[u8; 8] = b"X0XKVS1\0";
#[derive(Deserialize)]
struct SnapshotBody {
store: KvStore,
seq_counter: u64,
}
#[derive(Serialize)]
struct SnapshotBodyRef<'a> {
store: &'a KvStore,
seq_counter: u64,
}
fn encode_snapshot(store: &KvStore) -> Result<Vec<u8>> {
let body = SnapshotBodyRef {
store,
seq_counter: store.seq_counter_value(),
};
let mut out = Vec::with_capacity(256);
out.extend_from_slice(SNAPSHOT_MAGIC);
out.extend_from_slice(&bincode::serialize(&body)?);
Ok(out)
}
async fn persist_snapshot(store: &Arc<RwLock<KvStore>>, ctx: &PersistCtx) -> Result<()> {
let result = async {
let mut last = ctx.gate.lock().await;
let (version, bytes) = {
let s = store.read().await;
(s.current_version(), encode_snapshot(&s)?)
};
if last.is_some_and(|l| l >= version) {
return Ok(());
}
write_snapshot_atomic(&ctx.path, &bytes)?;
*last = Some(version);
Ok(())
}
.await;
ctx.degraded
.store(result.is_err(), std::sync::atomic::Ordering::Relaxed);
if let Err(e) = &result {
tracing::error!(
"kv snapshot persist failed for {}: {e} — store is durability-degraded; \
local writes are refused until a snapshot succeeds",
ctx.path.display()
);
}
result
}
fn write_snapshot_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let n = SNAPSHOT_TMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
{
use std::io::Write;
let mut f = std::fs::File::create(&tmp)?;
f.write_all(bytes)?;
f.sync_all()?;
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
#[cfg(unix)]
if let Some(parent) = path.parent() {
std::fs::File::open(parent)?.sync_all()?;
}
Ok(())
}
pub fn load_snapshot(path: &Path) -> Result<Option<KvStore>> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let Some(body_bytes) = bytes.strip_prefix(SNAPSHOT_MAGIC.as_slice()) else {
return Err(std::io::Error::other(
"unrecognized kv snapshot format (missing v1 magic) — corrupt or foreign file; \
refusing to start with amnesia",
)
.into());
};
let body: SnapshotBody = bincode::deserialize(body_bytes)?;
let store = body.store;
store.restore_seq_counter(body.seq_counter.max(store.current_version()));
Ok(Some(store))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::AgentId;
use crate::kv::store::AccessPolicy;
use crate::kv::{KvEntry, KvStoreId};
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 store_id(n: u8) -> KvStoreId {
KvStoreId::new([n; 32])
}
#[test]
fn snapshot_roundtrip_missing_and_corrupt() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("kv").join("snap.bin");
assert!(
matches!(load_snapshot(&path), Ok(None)),
"missing snapshot is a clean first run"
);
let mut store = KvStore::new(
store_id(7),
"log".to_string(),
agent(1),
AccessPolicy::AppendOnly,
);
store
.put(
"k1".to_string(),
b"v1".to_vec(),
"text/plain".to_string(),
peer(1),
)
.expect("put");
store.highest_checkpoint_seq = 5;
let _ = store.next_seq();
let _ = store.next_seq();
let counter_before = store.seq_counter_value();
let bytes = encode_snapshot(&store).expect("encode");
write_snapshot_atomic(&path, &bytes).expect("atomic write");
let restored = load_snapshot(&path)
.expect("load ok")
.expect("snapshot present");
assert_eq!(*restored.policy(), AccessPolicy::AppendOnly);
assert_eq!(
restored.get("k1").map(|e| e.value.clone()),
Some(b"v1".to_vec())
);
assert_eq!(restored.highest_checkpoint_seq, 5);
assert!(
restored.next_seq() > counter_before,
"restored seq counter must exceed every pre-restart seq"
);
std::fs::write(&path, bincode::serialize(&store).expect("serialize")).expect("write bare");
assert!(
load_snapshot(&path).is_err(),
"missing-magic snapshot must be an error (fail closed)"
);
std::fs::write(&path, b"not a snapshot").expect("corrupt");
assert!(
load_snapshot(&path).is_err(),
"corrupt snapshot must be an error (fail closed), not a silent fresh start"
);
let mut evil = SNAPSHOT_MAGIC.to_vec();
evil.extend_from_slice(b"\x01\x02\x03");
std::fs::write(&path, evil).expect("write garbage body");
assert!(
load_snapshot(&path).is_err(),
"garbage body must be an error (fail closed)"
);
}
async fn make_node() -> Arc<NetworkNode> {
Arc::new(
NetworkNode::new(NetworkConfig::default(), None, None)
.await
.expect("network node"),
)
}
async fn make_sync(topic: &str, policy: AccessPolicy) -> KvStoreSync {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let store = KvStore::new(store_id(1), "Test".to_string(), agent(1), policy);
KvStoreSync::new(store, pubsub, topic.to_string(), peer(1), Some(agent(1)))
.expect("kv sync")
}
async fn make_sync_with_pubsub(
topic: &str,
policy: AccessPolicy,
) -> (KvStoreSync, Arc<PubSubManager>) {
let node = make_node().await;
let pubsub = Arc::new(PubSubManager::new(node, None).expect("pubsub"));
let store = KvStore::new(store_id(1), "Test".to_string(), agent(1), policy);
let sync = KvStoreSync::new(
store,
Arc::clone(&pubsub),
topic.to_string(),
peer(1),
Some(agent(1)),
)
.expect("kv sync");
(sync, pubsub)
}
#[tokio::test]
async fn test_kv_store_sync_creation() {
let owner = agent(1);
let store = KvStore::new(store_id(1), "Test".to_string(), owner, AccessPolicy::Signed);
let _store_for_sync = store;
}
#[tokio::test]
async fn test_apply_delta_directly() {
let owner = agent(1);
let writer = agent(2);
let p2 = peer(2);
let mut store = KvStore::new(
store_id(1),
"Test".to_string(),
owner,
AccessPolicy::Allowlisted,
);
store.allow_writer(writer, &owner).expect("allow");
let store_arc = Arc::new(RwLock::new(store));
let entry = KvEntry::new(
"newkey".to_string(),
b"value".to_vec(),
"text/plain".to_string(),
);
let mut delta = KvStoreDelta::new(1);
delta.added.insert("newkey".to_string(), (entry, (p2, 1)));
{
let mut s = store_arc.write().await;
s.merge_delta(&delta, p2, Some(&writer)).expect("merge");
}
{
let s = store_arc.read().await;
assert!(s.get("newkey").is_some());
}
}
#[tokio::test]
async fn test_concurrent_reads() {
let owner = agent(1);
let store = KvStore::new(store_id(1), "Test".to_string(), owner, AccessPolicy::Signed);
let store_arc = Arc::new(RwLock::new(store));
let s1 = store_arc.read().await;
let s2 = store_arc.read().await;
assert_eq!(s1.name(), "Test");
assert_eq!(s2.name(), "Test");
}
#[tokio::test]
async fn new_sets_topic_and_yields_accessible_guards() {
let sync = make_sync("store/A", AccessPolicy::Signed).await;
assert_eq!(sync.topic(), "store/A");
{
let s = sync.read().await;
assert_eq!(s.name(), "Test");
assert!(s.is_empty());
}
let owner = agent(1);
let entry = KvEntry::new(
"owner-key".to_string(),
b"v".to_vec(),
"text/plain".to_string(),
);
let mut delta = KvStoreDelta::new(1);
delta
.added
.insert("owner-key".to_string(), (entry, (peer(1), 1)));
{
let mut s = sync.write().await;
s.merge_delta(&delta, peer(1), Some(&owner))
.expect("owner merge");
}
let s = sync.read().await;
assert!(s.get("owner-key").is_some(), "owner write must be visible");
}
#[tokio::test]
async fn state_sync_topic_appends_side_channel_suffix() {
let sync = make_sync("store/B", AccessPolicy::Signed).await;
assert_eq!(sync.state_sync_topic(), "store/B/state-sync");
let sync2 = make_sync("store/B/nested", AccessPolicy::Signed).await;
assert_eq!(sync2.state_sync_topic(), "store/B/nested/state-sync");
}
#[tokio::test]
async fn publish_delta_delivers_encoded_pair_to_subscriber() {
let (sync, pubsub) = make_sync_with_pubsub("store/C", AccessPolicy::Signed).await;
let mut sub = pubsub.subscribe("store/C".to_string()).await;
let sender = peer(7);
let entry = KvEntry::new(
"remote".to_string(),
b"payload".to_vec(),
"application/octet-stream".to_string(),
);
let mut delta = KvStoreDelta::new(9);
delta
.added
.insert("remote".to_string(), (entry, (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::<KvStoreDelta>(&msg.payload).expect("wire decode");
assert_eq!(observed_sender, sender);
assert_eq!(observed_delta.version, 9);
assert!(observed_delta.added.contains_key("remote"));
assert_eq!(msg.topic, "store/C");
let reencoded = encode_delta(sender, &observed_delta).expect("re-encode");
let (s2, d2) = decode_delta::<KvStoreDelta>(&reencoded).expect("re-decode");
assert_eq!(s2, sender);
assert_eq!(d2.version, 9);
}
#[tokio::test]
async fn start_with_spawner_subscribes_and_returns_ok() {
let sync = make_sync("store/D", AccessPolicy::Signed).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(
"store/E",
AccessPolicy::Encrypted {
group_id: vec![1, 2, 3],
},
)
.await;
sync.start().await.expect("start");
tokio::time::sleep(Duration::from_millis(100)).await;
let entry = KvEntry::new(
"merged-key".to_string(),
b"hello".to_vec(),
"text/plain".to_string(),
);
let mut delta = KvStoreDelta::new(1);
delta
.added
.insert("merged-key".to_string(), (entry, (peer(2), 1)));
sync.publish_delta(peer(2), delta).await.expect("publish");
let landed = tokio::time::timeout(Duration::from_secs(2), async {
loop {
let present = {
let s = sync.read().await;
s.get("merged-key").is_some()
};
if present {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await;
assert!(
landed.is_ok(),
"remote delta was not merged by start() loop"
);
}
#[tokio::test]
async fn stop_returns_ok_and_is_idempotent() {
let sync = make_sync("store/F", AccessPolicy::Signed).await;
sync.stop().await.expect("first stop");
sync.stop().await.expect("second stop (idempotent)");
}
}