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