1use 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
15const IN_FLIGHT_SUFFIX: &str = "~new";
18
19#[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#[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#[derive(Clone, Debug)]
83pub struct PinCleanupScheduler {
84 pending: Arc<Mutex<HashMap<PinCleanupKey, PinCleanupRequest>>>,
85 wake: mpsc::Sender<PinCleanupKey>,
86}
87
88impl PinCleanupScheduler {
89 #[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 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
144pub 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
169pub 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
191pub 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 return removed_any;
299 }
300
301 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 return removed_any;
369 }
370
371 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}