Merge pull request #448 from AtalayaLabs/claude/gracious-heisenberg-5u1raf

This commit is contained in:
Dionisio Pozo
2026-06-10 16:14:51 +02:00
committed by GitHub
10 changed files with 577 additions and 133 deletions
@@ -59,8 +59,14 @@ pub struct FileBlobReadRepository {
dedup: Arc<DedupService>,
/// Lock-free cache: file_id → blob_hash.
/// Populated by `get_file()` and `resolve_blob_hash()` (slow path).
/// Entries persist until TTI expiry (30 s idle) or capacity eviction —
/// safe because blob_hash is content-addressed and never mutated.
/// Entries persist until TTI expiry (30 s idle) or capacity eviction.
/// Content updates DO remap a file_id to a new hash in place
/// (`swap_blob_hash`), so the write repository shares this cache (see
/// [`Self::blob_hash_cache`]) and invalidates the entry on every
/// content swap and hard delete — without that, streaming downloads
/// kept serving the previous blob for the TTI window after a PUT
/// update (or 500'd once the old blob was garbage-collected), and
/// every read refreshed the TTI, extending the window indefinitely.
hash_cache: Cache<String, String>,
}
@@ -80,6 +86,14 @@ impl FileBlobReadRepository {
}
}
/// Shared handle to the file_id → blob_hash cache (moka clones share
/// the underlying storage). Handed to `FileBlobWriteRepository` at DI
/// time so content swaps and hard deletes invalidate the mapping the
/// moment they commit.
pub fn blob_hash_cache(&self) -> Cache<String, String> {
self.hash_cache.clone()
}
/// Returns the user_id (owner) for a given file ID.
/// Mirrors `FolderDbRepository::get_folder_user_id`.
/// Used by the AuthorizationEngine for owner short-circuit.
@@ -157,9 +171,9 @@ impl FileBlobReadRepository {
/// subsequent reads for the same file (e.g. Range Requests on a video,
/// thumbnail + download, browser re-fetch) hit the cache instead of PG.
///
/// This is safe because `blob_hash` is content-addressed (SHA-256)
/// and never mutated — if the file's content changes, a new row with a
/// new `blob_hash` is created.
/// Staleness safety: content updates remap the file to a new hash in
/// place — the write repository invalidates this cache (shared via
/// [`Self::blob_hash_cache`]) right after every swap/delete commits.
async fn resolve_blob_hash(&self, file_id: &str) -> Result<String, DomainError> {
// Fast path: cached (lock-free read, refreshes TTI automatically)
if let Some(hash) = self.hash_cache.get(file_id) {
@@ -7,6 +7,7 @@
//! File paths are resolved by querying the materialized `storage.folders.path`
//! column (O(1) per lookup), so no recursive CTEs are needed.
use moka::sync::Cache;
use sqlx::PgPool;
use std::path::PathBuf;
use std::sync::Arc;
@@ -26,6 +27,10 @@ pub struct FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<DedupService>,
folder_repo: Arc<FolderDbRepository>,
/// Shared handle to `FileBlobReadRepository`'s file_id → blob_hash
/// cache. Content swaps and hard deletes invalidate the mapping here
/// so the read side can never serve a stale blob after a PUT update.
hash_cache: Cache<String, String>,
}
impl FileBlobWriteRepository {
@@ -33,11 +38,13 @@ impl FileBlobWriteRepository {
pool: Arc<PgPool>,
dedup: Arc<DedupService>,
folder_repo: Arc<FolderDbRepository>,
hash_cache: Cache<String, String>,
) -> Self {
Self {
pool,
dedup,
folder_repo,
hash_cache,
}
}
@@ -54,6 +61,7 @@ impl FileBlobWriteRepository {
),
dedup: Arc::new(DedupService::new_stub()),
folder_repo: Arc::new(super::folder_db_repository::FolderDbRepository::new_stub()),
hash_cache: Cache::builder().max_capacity(10_000).build(),
}
}
@@ -505,6 +513,8 @@ impl FileWritePort for FileBlobWriteRepository {
return Err(DomainError::not_found("File", id));
}
// Drop the read-side file_id → blob_hash mapping for the dead row.
self.hash_cache.invalidate(id);
Ok(())
}
@@ -524,8 +534,14 @@ impl FileWritePort for FileBlobWriteRepository {
.await?;
let new_hash = dedup_result.hash().to_string();
self.swap_blob_hash(file_id, &new_hash, size as i64, modified_at)
.await
let swapped = self
.swap_blob_hash(file_id, &new_hash, size as i64, modified_at)
.await?;
// The file now maps to a different blob — drop the read-side cache
// entry so streaming downloads cannot serve the previous content
// for the rest of its TTI window.
self.hash_cache.invalidate(file_id);
Ok(swapped)
}
async fn register_file_deferred(
@@ -47,6 +47,40 @@ impl TrashDbRepository {
}
}
/// Runs a LIMIT-ed DELETE statement repeatedly until a round affects
/// fewer rows than `batch_size`, yielding to the runtime between rounds.
///
/// `sql` must bind `$1` = cutoff timestamp and `$2` = batch size; the
/// candidate sub-select is served by the `idx_*_trash_expiry` partial
/// indexes. Each round is its own implicit transaction, so row locks,
/// WAL volume and the statement-trigger transition tables stay bounded
/// no matter how many items expired. Partial progress is fine — the
/// next retention sweep continues where this one stopped.
async fn delete_expired_batch_loop(
&self,
sql: &'static str,
cutoff: DateTime<Utc>,
batch_size: i64,
) -> Result<u64> {
let mut total: u64 = 0;
loop {
let affected = sqlx::query(sql)
.bind(cutoff)
.bind(batch_size)
.execute(self.pool.as_ref())
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("bulk delete batch: {e}"))
})?
.rows_affected();
total += affected;
if affected < batch_size as u64 {
return Ok(total);
}
tokio::task::yield_now().await;
}
}
/// Convert a trash_items view row into a TrashedItem entity.
fn row_to_trashed_item(
&self,
@@ -180,40 +214,36 @@ impl TrashRepository for TrashDbRepository {
async fn delete_expired_bulk(&self) -> Result<(u64, u64)> {
let cutoff = Utc::now() - chrono::Duration::days(self.retention_days);
let mut tx = self
.pool
.begin()
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("begin tx: {e}")))?;
// 1. Bulk-delete expired trashed files.
// 1. Bulk-delete expired trashed files in batches.
// The PG trigger `trg_files_decrement_blob_ref` automatically
// decrements blob ref_count for every deleted row.
let files_deleted =
sqlx::query("DELETE FROM storage.files WHERE is_trashed = TRUE AND trashed_at < $1")
.bind(cutoff)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("bulk delete files: {e}"))
})?
.rows_affected();
let files_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.files
WHERE id IN (SELECT id FROM storage.files
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
LIMIT $2)",
cutoff,
1_000,
)
.await?;
// 2. Bulk-delete expired trashed folders.
// FK ON DELETE CASCADE handles descendant folders and their files.
let folders_deleted =
sqlx::query("DELETE FROM storage.folders WHERE is_trashed = TRUE AND trashed_at < $1")
.bind(cutoff)
.execute(&mut *tx)
.await
.map_err(|e| {
DomainError::internal_error("TrashDb", format!("bulk delete folders: {e}"))
})?
.rows_affected();
tx.commit()
.await
.map_err(|e| DomainError::internal_error("TrashDb", format!("commit tx: {e}")))?;
// 2. Bulk-delete expired trashed folders in batches.
// FK ON DELETE CASCADE handles descendant folders and their
// files, so each row can fan out to an entire subtree — hence
// the smaller batch size.
let folders_deleted = self
.delete_expired_batch_loop(
"DELETE FROM storage.folders
WHERE id IN (SELECT id FROM storage.folders
WHERE is_trashed = TRUE AND trashed_at < $1
ORDER BY trashed_at
LIMIT $2)",
cutoff,
100,
)
.await?;
Ok((files_deleted, folders_deleted))
}