Skip to main content

khive_db/stores/
blob.rs

1//! Filesystem-backed `BlobStore` — content-addressed, BLAKE3-sharded on disk.
2//!
3//! Layout: `<root>/<hex[0..2]>/<hex[2..4]>/<hex>`, plus a root-local advisory
4//! lock file. The two-level shard is identical in shape to git's loose-object
5//! store, so a root holding millions of blobs never puts more than a few
6//! thousand entries in one directory. Writes are atomic-publish (khive#292):
7//! bytes land in a temporary entry in the SAME shard directory as the final
8//! path (guaranteeing same-filesystem rename), the written length is checked
9//! against the input length, then an atomic rename publishes the entry —
10//! crash-safe (a crash mid-write leaves an orphaned temp file, never a
11//! partially-committed blob). On Unix, publication also synchronizes the
12//! shard directory chain before acknowledging the reference. Initialization
13//! synchronizes the root and its parent, which must already exist and be
14//! durable. This is not a power-loss guarantee for arbitrary filesystems or
15//! devices. Non-Unix publication does not provide these directory barriers.
16
17use 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";
76/// Maximum candidates represented by one claim transaction and its matching
77/// physical-delete/cleanup cycle. This bounds JSON binding, returned rows,
78/// claim-table pages dirtied per transaction, and exclusive-writer hold time.
79const 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
91/// Whether writing `required_write_bytes` more bytes to a volume currently
92/// reporting `available` free bytes would leave it below `floor_bytes`.
93///
94/// Pure and filesystem-independent on purpose: the
95/// exact boundary this guards — `available == floor_bytes + 1` must still
96/// refuse a 2-byte write, because a floor-only check (`available <
97/// floor_bytes`) does not account for the pending write's own size — is unit
98/// tested directly against this function rather than against the real
99/// filesystem's `fs4::available_space`, which fluctuates under concurrent
100/// build/agent activity on a shared machine and made an earlier
101/// exact-boundary integration test flaky. `saturating_sub` avoids underflow
102/// when `required_write_bytes` exceeds `available` outright — that case
103/// still correctly refuses for any nonzero floor.
104fn 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        // An in-memory database cannot be shared by another process. The
130        // process-local lock below is therefore the complete owner fence.
131        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
151/// Perform every filesystem-dependent part of candidate classification before
152/// SQLite's writer transaction opens.
153fn 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
195/// Whether this database carries the complete V21 attachment-only GC fencing
196/// set and durable completed cutover marker.
197///
198/// `transactional_orphan_sweep` is reachable from any `SqlAccess` a caller
199/// hands it, including a `StorageBackend` constructed directly (e.g.
200/// `StorageBackend::memory()`/`sqlite()` used without `prepare_core_schema`)
201/// that never ran core migrations. The triggers are the fence that keeps a
202/// concurrent attachment write from resurrecting a claimed digest in the
203/// released-writer window, so a database missing any element of the set
204/// cannot satisfy the fail-closed guarantee the
205/// [`BlobStore::transactional_orphan_sweep`] contract requires; the sweep
206/// refuses with [`StorageError::Unsupported`] rather than degrading to
207/// unfenced deletion.
208async 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    // The sweep is admitted only for the EXACT completed V21 epoch
257    // (ADR-160 Phase 4a: "report-only and destructive sweeps only for an
258    // exact completed V21 epoch … ahead-of-V21 epochs return typed
259    // Unsupported"). A ledger above V21 — whether ahead of this binary or a
260    // migration this same binary applied — belongs to a schema epoch whose
261    // attachment/liveness semantics this gate never validated, so it fails
262    // closed; the author of a future migration extends the gate in the same
263    // change that proves the new epoch's liveness set, never by default.
264    // General schema validation (`attachment_cutover_status`) deliberately
265    // keeps accepting later versions on top of a completed cutover: that is
266    // the normal serving course, and the exact-epoch rule is scoped to
267    // destructive GC admission.
268    //
269    // "Exact completed V21 epoch" means the WHOLE canonical ledger, not a
270    // V21 terminal row: `version` is the table's PRIMARY KEY, so
271    // COUNT(*) = 21 ∧ MIN = 1 ∧ MAX = 21 holds if and only if the ledger is
272    // exactly the contiguous set {1..21}. A ledger that merely retains a V21
273    // row at MAX(version) = 21 while earlier rows are missing is an
274    // incomplete migration history whose physical schema this gate never
275    // validated — it must fail closed, same as ahead-of-V21. Name-level
276    // canonicality of the below-terminal rows stays boot's job
277    // (`validate_applied_migration_ledger`); this predicate enforces the
278    // structural contiguity a destructive sweep's admission rests on, plus
279    // the named V21 row itself.
280    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
316/// The sentinel digest the fence probe claims. All zeros is canonical-form
317/// valid (64 lowercase hex) and unreachable as a real BLAKE3 digest for any
318/// stored object in practice; probe rows never survive the probe transaction.
319const BLOB_GC_FENCE_PROBE_REF: &str =
320    "0000000000000000000000000000000000000000000000000000000000000000";
321
322/// The RAISE(ABORT) message shared by the V20 and V21 fencing triggers. The probe
323/// requires the rejection to be OUR fence, not an incidental failure.
324const BLOB_GC_FENCE_TRIGGER_MESSAGE: &str = "content_ref is reserved by an active blob sweep";
325
326/// Prove the V21 fence actually fences, not merely that objects with the
327/// right NAMES exist in `sqlite_master`. Same-named no-op triggers (or a
328/// rewritten trigger body) would pass the name census while letting a
329/// claimed `content_ref` become live in the released-writer window, so the
330/// gate exercises the fence: inside one writer transaction it claims the
331/// all-zero sentinel AND a second random digest, and attempts the attachment
332/// INSERT and attachment UPDATE the triggers must reject for EACH claimed
333/// digest — with the second digest's arms using a different attachment shape
334/// (substrate `note`, role `evidence`), so a trigger rewrite restricted to
335/// one digest, substrate, or role fails an arm instead of passing a
336/// fixed-sentinel census. Every arm must fail with the triggers' own RAISE
337/// message, and every probe row is deleted before the unit commits. Any
338/// other outcome — a write accepted, or rejected for a different reason —
339/// refuses the sweep with [`StorageError::Unsupported`].
340async 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
353/// Probe body with explicit row ids so tests can force an id collision.
354/// Production callers go through [`blob_gc_fence_probe`], which mints
355/// per-run random ids; the guard below still refuses to run — touching
356/// nothing — if any minted id already names a row.
357async 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            // Ownership guard: the cleanup below deletes these ids
395            // unconditionally, so the probe may only proceed when it can
396            // prove every id is unclaimed in EVERY table cleanup touches.
397            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            // The UPDATE arm needs a valid, initially unclaimed reference.
434            // Select it under this same writer transaction instead of using a
435            // fixed sentinel that a recoverable abandoned claim could fence
436            // forever. Eight fresh candidates keep collision handling bounded;
437            // no candidate means a safe, retryable refusal.
438            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            // The second CLAIMED digest. A trigger rewrite conditioned on the
488            // fixed all-zero sentinel passes that sentinel's arms; this digest
489            // is random per run, so such a rewrite fails the arms below.
490            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            // Remove every probe row before this unit commits, including an
613            // attachment row a dead fence let through.
614            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    // These full-table integrity probes are statement-scoped reads. Keep them
681    // off the single writer; only their one-row result is materialized. The
682    // database sweep owner excludes another claim producer, and each bounded
683    // claim unit anti-joins the then-current live rows under its writer lock.
684    let mut reader = sql.reader().await?;
685    // length() and GLOB both stop at an embedded NUL, so a value of 64 hex
686    // characters followed by a NUL and arbitrary bytes passes them while
687    // failing the exact-equality liveness anti-join. The byte-length arm
688    // closes that class: chars = 64 AND bytes = 64 * the encoding's bytes
689    // per ASCII character forces a NUL-free canonical value. CAST(TEXT AS
690    // BLOB) yields the database text encoding's bytes (1 per hex char in
691    // UTF-8, 2 in UTF-16), so the width is derived from the same database
692    // rather than assumed, and an unrecognizable answer fails closed.
693    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
775/// Candidate ownership for every GC accounting and claim site.
776///
777/// The exact-V21 admission gate remains unchanged. Its legacy schema has no
778/// quarantine table, so callers select the canonical-only fragment when that
779/// table is absent rather than preparing a reference to a missing table.
780fn 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
959/// Process-wide database owner fence for transactional blob sweeps.
960///
961/// Claims live in the database and their attachment triggers are database-global,
962/// so a root-only lock is insufficient: two differently configured roots for
963/// one database must not recover each other's live claims. File-backed pools
964/// additionally take [`acquire_database_gc_lock`] for cross-process exclusion.
965type 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
1055/// Exclusive canonical-database ownership shared by transactional blob GC and
1056/// the boot-gated V21 attachment cutover.
1057///
1058/// The process-local mutex is acquired first and retained while the advisory
1059/// file lock is acquired on a blocking thread. Moving the owned mutex guard
1060/// into that closure makes cancellation safe: dropping the outer future cannot
1061/// release process ownership while a blocking advisory acquisition continues.
1062pub 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
1080/// Try to acquire canonical database-GC ownership without waiting.
1081///
1082/// This is the fail-closed bridge for the legacy raw-connection migration API:
1083/// a caller may already hold an opaque pooled writer guard, so waiting here
1084/// could invert the canonical owner-before-writer order used by sweeps. The
1085/// production backend boot path uses the blocking helper before writer
1086/// checkout instead.
1087pub(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    /// Canonical database path that keys this owner, or `None` for an
1127    /// in-memory database whose process-local mutex is the complete fence.
1128    pub fn database_path(&self) -> Option<&Path> {
1129        self.database_path.as_deref()
1130    }
1131}
1132
1133/// Acquire the canonical database owner used by both V21 boot cutover and
1134/// transactional blob sweep. Callers must retain the returned guard across
1135/// every stage that must exclude the other protocol.
1136pub 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/// A `BlobStore` backed by a BLAKE3-sharded directory tree.
1146#[derive(Debug)]
1147pub struct FsBlobStore {
1148    root: PathBuf,
1149    /// Initialization-time filesystem authority for `root`. Every blob
1150    /// descriptor walk starts from this retained handle rather than
1151    /// re-opening the root path and trusting its mutable ancestors again.
1152    root_handle: Arc<fs::File>,
1153    floor_bytes: u64,
1154    /// Shared per-canonical-root guard (see `write_lock_for_root`) that
1155    /// serializes the check-then-publish critical section of `put`: without
1156    /// this, two puts (whether on the same
1157    /// `FsBlobStore` instance or two independently constructed ones for the
1158    /// same root) can each observe the same pre-write `available_space`
1159    /// snapshot, each pass their own write-size-aware floor check against
1160    /// it, and then both write, jointly pushing the volume under the floor.
1161    /// `put` acquires this as an OWNED guard (`lock_owned`) and MOVES it
1162    /// into the `spawn_blocking` closure rather than borrowing it across the
1163    /// closure's `.await` — cancelling/dropping the outer `put` future then
1164    /// cannot release the guard before the underlying blocking write (which
1165    /// keeps running on its own thread regardless of the outer future's
1166    /// fate) actually finishes. A per-root async mutex is adequate at this
1167    /// write rate. The blocking write also takes a root-local advisory file
1168    /// lock to coordinate with publishers and transactional sweeps in other
1169    /// processes.
1170    write_lock: Arc<tokio::sync::Mutex<()>>,
1171    upload_observations: Arc<StdMutex<uploads::lease::Observations>>,
1172    /// How long a blob with zero live references is left alone before an
1173    /// orphan sweep will delete it — see `within_publish_grace`. Bounds the
1174    /// window between `put` (bytes land, lock released) and the later,
1175    /// separate attachment write that commits a `content_ref` to it; it does not
1176    /// close that window entirely; see `within_publish_grace` and
1177    /// `transactional_orphan_sweep`'s doc comment for the residual exposure.
1178    orphan_sweep_grace: Duration,
1179}
1180
1181impl FsBlobStore {
1182    /// Default fail-closed free-space floor (khive#292 SPEC-gate ruling):
1183    /// 100 GB. Config-overridable via the `floor_bytes` constructor argument.
1184    pub const DEFAULT_FLOOR_BYTES: u64 = 100_000_000_000;
1185
1186    /// Default orphan-sweep publish grace period: 1 hour. Generous on
1187    /// purpose — it only needs to outlast the gap between a client's `put`
1188    /// call returning and its follow-up attachment write landing, not any
1189    /// steady-state condition.
1190    pub const DEFAULT_ORPHAN_SWEEP_GRACE: Duration = Duration::from_secs(3600);
1191
1192    /// Create a store rooted at `root`, creating only that directory if absent.
1193    /// Its parent must already exist and be durable. Missing ancestors are
1194    /// refused rather than becoming an unverified anchor after a failed retry.
1195    /// On Unix every call synchronizes the root and its parent, including when
1196    /// the root already exists. `open_existing` performs no such mutation.
1197    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    /// Open a store rooted at an existing directory without creating any
1231    /// filesystem entry. Used by snapshot runtimes so boot can retain blob
1232    /// reads while remaining side-effect free; mutation is fenced by the
1233    /// runtime's read-only wrapper.
1234    pub fn open_existing(root: PathBuf, floor_bytes: u64) -> Result<Self, SqliteError> {
1235        // Preserve existing support for configured symlink spellings without
1236        // carrying that mutable indirection into handle-relative reads. The
1237        // bounded reader deliberately opens its stored root with NOFOLLOW.
1238        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    /// Override the orphan-sweep publish grace period (default: one hour —
1260    /// see `DEFAULT_ORPHAN_SWEEP_GRACE`).
1261    pub fn with_orphan_sweep_grace(mut self, grace_period: Duration) -> Self {
1262        self.orphan_sweep_grace = grace_period;
1263        self
1264    }
1265
1266    /// The resolved root directory this store writes under.
1267    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        // Keep the owned async guard with the blocking work even when the
1276        // caller is cancelled; the same root lock serializes put and sweep.
1277        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        // OWNED guard, MOVED into the blocking closure below: a guard merely
1333        // borrowed here and held in this
1334        // async fn's own stack frame would be released the instant the
1335        // *outer* `put` future is cancelled or dropped, while an
1336        // already-started `spawn_blocking` closure keeps running on its own
1337        // thread regardless — letting a second `put` pass its check against
1338        // an unprotected in-flight write. Moving the owned guard into the
1339        // closure ties its lifetime to the blocking work itself, not to
1340        // whether anyone is still awaiting this future.
1341        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        // `sync_hook::take` (added for PR #922) is the
1346        // test-only seam that lets regression tests observe/control
1347        // exactly when this call is inside the guarded section, replacing
1348        // fixed-sleep/fixed-duration-poll timing assumptions with
1349        // deterministic, event-driven synchronization. `#[cfg(test)]`-
1350        // gated end to end -- zero effect on non-test builds.
1351        #[cfg(test)]
1352        let hook = sync_hook::take(&root);
1353        tokio::task::spawn_blocking(move || {
1354            // The guard lives in this inner block so it is dropped BEFORE
1355            // the test hook's `done` signal fires below -- a test that
1356            // waits on `done` and then immediately asserts the lock is
1357            // free needs that ordering to hold exactly, not "usually".
1358            #[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        // Argument validation is deliberately outside `spawn_blocking`: an
1394        // invalid portable-envelope request must fail before any backend work
1395        // is scheduled or the filesystem is touched (ADR-160 D2).
1396        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            // Open exactly once. Both metadata and bytes below come from this
1413            // no-follow handle, so replacing the path after open cannot
1414            // switch the integrity authority underneath the read.
1415            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            // Read at most one sentinel byte beyond the caller's limit. The
1452            // +1 is safe because max_bytes was already bounded to 64 MiB.
1453            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    // Disabled for this compatibility release (Phase4a, ADR-111 §8 amended
1536    // 2026-08-21): this API has no `SqlAccess` capability of its own, so it
1537    // cannot prove the completed-V21 epoch `transactional_orphan_sweep`
1538    // requires before any destructive path runs. A caller-assembled
1539    // `live_refs` snapshot could otherwise delete an object a V20 SQL query
1540    // cannot see as live (e.g. a moodboard FANN network), bypassing the
1541    // epoch gate entirely. Report-only and destructive calls both refuse,
1542    // matching the trait default, until a snapshot API can carry its own
1543    // epoch proof.
1544    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    // `put` and the attachment write that later commits a `content_ref` to its
1560    // result are two separate steps of the client protocol -- the write
1561    // lock this method takes only serializes it against a concurrent `put`,
1562    // it is not held across the caller's own gap between finishing `put` and
1563    // issuing that follow-up attachment write. A blob can therefore be fully on
1564    // disk with zero live references purely because its referencing write
1565    // hasn't landed yet, not because it is actually orphaned.
1566    // `within_publish_grace` (via `orphan_sweep_grace`) is what protects that
1567    // window: a file younger than the grace period is left alone regardless
1568    // of liveness. Residual assumption: a client that waits longer than the
1569    // grace period between `put` returning and its attachment write committing
1570    // is still exposed to this method deleting the blob out from under it --
1571    // callers with an unusually slow publish path should widen the grace
1572    // period (`FsBlobStore::with_orphan_sweep_grace`) accordingly.
1573    //
1574    // Cross-resource ordering (#1850): database/root ownership and filesystem
1575    // walk/metadata happen before SQL. Bounded SQL-only units recover abandoned
1576    // rows and commit at most 128 fresh claims whose attachment triggers fence new
1577    // live references; physical deletion happens after each COMMIT; a second
1578    // bounded SQL-only unit releases that batch. Database/root owners span the
1579    // destructive phases, but SQLite's single writer never spans external I/O.
1580    async fn transactional_orphan_sweep(
1581        &self,
1582        sql: &dyn SqlAccess,
1583        dry_run: bool,
1584    ) -> StorageResult<BlobOrphanSweepResult> {
1585        // This compatibility gate deliberately precedes every database/root
1586        // owner wait, filesystem walk, and abandoned-claim cleanup. V20 and
1587        // staged V21 cannot represent the complete attachment liveness set,
1588        // so even report-only sweeps must refuse without observable mutation.
1589        if !blob_gc_fencing_complete(sql).await? {
1590            return Err(unsupported_blob_gc_epoch());
1591        }
1592
1593        // Claims and their attachment triggers are database-global. Serialize the
1594        // whole cross-resource protocol by database before taking the root
1595        // locks, so differently configured roots cannot recover one another's
1596        // active claim batches. The OS lock is the crash-detecting owner:
1597        // acquiring it proves that every row left in this database is
1598        // abandoned, including rows copied by backup or left before a root
1599        // relocation.
1600        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        // Recheck immediately once database ownership -- the process-local
1624        // guard plus the cross-process advisory lock -- is held, and before
1625        // ever waiting on the root guard/lock or walking the filesystem, so
1626        // the documented pre-lock refusal contract holds even if the epoch
1627        // regressed between the read-only preflight above and ownership.
1628        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            // `self.root` was canonicalized at construction. Preserve that
1642            // spelling instead of re-resolving mutable ancestors here.
1643            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        // Every unit below has a strict cardinality bound. The database owner
1693        // and root locks span the sequence, while each claim transaction and
1694        // cleanup transaction commits before filesystem work or the next
1695        // batch. SQLite can therefore checkpoint/reuse claim-table pages
1696        // between batches instead of receiving one orphan-population-sized
1697        // transaction.
1698        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                    // Field order is load-bearing under cancellation: a
1752                    // discarded blocking-task result drops tuple fields from
1753                    // left to right, so both owner guards release before the
1754                    // test hook's `done` sender disconnects.
1755                    (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 only this bounded batch after its physical phase. On
1770            // cancellation or process death before this commit, the claims
1771            // remain fail-closed and the next exclusive database owner
1772            // reevaluates them rather than resuming deletion blindly.
1773            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/// Test-only synchronization seam into blob write-lock-guarded critical
1795/// sections (added for PR #922 and reused by the transactional sweep).
1796///
1797/// The prior regression tests proved mutual exclusion and cancellation-
1798/// safety with a fixed sleep before racing/aborting and a fixed-duration
1799/// poll loop waiting for the lock to free -- timing-dependent, and the poll
1800/// loop actually failed once in a required-suite run (a flaky
1801/// gate, not a real regression). This seam replaces both edges of the race
1802/// with event-driven coordination: a one-shot hook, queued per canonical
1803/// root, signals `reached` the instant execution is inside the guarded
1804/// closure (the owned guard already moved in) and blocks there until the
1805/// test sends `release`; `done` fires only after the guard has actually
1806/// been dropped (see `put`'s inner-block scoping of `_owned_guard`).
1807/// `#[cfg(test)]`-gated end to end -- zero effect on non-test builds.
1808#[cfg(test)]
1809#[path = "blob/sync_hook_tests.rs"]
1810mod sync_hook;
1811
1812/// Test-only pause fired the next time the orphan-sweep walk is about to
1813/// open a leaf candidate file in `walk_blob_files_from_root_handle`. Lets a
1814/// test replace that exact on-disk leaf entry with a symlink to an outside
1815/// decoy between candidate discovery and the handle-relative open/fstat that
1816/// classifies it, proving the classification stays anchored to the opened
1817/// handle rather than re-resolving a path.
1818///
1819/// Keyed by the walking store's canonical root path (`entry`/`take` mirror
1820/// `sync_hook` above), not a single process-wide slot: a process-wide slot
1821/// is stealable by ANY other sweep walk that reaches a valid leaf while
1822/// running concurrently in the same test binary (the crate's default
1823/// parallel test runner interleaves `#[tokio::test]` functions), which made
1824/// the swap regression test flaky under that interleaving. Each test in this
1825/// module sweeps its own tempdir root, so keying by canonical root gives
1826/// each test's hook install/take pair exclusive use of its own slot.
1827#[cfg(all(test, unix))]
1828#[path = "blob/walk_leaf_sync_hook_tests.rs"]
1829mod walk_leaf_sync_hook;
1830
1831/// Test-only pause after a bounded read has opened its authoritative handle
1832/// and captured handle metadata, but before its first byte read. This makes
1833/// append/truncate/path-replacement races deterministic without timing sleeps
1834/// and is deliberately separate from the put/GC lock hook above.
1835#[cfg(test)]
1836#[path = "blob/bounded_read_sync_hook_tests.rs"]
1837mod bounded_read_sync_hook;
1838
1839/// Test-only pause on `transactional_orphan_sweep`'s database-ownership
1840/// blocking task, right after the cross-process advisory lock is acquired
1841/// and before the epoch recheck that immediately follows it. Lets a test
1842/// mutate the database strictly between the read-only preflight and the
1843/// recheck, making the recheck's ordering relative to the root guard/lock
1844/// deterministic instead of racing on scheduling.
1845#[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;