Skip to main content

saorsa_core/bootstrap/
cache.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: david@saorsalabs.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! Close group cache for persisting trusted peers across restarts.
15//!
16//! Stores the node's close group peers with their addresses and trust scores
17//! in a single JSON file. Loaded on startup to warm the routing table with
18//! trusted peers, preserving close group consistency across restarts.
19
20use crate::PeerId;
21use crate::adaptive::trust::TrustRecord;
22use crate::address::MultiAddr;
23use serde::{Deserialize, Serialize};
24use std::io::Write as _;
25use std::path::Path;
26
27/// Filename used for the close group cache inside the configured directory.
28const CACHE_FILENAME: &str = "close_group_cache.json";
29
30/// A peer in the persisted close group cache.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct CachedCloseGroupPeer {
33    /// Peer identity
34    pub peer_id: PeerId,
35    /// Known addresses for this peer
36    pub addresses: Vec<MultiAddr>,
37    /// Trust score at time of save
38    pub trust: TrustRecord,
39}
40
41/// Persisted close group snapshot with trust scores.
42///
43/// Saved periodically and on shutdown. Loaded on startup to reconnect
44/// to the same trusted close group peers, preserving close group
45/// consistency across restarts.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct CloseGroupCache {
48    /// Close group peers with their trust scores
49    pub peers: Vec<CachedCloseGroupPeer>,
50    /// When this snapshot was saved (seconds since UNIX epoch)
51    pub saved_at_epoch_secs: u64,
52}
53
54impl CloseGroupCache {
55    /// Save the cache to `{dir}/close_group_cache.json`.
56    ///
57    /// Uses [`tempfile::NamedTempFile::persist`] for atomicity: the temp file
58    /// has a unique name (safe under concurrent saves) and `persist` is an
59    /// atomic rename on Unix and a replace-then-rename on Windows.
60    pub async fn save_to_dir(&self, dir: &Path) -> anyhow::Result<()> {
61        // Ensure the directory exists (first run or after cache dir deletion).
62        tokio::fs::create_dir_all(dir).await.map_err(|e| {
63            anyhow::anyhow!(
64                "failed to create close group cache directory {}: {e}",
65                dir.display()
66            )
67        })?;
68
69        let path = dir.join(CACHE_FILENAME);
70        let json = serde_json::to_string_pretty(self)
71            .map_err(|e| anyhow::anyhow!("failed to serialize close group cache: {e}"))?;
72
73        // Spawn blocking because NamedTempFile I/O is synchronous.
74        let dir_owned = dir.to_path_buf();
75        tokio::task::spawn_blocking(move || {
76            let mut tmp = tempfile::NamedTempFile::new_in(&dir_owned).map_err(|e| {
77                anyhow::anyhow!("failed to create temp file in {}: {e}", dir_owned.display())
78            })?;
79            tmp.write_all(json.as_bytes())
80                .map_err(|e| anyhow::anyhow!("failed to write close group cache: {e}"))?;
81            tmp.persist(&path).map_err(|e| {
82                anyhow::anyhow!(
83                    "failed to persist close group cache to {}: {e}",
84                    path.display()
85                )
86            })?;
87            Ok(())
88        })
89        .await
90        .map_err(|e| anyhow::anyhow!("close group cache save task panicked: {e}"))?
91    }
92
93    /// Load the cache from `{dir}/close_group_cache.json`.
94    ///
95    /// Returns `None` if the file doesn't exist (fresh start).
96    pub async fn load_from_dir(dir: &Path) -> anyhow::Result<Option<Self>> {
97        let path = dir.join(CACHE_FILENAME);
98        match tokio::fs::read_to_string(&path).await {
99            Ok(json) => {
100                let cache: Self = serde_json::from_str(&json)
101                    .map_err(|e| anyhow::anyhow!("failed to deserialize close group cache: {e}"))?;
102                Ok(Some(cache))
103            }
104            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
105            Err(e) => Err(anyhow::anyhow!(
106                "failed to read close group cache from {}: {e}",
107                path.display()
108            )),
109        }
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::adaptive::trust::TrustRecord;
117
118    #[tokio::test]
119    async fn test_save_load_roundtrip() {
120        let cache = CloseGroupCache {
121            peers: vec![
122                CachedCloseGroupPeer {
123                    peer_id: PeerId::random(),
124                    addresses: vec!["/ip4/10.0.1.1/udp/9000/quic".parse().unwrap()],
125                    trust: TrustRecord {
126                        score: 0.8,
127                        last_updated_epoch_secs: 1_234_567_890,
128                    },
129                },
130                CachedCloseGroupPeer {
131                    peer_id: PeerId::random(),
132                    addresses: vec!["/ip4/10.0.2.1/udp/9000/quic".parse().unwrap()],
133                    trust: TrustRecord {
134                        score: 0.6,
135                        last_updated_epoch_secs: 1_234_567_890,
136                    },
137                },
138            ],
139            saved_at_epoch_secs: 1_234_567_890,
140        };
141
142        let dir = tempfile::tempdir().unwrap();
143
144        cache.save_to_dir(dir.path()).await.unwrap();
145        let loaded = CloseGroupCache::load_from_dir(dir.path())
146            .await
147            .unwrap()
148            .unwrap();
149
150        assert_eq!(loaded.peers.len(), 2);
151        assert_eq!(loaded.peers[0].peer_id, cache.peers[0].peer_id);
152        assert!((loaded.peers[0].trust.score - 0.8).abs() < f64::EPSILON);
153        assert_eq!(loaded.saved_at_epoch_secs, 1_234_567_890);
154    }
155
156    #[tokio::test]
157    async fn test_load_nonexistent_returns_none() {
158        let dir = tempfile::tempdir().unwrap();
159        let result = CloseGroupCache::load_from_dir(dir.path()).await.unwrap();
160        assert!(result.is_none());
161    }
162
163    #[tokio::test]
164    async fn test_empty_cache() {
165        let cache = CloseGroupCache {
166            peers: vec![],
167            saved_at_epoch_secs: 0,
168        };
169
170        let dir = tempfile::tempdir().unwrap();
171
172        cache.save_to_dir(dir.path()).await.unwrap();
173        let loaded = CloseGroupCache::load_from_dir(dir.path())
174            .await
175            .unwrap()
176            .unwrap();
177        assert!(loaded.peers.is_empty());
178    }
179}