@@ -11,8 +11,10 @@ use tokio::fs as tokio_fs;
1111use tokio:: io:: { AsyncReadExt , AsyncSeekExt , AsyncWriteExt } ;
1212use tracing:: { debug, warn} ;
1313
14+ use std:: sync:: Arc ;
15+
1416use super :: handle:: { FileHandle , HandleManager } ;
15- use super :: quota:: QuotaManager ;
17+ use super :: quota:: { QuotaManager , allocated_path_size } ;
1618use super :: { DirEntry , FileAttributes , FileTime , FileType , Filesystem , FsStats } ;
1719use 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
3133impl 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 ( ) ;
0 commit comments