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";
17
18#[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#[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#[derive(Clone, Debug)]
82pub struct PinCleanupScheduler {
83 pending: Arc<Mutex<HashMap<PinCleanupKey, PinCleanupRequest>>>,
84 wake: mpsc::Sender<PinCleanupKey>,
85}
86
87impl PinCleanupScheduler {
88 #[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 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
143pub 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
168pub 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
189pub 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 return removed_any;
291 }
292
293 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 return removed_any;
361 }
362
363 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}