Skip to content

fix(costs): version default-section storage removal accounting - #726

Draft
QuantumExplorer wants to merge 4 commits into
developfrom
codex/fix-683-storage-removal-default-section
Draft

fix(costs): version default-section storage removal accounting#726
QuantumExplorer wants to merge 4 commits into
developfrom
codex/fix-683-storage-removal-default-section

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented May 20, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #683 — combining a BasicStorageRemoval with a SectionedStorageRemoval dropped the default identifier's section, undercounting removed bytes (and therefore refunds).

The bug. In StorageRemovedBytes, three of the four basic-into-sectioned arms — Add: Basic + Sectioned, Add: Sectioned + Basic, AddAssign: Basic += Sectioned — removed the default identifier's epoch map from the sectioned map, folded the basic bytes into its UNKNOWN_EPOCH entry, and then never reinserted it. The whole default section (not just the basic bytes) was lost. The fourth arm, AddAssign: Sectioned += Basic, always reinserted correctly. The shape Drive's sectional-removal callback produces (key bytes as Basic, value bytes as Sectioned under the default identifier) hits the broken Basic += Sectioned arm every time Merk folds a deletion's key and value removals together, so a full deletion refunded 0 bytes.

The fix, gated to GROVE_V4. GROVE_V3 is live on mainnet with the buggy arithmetic and removal totals feed fee refunds, so historical blocks must replay to the same (wrong) figures.

  • New version slot grovedb_versions.storage_costs.add_basic_storage_removal_to_sectioned_storage_removal: 0 on V1/V2/V3 (legacy, byte-identical to shipped), 1 on V4 (default section preserved).
  • The arithmetic lives in the Add/AddAssign operator impls in grovedb-costs, reached through hundreds of version-less add_cost / cost_return_on_error! sites, and grovedb-costs has no grovedb-version dependency. So the slot is carried by a thread-local selector (Cell<u16>) with an RAII guard (use_basic_sectioned_removal_addition_version / with_basic_sectioned_removal_addition_version) installed at the version-aware entry points: Merk::apply_unchecked_with_old_value_observer (innermost apply, which every Merk apply funnels through), GroveDb::delete_with_sectional_storage_function, delete_if_empty_tree_with_sectional_storage_function, apply_batch_with_element_flags_update, and apply_partial_batch_with_element_flags_update.
  • The selector defaults to 0 (legacy). An un-guarded caller reproduces shipped behaviour rather than silently upgrading — the safe failure direction.
  • The always-correct Sectioned += Basic arm bypasses the selector entirely; routing it through the gate would have regressed v1..v3 output (see the second commit).

Verification

Pinned regressions (exact figures, not inequalities):

  • grovedb-costs coverage_regression: legacy path pins each of the three buggy arms to their shipped (dropping) totals and the always-correct arm to 13; latest_storage_removed_bytes_add_preserves_default_section pins all four arms under version 1.
  • grovedb-version: v4_uses_fixed_basic_to_sectioned_storage_removal_addition asserts 0/0/0/1 across V1..V4.
  • grovedb (batch/single_deletion_cost_tests.rs): insert key1 → Item("cat", flags "apple") at the root (155 added bytes), delete via delete_with_sectional_storage_function with key bytes as Basic and value bytes as Sectioned{default: {UNKNOWN_EPOCH}}:
    • latest_… (V4): removal is Sectioned{default: {UNKNOWN_EPOCH: 155}}, total 155 = added.
    • v3_…_keeps_legacy_…: removal is Sectioned{} (empty map), total 0 of 155 — the shipped undercount, pinned exactly.
cargo test -p grovedb-costs -p grovedb-version
cargo test -p grovedb --lib basic_plus_default_sectioned_removal_cost
cargo clippy --workspace --all-features -- -D warnings
cargo fmt --all --check
cargo nextest run --workspace --all-features

Scope note / Platform follow-up

