1#![allow(dead_code)]
2
3use std::collections::{HashMap, HashSet};
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::{LazyLock, Mutex};
8use std::time::{Duration, SystemTime};
9
10use anyhow::{Result, bail};
11use chrono::{DateTime, SecondsFormat, Utc};
12use sqlx::{Row, SqliteConnection};
13
14use crate::attachments::storage::object_path;
15use crate::attachments::validation::validate_sha256;
16use crate::db::{begin_immediate, get_meta, set_meta};
17use crate::ids::new_id;
18
19pub const DEFAULT_LOCAL_GRACE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
20pub const DEFAULT_ORIGINAL_QUOTA_BYTES: i64 = 10 * 1024 * 1024 * 1024;
21pub const DEFAULT_PREVIEW_QUOTA_BYTES: u64 = 512 * 1024 * 1024;
22pub const DEFAULT_MAINTENANCE_LIMIT: usize = 128;
23const LEASE_TTL: Duration = Duration::from_secs(10 * 60);
24const LIVENESS_CURSOR_META_KEY: &str = "attachment_liveness_cursor";
25
26pub trait Clock: Send + Sync {
27 fn now(&self) -> DateTime<Utc>;
28}
29
30pub struct SystemClock;
31
32impl Clock for SystemClock {
33 fn now(&self) -> DateTime<Utc> {
34 Utc::now()
35 }
36}
37
38#[derive(Debug, Clone, Copy)]
39pub struct LifecyclePolicy {
40 pub grace: Duration,
41 pub quota_bytes: i64,
42 pub preview_quota_bytes: u64,
43 pub maintenance_limit: usize,
44}
45
46impl Default for LifecyclePolicy {
47 fn default() -> Self {
48 Self {
49 grace: DEFAULT_LOCAL_GRACE,
50 quota_bytes: DEFAULT_ORIGINAL_QUOTA_BYTES,
51 preview_quota_bytes: DEFAULT_PREVIEW_QUOTA_BYTES,
52 maintenance_limit: DEFAULT_MAINTENANCE_LIMIT,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub struct ByteCount {
59 pub count: u64,
60 pub bytes: u64,
61}
62
63#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
64pub struct LifecycleReport {
65 pub referenced: ByteCount,
66 pub protected: ByteCount,
67 pub grace_period: ByteCount,
68 pub eligible: ByteCount,
69 pub staging: ByteCount,
70 pub trash: ByteCount,
71 pub reservations: ByteCount,
72 pub quota: ByteCount,
73 pub inconsistencies: ByteCount,
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub struct PruneSummary {
78 pub eligible: ByteCount,
79 pub pruned: ByteCount,
80}
81
82fn timestamp(now: DateTime<Utc>) -> String {
83 now.to_rfc3339_opts(SecondsFormat::Secs, true)
84}
85
86fn cutoff(now: DateTime<Utc>, grace: Duration) -> Result<String> {
87 let grace = chrono::Duration::from_std(grace)?;
88 Ok(timestamp(now - grace))
89}
90
91fn trash_dir(blob_dir: &Path) -> PathBuf {
92 blob_dir.join("trash")
93}
94
95fn staging_dir(blob_dir: &Path) -> PathBuf {
96 blob_dir.join("objects").join("sha256")
97}
98
99struct ScannedFile {
100 path: PathBuf,
101 name: String,
102 len: u64,
103 modified: SystemTime,
104}
105
106#[derive(Default)]
107struct DirectoryCursor {
108 entries: Option<fs::ReadDir>,
109}
110
111static DIRECTORY_CURSORS: LazyLock<Mutex<HashMap<PathBuf, DirectoryCursor>>> =
112 LazyLock::new(|| Mutex::new(HashMap::new()));
113
114async fn scan_directory_all(dir: PathBuf) -> Result<Vec<ScannedFile>> {
115 crate::attachments::blocking::run(move || {
116 let entries = match fs::read_dir(&dir) {
117 Ok(entries) => entries,
118 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
119 Err(error) => return Err(error.into()),
120 };
121 let mut files = Vec::new();
122 for entry in entries {
123 let entry = entry?;
124 if !entry.file_type()?.is_file() {
125 continue;
126 }
127 let metadata = entry.metadata()?;
128 files.push(ScannedFile {
129 path: entry.path(),
130 name: entry.file_name().to_string_lossy().into_owned(),
131 len: metadata.len(),
132 modified: metadata.modified()?,
133 });
134 }
135 Ok(files)
136 })
137 .await
138}
139
140async fn scan_directory_page(dir: PathBuf, limit: usize) -> Result<Vec<ScannedFile>> {
141 scan_directory_page_with(dir, limit, |entries| entries.next().transpose()).await
142}
143
144async fn scan_directory_page_with<F>(
145 dir: PathBuf,
146 limit: usize,
147 mut next_entry: F,
148) -> Result<Vec<ScannedFile>>
149where
150 F: FnMut(&mut fs::ReadDir) -> io::Result<Option<fs::DirEntry>> + Send + 'static,
151{
152 if limit == 0 {
153 return Ok(Vec::new());
154 }
155 crate::attachments::blocking::run(move || {
156 let key = dir.clone();
157 let mut cursors = DIRECTORY_CURSORS
158 .lock()
159 .map_err(|_| anyhow::anyhow!("attachment directory cursor poisoned"))?;
160 let mut cursor = match cursors.remove(&key) {
161 Some(cursor) => cursor,
162 None => {
163 let entries = match fs::read_dir(&dir) {
164 Ok(entries) => entries,
165 Err(error) if error.kind() == io::ErrorKind::NotFound => {
166 return Ok(Vec::new());
167 }
168 Err(error) => return Err(error.into()),
169 };
170 DirectoryCursor {
171 entries: Some(entries),
172 }
173 }
174 };
175 let mut files = Vec::new();
176 let mut exhausted = false;
177 for _ in 0..limit {
178 let Some(entry) = next_entry(
179 cursor
180 .entries
181 .as_mut()
182 .expect("attachment directory cursor should be open"),
183 )?
184 else {
185 exhausted = true;
186 break;
187 };
188 if !entry.file_type()?.is_file() {
189 continue;
190 }
191 let metadata = entry.metadata()?;
192 files.push(ScannedFile {
193 path: entry.path(),
194 name: entry.file_name().to_string_lossy().into_owned(),
195 len: metadata.len(),
196 modified: metadata.modified()?,
197 });
198 }
199 if !exhausted {
200 cursors.insert(key, cursor);
201 }
202 Ok(files)
203 })
204 .await
205}
206
207async fn remove_files(files: Vec<PathBuf>) -> Result<()> {
208 crate::attachments::blocking::run(move || {
209 for path in files {
210 match fs::remove_file(path) {
211 Ok(()) => {}
212 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
213 Err(error) => return Err(error.into()),
214 }
215 }
216 Ok(())
217 })
218 .await
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222enum FileMove {
223 Moved,
224 SourceMissing,
225 TargetExists,
226}
227
228fn sync_parent(path: &Path) -> io::Result<()> {
229 let parent = path
230 .parent()
231 .ok_or_else(|| io::Error::other("attachment move target has no parent"))?;
232 fs::File::open(parent)?.sync_all()
233}
234
235fn finish_file_move(source: &Path, target: &Path) -> Result<FileMove> {
236 if let Err(error) = sync_parent(target) {
237 let _ = fs::remove_file(target);
238 return Err(error.into());
239 }
240 if let Err(error) = fs::remove_file(source) {
241 let _ = fs::remove_file(target);
242 let _ = sync_parent(target);
243 return Err(error.into());
244 }
245 Ok(FileMove::Moved)
246}
247
248fn copy_file_without_replacing(source: &Path, target: &Path) -> Result<FileMove> {
249 let mut source_file = match fs::File::open(source) {
250 Ok(file) => file,
251 Err(error) if error.kind() == io::ErrorKind::NotFound => {
252 return Ok(FileMove::SourceMissing);
253 }
254 Err(error) => return Err(error.into()),
255 };
256 let parent = target
257 .parent()
258 .ok_or_else(|| io::Error::other("attachment move target has no parent"))?;
259 let mut staging = tempfile::Builder::new()
260 .prefix(".aven-move-")
261 .tempfile_in(parent)?;
262 io::copy(&mut source_file, &mut staging)?;
263 staging.as_file().sync_all()?;
264 match staging.persist_noclobber(target) {
265 Ok(_) => finish_file_move(source, target),
266 Err(error) if error.error.kind() == io::ErrorKind::AlreadyExists => {
267 Ok(FileMove::TargetExists)
268 }
269 Err(error) => Err(error.error.into()),
270 }
271}
272
273fn move_file_without_replacing(source: &Path, target: &Path) -> Result<FileMove> {
274 move_file_without_replacing_with(source, target, |source, target| {
275 fs::hard_link(source, target)
276 })
277}
278
279fn move_file_without_replacing_with<F>(
280 source: &Path,
281 target: &Path,
282 hard_link: F,
283) -> Result<FileMove>
284where
285 F: FnOnce(&Path, &Path) -> io::Result<()>,
286{
287 match hard_link(source, target) {
288 Ok(()) => finish_file_move(source, target),
289 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(FileMove::TargetExists),
290 Err(error) if error.kind() == io::ErrorKind::NotFound && !source.exists() => {
291 Ok(FileMove::SourceMissing)
292 }
293 Err(_) => copy_file_without_replacing(source, target),
294 }
295}
296
297async fn restore_trashed_files(files: Vec<(PathBuf, PathBuf)>) {
298 let _ = crate::attachments::blocking::run(move || {
299 for (source, target) in files {
300 if !target.exists() {
301 continue;
302 }
303 if source.exists() {
304 fs::remove_file(target)?;
305 } else {
306 let _ = move_file_without_replacing(&target, &source)?;
307 }
308 }
309 Ok(())
310 })
311 .await;
312}
313
314fn live_blob_references_sql(sha256_expr: &str) -> String {
315 format!(
316 "EXISTS(
317 SELECT 1 FROM task_attachments ta
318 JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
319 WHERE ta.sha256 = {sha256_expr} AND ta.deleted = 0 AND t.deleted = 0
320 ) OR EXISTS(
321 SELECT 1 FROM server_blob_references sbr
322 LEFT JOIN server_task_tombstones st
323 ON st.workspace_id = sbr.workspace_id AND st.task_id = sbr.task_id
324 WHERE sbr.sha256 = {sha256_expr} AND sbr.deleted = 0
325 AND COALESCE(st.deleted, 0) = 0
326 )"
327 )
328}
329
330pub async fn reconcile_liveness(conn: &mut SqliteConnection, clock: &dyn Clock) -> Result<()> {
331 let mut tx = begin_immediate(conn).await?;
332 reconcile_liveness_in_transaction(&mut tx, clock).await?;
333 tx.commit().await?;
334 Ok(())
335}
336
337pub(crate) async fn reconcile_liveness_in_transaction(
338 conn: &mut SqliteConnection,
339 clock: &dyn Clock,
340) -> Result<()> {
341 let now = timestamp(clock.now());
342 sqlx::query("DELETE FROM blob_leases WHERE expires_at <= ?")
343 .bind(&now)
344 .execute(&mut *conn)
345 .await?;
346 sqlx::query("DELETE FROM blob_upload_reservations WHERE expires_at <= ?")
347 .bind(&now)
348 .execute(&mut *conn)
349 .await?;
350 sqlx::query(
351 "INSERT OR IGNORE INTO blob_lifecycle(sha256, unreferenced_at)
352 SELECT sha256, NULL FROM blob_inventory",
353 )
354 .execute(&mut *conn)
355 .await?;
356 let live_blob_references = live_blob_references_sql("blob_lifecycle.sha256");
357 sqlx::query(sqlx::AssertSqlSafe(format!(
358 "UPDATE blob_lifecycle SET unreferenced_at = NULL
359 WHERE unreferenced_at IS NOT NULL
360 AND ({live_blob_references})"
361 )))
362 .execute(&mut *conn)
363 .await?;
364 sqlx::query(sqlx::AssertSqlSafe(format!(
365 "UPDATE blob_lifecycle SET unreferenced_at = ?
366 WHERE unreferenced_at IS NULL
367 AND NOT ({live_blob_references})"
368 )))
369 .bind(&now)
370 .execute(&mut *conn)
371 .await?;
372 Ok(())
373}
374
375pub(crate) async fn reconcile_liveness_for_hashes_in_transaction(
376 conn: &mut SqliteConnection,
377 hashes: &[String],
378 clock: &dyn Clock,
379) -> Result<()> {
380 if hashes.is_empty() {
381 return Ok(());
382 }
383 let now = timestamp(clock.now());
384 let hashes = serde_json::to_string(hashes)?;
385 let live_inventory_references = live_blob_references_sql("bi.sha256");
386 sqlx::query(sqlx::AssertSqlSafe(format!(
387 "INSERT OR IGNORE INTO blob_lifecycle(sha256, unreferenced_at)
388 SELECT bi.sha256,
389 CASE WHEN ({live_inventory_references}) THEN NULL ELSE ? END
390 FROM blob_inventory bi
391 JOIN (SELECT DISTINCT value AS sha256 FROM json_each(?)) affected
392 ON affected.sha256 = bi.sha256"
393 )))
394 .bind(&now)
395 .bind(&hashes)
396 .execute(&mut *conn)
397 .await?;
398
399 let live_blob_references = live_blob_references_sql("blob_lifecycle.sha256");
400 sqlx::query(sqlx::AssertSqlSafe(format!(
401 "UPDATE blob_lifecycle SET unreferenced_at = NULL
402 WHERE unreferenced_at IS NOT NULL
403 AND sha256 IN (SELECT value FROM json_each(?))
404 AND ({live_blob_references})"
405 )))
406 .bind(&hashes)
407 .execute(&mut *conn)
408 .await?;
409 sqlx::query(sqlx::AssertSqlSafe(format!(
410 "UPDATE blob_lifecycle SET unreferenced_at = ?
411 WHERE unreferenced_at IS NULL
412 AND sha256 IN (SELECT value FROM json_each(?))
413 AND NOT ({live_blob_references})"
414 )))
415 .bind(&now)
416 .bind(&hashes)
417 .execute(&mut *conn)
418 .await?;
419 Ok(())
420}
421
422async fn reconcile_liveness_bounded(
423 conn: &mut SqliteConnection,
424 limit: usize,
425 clock: &dyn Clock,
426) -> Result<()> {
427 let mut tx = begin_immediate(conn).await?;
428 let now = timestamp(clock.now());
429 sqlx::query("DELETE FROM blob_leases WHERE expires_at <= ?")
430 .bind(&now)
431 .execute(&mut *tx)
432 .await?;
433 sqlx::query("DELETE FROM blob_upload_reservations WHERE expires_at <= ?")
434 .bind(&now)
435 .execute(&mut *tx)
436 .await?;
437 if limit == 0 {
438 tx.commit().await?;
439 return Ok(());
440 }
441
442 let cursor = get_meta(&mut tx, LIVENESS_CURSOR_META_KEY)
443 .await?
444 .unwrap_or_default();
445 let hashes = sqlx::query_scalar::<_, String>(
446 "SELECT sha256 FROM blob_inventory
447 WHERE sha256 > ? ORDER BY sha256 LIMIT ?",
448 )
449 .bind(&cursor)
450 .bind(i64::try_from(limit)?)
451 .fetch_all(&mut *tx)
452 .await?;
453 reconcile_liveness_for_hashes_in_transaction(&mut tx, &hashes, clock).await?;
454 set_meta(
455 &mut tx,
456 LIVENESS_CURSOR_META_KEY,
457 if hashes.len() < limit {
458 ""
459 } else {
460 hashes.last().map(String::as_str).unwrap_or_default()
461 },
462 )
463 .await?;
464 tx.commit().await?;
465 Ok(())
466}
467
468async fn is_protected(conn: &mut SqliteConnection, sha256: &str, now: &str) -> Result<bool> {
469 let live_blob_references = live_blob_references_sql("?");
470 Ok(sqlx::query_scalar::<_, bool>(sqlx::AssertSqlSafe(format!(
471 "SELECT
472 {live_blob_references} OR EXISTS(
473 SELECT 1 FROM changes
474 WHERE server_seq IS NULL AND op_type = 'attachment_add'
475 AND json_extract(payload, '$.sha256') = ?
476 ) OR EXISTS(
477 SELECT 1 FROM blob_leases WHERE sha256 = ? AND expires_at > ?
478 ) OR EXISTS(
479 SELECT 1 FROM blob_upload_reservations WHERE sha256 = ? AND expires_at > ?
480 )"
481 )))
482 .bind(sha256)
483 .bind(sha256)
484 .bind(sha256)
485 .bind(sha256)
486 .bind(now)
487 .bind(sha256)
488 .bind(now)
489 .fetch_one(&mut *conn)
490 .await?)
491}
492
493pub async fn acquire_lease(
494 conn: &mut SqliteConnection,
495 sha256: &str,
496 kind: &str,
497 clock: &dyn Clock,
498) -> Result<String> {
499 validate_sha256(sha256)?;
500 if !matches!(kind, "staging" | "read" | "backup" | "transfer") {
501 bail!("error attachment-lease-kind-invalid");
502 }
503 let lease_id = new_id();
504 let now = clock.now();
505 let expires = now + chrono::Duration::from_std(LEASE_TTL)?;
506 sqlx::query(
507 "INSERT INTO blob_leases(lease_id, sha256, kind, created_at, expires_at)
508 VALUES (?, ?, ?, ?, ?)",
509 )
510 .bind(&lease_id)
511 .bind(sha256)
512 .bind(kind)
513 .bind(timestamp(now))
514 .bind(timestamp(expires))
515 .execute(&mut *conn)
516 .await?;
517 Ok(lease_id)
518}
519
520pub async fn release_lease(conn: &mut SqliteConnection, lease_id: &str) -> Result<()> {
521 sqlx::query("DELETE FROM blob_leases WHERE lease_id = ?")
522 .bind(lease_id)
523 .execute(&mut *conn)
524 .await?;
525 Ok(())
526}
527
528pub async fn reserve_upload(
529 conn: &mut SqliteConnection,
530 workspace_id: &str,
531 sha256: &str,
532 byte_size: i64,
533 quota_bytes: i64,
534 clock: &dyn Clock,
535) -> Result<Option<String>> {
536 validate_sha256(sha256)?;
537 let mut tx = begin_immediate(conn).await?;
538 let existing: bool = sqlx::query_scalar(
539 "SELECT EXISTS(
540 SELECT 1 FROM server_blob_references sbr
541 LEFT JOIN server_task_tombstones st
542 ON st.workspace_id = sbr.workspace_id AND st.task_id = sbr.task_id
543 WHERE sbr.workspace_id = ? AND sbr.sha256 = ? AND sbr.deleted = 0
544 AND COALESCE(st.deleted, 0) = 0
545 )",
546 )
547 .bind(workspace_id)
548 .bind(sha256)
549 .fetch_one(&mut *tx)
550 .await?;
551 if existing {
552 tx.commit().await?;
553 return Ok(None);
554 }
555 let used: i64 = sqlx::query_scalar(
556 "SELECT COALESCE(SUM(byte_size), 0) FROM (
557 SELECT sbr.sha256, MAX(sbr.byte_size) AS byte_size
558 FROM server_blob_references sbr
559 LEFT JOIN server_task_tombstones st
560 ON st.workspace_id = sbr.workspace_id AND st.task_id = sbr.task_id
561 WHERE sbr.workspace_id = ? AND sbr.deleted = 0 AND COALESCE(st.deleted, 0) = 0
562 GROUP BY sbr.sha256
563 )",
564 )
565 .bind(workspace_id)
566 .fetch_one(&mut *tx)
567 .await?;
568 let reserved: i64 = sqlx::query_scalar(
569 "SELECT COALESCE(SUM(byte_size), 0) FROM blob_upload_reservations
570 WHERE workspace_id = ? AND sha256 != ? AND expires_at > ?",
571 )
572 .bind(workspace_id)
573 .bind(sha256)
574 .bind(timestamp(clock.now()))
575 .fetch_one(&mut *tx)
576 .await?;
577 if used.saturating_add(reserved).saturating_add(byte_size) > quota_bytes {
578 tx.rollback().await?;
579 bail!("error attachment-quota-exceeded");
580 }
581 let reservation_id = new_id();
582 let now = clock.now();
583 let expires = now + chrono::Duration::from_std(LEASE_TTL)?;
584 sqlx::query(
585 "INSERT INTO blob_upload_reservations(
586 reservation_id, workspace_id, sha256, byte_size, created_at, expires_at
587 ) VALUES (?, ?, ?, ?, ?, ?)
588 ON CONFLICT(workspace_id, sha256) DO UPDATE SET
589 reservation_id = excluded.reservation_id,
590 byte_size = excluded.byte_size,
591 created_at = excluded.created_at,
592 expires_at = excluded.expires_at",
593 )
594 .bind(&reservation_id)
595 .bind(workspace_id)
596 .bind(sha256)
597 .bind(byte_size)
598 .bind(timestamp(now))
599 .bind(timestamp(expires))
600 .execute(&mut *tx)
601 .await?;
602 tx.commit().await?;
603 Ok(Some(reservation_id))
604}
605
606pub async fn release_reservation(conn: &mut SqliteConnection, reservation_id: &str) -> Result<()> {
607 sqlx::query("DELETE FROM blob_upload_reservations WHERE reservation_id = ?")
608 .bind(reservation_id)
609 .execute(&mut *conn)
610 .await?;
611 Ok(())
612}
613
614pub async fn local_unique_bytes(conn: &mut SqliteConnection) -> Result<i64> {
615 Ok(sqlx::query_scalar(
616 "SELECT COALESCE(SUM(byte_size), 0) FROM blob_inventory WHERE available = 1",
617 )
618 .fetch_one(&mut *conn)
619 .await?)
620}
621
622pub async fn ensure_local_capacity(
623 conn: &mut SqliteConnection,
624 blob_dir: &Path,
625 sha256: &str,
626 byte_size: i64,
627 policy: LifecyclePolicy,
628 clock: &dyn Clock,
629) -> Result<Option<String>> {
630 let used = local_unique_bytes(conn).await?;
631 if used.saturating_add(byte_size) > policy.quota_bytes {
632 prune(conn, blob_dir, policy, true, clock).await?;
633 }
634 let now = clock.now();
635 let mut tx = begin_immediate(conn).await?;
636 let existing: bool = sqlx::query_scalar(
637 "SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ? AND available = 1)",
638 )
639 .bind(sha256)
640 .fetch_one(&mut *tx)
641 .await?;
642 if existing {
643 tx.commit().await?;
644 return Ok(None);
645 }
646 let used: i64 = sqlx::query_scalar(
647 "SELECT COALESCE(SUM(byte_size), 0) FROM blob_inventory WHERE available = 1",
648 )
649 .fetch_one(&mut *tx)
650 .await?;
651 let reserved: i64 = sqlx::query_scalar(
652 "SELECT COALESCE(SUM(byte_size), 0) FROM blob_upload_reservations
653 WHERE workspace_id = '__local__' AND sha256 != ? AND expires_at > ?",
654 )
655 .bind(sha256)
656 .bind(timestamp(now))
657 .fetch_one(&mut *tx)
658 .await?;
659 if used.saturating_add(reserved).saturating_add(byte_size) > policy.quota_bytes {
660 tx.rollback().await?;
661 bail!("error attachment-quota-exceeded");
662 }
663 let reservation_id = new_id();
664 let expires = now + chrono::Duration::from_std(LEASE_TTL)?;
665 sqlx::query(
666 "INSERT INTO blob_upload_reservations(
667 reservation_id, workspace_id, sha256, byte_size, created_at, expires_at
668 ) VALUES (?, '__local__', ?, ?, ?, ?)
669 ON CONFLICT(workspace_id, sha256) DO UPDATE SET
670 reservation_id = excluded.reservation_id,
671 byte_size = excluded.byte_size,
672 created_at = excluded.created_at,
673 expires_at = excluded.expires_at",
674 )
675 .bind(&reservation_id)
676 .bind(sha256)
677 .bind(byte_size)
678 .bind(timestamp(now))
679 .bind(timestamp(expires))
680 .execute(&mut *tx)
681 .await?;
682 tx.commit().await?;
683 Ok(Some(reservation_id))
684}
685
686async fn reconcile_trash_files(
687 conn: &mut SqliteConnection,
688 blob_dir: &Path,
689 files: Vec<ScannedFile>,
690) -> Result<()> {
691 let hashes = files
692 .iter()
693 .filter(|file| validate_sha256(&file.name).is_ok())
694 .map(|file| file.name.clone())
695 .collect::<Vec<_>>();
696 let available = if hashes.is_empty() {
697 HashSet::new()
698 } else {
699 sqlx::query_scalar::<_, String>(
700 "SELECT sha256 FROM blob_inventory
701 WHERE available = 1 AND sha256 IN (SELECT value FROM json_each(?))",
702 )
703 .bind(serde_json::to_string(&hashes)?)
704 .fetch_all(&mut *conn)
705 .await?
706 .into_iter()
707 .collect()
708 };
709 let blob_dir = blob_dir.to_path_buf();
710 crate::attachments::blocking::run(move || {
711 for file in files {
712 if validate_sha256(&file.name).is_err() {
713 continue;
714 }
715 let target = object_path(&blob_dir, &file.name)?;
716 if available.contains(&file.name) {
717 if target.exists() {
718 fs::remove_file(file.path)?;
719 } else {
720 if let Some(parent) = target.parent() {
721 fs::create_dir_all(parent)?;
722 }
723 match move_file_without_replacing(&file.path, &target)? {
724 FileMove::Moved | FileMove::SourceMissing | FileMove::TargetExists => {}
725 }
726 }
727 } else {
728 match fs::remove_file(file.path) {
729 Ok(()) => {}
730 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
731 Err(error) => return Err(error.into()),
732 }
733 }
734 }
735 Ok(())
736 })
737 .await
738}
739
740pub async fn reconcile_trash(conn: &mut SqliteConnection, blob_dir: &Path) -> Result<()> {
741 reconcile_trash_files(
742 conn,
743 blob_dir,
744 scan_directory_all(trash_dir(blob_dir)).await?,
745 )
746 .await
747}
748
749async fn reconcile_trash_page(
750 conn: &mut SqliteConnection,
751 blob_dir: &Path,
752 limit: usize,
753) -> Result<()> {
754 reconcile_trash_files(
755 conn,
756 blob_dir,
757 scan_directory_page(trash_dir(blob_dir), limit).await?,
758 )
759 .await
760}
761
762pub async fn reconcile_staging(blob_dir: &Path) -> Result<ByteCount> {
763 let files = scan_directory_all(staging_dir(blob_dir)).await?;
764 let stale = files
765 .into_iter()
766 .filter(|file| {
767 file.name.starts_with(".aven-stage-")
768 && file.modified.elapsed().is_ok_and(|age| age >= LEASE_TTL)
769 })
770 .collect::<Vec<_>>();
771 let removed = ByteCount {
772 count: u64::try_from(stale.len())?,
773 bytes: stale.iter().map(|file| file.len).sum(),
774 };
775 remove_files(stale.into_iter().map(|file| file.path).collect()).await?;
776 Ok(removed)
777}
778
779pub async fn reconcile_missing_objects(
780 conn: &mut SqliteConnection,
781 blob_dir: &Path,
782 clock: &dyn Clock,
783) -> Result<ByteCount> {
784 let rows = sqlx::query(
785 "SELECT sha256, byte_size FROM blob_inventory
786 WHERE available = 1 ORDER BY sha256",
787 )
788 .fetch_all(&mut *conn)
789 .await?;
790 let verified_at = timestamp(clock.now());
791 let mut missing = ByteCount::default();
792 for row in rows {
793 let sha256: String = row.get("sha256");
794 if object_path(blob_dir, &sha256)?.exists() {
795 continue;
796 }
797 sqlx::query(
798 "UPDATE blob_inventory SET available = 0, last_verified_at = ?
799 WHERE sha256 = ? AND available = 1",
800 )
801 .bind(&verified_at)
802 .bind(&sha256)
803 .execute(&mut *conn)
804 .await?;
805 missing.count += 1;
806 missing.bytes += u64::try_from(row.get::<i64, _>("byte_size"))?;
807 }
808 Ok(missing)
809}
810
811async fn reconcile_object_directory(
812 conn: &mut SqliteConnection,
813 blob_dir: &Path,
814 grace: Duration,
815 limit: usize,
816 clock: &dyn Clock,
817) -> Result<ByteCount> {
818 let files = scan_directory_page(staging_dir(blob_dir), limit).await?;
819 let stale_staging = files
820 .iter()
821 .filter(|file| {
822 file.name.starts_with(".aven-stage-")
823 && file.modified.elapsed().is_ok_and(|age| age >= LEASE_TTL)
824 })
825 .map(|file| file.path.clone())
826 .collect::<Vec<_>>();
827 remove_files(stale_staging).await?;
828
829 let cutoff = clock.now() - chrono::Duration::from_std(grace)?;
830 let canonical = files
831 .iter()
832 .filter(|file| {
833 validate_sha256(&file.name).is_ok() && DateTime::<Utc>::from(file.modified) <= cutoff
834 })
835 .collect::<Vec<_>>();
836 if canonical.is_empty() {
837 return Ok(ByteCount::default());
838 }
839 let hashes = canonical
840 .iter()
841 .map(|file| file.name.clone())
842 .collect::<Vec<_>>();
843 let candidates = serde_json::to_string(&hashes)?;
844 let now = timestamp(clock.now());
845 let live_blob_references = live_blob_references_sql("candidate.value");
846 let protected = sqlx::query_scalar::<_, String>(sqlx::AssertSqlSafe(format!(
847 "SELECT candidate.value FROM json_each(?) candidate
848 WHERE EXISTS(SELECT 1 FROM blob_inventory bi WHERE bi.sha256 = candidate.value)
849 OR {live_blob_references}
850 OR EXISTS(
851 SELECT 1 FROM changes
852 WHERE server_seq IS NULL AND op_type = 'attachment_add'
853 AND json_extract(payload, '$.sha256') = candidate.value
854 )
855 OR EXISTS(
856 SELECT 1 FROM blob_leases
857 WHERE sha256 = candidate.value AND expires_at > ?
858 )
859 OR EXISTS(
860 SELECT 1 FROM blob_upload_reservations
861 WHERE sha256 = candidate.value AND expires_at > ?
862 )"
863 )))
864 .bind(candidates)
865 .bind(&now)
866 .bind(&now)
867 .fetch_all(&mut *conn)
868 .await?
869 .into_iter()
870 .collect::<HashSet<_>>();
871 let removable = canonical
872 .into_iter()
873 .filter(|file| !protected.contains(&file.name))
874 .map(|file| (file.path.clone(), file.name.clone(), file.len))
875 .collect::<Vec<_>>();
876 if removable.is_empty() {
877 return Ok(ByteCount::default());
878 }
879 let blob_dir = blob_dir.to_path_buf();
880 crate::attachments::blocking::run(move || {
881 let trash = trash_dir(&blob_dir);
882 fs::create_dir_all(&trash)?;
883 let mut removed = ByteCount::default();
884 for (source, name, len) in removable {
885 let target = trash.join(name);
886 if move_file_without_replacing(&source, &target)? == FileMove::Moved {
887 fs::remove_file(target)?;
888 removed.count += 1;
889 removed.bytes += len;
890 }
891 }
892 Ok(removed)
893 })
894 .await
895}
896
897pub async fn reconcile_orphan_objects(
898 conn: &mut SqliteConnection,
899 blob_dir: &Path,
900 grace: Duration,
901 clock: &dyn Clock,
902) -> Result<ByteCount> {
903 reconcile_object_directory(conn, blob_dir, grace, usize::MAX, clock).await
904}
905
906pub async fn prune(
907 conn: &mut SqliteConnection,
908 blob_dir: &Path,
909 policy: LifecyclePolicy,
910 apply: bool,
911 clock: &dyn Clock,
912) -> Result<PruneSummary> {
913 if apply {
914 reconcile_trash_page(conn, blob_dir, policy.maintenance_limit).await?;
915 reconcile_object_directory(
916 conn,
917 blob_dir,
918 policy.grace,
919 policy.maintenance_limit,
920 clock,
921 )
922 .await?;
923 reconcile_missing_objects(conn, blob_dir, clock).await?;
924 }
925 reconcile_liveness_bounded(conn, policy.maintenance_limit, clock).await?;
926 let now = timestamp(clock.now());
927 let cutoff = cutoff(clock.now(), policy.grace)?;
928 let rows = sqlx::query(
929 "SELECT bi.sha256, bi.byte_size
930 FROM blob_inventory bi
931 JOIN blob_lifecycle bl ON bl.sha256 = bi.sha256
932 WHERE bi.available = 1 AND bl.unreferenced_at IS NOT NULL
933 AND bl.unreferenced_at <= ?
934 ORDER BY bl.unreferenced_at, bi.sha256 LIMIT ?",
935 )
936 .bind(&cutoff)
937 .bind(i64::try_from(policy.maintenance_limit)?)
938 .fetch_all(&mut *conn)
939 .await?;
940 let mut summary = PruneSummary::default();
941 for row in rows {
942 let sha256: String = row.get("sha256");
943 let byte_size: i64 = row.get("byte_size");
944 if is_protected(conn, &sha256, &now).await? {
945 continue;
946 }
947 summary.eligible.count += 1;
948 summary.eligible.bytes += u64::try_from(byte_size)?;
949 if !apply {
950 continue;
951 }
952 let mut tx = begin_immediate(conn).await?;
953 let still_eligible: bool = sqlx::query_scalar(
954 "SELECT EXISTS(
955 SELECT 1 FROM blob_inventory bi
956 JOIN blob_lifecycle bl ON bl.sha256 = bi.sha256
957 WHERE bi.sha256 = ? AND bi.available = 1
958 AND bl.unreferenced_at IS NOT NULL AND bl.unreferenced_at <= ?
959 )",
960 )
961 .bind(&sha256)
962 .bind(&cutoff)
963 .fetch_one(&mut *tx)
964 .await?;
965 if !still_eligible || is_protected(&mut tx, &sha256, &now).await? {
966 tx.rollback().await?;
967 continue;
968 }
969 let source = object_path(blob_dir, &sha256)?;
970 let trash = trash_dir(blob_dir);
971 let trashed = trash.join(&sha256);
972 let source_for_move = source.clone();
973 let trashed_for_move = trashed.clone();
974 let moved = crate::attachments::blocking::run(move || {
975 fs::create_dir_all(&trash)?;
976 move_file_without_replacing(&source_for_move, &trashed_for_move)
977 })
978 .await?;
979 if moved == FileMove::TargetExists {
980 tx.rollback().await?;
981 continue;
982 }
983 if let Err(error) = sqlx::query(
984 "UPDATE blob_inventory SET available = 0, last_verified_at = ? WHERE sha256 = ?",
985 )
986 .bind(&now)
987 .bind(&sha256)
988 .execute(&mut *tx)
989 .await
990 {
991 let _ = tx.rollback().await;
992 if moved == FileMove::Moved {
993 restore_trashed_files(vec![(source, trashed)]).await;
994 }
995 return Err(error.into());
996 }
997 if let Err(error) = tx.commit().await {
998 if moved == FileMove::Moved {
999 restore_trashed_files(vec![(source, trashed)]).await;
1000 }
1001 return Err(error.into());
1002 }
1003 if moved == FileMove::Moved {
1004 remove_files(vec![trashed]).await?;
1005 }
1006 summary.pruned.count += 1;
1007 summary.pruned.bytes += u64::try_from(byte_size)?;
1008 }
1009 let blob_dir = blob_dir.to_path_buf();
1010 crate::attachments::blocking::run(move || {
1011 prune_preview_cache(&blob_dir, policy.preview_quota_bytes)
1012 })
1013 .await?;
1014 Ok(summary)
1015}
1016
1017pub fn prune_preview_cache(blob_dir: &Path, quota: u64) -> Result<ByteCount> {
1018 let root = blob_dir.join("cache").join("previews");
1019 if !root.exists() {
1020 return Ok(ByteCount::default());
1021 }
1022 let mut files = Vec::new();
1023 let mut dirs = vec![root];
1024 while let Some(dir) = dirs.pop() {
1025 for entry in fs::read_dir(dir)? {
1026 let entry = entry?;
1027 if entry.file_type()?.is_dir() {
1028 dirs.push(entry.path());
1029 } else if entry.file_type()?.is_file() {
1030 let metadata = entry.metadata()?;
1031 files.push((metadata.modified()?, metadata.len(), entry.path()));
1032 }
1033 }
1034 }
1035 let mut total: u64 = files.iter().map(|(_, size, _)| *size).sum();
1036 files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
1037 let mut removed = ByteCount::default();
1038 for (_, size, path) in files {
1039 if total <= quota {
1040 break;
1041 }
1042 fs::remove_file(path)?;
1043 total -= size;
1044 removed.count += 1;
1045 removed.bytes += size;
1046 }
1047 Ok(removed)
1048}
1049
1050pub async fn lifecycle_report(
1051 conn: &mut SqliteConnection,
1052 blob_dir: &Path,
1053 policy: LifecyclePolicy,
1054 clock: &dyn Clock,
1055) -> Result<LifecycleReport> {
1056 reconcile_liveness(conn, clock).await?;
1057 let now = timestamp(clock.now());
1058 let cutoff = cutoff(clock.now(), policy.grace)?;
1059 let live_blob_references = live_blob_references_sql("bi.sha256");
1060 let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
1061 "SELECT bi.sha256, bi.byte_size, bi.available, bl.unreferenced_at,
1062 {live_blob_references} AS referenced
1063 FROM blob_inventory bi LEFT JOIN blob_lifecycle bl ON bl.sha256 = bi.sha256"
1064 )))
1065 .fetch_all(&mut *conn)
1066 .await?;
1067 let mut report = LifecycleReport::default();
1068 for row in rows {
1069 let sha256: String = row.get("sha256");
1070 let bytes = u64::try_from(row.get::<i64, _>("byte_size"))?;
1071 let available = row.get::<i64, _>("available") != 0;
1072 let referenced = row.get::<i64, _>("referenced") != 0;
1073 let unreferenced_at: Option<String> = row.get("unreferenced_at");
1074 if available {
1075 report.quota.count += 1;
1076 report.quota.bytes += bytes;
1077 }
1078 if referenced {
1079 report.referenced.count += 1;
1080 report.referenced.bytes += bytes;
1081 } else if is_protected(conn, &sha256, &now).await? {
1082 report.protected.count += 1;
1083 report.protected.bytes += bytes;
1084 } else if unreferenced_at
1085 .as_deref()
1086 .is_some_and(|at| at <= cutoff.as_str())
1087 {
1088 report.eligible.count += 1;
1089 report.eligible.bytes += bytes;
1090 } else {
1091 report.grace_period.count += 1;
1092 report.grace_period.bytes += bytes;
1093 }
1094 if referenced && unreferenced_at.is_some() || !referenced && unreferenced_at.is_none() {
1095 report.inconsistencies.count += 1;
1096 report.inconsistencies.bytes += bytes;
1097 }
1098 }
1099 let (reservation_count, reservation_bytes): (i64, i64) = sqlx::query_as(
1100 "SELECT COUNT(*), COALESCE(SUM(byte_size), 0)
1101 FROM blob_upload_reservations WHERE expires_at > ?",
1102 )
1103 .bind(&now)
1104 .fetch_one(&mut *conn)
1105 .await?;
1106 report.reservations = ByteCount {
1107 count: u64::try_from(reservation_count)?,
1108 bytes: u64::try_from(reservation_bytes)?,
1109 };
1110 for entry in fs::read_dir(staging_dir(blob_dir))
1111 .into_iter()
1112 .flatten()
1113 .flatten()
1114 {
1115 let name = entry.file_name().to_string_lossy().to_string();
1116 let metadata = entry.metadata()?;
1117 if name.starts_with(".aven-stage-") {
1118 report.staging.count += 1;
1119 report.staging.bytes += metadata.len();
1120 } else if validate_sha256(&name).is_ok() {
1121 let tracked: bool =
1122 sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blob_inventory WHERE sha256 = ?)")
1123 .bind(&name)
1124 .fetch_one(&mut *conn)
1125 .await?;
1126 if !tracked {
1127 report.staging.count += 1;
1128 report.staging.bytes += metadata.len();
1129 report.inconsistencies.count += 1;
1130 report.inconsistencies.bytes += metadata.len();
1131 }
1132 }
1133 }
1134 for entry in fs::read_dir(trash_dir(blob_dir))
1135 .into_iter()
1136 .flatten()
1137 .flatten()
1138 {
1139 if entry.file_type()?.is_file() {
1140 report.trash.count += 1;
1141 report.trash.bytes += entry.metadata()?.len();
1142 }
1143 }
1144 Ok(report)
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149 use std::sync::{Arc, Mutex};
1150
1151 use chrono::DateTime;
1152 use sqlx::Connection as _;
1153 use sqlx::sqlite::SqliteConnectOptions;
1154
1155 use crate::attachments::storage::{object_path, upsert_inventory_available};
1156 use crate::db::open_db;
1157
1158 use super::*;
1159
1160 #[derive(Clone)]
1161 struct TestClock(Arc<Mutex<DateTime<Utc>>>);
1162
1163 impl TestClock {
1164 fn at(value: &str) -> Self {
1165 Self(Arc::new(Mutex::new(
1166 DateTime::parse_from_rfc3339(value).unwrap().to_utc(),
1167 )))
1168 }
1169
1170 fn advance(&self, duration: chrono::Duration) {
1171 let mut now = self.0.lock().unwrap();
1172 *now += duration;
1173 }
1174 }
1175
1176 impl Clock for TestClock {
1177 fn now(&self) -> DateTime<Utc> {
1178 *self.0.lock().unwrap()
1179 }
1180 }
1181
1182 async fn insert_task(conn: &mut SqliteConnection, task_id: &str) {
1183 sqlx::query(
1184 "INSERT INTO tasks(
1185 workspace_id, id, title, description, project_id, status, priority,
1186 created_at, updated_at, queue_activity_at
1187 ) VALUES ('0000000000000000', ?, 'task', '', 'project', 'inbox', 'none',
1188 '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')",
1189 )
1190 .bind(task_id)
1191 .execute(conn)
1192 .await
1193 .unwrap();
1194 }
1195
1196 async fn insert_attachment(
1197 conn: &mut SqliteConnection,
1198 attachment_id: &str,
1199 task_id: &str,
1200 sha256: &str,
1201 deleted: bool,
1202 ) {
1203 sqlx::query(
1204 "INSERT INTO task_attachments(
1205 workspace_id, attachment_id, task_id, sha256, byte_size, media_type, width, height,
1206 created_at, deleted, deleted_at
1207 ) VALUES ('0000000000000000', ?, ?, ?, 4, 'image/png', 1, 1,
1208 '2026-01-01T00:00:00Z', ?, ?)",
1209 )
1210 .bind(attachment_id)
1211 .bind(task_id)
1212 .bind(sha256)
1213 .bind(i64::from(deleted))
1214 .bind(deleted.then_some("2026-01-01T00:00:00Z"))
1215 .execute(conn)
1216 .await
1217 .unwrap();
1218 }
1219
1220 #[tokio::test]
1221 async fn final_live_reference_starts_grace_once_and_restore_clears_it() {
1222 let temp = tempfile::tempdir().unwrap();
1223 let pool = open_db(&temp.path().join("test.sqlite")).await.unwrap();
1224 let mut conn = pool.acquire().await.unwrap();
1225 let hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
1226 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1227 .await
1228 .unwrap();
1229 insert_task(&mut conn, "0000000000000001").await;
1230 insert_task(&mut conn, "0000000000000002").await;
1231 insert_attachment(
1232 &mut conn,
1233 "0000000000000011",
1234 "0000000000000001",
1235 hash,
1236 false,
1237 )
1238 .await;
1239 insert_attachment(
1240 &mut conn,
1241 "0000000000000012",
1242 "0000000000000002",
1243 hash,
1244 false,
1245 )
1246 .await;
1247 let clock = TestClock::at("2026-07-01T00:00:00Z");
1248
1249 reconcile_liveness(&mut conn, &clock).await.unwrap();
1250 sqlx::query("UPDATE task_attachments SET deleted = 1, deleted_at = 'x' WHERE attachment_id = '0000000000000011'")
1251 .execute(&mut *conn).await.unwrap();
1252 reconcile_liveness(&mut conn, &clock).await.unwrap();
1253 let value: Option<String> =
1254 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1255 .bind(hash)
1256 .fetch_one(&mut *conn)
1257 .await
1258 .unwrap();
1259 assert_eq!(value, None, "one live reference keeps the hash live");
1260
1261 sqlx::query("UPDATE task_attachments SET deleted = 1, deleted_at = 'x' WHERE attachment_id = '0000000000000012'")
1262 .execute(&mut *conn).await.unwrap();
1263 reconcile_liveness(&mut conn, &clock).await.unwrap();
1264 let first: String =
1265 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1266 .bind(hash)
1267 .fetch_one(&mut *conn)
1268 .await
1269 .unwrap();
1270 clock.advance(chrono::Duration::days(1));
1271 reconcile_liveness(&mut conn, &clock).await.unwrap();
1272 let second: String =
1273 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1274 .bind(hash)
1275 .fetch_one(&mut *conn)
1276 .await
1277 .unwrap();
1278 assert_eq!(first, second, "grace starts exactly once");
1279
1280 sqlx::query("UPDATE task_attachments SET deleted = 0, deleted_at = NULL WHERE attachment_id = '0000000000000012'")
1281 .execute(&mut *conn).await.unwrap();
1282 reconcile_liveness(&mut conn, &clock).await.unwrap();
1283 let restored: Option<String> =
1284 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1285 .bind(hash)
1286 .fetch_one(&mut *conn)
1287 .await
1288 .unwrap();
1289 assert_eq!(restored, None);
1290 }
1291
1292 #[tokio::test]
1293 async fn affected_liveness_reconciliation_is_scoped_and_write_minimal() {
1294 let temp = tempfile::tempdir().unwrap();
1295 let pool = open_db(&temp.path().join("test.sqlite")).await.unwrap();
1296 let mut conn = pool.acquire().await.unwrap();
1297 let affected =
1298 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string();
1299 let unrelated =
1300 "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string();
1301 let clock = TestClock::at("2026-07-01T00:00:00Z");
1302 for hash in [&affected, &unrelated] {
1303 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1304 .await
1305 .unwrap();
1306 }
1307 insert_task(&mut conn, "0000000000000001").await;
1308 insert_attachment(
1309 &mut conn,
1310 "0000000000000011",
1311 "0000000000000001",
1312 &affected,
1313 false,
1314 )
1315 .await;
1316 for hash in [&affected, &unrelated] {
1317 sqlx::query("INSERT INTO blob_lifecycle(sha256, unreferenced_at) VALUES (?, ?)")
1318 .bind(hash)
1319 .bind("2026-06-01T00:00:00Z")
1320 .execute(&mut *conn)
1321 .await
1322 .unwrap();
1323 }
1324 sqlx::query(
1325 "CREATE TABLE lifecycle_updates(count INTEGER NOT NULL DEFAULT 0);
1326 CREATE TRIGGER count_lifecycle_updates
1327 AFTER UPDATE OF unreferenced_at ON blob_lifecycle
1328 BEGIN
1329 INSERT INTO lifecycle_updates(count) VALUES (1);
1330 END",
1331 )
1332 .execute(&mut *conn)
1333 .await
1334 .unwrap();
1335
1336 reconcile_liveness_for_hashes_in_transaction(
1337 &mut conn,
1338 std::slice::from_ref(&affected),
1339 &clock,
1340 )
1341 .await
1342 .unwrap();
1343 let unrelated_at: String =
1344 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1345 .bind(&unrelated)
1346 .fetch_one(&mut *conn)
1347 .await
1348 .unwrap();
1349 assert_eq!(unrelated_at, "2026-06-01T00:00:00Z");
1350 let updates: i64 = sqlx::query_scalar("SELECT count(*) FROM lifecycle_updates")
1351 .fetch_one(&mut *conn)
1352 .await
1353 .unwrap();
1354 assert_eq!(updates, 1, "the live affected row changes once");
1355
1356 reconcile_liveness_for_hashes_in_transaction(
1357 &mut conn,
1358 std::slice::from_ref(&affected),
1359 &clock,
1360 )
1361 .await
1362 .unwrap();
1363 let repeated_updates: i64 = sqlx::query_scalar("SELECT count(*) FROM lifecycle_updates")
1364 .fetch_one(&mut *conn)
1365 .await
1366 .unwrap();
1367 assert_eq!(repeated_updates, 1, "an already-live row is not rewritten");
1368
1369 sqlx::query(
1370 "UPDATE task_attachments SET deleted = 1, deleted_at = 'x'
1371 WHERE attachment_id = '0000000000000011'",
1372 )
1373 .execute(&mut *conn)
1374 .await
1375 .unwrap();
1376 reconcile_liveness_for_hashes_in_transaction(
1377 &mut conn,
1378 std::slice::from_ref(&affected),
1379 &clock,
1380 )
1381 .await
1382 .unwrap();
1383 let first_unreferenced: String =
1384 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1385 .bind(&affected)
1386 .fetch_one(&mut *conn)
1387 .await
1388 .unwrap();
1389 assert_eq!(first_unreferenced, "2026-07-01T00:00:00Z");
1390 clock.advance(chrono::Duration::days(1));
1391 reconcile_liveness_for_hashes_in_transaction(
1392 &mut conn,
1393 std::slice::from_ref(&affected),
1394 &clock,
1395 )
1396 .await
1397 .unwrap();
1398 let second_unreferenced: String =
1399 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1400 .bind(&affected)
1401 .fetch_one(&mut *conn)
1402 .await
1403 .unwrap();
1404 assert_eq!(first_unreferenced, second_unreferenced);
1405
1406 sqlx::query(
1407 "UPDATE task_attachments SET deleted = 0, deleted_at = NULL
1408 WHERE attachment_id = '0000000000000011'",
1409 )
1410 .execute(&mut *conn)
1411 .await
1412 .unwrap();
1413 reconcile_liveness(&mut conn, &clock).await.unwrap();
1414 let restored: Option<String> =
1415 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1416 .bind(&affected)
1417 .fetch_one(&mut *conn)
1418 .await
1419 .unwrap();
1420 assert_eq!(restored, None);
1421 let full_updates: i64 = sqlx::query_scalar("SELECT count(*) FROM lifecycle_updates")
1422 .fetch_one(&mut *conn)
1423 .await
1424 .unwrap();
1425 assert_eq!(full_updates, 3, "the full reset writes only the transition");
1426 }
1427
1428 #[tokio::test]
1429 async fn lease_protects_expired_unreferenced_blob_until_release() {
1430 let temp = tempfile::tempdir().unwrap();
1431 let db_path = temp.path().join("test.sqlite");
1432 let pool = open_db(&db_path).await.unwrap();
1433 let mut conn = pool.acquire().await.unwrap();
1434 let blob_dir = temp.path().join("blobs");
1435 let hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1436 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1437 .await
1438 .unwrap();
1439 let path = object_path(&blob_dir, hash).unwrap();
1440 fs::create_dir_all(path.parent().unwrap()).unwrap();
1441 fs::write(&path, b"blob").unwrap();
1442 let clock = TestClock::at("2026-07-10T00:00:00Z");
1443 reconcile_liveness(&mut conn, &clock).await.unwrap();
1444 clock.advance(chrono::Duration::days(8));
1445 let lease = acquire_lease(&mut conn, hash, "backup", &clock)
1446 .await
1447 .unwrap();
1448 let policy = LifecyclePolicy::default();
1449 let blocked = prune(&mut conn, &blob_dir, policy, true, &clock)
1450 .await
1451 .unwrap();
1452 assert_eq!(blocked.pruned.count, 0);
1453 assert!(path.exists());
1454
1455 release_lease(&mut conn, &lease).await.unwrap();
1456 let pruned = prune(&mut conn, &blob_dir, policy, true, &clock)
1457 .await
1458 .unwrap();
1459 assert_eq!(pruned.pruned.count, 1);
1460 assert!(!path.exists());
1461 }
1462
1463 #[tokio::test]
1464 async fn quota_is_unique_by_hash_and_reservations_are_workspace_scoped() {
1465 let temp = tempfile::tempdir().unwrap();
1466 let pool = open_db(&temp.path().join("test.sqlite")).await.unwrap();
1467 let mut conn = pool.acquire().await.unwrap();
1468 let hash = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1469 let clock = TestClock::at("2026-07-01T00:00:00Z");
1470 let first = reserve_upload(&mut conn, "workspace-a", hash, 8, 8, &clock)
1471 .await
1472 .unwrap();
1473 assert!(first.is_some());
1474 let replacement = reserve_upload(&mut conn, "workspace-a", hash, 8, 8, &clock)
1475 .await
1476 .unwrap();
1477 assert!(replacement.is_some());
1478 let other = reserve_upload(&mut conn, "workspace-b", hash, 8, 8, &clock)
1479 .await
1480 .unwrap();
1481 assert!(other.is_some());
1482 let count: i64 = sqlx::query_scalar("SELECT count(*) FROM blob_upload_reservations")
1483 .fetch_one(&mut *conn)
1484 .await
1485 .unwrap();
1486 assert_eq!(count, 2);
1487 }
1488
1489 #[tokio::test]
1490 async fn concurrent_attach_wins_prune_recheck() {
1491 let temp = tempfile::tempdir().unwrap();
1492 let db_path = temp.path().join("test.sqlite");
1493 let pool = open_db(&db_path).await.unwrap();
1494 let mut conn = pool.acquire().await.unwrap();
1495 let blob_dir = temp.path().join("blobs");
1496 let hash = "abababababababababababababababababababababababababababababababab";
1497 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1498 .await
1499 .unwrap();
1500 insert_task(&mut conn, "0000000000000003").await;
1501 let path = object_path(&blob_dir, hash).unwrap();
1502 fs::create_dir_all(path.parent().unwrap()).unwrap();
1503 fs::write(&path, b"blob").unwrap();
1504 let clock = TestClock::at("2026-07-01T00:00:00Z");
1505 reconcile_liveness(&mut conn, &clock).await.unwrap();
1506 clock.advance(chrono::Duration::days(8));
1507 let options = SqliteConnectOptions::new()
1508 .filename(&db_path)
1509 .busy_timeout(Duration::from_secs(5));
1510 let prune_conn = SqliteConnection::connect_with(&options).await.unwrap();
1511
1512 let mut tx = begin_immediate(&mut conn).await.unwrap();
1513 insert_attachment(&mut tx, "0000000000000013", "0000000000000003", hash, false).await;
1514 let prune_dir = blob_dir.clone();
1515 let prune_clock = clock.clone();
1516 let pruning = tokio::spawn(async move {
1517 let mut prune_conn = prune_conn;
1518 prune(
1519 &mut prune_conn,
1520 &prune_dir,
1521 LifecyclePolicy::default(),
1522 true,
1523 &prune_clock,
1524 )
1525 .await
1526 .unwrap()
1527 });
1528 tokio::time::sleep(Duration::from_millis(50)).await;
1529 tx.commit().await.unwrap();
1530
1531 let summary = pruning.await.unwrap();
1532 assert_eq!(summary.pruned.count, 0);
1533 assert!(path.exists());
1534 }
1535
1536 #[tokio::test]
1537 async fn accepted_server_reference_keeps_blob_live() {
1538 let temp = tempfile::tempdir().unwrap();
1539 let pool = open_db(&temp.path().join("test.sqlite")).await.unwrap();
1540 let mut conn = pool.acquire().await.unwrap();
1541 let hash = "acacacacacacacacacacacacacacacacacacacacacacacacacacacacacacacac";
1542 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1543 .await
1544 .unwrap();
1545 sqlx::query(
1546 "INSERT INTO server_blob_references(
1547 workspace_id, attachment_id, task_id, sha256, byte_size
1548 ) VALUES ('workspace', '0000000000000042', '0000000000000004', ?, 4)",
1549 )
1550 .bind(hash)
1551 .execute(&mut *conn)
1552 .await
1553 .unwrap();
1554 let clock = TestClock::at("2026-07-01T00:00:00Z");
1555
1556 reconcile_liveness(&mut conn, &clock).await.unwrap();
1557 let unreferenced_at: Option<String> =
1558 sqlx::query_scalar("SELECT unreferenced_at FROM blob_lifecycle WHERE sha256 = ?")
1559 .bind(hash)
1560 .fetch_one(&mut *conn)
1561 .await
1562 .unwrap();
1563 assert_eq!(unreferenced_at, None);
1564 }
1565
1566 #[tokio::test]
1567 async fn local_quota_boundary_is_hash_idempotent() {
1568 let temp = tempfile::tempdir().unwrap();
1569 let db_path = temp.path().join("test.sqlite");
1570 let pool = open_db(&db_path).await.unwrap();
1571 let mut conn = pool.acquire().await.unwrap();
1572 let blob_dir = temp.path().join("blobs");
1573 let existing = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
1574 let new_hash = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
1575 upsert_inventory_available(&mut conn, existing, 8, "image/png")
1576 .await
1577 .unwrap();
1578 let clock = TestClock::at("2026-07-01T00:00:00Z");
1579 let policy = LifecyclePolicy {
1580 quota_bytes: 8,
1581 ..LifecyclePolicy::default()
1582 };
1583
1584 ensure_local_capacity(&mut conn, &blob_dir, existing, 8, policy, &clock)
1585 .await
1586 .unwrap();
1587 let error = ensure_local_capacity(&mut conn, &blob_dir, new_hash, 1, policy, &clock)
1588 .await
1589 .unwrap_err();
1590 assert_eq!(error.to_string(), "error attachment-quota-exceeded");
1591 }
1592
1593 #[tokio::test]
1594 async fn unavailable_directory_releases_cursor_owned_by_lexical_path() {
1595 let temp = tempfile::tempdir().unwrap();
1596 let scan_root = temp.path().join("scan");
1597 let lexical_root = temp.path().join("anchor").join("..").join("scan");
1598 fs::create_dir_all(temp.path().join("anchor")).unwrap();
1599 fs::create_dir_all(&scan_root).unwrap();
1600 fs::write(scan_root.join("first"), b"first").unwrap();
1601 fs::write(scan_root.join("second"), b"second").unwrap();
1602 let canonical_root = fs::canonicalize(&lexical_root).unwrap();
1603
1604 scan_directory_page(lexical_root.clone(), 1).await.unwrap();
1605 assert!(
1606 DIRECTORY_CURSORS
1607 .lock()
1608 .unwrap()
1609 .contains_key(&lexical_root)
1610 );
1611
1612 fs::remove_dir_all(&scan_root).unwrap();
1613 let _ = scan_directory_page(lexical_root.clone(), 1).await;
1614
1615 let cursors = DIRECTORY_CURSORS.lock().unwrap();
1616 assert!(!cursors.contains_key(&lexical_root));
1617 assert!(!cursors.contains_key(&canonical_root));
1618 }
1619
1620 #[tokio::test]
1621 async fn directory_iteration_error_releases_cursor_state() {
1622 let temp = tempfile::tempdir().unwrap();
1623 let dir = temp.path().join("scan");
1624 fs::create_dir_all(&dir).unwrap();
1625 fs::write(dir.join("first"), b"first").unwrap();
1626 fs::write(dir.join("second"), b"second").unwrap();
1627
1628 scan_directory_page(dir.clone(), 1).await.unwrap();
1629 assert!(DIRECTORY_CURSORS.lock().unwrap().contains_key(&dir));
1630
1631 let result = scan_directory_page_with(dir.clone(), 1, |_| {
1632 Err(io::Error::other("injected directory iteration failure"))
1633 })
1634 .await;
1635 assert!(result.as_ref().is_err_and(|error| {
1636 error
1637 .to_string()
1638 .contains("injected directory iteration failure")
1639 }));
1640 assert!(!DIRECTORY_CURSORS.lock().unwrap().contains_key(&dir));
1641 }
1642
1643 #[test]
1644 fn file_move_copies_atomically_when_hard_links_are_unavailable() {
1645 let temp = tempfile::tempdir().unwrap();
1646 let source = temp.path().join("source");
1647 let target = temp.path().join("target");
1648 fs::write(&source, b"attachment").unwrap();
1649
1650 let moved = move_file_without_replacing_with(&source, &target, |_, _| {
1651 Err(io::Error::new(
1652 io::ErrorKind::Unsupported,
1653 "injected hard-link failure",
1654 ))
1655 })
1656 .unwrap();
1657
1658 assert_eq!(moved, FileMove::Moved);
1659 assert!(!source.exists());
1660 assert_eq!(fs::read(target).unwrap(), b"attachment");
1661 }
1662
1663 #[test]
1664 fn file_move_copy_fallback_does_not_replace_target() {
1665 let temp = tempfile::tempdir().unwrap();
1666 let source = temp.path().join("source");
1667 let target = temp.path().join("target");
1668 fs::write(&source, b"source").unwrap();
1669 fs::write(&target, b"target").unwrap();
1670
1671 let moved = move_file_without_replacing_with(&source, &target, |_, _| {
1672 Err(io::Error::new(
1673 io::ErrorKind::Unsupported,
1674 "injected hard-link failure",
1675 ))
1676 })
1677 .unwrap();
1678
1679 assert_eq!(moved, FileMove::TargetExists);
1680 assert_eq!(fs::read(source).unwrap(), b"source");
1681 assert_eq!(fs::read(target).unwrap(), b"target");
1682 }
1683
1684 #[tokio::test]
1685 async fn interrupted_atomic_create_object_is_reconciled_after_grace() {
1686 let temp = tempfile::tempdir().unwrap();
1687 let db_path = temp.path().join("test.sqlite");
1688 let pool = open_db(&db_path).await.unwrap();
1689 let mut conn = pool.acquire().await.unwrap();
1690 let blob_dir = temp.path().join("blobs");
1691 let hash = "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
1692 let path = object_path(&blob_dir, hash).unwrap();
1693 fs::create_dir_all(path.parent().unwrap()).unwrap();
1694 fs::write(&path, b"orphan").unwrap();
1695 let clock = TestClock::at("2030-07-01T00:00:00Z");
1696 let policy = LifecyclePolicy {
1697 grace: Duration::ZERO,
1698 ..LifecyclePolicy::default()
1699 };
1700
1701 prune(&mut conn, &blob_dir, policy, true, &clock)
1702 .await
1703 .unwrap();
1704 assert!(!path.exists());
1705 }
1706
1707 #[tokio::test]
1708 async fn orphan_traversal_obeys_limit_and_resumes_between_steps() {
1709 let temp = tempfile::tempdir().unwrap();
1710 let pool = open_db(&temp.path().join("test.sqlite")).await.unwrap();
1711 let mut conn = pool.acquire().await.unwrap();
1712 let blob_dir = temp.path().join("blobs");
1713 for digit in ['6', '7', '8', '9', 'a'] {
1714 let hash = digit.to_string().repeat(64);
1715 let path = object_path(&blob_dir, &hash).unwrap();
1716 fs::create_dir_all(path.parent().unwrap()).unwrap();
1717 fs::write(path, b"orphan").unwrap();
1718 }
1719 let clock = TestClock::at("2030-07-01T00:00:00Z");
1720 let policy = LifecyclePolicy {
1721 grace: Duration::ZERO,
1722 maintenance_limit: 2,
1723 ..LifecyclePolicy::default()
1724 };
1725
1726 prune(&mut conn, &blob_dir, policy, true, &clock)
1727 .await
1728 .unwrap();
1729 let after_one = fs::read_dir(staging_dir(&blob_dir)).unwrap().count();
1730 assert!(after_one >= 3);
1731
1732 for _ in 0..4 {
1733 prune(&mut conn, &blob_dir, policy, true, &clock)
1734 .await
1735 .unwrap();
1736 }
1737 assert_eq!(fs::read_dir(staging_dir(&blob_dir)).unwrap().count(), 0);
1738 }
1739
1740 #[tokio::test]
1741 async fn interrupted_trash_move_restores_available_object() {
1742 let temp = tempfile::tempdir().unwrap();
1743 let db_path = temp.path().join("test.sqlite");
1744 let pool = open_db(&db_path).await.unwrap();
1745 let mut conn = pool.acquire().await.unwrap();
1746 let blob_dir = temp.path().join("blobs");
1747 let hash = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
1748 upsert_inventory_available(&mut conn, hash, 4, "image/png")
1749 .await
1750 .unwrap();
1751 let source = object_path(&blob_dir, hash).unwrap();
1752 let trash = trash_dir(&blob_dir).join(hash);
1753 fs::create_dir_all(source.parent().unwrap()).unwrap();
1754 fs::create_dir_all(trash.parent().unwrap()).unwrap();
1755 fs::write(&source, b"blob").unwrap();
1756 fs::rename(&source, &trash).unwrap();
1757
1758 reconcile_trash(&mut conn, &blob_dir).await.unwrap();
1759 assert!(source.exists());
1760 assert!(!trash.exists());
1761 }
1762}