Skip to main content

aven_core/attachments/
lifecycle.rs

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}