vector_core/
negentropy.rs1use std::collections::HashSet;
8use std::time::Duration;
9
10use nostr_sdk::prelude::*;
11
12const NEG_CAP_TTL_SECS: u64 = 24 * 3600;
24
25fn cap_key(relay_url: &str) -> String {
26 format!("neg_cap:{}", relay_url.trim_end_matches('/'))
27}
28
29fn now_secs() -> u64 {
30 std::time::SystemTime::now()
31 .duration_since(std::time::UNIX_EPOCH)
32 .map(|d| d.as_secs())
33 .unwrap_or(0)
34}
35
36pub fn neg_supported_cached(relay_url: &str) -> Option<bool> {
39 let raw = crate::db::get_sql_setting(cap_key(relay_url)).ok()??;
40 let (supported, checked_at) = parse_cap_entry(&raw)?;
41 (now_secs().saturating_sub(checked_at) < NEG_CAP_TTL_SECS).then_some(supported)
42}
43
44pub fn record_neg_support(relay_url: &str, supported: bool) {
48 let _ = crate::db::set_sql_setting(
49 cap_key(relay_url),
50 format!("{}:{}", u8::from(supported), now_secs()),
51 );
52}
53
54fn parse_cap_entry(raw: &str) -> Option<(bool, u64)> {
55 let (flag, ts) = raw.split_once(':')?;
56 let supported = match flag {
57 "1" => true,
58 "0" => false,
59 _ => return None,
60 };
61 Some((supported, ts.parse().ok()?))
62}
63
64pub async fn wait_connected(relay: &Relay, allowance: Duration) -> bool {
76 let deadline = tokio::time::Instant::now() + allowance;
77 loop {
78 match relay.status() {
79 RelayStatus::Connected => return true,
80 RelayStatus::Terminated | RelayStatus::Banned => return false,
81 _ => {}
82 }
83 if tokio::time::Instant::now() >= deadline {
84 return false;
85 }
86 tokio::time::sleep(Duration::from_millis(150)).await;
87 }
88}
89
90fn cursor_key(relay_url: &str) -> String {
104 format!("neg_cursor:{}", relay_url.trim_end_matches('/'))
105}
106
107pub fn reconcile_cursor(relay_url: &str) -> Option<u64> {
109 crate::db::get_sql_setting(cursor_key(relay_url)).ok()??.parse().ok()
110}
111
112pub fn advance_reconcile_cursor(relay_url: &str, anchor_secs: u64, session: &crate::state::SessionGuard) {
118 if !session.is_valid() {
119 return;
120 }
121 let _ = crate::db::advance_u64_setting(cursor_key(relay_url), anchor_secs);
122}
123
124pub fn is_transient_sync_error(err: &str) -> bool {
130 err == "timeout"
131 || err.contains("not connected")
132 || err.contains("transport dispatcher")
133 || err.contains("lagged")
136}
137
138pub fn classify_neg_sync_error(err: &str, relay_was_connected: bool) -> Option<bool> {
139 if err.contains("negentropy not supported")
140 || err.contains("unknown negentropy error")
141 || (err.contains("negentropy") && err.contains("protocol version"))
142 {
143 return Some(false);
144 }
145 if err == "timeout" && relay_was_connected {
146 return Some(false);
147 }
148 None
149}
150
151pub async fn reconcile_missing(
163 filter: Filter,
164 local_items: Vec<(EventId, Timestamp)>,
165 timeout: Duration,
166) -> Result<HashSet<EventId>, String> {
167 use futures_util::stream::{FuturesUnordered, StreamExt};
168
169 let client = crate::state::nostr_client().ok_or("Nostr client not initialized")?;
170
171 let opts = SyncOptions::new()
172 .direction(SyncDirection::Down)
173 .initial_timeout(timeout)
174 .dry_run();
175
176 let relay_map = client.relays().await;
179 let trusted = crate::state::active_trusted_relays().await;
180 let relays: Vec<(String, Relay)> = trusted.iter().filter_map(|url| {
181 if neg_supported_cached(url) == Some(false) {
182 crate::log_debug!("[Negentropy] {} skipped (cached: no NIP-77)", url);
183 return None;
184 }
185 let normalized = url.trim_end_matches('/');
186 relay_map.iter()
187 .find(|(u, _)| u.as_str().trim_end_matches('/') == normalized)
188 .map(|(_, r)| (url.to_string(), r.clone()))
189 }).collect();
190 drop(relay_map);
191
192 if relays.is_empty() {
193 crate::log_warn!("[Negentropy] No trusted relays available for reconciliation");
194 return Ok(HashSet::new());
195 }
196
197 let connect_allowance = crate::relay_request_timeout(Duration::from_secs(3)).min(timeout);
198 let mut futs = FuturesUnordered::new();
199 for (url, relay) in &relays {
200 let url = url.clone();
201 let relay = relay.clone();
202 let f = filter.clone();
203 let items = local_items.clone();
204 let o = opts.clone();
205 futs.push(async move {
206 if !wait_connected(&relay, connect_allowance).await {
207 return (url, None, false);
208 }
209 let r = tokio::time::timeout(timeout, relay.sync(f).items(items).opts(o)).await;
210 let connected = relay.status() == RelayStatus::Connected;
211 (url, Some(r), connected)
212 });
213 }
214
215 let session = crate::state::SessionGuard::capture();
216 let mut missing: HashSet<EventId> = HashSet::new();
217 while let Some((url, result, connected)) = futs.next().await {
218 let Some(result) = result else {
219 crate::log_debug!("[Negentropy] {} skipped: not connected", url);
220 continue;
221 };
222 match result {
223 Ok(Ok(recon)) => {
224 let n = recon.remote.len();
225 missing.extend(recon.remote);
226 crate::log_debug!("[Negentropy] {} reconciled: {} missing", url, n);
227 if session.is_valid() {
228 record_neg_support(&url, true);
229 }
230 }
231 Ok(Err(e)) => {
232 crate::log_warn!("[Negentropy] {} failed: {}", url, e);
233 if session.is_valid()
234 && classify_neg_sync_error(&e.to_string(), connected) == Some(false)
235 {
236 crate::log_info!("[Negentropy] {} marked no-NIP-77 for 24h", url);
237 record_neg_support(&url, false);
238 }
239 }
240 Err(_) => crate::log_warn!("[Negentropy] {} timed out", url),
241 }
242 }
243
244 Ok(missing)
245}
246
247#[cfg(test)]
248mod cap_tests {
249 use super::*;
250
251 #[test]
252 fn classify_detects_deterministic_refusals_regardless_of_connection() {
253 for err in [
254 "negentropy not supported",
255 "unknown negentropy error",
256 "negentropy: unsupported protocol version",
257 ] {
258 assert_eq!(classify_neg_sync_error(err, true), Some(false), "{err}");
259 assert_eq!(classify_neg_sync_error(err, false), Some(false), "{err}");
260 }
261 }
262
263 #[test]
264 fn classify_timeout_only_counts_when_connected() {
265 assert_eq!(classify_neg_sync_error("timeout", true), Some(false));
266 assert_eq!(classify_neg_sync_error("timeout", false), None);
267 }
268
269 #[test]
270 fn classify_ignores_unrelated_errors() {
271 for err in [
272 "auth-required: we can't serve DMs to unauthenticated users",
273 "relay message too large: size=200000, max_size=131072",
274 "not connected",
275 "timeout exceeded", ] {
277 assert_eq!(classify_neg_sync_error(err, true), None, "{err}");
278 }
279 }
280
281 #[test]
282 fn transient_errors_never_skip_the_archive() {
283 assert!(is_transient_sync_error("timeout"));
284 assert!(is_transient_sync_error("relay not connected"));
285 assert!(is_transient_sync_error("not connected"));
286 assert!(is_transient_sync_error("can't send message to the transport dispatcher"));
287 assert!(is_transient_sync_error("channel lagged by 558"));
288 assert!(!is_transient_sync_error("negentropy not supported"));
289 assert!(!is_transient_sync_error("unknown negentropy error"));
290 assert!(!is_transient_sync_error("blocked: sync too big"));
291 }
292
293 #[test]
294 fn cap_entry_parses_and_rejects() {
295 assert_eq!(parse_cap_entry("1:1753900000"), Some((true, 1753900000)));
296 assert_eq!(parse_cap_entry("0:42"), Some((false, 42)));
297 assert_eq!(parse_cap_entry("2:42"), None);
298 assert_eq!(parse_cap_entry("1:"), None);
299 assert_eq!(parse_cap_entry("1"), None);
300 assert_eq!(parse_cap_entry("nonsense"), None);
301 assert_eq!(parse_cap_entry(""), None);
302 }
303
304 #[test]
305 fn cap_key_normalizes_trailing_slash() {
306 assert_eq!(cap_key("wss://r.example/"), cap_key("wss://r.example"));
307 assert_eq!(cursor_key("wss://r.example/"), cursor_key("wss://r.example"));
308 }
309
310}