The guard covers every GroveDB/Merk path that combines removals during an operation. Removal aggregation that happens outside a GroveDB call — e.g. Drive summing per-operation costs with += / combine_cost_operations — still runs with the default (legacy) selector even under V4 unless Drive installs the guard itself (grovedb_costs::storage_cost::removal::use_basic_sectioned_removal_addition_version(1)) around that aggregation. That is a Platform-side follow-up; it does not affect the figures GroveDB returns.

Summary by CodeRabbit

  • Bug Fixes
    • Corrected storage removal cost calculations when combining basic and sectioned removals.
    • Updated current versions to preserve default-section bytes and provide accurate refunds.
    • Maintained legacy calculation behavior for earlier versions to support compatibility.
    • Added coverage for batch, deletion, and storage-cost calculations across supported versions.

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 923a2754-4fa2-419f-8f59-4a4edba13dad

📥 Commits

Reviewing files that changed from the base of the PR and between 0e904fe and 6fbb628.

📒 Files selected for processing (12)
  • costs/src/storage_cost/removal.rs
  • costs/tests/coverage_regression.rs
  • grovedb-version/src/tests.rs
  • grovedb-version/src/version/grovedb_versions.rs
  • grovedb-version/src/version/v1.rs
  • grovedb-version/src/version/v2.rs
  • grovedb-version/src/version/v3.rs
  • grovedb-version/src/version/v4.rs
  • grovedb/src/batch/mod.rs
  • grovedb/src/batch/single_deletion_cost_tests.rs
  • grovedb/src/operations/delete/mod.rs
  • merk/src/merk/apply.rs
🚧 Files skipped from review as they are similar to previous changes (8)
  • grovedb-version/src/version/v1.rs
  • grovedb-version/src/tests.rs
  • grovedb-version/src/version/v2.rs
  • costs/src/storage_cost/removal.rs
  • grovedb-version/src/version/grovedb_versions.rs
  • grovedb/src/batch/mod.rs
  • costs/tests/coverage_regression.rs
  • grovedb/src/operations/delete/mod.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

This PR fixes storage-removal arithmetic that dropped default-section entries. It adds version-gated behavior, keeps legacy behavior for V1–V3, enables corrected behavior in V4, and applies the setting across batch, deletion, and Merk operations.

Changes

Storage Removal Addition Versioning

Layer / File(s) Summary
Versioned storage removal arithmetic
costs/src/storage_cost/removal.rs
Adds scoped version selection. Corrected arithmetic preserves the default section. Legacy arithmetic remains available.
Storage-cost version configuration
grovedb-version/src/version/grovedb_versions.rs, grovedb-version/src/version/v1.rs, grovedb-version/src/version/v2.rs, grovedb-version/src/version/v3.rs, grovedb-version/src/version/v4.rs, grovedb-version/src/tests.rs
Adds storage-cost version settings. V1–V3 use version 0. V4 uses version 1. Tests verify these values.
Runtime guard integration
grovedb/src/batch/mod.rs, grovedb/src/operations/delete/mod.rs, merk/src/merk/apply.rs
Applies the configured storage-removal version during batch application, deletion, and tree application.
Removal arithmetic and deletion-cost validation
costs/tests/coverage_regression.rs, grovedb/src/batch/single_deletion_cost_tests.rs
Tests addition and assignment combinations, default-section preservation, corrected V4 behavior, and legacy V3 behavior.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to 6fbb6

The change preserves legacy accounting for older versions while correcting default-section removal accounting in V4; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant GroveVersion
  participant GroveDBOperation
  participant Merk
  participant RemovalArithmetic
  GroveVersion->>GroveDBOperation: provide storage-cost version
  GroveDBOperation->>RemovalArithmetic: activate scoped version guard
  GroveVersion->>Merk: provide storage-cost version
  Merk->>RemovalArithmetic: apply version during walker and commit
  RemovalArithmetic-->>GroveDBOperation: calculate versioned removal total
  RemovalArithmetic-->>Merk: calculate versioned removal total
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes preserve default-section removals for the affected addition paths and add regression tests for the required combinations in issue #683.
Out of Scope Changes check ✅ Passed The versioning, integration updates, and regression tests directly support the storage-removal accounting fix and its compatibility requirements.
Docstring Coverage ✅ Passed Docstring coverage is 82.61% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 12 files.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the versioned fix for default-section storage removal accounting, which is the main change.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/fix-683-storage-removal-default-section

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented May 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.34711% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.45%. Comparing base (0e904fe) to head (6fbb628).

