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
153pub fn local_object_missing(blob_dir: &Path, sha256: &str) -> Result<bool> {
156 let path = crate::attachments::storage::object_path(blob_dir, sha256)?;
157 Ok(matches!(path.try_exists(), Ok(false)))
158}
159
160async fn existing_live_attachments_by_sha(
161 conn: &mut SqliteConnection,
162 workspace_id: &crate::ids::WorkspaceId,
163 task_id: &TaskId,
164 sha256: &str,
165) -> Result<Vec<TaskAttachment>> {
166 let rows = sqlx::query(AssertSqlSafe(format!(
167 "SELECT {ATTACHMENT_COLUMNS}
168 FROM task_attachments ta
169 WHERE ta.workspace_id = ? AND ta.task_id = ? AND ta.sha256 = ? AND ta.deleted = 0
170 ORDER BY ta.created_at, ta.attachment_id"
171 )))
172 .bind(workspace_id)
173 .bind(task_id)
174 .bind(sha256)
175 .fetch_all(&mut *conn)
176 .await?;
177
178 Ok(rows.iter().map(attachment_from_row).collect())
179}
180
181pub async fn prepare_task_attachment(input: TaskAttachmentAddInput) -> Result<PreparedAttachment> {
182 validate_attachment_id(&input.attachment_id)?;
183 validate_filename(input.input.filename.as_deref())?;
184 validate_alt_text(input.input.alt_text.as_deref())?;
185
186 let source = validate_image(input.input.bytes, input.input.declared_media_type).await?;
187 let source_facts = source.facts.clone();
188 let optimized = optimize_image_bytes(
189 &source_facts.media_type,
190 source.bytes,
191 input.input.optimization_policy,
192 )
193 .await?;
194 let optimized_flag = optimized.optimized;
195 let stored_image = if optimized_flag {
196 validate_image(optimized.bytes, None).await?
197 } else {
198 ValidatedImage {
199 bytes: optimized.bytes,
200 facts: source_facts,
201 }
202 };
203 let sha256 = sha256_hex(&stored_image.bytes);
204 let byte_size = i64::try_from(stored_image.bytes.len())?;
205 Ok(PreparedAttachment {
206 attachment_id: input.attachment_id,
207 filename: input.input.filename,
208 alt_text: input.input.alt_text,
209 sha256,
210 byte_size,
211 facts: stored_image.facts,
212 bytes: stored_image.bytes,
213 optimized: optimized_flag,
214 })
215}
216
217struct AttachmentRecordDraft<'a> {
218 attachment_id: &'a str,
219 sha256: &'a str,
220 byte_size: i64,
221 media_type: &'a str,
222 filename: Option<&'a str>,
223 alt_text: Option<&'a str>,
224 width: i64,
225 height: i64,
226 created_at: &'a str,
227}
228
229async fn record_attachment_add(
230 conn: &mut SqliteConnection,
231 workspace: &Workspace,
232 task_id: &TaskId,
233 draft: AttachmentRecordDraft<'_>,
234) -> Result<String> {
235 let change_id = append_change(
236 conn,
237 ChangeEntity::Task,
238 task_id,
239 Some("attachments"),
240 op_type::ATTACHMENT_ADD,
241 ChangePayload::workspace(workspace)
242 .set("attachment_id", draft.attachment_id)
243 .set("sha256", draft.sha256)
244 .set("byte_size", draft.byte_size)
245 .set("media_type", draft.media_type)
246 .set("filename", draft.filename)
247 .set("alt_text", draft.alt_text)
248 .set("width", draft.width)
249 .set("height", draft.height)
250 .set("created_at", draft.created_at),
251 )
252 .await?;
253 sqlx::query(
254 "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)
255 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
256 )
257 .bind(&workspace.id)
258 .bind(draft.attachment_id)
259 .bind(task_id)
260 .bind(draft.sha256)
261 .bind(draft.byte_size)
262 .bind(draft.media_type)
263 .bind(draft.filename)
264 .bind(draft.alt_text)
265 .bind(draft.width)
266 .bind(draft.height)
267 .bind(draft.created_at)
268 .bind(&change_id)
269 .execute(&mut *conn)
270 .await?;
271 Ok(change_id)
272}
273
274pub(super) async fn insert_prepared_attachment(
275 conn: &mut SqliteConnection,
276 workspace: &Workspace,
277 task_id: &TaskId,
278 prepared: &PreparedAttachment,
279 created_at: &str,
280) -> Result<String> {
281 record_attachment_add(
282 conn,
283 workspace,
284 task_id,
285 AttachmentRecordDraft {
286 attachment_id: &prepared.attachment_id,
287 sha256: &prepared.sha256,
288 byte_size: prepared.byte_size,
289 media_type: &prepared.facts.media_type,
290 filename: prepared.filename.as_deref(),
291 alt_text: prepared.alt_text.as_deref(),
292 width: prepared.facts.width,
293 height: prepared.facts.height,
294 created_at,
295 },
296 )
297 .await
298}
299
300struct AttachmentCommitIdentity {
301 attachment_id: Option<String>,
302 created_at: Option<String>,
303}
304
305pub async fn add_task_attachment(
306 conn: &mut SqliteConnection,
307 workspace: &Workspace,
308 blob_dir: &Path,
309 policy: crate::attachments::lifecycle::LifecyclePolicy,
310 task_id: &TaskId,
311 input: AttachmentAddInput,
312) -> Result<AttachmentAddOutcome> {
313 add_task_attachment_inner(
314 conn,
315 workspace,
316 blob_dir,
317 policy,
318 task_id,
319 AttachmentCommitIdentity {
320 attachment_id: None,
321 created_at: None,
322 },
323 input,
324 )
325 .await
326}
327
328pub(crate) async fn add_ordered_task_attachment(
329 conn: &mut SqliteConnection,
330 workspace: &Workspace,
331 blob_dir: &Path,
332 policy: crate::attachments::lifecycle::LifecyclePolicy,
333 task_id: &TaskId,
334 created_at: String,
335 input: TaskAttachmentAddInput,
336) -> Result<AttachmentAddOutcome> {
337 add_task_attachment_inner(
338 conn,
339 workspace,
340 blob_dir,
341 policy,
342 task_id,
343 AttachmentCommitIdentity {
344 attachment_id: Some(input.attachment_id),
345 created_at: Some(created_at),
346 },
347 input.input,
348 )
349 .await
350}
351
352async fn add_task_attachment_inner(
353 conn: &mut SqliteConnection,
354 workspace: &Workspace,
355 blob_dir: &Path,
356 policy: crate::attachments::lifecycle::LifecyclePolicy,
357 task_id: &TaskId,
358 identity: AttachmentCommitIdentity,
359 input: AttachmentAddInput,
360) -> Result<AttachmentAddOutcome> {
361 validate_filename(input.filename.as_deref())?;
362 validate_alt_text(input.alt_text.as_deref())?;
363
364 let source = validate_image(input.bytes, input.declared_media_type).await?;
365 let source_facts = source.facts.clone();
366 let optimized = optimize_image_bytes(
367 &source_facts.media_type,
368 source.bytes,
369 input.optimization_policy,
370 )
371 .await?;
372 let optimized_flag = optimized.optimized;
373 let stored_image = if optimized_flag {
374 validate_image(optimized.bytes, Some(source_facts.media_type.clone())).await?
375 } else {
376 ValidatedImage {
377 bytes: optimized.bytes,
378 facts: source_facts,
379 }
380 };
381 let sha256 = sha256_hex(&stored_image.bytes);
382 let byte_size = i64::try_from(stored_image.bytes.len())?;
383 let capacity_reservation = crate::attachments::lifecycle::ensure_local_capacity(
384 conn,
385 blob_dir,
386 &sha256,
387 byte_size,
388 policy,
389 &crate::attachments::lifecycle::SystemClock,
390 )
391 .await?;
392 let staging_lease = crate::attachments::lifecycle::acquire_lease(
393 conn,
394 &sha256,
395 "staging",
396 &crate::attachments::lifecycle::SystemClock,
397 )
398 .await?;
399 let stored = match store_validated_blob(conn, blob_dir, stored_image).await {
400 Ok(stored) => stored,
401 Err(error) => {
402 let _ = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
403 if let Some(reservation_id) = capacity_reservation.as_deref() {
404 let _ =
405 crate::attachments::lifecycle::release_reservation(conn, reservation_id).await;
406 }
407 return Err(error);
408 }
409 };
410 if let Some(reservation_id) = capacity_reservation {
411 crate::attachments::lifecycle::release_reservation(conn, &reservation_id).await?;
412 }
413 let database_result = async {
414 let mutation_at = now();
415 let mut tx = begin_immediate(conn).await?;
416 crate::operations::route_recurrence_task_mutation(
417 &mut tx,
418 workspace,
419 task_id,
420 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Attachments),
421 &mutation_at,
422 )
423 .await?;
424 let task_exists = sqlx::query_scalar::<_, bool>(
425 "SELECT EXISTS(SELECT 1 FROM tasks WHERE workspace_id = ? AND id = ?)",
426 )
427 .bind(&workspace.id)
428 .bind(task_id)
429 .fetch_one(&mut *tx)
430 .await?;
431 if !task_exists {
432 bail!("error task-not-found task_id={task_id}");
433 }
434
435 if input.dedupe_existing
436 && let Some(existing) =
437 existing_live_attachments_by_sha(&mut tx, &workspace.id, task_id, &stored.sha256)
438 .await?
439 .first()
440 {
441 let attachment_id = existing.attachment_id.clone();
442 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
443 &mut tx,
444 std::slice::from_ref(&stored.sha256),
445 &crate::attachments::lifecycle::SystemClock,
446 )
447 .await?;
448 tx.commit().await?;
449 return Ok::<_, anyhow::Error>((attachment_id, false));
450 }
451
452 let attachment_id = identity.attachment_id.unwrap_or_else(new_id);
453 let created_at = identity.created_at.unwrap_or_else(|| mutation_at.clone());
454 record_attachment_add(
455 &mut tx,
456 workspace,
457 task_id,
458 AttachmentRecordDraft {
459 attachment_id: &attachment_id,
460 sha256: &stored.sha256,
461 byte_size: stored.byte_size,
462 media_type: &stored.facts.media_type,
463 filename: input.filename.as_deref(),
464 alt_text: input.alt_text.as_deref(),
465 width: stored.facts.width,
466 height: stored.facts.height,
467 created_at: &created_at,
468 },
469 )
470 .await?;
471 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
472 &mut tx,
473 std::slice::from_ref(&stored.sha256),
474 &crate::attachments::lifecycle::SystemClock,
475 )
476 .await?;
477 tx.commit().await?;
478 Ok((attachment_id, true))
479 }
480 .await;
481 let release_result = crate::attachments::lifecycle::release_lease(conn, &staging_lease).await;
482 let (attachment_id, created) = match database_result {
483 Ok(result) => result,
484 Err(error) => {
485 crate::attachments::storage::remove_staged_blob_if_unreferenced(
486 conn,
487 blob_dir,
488 &stored.sha256,
489 )
490 .await;
491 return Err(error);
492 }
493 };
494 release_result?;
495 crate::attachments::lifecycle::reconcile_liveness(
496 conn,
497 &crate::attachments::lifecycle::SystemClock,
498 )
499 .await?;
500 let outcome = attachment_by_id(conn, workspace, &attachment_id).await?;
501 Ok(AttachmentAddOutcome {
502 outcome,
503 created,
504 optimized: optimized_flag,
505 })
506}
507
508pub async fn attachment_by_id(
509 conn: &mut SqliteConnection,
510 workspace: &Workspace,
511 attachment_id: &str,
512) -> Result<AttachmentOutcome> {
513 let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
514 let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
515 Ok(AttachmentOutcome {
516 attachment,
517 has_blob,
518 })
519}
520
521pub async fn delete_task_attachment(
522 conn: &mut SqliteConnection,
523 workspace: &Workspace,
524 attachment_id: &str,
525) -> Result<AttachmentOutcome> {
526 let deleted_at = now();
527 let attachment = require_attachment(conn, &workspace.id, attachment_id).await?;
528 if attachment.deleted {
529 let has_blob = attachment_has_blob(conn, &attachment.sha256).await?;
530 return Ok(AttachmentOutcome {
531 attachment,
532 has_blob,
533 });
534 }
535
536 let task_id = attachment.task_id.clone();
537
538 let mut tx = begin_immediate(conn).await?;
539 crate::operations::route_recurrence_task_mutation(
540 &mut tx,
541 workspace,
542 &task_id,
543 RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Attachments),
544 &deleted_at,
545 )
546 .await?;
547
548 let change_id = append_change(
549 &mut tx,
550 ChangeEntity::Task,
551 &task_id,
552 Some("attachments"),
553 op_type::ATTACHMENT_DELETE,
554 ChangePayload::workspace(workspace)
555 .set("attachment_id", attachment_id)
556 .set("filename", attachment.filename.as_deref())
557 .set("media_type", &attachment.media_type)
558 .set("deleted_at", &deleted_at),
559 )
560 .await?;
561
562 sqlx::query(
563 "UPDATE task_attachments SET deleted = 1, deleted_at = ?, deleted_by_change_id = ? WHERE workspace_id = ? AND attachment_id = ?",
564 )
565 .bind(&deleted_at)
566 .bind(&change_id)
567 .bind(&workspace.id)
568 .bind(attachment_id)
569 .execute(&mut *tx)
570 .await?;
571
572 crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
573 &mut tx,
574 std::slice::from_ref(&attachment.sha256),
575 &crate::attachments::lifecycle::SystemClock,
576 )
577 .await?;
578 tx.commit().await?;
579
580 attachment_by_id(conn, workspace, attachment_id).await
581}
582
583pub async fn attachment_read_items_by_task(
584 conn: &mut SqliteConnection,
585 workspace_id: &str,
586 task_id: &str,
587 include_deleted: bool,
588) -> Result<Vec<AttachmentReadItem>> {
589 let attachments = attachments_by_task(conn, workspace_id, task_id, include_deleted).await?;
590 let mut items = Vec::with_capacity(attachments.len());
591 for attachment in attachments {
592 let bytes_state = attachment_bytes_state(conn, &attachment.sha256).await?;
593 items.push(AttachmentReadItem {
594 attachment,
595 has_blob: bytes_state == AttachmentBytesState::Present,
596 bytes_state,
597 });
598 }
599 Ok(items)
600}
601
602pub async fn attachments_by_task(
603 conn: &mut SqliteConnection,
604 workspace_id: &str,
605 task_id: &str,
606 include_deleted: bool,
607) -> Result<Vec<TaskAttachment>> {
608 let deleted_predicate = if include_deleted {
609 ""
610 } else {
611 " AND ta.deleted = 0"
612 };
613 let rows = sqlx::query(AssertSqlSafe(format!(
614 "SELECT {ATTACHMENT_COLUMNS}
615 FROM task_attachments ta
616 WHERE ta.workspace_id = ? AND ta.task_id = ?{deleted_predicate}
617 ORDER BY ta.created_at, ta.attachment_id"
618 )))
619 .bind(workspace_id)
620 .bind(task_id)
621 .fetch_all(&mut *conn)
622 .await?;
623
624 let mut attachments = Vec::with_capacity(rows.len());
625 for row in &rows {
626 attachments.push(attachment_from_row(row));
627 }
628 Ok(attachments)
629}
630
631impl Database {
632 pub async fn add_task_attachment(
633 &self,
634 workspace: &Workspace,
635 blob_dir: &Path,
636 policy: crate::attachments::lifecycle::LifecyclePolicy,
637 task_id: &TaskId,
638 input: AttachmentAddInput,
639 ) -> Result<AttachmentAddOutcome> {
640 let mut conn = self.acquire_writer().await?;
641 add_task_attachment(&mut conn, workspace, blob_dir, policy, task_id, input).await
642 }
643
644 pub async fn add_ordered_task_attachment(
645 &self,
646 workspace: &Workspace,
647 blob_dir: &Path,
648 policy: crate::attachments::lifecycle::LifecyclePolicy,
649 task_id: &TaskId,
650 created_at: String,
651 input: TaskAttachmentAddInput,
652 ) -> Result<AttachmentAddOutcome> {
653 let mut conn = self.acquire_writer().await?;
654 add_ordered_task_attachment(
655 &mut conn, workspace, blob_dir, policy, task_id, created_at, input,
656 )
657 .await
658 }
659
660 pub async fn attachment_by_id(
661 &self,
662 workspace: &Workspace,
663 attachment_id: &str,
664 ) -> Result<AttachmentOutcome> {
665 let mut conn = self.acquire_reader().await?;
666 attachment_by_id(&mut conn, workspace, attachment_id).await
667 }
668
669 pub async fn delete_task_attachment(
670 &self,
671 workspace: &Workspace,
672 attachment_id: &str,
673 ) -> Result<AttachmentOutcome> {
674 let mut conn = self.acquire_writer().await?;
675 delete_task_attachment(&mut conn, workspace, attachment_id).await
676 }
677
678 pub async fn attachment_read_items_by_task(
681 &self,
682 blob_dir: &Path,
683 workspace_id: &WorkspaceId,
684 task_id: &TaskId,
685 include_deleted: bool,
686 ) -> Result<Vec<AttachmentReadItem>> {
687 let mut conn = self.acquire_reader().await?;
688 let mut items = attachment_read_items_by_task(
689 &mut conn,
690 workspace_id.as_str(),
691 task_id.as_str(),
692 include_deleted,
693 )
694 .await?;
695 drop(conn);
696 let present = items
697 .iter()
698 .enumerate()
699 .filter(|(_, item)| item.bytes_state == AttachmentBytesState::Present)
700 .map(|(index, item)| (index, item.attachment.sha256.clone()))
701 .collect::<Vec<_>>();
702 let blob_dir = blob_dir.to_path_buf();
703 let missing = crate::attachments::run_blocking(move || {
704 let mut missing = Vec::new();
705 for (index, sha256) in present {
706 if local_object_missing(&blob_dir, &sha256)? {
707 missing.push(index);
708 }
709 }
710 Ok(missing)
711 })
712 .await?;
713 for index in missing {
714 items[index].bytes_state = AttachmentBytesState::Unavailable;
715 items[index].has_blob = false;
716 }
717 Ok(items)
718 }
719
720 pub async fn prune_attachments(
721 &self,
722 blob_dir: &Path,
723 policy: crate::attachments::lifecycle::LifecyclePolicy,
724 apply: bool,
725 ) -> Result<crate::attachments::lifecycle::PruneSummary> {
726 let mut conn = self.acquire_writer().await?;
727 crate::attachments::lifecycle::prune(
728 &mut conn,
729 blob_dir,
730 policy,
731 apply,
732 &crate::attachments::lifecycle::SystemClock,
733 )
734 .await
735 }
736
737 pub async fn acquire_attachment_lease(&self, sha256: &str, purpose: &str) -> Result<String> {
738 let mut conn = self.acquire_writer().await?;
739 crate::attachments::lifecycle::acquire_lease(
740 &mut conn,
741 sha256,
742 purpose,
743 &crate::attachments::lifecycle::SystemClock,
744 )
745 .await
746 }
747
748 pub async fn acquire_live_attachment_read_lease(
749 &self,
750 workspace: &Workspace,
751 attachment_id: &str,
752 ) -> Result<AttachmentReadLease> {
753 let mut conn = self.acquire_writer().await?;
754 let mut tx = begin_immediate(&mut conn).await?;
755 let row = sqlx::query(
756 "SELECT ta.task_id, ta.byte_size, ta.sha256, ta.media_type, bi.available
757 FROM task_attachments ta
758 JOIN tasks t ON t.workspace_id = ta.workspace_id AND t.id = ta.task_id
759 LEFT JOIN blob_inventory bi ON bi.sha256 = ta.sha256
760 WHERE ta.workspace_id = ? AND ta.attachment_id = ?
761 AND ta.deleted = 0 AND t.deleted = 0",
762 )
763 .bind(&workspace.id)
764 .bind(attachment_id)
765 .fetch_optional(&mut *tx)
766 .await?
767 .ok_or(AttachmentReadFailure::Invalidated)?;
768 if !row.try_get::<bool, _>("available").unwrap_or(false) {
769 return Err(AttachmentReadFailure::Unavailable.into());
770 }
771 let sha256: String = row.get("sha256");
772 let media_type: String = row.get("media_type");
773 let lease_id = crate::attachments::lifecycle::acquire_lease(
774 &mut tx,
775 &sha256,
776 "read",
777 &crate::attachments::lifecycle::SystemClock,
778 )
779 .await?;
780 tx.commit().await?;
781 Ok(AttachmentReadLease {
782 task_id: row.get("task_id"),
783 byte_size: row.get("byte_size"),
784 sha256,
785 media_type,
786 lease_id,
787 })
788 }
789
790 pub async fn release_attachment_lease(&self, lease_id: &str) -> Result<()> {
791 let mut conn = self.acquire_writer().await?;
792 crate::attachments::lifecycle::release_lease(&mut conn, lease_id).await
793 }
794
795 pub async fn attachment_lifecycle_report(
796 &self,
797 blob_dir: &Path,
798 policy: crate::attachments::lifecycle::LifecyclePolicy,
799 ) -> Result<crate::attachments::lifecycle::LifecycleReport> {
800 let mut conn = self.acquire_writer().await?;
801 crate::attachments::lifecycle::lifecycle_report(
802 &mut conn,
803 blob_dir,
804 policy,
805 &crate::attachments::lifecycle::SystemClock,
806 )
807 .await
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use std::time::Duration;
814
815 use super::*;
816
817 #[tokio::test]
818 async fn lifecycle_report_waits_for_guarded_writer() {
819 let temp = tempfile::tempdir().unwrap();
820 let database = Database::open(&temp.path().join("lifecycle.sqlite"))
821 .await
822 .unwrap();
823 let writer = database.acquire_writer().await.unwrap();
824
825 let reporting_database = database.clone();
826 let blob_dir = temp.path().join("blobs");
827 let mut report = tokio::spawn(async move {
828 reporting_database
829 .attachment_lifecycle_report(
830 &blob_dir,
831 crate::attachments::lifecycle::LifecyclePolicy::default(),
832 )
833 .await
834 });
835
836 assert!(
837 tokio::time::timeout(Duration::from_millis(100), &mut report)
838 .await
839 .is_err(),
840 "lifecycle reporting should wait for the guarded writer"
841 );
842
843 drop(writer);
844 tokio::time::timeout(Duration::from_secs(1), report)
845 .await
846 .expect("lifecycle reporting should proceed after the writer is released")
847 .unwrap()
848 .unwrap();
849 }
850}
851
852#[cfg(test)]
853mod local_object_tests {
854 use super::local_object_missing;
855 use crate::attachments::storage::object_path;
856
857 #[test]
858 fn missing_only_when_object_file_is_absent() {
859 let dir = tempfile::tempdir().unwrap();
860 let sha256 = "a".repeat(64);
861 assert!(local_object_missing(dir.path(), &sha256).unwrap());
862
863 let path = object_path(dir.path(), &sha256).unwrap();
864 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
865 std::fs::write(&path, b"bytes").unwrap();
866 assert!(!local_object_missing(dir.path(), &sha256).unwrap());
867 assert!(local_object_missing(dir.path(), "short").is_err());
868 }
869
870 #[cfg(unix)]
871 #[test]
872 fn unreadable_object_directory_is_not_reported_missing() {
873 use std::os::unix::fs::PermissionsExt;
874
875 let dir = tempfile::tempdir().unwrap();
876 let sha256 = "b".repeat(64);
877 let path = object_path(dir.path(), &sha256).unwrap();
878 let objects = path.parent().unwrap();
879 std::fs::create_dir_all(objects).unwrap();
880 std::fs::write(&path, b"bytes").unwrap();
881 std::fs::set_permissions(objects, std::fs::Permissions::from_mode(0o000)).unwrap();
882 let denied = std::fs::metadata(&path).is_err();
883 let missing = local_object_missing(dir.path(), &sha256).unwrap();
884 std::fs::set_permissions(objects, std::fs::Permissions::from_mode(0o700)).unwrap();
885 if denied {
886 assert!(!missing);
887 }
888 }
889}