Skip to content

Commit 0a85404

Browse files
committed
fix(storage): budget delete copy-on-write by growth, not by grant
A store pinned to its file size cannot always copy-on-write a delete, so the delete path offers a temporary ceiling raise. That raise was budgeted one grant per low-disk episode, on the assumption that the first assisted delete frees pages the next one reuses. That assumption is wrong. LMDB will not hand back pages a still-recent transaction freed, so consecutive deletes on a full store can each need a little room. Charging per grant therefore stopped a node pruning after its first assisted delete, which is the opposite of what the allowance exists for. It passed locally and failed in CI because the two differ in page size: 16 KiB pages left enough slack in the first grant to cover later deletes, 4 KiB pages did not. What needs bounding is permanent file growth, since LMDB never returns file space, not the number of times slack was offered. The allowance is now charged the bytes `data.mdb` actually gained, measured across the delete. A delete that finds room inside the file costs nothing and pruning continues indefinitely, while repeated fill-then-delete cycles are still stopped from walking the file into the disk reserve. The test that pruned a pinned store now also asserts the accounting rule directly, so a regression to per-grant charging fails on any page size rather than only on hosts with small pages.
1 parent 154863d commit 0a85404

1 file changed

Lines changed: 76 additions & 67 deletions

File tree

src/storage/lmdb.rs

Lines changed: 76 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,34 @@ const WINDOWS_MAP_HEADROOM: u64 = 32 * GIB;
7272
/// relative to chunk-write throughput, so a multi-second window is safe.
7373
const 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.
8788
const 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)]
91105
pub 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

Comments
 (0)