Files with missing lines Patch % Lines
costs/src/storage_cost/removal.rs 96.96% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #726      +/-   ##
===========================================
+ Coverage    92.41%   92.45%   +0.04%     
===========================================
  Files          289      289              
  Lines        89316    89364      +48     
===========================================
+ Hits         82537    82618      +81     
+ Misses        6779     6746      -33     
Components Coverage Δ
grovedb-core 90.68% <100.00%> (+0.04%) ⬆️
merk 93.27% <100.00%> (+<0.01%) ⬆️
storage 87.08% <ø> (ø)
commitment-tree 96.38% <ø> (ø)
mmr 96.49% <ø> (ø)
bulk-append-tree 92.43% <ø> (ø)
element 97.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@QuantumExplorer
QuantumExplorer force-pushed the codex/fix-683-storage-removal-default-section branch from 877c46b to 0b58b11 Compare May 21, 2026 00:42
@QuantumExplorer QuantumExplorer changed the title Preserve default section storage removals fix(costs): version default-section storage removal accounting May 21, 2026
@QuantumExplorer

Copy link
Copy Markdown
Member Author

This is Claude (AI-assisted). Pushed a rework (commit 5d827ed) addressing the consensus-safety problem in the previous version.

The bug being fixed: the default-section-drop only ever affected three of the four basic-into-sectioned arms — Add: Basic+Sectioned, Add: Sectioned+Basic, and AddAssign: Basic+=Sectioned. The fourth, AddAssign: Sectioned+=Basic, already reinserted the default section correctly in every shipped version.

The prior version routed all four arms through the version selector, so the legacy/v0 path dropped the default section for the fourth arm too — i.e. it changed shipped v1/v2 output (its own regression test even asserted the wrong 0 for that case). Since storage-removal bytes feed fee/state accounting, that's a v1/v2 replay divergence.

Change:

  • Sectioned += Basic now calls the default-section-preserving helper unconditionally (it was never broken, so it must not be gated). The three genuinely-buggy arms remain gated to v3.
  • Fixed the regression test to assert the preserved total (13) on the legacy path, with a comment explaining why that arm is version-independent.
  • Documented why the thread-local selector exists (the version-sensitive combination happens inside Add/AddAssign, reached through ~hundreds of version-less StorageCost/OperationCost aggregation sites; the costs crate has no grovedb-version dep) and that its default (0/legacy) is the safe one.

On the thread-local: I looked at threading the version explicitly instead, but the combination flows through StorageCost/OperationCost operator overloads at ~951 version-less call sites, so explicit threading would require a large, risky refactor of the cost system. The thread-local is the pragmatic localization; it's now documented and the legacy default is safe.

Verified: grovedb-costs + grovedb-version tests pass (incl. v3_uses_fixed_basic_to_sectioned_storage_removal_addition and the corrected regression test); grovedb deletion-cost tests pass (incl. latest_delete_preserves_basic_plus_default_sectioned_removal_cost); clippy clean across all three.

QuantumExplorer added a commit that referenced this pull request Jul 30, 2026
* feat(version): add GROVE_V4, behaviourally identical to V3

