1use std::collections::HashMap;
18use std::fs;
19use std::io::{Read, Write};
20use std::path::{Path, PathBuf};
21use std::sync::{Arc, Mutex as StdMutex, OnceLock};
22use std::time::{Duration, SystemTime};
23
24use async_trait::async_trait;
25
26#[cfg(unix)]
27use khive_fs::fd_relative::{clear_errno, current_errno};
28use khive_storage::blob::{
29 BlobOrphanSweepConfig, BlobOrphanSweepResult, BlobStore, ContentRef, UploadId,
30 UploadLeaseConfig, MAX_BLOB_WHOLE_BYTES,
31};
32use khive_storage::error::StorageError;
33use khive_storage::types::{SqlRow, SqlStatement, SqlValue, StorageResult};
34use khive_storage::{AtomicUnitOp, SqlAccess, StorageCapability};
35
36use crate::error::SqliteError;
37use uuid::Uuid;
38
39mod root_handle;
40#[cfg(windows)]
41use root_handle::open_blob_shard_file_no_follow_windows;
42pub use root_handle::resolve_blob_root;
43#[cfg(unix)]
44use root_handle::{
45 acquire_root_write_lock_at, available_space_at, create_regular_file_at_no_follow,
46 open_or_create_dir_at_no_follow, openat_dir_no_follow, openat_regular_file_no_follow,
47 rename_entry_at, unlink_entry_at,
48};
49use root_handle::{
50 blob_root_key, open_blob_root_handle, open_blob_shard_file_no_follow,
51 unlink_blob_shard_file_no_follow, verify_blob_root_identity,
52};
53
54mod publish;
55#[cfg(not(unix))]
56use publish::publish_blob_path;
57#[cfg(all(test, unix))]
58use publish::sync_directory;
59use publish::{
60 acquire_root_write_lock_anchored, put_blocking_from_root_handle,
61 refresh_publish_grace_blocking, walk_blob_files_from_root_handle, within_publish_grace,
62};
63#[cfg(unix)]
64use publish::{publish_blob_at, read_dir_names_no_follow, BlobPublication};
65#[cfg(test)]
66use publish::{put_blocking, put_blocking_with_space_probe};
67
68#[path = "blob_uploads.rs"]
69mod uploads;
70#[path = "blob_write_locks.rs"]
71mod write_locks;
72use write_locks::write_lock_for_root;
73
74const ROOT_WRITE_LOCK_FILE: &str = ".khive-blob-write.lock";
75const DATABASE_GC_LOCK_SUFFIX: &str = ".khive-blob-gc.lock";
76const BLOB_GC_CLAIM_BATCH_SIZE: usize = 128;
80
81fn map_io_err(e: std::io::Error, op: &'static str) -> StorageError {
82 StorageError::driver(StorageCapability::Blob, op, e)
83}
84
85#[cfg(any(test, not(unix)))]
86fn shard_path(root: &Path, content_ref: &ContentRef) -> PathBuf {
87 let hex = content_ref.as_str();
88 root.join(&hex[0..2]).join(&hex[2..4]).join(hex)
89}
90
91fn crosses_floor(available: u64, required_write_bytes: u64, floor_bytes: u64) -> bool {
105 available.saturating_sub(required_write_bytes) < floor_bytes
106}
107
108#[cfg(any(test, not(unix)))]
109fn acquire_root_write_lock(root: &Path) -> StorageResult<fs::File> {
110 let lock_file = fs::OpenOptions::new()
111 .read(true)
112 .write(true)
113 .create(true)
114 .truncate(false)
115 .open(root.join(ROOT_WRITE_LOCK_FILE))
116 .map_err(|e| map_io_err(e, "root_write_lock_open"))?;
117 fs4::FileExt::lock(&lock_file).map_err(|e| map_io_err(e, "root_write_lock_acquire"))?;
118 Ok(lock_file)
119}
120
121fn database_gc_lock_path(database_path: &Path) -> PathBuf {
122 let mut lock_path = database_path.as_os_str().to_os_string();
123 lock_path.push(DATABASE_GC_LOCK_SUFFIX);
124 PathBuf::from(lock_path)
125}
126
127fn acquire_database_gc_lock(database_path: Option<&Path>) -> StorageResult<Option<fs::File>> {
128 let Some(database_path) = database_path else {
129 return Ok(None);
132 };
133 let lock_path = database_gc_lock_path(database_path);
134 let lock_file = fs::OpenOptions::new()
135 .read(true)
136 .write(true)
137 .create(true)
138 .truncate(false)
139 .open(&lock_path)
140 .map_err(|e| map_io_err(e, "database_gc_lock_open"))?;
141 fs4::FileExt::lock(&lock_file).map_err(|e| map_io_err(e, "database_gc_lock_acquire"))?;
142 Ok(Some(lock_file))
143}
144
145#[derive(Debug)]
146struct PreparedTransactionalSweep {
147 result: BlobOrphanSweepResult,
148 candidates: Vec<(ContentRef, bool)>,
149}
150
151fn prepare_transactional_sweep(
154 files: Vec<(ContentRef, Option<SystemTime>)>,
155 grace_period: Duration,
156) -> PreparedTransactionalSweep {
157 let now = SystemTime::now();
158 let mut result = BlobOrphanSweepResult::default();
159 let mut candidates = Vec::with_capacity(files.len());
160 for (content_ref, mtime) in files {
161 result.scanned += 1;
162 let within_grace = within_publish_grace(mtime, now, grace_period);
163 candidates.push((content_ref, within_grace));
164 }
165 PreparedTransactionalSweep { result, candidates }
166}
167
168#[derive(Debug)]
169struct BlobGcBatchRows {
170 grace_period_skipped: u64,
171 would_delete: u64,
172 claimed_rows: Vec<SqlRow>,
173}
174
175fn required_nonnegative_count(
176 value: Option<SqlValue>,
177 operation: &'static str,
178) -> StorageResult<u64> {
179 match value {
180 Some(SqlValue::Integer(value)) if value >= 0 => Ok(value as u64),
181 other => Err(StorageError::Internal(format!(
182 "{operation} returned an invalid count: {other:?}"
183 ))),
184 }
185}
186
187fn invalid_content_ref(message: String) -> StorageError {
188 StorageError::InvalidInput {
189 capability: StorageCapability::Blob,
190 operation: "transactional_orphan_sweep".into(),
191 message,
192 }
193}
194
195async fn blob_gc_fencing_complete(sql: &dyn SqlAccess) -> StorageResult<bool> {
209 let mut reader = sql.reader().await?;
210 let present = required_nonnegative_count(
211 reader
212 .query_scalar(SqlStatement {
213 sql: "SELECT COUNT(*) FROM sqlite_master \
214 WHERE (type = 'table' AND name IN ( \
215 'blob_gc_claims', 'attachments', \
216 'attachment_cutover_state')) \
217 OR (type = 'index' AND name IN ( \
218 'idx_blob_gc_claims_content_ref', \
219 'idx_attachments_content_ref')) \
220 OR (type = 'trigger' AND name IN ( \
221 'attachments_reject_claimed_blob_insert', \
222 'attachments_reject_claimed_blob_update'))"
223 .to_string(),
224 params: vec![],
225 label: Some("blob_gc_fencing_complete".to_string()),
226 })
227 .await?,
228 "blob_gc_fencing_complete",
229 )?;
230 if present != 7 {
231 return Ok(false);
232 }
233
234 let legacy_objects = required_nonnegative_count(
235 reader
236 .query_scalar(SqlStatement {
237 sql: "SELECT \
238 (SELECT COUNT(*) FROM pragma_table_info('entities') \
239 WHERE name = 'content_ref') \
240 + (SELECT COUNT(*) FROM sqlite_master \
241 WHERE (type = 'index' AND name = 'idx_entities_content_ref') \
242 OR (type = 'trigger' AND name IN ( \
243 'entities_reject_claimed_blob_insert', \
244 'entities_reject_claimed_blob_update')))"
245 .to_string(),
246 params: vec![],
247 label: Some("blob_gc_legacy_fencing_absent".to_string()),
248 })
249 .await?,
250 "blob_gc_legacy_fencing_absent",
251 )?;
252 if legacy_objects != 0 {
253 return Ok(false);
254 }
255
256 let complete = required_nonnegative_count(
281 reader
282 .query_scalar(SqlStatement {
283 sql: "SELECT COUNT(*) FROM attachment_cutover_state AS cutover \
284 WHERE cutover.singleton = 1 \
285 AND cutover.state = 'complete' \
286 AND cutover.completed_at IS NOT NULL \
287 AND (SELECT COUNT(*) FROM _schema_migrations \
288 WHERE version = ?1 \
289 AND name = 'attachments_first_class') = 1 \
290 AND (SELECT COUNT(*) FROM _schema_migrations) = ?1 \
291 AND (SELECT MIN(version) FROM _schema_migrations) = 1 \
292 AND (SELECT MAX(version) FROM _schema_migrations) = ?1"
293 .to_string(),
294 params: vec![SqlValue::Integer(i64::from(
295 crate::migrations::ATTACHMENT_CUTOVER_VERSION,
296 ))],
297 label: Some("blob_gc_cutover_complete".to_string()),
298 })
299 .await?,
300 "blob_gc_cutover_complete",
301 )?;
302 Ok(complete == 1)
303}
304
305fn unsupported_blob_gc_epoch() -> StorageError {
306 StorageError::Unsupported {
307 capability: StorageCapability::Blob,
308 operation: "transactional_orphan_sweep".into(),
309 message: "transactional blob GC requires a complete V21 attachment cutover with \
310 the attachment claim-fencing set; refusing both report-only and \
311 destructive sweep in this database epoch"
312 .into(),
313 }
314}
315
316const BLOB_GC_FENCE_PROBE_REF: &str =
320 "0000000000000000000000000000000000000000000000000000000000000000";
321
322const BLOB_GC_FENCE_TRIGGER_MESSAGE: &str = "content_ref is reserved by an active blob sweep";
325
326async fn blob_gc_fence_probe(sql: &dyn SqlAccess) -> StorageResult<()> {
341 let run = Uuid::new_v4().simple().to_string();
342 blob_gc_fence_probe_with_ids(
343 sql,
344 format!("__blob-gc-fence-probe-insert-{run}__"),
345 format!("__blob-gc-fence-probe-update-{run}__"),
346 format!("__blob-gc-fence-probe-insert2-{run}__"),
347 format!("__blob-gc-fence-probe-update2-{run}__"),
348 format!("__fence_probe-{run}__"),
349 )
350 .await
351}
352
353async fn blob_gc_fence_probe_with_ids(
358 sql: &dyn SqlAccess,
359 insert_id: String,
360 update_id: String,
361 insert2_id: String,
362 update2_id: String,
363 claim_key: String,
364) -> StorageResult<()> {
365 fn fence_rejection(result: Result<u64, StorageError>) -> Result<bool, String> {
366 match result {
367 Ok(_) => Ok(false),
368 Err(error) => {
369 let text = error.to_string();
370 if text.contains(BLOB_GC_FENCE_TRIGGER_MESSAGE) {
371 Ok(true)
372 } else {
373 Err(text)
374 }
375 }
376 }
377 }
378
379 fn required_seed(value: Option<SqlValue>) -> StorageResult<String> {
380 match value {
381 Some(SqlValue::Text(seed)) => Ok(seed),
382 _ => Err(StorageError::Unsupported {
383 capability: StorageCapability::Blob,
384 operation: "transactional_orphan_sweep".into(),
385 message: "the blob GC fence probe could not select an unclaimed \
386 canonical seed; refusing deletion so a later sweep can retry"
387 .into(),
388 }),
389 }
390 }
391
392 let op: AtomicUnitOp = Box::new(move |writer| {
393 Box::pin(async move {
394 let preexisting = writer
398 .query_row(SqlStatement {
399 sql: "SELECT (SELECT COUNT(*) FROM attachments \
400 WHERE record_uuid IN (?1, ?2, ?3, ?4)) \
401 + (SELECT COUNT(*) FROM blob_gc_claims WHERE root_key = ?5)"
402 .to_string(),
403 params: vec![
404 SqlValue::Text(insert_id.clone()),
405 SqlValue::Text(update_id.clone()),
406 SqlValue::Text(insert2_id.clone()),
407 SqlValue::Text(update2_id.clone()),
408 SqlValue::Text(claim_key.clone()),
409 ],
410 label: Some("blob_gc_fence_probe_ownership_guard".to_string()),
411 })
412 .await?
413 .and_then(|row| row.columns.first().map(|c| c.value.clone()));
414 match preexisting {
415 Some(SqlValue::Integer(0)) => {}
416 Some(SqlValue::Integer(_)) => {
417 return Err(StorageError::Unsupported {
418 capability: StorageCapability::Blob,
419 operation: "transactional_orphan_sweep".into(),
420 message: "the blob GC fence probe's row ids collide with existing \
421 rows; refusing to probe rather than delete data the \
422 probe does not own"
423 .into(),
424 });
425 }
426 _ => {
427 return Err(StorageError::Internal(
428 "blob GC fence probe ownership guard returned no count".into(),
429 ));
430 }
431 }
432
433 let seed_ref = writer
439 .query_row(SqlStatement {
440 sql: "WITH RECURSIVE candidates(attempt, content_ref) AS ( \
441 SELECT 1, lower(hex(randomblob(32))) \
442 UNION ALL \
443 SELECT attempt + 1, lower(hex(randomblob(32))) \
444 FROM candidates WHERE attempt < 8 \
445 ) \
446 SELECT candidate.content_ref FROM candidates AS candidate \
447 WHERE candidate.content_ref <> ?1 \
448 AND NOT EXISTS ( \
449 SELECT 1 FROM blob_gc_claims \
450 WHERE content_ref = candidate.content_ref \
451 ) \
452 LIMIT 1"
453 .to_string(),
454 params: vec![SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string())],
455 label: Some("blob_gc_fence_probe_select_seed".to_string()),
456 })
457 .await?
458 .and_then(|row| row.columns.first().map(|column| column.value.clone()));
459 let seed_ref = required_seed(seed_ref)?;
460
461 let seed2_ref = writer
462 .query_row(SqlStatement {
463 sql: "WITH RECURSIVE candidates(attempt, content_ref) AS ( \
464 SELECT 1, lower(hex(randomblob(32))) \
465 UNION ALL \
466 SELECT attempt + 1, lower(hex(randomblob(32))) \
467 FROM candidates WHERE attempt < 8 \
468 ) \
469 SELECT candidate.content_ref FROM candidates AS candidate \
470 WHERE candidate.content_ref NOT IN (?1, ?2) \
471 AND NOT EXISTS ( \
472 SELECT 1 FROM blob_gc_claims \
473 WHERE content_ref = candidate.content_ref \
474 ) \
475 LIMIT 1"
476 .to_string(),
477 params: vec![
478 SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string()),
479 SqlValue::Text(seed_ref.clone()),
480 ],
481 label: Some("blob_gc_fence_probe_select_seed2".to_string()),
482 })
483 .await?
484 .and_then(|row| row.columns.first().map(|column| column.value.clone()));
485 let seed2_ref = required_seed(seed2_ref)?;
486
487 let probe2_ref = writer
491 .query_row(SqlStatement {
492 sql: "WITH RECURSIVE candidates(attempt, content_ref) AS ( \
493 SELECT 1, lower(hex(randomblob(32))) \
494 UNION ALL \
495 SELECT attempt + 1, lower(hex(randomblob(32))) \
496 FROM candidates WHERE attempt < 8 \
497 ) \
498 SELECT candidate.content_ref FROM candidates AS candidate \
499 WHERE candidate.content_ref NOT IN (?1, ?2, ?3) \
500 AND NOT EXISTS ( \
501 SELECT 1 FROM blob_gc_claims \
502 WHERE content_ref = candidate.content_ref \
503 ) \
504 LIMIT 1"
505 .to_string(),
506 params: vec![
507 SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string()),
508 SqlValue::Text(seed_ref.clone()),
509 SqlValue::Text(seed2_ref.clone()),
510 ],
511 label: Some("blob_gc_fence_probe_select_probe2".to_string()),
512 })
513 .await?
514 .and_then(|row| row.columns.first().map(|column| column.value.clone()));
515 let probe2_ref = required_seed(probe2_ref)?;
516
517 writer
518 .execute(SqlStatement {
519 sql: "INSERT INTO blob_gc_claims (root_key, content_ref, claimed_at) \
520 VALUES (?1, ?2, 0), (?1, ?3, 0)"
521 .to_string(),
522 params: vec![
523 SqlValue::Text(claim_key.clone()),
524 SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string()),
525 SqlValue::Text(probe2_ref.clone()),
526 ],
527 label: Some("blob_gc_fence_probe_claim".to_string()),
528 })
529 .await?;
530
531 let insert_attempt = writer
532 .execute(SqlStatement {
533 sql: "INSERT INTO attachments \
534 (record_uuid, substrate, role, content_ref, created_at) \
535 VALUES (?1, 'entity', 'content', ?2, 0)"
536 .to_string(),
537 params: vec![
538 SqlValue::Text(insert_id.clone()),
539 SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string()),
540 ],
541 label: Some("blob_gc_fence_probe_insert_arm".to_string()),
542 })
543 .await;
544 let insert_fenced = fence_rejection(insert_attempt);
545
546 writer
547 .execute(SqlStatement {
548 sql: "INSERT INTO attachments \
549 (record_uuid, substrate, role, content_ref, created_at) \
550 VALUES (?1, 'entity', 'content', ?2, 0)"
551 .to_string(),
552 params: vec![SqlValue::Text(update_id.clone()), SqlValue::Text(seed_ref)],
553 label: Some("blob_gc_fence_probe_update_arm_seed".to_string()),
554 })
555 .await?;
556 let update_attempt = writer
557 .execute(SqlStatement {
558 sql: "UPDATE attachments SET content_ref = ?1 \
559 WHERE record_uuid = ?2 AND role = 'content'"
560 .to_string(),
561 params: vec![
562 SqlValue::Text(BLOB_GC_FENCE_PROBE_REF.to_string()),
563 SqlValue::Text(update_id.clone()),
564 ],
565 label: Some("blob_gc_fence_probe_update_arm".to_string()),
566 })
567 .await;
568 let update_fenced = fence_rejection(update_attempt);
569
570 let insert2_attempt = writer
571 .execute(SqlStatement {
572 sql: "INSERT INTO attachments \
573 (record_uuid, substrate, role, content_ref, created_at) \
574 VALUES (?1, 'note', 'evidence', ?2, 0)"
575 .to_string(),
576 params: vec![
577 SqlValue::Text(insert2_id.clone()),
578 SqlValue::Text(probe2_ref.clone()),
579 ],
580 label: Some("blob_gc_fence_probe_insert2_arm".to_string()),
581 })
582 .await;
583 let insert2_fenced = fence_rejection(insert2_attempt);
584
585 writer
586 .execute(SqlStatement {
587 sql: "INSERT INTO attachments \
588 (record_uuid, substrate, role, content_ref, created_at) \
589 VALUES (?1, 'note', 'evidence', ?2, 0)"
590 .to_string(),
591 params: vec![
592 SqlValue::Text(update2_id.clone()),
593 SqlValue::Text(seed2_ref),
594 ],
595 label: Some("blob_gc_fence_probe_update2_arm_seed".to_string()),
596 })
597 .await?;
598 let update2_attempt = writer
599 .execute(SqlStatement {
600 sql: "UPDATE attachments SET content_ref = ?1 \
601 WHERE record_uuid = ?2 AND role = 'evidence'"
602 .to_string(),
603 params: vec![
604 SqlValue::Text(probe2_ref),
605 SqlValue::Text(update2_id.clone()),
606 ],
607 label: Some("blob_gc_fence_probe_update2_arm".to_string()),
608 })
609 .await;
610 let update2_fenced = fence_rejection(update2_attempt);
611
612 writer
615 .execute(SqlStatement {
616 sql: "DELETE FROM attachments WHERE record_uuid IN (?1, ?2, ?3, ?4)"
617 .to_string(),
618 params: vec![
619 SqlValue::Text(insert_id.clone()),
620 SqlValue::Text(update_id.clone()),
621 SqlValue::Text(insert2_id.clone()),
622 SqlValue::Text(update2_id.clone()),
623 ],
624 label: Some("blob_gc_fence_probe_cleanup_attachments".to_string()),
625 })
626 .await?;
627 writer
628 .execute(SqlStatement {
629 sql: "DELETE FROM blob_gc_claims WHERE root_key = ?1".to_string(),
630 params: vec![SqlValue::Text(claim_key)],
631 label: Some("blob_gc_fence_probe_cleanup_claim".to_string()),
632 })
633 .await?;
634
635 Ok(
636 Box::new((insert_fenced, update_fenced, insert2_fenced, update2_fenced))
637 as Box<dyn std::any::Any + Send>,
638 )
639 })
640 });
641 let outcome = sql.atomic_unit(op).await?;
642 let (insert_fenced, update_fenced, insert2_fenced, update2_fenced) = *outcome
643 .downcast::<(
644 Result<bool, String>,
645 Result<bool, String>,
646 Result<bool, String>,
647 Result<bool, String>,
648 )>()
649 .map_err(|_| {
650 StorageError::Internal("blob GC fence probe returned an unexpected outcome type".into())
651 })?;
652 let arm_verdict = |arm: &str, fenced: Result<bool, String>| -> StorageResult<()> {
653 match fenced {
654 Ok(true) => Ok(()),
655 Ok(false) => Err(StorageError::Unsupported {
656 capability: StorageCapability::Blob,
657 operation: "transactional_orphan_sweep".into(),
658 message: format!(
659 "the V21 fencing triggers exist by name but did not reject a claimed \
660 content_ref on the attachment {arm} path; refusing unfenced deletion"
661 ),
662 }),
663 Err(other) => Err(StorageError::Unsupported {
664 capability: StorageCapability::Blob,
665 operation: "transactional_orphan_sweep".into(),
666 message: format!(
667 "the blob GC fence probe could not verify the attachment {arm} fence \
668 (unexpected rejection: {other}); refusing unfenced deletion"
669 ),
670 }),
671 }
672 };
673 arm_verdict("INSERT", insert_fenced)?;
674 arm_verdict("UPDATE", update_fenced)?;
675 arm_verdict("second-digest INSERT", insert2_fenced)?;
676 arm_verdict("second-digest UPDATE", update2_fenced)
677}
678
679async fn validate_blob_gc_evidence(sql: &dyn SqlAccess) -> StorageResult<()> {
680 let mut reader = sql.reader().await?;
685 let canonical_bytes = match reader
694 .query_row(SqlStatement {
695 sql: "SELECT length(CAST('x' AS BLOB))".to_string(),
696 params: vec![],
697 label: Some("blob_gc_validate_encoding_width".to_string()),
698 })
699 .await?
700 .and_then(|row| row.columns.first().map(|column| column.value.clone()))
701 {
702 Some(SqlValue::Integer(width)) if (1..=4).contains(&width) => width * 64,
703 other => {
704 return Err(invalid_content_ref(format!(
705 "the text-encoding width probe returned {other:?}; refusing GC validation"
706 )));
707 }
708 };
709 let invalid_claim = reader
710 .query_row(SqlStatement {
711 sql: "SELECT content_ref FROM blob_gc_claims \
712 WHERE typeof(content_ref) <> 'text' \
713 OR length(content_ref) <> 64 \
714 OR length(CAST(content_ref AS BLOB)) <> ?1 \
715 OR content_ref GLOB '*[^0-9a-f]*' \
716 LIMIT 1"
717 .to_string(),
718 params: vec![SqlValue::Integer(canonical_bytes)],
719 label: Some("blob_gc_validate_existing_claims".to_string()),
720 })
721 .await?;
722 if invalid_claim.is_some() {
723 return Err(invalid_content_ref(
724 "blob_gc_claims.content_ref contained a non-canonical value".into(),
725 ));
726 }
727
728 let invalid_live = reader
729 .query_row(SqlStatement {
730 sql: "SELECT content_ref FROM attachments \
731 WHERE typeof(content_ref) <> 'text' \
732 OR length(content_ref) <> 64 \
733 OR length(CAST(content_ref AS BLOB)) <> ?1 \
734 OR content_ref GLOB '*[^0-9a-f]*' \
735 LIMIT 1"
736 .to_string(),
737 params: vec![SqlValue::Integer(canonical_bytes)],
738 label: Some("blob_gc_validate_live_refs".to_string()),
739 })
740 .await?;
741 if invalid_live.is_some() {
742 return Err(invalid_content_ref(
743 "attachments.content_ref contained a non-canonical value".into(),
744 ));
745 }
746 Ok(())
747}
748
749async fn release_abandoned_blob_gc_claim_batch(sql: &dyn SqlAccess) -> StorageResult<u64> {
750 let op: AtomicUnitOp = Box::new(move |writer| {
751 Box::pin(async move {
752 let released = writer
753 .execute(SqlStatement {
754 sql: "DELETE FROM blob_gc_claims \
755 WHERE rowid IN ( \
756 SELECT rowid FROM blob_gc_claims \
757 ORDER BY rowid LIMIT ?1 \
758 )"
759 .to_string(),
760 params: vec![SqlValue::Integer(BLOB_GC_CLAIM_BATCH_SIZE as i64)],
761 label: Some("blob_gc_release_abandoned_claim_batch".to_string()),
762 })
763 .await?;
764 Ok(Box::new(released) as Box<dyn std::any::Any + Send>)
765 })
766 });
767 let released = sql.atomic_unit(op).await?;
768 released.downcast::<u64>().map(|count| *count).map_err(|_| {
769 StorageError::Internal(
770 "transactional orphan sweep returned an unexpected recovery count type".into(),
771 )
772 })
773}
774
775fn blob_gc_unowned_attachment_predicate(quarantine_present: bool) -> &'static str {
781 if quarantine_present {
782 "NOT EXISTS ( \
783 SELECT 1 FROM attachments \
784 WHERE content_ref = candidate.value \
785 ) AND NOT EXISTS ( \
786 SELECT 1 FROM attachment_quarantine \
787 WHERE content_ref = candidate.value \
788 )"
789 } else {
790 "NOT EXISTS ( \
791 SELECT 1 FROM attachments \
792 WHERE content_ref = candidate.value \
793 )"
794 }
795}
796
797async fn claim_blob_gc_batch(
798 sql: &dyn SqlAccess,
799 root_key: String,
800 candidates: &[(ContentRef, bool)],
801 dry_run: bool,
802) -> StorageResult<BlobGcBatchRows> {
803 debug_assert!(candidates.len() <= BLOB_GC_CLAIM_BATCH_SIZE);
804 let eligible_refs = candidates
805 .iter()
806 .filter(|(_, within_grace)| !within_grace)
807 .map(|(content_ref, _)| content_ref.to_string())
808 .collect::<Vec<_>>();
809 let grace_refs = candidates
810 .iter()
811 .filter(|(_, within_grace)| *within_grace)
812 .map(|(content_ref, _)| content_ref.to_string())
813 .collect::<Vec<_>>();
814 let eligible_json = serde_json::to_string(&eligible_refs).map_err(|error| {
815 StorageError::Internal(format!(
816 "failed to prepare blob GC eligible candidate batch: {error}"
817 ))
818 })?;
819 let grace_json = serde_json::to_string(&grace_refs).map_err(|error| {
820 StorageError::Internal(format!(
821 "failed to prepare blob GC grace candidate batch: {error}"
822 ))
823 })?;
824 let claimed_at = chrono::Utc::now().timestamp_micros();
825 let op: AtomicUnitOp = Box::new(move |writer| {
826 Box::pin(async move {
827 let quarantine_present = required_nonnegative_count(
828 writer
829 .query_scalar(SqlStatement {
830 sql: "SELECT COUNT(*) FROM sqlite_master \
831 WHERE type = 'table' AND name = 'attachment_quarantine'"
832 .to_string(),
833 params: vec![],
834 label: Some("blob_gc_quarantine_table_present".to_string()),
835 })
836 .await?,
837 "blob_gc_quarantine_table_present",
838 )?;
839 let ownership_predicate = match quarantine_present {
840 0 => blob_gc_unowned_attachment_predicate(false),
841 1 => blob_gc_unowned_attachment_predicate(true),
842 _ => {
843 return Err(StorageError::Internal(
844 "blob GC quarantine table presence returned an invalid count".into(),
845 ));
846 }
847 };
848 let grace_period_skipped = required_nonnegative_count(
849 writer
850 .query_scalar(SqlStatement {
851 sql: format!(
852 "SELECT COUNT(*) FROM json_each(?1) AS candidate \
853 WHERE {ownership_predicate}"
854 ),
855 params: vec![SqlValue::Text(grace_json)],
856 label: Some("blob_gc_count_grace_candidates_batch".to_string()),
857 })
858 .await?,
859 "blob_gc_count_grace_candidates_batch",
860 )?;
861
862 if dry_run {
863 let would_delete = required_nonnegative_count(
864 writer
865 .query_scalar(SqlStatement {
866 sql: format!(
867 "SELECT COUNT(*) FROM json_each(?1) AS candidate \
868 WHERE {ownership_predicate}"
869 ),
870 params: vec![SqlValue::Text(eligible_json)],
871 label: Some("blob_gc_count_dry_run_candidates_batch".to_string()),
872 })
873 .await?,
874 "blob_gc_count_dry_run_candidates_batch",
875 )?;
876 return Ok(Box::new(BlobGcBatchRows {
877 grace_period_skipped,
878 would_delete,
879 claimed_rows: Vec::new(),
880 }) as Box<dyn std::any::Any + Send>);
881 }
882
883 writer
884 .execute(SqlStatement {
885 sql: format!(
886 "INSERT INTO blob_gc_claims (root_key, content_ref, claimed_at) \
887 SELECT ?1, candidate.value, ?3 \
888 FROM json_each(?2) AS candidate \
889 WHERE {ownership_predicate}"
890 ),
891 params: vec![
892 SqlValue::Text(root_key.clone()),
893 SqlValue::Text(eligible_json),
894 SqlValue::Integer(claimed_at),
895 ],
896 label: Some("blob_gc_claim_candidate_batch".to_string()),
897 })
898 .await?;
899
900 let claimed_rows = writer
901 .query_all(SqlStatement {
902 sql: "SELECT content_ref FROM blob_gc_claims \
903 WHERE root_key = ?1 ORDER BY content_ref"
904 .to_string(),
905 params: vec![SqlValue::Text(root_key)],
906 label: Some("blob_gc_claimed_candidate_batch".to_string()),
907 })
908 .await?;
909 Ok(Box::new(BlobGcBatchRows {
910 grace_period_skipped,
911 would_delete: claimed_rows.len() as u64,
912 claimed_rows,
913 }) as Box<dyn std::any::Any + Send>)
914 })
915 });
916 let rows = sql.atomic_unit(op).await?;
917 rows.downcast::<BlobGcBatchRows>()
918 .map(|rows| *rows)
919 .map_err(|_| {
920 StorageError::Internal(
921 "transactional orphan sweep returned an unexpected batch-row type".into(),
922 )
923 })
924}
925
926fn parse_blob_gc_claim_rows(rows: Vec<SqlRow>) -> StorageResult<Vec<ContentRef>> {
927 let mut claimed = Vec::with_capacity(rows.len());
928 for row in rows {
929 let raw = match row.get("content_ref") {
930 Some(SqlValue::Text(raw)) => raw.clone(),
931 _ => {
932 return Err(invalid_content_ref(
933 "blob_gc_claims.content_ref contained a non-text value".into(),
934 ));
935 }
936 };
937 claimed.push(ContentRef::from_hex(raw).map_err(invalid_content_ref)?);
938 }
939 Ok(claimed)
940}
941
942async fn release_blob_gc_batch(sql: &dyn SqlAccess, root_key: String) -> StorageResult<()> {
943 let cleanup: AtomicUnitOp = Box::new(move |writer| {
944 Box::pin(async move {
945 writer
946 .execute(SqlStatement {
947 sql: "DELETE FROM blob_gc_claims WHERE root_key = ?1".to_string(),
948 params: vec![SqlValue::Text(root_key)],
949 label: Some("blob_gc_release_claim_batch".to_string()),
950 })
951 .await?;
952 Ok(Box::new(()) as Box<dyn std::any::Any + Send>)
953 })
954 });
955 sql.atomic_unit(cleanup).await?;
956 Ok(())
957}
958
959type SweepLockMap = HashMap<Option<PathBuf>, Arc<DatabaseGcProcessLock>>;
966
967#[derive(Debug, Default)]
968struct DatabaseGcProcessLock {
969 held: StdMutex<bool>,
970 released: std::sync::Condvar,
971 #[cfg(test)]
972 waiters: std::sync::atomic::AtomicUsize,
973}
974
975impl DatabaseGcProcessLock {
976 fn acquire(self: &Arc<Self>) -> DatabaseGcProcessGuard {
977 let mut held = self
978 .held
979 .lock()
980 .unwrap_or_else(std::sync::PoisonError::into_inner);
981 while *held {
982 #[cfg(test)]
983 self.waiters
984 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
985 held = self
986 .released
987 .wait(held)
988 .unwrap_or_else(std::sync::PoisonError::into_inner);
989 #[cfg(test)]
990 self.waiters
991 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
992 }
993 *held = true;
994 DatabaseGcProcessGuard {
995 lock: Arc::clone(self),
996 }
997 }
998
999 fn try_acquire(self: &Arc<Self>) -> Option<DatabaseGcProcessGuard> {
1000 let mut held = self
1001 .held
1002 .lock()
1003 .unwrap_or_else(std::sync::PoisonError::into_inner);
1004 if *held {
1005 return None;
1006 }
1007 *held = true;
1008 Some(DatabaseGcProcessGuard {
1009 lock: Arc::clone(self),
1010 })
1011 }
1012}
1013
1014#[derive(Debug)]
1015struct DatabaseGcProcessGuard {
1016 lock: Arc<DatabaseGcProcessLock>,
1017}
1018
1019impl Drop for DatabaseGcProcessGuard {
1020 fn drop(&mut self) {
1021 let mut held = self
1022 .lock
1023 .held
1024 .lock()
1025 .unwrap_or_else(std::sync::PoisonError::into_inner);
1026 debug_assert!(*held, "database GC process owner released twice");
1027 *held = false;
1028 self.lock.released.notify_one();
1029 }
1030}
1031
1032fn database_sweep_locks() -> &'static StdMutex<SweepLockMap> {
1033 static REGISTRY: OnceLock<StdMutex<SweepLockMap>> = OnceLock::new();
1034 REGISTRY.get_or_init(|| StdMutex::new(HashMap::new()))
1035}
1036
1037fn sweep_lock_for_database(database_path: Option<&Path>) -> Arc<DatabaseGcProcessLock> {
1038 let key = database_path.map(Path::to_path_buf);
1039 let mut locks = database_sweep_locks()
1040 .lock()
1041 .unwrap_or_else(std::sync::PoisonError::into_inner);
1042 locks
1043 .entry(key)
1044 .or_insert_with(|| Arc::new(DatabaseGcProcessLock::default()))
1045 .clone()
1046}
1047
1048#[cfg(test)]
1049pub(crate) fn database_gc_waiter_count(database_path: Option<&Path>) -> usize {
1050 sweep_lock_for_database(database_path)
1051 .waiters
1052 .load(std::sync::atomic::Ordering::SeqCst)
1053}
1054
1055pub struct DatabaseGcOwnerGuard {
1063 _process_guard: DatabaseGcProcessGuard,
1064 _advisory_guard: Option<fs::File>,
1065 database_path: Option<PathBuf>,
1066}
1067
1068pub(crate) fn acquire_database_gc_owner_for_path_blocking(
1069 database_path: Option<PathBuf>,
1070) -> StorageResult<DatabaseGcOwnerGuard> {
1071 let process_guard = sweep_lock_for_database(database_path.as_deref()).acquire();
1072 let advisory_guard = acquire_database_gc_lock(database_path.as_deref())?;
1073 Ok(DatabaseGcOwnerGuard {
1074 _process_guard: process_guard,
1075 _advisory_guard: advisory_guard,
1076 database_path,
1077 })
1078}
1079
1080pub(crate) fn try_acquire_database_gc_owner_for_path(
1088 database_path: PathBuf,
1089) -> StorageResult<DatabaseGcOwnerGuard> {
1090 let process_guard = sweep_lock_for_database(Some(&database_path))
1091 .try_acquire()
1092 .ok_or_else(|| {
1093 StorageError::Internal(format!(
1094 "database GC owner for {} is already held; retry schema migration through the \
1095 coordinated backend boot path",
1096 database_path.display()
1097 ))
1098 })?;
1099 let lock_path = database_gc_lock_path(&database_path);
1100 let advisory_guard = fs::OpenOptions::new()
1101 .read(true)
1102 .write(true)
1103 .create(true)
1104 .truncate(false)
1105 .open(&lock_path)
1106 .map_err(|error| map_io_err(error, "database_gc_lock_open"))?;
1107 fs4::FileExt::try_lock(&advisory_guard)
1108 .map_err(|error| map_io_err(error.into(), "database_gc_lock_try_acquire"))?;
1109 Ok(DatabaseGcOwnerGuard {
1110 _process_guard: process_guard,
1111 _advisory_guard: Some(advisory_guard),
1112 database_path: Some(database_path),
1113 })
1114}
1115
1116impl std::fmt::Debug for DatabaseGcOwnerGuard {
1117 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1118 formatter
1119 .debug_struct("DatabaseGcOwnerGuard")
1120 .field("database_path", &self.database_path)
1121 .finish_non_exhaustive()
1122 }
1123}
1124
1125impl DatabaseGcOwnerGuard {
1126 pub fn database_path(&self) -> Option<&Path> {
1129 self.database_path.as_deref()
1130 }
1131}
1132
1133pub async fn acquire_database_gc_owner(sql: &dyn SqlAccess) -> StorageResult<DatabaseGcOwnerGuard> {
1137 let database_path = sql.database_path();
1138 tokio::task::spawn_blocking(move || acquire_database_gc_owner_for_path_blocking(database_path))
1139 .await
1140 .map_err(|error| {
1141 StorageError::driver(StorageCapability::Blob, "acquire_database_gc_owner", error)
1142 })?
1143}
1144
1145#[derive(Debug)]
1147pub struct FsBlobStore {
1148 root: PathBuf,
1149 root_handle: Arc<fs::File>,
1153 floor_bytes: u64,
1154 write_lock: Arc<tokio::sync::Mutex<()>>,
1171 upload_observations: Arc<StdMutex<uploads::lease::Observations>>,
1172 orphan_sweep_grace: Duration,
1179}
1180
1181impl FsBlobStore {
1182 pub const DEFAULT_FLOOR_BYTES: u64 = 100_000_000_000;
1185
1186 pub const DEFAULT_ORPHAN_SWEEP_GRACE: Duration = Duration::from_secs(3600);
1191
1192 pub fn new(root: PathBuf, floor_bytes: u64) -> Result<Self, SqliteError> {
1198 match fs::create_dir(&root) {
1199 Ok(()) => {}
1200 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1201 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1202 let parent = root
1203 .parent()
1204 .filter(|path| !path.as_os_str().is_empty())
1205 .unwrap_or_else(|| Path::new("."));
1206 return Err(std::io::Error::new(
1207 std::io::ErrorKind::NotFound,
1208 format!("blob root parent missing: {}", parent.display()),
1209 )
1210 .into());
1211 }
1212 Err(error) => return Err(error.into()),
1213 }
1214 let store = Self::open_existing(root, floor_bytes)?;
1215 #[cfg(unix)]
1216 {
1217 use std::os::fd::AsRawFd;
1218
1219 let publication = BlobPublication {
1220 #[cfg(test)]
1221 hook: sync_hook::take(&store.root).and_then(|hook| hook.publication),
1222 };
1223 let parent = openat_dir_no_follow(store.root_handle.as_raw_fd(), "..")?;
1224 publication.sync_directory("init_sync_root", &store.root_handle)?;
1225 publication.sync_directory("init_sync_parent", &parent)?;
1226 }
1227 Ok(store)
1228 }
1229
1230 pub fn open_existing(root: PathBuf, floor_bytes: u64) -> Result<Self, SqliteError> {
1235 let root = root.canonicalize()?;
1239 let metadata = fs::metadata(&root)?;
1240 if !metadata.is_dir() {
1241 return Err(SqliteError::InvalidData(format!(
1242 "blob store root is not a directory: {}",
1243 root.display()
1244 )));
1245 }
1246 let root_handle = Arc::new(open_blob_root_handle(&root)?);
1247 let write_lock = write_lock_for_root(&root)?;
1248 verify_blob_root_identity(&root, &root_handle)?;
1249 Ok(Self {
1250 root,
1251 root_handle,
1252 floor_bytes,
1253 write_lock,
1254 upload_observations: Arc::default(),
1255 orphan_sweep_grace: Self::DEFAULT_ORPHAN_SWEEP_GRACE,
1256 })
1257 }
1258
1259 pub fn with_orphan_sweep_grace(mut self, grace_period: Duration) -> Self {
1262 self.orphan_sweep_grace = grace_period;
1263 self
1264 }
1265
1266 pub fn root(&self) -> &Path {
1268 &self.root
1269 }
1270}
1271
1272#[async_trait]
1273impl BlobStore for FsBlobStore {
1274 async fn refresh_publish_grace(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
1275 let guard = self.write_lock.clone().lock_owned().await;
1278 let root = self.root.clone();
1279 let root_handle = Arc::clone(&self.root_handle);
1280 let content_ref = content_ref.clone();
1281 tokio::task::spawn_blocking(move || {
1282 let _guard = guard;
1283 refresh_publish_grace_blocking(&root, &root_handle, &content_ref)
1284 })
1285 .await
1286 .map_err(|error| {
1287 StorageError::driver(StorageCapability::Blob, "refresh_publish_grace", error)
1288 })?
1289 }
1290
1291 fn upload_lease_idle_cap(&self) -> Option<Duration> {
1292 Some(Duration::from_secs(uploads::lease::MAX_IDLE_SECS))
1293 }
1294
1295 async fn begin_upload_with_lease(
1296 &self,
1297 size: u64,
1298 config: UploadLeaseConfig,
1299 ) -> StorageResult<UploadId> {
1300 uploads::begin_leased(self, size, config).await
1301 }
1302
1303 async fn renew_upload(&self, id: &UploadId) -> StorageResult<()> {
1304 uploads::renew(self, id.clone()).await
1305 }
1306
1307 async fn begin_upload(&self, _declared_size: u64) -> StorageResult<UploadId> {
1308 Err(StorageError::Unsupported {
1309 capability: StorageCapability::Blob,
1310 operation: "begin_upload".into(),
1311 message: "the filesystem store requires a lease: use begin_upload_with_lease".into(),
1312 })
1313 }
1314
1315 async fn append_part(&self, id: &UploadId, bytes: Vec<u8>) -> StorageResult<u64> {
1316 uploads::append(self, id.clone(), bytes).await
1317 }
1318
1319 async fn commit_upload(&self, id: &UploadId, content_ref: &ContentRef) -> StorageResult<()> {
1320 uploads::commit(self, id.clone(), content_ref.clone()).await
1321 }
1322
1323 async fn abort_upload(&self, id: &UploadId) -> StorageResult<()> {
1324 uploads::abort(self, id.clone()).await
1325 }
1326
1327 async fn sweep_uploads(&self, idle_for: Duration) -> StorageResult<u64> {
1328 uploads::sweep(self, idle_for).await
1329 }
1330
1331 async fn put(&self, bytes: Vec<u8>) -> StorageResult<ContentRef> {
1332 let owned_guard = self.write_lock.clone().lock_owned().await;
1342 let root = self.root.clone();
1343 let root_handle = Arc::clone(&self.root_handle);
1344 let floor_bytes = self.floor_bytes;
1345 #[cfg(test)]
1352 let hook = sync_hook::take(&root);
1353 tokio::task::spawn_blocking(move || {
1354 #[cfg_attr(not(test), allow(clippy::let_and_return))]
1359 let result = {
1360 let _owned_guard = owned_guard;
1361 #[cfg(test)]
1362 if let Some(h) = &hook {
1363 let _ = h.reached.send(());
1364 let _ = h.release.recv();
1365 }
1366 put_blocking_from_root_handle(
1367 &root,
1368 &root_handle,
1369 floor_bytes,
1370 bytes,
1371 #[cfg(unix)]
1372 &BlobPublication {
1373 #[cfg(test)]
1374 hook: hook.as_ref().and_then(|hook| hook.publication.clone()),
1375 },
1376 )
1377 };
1378 #[cfg(test)]
1379 if let Some(h) = &hook {
1380 let _ = h.done.send(());
1381 }
1382 result
1383 })
1384 .await
1385 .map_err(|e| StorageError::driver(StorageCapability::Blob, "put", e))?
1386 }
1387
1388 async fn get_bounded_verified(
1389 &self,
1390 content_ref: &ContentRef,
1391 max_bytes: u64,
1392 ) -> StorageResult<Vec<u8>> {
1393 if max_bytes > MAX_BLOB_WHOLE_BYTES {
1397 return Err(StorageError::InvalidInput {
1398 capability: StorageCapability::Blob,
1399 operation: "get_bounded_verified".into(),
1400 message: format!(
1401 "max_bytes {max_bytes} exceeds the {MAX_BLOB_WHOLE_BYTES}-byte portable whole-buffer envelope"
1402 ),
1403 });
1404 }
1405
1406 let root = self.root.clone();
1407 let root_handle = Arc::clone(&self.root_handle);
1408 let content_ref = content_ref.clone();
1409 #[cfg(test)]
1410 let read_hook = bounded_read_sync_hook::take(&root);
1411 tokio::task::spawn_blocking(move || {
1412 let mut file = open_blob_shard_file_no_follow(&root, &root_handle, &content_ref)
1416 .map_err(|e| {
1417 if e.kind() == std::io::ErrorKind::NotFound {
1418 StorageError::NotFound {
1419 capability: StorageCapability::Blob,
1420 resource: "blob",
1421 key: content_ref.to_string(),
1422 }
1423 } else if e.kind() == std::io::ErrorKind::Unsupported {
1424 StorageError::Unsupported {
1425 capability: StorageCapability::Blob,
1426 operation: "get_bounded_verified".into(),
1427 message: e.to_string(),
1428 }
1429 } else {
1430 map_io_err(e, "get_bounded_verified.open")
1431 }
1432 })?;
1433
1434 let metadata_bytes = file
1435 .metadata()
1436 .map_err(|e| map_io_err(e, "get_bounded_verified.metadata"))?
1437 .len();
1438 #[cfg(test)]
1439 if let Some(hook) = &read_hook {
1440 let _ = hook.reached.send(());
1441 let _ = hook.release.recv();
1442 }
1443 if metadata_bytes > max_bytes {
1444 return Err(StorageError::BlobTooLarge {
1445 content_ref,
1446 max_bytes,
1447 observed_at_least: metadata_bytes,
1448 });
1449 }
1450
1451 let mut bytes = Vec::with_capacity(metadata_bytes as usize);
1454 (&mut file)
1455 .take(max_bytes + 1)
1456 .read_to_end(&mut bytes)
1457 .map_err(|e| map_io_err(e, "get_bounded_verified.read"))?;
1458 let actual_bytes = bytes.len() as u64;
1459 if actual_bytes > max_bytes {
1460 return Err(StorageError::BlobTooLarge {
1461 content_ref,
1462 max_bytes,
1463 observed_at_least: actual_bytes,
1464 });
1465 }
1466 if metadata_bytes != actual_bytes {
1467 return Err(StorageError::BlobSizeMismatch {
1468 content_ref,
1469 metadata_bytes,
1470 actual_bytes,
1471 });
1472 }
1473
1474 let actual = ContentRef::from_digest_bytes(blake3::hash(&bytes).as_bytes());
1475 if actual != content_ref {
1476 return Err(StorageError::BlobDigestMismatch {
1477 expected: content_ref,
1478 actual,
1479 });
1480 }
1481 Ok(bytes)
1482 })
1483 .await
1484 .map_err(|e| StorageError::driver(StorageCapability::Blob, "get_bounded_verified", e))?
1485 }
1486
1487 async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool> {
1488 let root = self.root.clone();
1489 let root_handle = Arc::clone(&self.root_handle);
1490 let content_ref = content_ref.clone();
1491 tokio::task::spawn_blocking(move || {
1492 match open_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
1493 Ok(_) => Ok(true),
1494 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1495 Err(error) => Err(map_io_err(error, "exists")),
1496 }
1497 })
1498 .await
1499 .map_err(|e| StorageError::driver(StorageCapability::Blob, "exists", e))?
1500 }
1501
1502 async fn size(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
1503 let root = self.root.clone();
1504 let root_handle = Arc::clone(&self.root_handle);
1505 let content_ref = content_ref.clone();
1506 tokio::task::spawn_blocking(move || {
1507 match open_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
1508 Ok(file) => file
1509 .metadata()
1510 .map(|metadata| Some(metadata.len()))
1511 .map_err(|error| map_io_err(error, "size")),
1512 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1513 Err(error) => Err(map_io_err(error, "size")),
1514 }
1515 })
1516 .await
1517 .map_err(|e| StorageError::driver(StorageCapability::Blob, "size", e))?
1518 }
1519
1520 async fn delete(&self, content_ref: &ContentRef) -> StorageResult<bool> {
1521 let root = self.root.clone();
1522 let root_handle = Arc::clone(&self.root_handle);
1523 let content_ref = content_ref.clone();
1524 tokio::task::spawn_blocking(move || {
1525 match unlink_blob_shard_file_no_follow(&root, &root_handle, &content_ref) {
1526 Ok(()) => Ok(true),
1527 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
1528 Err(e) => Err(map_io_err(e, "delete")),
1529 }
1530 })
1531 .await
1532 .map_err(|e| StorageError::driver(StorageCapability::Blob, "delete", e))?
1533 }
1534
1535 async fn orphan_sweep(
1545 &self,
1546 config: &BlobOrphanSweepConfig,
1547 ) -> StorageResult<BlobOrphanSweepResult> {
1548 let _ = config;
1549 Err(StorageError::Unsupported {
1550 capability: StorageCapability::Blob,
1551 operation: "orphan_sweep".into(),
1552 message: "caller-snapshot orphan_sweep is disabled in this compatibility release; \
1553 it cannot prove a completed V21 attachment epoch, use \
1554 transactional_orphan_sweep instead"
1555 .into(),
1556 })
1557 }
1558
1559 async fn transactional_orphan_sweep(
1581 &self,
1582 sql: &dyn SqlAccess,
1583 dry_run: bool,
1584 ) -> StorageResult<BlobOrphanSweepResult> {
1585 if !blob_gc_fencing_complete(sql).await? {
1590 return Err(unsupported_blob_gc_epoch());
1591 }
1592
1593 let database_path = sql.database_path();
1601 let lock_database_path = database_path.clone();
1602 #[cfg(test)]
1603 let hook_database_path = database_path.clone();
1604 let (database_guard, database_file_guard) = tokio::task::spawn_blocking(move || {
1605 let process_guard = sweep_lock_for_database(lock_database_path.as_deref()).acquire();
1606 let file_guard = acquire_database_gc_lock(lock_database_path.as_deref())?;
1607 #[cfg(test)]
1608 if let Some(hook) = db_ownership_sync_hook::take(hook_database_path.as_deref()) {
1609 let _ = hook.reached.send(());
1610 let _ = hook.release.recv();
1611 }
1612 Ok::<_, StorageError>((process_guard, file_guard))
1613 })
1614 .await
1615 .map_err(|e| {
1616 StorageError::driver(
1617 StorageCapability::Blob,
1618 "transactional_orphan_sweep_lock",
1619 e,
1620 )
1621 })??;
1622
1623 if !blob_gc_fencing_complete(sql).await? {
1629 return Err(unsupported_blob_gc_epoch());
1630 }
1631
1632 let root_guard = self.write_lock.clone().lock_owned().await;
1633 let root = self.root.clone();
1634 let root_handle = Arc::clone(&self.root_handle);
1635 let scan_root = root.clone();
1636 let scan_root_handle = Arc::clone(&root_handle);
1637 let grace_period = self.orphan_sweep_grace;
1638 let (write_guards, canonical_root, prepared) = tokio::task::spawn_blocking(move || {
1639 verify_blob_root_identity(&scan_root, &scan_root_handle)
1640 .map_err(|e| map_io_err(e, "transactional_orphan_sweep_root"))?;
1641 let canonical_root = scan_root;
1644 let root_write_guard =
1645 acquire_root_write_lock_anchored(&canonical_root, &scan_root_handle)?;
1646 let candidates = walk_blob_files_from_root_handle(&scan_root_handle, &canonical_root)
1647 .map_err(|e| map_io_err(e, "transactional_orphan_sweep_walk"))?;
1648 verify_blob_root_identity(&canonical_root, &scan_root_handle)
1649 .map_err(|e| map_io_err(e, "transactional_orphan_sweep_root"))?;
1650 let prepared = prepare_transactional_sweep(candidates, grace_period);
1651 Ok::<_, StorageError>((
1652 (
1653 database_guard,
1654 database_file_guard,
1655 root_guard,
1656 root_write_guard,
1657 ),
1658 canonical_root,
1659 prepared,
1660 ))
1661 })
1662 .await
1663 .map_err(|e| {
1664 StorageError::driver(
1665 StorageCapability::Blob,
1666 "transactional_orphan_sweep_walk",
1667 e,
1668 )
1669 })??;
1670 let root_key = blob_root_key(&canonical_root);
1671 validate_blob_gc_evidence(sql).await?;
1672 blob_gc_fence_probe(sql).await?;
1673 if !dry_run {
1674 loop {
1675 let released = release_abandoned_blob_gc_claim_batch(sql).await?;
1676 if released < BLOB_GC_CLAIM_BATCH_SIZE as u64 {
1677 break;
1678 }
1679 }
1680 }
1681
1682 let mut write_guards = write_guards;
1683 let mut result = prepared.result;
1684 let mut delete_error = None;
1685 #[cfg(test)]
1686 let mut hook: Option<sync_hook::Hook> = None;
1687 #[cfg(not(test))]
1688 let mut hook: Option<()> = None;
1689 #[cfg(test)]
1690 let mut hook_paused = false;
1691
1692 for candidates in prepared.candidates.chunks(BLOB_GC_CLAIM_BATCH_SIZE) {
1699 let batch = claim_blob_gc_batch(sql, root_key.clone(), candidates, dry_run).await?;
1700 result.grace_period_skipped += batch.grace_period_skipped;
1701 result.would_delete += batch.would_delete;
1702 if dry_run {
1703 continue;
1704 }
1705
1706 let claimed_refs = parse_blob_gc_claim_rows(batch.claimed_rows)?;
1707 if claimed_refs.is_empty() {
1708 continue;
1709 }
1710
1711 #[cfg(test)]
1712 if hook.is_none() {
1713 hook = sync_hook::take(&root);
1714 }
1715 #[cfg(test)]
1716 let pause_hook = hook.is_some() && !hook_paused;
1717 #[cfg(test)]
1718 if pause_hook {
1719 hook_paused = true;
1720 }
1721
1722 let delete_root = canonical_root.clone();
1723 let delete_root_handle = Arc::clone(&root_handle);
1724 let (returned_guards, deleted, batch_delete_error, returned_hook) =
1725 tokio::task::spawn_blocking(move || {
1726 #[cfg(test)]
1727 if pause_hook {
1728 if let Some(hook) = &hook {
1729 let _ = hook.reached.send(());
1730 let _ = hook.release.recv();
1731 }
1732 }
1733
1734 let mut deleted = 0_u64;
1735 let mut first_error = None;
1736 for content_ref in claimed_refs {
1737 match unlink_blob_shard_file_no_follow(
1738 &delete_root,
1739 &delete_root_handle,
1740 &content_ref,
1741 ) {
1742 Ok(()) => deleted += 1,
1743 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1744 Err(error) => {
1745 first_error =
1746 Some(map_io_err(error, "transactional_orphan_sweep_delete"));
1747 break;
1748 }
1749 }
1750 }
1751 (write_guards, deleted, first_error, hook)
1756 })
1757 .await
1758 .map_err(|error| {
1759 StorageError::driver(
1760 StorageCapability::Blob,
1761 "transactional_orphan_sweep_delete",
1762 error,
1763 )
1764 })?;
1765 write_guards = returned_guards;
1766 hook = returned_hook;
1767 result.deleted += deleted;
1768
1769 release_blob_gc_batch(sql, root_key.clone()).await?;
1774 if batch_delete_error.is_some() {
1775 delete_error = batch_delete_error;
1776 break;
1777 }
1778 }
1779
1780 drop(write_guards);
1781 #[cfg(test)]
1782 if let Some(hook) = hook {
1783 let _ = hook.done.send(());
1784 }
1785 #[cfg(not(test))]
1786 let _ = hook;
1787 if let Some(error) = delete_error {
1788 return Err(error);
1789 }
1790 Ok(result)
1791 }
1792}
1793
1794#[cfg(test)]
1809#[path = "blob/sync_hook_tests.rs"]
1810mod sync_hook;
1811
1812#[cfg(all(test, unix))]
1828#[path = "blob/walk_leaf_sync_hook_tests.rs"]
1829mod walk_leaf_sync_hook;
1830
1831#[cfg(test)]
1836#[path = "blob/bounded_read_sync_hook_tests.rs"]
1837mod bounded_read_sync_hook;
1838
1839#[cfg(test)]
1846#[path = "blob/db_ownership_sync_hook_tests.rs"]
1847mod db_ownership_sync_hook;
1848
1849#[cfg(test)]
1850#[path = "blob/quarantine_liveness_tests.rs"]
1851mod quarantine_liveness_tests;
1852
1853#[cfg(test)]
1854#[path = "blob_tests.rs"]
1855mod tests;