Skip to main content

ma_core/kubo/
pinning.rs

1//! Best-effort background cleanup for named local and remote pins.
2
3use std::{
4    collections::HashMap,
5    hash::{Hash, Hasher},
6    sync::{Arc, Mutex, OnceLock},
7};
8
9use tokio::{sync::mpsc, time::Duration};
10use tracing::{debug, warn};
11
12const DEFAULT_QUEUE_CAPACITY: usize = 64;
13const CLEANUP_YIELD: Duration = Duration::from_millis(100);
14
15/// Legacy suffix marking a pin that was not finalised by an older publisher.
16const IN_FLIGHT_SUFFIX: &str = "~new";
17
18/// The temporary name protecting a fresh pin while stale pins are removed.
19#[must_use]
20pub fn in_flight_pin_name(name: &str) -> String {
21    format!("{name}{IN_FLIGHT_SUFFIX}")
22}
23
24fn stale_pins(pins: Vec<String>, protected_cid: &str) -> Vec<String> {
25    pins.into_iter()
26        .filter(|cid| cid != protected_cid)
27        .collect()
28}
29
30/// A fully confirmed pin that must never be removed by a cleanup job.
31#[derive(Clone, Debug)]
32pub struct PinCleanupRequest {
33    pub kubo_url: String,
34    pub name: String,
35    pub protected_cid: String,
36    pub cleanup_local: bool,
37    pub remote_service: Option<String>,
38}
39
40#[derive(Clone, Debug, Eq)]
41struct PinCleanupKey {
42    kubo_url: String,
43    name: String,
44    cleanup_local: bool,
45    remote_service: Option<String>,
46}
47
48impl PartialEq for PinCleanupKey {
49    fn eq(&self, other: &Self) -> bool {
50        self.kubo_url == other.kubo_url
51            && self.name == other.name
52            && self.cleanup_local == other.cleanup_local
53            && self.remote_service == other.remote_service
54    }
55}
56
57impl Hash for PinCleanupKey {
58    fn hash<H: Hasher>(&self, state: &mut H) {
59        self.kubo_url.hash(state);
60        self.name.hash(state);
61        self.cleanup_local.hash(state);
62        self.remote_service.hash(state);
63    }
64}
65
66impl From<&PinCleanupRequest> for PinCleanupKey {
67    fn from(request: &PinCleanupRequest) -> Self {
68        Self {
69            kubo_url: request.kubo_url.clone(),
70            name: request.name.clone(),
71            cleanup_local: request.cleanup_local,
72            remote_service: request.remote_service.clone(),
73        }
74    }
75}
76
77/// Detached, bounded cleanup scheduler.
78///
79/// A full queue deliberately drops new cleanup work. The next publication of
80/// the same named pin will schedule another best-effort pass.
81#[derive(Clone, Debug)]
82pub struct PinCleanupScheduler {
83    pending: Arc<Mutex<HashMap<PinCleanupKey, PinCleanupRequest>>>,
84    wake: mpsc::Sender<PinCleanupKey>,
85}
86
87impl PinCleanupScheduler {
88    /// Returns the process-wide detached scheduler used by canonical publishers.
89    #[must_use]
90    pub fn global() -> &'static Self {
91        static SCHEDULER: OnceLock<PinCleanupScheduler> = OnceLock::new();
92        SCHEDULER.get_or_init(Self::new)
93    }
94
95    #[must_use]
96    pub fn new() -> Self {
97        Self::with_capacity(DEFAULT_QUEUE_CAPACITY)
98    }
99
100    #[must_use]
101    pub fn with_capacity(capacity: usize) -> Self {
102        let capacity = capacity.max(1);
103        let pending = Arc::new(Mutex::new(HashMap::new()));
104        let (wake, receiver) = mpsc::channel(capacity);
105        tokio::spawn(run_cleanup_worker(
106            Arc::clone(&pending),
107            wake.clone(),
108            receiver,
109        ));
110        Self { pending, wake }
111    }
112
113    /// Queue cleanup without awaiting Kubo or queue capacity.
114    ///
115    /// Returns `false` when the bounded queue is full or its worker has ended.
116    pub fn schedule(&self, request: PinCleanupRequest) -> bool {
117        let key = PinCleanupKey::from(&request);
118        let mut pending = self.pending.lock().expect("pin cleanup scheduler poisoned");
119        if let std::collections::hash_map::Entry::Occupied(mut entry) = pending.entry(key.clone()) {
120            entry.insert(request);
121            return true;
122        }
123
124        match self.wake.try_send(key.clone()) {
125            Ok(()) => {
126                pending.insert(key, request);
127                true
128            }
129            Err(error) => {
130                debug!(name = %request.name, error = %error, "dropping bounded pin cleanup job");
131                false
132            }
133        }
134    }
135}
136
137impl Default for PinCleanupScheduler {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143/// Schedule best-effort removal of all local recursive pins with this exact
144/// name, keeping only the current CID.
145///
146/// Pin the current CID under [`in_flight_pin_name`] first; the worker removes
147/// every stale pin under both names and only then renames the surviving pin
148/// to the requested name. An interrupted run leaves the in-flight pin intact,
149/// so the data stays protected and the unfinished state is visible.
150///
151/// The function returns immediately; the detached worker re-lists and removes
152/// until no stale pins remain. A full queue drops this pass and a later
153/// publication may schedule another.
154pub fn delete_local_pins_named_in_background(
155    kubo_url: impl Into<String>,
156    name: impl Into<String>,
157    protected_cid: impl Into<String>,
158) -> bool {
159    PinCleanupScheduler::global().schedule(PinCleanupRequest {
160        kubo_url: kubo_url.into(),
161        name: name.into(),
162        protected_cid: protected_cid.into(),
163        cleanup_local: true,
164        remote_service: None,
165    })
166}
167
168/// Schedule best-effort removal of all remote pins with this exact name,
169/// keeping only the current CID.
170///
171/// Also migrates pins left under the legacy in-flight name. Prefer
172/// [`remote_pin_replace_named`], which pins the current CID and schedules this
173/// cleanup in one call. This does not remove local pins.
174pub fn delete_remote_pins_named_in_background(
175    kubo_url: impl Into<String>,
176    service: impl Into<String>,
177    name: impl Into<String>,
178    protected_cid: impl Into<String>,
179) -> bool {
180    PinCleanupScheduler::global().schedule(PinCleanupRequest {
181        kubo_url: kubo_url.into(),
182        name: name.into(),
183        protected_cid: protected_cid.into(),
184        cleanup_local: false,
185        remote_service: Some(service.into()),
186    })
187}
188
189/// Pin `cid` on the remote service and schedule best-effort replacement of
190/// stale pins with this name.
191///
192/// With `overwrite` the fresh pin is added under the requested name before the
193/// background worker removes stale CIDs with that exact name. Returns whether
194/// cleanup was scheduled.
195pub async fn remote_pin_replace_named(
196    kubo_url: &str,
197    service: &str,
198    name: &str,
199    cid: &str,
200    overwrite: bool,
201) -> anyhow::Result<bool> {
202    crate::kubo::kubo::remote_pin_add_named(kubo_url, service, cid, name).await?;
203    Ok(overwrite && delete_remote_pins_named_in_background(kubo_url, service, name, cid))
204}
205
206async fn run_cleanup_worker(
207    pending: Arc<Mutex<HashMap<PinCleanupKey, PinCleanupRequest>>>,
208    wake: mpsc::Sender<PinCleanupKey>,
209    mut receiver: mpsc::Receiver<PinCleanupKey>,
210) {
211    while let Some(key) = receiver.recv().await {
212        let request = pending
213            .lock()
214            .expect("pin cleanup scheduler poisoned")
215            .remove(&key);
216        let Some(request) = request else {
217            continue;
218        };
219        if cleanup_one_batch(&request).await {
220            let should_requeue = {
221                let mut pending = pending.lock().expect("pin cleanup scheduler poisoned");
222                if pending.contains_key(&key) {
223                    false
224                } else {
225                    pending.insert(key.clone(), request);
226                    true
227                }
228            };
229            if should_requeue {
230                tokio::time::sleep(CLEANUP_YIELD).await;
231                if let Err(error) = wake.try_send(key) {
232                    debug!(error = %error, "dropping delayed pin cleanup batch");
233                }
234            }
235        }
236    }
237}
238
239async fn cleanup_one_batch(request: &PinCleanupRequest) -> bool {
240    let local_more = if request.cleanup_local {
241        cleanup_local_pass(request).await
242    } else {
243        false
244    };
245    let remote_more = if request.remote_service.is_some() {
246        cleanup_remote_pass(request).await
247    } else {
248        false
249    };
250    local_more || remote_more
251}
252
253async fn cleanup_local_pass(request: &PinCleanupRequest) -> bool {
254    let temp_name = in_flight_pin_name(&request.name);
255    let final_pins = match crate::kubo::kubo::list_named_recursive_pins(
256        &request.kubo_url,
257        &request.name,
258    )
259    .await
260    {
261        Ok(pins) => pins,
262        Err(error) => {
263            warn!(name = %request.name, error = %error, "local old-pin lookup failed");
264            return false;
265        }
266    };
267    let temp_pins =
268        match crate::kubo::kubo::list_named_recursive_pins(&request.kubo_url, &temp_name).await {
269            Ok(pins) => pins,
270            Err(error) => {
271                warn!(name = %temp_name, error = %error, "local in-flight pin lookup failed");
272                return false;
273            }
274        };
275
276    let mut all_pins = final_pins.clone();
277    all_pins.extend(temp_pins);
278    let stale = stale_pins(all_pins, &request.protected_cid);
279    if !stale.is_empty() {
280        let mut removed_any = false;
281        for cid in stale {
282            match crate::kubo::kubo::pin_rm(&request.kubo_url, &cid).await {
283                Ok(()) => removed_any = true,
284                Err(error) => {
285                    warn!(name = %request.name, cid = %cid, error = %error, "local old-pin cleanup failed");
286                }
287            }
288        }
289        // Re-list only while we make progress, so a broken Kubo cannot spin us.
290        return removed_any;
291    }
292
293    // Clean: finalise by renaming the in-flight pin to the requested name.
294    // On failure the in-flight pin still protects the data for a later pass.
295    if !final_pins.iter().any(|cid| cid == &request.protected_cid) {
296        if let Err(error) = crate::kubo::kubo::pin_add_named(
297            &request.kubo_url,
298            &request.protected_cid,
299            &request.name,
300        )
301        .await
302        {
303            warn!(name = %request.name, cid = %request.protected_cid, error = %error, "local pin finalisation failed");
304        }
305    }
306    false
307}
308
309async fn cleanup_remote_pass(request: &PinCleanupRequest) -> bool {
310    let Some(service) = request.remote_service.as_deref() else {
311        return false;
312    };
313    let temp_name = in_flight_pin_name(&request.name);
314    let final_pins = match crate::kubo::kubo::list_named_remote_pins(
315        &request.kubo_url,
316        service,
317        &request.name,
318    )
319    .await
320    {
321        Ok(pins) => pins,
322        Err(error) => {
323            warn!(name = %request.name, service, error = %error, "remote old-pin lookup failed");
324            return false;
325        }
326    };
327    let temp_pins = match crate::kubo::kubo::list_named_remote_pins(
328        &request.kubo_url,
329        service,
330        &temp_name,
331    )
332    .await
333    {
334        Ok(pins) => pins,
335        Err(error) => {
336            warn!(name = %temp_name, service, error = %error, "remote in-flight pin lookup failed");
337            return false;
338        }
339    };
340
341    let stale_final = stale_pins(final_pins.clone(), &request.protected_cid);
342    let stale_temp = stale_pins(temp_pins.clone(), &request.protected_cid);
343    if !stale_final.is_empty() || !stale_temp.is_empty() {
344        let mut removed_any = false;
345        for (cid, pin_name) in stale_final
346            .iter()
347            .map(|cid| (cid, request.name.as_str()))
348            .chain(stale_temp.iter().map(|cid| (cid, temp_name.as_str())))
349        {
350            match crate::kubo::kubo::remote_pin_rm_named(&request.kubo_url, service, cid, pin_name)
351                .await
352            {
353                Ok(()) => removed_any = true,
354                Err(error) => {
355                    warn!(name = %pin_name, service, cid = %cid, error = %error, "remote old-pin cleanup failed");
356                }
357            }
358        }
359        // Re-list only while we make progress, so a broken service cannot spin us.
360        return removed_any;
361    }
362
363    // Migrate pins stranded by the old in-flight protocol. Remote services
364    // commonly reject adding the same CID under a second name, so remove the
365    // temporary record before recreating it under the requested name.
366    if temp_pins.iter().any(|cid| cid == &request.protected_cid) {
367        if !final_pins.iter().any(|cid| cid == &request.protected_cid) {
368            if let Err(error) = crate::kubo::kubo::remote_pin_rm_named(
369                &request.kubo_url,
370                service,
371                &request.protected_cid,
372                &temp_name,
373            )
374            .await
375            {
376                warn!(name = %temp_name, service, cid = %request.protected_cid, error = %error, "legacy in-flight pin removal failed");
377                return false;
378            }
379            if let Err(error) = crate::kubo::kubo::remote_pin_add_named(
380                &request.kubo_url,
381                service,
382                &request.protected_cid,
383                &request.name,
384            )
385            .await
386            {
387                warn!(name = %request.name, service, cid = %request.protected_cid, error = %error, "legacy pin migration failed");
388            }
389        } else if let Err(error) = crate::kubo::kubo::remote_pin_rm_named(
390            &request.kubo_url,
391            service,
392            &request.protected_cid,
393            &temp_name,
394        )
395        .await
396        {
397            warn!(name = %temp_name, service, cid = %request.protected_cid, error = %error, "legacy in-flight pin removal failed");
398        }
399    }
400    false
401}
402
403#[cfg(test)]
404mod tests {
405    use super::stale_pins;
406
407    #[test]
408    fn stale_pins_protects_current_cid() {
409        let stale = stale_pins(
410            vec![
411                "old-a".to_string(),
412                "current".to_string(),
413                "old-b".to_string(),
414                "old-c".to_string(),
415            ],
416            "current",
417        );
418
419        assert_eq!(stale, ["old-a", "old-b", "old-c"]);
420    }
421
422    #[test]
423    fn stale_pins_returns_empty_when_only_current_remains() {
424        let stale = stale_pins(vec!["current".to_string()], "current");
425
426        assert!(stale.is_empty());
427    }
428}