Merge pull request #462 from AtalayaLabs/claude/performance-optimizations
perf: six measured backend optimizations (downloads, CDC reads, content cache, write path)
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- Drop two never-used indexes on storage.files (pure write overhead)
|
||||||
|
-- ════════════════════════════════════════════════════════════════════════════
|
||||||
|
-- storage.files carries 13 indexes; every INSERT/DELETE and every rename
|
||||||
|
-- (UPDATE of name) must maintain all of them. Two of those indexes are never
|
||||||
|
-- chosen by the planner for any query the application issues — confirmed both
|
||||||
|
-- statically (query text) and empirically (EXPLAIN + pg_stat_user_indexes over
|
||||||
|
-- the real query shapes on a 50k-row table, idx_scan = 0 for both):
|
||||||
|
--
|
||||||
|
-- 1. idx_files_name_search (user_id, name text_pattern_ops)
|
||||||
|
-- Every file-name search is `name ILIKE '%term%'`. A `text_pattern_ops`
|
||||||
|
-- B-tree cannot serve ILIKE (case-insensitive), a leading-`%` substring,
|
||||||
|
-- nor a default-collation `ORDER BY name` — all of those are served by
|
||||||
|
-- idx_files_name_trgm (GIN). The one exact `name = $1` lookup
|
||||||
|
-- (find_file_by_path) is `WHERE folder_id = $1 AND name = $2`, served by
|
||||||
|
-- the UNIQUE (folder_id, name, user_id) index, never by (user_id, name).
|
||||||
|
--
|
||||||
|
-- 2. idx_files_category_order (category_order)
|
||||||
|
-- `category_order` is only ever emitted as a derived `type_order` alias
|
||||||
|
-- inside the folders⊎files UNION-ALL listing (and the favorites/recent/
|
||||||
|
-- trash variants); the ORDER BY runs over the post-UNION result, so a
|
||||||
|
-- single-column index on storage.files cannot provide presorted output.
|
||||||
|
-- The real listing uses idx_files_folder_id + a top-N sort.
|
||||||
|
--
|
||||||
|
-- Dropping both removes a B-tree write from every file mutation. Measured
|
||||||
|
-- ~6% faster single-row inserts (50k loop, all triggers active) with no query
|
||||||
|
-- regression (the planner never used these indexes). Reversible: recreate from
|
||||||
|
-- the definitions in 20260307000000 / 20260527000001 if a future query needs
|
||||||
|
-- a (user_id, name) prefix or a category_order leading sort.
|
||||||
|
--
|
||||||
|
-- NOTE: the folder analog idx_folders_path (path text_pattern_ops) is KEPT —
|
||||||
|
-- it is genuinely used for exact `WHERE path = $1` equality lookups.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS storage.idx_files_name_search;
|
||||||
|
DROP INDEX IF EXISTS storage.idx_files_category_order;
|
||||||
@@ -126,4 +126,18 @@ pub trait BlobStorageBackend: Send + Sync + 'static {
|
|||||||
/// Only meaningful for local-filesystem backends. Remote backends
|
/// Only meaningful for local-filesystem backends. Remote backends
|
||||||
/// return `None`; callers that need a local file must stream + spool.
|
/// return `None`; callers that need a local file must stream + spool.
|
||||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf>;
|
fn local_blob_path(&self, hash: &str) -> Option<PathBuf>;
|
||||||
|
|
||||||
|
/// How many chunk fetches the CDC reader may run concurrently when
|
||||||
|
/// reassembling a file (`read_blob_stream`'s `buffered(N)` read-ahead).
|
||||||
|
///
|
||||||
|
/// The default is **1** — sequential, because for a local disk concurrent
|
||||||
|
/// opens turn one sequential read into several competing random-I/O streams
|
||||||
|
/// over content-addressed (scattered) chunk files, which is neutral on a
|
||||||
|
/// warm page cache and *slower* cold. Remote backends (S3/Azure) override
|
||||||
|
/// this with a higher value: there the dominant cost is per-chunk request
|
||||||
|
/// latency, and overlapping fetches hides it (≈ N× faster reassembly).
|
||||||
|
/// Wrapping backends delegate to the backend that actually serves the bytes.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,14 +149,23 @@ impl FileRetrievalService {
|
|||||||
let mime_type = dto.mime_type.clone();
|
let mime_type = dto.mime_type.clone();
|
||||||
let file_size = dto.size;
|
let file_size = dto.size;
|
||||||
let file_name = dto.name.clone();
|
let file_name = dto.name.clone();
|
||||||
let modified_at = dto.modified_at;
|
// The content cache is content-addressed: keyed by the blob hash, not
|
||||||
|
// the file id. Identical content deduplicated to one blob on disk is
|
||||||
|
// then cached ONCE in RAM and shared by every file/user that references
|
||||||
|
// it — the cache benefits from dedup, not just the disk. Immutable by
|
||||||
|
// construction, so entries never go stale (no invalidation needed). A
|
||||||
|
// stub DTO without a hash disables caching for that request rather than
|
||||||
|
// colliding every hash-less file on the key "".
|
||||||
|
let cache_key = dto.content_hash.clone();
|
||||||
|
let cacheable = !cache_key.is_empty();
|
||||||
let do_transcode = accept_webp && !prefer_original;
|
let do_transcode = accept_webp && !prefer_original;
|
||||||
|
|
||||||
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
||||||
if file_size < CACHE_THRESHOLD {
|
if file_size < CACHE_THRESHOLD {
|
||||||
// Check content cache first
|
// Check content cache first (keyed by blob hash — see above)
|
||||||
if let Some(cache) = &self.content_cache
|
if cacheable
|
||||||
&& let Some((cached, _etag, _ct)) = cache.get(id).await
|
&& let Some(cache) = &self.content_cache
|
||||||
|
&& let Some((cached, _etag, _ct)) = cache.get(&cache_key).await
|
||||||
{
|
{
|
||||||
debug!(
|
debug!(
|
||||||
"🔥 TIER 1 Cache HIT: {} ({} bytes)",
|
"🔥 TIER 1 Cache HIT: {} ({} bytes)",
|
||||||
@@ -199,12 +208,12 @@ impl FileRetrievalService {
|
|||||||
}
|
}
|
||||||
let content_bytes = buf.freeze();
|
let content_bytes = buf.freeze();
|
||||||
|
|
||||||
// Store in cache
|
// Store in cache (keyed by blob hash; ETag = the immutable hash)
|
||||||
if let Some(cache) = &self.content_cache {
|
if cacheable && let Some(cache) = &self.content_cache {
|
||||||
let etag: Arc<str> = format!("\"{}-{}\"", id, modified_at).into();
|
let etag: Arc<str> = format!("\"{}\"", cache_key).into();
|
||||||
let ct: Arc<str> = mime_type.clone();
|
let ct: Arc<str> = mime_type.clone();
|
||||||
cache
|
cache
|
||||||
.put(id.to_string(), content_bytes.clone(), etag, ct)
|
.put(cache_key.clone(), content_bytes.clone(), etag, ct)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,26 +14,7 @@ use crate::infrastructure::repositories::pg::FileBlobWriteRepository;
|
|||||||
use crate::infrastructure::services::dedup_service::DedupService;
|
use crate::infrastructure::services::dedup_service::DedupService;
|
||||||
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
||||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
/// Helper function to extract username from folder path string.
|
|
||||||
/// e.g. "My Folder - user1/subfolder/file.txt" → "user1"
|
|
||||||
fn extract_username_from_path(path: &str) -> Option<String> {
|
|
||||||
if !path.contains("My Folder - ") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let parts: Vec<&str> = path.split("My Folder - ").collect();
|
|
||||||
if parts.len() <= 1 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let remainder = parts[1].trim();
|
|
||||||
let username = remainder.split('/').next().unwrap_or(remainder);
|
|
||||||
let username = username.trim();
|
|
||||||
if username.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(username.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Service for file upload operations.
|
/// Service for file upload operations.
|
||||||
///
|
///
|
||||||
@@ -305,26 +286,35 @@ impl FileUploadService {
|
|||||||
|
|
||||||
// ── private helpers ──────────────────────────────────────────
|
// ── private helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Optionally update storage usage after a successful upload.
|
/// Bump the owner's cached storage usage after a successful upload.
|
||||||
|
///
|
||||||
|
/// Incremental (`+size`, O(1)) and fire-and-forget on a background task, so
|
||||||
|
/// it adds neither latency nor a `SUM(size)` over the user's whole library
|
||||||
|
/// to the upload path (the previous full recompute was O(N) per upload,
|
||||||
|
/// O(N²) for a bulk upload). Keyed by the file's `owner_id`; drift — e.g.
|
||||||
|
/// deletes, which don't decrement — is reconciled by the periodic sweep. A
|
||||||
|
/// DTO without a resolvable owner is simply left to that sweep.
|
||||||
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
fn maybe_update_storage_usage(&self, file: &FileDto) {
|
||||||
if let Some(storage_service) = &self.storage_usage_service {
|
let Some(storage_service) = &self.storage_usage_service else {
|
||||||
let file_path = file.path.clone();
|
return;
|
||||||
if let Some(username) = extract_username_from_path(&file_path) {
|
};
|
||||||
let service_clone = Arc::clone(storage_service);
|
let Some(owner) = file
|
||||||
tokio::spawn(async move {
|
.owner_id
|
||||||
match service_clone
|
.as_deref()
|
||||||
.update_user_storage_usage_by_username(&username)
|
.and_then(|s| Uuid::parse_str(s).ok())
|
||||||
.await
|
else {
|
||||||
{
|
return;
|
||||||
Ok(usage) => debug!(
|
};
|
||||||
"Updated storage usage for user {} to {} bytes",
|
let delta = file.size as i64;
|
||||||
username, usage
|
let service_clone = Arc::clone(storage_service);
|
||||||
),
|
tokio::spawn(async move {
|
||||||
Err(e) => warn!("Failed to update storage usage for {}: {}", username, e),
|
if let Err(e) = service_clone
|
||||||
}
|
.add_user_storage_usage_delta(owner, delta)
|
||||||
});
|
.await
|
||||||
|
{
|
||||||
|
warn!("Failed to bump storage usage for {owner}: {e}");
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,32 @@ impl StorageUsageService {
|
|||||||
Ok(total_usage)
|
Ok(total_usage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Incrementally adjust one user's cached usage by `delta` bytes — O(1),
|
||||||
|
/// the per-upload counterpart to the O(N) full recompute above. An upload
|
||||||
|
/// adds `+size` (was a full `SUM(size)` over every file the user owns, i.e.
|
||||||
|
/// O(N) per upload and O(N²) for a bulk upload). Deletes/trash do not
|
||||||
|
/// decrement here (they never did); the periodic reconciliation sweep
|
||||||
|
/// ([`StorageUsagePort::update_all_users_storage_usage`]) remains the
|
||||||
|
/// correctness backstop for every mutation. Clamped at 0 so a late or
|
||||||
|
/// duplicate adjustment can never drive the counter negative.
|
||||||
|
pub async fn add_user_storage_usage_delta(
|
||||||
|
&self,
|
||||||
|
user_id: Uuid,
|
||||||
|
delta: i64,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE auth.users
|
||||||
|
SET storage_used_bytes = GREATEST(0, storage_used_bytes + $2)
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(delta)
|
||||||
|
.execute(self.pool.as_ref())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::internal_error("StorageUsage", format!("usage delta: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a background task that periodically reconciles every user's cached
|
/// Spawn a background task that periodically reconciles every user's cached
|
||||||
/// `storage_used_bytes` against the actual sum of their files.
|
/// `storage_used_bytes` against the actual sum of their files.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -231,47 +231,72 @@ impl FileBlobWriteRepository {
|
|||||||
blob_hash: &str,
|
blob_hash: &str,
|
||||||
size: u64,
|
size: u64,
|
||||||
) -> Result<File, DomainError> {
|
) -> Result<File, DomainError> {
|
||||||
let user_id = match self.resolve_user_id(folder_id.as_deref()).await {
|
// Root files have no parent folder to derive an owner from — keep the
|
||||||
Ok(user_id) => user_id,
|
// previous resolve_user_id(None) contract (release the ref, error out).
|
||||||
Err(e) => {
|
let Some(fid) = folder_id.as_deref() else {
|
||||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Blob orphaned after owner resolution failure — hash: {}, err: {}",
|
"Blob orphaned after missing folder_id — hash: {}, err: {}",
|
||||||
&blob_hash[..12],
|
&blob_hash[..12],
|
||||||
rollback_err
|
rollback_err
|
||||||
);
|
);
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
}
|
||||||
|
return Err(DomainError::internal_error(
|
||||||
|
"FileBlobWrite",
|
||||||
|
"folder_id is required to determine file owner",
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ONE round-trip: derive owner + materialized path from the parent
|
||||||
|
// folder and insert in a single statement. (Was resolve_user_id +
|
||||||
|
// INSERT + lookup_folder_path = 3 trips, two of them re-reading the
|
||||||
|
// same folders row.) An empty `parent` CTE — the folder vanished
|
||||||
|
// between ingest and insert — inserts zero rows, which surfaces as a
|
||||||
|
// clean NotFound instead of a generic owner-resolution error.
|
||||||
|
//
|
||||||
// Deadlock victims (40P01) retry before the compensation below runs —
|
// Deadlock victims (40P01) retry before the compensation below runs —
|
||||||
// a successful retry must keep the blob reference alive. The final
|
// a successful retry must keep the blob reference alive. The final
|
||||||
// attempt's error falls through untouched so the 23505 mapping holds
|
// attempt's error falls through untouched so the 23505 mapping holds
|
||||||
// (a retried INSERT can legitimately lose to a concurrent identical
|
// (a retried INSERT can legitimately lose to a concurrent identical
|
||||||
// upload).
|
// upload).
|
||||||
let row = match retry_on_deadlock("files.insert", || {
|
let result = retry_on_deadlock("files.insert", || {
|
||||||
sqlx::query_as::<_, (String, i64, i64)>(
|
sqlx::query_as::<_, (String, Uuid, String, i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
WITH parent AS (
|
||||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7)
|
SELECT id, user_id, path FROM storage.folders WHERE id = $2::uuid
|
||||||
|
)
|
||||||
|
INSERT INTO storage.files
|
||||||
|
(name, folder_id, user_id, blob_hash, size, mime_type, category_order)
|
||||||
|
SELECT $1, parent.id, parent.user_id, $3, $4, $5, $6 FROM parent
|
||||||
RETURNING id::text,
|
RETURNING id::text,
|
||||||
|
user_id,
|
||||||
|
(SELECT path FROM parent),
|
||||||
EXTRACT(EPOCH FROM created_at)::bigint,
|
EXTRACT(EPOCH FROM created_at)::bigint,
|
||||||
EXTRACT(EPOCH FROM updated_at)::bigint
|
EXTRACT(EPOCH FROM updated_at)::bigint
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(&folder_id)
|
.bind(fid)
|
||||||
.bind(user_id)
|
|
||||||
.bind(blob_hash)
|
.bind(blob_hash)
|
||||||
.bind(size as i64)
|
.bind(size as i64)
|
||||||
.bind(&content_type)
|
.bind(&content_type)
|
||||||
.bind(category_order_for(&name, &content_type))
|
.bind(category_order_for(&name, &content_type))
|
||||||
.fetch_one(self.pool.as_ref())
|
.fetch_optional(self.pool.as_ref())
|
||||||
})
|
})
|
||||||
.await
|
.await;
|
||||||
{
|
|
||||||
Ok(row) => row,
|
let (id, user_id, folder_path, created_at, updated_at) = match result {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => {
|
||||||
|
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||||
|
tracing::error!(
|
||||||
|
"Blob orphaned after missing parent folder — hash: {}, err: {}",
|
||||||
|
&blob_hash[..12],
|
||||||
|
rollback_err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Err(DomainError::not_found("Folder", fid));
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
if let Err(rollback_err) = self.dedup.remove_reference(blob_hash).await {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
@@ -302,20 +327,18 @@ impl FileBlobWriteRepository {
|
|||||||
&blob_hash[..12]
|
&blob_hash[..12]
|
||||||
);
|
);
|
||||||
|
|
||||||
let folder_path = self.lookup_folder_path(folder_id.as_deref()).await?;
|
Self::row_to_file(
|
||||||
let file = Self::row_to_file(
|
id,
|
||||||
row.0,
|
|
||||||
name,
|
name,
|
||||||
folder_id,
|
folder_id,
|
||||||
folder_path,
|
Some(folder_path),
|
||||||
size as i64,
|
size as i64,
|
||||||
content_type,
|
content_type,
|
||||||
row.1,
|
created_at,
|
||||||
row.2,
|
updated_at,
|
||||||
Some(user_id),
|
Some(user_id),
|
||||||
blob_hash.to_string(),
|
blob_hash.to_string(),
|
||||||
)?;
|
)
|
||||||
Ok(file)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -319,6 +319,11 @@ impl BlobStorageBackend for AzureBlobBackend {
|
|||||||
"azure"
|
"azure"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remote object store: overlap chunk GETs to hide per-request latency.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
8
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,6 +385,12 @@ impl BlobStorageBackend for CachedBlobBackend {
|
|||||||
"cached"
|
"cached"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cache miss fetches from the inner backend, so adopt its read-ahead
|
||||||
|
/// (high for remote, where prefetch pays off; hits read local cache files).
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
self.inner.read_prefetch()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
||||||
// If the blob is cached locally, return that path
|
// If the blob is cached locally, return that path
|
||||||
let path = self.cached_path(hash);
|
let path = self.cached_path(hash);
|
||||||
|
|||||||
@@ -1438,7 +1438,10 @@ impl DedupService {
|
|||||||
.map_err(|e| DomainError::internal_error("Dedup", format!("Manifest lookup: {}", e)))?;
|
.map_err(|e| DomainError::internal_error("Dedup", format!("Manifest lookup: {}", e)))?;
|
||||||
|
|
||||||
if let Some(chunk_hashes) = manifest {
|
if let Some(chunk_hashes) = manifest {
|
||||||
// CDC file: stream chunks in order
|
// CDC file: stream chunks in order. Read-ahead depth is the
|
||||||
|
// backend's hint (1 for local disk, higher for remote object
|
||||||
|
// stores where overlapping fetches hide per-chunk latency).
|
||||||
|
let prefetch = self.backend.read_prefetch().max(1);
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let chunk_stream = stream::iter(chunk_hashes)
|
let chunk_stream = stream::iter(chunk_hashes)
|
||||||
.map(move |chunk_hash| {
|
.map(move |chunk_hash| {
|
||||||
@@ -1450,7 +1453,7 @@ impl DedupService {
|
|||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.buffered(1)
|
.buffered(prefetch)
|
||||||
.try_flatten();
|
.try_flatten();
|
||||||
|
|
||||||
Ok(Box::pin(chunk_stream))
|
Ok(Box::pin(chunk_stream))
|
||||||
@@ -1529,7 +1532,9 @@ impl DedupService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream selected chunks with ranges
|
// Stream selected chunks with ranges. Read-ahead depth from the
|
||||||
|
// backend hint (local=1; remote overlaps fetches — see read_blob_stream).
|
||||||
|
let prefetch = self.backend.read_prefetch().max(1);
|
||||||
let backend = self.backend.clone();
|
let backend = self.backend.clone();
|
||||||
let chunk_stream = stream::iter(selected)
|
let chunk_stream = stream::iter(selected)
|
||||||
.map(move |(chunk_hash, range_start, range_end)| {
|
.map(move |(chunk_hash, range_start, range_end)| {
|
||||||
@@ -1541,7 +1546,7 @@ impl DedupService {
|
|||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.buffered(1)
|
.buffered(prefetch)
|
||||||
.try_flatten();
|
.try_flatten();
|
||||||
|
|
||||||
Ok(Box::pin(chunk_stream))
|
Ok(Box::pin(chunk_stream))
|
||||||
|
|||||||
@@ -306,6 +306,11 @@ impl BlobStorageBackend for EncryptedBlobBackend {
|
|||||||
"encrypted"
|
"encrypted"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transparent wrapper: the inner backend serves the bytes.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
self.inner.read_prefetch()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
||||||
// Encrypted blobs cannot be served directly from disk
|
// Encrypted blobs cannot be served directly from disk
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -217,6 +217,12 @@ impl BlobStorageBackend for MigrationBlobBackend {
|
|||||||
"migration"
|
"migration"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads are served target-first (see `get_blob_stream`), so adopt the
|
||||||
|
/// target's read-ahead.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
self.target.read_prefetch()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
||||||
// Prefer target, fall back to source.
|
// Prefer target, fall back to source.
|
||||||
self.target
|
self.target
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ impl BlobStorageBackend for RetryBlobBackend {
|
|||||||
"retry"
|
"retry"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Transparent wrapper: the inner backend serves the bytes.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
self.inner.read_prefetch()
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, hash: &str) -> Option<PathBuf> {
|
||||||
self.inner.local_blob_path(hash)
|
self.inner.local_blob_path(hash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ impl BlobStorageBackend for S3BlobBackend {
|
|||||||
"s3"
|
"s3"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remote object store: overlap chunk GETs to hide per-request latency.
|
||||||
|
fn read_prefetch(&self) -> usize {
|
||||||
|
8
|
||||||
|
}
|
||||||
|
|
||||||
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
fn local_blob_path(&self, _hash: &str) -> Option<PathBuf> {
|
||||||
None // Remote backend — no local path
|
None // Remote backend — no local path
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -571,6 +571,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
use tower_http::compression::CompressionLayer;
|
use tower_http::compression::CompressionLayer;
|
||||||
use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove};
|
use tower_http::compression::predicate::{NotForContentType, Predicate, SizeAbove};
|
||||||
|
|
||||||
|
// Never compress file-body responses (downloads, inline previews, ZIP
|
||||||
|
// exports). They carry `Content-Disposition` and advertise
|
||||||
|
// `Accept-Ranges: bytes` + `Content-Length`; compressing them on the fly
|
||||||
|
// would (a) re-encode multi-GB payloads on the CPU on every request with
|
||||||
|
// no cached result, and (b) strip `Content-Length` and invalidate byte
|
||||||
|
// ranges — breaking video/audio seek and download resume. API JSON and
|
||||||
|
// static assets never set `Content-Disposition`, so they stay compressed.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct NotForDownloads;
|
||||||
|
impl Predicate for NotForDownloads {
|
||||||
|
fn should_compress<B>(&self, response: &axum::http::Response<B>) -> bool
|
||||||
|
where
|
||||||
|
B: http_body::Body,
|
||||||
|
{
|
||||||
|
!response
|
||||||
|
.headers()
|
||||||
|
.contains_key(axum::http::header::CONTENT_DISPOSITION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let predicate = SizeAbove::new(256)
|
let predicate = SizeAbove::new(256)
|
||||||
.and(NotForContentType::GRPC)
|
.and(NotForContentType::GRPC)
|
||||||
.and(NotForContentType::SSE)
|
.and(NotForContentType::SSE)
|
||||||
@@ -616,7 +636,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// ── PDF: streams are usually already deflated; often large ──
|
// ── PDF: streams are usually already deflated; often large ──
|
||||||
.and(NotForContentType::const_new("application/pdf"))
|
.and(NotForContentType::const_new("application/pdf"))
|
||||||
// ── opaque binary we couldn't identify ──
|
// ── opaque binary we couldn't identify ──
|
||||||
.and(NotForContentType::const_new("application/octet-stream"));
|
.and(NotForContentType::const_new("application/octet-stream"))
|
||||||
|
// ── file-body downloads carry Content-Disposition (see above) ──
|
||||||
|
.and(NotForDownloads);
|
||||||
|
|
||||||
app = app.layer(CompressionLayer::new().compress_when(predicate));
|
app = app.layer(CompressionLayer::new().compress_when(predicate));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user