Skip to content

fix(storage): treat a node as full only when disk and store are both out of room - #210

Merged
jacderida merged 2 commits into
WithAutonomi:mainfrom
grumbach:fix/capacity-check-lmdb-reuse
Aug 24, 2026
Merged

fix(storage): treat a node as full only when disk and store are both out of room#210
jacderida merged 2 commits into
WithAutonomi:mainfrom
grumbach:fix/capacity-check-lmdb-reuse

Conversation

@grumbach

@grumbach grumbach commented Aug 20, 2026

Copy link
Copy Markdown
Member

Linear issue

https://linear.app/autonominetwork/issue/V2-1034/capacity-check-ignores-reusable-lmdb-pages-so-pruned-nodes-refuse

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

This is a bug fix restoring the intended capacity check, not a new design: the two-part predicate ("full" = no disk and no reusable page) was always what the guard was supposed to express, and the MapFull half was already implemented and simply unreachable. No wire message, stored format, payment path or upgrade mechanism changes, and behaviour above the disk reserve is byte-for-byte unchanged.

Reviewer note: the only behavioural delta is on a node already under its reserve, which today refuses every write. Flag it if you read that as T2.

Compatibility

  • Wire: none. No message type, field or protocol identifier changes.
  • Storage: none. No change to the LMDB layout, key or value encoding. The map ceiling is a runtime setting, not part of the file format, and LMDB clamps any request below the space in use, so a store written by this build opens unchanged on the previous one.
  • API: none externally. check_capacity keeps its signature and its Insufficient disk space … error text; only the condition under which it fires changes.

Semver impact

  • breaking
  • feature
  • fix

Test evidence

cargo test --lib: 936 passed, 0 failed. cfd (fmt + clippy + doc) clean.

Seven tests added or rewritten, each pinned to a claim rather than to the implementation:

Test Proves
below_reserve_put_reuses_freed_pages The regression itself. Below the reserve, a write refused before any delete succeeds after one, and data.mdb does not grow doing it.
below_reserve_refused_put_does_not_grow_the_file Refused writes leave the file and the map ceiling untouched, so the reserve is preserved. Asserts the specific error, not just any failure.
large_refusal_does_not_block_a_smaller_put A refused maximum-sized value does not lock out a smaller one.
full_store_below_reserve_can_still_delete A pinned store can still prune, no allowance is left in the ceiling afterwards, and pinning still refuses an oversized write.
check_capacity_tracks_reusable_space_not_just_disk The pre-check refuses with no reusable space and reopens after pruning, with statvfs unchanged throughout.
leaving_no_growth_restores_head_room Freeing disk lifts the pin and re-grows the map.
above_reserve_behaviour_is_unchanged No pinning and no behaviour change on a healthy node.

test_put_rejected_on_insufficient_capacity_before_verification (handler) still proves a genuinely full node short-circuits ahead of payment verification, which is the saving the earlier pre-check work introduced.

Adversarially reviewed over six rounds. Findings addressed rather than argued: a store-wide MapFull verdict that let one large chunk lock out small writes; a permanent map slack that was really ordinary put capacity and multiplied by nodes per volume; transition races between the mode flag and the map size; a torn read in the reusable estimate that could under-report; and leak paths for both the raised ceiling and the delete allowance under error, panic and cancellation.

Not yet done: no testnet run. Worth exercising on a deliberately filled host before it rides a train, because the behaviour only differs once a volume is under its reserve.

New dependency

none

ADR

n/a — bug fix, not an architectural decision.

The reasoning that would have gone in one is in the commit message and in the code comments at the decision points: why the allocator answers "does this write fit" instead of a page-count estimate, why non_free_pages_size() must not be called, why a delete needs a budgeted allowance, and why an async lock cannot order two resizes.

Mitigation / rollback

Revert the commit. The change is confined to src/storage/lmdb.rs and one handler test, adds no state that outlives the process, and writes nothing new to disk. Behaviour above the disk reserve is unchanged, so the blast radius is limited to nodes already under their reserve, which today refuse every write regardless.

…out of room

A node decided it was full from `fs2::available_space()` alone and never asked
LMDB whether the write would actually fit. Deleting a record returns its pages
to LMDB's free list and never to the filesystem, so a node that has pruned
heavily sits on reusable capacity while `statvfs` still reports the volume as
full. It refused every write anyway, including writes that would land in a
freed page without growing `data.mdb` by a byte.

On the production fleet one host crossed the 500 MiB reserve and logged 423,202
write rejections in six hours across all 13 of its nodes, having pruned 4,609
records in the preceding day. The store had room; the guard could not see it.

