@@ -72,20 +72,34 @@ const WINDOWS_MAP_HEADROOM: u64 = 32 * GIB;
7272/// relative to chunk-write throughput, so a multi-second window is safe.
7373const DISK_CHECK_INTERVAL_SECS : u64 = 5 ;
7474
75- /// Slack granted to a *delete* that cannot copy-on-write inside the pinned map.
75+ /// Ceiling raise offered to a single *delete* that cannot copy-on-write inside
76+ /// the pinned map.
7677///
7778/// A delete is itself a write: LMDB copies the B-tree path before it frees the
78- /// leaf pages. On a store with no free page at all, a map pinned exactly to the
79- /// file size leaves a delete nowhere to go, so the node could not prune its way
80- /// back to health.
79+ /// leaf pages, and it may need a page for the free-list's own bookkeeping. On a
80+ /// map pinned exactly to the file size a delete therefore has nowhere to go,
81+ /// and the node could not prune its way back to health.
8182///
82- /// The slack is granted **only** on the delete retry path and taken away again
83- /// immediately , so an ordinary store can never allocate from it. Leaving it
84- /// permanently in the ceiling would hand every node a little more of the very
85- /// reserve this mode exists to protect, which on a shared volume multiplies by
86- /// the number of nodes .
83+ /// Granted **only** on the delete retry path and taken away again inside the
84+ /// same locked scope , so an ordinary store can never allocate from it. Leaving
85+ /// it permanently in the ceiling would hand every node a little more of the
86+ /// very reserve this mode exists to protect, multiplied by the nodes sharing
87+ /// the volume .
8788const DELETE_COW_SLACK : u64 = 256 * 1024 ;
8889
90+ /// Total permanent file growth deletes may cause per low-disk episode.
91+ ///
92+ /// What actually needs bounding is *growth*, not grants. Most slack-assisted
93+ /// deletes reuse pages already inside `data.mdb` and grow it by nothing, and
94+ /// those must stay free: a node has to be able to prune indefinitely, and page
95+ /// reuse is not reliably available to the very next delete because LMDB cannot
96+ /// hand back pages a still-recent transaction freed. Charging per grant instead
97+ /// of per byte stops a node pruning after its first assisted delete.
98+ ///
99+ /// Only bytes the file actually gained are charged here. Reset when the store
100+ /// leaves no-growth mode. A rounding error against [`DEFAULT_DISK_RESERVE`].
101+ const DELETE_COW_GROWTH_BUDGET : u64 = 1024 * 1024 ;
102+
89103/// Configuration for LMDB storage.
90104#[ derive( Debug , Clone ) ]
91105pub struct LmdbStorageConfig {
@@ -187,13 +201,14 @@ pub struct LmdbStorage {
187201 /// can interleave so the flag ends up describing a map size that was never
188202 /// applied, leaving the store unpinned while it believes it is pinned.
189203 growth_mode_lock : tokio:: sync:: Mutex < ( ) > ,
190- /// Maintenance allowance already spent in this low-disk episode.
204+ /// Bytes `data.mdb` has permanently gained to slack-assisted deletes in
205+ /// this low-disk episode.
191206 ///
192- /// A delete's copy-on-write can extend `data.mdb` , and LMDB never gives
193- /// file space back, so that growth is permanent. Budgeting the grant stops
194- /// repeated fill-then-delete cycles walking the file into the reserve.
195- /// Reset when the store leaves no-growth mode.
196- delete_slack_granted : Arc < AtomicU64 > ,
207+ /// A delete's copy-on-write can extend the file , and LMDB never gives file
208+ /// space back, so that growth is permanent. Bounding it stops repeated
209+ /// fill-then-delete cycles walking the file into the reserve. Deletes that
210+ /// find room inside the file cost nothing. Reset on leaving no-growth mode.
211+ delete_growth_charged : Arc < AtomicU64 > ,
197212 /// Tracks every LMDB blocking task spawned by this storage.
198213 ///
199214 /// A `spawn_blocking` closure owns a cloned [`Env`] and keeps running
@@ -301,7 +316,7 @@ impl LmdbStorage {
301316 last_disk_ok : parking_lot:: Mutex :: new ( None ) ,
302317 no_growth : Arc :: new ( AtomicBool :: new ( false ) ) ,
303318 growth_mode_lock : tokio:: sync:: Mutex :: new ( ( ) ) ,
304- delete_slack_granted : Arc :: new ( AtomicU64 :: new ( 0 ) ) ,
319+ delete_growth_charged : Arc :: new ( AtomicU64 :: new ( 0 ) ) ,
305320 blocking_tracker : TaskTracker :: new ( ) ,
306321 #[ cfg( any( test, feature = "test-utils" ) ) ]
307322 test_put_gate : Arc :: new ( parking_lot:: RwLock :: new ( ( ) ) ) ,
@@ -891,7 +906,7 @@ impl LmdbStorage {
891906 // Real disk again: the maintenance allowance is refreshed. Done on
892907 // every healthy pass, not just the transition, so an allowance
893908 // spent while the flag happened to be clear is still returned.
894- self . delete_slack_granted . store ( 0 , Ordering :: Release ) ;
909+ self . delete_growth_charged . store ( 0 , Ordering :: Release ) ;
895910 return Ok ( false ) ;
896911 }
897912
@@ -1006,52 +1021,52 @@ impl LmdbStorage {
10061021 /// granted for, and an error or cancellation between the steps would leave
10071022 /// the ceiling raised for good.
10081023 ///
1009- /// The grant is budgeted. If the delete's copy-on-write does extend
1010- /// `data.mdb`, that growth is permanent — LMDB never returns file space —
1011- /// so an unbudgeted grant would let repeated fill-then-delete cycles walk
1012- /// the file into the reserve a slice at a time. In practice one grant is
1013- /// enough: once a delete commits there are free pages again, and later
1014- /// deletes reuse them. The budget resets when the store leaves no-growth
1024+ /// What is budgeted is the *growth*, not the grant. If the copy-on-write
1025+ /// does extend `data.mdb` that growth is permanent, since LMDB never
1026+ /// returns file space, so repeated fill-then-delete cycles could otherwise
1027+ /// walk the file into the reserve a slice at a time. A delete that finds
1028+ /// room inside the file is charged nothing.
1029+ ///
1030+ /// Charging per grant instead would be wrong, and was: page reuse is not
1031+ /// reliably available to the very next delete, because LMDB will not hand
1032+ /// back pages a still-recent transaction freed. A one-grant budget
1033+ /// therefore stopped a node pruning after its first assisted delete, which
1034+ /// showed up as every delete failing on 4 KiB-page hosts while passing on
1035+ /// 16 KiB-page ones. The budget resets when the store leaves no-growth
10151036 /// mode, i.e. when there is real disk to work with again.
10161037 #[ allow( unsafe_code) ]
10171038 async fn delete_with_slack ( & self , key : & XorName ) -> Result < bool > {
10181039 let key = * key;
10191040 let env = self . env . clone ( ) ;
10201041 let db = self . db ;
10211042 let lock = Arc :: clone ( & self . env_lock ) ;
1022- let budget = Arc :: clone ( & self . delete_slack_granted ) ;
1043+ let budget = Arc :: clone ( & self . delete_growth_charged ) ;
10231044
10241045 let outcome = self
10251046 . blocking_tracker
10261047 . spawn_blocking ( move || -> Result < DeleteOutcome > {
1027- // Claim, spend and settle the allowance entirely inside the
1028- // closure. A `spawn_blocking` body keeps running when its
1029- // awaiter is dropped, so accounting split across the await could
1030- // claim the budget and then never release it, permanently
1031- // costing the node its ability to prune.
1032- if budget
1033- . compare_exchange ( 0 , DELETE_COW_SLACK , Ordering :: AcqRel , Ordering :: Acquire )
1034- . is_err ( )
1035- {
1048+ // Checked and charged entirely inside the closure. A
1049+ // `spawn_blocking` body keeps running when its awaiter is
1050+ // dropped, so accounting split across the await could be
1051+ // skipped, permanently costing the node its ability to prune.
1052+ if budget. load ( Ordering :: Acquire ) >= DELETE_COW_GROWTH_BUDGET {
10361053 return Err ( Error :: Storage ( format ! (
1037- "Cannot delete: the local store is full and its {DELETE_COW_SLACK} B \
1038- maintenance allowance is already spent. Free disk space to continue."
1054+ "Cannot delete: the local store is full and deletes have already used \
1055+ their {DELETE_COW_GROWTH_BUDGET} B growth allowance. \
1056+ Free disk space to continue."
10391057 ) ) ) ;
10401058 }
10411059
1042- // From here every exit settles the charge, including a panic.
1043- let mut allowance = DeleteAllowance {
1044- budget : & budget,
1045- keep : false ,
1046- } ;
1047-
10481060 // Exclusive for the whole sequence: no transaction may be
10491061 // active across either resize, and no put may observe the
10501062 // raised ceiling.
10511063 let _guard = lock. write ( ) ;
10521064
10531065 let page = page_size:: get ( ) as u64 ;
10541066 let previous_map = env. info ( ) . map_size ;
1067+ let file_before = env
1068+ . real_disk_size ( )
1069+ . map_err ( |e| Error :: Storage ( format ! ( "Failed to query LMDB file size: {e}" ) ) ) ?;
10551070 let raised = ( previous_map as u64 )
10561071 . saturating_add ( DELETE_COW_SLACK )
10571072 . div_ceil ( page)
@@ -1074,13 +1089,16 @@ impl LmdbStorage {
10741089
10751090 let outcome = delete_in_txn ( & env, db, & key) ;
10761091
1077- // Keep the charge the moment the delete commits: that is the
1078- // one outcome whose copy-on-write can have extended
1079- // `data.mdb`, and that growth is permanent. Deciding here
1080- // rather than on the combined result means a failure to restore
1081- // the ceiling cannot refund an allowance that was really spent.
1082- if matches ! ( outcome, Ok ( DeleteOutcome :: Done ( _) ) ) {
1083- allowance. keep = true ;
1092+ // Charge what the file actually gained, not the fact that slack
1093+ // was offered. A delete that found room inside `data.mdb` costs
1094+ // nothing and must not consume the allowance, otherwise a node
1095+ // stops being able to prune after its first assisted delete.
1096+ // Measured before the ceiling is restored, and before any error
1097+ // is propagated, so a committed delete is always accounted for.
1098+ let file_after = env. real_disk_size ( ) . unwrap_or ( file_before) ;
1099+ let grew = file_after. saturating_sub ( file_before) ;
1100+ if grew > 0 {
1101+ budget. fetch_add ( grew, Ordering :: AcqRel ) ;
10841102 }
10851103
10861104 // Undo the raise before releasing the lock, on every path and
@@ -1266,25 +1284,6 @@ impl Drop for MapCeilingRestorer<'_> {
12661284 }
12671285}
12681286
1269- /// Settles the delete maintenance allowance when dropped, including on unwind.
1270- ///
1271- /// The allowance is claimed before the ceiling is raised, so every exit from
1272- /// that scope has to either keep the charge or return it. A `Drop` impl is the
1273- /// only form that also covers a panic: a stranded charge would permanently stop
1274- /// the node pruning for the rest of the low-disk episode.
1275- struct DeleteAllowance < ' a > {
1276- budget : & ' a AtomicU64 ,
1277- keep : bool ,
1278- }
1279-
1280- impl Drop for DeleteAllowance < ' _ > {
1281- fn drop ( & mut self ) {
1282- if !self . keep {
1283- self . budget . store ( 0 , Ordering :: Release ) ;
1284- }
1285- }
1286- }
1287-
12881287/// Run one delete in its own write transaction, reporting `MapFull` rather than
12891288/// raising it.
12901289///
@@ -2016,6 +2015,16 @@ mod tests {
20162015 }
20172016 assert_eq ! ( storage. current_chunks( ) . expect( "current_chunks" ) , 0 ) ;
20182017
2018+ // The allowance bounds permanent file growth, not the number of
2019+ // assisted deletes. Pruning a pinned store must stay possible however
2020+ // many deletes it takes, so whatever was charged has to be growth the
2021+ // file really took, and has to stay inside the budget.
2022+ let charged = storage. delete_growth_charged . load ( Ordering :: Acquire ) ;
2023+ assert ! (
2024+ charged < DELETE_COW_GROWTH_BUDGET ,
2025+ "deletes exhausted the growth allowance ({charged} B) while pruning a pinned store"
2026+ ) ;
2027+
20192028 // Whether or not any delete needed the maintenance allowance, none of
20202029 // it may be left in the ceiling afterwards: a raised ceiling is
20212030 // ordinary put capacity, so leaking it hands away the reserve.
0 commit comments