Skip to content

Commit 792437d

Browse files
committed
fsal: add background quota reconciliation task
Adds scan_and_reconcile() and reconcile_all() to QuotaManager that walk each tracked quota directory on disk, recompute its real byte footprint, and overwrite the in-memory + redb usage counter. This repairs any drift between the tracked value and reality caused by out-of-band filesystem changes (e.g. admin-side cp/rm) or lost updates. LocalFilesystem wraps its QuotaManager in Arc and exposes start_quota_reconciliation() through the Filesystem trait so main can fire-and-forget the scan on startup without blocking request handling. The trait method has a default no-op so backends without quotas are unaffected. main schedules the scan when config.quota.enabled is set and logs it in the startup banner. Missing directories reconcile to zero (the PVC was deleted out of band), and per-directory failures are logged without aborting the whole pass. Signed-off-by: Vicente Cheng <vicente.cheng@suse.com>
1 parent b920686 commit 792437d

4 files changed

Lines changed: 329 additions & 48 deletions

File tree

src/fsal/local/mod.rs

Lines changed: 60 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ use tokio::fs as tokio_fs;
1111
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
1212
use tracing::{debug, warn};
1313

14+
use std::sync::Arc;
15+
1416
use super::handle::{FileHandle, HandleManager};
15-
use super::quota::QuotaManager;
17+
use super::quota::{QuotaManager, allocated_path_size};
1618
use super::{DirEntry, FileAttributes, FileTime, FileType, Filesystem, FsStats};
1719
use crate::config::QuotaConfig;
1820

@@ -25,7 +27,7 @@ pub struct LocalFilesystem {
2527
/// Root file handle
2628
root_handle: FileHandle,
2729
/// Optional folder quota manager (present when quota is enabled in config)
28-
quota_manager: Option<QuotaManager>,
30+
quota_manager: Option<Arc<QuotaManager>>,
2931
}
3032

