Skip to main content

aven_core/operations/
attachments.rs

1use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
2use std::path::Path;
3
4use anyhow::{Result, bail};
5use sqlx::SqliteConnection;
6use sqlx::{AssertSqlSafe, Row};
7
8use crate::attachments::AttachmentBytesState;
9use crate::attachments::decode::{ImageFacts, ValidatedImage, validate_image};
10use crate::attachments::optimization::{ImageOptimizationPolicy, optimize_image_bytes};
11use crate::attachments::storage::{blob_inventory_row, sha256_hex, store_validated_blob};
12use crate::attachments::validation::{
13    validate_alt_text, validate_attachment_id, validate_filename,
14};
15use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
16use crate::db::{Database, begin_immediate};
17use crate::ids::{TaskId, WorkspaceId, new_id, now};
18use crate::types::TaskAttachment;
19use crate::workspaces::Workspace;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AttachmentAddInput {
23    pub filename: Option<String>,
24    pub alt_text: Option<String>,
25    pub declared_media_type: Option<String>,
26    pub bytes: Vec<u8>,
27    pub optimization_policy: ImageOptimizationPolicy,
28    pub dedupe_existing: bool,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TaskAttachmentAddInput {
33    pub attachment_id: String,
34    pub input: AttachmentAddInput,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PreparedAttachment {
39    pub attachment_id: String,
40    pub filename: Option<String>,
41    pub alt_text: Option<String>,
42    pub sha256: String,
43    pub byte_size: i64,
44    pub facts: ImageFacts,
45    pub bytes: Vec<u8>,
46    pub optimized: bool,
47}
48
49pub struct AttachmentAddOutcome {
50    pub outcome: AttachmentOutcome,
51    pub created: bool,
52    pub optimized: bool,
53}
54
55pub struct AttachmentOutcome {
56    pub attachment: TaskAttachment,
57    pub has_blob: bool,
58}
59
60#[derive(Debug, Clone, Copy)]
61pub(crate) enum AttachmentReadFailure {
62    Invalidated,
63    Unavailable,
64}
65
66impl std::fmt::Display for AttachmentReadFailure {
67    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        formatter.write_str(match self {
69            Self::Invalidated => "error attachment-invalidated",
70            Self::Unavailable => "error attachment-blob-unavailable",
71        })
72    }
73}
74
75impl std::error::Error for AttachmentReadFailure {}
76
77pub struct AttachmentReadLease {
78    pub task_id: String,
79    pub byte_size: i64,
80    pub sha256: String,
81    pub media_type: String,
82    pub lease_id: String,
83}
84
85#[derive(Debug, Clone)]
86pub struct AttachmentReadItem {
87    pub attachment: TaskAttachment,
88    pub bytes_state: AttachmentBytesState,
89    pub has_blob: bool,
90}
91
92const ATTACHMENT_COLUMNS: &str =
93    "ta.workspace_id, ta.attachment_id, ta.task_id, ta.sha256, ta.byte_size,
94    ta.media_type, ta.filename, ta.alt_text, ta.width, ta.height,
95    ta.created_at, ta.created_by_change_id, ta.deleted, ta.deleted_at, ta.deleted_by_change_id";
96
97fn attachment_from_row(row: &sqlx::sqlite::SqliteRow) -> TaskAttachment {
98    TaskAttachment {
99        workspace_id: row.get("workspace_id"),
100        attachment_id: row.get("attachment_id"),
101        task_id: row.get("task_id"),
102        sha256: row.get("sha256"),
103        byte_size: row.get("byte_size"),
104        media_type: row.get("media_type"),
105        filename: row.get("filename"),
106        alt_text: row.get("alt_text"),
107        width: row.get("width"),
108        height: row.get("height"),
109        created_at: row.get("created_at"),
110        created_by_change_id: row.get("created_by_change_id"),
111        deleted: row.get::<i64, _>("deleted") != 0,
112        deleted_at: row.get("deleted_at"),
113        deleted_by_change_id: row.get("deleted_by_change_id"),
114    }
115}
116
117async fn require_attachment(
118    conn: &mut SqliteConnection,
119    workspace_id: &WorkspaceId,
120    attachment_id: &str,
121) -> Result<TaskAttachment> {
122    let row = sqlx::query(AssertSqlSafe(format!(
123        "SELECT {ATTACHMENT_COLUMNS}
124         FROM task_attachments ta
125         WHERE ta.workspace_id = ? AND ta.attachment_id = ?"
126    )))
127    .bind(workspace_id)
128    .bind(attachment_id)
129    .fetch_optional(&mut *conn)
130    .await?;
131
132    let Some(row) = row else {
133        bail!("error attachment-not-found id={}", attachment_id);
134    };
135    Ok(attachment_from_row(&row))
136}
137
138async fn attachment_bytes_state(
139    conn: &mut SqliteConnection,
140    sha256: &str,
141) -> Result<AttachmentBytesState> {
142    Ok(match blob_inventory_row(conn, sha256).await? {
143        Some(row) if row.available => AttachmentBytesState::Present,
144        Some(_) => AttachmentBytesState::Unavailable,
145        None => AttachmentBytesState::PendingDownload,
146    })
147}
148
149async fn attachment_has_blob(conn: &mut SqliteConnection, sha256: &str) -> Result<bool> {
150    Ok(attachment_bytes_state(conn, sha256).await? == AttachmentBytesState::Present)
151}
152
153async fn existing_live_attachments_by_sha(
154    conn: &mut SqliteConnection,
155    workspace_id: &crate::ids::WorkspaceId,
156    task_id: &TaskId,
157    sha256: &str,
158) -> Result<Vec<TaskAttachment>> {
159    let rows = sqlx::query(AssertSqlSafe(format!(
160        "SELECT {ATTACHMENT_COLUMNS}
161         FROM task_attachments ta
162         WHERE ta.workspace_id = ? AND ta.task_id = ? AND ta.sha256 = ? AND ta.deleted = 0
163         ORDER BY ta.created_at, ta.attachment_id"
164    )))
165    .bind(workspace_id)
166    .bind(task_id)
167    .bind(sha256)
168    .fetch_all(&mut *conn)
169    .await?;
170
171    Ok(rows.iter().map(attachment_from_row).collect())
172}
173
174pub async fn prepare_task_attachment(input: TaskAttachmentAddInput) -> Result<PreparedAttachment> {
175    validate_attachment_id(&input.attachment_id)?;
176    validate_filename(input.input.filename.as_deref())?;
177    validate_alt_text(input.input.alt_text.as_deref())?;
178
179    let source = validate_image(input.input.bytes, input.input.declared_media_type).await?;
180    let source_facts = source.facts.clone();
181    let optimized = optimize_image_bytes(
182        &source_facts.media_type,
183        source.bytes,
184        input.input.optimization_policy,
185    )
186    .await?;
187    let optimized_flag = optimized.optimized;
188    let stored_image = if optimized_flag {
189        validate_image(optimized.bytes, None).await?
190    } else {
191        ValidatedImage {
192            bytes: optimized.bytes,
193            facts: source_facts,
194        }
195    };
196    let sha256 = sha256_hex(&stored_image.bytes);
197    let byte_size = i64::try_from(stored_image.bytes.len())?;
198    Ok(PreparedAttachment {
199        attachment_id: input.attachment_id,
200        filename: input.input.filename,
201        alt_text: input.input.alt_text,
202        sha256,
203        byte_size,
204        facts: stored_image.facts,
205        bytes: stored_image.bytes,
206        optimized: optimized_flag,
207    })
208}
209
210struct AttachmentRecordDraft<'a> {
211    attachment_id: &'a str,
212    sha256: &'a str,
213    byte_size: i64,
214    media_type: &'a str,
215    filename: Option<&'a str>,
216    alt_text: Option<&'a str>,
217    width: i64,
218    height: i64,
219    created_at: &'a str,
220}
221
222async fn record_attachment_add(
223    conn: &mut SqliteConnection,
224    workspace: &Workspace,
225    task_id: &TaskId,
226    draft: AttachmentRecordDraft<'_>,
227) -> Result<String> {
228    let change_id = append_change(
229        conn,
230        ChangeEntity::Task,
231        task_id,
232        Some("attachments"),
233        op_type::ATTACHMENT_ADD,
234        ChangePayload::workspace(workspace)
235            .set("attachment_id", draft.attachment_id)
236            .set("sha256", draft.sha256)
237            .set("byte_size", draft.byte_size)
238            .set("media_type", draft.media_type)
239            .set("filename", draft.filename)
240            .set("alt_text", draft.alt_text)
241            .set("width", draft.width)
242            .set("height", draft.height)
243            .set("created_at", draft.created_at),
244    )
245    .await?;
246    sqlx::query(
247        "INSERT INTO task_attachments(workspace_id, attachment_id, task_id, sha256, byte_size, media_type, filename, alt_text, width, height, created_at, created_by_change_id)
248         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
249    )
250    .bind(&workspace.id)
251    .bind(draft.attachment_id)
252    .bind(task_id)
253    .bind(draft.sha256)
254    .bind(draft.byte_size)
255    .bind(draft.media_type)
256    .bind(draft.filename)
257    .bind(draft.alt_text)
258    .bind(draft.width)
259    .bind(draft.height)
260    .bind(draft.created_at)
261    .bind(&change_id)
262    .execute(&mut *conn)
263    .await?;
264    Ok(change_id)
265}
266
267pub(super) async fn insert_prepared_attachment(
268    conn: &mut SqliteConnection,
269    workspace: &Workspace,
270    task_id: &TaskId,
271    prepared: &PreparedAttachment,
272    created_at: &str,
273) -> Result<String> {
274    record_attachment_add(
275        conn,
276        workspace,
277        task_id,
278        AttachmentRecordDraft {
279            attachment_id: &prepared.attachment_id,
280            sha256: &prepared.sha256,
281            byte_size: prepared.byte_size,
282            media_type: &prepared.facts.media_type,
283            filename: prepared.filename.as_deref(),
284            alt_text: prepared.alt_text.as_deref(),
285            width: prepared.facts.width,
286            height: prepared.facts.height,
287            created_at,
288        },
289    )
290    .await
291}
292
293struct AttachmentCommitIdentity {
294    attachment_id: Option<String>,
295    created_at: Option<String>,
296}
297
298pub async fn add_task_attachment(
299    conn: &mut SqliteConnection,
300    workspace: &Workspace,
301    blob_dir: &Path,
302    policy: crate::attachments::lifecycle::LifecyclePolicy,
303    task_id: &TaskId,
304    input: AttachmentAddInput,
305) -> Result<AttachmentAddOutcome> {
306    add_task_attachment_inner(
307        conn,
308        workspace,
309        blob_dir,
310        policy,
311        task_id,
312        AttachmentCommitIdentity {
313            attachment_id: None,
314            created_at: None,
315        },
316        input,
317    )
318    .await
319}
320
321pub(crate) async fn add_ordered_task_attachment(
322    conn: &mut SqliteConnection,
323    workspace: &Workspace,
324    blob_dir: &Path,
325    policy: crate::attachments::lifecycle::LifecyclePolicy,
326    task_id: &TaskId,
327    created_at: String,
328    input: TaskAttachmentAddInput,
329) -> Result<AttachmentAddOutcome> {
330    add_task_attachment_inner(
331        conn,
332        workspace,
333        blob_dir,
334        policy,
335        task_id,
336        AttachmentCommitIdentity {
337            attachment_id: Some(input.attachment_id),
338            created_at: Some(created_at),
339        },
340        input.input,
341    )
342    .await
343}
344
345async fn add_task_attachment_inner(
346    conn: &mut SqliteConnection,
347    workspace: &Workspace,
348    blob_dir: &Path,
349    policy: crate::attachments::lifecycle::LifecyclePolicy,
350    task_id: &TaskId,
351    identity: AttachmentCommitIdentity,
352    input: AttachmentAddInput,
353) -> Result<AttachmentAddOutcome> {
354    validate_filename(input.filename.as_deref())?;
355    validate_alt_text(input.alt_text.as_deref())?;
356
357    let source = validate_image(input.bytes, input.declared_media_type).await?;
358    let source_facts = source.facts.clone();
359    let optimized = optimize_image_bytes(
360        &source_facts.media_type,
361        source.bytes,
362        input.optimization_policy,
363    )
364    .await?;
365    let optimized_flag = optimized.optimized;
366    let stored_image = if optimized_flag {
367        validate_image(optimized.bytes, Some(source_facts.media_type.clone())).await?
368    } else {
369        ValidatedImage {
370            bytes: optimized.bytes,
371            facts: source_facts,
372        }
373    };
374    let sha256 = sha256_hex(&stored_image.bytes);
375    let byte_size = i64::try_from(stored_image.bytes.len())?;
376    let capacity_reservation = crate::attachments::lifecycle::ensure_local_capacity(
377        conn,
378        blob_dir,
379        &sha256,
380        byte_size,
381        policy,
382        &crate::attachments::lifecycle::SystemClock,
383    )
384    .await?;
385    let staging_lease = crate::attachments::lifecycle::acquire_lease(
386        conn,
387        &sha256,
388        "staging",
389        &crate::attachments::lifecycle::SystemClock,
390    )
391    .await?;
392    let stored = match store_validated_blob(conn, blob_dir, stored_image).await {
393        Ok(stored) => stored,
394        Err(error) => {
395            let _ = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
396            if let Some(reservation_id) = capacity_reservation.as_deref() {
397                let _ =
398                    crate::attachments::lifecycle::release_reservation(conn, reservation_id).await;
399            }
400            return Err(error);
401        }
402    };
403    if let Some(reservation_id) = capacity_reservation {
404        crate::attachments::lifecycle::release_reservation(conn, &reservation_id).await?;
405    }
406    let database_result = async {
407        let mutation_at = now();
408        let mut tx = begin_immediate(conn).await?;
409        crate::operations::route_recurrence_task_mutation(
410            &mut tx,
411            workspace,
412            task_id,
413            RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Attachments),
414            &mutation_at,
415        )
416        .await?;
417        let task_exists = sqlx::query_scalar::<_, bool>(
418            "SELECT EXISTS(SELECT 1 FROM tasks WHERE workspace_id = ? AND id = ?)",
419        )
420        .bind(&workspace.id)
421        .bind(task_id)
422        .fetch_one(&mut *tx)
423        .await?;
424        if !task_exists {
425            bail!("error task-not-found task_id={task_id}");
426        }
427
428        if input.dedupe_existing
429            && let Some(existing) =
430                existing_live_attachments_by_sha(&mut tx, &workspace.id, task_id, &stored.sha256)
431                    .await?
432                    .first()
433        {
434            let attachment_id = existing.attachment_id.clone();
435            crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
436                &mut tx,
437                std::slice::from_ref(&stored.sha256),
438                &crate::attachments::lifecycle::SystemClock,
439            )
440            .await?;
441            tx.commit().await?;
442            return Ok::<_, anyhow::Error>((attachment_id, false));
443        }
444
445        let attachment_id = identity.attachment_id.unwrap_or_else(new_id);
446        let created_at = identity.created_at.unwrap_or_else(|| mutation_at.clone());
447        record_attachment_add(
448            &mut tx,
449            workspace,
450            task_id,
451            AttachmentRecordDraft {
452                attachment_id: &attachment_id,
453                sha256: &stored.sha256,
454                byte_size: stored.byte_size,
455                media_type: &stored.facts.media_type,
456                filename: input.filename.as_deref(),
457                alt_text: input.alt_text.as_deref(),
458                width: stored.facts.width,
459                height: stored.facts.height,
460                created_at: &created_at,
461            },
462        )
463        .await?;
464        crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
465            &mut tx,
466            std::slice::from_ref(&stored.sha256),
467            &crate::attachments::lifecycle::SystemClock,
468        )
469        .await?;
470        tx.commit().await?;
471        Ok((attachment_id, true))
472    }
473    .await;
474    let release_result = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
475    let (attachment_id, created) = match database_result {
476        Ok(result) => result,
477        Err(error) => {
478            crate::attachments::storage::remove_staged_blob_if_unreferenced(
479                conn,
480                blob_dir,
481                &stored.sha256,
482            )
483            .await;
484            return Err(error);
485        }
486    };
487    release_result?;
488    crate::attachments::lifecycle::reconcile_liveness(
489        conn,
490        &crate::attachments::lifecycle::SystemClock,
491    )
492    .await?;
493    let outcome = attachment_by_id(conn, workspace, &attachment_id).await?;
494    Ok(AttachmentAddOutcome {
495        outcome,
496        created,
497        optimized: optimized_flag,
498    })
499}
500
501pub async fn attachment_by_id(
502    conn: &mut SqliteConnection,
503    workspace: &Workspace,
504    attachment_id: &str,
505) -> Result<AttachmentOutcome> {
506    let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
507    let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
508    Ok(AttachmentOutcome {
509        attachment,
510        has_blob,
511    })
512}
513
514pub async fn delete_task_attachment(
515    conn: &mut SqliteConnection,
516    workspace: &Workspace,
517    attachment_id: &str,
518) -> Result<AttachmentOutcome> {
519    let deleted_at = now();
520    let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
521    if attachment.deleted {
522        let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
523        return Ok(AttachmentOutcome {
524            attachment,
525            has_blob,
526        });
527    }
528
529    let task_id = attachment.task_id.clone();
530
531    let mut tx = begin_immediate(conn).await?;
532    crate::operations::route_recurrence_task_mutation(
533        &mut tx,
534        workspace,
535        &task_id,
536        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Attachments),
537        &deleted_at,
538    )
539    .await?;
540
541    let change_id = append_change(
542        &mut tx,
543        ChangeEntity::Task,
544        &task_id,
545        Some("attachments"),
546        op_type::ATTACHMENT_DELETE,
547        ChangePayload::workspace(workspace)
548            .set("attachment_id", attachment_id)
549            .set("filename", attachment.filename.as_deref())
550            .set("media_type", &attachment.media_type)
551            .set("deleted_at", &deleted_at),
552    )
553    .await?;
554
555    sqlx::query(
556        "UPDATE task_attachments SET deleted = 1, deleted_at = ?, deleted_by_change_id = ? WHERE workspace_id = ? AND attachment_id = ?",
557    )
558    .bind(&deleted_at)
559    .bind(&change_id)
560    .bind(&workspace.id)
561    .bind(attachment_id)
562    .execute(&mut *tx)
563    .await?;
564
565    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
566        &mut tx,
567        std::slice::from_ref(&attachment.sha256),
568        &crate::attachments::lifecycle::SystemClock,
569    )
570    .await?;
571    tx.commit().await?;
572
573    attachment_by_id(conn, workspace, attachment_id).await
574}
575
576pub async fn attachment_read_items_by_task(
577    conn: &mut SqliteConnection,
578    workspace_id: &str,
579    task_id: &str,
580    include_deleted: bool,
581) -> Result<Vec<AttachmentReadItem>> {
582    let attachments = attachments_by_task(conn, workspace_id, task_id, include_deleted).await?;
583    let mut items = Vec::with_capacity(attachments.len());
584    for attachment in attachments {
585        let bytes_state = attachment_bytes_state(conn, &attachment.sha256).await?;
586        items.push(AttachmentReadItem {
587            attachment,
588            has_blob: bytes_state == AttachmentBytesState::Present,
589            bytes_state,
590        });
591    }
592    Ok(items)
593}
594
595pub async fn attachments_by_task(
596    conn: &mut SqliteConnection,
597    workspace_id: &str,
598    task_id: &str,
599    include_deleted: bool,
600) -> Result<Vec<TaskAttachment>> {
601    let deleted_predicate = if include_deleted {
602        ""
603    } else {
604        " AND ta.deleted = 0"
605    };
606    let rows = sqlx::query(AssertSqlSafe(format!(
607        "SELECT {ATTACHMENT_COLUMNS}
608         FROM task_attachments ta
609         WHERE ta.workspace_id = ? AND ta.task_id = ?{deleted_predicate}
610         ORDER BY ta.created_at, ta.attachment_id"
611    )))
612    .bind(workspace_id)
613    .bind(task_id)
614    .fetch_all(&mut *conn)
615    .await?;
616
617    let mut attachments = Vec::with_capacity(rows.len());
618    for row in &rows {
619        attachments.push(attachment_from_row(row));
620    }
621    Ok(attachments)
622}
623
624impl Database {
625    pub async fn add_task_attachment(
626        &self,
627        workspace: &Workspace,
628        blob_dir: &Path,
629        policy: crate::attachments::lifecycle::LifecyclePolicy,
630        task_id: &TaskId,
631        input: AttachmentAddInput,
632    ) -> Result<AttachmentAddOutcome> {
633        let mut conn = self.acquire_writer().await?;
634        add_task_attachment(&mut conn, workspace, blob_dir, policy, task_id, input).await
635    }
636
637    pub async fn add_ordered_task_attachment(
638        &self,
639        workspace: &Workspace,
640        blob_dir: &Path,
641        policy: crate::attachments::lifecycle::LifecyclePolicy,
642        task_id: &TaskId,
643        created_at: String,
644        input: TaskAttachmentAddInput,
645    ) -> Result<AttachmentAddOutcome> {
646        let mut conn = self.acquire_writer().await?;
647        add_ordered_task_attachment(
648            &mut conn, workspace, blob_dir, policy, task_id, created_at, input,
649        )
650        .await
651    }
652
653    pub async fn attachment_by_id(
654        &self,
655        workspace: &Workspace,
656        attachment_id: &str,
657    ) -> Result<AttachmentOutcome> {
658        let mut conn = self.acquire_reader().await?;
659        attachment_by_id(&mut conn, workspace, attachment_id).await
660    }
661
662    pub async fn delete_task_attachment(
663        &self,
664        workspace: &Workspace,
665        attachment_id: &str,
666    ) -> Result<AttachmentOutcome> {
667        let mut conn = self.acquire_writer().await?;
668        delete_task_attachment(&mut conn, workspace, attachment_id).await
669    }
670
671    pub async fn attachment_read_items_by_task(
672        &self,
673        workspace_id: &WorkspaceId,
674        task_id: &TaskId,
675        include_deleted: bool,
676    ) -> Result<Vec<AttachmentReadItem>> {
677        let mut conn = self.acquire_reader().await?;
678        attachment_read_items_by_task(
679            &mut conn,
680            workspace_id.as_str(),
681            task_id.as_str(),
682            include_deleted,
683        )
684        .await
685    }
686
687    pub async fn prune_attachments(
688        &self,
689        blob_dir: &Path,
690        policy: crate::attachments::lifecycle::LifecyclePolicy,
691        apply: bool,
692    ) -> Result<crate::attachments::lifecycle::PruneSummary> {
693        let mut conn = self.acquire_writer().await?;
694        crate::attachments::lifecycle::prune(
695            &mut conn,
696            blob_dir,
697            policy,
698            apply,
699            &crate::attachments::lifecycle::SystemClock,
700        )
701        .await
702    }
703
704    pub async fn acquire_attachment_lease(&self, sha256: &str, purpose: &str) -> Result<String> {
705        let mut conn = self.acquire_writer().await?;
706        crate::attachments::lifecycle::acquire_lease(
707            &mut conn,
708            sha256,
709            purpose,
710            &crate::attachments::lifecycle::SystemClock,
711        )
712        .await
713    }
714
715    pub async fn acquire_live_attachment_read_lease(
716        &self,
717        workspace: &Workspace,
718        attachment_id: &str,
719    ) -> Result<AttachmentReadLease> {
720        let mut conn = self.acquire_writer().await?;
721        let mut tx = begin_immediate(&mut conn).await?;
722        let row = sqlx::query(
723            "SELECT ta.task_id, ta.byte_size, ta.sha256, ta.media_type, bi.available
724             FROM task_attachments ta
725             JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
726             LEFT JOIN blob_inventory bi ON bi.sha256 = ta.sha256
727             WHERE ta.workspace_id = ? AND ta.attachment_id = ?
728               AND ta.deleted = 0 AND t.deleted = 0",
729        )
730        .bind(&workspace.id)
731        .bind(attachment_id)
732        .fetch_optional(&mut *tx)
733        .await?
734        .ok_or(AttachmentReadFailure::Invalidated)?;
735        if !row.try_get::<bool, _>("available").unwrap_or(false) {
736            return Err(AttachmentReadFailure::Unavailable.into());
737        }
738        let sha256: String = row.get("sha256");
739        let media_type: String = row.get("media_type");
740        let lease_id = crate::attachments::lifecycle::acquire_lease(
741            &mut tx,
742            &sha256,
743            "read",
744            &crate::attachments::lifecycle::SystemClock,
745        )
746        .await?;
747        tx.commit().await?;
748        Ok(AttachmentReadLease {
749            task_id: row.get("task_id"),
750            byte_size: row.get("byte_size"),
751            sha256,
752            media_type,
753            lease_id,
754        })
755    }
756
757    pub async fn release_attachment_lease(&self, lease_id: &str) -> Result<()> {
758        let mut conn = self.acquire_writer().await?;
759        crate::attachments::lifecycle::release_lease(&mut conn, lease_id).await
760    }
761
762    pub async fn attachment_lifecycle_report(
763        &self,
764        blob_dir: &Path,
765        policy: crate::attachments::lifecycle::LifecyclePolicy,
766    ) -> Result<crate::attachments::lifecycle::LifecycleReport> {
767        let mut conn = self.acquire_writer().await?;
768        crate::attachments::lifecycle::lifecycle_report(
769            &mut conn,
770            blob_dir,
771            policy,
772            &crate::attachments::lifecycle::SystemClock,
773        )
774        .await
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use std::time::Duration;
781
782    use super::*;
783
784    #[tokio::test]
785    async fn lifecycle_report_waits_for_guarded_writer() {
786        let temp = tempfile::tempdir().unwrap();
787        let database = Database::open(&temp.path().join("lifecycle.sqlite"))
788            .await
789            .unwrap();
790        let writer = database.acquire_writer().await.unwrap();
791
792        let reporting_database = database.clone();
793        let blob_dir = temp.path().join("blobs");
794        let mut report = tokio::spawn(async move {
795            reporting_database
796                .attachment_lifecycle_report(
797                    &blob_dir,
798                    crate::attachments::lifecycle::LifecyclePolicy::default(),
799                )
800                .await
801        });
802
803        assert!(
804            tokio::time::timeout(Duration::from_millis(100), &mut report)
805                .await
806                .is_err(),
807            "lifecycle reporting should wait for the guarded writer"
808        );
809
810        drop(writer);
811        tokio::time::timeout(Duration::from_secs(1), report)
812            .await
813            .expect("lifecycle reporting should proceed after the writer is released")
814            .unwrap()
815            .unwrap();
816    }
817}