The predicate now has both halves. Below the reserve the map is pinned to the
file's high-water mark, so a put succeeds exactly when LMDB can serve it from
the free list and returns `MDB_MAP_FULL` the moment it would extend the file.
The allocator is the authority, not an estimate: no page count can account for
the copy-on-write of the B-tree path, the contiguous run a multi-megabyte value
needs, or pages still pinned by an open read transaction.

`check_capacity` remains a cheap pre-check and stays biased towards admitting.
It estimates reusable bytes from `env.stat()` and refuses only when there is
not one chunk's worth, preserving the saving of rejecting a full node before
payment verification without blinding one that still has room. It deliberately
avoids heed's `non_free_pages_size()`, which walks the unnamed database calling
`String::from_utf8(key).unwrap()` and so panics on 32-byte binary keys.

Two hazards the pinned mode introduces are handled explicitly:

- A delete is itself a write. On a store with no free page it cannot
  copy-on-write inside a map pinned to the file size, so the node could never
  prune its way out. `delete` raises the ceiling by a budgeted allowance,
  retries, and restores it inside one exclusive-lock scope, with RAII guards so
  neither the ceiling nor the allowance can leak on an unwind. The allowance is
  charged only when the delete commits, because that is the only outcome whose
  copy-on-write can have extended the file permanently.
- A `spawn_blocking` body outlives a cancelled awaiter, so an async lock cannot
  order two resizes. The mode's intent is published before the work, and both
  resize closures re-read it under the exclusive lock and decline if it has
  since been reversed. `try_resize` also measures the disk inside the closure,
  so a late one sizes from the disk as it is rather than as it was.

Reviewed adversarially over six rounds; the findings on cross-size verdict
caching, permanent map slack, transition races, torn reads and leak paths are
all addressed.
@grumbach
grumbach force-pushed the fix/capacity-check-lmdb-reuse branch from b73a09a to 154863d Compare August 20, 2026 09:26
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.
grumbach added a commit to grumbach/ant-node that referenced this pull request Aug 21, 2026
…e predicate

The replication verification cycle gates its close-group probe on
`LmdbStorage::capacity_verdict`, while `execute_single_fetch` gates the dial on
`LmdbStorage::check_capacity`. The two restate one comparison rather than
sharing it, so they can drift apart, and a verdict stricter than the pre-check
is the harmful direction: a node the pre-check would let write stops
discovering holders for keys it could have stored, which is under-replication
rather than a saved probe.

That is not hypothetical. WithAutonomi#210 makes the pre-check two-part, below the reserve
and out of reusable pages inside the store, because LMDB returns a deleted
record's pages to its own free list and never to the filesystem. A heavily
pruned node fails only the first half, so with WithAutonomi#210 in and the verdict left as
it stands, such a node stands its keys down for five minutes at a time instead
of looking for chunks it can store.

Add a unit test built on that state rather than on a bare full disk, where the
two predicates still agree and a test would pass straight through the
divergence. It writes and deletes two chunks so the store carries more than one
chunk of reusable space, establishes the below-reserve precondition without
either function under test, and asserts the two refuse together. Applying
WithAutonomi#210's predicate to the pre-check fails this test and nothing else in the
934-test suite.

Record the coupling in ADR-0011, as a trade-off, a validation entry and a
review trigger, so the constraint outlives the pull request that found it.

@dirvine dirvine left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — reviewed exact head 0a8540418ce518c033fc0211f5f4cb7188019230.

No material blockers found. Map sizing accounts for live data and reusable pages; runtime resize and all database operations are coordinated under env_lock; cancellation paths re-check growth mode; deletion retains bounded COW slack while full; restart restores the intended high-water behaviour. CI is green. Local verification: formatting and all 23 focused LMDB tests passed.

Integration note with #207: preserve #210's two-part capacity predicate when reconciling capacity_verdict; the cross-PR tripwire test should remain green.

@jacderida

Copy link
Copy Markdown
Member

Testnet evidence — PASS, with one observability finding

Tested at scale on a 195-service testnet (V2-1055, DEV-01 run 538), combined with #207 on branch testnet/pr207-pr210-combined @ 239aa94 — both merged onto main with the reconciliation #207 prescribes, so capacity_verdict() and check_capacity() refuse on the same two-part condition.

Setup: 39 OVH VMs × 5 services; 10 VMs (50 nodes, 25.6%) artificially storage-full at full_headroom_mb: 100, i.e. ~99 MiB free against the 500 MiB reserve, capacity-blocked from T0. Continuous 10/50 MB uploads and downloads for ~5.5 h, then filler removed on 5 of the 10 with the other 5 held as control.

The two-part predicate is live and correct