3133
impl LocalFilesystem {
@@ -69,7 +71,7 @@ impl LocalFilesystem {
6971
cfg.db_path
7072
))?;
7173
debug!("LocalFilesystem: quota enabled, db={:?}", cfg.db_path);
72-
Some(qm)
74+
Some(Arc::new(qm))
7375
}
7476
_ => None,
7577
};
@@ -87,7 +89,7 @@ impl LocalFilesystem {
8789
/// Access the quota manager, if one is configured.
8890
#[allow(dead_code)]
8991
pub fn quota_manager(&self) -> Option<&QuotaManager> {
90-
self.quota_manager.as_ref()
92+
self.quota_manager.as_deref()
9193
}
9294

9395
/// Resolve a path to its owning quota directory, if any. Returns the
@@ -804,7 +806,7 @@ impl Filesystem for LocalFilesystem {
804806
let size_bytes = if need_size {
805807
// Compute the total byte footprint of the source before renaming.
806808
let src = from_full_path.clone();
807-
tokio::task::spawn_blocking(move || path_size(&src))
809+
tokio::task::spawn_blocking(move || allocated_path_size(&src))
808810
.await
809811
.context("spawn_blocking failed")??
810812
} else {
@@ -1105,6 +1107,17 @@ impl Filesystem for LocalFilesystem {
11051107

11061108
Ok(real_stats)
11071109
}
1110+
1111+
fn start_quota_reconciliation(&self) {
1112+
let Some(qm) = self.quota_manager.clone() else {
1113+
return;
1114+
};
1115+
tokio::spawn(async move {
1116+
tracing::info!("Quota reconciliation task started");
1117+
qm.reconcile_all().await;
1118+
tracing::info!("Quota reconciliation task finished");
1119+
});
1120+
}
11081121
}
11091122

11101123
/// Logically normalize a path: collapse `.` and `..` components and
@@ -1222,49 +1235,6 @@ fn check_db_path_outside_root(db_path: &Path, root_path: &Path) -> Result<()> {
12221235
Ok(())
12231236
}
12241237

1225-
/// Recursively sum the on-disk byte footprint (st_blocks * 512) of all
1226-
/// regular files rooted at `path`. Used by cross-quota rename to compute
1227-
/// the amount of usage to transfer between quota directories — the unit
1228-
/// matches the allocated-bytes accounting used by `write()`/`remove()`.
1229-
///
1230-
/// Symlinks and other non-regular entries are not followed and do not
1231-
/// contribute to the total — `symlink_metadata` is called per entry so
1232-
/// the walk stays inside the export tree.
1233-
fn path_size(path: &Path) -> Result<u64> {
1234-
let metadata =
1235-
fs::symlink_metadata(path).context(format!("Failed to stat path: {:?}", path))?;
1236-
1237-
if metadata.is_file() {
1238-
return Ok(metadata.blocks().saturating_mul(512));
1239-
}
1240-
if !metadata.is_dir() {
1241-
return Ok(0);
1242-
}
1243-
1244-
let mut total: u64 = 0;
1245-
let mut stack = vec![path.to_path_buf()];
1246-
while let Some(current) = stack.pop() {
1247-
let rd = fs::read_dir(&current).context(format!(
1248-
"Failed to read dir while summing size: {:?}",
1249-
current
1250-
))?;
1251-
for entry in rd {
1252-
let entry = entry?;
1253-
let entry_path = entry.path();
1254-
let meta = fs::symlink_metadata(&entry_path).context(format!(
1255-
"Failed to stat path while summing size: {:?}",
1256-
entry_path
1257-
))?;
1258-
if meta.is_dir() {
1259-
stack.push(entry_path);
1260-
} else if meta.is_file() {
1261-
total = total.saturating_add(meta.blocks().saturating_mul(512));
1262-
}
1263-
}
1264-
}
1265-
Ok(total)
1266-
}
1267-
12681238
/// Return the on-disk byte footprint of the **symlink-resolved** path,
12691239
/// computed from `st_blocks`. Used by content-mutating operations
12701240
/// (`write`, `setattr_size`) where the kernel itself follows the link
@@ -2197,6 +2167,48 @@ mod tests {
21972167
);
21982168
}
21992169

2170+
#[tokio::test]
2171+
async fn test_start_quota_reconciliation_runs_scan() {
2172+
let (fs, export, _db) = create_test_fs_with_quota();
2173+
let qm = fs.quota_manager().unwrap();
2174+
qm.set_quota("pvc-a", 1_000_000).await.unwrap();
2175+
// Stale usage recorded in redb/in-memory.
2176+
qm.add_usage("pvc-a", 9999).await.unwrap();
2177+
2178+
// Put real files on disk totaling two blocks. Reconciliation now
2179+
// accounts in allocated bytes (st_blocks * 512), so block-aligned
2180+
// writes give a deterministic expected value.
2181+
let pvc_dir = export.path().join("pvc-a");
2182+
std::fs::create_dir_all(&pvc_dir).unwrap();
2183+
std::fs::write(pvc_dir.join("a.bin"), vec![0u8; BLOCK]).unwrap();
2184+
std::fs::write(pvc_dir.join("b.bin"), vec![0u8; BLOCK]).unwrap();
2185+
let expected_usage = (2 * BLOCK) as u64;
2186+
2187+
fs.start_quota_reconciliation();
2188+
2189+
// Poll until the background task has reconciled. Use a short loop
2190+
// with a cap to avoid hanging a broken test.
2191+
let mut waited = 0u64;
2192+
loop {
2193+
let info = fs.quota_manager().unwrap().get_quota_info("pvc-a").await;
2194+
if info == Some((1_000_000, expected_usage)) {
2195+
break;
2196+
}
2197+
if waited > 2000 {
2198+
panic!("Reconciliation did not complete, last={:?}", info);
2199+
}
2200+
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
2201+
waited += 20;
2202+
}
2203+
}
2204+
2205+
#[tokio::test]
2206+
async fn test_start_quota_reconciliation_no_quota_is_noop() {
2207+
let (fs, _export) = create_test_fs();
2208+
// Must not panic even though no quota manager is configured.
2209+
fs.start_quota_reconciliation();
2210+
}
2211+
22002212
#[tokio::test]
22012213
async fn test_rename_across_quota_dirs_rejected_when_target_full() {
22022214
let (fs, _export, _db) = create_test_fs_with_quota();

src/fsal/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,13 @@ pub trait Filesystem: Send + Sync {
339339
/// when applicable; inode counts always come from the underlying
340340
/// filesystem.
341341
async fn statvfs(&self, handle: &FileHandle) -> Result<FsStats>;
342+
343+
/// Start a background reconciliation task that scans tracked quota
344+
/// directories and corrects usage drift.
345+
///
346+
/// The default implementation does nothing. Backends that support
347+
/// quotas override this to spawn their own task.
348+
fn start_quota_reconciliation(&self) {}
342349
}
343350

344351
/// Filesystem backend types

0 commit comments

Comments
 (0)