GROVE_V3 is live, so a fix that changes an accepted/rejected outcome, a
committed root hash, or a tracked cost cannot be applied unconditionally —
nodes carrying it would diverge from nodes that do not. There is currently
nowhere for such a fix to land, which has left several of them stuck:

  - #776: overwriting an indexed tree with a bare Reference skips the
    per-axis secondary cleanup. Closing it costs an extra stored-element read
    on EVERY reference overwrite (+1 seek, +79 storage_loaded_bytes, measured
    by the refresh-reference cost tests), and references over plain trees are
    shipped functionality.
  - Batch DeleteTree treats the caller-declared tree type as authoritative
    when selecting cleanup namespaces. Reading the stored element instead
    fixes both an indexed type-confusion and a live CommitmentTree
    wrong-emptiness-path bug, but adds a read to a released path.
  - Per project notes, five audit-fix PRs (#726, #730, #732, #734, #739)
    are gated on v3 and need re-gating before they can merge.

This adds the version and nothing else. Every method-version slot is copied
from V3 unchanged, so activating protocol version 4 today is a no-op; each
gate is a deliberate, separately-reviewable slot bump.

Verified rather than assumed: registering V4 changes what
`GroveVersion::latest()` resolves to, and the whole test suite defaults to
latest. The full workspace suite passes with V4 as latest (2459 grovedb + 705
merk + the rest), and the only two failures were the version registry's own
self-describing tests — `grove_version_latest_returns_v3` and
`grove_versions_count` — which are updated here. That is the evidence the
change is inert.

Adds `grove_v4_is_behaviourally_identical_to_v3_until_a_gate_is_added`, which
compares every slot and fails the moment one is bumped. That failure is the
intended prompt to document the gate rather than let V4 accrete behaviour
silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(version): drop the V3/V4 slot-identity assertion

It pinned V4's initial state as inert, which was worth verifying once but
becomes churn the moment a gate is added — and the DeleteTree read and #776
are both queued to gate on V4 next, so it would fail immediately and be
deleted anyway.

The evidence it provided is preserved where it belongs: the PR description
records that the full workspace suite passed with V4 as latest and that only
the registry's own self-describing tests changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the codex/fix-683-storage-removal-default-section branch from 5d827ed to 0ab0499 Compare August 21, 2026 19:00
QuantumExplorer and others added 4 commits August 22, 2026 17:44
The default-section-drop bug only ever affected three of the four
basic-into-sectioned arms (Add: Basic+Sectioned, Add: Sectioned+Basic,
AddAssign: Basic+=Sectioned). The fourth, AddAssign: Sectioned+=Basic,
reinserted the default section correctly in every shipped version. The
prior rework routed all four through the version selector, which made the
legacy/v0 path *drop* the default section for the fourth arm too —
regressing shipped v1/v2 output (its own test asserted the wrong 0).

Route Sectioned+=Basic through the always-correct helper unconditionally
and assert the preserved total (13). Document why the thread-local
selector exists and which arms it governs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GROVE_V3 went live on mainnet (~mid-June 2026) with the legacy
default-section-dropping removal arithmetic, so the issue #683 fix can
no longer activate in v3 without forking replay of v3 blocks. Move the
activation to GROVE_V4: v1..v3 keep the legacy behavior, v4+ preserves
the default section. Adds a v3 regression test asserting the legacy
undercount is kept, and extends the version-gate test to cover v4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The v3 regression only asserted the legacy total was below the inserted
bytes. Replay compatibility depends on the exact shipped value, so pin it:
under GROVE_V3 the combined Basic+=Sectioned removal is an empty sectioned
map (0 of 155 added bytes), under GROVE_V4 it is {default: {UNKNOWN_EPOCH:
155}}. Both tests share one helper that also captures the key/value bytes
the sectional callback observed, so the undercount is stated explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the codex/fix-683-storage-removal-default-section branch from 0ab0499 to 6fbb628 Compare August 22, 2026 11:05
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@QuantumExplorer

Copy link
Copy Markdown
Member Author

This is Claude (AI-assisted). Assessment of how needed this fix is, and why the PR is being marked deferred rather than merged now.

TL;DR: the fix is correct, V4-gated, CI-green and the legacy path is pinned byte-exact — but the bug has no effect on any fee, refund, pool credit, or root hash Platform computes today. It is hygiene / future-proofing, not a live defect, so it is parked rather than carrying a thread-local version selector into grovedb-costs for a no-impact bug.

What the bug can actually lose

  • Only the default identifier ([0; 32]) section is dropped by the three buggy arms (Add: Basic + Sectioned, Add: Sectioned + Basic, AddAssign: Basic += Sectioned). Identity-owned sections (owner_id ≠ default) are never touched, so identity fee refunds are unaffected in every shipped version.
  • What is lost is "system" bytes: the Basic removal being folded in, plus the default section it was folded into.

How Drive reaches the buggy arms

  • Within a single Drive op: never. StorageFlags::split_removal_bytes (grovedb-epoch-based-storage-flags/src/split_removal_bytes.rs) returns Basic/Basic for unflagged elements and Sectioned/Sectioned for flagged ones, so a deletion's key + value removals always combine through a same-kind (correct) arm.
  • Across ops in one batch / cost accumulation: only when an unflagged removal (Basic) is aggregated with a no-owner-flagged removal (default-identifier section), and only in the Basic += Sectioned order (Sectioned += Basic was always correct). The only production no-owner flags in rs-drive are the identity-tree elements (add_new_identity: "no reason to store the owner id"); documents, contracts, etc. all carry an owner. Identities are never deleted and their values rarely shrink, so the trigger is rare.

What happens when it does fire

  • Drive routes both BasicStorageRemoval amounts and the default-identifier section to FeeResult.removed_bytes_from_system (rs-drive/src/fees/op.rs, the BasicStorageRemoval(amount) => (FeeRefunds::default(), amount) / remove(&Identifier::default()) arms).
  • Nothing in rs-drive-abci consumes that field: it is summed in FeeResult::checked_add_assign, zeroed in estimates, and not part of validate_fees_of_event (which compares total_base_fee / storage_fee / processing_fee). Storage fees are added_bytes × credit_per_byte; refunds come only from owner sections.
  • So even when triggered: no fee, refund, pool credit, or committed state changes.

Why defer rather than merge

  • The Codex "medium" rating describes the arithmetic in isolation; the Drive-level impact is nil today.
  • The fix is cheap and safe, but its design cost is real: a thread-local Cell<u16> selector in grovedb-costs installed via RAII guards at the Merk/GroveDB entry points (the operator impls can't carry a grove_version and grovedb-costs has no grovedb-version dependency). It also leaves a gap — removal aggregation outside a GroveDB call (Drive's combine_cost_operations, FeeResult sums) stays on the legacy arithmetic under V4 unless Drive installs the guard itself.
  • Both the mechanism and the gap only matter if something starts consuming removed_bytes_from_system (e.g. system storage accounting). Until then there is nothing to fix in production.

State of the branch (so it can be picked back up quickly)

  • Rebased on develop, V4-gated (v1..v3 = 0, v4 = 1), CI green, CodeRabbit clean.
  • Regressions pin the exact shipped figures: under GROVE_V3 the combined removal is SectionedStorageRemoval({})0 of 155 added bytes; under GROVE_V4 {default: {UNKNOWN_EPOCH: 155}}155.
  • Re-open / merge when: Platform starts consuming removed_bytes_from_system, or a GroveDB consumer needs Basic + default-section removals to aggregate correctly, or the next time a V4 cost-gate sweep is being merged anyway (zero marginal risk then). Otherwise this can be closed with an entry in docs/audit-non-issues.md ("default-section loss only affects removed_bytes_from_system, which has no consumer").

@QuantumExplorer QuantumExplorer added the deferred Correct but parked: no live impact, revisit when the trigger condition holds label Aug 22, 2026
@QuantumExplorer
QuantumExplorer marked this pull request as draft August 22, 2026 11:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deferred Correct but parked: no live impact, revisit when the trigger condition holds

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[audit][medium] Storage removal addition drops default-section removals

1 participant