Directly observable on the real decision path. Every rejection on the full cohort carries it verbatim:

Rejecting PUT before payment verification: storage error: Insufficient disk space:
0.09 GiB available, 0.49 GiB reserve required, and only 8192 B reusable inside the
local store. Free disk space or increase the partition to continue.

The and only 8192 B reusable inside the local store clause is this PR's change being consulted. 8192 B = two pages < one chunk, so these near-empty-store nodes correctly read as full — matching pre-#210 semantics for this cohort, which is the required behaviour. Sustained across the whole run: 71,280 rejections during the 3 h measurement window across 10/10 hosts and all 5 service numbers (~24k/h), plus 12,103 more on the control cohort in the hour after the others were freed. No flapping.

Clean no-growth exit

On the freed VMs after the volume went back above the reserve, over the following hour:

pattern count
MapFull 0
Cannot delete: the local store is full 0
Failed to restore LMDB map 0
Failed to pin LMDB map 0
Failed to grant delete slack 0

All five literals were confirmed present in the pinned source first, so these are genuine behavioural absences rather than renamed strings. Store files grew again immediately — 9.4 – 12.8 GB within the hour, against 67 – 95 MiB on the untouched still-full control — with no restart (ActiveEnterTimestamp still T0), so sync_growth_mode unpinned and try_resize re-grew the map on the live process.

No regression above the reserve

This PR claims byte-for-byte unchanged behaviour on healthy nodes; the 145 healthy services support that. Fetch lane 16.41 / 15.79 / 15.41 GB/h — within 1.5% of the V2-987 reference arm's 16.65 GB/h for the same hour. Fleet fetch requests-to-responses 1:1. Downloads 2,284 / 2,284 = 100%. 0 restarts, 0 failed units, 0 panics across all 39 VMs / 195 services. No new WARN/ERROR patterns from the resize/pin/delete-allowance machinery.

Finding: the pinned LMDB map INFO line is unreachable for a node that starts full

The run plan expected every full-cohort node to log Disk below reserve: pinned LMDB map … during warm-up. It fired 0 times fleet-wide over the entire run, while those same nodes were demonstrably capacity-blocked throughout. This is structural, not a defect in this PR:

  • The line is emitted by pin_map_to_high_water() (src/storage/lmdb.rs:1031), reached only via sync_growth_mode() (lmdb.rs:936), whose only callers are LmdbStore::put (lmdb.rs:379) and LmdbStore::delete (lmdb.rs:597).
  • But src/storage/handler.rs:546 runs the cheap disk pre-check before payment verification and returns early — its comment notes "the store path keeps its own check as defence-in-depth", i.e. the store layer sits deliberately downstream of that gate.
  • So a node that is below the reserve with a near-empty store has every PUT and fresh replication offer rejected upstream, and the pin path is never entered. The pin is reachable only by a node that crosses below the reserve while writing, or by a pruned node whose reusable pages let check_capacity pass — and that pruned case isn't constructible in a 6-hour fleet run, since the artificially-full cohort starts with no deleted pages.

Two things follow, both worth your call rather than mine:

  1. For evidence purposes this is fine — the predicate itself is observable on the decision path (above), which is arguably better evidence than the pin line, since it shows the check being consulted where it matters. Combined with the seven unit tests and the tripwire test, I'd treat the fleet evidence for this PR as sufficient.
  2. You may want an INFO line on the no-growth entry decision regardless of which layer refuses, or the pinning machinery is invisible in production logs for the most common way a node gets full (gradually, then staying full with a non-empty store — where it would pin, but only once a write reaches the store layer). Not a blocker; just noting that the current instrumentation can't distinguish "pinned and refusing" from "precheck-refused before ever pinning" from the outside.

Full write-up: V2-1055.

@jacderida
jacderida merged commit c35535c into WithAutonomi:main Aug 24, 2026
15 checks passed
jacderida added a commit to grumbach/ant-node that referenced this pull request Aug 24, 2026
Carries WithAutonomi#210's two-part fullness predicate into the capacity verdict, as
this PR's own notes require of whichever change lands second:
capacity_verdict() now consults reusable_bytes() when the disk half reads
Full, so the verification gate and the dial pre-check refuse on the same
condition and a pruned node below its reserve keeps discovering holders
for keys it can still store. check_disk_space() is dropped (WithAutonomi#210 removed
its only callers; this branch added none).

The tripwire test capacity_verdict_refuses_exactly_when_check_capacity_does
passes on the divergent (pruned) state. cargo test --lib: 941/941. e2e
write_blocked_node_neither_probes_nor_dials passes. Tree is identical to
the V2-1055 testnet build (jacderida/ant-node 239aa94).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants