fix
This commit is contained in:
@@ -29,6 +29,7 @@ pub mod thumbnail_service;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_service_test;
|
||||
pub mod trash_cleanup_service;
|
||||
pub mod tree_etag_flush_service;
|
||||
pub mod webdav_lock_service;
|
||||
pub mod wopi_discovery_service;
|
||||
pub mod zip_service;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
use sqlx::PgPool;
|
||||
use std::sync::Arc;
|
||||
use tracing::{debug, error, info, instrument};
|
||||
|
||||
use crate::infrastructure::repositories::pg::transaction_utils::retry_on_deadlock;
|
||||
|
||||
/// Background drainer for `storage.tree_etag_dirty` — the asynchronous half
|
||||
/// of the tree-ETag bump (see migration `20260627000000_async_tree_etag_queue`).
|
||||
///
|
||||
/// The statement triggers on `storage.files` / `storage.folders` only append
|
||||
/// "bump request" lpaths to the queue, taking zero folder-row locks, so any
|
||||
/// number of concurrent uploads never conflict with each other. This service
|
||||
/// turns those requests into actual `tree_modified_at` updates: every
|
||||
/// `interval_ms` it drains the queue and bumps the deduplicated ancestor
|
||||
/// closure in ONE batched UPDATE with deterministic id-order locking.
|
||||
///
|
||||
/// Correctness invariants this relies on:
|
||||
/// * The drain (DELETE) and the bump (UPDATE) are one statement — one
|
||||
/// transaction. A crash mid-flush rolls both back and the queue rows
|
||||
/// survive; the first flush after startup drains any leftovers, so no
|
||||
/// bump is ever lost (a lost bump means sync clients never discover
|
||||
/// the change).
|
||||
/// * Queue rows are dual-keyed (`lpath` captured at mutation time,
|
||||
/// `folder_id` re-resolved at flush time) and the target set is the
|
||||
/// UNION of both — so neither a folder deleted/moved away (captured
|
||||
/// lpath wins) nor a folder re-rooted by a move inside the flush
|
||||
/// window (resolved lpath wins) can lose its pending bump.
|
||||
/// * The bump is monotonic per folder:
|
||||
/// `GREATEST(NOW(), tree_modified_at + interval '1 second')`. Folder
|
||||
/// ETags have whole-second granularity (`{id}-{epoch_seconds}`, see
|
||||
/// `Folder::compute_etag`); two flushes inside the same wall-clock
|
||||
/// second must still produce two distinct ETags or a client polling
|
||||
/// between them would permanently miss the second change.
|
||||
/// * The flusher is the ONLY writer of `tree_modified_at`, runs as a
|
||||
/// single instance, and locks victims in id order — so it cannot
|
||||
/// deadlock against itself. It can still lose a race against a folder
|
||||
/// move's descendant lpath cascade, which is why the statement runs
|
||||
/// under [`retry_on_deadlock`]; a retried flush re-drains the intact
|
||||
/// queue, invisible to users.
|
||||
pub struct TreeEtagFlushService {
|
||||
pool: Arc<PgPool>,
|
||||
interval_ms: u64,
|
||||
}
|
||||
|
||||
/// Rows drained from the queue per statement. Bounds the lock footprint of
|
||||
/// one flush under burst load (bulk trash purge, recursive copy); leftovers
|
||||
/// are picked up by the in-tick drain loop or the next tick.
|
||||
const DRAIN_BATCH: i64 = 5_000;
|
||||
|
||||
/// Max drain statements per tick, so a pathological backlog cannot
|
||||
/// monopolise the maintenance connection forever within one tick.
|
||||
const MAX_BATCHES_PER_TICK: u32 = 8;
|
||||
|
||||
impl TreeEtagFlushService {
|
||||
pub fn new(pool: Arc<PgPool>, interval_ms: u64) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
// Floor the cadence so a misconfiguration can't busy-loop the
|
||||
// maintenance pool.
|
||||
interval_ms: interval_ms.max(100),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the flush loop. Fire-and-forget: the loop logs and survives
|
||||
/// every error (an exited loop would silently freeze all folder ETags),
|
||||
/// and the first flush runs immediately to drain rows left over from a
|
||||
/// previous run.
|
||||
#[instrument(skip(self))]
|
||||
pub fn start_flush_job(&self) {
|
||||
let pool = self.pool.clone();
|
||||
let interval_ms = self.interval_ms;
|
||||
info!(
|
||||
"Starting tree-ETag flush job (every {}ms, batch {})",
|
||||
interval_ms, DRAIN_BATCH
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(interval_ms));
|
||||
// If a flush overruns the interval, fire the next one a full
|
||||
// interval later instead of bursting to catch up.
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match Self::flush(&pool).await {
|
||||
Ok((0, _)) => {}
|
||||
Ok((drained, bumped)) => {
|
||||
debug!(
|
||||
"Tree-ETag flush: drained {} queue row(s), bumped {} folder(s)",
|
||||
drained, bumped
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Tree-ETag flush failed (queue preserved, will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Drain the dirty queue and bump the ancestor closure. Returns
|
||||
/// `(queue rows drained, folder rows bumped)` summed over the batches
|
||||
/// processed this tick.
|
||||
async fn flush(pool: &PgPool) -> Result<(i64, i64), sqlx::Error> {
|
||||
let mut total_drained = 0i64;
|
||||
let mut total_bumped = 0i64;
|
||||
|
||||
for _ in 0..MAX_BATCHES_PER_TICK {
|
||||
let (drained, bumped) = retry_on_deadlock("tree_etag_flush", || {
|
||||
sqlx::query_as::<_, (i64, i64)>(
|
||||
r#"
|
||||
WITH drained AS (
|
||||
DELETE FROM storage.tree_etag_dirty
|
||||
WHERE id IN (SELECT id
|
||||
FROM storage.tree_etag_dirty
|
||||
ORDER BY id
|
||||
LIMIT $1)
|
||||
RETURNING lpath, folder_id
|
||||
),
|
||||
targets AS (
|
||||
-- Captured chain: covers target folders deleted or
|
||||
-- moved away since enqueue (the old location's
|
||||
-- surviving ancestors still get their bump).
|
||||
SELECT lpath FROM drained
|
||||
UNION
|
||||
-- Flush-time resolution: a folder MOVED since
|
||||
-- enqueue had its subtree's lpaths rewritten, so
|
||||
-- the captured lpath no longer matches it — its id
|
||||
-- resolves to the CURRENT chain instead. Without
|
||||
-- this, a bump queued just before a move would be
|
||||
-- silently lost and sync clients would never
|
||||
-- discover the change.
|
||||
SELECT fo.lpath
|
||||
FROM storage.folders fo
|
||||
JOIN drained d ON fo.id = d.folder_id
|
||||
),
|
||||
victims AS (
|
||||
-- `lpath @> target` = the target folder itself plus
|
||||
-- every ancestor (GiST-indexed). Folder rows deleted
|
||||
-- since enqueue simply don't match. Lock in id order
|
||||
-- so overlapping closures cannot deadlock.
|
||||
SELECT f.id
|
||||
FROM storage.folders f
|
||||
WHERE EXISTS (SELECT 1 FROM targets t
|
||||
WHERE f.lpath @> t.lpath)
|
||||
ORDER BY f.id
|
||||
FOR NO KEY UPDATE
|
||||
),
|
||||
bumped AS (
|
||||
UPDATE storage.folders f
|
||||
SET tree_modified_at =
|
||||
GREATEST(NOW(), f.tree_modified_at + interval '1 second')
|
||||
FROM victims v
|
||||
WHERE f.id = v.id
|
||||
RETURNING f.id
|
||||
)
|
||||
SELECT (SELECT COUNT(*) FROM drained)::bigint,
|
||||
(SELECT COUNT(*) FROM bumped)::bigint
|
||||
"#,
|
||||
)
|
||||
.bind(DRAIN_BATCH)
|
||||
.fetch_one(pool)
|
||||
})
|
||||
.await?;
|
||||
|
||||
total_drained += drained;
|
||||
total_bumped += bumped;
|
||||
if drained < DRAIN_BATCH {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((total_drained, total_bumped))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user