diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f87e720a..2fb734e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Bidirectional references (#345): four new `Element` variants — + `BidirectionalReference` (discriminant 25), `ItemWithBackwardsReferences` + (26), `SumItemWithBackwardsReferences` (27), and + `ItemWithSumItemWithBackwardsReferences` (28, the `ItemWithSumItem` + twin) — plus a + backward-references subsystem that keeps reference chains consistent: + updating a referenced element propagates the new hash along every chain, + and deleting/overwriting it cascades the chains away (each affected + reference must opt in via `cascade_on_update`). Opt-in per call through + the new `propagate_backward_references` flag on `InsertOptions` / + `DeleteOptions`. The referrer list is stored on the element itself under a + two-layer hash (`combine(inner, backrefs)`), so registering a referrer + never re-hashes what existing referrers committed to; public reads return + the stripped element, and proofs authenticate these elements through the + new `Node::KVBackwardsReferencesValueHash` wire node whose value hash the + verifier recomputes. Requires `GROVE_V4`; earlier versions, V0 proofs, and + `Provable*` aggregate parents reject the new variants (fail closed). + `apply_batch` supports the whole family when the batch opts in via + `BatchApplyOptions::propagate_backward_references`: a preprocessing pass + expands the batch into the derived registration/propagation/cascade + operations the live flagged flow performs (shared semantic core, so batch + and non-batch execution produce byte-identical root hashes), including + references whose targets are created in the same batch; conflicting + combinations (a reference plus its target's deletion, a cascade hitting + another op's position, `RefreshReference` on a bidirectional reference) + fail closed. Each backward-references item declares how many referrers it + accepts (`BackwardReferences::max_incoming`, authenticated with the + element, at most the `MAX_BACKWARD_REFERENCES` ceiling of 256; the plain + constructors declare 32, the `_with_capacity` constructors take an explicit + value): registration past the capacity fails and an update may not lower + it below the registered referrers. Average/worst-case batch estimation + charges the derived fan-out on `GROVE_V4` (a written item's declared + capacity, the ceiling for writes that cannot see the element they + displace, ≤10-hop chains, 1 referrer per reference) while pre-V4 + estimation stays byte-stable for replay. See + `adr/bidirectional_references.md`. - **BREAKING**: Added `add_parent_tree_on_subquery` feature to PathQuery (#379) - New field in `Query` struct: `add_parent_tree_on_subquery: bool` - When set to `true`, parent tree elements (like CountTree or SumTree) are included in query results when performing subqueries diff --git a/adr/atomicity.md b/adr/atomicity.md new file mode 100644 index 000000000..3e7c354ee --- /dev/null +++ b/adr/atomicity.md @@ -0,0 +1,90 @@ +# Addressing Atomicity + +## Level 1: RocksDB Transactions + +In GroveDB, almost no operation -- if any at all -- can be executed as a single atomic +operation in RocksDB, the underlying storage used by GroveDB. As long as parallel access +to GroveDB is allowed, there is no guarantee that data will remain consistent across the +multiple operations required at the RocksDB level. Partially, we address this issue using +RocksDB batches, which will be discussed in more detail in the next section. However, +these batches do not address data fetches that may occur while the final RocksDB batch is +being constructed. The data fetched at one step of the operation may be inconsistent with +the data fetched later, as background updates may have occurred in the meantime. + +To demonstrate the problem, let’s consider a scenario where there is an only key `c` +under the subtree `[a,b]`. One actor updates this key with a new value while another actor +performs an insertion at a different location: + +```text +Actor 1: Actor 2: + - load subtree [a,b] with root -- c +- under subtree [a,b] key c insert value x, + we're not going into much detail there as - insert empty subtree into [a,b] under key d + as it was done in one batch and we care - under subtree [a,b,d] key e insert value y + only about what happened to c - under subtree [a,b] key d insert new root + ... hash and root key of subtree [a,b,d] + - compute root [a,b] hash as hash of joined + hashes of c and d *WE HAVE OLD C* + - under subtree [a] key b insert new root + hash and root key of subtree [a,b] + - under subtree [] key a insert new root + hash and root key of subtree [a] +``` + +... and not to mention what will happen with the ancestors' hashes. + +__Solution__: all operations shall be performed via RocksDB transactions. + +While this is straightforward for modifications, queries and `get` operations also require +transactions. In general, they cannot be represented by a single RocksDB operation too. +Although `get` may be an exception when no references are involved, data still needs to be +loaded first, and isolation might be required. Therefore, transactions should be provided +from the start. + +Since the first release transaction arguments are optional, now we internally start a +transaction if none is provided. To facilitate this, `crate::utils::TxRef` was introduced. + +`TxRef` wraps a transactions provided from user if any, otherwise starts a new one. The +rest of the GroveDB internals are unaware of the transaction source and uses what `TxRef` +provided to them with `TxRef::as_ref` method. + +In case the transaction was started internally it shall be committed internally as well, +for that purpose `TxRef::commit_local` is used, that will commit the transaction if it is +indeed "local" or is no-operation if the transaction is passed by user, leaving it to the +user to decide what to do with it. + +## Level 2: RocksDB Batches + +_Not to be confused with GroveDB batches!_ + +In general, if an operation fails, it doesn't necessarily mean that the entire transaction +should be aborted, unless it came into an inconsistent state. At least, this is not the +desired behavior in GroveDB, as it is used in Dash Platform: a transaction should live +for the duration of a block, with operations happening seamlessly -- even those that +may fail. + +As stated before, an operation that changes the state of GroveDB consists of many RocksDB +operations. However, we do not apply them directly to the provided transaction. Instead, +we aggregate them into a RocksDB batch, which is applied to the transaction all at once +at the end of the GroveDB operation. This approach allows for failure without aborting +the entire transaction, as it will only abort the batch, leaving the transaction state +untouched. + +To apply the `StorageBatch` with these deferred operations onto a running transaction, +`Storage::commit_multi_context_batch` is used, where the main implementation of `Storage` +in our case is `RocksDbStorage`. + +## Level 3: GroveDB Batches + +While RocksDB batches are an implementation detail, GroveDB batches are part of the public +API, on par with regular operations provided by GroveDB. When several updates to GroveDB +need to be performed atomically from a user perspective, without sacrificing a transaction +in case of failure, GroveDB batches are used. + +The main takeaways are: + +- Always a transaction, whether provided externally or not. +- Always one RocksDB batch applied for modifications. +- Calling `insert*/delete*` results in one RocksDB batch being applied. +- Applying a GroveDB batch full of `insert*/delete*` results in one RocksDB batch, likely + just larger. diff --git a/adr/bidirectional_references.md b/adr/bidirectional_references.md new file mode 100644 index 000000000..5cb73020b --- /dev/null +++ b/adr/bidirectional_references.md @@ -0,0 +1,296 @@ +# Bidirectional references + +GroveDB has supported references since its first release; however, the consistency between +references and the data they refer to is only guaranteed at the moment they are inserted. +Subsequent updates to the data do not propagate to the references pointing to it, which +can lead to diverged hashes or references pointing to deleted items. + +If the lack of consistency between references and data becomes a problem for a part of the +application using GroveDB, it can choose to use bidirectional references instead. + +For this purpose, several new `Element` variants were introduced: + +```rust +pub enum Element { + ... + /// A reference to an object by its path — discriminant 25 + BidirectionalReference(BidirectionalReference, Option), + /// An ordinary value that can be targeted by bidirectional references — + /// discriminant 26 + ItemWithBackwardsReferences(Vec, Vec, Option), + /// Signed integer value that can be totaled in a sum tree and targeted + /// by bidirectional references — discriminant 27 + SumItemWithBackwardsReferences(SumValue, Vec, Option), + /// Item carrying an explicit sum value (like `ItemWithSumItem`) that can + /// be targeted by bidirectional references — discriminant 28 + ItemWithSumItemWithBackwardsReferences( + Vec, + SumValue, + Vec, + Option, + ), +} + +pub struct BidirectionalReference { + pub forward_reference_path: ReferencePathType, + pub cascade_on_update: CascadeOnUpdate, + pub max_hop: MaxReferenceHop, + pub backward_references: Vec, +} + +pub struct BackwardReference { + /// Inverted path leading back to the referrer. + pub inverted_reference: ReferencePathType, + pub cascade_on_update: bool, +} +``` + +These items are counterparts of existing ones: items, sum items, and regular references. +A regular item with ordinary references does not propagate updates back to the reference +chain origin. When such behavior is required, a different type of element should be used. +Moreover, these types are incompatible, which will be discussed in the "Rules" section. + +Additionally, a new flag was added to `InsertOptions` and `DeleteOptions` +called `propagate_backward_references` (`ClearOptions` support is deferred — +see the limitations below). Since propagation incurs a cost, starting with the +checks required to determine whether it should be performed, bidirectional references are +optional and must be explicitly enabled. + +Even when a user inserts something unrelated to the bidirectional references feature, +a check must still be performed to determine whether the insertion overwrites an item +with backward references. If it does, this could trigger a cascade deletion or fail with +an error if cascade deletion is not allowed in the bidirectional references parameters. +However, propagation must be enabled from the start for this check to take place at all. +Fetching the previous item on every modification introduces additional overhead, which +would be unfair to applications that do not use this feature or for database sections that +do not require it. To address this, the flag was introduced. + +## Versioning and scope + +The whole feature activates with **`GROVE_V4`**: the four element variants +are rejected by earlier protocol versions (fail closed), `GROVE_V4` selects +`insert_on_transaction` v1 and `delete_internal_on_transaction` v2 — both +behaviour-preserving routers whose flag-less calls run the previous +version's body byte-for-byte. + +Current limitations (fail closed, lift as needed): + +- The four variants may not be wrapped in the aggregation wrappers + (`NonCounted` / `NotSummed` / `NotCountedOrSummed`). +- `apply_batch` supports the family when the batch opts in via + `BatchApplyOptions::propagate_backward_references` (see the batching + section under Implementation); batches without the flag — and partial + batches, which have no expansion support — reject ops carrying the + family. A flagged batch also refuses to delete a NON-EMPTY subtree: + its descendants may hold bidirectional-reference participants whose + external registrations, cascade consents, and surviving referrers the + batch engine's wholesale clearing would skip — use the live flagged + delete (which walks descendants with full bookkeeping) or empty the + subtree first. Unflagged ops that DELETE or OVERWRITE an existing + backward-references participant are still admitted, exactly like any + other unflagged write: consistency is forfeited at that point. A + backward reference left dangling this way is tolerated — later flagged + propagations and cascades skip it and lazily clear its slot — but + `verify_grovedb` reports the affected references until the chain is + rewritten through flagged operations. +- `clear_subtree` has no `propagate_backward_references` option yet; use + `delete` with the flag for cascade-aware removal. +- Under the flag, insert supports items, references, and empty plain-Merk + trees; delete supports plain Merk subtrees. The specialized data trees + (commitment / MMR / bulk-append / dense / private document store) and + indexed trees are rejected with the flag set — none of their contents + can be targeted by bidirectional references, so insert/delete them + without the flag. + +## Rules + +Next, we’ll go over the rules and limitations for using bidirectional references. + +Note that for the rules to apply, the `propagate_backward_references` flag needs to be +set. + +An 'Element with backward references' refers to `ItemWithBackwardsReferences`, +`SumItemWithBackwardsReferences`, `ItemWithSumItemWithBackwardsReferences`, and +`BidirectionalReference`, as all these types contain a list of backward references +associated with them. + +- __Only elements with backward references can be targets of bidirectional references.__ +Trying to create a bidirectional reference to a regular item will result in an error. And +just like regular references, bidirectional references cannot point to subtrees. +- __A (Sum)Item with backward references declares how many bidirectional references may +point at it__ (`BackwardReferences::max_incoming`, at most the protocol ceiling +`MAX_BACKWARD_REFERENCES` = 256; the convenience constructors declare 32). The declaration is +authenticated with the element — it sits in the inner (stripped) bytes every referrer commits +to — so every node enforces the same value. Registration past the declared capacity fails, +declaring a capacity above the ceiling fails at construction and at decode, and an update may +raise the capacity freely but may not lower it below the referrers already registered. +Declaring capacity allocates nothing: the list stores only actual registrations. The +declaration exists so worst-case costs stay predictable AND proportionate: a write of the +item carries its own capacity, so the cost estimator charges an item indexed four ways for +four chains instead of for the ceiling. The ceiling still bounds the work of operations that +cannot see the stored element they displace (a delete, a plain payload landing on a +registered element) and the node growth registrations can inflict on a target. +- __A bidirectional reference's declared `max_hop` must admit its own chain at +insertion.__ Public reads enforce the declared budget deterministically, so an edge whose +chain is already longer than its declaration would never resolve; the write path rejects +such dead edges instead of persisting them. (An edge can still fall out of budget later — +e.g. its target is overwritten into a plain reference through an unflagged write — and +reads then return `ReferenceLimit`.) +- __Both ends of a bidirectional edge must sit at most 32 subtree levels deep__ +(`MAX_BACKWARD_REFERENCES_GROVE_DEPTH`, enforced at registration). Every later derived +write — propagation rewrite, cascade deletion, registration cleanup — lands at one of the +edge's positions, and cost estimation charges up to this many ancestor updates per derived +foreign-subtree propagation; without the bound, a referrer parked arbitrarily deep would +make its propagation cost exceed any fixed estimate. +- __A bidirectional reference can be referenced by another bidirectional reference, but +no more than 1.__ This limitation was introduced for the same reason as before: to keep +propagation costs predictable. By restricting chains to one reference per bidirectional +reference, we ensure that an item with up to `max_incoming` bidirectional references (each +chain containing no more than 10 links) can be traced without branching into more paths — +at most `max_incoming × 10` affected nodes — allowing us to predict and manage the +worst-case update costs. +- __If an element with backward references is updated with another element with backward +references, hash propagation happens.__ All bidirectional references across all chains +shall update their hashes using the new one of the updated item. If the updated item is +a new bidirectional reference itself, it will follow the chain forward first to get the +value hash that will be used for propagation. +- __If an element can no longer be targeted (for example, updated to an item with no +backward references support or deleted entirely), a cascade deletion of bidirectional +references occurs.__ This requires the `cascade_on_update` setting for each affected +bidirectional reference. If this setting is not enabled, an error will be raised, +preventing the operation from completing successfully. + +## Implementation + +### Batching + +`apply_batch` supports the whole family when the batch sets +`BatchApplyOptions::propagate_backward_references` (GROVE_V4+, riding the +same activation as the live flagged flow). A preprocessing pass +(`batch::backward_references`) expands the user's operations into the +derived operations the live flow would perform, planned by the SAME +semantic core (`bidirectional_references::semantics`) the `MerkCache` +driver uses, so live and batched semantics cannot drift. The master +invariant, enforced by tests: for any logical operation set, batch and +non-batch execution produce byte-identical root hashes. + +The expansion simulates one canonical sequential order over an overlay of +pending position states (pre-batch DB state plus the batch's staged +effects): first every non-reference op in user order, then every +`BidirectionalReference` op in topological order — targets before their +referrers. This makes references to targets created in the same batch +work regardless of op order, lets whole chains be created in one batch, +and validates the hop/component budgets against the prospective +post-batch state. Derived writes execute through the internal +`GroveOp::ReplaceBackwardReferenceFamilyMember` (rejected when supplied by +callers), which installs the fully-combined node value hash; user +reference ops are themselves converted into that form (an identical-edge +re-insert converts into nothing, mirroring the live no-op), and +registrations onto targets written in the same batch merge into the +target op's element. + +Conflicts fail closed with specified errors: a reference inserted in the +same batch that deletes its target; a cascade deleting a position another +op touches; a propagation rewrite hitting a user delete; and +`RefreshReference` on a position holding a bidirectional reference. + +Estimated costs (average and worst case) model the derived fan-out on +GROVE_V4+ under the batch flag, bounded by the budgets above (a written +item's DECLARED referrer capacity, the 256 ceiling for writes that cannot +see the element they displace, ≤10-hop chains, 1 referrer per reference); +pre-V4 +estimation is preserved byte-for-byte for replay of historical admission +decisions. + +Bidirectional references are optional for each call to GroveDB's public API, and a flag is +used to enable their functionality for that specific call. Essentially, when the flag is +present, it modifies the regular execution process in two ways: + +1. Modifications (both writes and deletions) will fetch the data being updated. +2. If the fetched item is an element with backward references, control is passed to the + `bidirectional_references` module in the GroveDB root for post-processing. This occurs for + bidirectional reference insertion regardless of whether the flag is set. + +Quite a lot happens behind this "post-processing," and we'll go into the details shortly. + +### On-element storage and two-layer hashing + +The referrer list lives directly on the element: each of the four variants carries a +`Vec`. Registering or removing a referrer rewrites the TARGET element's +bytes — but a naive design would then re-hash the target's value, changing the very hash +every existing referrer has committed to, and each registration would trigger a cascade of +propagations across all other referrers. + +To avoid that, elements with backward references use a two-layer hash: + +```text +inner_hash = H(serialize(element with backward_references = [])) // the LOGICAL hash +backrefs_hash = H(serialize(backward_references)) +node value_hash: + (Sum)ItemWithBackwardsReferences = combine(inner_hash, backrefs_hash) + BidirectionalReference = combine(combine(inner_hash, backrefs_hash), end_hash) +``` + +where `end_hash` for a bidirectional reference is the hash of what it transitively points +at, and `combine` is the existing two-input node-hash combinator. + +Every reference in a chain (ordinary or bidirectional) commits to the target's *logical* +(`inner`) hash — the hash of the stripped serialization. This rule binds every write +path, including `apply_batch`: a batch-inserted plain reference that terminates on (or +chains through) a backward-references element commits the stripped hash, never the +node's combined hash. Registering another referrer on a +target changes only `backrefs_hash`, so the target's own node re-hashes (and propagates up +its subtree as usual), while every referrer that already points at it keeps its stored +node hash bit-for-bit. Cascaded hash propagation only happens when the *payload* — the +logical hash — actually changes. + +Public reads (`get`, `get_raw`, query results, proved results) return the STRIPPED +element: the referrer list is internal bookkeeping and never crosses the API boundary. +Internal flows (propagation, cascade deletion, `verify_grovedb`) read the full element at +the merk level. + +Since the referrer list is ordinary element data, state sync and chunk restore carry it +for free — no side-channel storage has to be reconstructed. + +### Proofs + +Because the node's `value_hash` is no longer `H(value_bytes)`, a plain `KVValueHash` proof +node cannot authenticate these elements. A dedicated proof node kind ships the stripped +payload plus the 32-byte referrer-list hash: + +```text +Node::KVBackwardsReferencesValueHash(key, stripped_value_bytes, backrefs_hash) +``` + +The verifier RECOMPUTES `combine(H(stripped_value_bytes), backrefs_hash)` as the node's +value hash — the payload bytes are bound by the recomputation rather than trusted, and +tampering with either the payload or the referrer-list hash breaks the root-hash chain. +The verifier also rejects backward-references elements smuggled inside plain `KVValueHash` +/ `KVValueHashFeatureType` nodes, and the node kind itself is rejected in V0 proofs +(V0 is a frozen wire format). Bidirectional references resolve through the existing +`KVRefValueHash` mechanics with `combine(inner, backrefs)` as the carried self-hash. + +One consequence: elements with backward references are rejected inside `Provable*` +aggregate trees (`ProvableCountTree`, `ProvableSumTree`, `ProvableCountSumTree`, +`ProvableCountProvableSumTree`) — their proof nodes carry aggregate data in shapes that +have no backward-references twin. Plain `SumTree` / `CountTree` parents work. + +### Propagation + +Previous read: [Merk cache](./merk_cache.md). + +Deletion or an update of an element with backward references triggers a cascade hash +update or a deletion, both of which alter the state of affected subtrees, leading to +regular hash propagation to ancestor subtrees up to the GroveDB root. In short, operations +with the required flag enabled can trigger updates across several subtrees simultaneously. + +Thus, there are two ongoing propagations: + +1. Backward references chain hash propagation / cascade deletion. +2. Regular hash propagation of subtrees. + +It is possible that a reference propagation could impact a subtree that is also affected +by regular propagation from one of its descendants. This is difficult to predict. Since +these propagations happen at different steps, they can result in multiple Merk openings +causing issues. To manage this, caching becomes mandatory. This led to the introduction of +`MerkCache`, which has become a crucial component for handling bidirectional references. diff --git a/adr/merk_cache.md b/adr/merk_cache.md new file mode 100644 index 000000000..504c2e3f0 --- /dev/null +++ b/adr/merk_cache.md @@ -0,0 +1,198 @@ +# Merk Cache + +An easy to use answer to GroveDB's implementation challenges. + +Previous read: [atomicity](./atomicity.md). + +Mandatory operation batching and deferred application, which are used for all GroveDB +updates, separate the state into two perspectives: one is the current state reflected in +the running transaction, and the other is a hypothetical state that will become real after +the batch is committed. + +A common scenario is using the updated "state" for further operation processing, such +as propagating updated hashes upwards. Accessing the transaction state is not an option +in this case because we have just performed an update. However, we do have an in-memory +representation of the data we are working with: Merk trees. The updates made to them are +reflected in their structure, and for any missing data, they fall back to storage (the +transaction state in this case). + +However, losing the handle to a Merk tree means losing access to the uncommitted state. +Reopening the Merk tree will only provide access to the transaction state, which does not +reflect the pending changes inside the storage batch. To solve this problem and to keep +Merks open as long as needed, we introduce `MerkCache`. + +## Usage + +### Working with a Merk tree + +Since `MerkCache` manages the lifecycle of Merk handles, it replaces the previous approach +of opening a Merk instance using GroveDB’s internal helpers. Now, this is done via +`MerkCache::get_merk`. + +To maintain control over the Merk tree’s lifecycle, `get_merk` returns a `MerkHandle` +instead of a direct `Merk` reference. For safety reasons (discussed in the next section), +`MerkHandle` cannot be dereferenced into `Merk`. Instead, the `MerkHandle::for_merk` +method is provided, which accepts a closure with a single argument -- a mutable reference +to the desired `Merk` instance. This allows data to be returned to the outer scope while +ensuring safe access. + +Since the reference to `Merk` is unique, nested calls to `for_merk` that attempt to +access the same `Merk` instance simultaneously will result in a panic. Additionally, due +to implementation details, the parent `Merk` might already be in use behind the scenes, +further increasing the risk of a panic. Therefore, caution is advised when making nested +`for_merk` calls -- ideally, they should be avoided altogether. + +On the other hand, holding multiple `MerkHandle` instances is not only safe but also +recommended, as it avoids additional lookups in `MerkCache`. However, if a handle is lost, +it is not an issue, since it can be retrieved again with a lookup when needed and without +reopening a `Merk` -- one of the reasons `MerkCache` was introduced in the first place. + +### Subtree deletion + +If a `Merk` is semantically deleted, `MerkCache` must be explicitly notified using the +`MerkCache::mark_deleted` method. This ensures that any subsequent attempt to retrieve +it will explicitly check the parent entry to determine whether it was reinserted. If it +wasn't, the operation will result in an error. + +This operation requires a borrow, just like `for_merk`, so it should preferably not be +nested within one. + +### Finalization + +`MerkCache` receives a transaction upon initialization but no storage batch, as it relies +on the transaction state and evolves on top of it. Since no data is committed until +explicitly done at the end of the GroveDB operation, `MerkCache` finalizes its usage with +a delta of the transaction state. All modifications made to `MerkCache` are returned as +a storage batch via the `MerkCache::into_batch` method, which consumes the cache and, +through Rust lifetimes, renders all `MerkHandle`s inaccessible. The returned storage batch +can then be merged with the main batch, though this is not a concern of `MerkCache`. + +## Implementation details + +The concept of this type of cache is not new and was previously achieved in GroveDB +using a `HashMap`. However, due to its constraints, managing multiple `Merk` references +was problematic, as obtaining a mutable reference to a `HashMap` entry restricted access +to the entire `HashMap`. To work around this limitation, a hack was used: entries were +temporarily removed from the cache to detach ownership, then reinserted afterward. This +approach was inefficient and error-prone, as it introduced the risk of forgetting to +reinsert entries. Overall, it was a fragile, ad-hoc solution that required excessive +manual handling, and with the newest requirements (known as [[bidirectional_references]]) +increasing the demand for caching, a better, reusable solution was needed. + +A better solution is defined in the `merk_cache.rs` module under the GroveDB root, already +known as `MerkCache`. + +### The HashMap "problem" + +As stated above, taking a unique reference to an entry inside the `HashMap` requires a +unique reference to the entire structure itself. This is done for a reason: mutations to +the structure can trigger reallocations or other changes that would invalidate existing +references, and Rust protects us from this. However, when Rust's restrictions are too +strict, we can take matters into our own hands using `unsafe`. This is one of the reasons +why `MerkCache` has so few limitations -- it is tailored specifically to our task, whereas +standard collections are general and defensive, as they should be. + +The underlying implementation still uses a map structure (`BTreeMap` in our case, to +maintain ordered paths for subtrees), but it employs indirection, so when the structure's +memory alignment changes, it doesn't affect the actual Merks. Additionally, we don't +remove entries at all. + +### Ensuring Safety + +By avoiding the limitations of a map structure, we introduce a burden of unsafe code +that requires careful understanding and support. + +The explanations will follow a bottom-up approach. + +#### MerkHandle + +```rust +/// Wrapper over `Merk` tree to manage unique borrow dynamically. +#[derive(Clone, Debug)] +pub(crate) struct MerkHandle<'db, 'c> { + merk: *mut Subtree<'db>, + taken_handle: &'c Cell, +} +``` + +Here, `Subtree` is an enum that represents either a `Merk` or a state marked as deleted +(note that the entry remains at the same place in memory and is valid regardless of its +"deletion" status). + +`MerkHandle` is the only way to obtain a `&mut Merk` when using `MerkCache` via `for_merk` +method. The conversion from raw pointer to a unique reference that happens at the time of +this call of the subtree is safe due to the following reasons: + +1. Lifetime `'c` is tied to `MerkCache`, ensuring its presence for the duration of the + operation. +2. The ownership of `Subtree` is indirect, meaning that changes in the map structure (such + as reallocations or memory movements) won't affect its memory address or validity. +3. `MerkCache` never frees this memory until the very end, either by consuming `self` + or via a destructor that requires `&mut self`. Both operations are exclusive to `'c` of + `MerkHandle` (from point 1). +4. The reference is unique because `taken_handle` is checked before conversion, and it is + set during the `for_merk` call. Sharing the `Cell` reference is safe because the purpose + of `Cell` is to provide interior mutability with shared access. + +#### CachedMerkEntry + +```rust +/// We store Merk on heap to preserve its location as well as borrow flag alongside. +type CachedMerkEntry<'db> = Box<(Cell, Subtree<'db>)>; +``` + +An immovable allocation that `MerkCache` owns and `MerkHandle` refers to. `MerkHandle` +holds a shared reference to this `Cell` and a pointer to `Subtree`, without taking a +reference unless needed. + +#### Merks + +```rust +type Merks<'db, 'b, B> = BTreeMap, CachedMerkEntry<'db>>; +``` + +This is the heart of `MerkCache`, where paths are mapped to Merks wrapped with metadata. +The `BTreeMap` ensures that data is stored in order, with its ordering implementation +placing the longest paths first. + +As `CachedMerkEntry` is a `Box`, this creates an indirection between the `Merks` memory +where it stores values and the actual data of the Merk. + +Another implementation detail: `MerkCache` propagates hash updates from modified +subtrees to their parents up to the GroveDB root. This propagation happens during batch +finalization, and maintaining cached items in order greatly aids this process. + +#### MerkCache + +The final structure. + +```rust +/// Structure to keep subtrees open in memory for repeated access. +pub(crate) struct MerkCache<'db, 'b, B: AsRef<[u8]>> { + db: &'db GroveDb, + pub(crate) version: &'db GroveVersion, + batch: Box, + tx: &'db Transaction<'db>, + merks: UnsafeCell>, +} +``` + +Many of the fields are references, as `MerkCache` delegates storage interactions before +data gets cached. + +The storage batch is owned because the result of `MerkCache` usage is a batch that it +builds over time. It also has a layer of indirection via `Box`, as cached Merks hold a +reference to the batch. This makes the entire `MerkCache` self-referential, requiring +indirection to ensure safety in case the cache value is moved. + +Wrapping `Merks` in `UnsafeCell` is somewhat redundant. + +We use lifetimes to enforce at compile time that `MerkHandle`s won't outlive the +cache, even though we don’t hold any direct references to it. A `&mut` reference on +`get_merk` call would make this borrow exclusive for the entire lifetime of the returned +`MerkHandle`, which is not an option since we want to have multiple `MerkHandle`s at the +same time. Therefore, it must initially go through a shared reference. However, with a +shared reference, we wouldn't be able to update the `BTreeMap` with new entries. + +That's why `UnsafeCell` is needed -- any mutation that goes through a shared reference +must happen inside `UnsafeCell`, or it results in undefined behavior. diff --git a/grovedb-element/src/bidirectional_reference.rs b/grovedb-element/src/bidirectional_reference.rs new file mode 100644 index 000000000..449c0729a --- /dev/null +++ b/grovedb-element/src/bidirectional_reference.rs @@ -0,0 +1,765 @@ +//! Bidirectional reference definitions. +//! +//! A bidirectional reference behaves like [`crate::Element::Reference`] on +//! reads, but additionally registers itself in its target's backward-reference +//! list so that updates to the target propagate back along the reference +//! chain (or cascade-delete it). Only elements that opt into backward +//! references (`ItemWithBackwardsReferences`, `SumItemWithBackwardsReferences`, +//! or another `BidirectionalReference`) may be targeted. +//! +//! # Storage & hashing model +//! +//! Backward references live **on the target element itself** and are covered +//! by the node hash through a two-layer scheme: +//! +//! ```text +//! inner_hash = H(serialize(element with backward_references = [])) +//! backrefs_hash = H(serialize(backward_references)) +//! node value_hash = combine(inner_hash, backrefs_hash) // items +//! = combine3(inner_hash, target_inner_hash, +//! backrefs_hash) // bidi refs +//! ``` +//! +//! Forward references (and every member of a reference chain) commit to the +//! target's **inner** hash, so registering or removing a referrer changes +//! only the target's own node hash — never the hashes stored by other +//! referrers. Proofs carry the stripped (inner) serialization plus the +//! 32-byte `backrefs_hash`, so the referrer set is authenticated without +//! bloating or leaking into result sets. + +use bincode::{Decode, Encode}; + +use crate::{element::MaxReferenceHop, reference_path::ReferencePathType}; + +/// The protocol ceiling on the referrer capacity a backward-references +/// ITEM (`ItemWithBackwardsReferences` / `SumItemWithBackwardsReferences` / +/// `ItemWithSumItemWithBackwardsReferences`) may declare, and therefore on +/// the number of backward references it can ever carry. +/// +/// Each item declares its own capacity ([`BackwardReferences::max_incoming`]) +/// at or below this ceiling; the ceiling is what bounds the cascade work of +/// an operation that cannot see the stored element it displaces (a delete, +/// or a plain payload landing on a registered element) and what bounds the +/// node growth registrations can inflict on a target. +pub const MAX_BACKWARD_REFERENCES: usize = 256; + +/// The referrer capacity the convenience constructors declare when the +/// caller does not choose one — the flat per-item limit the family shipped +/// with. Callers that know their fan-out (an item indexed four ways +/// declares four) should declare it explicitly: declared capacity is what +/// the batch cost estimator charges for a write. +pub const DEFAULT_BACKWARD_REFERENCES_CAPACITY: u16 = 32; + +/// The maximum number of backward references a `BidirectionalReference` +/// itself may carry (chains do not branch, keeping worst-case propagation +/// linear in the target's declared capacity: at most +/// `capacity × MAX_REFERENCE_HOPS` nodes are affected). +pub const MAX_BACKWARD_REFERENCES_ON_REFERENCE: usize = 1; + +/// Flag to indicate whether the bidirectional reference should be deleted when +/// the pointed-to item no longer exists or becomes incompatible. When unset, +/// such an update is refused with an error instead. +pub type CascadeOnUpdate = bool; + +/// One registered referrer of a backward-references-capable element: the +/// inverse path leading back to the referring `BidirectionalReference`, +/// plus its cascade policy. +/// +/// A referrer is identified by its `inverted_reference` (which is derived +/// from the referrer's position, so it is unique per referrer); there are +/// no slot indices. +#[derive(Clone, Debug, Encode, Decode, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BackwardReference { + /// Path leading back to the referring `BidirectionalReference`. + pub inverted_reference: ReferencePathType, + /// Whether the referrer may be cascade-deleted when this element is + /// removed or becomes incompatible. + pub cascade_on_update: bool, +} + +/// The referrer list of a backward-references ITEM together with the +/// capacity the element declares for it. +/// +/// `max_incoming` is authenticated with the element: it sits in the +/// STRIPPED (inner) bytes, so every referrer's commitment covers it and +/// every node enforces the same value. `entries` are the registered +/// referrers, excluded from the inner hash (see the module docs); there +/// are never more of them than `max_incoming`, and `max_incoming` never +/// exceeds [`MAX_BACKWARD_REFERENCES`]. +/// +/// Declaring a capacity allocates nothing: the list stores only actual +/// registrations. Raising the capacity on an update is always accepted; +/// lowering it below the number of registered referrers is refused. +/// +/// The type dereferences to its entries so the list can be read and +/// maintained like the plain `Vec` it replaced. +#[derive(Clone, Debug, Encode, Decode, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BackwardReferences { + /// How many referrers this element accepts (at most + /// [`MAX_BACKWARD_REFERENCES`]). Part of the inner hash. + pub max_incoming: u16, + /// The registered referrers, at most `max_incoming` of them. + pub entries: Vec, +} + +impl BackwardReferences { + /// An empty list declaring `max_incoming` as its capacity. + pub fn with_max_incoming(max_incoming: u16) -> Self { + Self { + max_incoming, + entries: Vec::new(), + } + } + + /// A list with the given entries declaring `max_incoming` as its + /// capacity. Budgets are enforced by + /// `Element::validate_backward_references_limits`, not here. + pub fn new(max_incoming: u16, entries: Vec) -> Self { + Self { + max_incoming, + entries, + } + } + + /// The declared capacity. + pub fn max_incoming(&self) -> u16 { + self.max_incoming + } + + /// Whether another referrer can register. + pub fn is_full(&self) -> bool { + self.entries.len() >= self.max_incoming as usize + } +} + +impl Default for BackwardReferences { + /// Empty, declaring [`DEFAULT_BACKWARD_REFERENCES_CAPACITY`]. + fn default() -> Self { + Self::with_max_incoming(DEFAULT_BACKWARD_REFERENCES_CAPACITY) + } +} + +impl From> for BackwardReferences { + /// The entries under [`DEFAULT_BACKWARD_REFERENCES_CAPACITY`]. + fn from(entries: Vec) -> Self { + Self::new(DEFAULT_BACKWARD_REFERENCES_CAPACITY, entries) + } +} + +impl std::ops::Deref for BackwardReferences { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.entries + } +} + +impl std::ops::DerefMut for BackwardReferences { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.entries + } +} + +/// Payload of [`crate::Element::BidirectionalReference`]. +#[derive(Clone, Debug, Encode, Decode, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BidirectionalReference { + /// Where this reference points to, like a regular reference. + pub forward_reference_path: ReferencePathType, + /// Whether overwriting/deleting the target may cascade-delete this + /// reference (otherwise such an update errors). + pub cascade_on_update: CascadeOnUpdate, + /// Maximum number of reference hops allowed when following the chain. + pub max_hop: MaxReferenceHop, + /// Referrers registered on THIS reference (it can itself be targeted by + /// at most [`MAX_BACKWARD_REFERENCES_ON_REFERENCE`] other bidirectional + /// references). Excluded from the inner hash; see the module docs. + pub backward_references: Vec, +} + +/// Canonical serialization of a backward-references list (bincode, big +/// endian, no limit — the same codec configuration `Element` uses). The +/// 32-byte hash of these bytes is the `backrefs_hash` half of the node's +/// combined value hash, so this encoding is consensus-critical. +pub fn serialize_backward_references( + backward_references: &[BackwardReference], +) -> Result, crate::error::ElementError> { + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + bincode::encode_to_vec(backward_references, config).map_err(|e| { + crate::error::ElementError::CorruptedData(format!( + "unable to serialize backward references: {e}" + )) + }) +} + +/// Inverse of [`serialize_backward_references`]. +pub fn deserialize_backward_references( + bytes: &[u8], +) -> Result, crate::error::ElementError> { + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + bincode::decode_from_slice(bytes, config) + .map_err(|e| { + crate::error::ElementError::CorruptedData(format!( + "unable to deserialize backward references: {e}" + )) + }) + .and_then(|(v, consumed)| { + // Trailing bytes would let two different byte strings decode to + // the same list; the codec is consensus-critical, so reject + // them like `Element::deserialize` does. + if consumed == bytes.len() { + Ok(v) + } else { + Err(crate::error::ElementError::CorruptedData( + "trailing bytes after backward references".to_string(), + )) + } + }) +} + +#[cfg(test)] +mod tests { + use grovedb_version::version::GroveVersion; + + use super::*; + use crate::{element::ElementFlags, Element, ElementType, ProofNodeType}; + + fn sibling_ref() -> ReferencePathType { + ReferencePathType::SiblingReference(b"target".to_vec()) + } + + fn backref(tag: &[u8]) -> BackwardReference { + BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(tag.to_vec()), + cascade_on_update: true, + } + } + + fn bidi(flags: Option) -> Element { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: sibling_ref(), + cascade_on_update: true, + max_hop: Some(5), + backward_references: Vec::new(), + }, + flags, + ) + } + + #[test] + fn constructors_produce_expected_shapes() { + assert_eq!( + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None) + ); + assert_eq!( + Element::new_sum_item_allowing_bidirectional_references_with_flags(7, Some(vec![2])), + Element::SumItemWithBackwardsReferences(7, Default::default(), Some(vec![2])) + ); + assert_eq!( + Element::new_item_allowing_bidirectional_references_with_flags( + b"v".to_vec(), + Some(vec![3]) + ), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), Some(vec![3])) + ); + let Element::BidirectionalReference(full, full_flags) = + Element::new_bidirectional_reference_with_options( + sibling_ref(), + Some(2), + true, + Some(vec![9]), + ) + else { + panic!("expected a bidirectional reference"); + }; + assert_eq!(full.max_hop, Some(2)); + assert!(full.cascade_on_update); + assert!(full.backward_references.is_empty()); + assert_eq!(full_flags, Some(vec![9])); + } + + #[test] + fn classification_of_backward_references_family() { + let bidi = bidi(None); + let item = Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None); + let sum_item = Element::SumItemWithBackwardsReferences(-3, Default::default(), None); + + assert!(bidi.is_reference()); + assert!(item.is_any_item()); + assert!(sum_item.is_sum_item()); + assert!(sum_item.is_sum_bearing_child()); + assert!(item.supports_backward_references()); + assert!(bidi.supports_backward_references()); + assert!(!Element::new_item(b"x".to_vec()).supports_backward_references()); + + assert_eq!(bidi.element_type(), ElementType::BidirectionalReference); + assert_eq!(sum_item.sum_value_or_default(), -3); + assert_eq!(item.as_item_bytes().unwrap(), b"v"); + assert_eq!( + bidi.clone().into_reference_path_type().unwrap(), + sibling_ref() + ); + + // Proof shapes: the reference resolves through KvRefValueHash; the + // items use the dedicated recombining node kind. + assert_eq!( + ElementType::BidirectionalReference.proof_node_type(Some(ElementType::Tree)), + ProofNodeType::KvRefValueHash + ); + assert_eq!( + ElementType::ItemWithBackwardsReferences.proof_node_type(Some(ElementType::Tree)), + ProofNodeType::KvBackwardsReferencesValueHash + ); + } + + #[test] + fn stripping_and_limits() { + let grove_version = GroveVersion::latest(); + + let mut item = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + vec![backref(b"a"), backref(b"b")].into(), + Some(vec![1]), + ); + let stripped = item.stripped_of_backward_references(); + assert_eq!( + stripped, + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), Some(vec![1])) + ); + // Stripped form round-trips the codec (it IS a valid element). + let bytes = stripped.serialize(grove_version).unwrap(); + assert_eq!( + Element::deserialize(&bytes, grove_version).unwrap(), + stripped + ); + + // Registering a referrer never changes the stripped form. + item.backward_references_mut().unwrap().push(backref(b"c")); + assert_eq!(item.stripped_of_backward_references(), stripped); + + // Budgets: 32 for items... + let full_list: Vec<_> = (0..32u8).map(|i| backref(&[i])).collect(); + let at_limit = + Element::ItemWithBackwardsReferences(b"v".to_vec(), full_list.clone().into(), None); + assert!(at_limit.validate_backward_references_limits().is_ok()); + assert!(at_limit.serialize(grove_version).is_ok()); + let mut over = full_list; + over.push(backref(b"!")); + let over_limit = Element::ItemWithBackwardsReferences(b"v".to_vec(), over.into(), None); + assert!(over_limit.validate_backward_references_limits().is_err()); + assert!(over_limit.serialize(grove_version).is_err()); + + // ...and 1 for references. + let Element::BidirectionalReference(mut reference, _) = bidi(None) else { + unreachable!() + }; + reference.backward_references = vec![backref(b"a"), backref(b"b")]; + let over_ref = Element::BidirectionalReference(reference, None); + assert!(over_ref.validate_backward_references_limits().is_err()); + assert!(over_ref.serialize(grove_version).is_err()); + } + + #[test] + fn display_and_type_names_for_the_family() { + let bidi_with = Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: sibling_ref(), + cascade_on_update: false, + max_hop: None, + backward_references: Vec::new(), + }, + Some(vec![7]), + ); + let s = format!("{}", bidi_with); + assert!( + s.starts_with("BidirectionalReference(") && s.contains("flags"), + "got: {s}" + ); + let s = format!("{}", bidi(None)); + assert!(s.contains("max_hop: 5") && !s.contains("flags"), "got: {s}"); + + let item = + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), Some(vec![1])); + let s = format!("{}", item); + assert!(s.starts_with("ItemWithBackwardsReferences("), "got: {s}"); + let sum = Element::SumItemWithBackwardsReferences(-2, Default::default(), None); + let s = format!("{}", sum); + assert!( + s.starts_with("SumItemWithBackwardsReferences(-2"), + "got: {s}" + ); + + assert_eq!( + item.element_type(), + ElementType::ItemWithBackwardsReferences + ); + assert_eq!( + sum.element_type(), + ElementType::SumItemWithBackwardsReferences + ); + assert_eq!( + ElementType::BidirectionalReference.as_str(), + "bidirectional reference" + ); + assert_eq!( + ElementType::ItemWithBackwardsReferences.as_str(), + "item with backwards references" + ); + assert_eq!( + ElementType::SumItemWithBackwardsReferences.as_str(), + "sum item with backwards references" + ); + } + + #[test] + fn aggregation_wrappers_reject_the_family() { + for element in [ + bidi(None), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None), + Element::SumItemWithBackwardsReferences(1, Default::default(), None), + ] { + assert!( + Element::new_non_counted(element.clone()).is_err(), + "NonCounted must reject {element}" + ); + let hand_built = Element::NonCounted(Box::new(element)); + assert!(hand_built.validate_wrapper_invariants().is_err()); + } + } + + #[test] + fn value_accessors_see_through_the_family() { + let item = Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None); + assert_eq!(item.clone().into_item_bytes().unwrap(), b"v".to_vec()); + let sum = Element::SumItemWithBackwardsReferences(-4, Default::default(), None); + assert_eq!(sum.as_sum_item_value().unwrap(), -4); + assert_eq!(sum.clone().into_sum_item_value().unwrap(), -4); + } + + #[test] + fn flags_accessors_cover_the_family() { + for mut element in [ + bidi(Some(vec![1])), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), Some(vec![1])), + Element::SumItemWithBackwardsReferences(1, Default::default(), Some(vec![1])), + ] { + assert_eq!(element.get_flags(), &Some(vec![1])); + *element.get_flags_mut() = Some(vec![2]); + element.set_flags(Some(vec![3])); + assert_eq!(element.clone().get_flags_owned(), Some(vec![3])); + } + } + + #[test] + fn bidirectional_forward_path_can_be_made_absolute() { + let element = bidi(None); + let absolute = element + .convert_if_reference_to_absolute_reference( + &[b"root".as_slice(), b"leaf".as_slice()], + Some(b"me".as_slice()), + ) + .unwrap(); + let Element::BidirectionalReference(reference, _) = absolute else { + panic!("expected a bidirectional reference"); + }; + assert_eq!( + reference.forward_reference_path, + ReferencePathType::AbsolutePathReference(vec![ + b"root".to_vec(), + b"leaf".to_vec(), + b"target".to_vec() + ]) + ); + // An already-absolute forward path is returned unchanged. + let element = Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + b"x".to_vec() + ]), + cascade_on_update: false, + max_hop: None, + backward_references: Vec::new(), + }, + None, + ); + assert_eq!( + element + .clone() + .convert_if_reference_to_absolute_reference(&[b"a".as_slice()], None) + .unwrap(), + element + ); + } + + #[test] + fn corrupt_and_over_limit_bytes_are_rejected() { + let grove_version = GroveVersion::latest(); + + // Garbage referrer-list bytes fail the standalone codec. + assert!(deserialize_backward_references(&[0xff, 0xff, 0xff]).is_err()); + + // Raw element bytes with an over-limit referrer list (crafted by + // encoding the enum directly, bypassing `serialize`'s validation) + // are rejected on deserialize. + let over = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + (0..33u8).map(|i| backref(&[i])).collect::>().into(), + None, + ); + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + let bytes = bincode::encode_to_vec(&over, config).unwrap(); + assert!(Element::deserialize(&bytes, grove_version).is_err()); + } + + #[test] + fn item_with_sum_item_twin_matches_family_semantics() { + let grove_version = GroveVersion::latest(); + let element = + Element::new_item_with_sum_item_allowing_bidirectional_references(b"v".to_vec(), 7); + assert_eq!( + element, + Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 7, + Default::default(), + None + ) + ); + assert_eq!( + Element::new_item_with_sum_item_allowing_bidirectional_references_with_flags( + b"v".to_vec(), + 7, + Some(vec![1]) + ), + Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 7, + Default::default(), + Some(vec![1]) + ) + ); + + // Classification mirrors ItemWithSumItem plus the family flags. + assert!(element.is_any_item()); + assert!(element.is_sum_item()); + assert!(element.is_item_with_sum_item()); + assert!(element.has_basic_item()); + assert!(element.is_sum_bearing_child()); + assert!(element.is_count_and_sum_bearing_child()); + assert!(element.supports_backward_references()); + assert_eq!(element.sum_value_or_default(), 7); + assert_eq!(element.as_item_bytes().unwrap(), b"v"); + assert_eq!(element.as_sum_item_value().unwrap(), 7); + assert_eq!(element.clone().into_item_bytes().unwrap(), b"v".to_vec()); + assert_eq!(element.clone().into_sum_item_value().unwrap(), 7); + assert_eq!( + element.element_type(), + ElementType::ItemWithSumItemWithBackwardsReferences + ); + assert_eq!( + ElementType::ItemWithSumItemWithBackwardsReferences.as_str(), + "item with sum item with backwards references" + ); + assert_eq!( + ElementType::ItemWithSumItemWithBackwardsReferences + .proof_node_type(Some(ElementType::Tree)), + ProofNodeType::KvBackwardsReferencesValueHash + ); + let shown = format!("{element}"); + assert!( + shown.starts_with("ItemWithSumItemWithBackwardsReferences("), + "got: {shown}" + ); + + // Wrapper rejection. + assert!(Element::new_non_counted(element.clone()).is_err()); + + // Codec: roundtrip, stripping, and the 32-entry budget. + let with_refs = Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 7, + vec![backref(b"a"), backref(b"b")].into(), + Some(vec![2]), + ); + let bytes = with_refs.serialize(grove_version).unwrap(); + assert_eq!( + Element::deserialize(&bytes, grove_version).unwrap(), + with_refs + ); + assert_eq!(bytes[0], 28, "wire discriminant is pinned"); + assert_eq!( + with_refs.stripped_of_backward_references(), + Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 7, + Default::default(), + Some(vec![2]) + ) + ); + let over = Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 7, + (0..33u8).map(|i| backref(&[i])).collect::>().into(), + None, + ); + assert!(over.validate_backward_references_limits().is_err()); + assert!(over.serialize(grove_version).is_err()); + } + + #[test] + fn backward_references_codec_round_trips() { + let list = vec![backref(b"a"), backref(b"zz")]; + let bytes = serialize_backward_references(&list).unwrap(); + assert_eq!(deserialize_backward_references(&bytes).unwrap(), list); + // Trailing bytes are rejected — one list, one byte string. + let mut padded = bytes.clone(); + padded.push(0); + assert!(deserialize_backward_references(&padded).is_err()); + // The empty list has a stable 1-byte encoding (its hash is a + // protocol constant). + assert_eq!(serialize_backward_references(&[]).unwrap().len(), 1); + } + /// The declared capacity bounds registrations, the protocol ceiling + /// bounds declarations, and decode enforces both. + #[test] + fn declared_capacity_bounds_registrations_and_the_ceiling_bounds_declarations() { + let grove_version = GroveVersion::latest(); + let entries = |n: u8| (0..n).map(|i| backref(&[i])).collect::>(); + + let within = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + BackwardReferences::new(2, entries(2)), + None, + ); + assert!(within.validate_backward_references_limits().is_ok()); + assert_eq!(within.max_incoming_references(), Some(2)); + + let over_capacity = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + BackwardReferences::new(2, entries(3)), + None, + ); + assert!(over_capacity.validate_backward_references_limits().is_err()); + + let at_ceiling = Element::SumItemWithBackwardsReferences( + 1, + BackwardReferences::with_max_incoming(MAX_BACKWARD_REFERENCES as u16), + None, + ); + assert!(at_ceiling.validate_backward_references_limits().is_ok()); + + let over_ceiling = Element::ItemWithSumItemWithBackwardsReferences( + b"v".to_vec(), + 1, + BackwardReferences::with_max_incoming(MAX_BACKWARD_REFERENCES as u16 + 1), + None, + ); + assert!(over_ceiling.validate_backward_references_limits().is_err()); + + // Serialization refuses both shapes … + assert!(over_capacity.serialize(grove_version).is_err()); + assert!(over_ceiling.serialize(grove_version).is_err()); + // … and decode enforces the same rules on bytes a peer hands over. + // Layout of `ItemWithBackwardsReferences(b"v", refs, None)`: + // discriminant, value length, value byte, then `max_incoming`. + let bytes = within.serialize(grove_version).unwrap(); + assert_eq!(Element::deserialize(&bytes, grove_version).unwrap(), within); + assert_eq!(bytes[3], 2, "the declared capacity (one-byte varint)"); + let mut over_capacity_bytes = bytes.clone(); + over_capacity_bytes[3] = 1; + assert!(Element::deserialize(&over_capacity_bytes, grove_version).is_err()); + // At the ceiling the capacity is a three-byte varint (marker, then + // the big-endian u16); bumping it one past the ceiling must fail. + let ceiling_bytes = at_ceiling.serialize(grove_version).unwrap(); + assert_eq!( + ceiling_bytes[0], 27, + "SumItemWithBackwardsReferences discriminant" + ); + assert_eq!( + ceiling_bytes[2..5], + [0xFB, 0x01, 0x00], + "sum value varint then the ceiling as a three-byte varint" + ); + let mut over_ceiling_bytes = ceiling_bytes.clone(); + over_ceiling_bytes[3..5] + .copy_from_slice(&(MAX_BACKWARD_REFERENCES as u16 + 1).to_be_bytes()); + assert!(Element::deserialize(&over_ceiling_bytes, grove_version).is_err()); + assert_eq!( + Element::deserialize(&ceiling_bytes, grove_version).unwrap(), + at_ceiling + ); + + // The plain constructors declare the historical default; the + // explicit ones declare what they are given. + assert_eq!( + Element::new_item_allowing_bidirectional_references(b"v".to_vec()) + .max_incoming_references(), + Some(DEFAULT_BACKWARD_REFERENCES_CAPACITY) + ); + assert_eq!( + Element::new_item_allowing_bidirectional_references_with_capacity(b"v".to_vec(), 4) + .max_incoming_references(), + Some(4) + ); + assert!(BackwardReferences::new(1, entries(1)).is_full()); + assert!(!BackwardReferences::with_max_incoming(1).is_full()); + } + + /// The capacity is part of the INNER (stripped) bytes — every referrer + /// commits to it — while the entries stay outside them. + #[test] + fn capacity_is_in_the_inner_bytes_and_entries_are_not() { + let grove_version = GroveVersion::latest(); + let a = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + BackwardReferences::new(4, vec![backref(b"x")]), + None, + ); + let b = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + BackwardReferences::new(4, vec![backref(b"y"), backref(b"z")]), + None, + ); + let c = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + BackwardReferences::new(5, vec![backref(b"x")]), + None, + ); + let stripped = |e: &Element| { + e.stripped_of_backward_references() + .serialize(grove_version) + .unwrap() + }; + assert_eq!( + stripped(&a), + stripped(&b), + "entries are outside the inner bytes" + ); + assert_ne!( + stripped(&a), + stripped(&c), + "capacity is inside the inner bytes" + ); + assert_eq!( + a.stripped_of_backward_references() + .max_incoming_references(), + Some(4), + "stripping keeps the declaration" + ); + assert!(a + .stripped_of_backward_references() + .backward_references() + .unwrap() + .is_empty()); + } +} diff --git a/grovedb-element/src/element/constructor.rs b/grovedb-element/src/element/constructor.rs index b426397f7..9a9380f97 100644 --- a/grovedb-element/src/element/constructor.rs +++ b/grovedb-element/src/element/constructor.rs @@ -78,6 +78,203 @@ impl Element { Element::SumItem(value, flags) } + /// Set element to an item without flags that can be targeted by + /// bidirectional references + pub fn new_item_allowing_bidirectional_references(item_value: Vec) -> Self { + Element::ItemWithBackwardsReferences( + item_value, + crate::bidirectional_reference::BackwardReferences::default(), + None, + ) + } + + /// Set element to an item with flags that can be targeted by + /// bidirectional references + pub fn new_item_allowing_bidirectional_references_with_flags( + item_value: Vec, + flags: Option, + ) -> Self { + Element::ItemWithBackwardsReferences( + item_value, + crate::bidirectional_reference::BackwardReferences::default(), + flags, + ) + } + + /// Set element to a sum item without flags that can be targeted by + /// bidirectional references + pub fn new_sum_item_allowing_bidirectional_references(value: i64) -> Self { + Element::SumItemWithBackwardsReferences( + value, + crate::bidirectional_reference::BackwardReferences::default(), + None, + ) + } + + /// Set element to an item carrying an explicit sum value that can be + /// targeted by bidirectional references + pub fn new_item_with_sum_item_allowing_bidirectional_references( + item_value: Vec, + sum_value: i64, + ) -> Self { + Element::ItemWithSumItemWithBackwardsReferences( + item_value, + sum_value, + crate::bidirectional_reference::BackwardReferences::default(), + None, + ) + } + + /// Set element to an item carrying an explicit sum value, with flags, + /// that can be targeted by bidirectional references + pub fn new_item_with_sum_item_allowing_bidirectional_references_with_flags( + item_value: Vec, + sum_value: i64, + flags: Option, + ) -> Self { + Element::ItemWithSumItemWithBackwardsReferences( + item_value, + sum_value, + crate::bidirectional_reference::BackwardReferences::default(), + flags, + ) + } + + /// Set element to a sum item with flags that can be targeted by + /// bidirectional references + pub fn new_sum_item_allowing_bidirectional_references_with_flags( + value: i64, + flags: Option, + ) -> Self { + Element::SumItemWithBackwardsReferences( + value, + crate::bidirectional_reference::BackwardReferences::default(), + flags, + ) + } + + /// Set element to an item without flags that can be targeted by up to + /// `max_incoming` bidirectional references (at most + /// [`crate::MAX_BACKWARD_REFERENCES`]; the declared capacity is what the + /// batch cost estimator charges for a write of this element). + pub fn new_item_allowing_bidirectional_references_with_capacity( + item_value: Vec, + max_incoming: u16, + ) -> Self { + Element::ItemWithBackwardsReferences( + item_value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + None, + ) + } + + /// Set element to an item with flags that can be targeted by up to + /// `max_incoming` bidirectional references. + pub fn new_item_allowing_bidirectional_references_with_capacity_and_flags( + item_value: Vec, + max_incoming: u16, + flags: Option, + ) -> Self { + Element::ItemWithBackwardsReferences( + item_value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + flags, + ) + } + + /// Set element to a sum item without flags that can be targeted by up + /// to `max_incoming` bidirectional references. + pub fn new_sum_item_allowing_bidirectional_references_with_capacity( + value: i64, + max_incoming: u16, + ) -> Self { + Element::SumItemWithBackwardsReferences( + value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + None, + ) + } + + /// Set element to a sum item with flags that can be targeted by up to + /// `max_incoming` bidirectional references. + pub fn new_sum_item_allowing_bidirectional_references_with_capacity_and_flags( + value: i64, + max_incoming: u16, + flags: Option, + ) -> Self { + Element::SumItemWithBackwardsReferences( + value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + flags, + ) + } + + /// Set element to an item carrying an explicit sum value that can be + /// targeted by up to `max_incoming` bidirectional references. + pub fn new_item_with_sum_item_allowing_bidirectional_references_with_capacity( + item_value: Vec, + sum_value: i64, + max_incoming: u16, + ) -> Self { + Element::ItemWithSumItemWithBackwardsReferences( + item_value, + sum_value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + None, + ) + } + + /// Set element to an item carrying an explicit sum value, with flags, + /// that can be targeted by up to `max_incoming` bidirectional + /// references. + pub fn new_item_with_sum_item_allowing_bidirectional_references_with_capacity_and_flags( + item_value: Vec, + sum_value: i64, + max_incoming: u16, + flags: Option, + ) -> Self { + Element::ItemWithSumItemWithBackwardsReferences( + item_value, + sum_value, + crate::bidirectional_reference::BackwardReferences::with_max_incoming(max_incoming), + flags, + ) + } + + /// Set element to a bidirectional reference without flags. The + /// `backward_references` list is bookkeeping maintained by insertion — + /// anything supplied here is overwritten by the write path. + pub fn new_bidirectional_reference(reference_path: ReferencePathType) -> Self { + Element::BidirectionalReference( + crate::bidirectional_reference::BidirectionalReference { + forward_reference_path: reference_path, + cascade_on_update: false, + max_hop: None, + backward_references: Vec::new(), + }, + None, + ) + } + + /// Set element to a bidirectional reference with every knob exposed. The + /// `backward_references` list is bookkeeping maintained by insertion. + pub fn new_bidirectional_reference_with_options( + reference_path: ReferencePathType, + max_hop: MaxReferenceHop, + cascade_on_update: bool, + flags: Option, + ) -> Self { + Element::BidirectionalReference( + crate::bidirectional_reference::BidirectionalReference { + forward_reference_path: reference_path, + cascade_on_update, + max_hop, + backward_references: Vec::new(), + }, + flags, + ) + } + /// Set element to an item with sum value (no flags) pub fn new_item_with_sum_item(item_value: Vec, sum_value: SumValue) -> Self { Element::ItemWithSumItem(item_value, sum_value, None) @@ -714,6 +911,17 @@ impl Element { "NonCounted cannot wrap another wrapper", )); } + if matches!( + inner, + Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + ) { + return Err(ElementError::InvalidInput( + "NonCounted cannot wrap backward-references elements", + )); + } Ok(Element::NonCounted(Box::new(inner))) } @@ -740,7 +948,11 @@ impl Element { Element::NotCountedOrSummed(_) => Err(ElementError::InvalidInput( "cannot wrap NotCountedOrSummed in NonCounted; wrappers are mutually exclusive", )), - other => Ok(Element::NonCounted(Box::new(other))), + // Delegate so every `new_non_counted` guard (notably the + // backward-references family rejection) applies here too — + // otherwise this helper would return an element that wrapper + // validation and serialization then refuse. + other => Self::new_non_counted(other), } } diff --git a/grovedb-element/src/element/helpers.rs b/grovedb-element/src/element/helpers.rs index a4774c7da..01879c523 100644 --- a/grovedb-element/src/element/helpers.rs +++ b/grovedb-element/src/element/helpers.rs @@ -102,7 +102,9 @@ impl Element { | Element::ProvableCountSumTree(_, _, sum_value, _) | Element::ProvableSumTree(_, sum_value, _) | Element::ProvableCountProvableSumTree(_, _, sum_value, _) - | Element::ReferenceWithSumItem(_, _, sum_value, _) => *sum_value, + | Element::ReferenceWithSumItem(_, _, sum_value, _) + | Element::SumItemWithBackwardsReferences(sum_value, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, sum_value, _, _) => *sum_value, // PSIT mirrors ProvableSumTree's contribution shape. Element::ProvableSumIndexedTree(_, _, sum_value, _) => *sum_value, // PCPSIT mirrors ProvableCountProvableSumTree. @@ -153,7 +155,11 @@ impl Element { | Element::ItemWithSumItem(_, sum_value, _) | Element::SumTree(_, sum_value, _) | Element::ProvableSumTree(_, sum_value, _) - | Element::ReferenceWithSumItem(_, _, sum_value, _) => (1, *sum_value), + | Element::ReferenceWithSumItem(_, _, sum_value, _) + | Element::SumItemWithBackwardsReferences(sum_value, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, sum_value, _, _) => { + (1, *sum_value) + } Element::CountTree(_, count_value, _) => (*count_value, 0), Element::CountSumTree(_, count_value, sum_value, _) | Element::ProvableCountSumTree(_, count_value, sum_value, _) @@ -187,7 +193,11 @@ impl Element { | Element::ProvableCountSumTree(_, _, sum_value, _) | Element::ProvableSumTree(_, sum_value, _) | Element::ProvableCountProvableSumTree(_, _, sum_value, _) - | Element::ReferenceWithSumItem(_, _, sum_value, _) => *sum_value as i128, + | Element::ReferenceWithSumItem(_, _, sum_value, _) + | Element::SumItemWithBackwardsReferences(sum_value, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, sum_value, _, _) => { + *sum_value as i128 + } Element::ProvableSumIndexedTree(_, _, sum_value, _) => *sum_value as i128, Element::ProvableCountProvableSumIndexedTree(_, _, sum_value, _, _) => { *sum_value as i128 @@ -205,6 +215,8 @@ impl Element { Element::SumItem(value, _) => Ok(*value), Element::ItemWithSumItem(_, value, _) => Ok(*value), Element::ReferenceWithSumItem(_, _, value, _) => Ok(*value), + Element::SumItemWithBackwardsReferences(value, _, _) => Ok(*value), + Element::ItemWithSumItemWithBackwardsReferences(_, value, _, _) => Ok(*value), _ => Err(ElementError::WrongElementType("expected a sum item")), } } @@ -217,6 +229,8 @@ impl Element { Element::SumItem(value, _) => Ok(value), Element::ItemWithSumItem(_, value, _) => Ok(value), Element::ReferenceWithSumItem(_, _, value, _) => Ok(value), + Element::SumItemWithBackwardsReferences(value, _, _) => Ok(value), + Element::ItemWithSumItemWithBackwardsReferences(_, value, _, _) => Ok(value), _ => Err(ElementError::WrongElementType("expected a sum item")), } } @@ -245,6 +259,8 @@ impl Element { match self.underlying() { Element::Item(value, _) => Ok(value), Element::ItemWithSumItem(value, ..) => Ok(value), + Element::ItemWithBackwardsReferences(value, _, _) => Ok(value), + Element::ItemWithSumItemWithBackwardsReferences(value, ..) => Ok(value), _ => Err(ElementError::WrongElementType("expected an item")), } } @@ -255,6 +271,8 @@ impl Element { match self.into_underlying() { Element::Item(value, _) => Ok(value), Element::ItemWithSumItem(value, ..) => Ok(value), + Element::ItemWithBackwardsReferences(value, _, _) => Ok(value), + Element::ItemWithSumItemWithBackwardsReferences(value, ..) => Ok(value), _ => Err(ElementError::WrongElementType("expected an item")), } } @@ -266,6 +284,7 @@ impl Element { match self.into_underlying() { Element::Reference(value, ..) => Ok(value), Element::ReferenceWithSumItem(value, ..) => Ok(value), + Element::BidirectionalReference(reference, _) => Ok(reference.forward_reference_path), _ => Err(ElementError::WrongElementType("expected a reference")), } } @@ -537,7 +556,9 @@ impl Element { pub fn is_reference(&self) -> bool { matches!( self.underlying(), - Element::Reference(..) | Element::ReferenceWithSumItem(..) + Element::Reference(..) + | Element::ReferenceWithSumItem(..) + | Element::BidirectionalReference(..) ) } @@ -553,7 +574,12 @@ impl Element { pub fn is_any_item(&self) -> bool { matches!( self.underlying(), - Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) + Element::Item(..) + | Element::SumItem(..) + | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) ) } @@ -562,12 +588,15 @@ impl Element { matches!(self.underlying(), Element::Item(..)) } - /// Check if the element has a basic item value (Item or ItemWithSumItem). - /// Looks through `NonCounted`. + /// Check if the element has a basic item value (Item, ItemWithSumItem, + /// or their backward-references twins). Looks through `NonCounted`. pub fn has_basic_item(&self) -> bool { matches!( self.underlying(), - Element::Item(..) | Element::ItemWithSumItem(..) + Element::Item(..) + | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) ) } @@ -575,14 +604,20 @@ impl Element { pub fn is_sum_item(&self) -> bool { matches!( self.underlying(), - Element::SumItem(..) | Element::ItemWithSumItem(..) + Element::SumItem(..) + | Element::ItemWithSumItem(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) ) } /// Check if the element is an item-with-sum-item. Looks through /// `NonCounted`. pub fn is_item_with_sum_item(&self) -> bool { - matches!(self.underlying(), Element::ItemWithSumItem(..)) + matches!( + self.underlying(), + Element::ItemWithSumItem(..) | Element::ItemWithSumItemWithBackwardsReferences(..) + ) } /// Returns whether this element legitimately contributes a sum value @@ -607,6 +642,8 @@ impl Element { Element::SumItem(..) | Element::ItemWithSumItem(..) | Element::ReferenceWithSumItem(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) | Element::SumTree(..) | Element::BigSumTree(..) | Element::CountSumTree(..) @@ -644,6 +681,7 @@ impl Element { matches!( self, Element::ItemWithSumItem(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) | Element::ReferenceWithSumItem(..) | Element::CountSumTree(..) | Element::ProvableCountSumTree(..) @@ -676,7 +714,11 @@ impl Element { | Element::PrivateDocumentStore(.., flags) | Element::ProvableSumIndexedTree(.., flags) | Element::ProvableCountIndexedTree(.., flags) - | Element::ReferenceWithSumItem(.., flags) => flags, + | Element::ReferenceWithSumItem(.., flags) + | Element::ItemWithBackwardsReferences(_, _, flags) + | Element::SumItemWithBackwardsReferences(_, _, flags) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, _, flags) => flags, + Element::BidirectionalReference(_, flags) => flags, // PCPSIT's flags are the trailing field after `axes`. Element::ProvableCountProvableSumIndexedTree(_, _, _, _, flags) => flags, Element::NonCounted(inner) @@ -709,7 +751,11 @@ impl Element { | Element::PrivateDocumentStore(.., flags) | Element::ProvableSumIndexedTree(.., flags) | Element::ProvableCountIndexedTree(.., flags) - | Element::ReferenceWithSumItem(.., flags) => flags, + | Element::ReferenceWithSumItem(.., flags) + | Element::ItemWithBackwardsReferences(_, _, flags) + | Element::SumItemWithBackwardsReferences(_, _, flags) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, _, flags) => flags, + Element::BidirectionalReference(_, flags) => flags, // PCPSIT's flags are the trailing field after `axes`. Element::ProvableCountProvableSumIndexedTree(_, _, _, _, flags) => flags, Element::NonCounted(inner) @@ -742,7 +788,11 @@ impl Element { | Element::PrivateDocumentStore(.., flags) | Element::ProvableSumIndexedTree(.., flags) | Element::ProvableCountIndexedTree(.., flags) - | Element::ReferenceWithSumItem(.., flags) => flags, + | Element::ReferenceWithSumItem(.., flags) + | Element::ItemWithBackwardsReferences(_, _, flags) + | Element::SumItemWithBackwardsReferences(_, _, flags) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, _, flags) => flags, + Element::BidirectionalReference(_, flags) => flags, Element::ProvableCountProvableSumIndexedTree(_, _, _, _, flags) => flags, Element::NonCounted(inner) | Element::NotSummed(inner) @@ -774,7 +824,11 @@ impl Element { | Element::PrivateDocumentStore(.., flags) | Element::ProvableSumIndexedTree(.., flags) | Element::ProvableCountIndexedTree(.., flags) - | Element::ReferenceWithSumItem(.., flags) => *flags = new_flags, + | Element::ReferenceWithSumItem(.., flags) + | Element::ItemWithBackwardsReferences(_, _, flags) + | Element::SumItemWithBackwardsReferences(_, _, flags) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, _, flags) => *flags = new_flags, + Element::BidirectionalReference(_, flags) => *flags = new_flags, Element::ProvableCountProvableSumIndexedTree(_, _, _, _, flags) => *flags = new_flags, Element::NonCounted(inner) | Element::NotSummed(inner) @@ -910,6 +964,24 @@ impl Element { } } } + Element::BidirectionalReference(ref reference, ref flags) => { + match reference.forward_reference_path { + ReferencePathType::AbsolutePathReference(..) => self, + _ => { + // Mirror the Reference arm: rebuild the forward path + // as absolute, preserving every other field. + let absolute_path = path_from_reference_path_type( + reference.forward_reference_path.clone(), + path, + key, + )?; + let mut reference = reference.clone(); + reference.forward_reference_path = + ReferencePathType::AbsolutePathReference(absolute_path); + Element::BidirectionalReference(reference, flags.clone()) + } + } + } Element::NonCounted(inner) => Element::NonCounted(Box::new( inner.convert_if_reference_to_absolute_reference(path, key)?, )), @@ -958,6 +1030,21 @@ mod non_counted_tests { assert!(ns.into_non_counted().is_err()); } + #[test] + fn into_non_counted_rejects_backward_references_family() { + // `into_non_counted` delegates to `new_non_counted`, so the family + // guard applies on both paths — otherwise the helper would return + // Ok with an element that wrapper validation and serialization + // then refuse. + for element in [ + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + Element::new_sum_item_allowing_bidirectional_references(5), + Element::new_item_with_sum_item_allowing_bidirectional_references(b"v".to_vec(), 5), + ] { + assert!(element.into_non_counted().is_err()); + } + } + #[test] fn new_non_counted_rejects_not_summed() { // Symmetric to `new_not_summed_rejects_non_counted` — `new_non_counted` @@ -2090,3 +2177,111 @@ mod indexed_tree_aggregate_helpers_tests { ); } } + +impl Element { + /// The backward references carried by this element, when it is one of + /// the backward-references-capable variants. + pub fn backward_references( + &self, + ) -> Option<&[crate::bidirectional_reference::BackwardReference]> { + match self { + Element::ItemWithBackwardsReferences(_, refs, _) + | Element::SumItemWithBackwardsReferences(_, refs, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, refs, _) => Some(&refs.entries), + Element::BidirectionalReference(reference, _) => Some(&reference.backward_references), + _ => None, + } + } + + /// The number of referrers this element declares it accepts: an item's + /// declared [`crate::bidirectional_reference::BackwardReferences::max_incoming`], + /// [`crate::MAX_BACKWARD_REFERENCES_ON_REFERENCE`] for a bidirectional + /// reference, `None` for elements without backward-references support. + pub fn max_incoming_references(&self) -> Option { + match self { + Element::ItemWithBackwardsReferences(_, refs, _) + | Element::SumItemWithBackwardsReferences(_, refs, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, refs, _) => { + Some(refs.max_incoming) + } + Element::BidirectionalReference(..) => { + Some(crate::MAX_BACKWARD_REFERENCES_ON_REFERENCE as u16) + } + _ => None, + } + } + + /// Mutable access to the backward references carried by this element. + pub fn backward_references_mut( + &mut self, + ) -> Option<&mut Vec> { + match self { + Element::ItemWithBackwardsReferences(_, refs, _) + | Element::SumItemWithBackwardsReferences(_, refs, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, refs, _) => { + Some(&mut refs.entries) + } + Element::BidirectionalReference(reference, _) => { + Some(&mut reference.backward_references) + } + _ => None, + } + } + + /// Whether this element can be targeted by bidirectional references + /// (i.e. carries a backward-references list). + pub fn supports_backward_references(&self) -> bool { + matches!( + self, + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + | Element::BidirectionalReference(..) + ) + } + + /// This element with its backward-references list emptied — the + /// "inner" form whose serialization feeds the inner hash, appears in + /// proofs, and is returned from result sets. For every other element + /// this is a plain clone. + pub fn stripped_of_backward_references(&self) -> Element { + let mut stripped = self.clone(); + if let Some(refs) = stripped.backward_references_mut() { + refs.clear(); + } + stripped + } + + /// Enforce the backward-references budgets: an item declares a + /// capacity of at most [`crate::MAX_BACKWARD_REFERENCES`] and carries + /// at most that many referrers; a bidirectional reference carries at + /// most [`crate::MAX_BACKWARD_REFERENCES_ON_REFERENCE`]. + pub fn validate_backward_references_limits(&self) -> Result<(), ElementError> { + match self { + Element::ItemWithBackwardsReferences(_, refs, _) + | Element::SumItemWithBackwardsReferences(_, refs, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, _, refs, _) => { + if refs.max_incoming as usize > crate::MAX_BACKWARD_REFERENCES { + return Err(ElementError::InvalidInput( + "an element may declare at most 256 incoming backward references", + )); + } + if refs.len() > refs.max_incoming as usize { + return Err(ElementError::InvalidInput( + "an element carries more backward references than it declares", + )); + } + } + Element::BidirectionalReference(reference, _) + if reference.backward_references.len() + > crate::MAX_BACKWARD_REFERENCES_ON_REFERENCE => + { + return Err(ElementError::InvalidInput( + "a bidirectional reference supports at most 1 backward reference", + )); + } + _ => {} + } + Ok(()) + } +} diff --git a/grovedb-element/src/element/mod.rs b/grovedb-element/src/element/mod.rs index a8b9c1013..a65bba8e7 100644 --- a/grovedb-element/src/element/mod.rs +++ b/grovedb-element/src/element/mod.rs @@ -15,7 +15,10 @@ use std::fmt; use bincode::{Decode, Encode}; -use crate::{element_type::ElementType, reference_path::ReferencePathType}; +use crate::{ + bidirectional_reference::BidirectionalReference, element_type::ElementType, + reference_path::ReferencePathType, +}; /// Optional meta-data to be stored per element pub type ElementFlags = Vec; @@ -348,6 +351,65 @@ pub enum Element { /// Variant order in this enum determines bincode's variant-index /// encoding on disk. This variant gets index 24. PrivateDocumentStore(u64, u32, u8, Option), + /// A reference that registers itself in its target's backward-reference + /// meta storage, so target updates propagate back along the chain (or + /// cascade-delete it). Resolves like `Reference` on reads. May only + /// target elements with backward-reference support + /// (`ItemWithBackwardsReferences`, `SumItemWithBackwardsReferences`, + /// or another `BidirectionalReference`). + /// + /// May not be wrapped in `NonCounted` / `NotSummed` / + /// `NotCountedOrSummed`, and is rejected by `apply_batch` (batch + /// support for backward-reference propagation is not implemented yet). + /// + /// Discriminant 25. + BidirectionalReference(BidirectionalReference, Option), + /// An ordinary value that supports being targeted by bidirectional + /// references: up to 32 backward references are carried ON the element + /// and covered by the node hash through the two-layer scheme described + /// in [`crate::bidirectional_reference`]. Behaves like `Item` in every + /// other way; readers and proofs see the stripped (inner) form. + /// + /// May not be wrapped in the aggregation wrappers and is rejected by + /// `apply_batch` (same reason as `BidirectionalReference`). + /// + /// Discriminant 26. + ItemWithBackwardsReferences( + Vec, + crate::bidirectional_reference::BackwardReferences, + Option, + ), + /// A signed integer value that can be totaled in a sum tree AND supports + /// being targeted by bidirectional references. Behaves like `SumItem` + /// in every other way. + /// + /// May not be wrapped in the aggregation wrappers and is rejected by + /// `apply_batch` (same reason as `BidirectionalReference`). + /// + /// Discriminant 27. + SumItemWithBackwardsReferences( + SumValue, + crate::bidirectional_reference::BackwardReferences, + Option, + ), + /// An item that simultaneously carries an explicit `SumValue` (like + /// `ItemWithSumItem`) AND supports being targeted by bidirectional + /// references. The sum contributes to sum-bearing parents exactly as + /// `ItemWithSumItem`'s does; the referrer list is carried ON the + /// element and covered by the node hash through the two-layer scheme + /// described in [`crate::bidirectional_reference`]. Readers and proofs + /// see the stripped (inner) form. + /// + /// May not be wrapped in the aggregation wrappers and is rejected by + /// `apply_batch` (same reason as `BidirectionalReference`). + /// + /// Discriminant 28. + ItemWithSumItemWithBackwardsReferences( + Vec, + SumValue, + crate::bidirectional_reference::BackwardReferences, + Option, + ), } pub fn hex_to_ascii(hex_value: &[u8]) -> String { @@ -668,6 +730,57 @@ impl fmt::Display for Element { .map_or(String::new(), |f| format!(", flags: {:?}", f)) ) } + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path, + cascade_on_update, + max_hop, + .. + }, + flags, + ) => { + write!( + f, + "BidirectionalReference({}, max_hop: {}, cascade: {}{})", + forward_reference_path, + max_hop.map_or("None".to_string(), |h| h.to_string()), + cascade_on_update, + flags + .as_ref() + .map_or(String::new(), |f| format!(", flags: {:?}", f)) + ) + } + Element::ItemWithBackwardsReferences(data, _, flags) => { + write!( + f, + "ItemWithBackwardsReferences({}{})", + hex_to_ascii(data), + flags + .as_ref() + .map_or(String::new(), |f| format!(", flags: {:?}", f)) + ) + } + Element::SumItemWithBackwardsReferences(sum_value, _, flags) => { + write!( + f, + "SumItemWithBackwardsReferences({}{})", + sum_value, + flags + .as_ref() + .map_or(String::new(), |f| format!(", flags: {:?}", f)) + ) + } + Element::ItemWithSumItemWithBackwardsReferences(data, sum_value, _, flags) => { + write!( + f, + "ItemWithSumItemWithBackwardsReferences({}, {}{})", + hex_to_ascii(data), + sum_value, + flags + .as_ref() + .map_or(String::new(), |f| format!(", flags: {:?}", f)) + ) + } } } } @@ -705,6 +818,14 @@ impl Element { ElementType::ProvableCountProvableSumIndexedTree } Element::PrivateDocumentStore(..) => ElementType::PrivateDocumentStore, + Element::BidirectionalReference(..) => ElementType::BidirectionalReference, + Element::ItemWithBackwardsReferences(..) => ElementType::ItemWithBackwardsReferences, + Element::SumItemWithBackwardsReferences(..) => { + ElementType::SumItemWithBackwardsReferences + } + Element::ItemWithSumItemWithBackwardsReferences(..) => { + ElementType::ItemWithSumItemWithBackwardsReferences + } Element::NonCounted(inner) => match inner.element_type() { ElementType::Item => ElementType::NonCountedItem, ElementType::Reference => ElementType::NonCountedReference, @@ -835,6 +956,20 @@ impl Element { "NonCounted cannot wrap another wrapper", )); } + if matches!( + **inner, + Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + ) { + return Err(crate::error::ElementError::InvalidInput( + "NonCounted cannot wrap backward-references elements \ + (BidirectionalReference, ItemWithBackwardsReferences, \ + SumItemWithBackwardsReferences, \ + ItemWithSumItemWithBackwardsReferences)", + )); + } } Element::NotSummed(inner) => match **inner { Element::SumTree(..) @@ -964,6 +1099,26 @@ mod serde_impl { Option, ), PrivateDocumentStore(u64, u32, u8, Option), + BidirectionalReference( + crate::bidirectional_reference::BidirectionalReference, + Option, + ), + ItemWithBackwardsReferences( + Vec, + crate::bidirectional_reference::BackwardReferences, + Option, + ), + SumItemWithBackwardsReferences( + SumValue, + crate::bidirectional_reference::BackwardReferences, + Option, + ), + ItemWithSumItemWithBackwardsReferences( + Vec, + SumValue, + crate::bidirectional_reference::BackwardReferences, + Option, + ), } impl From for Element { @@ -1016,6 +1171,18 @@ mod serde_impl { ElementShadow::PrivateDocumentStore(c, e, p, f) => { Element::PrivateDocumentStore(c, e, p, f) } + ElementShadow::BidirectionalReference(r, f) => { + Element::BidirectionalReference(r, f) + } + ElementShadow::ItemWithBackwardsReferences(v, b, f) => { + Element::ItemWithBackwardsReferences(v, b, f) + } + ElementShadow::SumItemWithBackwardsReferences(v, b, f) => { + Element::SumItemWithBackwardsReferences(v, b, f) + } + ElementShadow::ItemWithSumItemWithBackwardsReferences(v, sv, b, f) => { + Element::ItemWithSumItemWithBackwardsReferences(v, sv, b, f) + } } } } @@ -1036,6 +1203,9 @@ mod serde_impl { element .validate_private_document_store_config() .map_err(D::Error::custom)?; + element + .validate_backward_references_limits() + .map_err(D::Error::custom)?; Ok(element) } } @@ -1071,6 +1241,20 @@ mod serde_impl { Element::Item(b"abc".to_vec(), None), Element::SumTree(Some(b"r".to_vec()), 42, None), Element::PrivateDocumentStore(9, 64, 4, Some(vec![1])), + Element::BidirectionalReference( + crate::bidirectional_reference::BidirectionalReference { + forward_reference_path: + crate::reference_path::ReferencePathType::SiblingReference( + b"t".to_vec(), + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(4), + }, + Some(vec![7]), + ), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None), + Element::SumItemWithBackwardsReferences(-9, Default::default(), Some(vec![1])), Element::new_non_counted(Element::Item(b"x".to_vec(), None)).unwrap(), Element::new_not_summed(Element::SumTree(None, 100, None)).unwrap(), Element::new_not_counted_or_summed(Element::CountSumTree(None, 3, 100, None)) diff --git a/grovedb-element/src/element/serialize.rs b/grovedb-element/src/element/serialize.rs index 16a7ad338..ceff99ac6 100644 --- a/grovedb-element/src/element/serialize.rs +++ b/grovedb-element/src/element/serialize.rs @@ -49,6 +49,19 @@ impl Element { "NonCounted cannot wrap another wrapper".to_string(), )); } + if let Element::NonCounted(inner) = self + && matches!( + **inner, + Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + ) + { + return Err(ElementError::CorruptedData( + "NonCounted cannot wrap backward-references elements".to_string(), + )); + } if let Element::NotSummed(inner) = self { match **inner { Element::SumTree(..) @@ -89,6 +102,14 @@ impl Element { e ))); } + // The backward-references budgets bound worst-case propagation cost; + // an over-limit list must never reach disk. + if let Err(e) = self.validate_backward_references_limits() { + return Err(ElementError::CorruptedData(format!( + "invalid backward references: {}", + e + ))); + } let config = config::standard().with_big_endian().with_no_limit(); bincode::encode_to_vec(self, config) .map_err(|e| ElementError::CorruptedData(format!("unable to serialize element {}", e))) @@ -167,6 +188,19 @@ impl Element { "deserialized NonCounted wrapping another wrapper".to_string(), )); } + if let Element::NonCounted(inner) = &elem + && matches!( + **inner, + Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + ) + { + return Err(ElementError::CorruptedData( + "deserialized NonCounted wrapping a backward-references element".to_string(), + )); + } if let Element::NotSummed(inner) = &elem { match **inner { Element::SumTree(..) @@ -210,6 +244,12 @@ impl Element { e ))); } + if let Err(e) = elem.validate_backward_references_limits() { + return Err(ElementError::CorruptedData(format!( + "deserialized element with invalid backward references: {}", + e + ))); + } Ok(elem) } } @@ -372,4 +412,66 @@ mod tests { ); } } + + /// The backward-references family occupies wire discriminants 25/26/27 + /// (bincode variant indices, append-only). Pin them: a reorder of the + /// enum would silently change the on-disk format. + #[test] + fn backward_references_family_wire_discriminants_are_pinned() { + let grove_version = GroveVersion::latest(); + + let bidi = Element::new_bidirectional_reference( + crate::reference_path::ReferencePathType::AbsolutePathReference(vec![b"a".to_vec()]), + ); + let item = Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None); + let sum_item = Element::SumItemWithBackwardsReferences(7, Default::default(), None); + + assert_eq!(bidi.serialize(grove_version).unwrap()[0], 25); + assert_eq!(item.serialize(grove_version).unwrap()[0], 26); + assert_eq!(sum_item.serialize(grove_version).unwrap()[0], 27); + + // And they round-trip. + for element in [bidi, item, sum_item] { + let bytes = element.serialize(grove_version).unwrap(); + assert_eq!( + Element::deserialize(&bytes, grove_version).unwrap(), + element + ); + } + } + + /// `NonCounted` may not wrap the backward-references family — enforced + /// at construction, serialization, AND deserialization (fail closed + /// symmetric in both directions). + #[test] + fn non_counted_rejects_backward_references_family() { + let grove_version = GroveVersion::latest(); + + let inners = [ + Element::new_bidirectional_reference( + crate::reference_path::ReferencePathType::AbsolutePathReference( + vec![b"a".to_vec()], + ), + ), + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None), + Element::SumItemWithBackwardsReferences(7, Default::default(), None), + ]; + + for inner in inners { + // Constructor refuses. + assert!(Element::new_non_counted(inner.clone()).is_err()); + + // A hand-built wrapper refuses to serialize. + let wrapped = Element::NonCounted(Box::new(inner.clone())); + assert!(wrapped.serialize(grove_version).is_err()); + + // Hand-built wire bytes refuse to deserialize. + let mut bytes = vec![15u8]; + bytes.extend(inner.serialize(grove_version).unwrap()); + assert!(Element::deserialize(&bytes, grove_version).is_err()); + + // The type-classifier guard also rejects the pair. + assert!(crate::ElementType::from_serialized_value(&bytes).is_err()); + } + } } diff --git a/grovedb-element/src/element/visualize.rs b/grovedb-element/src/element/visualize.rs index 652706072..90649711d 100644 --- a/grovedb-element/src/element/visualize.rs +++ b/grovedb-element/src/element/visualize.rs @@ -31,6 +31,55 @@ impl Visualize for Element { drawer = f.visualize(drawer)?; } } + Element::BidirectionalReference(reference, flags) => { + drawer.write( + format!( + "bidi_ref: [forward: {}, cascade: {}, max_hop: {}, backrefs: {}]", + reference.forward_reference_path, + reference.cascade_on_update, + reference + .max_hop + .map_or("None".to_string(), |h| h.to_string()), + reference.backward_references.len(), + ) + .as_bytes(), + )?; + if let Some(f) = flags + && !f.is_empty() + { + drawer = f.visualize(drawer)?; + } + } + Element::ItemWithBackwardsReferences(value, _, flags) => { + drawer.write(b"item_with_backwards_references: ")?; + drawer = value.visualize(drawer)?; + + if let Some(f) = flags + && !f.is_empty() + { + drawer = f.visualize(drawer)?; + } + } + Element::SumItemWithBackwardsReferences(value, _, flags) => { + drawer.write(format!("sum_item_with_backwards_references: {value}").as_bytes())?; + + if let Some(f) = flags + && !f.is_empty() + { + drawer = f.visualize(drawer)?; + } + } + Element::ItemWithSumItemWithBackwardsReferences(value, sum_value, _, flags) => { + drawer.write(b"item_with_sum_item_with_backwards_references: ")?; + drawer = value.visualize(drawer)?; + drawer.write(format!(", sum: {sum_value}").as_bytes())?; + + if let Some(f) = flags + && !f.is_empty() + { + drawer = f.visualize(drawer)?; + } + } Element::Reference(_ref, ..) => { drawer.write(b"ref")?; // drawer.write(b"ref: [path: ")?; @@ -310,6 +359,62 @@ impl fmt::Debug for Element { mod tests { use grovedb_visualize::to_hex; + #[test] + fn visualize_backward_references_family() { + let render = |e: &Element| { + let mut out = Vec::new(); + let drawer = Drawer::new(&mut out); + e.visualize(drawer).expect("visualize IO error"); + String::from_utf8_lossy(&out).into_owned() + }; + + let bidi = Element::BidirectionalReference( + crate::BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"t".to_vec()), + cascade_on_update: true, + max_hop: Some(3), + backward_references: Vec::new(), + }, + Some(vec![1]), + ); + let s = render(&bidi); + assert!( + s.starts_with("bidi_ref: [") && s.contains("cascade: true") && s.contains("max_hop: 3"), + "got: {s}" + ); + + let bidi_plain = Element::BidirectionalReference( + crate::BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"t".to_vec()), + cascade_on_update: false, + max_hop: None, + backward_references: Vec::new(), + }, + None, + ); + assert!(render(&bidi_plain).contains("max_hop: None")); + + let item = + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), Some(vec![2])); + let s = render(&item); + assert!( + s.starts_with("item_with_backwards_references: "), + "got: {s}" + ); + let item_plain = + Element::ItemWithBackwardsReferences(b"v".to_vec(), Default::default(), None); + render(&item_plain); + + let sum = Element::SumItemWithBackwardsReferences(-7, Default::default(), Some(vec![3])); + let s = render(&sum); + assert!( + s.starts_with("sum_item_with_backwards_references: -7"), + "got: {s}" + ); + let sum_plain = Element::SumItemWithBackwardsReferences(-7, Default::default(), None); + render(&sum_plain); + } + use super::*; use crate::reference_path::ReferencePathType; diff --git a/grovedb-element/src/element_type.rs b/grovedb-element/src/element_type.rs index 5d4400a6c..e7197a43d 100644 --- a/grovedb-element/src/element_type.rs +++ b/grovedb-element/src/element_type.rs @@ -92,6 +92,18 @@ pub enum ProofNodeType { /// ProvableCountTree parent KvValueHash, + /// Use `Node::KVBackwardsReferencesValueHash` — the node carries the + /// element's STRIPPED (inner) serialization plus the 32-byte hash of + /// its backward-references list; the verifier recomputes + /// `value_hash = combine(H(stripped), backrefs_hash)`, which binds the + /// payload bytes without shipping (or leaking) the referrer set. + /// + /// Used for: ItemWithBackwardsReferences, SumItemWithBackwardsReferences, + /// ItemWithSumItemWithBackwardsReferences + /// (which are rejected inside Provable* aggregate parents, so no + /// count/sum-carrying twin is needed). + KvBackwardsReferencesValueHash, + /// Use `Node::KVRefValueHash` - like KVValueHash but for references. /// /// At the merk layer, this generates `KVValueHash` (since merk doesn't @@ -297,6 +309,22 @@ pub enum ElementType { /// `BulkAppendTree`, with the `{entry_size, chunk_power}` config bound /// into the state root) - discriminant 24. PrivateDocumentStore = 24, + /// Bidirectional reference - discriminant 25. Resolves like `Reference` + /// (combined value hash, followed by the same reference chain) but also + /// registers a backward reference in its target's meta storage. No + /// wrapper twins: the aggregation wrappers reject it. + BidirectionalReference = 25, + /// Item that supports being targeted by bidirectional references - + /// discriminant 26. Hashes like `Item`. No wrapper twins. + ItemWithBackwardsReferences = 26, + /// Sum item that supports being targeted by bidirectional references - + /// discriminant 27. Hashes like `SumItem`. No wrapper twins. + SumItemWithBackwardsReferences = 27, + /// Item carrying an explicit sum value that supports being targeted by + /// bidirectional references - discriminant 28. Hashes through the + /// combined (stripped ‖ backrefs) scheme like the other two backward- + /// references item variants. No wrapper twins. + ItemWithSumItemWithBackwardsReferences = 28, /// Non-counted wrapper around `Item` - discriminant 128 NonCountedItem = 128, /// Non-counted wrapper around `Reference` - discriminant 129 @@ -417,9 +445,13 @@ impl ElementType { // `23` (ProvableCountProvableSumIndexedTree), and `24` // (PrivateDocumentStore). // Bytes 15, 16, and 17 are the wrapper bytes themselves - // (nested wrappers forbidden in either direction); 25..=127 - // are unallocated; 128..=152 are the synthetic NonCountedXxx - // twins which never appear on disk. + // (nested wrappers forbidden in either direction); 25..=28 are + // the backward-references family (BidirectionalReference / + // ItemWithBackwardsReferences / SumItemWithBackwardsReferences), + // which the aggregation wrappers deliberately reject (fail + // closed — their interaction with count/sum suppression is + // undefined); 29..=127 are unallocated; 128..=152 are the + // synthetic NonCountedXxx twins which never appear on disk. // Without this check, the bitwise OR below would collapse // `0x80 | inner_byte` into `inner_byte` and a payload like // `[15, 128, ...]` would silently parse as `NonCountedItem`. @@ -574,6 +606,21 @@ impl ElementType { } } + /// True for the backward-references ITEM variants — the elements whose + /// stored node hash is the two-layer combined hash and whose + /// chain-terminal commitment is the stripped LOGICAL hash. Every site + /// that special-cases the family by serialized type must use this + /// predicate, so adding a member cannot silently miss a dispatch. + #[inline] + pub fn is_backward_references_item(self) -> bool { + matches!( + self.base(), + ElementType::ItemWithBackwardsReferences + | ElementType::SumItemWithBackwardsReferences + | ElementType::ItemWithSumItemWithBackwardsReferences + ) + } + /// Returns the type of proof node that should be used for this element /// type, given the parent tree type. /// @@ -657,7 +704,17 @@ impl ElementType { || is_provable_count_and_provable_sum_tree; let base = self.base(); - if base.has_simple_value_hash() { + if matches!( + base, + ElementType::ItemWithBackwardsReferences + | ElementType::SumItemWithBackwardsReferences + | ElementType::ItemWithSumItemWithBackwardsReferences + ) { + // Combined-hash items: stripped payload + backrefs hash. These + // are rejected inside Provable* aggregate parents at insertion, + // so no aggregate-carrying variant exists. + ProofNodeType::KvBackwardsReferencesValueHash + } else if base.has_simple_value_hash() { // Items (Item, SumItem, ItemWithSumItem) if is_provable_count_and_provable_sum_tree { ProofNodeType::KvCountSum @@ -776,14 +833,17 @@ impl ElementType { } /// Returns true if this element type is a reference. Looks through the - /// `NonCounted` wrapper. Both `Reference` and `ReferenceWithSumItem` are - /// references — they share the combined-value-hash proof shape and are - /// resolved by the same `follow_reference` chain. + /// `NonCounted` wrapper. `Reference`, `ReferenceWithSumItem`, and + /// `BidirectionalReference` are all references — they share the + /// combined-value-hash proof shape and are resolved by the same + /// `follow_reference` chain. #[inline] pub fn is_reference(&self) -> bool { matches!( self.base(), - ElementType::Reference | ElementType::ReferenceWithSumItem + ElementType::Reference + | ElementType::ReferenceWithSumItem + | ElementType::BidirectionalReference ) } @@ -793,7 +853,12 @@ impl ElementType { pub fn is_item(&self) -> bool { matches!( self.base(), - ElementType::Item | ElementType::SumItem | ElementType::ItemWithSumItem + ElementType::Item + | ElementType::SumItem + | ElementType::ItemWithSumItem + | ElementType::ItemWithBackwardsReferences + | ElementType::SumItemWithBackwardsReferences + | ElementType::ItemWithSumItemWithBackwardsReferences ) } @@ -824,6 +889,12 @@ impl ElementType { "provable count provable sum indexed tree" } ElementType::PrivateDocumentStore => "private_document_store", + ElementType::BidirectionalReference => "bidirectional reference", + ElementType::ItemWithBackwardsReferences => "item with backwards references", + ElementType::SumItemWithBackwardsReferences => "sum item with backwards references", + ElementType::ItemWithSumItemWithBackwardsReferences => { + "item with sum item with backwards references" + } ElementType::NonCountedItem => "non_counted item", ElementType::NonCountedReference => "non_counted reference", ElementType::NonCountedTree => "non_counted tree", @@ -914,6 +985,10 @@ impl TryFrom for ElementType { 22 => Ok(ElementType::ProvableCountIndexedTree), 23 => Ok(ElementType::ProvableCountProvableSumIndexedTree), 24 => Ok(ElementType::PrivateDocumentStore), + 25 => Ok(ElementType::BidirectionalReference), + 26 => Ok(ElementType::ItemWithBackwardsReferences), + 27 => Ok(ElementType::SumItemWithBackwardsReferences), + 28 => Ok(ElementType::ItemWithSumItemWithBackwardsReferences), 128 => Ok(ElementType::NonCountedItem), 129 => Ok(ElementType::NonCountedReference), 130 => Ok(ElementType::NonCountedTree), @@ -1049,8 +1124,25 @@ mod tests { ElementType::try_from(24).unwrap(), ElementType::PrivateDocumentStore ); - // 25..=127 are unallocated and invalid. - assert!(ElementType::try_from(25).is_err()); + // 25..=28: the backward-references family. + assert_eq!( + ElementType::try_from(25).unwrap(), + ElementType::BidirectionalReference + ); + assert_eq!( + ElementType::try_from(26).unwrap(), + ElementType::ItemWithBackwardsReferences + ); + assert_eq!( + ElementType::try_from(27).unwrap(), + ElementType::SumItemWithBackwardsReferences + ); + assert_eq!( + ElementType::try_from(28).unwrap(), + ElementType::ItemWithSumItemWithBackwardsReferences + ); + // 29..=127 are unallocated and invalid. + assert!(ElementType::try_from(29).is_err()); assert!(ElementType::try_from(100).is_err()); // NonCounted twins (0x80 | base): 128..142, plus 146 (= 0x80|18 = @@ -1751,12 +1843,17 @@ mod tests { // guard, `0x80 | 128 == 128` would silently parse as `NonCountedItem`. assert!(ElementType::from_serialized_value(&[15, 128]).is_err()); assert!(ElementType::from_serialized_value(&[15, 142]).is_err()); - // Wrapper with an unallocated mid-range inner byte (16, 17, - // 25..=127) is also rejected, even though it has no high bit - // set. + // Wrapper with a non-wrappable mid-range inner byte is also + // rejected, even though it has no high bit set: 16/17 are the other + // wrapper bytes, 25..=28 are the backward-references family (which + // the aggregation wrappers refuse — fail closed), and 29..=127 are + // unallocated. assert!(ElementType::from_serialized_value(&[15, 16]).is_err()); assert!(ElementType::from_serialized_value(&[15, 17]).is_err()); assert!(ElementType::from_serialized_value(&[15, 25]).is_err()); + assert!(ElementType::from_serialized_value(&[15, 26]).is_err()); + assert!(ElementType::from_serialized_value(&[15, 27]).is_err()); + assert!(ElementType::from_serialized_value(&[15, 28]).is_err()); assert!(ElementType::from_serialized_value(&[15, 100]).is_err()); // Inner byte 18 (ReferenceWithSumItem) IS a legal base; resolves to diff --git a/grovedb-element/src/lib.rs b/grovedb-element/src/lib.rs index e009b60ef..58e246886 100644 --- a/grovedb-element/src/lib.rs +++ b/grovedb-element/src/lib.rs @@ -1,6 +1,13 @@ +mod bidirectional_reference; mod element; mod element_type; +pub use bidirectional_reference::{ + deserialize_backward_references, serialize_backward_references, BackwardReference, + BackwardReferences, BidirectionalReference, CascadeOnUpdate, + DEFAULT_BACKWARD_REFERENCES_CAPACITY, MAX_BACKWARD_REFERENCES, + MAX_BACKWARD_REFERENCES_ON_REFERENCE, +}; pub use element::*; pub use element_type::{ElementType, ProofNodeType}; pub mod error; diff --git a/grovedb-query/src/proofs/encoding.rs b/grovedb-query/src/proofs/encoding.rs index a864ca369..559bca04b 100644 --- a/grovedb-query/src/proofs/encoding.rs +++ b/grovedb-query/src/proofs/encoding.rs @@ -91,6 +91,22 @@ impl Encode for Op { dest.write_all(value_hash)?; } } + Op::Push(Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash)) => { + let key_len = key_len_u8(key)?; + if value.len() < 65536 { + dest.write_all(&[0x50, key_len])?; + dest.write_all(key)?; + (value.len() as u16).encode_into(dest)?; + dest.write_all(value)?; + dest.write_all(backrefs_hash)?; + } else { + dest.write_all(&[0x51, key_len])?; + dest.write_all(key)?; + (value.len() as u32).encode_into(dest)?; + dest.write_all(value)?; + dest.write_all(backrefs_hash)?; + } + } Op::Push(Node::KVValueHashFeatureType(key, value, value_hash, feature_type)) => { let key_len = key_len_u8(key)?; if value.len() < 65536 { @@ -236,6 +252,22 @@ impl Encode for Op { dest.write_all(key)?; dest.write_all(value_hash)?; } + Op::PushInverted(Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash)) => { + let key_len = key_len_u8(key)?; + if value.len() < 65536 { + dest.write_all(&[0x52, key_len])?; + dest.write_all(key)?; + (value.len() as u16).encode_into(dest)?; + dest.write_all(value)?; + dest.write_all(backrefs_hash)?; + } else { + dest.write_all(&[0x53, key_len])?; + dest.write_all(key)?; + (value.len() as u32).encode_into(dest)?; + dest.write_all(value)?; + dest.write_all(backrefs_hash)?; + } + } Op::PushInverted(Node::KVRefValueHash(key, value, value_hash)) => { let key_len = key_len_u8(key)?; if value.len() < 65536 { @@ -658,6 +690,11 @@ impl Encode for Op { let header = if value.len() < 65536 { 4 } else { 6 }; header + key.len() + value.len() + HASH_LENGTH } + Op::Push(Node::KVBackwardsReferencesValueHash(key, value, _)) + | Op::PushInverted(Node::KVBackwardsReferencesValueHash(key, value, _)) => { + let header = if value.len() < 65536 { 4 } else { 6 }; + header + key.len() + value.len() + HASH_LENGTH + } Op::Push(Node::KVValueHashFeatureType(key, value, _, feature_type)) => { let header = if value.len() < 65536 { 4 } else { 6 }; header + key.len() + value.len() + HASH_LENGTH + feature_type.encoding_length()? @@ -895,6 +932,60 @@ impl Decode for Op { Self::Push(Node::KVRefValueHash(key, value, value_hash)) } + 0x50 | 0x51 => { + let key_len: u8 = Decode::decode(&mut input)?; + let mut key = vec![0; key_len as usize]; + input.read_exact(key.as_mut_slice())?; + + let value_len = if variant == 0x50 { + let len: u16 = Decode::decode(&mut input)?; + len as usize + } else { + let len: u32 = Decode::decode(&mut input)?; + if len > MAX_VALUE_LEN { + return Err(ed::Error::UnexpectedByte(0x51)); + } + len as usize + }; + let mut value = vec![0; value_len]; + input.read_exact(value.as_mut_slice())?; + + let mut backrefs_hash = [0; HASH_LENGTH]; + input.read_exact(&mut backrefs_hash)?; + + Self::Push(Node::KVBackwardsReferencesValueHash( + key, + value, + backrefs_hash, + )) + } + 0x52 | 0x53 => { + let key_len: u8 = Decode::decode(&mut input)?; + let mut key = vec![0; key_len as usize]; + input.read_exact(key.as_mut_slice())?; + + let value_len = if variant == 0x52 { + let len: u16 = Decode::decode(&mut input)?; + len as usize + } else { + let len: u32 = Decode::decode(&mut input)?; + if len > MAX_VALUE_LEN { + return Err(ed::Error::UnexpectedByte(0x53)); + } + len as usize + }; + let mut value = vec![0; value_len]; + input.read_exact(value.as_mut_slice())?; + + let mut backrefs_hash = [0; HASH_LENGTH]; + input.read_exact(&mut backrefs_hash)?; + + Self::PushInverted(Node::KVBackwardsReferencesValueHash( + key, + value, + backrefs_hash, + )) + } 0x07 => { let key_len: u8 = Decode::decode(&mut input)?; let mut key = vec![0; key_len as usize]; diff --git a/grovedb-query/src/proofs/mod.rs b/grovedb-query/src/proofs/mod.rs index 8d9601742..92c614bdb 100644 --- a/grovedb-query/src/proofs/mod.rs +++ b/grovedb-query/src/proofs/mod.rs @@ -98,6 +98,21 @@ pub enum Node { /// Contains: `(key, referenced_value, reference_element_hash)` KVRefValueHash(Vec, Vec, CryptoHash), + /// Key, the element's STRIPPED serialization (backward-references list + /// emptied), and the 32-byte hash of the serialized backward-references + /// list. For GroveDB's backward-references-capable elements + /// (`ItemWithBackwardsReferences` / `SumItemWithBackwardsReferences` / + /// `ItemWithSumItemWithBackwardsReferences`), whose node value hash is + /// `combine_hash(H(stripped_value), backrefs_hash)`. + /// + /// The verifier RECOMPUTES that combination, so the payload bytes are + /// bound by the proof (unlike `KVValueHash`, whose value bytes are + /// carried on trust) while the referrer set itself stays out of the + /// proof — only its hash travels. + /// + /// Contains: `(key, stripped_value, backward_references_hash)` + KVBackwardsReferencesValueHash(Vec, Vec, CryptoHash), + /// Key, value, and count. For queried Items in ProvableCountTree. /// /// Contains: `(key, value, count)` @@ -263,6 +278,12 @@ impl fmt::Display for Node { hex_to_ascii(value), hex::encode(value_hash) ), + Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash) => format!( + "KVBackwardsReferencesValueHash({}, {}, HASH[{}])", + hex_to_ascii(key), + hex_to_ascii(value), + hex::encode(backrefs_hash) + ), Node::KVDigest(key, value_hash) => format!( "KVDigest({}, HASH[{}])", hex_to_ascii(key), @@ -399,6 +420,39 @@ impl fmt::Display for Node { #[cfg(test)] mod tests { + + #[test] + fn backwards_references_node_display_and_codec() { + use crate::proofs::encoding::encode_into; + + let small = Node::KVBackwardsReferencesValueHash( + b"key".to_vec(), + b"stripped-value".to_vec(), + [7; 32], + ); + let shown = format!("{}", small); + assert!( + shown.contains("KVBackwardsReferencesValueHash("), + "got: {shown}" + ); + + // A value over u16::MAX bytes selects the wide length encoding. + let large = + Node::KVBackwardsReferencesValueHash(b"key".to_vec(), vec![0xAB; 70_000], [9; 32]); + + for node in [small, large] { + for op in [Op::Push(node.clone()), Op::PushInverted(node.clone())] { + let mut bytes = Vec::new(); + encode_into([op.clone()].iter(), &mut bytes); + assert_eq!(bytes.len(), ed::Encode::encoding_length(&op).unwrap()); + let decoded: Vec = Decoder::new(&bytes) + .collect::>() + .expect("decode"); + assert_eq!(decoded, vec![op]); + } + } + } + use super::*; #[test] diff --git a/grovedb-version/src/lib.rs b/grovedb-version/src/lib.rs index 805b33a27..044b378e1 100644 --- a/grovedb-version/src/lib.rs +++ b/grovedb-version/src/lib.rs @@ -21,6 +21,24 @@ macro_rules! check_grovedb_v0_with_cost { }}; } +#[macro_export] +macro_rules! check_grovedb_v1_with_cost { + ($method:expr, $version:expr) => {{ + const EXPECTED_VERSION: u16 = 1; + if $version != EXPECTED_VERSION { + return grovedb_costs::CostsExt::wrap_with_cost( + Err($crate::error::GroveVersionError::UnknownVersionMismatch { + method: $method.to_string(), + known_versions: vec![EXPECTED_VERSION], + received: $version, + } + .into()), + Default::default(), + ); + } + }}; +} + #[macro_export] macro_rules! check_grovedb_v0 { ($method:expr, $version:expr) => {{ @@ -61,7 +79,7 @@ macro_rules! check_grovedb_v0_or_v1 { const EXPECTED_VERSION_V0: u16 = 0; const EXPECTED_VERSION_V1: u16 = 1; if $version != EXPECTED_VERSION_V0 && $version != EXPECTED_VERSION_V1 { - return Err(GroveVersionError::UnknownVersionMismatch { + return Err($crate::error::GroveVersionError::UnknownVersionMismatch { method: $method.to_string(), known_versions: vec![EXPECTED_VERSION_V0, EXPECTED_VERSION_V1], received: $version, @@ -97,6 +115,34 @@ macro_rules! check_grovedb_v0_v1_or_v2 { }}; } +/// Dispatches a cost-returning method body by feature version. Unknown +/// versions return `UnknownVersionMismatch` wrapped in a `CostResult`, so +/// this belongs in functions returning `CostResult<_, _>`. +#[macro_export] +macro_rules! dispatch_version { + ($method:expr, $version:expr, $($($version_num:literal)|+ => {$($body:tt)*})+) => { + { + let version = $version; + if $($(version != $version_num)&&*)&&* { + return grovedb_costs::CostsExt::wrap_with_cost( + Err($crate::error::GroveVersionError::UnknownVersionMismatch { + method: $method.to_string(), + known_versions: vec![$($($version_num),*),*], + received: $version, + } + .into()), + Default::default(), + ); + } + + match version { + $($($version_num)|+ => {$($body)*})* + _ => unreachable!() + } + } + }; +} + #[macro_export] macro_rules! check_merk_v0_with_cost { ($method:expr, $version:expr) => {{ diff --git a/grovedb-version/src/tests.rs b/grovedb-version/src/tests.rs index 7d68f661a..8ff770e0e 100644 --- a/grovedb-version/src/tests.rs +++ b/grovedb-version/src/tests.rs @@ -516,6 +516,8 @@ fn delete_internal_on_transaction_is_legacy_until_v4() { // Reusing the already-open parent Merk for non-empty child tree deletes // (issue #686) activates at GROVE_V4; v1-v3 are live in production and // must keep the legacy reopen labeled with the child's tree type. + // GROVE_V4 selects v2: the backward-references router, whose flag-less + // calls run the exact v1 (parent-reuse) body. for v in [&GROVE_V1, &GROVE_V2, &GROVE_V3] { assert_eq!( v.grovedb_versions @@ -531,8 +533,32 @@ fn delete_internal_on_transaction_is_legacy_until_v4() { .operations .delete .delete_internal_on_transaction, + 2 + ); +} + +#[test] +fn backward_references_flows_activate_at_v4() { + // The backward-references feature (PR #345) gates on GROVE_V4: the + // insert router (v1), the delete router (v2, above), and the + // element-level insert_if_changed_value Merk-read variant (v1). All + // shipped versions stay at 0. + for v in [&GROVE_V1, &GROVE_V2, &GROVE_V3] { + assert_eq!( + v.grovedb_versions.operations.insert.insert_on_transaction, + 0 + ); + assert_eq!(v.grovedb_versions.element.insert_if_changed_value, 0); + } + assert_eq!( + GROVE_V4 + .grovedb_versions + .operations + .insert + .insert_on_transaction, 1 ); + assert_eq!(GROVE_V4.grovedb_versions.element.insert_if_changed_value, 1); } #[test] diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 2c404b4b6..2d938a630 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -241,6 +241,7 @@ pub struct GroveDBOperationsGetVersions { pub get: FeatureVersion, pub get_caching_optional: FeatureVersion, pub follow_reference: FeatureVersion, + pub ref_path_follow_reference: FeatureVersion, pub follow_reference_once: FeatureVersion, pub get_raw: FeatureVersion, pub get_raw_caching_optional: FeatureVersion, @@ -432,6 +433,20 @@ pub struct GroveDBOperationsAverageCaseVersions { /// full ommer cascade, dense-buffer recompute, and epoch compaction /// (issue #812). pub average_case_commitment_tree_insert: FeatureVersion, + /// Cost model for backward-references family ops in batch estimation. + /// + /// - `0` (V1..V3): the family is estimated like plain elements with no + /// derived fan-out, and `ReplaceBackwardReferenceFamilyMember` is + /// refused. Matches those versions' apply path, which rejects the + /// family in batches, so historical admission decisions replay + /// byte-identically. + /// - `1` (V4+): family-carrying ops and (under + /// `BatchApplyOptions::propagate_backward_references`) deletes charge + /// the derived registration / propagation / cascade fan-out, bounded + /// by the apply path's budgets (≤32 referrers per item, ≤10-hop + /// chains, 1 referrer per reference), and the derived op itself gets + /// a real model. + pub average_case_backward_references_fan_out: FeatureVersion, } #[derive(Clone, Debug, Default)] @@ -459,15 +474,18 @@ pub struct GroveDBOperationsWorstCaseVersions { /// full ommer cascade, dense-buffer recompute, and epoch compaction /// (issue #812). pub worst_case_commitment_tree_insert: FeatureVersion, + /// Cost model for backward-references family ops in batch estimation. + /// Same contract as + /// `GroveDBOperationsAverageCaseVersions::average_case_backward_references_fan_out`, + /// with the worst-case bounds charged in full. + pub worst_case_backward_references_fan_out: FeatureVersion, } #[derive(Clone, Debug, Default)] pub struct GroveDBOperationsInsertVersions { pub insert: FeatureVersion, pub insert_on_transaction: FeatureVersion, - pub insert_without_transaction: FeatureVersion, pub add_element_on_transaction: FeatureVersion, - pub add_element_without_transaction: FeatureVersion, pub insert_if_not_exists: FeatureVersion, pub insert_if_not_exists_return_existing_element: FeatureVersion, pub insert_if_changed_value: FeatureVersion, @@ -482,7 +500,6 @@ pub struct GroveDBOperationsDeleteVersions { pub delete_if_empty_tree_with_sectional_storage_function: FeatureVersion, pub delete_operation_for_delete_internal: FeatureVersion, pub delete_internal_on_transaction: FeatureVersion, - pub delete_internal_without_transaction: FeatureVersion, pub average_case_delete_operation_for_delete: FeatureVersion, pub worst_case_delete_operation_for_delete: FeatureVersion, } @@ -537,6 +554,7 @@ pub struct GroveDBElementMethodVersions { pub insert_if_not_exists: FeatureVersion, pub insert_if_not_exists_into_batch_operations: FeatureVersion, pub insert_if_changed_value: FeatureVersion, + pub insert_subtree_if_changed: FeatureVersion, pub insert_if_changed_value_into_batch_operations: FeatureVersion, pub insert_reference: FeatureVersion, pub insert_reference_into_batch_operations: FeatureVersion, diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index b5f05eae5..9a45e7195 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -63,6 +63,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { insert_if_not_exists: 0, insert_if_not_exists_into_batch_operations: 0, insert_if_changed_value: 0, + insert_subtree_if_changed: 0, insert_if_changed_value_into_batch_operations: 0, insert_reference: 0, insert_reference_into_batch_operations: 0, @@ -92,6 +93,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { get: 0, get_caching_optional: 0, follow_reference: 0, + ref_path_follow_reference: 0, get_raw: 0, get_raw_caching_optional: 0, get_raw_optional: 0, @@ -112,9 +114,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { insert: GroveDBOperationsInsertVersions { insert: 0, insert_on_transaction: 0, - insert_without_transaction: 0, add_element_on_transaction: 0, - add_element_without_transaction: 0, insert_if_not_exists: 0, insert_if_not_exists_return_existing_element: 0, insert_if_changed_value: 0, @@ -127,7 +127,6 @@ pub const GROVE_V1: GroveVersion = GroveVersion { delete_if_empty_tree_with_sectional_storage_function: 0, delete_operation_for_delete_internal: 0, delete_internal_on_transaction: 0, - delete_internal_without_transaction: 0, average_case_delete_operation_for_delete: 0, worst_case_delete_operation_for_delete: 0, }, @@ -201,6 +200,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { add_average_case_get_raw_tree_cost: 0, add_average_case_get_cost: 0, average_case_commitment_tree_insert: 0, + average_case_backward_references_fan_out: 0, }, worst_case: GroveDBOperationsWorstCaseVersions { add_worst_case_get_merk_at_path: 0, @@ -216,6 +216,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { add_worst_case_get_raw_cost: 0, add_worst_case_get_cost: 0, worst_case_commitment_tree_insert: 0, + worst_case_backward_references_fan_out: 0, }, // PrivateDocumentStore is unavailable before GROVE_V4: every // slot is 0 and the operations fail closed. diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index 3ed8c3fd3..ffaf1e69a 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -63,6 +63,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { insert_if_not_exists: 0, insert_if_not_exists_into_batch_operations: 0, insert_if_changed_value: 0, + insert_subtree_if_changed: 0, insert_if_changed_value_into_batch_operations: 0, insert_reference: 0, insert_reference_into_batch_operations: 0, @@ -92,6 +93,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { get: 0, get_caching_optional: 0, follow_reference: 0, + ref_path_follow_reference: 0, get_raw: 0, get_raw_caching_optional: 0, get_raw_optional: 0, @@ -112,9 +114,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { insert: GroveDBOperationsInsertVersions { insert: 0, insert_on_transaction: 0, - insert_without_transaction: 0, add_element_on_transaction: 0, - add_element_without_transaction: 0, insert_if_not_exists: 0, insert_if_not_exists_return_existing_element: 0, insert_if_changed_value: 0, @@ -127,7 +127,6 @@ pub const GROVE_V2: GroveVersion = GroveVersion { delete_if_empty_tree_with_sectional_storage_function: 0, delete_operation_for_delete_internal: 0, delete_internal_on_transaction: 0, - delete_internal_without_transaction: 0, average_case_delete_operation_for_delete: 0, worst_case_delete_operation_for_delete: 0, }, @@ -201,6 +200,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { add_average_case_get_raw_tree_cost: 0, add_average_case_get_cost: 0, average_case_commitment_tree_insert: 0, + average_case_backward_references_fan_out: 0, }, worst_case: GroveDBOperationsWorstCaseVersions { add_worst_case_get_merk_at_path: 0, @@ -216,6 +216,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { add_worst_case_get_raw_cost: 0, add_worst_case_get_cost: 0, worst_case_commitment_tree_insert: 0, + worst_case_backward_references_fan_out: 0, }, // PrivateDocumentStore is unavailable before GROVE_V4: every // slot is 0 and the operations fail closed. diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index 364fc34bb..0453d7f45 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -63,6 +63,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { insert_if_not_exists: 0, insert_if_not_exists_into_batch_operations: 0, insert_if_changed_value: 0, + insert_subtree_if_changed: 0, insert_if_changed_value_into_batch_operations: 0, insert_reference: 0, insert_reference_into_batch_operations: 0, @@ -92,6 +93,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { get: 0, get_caching_optional: 0, follow_reference: 0, + ref_path_follow_reference: 0, get_raw: 0, get_raw_caching_optional: 0, get_raw_optional: 0, @@ -112,13 +114,11 @@ pub const GROVE_V3: GroveVersion = GroveVersion { insert: GroveDBOperationsInsertVersions { insert: 0, insert_on_transaction: 0, - insert_without_transaction: 0, // v1: non-batch insert writes CountSumTree / ProvableCountTree / // ProvableCountSumTree as layered subtrees, consistent with the // batch path. GROVE_V1 / GROVE_V2 keep v0 (Op::Put) to preserve // the protocol-v11 consensus root (testnet block 245,344). add_element_on_transaction: 1, - add_element_without_transaction: 0, insert_if_not_exists: 0, insert_if_not_exists_return_existing_element: 0, insert_if_changed_value: 0, @@ -131,7 +131,6 @@ pub const GROVE_V3: GroveVersion = GroveVersion { delete_if_empty_tree_with_sectional_storage_function: 0, delete_operation_for_delete_internal: 0, delete_internal_on_transaction: 0, - delete_internal_without_transaction: 0, average_case_delete_operation_for_delete: 0, worst_case_delete_operation_for_delete: 0, }, @@ -205,6 +204,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { add_average_case_get_raw_tree_cost: 0, add_average_case_get_cost: 0, average_case_commitment_tree_insert: 0, + average_case_backward_references_fan_out: 0, }, worst_case: GroveDBOperationsWorstCaseVersions { add_worst_case_get_merk_at_path: 0, @@ -220,6 +220,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { add_worst_case_get_raw_cost: 0, add_worst_case_get_cost: 0, worst_case_commitment_tree_insert: 0, + worst_case_backward_references_fan_out: 0, }, // PrivateDocumentStore is unavailable before GROVE_V4: every // slot is 0 and the operations fail closed. diff --git a/grovedb-version/src/version/v4.rs b/grovedb-version/src/version/v4.rs index 88b56c5b2..0d58f6cda 100644 --- a/grovedb-version/src/version/v4.rs +++ b/grovedb-version/src/version/v4.rs @@ -151,6 +151,17 @@ //! admission bound: raising it ungated would make already-committed //! shield transitions re-validate as under-funded and brick sync. //! +//! - `operations.average_case.average_case_backward_references_fan_out: 1` +//! and `operations.worst_case.worst_case_backward_references_fan_out: 1` +//! — batch estimation charges the backward-references family's derived +//! fan-out (registration, chain propagation, cascade deletion), bounded +//! by the apply path's budgets (≤32 referrers per item, ≤10-hop chains, +//! 1 referrer per reference), and models the internal +//! `ReplaceBackwardReferenceFamilyMember` op. V1..V3 keep estimating the +//! family as plain elements (their apply path rejects it in batches, so +//! the legacy figures were never admission-relevant) — preserved for +//! replay. +//! //! - `bulk_append_tree_versions.cost.append_storage_accounting: 1` and //! `commitment_tree_versions.cost.frontier_save_storage_accounting: 1` — //! the append-only family (`BulkAppendTree`, `CommitmentTree`, @@ -307,7 +318,10 @@ pub const GROVE_V4: GroveVersion = GroveVersion { insert_into_batch_operations: 0, insert_if_not_exists: 0, insert_if_not_exists_into_batch_operations: 0, - insert_if_changed_value: 0, + // v1: reads the previous value through the Merk tree (sees + // uncommitted MerkCache state) instead of committed storage. + insert_if_changed_value: 1, + insert_subtree_if_changed: 0, insert_if_changed_value_into_batch_operations: 0, insert_reference: 0, insert_reference_into_batch_operations: 0, @@ -344,6 +358,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { get: 0, get_caching_optional: 0, follow_reference: 0, + ref_path_follow_reference: 0, get_raw: 0, get_raw_caching_optional: 0, get_raw_optional: 0, @@ -363,8 +378,10 @@ pub const GROVE_V4: GroveVersion = GroveVersion { }, insert: GroveDBOperationsInsertVersions { insert: 0, - insert_on_transaction: 0, - insert_without_transaction: 0, + // v1: backward-references router. Calls that neither insert a + // BidirectionalReference nor set + // propagate_backward_references run the exact v0 body. + insert_on_transaction: 1, // v2: a directly inserted Reference binds the value hash of its // terminal's STORED bytes (wrapper included for a NonCounted // terminal), matching what the batch reference resolver has @@ -380,7 +397,6 @@ pub const GROVE_V4: GroveVersion = GroveVersion { // element, matching the batch resolver; v1 accepted it and // committed a hash that does not bind the subtree's contents. add_element_on_transaction: 2, - add_element_without_transaction: 0, insert_if_not_exists: 0, insert_if_not_exists_return_existing_element: 0, insert_if_changed_value: 0, @@ -397,8 +413,9 @@ pub const GROVE_V4: GroveVersion = GroveVersion { // with the child's tree type (issue #686). v0 (GROVE_V1..V3) // keeps the legacy reopen byte-for-byte for replay // compatibility. - delete_internal_on_transaction: 1, - delete_internal_without_transaction: 0, + // v2: backward-references router on top of v1 — flag-less + // calls run the exact v1 body. + delete_internal_on_transaction: 2, average_case_delete_operation_for_delete: 0, worst_case_delete_operation_for_delete: 0, }, @@ -472,6 +489,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { add_average_case_get_raw_tree_cost: 0, add_average_case_get_cost: 0, average_case_commitment_tree_insert: 1, + average_case_backward_references_fan_out: 1, }, worst_case: GroveDBOperationsWorstCaseVersions { add_worst_case_get_merk_at_path: 0, @@ -487,6 +505,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { add_worst_case_get_raw_cost: 0, add_worst_case_get_cost: 0, worst_case_commitment_tree_insert: 1, + worst_case_backward_references_fan_out: 1, }, // PrivateDocumentStore activates in GROVE_V4. private_document_store: GroveDBOperationsPrivateDocumentStoreVersions { diff --git a/grovedb/src/batch/backward_references.rs b/grovedb/src/batch/backward_references.rs new file mode 100644 index 000000000..fa5008ef4 --- /dev/null +++ b/grovedb/src/batch/backward_references.rs @@ -0,0 +1,1150 @@ +//! The backward-references batch preprocessor (batching milestones M2–M4). +//! +//! When [`super::BatchApplyOptions::propagate_backward_references`] is set, +//! user operations touching the backward-references family — the three ITEM +//! variants and `BidirectionalReference` itself — expand into the derived +//! operations the live flagged flow would perform. The decisions come from +//! the shared semantic core in +//! [`crate::bidirectional_references::semantics`] — the same planners the +//! `MerkCache` driver uses — so live and batched semantics cannot drift. +//! +//! # Sequential simulation over an overlay +//! +//! A batch is an unordered set (one op per position), but the family's +//! bookkeeping is inherently sequential: a reference can target an element +//! the same batch creates, and an overwrite's propagation must see earlier +//! registrations. The preprocessor therefore simulates ONE canonical +//! sequential order and expands the batch into the ops that produce exactly +//! that order's outcome: +//! +//! 1. every non-reference op, in user order (staging its effect into an +//! overlay of pending position states and planning item-family and +//! bidi-position bookkeeping against DB-plus-overlay), then +//! 2. every `BidirectionalReference` op, in topological order — targets +//! before their referrers — so in-batch chains resolve, registrations +//! land on pending elements, and the hop/component budgets are validated +//! against the prospective POST-batch state. +//! +//! Planners read through [`OverlayChainStore`]: staged pending state first, +//! the transaction's pre-batch DB state otherwise. +//! +//! Derived writes carry their final node value hash (the two-layer +//! combine), computed here exactly as the live applier computes it, and +//! execute through [`super::GroveOp::ReplaceBackwardReferenceFamilyMember`]. +//! A user `BidirectionalReference` op is itself converted into that derived +//! form (its end hash resolved against the overlay); an identical-edge +//! re-insert converts into nothing, mirroring the live no-op. +//! +//! # Conflict rules (milestone M4) — fail closed +//! +//! Derived mutations merge into the batch only where the semantics are +//! unambiguous; everything else is an error: +//! +//! - a bidirectional reference inserted in the same batch that deletes its +//! target → error (checked explicitly for the first hop; deeper links +//! surface as missing-reference resolution errors); +//! - a cascade deletion hitting a position another op writes or deletes → +//! error; +//! - a propagation/registration rewrite hitting a position whose user op is +//! a delete or a `RefreshReference` → error; +//! - `RefreshReference` on a position holding a bidirectional reference → +//! rejected (re-insert the reference through a flagged op instead); +//! - registrations onto targets written in the same batch merge into the +//! target op's element — but ONLY into an already-processed, +//! guaranteed-to-execute family write (`InsertIfNotExists` over an +//! existing key writes nothing and is dropped outright, so no rewrite +//! can be folded into an op that never lands); +//! - a rewrite hitting a write op LATER in the canonical order is kept as +//! a derived op and superseded when that op's own turn comes — its +//! processing drops the pending derived op and plans the bookkeeping its +//! own overwrite requires, preserving sequential semantics. + +use std::{ + cell::RefCell, + collections::{BTreeMap, HashMap, HashSet}, +}; + +use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; +use grovedb_merk::{ + element::{get::ElementFetchFromStorageExtensions, ElementExt}, + CryptoHash, +}; +use grovedb_path::SubtreePath; +use grovedb_version::version::GroveVersion; + +use super::{key_info::KeyInfo, GroveOp, KeyInfoPath, QualifiedGroveDbOp}; +use crate::{ + bidirectional_references::semantics::{ + plan_element_update, plan_reference_insertion, ChainStore, DerivedMutation, Position, + ResolvedPosition, + }, + operations::get::MAX_REFERENCE_HOPS, + reference_path::{path_from_reference_path_type, ReferencePathType}, + util::TxRef, + Element, Error, GroveDb, Transaction, +}; + +/// [`ChainStore`] over the batch's prospective state: an overlay of staged +/// pending position states (what the batch has decided each position will +/// hold) falling back to the database at the batch's transaction snapshot. +/// Hashes are derived from element bytes via the logical-hash convention, +/// so no merk node reads are required. +pub(super) struct OverlayChainStore<'db, 'g> { + db: &'g GroveDb, + tx: &'db Transaction<'db>, + version: &'g GroveVersion, + /// Staged pending state: `Some(element)` = the batch writes this, + /// `None` = the batch deletes the position. Absent = untouched. + overlay: RefCell>>, + /// Qualified paths of subtrees whose prospective content is defined by + /// the batch alone: a tree element written where committed storage held + /// no tree. Reads beneath them must not touch committed storage — the + /// staged parent does not exist there yet, and opening it would fail + /// the whole (otherwise valid) batch. + fresh_subtrees: RefCell>>>, + /// DECLARED final edges of `BidirectionalReference` ops deferred to + /// pass 2 and not yet planned: the prospective component budget must + /// validate against these (a stored ancestor's budget may be raised, + /// or its edge retargeted away, in the same unordered batch). Entries + /// are removed as their ops get planned (or dissolve), after which the + /// overlay carries the authoritative staged state. + pending_references: RefCell)>>, +} + +impl<'db, 'g> OverlayChainStore<'db, 'g> { + fn new(db: &'g GroveDb, tx: &'db Transaction<'db>, version: &'g GroveVersion) -> Self { + Self { + db, + tx, + version, + overlay: RefCell::new(HashMap::new()), + fresh_subtrees: RefCell::new(HashSet::new()), + pending_references: RefCell::new(HashMap::new()), + } + } + + /// Record the declared final edge of a pass-2 reference op. + fn stage_pending_reference( + &self, + position: Position, + forward: ReferencePathType, + max_hop: Option, + ) { + self.pending_references + .borrow_mut() + .insert(position, (forward, max_hop)); + } + + /// Remove a pending declaration once its op is planned or dissolves. + fn clear_pending_reference(&self, position: &Position) { + self.pending_references.borrow_mut().remove(position); + } + + /// Stage the pending state of a position. + fn stage(&self, position: Position, element: Option) { + self.overlay.borrow_mut().insert(position, element); + } + + /// Record that the subtree at `qualified` is created by this batch with + /// no committed counterpart (fresh — its prospective content is only + /// what later ops stage under it). + fn stage_fresh_subtree(&self, qualified: Vec>) { + self.fresh_subtrees.borrow_mut().insert(qualified); + } + + /// Whether `path` lies at or below a batch-created fresh subtree. + fn under_fresh_subtree(&self, path: &[Vec]) -> bool { + let fresh = self.fresh_subtrees.borrow(); + (1..=path.len()).any(|i| fresh.contains(&path[..i])) + } + + /// Whether the subtree at `qualified` holds any prospective content: + /// positions the batch stages under it, or committed elements (unless + /// the subtree is fresh, in which case committed storage has nothing). + fn subtree_has_content(&self, qualified: &[Vec]) -> CostResult { + let staged_content = self.overlay.borrow().iter().any(|((path, _), element)| { + element.is_some() + && path.len() >= qualified.len() + && path[..qualified.len()] == *qualified + }); + if staged_content { + return Ok(true).wrap_with_cost(OperationCost::default()); + } + if self.under_fresh_subtree(qualified) || self.fresh_subtrees.borrow().contains(qualified) { + return Ok(false).wrap_with_cost(OperationCost::default()); + } + let mut cost = OperationCost::default(); + let path_slices: Vec<&[u8]> = qualified.iter().map(|p| p.as_slice()).collect(); + let merk = cost_return_on_error!( + &mut cost, + self.db.open_transactional_merk_at_path( + SubtreePath::from(path_slices.as_slice()), + self.tx, + None, + self.version, + ) + ); + let is_empty = merk.is_empty_tree().unwrap_add_cost(&mut cost); + Ok(!is_empty).wrap_with_cost(cost) + } + + fn resolve_position( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + cost: &mut OperationCost, + ) -> Result<(Position, Element), Error> { + let qualified = path_from_reference_path_type(reference_path, path, Some(key))?; + let (target_key, target_path) = qualified + .split_last() + .ok_or(Error::CorruptedPath("empty reference".to_string()))?; + let position = (target_path.to_vec(), target_key.clone()); + let element = self + .element_at(&position.0, &position.1) + .unwrap_add_cost(cost)? + .ok_or_else(|| { + Error::CorruptedReferencePathKeyNotFound(format!( + "batch backward-references expansion: missing element at {}", + hex::encode(target_key) + )) + })?; + Ok((position, element)) + } +} + +impl<'db, 'g> ChainStore for OverlayChainStore<'db, 'g> { + fn element_at(&self, path: &[Vec], key: &[u8]) -> CostResult, Error> { + if let Some(staged) = self + .overlay + .borrow() + .get(&(path.to_vec(), key.to_vec())) + .cloned() + { + return Ok(staged).wrap_with_cost(OperationCost::default()); + } + // Below a subtree this batch creates, committed storage has nothing + // — not even the parent tree. Everything that exists there is in + // the overlay (checked above). + if self.under_fresh_subtree(path) { + return Ok(None).wrap_with_cost(OperationCost::default()); + } + let mut cost = OperationCost::default(); + let path_slices: Vec<&[u8]> = path.iter().map(|p| p.as_slice()).collect(); + let merk = cost_return_on_error!( + &mut cost, + self.db.open_transactional_merk_at_path( + SubtreePath::from(path_slices.as_slice()), + self.tx, + None, + self.version, + ) + ); + Element::get_optional(&merk, key, true, self.version) + .map_err(Error::MerkError) + .add_cost(cost) + } + + fn resolve_once( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + let mut cost = OperationCost::default(); + let result = self + .resolve_position(path, key, reference_path, &mut cost) + .and_then(|((path, key), element)| { + let node_value_hash = element + .logical_value_hash(self.version) + .unwrap_add_cost(&mut cost)?; + Ok(ResolvedPosition { + path, + key, + element, + node_value_hash, + hops: 1, + }) + }); + result.wrap_with_cost(cost) + } + + fn resolve_chain( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + let mut cost = OperationCost::default(); + let mut visited: HashSet = Default::default(); + visited.insert((path.to_vec(), key.to_vec())); + let mut current = (path.to_vec(), key.to_vec(), reference_path); + let mut hops = 0usize; + loop { + hops += 1; + if hops > MAX_REFERENCE_HOPS { + return Err(Error::ReferenceLimit).wrap_with_cost(cost); + } + let (position, element) = + match self.resolve_position(¤t.0, ¤t.1, current.2.clone(), &mut cost) { + Ok(resolved) => resolved, + Err(e) => return Err(e).wrap_with_cost(cost), + }; + if !visited.insert(position.clone()) { + return Err(Error::CyclicReference).wrap_with_cost(cost); + } + match element { + Element::BidirectionalReference(reference, _) => { + current = (position.0, position.1, reference.forward_reference_path); + } + Element::Reference(reference_path, ..) + | Element::ReferenceWithSumItem(reference_path, ..) => { + current = (position.0, position.1, reference_path); + } + element => { + let node_value_hash = match element + .logical_value_hash(self.version) + .unwrap_add_cost(&mut cost) + { + Ok(hash) => hash, + Err(e) => return Err(e.into()).wrap_with_cost(cost), + }; + return Ok(ResolvedPosition { + path: position.0, + key: position.1, + element, + node_value_hash, + hops, + }) + .wrap_with_cost(cost); + } + } + } + } + + fn version(&self) -> &GroveVersion { + self.version + } + + fn pending_reference_at( + &self, + path: &[Vec], + key: &[u8], + ) -> Option<(ReferencePathType, Option)> { + self.pending_references + .borrow() + .get(&(path.to_vec(), key.to_vec())) + .cloned() + } +} + +fn is_family_item(element: &Element) -> bool { + matches!( + element, + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + ) +} + +/// The node value hash a derived write installs: for a bidirectional +/// reference `combine(combined, end_hash)`, for the item variants the +/// element's own combined hash — exactly what the live applier's writes +/// produce. +fn derived_node_value_hash( + element: &Element, + end_hash: Option, + version: &GroveVersion, +) -> CostResult { + let mut cost = OperationCost::default(); + let hashes = match element + .backward_references_hashes(version) + .unwrap_add_cost(&mut cost) + { + Ok(Some(hashes)) => hashes, + Ok(None) => { + return Err(Error::CorruptedCodeExecution( + "derived write for an element outside the backward-references family", + )) + .wrap_with_cost(cost) + } + Err(e) => return Err(e.into()).wrap_with_cost(cost), + }; + match (element, end_hash) { + (Element::BidirectionalReference(..), Some(end_hash)) => { + let combined = grovedb_merk::tree::hash::combine_hash(&hashes.combined, &end_hash) + .unwrap_add_cost(&mut cost); + Ok(combined).wrap_with_cost(cost) + } + (Element::BidirectionalReference(..), None) => Err(Error::CorruptedCodeExecution( + "a derived bidirectional-reference rewrite requires its end hash", + )) + .wrap_with_cost(cost), + _ => Ok(hashes.combined).wrap_with_cost(cost), + } +} + +/// The expansion state: the user ops (slots emptied when an op is dropped +/// or converted), the derived ops keyed by position, and the overlay store +/// everything is planned against. +struct Expansion<'db, 'g> { + store: OverlayChainStore<'db, 'g>, + /// User ops; a slot becomes `None` when the op is dropped (an inert + /// insert, an identical-edge no-op) or converted into a derived op. + ops: Vec>, + /// Position of every KEYED user op, whether retained or not. + user_index_by_position: HashMap, + /// Positions the user's own ops delete (used for the explicit + /// ref-plus-target-delete conflict rule). + user_deleted_positions: HashSet, + /// Derived ops by position; a write is upserted (recomputed hash), a + /// cascade delete replaces a pending derived write. + derived: BTreeMap, + validate_insertion_does_not_override: bool, +} + +impl<'db, 'g> Expansion<'db, 'g> { + fn op_position(op: &QualifiedGroveDbOp) -> Option { + op.key + .as_ref() + .map(|key| (op.path.to_path(), key.get_key_clone())) + } + + /// Apply a plan's mutations: stage each into the overlay and merge it + /// into the batch per the M4 conflict rules. `current_index` is the + /// position in the canonical sequential order of the op whose plan + /// these mutations come from (`usize::MAX` in pass 2, where every + /// non-reference op has been processed). + fn apply_mutations( + &mut self, + mutations: Vec, + current_index: usize, + ) -> CostResult<(), Error> { + let mut cost = OperationCost::default(); + for mutation in mutations { + match mutation { + DerivedMutation::Write { + path, + key, + element, + end_hash, + .. + } => { + let position = (path.clone(), key.clone()); + let retained_user_op = self + .user_index_by_position + .get(&position) + .copied() + .filter(|i| self.ops[*i].is_some()); + // A rewrite may fold into the user op's payload ONLY + // when that op has already been processed in the + // canonical order, is guaranteed to execute, and holds + // a family element the rewrite semantically extends + // (op element + registration/cleanup). Everything else + // stays a separate derived op: + // - an unprocessed op (a later overwrite, or a pass-2 + // BidirectionalReference) supersedes the rewrite when + // its own turn comes — folding would either discard + // the caller's payload or resurrect a write the + // caller replaced; + // - conditional inserts that turned out not to execute + // are dropped at processing time, so a retained op + // here always writes. + // + // The CURRENTLY processed op counts as processed + // (`<=`): its own plan may clean a stale referrer + // entry off the very element it writes (a dangling + // registration on the overwritten family item), and + // that cleanup must fold into the op rather than + // become a second op on the same position. + let mergeable_into_user_op = retained_user_op + .filter(|&index| index <= current_index) + .filter(|&index| { + let user_op = self.ops[index].as_ref().expect("retained above"); + match &user_op.op { + GroveOp::InsertOrReplace { + element: op_element, + } + | GroveOp::Replace { + element: op_element, + } + | GroveOp::Patch { + element: op_element, + .. + } + | GroveOp::InsertIfNotExists { + element: op_element, + .. + } + | GroveOp::InsertWithKnownToNotAlreadyExist { + element: op_element, + } => is_family_item(op_element), + _ => false, + } + }); + if let Some(index) = mergeable_into_user_op { + let user_op = self.ops[index].as_mut().expect("retained above"); + match &mut user_op.op { + GroveOp::InsertOrReplace { + element: op_element, + } + | GroveOp::Replace { + element: op_element, + } + | GroveOp::Patch { + element: op_element, + .. + } + | GroveOp::InsertIfNotExists { + element: op_element, + .. + } + | GroveOp::InsertWithKnownToNotAlreadyExist { + element: op_element, + } => *op_element = element.clone(), + _ => unreachable!("filtered to family-write kinds above"), + } + self.store.stage(position, Some(element)); + } else if let Some(index) = retained_user_op { + // Retained but not mergeable: allowed only for + // write kinds whose own processing supersedes this + // rewrite (unprocessed writes and pass-2 reference + // ops). Deletes and refreshes stay fail-closed. + let user_op = self.ops[index].as_ref().expect("retained above"); + let (colliding_write, payload_is_bidi) = match &user_op.op { + GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::Patch { element, .. } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => { + (true, matches!(element, Element::BidirectionalReference(..))) + } + _ => (false, false), + }; + // A BidirectionalReference payload is processed in + // pass 2 whatever its index, so it always counts as + // still-to-come here. + let processed = index < current_index && !payload_is_bidi; + if !colliding_write || processed { + return Err(Error::InvalidBatchOperation( + "a derived backward-references rewrite conflicts with \ + another operation in the batch", + )) + .wrap_with_cost(cost); + } + let node_value_hash = cost_return_on_error!( + &mut cost, + derived_node_value_hash(&element, end_hash, self.store.version) + ); + self.derived.insert( + position.clone(), + QualifiedGroveDbOp { + path: KeyInfoPath::from_known_owned_path(path), + key: Some(KeyInfo::KnownKey(key)), + op: GroveOp::ReplaceBackwardReferenceFamilyMember { + element: element.clone(), + node_value_hash, + end_hash, + }, + }, + ); + self.store.stage(position, Some(element)); + } else { + let node_value_hash = cost_return_on_error!( + &mut cost, + derived_node_value_hash(&element, end_hash, self.store.version) + ); + self.derived.insert( + position.clone(), + QualifiedGroveDbOp { + path: KeyInfoPath::from_known_owned_path(path), + key: Some(KeyInfo::KnownKey(key)), + op: GroveOp::ReplaceBackwardReferenceFamilyMember { + element: element.clone(), + node_value_hash, + end_hash, + }, + }, + ); + self.store.stage(position, Some(element)); + } + } + DerivedMutation::Delete { path, key } => { + let position = (path.clone(), key.clone()); + let touched_by_user = self + .user_index_by_position + .get(&position) + .copied() + .filter(|i| self.ops[*i].is_some()) + .is_some(); + if touched_by_user { + // M4: a cascade may not delete a position another + // op in the batch touches — whether it writes it + // (order-dependent outcome) or deletes it + // (double delete). + return Err(Error::InvalidBatchOperation( + "a backward-references cascade would delete a position another \ + operation in the batch touches", + )) + .wrap_with_cost(cost); + } + self.derived + .insert(position.clone(), QualifiedGroveDbOp::delete_op(path, key)); + self.store.stage(position, None); + } + } + } + Ok(()).wrap_with_cost(cost) + } +} + +/// Expand `ops` with the derived operations the backward-references rules +/// require, per the module documentation. +pub(super) fn expand_backward_references_ops( + db: &GroveDb, + tx: &TxRef<'_, '_>, + ops: Vec, + validate_insertion_does_not_override: bool, + grove_version: &GroveVersion, +) -> CostResult, Error> { + let mut cost = OperationCost::default(); + + let mut expansion = Expansion { + store: OverlayChainStore::new(db, tx.as_ref(), grove_version), + user_index_by_position: HashMap::new(), + user_deleted_positions: HashSet::new(), + derived: BTreeMap::new(), + validate_insertion_does_not_override, + ops: Vec::new(), + }; + + for (index, op) in ops.iter().enumerate() { + if let Some(position) = Expansion::op_position(op) { + // Consistency checking has already rejected duplicate + // positions; a stray duplicate would silently lose an op here, + // so refuse it outright. + if expansion + .user_index_by_position + .insert(position.clone(), index) + .is_some() + { + return Err(Error::InvalidBatchOperation( + "batch operations fail consistency checks", + )) + .wrap_with_cost(cost); + } + if matches!(op.op, GroveOp::Delete | GroveOp::DeleteTree(..)) { + expansion.user_deleted_positions.insert(position); + } + } + } + expansion.ops = ops.into_iter().map(Some).collect(); + + // Fresh-subtree pre-scan: a tree written where committed storage holds + // no tree defines a subtree whose prospective content exists only in + // the overlay. Marked BEFORE any op processing — shallowest paths + // first, so a nested new tree sees its parent already marked — because + // the batch is unordered: an op under such a subtree may appear before + // the op creating it, and its previous-state read must not touch + // committed storage (the parent does not exist there). + let mut tree_write_positions: Vec = expansion + .ops + .iter() + .flatten() + .filter_map(|op| match &op.op { + GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::Patch { element, .. } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } + if element.is_any_tree() => + { + Expansion::op_position(op) + } + _ => None, + }) + .collect(); + tree_write_positions.sort_by_key(|(path, _)| path.len()); + for (path, key) in tree_write_positions { + let previous_is_tree = if expansion.store.under_fresh_subtree(&path) { + false + } else { + cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)) + .map(|p| p.is_any_tree()) + .unwrap_or(false) + }; + if !previous_is_tree { + let mut qualified = path; + qualified.push(key); + expansion.store.stage_fresh_subtree(qualified); + } + } + + // Pass 1: every non-reference op in user order. Each op's effect is + // staged into the overlay; item-family and bidi-position bookkeeping is + // planned against DB-plus-overlay. `BidirectionalReference` ops are + // deferred to pass 2. + let mut bidi_op_indices: Vec = Vec::new(); + + for index in 0..expansion.ops.len() { + let Some((path, key, op_kind)) = expansion.ops[index].as_ref().and_then(|op| { + op.key + .as_ref() + .map(|k| (op.path.to_path(), k.get_key_clone(), op.op.clone())) + }) else { + continue; + }; + let position = (path.clone(), key.clone()); + + match &op_kind { + GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::Patch { element, .. } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => { + if let Element::BidirectionalReference(reference, _) = element { + // A conditional insert whose gate will SKIP it must not + // advertise a pending edge: `InsertIfNotExists` over an + // existing position writes nothing, so the STORED edge + // stays authoritative for prospective-component checks. + // (One op per position, and cascades refuse user-op + // positions, so existence here is stable through + // planning.) An erroring gate fails the whole batch + // anyway, making its staleness irrelevant. + let will_write = if matches!( + op_kind, + GroveOp::InsertIfNotExists { .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { .. } + ) { + cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)) + .is_none() + } else { + true + }; + if will_write { + expansion.store.stage_pending_reference( + position.clone(), + reference.forward_reference_path.clone(), + reference.max_hop, + ); + } + bidi_op_indices.push(index); + continue; + } + let mut element = element.clone(); + let writes_over_existing = matches!( + op_kind, + GroveOp::InsertOrReplace { .. } + | GroveOp::Replace { .. } + | GroveOp::Patch { .. } + ); + let (is_insert_if_not_exists, error_if_exists) = match &op_kind { + GroveOp::InsertIfNotExists { + error_if_exists, .. + } => (true, *error_if_exists), + _ => (false, false), + }; + let is_known_new = + matches!(op_kind, GroveOp::InsertWithKnownToNotAlreadyExist { .. }); + + let previous = + cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)); + + if is_family_item(&element) { + // The stored referrer list is authoritative; whatever + // the caller supplied is not theirs to claim. + if let Some(refs) = element.backward_references_mut() { + *refs = previous + .as_ref() + .and_then(|p| p.backward_references()) + .map(|p| p.to_vec()) + .unwrap_or_default(); + } + // Update the op in place so execution writes the + // authoritative list. + if let Some(user_op) = expansion.ops[index].as_mut() { + match &mut user_op.op { + GroveOp::InsertOrReplace { element: e } + | GroveOp::Replace { element: e } + | GroveOp::Patch { element: e, .. } + | GroveOp::InsertIfNotExists { element: e, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element: e } => { + *e = element.clone(); + } + _ => unreachable!("matched an insert variant above"), + } + } + } + + if let Some(previous) = previous { + if is_insert_if_not_exists { + if error_if_exists || expansion.validate_insertion_does_not_override { + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + // InsertIfNotExists over an existing key writes + // nothing — for ANY payload. Drop the op entirely + // so a later derived rewrite of the position (e.g. + // a registration on the stored element) lands as a + // derived op instead of being folded into an op + // that never executes and silently swallowed. + expansion.ops[index] = None; + continue; + } + if is_known_new && is_family_item(&element) { + // The caller's not-exists assertion is false. The + // plain-element path skips the existence check by + // design, but a blind family overwrite would skip + // the bookkeeping below — refuse instead. + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + if !writes_over_existing { + continue; + } + + let previous_needs_bookkeeping = + matches!(previous, Element::BidirectionalReference(..)) + || previous + .backward_references() + .map(|refs| !refs.is_empty()) + .unwrap_or(false); + expansion + .store + .stage(position.clone(), Some(element.clone())); + // This write comes LATER in the canonical order than + // any derived rewrite already recorded for the + // position (an earlier op's propagation): the write + // supersedes it, and this op's own planning below + // handles the bookkeeping the displaced element needs. + expansion.derived.remove(&position); + if !previous_needs_bookkeeping { + continue; + } + if element == previous { + // No logical change (the referrer list was just + // merged from the stored element, so the comparison + // covers the full stored form): nothing propagates — + // mirroring the live flow's delta gate. + continue; + } + let plan = cost_return_on_error!( + &mut cost, + plan_element_update( + &expansion.store, + &path, + &key, + previous, + Some(element.clone()) + ) + ); + cost_return_on_error!( + &mut cost, + expansion.apply_mutations(plan.mutations, index) + ); + } else { + // Fresh insert: no bookkeeping (fresh family items get + // their empty authoritative list above). + expansion.store.stage(position, Some(element)); + } + } + GroveOp::Delete | GroveOp::DeleteTree(..) => { + let previous = + cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)); + // Deleting a NON-EMPTY subtree is refused under the flag: + // its descendants may hold bidirectional-reference + // participants whose external registrations, cascade + // consents, and surviving referrers the batch engine's + // wholesale clearing would silently skip. The live flagged + // delete walks descendants with full bookkeeping — use it, + // or empty the subtree first. + if previous.as_ref().map(|p| p.is_any_tree()).unwrap_or(false) { + let mut qualified = path.clone(); + qualified.push(key.clone()); + let non_empty = cost_return_on_error!( + &mut cost, + expansion.store.subtree_has_content(&qualified) + ); + if non_empty { + return Err(Error::NotSupported( + "deleting a non-empty subtree in a batch with \ + propagate_backward_references is not supported; delete it through \ + the live flagged flow (which cascades descendants) or empty it \ + first" + .to_owned(), + )) + .wrap_with_cost(cost); + } + } + expansion.store.stage(position, None); + let Some(previous) = previous else { continue }; + let needs_bookkeeping = matches!(previous, Element::BidirectionalReference(..)) + || (is_family_item(&previous) + && previous + .backward_references() + .map(|refs| !refs.is_empty()) + .unwrap_or(false)); + if !needs_bookkeeping { + continue; + } + let plan = cost_return_on_error!( + &mut cost, + plan_element_update(&expansion.store, &path, &key, previous, None) + ); + cost_return_on_error!(&mut cost, expansion.apply_mutations(plan.mutations, index)); + } + GroveOp::RefreshReference { + reference_path_type, + max_reference_hop, + mode, + flags, + .. + } => { + let previous = + cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)); + if matches!(previous, Some(Element::BidirectionalReference(..))) { + // M4: refreshing a bidirectional reference is rejected — + // a refresh rewrites the node without the registration / + // propagation bookkeeping. Re-insert the reference + // through a flagged op instead. + return Err(Error::NotSupported( + "RefreshReference cannot target a bidirectional reference; re-insert \ + the reference through a flagged batch operation instead" + .to_owned(), + )) + .wrap_with_cost(cost); + } + // Trusted variants overwrite the stored path; stage the + // rebuilt shape so later resolutions follow the new edge. + // Untrusted variants keep the stored path — the DB state + // is already what resolution should see. + use super::RefreshReferenceMode; + match mode { + RefreshReferenceMode::PlainReferenceTrusted => { + expansion.store.stage( + position, + Some(Element::Reference( + reference_path_type.clone(), + *max_reference_hop, + flags.clone(), + )), + ); + } + RefreshReferenceMode::SumItemReferenceTrusted(sum) => { + expansion.store.stage( + position, + Some(Element::ReferenceWithSumItem( + reference_path_type.clone(), + *max_reference_hop, + *sum, + flags.clone(), + )), + ); + } + _ => {} + } + } + _ => {} + } + } + + // Pass 2: `BidirectionalReference` ops, targets before referrers, so + // in-batch chains resolve through the overlay and every insertion's + // component budget is measured against the prospective post-batch + // state. (Cycles among pending references get an arbitrary order; their + // planning then fails on resolution, which is the right outcome.) + let ordered_bidi_ops = order_reference_ops_targets_first(&expansion.ops, &bidi_op_indices); + + for index in ordered_bidi_ops { + let Some((path, key, op_kind)) = expansion.ops[index].as_ref().and_then(|op| { + op.key + .as_ref() + .map(|k| (op.path.to_path(), k.get_key_clone(), op.op.clone())) + }) else { + continue; + }; + + let (reference, reference_flags) = match &op_kind { + GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => { + let Element::BidirectionalReference(reference, flags) = element else { + unreachable!("collected as a bidirectional-reference op"); + }; + (reference.clone(), flags.clone()) + } + GroveOp::Patch { .. } => { + return Err(Error::NotSupported( + "Patch operations cannot carry bidirectional references".to_owned(), + )) + .wrap_with_cost(cost); + } + _ => unreachable!("collected as a bidirectional-reference op"), + }; + + // Per-kind gating against the sequential previous state. + let previous = cost_return_on_error!(&mut cost, expansion.store.element_at(&path, &key)); + match &op_kind { + GroveOp::InsertIfNotExists { + error_if_exists, .. + } if previous.is_some() => { + if *error_if_exists || expansion.validate_insertion_does_not_override { + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + // Writes nothing; the op dissolves — the STORED edge is + // authoritative again for prospective-component checks. + expansion.store.clear_pending_reference(&(path, key)); + expansion.ops[index] = None; + continue; + } + GroveOp::InsertWithKnownToNotAlreadyExist { .. } if previous.is_some() => { + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + GroveOp::InsertOrReplace { .. } | GroveOp::Replace { .. } + if previous.is_some() && expansion.validate_insertion_does_not_override => + { + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + GroveOp::Replace { .. } if previous.is_none() => { + return Err(Error::InvalidBatchOperation( + "attempting to replace an element that does not exist", + )) + .wrap_with_cost(cost); + } + _ => {} + } + + // M4: a reference and its target's deletion cannot share a batch. + if let Some(target_position) = + first_hop_position(&reference.forward_reference_path, &path, &key) + && expansion.user_deleted_positions.contains(&target_position) + { + return Err(Error::InvalidBatchOperation( + "a bidirectional reference cannot be inserted in the same batch that deletes \ + its target", + )) + .wrap_with_cost(cost); + } + + // The op is being planned NOW: its declaration graduates from + // "pending" — its own upstream walk must read the state around it, + // and after planning the overlay carries its staged element. + expansion + .store + .clear_pending_reference(&(path.clone(), key.clone())); + let plan = cost_return_on_error!( + &mut cost, + plan_reference_insertion(&expansion.store, &path, &key, reference, reference_flags) + ); + + // The op is consumed either way: an identical edge dissolves (any + // derived rewrite staged earlier for this position stays — it + // carries a propagated end hash); a real plan replaces it with the + // derived form via its primary write. + expansion.ops[index] = None; + + if let Some(plan) = plan { + cost_return_on_error!( + &mut cost, + expansion.apply_mutations(plan.mutations, usize::MAX) + ); + } + } + + let mut expanded: Vec = expansion.ops.into_iter().flatten().collect(); + expanded.extend(expansion.derived.into_values()); + Ok(expanded).wrap_with_cost(cost) +} + +/// The qualified position of a reference's first hop, when the path type is +/// resolvable syntactically. `None` falls back to plan-time resolution +/// errors. +fn first_hop_position( + reference_path: &ReferencePathType, + path: &[Vec], + key: &[u8], +) -> Option { + let qualified = path_from_reference_path_type(reference_path.clone(), path, Some(key)).ok()?; + let (target_key, target_path) = qualified.split_last()?; + Some((target_path.to_vec(), target_key.clone())) +} + +/// Order the pending `BidirectionalReference` ops so that every op whose +/// forward edge targets another pending op's position comes AFTER that op +/// (targets first). Cycles keep their relative user order. +fn order_reference_ops_targets_first( + ops: &[Option], + bidi_op_indices: &[usize], +) -> Vec { + let mut position_to_index: HashMap = HashMap::new(); + let mut forward_target: HashMap> = HashMap::new(); + + for &index in bidi_op_indices { + let Some(op) = ops[index].as_ref() else { + continue; + }; + let Some(position) = Expansion::op_position(op) else { + continue; + }; + let reference = match &op.op { + GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::Patch { element, .. } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => match element { + Element::BidirectionalReference(reference, _) => Some(reference), + _ => None, + }, + _ => None, + }; + let target = reference + .and_then(|r| first_hop_position(&r.forward_reference_path, &position.0, &position.1)); + position_to_index.insert(position, index); + forward_target.insert(index, target); + } + + let mut ordered = Vec::with_capacity(bidi_op_indices.len()); + let mut state: HashMap = HashMap::new(); // 0/absent = new, 1 = visiting, 2 = done + + for &start in bidi_op_indices { + // Iterative DFS along forward edges; emit post-order (target before + // referrer). A back-edge (cycle) is skipped — the member order then + // stays the user order, and planning reports the cycle. + let mut stack = vec![(start, false)]; + while let Some((index, children_done)) = stack.pop() { + if children_done { + state.insert(index, 2); + ordered.push(index); + continue; + } + match state.get(&index) { + Some(1) | Some(2) => continue, + _ => {} + } + state.insert(index, 1); + stack.push((index, true)); + if let Some(Some(target)) = forward_target.get(&index) + && let Some(&target_index) = position_to_index.get(target) + && !matches!(state.get(&target_index), Some(1) | Some(2)) + { + stack.push((target_index, false)); + } + } + } + + ordered +} diff --git a/grovedb/src/batch/batch_structure.rs b/grovedb/src/batch/batch_structure.rs index e3dcfebeb..53038765b 100644 --- a/grovedb/src/batch/batch_structure.rs +++ b/grovedb/src/batch/batch_structure.rs @@ -226,7 +226,8 @@ where | GroveOp::InsertIfNotExists { element, .. } | GroveOp::InsertOrReplace { element } | GroveOp::Replace { element } - | GroveOp::Patch { element, .. } => { + | GroveOp::Patch { element, .. } + | GroveOp::ReplaceBackwardReferenceFamilyMember { element, .. } => { if let Some(tree_type) = element.tree_type() { cost_return_on_error!( &mut cost, diff --git a/grovedb/src/batch/estimated_costs/average_case_costs.rs b/grovedb/src/batch/estimated_costs/average_case_costs.rs index 72af3090a..6825e8047 100644 --- a/grovedb/src/batch/estimated_costs/average_case_costs.rs +++ b/grovedb/src/batch/estimated_costs/average_case_costs.rs @@ -15,6 +15,10 @@ use grovedb_merk::estimated_costs::average_case_costs::{ add_average_case_get_merk_node, add_average_case_merk_has_value, average_case_merk_propagate, EstimatedLayerInformation, }; +#[cfg(feature = "minimal")] +use grovedb_merk::estimated_costs::{ + add_cost_case_merk_replace_layered, add_cost_case_merk_replace_same_size, +}; use grovedb_merk::{ element::tree_type::ElementTreeTypeExtensions, tree::AggregateData, tree_type::TreeType, RootHashKeyAndAggregateData, @@ -46,6 +50,10 @@ impl GroveOp { /// CostResult. fn average_case_cost( &self, + // The op's own path: sizes the inverted-registration growth bound + // (every `invert()` output is built from the origin's qualified + // path). + path: &KeyInfoPath, key: &KeyInfo, layer_element_estimates: &EstimatedLayerInformation, // The declared chunk power of the append-only tree this op targets @@ -55,6 +63,10 @@ impl GroveOp { // and compaction terms by `2^chunk_power`, which the op itself does // not carry. Ignored by every other op type. append_tree_chunk_power: Option, + // Whether the batch opts into backward-references bookkeeping + // (`BatchApplyOptions::propagate_backward_references`): family ops + // and deletes then charge the derived fan-out on GROVE_V4+. + backward_references_enabled: bool, propagate: bool, grove_version: &GroveVersion, ) -> CostResult<(), Error> { @@ -66,7 +78,119 @@ impl GroveOp { None } }; + let fan_out_version = grove_version + .grovedb_versions + .operations + .average_case + .average_case_backward_references_fan_out; + // The derived fan-out charged on top of an op's own model, per the + // documented shape (see the model in `super`). `None` when the op + // triggers no bookkeeping or the flag/version leaves it inactive. + let backward_references_fan_out = |element: Option<&Element>| { + if !backward_references_enabled || fan_out_version == 0 { + return None; + } + match element { + Some(Element::BidirectionalReference(..)) => { + // The registration entry appended to the target: an + // inverted path built from the referrer's qualified + // origin (this op's path segments plus its key — an + // absolute inversion serializes them all), the cascade + // flag, and framing. + let origin_bytes: u32 = path + .0 + .iter() + .map(|segment| 4 + segment.max_length() as u32) + .sum::() + .saturating_add(4 + key.max_length() as u32); + let entry_bound = origin_bytes.saturating_add(16); + Some(super::BackwardReferencesFanOut::average_reference( + entry_bound, + )) + } + // A backward-references ITEM write carries its own referrer + // capacity (the apply path refuses it unless the carried-over + // referrers fit), so the typical shape is capped by what it + // declares. + Some( + element @ (Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..)), + ) => Some(super::BackwardReferencesFanOut::average_item_with_capacity( + element.max_incoming_references().unwrap_or(0), + )), + // The estimator cannot see the STORED element the op + // displaces (or deletes): any other write can land on a + // registered family element needing propagation/cascade + // work, so every write charges the typical item shape. + Some(_) | None => Some(super::BackwardReferencesFanOut::average_item()), + } + }; + let with_fan_out = |base: CostResult<(), Error>, + fan_out: Option| + -> CostResult<(), Error> { + let Some(fan_out) = fan_out else { return base }; + let mut extra = OperationCost::default(); + match add_average_case_backward_references_fan_out( + &mut extra, + fan_out, + layer_element_estimates, + grove_version, + ) { + Ok(()) => base.add_cost(extra), + Err(e) => Err(e).wrap_with_cost(extra), + } + }; + // The flagged apply path probes a deleted tree's child subtree for + // emptiness (a merk open and its root read) before admitting the + // deletion — charged whenever the fan-out is active. + let flagged_delete_probe = || { + let mut probe = OperationCost::default(); + if backward_references_enabled && fan_out_version != 0 { + let key_width = GroveDb::average_case_layer_key_size( + &layer_element_estimates.estimated_layer_sizes, + ); + let node_value_size = layer_element_estimates + .estimated_layer_sizes + .value_with_feature_and_flags_size(grove_version) + .unwrap_or(0); + for _ in 0..2 { + let _ = add_average_case_get_merk_node( + &mut probe, + key_width, + node_value_size, + layer_element_estimates.tree_type.inner_node_type(), + ); + } + } + probe + }; match self { + // The internal derived rewrite: a same-size element replace + // whose node hash is provided precombined — the standard + // replace model plus the two combine calls. + GroveOp::ReplaceBackwardReferenceFamilyMember { element, .. } => { + if fan_out_version == 0 { + return Err(Error::NotSupported( + "estimated costs for backward-references batch operations require \ + GROVE_V4+" + .to_owned(), + )) + .wrap_with_cost(OperationCost::default()); + } + let combine_cost = OperationCost { + hash_node_calls: 2, + ..Default::default() + }; + GroveDb::average_case_merk_replace_element( + key, + element, + in_tree_type, + propagate_if_input(), + grove_version, + ) + .add_cost(combine_cost) + } GroveOp::ReplaceTreeRootKey { aggregate_data, .. } => { GroveDb::average_case_merk_replace_tree( key, @@ -98,15 +222,16 @@ impl GroveOp { grove_version, ), GroveOp::InsertOrReplace { element } - | GroveOp::InsertWithKnownToNotAlreadyExist { element } => { + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => with_fan_out( GroveDb::average_case_merk_insert_element( key, element, in_tree_type, propagate_if_input(), grove_version, - ) - } + ), + backward_references_fan_out(Some(element)), + ), GroveOp::InsertIfNotExists { element, .. } => { // Same insert cost as InsertWithKnownToNotAlreadyExist, plus an // additional seek to check whether the key already exists. @@ -125,12 +250,15 @@ impl GroveOp { key.max_length() as u32, estimated_element_size, ); - GroveDb::average_case_merk_insert_element( - key, - element, - in_tree_type, - propagate_if_input(), - grove_version, + with_fan_out( + GroveDb::average_case_merk_insert_element( + key, + element, + in_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ) .add_cost(has_cost) } @@ -186,37 +314,51 @@ impl GroveOp { grove_version, ) } - GroveOp::Replace { element } => GroveDb::average_case_merk_replace_element( - key, - element, - in_tree_type, - propagate_if_input(), - grove_version, + GroveOp::Replace { element } => with_fan_out( + GroveDb::average_case_merk_replace_element( + key, + element, + in_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ), GroveOp::Patch { element, change_in_bytes, - } => GroveDb::average_case_merk_patch_element( - key, - element, - *change_in_bytes, - in_tree_type, - propagate_if_input(), - grove_version, - ), - GroveOp::Delete => GroveDb::average_case_merk_delete_element( - key, - layer_element_estimates, - propagate, - grove_version, - ), - GroveOp::DeleteTree(tree_type, _) => GroveDb::average_case_merk_delete_tree( - key, - *tree_type, - layer_element_estimates, - propagate, - grove_version, + } => with_fan_out( + GroveDb::average_case_merk_patch_element( + key, + element, + *change_in_bytes, + in_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ), + GroveOp::Delete => with_fan_out( + GroveDb::average_case_merk_delete_element( + key, + layer_element_estimates, + propagate, + grove_version, + ), + backward_references_fan_out(None), + ) + .add_cost(flagged_delete_probe()), + GroveOp::DeleteTree(tree_type, _) => with_fan_out( + GroveDb::average_case_merk_delete_tree( + key, + *tree_type, + layer_element_estimates, + propagate, + grove_version, + ), + backward_references_fan_out(None), + ) + .add_cost(flagged_delete_probe()), GroveOp::CommitmentTreeInsert { payload, .. } => { Self::average_case_commitment_tree_insert( payload, @@ -651,6 +793,75 @@ impl GroveOp { } } +#[cfg(feature = "minimal")] +/// Charge the derived backward-references fan-out (see the model in +/// `super`): each rewrite is a node load plus a same-size node rewrite +/// with the family's hash calls, each resolution a node load, and each +/// propagation a replay of the layer's merk propagation — all sized from +/// the op's own declared layer, the model's documented proxy for the +/// component's subtrees. +fn add_average_case_backward_references_fan_out( + cost: &mut OperationCost, + fan_out: super::BackwardReferencesFanOut, + layer_element_estimates: &EstimatedLayerInformation, + grove_version: &GroveVersion, +) -> Result<(), Error> { + let key_width = + GroveDb::average_case_layer_key_size(&layer_element_estimates.estimated_layer_sizes); + let node_value_size = layer_element_estimates + .estimated_layer_sizes + .value_with_feature_and_flags_size(grove_version) + .map_err(Error::MerkError)?; + let node_type = layer_element_estimates.tree_type.inner_node_type(); + for _ in 0..fan_out.rewrites { + add_average_case_get_merk_node(cost, key_width, node_value_size, node_type) + .map_err(Error::MerkError)?; + add_cost_case_merk_replace_same_size( + cost, + key_width, + node_value_size, + layer_element_estimates.tree_type, + ); + cost.hash_node_calls = cost + .hash_node_calls + .saturating_add(super::BACKWARD_REFERENCES_REWRITE_HASH_CALLS); + } + for _ in 0..fan_out.resolution_loads { + add_average_case_get_merk_node(cost, key_width, node_value_size, node_type) + .map_err(Error::MerkError)?; + } + cost.storage_cost.added_bytes = cost + .storage_cost + .added_bytes + .saturating_add(fan_out.registration_added_bytes); + for _ in 0..fan_out.propagations { + average_case_merk_propagate(layer_element_estimates, grove_version) + .unwrap_add_cost(cost) + .map_err(Error::MerkError)?; + // A derived write in a FOREIGN subtree also propagates up the + // Grove: charge a typical shallow ancestor walk (the worst-case + // model charges the full registration depth bound). Each level is + // a parent-Merk open, the changed tree element's rewrite, and the + // in-Merk propagation to that Merk's root. + for _ in 0..super::BACKWARD_REFERENCES_AVERAGE_ANCESTOR_LEVELS { + add_average_case_get_merk_node(cost, key_width, node_value_size, node_type) + .map_err(Error::MerkError)?; + add_average_case_get_merk_node(cost, key_width, node_value_size, node_type) + .map_err(Error::MerkError)?; + add_cost_case_merk_replace_layered( + cost, + key_width, + node_value_size, + layer_element_estimates.tree_type, + ); + average_case_merk_propagate(layer_element_estimates, grove_version) + .unwrap_add_cost(cost) + .map_err(Error::MerkError)?; + } + } + Ok(()) +} + #[cfg(feature = "minimal")] /// Axes whose secondary Merks an indexed primary maintains. /// @@ -758,7 +969,7 @@ impl TreeCache for AverageCaseTreeCacheKnownPaths { path: &KeyInfoPath, ops_at_path_by_key: BTreeMap, _ops_by_qualified_paths: &BTreeMap>, GroveOp>, - _batch_apply_options: &BatchApplyOptions, + batch_apply_options: &BatchApplyOptions, _flags_update: &mut G, _split_removal_bytes: &mut SR, grove_version: &GroveVersion, @@ -920,9 +1131,11 @@ impl TreeCache for AverageCaseTreeCacheKnownPaths { cost_return_on_error!( &mut cost, op.average_case_cost( + path, &key, layer_element_estimates, append_tree_chunk_power, + batch_apply_options.propagate_backward_references, false, grove_version ) @@ -2020,11 +2233,27 @@ mod tests { // The V4+ model requires the tree's chunk power (normally read from // the tree's own declared layer); an undeclared dispatch errors. assert!(op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version + ) .cost_as_result() .is_err()); let cost = op - .average_case_cost(&key, &layer_info, Some(10), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(10), + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for commitment tree insert"); // CommitmentTreeInsert includes frontier I/O and buffer writes plus @@ -2071,7 +2300,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for mmr tree append"); // MmrTreeAppend includes parent replace cost plus MMR node I/O. @@ -2112,7 +2349,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for bulk append"); // BulkAppend includes parent replace cost plus buffer write + running @@ -2148,7 +2393,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, Some(4), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(4), + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for private document store insert"); // PrivateDocumentStoreInsert mirrors BulkAppend: parent replace cost @@ -2176,16 +2429,32 @@ mod tests { entry: vec![42u8; 128], }; let cost_large = op_large - .average_case_cost(&key, &layer_info, Some(4), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(4), + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for larger entry"); // Undeclared layer must fail loudly rather than silently guessing a // chunk power, matching the CommitmentTreeInsert contract. assert!( - op.average_case_cost(&key, &layer_info, None, false, grove_version) - .cost_as_result() - .is_err(), + op.average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version + ) + .cost_as_result() + .is_err(), "estimation without a declared PrivateDocumentStore layer must error" ); @@ -2194,11 +2463,27 @@ mod tests { // smaller amortized compaction share — the hash figure is exactly // the model's plus the compaction bound plus the three roots. let small = op - .average_case_cost(&key, &layer_info, Some(2), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(2), + false, + false, + grove_version, + ) .cost_as_result() .expect("cost at chunk_power 2"); let big = op - .average_case_cost(&key, &layer_info, Some(10), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(10), + false, + false, + grove_version, + ) .cost_as_result() .expect("cost at chunk_power 10"); let own_hashes = |chunk_power: u8| { @@ -2245,7 +2530,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for dense tree insert"); // DenseTreeInsert includes parent replace cost plus value write and @@ -2289,7 +2582,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for replace non-merk tree root"); // ReplaceNonMerkTreeRoot delegates to average_case_merk_replace_tree. @@ -2324,7 +2625,15 @@ mod tests { estimated_layer_sizes: AllSubtrees(4, NoSumTrees, None), }; let cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected cost for insert non-merk tree"); // InsertNonMerkTree delegates to average_case_merk_insert_tree. @@ -2373,9 +2682,17 @@ mod tests { not_summed, not_counted_or_summed, }; - op.average_case_cost(&key, &layer_info, None, false, grove_version) - .cost_as_result() - .expect("expected cost for InsertTreeWithRootHash") + op.average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) + .cost_as_result() + .expect("expected cost for InsertTreeWithRootHash") }; let bare = cost_for(false, false, false); let nc = cost_for(true, false, false); @@ -2427,9 +2744,17 @@ mod tests { meta: NonMerkTreeMeta::MmrTree { mmr_size: 50 }, non_counted, }; - op.average_case_cost(&key, &layer_info, None, false, grove_version) - .cost_as_result() - .expect("expected cost for InsertNonMerkTree") + op.average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) + .cost_as_result() + .expect("expected cost for InsertNonMerkTree") }; let bare = cost_for(false); let nc = cost_for(true); @@ -2466,7 +2791,15 @@ mod tests { axes: vec![(0u8, [0xEFu8; 32], Some(b"srk".to_vec()))], }; let cost_count = op_count - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected average case cost for Count cidx replace"); assert!(cost_count.seek_count > 0 || cost_count.hash_node_calls > 0); @@ -2478,7 +2811,15 @@ mod tests { axes: vec![(0u8, [8u8; 32], None)], }; let cost_pcount = op_pcount - .average_case_cost(&key, &layer_info, None, true, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + true, + grove_version, + ) .cost_as_result() .expect("expected average case cost for ProvableCount cidx replace (propagate)"); assert!( @@ -3133,13 +3474,29 @@ mod tests { }; let arm_cost = op - .average_case_cost(&key, &layer_info, None, false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + None, + false, + false, + grove_version, + ) .cost_as_result() .expect("expected V3 average case cost"); // A declared chunk power must not change the V3 output — the // declared-layer machinery is part of the V4+ model only. let arm_cost_with_declared_chunk_power = op - .average_case_cost(&key, &layer_info, Some(4), false, grove_version) + .average_case_cost( + &KeyInfoPath(vec![]), + &key, + &layer_info, + Some(4), + false, + false, + grove_version, + ) .cost_as_result() .expect("expected V3 average case cost with declared chunk power"); assert_eq!(arm_cost, arm_cost_with_declared_chunk_power); diff --git a/grovedb/src/batch/estimated_costs/mod.rs b/grovedb/src/batch/estimated_costs/mod.rs index c207723cb..a7092d49d 100644 --- a/grovedb/src/batch/estimated_costs/mod.rs +++ b/grovedb/src/batch/estimated_costs/mod.rs @@ -41,6 +41,254 @@ pub(in crate::batch) fn wrapper_overhead_for( } } +// ── Backward-references fan-out estimation model ──────────────────────── +// +// Under `BatchApplyOptions::propagate_backward_references` (GROVE_V4+), a +// single op can expand into derived operations in OTHER subtrees: +// registering on a target, rewriting every referrer chain with a new end +// hash, cascading deletions through referrer chains. The estimator cannot +// see the stored referrer graph, so the models charge counts derived from +// the apply path's hard budgets (an item DECLARES how many referrers it +// accepts, at most `MAX_BACKWARD_REFERENCES` = 256; 1 per reference; +// `MAX_REFERENCE_HOPS` = 10 per component): +// +// - A write of a backward-references ITEM carries its declared capacity, +// and the apply path refuses the write unless every referrer carried +// over from the displaced element fits it — so that write's fan-out is +// bounded by the DECLARED capacity (plus the de-registration a displaced +// reference owes), and an item declaring four referrers pays for four. +// - The estimator cannot see the STORED element any OTHER op displaces +// (a plain payload, a reference, a delete), so those charge the +// displaced-state bound at the protocol ceiling — a plain payload can +// land on a registered family element whose bookkeeping the +// preprocessor must perform. +// - WORST case charges the full bound — an item's referrer graph is at +// most `capacity` chains of at most `MAX_REFERENCE_HOPS` nodes each, +// every one rewritten (or cascaded away); a reference insertion +// additionally touches its target, its old target, and its single +// upstream chain. +// - AVERAGE case charges a small typical shape (one referrer chain of two +// for the displaced state, registration + removal + one propagation for +// references) — like the MMR model's trailing-ones average, this is a +// calibration constant, not a bound. +// - A reference insertion's registration GROWS the target element by a +// `BackwardReference` entry: the inverted path re-anchors at the +// referrer's qualified position, so the bound is sized from the op's +// OWN path and key (every `invert()` output is built from subsets of +// the origin's qualified path plus small scalars). +// +// Each derived rewrite is charged as a node load + a node rewrite + a merk +// propagation, all sized from the op's OWN declared layer — referrer +// subtrees are not GroveDB-declarable here, so the model assumes the +// component's nodes are shaped like the declared layer. Callers must +// declare a layer that dominates every Merk in the component — the +// referrer subtrees AND their ancestor Merks (whose in-Merk propagation +// the ancestor walk below charges from the same declared shape). +// +// Each derived propagation additionally charges the GROVE-DEPTH ancestor +// walk of its foreign subtree: registration enforces +// `MAX_BACKWARD_REFERENCES_GROVE_DEPTH` on every bidirectional-edge +// position, so the worst model charges exactly that many ancestor levels +// per propagation (without the registration bound this walk would be +// unboundable — a referrer parked arbitrarily deep would out-cost any +// fixed estimate). Each level is a full step of the actual bubbling: the +// parent-Merk open, the changed tree element's biggest-node rewrite, and +// that Merk's own worst-case propagation to its root. + +/// Worst-case number of derived node rewrites (or cascade deletions) an +/// overwrite/delete of a backward-references ITEM can trigger: every entry +/// heads a referrer chain bounded by the component hop budget. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT: u32 = + grovedb_element::MAX_BACKWARD_REFERENCES as u32 + * crate::operations::get::MAX_REFERENCE_HOPS as u32; + +/// Worst-case number of derived node rewrites a bidirectional-reference +/// insertion can trigger: the registration on the new target, the +/// registration removal on the old target, and the upstream propagation +/// along the reference's single referrer chain. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_WORST_REFERENCE_FAN_OUT: u32 = + 2 + (crate::operations::get::MAX_REFERENCE_HOPS as u32 - 1); + +/// Worst-case number of chain-resolution element loads a +/// bidirectional-reference insertion performs (the new and the old forward +/// chains, each bounded by the hop budget). +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_WORST_REFERENCE_RESOLUTION_LOADS: u32 = + 2 * crate::operations::get::MAX_REFERENCE_HOPS as u32; + +/// Average-case derived rewrites for an ITEM overwrite/delete: one +/// referrer chain of two. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT: u32 = 2; + +/// Average-case derived rewrites for a reference insertion: the +/// registration, one superseded-registration removal, one propagation. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_AVERAGE_REFERENCE_FAN_OUT: u32 = 3; + +/// Average-case chain-resolution loads for a reference insertion. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_AVERAGE_REFERENCE_RESOLUTION_LOADS: u32 = 4; + +/// Average-case ancestor levels a derived foreign-subtree propagation +/// walks (worst case charges the full +/// `MAX_BACKWARD_REFERENCES_GROVE_DEPTH` registration bound). +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_AVERAGE_ANCESTOR_LEVELS: u32 = 2; + +/// Hash calls per derived family rewrite: stripped value hash, referrer- +/// list hash, the two-layer combine, the end-hash combine, the kv digest +/// and the node hash. +#[cfg(feature = "minimal")] +pub(in crate::batch) const BACKWARD_REFERENCES_REWRITE_HASH_CALLS: u32 = 6; + +/// Merge `unit × times` into `cost` with saturating arithmetic. The +/// worst-case ancestor-walk bound can exceed the u32 cost domain +/// (hundreds of derived propagations × the full registration depth × +/// biggest-node in-Merk propagation); any REAL batch's actual cost must +/// itself fit that domain, so a saturated estimate still dominates every +/// actual — while ordinary `+=` would panic on overflow in debug builds. +#[cfg(feature = "minimal")] +pub(in crate::batch) fn add_saturating_scaled( + cost: &mut OperationCost, + unit: &OperationCost, + times: u64, +) { + let clamp_u32 = |value: u64| -> u32 { value.min(u32::MAX as u64) as u32 }; + let scale_u32 = |base: u32, unit: u32| -> u32 { + clamp_u32(base as u64 + (unit as u64).saturating_mul(times).min(u32::MAX as u64)) + }; + cost.seek_count = scale_u32(cost.seek_count, unit.seek_count); + cost.hash_node_calls = scale_u32(cost.hash_node_calls, unit.hash_node_calls); + cost.storage_cost.added_bytes = + scale_u32(cost.storage_cost.added_bytes, unit.storage_cost.added_bytes); + cost.storage_cost.replaced_bytes = scale_u32( + cost.storage_cost.replaced_bytes, + unit.storage_cost.replaced_bytes, + ); + cost.storage_loaded_bytes = cost + .storage_loaded_bytes + .saturating_add(unit.storage_loaded_bytes.saturating_mul(times)); +} + +/// The derived fan-out shape of a batch op under the backward-references +/// flag: how many derived node rewrites, chain-resolution loads, and +/// subtree propagations to charge. +#[cfg(feature = "minimal")] +#[derive(Clone, Copy)] +pub(in crate::batch) struct BackwardReferencesFanOut { + /// Derived node rewrites (registrations, propagation rewrites, cascade + /// deletions), each charged as load + rewrite. + pub rewrites: u32, + /// Chain-resolution element loads (no writes). + pub resolution_loads: u32, + /// Distinct referrer subtrees whose merk root paths re-propagate. + pub propagations: u32, + /// Node GROWTH from registering on the target: the target's element + /// gains a `BackwardReference` entry (the inverted reference path plus + /// the cascade flag), which is added — not replaced — bytes. Every + /// `invert()` output is built from subsets of the referrer's qualified + /// origin path plus small scalars, so the caller sizes this bound from + /// the op's own path segments and key. + pub registration_added_bytes: u32, +} + +#[cfg(feature = "minimal")] +impl BackwardReferencesFanOut { + /// The worst-case fan-out of any overwrite-capable op (or delete) + /// under the flag. The estimator cannot see the STORED element the op + /// displaces: a plain payload can still land on a registered family + /// element, whose propagation/cascade work is the full item bound — + /// so every write and delete charges it. Every rewrite may live in + /// its own subtree, so each charges a propagation. Propagation + /// rewrites and cascade deletions never grow nodes. + pub(in crate::batch) fn worst_item() -> Self { + Self { + rewrites: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT, + resolution_loads: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT, + propagations: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT, + registration_added_bytes: 0, + } + } + + /// The worst-case fan-out of a write of a backward-references ITEM + /// declaring `max_incoming` referrers. The write carries its own + /// declaration, and the apply path refuses it unless every referrer + /// carried over from the displaced element fits that capacity, so the + /// propagation it can trigger is bounded by the DECLARED capacity + /// rather than the protocol ceiling: at most `max_incoming` chains of + /// `MAX_REFERENCE_HOPS` nodes. Displacing a bidirectional reference + /// additionally removes its registration from its old target (one + /// rewrite, one chain resolution). + pub(in crate::batch) fn worst_item_with_capacity(max_incoming: u16) -> Self { + let hops = crate::operations::get::MAX_REFERENCE_HOPS as u32; + let chains = max_incoming as u32 * hops; + Self { + rewrites: chains + 1, + resolution_loads: chains + hops, + propagations: chains + 1, + registration_added_bytes: 0, + } + } + + /// The worst-case fan-out of a `BidirectionalReference` insertion: + /// its own registration/propagation terms PLUS the displaced-state + /// item bound (the reference can overwrite a registered family + /// element, cascading its referrers). + pub(in crate::batch) fn worst_reference(registration_added_bytes: u32) -> Self { + Self { + rewrites: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT + + BACKWARD_REFERENCES_WORST_REFERENCE_FAN_OUT, + resolution_loads: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT + + BACKWARD_REFERENCES_WORST_REFERENCE_RESOLUTION_LOADS, + propagations: BACKWARD_REFERENCES_WORST_ITEM_FAN_OUT + + BACKWARD_REFERENCES_WORST_REFERENCE_FAN_OUT, + registration_added_bytes, + } + } + + /// The average-case fan-out of any overwrite-capable op (or delete) + /// under the flag: the displaced element is unseen, so every write + /// charges the typical item shape. + pub(in crate::batch) fn average_item() -> Self { + Self { + rewrites: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT, + resolution_loads: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT, + propagations: 1, + registration_added_bytes: 0, + } + } + + /// The average-case fan-out of a write of a backward-references ITEM + /// declaring `max_incoming` referrers: the typical item shape, never + /// more than the declared worst case (an item declaring no referrers + /// can only owe the de-registration of a displaced reference). + pub(in crate::batch) fn average_item_with_capacity(max_incoming: u16) -> Self { + let worst = Self::worst_item_with_capacity(max_incoming); + Self { + rewrites: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT.min(worst.rewrites), + resolution_loads: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT.min(worst.resolution_loads), + propagations: 1.min(worst.propagations), + registration_added_bytes: 0, + } + } + + /// The average-case fan-out of a `BidirectionalReference` insertion: + /// reference terms plus the typical displaced-item shape. + pub(in crate::batch) fn average_reference(registration_added_bytes: u32) -> Self { + Self { + rewrites: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT + + BACKWARD_REFERENCES_AVERAGE_REFERENCE_FAN_OUT, + resolution_loads: BACKWARD_REFERENCES_AVERAGE_ITEM_FAN_OUT + + BACKWARD_REFERENCES_AVERAGE_REFERENCE_RESOLUTION_LOADS, + propagations: 1, + registration_added_bytes, + } + } +} + // ── CommitmentTreeInsert estimation model ─────────────────────────────── // // Every constant below is an UPPER BOUND, not an average. Downstream diff --git a/grovedb/src/batch/estimated_costs/worst_case_costs.rs b/grovedb/src/batch/estimated_costs/worst_case_costs.rs index 80b78abcf..2fda1a4ba 100644 --- a/grovedb/src/batch/estimated_costs/worst_case_costs.rs +++ b/grovedb/src/batch/estimated_costs/worst_case_costs.rs @@ -12,8 +12,12 @@ use grovedb_costs::{ }; #[cfg(feature = "minimal")] use grovedb_merk::estimated_costs::worst_case_costs::{ - add_worst_case_merk_has_value, worst_case_merk_propagate, WorstCaseLayerInformation, - MERK_BIGGEST_VALUE_SIZE, + add_worst_case_get_merk_node, add_worst_case_merk_has_value, worst_case_merk_propagate, + WorstCaseLayerInformation, MERK_BIGGEST_KEY_SIZE, MERK_BIGGEST_VALUE_SIZE, +}; +#[cfg(feature = "minimal")] +use grovedb_merk::estimated_costs::{ + add_cost_case_merk_replace_layered, add_cost_case_merk_replace_same_size, }; use grovedb_merk::{ element::tree_type::ElementTreeTypeExtensions, tree::AggregateData, tree_type::TreeType, @@ -41,9 +45,17 @@ use crate::{ impl GroveOp { fn worst_case_cost( &self, + // The op's own path: sizes the inverted-registration growth bound + // (every `invert()` output is built from the origin's qualified + // path). + path: &KeyInfoPath, key: &KeyInfo, in_parent_tree_type: TreeType, worst_case_layer_element_estimates: &WorstCaseLayerInformation, + // Whether the batch opts into backward-references bookkeeping + // (`BatchApplyOptions::propagate_backward_references`): family ops + // and deletes then charge the derived fan-out on GROVE_V4+. + backward_references_enabled: bool, propagate: bool, grove_version: &GroveVersion, ) -> CostResult<(), Error> { @@ -54,7 +66,112 @@ impl GroveOp { None } }; + let fan_out_version = grove_version + .grovedb_versions + .operations + .worst_case + .worst_case_backward_references_fan_out; + // The derived fan-out charged on top of an op's own model, per the + // documented worst-case bounds (see the model in `super`). + let backward_references_fan_out = |element: Option<&Element>| { + if !backward_references_enabled || fan_out_version == 0 { + return None; + } + match element { + Some(Element::BidirectionalReference(..)) => { + // The registration entry appended to the target: an + // inverted path built from the referrer's qualified + // origin (this op's path segments plus its key — an + // absolute inversion serializes them all), the cascade + // flag, and framing. + let origin_bytes: u32 = path + .0 + .iter() + .map(|segment| 4 + segment.max_length() as u32) + .sum::() + .saturating_add(4 + key.max_length() as u32); + let entry_bound = origin_bytes.saturating_add(16); + Some(super::BackwardReferencesFanOut::worst_reference( + entry_bound, + )) + } + // A backward-references ITEM write carries its own referrer + // capacity, and the apply path refuses it unless the + // referrers carried over from the displaced element fit + // that capacity — so its fan-out is bounded by what it + // DECLARES, not by the protocol ceiling. + Some( + element @ (Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..)), + ) => Some(super::BackwardReferencesFanOut::worst_item_with_capacity( + element.max_incoming_references().unwrap_or(0), + )), + // The estimator cannot see the STORED element the op + // displaces (or deletes): any other write can land on a + // registered family element whose propagation/cascade work + // is the full item bound at the protocol ceiling. + Some(_) | None => Some(super::BackwardReferencesFanOut::worst_item()), + } + }; + let with_fan_out = |base: CostResult<(), Error>, + fan_out: Option| + -> CostResult<(), Error> { + let Some(fan_out) = fan_out else { return base }; + let mut extra = OperationCost::default(); + match add_worst_case_backward_references_fan_out( + &mut extra, + fan_out, + in_parent_tree_type, + worst_case_layer_element_estimates, + ) { + Ok(()) => base.add_cost(extra), + Err(e) => Err(e).wrap_with_cost(extra), + } + }; + // The flagged apply path probes a deleted tree's child subtree for + // emptiness (a merk open and its root read) before admitting the + // deletion — charged whenever the fan-out is active. + let flagged_delete_probe = || { + let mut probe = OperationCost::default(); + if backward_references_enabled && fan_out_version != 0 { + for _ in 0..2 { + let _ = add_worst_case_get_merk_node( + &mut probe, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + in_parent_tree_type.inner_node_type(), + ); + } + } + probe + }; match self { + // The internal derived rewrite: a same-size element replace + // whose node hash is provided precombined — the standard + // replace model plus the two combine calls. + GroveOp::ReplaceBackwardReferenceFamilyMember { element, .. } => { + if fan_out_version == 0 { + return Err(Error::NotSupported( + "estimated costs for backward-references batch operations require \ + GROVE_V4+" + .to_owned(), + )) + .wrap_with_cost(OperationCost::default()); + } + let combine_cost = OperationCost { + hash_node_calls: 2, + ..Default::default() + }; + GroveDb::worst_case_merk_replace_element( + key, + element, + in_parent_tree_type, + propagate_if_input(), + grove_version, + ) + .add_cost(combine_cost) + } GroveOp::ReplaceTreeRootKey { aggregate_data, .. } => { GroveDb::worst_case_merk_replace_tree( key, @@ -83,15 +200,16 @@ impl GroveOp { grove_version, ), GroveOp::InsertOrReplace { element } - | GroveOp::InsertWithKnownToNotAlreadyExist { element } => { + | GroveOp::InsertWithKnownToNotAlreadyExist { element } => with_fan_out( GroveDb::worst_case_merk_insert_element( key, element, in_parent_tree_type, propagate_if_input(), grove_version, - ) - } + ), + backward_references_fan_out(Some(element)), + ), GroveOp::InsertIfNotExists { element, .. } => { // Same insert cost as InsertWithKnownToNotAlreadyExist, plus an // additional seek to check whether the key already exists. @@ -104,12 +222,15 @@ impl GroveOp { key.max_length() as u32, MERK_BIGGEST_VALUE_SIZE, ); - GroveDb::worst_case_merk_insert_element( - key, - element, - in_parent_tree_type, - propagate_if_input(), - grove_version, + with_fan_out( + GroveDb::worst_case_merk_insert_element( + key, + element, + in_parent_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ) .add_cost(has_cost) } @@ -162,36 +283,50 @@ impl GroveOp { grove_version, ) } - GroveOp::Replace { element } => GroveDb::worst_case_merk_replace_element( - key, - element, - in_parent_tree_type, - propagate_if_input(), - grove_version, + GroveOp::Replace { element } => with_fan_out( + GroveDb::worst_case_merk_replace_element( + key, + element, + in_parent_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ), GroveOp::Patch { element, change_in_bytes: _, - } => GroveDb::worst_case_merk_replace_element( - key, - element, - in_parent_tree_type, - propagate_if_input(), - grove_version, - ), - GroveOp::Delete => GroveDb::worst_case_merk_delete_element( - key, - worst_case_layer_element_estimates, - propagate, - grove_version, - ), - GroveOp::DeleteTree(tree_type, _) => GroveDb::worst_case_merk_delete_tree( - key, - *tree_type, - worst_case_layer_element_estimates, - propagate, - grove_version, + } => with_fan_out( + GroveDb::worst_case_merk_replace_element( + key, + element, + in_parent_tree_type, + propagate_if_input(), + grove_version, + ), + backward_references_fan_out(Some(element)), ), + GroveOp::Delete => with_fan_out( + GroveDb::worst_case_merk_delete_element( + key, + worst_case_layer_element_estimates, + propagate, + grove_version, + ), + backward_references_fan_out(None), + ) + .add_cost(flagged_delete_probe()), + GroveOp::DeleteTree(tree_type, _) => with_fan_out( + GroveDb::worst_case_merk_delete_tree( + key, + *tree_type, + worst_case_layer_element_estimates, + propagate, + grove_version, + ), + backward_references_fan_out(None), + ) + .add_cost(flagged_delete_probe()), GroveOp::CommitmentTreeInsert { payload, .. } => { Self::worst_case_commitment_tree_insert( payload, @@ -550,6 +685,103 @@ impl GroveOp { } } +#[cfg(feature = "minimal")] +/// Charge the derived backward-references fan-out at its worst (see the +/// model in `super`): each rewrite is a biggest-node load plus a same-size +/// biggest-node rewrite with the family's hash calls, each resolution a +/// biggest-node load, and each propagation a replay of the layer's merk +/// propagation. +fn add_worst_case_backward_references_fan_out( + cost: &mut OperationCost, + fan_out: super::BackwardReferencesFanOut, + in_parent_tree_type: TreeType, + worst_case_layer_element_estimates: &WorstCaseLayerInformation, +) -> Result<(), Error> { + let node_type = in_parent_tree_type.inner_node_type(); + for _ in 0..fan_out.rewrites { + add_worst_case_get_merk_node( + cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + node_type, + ) + .map_err(Error::MerkError)?; + add_cost_case_merk_replace_same_size( + cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + in_parent_tree_type, + ); + cost.hash_node_calls = cost + .hash_node_calls + .saturating_add(super::BACKWARD_REFERENCES_REWRITE_HASH_CALLS); + } + for _ in 0..fan_out.resolution_loads { + add_worst_case_get_merk_node( + cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + node_type, + ) + .map_err(Error::MerkError)?; + } + cost.storage_cost.added_bytes = cost + .storage_cost + .added_bytes + .saturating_add(fan_out.registration_added_bytes); + for _ in 0..fan_out.propagations { + worst_case_merk_propagate(worst_case_layer_element_estimates) + .unwrap_add_cost(cost) + .map_err(Error::MerkError)?; + } + // A derived write in a FOREIGN subtree also propagates up the Grove. + // Per ancestor level (the registration rule bounds every + // bidirectional-edge position to + // `MAX_BACKWARD_REFERENCES_GROVE_DEPTH` levels), actual bubbling + // opens the parent Merk, rewrites the changed tree element, and + // propagates it THROUGH that Merk to its root — height-dependent + // work, charged as the declared layer's full worst-case propagation + // per level. The declared layer must therefore dominate every Merk + // in the component, ancestor Merks of referrer subtrees included + // (see the model contract in `super`). The per-level unit is + // computed once and scaled saturatingly: at full fan-out the true + // bound exceeds the u32 cost domain, which no real batch can reach. + let mut level_cost = OperationCost::default(); + // The parent-Merk open (its root node load)… + add_worst_case_get_merk_node( + &mut level_cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + node_type, + ) + .map_err(Error::MerkError)?; + // …the changed tree element's load and layered rewrite… + add_worst_case_get_merk_node( + &mut level_cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + node_type, + ) + .map_err(Error::MerkError)?; + add_cost_case_merk_replace_layered( + &mut level_cost, + MERK_BIGGEST_KEY_SIZE, + MERK_BIGGEST_VALUE_SIZE, + in_parent_tree_type, + ); + // …and the in-Merk propagation to that Merk's root. + worst_case_merk_propagate(worst_case_layer_element_estimates) + .unwrap_add_cost(&mut level_cost) + .map_err(Error::MerkError)?; + super::add_saturating_scaled( + cost, + &level_cost, + fan_out.propagations as u64 + * crate::bidirectional_references::MAX_BACKWARD_REFERENCES_GROVE_DEPTH as u64, + ); + Ok(()) +} + #[cfg(feature = "minimal")] /// Cache for subtree paths for worst case scenario costs. #[derive(Default)] @@ -606,7 +838,7 @@ impl TreeCache for WorstCaseTreeCacheKnownPaths { path: &KeyInfoPath, ops_at_path_by_key: BTreeMap, _ops_by_qualified_paths: &BTreeMap>, GroveOp>, - _batch_apply_options: &BatchApplyOptions, + batch_apply_options: &BatchApplyOptions, _flags_update: &mut G, _split_removal_bytes: &mut SR, grove_version: &GroveVersion, @@ -653,9 +885,11 @@ impl TreeCache for WorstCaseTreeCacheKnownPaths { cost_return_on_error!( &mut cost, op.worst_case_cost( + path, &key, TreeType::NormalTree, worst_case_layer_element_estimates, + batch_apply_options.propagate_backward_references, false, grove_version ) @@ -1314,10 +1548,12 @@ mod tests { let key = KeyInfo::KnownKey(b"tree_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1338,9 +1574,11 @@ mod tests { let key = KeyInfo::KnownKey(b"tree_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), + false, true, grove_version, ) @@ -1361,10 +1599,12 @@ mod tests { let key = KeyInfo::KnownKey(b"mmr_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1384,10 +1624,12 @@ mod tests { let key = KeyInfo::KnownKey(b"bulk_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1406,10 +1648,12 @@ mod tests { let key = KeyInfo::KnownKey(b"pds_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1440,10 +1684,12 @@ mod tests { let key = KeyInfo::KnownKey(b"dense_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1466,9 +1712,11 @@ mod tests { let key = KeyInfo::KnownKey(b"nmerk_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), + false, true, grove_version, ) @@ -1488,9 +1736,11 @@ mod tests { let key = KeyInfo::KnownKey(b"nmerk_mmr".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(50), + false, true, grove_version, ) @@ -1518,10 +1768,12 @@ mod tests { let key = KeyInfo::KnownKey(b"new_dense".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1548,9 +1800,11 @@ mod tests { let key = KeyInfo::KnownKey(b"new_bulk".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), + false, true, grove_version, ) @@ -1582,10 +1836,12 @@ mod tests { not_counted_or_summed, }; op.worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1636,10 +1892,12 @@ mod tests { non_counted, }; op.worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1705,10 +1963,12 @@ mod tests { }; let cost_count = op_count .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() @@ -1730,9 +1990,11 @@ mod tests { }; let cost_pcount = op_pcount .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), + false, true, grove_version, ) @@ -1778,10 +2040,12 @@ mod tests { let arm_cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &layer_info, false, + false, grove_version, ) .cost_as_result() @@ -1836,10 +2100,12 @@ mod tests { let key = KeyInfo::KnownKey(b"tree_key".to_vec()); let cost = op .worst_case_cost( + &KeyInfoPath(vec![]), &key, TreeType::NormalTree, &MaxElementsNumber(100), false, + false, grove_version, ) .cost_as_result() diff --git a/grovedb/src/batch/indexed_tree/pre_state.rs b/grovedb/src/batch/indexed_tree/pre_state.rs index 6496adb52..f4387f804 100644 --- a/grovedb/src/batch/indexed_tree/pre_state.rs +++ b/grovedb/src/batch/indexed_tree/pre_state.rs @@ -87,6 +87,7 @@ fn validate_indexed_child_ops( // internally derived rather than caller-claimed. GroveOp::Delete | GroveOp::DeleteTree(..) + | GroveOp::ReplaceBackwardReferenceFamilyMember { .. } | GroveOp::ReplaceTreeRootKey { .. } | GroveOp::InsertTreeWithRootHash { .. } | GroveOp::ReplaceNonMerkTreeRoot { .. } diff --git a/grovedb/src/batch/mod.rs b/grovedb/src/batch/mod.rs index 1ba58a71e..9d473b790 100644 --- a/grovedb/src/batch/mod.rs +++ b/grovedb/src/batch/mod.rs @@ -1,5 +1,6 @@ //! Apply multiple GroveDB operations atomically. +mod backward_references; mod batch_structure; /// Indexed-tree helpers for the batch apply pipeline (pre-apply @@ -57,6 +58,7 @@ use grovedb_costs::{ }, CostResult, CostsExt, OperationCost, }; +use grovedb_element::ElementType; use grovedb_merk::{ element::{ costs::ElementCostExtensions, delete::ElementDeleteFromStorageExtensions, @@ -91,6 +93,7 @@ pub use crate::batch::batch_structure::{OpsByLevelPath, OpsByPath}; use crate::batch::estimated_costs::EstimatedCostsType; use crate::{ batch::{batch_structure::BatchStructure, mode::BatchRunMode}, + bidirectional_references::BidirectionalReference, element::{MaxReferenceHop, SumValue}, operations::{delete::DeleteOptions, get::MAX_REFERENCE_HOPS, proof::util::hex_to_ascii}, reference_path::{ @@ -519,6 +522,25 @@ pub enum GroveOp { /// `reference_path_type`, `max_reference_hop`, and `flags` are /// used only for the average / worst case cost models in /// untrusted mode. + /// INTERNAL — derived by the backward-references batch preprocessor, + /// never accepted from callers. Writes `element` with the explicitly + /// provided node value hash, already combined per the family's + /// two-layer scheme: a referrer rewrite carries + /// `combine(element_combined, end_hash)`, a lazy referrer-list cleanup + /// carries the cleaned element's own combined hash. + #[non_exhaustive] + ReplaceBackwardReferenceFamilyMember { + /// The full family element to store (referrer lists included). + element: Element, + /// The node value hash the write installs. + node_value_hash: CryptoHash, + /// The resolved end-of-chain hash a bidirectional reference commits + /// to (`None` for the item variants); lets merk recompute the node + /// value hash if a flags-update callback rewrites the stored bytes. + end_hash: Option, + }, + /// Re-resolves and rewrites a stored reference's value hash. See + /// [`RefreshReferenceMode`] for the per-variant contract. RefreshReference { /// The reference path written under trusted variants. Under /// untrusted variants the on-disk path is preserved; this @@ -606,6 +628,7 @@ impl GroveOp { GroveOp::ReplaceAggregateIndexedTreeRootKeys { .. } => 17, GroveOp::InsertAggregateIndexedTreeRootKeys { .. } => 18, GroveOp::PrivateDocumentStoreInsert { .. } => 19, + GroveOp::ReplaceBackwardReferenceFamilyMember { .. } => 20, } } @@ -637,6 +660,7 @@ impl GroveOp { // key; delete removes it. All require secondary mirror. GroveOp::InsertWithKnownToNotAlreadyExist { .. } | GroveOp::InsertIfNotExists { .. } + | GroveOp::ReplaceBackwardReferenceFamilyMember { .. } | GroveOp::InsertOrReplace { .. } | GroveOp::Replace { .. } | GroveOp::Patch { .. } @@ -693,6 +717,7 @@ impl GroveOp { match self { GroveOp::InsertWithKnownToNotAlreadyExist { .. } | GroveOp::InsertIfNotExists { .. } + | GroveOp::ReplaceBackwardReferenceFamilyMember { .. } | GroveOp::InsertOrReplace { .. } | GroveOp::Replace { .. } | GroveOp::Patch { .. } @@ -985,6 +1010,15 @@ impl fmt::Debug for QualifiedGroveDbOp { reference_path_type, max_reference_hop, mode_render, non_counted, ) } + GroveOp::ReplaceBackwardReferenceFamilyMember { + element, + node_value_hash, + .. + } => format!( + "Replace Backward-Reference Family Member {:?} (value hash {})", + element, + hex::encode(node_value_hash) + ), GroveOp::Delete => "Delete".to_string(), GroveOp::DeleteTree(tree_type, check) => { format!("Delete Tree {} ({:?})", tree_type, check) @@ -1980,9 +2014,9 @@ where )), }; - let referenced_element_value_hash_opt = cost_return_on_error!( + let referenced_value_and_hash_opt = cost_return_on_error!( &mut cost, - merk.get_value_hash( + merk.get_value_and_value_hash( key.as_ref(), true, Some(Element::value_defined_cost_for_serialized_value), @@ -1991,9 +2025,9 @@ where .map_err(|e| Error::CorruptedData(e.to_string())) ); - let referenced_element_value_hash = cost_return_on_error!( + let (referenced_value, referenced_element_value_hash) = cost_return_on_error!( &mut cost, - referenced_element_value_hash_opt + referenced_value_and_hash_opt .ok_or({ let reference_string = reference_path .iter() @@ -2009,6 +2043,48 @@ where .wrap_with_cost(OperationCost::default()) ); + // One exception to the read-the-stored-hash shortcut: a + // backward-references item terminal stores the COMBINED + // (inner ‖ backrefs) hash, while every reference in a chain + // commits to the target's LOGICAL (stripped) hash — otherwise + // registering a referrer would ripple through chains. Sniff the + // type from the serialized bytes and recompute for that family + // only; everything else keeps the fast path unchanged. + if ElementType::from_serialized_value(&referenced_value) + .map(|et| et.is_backward_references_item()) + .unwrap_or(false) + { + let element = cost_return_on_error_into_no_add!( + cost, + Element::deserialize(&referenced_value, grove_version) + ); + let serialized = cost_return_on_error_into_no_add!( + cost, + element + .stripped_of_backward_references() + .serialize(grove_version) + ); + let logical_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); + return Ok(logical_hash).wrap_with_cost(cost); + } + // A bidirectional reference here means the declared hop budget + // ran out one hop short of a terminal: its stored hash is the + // COMBINED reference hash, never the terminal logical hash a + // dependent reference commits to. Fail closed rather than bake + // a hash that verify_grovedb will report as corrupt. (Plain + // references keep the long-standing documented contract: an + // ill-formed hop-1 chain surfaces at verification instead.) + if matches!( + ElementType::from_serialized_value(&referenced_value).map(|et| et.base()), + Ok(ElementType::BidirectionalReference) + ) { + return Err(Error::InvalidBatchOperation( + "reference hop budget exhausted on a bidirectional reference; the chain \ + needs at least one more hop to reach its terminal", + )) + .wrap_with_cost(cost); + } + return Ok(referenced_element_value_hash).wrap_with_cost(cost); } @@ -2222,9 +2298,36 @@ where let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); Ok(val_hash).wrap_with_cost(cost) } - // Both reference variants follow the same chain-resolution path - // to compute their effective value hash. - Element::Reference(path, ..) | Element::ReferenceWithSumItem(path, ..) => { + // A chain terminal with backward references: every reference in + // a chain commits to the target's LOGICAL (stripped) hash — the + // referrer list is excluded so registrations never ripple + // through chains. (These elements reject aggregation wrappers, + // so the outer element IS the underlying one.) + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + let serialized = cost_return_on_error_into_no_add!( + cost, + element + .stripped_of_backward_references() + .serialize(grove_version) + ); + let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); + Ok(val_hash).wrap_with_cost(cost) + } + // All reference variants follow the same chain-resolution path + // to compute their effective value hash. A pre-existing + // `BidirectionalReference` (inserted through the non-batch + // path) resolves through its forward path like any reference. + Element::Reference(path, ..) + | Element::ReferenceWithSumItem(path, ..) + | Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: path, + .. + }, + _, + ) => { let path = cost_return_on_error_into_no_add!( cost, path_from_reference_qualified_path_type(path.clone(), qualified_path) @@ -2315,6 +2418,46 @@ where if let Some(op) = ops_by_qualified_paths.get(qualified_path) { // the path is being modified, inserted or deleted in the batch of operations match op { + // A derived backward-references rewrite: dependent chains + // commit to the LOGICAL (stripped) hash of item terminals + // and follow bidirectional references through their forward + // path, exactly like the on-disk dispatch below. + GroveOp::ReplaceBackwardReferenceFamilyMember { element, .. } => match element { + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + let serialized = cost_return_on_error_into_no_add!( + cost, + element + .stripped_of_backward_references() + .serialize(grove_version) + ); + let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); + Ok(val_hash).wrap_with_cost(cost) + } + Element::BidirectionalReference(reference, _) => { + let path = cost_return_on_error_into_no_add!( + cost, + path_from_reference_qualified_path_type( + reference.forward_reference_path.clone(), + qualified_path + ) + ); + self.follow_reference_get_value_hash( + path.as_slice(), + ops_by_qualified_paths, + recursions_allowed - 1, + flags_update, + split_removal_bytes, + visited, + grove_version, + ) + } + _ => Err(Error::CorruptedCodeExecution( + "derived backward-references op carries a non-family element", + )) + .wrap_with_cost(cost), + }, GroveOp::ReplaceTreeRootKey { .. } | GroveOp::InsertTreeWithRootHash { .. } | GroveOp::ReplaceNonMerkTreeRoot { .. } @@ -2384,6 +2527,91 @@ where } } } + // A pending write of a chain terminal with backward + // references: dependent references commit to the + // LOGICAL (stripped) hash — the referrer list is + // excluded so registrations never ripple through + // chains. (These elements reject aggregation + // wrappers, so the outer element IS the underlying + // one.) + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // Referrers commit to the LOGICAL (stripped) + // hash — and, exactly like the `Item` arm above, + // the hash must reflect the flags the APPLY path + // will actually write, so storage flags go + // through the same old-flags merge over the + // stripped shape. + let stripped = element.stripped_of_backward_references(); + let serialized = cost_return_on_error_into_no_add!( + cost, + stripped.serialize(grove_version) + ); + if element.get_flags().is_none() { + let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); + Ok(val_hash).wrap_with_cost(cost) + } else { + let mut new_element = stripped.clone(); + let (key, reference_path) = qualified_path + .split_last() + .expect("path validated non-empty above"); + let serialized_element_result = cost_return_on_error!( + &mut cost, + self.get_and_deserialize_referenced_element( + key, + reference_path, + grove_version + ) + ); + if let Some((old_element, old_serialized_element, is_in_sum_tree)) = + serialized_element_result + { + let value_hash = cost_return_on_error!( + &mut cost, + Self::process_old_element_flags( + key, + &serialized, + &mut new_element, + old_element, + &old_serialized_element, + is_in_sum_tree, + flags_update, + split_removal_bytes, + grove_version, + ) + ); + Ok(value_hash).wrap_with_cost(cost) + } else { + let value_hash = + value_hash(&serialized).unwrap_add_cost(&mut cost); + Ok(value_hash).wrap_with_cost(cost) + } + } + } + // A pending bidirectional reference resolves through + // its forward path like any reference. (Under the + // backward-references flag the preprocessor converts + // these ops into the derived form, handled above; + // this arm keeps the dispatch total.) + Element::BidirectionalReference(reference, _) => { + let path = cost_return_on_error_into_no_add!( + cost, + path_from_reference_qualified_path_type( + reference.forward_reference_path.clone(), + qualified_path + ) + ); + self.follow_reference_get_value_hash( + path.as_slice(), + ops_by_qualified_paths, + recursions_allowed - 1, + flags_update, + split_removal_bytes, + visited, + grove_version, + ) + } // Both reference variants follow the same chain. Element::Reference(path, ..) | Element::ReferenceWithSumItem(path, ..) => { let path = cost_return_on_error_into_no_add!( @@ -2441,6 +2669,40 @@ where let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); Ok(val_hash).wrap_with_cost(cost) } + // Same chain-hash convention as the InsertOrReplace + // group above: item terminals commit the stripped + // logical hash; a pending bidirectional reference + // resolves through its forward path. + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + let serialized = cost_return_on_error_into_no_add!( + cost, + element + .stripped_of_backward_references() + .serialize(grove_version) + ); + let val_hash = value_hash(&serialized).unwrap_add_cost(&mut cost); + Ok(val_hash).wrap_with_cost(cost) + } + Element::BidirectionalReference(reference, _) => { + let path = cost_return_on_error_into_no_add!( + cost, + path_from_reference_qualified_path_type( + reference.forward_reference_path.clone(), + qualified_path + ) + ); + self.follow_reference_get_value_hash( + path.as_slice(), + ops_by_qualified_paths, + recursions_allowed - 1, + flags_update, + split_removal_bytes, + visited, + grove_version, + ) + } Element::Reference(path, ..) | Element::ReferenceWithSumItem(path, ..) => { let path = cost_return_on_error_into_no_add!( cost, @@ -2677,6 +2939,51 @@ where let mut batch_operations: Vec<(Vec, Op)> = vec![]; for (key_info, op) in ops_at_path_by_key.into_iter() { match op { + // Derived by the backward-references preprocessor: write the + // full element with its precomputed combined node value hash + // (the two-layer scheme's combine for items; for referrer + // rewrites additionally combined with the resolved end + // hash). + GroveOp::ReplaceBackwardReferenceFamilyMember { + element, + node_value_hash, + end_hash, + } => { + use grovedb_merk::element::insert::ElementInsertToStorageExtensions; + // The same host-tree rules as direct insertion apply: + // notably, backward-references ITEM variants are not + // representable in Provable* aggregate hosts (no proof + // node binds both their combined value hash and the + // aggregate), so a derived rewrite may not create that + // combination either. + cost_return_on_error_into!( + &mut cost, + element + .validate_insertable_into(in_tree_type) + .wrap_with_cost(OperationCost::default()) + ); + let serialized = cost_return_on_error_into!( + &mut cost, + element + .serialize(grove_version) + .wrap_with_cost(OperationCost::default()) + ); + let merk_feature_type = cost_return_on_error_into!( + &mut cost, + element + .get_feature_type(in_tree_type) + .wrap_with_cost(OperationCost::default()) + ); + batch_operations.push(( + key_info.get_key(), + Op::PutWithProvidedValueHash( + serialized, + node_value_hash, + end_hash, + merk_feature_type, + ), + )); + } op_ref @ (GroveOp::InsertWithKnownToNotAlreadyExist { .. } | GroveOp::InsertIfNotExists { .. } | GroveOp::InsertOrReplace { .. } @@ -3317,6 +3624,97 @@ where ) ); } + // Unreachable backstop: unflagged batches reject + // bidirectional-reference ops at every entry point, + // and under the flag the preprocessor converts them + // into `ReplaceBackwardReferenceFamilyMember` (a + // reference's node hash needs its resolved end hash, + // which this generic path does not carry). + Element::BidirectionalReference(..) => { + return Err(Error::NotSupported( + "BidirectionalReference ops must go through the \ + backward-references batch preprocessor" + .to_owned(), + )) + .wrap_with_cost(cost); + } + // Backward-references items store their COMBINED + // (stripped ‖ referrer-list) hash; the preprocessor + // has already replaced the caller-supplied referrer + // list with the stored one. + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // Same guard the live `Element::insert` applies: + // the family's combined value hash has no + // aggregate-carrying proof-node variant, so + // Provable* aggregate parents refuse it. + { + use grovedb_merk::element::insert::ElementInsertToStorageExtensions; + cost_return_on_error_into!( + &mut cost, + element + .validate_insertable_into(in_tree_type) + .wrap_with_cost(OperationCost::default()) + ); + } + let merk_feature_type = cost_return_on_error_into!( + &mut cost, + element + .get_feature_type(in_tree_type) + .wrap_with_cost(OperationCost::default()) + ); + if is_insert_if_not_exists + || batch_apply_options.validate_insertion_does_not_override + { + let merk = self.merks.get_mut(path).expect("the Merk is cached"); + let exists = cost_return_on_error_into!( + &mut cost, + element.element_at_key_already_exists( + merk, + key_info.as_slice(), + grove_version + ) + ); + if exists + && (error_if_exists + || batch_apply_options.validate_insertion_does_not_override) + { + return Err(Error::InvalidBatchOperation( + "attempting to insert element that already exists", + )) + .wrap_with_cost(cost); + } + if exists { + // InsertIfNotExists over an existing key + // writes nothing. + continue; + } + } + let serialized = cost_return_on_error_into!( + &mut cost, + element + .serialize(grove_version) + .wrap_with_cost(OperationCost::default()) + ); + let hashes = { + use grovedb_merk::element::ElementExt; + cost_return_on_error_into!( + &mut cost, + element.backward_references_hashes(grove_version) + ) + .expect("backward-references elements carry hashes") + }; + batch_operations.push(( + key_info.get_key(), + Op::PutWithProvidedValueHash( + serialized, + hashes.combined, + None, + merk_feature_type, + ), + )); + } Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) => { let merk_feature_type = cost_return_on_error_into!( &mut cost, @@ -4754,6 +5152,16 @@ impl GroveDb { .wrap_with_cost(cost); } } + GroveOp::ReplaceBackwardReferenceFamilyMember { + .. + } => { + return Err(Error::InvalidBatchOperation( + "backward-references family members are \ + not trees and cannot receive child \ + propagation", + )) + .wrap_with_cost(cost); + } GroveOp::RefreshReference { .. } => { return Err(Error::InvalidBatchOperation( "insertion of element under a refreshed \ @@ -5037,6 +5445,13 @@ impl GroveDb { let mut cost = OperationCost::default(); for op in ops.into_iter() { match op.op { + GroveOp::ReplaceBackwardReferenceFamilyMember { .. } => { + return Err(Error::NotSupported( + "derived backward-references ops cannot be applied without batching" + .to_owned(), + )) + .wrap_with_cost(cost); + } GroveOp::InsertOrReplace { element } | GroveOp::Replace { element } => { // TODO: paths in batches is something to think about let path_slices: Vec<&[u8]> = @@ -5190,6 +5605,11 @@ impl GroveDb { .as_ref() .is_none_or(|o| o.base_root_storage_is_free), validate_tree_at_path_exists: false, + // Same decision as `as_delete_options`: the batch's + // opt-in extends to its deletes. + propagate_backward_references: options + .as_ref() + .is_some_and(|o| o.propagate_backward_references), }; cost_return_on_error!( &mut cost, @@ -5934,6 +6354,59 @@ impl GroveDb { Ok(scan).wrap_with_cost(cost) } + /// Backward-references family elements are only valid in batches that + /// opt into the bookkeeping via + /// `BatchApplyOptions::propagate_backward_references` (GROVE_V4+), + /// where the preprocessor expands them into the derived operations the + /// live flagged flow performs. Everywhere else (`allow_family` false: + /// unflagged batches, partial batches, partial-batch add-on ops) they + /// fail closed — the pipeline would otherwise silently produce + /// inconsistent backward-reference state (a `BidirectionalReference` + /// whose target never learns about it). + fn reject_backward_references_elements_in_batch( + ops: &[QualifiedGroveDbOp], + allow_family: bool, + ) -> Result<(), Error> { + for op in ops { + // The derived write op is internal to the preprocessor; a + // caller supplying one could install arbitrary value hashes. + if matches!(op.op, GroveOp::ReplaceBackwardReferenceFamilyMember { .. }) { + return Err(Error::NotSupported( + "ReplaceBackwardReferenceFamilyMember is derived internally and cannot be \ + supplied in a batch" + .to_owned(), + )); + } + let element = match &op.op { + GroveOp::InsertWithKnownToNotAlreadyExist { element } + | GroveOp::InsertIfNotExists { element, .. } + | GroveOp::InsertOrReplace { element } + | GroveOp::Replace { element } + | GroveOp::Patch { element, .. } => element, + _ => continue, + }; + if element.underlying().supports_backward_references() { + // Never wrapped: the wrappers' constructors refuse the family + // and deserialization rejects the shape, so a hand-built + // `NonCounted(family)` must not be written. + if element.is_wrapped() { + return Err(Error::InvalidBatchOperation( + "backward-references family elements cannot be wrapped in NonCounted / \ + NotSummed / NotCountedOrSummed", + )); + } + if !allow_family { + return Err(Error::NotSupported( + "backward-references family elements require \ + BatchApplyOptions::propagate_backward_references (GROVE_V4+)" + .to_owned(), + )); + } + } + } + Ok(()) + } + /// Applies batch of operations on GroveDB pub fn apply_batch_with_element_flags_update( &self, @@ -5987,6 +6460,56 @@ impl GroveDb { } } + // Backward-references bookkeeping is a per-batch opt-in, and rides + // the same activation as the live flagged flow (`GROVE_V4`+, where + // `insert_on_transaction` dispatches to v1). + let backward_references_enabled = batch_apply_options + .as_ref() + .map(|options| options.propagate_backward_references) + .unwrap_or(false) + && grove_version + .grovedb_versions + .operations + .insert + .insert_on_transaction + >= 1; + cost_return_on_error_no_add!( + cost, + Self::reject_backward_references_elements_in_batch(&ops, backward_references_enabled) + ); + let ops = if backward_references_enabled { + let ops = cost_return_on_error!( + &mut cost, + backward_references::expand_backward_references_ops( + self, + &tx, + ops, + batch_apply_options + .as_ref() + .map(|options| options.validate_insertion_does_not_override) + .unwrap_or_default(), + grove_version + ) + ); + // The expansion merges derived mutations into the batch under + // the M4 conflict rules and errors on everything ambiguous, so + // the expanded set should always be consistent; this re-check + // is a backstop against expansion bugs. + if check_batch_operation_consistency { + let consistency_result = QualifiedGroveDbOp::verify_consistency_of_operations(&ops); + if !consistency_result.is_empty() { + return Err(Error::InvalidBatchOperation( + "derived backward-references operations conflict with the batch's own \ + operations", + )) + .wrap_with_cost(cost); + } + } + ops + } else { + ops + }; + cost_return_on_error!( &mut cost, indexed_tree::reject_indexed_overwrite_with_descendants( @@ -6431,6 +6954,11 @@ impl GroveDb { } } + cost_return_on_error_no_add!( + cost, + Self::reject_backward_references_elements_in_batch(&ops, false) + ); + cost_return_on_error!( &mut cost, indexed_tree::reject_indexed_overwrite_with_descendants( @@ -6613,6 +7141,14 @@ impl GroveDb { } } + // The callback is caller-provided, so add-on ops get the same + // backward-references gate the initial ops got — otherwise an op + // carrying the family would only fail deep inside execution. + cost_return_on_error_no_add!( + cost, + Self::reject_backward_references_elements_in_batch(&new_operations, false) + ); + // we are trying to finalize batch_apply_options.batch_pause_height = None; @@ -7165,6 +7701,7 @@ mod tests { disable_operation_consistency_check: true, base_root_storage_is_free: true, batch_pause_height: None, + propagate_backward_references: false, }), None, grove_version @@ -7770,6 +8307,7 @@ mod tests { disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, + propagate_backward_references: false, }), None, grove_version @@ -7811,6 +8349,7 @@ mod tests { validate_insertion_does_not_override: true, base_root_storage_is_free: true, batch_pause_height: None, + propagate_backward_references: false, }), None, grove_version @@ -7844,6 +8383,7 @@ mod tests { disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, + propagate_backward_references: false, }), None, grove_version diff --git a/grovedb/src/batch/options.rs b/grovedb/src/batch/options.rs index 5157f036c..b38c156f9 100644 --- a/grovedb/src/batch/options.rs +++ b/grovedb/src/batch/options.rs @@ -73,6 +73,14 @@ pub struct BatchApplyOptions { /// At what height do we want to pause applying batch operations /// Most of the time this should be not set pub batch_pause_height: Option, + /// Opt into backward-references bookkeeping for this batch: ops + /// carrying the backward-references family (`BidirectionalReference` + /// and the three backward-references item variants) become valid, and + /// the batch expands into the derived registration / propagation / + /// cascade operations the live flagged flow would perform — including + /// references whose targets are created in the same batch. See + /// `batch::backward_references` for the expansion and conflict rules. + pub propagate_backward_references: bool, } #[cfg(feature = "minimal")] @@ -84,6 +92,7 @@ impl Default for BatchApplyOptions { disable_operation_consistency_check: false, base_root_storage_is_free: true, batch_pause_height: None, + propagate_backward_references: false, } } } @@ -97,6 +106,7 @@ impl BatchApplyOptions { validate_insertion_does_not_override_tree: self .validate_insertion_does_not_override_tree, base_root_storage_is_free: self.base_root_storage_is_free, + propagate_backward_references: self.propagate_backward_references, } } @@ -107,6 +117,11 @@ impl BatchApplyOptions { deleting_non_empty_trees_returns_error: true, base_root_storage_is_free: self.base_root_storage_is_free, validate_tree_at_path_exists: false, + // Forwarded like `as_insert_options` does: a batch that opts + // into backward-references bookkeeping keeps its deletes + // flagged too, so registered targets cascade instead of + // silently dangling. + propagate_backward_references: self.propagate_backward_references, } } diff --git a/grovedb/src/bidirectional_references/handling.rs b/grovedb/src/bidirectional_references/handling.rs new file mode 100644 index 000000000..4d12847a1 --- /dev/null +++ b/grovedb/src/bidirectional_references/handling.rs @@ -0,0 +1,314 @@ +//! The `MerkCache` driver for backward-references bookkeeping. +//! +//! All decisions live in the pure planners of [`super::semantics`]; this +//! module supplies the two halves the planners abstract over: +//! - [`MerkCacheChainStore`], the read-only [`ChainStore`] view backed by +//! the transaction's `MerkCache` (so planning sees uncommitted writes of +//! EARLIER operations), and +//! - [`apply_plan`], which executes a [`Plan`]'s mutations through the same +//! cache in order. +//! +//! Backward references live ON their target element and are covered by the +//! node hash through the two-layer scheme described in +//! `grovedb_element::bidirectional_reference`: forward references commit to +//! the target's INNER (stripped) hash, so registering or removing a +//! referrer rewrites only the target itself — never the hashes stored by +//! other referrers. + +use grovedb_costs::{ + cost_return_on_error, storage_cost::removal::StorageRemovedBytes, CostResult, CostsExt, +}; +use grovedb_merk::{ + element::{ + delete::ElementDeleteFromStorageExtensions, + get::ElementFetchFromStorageExtensions, + insert::{Delta, ElementInsertToStorageExtensions}, + }, + CryptoHash, +}; +use grovedb_path::{SubtreePath, SubtreePathBuilder}; + +use super::{ + semantics::{ + plan_element_update, plan_reference_insertion, ChainStore, DerivedMutation, Plan, + ResolvedPosition, + }, + BidirectionalReference, +}; +use crate::{ + merk_cache::MerkCache, + merk_cache::MerkHandle, + operations::insert::InsertOptions, + reference_path::{follow_reference, follow_reference_once, ReferencePathType}, + Element, Error, +}; + +/// The caller's sectioned-removal policy (flags → how removed key/value +/// bytes are accounted), threaded through every deletion a plan performs so +/// cascaded referrers get the same refund allocation — and the same +/// callback errors — as the element the caller deleted directly. +pub(crate) type SectionedRemovalFn<'a> = + &'a mut dyn FnMut( + &Vec, + u32, + u32, + ) + -> Result<(StorageRemovedBytes, StorageRemovedBytes), grovedb_merk::Error>; + +/// The default policy for flows that carry no caller callback (inserts): +/// plain basic removal accounting. +pub(crate) fn basic_sectioned_removal() -> impl FnMut( + &Vec, + u32, + u32, +) -> Result< + (StorageRemovedBytes, StorageRemovedBytes), + grovedb_merk::Error, +> { + |_flags: &Vec, removed_key_bytes: u32, removed_value_bytes: u32| { + Ok(( + StorageRemovedBytes::BasicStorageRemoval(removed_key_bytes), + StorageRemovedBytes::BasicStorageRemoval(removed_value_bytes), + )) + } +} + +/// [`ChainStore`] over the transaction's `MerkCache`. +struct MerkCacheChainStore<'c, 'db, 'b, B: AsRef<[u8]>>(&'c MerkCache<'db, 'b, B>); + +impl<'c, 'db, 'b, B: AsRef<[u8]>> MerkCacheChainStore<'c, 'db, 'b, B> { + fn builder(&self, path: &[Vec]) -> SubtreePathBuilder<'b, B> { + SubtreePathBuilder::owned_from_iter(path) + } +} + +impl<'c, 'db, 'b, B: AsRef<[u8]>> ChainStore for MerkCacheChainStore<'c, 'db, 'b, B> { + fn element_at(&self, path: &[Vec], key: &[u8]) -> CostResult, Error> { + let mut cost = Default::default(); + let mut merk = cost_return_on_error!(&mut cost, self.0.get_merk(self.builder(path))); + merk.for_merk(|m| { + Element::get_optional(m, key, true, self.0.version).map_err(Error::MerkError) + }) + .add_cost(cost) + } + + fn resolve_once( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + follow_reference_once(self.0, self.builder(path), key, reference_path).map_ok(|resolved| { + ResolvedPosition { + path: resolved.target_path.to_vec(), + key: resolved.target_key, + element: resolved.target_element, + node_value_hash: resolved.target_node_value_hash, + hops: resolved.hops, + } + }) + } + + fn resolve_chain( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + follow_reference(self.0, self.builder(path), key, reference_path).map_ok(|resolved| { + ResolvedPosition { + path: resolved.target_path.to_vec(), + key: resolved.target_key, + element: resolved.target_element, + node_value_hash: resolved.target_node_value_hash, + hops: resolved.hops, + } + }) + } + + fn version(&self) -> &grovedb_version::version::GroveVersion { + self.0.version + } +} + +/// Execute a single derived write through the cache. Bidirectional +/// references are written through `insert_reference` with their resolved +/// end hash; the item variants derive their combined hash from the bytes. +fn apply_write( + merk: &mut MerkHandle<'_, '_>, + key: &[u8], + element: Element, + end_hash: Option, + options: Option, + version: &grovedb_version::version::GroveVersion, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + match (&element, end_hash) { + (Element::BidirectionalReference(..), Some(end_hash)) => { + cost_return_on_error!( + &mut cost, + merk.for_merk(|m| { + element + .insert_reference( + m, + key, + end_hash, + options.map(|o| o.as_merk_options()), + version, + ) + .map_err(Error::MerkError) + }) + ); + } + (Element::BidirectionalReference(..), None) => { + return Err(Error::InternalError( + "rewriting a bidirectional reference requires its resolved end hash".to_owned(), + )) + .wrap_with_cost(cost); + } + _ => { + cost_return_on_error!( + &mut cost, + merk.for_merk(|m| { + element + .insert(m, key, options.map(|o| o.as_merk_options()), version) + .map_err(Error::MerkError) + }) + ); + } + } + Ok(()).wrap_with_cost(cost) +} + +/// Apply a plan's mutations in order through the `MerkCache`. +/// `primary_options` are the caller's insert options, applied to the plan's +/// primary write only (the user-visible element the plan was derived from); +/// `sectioned_removal` is the caller's removal-accounting policy, applied to +/// every deletion the plan performs. +fn apply_plan<'b, B: AsRef<[u8]>>( + merk_cache: &MerkCache<'_, 'b, B>, + plan: Plan, + primary_options: Option, + sectioned_removal: SectionedRemovalFn<'_>, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + + for mutation in plan.mutations { + match mutation { + DerivedMutation::Write { + path, + key, + element, + end_hash, + is_primary, + } => { + let mut merk = cost_return_on_error!( + &mut cost, + merk_cache.get_merk(SubtreePathBuilder::owned_from_iter(&path)) + ); + let options = if is_primary { + primary_options.clone() + } else { + None + }; + cost_return_on_error!( + &mut cost, + apply_write( + &mut merk, + &key, + element, + end_hash, + options, + merk_cache.version + ) + ); + } + DerivedMutation::Delete { path, key } => { + let mut merk = cost_return_on_error!( + &mut cost, + merk_cache.get_merk(SubtreePathBuilder::owned_from_iter(&path)) + ); + cost_return_on_error!( + &mut cost, + merk.for_merk(|m| { + Element::delete_with_sectioned_removal_bytes( + m, + &key, + None, + false, + m.tree_type, + &mut |flags: &Vec, removed_key_bytes, removed_value_bytes| { + sectioned_removal(flags, removed_key_bytes, removed_value_bytes) + }, + merk_cache.version, + ) + .map_err(Error::MerkError) + }) + ); + } + } + } + + Ok(()).wrap_with_cost(cost) +} + +/// Insert bidirectional reference at specified location performing required +/// checks and updates. +pub(crate) fn process_bidirectional_reference_insertion<'b, B: AsRef<[u8]>>( + merk_cache: &MerkCache<'_, 'b, B>, + path: SubtreePath<'b, B>, + key: &[u8], + reference: BidirectionalReference, + flags: Option, + options: Option, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + + let store = MerkCacheChainStore(merk_cache); + let plan = cost_return_on_error!( + &mut cost, + plan_reference_insertion(&store, &path.to_vec(), key, reference, flags) + ); + let Some(plan) = plan else { + // Identical logical edge: a true no-op. + return Ok(()).wrap_with_cost(cost); + }; + // Insertion plans never delete, so no caller removal policy applies. + apply_plan(merk_cache, plan, options, &mut basic_sectioned_removal()).add_cost(cost) +} + +/// Post-processing of possible backward references relationships after +/// insertion of anything but bidirectional reference (because there is +/// [process_bidirectional_reference_insertion] for that). +/// +/// `sectioned_removal` is the caller's removal-accounting policy; it governs +/// every referrer a cascade deletes, exactly as it governed the element the +/// caller updated. +pub(crate) fn process_update_element_with_backward_references<'db, 'b, 'c, B: AsRef<[u8]>>( + merk_cache: &'c MerkCache<'db, 'b, B>, + merk: MerkHandle<'db, 'c>, + path: SubtreePathBuilder<'b, B>, + key: &[u8], + delta: Delta, + sectioned_removal: SectionedRemovalFn<'_>, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + let _ = merk; + + // On no changes no propagations shall happen: + if !delta.has_changed() { + return Ok(()).wrap_with_cost(cost); + } + + // If there was no overwrite we short-circuit as well: + let Some(old) = delta.old else { + return Ok(()).wrap_with_cost(cost); + }; + + let store = MerkCacheChainStore(merk_cache); + let plan = cost_return_on_error!( + &mut cost, + plan_element_update(&store, &path.to_vec(), key, old, delta.new.cloned()) + ); + apply_plan(merk_cache, plan, None, sectioned_removal).add_cost(cost) +} diff --git a/grovedb/src/bidirectional_references/mod.rs b/grovedb/src/bidirectional_references/mod.rs new file mode 100644 index 000000000..6f2b0eb9b --- /dev/null +++ b/grovedb/src/bidirectional_references/mod.rs @@ -0,0 +1,25 @@ +//! Bidirectional references management module. +//! +//! The type definitions ([`BidirectionalReference`] and friends) live in the +//! `grovedb-element` crate because the `Element` enum embeds them; this +//! module hosts the propagation machinery: backward-reference meta storage +//! bookkeeping, hash propagation along reference chains, and cascade +//! deletion. See `adr/bidirectional_references.md`. + +mod handling; +pub(crate) mod semantics; +pub(crate) use semantics::check_carried_referrers_fit; + +pub use grovedb_element::{BackwardReference, BidirectionalReference}; +pub(crate) use handling::*; + +/// Maximum Grove path depth (number of subtree levels) of any position +/// participating in a bidirectional edge — the referrer's own position and +/// its resolved target. Enforced at registration time by the shared +/// semantic core, so every later derived write (propagation rewrite, +/// cascade deletion, registration cleanup) is guaranteed to land at a +/// bounded depth. Estimation relies on this: each derived foreign-subtree +/// propagation charges up to this many ancestor updates, which would be +/// unboundable otherwise (a referrer parked arbitrarily deep would make +/// its propagation cost exceed any fixed estimate). +pub const MAX_BACKWARD_REFERENCES_GROVE_DEPTH: usize = 32; diff --git a/grovedb/src/bidirectional_references/semantics.rs b/grovedb/src/bidirectional_references/semantics.rs new file mode 100644 index 000000000..dd43620a2 --- /dev/null +++ b/grovedb/src/bidirectional_references/semantics.rs @@ -0,0 +1,1629 @@ +//! The pure semantic core of backward-references bookkeeping. +//! +//! Every rule about WHAT a write to the backward-references family implies — +//! which target gains a registration, which referrers get rewritten with a +//! new end hash, which chains cascade away, which budgets bound the result — +//! lives here as planning functions over an abstract read-only view of the +//! grove ([`ChainStore`]). Planners never mutate anything: they return a +//! [`Plan`], an ordered list of [`DerivedMutation`]s for the driver to +//! apply. +//! +//! Two drivers share this core so their semantics cannot drift: +//! - the live `MerkCache` flow in [`super::handling`], which applies the +//! plan inside the current transaction, and +//! - the (future) `apply_batch` preprocessor, which turns the plan into +//! ordinary batch ops. +//! +//! Planners must never depend on their own writes: wherever the old +//! interleaved code re-read a value it had just written, the planner now +//! threads the known new value explicitly. Reads through [`ChainStore`] +//! observe the PRE-plan state plus whatever the driver already committed. + +use std::collections::VecDeque; + +use grovedb_costs::{cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt}; +use grovedb_merk::{element::ElementExt, CryptoHash}; + +use super::{BackwardReference, BidirectionalReference}; +use crate::{ + operations::get::MAX_REFERENCE_HOPS, + reference_path::{path_from_reference_path_type, ReferencePathType}, + Element, Error, +}; + +/// A fully-qualified element position: subtree path segments plus the key. +pub(crate) type Position = (Vec>, Vec); + +/// The outcome of resolving a reference (one hop or a full chain) through a +/// [`ChainStore`]. +pub(crate) struct ResolvedPosition { + pub path: Vec>, + pub key: Vec, + pub element: Element, + /// The node value hash of the resolved element (for a full-chain + /// resolution: the hash every chain member commits to). + pub node_value_hash: CryptoHash, + /// Reference edges traversed (1 for a direct target). + pub hops: usize, +} + +impl ResolvedPosition { + pub(crate) fn position(&self) -> Position { + (self.path.clone(), self.key.clone()) + } +} + +/// Read-only view of the grove the planners run against. Implementations +/// must surface the same error vocabulary the live flows use — notably +/// `Error::CorruptedReferencePathKeyNotFound` for dangling resolutions, +/// which several rules deliberately tolerate. +pub(crate) trait ChainStore { + /// The element at the position, or `None` when the key is absent. + fn element_at(&self, path: &[Vec], key: &[u8]) -> CostResult, Error>; + + /// Resolve exactly one reference hop from `(path, key)` along + /// `reference_path`. + fn resolve_once( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult; + + /// Resolve the full chain from `(path, key)` along `reference_path`, + /// applying the global hop budget and cycle detection seeded with the + /// starting position (so a chain looping back through the start is + /// reported as `Error::CyclicReference`). + fn resolve_chain( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult; + + /// The grove version planning runs under. + fn version(&self) -> &grovedb_version::version::GroveVersion; + + /// The DECLARED final edge of a pending-but-not-yet-planned + /// bidirectional-reference write at the position, when the driver + /// knows of one (the batch preprocessor's deferred pass-2 ops). The + /// prospective component must be validated against these declarations + /// — a stored ancestor's budget may be raised, or its edge retargeted + /// away, by an op in the same unordered batch. Live flows have no + /// pending ops and use the default. + fn pending_reference_at( + &self, + _path: &[Vec], + _key: &[u8], + ) -> Option<(ReferencePathType, Option)> { + None + } +} + +/// One mutation a plan requires. Mutations are ordered; drivers apply them +/// in sequence. +#[derive(Debug)] +pub(crate) enum DerivedMutation { + /// Write `element` at the position. For a bidirectional reference, + /// `end_hash` carries the resolved end-of-chain hash its node commits + /// to; for the other family members it is `None` (their combined hash + /// derives from the element bytes alone). + Write { + path: Vec>, + key: Vec, + element: Element, + end_hash: Option, + /// Set only for the user-visible write a plan was derived FROM + /// (the inserted reference itself), so the driver can apply the + /// caller's merk options to exactly that write. + is_primary: bool, + }, + /// Delete the element at the position (a cascade member). + Delete { path: Vec>, key: Vec }, +} + +/// An ordered list of derived mutations. +/// +/// A position is written at most once: a later write of a position the +/// plan already writes folds into the earlier slot (last element and end +/// hash win, the primary marker is kept). Planners read the pre-plan store, +/// never their own earlier writes, so folding changes no planning decision +/// — it only spares drivers an intermediate overwrite (and, for the primary +/// position, keeps the caller's insert options on the write that becomes +/// final: propagation's lazy referrer-list cleanup can land on the very +/// reference being inserted). +#[derive(Debug, Default)] +pub(crate) struct Plan { + pub mutations: Vec, +} + +impl Plan { + fn write( + &mut self, + path: Vec>, + key: Vec, + element: Element, + end_hash: Option, + ) { + self.push_write(path, key, element, end_hash, false); + } + + fn write_primary( + &mut self, + path: Vec>, + key: Vec, + element: Element, + end_hash: Option, + ) { + self.push_write(path, key, element, end_hash, true); + } + + fn push_write( + &mut self, + path: Vec>, + key: Vec, + element: Element, + end_hash: Option, + is_primary: bool, + ) { + let existing = self.mutations.iter_mut().find(|mutation| { + matches!( + mutation, + DerivedMutation::Write { + path: existing_path, + key: existing_key, + .. + } if *existing_path == path && *existing_key == key + ) + }); + match existing { + Some(DerivedMutation::Write { + element: existing_element, + end_hash: existing_end_hash, + is_primary: existing_is_primary, + .. + }) => { + *existing_element = element; + *existing_end_hash = end_hash; + *existing_is_primary |= is_primary; + } + _ => self.mutations.push(DerivedMutation::Write { + path, + key, + element, + end_hash, + is_primary, + }), + } + } + + fn delete(&mut self, path: Vec>, key: Vec) { + self.mutations.push(DerivedMutation::Delete { path, key }); + } +} + +/// A backward entry only identifies its referrer by position. Before acting +/// on the occupant of that position, confirm it really is the registered +/// referrer: a `BidirectionalReference` whose forward path resolves back to +/// the element carrying the entry. +pub(crate) fn referrer_points_back( + origin_element: &Element, + origin_path: &[Vec], + origin_key: &[u8], + expected_path: &[Vec], + expected_key: &[u8], +) -> bool { + let Element::BidirectionalReference(reference, _) = origin_element else { + return false; + }; + let Ok(forward_qualified) = path_from_reference_path_type( + reference.forward_reference_path.clone(), + origin_path, + Some(origin_key), + ) else { + return false; + }; + let mut expected_qualified = expected_path.to_vec(); + expected_qualified.push(expected_key.to_vec()); + forward_qualified == expected_qualified +} + +/// Upsert `entry` into `target`'s referrer list (a referrer is identified +/// by its inverted path) and enforce the budgets. Returns the updated +/// element. +fn with_registration(target: Element, entry: BackwardReference) -> Result { + let mut target = target; + let capacity = target + .max_incoming_references() + .ok_or(Error::BidirectionalReferenceRule( + "target does not support backward references".to_owned(), + ))?; + { + let refs = target + .backward_references_mut() + .ok_or(Error::BidirectionalReferenceRule( + "target does not support backward references".to_owned(), + ))?; + if let Some(existing) = refs + .iter_mut() + .find(|r| r.inverted_reference == entry.inverted_reference) + { + *existing = entry; + } else { + // The target declares how many referrers it accepts (an item's + // `max_incoming`, one for a bidirectional reference); a full + // list refuses the registration rather than growing past what + // the target's owner agreed to pay for on updates. + if refs.len() >= capacity as usize { + return Err(Error::BidirectionalReferenceRule(format!( + "the target accepts at most {capacity} incoming backward references and is \ + full" + ))); + } + refs.push(entry); + } + } + target.validate_backward_references_limits().map_err(|e| { + Error::BidirectionalReferenceRule(format!("backward references budget exceeded: {e}")) + })?; + Ok(target) +} + +/// A family item written over a position that carries referrers keeps +/// that referrer list (drivers carry it over before planning); the list +/// must fit the capacity the NEW element declares. Lowering an item's +/// capacity below its registered referrers is refused, raising it is +/// always accepted. +pub(crate) fn check_carried_referrers_fit(new: &Element) -> Result<(), Error> { + new.validate_backward_references_limits().map_err(|_| { + let registered = new.backward_references().map(|r| r.len()).unwrap_or(0); + let capacity = new.max_incoming_references().unwrap_or(0); + Error::BidirectionalReferenceRule(format!( + "the element declares a capacity of {capacity} incoming backward references but \ + {registered} referrers are registered on it; a capacity cannot be lowered below \ + the registered referrers" + )) + }) +} + +/// Plan the removal of the referrer entry matching the inversion of +/// `forward_reference_path` from whatever it resolves to (as seen from +/// `(current_path, current_key)`). Missing targets and missing entries are +/// tolerated — consistency can legitimately be bypassed by unflagged +/// writes. +fn plan_remove_registration( + store: &impl ChainStore, + plan: &mut Plan, + current_path: &[Vec], + current_key: &[u8], + forward_reference_path: ReferencePathType, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + + let inverted_reference = cost_return_on_error_no_add!( + cost, + forward_reference_path + .invert(grovedb_path::SubtreePath::from(current_path), current_key) + .ok_or_else(|| Error::BidirectionalReferenceRule( + "unable to get an inverted reference".to_owned() + )) + ); + + match store + .resolve_once(current_path, current_key, forward_reference_path) + .unwrap_add_cost(&mut cost) + { + Ok(resolved) => { + let mut target_element = resolved.element; + let Some(refs) = target_element.backward_references_mut() else { + // The target was overwritten by something without backward + // references support through a path that skipped + // bookkeeping; nothing to clean. + return Ok(()).wrap_with_cost(cost); + }; + let before = refs.len(); + refs.retain(|r| r.inverted_reference != inverted_reference); + if refs.len() == before { + // Entry already gone — tolerated. + return Ok(()).wrap_with_cost(cost); + } + // Rewriting a bidirectional-reference target needs the end hash + // its node commits to. + let end_hash = if let Element::BidirectionalReference(ref reference, _) = target_element + { + Some( + cost_return_on_error!( + &mut cost, + store.resolve_chain( + &resolved.path, + &resolved.key, + reference.forward_reference_path.clone(), + ) + ) + .node_value_hash, + ) + } else { + None + }; + plan.write(resolved.path, resolved.key, target_element, end_hash); + } + // We tolerate missing references because consistency can be + // bypassed, and out-of-sync situations might be common. + Err(Error::CorruptedReferencePathKeyNotFound(_)) => {} + Err(e) => return Err(e).wrap_with_cost(cost), + } + + Ok(()).wrap_with_cost(cost) +} + +/// Plan the insertion of a bidirectional reference at `(path, key)`: +/// target-eligibility and prospective-component checks, registration on the +/// target, the write of the reference itself, removal of a superseded +/// registration, and propagation to the reference's own referrers. +/// +/// Returns `None` when the insertion is an identical-edge no-op. +pub(crate) fn plan_reference_insertion( + store: &impl ChainStore, + path: &[Vec], + key: &[u8], + mut reference: BidirectionalReference, + flags: Option, +) -> CostResult, Error> { + let mut cost = Default::default(); + let mut plan = Plan::default(); + + // Read what the key currently holds first. The stored referrer list is + // carried over onto the new element (registrations survive an edge + // update), and re-inserting an identical edge must be a true no-op. + let previous_value = cost_return_on_error!(&mut cost, store.element_at(path, key)); + if let Some(Element::BidirectionalReference(ref old_ref, ref old_flags)) = previous_value { + // Carry the existing referrer list over. + reference.backward_references = old_ref.backward_references.clone(); + if old_ref.forward_reference_path == reference.forward_reference_path + && old_ref.cascade_on_update == reference.cascade_on_update + && old_ref.max_hop == reference.max_hop + && *old_flags == flags + { + // Identical logical edge: nothing changed. + return Ok(None).wrap_with_cost(cost); + } + } else { + // The referrer list is bookkeeping this module maintains; whatever + // the caller supplied is not theirs to claim. + reference.backward_references.clear(); + } + + // Since we limit what kind of elements a bidirectional reference can + // target, a check goes first: + let target = cost_return_on_error!( + &mut cost, + store.resolve_once(path, key, reference.forward_reference_path.clone()) + ); + + if !target.element.supports_backward_references() { + return Err(Error::BidirectionalReferenceRule( + "Bidirectional references can only point variants with backward references support" + .to_owned(), + )) + .wrap_with_cost(cost); + } + + // Both ends of the edge must sit at a bounded Grove depth: every later + // derived write (propagation, cascade, cleanup) lands at one of these + // positions, and cost estimation charges ancestor propagation up to + // exactly this bound — an unboundedly deep referrer would make its + // propagation cost exceed any fixed estimate. + if path.len() > super::MAX_BACKWARD_REFERENCES_GROVE_DEPTH + || target.path.len() > super::MAX_BACKWARD_REFERENCES_GROVE_DEPTH + { + return Err(Error::BidirectionalReferenceRule(format!( + "bidirectional-reference positions may sit at most {} subtree levels deep", + super::MAX_BACKWARD_REFERENCES_GROVE_DEPTH + ))) + .wrap_with_cost(cost); + } + + // If the closest target is a bidirectional reference itself, follow the + // FULL chain starting from the position being written: the resolved + // end-of-chain hash is what every chain member stores, and the chain + // resolution seeds its visited set with the starting qualified path — + // so a cycle that would only materialize AFTER the write is rejected + // before any mutation. + let (target_value_hash, downstream_hops) = + if let Element::BidirectionalReference(..) = target.element { + let resolved = cost_return_on_error!( + &mut cost, + store.resolve_chain(path, key, reference.forward_reference_path.clone()) + ); + (resolved.node_value_hash, resolved.hops) + } else { + (target.node_value_hash, 1) + }; + + // The edge's own declared budget must admit its downstream chain: + // public reads enforce `max_hop` deterministically, so an edge whose + // chain is already longer than its declaration would never resolve — + // reject it at insertion instead of persisting a dead edge. + if let Some(declared) = reference.max_hop + && downstream_hops > declared as usize + { + return Err(Error::BidirectionalReferenceRule(format!( + "the reference's chain needs {downstream_hops} hops but its max_hop declares \ + {declared}" + ))) + .wrap_with_cost(cost); + } + + // The whole PROSPECTIVE component must fit the global hop budget: + // downstream was just measured; upstream is this position's referrer + // chain (each bidirectional reference holds at most one referrer, so it + // is a single path). Without this, repeated retargets could splice + // independently valid segments into chains longer than any reader will + // follow. + let mut upstream_hops: usize = 0; + { + let mut current_refs = reference.backward_references.clone(); + let mut current_path = path.to_vec(); + let mut current_key = key.to_vec(); + while let Some(entry) = current_refs.first().cloned() { + match store + .resolve_once(¤t_path, ¤t_key, entry.inverted_reference) + .unwrap_add_cost(&mut cost) + { + Ok(resolved) => { + // The ancestor edge that governs the PROSPECTIVE + // component: a pending bidirectional-reference write + // at the ancestor's position (an op in the same batch, + // deferred to a later planning turn) supersedes the + // stored edge — its declared budget may be raised, or + // its edge retargeted away, in the same unordered + // batch, and acceptance must not depend on op order. + let effective_edge = + match store.pending_reference_at(&resolved.path, &resolved.key) { + Some((pending_forward, pending_max_hop)) => { + let mut current_qualified = current_path.clone(); + current_qualified.push(current_key.clone()); + let still_points_back = path_from_reference_path_type( + pending_forward, + &resolved.path, + Some(&resolved.key), + ) + .map(|qualified| qualified == current_qualified) + .unwrap_or(false); + if !still_points_back { + // The ancestor is being retargeted AWAY + // from this component; its budget no + // longer constrains it (the pending op is + // validated at its own turn). + break; + } + Some(pending_max_hop) + } + None => { + if !referrer_points_back( + &resolved.element, + &resolved.path, + &resolved.key, + ¤t_path, + ¤t_key, + ) { + // Dangling or stale entries end the live + // upstream chain. + break; + } + match resolved.element { + Element::BidirectionalReference(ref ancestor, _) => { + Some(ancestor.max_hop) + } + _ => None, + } + } + }; + // The ancestor is a LIVE member of the prospective + // component: only now does it consume a hop — + // detached or stale ancestors above must not count + // (or a retarget of A away from B would wrongly + // charge B's component for A's hop, rejecting a + // downstream chain at exactly the global budget). + upstream_hops += 1; + if upstream_hops + downstream_hops > MAX_REFERENCE_HOPS { + break; + } + // Each upstream ancestor's OWN declared budget must + // still admit its chain through the retargeted edge: + // from this ancestor the chain runs `upstream_hops` + // hops down to the position being written, then the + // new `downstream_hops` beyond it. Without this, a + // valid `A(max_hop=2) -> B -> C` breaks silently when + // B is retargeted onto a two-hop chain — reads + // through A would deterministically hit + // `ReferenceLimit`. + if let Some(Some(declared)) = effective_edge + && (declared as usize) < upstream_hops + downstream_hops + { + return Err(Error::BidirectionalReferenceRule(format!( + "an upstream referrer's chain would need {} hops but its max_hop \ + declares {declared}", + upstream_hops + downstream_hops + ))) + .wrap_with_cost(cost); + } + // Registrations carry over on edge rewrites, so the + // STORED referrer list continues the walk either way. + current_refs = resolved + .element + .backward_references() + .map(|refs| refs.to_vec()) + .unwrap_or_default(); + current_path = resolved.path; + current_key = resolved.key; + } + _ => break, + } + } + } + if upstream_hops + downstream_hops > MAX_REFERENCE_HOPS { + return Err(Error::BidirectionalReferenceRule(format!( + "the resulting reference component would exceed the global budget of {} hops", + MAX_REFERENCE_HOPS + ))) + .wrap_with_cost(cost); + } + + // Different `ReferencePathType` encodings can resolve to the same + // position, so "did the target change?" must compare RESOLVED + // positions, never encodings. When a retarget stays on the same + // target, its old entry is replaced on the element being registered + // (planners never read their own writes — a separate removal write + // computed from the pre-plan target would win over the registration + // and strip the live edge entirely). + let old_edge_same_target = match previous_value { + Some(Element::BidirectionalReference(ref old_ref, _)) => { + let old_qualified = path_from_reference_path_type( + old_ref.forward_reference_path.clone(), + path, + Some(key), + ) + .ok(); + let mut new_qualified = target.path.clone(); + new_qualified.push(target.key.clone()); + old_qualified == Some(new_qualified) + } + _ => false, + }; + + // Register the backward edge on the target: + let inverted_reference = cost_return_on_error_no_add!( + cost, + reference + .forward_reference_path + .invert(grovedb_path::SubtreePath::from(path), key) + .ok_or_else(|| Error::BidirectionalReferenceRule( + "unable to get an inverted reference".to_owned() + )) + ); + // Rewriting a bidirectional-reference target needs the end hash ITS + // node commits to — the same end-of-chain hash just resolved. + let end_hash_for_target = if matches!(target.element, Element::BidirectionalReference(..)) { + Some(target_value_hash) + } else { + None + }; + let mut target_element = target.element; + if old_edge_same_target + && let Some(Element::BidirectionalReference(ref old_ref, _)) = previous_value + && let Some(old_inverted) = old_ref + .forward_reference_path + .clone() + .invert(grovedb_path::SubtreePath::from(path), key) + && let Some(refs) = target_element.backward_references_mut() + { + // Same target under a different encoding: drop the old entry so + // the upsert below replaces it instead of accumulating a + // duplicate referrer. + refs.retain(|r| r.inverted_reference != old_inverted); + } + let registered_target = cost_return_on_error_no_add!( + cost, + with_registration( + target_element, + BackwardReference { + inverted_reference, + cascade_on_update: reference.cascade_on_update, + }, + ) + ); + plan.write( + target.path, + target.key, + registered_target, + end_hash_for_target, + ); + + // Write the new reference itself (its node hash combines its stripped + // bytes, the resolved end hash, and its carried referrer list). + plan.write_primary( + path.to_vec(), + key.to_vec(), + Element::BidirectionalReference(reference.clone(), flags.clone()), + Some(target_value_hash), + ); + + match previous_value { + // If previous value was another bidirectional reference, its + // backward registration on the OLD target must be removed. + Some(Element::BidirectionalReference(old_reference, _)) => { + // Same RESOLVED target (whatever the encoding): the old entry + // was already replaced in place on the registration write + // above, and a separate removal — planned from the pre-plan + // target — would win over that write and strip the edge + // entirely. + if !old_edge_same_target { + cost_return_on_error!( + &mut cost, + plan_remove_registration( + store, + &mut plan, + path, + key, + old_reference.forward_reference_path, + ) + ); + } + + // The chain now resolves to a new end hash; referrers of THIS + // reference must be updated with it. The planner threads the + // NEW element explicitly (the interleaved flow re-read its own + // write here). + cost_return_on_error!( + &mut cost, + plan_propagation( + store, + &mut plan, + path, + key, + Element::BidirectionalReference(reference, flags), + target_value_hash, + ) + ); + } + // Overwriting an item with backward references is an error: those + // may carry up to 32 registrations while a bidirectional reference + // supports only one. + Some( + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..), + ) => { + return Err(Error::BidirectionalReferenceRule( + "insertion of bidirectional reference cannot override elements with backward \ + references (item/sum item) since only one backward reference is supported for \ + bidirectional reference and those may have up to 32" + .to_owned(), + )) + .wrap_with_cost(cost) + } + // Fresh insertion or overwrite of a plain element: nothing extra. + _ => {} + } + + Ok(Some(plan)).wrap_with_cost(cost) +} + +/// Plan the follow-up for an already-performed update of the element at +/// `(path, key)`: `old` is what the position held, `new` what it holds now +/// (`None` for deletion). Handles propagation, cascade, and registration +/// removal per the family rules. Returns an empty plan when nothing is +/// required. +pub(crate) fn plan_element_update( + store: &impl ChainStore, + path: &[Vec], + key: &[u8], + old: Element, + new: Option, +) -> CostResult { + let mut cost = Default::default(); + let mut plan = Plan::default(); + + match (old, new) { + ( + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..), + Some( + new @ (Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..)), + ), + ) => { + // Update with another backward references-compatible element: + // referrers commit to the INNER hash, so propagate the new one + // along every chain. The carried-over referrers must fit the + // capacity the new element declares. + cost_return_on_error_no_add!(cost, check_carried_referrers_fit(&new)); + let new_logical_hash = cost_return_on_error!( + &mut cost, + new.logical_value_hash(store.version()).map_err(Error::from) + ); + cost_return_on_error!( + &mut cost, + plan_propagation(store, &mut plan, path, key, new, new_logical_hash) + ); + } + ( + old @ (Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..)), + _, + ) => { + // Update with a non-compatible element (or deletion) equals + // cascade deletion of the referrer chains. + cost_return_on_error!(&mut cost, plan_cascade(store, &mut plan, path, key, old)); + } + ( + Element::BidirectionalReference(old_reference, _), + Some( + new @ (Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..)), + ), + ) => { + // Overwrite of a bidirectional reference with a compatible item + // triggers propagation and removes the old backward + // registration on the old target. The reference's own referrer + // (carried over) must fit the item's declared capacity. + cost_return_on_error_no_add!(cost, check_carried_referrers_fit(&new)); + let new_logical_hash = cost_return_on_error!( + &mut cost, + new.logical_value_hash(store.version()).map_err(Error::from) + ); + cost_return_on_error!( + &mut cost, + plan_propagation(store, &mut plan, path, key, new, new_logical_hash) + ); + cost_return_on_error!( + &mut cost, + plan_remove_registration( + store, + &mut plan, + path, + key, + old_reference.forward_reference_path, + ) + ); + } + (Element::BidirectionalReference(old_reference, old_flags), _) => { + // Overwrite with a non-compatible element (or deletion): + // cascade the referrer chains away and remove the backward + // registration from where the reference used to point. + cost_return_on_error!( + &mut cost, + plan_cascade( + store, + &mut plan, + path, + key, + Element::BidirectionalReference(old_reference.clone(), old_flags.clone()), + ) + ); + cost_return_on_error!( + &mut cost, + plan_remove_registration( + store, + &mut plan, + path, + key, + old_reference.forward_reference_path, + ) + ); + } + _ => { + // All other overwrites don't require special attention. + } + } + + Ok(plan).wrap_with_cost(cost) +} + +/// Plan the recursive cascade deletion of every referrer chain of +/// `(path, key)`. `start_element` is the (already overwritten/deleted) +/// element whose referrer list seeds the cascade; every affected referrer +/// must have opted in via `cascade_on_update`. +pub(crate) fn plan_cascade( + store: &impl ChainStore, + plan: &mut Plan, + path: &[Vec], + key: &[u8], + start_element: Element, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + let mut queue = VecDeque::new(); + // Each node has exactly one forward edge, so reverse reachability from + // one start forms a tree; a revisit means the on-disk graph encodes a + // cycle, which insertion rejects — corrupted state, bail instead of + // looping forever. + let mut visited: std::collections::HashSet = Default::default(); + + visited.insert((path.to_vec(), key.to_vec())); + queue.push_back((path.to_vec(), key.to_vec(), start_element, true)); + + while let Some((current_path, current_key, current_element, first)) = queue.pop_front() { + let backward_references = current_element + .backward_references() + .map(|refs| refs.to_vec()) + .unwrap_or_default(); + + for backward_ref in backward_references { + let resolved = store + .resolve_once(¤t_path, ¤t_key, backward_ref.inverted_reference) + .unwrap_add_cost(&mut cost); + + let origin = match resolved { + Ok(resolved) => resolved, + // Dangling referrer (removed by an unflagged write or a + // batch): nothing left to cascade there. + Err(Error::CorruptedReferencePathKeyNotFound(_)) => continue, + Err(e) => return Err(e).wrap_with_cost(cost), + }; + + // A reused position holding something other than the registered + // referrer is stale bookkeeping, not a cascade member; the + // entry disappears with the element being deleted. + if !referrer_points_back( + &origin.element, + &origin.path, + &origin.key, + ¤t_path, + ¤t_key, + ) { + continue; + } + + // Consent is only required from referrers that still exist: a + // stale registration left behind by an unflagged write must not + // block deleting its former target. + if !backward_ref.cascade_on_update { + return Err(Error::BidirectionalReferenceRule( + "deletion of backward references through deletion of an element requires \ + `cascade_on_update` setting" + .to_owned(), + )) + .wrap_with_cost(cost); + } + + if !visited.insert(origin.position()) { + return Err(Error::CyclicReference).wrap_with_cost(cost); + } + queue.push_back((origin.path, origin.key, origin.element, false)); + } + + // Delete the element itself, unless it is the cascade's start (the + // original was already overwritten or deleted by the caller). + if !first { + plan.delete(current_path, current_key); + } + } + + Ok(()).wrap_with_cost(cost) +} + +/// Plan the propagation of a new end-of-chain value hash to every referrer +/// chain of `(path, key)`. `current` is the element now stored at the +/// position (threaded explicitly — the plan may include its own write, and +/// planners never read their own writes). Dangling and stale referrer +/// entries are lazily cleaned. +pub(crate) fn plan_propagation( + store: &impl ChainStore, + plan: &mut Plan, + path: &[Vec], + key: &[u8], + current: Element, + referenced_element_value_hash: CryptoHash, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + let mut queue = VecDeque::new(); + // See the identical bound in `plan_cascade`. + let mut visited: std::collections::HashSet = Default::default(); + + visited.insert((path.to_vec(), key.to_vec())); + queue.push_back((path.to_vec(), key.to_vec(), current)); + + while let Some((current_path, current_key, current_element)) = queue.pop_front() { + let backward_references = current_element + .backward_references() + .map(|refs| refs.to_vec()) + .unwrap_or_default(); + let mut dangling: Vec = Vec::new(); + + for backward_ref in backward_references { + let resolved = store + .resolve_once( + ¤t_path, + ¤t_key, + backward_ref.inverted_reference.clone(), + ) + .unwrap_add_cost(&mut cost); + + let origin = match resolved { + Ok(resolved) => resolved, + // Dangling referrer (removed by an unflagged write or a + // batch): clean the stale entry lazily and keep going. + Err(Error::CorruptedReferencePathKeyNotFound(_)) => { + dangling.push(backward_ref.inverted_reference); + continue; + } + Err(e) => return Err(e).wrap_with_cost(cost), + }; + + // A reused position holding something other than the registered + // referrer must not be rewritten — treat the entry as stale and + // clean it lazily like a dangling one. + if !referrer_points_back( + &origin.element, + &origin.path, + &origin.key, + ¤t_path, + ¤t_key, + ) { + dangling.push(backward_ref.inverted_reference); + continue; + } + + // Rewrite the referrer with the new end hash (its own referrer + // list rides along inside the element bytes). + plan.write( + origin.path.clone(), + origin.key.clone(), + origin.element.clone(), + Some(referenced_element_value_hash), + ); + + if !visited.insert(origin.position()) { + return Err(Error::CyclicReference).wrap_with_cost(cost); + } + queue.push_back((origin.path, origin.key, origin.element)); + } + + if !dangling.is_empty() { + // Drop the dangling entries from the current element and write + // it back (for a bidirectional reference the end hash it + // commits to is exactly the one being propagated). + let mut updated = current_element; + if let Some(refs) = updated.backward_references_mut() { + refs.retain(|r| !dangling.contains(&r.inverted_reference)); + } + let end_hash = if matches!(updated, Element::BidirectionalReference(..)) { + Some(referenced_element_value_hash) + } else { + None + }; + plan.write(current_path, current_key, updated, end_hash); + } + } + + Ok(()).wrap_with_cost(cost) +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, collections::HashMap}; + + use grovedb_version::version::GroveVersion; + + use super::*; + + /// A minimal in-memory [`ChainStore`]: proves the planners run against + /// any driver, not just the `MerkCache`. + struct MapStore { + elements: RefCell>, + version: &'static GroveVersion, + } + + impl MapStore { + fn new() -> Self { + Self { + elements: RefCell::new(HashMap::new()), + version: GroveVersion::latest(), + } + } + + fn put(&self, path: &[&[u8]], key: &[u8], element: Element) { + self.elements.borrow_mut().insert( + (path.iter().map(|p| p.to_vec()).collect(), key.to_vec()), + element, + ); + } + + fn resolve_position( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> Result<(Position, Element), Error> { + let qualified = path_from_reference_path_type(reference_path, path, Some(key))?; + let (target_key, target_path) = qualified + .split_last() + .ok_or(Error::CorruptedPath("empty reference".to_string()))?; + let position = (target_path.to_vec(), target_key.clone()); + let element = self + .elements + .borrow() + .get(&position) + .cloned() + .ok_or_else(|| { + Error::CorruptedReferencePathKeyNotFound("missing in mock store".to_string()) + })?; + Ok((position, element)) + } + } + + impl ChainStore for MapStore { + fn element_at(&self, path: &[Vec], key: &[u8]) -> CostResult, Error> { + Ok(self + .elements + .borrow() + .get(&(path.to_vec(), key.to_vec())) + .cloned()) + .wrap_with_cost(Default::default()) + } + + fn resolve_once( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + self.resolve_position(path, key, reference_path) + .map(|((path, key), element)| { + let node_value_hash = element + .logical_value_hash(self.version) + .unwrap() + .expect("mock elements hash"); + ResolvedPosition { + path, + key, + element, + node_value_hash, + hops: 1, + } + }) + .wrap_with_cost(Default::default()) + } + + fn resolve_chain( + &self, + path: &[Vec], + key: &[u8], + reference_path: ReferencePathType, + ) -> CostResult { + let mut visited: std::collections::HashSet = Default::default(); + visited.insert((path.to_vec(), key.to_vec())); + let mut hops = 0usize; + let mut current = (path.to_vec(), key.to_vec(), reference_path); + loop { + hops += 1; + if hops > MAX_REFERENCE_HOPS { + return Err(Error::ReferenceLimit).wrap_with_cost(Default::default()); + } + let (position, element) = + match self.resolve_position(¤t.0, ¤t.1, current.2.clone()) { + Ok(resolved) => resolved, + Err(e) => return Err(e).wrap_with_cost(Default::default()), + }; + if !visited.insert(position.clone()) { + return Err(Error::CyclicReference).wrap_with_cost(Default::default()); + } + match element { + Element::BidirectionalReference(reference, _) => { + current = (position.0, position.1, reference.forward_reference_path); + } + element => { + let node_value_hash = element + .logical_value_hash(self.version) + .unwrap() + .expect("mock elements hash"); + return Ok(ResolvedPosition { + path: position.0, + key: position.1, + element, + node_value_hash, + hops, + }) + .wrap_with_cost(Default::default()); + } + } + } + } + + fn version(&self) -> &grovedb_version::version::GroveVersion { + self.version + } + } + + fn sibling_bidi(key: &[u8], cascade: bool) -> BidirectionalReference { + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(key.to_vec()), + backward_references: Vec::new(), + cascade_on_update: cascade, + max_hop: None, + } + } + + const LEAF: &[u8] = b"leaf"; + + fn leaf() -> Vec> { + vec![LEAF.to_vec()] + } + + #[test] + fn insertion_plan_registers_then_writes_primary() { + let store = MapStore::new(); + store.put( + &[LEAF], + b"target", + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + ); + + let plan = + plan_reference_insertion(&store, &leaf(), b"ref", sibling_bidi(b"target", true), None) + .unwrap() + .unwrap() + .expect("not an identical edge"); + + assert_eq!(plan.mutations.len(), 2); + let DerivedMutation::Write { + key, + element, + end_hash, + is_primary, + .. + } = &plan.mutations[0] + else { + panic!("expected the target registration write"); + }; + assert_eq!(key, b"target"); + assert!(!is_primary); + assert!(end_hash.is_none(), "item targets carry no end hash"); + assert_eq!( + element.backward_references().unwrap().len(), + 1, + "the registration is on the element" + ); + let DerivedMutation::Write { + key, + end_hash, + is_primary, + .. + } = &plan.mutations[1] + else { + panic!("expected the primary reference write"); + }; + assert_eq!(key, b"ref"); + assert!(is_primary); + assert!(end_hash.is_some(), "the reference commits to the end hash"); + } + + /// Retargeting a reference whose own referrer list carries a dangling + /// entry: the primary write and propagation's lazy cleanup of that + /// entry land on the same position, so the plan folds them into ONE + /// primary write carrying the cleaned element (the caller's insert + /// options must apply to the write that becomes final). + #[test] + fn lazy_cleanup_on_the_inserted_reference_folds_into_the_primary_write() { + let store = MapStore::new(); + let registration = |referrer: &[u8]| BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(referrer.to_vec()), + cascade_on_update: true, + }; + store.put( + &[LEAF], + b"old", + Element::ItemWithBackwardsReferences( + b"o".to_vec(), + vec![registration(b"ref")].into(), + None, + ), + ); + store.put( + &[LEAF], + b"new", + Element::new_item_allowing_bidirectional_references(b"n".to_vec()), + ); + // `gone` was removed by an unflagged write: its registration on the + // reference is stale. + let mut existing = sibling_bidi(b"old", true); + existing.backward_references = vec![registration(b"gone")]; + store.put( + &[LEAF], + b"ref", + Element::BidirectionalReference(existing, None), + ); + + let plan = + plan_reference_insertion(&store, &leaf(), b"ref", sibling_bidi(b"new", true), None) + .unwrap() + .unwrap() + .expect("a retarget is not an identical edge"); + + let ref_writes: Vec<_> = plan + .mutations + .iter() + .filter( + |mutation| matches!(mutation, DerivedMutation::Write { key, .. } if key == b"ref"), + ) + .collect(); + assert_eq!(ref_writes.len(), 1, "one write per position: {plan:?}"); + let DerivedMutation::Write { + element, + end_hash, + is_primary, + .. + } = ref_writes[0] + else { + unreachable!("filtered on writes"); + }; + assert!(is_primary, "the folded write keeps the primary marker"); + assert!(end_hash.is_some(), "and the reference's end hash"); + let Element::BidirectionalReference(written, _) = element else { + panic!("the reference itself is written"); + }; + assert!( + written.backward_references.is_empty(), + "the dangling registration was cleaned on the same write" + ); + assert_eq!( + written.forward_reference_path, + ReferencePathType::SiblingReference(b"new".to_vec()) + ); + // The other two writes: registration on `new`, de-registration on + // `old`. + assert_eq!(plan.mutations.len(), 3, "{plan:?}"); + } + + /// A target that has reached its declared capacity refuses another + /// registration. + #[test] + fn registration_refuses_a_full_target() { + let store = MapStore::new(); + let registration = |referrer: &[u8]| BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(referrer.to_vec()), + cascade_on_update: true, + }; + store.put( + &[LEAF], + b"target", + Element::ItemWithBackwardsReferences( + b"v".to_vec(), + grovedb_element::BackwardReferences::new(1, vec![registration(b"first")]), + None, + ), + ); + store.put( + &[LEAF], + b"first", + Element::BidirectionalReference(sibling_bidi(b"target", true), None), + ); + + let err = plan_reference_insertion( + &store, + &leaf(), + b"second", + sibling_bidi(b"target", true), + None, + ) + .unwrap() + .expect_err("the target is full"); + assert!( + matches!(&err, Error::BidirectionalReferenceRule(msg) if msg.contains("full")), + "{err:?}" + ); + + // Re-registering the SAME referrer (an edge update) is an upsert, + // not a new registration, and still fits. + assert!(plan_reference_insertion( + &store, + &leaf(), + b"first", + sibling_bidi(b"target", false), + None + ) + .unwrap() + .is_ok()); + } + + /// Updating an item may raise its capacity freely but may not lower it + /// below the referrers already registered on it. + #[test] + fn update_cannot_lower_capacity_below_registered_referrers() { + let store = MapStore::new(); + let registration = |referrer: &[u8]| BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(referrer.to_vec()), + cascade_on_update: true, + }; + let registered = vec![registration(b"r1"), registration(b"r2")]; + for referrer in [b"r1".as_ref(), b"r2"] { + store.put( + &[LEAF], + referrer, + Element::BidirectionalReference(sibling_bidi(b"target", true), None), + ); + } + let old = Element::ItemWithBackwardsReferences( + b"v".to_vec(), + grovedb_element::BackwardReferences::new(4, registered.clone()), + None, + ); + store.put(&[LEAF], b"target", old.clone()); + + // Drivers carry the stored list onto the new element before + // planning; the capacity is whatever the caller declared. + let carried = |capacity: u16| { + Element::ItemWithBackwardsReferences( + b"w".to_vec(), + grovedb_element::BackwardReferences::new(capacity, registered.clone()), + None, + ) + }; + let err = plan_element_update(&store, &leaf(), b"target", old.clone(), Some(carried(1))) + .unwrap() + .expect_err("two referrers do not fit a capacity of one"); + assert!( + matches!(&err, Error::BidirectionalReferenceRule(msg) if msg.contains("lowered")), + "{err:?}" + ); + for capacity in [2u16, 8] { + let plan = plan_element_update( + &store, + &leaf(), + b"target", + old.clone(), + Some(carried(capacity)), + ) + .unwrap() + .expect("the registered referrers fit"); + assert_eq!( + plan.mutations.len(), + 2, + "both referrers are rewritten: {plan:?}" + ); + } + } + + #[test] + fn identical_edge_plans_nothing() { + let store = MapStore::new(); + store.put( + &[LEAF], + b"target", + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + ); + store.put( + &[LEAF], + b"ref", + Element::BidirectionalReference(sibling_bidi(b"target", true), None), + ); + + assert!( + plan_reference_insertion(&store, &leaf(), b"ref", sibling_bidi(b"target", true), None) + .unwrap() + .unwrap() + .is_none(), + "re-inserting an identical edge is a no-op" + ); + } + + #[test] + fn same_target_retarget_under_a_different_encoding_replaces_the_entry() { + let store = MapStore::new(); + // The target carries the sibling-encoded registration of `ref`. + let mut target = Element::new_item_allowing_bidirectional_references(b"v".to_vec()); + target + .backward_references_mut() + .unwrap() + .push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"ref".to_vec()), + cascade_on_update: true, + }); + store.put(&[LEAF], b"target", target); + store.put( + &[LEAF], + b"ref", + Element::BidirectionalReference(sibling_bidi(b"target", true), None), + ); + + // Re-point the SAME target through an absolute-path encoding. + let retarget = BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + LEAF.to_vec(), + b"target".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }; + let plan = plan_reference_insertion(&store, &leaf(), b"ref", retarget, None) + .unwrap() + .unwrap() + .expect("a changed encoding is a real edge update"); + + // Registration write + primary write, and nothing else: no + // separate removal write may race the registration. + assert_eq!(plan.mutations.len(), 2); + let DerivedMutation::Write { key, element, .. } = &plan.mutations[0] else { + panic!("expected the target registration write"); + }; + assert_eq!(key, b"target"); + let refs = element.backward_references().unwrap(); + assert_eq!( + refs.len(), + 1, + "the old entry must be REPLACED, not duplicated and not stripped" + ); + assert!( + matches!( + refs[0].inverted_reference, + ReferencePathType::AbsolutePathReference(..) + ), + "the surviving entry is the new encoding's inversion" + ); + } + + #[test] + fn cascade_plan_deletes_the_whole_chain_in_order() { + let store = MapStore::new(); + // item <- r1 <- r2 (registrations carried on the elements). + let mut item = Element::new_item_allowing_bidirectional_references(b"v".to_vec()); + item.backward_references_mut() + .unwrap() + .push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"r1".to_vec()), + cascade_on_update: true, + }); + let mut r1 = sibling_bidi(b"item", true); + r1.backward_references.push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"r2".to_vec()), + cascade_on_update: true, + }); + store.put(&[LEAF], b"item", item.clone()); + store.put(&[LEAF], b"r1", Element::BidirectionalReference(r1, None)); + store.put( + &[LEAF], + b"r2", + Element::BidirectionalReference(sibling_bidi(b"r1", true), None), + ); + + let mut plan = Plan::default(); + plan_cascade(&store, &mut plan, &leaf(), b"item", item) + .unwrap() + .unwrap(); + + let deleted: Vec<&[u8]> = plan + .mutations + .iter() + .map(|m| match m { + DerivedMutation::Delete { key, .. } => key.as_slice(), + other => panic!("cascade plans only deletes, got {other:?}"), + }) + .collect(); + assert_eq!(deleted, vec![b"r1".as_slice(), b"r2".as_slice()]); + } + + #[test] + fn cascade_plan_requires_consent() { + let store = MapStore::new(); + let mut item = Element::new_item_allowing_bidirectional_references(b"v".to_vec()); + item.backward_references_mut() + .unwrap() + .push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"r1".to_vec()), + cascade_on_update: false, + }); + store.put( + &[LEAF], + b"r1", + Element::BidirectionalReference(sibling_bidi(b"item", false), None), + ); + + let mut plan = Plan::default(); + assert!(matches!( + plan_cascade(&store, &mut plan, &leaf(), b"item", item).unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + } + + #[test] + fn propagation_plan_rewrites_live_referrers_and_cleans_stale_ones() { + let store = MapStore::new(); + let mut item = Element::new_item_allowing_bidirectional_references(b"v2".to_vec()); + for referrer in [b"live".as_slice(), b"gone"] { + item.backward_references_mut() + .unwrap() + .push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(referrer.to_vec()), + cascade_on_update: true, + }); + } + store.put(&[LEAF], b"item", item.clone()); + store.put( + &[LEAF], + b"live", + Element::BidirectionalReference(sibling_bidi(b"item", true), None), + ); + // `gone` is absent: a dangling entry to be lazily cleaned. + + let new_end_hash = [42u8; 32]; + let mut plan = Plan::default(); + plan_propagation(&store, &mut plan, &leaf(), b"item", item, new_end_hash) + .unwrap() + .unwrap(); + + assert_eq!(plan.mutations.len(), 2); + let DerivedMutation::Write { key, end_hash, .. } = &plan.mutations[0] else { + panic!("expected the live referrer rewrite"); + }; + assert_eq!(key, b"live"); + assert_eq!(*end_hash, Some(new_end_hash)); + let DerivedMutation::Write { key, element, .. } = &plan.mutations[1] else { + panic!("expected the lazy cleanup write"); + }; + assert_eq!(key, b"item"); + assert_eq!( + element.backward_references().unwrap().len(), + 1, + "the dangling entry is dropped, the live one kept" + ); + } + + #[test] + fn component_budget_bounds_the_prospective_chain() { + let store = MapStore::new(); + // Downstream chain of MAX_REFERENCE_HOPS - 1 references ending on an + // item, plus one upstream referrer on the edge being written: the + // total exceeds the budget. + store.put( + &[LEAF], + b"t0", + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + ); + for i in 1..MAX_REFERENCE_HOPS { + let mut reference = sibling_bidi(format!("t{}", i - 1).as_bytes(), true); + if i == MAX_REFERENCE_HOPS - 1 { + // The insertion carries over this referrer list. + reference.backward_references.push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"up".to_vec()), + cascade_on_update: true, + }); + } + store.put( + &[LEAF], + format!("t{i}").as_bytes(), + Element::BidirectionalReference(reference, None), + ); + } + let top = format!("t{}", MAX_REFERENCE_HOPS - 1); + let mut up = sibling_bidi(top.as_bytes(), true); + up.backward_references.push(BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"up2".to_vec()), + cascade_on_update: true, + }); + store.put(&[LEAF], b"up", Element::BidirectionalReference(up, None)); + store.put( + &[LEAF], + b"up2", + Element::BidirectionalReference(sibling_bidi(b"up", true), None), + ); + + // Retarget the top of the chain (which carries the `up` referrer, + // itself referred to by `up2`): upstream 2 + downstream 9 = 11 + // exceeds the 10-hop budget. Rebuilding the same forward edge with + // a changed option forces a real plan. + let mut retarget = sibling_bidi(format!("t{}", MAX_REFERENCE_HOPS - 2).as_bytes(), true); + retarget.cascade_on_update = false; + let result = + plan_reference_insertion(&store, &leaf(), top.as_bytes(), retarget, None).unwrap(); + assert!( + matches!(result, Err(Error::BidirectionalReferenceRule(ref m)) if m.contains("budget")), + "got: {result:?}" + ); + } +} diff --git a/grovedb/src/debugger.rs b/grovedb/src/debugger.rs index f41f6c8c3..6d249f256 100644 --- a/grovedb/src/debugger.rs +++ b/grovedb/src/debugger.rs @@ -433,6 +433,12 @@ fn merk_proof_node_to_grovedbg(node: Node) -> Result MerkProofNode::Hash(hash), Node::KVHash(hash) => MerkProofNode::KVHash(hash), + // grovedbg has no dedicated variant; show as a KVValueHash-style + // node with the backrefs hash in the hash slot. + Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash) => { + let element = crate::Element::deserialize(&value, GroveVersion::latest())?; + MerkProofNode::KVValueHash(key, element_to_grovedbg(element), backrefs_hash) + } Node::KVDigest(key, hash) => MerkProofNode::KVDigest(key, hash), Node::KVDigestCount(key, hash, count) => { // KVDigestCount is like KVDigest but with count for ProvableCountTree @@ -864,10 +870,15 @@ fn reference_path_to_grovedbg( fn element_to_grovedbg(element: crate::Element) -> grovedbg_types::Element { match element { - crate::Element::Item(value, element_flags) => grovedbg_types::Element::Item { - value, - element_flags, - }, + crate::Element::Item(value, element_flags) + | crate::Element::ItemWithBackwardsReferences(value, _, element_flags) => { + // grovedbg has no backward-references variants; show the plain + // counterpart. + grovedbg_types::Element::Item { + value, + element_flags, + } + } crate::Element::Tree(root_key, element_flags) => grovedbg_types::Element::Subtree { root_key, element_flags, @@ -878,6 +889,13 @@ fn element_to_grovedbg(element: crate::Element) -> grovedbg_types::Element { element_flags, )) } + crate::Element::BidirectionalReference(reference, flags) => { + // Shown as its plain-reference shape. + grovedbg_types::Element::Reference(reference_path_to_grovedbg( + reference.forward_reference_path, + flags, + )) + } crate::Element::ReferenceWithSumItem( reference_path, _max_hop, @@ -887,17 +905,24 @@ fn element_to_grovedbg(element: crate::Element) -> grovedbg_types::Element { reference: reference_path_to_grovedbg(reference_path, element_flags), sum_item_value, }, - crate::Element::SumItem(value, element_flags) => grovedbg_types::Element::SumItem { - value, - element_flags, - }, - crate::Element::ItemWithSumItem(value, sum_value, element_flags) => { - grovedbg_types::Element::ItemWithSumItem { + crate::Element::SumItem(value, element_flags) + | crate::Element::SumItemWithBackwardsReferences(value, _, element_flags) => { + grovedbg_types::Element::SumItem { value, - sum_item_value: sum_value, element_flags, } } + crate::Element::ItemWithSumItem(value, sum_value, element_flags) + | crate::Element::ItemWithSumItemWithBackwardsReferences( + value, + sum_value, + _, + element_flags, + ) => grovedbg_types::Element::ItemWithSumItem { + value, + sum_item_value: sum_value, + element_flags, + }, crate::Element::SumTree(root_key, sum, element_flags) => grovedbg_types::Element::Sumtree { root_key, sum, diff --git a/grovedb/src/element/aggregate_sum_query/mod.rs b/grovedb/src/element/aggregate_sum_query/mod.rs index 066ea9d15..09c36eaef 100644 --- a/grovedb/src/element/aggregate_sum_query/mod.rs +++ b/grovedb/src/element/aggregate_sum_query/mod.rs @@ -420,9 +420,22 @@ impl ElementAggregateSumQueryExtensions for Element { // `ReferenceWithSumItem` is also a reference and resolves the // same way; its carried sum is a parent-aggregation property // and does not affect the chain destination. + // A source bidirectional edge's declared `max_hop` bounds the + // whole resolution (plain references keep their historical + // global-budget behavior). `source_budget` counts fetches + // allowed from the source; the loop's `hops_left` counts + // reference edges followed BEYOND the direct target, hence the + // `- 1` below. + let mut source_budget: Option = None; let ref_path = match element { Element::Reference(ref_path, _, _) | Element::ReferenceWithSumItem(ref_path, _, _, _) => ref_path, + // A bidirectional reference resolves through its forward + // path exactly like a plain reference. + Element::BidirectionalReference(reference, _) => { + source_budget = reference.max_hop.map(|m| m as usize); + reference.forward_reference_path + } _ => { return Err(Error::InternalError( "expected a reference after conversion".to_string(), @@ -430,6 +443,9 @@ impl ElementAggregateSumQueryExtensions for Element { .wrap_with_cost(cost); } }; + if source_budget == Some(0) { + return Err(Error::ReferenceLimit).wrap_with_cost(cost); + } let mut current_qualified_path = match ref_path { ReferencePathType::AbsolutePathReference(path) => path, @@ -442,7 +458,10 @@ impl ElementAggregateSumQueryExtensions for Element { }; let tx = TxRef::new(args.storage, args.transaction); - let mut hops_left = MAX_AGGREGATE_REFERENCE_HOPS; + let mut hops_left = source_budget + .map(|budget| budget - 1) + .unwrap_or(MAX_AGGREGATE_REFERENCE_HOPS) + .min(MAX_AGGREGATE_REFERENCE_HOPS); let mut visited: HashSet>> = HashSet::new(); loop { @@ -476,14 +495,35 @@ impl ElementAggregateSumQueryExtensions for Element { .map_err(|e| e.into()) ); + // An intermediate bidirectional edge's declaration caps the + // remaining budget: after following its own edge, at most + // `max_hop - 1` further reference hops remain. Plain + // references carry no per-edge cap here. + let mut edge_cap: Option = None; + let resolved = match resolved { + // An intermediate bidirectional reference continues the + // chain through its forward path like any reference. + Element::BidirectionalReference(reference, flags) => { + edge_cap = reference.max_hop.map(|m| m as usize); + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + flags, + ) + } + other => other, + }; match resolved { // Both reference variants continue the chain. Element::Reference(next_ref_path, _, _) | Element::ReferenceWithSumItem(next_ref_path, _, _, _) => { - if hops_left == 0 { + if hops_left == 0 || edge_cap == Some(0) { return Err(Error::ReferenceLimit).wrap_with_cost(cost); } hops_left -= 1; + if let Some(cap) = edge_cap { + hops_left = hops_left.min(cap - 1); + } current_qualified_path = cost_return_on_error_into_no_add!( cost, path_from_reference_qualified_path_type( @@ -792,8 +832,10 @@ impl ElementAggregateSumQueryExtensions for Element { // of element this aggregator should accept (suppress the count // contribution while still totaling the sum). let value = match element.into_underlying() { - Element::SumItem(value, _) => value, - Element::ItemWithSumItem(_, value, _) => value, + Element::SumItem(value, _) + | Element::ItemWithSumItem(_, value, _) + | Element::SumItemWithBackwardsReferences(value, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, value, _, _) => value, _ => return Err(Error::InvalidInput("Only sum items are allowed")), }; diff --git a/grovedb/src/element/aggregate_sum_query/tests.rs b/grovedb/src/element/aggregate_sum_query/tests.rs index c7e128195..f31feb8e6 100644 --- a/grovedb/src/element/aggregate_sum_query/tests.rs +++ b/grovedb/src/element/aggregate_sum_query/tests.rs @@ -3124,16 +3124,18 @@ fn test_cyclic_reference_detected_in_aggregate_sum_query() { merk.for_merk(|m| { ref_a .insert_reference(m, b"ref_a", NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert ref_a at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert ref_a at merk level"); merk.for_merk(|m| { ref_b .insert_reference(m, b"ref_b", NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert ref_b at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert ref_b at merk level"); drop(merk); @@ -3195,9 +3197,10 @@ fn test_self_referencing_element_detected_in_aggregate_sum_query() { merk.for_merk(|m| { ref_self .insert_reference(m, b"ref_self", NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert ref_self at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert ref_self at merk level"); drop(merk); diff --git a/grovedb/src/element/query.rs b/grovedb/src/element/query.rs index 837882e62..f72773983 100644 --- a/grovedb/src/element/query.rs +++ b/grovedb/src/element/query.rs @@ -443,6 +443,10 @@ impl ElementQueryExtensions for Element { } = args; let element = element.convert_if_reference_to_absolute_reference(path, key)?; + // Public results never carry referrer lists — they are internal + // bookkeeping (raw queries return backward-references elements + // unresolved, but still stripped). + let element = element.stripped_of_backward_references(); if budget.offset.unwrap_or(0) == 0 { match result_type { diff --git a/grovedb/src/error.rs b/grovedb/src/error.rs index 6f616ac54..d4317c5ae 100644 --- a/grovedb/src/error.rs +++ b/grovedb/src/error.rs @@ -57,6 +57,10 @@ pub enum Error { #[error("corrupted referenced path key not found: {0}")] CorruptedReferencePathParentLayerNotFound(String), + /// Bidirectional references rule was violated + #[error("bidirectional reference rule violation: {0}")] + BidirectionalReferenceRule(String), + /// The invalid parent layer path represents a logical error from the client /// library #[error("invalid parent layer path: {0}")] @@ -90,6 +94,10 @@ pub enum Error { /// Corrupted data CorruptedData(String), + /// Accessing a subtree that was marked deleted inside a `MerkCache` + #[error("merk cache, accessing deleted subtree: {0}")] + MerkCacheSubtreeDeleted(&'static str), + #[error("data storage error: {0}")] /// Corrupted storage CorruptedStorage(String), diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 3307cc201..742742605 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -136,6 +136,8 @@ #[cfg(feature = "minimal")] pub mod batch; #[cfg(feature = "minimal")] +mod bidirectional_references; +#[cfg(feature = "minimal")] mod checkpoints; #[cfg(feature = "grovedbg")] pub mod debugger; @@ -172,6 +174,8 @@ use std::sync::Arc; #[cfg(feature = "minimal")] use std::{collections::HashMap, option::Option::None, path::Path}; +#[cfg(feature = "minimal")] +use bidirectional_references::BidirectionalReference; #[cfg(feature = "grovedbg")] use debugger::start_visualizer; #[cfg(any(feature = "minimal", feature = "verify"))] @@ -188,6 +192,12 @@ use grovedb_costs::cost_return_on_error_into; use grovedb_costs::{ cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, }; +/// The declared referrer capacity of backward-references items: the list +/// type carrying it, the capacity the plain constructors declare, and the +/// protocol ceiling. +pub use grovedb_element::{ + BackwardReferences, DEFAULT_BACKWARD_REFERENCES_CAPACITY, MAX_BACKWARD_REFERENCES, +}; #[cfg(any(feature = "minimal", feature = "verify"))] pub use grovedb_merk::calculate_max_tree_depth_from_count; #[cfg(feature = "minimal")] @@ -630,17 +640,10 @@ impl GroveDb { transaction: TransactionArg, grove_version: &GroveVersion, ) -> CostResult>, Error> { - let mut cost = OperationCost { - ..Default::default() - }; - let tx = TxRef::new(&self.db, transaction); - let root_merk = - cost_return_on_error!(&mut cost, self.open_root_merk(tx.as_ref(), grove_version)); - - let root_key = root_merk.root_key(); - Ok(root_key).wrap_with_cost(cost) + self.open_root_merk(tx.as_ref(), grove_version) + .map_ok(|merk| merk.root_key()) } /// Returns root hash of GroveDb. @@ -2309,6 +2312,100 @@ impl GroveDb { Ok(()) } + /// Reciprocal audit for one element's authenticated referrer list: + /// every backward entry must name a live `BidirectionalReference` + /// whose forward path resolves back to this exact position, and no + /// inverted path may appear twice. Violations are recorded in + /// `issues` keyed by the REFERRER's qualified path, with a zero hash + /// in the middle slot marking a reciprocity (not value-hash) failure. + #[allow(clippy::too_many_arguments)] + fn verify_reciprocal_backward_references>( + &self, + element: &Element, + path: &SubtreePath, + key: &[u8], + allow_cache: bool, + transaction: &Transaction, + grove_version: &GroveVersion, + issues: &mut HashMap>, (CryptoHash, CryptoHash, CryptoHash)>, + ) -> Result<(), Error> { + let Some(backward_references) = element.backward_references() else { + return Ok(()); + }; + let hashes = element + .backward_references_hashes(grove_version) + .unwrap()? + .expect("backward-references elements carry hashes"); + let mut expected_forward = path.to_vec(); + expected_forward.push(key.to_vec()); + + let mut seen: std::collections::HashSet>> = Default::default(); + for entry in backward_references { + let referrer_qualified = match path_from_reference_path_type( + entry.inverted_reference.clone(), + &path.to_vec(), + Some(key), + ) { + Ok(qualified) => qualified, + Err(_) => { + let mut marker = expected_forward.clone(); + marker.push(b"?invalid-inverse".to_vec()); + issues.insert(marker, (hashes.combined, [0; 32], hashes.combined)); + continue; + } + }; + if !seen.insert(referrer_qualified.clone()) { + let mut marker = referrer_qualified.clone(); + marker.push(b"?duplicate-inverse".to_vec()); + issues.insert(marker, (hashes.combined, [0; 32], hashes.combined)); + continue; + } + let Some((referrer_key, referrer_path)) = referrer_qualified.split_last() else { + // A corrupt inverse (e.g. `AbsolutePathReference([])`) + // resolves to an EMPTY qualified path: report it instead of + // silently passing the audit. + let mut marker = expected_forward.clone(); + marker.push(b"?invalid-inverse".to_vec()); + issues.insert(marker, (hashes.combined, [0; 32], hashes.combined)); + continue; + }; + let referrer_path_slices: Vec<&[u8]> = + referrer_path.iter().map(|p| p.as_slice()).collect(); + let occupant = self + .get_raw_optional( + referrer_path_slices.as_slice().into(), + referrer_key, + Some(transaction), + grove_version, + ) + .unwrap(); + let reciprocal = match occupant { + Ok(Some(Element::BidirectionalReference(ref referrer, _))) => { + path_from_reference_path_type( + referrer.forward_reference_path.clone(), + referrer_path, + Some(referrer_key), + ) + .map(|forward| forward == expected_forward) + .unwrap_or(false) + } + Ok(_) => false, + Err(_) => false, + }; + if !reciprocal { + // A marker component keeps this reciprocity diagnostic from + // clobbering (or being clobbered by) the referrer's own + // value-hash entry at the bare path, mirroring the + // `?invalid-inverse` convention above. + let mut marker = referrer_qualified; + marker.push(b"?no-reciprocal-forward-edge".to_vec()); + issues.insert(marker, (hashes.combined, [0; 32], hashes.combined)); + } + } + let _ = allow_cache; + Ok(()) + } + fn verify_merk_and_submerks_in_transaction<'db, B: AsRef<[u8]>, S: StorageContext<'db>>( &'db self, merk: Merk, @@ -2678,12 +2775,62 @@ impl GroveDb { ); } } + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // The node commits to combine(inner_hash, backrefs_hash), + // both recomputable from the stored bytes. + let (_, element_value_hash) = merk + .get_value_and_value_hash( + &key, + allow_cache, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .unwrap() + .map_err(MerkError)? + .ok_or(Error::CorruptedData(format!( + "expected merk to contain value at key {} for {}", + hex_to_ascii(&key), + element.type_str() + )))?; + let hashes = element + .backward_references_hashes(grove_version) + .unwrap()? + .expect("backward-references elements carry hashes"); + if hashes.combined != element_value_hash { + issues.insert( + path.derive_owned_with_child(key.clone()).to_vec(), + (hashes.combined, element_value_hash, hashes.combined), + ); + } + if verify_references { + self.verify_reciprocal_backward_references( + &element, + path, + &key, + allow_cache, + transaction, + grove_version, + &mut issues, + )?; + } + } Element::Reference(ref reference_path, ..) - | Element::ReferenceWithSumItem(ref reference_path, ..) => { + | Element::ReferenceWithSumItem(ref reference_path, ..) + | Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ref reference_path, + .. + }, + _, + ) => { // Skip this whole check if we don't `verify_references`. // `ReferenceWithSumItem` shares this verification path — // the sum is hashed as part of the serialized value // bytes, so the combined-hash check below is identical. + // `BidirectionalReference` likewise: its forward path is + // followed exactly like a plain reference's. if !verify_references { continue; } @@ -2703,7 +2850,20 @@ impl GroveDb { hex_to_ascii(&key) )))?; - let self_actual_value_hash = value_hash(&kv_value).unwrap(); + // A bidirectional reference commits to THREE inputs: its + // stripped bytes, the resolved end hash, and its + // referrer-list hash — its "self" hash in the commitment + // is combine(inner, backrefs), not H(stored bytes). + let self_actual_value_hash = + if let Element::BidirectionalReference(..) = &element { + element + .backward_references_hashes(grove_version) + .unwrap()? + .expect("bidirectional references carry hashes") + .combined + } else { + value_hash(&kv_value).unwrap() + }; let referenced_value_hash = { let full_path = path_from_reference_path_type( reference_path.clone(), @@ -2727,7 +2887,9 @@ impl GroveDb { grove_version, ) .unwrap()?; - item.value_hash(grove_version).unwrap()? + // Every reference in a chain commits to the + // terminal's LOGICAL hash (referrer list stripped). + item.logical_value_hash(grove_version).unwrap()? }; // Check the commitment without rewriting the stored reference. @@ -2736,10 +2898,84 @@ impl GroveDb { if combined_value_hash != element_value_hash { issues.insert( - path.derive_owned_with_child(key).to_vec(), + path.derive_owned_with_child(key.clone()).to_vec(), (combined_value_hash, element_value_hash, combined_value_hash), ); } + + if matches!(element, Element::BidirectionalReference(..)) { + self.verify_reciprocal_backward_references( + &element, + path, + &key, + allow_cache, + transaction, + grove_version, + &mut issues, + )?; + // The FORWARD direction of the reciprocity audit: + // this edge's immediate target must carry the + // edge's canonical inverse in its referrer list — + // a missing registration leaves a live edge with + // no reverse path for propagation or cascade to + // follow. + if let Element::BidirectionalReference(ref reference, _) = element { + let target_qualified = path_from_reference_path_type( + reference.forward_reference_path.clone(), + &path.to_vec(), + Some(&key), + )?; + let expected_inverse = reference + .forward_reference_path + .clone() + .invert(path.clone(), &key); + let registered = match (target_qualified.split_last(), expected_inverse) + { + (Some((target_key, target_path)), Some(inverse)) => { + // Merk-level read: public reads STRIP + // referrer lists, and the list is + // exactly what this audit inspects. + let target_path_slices: Vec<&[u8]> = + target_path.iter().map(|p| p.as_slice()).collect(); + let target_element = self + .open_transactional_merk_at_path( + target_path_slices.as_slice().into(), + transaction, + batch, + grove_version, + ) + .unwrap() + .ok() + .and_then(|target_merk| { + Element::get_optional( + &target_merk, + target_key, + allow_cache, + grove_version, + ) + .unwrap() + .ok() + .flatten() + }); + match target_element { + Some(target) => target + .backward_references() + .map(|refs| { + refs.iter().any(|r| r.inverted_reference == inverse) + }) + .unwrap_or(false), + None => false, + } + } + _ => false, + }; + if !registered { + let mut marker = target_qualified; + marker.push(b"?missing-registration".to_vec()); + issues.insert(marker, ([0; 32], [0; 32], [0; 32])); + } + } + } } // ProvableSumIndexedTree integrity: identical shape to // PCIT but the secondary is a diff --git a/grovedb/src/merk_cache.rs b/grovedb/src/merk_cache.rs index 79cdecf86..e644847e9 100644 --- a/grovedb/src/merk_cache.rs +++ b/grovedb/src/merk_cache.rs @@ -6,18 +6,40 @@ use std::{ }; use grovedb_costs::{cost_return_on_error, CostResult, CostsExt}; -use grovedb_merk::Merk; +use grovedb_merk::{ + element::{ + costs::ElementCostExtensions, get::ElementFetchFromStorageExtensions, + tree_type::ElementTreeTypeExtensions, + }, + Merk, +}; use grovedb_path::SubtreePathBuilder; -use grovedb_storage::{rocksdb_storage::PrefixedRocksDbTransactionContext, StorageBatch}; +use grovedb_storage::{rocksdb_storage::PrefixedRocksDbTransactionContext, Storage, StorageBatch}; use grovedb_version::version::GroveVersion; -use crate::{Error, GroveDb, Transaction}; +use crate::{Element, Error, GroveDb, Transaction}; type TxMerk<'db> = Merk>; +/// Subtree that was put into the cache. +// The whole (flag, Subtree) pair already lives behind a `Box` in the merks +// map (required for pointer stability), so boxing the Merk again would only +// add an indirection. +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +enum Subtree<'db> { + /// Merk lazily loaded from backing storage. + LoadedMerk(TxMerk<'db>), + /// Subtree marked as deleted, this will prevent loading from backing storage + /// which can be unaware of uncommitted deletion. + Deleted, +} + /// We store Merk on heap to preserve its location as well as borrow flag /// alongside. -type CachedMerkEntry<'db> = Box<(Cell, TxMerk<'db>)>; +type CachedMerkEntry<'db> = Box<(Cell, Subtree<'db>)>; + +type Merks<'db, 'b, B> = BTreeMap, CachedMerkEntry<'db>>; /// Structure to keep subtrees open in memory for repeated access. pub(crate) struct MerkCache<'db, 'b, B: AsRef<[u8]>> { @@ -25,7 +47,7 @@ pub(crate) struct MerkCache<'db, 'b, B: AsRef<[u8]>> { pub(crate) version: &'db GroveVersion, batch: Box, tx: &'db Transaction<'db>, - merks: UnsafeCell, CachedMerkEntry<'db>>>, + merks: UnsafeCell>, } impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { @@ -44,6 +66,99 @@ impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { } } + pub(crate) fn mark_deleted(&self, path: SubtreePathBuilder<'b, B>) { + // SAFETY: there are no other references to `merks` memory at the same time. + // Note while it's possible to have direct references to actual Merk trees, + // outside of the scope of this function, this map (`merks`) has + // indirect connection to them through `Box`, thus there are no overlapping + // references, and that is requirement of `UnsafeCell` we have there. + let merks = unsafe { + self.merks + .get() + .as_mut() + .expect("`UnsafeCell` is never null") + }; + + merks + .entry(path) + .and_modify(|subtree| { + if subtree.0.get() { + panic!("Attempt to have double &mut borrow on Merk"); + } + subtree.1 = Subtree::Deleted + }) + .or_insert(Box::new((Default::default(), Subtree::Deleted))); + } + + /// Open Merk using data from parent subtree, returning errors in case + /// parent element isn't a subtree. + /// + /// If there is no parent subtree in the cache `None` will be returned + /// instead of Merk. + fn try_open_merk_using_cached_parent<'m>( + &self, + merks: &'m Merks<'db, 'b, B>, + batch: &'db StorageBatch, + path: SubtreePathBuilder<'b, B>, + ) -> CostResult>, Error> { + let Some((parent_merk, parent_key)) = + path.derive_parent_owned() + .and_then(|(parent_path, parent_key)| { + merks + .get(&parent_path) + .map(|parent_merk| (parent_merk, parent_key)) + }) + else { + return Ok(None).wrap_with_cost(Default::default()); + }; + + if parent_merk.0.get() { + panic!("Attempt to have double &mut borrow on Merk"); + } + + let mut cost = Default::default(); + let merk = match &parent_merk.1 { + Subtree::LoadedMerk(merk) => { + if let Some((root_key, tree_type)) = cost_return_on_error!( + &mut cost, + Element::get(merk, parent_key, true, self.version) + .map_ok(|element| element.root_key_and_tree_type_owned()) + .map_err(|e| match e { + grovedb_merk::error::Error::PathKeyNotFound(s) => + Error::PathKeyNotFound(s), + e => Error::MerkError(e), + }) + ) { + let storage = self + .db + .db + .get_transactional_storage_context((&path).into(), Some(batch), self.tx) + .unwrap_add_cost(&mut cost); + cost_return_on_error!( + &mut cost, + Merk::open_layered_with_root_key( + storage, + root_key, + tree_type, + Some(&Element::value_defined_cost_for_serialized_value), + self.version, + ) + .map_err(Into::into) + ) + } else { + return Err(Error::CorruptedData("parent must be a tree".to_owned())) + .wrap_with_cost(cost); + } + } + Subtree::Deleted => { + return Err(Error::MerkCacheSubtreeDeleted("parent was deleted")) + .wrap_with_cost(cost) + } + }; + + Ok(Some(merk)).wrap_with_cost(cost) + } + /// Gets a smart pointer to a cached Merk or opens one if needed. pub(crate) fn get_merk<'c>( &'c self, @@ -56,39 +171,113 @@ impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { // outside of the scope of this function, this map (`merks`) has // indirect connection to them through `Box`, thus there are no overlapping // references, and that is requirement of `UnsafeCell` we have there. - let boxed_flag_merk = match unsafe { + let merks = unsafe { self.merks .get() .as_mut() .expect("`UnsafeCell` is never null") - } - .entry(path) - { - Entry::Vacant(e) => { - let merk = cost_return_on_error!( - &mut cost, - self.db.open_transactional_merk_at_path( - e.key().into(), - self.tx, - // SAFETY: batch is allocated on the heap and we use only shared - // references, so as long as the `Box` allocation - // outlives those references we're safe, - // and it will outlive because Merks are dropped first. - Some(unsafe { - (&*self.batch as *const StorageBatch) - .as_ref() - .expect("`Box` is never null") - }), - self.version - ) - ); - e.insert(Box::new((false.into(), merk))) + }; + + // SAFETY: batch is allocated on the heap and we use only shared + // references, so as long as the `Box` allocation + // outlives those references we're safe, + // and it will outlive because Merks are dropped first. + let batch = unsafe { + (&*self.batch as *const StorageBatch) + .as_ref() + .expect("`Box` is never null") + }; + + // Getting mutable reference for subtree with lifetime unlinked from the rest + // of Merks map. + // SAFETY: we use borrow flag to ensure only one mutable reference to subtree + // memory will present. As for the rest: `MerkCache` guarantees + // subtrees to stay at their places through the whole lifetime of the cache + // structure using indirection via Box and not allowing actual deletions. + let boxed_flag_merk = if let Some(cached_subtree) = unsafe { + merks.get_mut(&path).map(|b: &mut Box<_>| { + (&mut (**b) as *mut (Cell, Subtree<'db>)) + .as_mut() + .expect("box is never null") + }) + } { + // While we can be certain that no one can conflict for Box memory of flag and + // subtree's pointer, the subtree itself can be referred from + // outside and we have to check the flag: + if cached_subtree.0.get() { + panic!("Attempt to have double &mut borrow on Merk"); + } + + match cached_subtree.1 { + // Cache hit, all good: + Subtree::LoadedMerk(_) => {} + // Cache hit, but marked as deleted, need to look at the parent to see whether it + // was re-inserted: + Subtree::Deleted => { + match cost_return_on_error!( + &mut cost, + self.try_open_merk_using_cached_parent(merks, batch, path) + ) { + Some(merk) => { + // Parent data indicates that Merk was re-inserted + cached_subtree.1 = Subtree::LoadedMerk(merk); + } + None => { + // This should not happen: subtree is marked as deleted, + // but no operations on parent are performed (element deletion is + // required as well by GroveDb + // structure requirements) + return Err(Error::InternalError( + "Subtree is marked as deleted, but parent wasn't updated" + .to_owned(), + )) + .wrap_with_cost(cost); + } + } + } + } + + cached_subtree + } else { + // Cache miss, Merk needs to be loaded, either from the storage or from the + // cached parent if it is present: + match cost_return_on_error!( + &mut cost, + self.try_open_merk_using_cached_parent(merks, batch, path.clone()) + ) { + Some(merk) => match merks.entry(path) { + Entry::Vacant(e) => { + e.insert(Box::new((Default::default(), Subtree::LoadedMerk(merk)))) + } + Entry::Occupied(e) => { + // This cannot happen since it's a cache miss branch, but whatever + let res = e.into_mut(); + res.1 = Subtree::LoadedMerk(merk); + res + } + }, + None => { + // No cached merk nor parent, going into storage: + merks.entry(path.clone()).or_insert(Box::new(( + Default::default(), + Subtree::LoadedMerk(cost_return_on_error!( + &mut cost, + self.db.open_transactional_merk_at_path( + (&path).into(), + self.tx, + Some(batch), + self.version, + ) + )), + ))) + } } - Entry::Occupied(e) => e.into_mut(), }; + // As long as we're not making mutable references out of shared references we're + // good: let taken_handle_ref: *const Cell = &boxed_flag_merk.0 as *const _; - let merk_ptr: *mut TxMerk<'db> = &mut boxed_flag_merk.1 as *mut _; + let merk_ptr: *mut Subtree<'db> = &mut boxed_flag_merk.1 as *mut _; // SAFETY: `MerkHandle` contains two references to the heap allocated memory, // and we want to be sure that the referenced data will outlive those @@ -119,13 +308,14 @@ impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { taken_handle: taken_handle_ref .as_ref() .expect("`Box` contents are never null"), + batch: &self.batch, } }) .wrap_with_cost(cost) } /// Consumes `MerkCache` into accumulated batch of uncommitted operations - /// with subtrees' root hash propagation done. + /// with subtrees' root hash propagation done. pub(crate) fn into_batch(mut self) -> CostResult, Error> { let mut cost = Default::default(); cost_return_on_error!(&mut cost, self.propagate_subtrees()); @@ -142,9 +332,29 @@ impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { // This relies on [SubtreePath]'s ordering implementation to put the deepest // path's first. while let Some((path, flag_and_merk)) = self.merks.get_mut().pop_first() { - let merk = flag_and_merk.1; + let Subtree::LoadedMerk(merk) = flag_and_merk.1 else { + continue; + }; + if let Some((parent_path, parent_key)) = path.derive_parent_owned() { - let mut parent_merk = cost_return_on_error!(&mut cost, self.get_merk(parent_path)); + // Error handling here ensures that it is not a major issue if the + // parent Merk was marked as deleted. `MerkCache` is not responsible for + // determining how a subtree ended up deleted, especially when some of + // its child subtrees still have changes. This situation can arise when + // a more efficient deletion process occurs outside the cache without + // spending extra time marking entries within it, but still marking the + // root of deletion as gone to prevent further propagations and wrong + // re-insertions. + let mut parent_merk = match self.get_merk(parent_path).unwrap_add_cost(&mut cost) { + Ok(merk) => merk, + Err(Error::MerkCacheSubtreeDeleted(_)) => continue, + // The parent element is already gone from ITS parent (a + // recursive deletion removed it without marking every + // descendant in this cache) — same situation as the + // explicit deleted marker above, so propagate nothing. + Err(Error::PathKeyNotFound(_)) => continue, + Err(e) => return Err(e).wrap_with_cost(cost), + }; let (root_hash, root_key, aggregate_data) = cost_return_on_error!( &mut cost, @@ -170,26 +380,63 @@ impl<'db, 'b, B: AsRef<[u8]>> MerkCache<'db, 'b, B> { } /// Wrapper over `Merk` tree to manage unique borrow dynamically. -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct MerkHandle<'db, 'c> { - merk: *mut TxMerk<'db>, + merk: *mut Subtree<'db>, taken_handle: &'c Cell, + /// The cache's shared batch, so every `for_merk` call can mark itself + /// as a new operation (see [`StorageBatch::next_operation`]). + batch: &'c StorageBatch, } impl<'db> MerkHandle<'db, '_> { - pub(crate) fn for_merk(&mut self, f: impl FnOnce(&mut TxMerk<'db>) -> T) -> T { + /// Borrow Merk exclusively to perform provided closure on it. + /// # Panics + /// *Rule of thumb: don't use nested `for_merk`*. + /// Nested usage of `for_merk` can cause a panic in situations involving + /// double borrowing, as there is no mechanism to prevent multiple + /// `MerkHandle`s from targeting the same Merk. A less obvious scenario + /// occurs when there is an implicit peek into a parent Merk to open + /// another Merk, which might already be inside a `for_merk` call for the + /// parent. Although such cases can generally lead to panics, they + /// remain memory-safe due to the checks in place. If necessary, these + /// nested `for_merk` calls are still available for use. + pub(crate) fn for_merk( + &mut self, + f: impl FnOnce(&mut TxMerk<'db>) -> CostResult, + ) -> CostResult { if self.taken_handle.get() { panic!("Attempt to have double &mut borrow on Merk"); } self.taken_handle.set(true); + // Every closure is one Merk operation sharing the cache's batch. + // Marking the boundary lets a later operation's delete supersede an + // earlier operation's put of the same key (a cascade deleting a node + // that the preceding delete's rebalancing just rewrote), while the + // put-wins rule Merk's rebalancing relies on still holds within the + // operation. + self.batch.next_operation(); + // SAFETY: here we want to have `&mut` reference to Merk out of a pointer, there // is a checklist for that: // 1. Memory is valid, because `MerkHandle` can't outlive `MerkCache` and heap // allocated Merks stay at their place for the whole `MerkCache` lifetime. // 2. No other references exist because of `taken_handle` check above. - let result = f(unsafe { self.merk.as_mut().expect("`Box` contents are never null") }); + let subtree = unsafe { self.merk.as_mut().expect("`Box` contents are never null") }; + let Subtree::LoadedMerk(merk) = subtree else { + // Release the borrow flag before bailing — otherwise every + // subsequent `for_merk` on this handle would panic with a + // phantom double-borrow. + self.taken_handle.set(false); + return Err(Error::InternalError( + "accessing subtree that was deleted".to_owned(), + )) + .wrap_with_cost(Default::default()); + }; + + let result = f(merk); self.taken_handle.set(false); @@ -199,22 +446,28 @@ impl<'db> MerkHandle<'db, '_> { #[cfg(test)] mod tests { - use grovedb_merk::element::insert::ElementInsertToStorageExtensions; - use grovedb_path::SubtreePath; + use grovedb_costs::{storage_cost::removal::StorageRemovedBytes, CostsExt}; + use grovedb_merk::{ + element::{ + delete::ElementDeleteFromStorageExtensions, insert::ElementInsertToStorageExtensions, + }, + TreeType, + }; + use grovedb_path::{SubtreePath, SubtreePathBuilder}; use grovedb_storage::StorageBatch; use grovedb_version::version::GroveVersion; use super::MerkCache; use crate::{ tests::{make_deep_tree, make_test_grovedb, TEST_LEAF}, - Element, + Element, Error, }; #[test] #[should_panic] fn cant_borrow_twice() { let version = GroveVersion::latest(); - let db = make_test_grovedb(version); + let db = make_test_grovedb(&version); let tx = db.start_transaction(); let cache = MerkCache::new(&db, &tx, version); @@ -228,17 +481,83 @@ mod tests { .unwrap() .unwrap(); - merk1.for_merk(|_m1| { - merk2.for_merk(|_m2| { - // this shouldn't happen + merk1 + .for_merk(|_m1| { + merk2.for_merk(|_m2| { + // this shouldn't happen + Ok(()).wrap_with_cost(Default::default()) + }) }) - }); + .unwrap() + .unwrap(); + } + + #[test] + #[should_panic] + fn cant_borrow_parent_twice() { + let version = GroveVersion::latest(); + let db = make_test_grovedb(&version); + let tx = db.start_transaction(); + + let cache = MerkCache::new(&db, &tx, version); + + let mut merk1 = cache + .get_merk(SubtreePath::empty().derive_owned()) + .unwrap() + .unwrap(); + // Opening child requires taking a peek into parent, but we're already inside of + // `for_merk` of parent + let mut _merk2 = merk1 + .for_merk(|_m1| cache.get_merk(SubtreePath::empty().derive_owned_with_child(b"nested"))) + .unwrap() + .unwrap(); + } + + #[test] + fn can_use_non_overlapping_for_merk() { + let version = GroveVersion::latest(); + let db = make_deep_tree(&version); + let tx = db.start_transaction(); + + let cache = MerkCache::new(&db, &tx, version); + + let mut merk1 = cache + .get_merk(SubtreePath::empty().derive_owned()) + .unwrap() + .unwrap(); + let mut _merk2 = merk1 + .for_merk(|_m1| { + cache.get_merk(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree", + ])) + }) + .unwrap() + .unwrap(); + } + + #[test] + fn cant_open_merk_with_deleted_parent() { + let version = GroveVersion::latest(); + let db = make_deep_tree(&version); + let tx = db.start_transaction(); + + let cache = MerkCache::new(&db, &tx, version); + + cache.mark_deleted(SubtreePathBuilder::new()); + + assert!(matches!( + cache + .get_merk(SubtreePathBuilder::owned_from_iter([TEST_LEAF])) + .unwrap(), + Err(Error::MerkCacheSubtreeDeleted(_)) + )); } #[test] fn subtrees_are_propagated() { let version = GroveVersion::latest(); - let db = make_deep_tree(version); + let db = make_deep_tree(&version); let tx = db.start_transaction(); let path = SubtreePath::from(&[TEST_LEAF, b"innertree"]); @@ -248,11 +567,11 @@ mod tests { let batch = StorageBatch::new(); let mut merk = db - .open_transactional_merk_at_path(path.clone(), &tx, Some(&batch), version) + .open_transactional_merk_at_path(path.clone(), &tx, Some(&batch), &version) .unwrap() .unwrap(); - item.insert(&mut merk, b"k1", None, version) + item.insert(&mut merk, b"k1", None, &version) .unwrap() .unwrap(); @@ -263,10 +582,124 @@ mod tests { let mut merk = cache.get_merk(path.derive_owned()).unwrap().unwrap(); - merk.for_merk(|m| item.insert(m, b"k1", None, version).unwrap().unwrap()); + merk.for_merk(|m| { + item.insert(m, b"k1", None, &version) + .map_err(Error::MerkError) + }) + .unwrap() + .unwrap(); drop(merk); assert!(cache.into_batch().unwrap().unwrap().len() > no_propagation_ops_count); } + + #[test] + fn deleted_subtree_can_be_reinserted() { + let version = GroveVersion::latest(); + let db = make_deep_tree(&version); + let tx = db.start_transaction(); + let cache = MerkCache::<[u8; 0]>::new(&db, &tx, version); + + let mut parent_merk = cache + .get_merk(SubtreePathBuilder::owned_from_iter([TEST_LEAF])) + .unwrap() + .unwrap(); + + // Delete child subtree element + parent_merk + .for_merk(|m| { + Element::delete_with_sectioned_removal_bytes( + m, + b"innertree", + None, + true, + TreeType::NormalTree, + &mut |_, removed_key_bytes, removed_value_bytes| { + Ok(( + StorageRemovedBytes::BasicStorageRemoval(removed_key_bytes), + StorageRemovedBytes::BasicStorageRemoval(removed_value_bytes), + )) + }, + version, + ) + .map_err(Error::MerkError) + }) + .unwrap() + .unwrap(); + + // Mark child subtree as deleted in cache (should be done by deletion in + // GroveDb, but we keep it local for now, emulating the logic) + cache.mark_deleted(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree", + ])); + + // Attempt to open merk that was deleted shall fail + assert!(matches!( + cache + .get_merk(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree" + ])) + .unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + + // Let's insert empty tree at that old place in parent + parent_merk + .for_merk(|m| { + Element::empty_tree() + .insert(m, b"innertree", None, version) + .map_err(Error::MerkError) + }) + .unwrap() + .unwrap(); + + // Shall be able to have a merk handle now (note it won't be empty, because we + // applied no operations to clean it up): + assert!(cache + .get_merk(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree", + ])) + .unwrap() + .is_ok()); + } + + #[test] + fn open_subtree_checks_on_parent() { + let version = GroveVersion::latest(); + let db = make_deep_tree(&version); + let tx = db.start_transaction(); + + let cache = MerkCache::<[u8; 0]>::new(&db, &tx, version); + let cost_with_no_cached_parent = cache + .get_merk(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree", + ])) + .cost; + + // This time with fresh cache we load parent first: + + let cache = MerkCache::<[u8; 0]>::new(&db, &tx, version); + + cache + .get_merk(SubtreePathBuilder::owned_from_iter([TEST_LEAF])) + .unwrap() + .unwrap(); + + let cost_with_cached_parent = cache + .get_merk(SubtreePathBuilder::owned_from_iter([ + TEST_LEAF, + b"innertree", + ])) + .cost; + + assert!( + cost_with_cached_parent.storage_loaded_bytes + < cost_with_no_cached_parent.storage_loaded_bytes + ); + } } diff --git a/grovedb/src/operations/delete/delete_internal_on_transaction/mod.rs b/grovedb/src/operations/delete/delete_internal_on_transaction/mod.rs index 866fef358..1a7ae89d4 100644 --- a/grovedb/src/operations/delete/delete_internal_on_transaction/mod.rs +++ b/grovedb/src/operations/delete/delete_internal_on_transaction/mod.rs @@ -33,11 +33,18 @@ //! The two implementations differ ONLY in that non-empty-child-tree branch; //! everything else is identical. See [v0] / [v1]. //! +//! * **[v2]** — `GROVE_V4`+ with backward-references support. A router: +//! calls without `DeleteOptions::propagate_backward_references` run the +//! exact v1 body (identical root hashes and costs); calls with the flag +//! run a `MerkCache`-based flow that cascades backward-reference chains. +//! //! [v0]: self::v0 //! [v1]: self::v1 +//! [v2]: self::v2 mod v0; mod v1; +mod v2; use grovedb_costs::{ storage_cost::removal::StorageRemovedBytes, CostResult, CostsExt, OperationCost, @@ -97,10 +104,19 @@ impl GroveDb { batch, grove_version, ), + 2 => self.delete_internal_on_transaction_v2( + path, + key, + options, + transaction, + sectioned_removal, + batch, + grove_version, + ), version => Err( grovedb_version::error::GroveVersionError::UnknownVersionMismatch { method: "delete_internal_on_transaction".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, } .into(), diff --git a/grovedb/src/operations/delete/delete_internal_on_transaction/v2.rs b/grovedb/src/operations/delete/delete_internal_on_transaction/v2.rs new file mode 100644 index 000000000..0d21eff75 --- /dev/null +++ b/grovedb/src/operations/delete/delete_internal_on_transaction/v2.rs @@ -0,0 +1,402 @@ +//! `delete_internal_on_transaction` — **v2** (`GROVE_V4`+). +//! +//! A behaviour-preserving router. A call without +//! [`DeleteOptions::propagate_backward_references`] runs the exact v1 body +//! (`GROVE_V4`'s parent-reuse delete, issue #686) — identical root hashes +//! and costs. A call WITH the flag runs the `MerkCache`-based flow below, +//! which fetches the deleted element, cascades backward-reference chains +//! (each affected bidirectional reference must allow `cascade_on_update`), +//! and for subtree deletion sweeps the subtree with a raw-iterator visitor +//! while cleaning up backward references along the way. See +//! `adr/bidirectional_references.md`. +//! +//! ## Support under the backward-references flow +//! +//! The flag-on flow supports plain Merk subtrees. Specialized tree types +//! (commitment / MMR / bulk-append / dense / private document store) and +//! indexed-tree primaries are rejected with the flag set — delete them +//! without the flag (none of their contents can be targeted by +//! bidirectional references). + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, storage_cost::removal::StorageRemovedBytes, + CostResult, CostsExt, +}; +use grovedb_merk::{ + element::{delete::ElementDeleteFromStorageExtensions, get::ElementFetchFromStorageExtensions}, + Error as MerkError, +}; +use grovedb_path::{SubtreePath, SubtreePathBuilder}; +use grovedb_storage::{ + rocksdb_storage::PrefixedRocksDbTransactionContext, StorageBatch, StorageContext, +}; +use grovedb_version::version::GroveVersion; + +use super::DeleteOptions; +use crate::{ + bidirectional_references, + merk_cache::MerkCache, + util::visitor::{GroveVisitor, Visit, WalkResult}, + Element, Error, GroveDb, Transaction, +}; + +impl GroveDb { + /// `delete_internal_on_transaction` v2 — see the module documentation. + #[allow(clippy::too_many_arguments)] + pub(crate) fn delete_internal_on_transaction_v2>( + &self, + path: SubtreePath, + key: &[u8], + options: &DeleteOptions, + transaction: &Transaction, + sectioned_removal: &mut impl FnMut( + &Vec, + u32, + u32, + ) -> Result< + (StorageRemovedBytes, StorageRemovedBytes), + MerkError, + >, + batch: &StorageBatch, + grove_version: &GroveVersion, + ) -> CostResult { + if options.propagate_backward_references { + self.delete_with_backward_references( + path, + key, + options, + transaction, + sectioned_removal, + batch, + grove_version, + ) + } else { + self.delete_internal_on_transaction_v1( + path, + key, + options, + transaction, + sectioned_removal, + batch, + grove_version, + ) + } + } + + /// The `MerkCache`-based delete flow with backward-references cascade. + #[allow(clippy::too_many_arguments)] + fn delete_with_backward_references>( + &self, + path: SubtreePath, + key: &[u8], + options: &DeleteOptions, + transaction: &Transaction, + sectioned_removal: &mut impl FnMut( + &Vec, + u32, + u32, + ) -> Result< + (StorageRemovedBytes, StorageRemovedBytes), + MerkError, + >, + batch: &StorageBatch, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = Default::default(); + + let cache = MerkCache::::new(self, transaction, grove_version); + + let mut subtree_to_delete_from = + cost_return_on_error!(&mut cost, cache.get_merk(path.derive_owned())); + + let subtree_to_delete_from_type = cost_return_on_error!( + &mut cost, + subtree_to_delete_from.for_merk(|m| Ok(m.tree_type).wrap_with_cost(Default::default())) + ); + + // Guard on the CONTAINING Merk's type, before even looking the key + // up: deleting a row out of an indexed-tree primary through this + // generic flow would strand its mirrored secondary state. (The + // separate check further down guards the case where the deleted + // element is itself a specialized/indexed tree.) + cost_return_on_error_no_add!( + cost, + crate::operations::indexed_tree::reject_generic_write_into_indexed_primary( + subtree_to_delete_from_type, + "delete with propagate_backward_references", + ) + ); + + let element = cost_return_on_error!( + &mut cost, + subtree_to_delete_from.for_merk(|m| { + Element::get(m, key, true, grove_version).map_err(Error::MerkError) + }) + ); + + if element.is_any_tree() { + // A subtree deletion was requested. + + // The visitor-based sweep below iterates Merk elements; the + // specialized data trees don't store their contents as Merk + // elements, and clearing an indexed primary would strand its + // secondary Merks. None of their contents can be targeted by + // bidirectional references — delete them without the flag. + if element.underlying().uses_non_merk_data_storage() + || element.underlying().is_indexed_tree() + { + return Err(Error::NotSupported( + "specialized data trees and indexed trees cannot be deleted with \ + propagate_backward_references set; delete them without the flag" + .to_owned(), + )) + .wrap_with_cost(cost); + } + + let merk_to_delete_path = path.derive_owned_with_child(key); + let mut merk_to_delete = + cost_return_on_error!(&mut cost, cache.get_merk(merk_to_delete_path.clone())); + let is_empty = cost_return_on_error!( + &mut cost, + merk_to_delete.for_merk(|m| m.is_empty_tree().map(Ok)) + ); + + if !options.allow_deleting_non_empty_trees && !is_empty { + return if options.deleting_non_empty_trees_returns_error { + Err(Error::DeletingNonEmptyTree( + "trying to do a delete operation for a non empty tree, but options not \ + allowing this", + )) + .wrap_with_cost(cost) + } else { + Ok(false).wrap_with_cost(cost) + }; + } + + let deletion_batch = if !is_empty { + // Perform recursive deletion of everything below the element + // we're deleting. During traversal bidirectional references + // are also cleaned up with all required procedures, altering + // the cache state. The rest of the deletion is done outside + // of the cache and is accumulated into a different batch + // that is merged in afterwards. + let visitor = GroveVisitor::new( + &self.db, + transaction, + DeletionVisitor::new( + &cache, + options.propagate_backward_references, + true, + sectioned_removal, + ), + true, + grove_version, + ); + + let WalkResult { + batch: deletion_batch, + .. + } = cost_return_on_error!( + &mut cost, + visitor.walk_from(merk_to_delete_path.clone()) + ); + + Some(deletion_batch) + } else { + None + }; + + // The tree element deletion itself: + cost_return_on_error!( + &mut cost, + subtree_to_delete_from.for_merk(|m| { + Element::delete_with_sectioned_removal_bytes( + m, + key, + Some(options.as_merk_options()), + true, + subtree_to_delete_from_type, + sectioned_removal, + grove_version, + ) + .map_err(Error::MerkError) + }) + ); + // And marking the subtree as deleted in the cache: + cache.mark_deleted(merk_to_delete_path); + + // Processing the given batch: + // 1. add deferred operations from the cache, such as reference and + // regular propagations, ensuring that the "root" of this + // deletion operation is removed beforehand, + // 2. append the batch of recursive deletions. Since the previous + // operations (from the cache) have already removed all + // connections to this data, no special handling is needed — + // just cleanup. + batch.merge_overwriting(*cost_return_on_error!(&mut cost, cache.into_batch())); + deletion_batch + .into_iter() + .for_each(|b| batch.merge_overwriting(b)); + Ok(true).wrap_with_cost(cost) + } else { + // A non-tree element deletion was requested. The removed element + // must be loaded for possible references propagation: + let old = cost_return_on_error!( + &mut cost, + subtree_to_delete_from.for_merk(|m| { + let mut inner_cost = Default::default(); + + let old = cost_return_on_error!( + &mut inner_cost, + Element::get_optional(m, key, true, grove_version) + .map_err(Error::MerkError) + ); + + cost_return_on_error!( + &mut inner_cost, + Element::delete_with_sectioned_removal_bytes( + m, + key, + Some(options.as_merk_options()), + false, + subtree_to_delete_from_type, + sectioned_removal, + grove_version, + ) + .map_err(Error::MerkError) + ); + + Ok(old).wrap_with_cost(inner_cost) + }) + ); + + cost_return_on_error!( + &mut cost, + bidirectional_references::process_update_element_with_backward_references( + &cache, + subtree_to_delete_from, + path.derive_owned(), + key, + grovedb_merk::element::insert::Delta { new: None, old }, + sectioned_removal, + ) + ); + + // Fill the provided batch with what we ended up with after + // deletion using the cache: + batch.merge_overwriting(*cost_return_on_error!(&mut cost, cache.into_batch())); + Ok(true).wrap_with_cost(cost) + } + } +} + +/// We perform recursive deletions by traversing GroveDB. +/// For performance reasons the visitor uses raw iterators and doesn't build +/// Merks, and at first glance it doesn't play well with the caching we have +/// to use for bidirectional references. However, since we're in control of +/// when and how we do modifications inside of the deletion implementation, +/// we're good as long as we do nothing outside of the cache, then finalize +/// it, and only then merge with the final deletion batches. +struct DeletionVisitor<'c, 'db, 'b, 's, B: AsRef<[u8]>> { + propagate_backward_references: bool, + allow_deleting_subtrees: bool, + cache: &'c MerkCache<'db, 'b, B>, + /// The caller's removal-accounting policy, applied to every referrer a + /// cascade deletes on the way. + sectioned_removal: bidirectional_references::SectionedRemovalFn<'s>, +} + +impl<'c, 'db, 'b, 's, B: AsRef<[u8]>> DeletionVisitor<'c, 'db, 'b, 's, B> { + fn new( + cache: &'c MerkCache<'db, 'b, B>, + propagate_backward_references: bool, + allow_deleting_subtrees: bool, + sectioned_removal: bidirectional_references::SectionedRemovalFn<'s>, + ) -> Self { + Self { + propagate_backward_references, + allow_deleting_subtrees, + cache, + sectioned_removal, + } + } +} + +impl<'b, B: AsRef<[u8]>> Visit<'b, B> for DeletionVisitor<'_, '_, 'b, '_, B> { + fn visit_merk(&mut self, _path: SubtreePathBuilder<'b, B>) -> CostResult { + Ok(false).wrap_with_cost(Default::default()) + } + + fn visit_element( + &mut self, + path: SubtreePathBuilder<'b, B>, + key: &[u8], + storage: &PrefixedRocksDbTransactionContext, + element: Element, + ) -> CostResult { + // The process involves two main tasks during traversal: cleaning up + // elements and optionally propagating backward references, possibly + // outside the deletion area. To achieve this efficiently within a + // single traversal, we use both a cache and an internal batch for + // traversal. These can then be merged in the correct order + // afterwards. + let mut cost = Default::default(); + + // Step 1: Delete visited element; the deletion is deferred and stays + // inside of the batch that will be returned after traversal: + if element.is_any_tree() && !self.allow_deleting_subtrees { + // If we're not allowing subtrees deletion, then quick way out + // with a report. + return Ok(true).wrap_with_cost(cost); + } else { + // The same fail-closed rule the directly selected element gets: + // a specialized data tree's contents are not Merk elements (the + // recursive sweep cannot even decode them), and clearing an + // indexed primary here would strand its secondary namespaces. + // Refuse the whole flagged deletion; the caller deletes those + // subtrees without the flag first. + if element.underlying().uses_non_merk_data_storage() + || element.underlying().is_indexed_tree() + { + return Err(Error::NotSupported( + "a descendant specialized data tree or indexed tree blocks deletion with \ + propagate_backward_references set; delete it without the flag first" + .to_owned(), + )) + .wrap_with_cost(cost); + } + cost_return_on_error!(&mut cost, storage.delete(key, None).map_err(Into::into)); + } + + // Step 2: perform backward references' deletion on top of cached + // data: + if self.propagate_backward_references + && matches!( + element, + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + | Element::BidirectionalReference(..) + ) + { + let cached_subtree = + cost_return_on_error!(&mut cost, self.cache.get_merk(path.clone())); + cost_return_on_error!( + &mut cost, + bidirectional_references::process_update_element_with_backward_references( + self.cache, + cached_subtree, + path, + key, + grovedb_merk::element::insert::Delta { + new: None, + old: Some(element) + }, + &mut *self.sectioned_removal, + ) + ); + } + + Ok(false).wrap_with_cost(cost) + } +} diff --git a/grovedb/src/operations/delete/delete_up_tree.rs b/grovedb/src/operations/delete/delete_up_tree.rs index f9f64c492..319cf550b 100644 --- a/grovedb/src/operations/delete/delete_up_tree.rs +++ b/grovedb/src/operations/delete/delete_up_tree.rs @@ -51,6 +51,7 @@ impl DeleteUpTreeOptions { deleting_non_empty_trees_returns_error: self.deleting_non_empty_trees_returns_error, base_root_storage_is_free: self.base_root_storage_is_free, validate_tree_at_path_exists: self.validate_tree_at_path_exists, + propagate_backward_references: false, } } } diff --git a/grovedb/src/operations/delete/mod.rs b/grovedb/src/operations/delete/mod.rs index 62eb54e5e..25823717f 100644 --- a/grovedb/src/operations/delete/mod.rs +++ b/grovedb/src/operations/delete/mod.rs @@ -2,15 +2,24 @@ //! //! # Dangling References //! -//! GroveDB does **not** track backward (incoming) references. When an element -//! is deleted, any existing [`Reference`](crate::Element::Reference) elements -//! that point to it become *dangling*. Attempting to follow a dangling -//! reference will return +//! For ordinary [`Reference`](crate::Element::Reference) elements, GroveDB +//! does **not** track backward (incoming) references. When an element is +//! deleted, any existing references that point to it become *dangling*. +//! Attempting to follow a dangling reference will return //! [`Error::CorruptedReferencePathKeyNotFound`](crate::Error::CorruptedReferencePathKeyNotFound) //! rather than incorrect data, so the failure mode is safe. //! -//! Callers are responsible for ensuring that all references to an element are -//! removed before (or atomically with) the deletion of that element. +//! Callers are responsible for ensuring that all ordinary references to an +//! element are removed before (or atomically with) the deletion of that +//! element. +//! +//! The exception is the opt-in bidirectional-references machinery +//! (`GROVE_V4`+): deleting with +//! [`DeleteOptions::propagate_backward_references`] set cascades any +//! [`BidirectionalReference`](crate::Element::BidirectionalReference) +//! chains that point at the deleted element (each affected reference must +//! allow `cascade_on_update`, otherwise the delete errors instead). See +//! `adr/bidirectional_references.md`. #[cfg(feature = "estimated_costs")] mod average_case; @@ -98,6 +107,13 @@ pub struct DeleteOptions { pub base_root_storage_is_free: bool, /// Validate tree at path exists pub validate_tree_at_path_exists: bool, + /// Propagate updates to elements with backward references. This enables + /// bidirectional-reference bookkeeping for this call: deletions of + /// backward-references elements cascade along the reference chains + /// (each affected reference must allow `cascade_on_update`, otherwise + /// the operation errors). Opt-in per call because the checks require an + /// extra fetch on every delete. Requires `GROVE_V4`+. + pub propagate_backward_references: bool, } #[cfg(feature = "minimal")] @@ -108,6 +124,7 @@ impl Default for DeleteOptions { deleting_non_empty_trees_returns_error: true, base_root_storage_is_free: true, validate_tree_at_path_exists: false, + propagate_backward_references: false, } } } @@ -127,13 +144,18 @@ impl GroveDb { /// /// # Dangling references /// - /// This operation does **not** check for incoming references. If other + /// Without [`DeleteOptions::propagate_backward_references`], this + /// operation does **not** check for incoming references. If other /// elements hold [`Reference`](crate::Element::Reference) paths that point /// to the deleted element, those references become dangling. Following a /// dangling reference will return /// [`Error::CorruptedReferencePathKeyNotFound`](crate::Error::CorruptedReferencePathKeyNotFound), /// not incorrect data. Callers must manage reference lifecycle and remove - /// or update any references to this element before deleting it. + /// or update any ordinary references to this element before deleting it. + /// + /// With the flag set (`GROVE_V4`+), bidirectional references pointing at + /// the deleted element are cascade-deleted instead — see the + /// [module-level documentation](self). pub fn delete<'b, B, P>( &self, path: P, @@ -192,10 +214,13 @@ impl GroveDb { /// /// # Dangling references /// - /// This operation does **not** check for incoming references. Any - /// [`Reference`](crate::Element::Reference) elements elsewhere in the - /// database that point to elements within the cleared subtree will become - /// dangling. See the [module-level documentation](self) for details. + /// This operation does **not** check for incoming references (it has no + /// backward-references propagation option). Any + /// [`Reference`](crate::Element::Reference) or + /// [`BidirectionalReference`](crate::Element::BidirectionalReference) + /// elements elsewhere in the database that point to elements within the + /// cleared subtree will become dangling. See the + /// [module-level documentation](self) for details. pub fn clear_subtree<'b, B, P>( &self, path: P, @@ -739,11 +764,14 @@ mod tests { use grovedb_version::version::{v3::GROVE_V3, GroveVersion}; use pretty_assertions::assert_eq; + use grovedb_path::SubtreePath; + use crate::{ operations::delete::{delete_up_tree::DeleteUpTreeOptions, ClearOptions, DeleteOptions}, reference_path::ReferencePathType, tests::{ - common::EMPTY_PATH, make_empty_grovedb, make_test_grovedb, ANOTHER_TEST_LEAF, TEST_LEAF, + common::{make_tree_with_bidi_references, EMPTY_PATH}, + make_empty_grovedb, make_test_grovedb, ANOTHER_TEST_LEAF, TEST_LEAF, }, Element, Error, }; @@ -2263,4 +2291,130 @@ mod tests { err ); } + + #[test] + fn delete_item_with_backward_references() { + // Deletion of an item with backward references shall trigger cascade + // deletions if the flag is set + let version = GroveVersion::latest(); + let db = make_tree_with_bidi_references(version); + + assert!(db + .get(&[TEST_LEAF, b"innertree"], b"ref", None, version) + .unwrap() + .is_ok()); + + db.delete( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_2"], + b"key5", + Some(DeleteOptions { + allow_deleting_non_empty_trees: false, + deleting_non_empty_trees_returns_error: true, + base_root_storage_is_free: true, + validate_tree_at_path_exists: true, + propagate_backward_references: true, + }), + None, + version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.get(&[TEST_LEAF, b"innertree"], b"ref", None, version) + .unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + } + + #[test] + fn recursive_deletion_with_bidirectional_references() { + // The purpose of this test is to check if bidirectional references + // chain propagation works properly when a part of it happens to be + // under a recursive deletion effect. + // The expected result is that references inside test_leaf and + // another_test_leaf shall be deleted as well; those that happened + // to be inside of deep_leaf are gone with the deleted subtree. + + let version = GroveVersion::latest(); + + let db = make_tree_with_bidi_references(version); + + let transaction = db.start_transaction(); + + // Perform recursive deletion: + db.delete( + SubtreePath::empty(), + b"deep_leaf", + Some(DeleteOptions { + allow_deleting_non_empty_trees: true, + deleting_non_empty_trees_returns_error: false, + base_root_storage_is_free: true, + validate_tree_at_path_exists: true, + propagate_backward_references: true, + }), + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + // Outside of deletion area: + assert!(matches!( + db.get( + &[TEST_LEAF, b"innertree"], + b"ref", + Some(&transaction), + version + ) + .unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + + // Inside: + assert!(matches!( + db.get( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_1"], + b"ref3", + Some(&transaction), + version + ) + .unwrap(), + Err(Error::PathParentLayerNotFound(_)) + )); + + // Commit and re-check against persisted state: the cascade must + // survive the transaction boundary, the whole graph must verify, + // and proofs over surviving data must check out against the new + // root hash. + db.commit_transaction(transaction).unwrap().unwrap(); + + assert!(matches!( + db.get(&[TEST_LEAF, b"innertree"], b"ref", None, version) + .unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + assert!(db + .verify_grovedb(None, true, true, version) + .unwrap() + .is_empty()); + + let mut query = crate::Query::new(); + query.insert_all(); + let path_query = + crate::PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"innertree".to_vec()], query); + let proof = db + .prove_query(&path_query, None, version) + .unwrap() + .expect("should prove after cascade deletion"); + let (proved_root, results) = crate::GroveDb::verify_query(&proof, &path_query, version) + .expect("proof should verify"); + assert_eq!( + proved_root, + db.root_hash(None, version).unwrap().unwrap(), + "proved root must match the committed root" + ); + // innertree originally held key1..key3 plus the now-cascaded `ref`. + assert_eq!(results.len(), 3); + } } diff --git a/grovedb/src/operations/get/mod.rs b/grovedb/src/operations/get/mod.rs index 3eaa652b5..b3e47cd64 100644 --- a/grovedb/src/operations/get/mod.rs +++ b/grovedb/src/operations/get/mod.rs @@ -28,6 +28,7 @@ use grovedb_path::SubtreePath; use grovedb_storage::StorageContext; use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; +use crate::bidirectional_references::BidirectionalReference; use crate::{ reference_path::{path_from_reference_path_type, path_from_reference_qualified_path_type}, util::TxRef, @@ -109,7 +110,32 @@ impl GroveDb { ) .add_cost(cost) } - other => Ok(other).wrap_with_cost(cost), + // A bidirectional reference's declared `max_hop` bounds the + // whole resolution, exactly like the query surfaces (plain + // references keep their historical global-budget behavior). + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: reference_path, + max_hop, + .. + }, + _, + ) => { + let path_owned = cost_return_on_error_into!( + &mut cost, + path_from_reference_path_type(reference_path, &path.to_vec(), Some(key)) + .wrap_with_cost(OperationCost::default()) + ); + self.follow_reference_with_max_hop( + path_owned.as_slice().into(), + max_hop, + allow_cache, + transaction, + grove_version, + ) + .add_cost(cost) + } + other => Ok(other.stripped_of_backward_references()).wrap_with_cost(cost), } } @@ -207,7 +233,7 @@ impl GroveDb { fn follow_reference_as_stored_visiting>( &self, path: SubtreePath, - mut visited: HashSet>>, + visited: HashSet>>, allow_cache: bool, transaction: TransactionArg, grove_version: &GroveVersion, @@ -221,9 +247,61 @@ impl GroveDb { .follow_reference ); + self.follow_reference_with_max_hop_visiting( + path, + None, + visited, + allow_cache, + transaction, + grove_version, + ) + } + + /// [`Self::follow_reference`] with the FIRST edge's declared `max_hop` + /// applied on top of the global budget. Mid-chain bidirectional edges + /// additionally cap the remaining budget with their own declarations, so + /// a chain never resolves through more hops than any of its + /// bidirectional members allow. + pub(crate) fn follow_reference_with_max_hop>( + &self, + path: SubtreePath, + max_hop: Option, + allow_cache: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { + self.follow_reference_with_max_hop_visiting( + path, + max_hop, + HashSet::new(), + allow_cache, + transaction, + grove_version, + ) + } + + /// The single walk behind every `follow_reference*` entry point: + /// `max_hop` is the first edge's declared budget (bidirectional edges + /// met along the way cap the remainder with their own), and `visited` + /// seeds the cycle check so a chain is refused as cyclic as soon as it + /// reaches any qualified path already in the set (a reference being + /// written seeds its own position; see + /// [`Self::follow_reference_as_stored_for_write`]). + fn follow_reference_with_max_hop_visiting>( + &self, + path: SubtreePath, + max_hop: Option, + mut visited: HashSet>>, + allow_cache: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult { let mut cost = OperationCost::default(); - let mut hops_left = MAX_REFERENCE_HOPS; + let mut hops_left = max_hop + .map(|m| m as usize) + .unwrap_or(MAX_REFERENCE_HOPS) + .min(MAX_REFERENCE_HOPS); let mut current_element; // TODO, still have to do because of references handling let mut current_path = path.to_vec(); @@ -263,21 +341,45 @@ impl GroveDb { // reference is followed instead of being returned as a value. // `ReferenceWithSumItem` is also followed — the carried sum is // irrelevant to chain destination. The terminal is handed back - // untouched (wrapper and all): it is the stored element. + // with its wrapper intact (it is the stored element); only its + // referrer list is stripped, see below. + // + // A bidirectional edge additionally carries a per-edge budget: + // this edge's declaration caps however much of the global + // budget remains. The fetch of THIS node is already paid for + // (the decrement below), and the edge's budget counts hops from + // here on — so cap at `edge_budget + 1`, leaving exactly + // `edge_budget` further fetches. An edge declaring `max_hop: 1` + // may reach its direct target but no reference beyond it. let next_hop = match current_element.underlying() { Element::Reference(reference_path, ..) - | Element::ReferenceWithSumItem(reference_path, ..) => Some(reference_path.clone()), + | Element::ReferenceWithSumItem(reference_path, ..) => { + Some((reference_path.clone(), None)) + } + Element::BidirectionalReference(reference, _) => { + Some((reference.forward_reference_path.clone(), reference.max_hop)) + } _ => None, }; match next_hop { - Some(reference_path) => { + Some((reference_path, edge_budget)) => { + if let Some(edge_budget) = edge_budget { + hops_left = hops_left.min(edge_budget as usize + 1); + } current_path = cost_return_on_error_into!( &mut cost, path_from_reference_qualified_path_type(reference_path, ¤t_path) .wrap_with_cost(OperationCost::default()) ) } - None => return Ok(current_element).wrap_with_cost(cost), + None => { + // The referrer list is internal bookkeeping; reads return + // the logical (stripped) form, matching what proofs carry + // and what forward references commit to. For every other + // element this is the stored element unchanged. + return Ok(current_element.stripped_of_backward_references()) + .wrap_with_cost(cost); + } } hops_left -= 1; } @@ -357,6 +459,7 @@ impl GroveDb { tx.as_ref(), grove_version, ) + .map_ok(|element| element.stripped_of_backward_references()) } /// Get Element at specified path and key @@ -408,6 +511,7 @@ impl GroveDb { tx.as_ref(), grove_version, ) + .map_ok(|element| element.map(|e| e.stripped_of_backward_references())) } /// Get tree item without following references diff --git a/grovedb/src/operations/get/query.rs b/grovedb/src/operations/get/query.rs index 3777da0e4..683f51e2b 100644 --- a/grovedb/src/operations/get/query.rs +++ b/grovedb/src/operations/get/query.rs @@ -94,7 +94,22 @@ impl GroveDb { // Look through `NonCounted` so a wrapped reference still // resolves; the wrapper is transparent at the query // layer. - match element.into_underlying() { + // A bidirectional reference resolves exactly like a + // plain reference; normalize it so the match below needs + // no extra arm. + let mut bidirectional_edge_budget = None; + let element = match element.into_underlying() { + Element::BidirectionalReference(reference, flags) => { + bidirectional_edge_budget = reference.max_hop; + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + flags, + ) + } + other => other, + }; + match element { Element::Reference(reference_path, ..) | Element::ReferenceWithSumItem(reference_path, ..) => match reference_path { @@ -104,8 +119,9 @@ impl GroveDb { // external costs accumulator instead of // returning costs from `map` call. let maybe_item = self - .follow_reference( + .follow_reference_with_max_hop( absolute_path.as_slice().into(), + bidirectional_edge_budget, allow_cache, transaction, grove_version, @@ -114,9 +130,16 @@ impl GroveDb { // Same treatment for the resolved value. match maybe_item.into_underlying() { - Element::Item(item, _) => Ok(item), - Element::ItemWithSumItem(item, ..) => Ok(item), - Element::SumItem(value, _) => Ok(value.encode_var_vec()), + Element::Item(item, _) + | Element::ItemWithSumItem(item, ..) + | Element::ItemWithBackwardsReferences(item, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(item, ..) => { + Ok(item) + } + Element::SumItem(value, _) + | Element::SumItemWithBackwardsReferences(value, _, _) => { + Ok(value.encode_var_vec()) + } _ => Err(Error::InvalidQuery( "the reference must result in an item", )), @@ -227,6 +250,18 @@ where { // resolves; a NonCounted item still returns itself. The wrapper's // sole effect is on parent count aggregation. let element = element.into_underlying(); + // A bidirectional reference resolves exactly like a plain reference; + // normalize it so the match below needs no extra arm. Never leaks to + // the caller: reference-family elements are always resolved, not + // returned. + let mut bidirectional_edge_budget = None; + let element = match element { + Element::BidirectionalReference(reference, flags) => { + bidirectional_edge_budget = reference.max_hop; + Element::Reference(reference.forward_reference_path, reference.max_hop, flags) + } + other => other, + }; match element { Element::Reference(reference_path, ..) | Element::ReferenceWithSumItem(reference_path, ..) => { @@ -244,8 +279,9 @@ where { // path; the sum carried on the source element does // not affect what `follow_reference` returns. let maybe_item = self - .follow_reference( + .follow_reference_with_max_hop( absolute_path.as_slice().into(), + bidirectional_edge_budget, allow_cache, transaction, grove_version, @@ -267,6 +303,9 @@ where { Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) | Element::SumTree(..) | Element::BigSumTree(..) | Element::CountTree(..) @@ -274,7 +313,14 @@ where { | Element::ProvableCountTree(..) | Element::ProvableCountSumTree(..) | Element::ProvableSumTree(..) - | Element::ProvableCountProvableSumTree(..) => Ok(element), + | Element::ProvableCountProvableSumTree(..) => { + // Public results carry the logical (stripped) form; the + // referrer list is internal bookkeeping. + Ok(element.stripped_of_backward_references()) + } + Element::BidirectionalReference(..) => { + unreachable!("normalized to Element::Reference above") + } Element::Tree(..) | Element::CommitmentTree(..) | Element::MmrTree(..) @@ -375,6 +421,21 @@ where { QueryResultElement::ElementResultItem(element) => { // NonCounted is transparent at this layer. let element = element.into_underlying(); + // Normalize a bidirectional reference to its + // plain-reference shape; resolution is identical and the + // reference element itself is never returned from here. + let mut bidirectional_edge_budget = None; + let element = match element { + Element::BidirectionalReference(reference, flags) => { + bidirectional_edge_budget = reference.max_hop; + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + flags, + ) + } + other => other, + }; match element { // `ReferenceWithSumItem` resolves to the target item // the same way `Reference` does; the carried sum is @@ -390,8 +451,9 @@ where { // external costs accumulator instead of // returning costs from `map` call. let maybe_item = self - .follow_reference( + .follow_reference_with_max_hop( absolute_path.as_slice().into(), + bidirectional_edge_budget, allow_cache, transaction, grove_version, @@ -400,8 +462,16 @@ where { match maybe_item.into_underlying() { Element::Item(item, _) - | Element::ItemWithSumItem(item, ..) => Ok(item), - Element::SumItem(item, _) => Ok(item.encode_var_vec()), + | Element::ItemWithSumItem(item, ..) + | Element::ItemWithBackwardsReferences(item, _, _) + | Element::ItemWithSumItemWithBackwardsReferences( + item, + .., + ) => Ok(item), + Element::SumItem(item, _) + | Element::SumItemWithBackwardsReferences(item, _, _) => { + Ok(item.encode_var_vec()) + } _ => Err(Error::InvalidQuery( "the reference must result in an item", )), @@ -412,8 +482,17 @@ where { )), } } - Element::Item(item, _) | Element::ItemWithSumItem(item, ..) => Ok(item), - Element::SumItem(item, _) => Ok(item.encode_var_vec()), + Element::Item(item, _) + | Element::ItemWithSumItem(item, ..) + | Element::ItemWithBackwardsReferences(item, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(item, ..) => Ok(item), + Element::SumItem(item, _) + | Element::SumItemWithBackwardsReferences(item, _, _) => { + Ok(item.encode_var_vec()) + } + Element::BidirectionalReference(..) => { + unreachable!("normalized to Element::Reference above") + } Element::Tree(..) | Element::SumTree(..) | Element::BigSumTree(..) @@ -490,6 +569,21 @@ where { QueryResultElement::ElementResultItem(element) => { // NonCounted is transparent at this layer. let element = element.into_underlying(); + // Normalize a bidirectional reference to its + // plain-reference shape; resolution is identical and the + // reference element itself is never returned from here. + let mut bidirectional_edge_budget = None; + let element = match element { + Element::BidirectionalReference(reference, flags) => { + bidirectional_edge_budget = reference.max_hop; + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + flags, + ) + } + other => other, + }; match element { // `ReferenceWithSumItem` resolves to the target item // exactly like `Reference`; the carried sum value @@ -505,8 +599,9 @@ where { // external costs accumulator instead of // returning costs from `map` call. let maybe_item = self - .follow_reference( + .follow_reference_with_max_hop( absolute_path.as_slice().into(), + bidirectional_edge_budget, allow_cache, transaction, grove_version, @@ -514,17 +609,24 @@ where { .unwrap_add_cost(&mut cost)?; match maybe_item.into_underlying() { - Element::Item(item, _) => { + Element::Item(item, _) + | Element::ItemWithBackwardsReferences(item, _, _) => { Ok(QueryItemOrSumReturnType::ItemData(item)) } - Element::SumItem(sum_value, _) => { - Ok(QueryItemOrSumReturnType::SumValue(sum_value)) - } - Element::ItemWithSumItem(item, sum_value, _) => { - Ok(QueryItemOrSumReturnType::ItemDataWithSumValue( - item, sum_value, - )) - } + Element::SumItem(sum_value, _) + | Element::SumItemWithBackwardsReferences( + sum_value, + _, + _, + ) => Ok(QueryItemOrSumReturnType::SumValue(sum_value)), + Element::ItemWithSumItem(item, sum_value, _) + | Element::ItemWithSumItemWithBackwardsReferences( + item, + sum_value, + .., + ) => Ok(QueryItemOrSumReturnType::ItemDataWithSumValue( + item, sum_value, + )), Element::SumTree(_, sum_value, _) => { Ok(QueryItemOrSumReturnType::SumValue(sum_value)) } @@ -590,13 +692,23 @@ where { )), } } - Element::Item(item, _) => Ok(QueryItemOrSumReturnType::ItemData(item)), - Element::SumItem(sum_value, _) => { + Element::Item(item, _) + | Element::ItemWithBackwardsReferences(item, _, _) => { + Ok(QueryItemOrSumReturnType::ItemData(item)) + } + Element::SumItem(sum_value, _) + | Element::SumItemWithBackwardsReferences(sum_value, _, _) => { Ok(QueryItemOrSumReturnType::SumValue(sum_value)) } - Element::ItemWithSumItem(item, sum_value, _) => Ok( - QueryItemOrSumReturnType::ItemDataWithSumValue(item, sum_value), - ), + Element::ItemWithSumItem(item, sum_value, _) + | Element::ItemWithSumItemWithBackwardsReferences(item, sum_value, ..) => { + Ok(QueryItemOrSumReturnType::ItemDataWithSumValue( + item, sum_value, + )) + } + Element::BidirectionalReference(..) => { + unreachable!("normalized to Element::Reference above") + } Element::SumTree(_, sum_value, _) => { Ok(QueryItemOrSumReturnType::SumValue(sum_value)) } @@ -1065,6 +1177,21 @@ where { QueryResultElement::ElementResultItem(element) => { // NonCounted is transparent at this layer. let element = element.into_underlying(); + // Normalize a bidirectional reference to its + // plain-reference shape; resolution is identical and the + // reference element itself is never returned from here. + let mut bidirectional_edge_budget = None; + let element = match element { + Element::BidirectionalReference(reference, flags) => { + bidirectional_edge_budget = reference.max_hop; + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + flags, + ) + } + other => other, + }; match element { // For `ReferenceWithSumItem` we follow the reference // just like `Reference` — the carried sum is a @@ -1081,21 +1208,27 @@ where { // external costs accumulator instead of // returning costs from `map` call. let maybe_item = self - .follow_reference( + .follow_reference_with_max_hop( absolute_path.as_slice().into(), + bidirectional_edge_budget, allow_cache, transaction, grove_version, ) .unwrap_add_cost(&mut cost)?; - if let Element::SumItem(item, _) = maybe_item.into_underlying() - { - Ok(item) - } else { - Err(Error::InvalidQuery( + match maybe_item.into_underlying() { + Element::SumItem(item, _) + | Element::ItemWithSumItem(_, item, _) + | Element::SumItemWithBackwardsReferences(item, _, _) + | Element::ItemWithSumItemWithBackwardsReferences( + _, + item, + .., + ) => Ok(item), + _ => Err(Error::InvalidQuery( "the reference must result in a sum item", - )) + )), } } _ => Err(Error::CorruptedCodeExecution( @@ -1103,8 +1236,12 @@ where { )), } } - Element::SumItem(item, _) | Element::ItemWithSumItem(_, item, _) => { - Ok(item) + Element::SumItem(item, _) + | Element::ItemWithSumItem(_, item, _) + | Element::SumItemWithBackwardsReferences(item, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, item, ..) => Ok(item), + Element::BidirectionalReference(..) => { + unreachable!("normalized to Element::Reference above") } Element::Tree(..) | Element::SumTree(..) @@ -1123,7 +1260,8 @@ where { | Element::ProvableSumIndexedTree(..) | Element::ProvableCountProvableSumIndexedTree(..) | Element::ProvableCountIndexedTree(..) - | Element::Item(..) => Err(Error::InvalidQuery( + | Element::Item(..) + | Element::ItemWithBackwardsReferences(..) => Err(Error::InvalidQuery( "path_queries over sum items can only refer to sum items and \ references", )), diff --git a/grovedb/src/operations/insert/add_element_on_transaction/v0.rs b/grovedb/src/operations/insert/add_element_on_transaction/v0.rs index e4b5b0916..e7a0ed685 100644 --- a/grovedb/src/operations/insert/add_element_on_transaction/v0.rs +++ b/grovedb/src/operations/insert/add_element_on_transaction/v0.rs @@ -268,6 +268,22 @@ impl GroveDb { // inserted via `Op::Put` here (NOT the layered-subtree arm above) to // preserve the grovedb v4.1.0 / protocol-v11 consensus root — see the // module docs. + Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // Backward-references elements activate with `GROVE_V4`; + // this v0 arm is selected by `GROVE_V1` / `GROVE_V2` where + // they must not exist. Fail closed. + return Err(Error::NotSupported( + "backward-references elements (BidirectionalReference, \ + ItemWithBackwardsReferences, SumItemWithBackwardsReferences, \ + ItemWithSumItemWithBackwardsReferences) require \ + GROVE_V4+" + .to_owned(), + )) + .wrap_with_cost(cost); + } Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) diff --git a/grovedb/src/operations/insert/add_element_on_transaction/v1.rs b/grovedb/src/operations/insert/add_element_on_transaction/v1.rs index e66ba3dc2..44bf858e4 100644 --- a/grovedb/src/operations/insert/add_element_on_transaction/v1.rs +++ b/grovedb/src/operations/insert/add_element_on_transaction/v1.rs @@ -127,7 +127,7 @@ impl GroveDb { let referenced_element_value_hash = cost_return_on_error_into!( &mut cost, - referenced_item.value_hash(grove_version) + referenced_item.logical_value_hash(grove_version) ); cost_return_on_error_into!( @@ -260,7 +260,26 @@ impl GroveDb { ) ); } - Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) => { + Element::Item(..) + | Element::SumItem(..) + | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // The backward-references item variants store exactly like + // their plain counterparts; the backward-reference + // bookkeeping only runs when the caller opts in via + // `propagate_backward_references` (routed before this call). + // + // DELIBERATE TRADEOFF (see adr/bidirectional_references.md): + // without the flag, overwriting a key that carries backward + // references performs NO cascade/propagation — any + // bidirectional references pointing at it keep their old + // hashes and `verify_grovedb(..., verify_references, ..)` + // will then report them. Detecting that case would require + // fetching the previous element on EVERY insert, a cost the + // flag exists to avoid; consistency is the caller's promise + // once they've mixed flagged and unflagged writes. cost_return_on_error_into!( &mut cost, element.insert( @@ -271,6 +290,19 @@ impl GroveDb { ) ); } + Element::BidirectionalReference(..) => { + // Inserting a bidirectional reference must register its + // backward reference in the target's meta storage, which + // only the `MerkCache`-based flow performs + // (`insert_on_transaction` v1 routes it there before ever + // reaching this function). Fail closed. + return Err(Error::NotSupported( + "bidirectional references can only be inserted through the \ + backward-references insertion flow (GROVE_V4+)" + .to_owned(), + )) + .wrap_with_cost(cost); + } Element::ProvableCountIndexedTree(primary, secondary, count_value, _) => { let (primary_root_hash, secondary_root_hash) = if primary.is_none() && secondary.is_none() diff --git a/grovedb/src/operations/insert/add_element_on_transaction/v2.rs b/grovedb/src/operations/insert/add_element_on_transaction/v2.rs index 47d2368b1..13fce898e 100644 --- a/grovedb/src/operations/insert/add_element_on_transaction/v2.rs +++ b/grovedb/src/operations/insert/add_element_on_transaction/v2.rs @@ -174,7 +174,7 @@ impl GroveDb { let referenced_element_value_hash = cost_return_on_error_into!( &mut cost, - referenced_item.value_hash(grove_version) + referenced_item.logical_value_hash(grove_version) ); cost_return_on_error_into!( @@ -307,7 +307,26 @@ impl GroveDb { ) ); } - Element::Item(..) | Element::SumItem(..) | Element::ItemWithSumItem(..) => { + Element::Item(..) + | Element::SumItem(..) + | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // The backward-references item variants store exactly like + // their plain counterparts; the backward-reference + // bookkeeping only runs when the caller opts in via + // `propagate_backward_references` (routed before this call). + // + // DELIBERATE TRADEOFF (see adr/bidirectional_references.md): + // without the flag, overwriting a key that carries backward + // references performs NO cascade/propagation — any + // bidirectional references pointing at it keep their old + // hashes and `verify_grovedb(..., verify_references, ..)` + // will then report them. Detecting that case would require + // fetching the previous element on EVERY insert, a cost the + // flag exists to avoid; consistency is the caller's promise + // once they've mixed flagged and unflagged writes. cost_return_on_error_into!( &mut cost, element.insert( @@ -318,6 +337,19 @@ impl GroveDb { ) ); } + Element::BidirectionalReference(..) => { + // Inserting a bidirectional reference must register its + // backward reference on the target element, which only the + // `MerkCache`-based flow performs (`insert_on_transaction` + // v1 routes it there before ever reaching this function). + // Fail closed. + return Err(Error::NotSupported( + "bidirectional references can only be inserted through the \ + backward-references insertion flow (GROVE_V4+)" + .to_owned(), + )) + .wrap_with_cost(cost); + } Element::ProvableCountIndexedTree(primary, secondary, count_value, _) => { let (primary_root_hash, secondary_root_hash) = if primary.is_none() && secondary.is_none() diff --git a/grovedb/src/operations/insert/insert_on_transaction/mod.rs b/grovedb/src/operations/insert/insert_on_transaction/mod.rs new file mode 100644 index 000000000..4814c3fda --- /dev/null +++ b/grovedb/src/operations/insert/insert_on_transaction/mod.rs @@ -0,0 +1,78 @@ +//! `insert_on_transaction` — versioned dispatch. +//! +//! The single-element insert executor behind `GroveDb::insert` (and through +//! it `insert_if_not_exists` / `insert_if_changed_value`). +//! +//! * **[v0]** — shipped behaviour, selected by `GROVE_V1`..`GROVE_V3`: +//! `add_element_on_transaction` + explicit parent propagation. Rejects the +//! backward-references element family (`BidirectionalReference`, +//! `ItemWithBackwardsReferences`, `SumItemWithBackwardsReferences`), which +//! activates with `GROVE_V4`. +//! * **[v1]** — `GROVE_V4`+. Behaviour-preserving router: calls that neither +//! insert a `BidirectionalReference` nor set +//! `InsertOptions::propagate_backward_references` run the exact v0 body +//! (same root hashes, same costs). The remainder run through the +//! `MerkCache`-based flow in [v1], which performs backward-reference +//! bookkeeping and propagation (see `adr/bidirectional_references.md`). +//! +//! [v0]: self::v0 +//! [v1]: self::v1 + +mod v0; +mod v1; + +use grovedb_costs::CostResult; +use grovedb_path::SubtreePath; +use grovedb_storage::StorageBatch; +use grovedb_version::{dispatch_version, version::GroveVersion}; + +use super::InsertOptions; +use crate::{Element, Error, GroveDb, Transaction}; + +impl GroveDb { + /// Insert an element on a transaction — versioned dispatch, see the + /// module documentation. + pub(crate) fn insert_on_transaction<'db, 'b, B: AsRef<[u8]>>( + &self, + path: SubtreePath<'b, B>, + key: &[u8], + element: Element, + options: InsertOptions, + transaction: &'db Transaction, + batch: &StorageBatch, + grove_version: &GroveVersion, + ) -> CostResult<(), Error> { + dispatch_version!( + "insert_on_transaction", + grove_version + .grovedb_versions + .operations + .insert + .insert_on_transaction, + 0 => { + v0::insert_on_transaction( + self, + path, + key, + element, + options, + transaction, + batch, + grove_version, + ) + } + 1 => { + v1::insert_on_transaction( + self, + path, + key, + element, + options, + transaction, + batch, + grove_version, + ) + } + ) + } +} diff --git a/grovedb/src/operations/insert/insert_on_transaction/v0.rs b/grovedb/src/operations/insert/insert_on_transaction/v0.rs new file mode 100644 index 000000000..52ded83eb --- /dev/null +++ b/grovedb/src/operations/insert/insert_on_transaction/v0.rs @@ -0,0 +1,103 @@ +//! `insert_on_transaction` — **v0** (shipped behaviour, `GROVE_V1`..`GROVE_V3`). +//! +//! `add_element_on_transaction` (itself versioned) followed by explicit +//! parent-propagation. The backward-references element family is rejected — +//! it activates with `GROVE_V4`, whose router ([`super::v1`]) still funnels +//! every non-backward-references call through [`insert_on_transaction_body`] +//! unchanged. + +use std::collections::HashMap; + +use grovedb_costs::{cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt}; +use grovedb_merk::Merk; +use grovedb_path::SubtreePath; +use grovedb_storage::{rocksdb_storage::PrefixedRocksDbTransactionContext, StorageBatch}; +use grovedb_version::version::GroveVersion; + +use super::super::InsertOptions; +use crate::{Element, Error, GroveDb, OperationCost, Transaction}; + +pub(super) fn insert_on_transaction<'db, 'b, B: AsRef<[u8]>>( + db: &GroveDb, + path: SubtreePath<'b, B>, + key: &[u8], + element: Element, + options: InsertOptions, + transaction: &'db Transaction, + batch: &StorageBatch, + grove_version: &GroveVersion, +) -> CostResult<(), Error> { + // Fail closed: the backward-references family activates with GROVE_V4. + // Checked on the underlying element so a hand-built wrapper + // (`NonCounted(ItemWithBackwardsReferences)`, which no constructor + // produces) cannot slip past the gate into the shipped insert body. + if element.underlying().supports_backward_references() { + return Err(Error::NotSupported( + "backward-references elements (BidirectionalReference, \ + ItemWithBackwardsReferences, SumItemWithBackwardsReferences, \ + ItemWithSumItemWithBackwardsReferences) require GROVE_V4+" + .to_owned(), + )) + .wrap_with_cost(Default::default()); + } + + insert_on_transaction_body( + db, + path, + key, + element, + options, + transaction, + batch, + grove_version, + ) +} + +/// The shipped insert flow, shared verbatim by v0 and by v1's +/// non-backward-references route so the two produce identical root hashes +/// and costs. +pub(super) fn insert_on_transaction_body<'db, 'b, B: AsRef<[u8]>>( + db: &GroveDb, + path: SubtreePath<'b, B>, + key: &[u8], + element: Element, + options: InsertOptions, + transaction: &'db Transaction, + batch: &StorageBatch, + grove_version: &GroveVersion, +) -> CostResult<(), Error> { + let mut cost = OperationCost::default(); + + let mut merk_cache: HashMap, Merk> = + HashMap::default(); + + let merk = cost_return_on_error!( + &mut cost, + db.add_element_on_transaction( + path.clone(), + key, + element, + options, + transaction, + batch, + grove_version + ) + ); + // A generic insert cannot mirror the new child's ordering value into + // an indexed primary's secondary index. Reject before propagation, so + // the `StorageBatch` is discarded and nothing is committed. + cost_return_on_error_no_add!( + cost, + crate::operations::indexed_tree::reject_generic_write_into_indexed_primary( + merk.tree_type, + "insert", + ) + ); + merk_cache.insert(path.clone(), merk); + cost_return_on_error!( + &mut cost, + db.propagate_changes_with_transaction(merk_cache, path, transaction, batch, grove_version) + ); + + Ok(()).wrap_with_cost(cost) +} diff --git a/grovedb/src/operations/insert/insert_on_transaction/v1.rs b/grovedb/src/operations/insert/insert_on_transaction/v1.rs new file mode 100644 index 000000000..0bf61e893 --- /dev/null +++ b/grovedb/src/operations/insert/insert_on_transaction/v1.rs @@ -0,0 +1,345 @@ +//! `insert_on_transaction` — **v1** (`GROVE_V4`+). +//! +//! A behaviour-preserving router. A call that neither inserts a +//! [`Element::BidirectionalReference`] nor sets +//! [`InsertOptions::propagate_backward_references`] runs the exact shipped +//! flow ([`super::v0::insert_on_transaction_body`]) — identical root hashes +//! and costs. The rest run through the `MerkCache`-based flow below, which +//! keeps every touched subtree open in one cache so backward-reference +//! bookkeeping, chain propagation, and ordinary parent propagation all see +//! each other's uncommitted writes (see `adr/bidirectional_references.md` +//! and `adr/merk_cache.md`). +//! +//! ## Element support under the backward-references flow +//! +//! The `MerkCache` flow supports items, references (all three variants), +//! and empty plain-Merk trees. Specialized tree types (commitment / MMR / +//! bulk-append / dense / private document store / indexed trees) and the +//! aggregation wrappers are rejected when `propagate_backward_references` +//! is set — their child-hash conventions live in +//! `add_element_on_transaction` and have no backward-references semantics +//! yet. Insert them without the flag (they cannot be targeted by +//! bidirectional references anyway). + +use grovedb_costs::{cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt}; +use grovedb_merk::{ + element::{ + costs::ElementCostExtensions, get::ElementFetchFromStorageExtensions, + insert::ElementInsertToStorageExtensions, + }, + tree::NULL_HASH, +}; +use grovedb_path::SubtreePath; +use grovedb_storage::StorageBatch; +use grovedb_version::version::GroveVersion; + +use super::super::InsertOptions; +use crate::{ + bidirectional_references::{ + basic_sectioned_removal, process_bidirectional_reference_insertion, + process_update_element_with_backward_references, + }, + merk_cache::MerkCache, + reference_path::follow_reference, + Element, Error, GroveDb, Transaction, +}; + +pub(super) fn insert_on_transaction<'db, 'b, B: AsRef<[u8]>>( + db: &GroveDb, + path: SubtreePath<'b, B>, + key: &[u8], + element: Element, + options: InsertOptions, + transaction: &'db Transaction, + batch: &StorageBatch, + grove_version: &GroveVersion, +) -> CostResult<(), Error> { + // Backward-references elements are never wrapped: the wrappers' + // constructors refuse them and deserialization rejects the shape, so a + // hand-built `NonCounted(family)` must not reach either route below + // (the plain route would store bytes no reader accepts). + if element.is_wrapped() && element.underlying().supports_backward_references() { + return Err(Error::InvalidInput( + "backward-references elements (BidirectionalReference, \ + ItemWithBackwardsReferences, SumItemWithBackwardsReferences, \ + ItemWithSumItemWithBackwardsReferences) cannot be wrapped in NonCounted / \ + NotSummed / NotCountedOrSummed", + )) + .wrap_with_cost(Default::default()); + } + + // A bidirectional reference must always register itself in its target's + // meta storage, flag or no flag; everything else opts into the + // backward-references flow via the flag. + if matches!(element, Element::BidirectionalReference(..)) + || options.propagate_backward_references + { + insert_with_backward_references( + db, + path, + key, + element, + options, + transaction, + batch, + grove_version, + ) + } else { + super::v0::insert_on_transaction_body( + db, + path, + key, + element, + options, + transaction, + batch, + grove_version, + ) + } +} + +/// The `MerkCache`-based insert flow with backward-references bookkeeping. +fn insert_with_backward_references<'db, 'b, B: AsRef<[u8]>>( + db: &GroveDb, + path: SubtreePath<'b, B>, + key: &[u8], + element: Element, + options: InsertOptions, + transaction: &'db Transaction, + batch: &StorageBatch, + grove_version: &GroveVersion, +) -> CostResult<(), Error> { + let mut cost = Default::default(); + + let cache = MerkCache::new(db, transaction, grove_version); + + let mut subtree_to_insert_into = + cost_return_on_error!(&mut cost, cache.get_merk(path.derive_owned())); + + if options.checks_for_override() { + let maybe_element_bytes = cost_return_on_error!( + &mut cost, + subtree_to_insert_into.for_merk(|m| m + .get( + key, + true, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .map_err(|e| Error::CorruptedData(e.to_string()))) + ); + if let Some(element_bytes) = maybe_element_bytes { + if options.validate_insertion_does_not_override { + return Err(Error::OverrideNotAllowed( + "insertion not allowed to override", + )) + .wrap_with_cost(cost); + } + if options.validate_insertion_does_not_override_tree { + let element = cost_return_on_error_no_add!( + cost, + Element::deserialize(element_bytes.as_slice(), grove_version).map_err(|_| { + Error::CorruptedData(String::from("unable to deserialize element")) + }) + ); + if element.is_any_tree() { + return Err(Error::OverrideNotAllowed( + "insertion not allowed to override tree", + )) + .wrap_with_cost(cost); + } + } + } + } + + match element { + Element::BidirectionalReference(reference, flags) => { + cost_return_on_error!( + &mut cost, + process_bidirectional_reference_insertion( + &cache, + path, + key, + reference, + flags, + Some(options) + ) + ); + } + Element::Reference(ref reference_path, ..) + | Element::ReferenceWithSumItem(ref reference_path, ..) => { + let resolved_reference = cost_return_on_error!( + &mut cost, + follow_reference(&cache, path.derive_owned(), key, reference_path.clone()) + ); + let referenced_item: Element = resolved_reference.target_element; + + if referenced_item.is_any_tree() { + return Err(Error::NotSupported( + "References cannot point to subtrees".to_owned(), + )) + .wrap_with_cost(cost); + } + + let delta = cost_return_on_error!( + &mut cost, + subtree_to_insert_into.for_merk(|m| { + element + .insert_reference_if_changed_value( + m, + key, + resolved_reference.target_node_value_hash, + Some(options.as_merk_options()), + grove_version, + ) + .map_err(Error::MerkError) + }) + ); + + cost_return_on_error!( + &mut cost, + process_update_element_with_backward_references( + &cache, + subtree_to_insert_into.clone(), + path.derive_owned(), + key, + delta, + &mut basic_sectioned_removal() + ) + ); + } + Element::Tree(ref root_key, _) + | Element::SumTree(ref root_key, ..) + | Element::BigSumTree(ref root_key, ..) + | Element::CountTree(ref root_key, ..) + | Element::CountSumTree(ref root_key, ..) + | Element::ProvableCountTree(ref root_key, ..) + | Element::ProvableCountSumTree(ref root_key, ..) + | Element::ProvableSumTree(ref root_key, ..) + | Element::ProvableCountProvableSumTree(ref root_key, ..) => { + if root_key.is_some() { + return Err(Error::InvalidCodeExecution( + "a tree should be empty at the moment of insertion when not using batches", + )) + .wrap_with_cost(cost); + } + let delta = cost_return_on_error!( + &mut cost, + subtree_to_insert_into.for_merk(|m| { + element + .insert_subtree_if_changed( + m, + key, + NULL_HASH, + Some(options.as_merk_options()), + grove_version, + ) + .map_err(Error::MerkError) + }) + ); + + cost_return_on_error!( + &mut cost, + process_update_element_with_backward_references( + &cache, + subtree_to_insert_into.clone(), + path.derive_owned(), + key, + delta, + &mut basic_sectioned_removal() + ) + ); + } + Element::CommitmentTree(..) + | Element::MmrTree(..) + | Element::BulkAppendTree(..) + | Element::DenseAppendOnlyFixedSizeTree(..) + | Element::PrivateDocumentStore(..) + | Element::ProvableSumIndexedTree(..) + | Element::ProvableCountIndexedTree(..) + | Element::ProvableCountProvableSumIndexedTree(..) + | Element::NonCounted(..) + | Element::NotSummed(..) + | Element::NotCountedOrSummed(..) => { + // These carry child-hash conventions or wrapper semantics that + // the MerkCache flow does not model; and none of them can be + // targeted by bidirectional references. Insert them without the + // flag. + return Err(Error::NotSupported( + "this element type cannot be inserted with propagate_backward_references set; \ + insert it without the flag" + .to_owned(), + )) + .wrap_with_cost(cost); + } + Element::Item(..) + | Element::SumItem(..) + | Element::ItemWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + // A backward-references element's referrer list is bookkeeping + // this flow maintains: carry the stored list over onto the new + // element so an update never silently drops registrations (and + // so the changed/unchanged comparison reflects the LOGICAL + // value, the referrer lists being equal on both sides). + let mut element = element; + if element.supports_backward_references() { + let previous = cost_return_on_error!( + &mut cost, + subtree_to_insert_into.for_merk(|m| { + Element::get_optional(m, key, true, grove_version).map_err(Error::MerkError) + }) + ); + if let Some(refs) = element.backward_references_mut() { + // The stored list is authoritative; whatever the caller + // supplied is not theirs to claim — forged entries would + // later let cascades and propagations follow arbitrary + // inverted paths. + *refs = previous + .as_ref() + .and_then(|p| p.backward_references()) + .map(|p| p.to_vec()) + .unwrap_or_default(); + } + // The carried-over referrers must fit the capacity the new + // element declares (checked before the write so the refusal + // is the family rule, not a serialization failure). + cost_return_on_error_no_add!( + cost, + crate::bidirectional_references::check_carried_referrers_fit(&element) + ); + } + let delta = cost_return_on_error!( + &mut cost, + subtree_to_insert_into.for_merk(|m| { + element + .insert_if_changed_value( + m, + key, + Some(options.as_merk_options()), + grove_version, + ) + .map_err(Error::MerkError) + }) + ); + cost_return_on_error!( + &mut cost, + process_update_element_with_backward_references( + &cache, + subtree_to_insert_into.clone(), + path.derive_owned(), + key, + delta, + &mut basic_sectioned_removal() + ) + ); + } + } + + let result_batch = cost_return_on_error!(&mut cost, cache.into_batch()); + + batch.merge(*result_batch); + + Ok(()).wrap_with_cost(cost) +} diff --git a/grovedb/src/operations/insert/mod.rs b/grovedb/src/operations/insert/mod.rs index 79149fdbc..bda70cc7b 100644 --- a/grovedb/src/operations/insert/mod.rs +++ b/grovedb/src/operations/insert/mod.rs @@ -1,20 +1,19 @@ //! Insert operations -use std::{collections::HashMap, option::Option::None}; +use std::option::Option::None; -use grovedb_costs::{ - cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, -}; -use grovedb_merk::{Merk, MerkOptions}; +use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; +use grovedb_merk::MerkOptions; use grovedb_path::SubtreePath; -use grovedb_storage::{rocksdb_storage::PrefixedRocksDbTransactionContext, Storage, StorageBatch}; +use grovedb_storage::{Storage, StorageBatch}; use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; -use crate::{util::TxRef, Element, Error, GroveDb, Transaction, TransactionArg}; +use crate::{util::TxRef, Element, Error, GroveDb, TransactionArg}; /// Versioned dispatch for `add_element_on_transaction` (the non-batch insert /// path). Consensus-critical — see the module docs. mod add_element_on_transaction; +mod insert_on_transaction; #[derive(Clone)] /// Insert options @@ -25,6 +24,14 @@ pub struct InsertOptions { pub validate_insertion_does_not_override_tree: bool, /// Base root storage is free pub base_root_storage_is_free: bool, + /// Propagate updates to elements with backward references. This enables + /// bidirectional-reference bookkeeping for this call: overwrites of + /// backward-references elements trigger hash propagation along the + /// reference chains, or cascade deletion when the new element no longer + /// supports backward references. Since the checks require an extra + /// fetch on every write, the feature is opt-in per call. Requires + /// `GROVE_V4`+; ignored (never set) by shipped v1..v3 flows. + pub propagate_backward_references: bool, } impl Default for InsertOptions { @@ -33,6 +40,7 @@ impl Default for InsertOptions { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, } } } @@ -42,7 +50,7 @@ impl InsertOptions { self.validate_insertion_does_not_override_tree || self.validate_insertion_does_not_override } - fn as_merk_options(&self) -> MerkOptions { + pub(crate) fn as_merk_options(&self) -> MerkOptions { MerkOptions { base_root_storage_is_free: self.base_root_storage_is_free, } @@ -113,67 +121,6 @@ impl GroveDb { tx.commit_local().wrap_with_cost(cost) } - fn insert_on_transaction<'db, 'b, B: AsRef<[u8]>>( - &self, - path: SubtreePath<'b, B>, - key: &[u8], - element: Element, - options: InsertOptions, - transaction: &'db Transaction, - batch: &StorageBatch, - grove_version: &GroveVersion, - ) -> CostResult<(), Error> { - check_grovedb_v0_with_cost!( - "insert_on_transaction", - grove_version - .grovedb_versions - .operations - .insert - .insert_on_transaction - ); - - let mut cost = OperationCost::default(); - - let mut merk_cache: HashMap, Merk> = - HashMap::default(); - - let merk = cost_return_on_error!( - &mut cost, - self.add_element_on_transaction( - path.clone(), - key, - element, - options, - transaction, - batch, - grove_version - ) - ); - // A generic insert cannot mirror the new child's ordering value into - // an indexed primary's secondary index. Reject before propagation, so - // the `StorageBatch` is discarded and nothing is committed. - cost_return_on_error_no_add!( - cost, - crate::operations::indexed_tree::reject_generic_write_into_indexed_primary( - merk.tree_type, - "insert", - ) - ); - merk_cache.insert(path.clone(), merk); - cost_return_on_error!( - &mut cost, - self.propagate_changes_with_transaction( - merk_cache, - path, - transaction, - batch, - grove_version - ) - ); - - Ok(()).wrap_with_cost(cost) - } - /// Insert if not exists /// Insert if not exists /// @@ -341,9 +288,15 @@ mod tests { use grovedb_version::version::GroveVersion; use pretty_assertions::assert_eq; + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + use grovedb_path::SubtreePath; + use crate::{ operations::insert::InsertOptions, - tests::{common::EMPTY_PATH, make_empty_grovedb, make_test_grovedb, TEST_LEAF}, + tests::{ + common::{make_tree_with_bidi_references, EMPTY_PATH}, + make_empty_grovedb, make_test_grovedb, TEST_LEAF, + }, Element, Error, }; @@ -521,6 +474,7 @@ mod tests { validate_insertion_does_not_override: true, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, gv, @@ -598,6 +552,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, } } @@ -3303,6 +3258,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }), Some(&tx), grove_version, @@ -3557,4 +3513,89 @@ mod tests { fn indexed_conversion_of_plain_tree_rejected_v1() { indexed_conversion_of_plain_tree_is_rejected(GroveVersion::latest()); } + + #[test] + fn update_item_with_backward_references() { + let version = GroveVersion::latest(); + + let db = make_tree_with_bidi_references(version); + + let transaction = db.start_transaction(); + + let get_hash = || { + Element::get_value_hash( + &db.open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF, b"innertree"]), + &transaction, + None, + version, + ) + .unwrap() + .unwrap(), + b"ref", + true, + version, + ) + .unwrap() + .unwrap() + .unwrap() + }; + + let hash_before = get_hash(); + + db.insert( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_2"], + b"key5", + Element::new_item_allowing_bidirectional_references(b"certainly new value".to_vec()), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + version, + ) + .unwrap() + .unwrap(); + + let hash_after = get_hash(); + + assert_ne!(hash_before, hash_after); + } + + #[test] + fn update_item_with_backward_references_with_no_support() { + // Overwriting an item that has backward references with an element + // that no longer supports them cascades the reference chain away + // (every reference allowed cascade_on_update). + let version = GroveVersion::latest(); + + let db = make_tree_with_bidi_references(version); + + let transaction = db.start_transaction(); + + db.insert( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_2"], + b"key5", + Element::new_item(b"hello".to_vec()), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.get( + &[TEST_LEAF, b"innertree"], + b"ref", + Some(&transaction), + version + ) + .unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + } } diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index 1954db2ae..7bec8322c 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -10,6 +10,7 @@ use grovedb_costs::{ }; use grovedb_dense_fixed_sized_merkle_tree::DenseTreeProof; use grovedb_merk::{ + element::ElementExt, proofs::{encode_into, query::QueryItem, Node, Op}, tree::{combine_hash, value_hash, NULL_HASH}, Merk, ProofWithoutEncodingResult, TreeFeatureType, @@ -32,6 +33,15 @@ use crate::{ Element, Error, GroveDb, PathQuery, Transaction, }; +/// A non-Merk subtree kind a V1 proof descends into, carrying the element +/// fields its layer prover needs. +enum NonMerkLowerLayer { + Mmr { mmr_size: u64 }, + BulkAppend { total_count: u64, chunk_power: u8 }, + Dense { dense_count: u16, dense_height: u8 }, + Commitment { total_count: u64, chunk_power: u8 }, +} + impl GroveDb { /// Prove one or more path queries. /// If we have more than one path query, we merge into a single path query @@ -1128,6 +1138,21 @@ impl GroveDb { }; match op { Op::Push(node) | Op::PushInverted(node) => match node { + // Merk emits this node kind for backward-references + // elements unconditionally; it is a V1-only wire shape + // and the frozen V0 format must refuse to carry it + // (released V0 verifiers reject the tag). Guard on the + // NODE KIND — the element-typed rejection below cannot + // fire for it because this outer match would otherwise + // skip the node entirely. + Node::KVBackwardsReferencesValueHash(..) => { + return Err(Error::NotSupported( + "backward-references elements are not supported in V0 proofs; they \ + require GROVE_V4+, which proves through V1" + .to_owned(), + )) + .wrap_with_cost(cost); + } Node::KV(key, value) | Node::KVValueHash(key, value, ..) | Node::KVCount(key, value, _) @@ -1171,8 +1196,9 @@ impl GroveDb { ) ); - let serialized_referenced_elem = - referenced_elem.serialize(grove_version); + let serialized_referenced_elem = referenced_elem + .stripped_of_backward_references() + .serialize(grove_version); if serialized_referenced_elem.is_err() { return Err(Error::CorruptedData(String::from( "unable to serialize element", @@ -1380,6 +1406,21 @@ impl GroveDb { // results, we can modify the proof to alter // } + // Backward-references elements activate with + // GROVE_V4, which proves through V1; the V0 + // prover (locked wire format, grove v1/v2) + // can never legitimately encounter them. + Ok(Element::BidirectionalReference(..)) + | Ok(Element::ItemWithBackwardsReferences(..)) + | Ok(Element::SumItemWithBackwardsReferences(..)) + | Ok(Element::ItemWithSumItemWithBackwardsReferences(..)) => { + return Err(Error::NotSupported( + "backward-references elements are not supported in V0 \ + proofs; they require GROVE_V4+, which proves through V1" + .to_owned(), + )) + .wrap_with_cost(cost); + } // Explicit: when done_with_results is true, the above guards fail // and we skip. Listed explicitly so adding a new Element variant // produces a compile error here instead of silently dropping it. @@ -2212,176 +2253,17 @@ impl GroveDb { // hard-error case (the caller asked for count-offset pagination // against something that isn't a count tree). if path.len() == path_query.path.len() && path_query.has_non_zero_offset() { - use grovedb_merk::TreeType as MerkTreeType; - let inner_range = cost_return_on_error_no_add!( - cost, - path_query.validate_count_offset_paginated().cloned() - ); - if !matches!( - subtree.tree_type, - MerkTreeType::ProvableCountTree - | MerkTreeType::ProvableCountSumTree - | MerkTreeType::ProvableCountProvableSumTree - ) { - return Err(Error::InvalidQuery( - "count-offset paginated queries are only valid against \ - ProvableCountTree / ProvableCountSumTree / ProvableCountProvableSumTree \ - merks", - )) - .wrap_with_cost(cost); - } - let offset = path_query.query.offset.map(|o| o as u64).unwrap_or(0); - // Carry the SizedQuery::limit into the merk-level proof so - // the prover stops emitting value nodes once the requested - // page is full. After the merk prover returns, decrement - // the outer overall_limit accordingly so the upstream - // multi-layer accounting (if any) reflects the consumed - // slots. - let limit_u64 = path_query.query.limit.map(|l| l as u64); - let mut prove_result = cost_return_on_error!( - &mut cost, - subtree - .prove_count_offset_on_range( - &inner_range, - offset, - limit_u64, - query.left_to_right, - grove_version, - ) - // Wrap with operational context so a downstream - // proof failure (corrupted merk, invariant - // violation in the prover, etc.) is identifiable - // as a count-offset-specific failure rather than - // an opaque `MerkError`. Mirrors the - // `prove_aggregate_sum_on_range` wrapping a few - // hundred lines up. - .map_err(|e| Error::CorruptedData(format!( - "prove_count_offset_on_range failed: {}", - e - ))) - ); - // Dereference reference rows before encoding. - // - // This short-circuit returns without reaching the main - // ref-rewriting loop below, which is why the count-offset flow - // used to reject reference entries outright. Running the same - // rewrite here closes that gap rather than bypassing it. - // - // These are ORDINARY user references, so they follow ordinary - // terminal-reference semantics — unlike an indexed secondary - // row, which binds its immediate primary node and is resolved - // by `indexed_axis::reference_resolution`. The two rules are - // deliberately separate code paths. - for op in prove_result.ops.iter_mut() { - let node = match op { - Op::Push(node) | Op::PushInverted(node) => node, - _ => continue, - }; - let Node::KVValueHashFeatureType(key, value, committed_value_hash, feature_type) = - node - else { - continue; - }; - let elem = match Element::deserialize(value, grove_version) { - Ok(e) => e.into_underlying(), - Err(_) => continue, - }; - let (Element::Reference(reference_path, ..) - | Element::ReferenceWithSumItem(reference_path, ..)) = elem - else { - continue; - }; - let absolute_path = match path_from_reference_path_type( - reference_path, - &path.to_vec(), - Some(key.as_slice()), - ) { - Ok(p) => p, - Err(e) => return Err(Error::from(e)).wrap_with_cost(cost), - }; - // Resolve stored bytes first, then select the representation - // bound by this row. Legacy direct writes may coexist with - // stored-terminal commitments in the same paginated tree. - let referenced_elem = cost_return_on_error!( - &mut cost, - self.follow_reference_as_stored( - absolute_path.as_slice().into(), - true, - None, - grove_version - ) - ); - let reference_element_hash = value_hash(value).unwrap_add_cost(&mut cost); - let referenced_elem = cost_return_on_error!( - &mut cost, - Self::reference_terminal_as_committed( - referenced_elem, - &reference_element_hash, - committed_value_hash, - grove_version, - ) - ); - let serialized_referenced_elem = match referenced_elem.serialize(grove_version) { - Ok(bytes) => bytes, - Err(_) => { - return Err(Error::CorruptedData(String::from( - "unable to serialize element", - ))) - .wrap_with_cost(cost); - } - }; - *node = match feature_type { - TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(count, sum) => { - Node::KVRefValueHashCountSum( - key.to_owned(), - serialized_referenced_elem, - reference_element_hash, - *count, - *sum, - ) - } - // `ProvableCountSumTree` is an eligible count-offset - // host but commits only the COUNT into its node hash - // (`binds_sum_into_hash` is true for PCPS alone), so - // its reference rows take the count-only node — the - // same variant `emit_returned_node` picks for its - // directly-valued rows. Without this arm a reference in - // such a tree hard-errored. - TreeFeatureType::ProvableCountedMerkNode(count) - | TreeFeatureType::ProvableCountedSummedMerkNode(count, _) => { - Node::KVRefValueHashCount( - key.to_owned(), - serialized_referenced_elem, - reference_element_hash, - *count, - ) - } - other => { - return Err(Error::CorruptedData(format!( - "count-offset proof: reference row {} carries non-count feature type \ - {other:?}", - hex::encode(key) - ))) - .wrap_with_cost(cost); - } - }; - } - let mut serialized = Vec::with_capacity(128); - encode_into(prove_result.ops.iter(), &mut serialized); - // Apply consumed limit slots to the outer accounting. - // (Count-offset queries reject per-instance limits, so - // `frame_instance` is always `None` here — charged anyway - // for uniformity.) - let returned_u16: u16 = prove_result.returned.min(u16::MAX as u64) as u16; - limit_state.charge_rows(returned_u16); - if let Some(instance) = frame_instance.as_mut() { - *instance = instance.saturating_sub(returned_u16); - } - return Ok(LayerProof { - merk_proof: ProofBytes::Merk(serialized), - lower_layers: BTreeMap::new(), - }) - .wrap_with_cost(cost); + return self + .prove_count_offset_layer_v1( + &subtree, + &path, + path_query, + query.left_to_right, + limit_state, + &mut frame_instance, + grove_version, + ) + .add_cost(cost); } // Whether the surrounding query is an aggregate-count carrier: @@ -2501,11 +2383,44 @@ impl GroveDb { match op { Op::Push(node) | Op::PushInverted(node) => match node { + // A FILLER bidirectional-reference row (past the limit) + // is still rewritten into its bound KVRefValueHash shape: + // the rewrite is node-hash-neutral, and the strict V1 + // verifier rejects raw bidirectional bytes in + // trusted-value results — a truncated window can still + // count such a row as in range. Node::KV(key, value) | Node::KVValueHash(key, value, ..) | Node::KVCount(key, value, _) | Node::KVSum(key, value, _) | Node::KVCountSum(key, value, ..) + | Node::KVValueHashFeatureType(key, value, ..) + if done_with_results + && matches!( + grovedb_element::ElementType::from_serialized_value(value) + .map(|et| et.base()), + Ok(grovedb_element::ElementType::BidirectionalReference) + ) => + { + *node = cost_return_on_error!( + &mut cost, + self.rewrite_filler_bidirectional_row_v1( + key, + value, + &path, + count_for_ref, + sum_for_ref, + count_sum_for_ref, + grove_version, + ) + ); + } + Node::KV(key, value) + | Node::KVValueHash(key, value, ..) + | Node::KVBackwardsReferencesValueHash(key, value, ..) + | Node::KVCount(key, value, _) + | Node::KVSum(key, value, _) + | Node::KVCountSum(key, value, ..) | Node::KVValueHashFeatureType(key, value, ..) if !done_with_results => { @@ -2514,6 +2429,40 @@ impl GroveDb { // the proof) keeps its wrapper byte either way. let elem = Element::deserialize(value, grove_version).map(|e| e.into_underlying()); + // Normalize a bidirectional reference to its plain + // reference shape: proof-wise both resolve the same + // way. A bidirectional reference's self-hash slot in + // KVRefValueHash* nodes is combine(inner, backrefs) + // (not H(stored bytes)) — capture it here, before + // normalization discards the distinction. + let mut reference_self_hash_override = None; + // A bidirectional edge's declared max_hop bounds + // proof dereferencing exactly like reads (plain + // references keep their historical global budget). + let mut bidi_max_hop = None; + let elem = match elem { + Ok(ref e @ Element::BidirectionalReference(..)) => { + let hashes = cost_return_on_error!( + &mut cost, + e.backward_references_hashes(grove_version) + .map_err(Error::from) + ) + .expect("bidirectional references carry hashes"); + reference_self_hash_override = Some(hashes.combined); + let Ok(Element::BidirectionalReference(reference, reference_flags)) = + elem + else { + unreachable!("checked above"); + }; + bidi_max_hop = reference.max_hop; + Ok(Element::Reference( + reference.forward_reference_path, + reference.max_hop, + reference_flags, + )) + } + other => other, + }; match elem { // `ReferenceWithSumItem` shares this proof path // with `Reference` — both produce a @@ -2526,88 +2475,34 @@ impl GroveDb { // that bind the unwrapped terminal. Ok(Element::Reference(reference_path, ..)) | Ok(Element::ReferenceWithSumItem(reference_path, ..)) => { - let absolute_path = cost_return_on_error_into!( + *node = cost_return_on_error!( &mut cost, - path_from_reference_path_type( + self.rewrite_reference_row_v1( + key, + value, reference_path, - &path.to_vec(), - Some(key.as_slice()) - ) - .wrap_with_cost(OperationCost::default()) - ); - - let referenced_elem = cost_return_on_error_into!( - &mut cost, - self.follow_reference_as_stored( - absolute_path.as_slice().into(), - true, - None, - grove_version - ) - ); - - let reference_element_hash = - value_hash(value).unwrap_add_cost(&mut cost); - let committed_value_hash = cost_return_on_error_no_add!( - cost, - committed_value_hash.ok_or_else(|| Error::CorruptedData( - "reference proof node is missing its committed value hash" - .to_string() - )) - ); - let referenced_elem = cost_return_on_error!( - &mut cost, - Self::reference_terminal_as_committed( - referenced_elem, - &reference_element_hash, - &committed_value_hash, + &path, + bidi_max_hop, + reference_self_hash_override, + committed_value_hash, + count_for_ref, + sum_for_ref, + count_sum_for_ref, grove_version, ) ); - - let serialized_referenced_elem = - referenced_elem.serialize(grove_version); - if serialized_referenced_elem.is_err() { - return Err(Error::CorruptedData(String::from( - "unable to serialize element", - ))) - .wrap_with_cost(cost); - } - - // Dispatch in priority order — dual-axis - // PCPS first (strictest invariant), then - // single-axis Sum, then single-axis Count, - // then plain ref. See the v1 loop for the - // longer-form comment. - *node = if let Some((count, sum)) = count_sum_for_ref { - Node::KVRefValueHashCountSum( - key.to_owned(), - serialized_referenced_elem.expect("confirmed ok above"), - reference_element_hash, - count, - sum, - ) - } else if let Some(sum) = sum_for_ref { - Node::KVRefValueHashSum( - key.to_owned(), - serialized_referenced_elem.expect("confirmed ok above"), - reference_element_hash, - sum, - ) - } else if let Some(count) = count_for_ref { - Node::KVRefValueHashCount( - key.to_owned(), - serialized_referenced_elem.expect("confirmed ok above"), - reference_element_hash, - count, - ) - } else { - Node::KVRefValueHash( - key.to_owned(), - serialized_referenced_elem.expect("confirmed ok above"), - reference_element_hash, - ) - }; + limit_state.charge_row_with_instance(&mut frame_instance); + has_a_result_at_level |= true; + } + Ok(Element::ItemWithBackwardsReferences(..)) + | Ok(Element::SumItemWithBackwardsReferences(..)) + | Ok(Element::ItemWithSumItemWithBackwardsReferences(..)) + if !done_with_results => + { + // Merk already emitted the dedicated + // KVBackwardsReferencesValueHash wire node + // (stripped bytes + referrer-list hash); + // only result bookkeeping remains here. limit_state.charge_row_with_instance(&mut frame_instance); has_a_result_at_level |= true; } @@ -2705,41 +2600,18 @@ impl GroveDb { { let mut lower_path = path.clone(); lower_path.push(key.as_slice()); - - // The non-Merk adapters bypass the recursive - // frame creation, so the lower query's own - // per-instance cap must be min-composed here — - // the verifier derives the identical value. - let mut non_merk_effective = limit_state - .effective_lower_layer_limit( - frame_instance, - cost_return_on_error_no_add!( - cost, - path_query - .query_items_at_path(&lower_path, grove_version) - ) - .and_then(|lower_query| lower_query.instance_limit), - ); - let non_merk_before = non_merk_effective; let layer_proof = cost_return_on_error!( &mut cost, - self.generate_mmr_layer_proof( + self.prove_non_merk_lower_layer_v1( + NonMerkLowerLayer::Mmr { mmr_size }, &lower_path, path_query, - mmr_size, - &mut non_merk_effective, + limit_state, + &mut frame_instance, transaction, grove_version, ) ); - - let non_merk_rows = non_merk_before - .unwrap_or(0) - .saturating_sub(non_merk_effective.unwrap_or(0)); - limit_state.charge_rows(non_merk_rows); - if let Some(instance) = frame_instance.as_mut() { - *instance = instance.saturating_sub(non_merk_rows); - } has_a_result_at_level |= true; lower_layers.insert(key.clone(), layer_proof); } @@ -2752,43 +2624,18 @@ impl GroveDb { { let mut lower_path = path.clone(); lower_path.push(key.as_slice()); - - // The non-Merk adapters bypass the recursive - // frame creation, so the lower query's own - // per-instance cap must be min-composed here — - // the verifier derives the identical value. - let mut non_merk_effective = limit_state - .effective_lower_layer_limit( - frame_instance, - cost_return_on_error_no_add!( - cost, - path_query - .query_items_at_path(&lower_path, grove_version) - ) - .and_then(|lower_query| lower_query.instance_limit), - ); - let non_merk_before = non_merk_effective; let layer_proof = cost_return_on_error!( &mut cost, - self.generate_bulk_append_layer_proof( + self.prove_non_merk_lower_layer_v1( + NonMerkLowerLayer::BulkAppend { total_count, chunk_power }, &lower_path, path_query, - [0u8; 32], // unused parameter - total_count, - chunk_power, - &mut non_merk_effective, + limit_state, + &mut frame_instance, transaction, grove_version, ) ); - - let non_merk_rows = non_merk_before - .unwrap_or(0) - .saturating_sub(non_merk_effective.unwrap_or(0)); - limit_state.charge_rows(non_merk_rows); - if let Some(instance) = frame_instance.as_mut() { - *instance = instance.saturating_sub(non_merk_rows); - } has_a_result_at_level |= true; lower_layers.insert(key.clone(), layer_proof); } @@ -2804,42 +2651,18 @@ impl GroveDb { { let mut lower_path = path.clone(); lower_path.push(key.as_slice()); - - // The non-Merk adapters bypass the recursive - // frame creation, so the lower query's own - // per-instance cap must be min-composed here — - // the verifier derives the identical value. - let mut non_merk_effective = limit_state - .effective_lower_layer_limit( - frame_instance, - cost_return_on_error_no_add!( - cost, - path_query - .query_items_at_path(&lower_path, grove_version) - ) - .and_then(|lower_query| lower_query.instance_limit), - ); - let non_merk_before = non_merk_effective; let layer_proof = cost_return_on_error!( &mut cost, - self.generate_dense_tree_layer_proof( + self.prove_non_merk_lower_layer_v1( + NonMerkLowerLayer::Dense { dense_count, dense_height }, &lower_path, path_query, - dense_count, - dense_height, - &mut non_merk_effective, + limit_state, + &mut frame_instance, transaction, grove_version, ) ); - - let non_merk_rows = non_merk_before - .unwrap_or(0) - .saturating_sub(non_merk_effective.unwrap_or(0)); - limit_state.charge_rows(non_merk_rows); - if let Some(instance) = frame_instance.as_mut() { - *instance = instance.saturating_sub(non_merk_rows); - } has_a_result_at_level |= true; lower_layers.insert(key.clone(), layer_proof); } @@ -2852,42 +2675,18 @@ impl GroveDb { { let mut lower_path = path.clone(); lower_path.push(key.as_slice()); - - // The non-Merk adapters bypass the recursive - // frame creation, so the lower query's own - // per-instance cap must be min-composed here — - // the verifier derives the identical value. - let mut non_merk_effective = limit_state - .effective_lower_layer_limit( - frame_instance, - cost_return_on_error_no_add!( - cost, - path_query - .query_items_at_path(&lower_path, grove_version) - ) - .and_then(|lower_query| lower_query.instance_limit), - ); - let non_merk_before = non_merk_effective; let layer_proof = cost_return_on_error!( &mut cost, - self.generate_commitment_tree_layer_proof( + self.prove_non_merk_lower_layer_v1( + NonMerkLowerLayer::Commitment { total_count, chunk_power }, &lower_path, path_query, - total_count, - chunk_power, - &mut non_merk_effective, + limit_state, + &mut frame_instance, transaction, grove_version, ) ); - - let non_merk_rows = non_merk_before - .unwrap_or(0) - .saturating_sub(non_merk_effective.unwrap_or(0)); - limit_state.charge_rows(non_merk_rows); - if let Some(instance) = frame_instance.as_mut() { - *instance = instance.saturating_sub(non_merk_rows); - } has_a_result_at_level |= true; lower_layers.insert(key.clone(), layer_proof); } @@ -3738,9 +3537,15 @@ impl GroveDb { // Explicit: when done_with_results is true, the above guards fail // and we skip. Listed explicitly so adding a new Element variant // produces a compile error here instead of silently dropping it. + Ok(Element::BidirectionalReference(..)) => { + unreachable!("normalized to Element::Reference above") + } Ok(Element::Item(..)) | Ok(Element::SumItem(..)) | Ok(Element::ItemWithSumItem(..)) + | Ok(Element::ItemWithBackwardsReferences(..)) + | Ok(Element::SumItemWithBackwardsReferences(..)) + | Ok(Element::ItemWithSumItemWithBackwardsReferences(..)) | Ok(Element::Tree(..)) | Ok(Element::SumTree(..)) | Ok(Element::BigSumTree(..)) @@ -3798,6 +3603,517 @@ impl GroveDb { .wrap_with_cost(cost) } + /// Count-offset paginated short-circuit of [`Self::prove_subqueries_v1`]. + /// + /// Kept out of line: the V1 prover recurses once per subtree level and + /// every local of every arm of its (very large) body is reserved in the + /// recursive frame, so self-contained sections live in their own frames + /// (see `proof_generation_succeeds_at_reasonable_depth`). + #[inline(never)] + fn prove_count_offset_layer_v1<'a, S>( + &self, + subtree: &'a Merk, + path: &[&[u8]], + path_query: &PathQuery, + left_to_right: bool, + limit_state: &mut super::V1LimitState, + frame_instance: &mut Option, + grove_version: &GroveVersion, + ) -> CostResult + where + S: StorageContext<'a> + 'a, + { + let mut cost = OperationCost::default(); + use grovedb_merk::TreeType as MerkTreeType; + let inner_range = cost_return_on_error_no_add!( + cost, + path_query.validate_count_offset_paginated().cloned() + ); + if !matches!( + subtree.tree_type, + MerkTreeType::ProvableCountTree + | MerkTreeType::ProvableCountSumTree + | MerkTreeType::ProvableCountProvableSumTree + ) { + return Err(Error::InvalidQuery( + "count-offset paginated queries are only valid against \ + ProvableCountTree / ProvableCountSumTree / ProvableCountProvableSumTree \ + merks", + )) + .wrap_with_cost(cost); + } + let offset = path_query.query.offset.map(|o| o as u64).unwrap_or(0); + // Carry the SizedQuery::limit into the merk-level proof so + // the prover stops emitting value nodes once the requested + // page is full. After the merk prover returns, decrement + // the outer overall_limit accordingly so the upstream + // multi-layer accounting (if any) reflects the consumed + // slots. + let limit_u64 = path_query.query.limit.map(|l| l as u64); + let mut prove_result = cost_return_on_error!( + &mut cost, + subtree + .prove_count_offset_on_range( + &inner_range, + offset, + limit_u64, + left_to_right, + grove_version, + ) + // Wrap with operational context so a downstream + // proof failure (corrupted merk, invariant + // violation in the prover, etc.) is identifiable + // as a count-offset-specific failure rather than + // an opaque `MerkError`. Mirrors the + // `prove_aggregate_sum_on_range` wrapping a few + // hundred lines up. + .map_err(|e| Error::CorruptedData(format!( + "prove_count_offset_on_range failed: {}", + e + ))) + ); + // Dereference reference rows before encoding. + // + // This short-circuit returns without reaching the main + // ref-rewriting loop below, which is why the count-offset flow + // used to reject reference entries outright. Running the same + // rewrite here closes that gap rather than bypassing it. + // + // These are ORDINARY user references, so they follow ordinary + // terminal-reference semantics — unlike an indexed secondary + // row, which binds its immediate primary node and is resolved + // by `indexed_axis::reference_resolution`. The two rules are + // deliberately separate code paths. + for op in prove_result.ops.iter_mut() { + let node = match op { + Op::Push(node) | Op::PushInverted(node) => node, + _ => continue, + }; + let Node::KVValueHashFeatureType(key, value, committed_value_hash, feature_type) = node + else { + continue; + }; + let elem = match Element::deserialize(value, grove_version) { + Ok(e) => e.into_underlying(), + Err(_) => continue, + }; + // Normalize a bidirectional reference to its plain-reference + // shape — proof-wise both resolve identically, and the + // node's `value` bytes (which feed value_hash) are left + // untouched. Mirrors the normalization in the general + // subquery path below. + let mut reference_self_hash_override = None; + // A bidirectional edge's declared max_hop bounds proof + // dereferencing exactly like reads (plain references keep + // their historical global budget). + let mut bidi_max_hop = None; + let elem = match elem { + ref e @ Element::BidirectionalReference(..) => { + let hashes = cost_return_on_error!( + &mut cost, + e.backward_references_hashes(grove_version) + .map_err(Error::from) + ) + .expect("bidirectional references carry hashes"); + reference_self_hash_override = Some(hashes.combined); + let Element::BidirectionalReference(reference, reference_flags) = elem else { + unreachable!("checked above"); + }; + bidi_max_hop = reference.max_hop; + Element::Reference( + reference.forward_reference_path, + reference.max_hop, + reference_flags, + ) + } + other => other, + }; + let (Element::Reference(reference_path, ..) + | Element::ReferenceWithSumItem(reference_path, ..)) = elem + else { + continue; + }; + let absolute_path = + match path_from_reference_path_type(reference_path, path, Some(key.as_slice())) { + Ok(p) => p, + Err(e) => return Err(Error::from(e)).wrap_with_cost(cost), + }; + // Resolve stored bytes first, then select the representation + // bound by this row. Legacy direct writes may coexist with + // stored-terminal commitments in the same paginated tree. + let referenced_elem = cost_return_on_error!( + &mut cost, + self.follow_reference_with_max_hop( + absolute_path.as_slice().into(), + bidi_max_hop, + true, + None, + grove_version + ) + ); + let reference_element_hash = match reference_self_hash_override { + Some(hash) => hash, + None => value_hash(value).unwrap_add_cost(&mut cost), + }; + let referenced_elem = cost_return_on_error!( + &mut cost, + Self::reference_terminal_as_committed( + referenced_elem, + &reference_element_hash, + committed_value_hash, + grove_version, + ) + ); + let serialized_referenced_elem = match referenced_elem + .stripped_of_backward_references() + .serialize(grove_version) + { + Ok(bytes) => bytes, + Err(_) => { + return Err(Error::CorruptedData(String::from( + "unable to serialize element", + ))) + .wrap_with_cost(cost); + } + }; + *node = match feature_type { + TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(count, sum) => { + Node::KVRefValueHashCountSum( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + *count, + *sum, + ) + } + // `ProvableCountSumTree` is an eligible count-offset + // host but commits only the COUNT into its node hash + // (`binds_sum_into_hash` is true for PCPS alone), so + // its reference rows take the count-only node — the + // same variant `emit_returned_node` picks for its + // directly-valued rows. Without this arm a reference in + // such a tree hard-errored. + TreeFeatureType::ProvableCountedMerkNode(count) + | TreeFeatureType::ProvableCountedSummedMerkNode(count, _) => { + Node::KVRefValueHashCount( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + *count, + ) + } + other => { + return Err(Error::CorruptedData(format!( + "count-offset proof: reference row {} carries non-count feature type \ + {other:?}", + hex::encode(key) + ))) + .wrap_with_cost(cost); + } + }; + } + let mut serialized = Vec::with_capacity(128); + encode_into(prove_result.ops.iter(), &mut serialized); + // Apply consumed limit slots to the outer accounting. + // (Count-offset queries reject per-instance limits, so + // `frame_instance` is always `None` here — charged anyway + // for uniformity.) + let returned_u16: u16 = prove_result.returned.min(u16::MAX as u64) as u16; + limit_state.charge_rows(returned_u16); + if let Some(instance) = frame_instance.as_mut() { + *instance = instance.saturating_sub(returned_u16); + } + Ok(LayerProof { + merk_proof: ProofBytes::Merk(serialized), + lower_layers: BTreeMap::new(), + }) + .wrap_with_cost(cost) + } + + /// The `KVRefValueHash*` node a dereferenced reference row takes, + /// dispatched in priority order — dual-axis PCPS first (strictest + /// invariant), then single-axis Sum, then single-axis Count, then the + /// plain reference node. + fn dereferenced_reference_node( + key: &[u8], + serialized_referenced_elem: Vec, + reference_element_hash: grovedb_merk::CryptoHash, + count_for_ref: Option, + sum_for_ref: Option, + count_sum_for_ref: Option<(u64, i64)>, + ) -> Node { + if let Some((count, sum)) = count_sum_for_ref { + Node::KVRefValueHashCountSum( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + count, + sum, + ) + } else if let Some(sum) = sum_for_ref { + Node::KVRefValueHashSum( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + sum, + ) + } else if let Some(count) = count_for_ref { + Node::KVRefValueHashCount( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + count, + ) + } else { + Node::KVRefValueHash( + key.to_owned(), + serialized_referenced_elem, + reference_element_hash, + ) + } + } + + /// Rewrite a FILLER bidirectional-reference row (past the limit) of a V1 + /// layer proof into its bound `KVRefValueHash*` shape: the rewrite is + /// node-hash-neutral, and the strict V1 verifier rejects raw + /// bidirectional bytes in trusted-value results — a truncated window can + /// still count such a row as in range. Out of line for the frame-size + /// reason documented on [`Self::prove_count_offset_layer_v1`]. + #[inline(never)] + fn rewrite_filler_bidirectional_row_v1( + &self, + key: &[u8], + value: &[u8], + path: &[&[u8]], + count_for_ref: Option, + sum_for_ref: Option, + count_sum_for_ref: Option<(u64, i64)>, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = OperationCost::default(); + let elem = cost_return_on_error_into!( + &mut cost, + Element::deserialize(value, grove_version).wrap_with_cost(OperationCost::default()) + ); + let hashes = cost_return_on_error!( + &mut cost, + elem.backward_references_hashes(grove_version) + .map_err(Error::from) + ) + .expect("bidirectional references carry hashes"); + let Element::BidirectionalReference(reference, _) = elem.into_underlying() else { + return Err(Error::CorruptedCodeExecution( + "filler bidirectional-reference rewrite reached a non-bidirectional element", + )) + .wrap_with_cost(cost); + }; + let absolute_path = cost_return_on_error_into!( + &mut cost, + path_from_reference_path_type(reference.forward_reference_path, path, Some(key)) + .wrap_with_cost(OperationCost::default()) + ); + let referenced_elem = cost_return_on_error_into!( + &mut cost, + self.follow_reference_with_max_hop( + absolute_path.as_slice().into(), + reference.max_hop, + true, + None, + grove_version + ) + ); + let serialized_referenced_elem = cost_return_on_error_into!( + &mut cost, + referenced_elem + .stripped_of_backward_references() + .serialize(grove_version) + .wrap_with_cost(OperationCost::default()) + ); + Ok(Self::dereferenced_reference_node( + key, + serialized_referenced_elem, + hashes.combined, + count_for_ref, + sum_for_ref, + count_sum_for_ref, + )) + .wrap_with_cost(cost) + } + + /// Rewrite a result reference row (`Reference` / `ReferenceWithSumItem`, + /// or a bidirectional reference normalized to that shape) of a V1 layer + /// proof into its dereferenced `KVRefValueHash*` node. + /// + /// The target bytes are selected by the node's existing commitment, not + /// the current GroveVersion: a V4 reader may encounter V3 direct-written + /// references that bind the unwrapped terminal. A bidirectional + /// reference's self-hash slot is `combine(inner, backrefs)`, captured by + /// the caller before normalization. Out of line for the frame-size + /// reason documented on [`Self::prove_count_offset_layer_v1`]. + #[inline(never)] + #[allow(clippy::too_many_arguments)] + fn rewrite_reference_row_v1( + &self, + key: &[u8], + value: &[u8], + reference_path: grovedb_element::reference_path::ReferencePathType, + path: &[&[u8]], + bidi_max_hop: Option, + reference_self_hash_override: Option, + committed_value_hash: Option, + count_for_ref: Option, + sum_for_ref: Option, + count_sum_for_ref: Option<(u64, i64)>, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = OperationCost::default(); + let absolute_path = cost_return_on_error_into!( + &mut cost, + path_from_reference_path_type(reference_path, path, Some(key)) + .wrap_with_cost(OperationCost::default()) + ); + let referenced_elem = cost_return_on_error_into!( + &mut cost, + self.follow_reference_with_max_hop( + absolute_path.as_slice().into(), + bidi_max_hop, + true, + None, + grove_version + ) + ); + let reference_element_hash = match reference_self_hash_override { + Some(hash) => hash, + None => value_hash(value).unwrap_add_cost(&mut cost), + }; + let committed_value_hash = cost_return_on_error_no_add!( + cost, + committed_value_hash.ok_or_else(|| Error::CorruptedData( + "reference proof node is missing its committed value hash".to_string() + )) + ); + let referenced_elem = cost_return_on_error!( + &mut cost, + Self::reference_terminal_as_committed( + referenced_elem, + &reference_element_hash, + &committed_value_hash, + grove_version, + ) + ); + let serialized_referenced_elem = match referenced_elem + .stripped_of_backward_references() + .serialize(grove_version) + { + Ok(bytes) => bytes, + Err(_) => { + return Err(Error::CorruptedData(String::from( + "unable to serialize element", + ))) + .wrap_with_cost(cost); + } + }; + Ok(Self::dereferenced_reference_node( + key, + serialized_referenced_elem, + reference_element_hash, + count_for_ref, + sum_for_ref, + count_sum_for_ref, + )) + .wrap_with_cost(cost) + } + + /// One non-Merk lower layer of a V1 proof (MMR / bulk-append / dense / + /// commitment tree), with the per-instance limit composition and row + /// charging the recursive frame used to do inline. Out of line for the + /// same frame-size reason as [`Self::prove_count_offset_layer_v1`]. + #[inline(never)] + fn prove_non_merk_lower_layer_v1( + &self, + layer: NonMerkLowerLayer, + lower_path: &[&[u8]], + path_query: &PathQuery, + limit_state: &mut super::V1LimitState, + frame_instance: &mut Option, + transaction: &Transaction, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = OperationCost::default(); + + // The non-Merk adapters bypass the recursive frame creation, so the + // lower query's own per-instance cap must be min-composed here — the + // verifier derives the identical value. + let mut non_merk_effective = limit_state.effective_lower_layer_limit( + *frame_instance, + cost_return_on_error_no_add!( + cost, + path_query.query_items_at_path(lower_path, grove_version) + ) + .and_then(|lower_query| lower_query.instance_limit), + ); + let non_merk_before = non_merk_effective; + let layer_proof = cost_return_on_error!( + &mut cost, + match layer { + NonMerkLowerLayer::Mmr { mmr_size } => self.generate_mmr_layer_proof( + lower_path, + path_query, + mmr_size, + &mut non_merk_effective, + transaction, + grove_version, + ), + NonMerkLowerLayer::BulkAppend { + total_count, + chunk_power, + } => self.generate_bulk_append_layer_proof( + lower_path, + path_query, + [0u8; 32], // unused parameter + total_count, + chunk_power, + &mut non_merk_effective, + transaction, + grove_version, + ), + NonMerkLowerLayer::Dense { + dense_count, + dense_height, + } => self.generate_dense_tree_layer_proof( + lower_path, + path_query, + dense_count, + dense_height, + &mut non_merk_effective, + transaction, + grove_version, + ), + NonMerkLowerLayer::Commitment { + total_count, + chunk_power, + } => self.generate_commitment_tree_layer_proof( + lower_path, + path_query, + total_count, + chunk_power, + &mut non_merk_effective, + transaction, + grove_version, + ), + } + ); + + let non_merk_rows = non_merk_before + .unwrap_or(0) + .saturating_sub(non_merk_effective.unwrap_or(0)); + limit_state.charge_rows(non_merk_rows); + if let Some(instance) = frame_instance.as_mut() { + *instance = instance.saturating_sub(non_merk_rows); + } + Ok(layer_proof).wrap_with_cost(cost) + } + /// Generate an MMR tree layer proof for a subquery. fn generate_mmr_layer_proof( &self, diff --git a/grovedb/src/operations/proof/mod.rs b/grovedb/src/operations/proof/mod.rs index f232a990d..1ed281ff3 100644 --- a/grovedb/src/operations/proof/mod.rs +++ b/grovedb/src/operations/proof/mod.rs @@ -1113,6 +1113,12 @@ fn node_to_string(node: &Node) -> Result { element_hex_to_ascii(value)?, hex::encode(value_hash) ), + Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash) => format!( + "KVBackwardsReferencesValueHash({}, {}, HASH[{}])", + hex_to_ascii(key), + element_hex_to_ascii(value)?, + hex::encode(backrefs_hash) + ), Node::KVDigest(key, value_hash) => format!( "KVDigest({}, HASH[{}])", hex_to_ascii(key), diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index 92ac1aab4..a416e040b 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -1315,10 +1315,14 @@ impl GroveDb { // its value itself (`H(value) == value_hash`); a composite row // (tree, reference) is bound only when the merk verifier // checked `combine_hash(H(value), child_hash) == value_hash` - // on a node carrying the child hash. A bare `KVValueHash` row - // satisfies neither: its bytes are free for a prover to - // rewrite under a genuine root, which is exactly how a sum - // item could be disguised as a tree and dropped. + // on a node carrying the child hash; a backward-references + // item row (`KVBackwardsReferencesValueHash`) is bound the same + // way, the merk verifier having recomputed + // `combine_hash(H(stripped), referrer_list_hash)` into the root. + // A bare `KVValueHash` row satisfies neither: its bytes are + // free for a prover to rewrite under a genuine root, which is + // exactly how a sum item could be disguised as a tree and + // dropped. let simply_bound = value_hash(value_bytes).value() == *row_value_hash; if !simply_bound && !*child_hash_verified { return Err(Error::InvalidProof( @@ -1344,17 +1348,21 @@ impl GroveDb { if element.is_reference() || !element.is_sum_item() { continue; } - // A sum item commits the plain hash of its bytes, and the - // merk verifier refuses item elements on child-hash nodes at - // proof version 1, so a row that reaches the fold is always - // simply bound on a proof it accepted. + // A plain sum item commits the plain hash of its bytes, and + // the merk verifier refuses item elements on child-hash nodes + // at proof version 1, so a row that reaches the fold is either + // simply bound or — for the backward-references sum items — + // bound through the recomputed referrer-list combination. debug_assert!( - simply_bound, - "a folded sum item row must be bound by its own hash" + simply_bound || *child_hash_verified, + "a folded sum item row must be bound by its own hash or a recomputed \ + combined hash" ); let value = match element.into_underlying() { - Element::SumItem(value, _) => value, - Element::ItemWithSumItem(_, value, _) => value, + Element::SumItem(value, _) + | Element::ItemWithSumItem(_, value, _) + | Element::SumItemWithBackwardsReferences(value, _, _) + | Element::ItemWithSumItemWithBackwardsReferences(_, value, _, _) => value, _ => { return Err(Error::InvalidProof( query.clone(), @@ -2360,7 +2368,11 @@ impl GroveDb { | Element::Item(..) | Element::ItemWithSumItem(..) | Element::Reference(..) - | Element::ReferenceWithSumItem(..) => { + | Element::ReferenceWithSumItem(..) + | Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { return Err(Error::InvalidProof( query.clone(), "V1 proof has lower layer for a non-tree element.".to_string(), @@ -3530,7 +3542,11 @@ impl GroveDb { | Element::Item(..) | Element::ItemWithSumItem(..) | Element::Reference(..) - | Element::ReferenceWithSumItem(..) => { + | Element::ReferenceWithSumItem(..) + | Element::BidirectionalReference(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { return Err(Error::InvalidProof( query.clone(), "Proof has lower layer for a non Tree.".to_string(), @@ -4680,6 +4696,7 @@ impl GroveDb { match node { Node::KV(key, value) | Node::KVValueHash(key, value, ..) + | Node::KVBackwardsReferencesValueHash(key, value, ..) | Node::KVValueHashFeatureType(key, value, ..) | Node::KVValueHashFeatureTypeWithChildHash(key, value, ..) | Node::KVCount(key, value, ..) diff --git a/grovedb/src/reference_path.rs b/grovedb/src/reference_path.rs index 0804e76e7..d72f39ab2 100644 --- a/grovedb/src/reference_path.rs +++ b/grovedb/src/reference_path.rs @@ -4,7 +4,10 @@ use std::collections::HashSet; use grovedb_costs::{cost_return_on_error, cost_return_on_error_into_no_add, CostResult, CostsExt}; pub use grovedb_element::reference_path::*; -use grovedb_merk::{element::get::ElementFetchFromStorageExtensions, CryptoHash}; +use grovedb_merk::{ + element::{get::ElementFetchFromStorageExtensions, ElementExt}, + CryptoHash, +}; use grovedb_path::SubtreePathBuilder; use grovedb_version::check_grovedb_v0_with_cost; @@ -20,6 +23,9 @@ pub(crate) struct ResolvedReference<'db, 'b, 'c, B> { pub target_key: Vec, pub target_element: Element, pub target_node_value_hash: CryptoHash, + /// Reference edges traversed to reach the terminal (1 for a direct + /// target). + pub hops: usize, } pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( @@ -37,12 +43,13 @@ pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( .grovedb_versions .operations .get - .follow_reference + .ref_path_follow_reference ); let mut cost = Default::default(); let mut hops_left = MAX_REFERENCE_HOPS; + let mut hops_taken: usize = 0; let mut visited = HashSet::new(); let mut qualified_path = path.clone(); @@ -55,6 +62,7 @@ pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( let mut current_ref = ref_path; while hops_left > 0 { + hops_taken += 1; let referred_qualified_path = cost_return_on_error_into_no_add!( cost, current_ref.absolute_qualified_path(current_path, ¤t_key) @@ -73,15 +81,16 @@ pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( cost_return_on_error!(&mut cost, merk_cache.get_merk(referred_path.clone())); let (element, value_hash) = cost_return_on_error!( &mut cost, - referred_merk - .for_merk(|m| { - Element::get_with_value_hash(m, &referred_key, true, merk_cache.version) - }) - .map_err(|e| match e { - grovedb_merk::error::Error::PathKeyNotFound(s) => - Error::CorruptedReferencePathKeyNotFound(s), - e => e.into(), - }) + referred_merk.for_merk(|m| { + Element::get_with_value_hash(m, &referred_key, true, merk_cache.version).map_err( + |e| match e { + grovedb_merk::error::Error::PathKeyNotFound(s) => { + Error::CorruptedReferencePathKeyNotFound(s) + } + e => e.into(), + }, + ) + }) ); // Look through wrapper variants so a wrapper-wrapped reference is @@ -96,6 +105,9 @@ pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( // share the resolution path. The carried sum on // `ReferenceWithSumItem` is irrelevant to chain following: it's a // parent-aggregation property, not a per-hop value. + // `BidirectionalReference` is a reference too: its forward path is + // followed exactly like a plain reference's. (Its backward slot is + // bookkeeping for update propagation, irrelevant to resolution.) match element.into_underlying() { Element::Reference(ref_path, ..) | Element::ReferenceWithSumItem(ref_path, ..) => { current_path = referred_path; @@ -103,13 +115,32 @@ pub(crate) fn follow_reference<'db, 'b, 'c, B: AsRef<[u8]>>( current_ref = ref_path; hops_left -= 1; } + Element::BidirectionalReference(reference, _) => { + current_path = referred_path; + current_key = referred_key; + current_ref = reference.forward_reference_path; + hops_left -= 1; + } e => { + // Referrers commit to the LOGICAL value hash: for + // backward-references elements that is the inner (stripped) + // hash, not the node's stored combined hash. + let target_node_value_hash = if e.supports_backward_references() { + cost_return_on_error!( + &mut cost, + e.logical_value_hash(merk_cache.version) + .map_err(Error::from) + ) + } else { + value_hash + }; return Ok(ResolvedReference { target_merk: referred_merk, target_path: referred_path, target_key: referred_key, target_element: e, - target_node_value_hash: value_hash, + target_node_value_hash, + hops: hops_taken, }) .wrap_with_cost(cost); } @@ -156,23 +187,37 @@ pub(crate) fn follow_reference_once<'db, 'b, 'c, B: AsRef<[u8]>>( cost_return_on_error!(&mut cost, merk_cache.get_merk(referred_path.clone())); let (element, value_hash) = cost_return_on_error!( &mut cost, - referred_merk - .for_merk(|m| { - Element::get_with_value_hash(m, &referred_key, true, merk_cache.version) - }) - .map_err(|e| match e { - grovedb_merk::error::Error::PathKeyNotFound(s) => - Error::CorruptedReferencePathKeyNotFound(s), - e => e.into(), + referred_merk.for_merk(|m| { + Element::get_with_value_hash(m, &referred_key, true, merk_cache.version).map_err(|e| { + match e { + grovedb_merk::error::Error::PathKeyNotFound(s) => { + Error::CorruptedReferencePathKeyNotFound(s) + } + e => e.into(), + } }) + }) ); + // See `follow_reference`: logical hash for backward-references elements. + let target_node_value_hash = if element.supports_backward_references() { + cost_return_on_error!( + &mut cost, + element + .logical_value_hash(merk_cache.version) + .map_err(Error::from) + ) + } else { + value_hash + }; + Ok(ResolvedReference { target_merk: referred_merk, target_path: referred_path, target_key: referred_key, target_element: element, - target_node_value_hash: value_hash, + target_node_value_hash, + hops: 1, }) .wrap_with_cost(cost) } diff --git a/grovedb/src/replication/verify.rs b/grovedb/src/replication/verify.rs index d9db09bea..60b74fb10 100644 --- a/grovedb/src/replication/verify.rs +++ b/grovedb/src/replication/verify.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use grovedb_merk::{ - element::costs::ElementCostExtensions, + element::{costs::ElementCostExtensions, ElementExt}, tree::{combine_hash, kv::ValueDefinedCostType, value_hash, TreeNode}, Merk, }; @@ -82,6 +82,14 @@ impl GroveDb { | Element::ReferenceWithSumItem(reference_path, ..) => { path = path_from_reference_qualified_path_type(reference_path.clone(), &path)?; } + // A bidirectional edge is followed like any other hop; the + // chain's members commit to the terminal's LOGICAL hash. + Element::BidirectionalReference(reference, _) => { + path = path_from_reference_qualified_path_type( + reference.forward_reference_path.clone(), + &path, + )?; + } _ => return Ok(element), } } @@ -144,6 +152,49 @@ impl GroveDb { combined } } + // Backward-references family (two-layer scheme): a + // bidirectional reference's node hash is + // `combine(combine(inner, referrer list), end hash)` where + // the end hash is the terminal's LOGICAL (referrer-list + // stripped) hash; the item variants commit to + // `combine(inner, referrer list)` alone. + Element::BidirectionalReference(reference, _) => { + let hashes = element + .backward_references_hashes(grove_version) + .unwrap()? + .ok_or_else(|| { + Error::CorruptedData( + "bidirectional reference without backward-references hashes" + .to_string(), + ) + })?; + let target_path = path_from_reference_path_type( + reference.forward_reference_path.clone(), + &path, + Some(node.key()), + )?; + let target = self.restored_reference_target( + target_path, + transaction, + grove_version, + )?; + let end_hash = target.logical_value_hash(grove_version).unwrap()?; + combine_hash(&hashes.combined, &end_hash).unwrap() + } + Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) => { + element + .backward_references_hashes(grove_version) + .unwrap()? + .ok_or_else(|| { + Error::CorruptedData( + "backward-references item without backward-references hashes" + .to_string(), + ) + })? + .combined + } _ if element.element_type().has_simple_value_hash() => actual_value_hash, _ if element.is_any_tree() => { // Child restores (including non-Merk replay and diff --git a/grovedb/src/tests/batch_backward_references_cost_tests.rs b/grovedb/src/tests/batch_backward_references_cost_tests.rs new file mode 100644 index 000000000..ac8a33f29 --- /dev/null +++ b/grovedb/src/tests/batch_backward_references_cost_tests.rs @@ -0,0 +1,677 @@ +//! Estimated-cost coverage for backward-references batch ops (batching +//! M5): under `BatchApplyOptions::propagate_backward_references`, the +//! GROVE_V4 estimators charge the derived fan-out (registration, chain +//! propagation, cascade deletion) so `worst-case estimate >= actual` holds +//! for flagged family batches, while pre-V4 estimation stays byte-stable +//! for replay. + +use std::collections::HashMap; + +use grovedb_merk::estimated_costs::{ + average_case_costs::{ + EstimatedLayerCount::EstimatedLevel, + EstimatedLayerInformation, + EstimatedLayerSizes::{AllItems, AllSubtrees}, + EstimatedSumTrees::NoSumTrees, + }, + worst_case_costs::WorstCaseLayerInformation::MaxElementsNumber, +}; +use grovedb_merk::tree_type::TreeType; +use grovedb_version::version::GroveVersion; + +use crate::{ + batch::{ + estimated_costs::EstimatedCostsType::{AverageCaseCostsType, WorstCaseCostsType}, + key_info::KeyInfo, + BatchApplyOptions, GroveOp, KeyInfoPath, QualifiedGroveDbOp, + }, + bidirectional_references::BidirectionalReference, + reference_path::ReferencePathType, + tests::{make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, Error, GroveDb, +}; + +fn batch_flag_on() -> Option { + Some(BatchApplyOptions { + propagate_backward_references: true, + ..Default::default() + }) +} + +fn sibling_bidi(key: &[u8]) -> Element { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(key.to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ) +} + +/// TEST_LEAF holding the registered chain `r2 -> r1 -> value`. +fn db_with_chain(grove_version: &GroveVersion) -> TempGroveDb { + let db = make_test_grovedb(grove_version); + for (key, element) in [ + ( + b"value".as_slice(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ), + (b"r1", sibling_bidi(b"value")), + (b"r2", sibling_bidi(b"r1")), + ] { + db.insert(&[TEST_LEAF], key, element, None, None, grove_version) + .unwrap() + .unwrap(); + } + db +} + +fn worst_case_layers( +) -> HashMap +{ + let mut paths = HashMap::new(); + paths.insert(KeyInfoPath(vec![]), MaxElementsNumber(4)); + paths.insert( + KeyInfoPath(vec![KeyInfo::KnownKey(TEST_LEAF.to_vec())]), + MaxElementsNumber(16), + ); + paths +} + +fn average_case_layers() -> HashMap { + let mut paths = HashMap::new(); + paths.insert( + KeyInfoPath(vec![]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(1, false), + estimated_layer_sizes: AllSubtrees(32, NoSumTrees, None), + }, + ); + paths.insert( + KeyInfoPath(vec![KeyInfo::KnownKey(TEST_LEAF.to_vec())]), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: EstimatedLevel(2, true), + estimated_layer_sizes: AllItems(32, 128, None), + }, + ); + paths +} + +fn worst_case_estimate( + ops: Vec, + options: Option, + grove_version: &GroveVersion, +) -> grovedb_costs::OperationCost { + GroveDb::estimated_case_operations_for_batch( + WorstCaseCostsType(worst_case_layers()), + ops, + options, + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + grove_version, + ) + .cost_as_result() + .expect("expected worst case costs") +} + +fn average_case_estimate( + ops: Vec, + options: Option, + grove_version: &GroveVersion, +) -> grovedb_costs::OperationCost { + GroveDb::estimated_case_operations_for_batch( + AverageCaseCostsType(average_case_layers()), + ops, + options, + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + grove_version, + ) + .cost_as_result() + .expect("expected average case costs") +} + +#[test] +fn worst_case_estimate_covers_flagged_family_overwrite() { + let grove_version = GroveVersion::latest(); + let db = db_with_chain(grove_version); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + )]; + let estimate = worst_case_estimate(ops.clone(), batch_flag_on(), grove_version); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the actual {actual:?}" + ); +} + +#[test] +fn worst_case_estimate_covers_flagged_delete_cascade() { + let grove_version = GroveVersion::latest(); + let db = db_with_chain(grove_version); + + let ops = vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + )]; + let estimate = worst_case_estimate(ops.clone(), batch_flag_on(), grove_version); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the actual cascade {actual:?}" + ); +} + +#[test] +fn worst_case_estimate_covers_bidi_insert_with_in_batch_target() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + let ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value"), + ), + ]; + let estimate = worst_case_estimate(ops.clone(), batch_flag_on(), grove_version); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the actual {actual:?}" + ); +} + +#[test] +fn fan_out_terms_activate_only_with_the_flag() { + let grove_version = GroveVersion::latest(); + + let family_op = || { + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + )] + }; + + // Flag on adds the fan-out on GROVE_V4+… + let flagged = worst_case_estimate(family_op(), batch_flag_on(), grove_version); + let unflagged = worst_case_estimate(family_op(), None, grove_version); + assert!( + flagged.seek_count > unflagged.seek_count + && flagged.storage_cost.replaced_bytes > unflagged.storage_cost.replaced_bytes, + "the flag must activate the fan-out terms: {flagged:?} vs {unflagged:?}" + ); + let flagged_avg = average_case_estimate(family_op(), batch_flag_on(), grove_version); + let unflagged_avg = average_case_estimate(family_op(), None, grove_version); + assert!(flagged_avg.seek_count > unflagged_avg.seek_count); + + // …and a PLAIN-item op also charges the displaced-state fan-out under + // the flag: the estimator cannot see the stored element the write + // lands on, which may be a registered family element whose + // propagation/cascade the preprocessor must perform. + let plain_op = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item(b"hello".to_vec()), + )]; + let flagged_plain = worst_case_estimate(plain_op.clone(), batch_flag_on(), grove_version); + let unflagged_plain = worst_case_estimate(plain_op, None, grove_version); + assert!( + flagged_plain.seek_count > unflagged_plain.seek_count, + "plain writes must charge the displaced-state bound under the flag" + ); +} + +/// The reviewer-reported gap: a flagged PLAIN overwrite landing on a +/// registered family element triggers real propagation work in +/// preprocessing — the worst-case estimate must cover it. +#[test] +fn worst_case_estimate_covers_flagged_plain_overwrite_of_registered_target() { + let grove_version = GroveVersion::latest(); + let db = db_with_chain(grove_version); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item(b"plain".to_vec()), + )]; + let estimate = worst_case_estimate(ops.clone(), batch_flag_on(), grove_version); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds — the chain cascades"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the displaced-state work {actual:?}" + ); +} + +/// The registration entry stored on the target serializes the referrer's +/// QUALIFIED ORIGIN path (an absolute inversion carries every segment): a +/// deep origin pointing at a shallow target must still be covered by the +/// worst-case added-bytes bound. +#[test] +fn worst_case_estimate_covers_deep_origin_registration_growth() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // An eight-level origin with 200-byte segment names. + let mut deep_path: Vec> = vec![TEST_LEAF.to_vec()]; + for i in 0..7u8 { + let segment = vec![b'a' + i; 200]; + let path_refs: Vec<&[u8]> = deep_path.iter().map(|p| p.as_slice()).collect(); + db.insert( + path_refs.as_slice(), + &segment, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + deep_path.push(segment); + } + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + deep_path.clone(), + b"ref".to_vec(), + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"value".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + )]; + + // Declare a layer for the op's path and every ancestor level the + // estimator bubbles through. + let mut paths = worst_case_layers(); + let mut ancestor = Vec::new(); + for segment in &deep_path { + ancestor.push(KeyInfo::KnownKey(segment.clone())); + paths.insert(KeyInfoPath(ancestor.clone()), MaxElementsNumber(4)); + } + let estimate = GroveDb::estimated_case_operations_for_batch( + WorstCaseCostsType(paths), + ops.clone(), + batch_flag_on(), + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + grove_version, + ) + .cost_as_result() + .expect("expected worst case costs"); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the deep-origin registration {actual:?}" + ); +} + +#[test] +fn pre_v4_estimation_is_byte_stable_for_replay() { + // On GROVE_V3 the fan-out version is 0: flagged and unflagged + // estimates of the same ops must stay identical, so historical + // admission decisions replay byte-for-byte. + let v3 = &grovedb_version::version::v3::GROVE_V3; + + let family_op = || { + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + )] + }; + assert_eq!( + worst_case_estimate(family_op(), batch_flag_on(), v3), + worst_case_estimate(family_op(), None, v3), + ); + assert_eq!( + average_case_estimate(family_op(), batch_flag_on(), v3), + average_case_estimate(family_op(), None, v3), + ); +} + +#[test] +fn derived_op_estimation_is_version_gated() { + let grove_version = GroveVersion::latest(); + let v3 = &grovedb_version::version::v3::GROVE_V3; + + // The internal derived op cannot be supplied through apply_batch, but + // the estimation surface must model it (an expanded batch could be + // estimated in-crate) — on GROVE_V4 only. + let derived_op = || { + vec![QualifiedGroveDbOp { + path: KeyInfoPath(vec![KeyInfo::KnownKey(TEST_LEAF.to_vec())]), + key: Some(KeyInfo::KnownKey(b"value".to_vec())), + op: GroveOp::ReplaceBackwardReferenceFamilyMember { + element: Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + node_value_hash: [7; 32], + end_hash: None, + }, + }] + }; + + let cost = worst_case_estimate(derived_op(), None, grove_version); + assert!(cost.seek_count > 0); + let cost = average_case_estimate(derived_op(), None, grove_version); + assert!(cost.seek_count > 0); + + let refused = GroveDb::estimated_case_operations_for_batch( + WorstCaseCostsType(worst_case_layers()), + derived_op(), + None, + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + v3, + ) + .cost_as_result(); + assert!(matches!(refused, Err(Error::NotSupported(_)))); +} + +/// The maximum legal component shape: `MAX_BACKWARD_REFERENCES` referrers +/// on one target, each in its OWN branch at the full +/// `MAX_BACKWARD_REFERENCES_GROVE_DEPTH` registration depth, with every +/// ancestor Merk populated (so bubbling does real in-Merk propagation at +/// each level). The worst-case estimate must cover the flagged overwrite +/// componentwise — this pins the height-dependent ancestor-walk term. +#[test] +fn worst_case_estimate_covers_max_fan_out_deep_component() { + use grovedb_element::MAX_BACKWARD_REFERENCES; + + use crate::bidirectional_references::MAX_BACKWARD_REFERENCES_GROVE_DEPTH; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // The target declares the protocol ceiling as its capacity; the + // overwrite below re-declares it (the carried-over referrers must fit). + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references_with_capacity( + b"hello".to_vec(), + MAX_BACKWARD_REFERENCES as u16, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // The branch skeletons (a subtree per level, each parent Merk + // populated so bubbling does real work there) are built in one batch: + // inserting them one node at a time would propagate every write to the + // root and take minutes at the ceiling's width. + let mut skeleton = Vec::new(); + let mut leaf_paths: Vec>> = Vec::new(); + for branch in 0..MAX_BACKWARD_REFERENCES { + let mut path: Vec> = vec![TEST_LEAF.to_vec()]; + // First segment distinguishes the branch; deeper segments are + // constant (each lives in its own parent Merk). + let mut next_segment = format!("b{branch:03}").into_bytes(); + while path.len() < MAX_BACKWARD_REFERENCES_GROVE_DEPTH { + skeleton.push(QualifiedGroveDbOp::insert_or_replace_op( + path.clone(), + next_segment.clone(), + Element::empty_tree(), + )); + skeleton.push(QualifiedGroveDbOp::insert_or_replace_op( + path.clone(), + [next_segment.as_slice(), b"_fill"].concat(), + Element::new_item(b"f".to_vec()), + )); + path.push(next_segment); + next_segment = b"d".to_vec(); + } + leaf_paths.push(path); + } + db.apply_batch(skeleton, None, None, grove_version) + .unwrap() + .expect("branch skeletons"); + + // The referrers themselves go in one at a time: each registers on + // `value` and propagates up its own branch. + for path in &leaf_paths { + let path_refs: Vec<&[u8]> = path.iter().map(|p| p.as_slice()).collect(); + db.insert( + path_refs.as_slice(), + b"ref", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"value".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references_with_capacity( + b"updated".to_vec(), + MAX_BACKWARD_REFERENCES as u16, + ), + )]; + // The declared layer must dominate every Merk in the component, + // ancestor Merks included (the model's documented contract). + let mut paths = HashMap::new(); + paths.insert(KeyInfoPath(vec![]), MaxElementsNumber(8)); + paths.insert( + KeyInfoPath(vec![KeyInfo::KnownKey(TEST_LEAF.to_vec())]), + MaxElementsNumber(128), + ); + let estimate = GroveDb::estimated_case_operations_for_batch( + WorstCaseCostsType(paths), + ops.clone(), + batch_flag_on(), + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + grove_version, + ) + .cost_as_result() + .expect("expected worst case costs"); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("apply succeeds — full-width propagation"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the maximum component shape {actual:?}" + ); +} + +/// The flagged apply path probes a deleted tree's subtree for emptiness +/// (a merk open plus its root read) before admitting the deletion; the +/// estimators must charge it — a flagged empty-tree deletion previously +/// cost more than its worst-case estimate. +#[test] +fn worst_case_estimate_covers_flagged_empty_tree_deletion() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"sub", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let ops = vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"sub".to_vec(), + )]; + let estimate = worst_case_estimate(ops.clone(), batch_flag_on(), grove_version); + let actual = db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("an empty subtree deletes under the flag"); + + assert!( + estimate.worse_or_eq_than(&actual), + "worst-case estimate {estimate:?} must cover the emptiness probe {actual:?}" + ); +} + +/// A written item's DECLARED referrer capacity bounds its worst-case +/// fan-out: a tighter declaration estimates strictly less than the default +/// one, a plain payload (which cannot declare anything) charges the +/// protocol ceiling, and the tight estimate still covers the actual flagged +/// overwrite of a registered target. +#[test] +fn declared_capacity_tightens_the_worst_case_estimate() { + let grove_version = GroveVersion::latest(); + let family = |capacity: u16| { + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references_with_capacity( + b"updated".to_vec(), + capacity, + ), + )] + }; + let plain = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item(b"updated".to_vec()), + )]; + + let tight = worst_case_estimate(family(1), batch_flag_on(), grove_version); + let default = worst_case_estimate( + family(grovedb_element::DEFAULT_BACKWARD_REFERENCES_CAPACITY), + batch_flag_on(), + grove_version, + ); + let ceiling = worst_case_estimate(plain, batch_flag_on(), grove_version); + assert!( + tight.seek_count < default.seek_count && tight.hash_node_calls < default.hash_node_calls, + "a capacity of one must estimate below the default: {tight:?} vs {default:?}" + ); + assert!( + default.seek_count < ceiling.seek_count + && default.hash_node_calls < ceiling.hash_node_calls, + "the default capacity must estimate below the ceiling a plain payload charges: \ + {default:?} vs {ceiling:?}" + ); + + // `value` carries one referrer (r1, itself referred to by r2): the + // capacity-one overwrite fits and its estimate covers the real + // propagation along the whole chain. + let db = db_with_chain(grove_version); + let actual = db + .apply_batch(family(1), batch_flag_on(), None, grove_version) + .cost_as_result() + .expect("one registered referrer fits a capacity of one"); + assert!( + tight.worse_or_eq_than(&actual), + "the declared-capacity estimate {tight:?} must cover the actual {actual:?}" + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); + + // The average model is capped by the declaration too. + let tight_average = average_case_estimate(family(0), batch_flag_on(), grove_version); + let default_average = average_case_estimate( + family(grovedb_element::DEFAULT_BACKWARD_REFERENCES_CAPACITY), + batch_flag_on(), + grove_version, + ); + assert!( + tight_average.seek_count <= default_average.seek_count + && tight_average.hash_node_calls < default_average.hash_node_calls, + "{tight_average:?} vs {default_average:?}" + ); +} diff --git a/grovedb/src/tests/batch_backward_references_tests.rs b/grovedb/src/tests/batch_backward_references_tests.rs new file mode 100644 index 000000000..55c7b5028 --- /dev/null +++ b/grovedb/src/tests/batch_backward_references_tests.rs @@ -0,0 +1,2435 @@ +//! Batch support for the backward-references family (batching M2–M4): the +//! master invariant is that a batch under +//! `BatchApplyOptions::propagate_backward_references` produces the exact +//! root hash the live flagged flow produces for the same logical +//! operations — including `BidirectionalReference` ops, in-batch targets +//! and chains, retargets, identical-edge no-ops, and the M4 conflict +//! rules. + +use grovedb_version::version::GroveVersion; + +use crate::{ + batch::{BatchApplyOptions, QualifiedGroveDbOp}, + bidirectional_references::BidirectionalReference, + operations::{delete::DeleteOptions, insert::InsertOptions}, + reference_path::ReferencePathType, + tests::{make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, Error, +}; + +fn flag_on() -> Option { + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }) +} + +fn batch_flag_on() -> Option { + Some(BatchApplyOptions { + propagate_backward_references: true, + ..Default::default() + }) +} + +fn sibling_bidi(key: &[u8], cascade: bool) -> Element { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(key.to_vec()), + backward_references: Vec::new(), + cascade_on_update: cascade, + max_hop: None, + }, + None, + ) +} + +/// Two identical databases: TEST_LEAF holding a registered target chain +/// `r2 -> r1 -> value`. +fn twin_dbs_with_chain(grove_version: &GroveVersion) -> (TempGroveDb, TempGroveDb) { + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r2", + sibling_bidi(b"r1", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + (build(), build()) +} + +fn roots_match(batch_db: &TempGroveDb, live_db: &TempGroveDb, grove_version: &GroveVersion) { + assert_eq!( + batch_db.root_hash(None, grove_version).unwrap().unwrap(), + live_db.root_hash(None, grove_version).unwrap().unwrap(), + "batch and live flows must produce byte-identical root hashes" + ); + assert!(batch_db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn batch_fresh_insert_matches_live() { + let grove_version = GroveVersion::latest(); + let batch_db = make_test_grovedb(grove_version); + let live_db = make_test_grovedb(grove_version); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_overwrite_propagates_along_the_chain_like_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + let updated = Element::new_item_allowing_bidirectional_references(b"updated".to_vec()); + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + updated.clone(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"value", + updated, + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_sum_twin_overwrite_matches_live() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"sums", + Element::new_sum_tree(None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"twin", + Element::new_item_with_sum_item_allowing_bidirectional_references(b"pay".to_vec(), 5), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"ref", + sibling_bidi(b"twin", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + let updated = + Element::new_item_with_sum_item_allowing_bidirectional_references(b"pay2".to_vec(), 8); + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec(), b"sums".to_vec()], + b"twin".to_vec(), + updated.clone(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF, b"sums"], + b"twin", + updated, + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_delete_cascades_like_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .delete( + &[TEST_LEAF], + b"value", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // The whole chain is gone on both sides. + for db in [&batch_db, &live_db] { + for key in [b"value".as_slice(), b"r1", b"r2"] { + assert!(matches!( + db.get(&[TEST_LEAF], key, None, grove_version).unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + } + } + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_overwrite_with_plain_item_cascades_like_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + let plain = Element::new_item(b"plain".to_vec()); + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + plain.clone(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"value", + plain, + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_cascade_requires_consent() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"value", false), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn batch_clears_caller_supplied_referrer_lists() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + use grovedb_path::SubtreePath; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let forged = crate::bidirectional_references::BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"victim".to_vec()), + cascade_on_update: true, + }; + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"planted".to_vec(), + Element::ItemWithBackwardsReferences(b"x".to_vec(), vec![forged].into(), None), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let tx = db.start_transaction(); + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + Element::get(&merk, b"planted", true, grove_version) + .unwrap() + .unwrap() + .backward_references() + .unwrap() + .len(), + 0, + "forged referrer entries must not persist through batches" + ); +} + +#[test] +fn batch_rejections_hold() { + let grove_version = GroveVersion::latest(); + let (db, _other) = twin_dbs_with_chain(grove_version); + + // Family item ops without the flag: rejected. + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"fresh".to_vec(), + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + )], + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); + + // BidirectionalReference element ops without the flag: rejected. + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"newref".to_vec(), + sibling_bidi(b"value", true), + )], + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); + + // The internal derived op cannot be supplied by callers. + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp { + path: crate::batch::KeyInfoPath::from_known_owned_path(vec![TEST_LEAF.to_vec()]), + key: Some(crate::batch::key_info::KeyInfo::KnownKey(b"value".to_vec())), + op: crate::batch::GroveOp::ReplaceBackwardReferenceFamilyMember { + element: Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + node_value_hash: [7; 32], + end_hash: None, + }, + }], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); + + // A derived rewrite colliding with a user op on the same position + // (the propagation from overwriting `value` must rewrite `r1`, which + // another op deletes): the M4 conflict rules fail closed. + assert!(matches!( + db.apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + ), + QualifiedGroveDbOp::delete_op(vec![TEST_LEAF.to_vec()], b"r1".to_vec()), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::InvalidBatchOperation(_)) + )); + + // Pre-V4 versions fail closed even with the flag. + let v3 = &grovedb_version::version::v3::GROVE_V3; + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"fresh".to_vec(), + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + )], + batch_flag_on(), + None, + v3, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +// ─── Batching M3: BidirectionalReference ops ──────────────────────────── + +#[test] +fn batch_bidi_insert_with_existing_target_matches_live() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value", true), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_bidi_insert_with_in_batch_target_matches_live_in_any_op_order() { + let grove_version = GroveVersion::latest(); + let target = Element::new_item_allowing_bidirectional_references(b"fresh".to_vec()); + + // The reference op comes FIRST in the batch — the preprocessor must + // still resolve it against the target created by the second op. + for (op_a, op_b) in [ + ( + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + target.clone(), + ), + ), + ( + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + target.clone(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value", true), + ), + ), + ] { + let batch_db = make_test_grovedb(grove_version); + let live_db = make_test_grovedb(grove_version); + batch_db + .apply_batch(vec![op_a, op_b], batch_flag_on(), None, grove_version) + .unwrap() + .unwrap(); + // The live twin's only valid sequential order is target first. + live_db + .insert( + &[TEST_LEAF], + b"value", + target.clone(), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + roots_match(&batch_db, &live_db, grove_version); + } +} + +#[test] +fn batch_whole_chain_created_in_one_batch_matches_live() { + let grove_version = GroveVersion::latest(); + let batch_db = make_test_grovedb(grove_version); + let live_db = make_test_grovedb(grove_version); + + // Shuffled op order: r2 -> r1 -> value, submitted referrers-first. + batch_db + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r2".to_vec(), + sibling_bidi(b"r1", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + sibling_bidi(b"value", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + for (key, element) in [ + ( + b"value".as_slice(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ), + (b"r1", sibling_bidi(b"value", true)), + (b"r2", sibling_bidi(b"r1", true)), + ] { + live_db + .insert(&[TEST_LEAF], key, element, flag_on(), None, grove_version) + .unwrap() + .unwrap(); + } + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_retarget_matches_live() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + for (key, element) in [ + ( + b"a".as_slice(), + Element::new_item_allowing_bidirectional_references(b"va".to_vec()), + ), + ( + b"b", + Element::new_item_allowing_bidirectional_references(b"vb".to_vec()), + ), + (b"ref", sibling_bidi(b"a", true)), + ] { + db.insert(&[TEST_LEAF], key, element, flag_on(), None, grove_version) + .unwrap() + .unwrap(); + } + db + }; + let (batch_db, live_db) = (build(), build()); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"b", true), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"b", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_retarget_with_upstream_referrer_matches_live() { + let grove_version = GroveVersion::latest(); + // r2 -> r1 -> value; retarget r1 onto a second item: r1's registration + // moves, and r2 must be rewritten with the new end hash. + let build = || { + let (db, _twin) = twin_dbs_with_chain(grove_version); + db.insert( + &[TEST_LEAF], + b"other", + Element::new_item_allowing_bidirectional_references(b"other".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + sibling_bidi(b"other", true), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"other", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_identical_edge_reinsert_is_a_no_op() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + let root_before = batch_db.root_hash(None, grove_version).unwrap().unwrap(); + + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + sibling_bidi(b"value", true), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert_eq!( + batch_db.root_hash(None, grove_version).unwrap().unwrap(), + root_before, + "an identical-edge re-insert must not change the root" + ); + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_bidi_delete_matches_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + // Deleting r1 removes its registration on `value` and cascades r2. + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .delete( + &[TEST_LEAF], + b"r1", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + for db in [&batch_db, &live_db] { + assert!(matches!( + db.get(&[TEST_LEAF], b"r2", None, grove_version).unwrap(), + Err(Error::PathKeyNotFound(_)) + )); + } + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_overwrite_bidi_with_plain_item_matches_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + let plain = Element::new_item(b"plain".to_vec()); + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + plain.clone(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert(&[TEST_LEAF], b"r1", plain, flag_on(), None, grove_version) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_two_refs_to_same_target_matches_live() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + batch_db + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ra".to_vec(), + sibling_bidi(b"value", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"rb".to_vec(), + sibling_bidi(b"value", true), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + for key in [b"ra".as_slice(), b"rb"] { + live_db + .insert( + &[TEST_LEAF], + key, + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_ref_plus_target_overwrite_in_same_batch_matches_live() { + let grove_version = GroveVersion::latest(); + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + // Overwrite the registered target AND add a new reference to it in the + // same batch: the registration merges into the overwrite op's element + // and the existing chain is rewritten with the new end hash. + let updated = Element::new_item_allowing_bidirectional_references(b"updated".to_vec()); + batch_db + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + updated.clone(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"rnew".to_vec(), + sibling_bidi(b"value", true), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"value", + updated, + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"rnew", + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_component_budget_enforced_against_prospective_state() { + use crate::operations::get::MAX_REFERENCE_HOPS; + + let grove_version = GroveVersion::latest(); + + // A whole chain created in one batch stays valid up to the hop budget… + let db = make_test_grovedb(grove_version); + let mut ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"t0".to_vec(), + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + )]; + for i in 1..MAX_REFERENCE_HOPS { + ops.push(QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + format!("t{i}").into_bytes(), + sibling_bidi(format!("t{}", i - 1).as_bytes(), true), + )); + } + db.apply_batch(ops.clone(), batch_flag_on(), None, grove_version) + .unwrap() + .expect("a chain at the hop budget is valid"); + + // …and the COMPONENT budget rejects splicing a pending chain under an + // existing referrer: r2 -> r1 -> value exists on disk; the batch + // creates a fresh chain t9 -> … -> t0 and retargets r1 onto t9. + // Upstream (r2) plus downstream (the 10 pending hops) exceeds the + // budget, even though every downstream element exists only + // prospectively in this same batch. + let (db, _twin) = twin_dbs_with_chain(grove_version); + ops.push(QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r1".to_vec(), + sibling_bidi(format!("t{}", MAX_REFERENCE_HOPS - 1).as_bytes(), true), + )); + assert!(matches!( + db.apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +// ─── Batching M4: conflict rules ──────────────────────────────────────── + +#[test] +fn batch_ref_insert_with_target_deleted_in_same_batch_errors() { + let grove_version = GroveVersion::latest(); + + // Both op orders: the rule is order-independent. + for flip in [false, true] { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value", true), + ), + QualifiedGroveDbOp::delete_op(vec![TEST_LEAF.to_vec()], b"value".to_vec()), + ]; + if flip { + ops.reverse(); + } + assert!( + matches!( + db.apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap(), + Err(Error::InvalidBatchOperation(_)) + | Err(Error::CorruptedReferencePathKeyNotFound(_)) + ), + "a reference and its target's deletion cannot share a batch" + ); + } +} + +#[test] +fn batch_cascade_hitting_a_user_write_errors() { + let grove_version = GroveVersion::latest(); + let (db, _twin) = twin_dbs_with_chain(grove_version); + + // Deleting `value` cascades r1 and r2; another op writes r2. + assert!(matches!( + db.apply_batch( + vec![ + QualifiedGroveDbOp::delete_op(vec![TEST_LEAF.to_vec()], b"value".to_vec()), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r2".to_vec(), + Element::new_item(b"squatter".to_vec()), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::InvalidBatchOperation(_)) + )); +} + +#[test] +fn batch_refresh_reference_on_bidi_errors() { + let grove_version = GroveVersion::latest(); + let (db, _twin) = twin_dbs_with_chain(grove_version); + + let refresh = QualifiedGroveDbOp { + path: crate::batch::KeyInfoPath::from_known_owned_path(vec![TEST_LEAF.to_vec()]), + key: Some(crate::batch::key_info::KeyInfo::KnownKey(b"r1".to_vec())), + op: crate::batch::GroveOp::RefreshReference { + reference_path_type: ReferencePathType::SiblingReference(b"value".to_vec()), + max_reference_hop: None, + mode: crate::batch::RefreshReferenceMode::PlainReferenceTrusted, + flags: None, + non_counted: false, + }, + }; + assert!(matches!( + db.apply_batch(vec![refresh], batch_flag_on(), None, grove_version) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +#[test] +fn batch_plain_reference_can_point_at_in_batch_family_target() { + let grove_version = GroveVersion::latest(); + let batch_db = make_test_grovedb(grove_version); + let live_db = make_test_grovedb(grove_version); + + // An ordinary (one-way) reference resolving through a family item + // written in the same batch commits the item's LOGICAL hash. + batch_db + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"plainref".to_vec(), + Element::new_reference(ReferencePathType::SiblingReference(b"value".to_vec())), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"plainref", + Element::new_reference(ReferencePathType::SiblingReference(b"value".to_vec())), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +#[test] +fn batch_bidi_ops_keep_caller_authority_rules() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + use grovedb_path::SubtreePath; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // A caller-supplied referrer list on an inserted reference is not + // theirs to claim: it must be cleared on a fresh insert. + let forged = crate::bidirectional_references::BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"victim".to_vec()), + cascade_on_update: true, + }; + let mut reference = sibling_bidi(b"value", true); + if let Element::BidirectionalReference(inner, _) = &mut reference { + inner.backward_references.push(forged); + } + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + reference, + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let tx = db.start_transaction(); + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + Element::get(&merk, b"ref", true, grove_version) + .unwrap() + .unwrap() + .backward_references() + .unwrap() + .len(), + 0, + "forged referrer entries must not persist through batches" + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +// ─── Review round: merge restrictions + fresh subtrees ────────────────── + +/// An `InsertIfNotExists` over an EXISTING key writes nothing: a +/// registration landing on that position must survive as a derived op +/// instead of being folded into the op that never executes. +#[test] +fn batch_no_op_insert_if_not_exists_does_not_swallow_registration() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + use grovedb_path::SubtreePath; + + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + batch_db + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_if_not_exists_or_skip_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::new_item(b"loser".to_vec()), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + sibling_bidi(b"value", true), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + // The live twin: the conditional insert is a no-op, then the reference. + live_db + .insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // The registration must exist on the target. + let tx = batch_db.start_transaction(); + let merk = batch_db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let target = Element::get(&merk, b"value", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + target.backward_references().unwrap().len(), + 1, + "the registration must not be swallowed by the non-executing insert" + ); + drop(merk); + drop(tx); + + roots_match(&batch_db, &live_db, grove_version); +} + +/// A propagation rewrite colliding with a LATER plain overwrite of the +/// same position must be superseded by that overwrite — the caller's +/// write wins, with the dereg bookkeeping its displacement requires — +/// in either op order, matching sequential execution. +#[test] +fn batch_later_plain_overwrite_supersedes_propagation() { + let grove_version = GroveVersion::latest(); + + for flip in [false, true] { + let (batch_db, live_db) = twin_dbs_with_chain(grove_version); + + let updated = Element::new_item_allowing_bidirectional_references(b"updated".to_vec()); + let squatter = Element::new_item(b"squatter".to_vec()); + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + updated.clone(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r2".to_vec(), + squatter.clone(), + ), + ]; + if flip { + ops.reverse(); + } + batch_db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap() + .unwrap(); + + // The live twin executes the same canonical order. + let live_ops: Vec<(&[u8], Element)> = if flip { + vec![(b"r2", squatter.clone()), (b"value", updated.clone())] + } else { + vec![(b"value", updated.clone()), (b"r2", squatter.clone())] + }; + for (key, element) in live_ops { + live_db + .insert(&[TEST_LEAF], key, element, flag_on(), None, grove_version) + .unwrap() + .unwrap(); + } + + // The caller's plain overwrite must have landed. + assert_eq!( + batch_db + .get(&[TEST_LEAF], b"r2", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item(b"squatter".to_vec()), + "the caller's overwrite must not be discarded by the propagation (flip: {flip})" + ); + roots_match(&batch_db, &live_db, grove_version); + } +} + +/// A flagged batch can create a subtree and populate it — with ordinary +/// items, family items, and references — in the same batch: reads under +/// the staged subtree resolve through the overlay, never committed +/// storage (which does not hold the parent yet). +/// +/// Exact root parity with a live sequential twin is NOT asserted here: +/// batch-built and sequentially-built merks legitimately differ in tree +/// shape when constructing a FRESH subtree (a long-standing property of +/// plain unflagged batches too). The master root-parity invariant covers +/// deltas on existing trees; fresh construction asserts semantic +/// equivalence and order-insensitivity of the expansion instead. +#[test] +fn batch_populates_a_subtree_created_in_the_same_batch() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + use grovedb_path::SubtreePath; + + let grove_version = GroveVersion::latest(); + let sub_path = vec![TEST_LEAF.to_vec(), b"sub".to_vec()]; + let nested_path = vec![TEST_LEAF.to_vec(), b"sub".to_vec(), b"nested".to_vec()]; + let ops = || { + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"sub".to_vec(), + Element::empty_tree(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + sub_path.clone(), + b"plain".to_vec(), + Element::new_item(b"p".to_vec()), + ), + QualifiedGroveDbOp::insert_or_replace_op( + sub_path.clone(), + b"family".to_vec(), + Element::new_item_allowing_bidirectional_references(b"f".to_vec()), + ), + QualifiedGroveDbOp::insert_or_replace_op( + sub_path.clone(), + b"ref".to_vec(), + sibling_bidi(b"family", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + sub_path.clone(), + b"nested".to_vec(), + Element::empty_tree(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + nested_path.clone(), + b"deep".to_vec(), + Element::new_item(b"d".to_vec()), + ), + ] + }; + + let batch_db = make_test_grovedb(grove_version); + batch_db + .apply_batch(ops(), batch_flag_on(), None, grove_version) + .unwrap() + .expect("a batch may create and populate a subtree under the flag"); + + // The expansion is order-insensitive: the reversed op order (children + // before their parent trees, reference before its target) produces the + // byte-identical root. + let reversed_db = make_test_grovedb(grove_version); + let mut reversed = ops(); + reversed.reverse(); + reversed_db + .apply_batch(reversed, batch_flag_on(), None, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + batch_db.root_hash(None, grove_version).unwrap().unwrap(), + reversed_db.root_hash(None, grove_version).unwrap().unwrap(), + "op order must not change the outcome" + ); + + // The bookkeeping landed: the in-subtree registration exists and the + // reference resolves through the fresh subtree. + let tx = batch_db.start_transaction(); + let merk = batch_db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF, b"sub".as_slice()]), + &tx, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let family = Element::get(&merk, b"family", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!(family.backward_references().unwrap().len(), 1); + drop(merk); + drop(tx); + assert_eq!( + batch_db + .get(&[TEST_LEAF, b"sub".as_slice()], b"ref", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"f".to_vec()), + ); + assert_eq!( + batch_db + .get( + &[TEST_LEAF, b"sub".as_slice(), b"nested".as_slice()], + b"deep", + None, + grove_version + ) + .unwrap() + .unwrap(), + Element::new_item(b"d".to_vec()), + ); + assert!(batch_db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// A flagged overwrite of a family item carrying a DANGLING registration +/// (its referrer was removed through an unflagged batch) plans a stale- +/// entry cleanup targeting the op's own position: that cleanup must fold +/// into the op itself, not become a second op that fails consistency. +#[test] +fn batch_flagged_overwrite_folds_own_stale_cleanup() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // Remove the referrer through the supported UNFLAGGED batch path: + // the registration on `value` is left dangling. + db.apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"ref".to_vec(), + )], + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let (batch_db, live_db) = (build(), build()); + + let updated = Element::new_item_allowing_bidirectional_references(b"updated".to_vec()); + batch_db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + updated.clone(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("the stale-entry cleanup must fold into the overwrite itself"); + live_db + .insert( + &[TEST_LEAF], + b"value", + updated, + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + roots_match(&batch_db, &live_db, grove_version); +} + +/// `Patch` executes as a general element write and can create a tree: the +/// fresh-subtree pre-scan must see it, or a child write under the patched +/// tree reads a parent absent from committed storage. +#[test] +fn batch_patch_created_subtree_is_fresh() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + db.apply_batch( + vec![ + QualifiedGroveDbOp::patch_op( + vec![TEST_LEAF.to_vec()], + b"sub".to_vec(), + Element::empty_tree(), + 0, + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec(), b"sub".to_vec()], + b"item".to_vec(), + Element::new_item(b"i".to_vec()), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("a Patch-created subtree is fresh like any other tree write"); + + assert_eq!( + db.get( + &[TEST_LEAF, b"sub".as_slice()], + b"item", + None, + grove_version + ) + .unwrap() + .unwrap(), + Element::new_item(b"i".to_vec()), + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Bidirectional-edge positions are bounded to +/// `MAX_BACKWARD_REFERENCES_GROVE_DEPTH` subtree levels — the depth the +/// estimation models charge per derived propagation. Deeper referrers are +/// rejected at registration, in the batch and the live flow alike. +#[test] +fn batch_registration_depth_is_bounded() { + use crate::bidirectional_references::MAX_BACKWARD_REFERENCES_GROVE_DEPTH; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // Nested plain trees down to one level beyond the bound (plain trees + // themselves have no depth rule). + let mut deep_path: Vec> = vec![TEST_LEAF.to_vec()]; + while deep_path.len() < MAX_BACKWARD_REFERENCES_GROVE_DEPTH + 1 { + let segment = format!("d{}", deep_path.len()).into_bytes(); + let path_refs: Vec<&[u8]> = deep_path.iter().map(|p| p.as_slice()).collect(); + db.insert( + path_refs.as_slice(), + &segment, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + deep_path.push(segment); + } + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let ref_to_value = || { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"value".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ) + }; + + // One level beyond the bound: rejected in the batch and live flows. + let too_deep = deep_path.clone(); + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + too_deep.clone(), + b"ref".to_vec(), + ref_to_value(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + let path_refs: Vec<&[u8]> = too_deep.iter().map(|p| p.as_slice()).collect(); + assert!(matches!( + db.insert( + path_refs.as_slice(), + b"ref", + ref_to_value(), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + + // At the bound: accepted. + let at_bound = &deep_path[..MAX_BACKWARD_REFERENCES_GROVE_DEPTH]; + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + at_bound.to_vec(), + b"ref".to_vec(), + ref_to_value(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("a referrer at the depth bound is valid"); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// A derived rewrite carries a precomputed node value hash. A flags +/// callback that mutates the element mid-apply (storage flags absorbing the +/// bytes a referrer entry costs, say) changes the stored bytes, so merk +/// recomputes the node value hash from the FINAL bytes with the family's +/// two-layer scheme — the batch succeeds and every node still verifies. +/// +/// The mutation lands on the REFERRER (a bidirectional reference, whose +/// recomputed hash must fold its end hash back in) and on an unreferenced +/// item; the referenced target carries no flags, so the callback leaves it +/// alone. A callback mutating a REFERENCED element would leave its +/// referrers committed to the pre-mutation bytes — exactly as plain +/// references behave under a mutating callback (the batch resolves pending +/// targets from the op's bytes, before any just-in-time rewrite) — which +/// `verify_grovedb` reports and the next flagged write of the target heals. +#[test] +fn batch_flags_mutation_on_derived_rewrite_rehashes_final_bytes() { + let grove_version = GroveVersion::latest(); + + let build = || { + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::ItemWithBackwardsReferences(b"hello".to_vec(), Default::default(), None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r1", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"value".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + Some(vec![1]), + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // An unreferenced flagged item: its rewrite goes through the + // user-op family arm (item scheme, no end hash). + db.insert( + &[TEST_LEAF], + b"lone", + Element::ItemWithBackwardsReferences( + b"one".to_vec(), + Default::default(), + Some(vec![2]), + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + let ops = || { + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Element::ItemWithBackwardsReferences(b"upd".to_vec(), Default::default(), None), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"lone".to_vec(), + Element::ItemWithBackwardsReferences( + b"two".to_vec(), + Default::default(), + Some(vec![2]), + ), + ), + ] + }; + + // A callback that leaves flags alone: the derived rewrite of r1 goes + // through untouched. + let db = build(); + db.apply_batch_with_element_flags_update( + ops(), + batch_flag_on(), + |_cost, _old_flags, _new_flags| Ok(false), + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + None, + grove_version, + ) + .unwrap() + .expect("an inert flags callback leaves derived rewrites intact"); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); + + // A callback that MUTATES flags: the mutated bytes are what gets + // stored, and their node value hash is recomputed to match — for the + // derived rewrite of the referrer (reference scheme, end hash folded + // back in) and for the user-written flagged item (item scheme). + let db = build(); + let mut mutated = 0usize; + db.apply_batch_with_element_flags_update( + ops(), + batch_flag_on(), + |_cost, _old_flags, new_flags| { + new_flags.push(7); + mutated += 1; + Ok(true) + }, + |_flags, _removed_key_bytes, _removed_value_bytes| { + Ok(( + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + grovedb_costs::storage_cost::removal::StorageRemovedBytes::NoStorageRemoval, + )) + }, + None, + grove_version, + ) + .unwrap() + .expect("a flags mutation on a provided-hash write is rehashed, not refused"); + // The just-in-time loop may consult the callback more than once per + // element (it re-runs after a size change on the ORIGINAL bytes), so + // count elements, not calls: r1 and lone carry flags; value does not. + assert!(mutated >= 2, "{mutated}"); + + let stored_r1 = db + .get_raw([TEST_LEAF].as_ref().into(), b"r1", None, grove_version) + .unwrap() + .expect("r1 stays present"); + assert_eq!(stored_r1.get_flags(), &Some(vec![1, 7])); + let stored_lone = db + .get_raw([TEST_LEAF].as_ref().into(), b"lone", None, grove_version) + .unwrap() + .expect("lone stays present"); + assert_eq!(stored_lone.get_flags(), &Some(vec![2, 7])); + assert_eq!(stored_lone.as_item_bytes().expect("item"), b"two"); + let resolved = db + .get(&[TEST_LEAF], b"r1", None, grove_version) + .unwrap() + .expect("r1 resolves"); + assert_eq!(resolved.as_item_bytes().expect("item"), b"upd"); + assert!( + db.verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty(), + "every rewritten node's bytes must agree with its committed hash" + ); +} + +/// Hand-built wrappers around backward-references elements (no constructor +/// produces them, deserialization rejects them) are refused at the batch +/// entry gate instead of being stored as bytes no reader accepts. +#[test] +fn batch_refuses_wrapped_backward_references_elements() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"ct", + Element::empty_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("count tree host"); + + let wrapped = Element::NonCounted(Box::new(Element::ItemWithBackwardsReferences( + b"x".to_vec(), + Default::default(), + None, + ))); + for flag in [batch_flag_on(), None] { + let err = db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec(), b"ct".to_vec()], + b"w".to_vec(), + wrapped.clone(), + )], + flag, + None, + grove_version, + ) + .unwrap() + .expect_err("a wrapped backward-references element is refused"); + assert!( + matches!(err, Error::InvalidBatchOperation(_)), + "wrapped family element must be an invalid batch operation, got {err:?}" + ); + } + assert!(db + .get_raw( + [TEST_LEAF, b"ct"].as_ref().into(), + b"w", + None, + grove_version + ) + .unwrap() + .is_err()); +} + +/// Deleting a NON-EMPTY subtree under the flag is refused: its +/// descendants may hold bidirectional-reference participants whose +/// bookkeeping the batch engine's wholesale clearing would skip. Empty +/// subtrees still delete. +#[test] +fn batch_flagged_non_empty_subtree_deletion_is_refused() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"sub", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sub"], + b"member", + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"sub".to_vec(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); + + // A subtree created AND populated within the same batch cannot be + // deleted by it either. + let db2 = make_test_grovedb(grove_version); + assert!(matches!( + db2.apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"sub".to_vec(), + Element::empty_tree(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec(), b"sub".to_vec()], + b"member".to_vec(), + Element::new_item(b"x".to_vec()), + ), + QualifiedGroveDbOp::delete_op(vec![TEST_LEAF.to_vec()], b"sub".to_vec()), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(_) + )); + + // An EMPTY subtree still deletes under the flag. + let db3 = make_test_grovedb(grove_version); + db3.insert( + &[TEST_LEAF], + b"empty_sub", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db3.apply_batch( + vec![QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"empty_sub".to_vec(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("an empty subtree deletes under the flag"); +} + +/// The prospective-component budget must validate against PENDING edges +/// in the same unordered batch, not just stored state: a batch that +/// retargets B onto a longer tail AND raises A's `max_hop` has a valid +/// final state and must be accepted in either op order — and a batch +/// that retargets A away from B frees B from A's budget entirely. +#[test] +fn batch_paired_upstream_updates_validate_against_pending_edges() { + let grove_version = GroveVersion::latest(); + let a_with = |max_hop: u8| { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"b".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(max_hop), + }, + None, + ) + }; + let build = || { + let db = make_test_grovedb(grove_version); + for (key, element) in [ + ( + b"c".as_slice(), + Element::new_item_allowing_bidirectional_references(b"c".to_vec()), + ), + ( + b"e", + Element::new_item_allowing_bidirectional_references(b"e".to_vec()), + ), + ( + b"f", + Element::new_item_allowing_bidirectional_references(b"f".to_vec()), + ), + (b"b", sibling_bidi(b"c", true)), + (b"d", sibling_bidi(b"e", true)), + ] { + db.insert(&[TEST_LEAF], key, element, None, None, grove_version) + .unwrap() + .unwrap(); + } + db.insert(&[TEST_LEAF], b"a", a_with(2), None, None, grove_version) + .unwrap() + .unwrap(); + db + }; + + // Retarget B onto d -> e (A now needs 3 hops) AND raise A to 3, in + // BOTH op orders: accepted, byte-identical to the live sequential + // twin (which must raise A first). + for flip in [false, true] { + let batch_db = build(); + let live_db = build(); + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(b"d", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"a".to_vec(), + a_with(3), + ), + ]; + if flip { + ops.reverse(); + } + batch_db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap() + .unwrap_or_else(|e| { + panic!( + "the paired update is valid against A's PENDING budget (flip: {flip}): {e:?}" + ) + }); + live_db + .insert(&[TEST_LEAF], b"a", a_with(3), None, None, grove_version) + .unwrap() + .unwrap(); + live_db + .insert( + &[TEST_LEAF], + b"b", + sibling_bidi(b"d", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + roots_match(&batch_db, &live_db, grove_version); + } + + // Retargeting A AWAY from B in the same batch frees B from A's + // budget: accepted in both op orders too. + for flip in [false, true] { + let batch_db = build(); + let a_away = Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"f".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(2), + }, + None, + ); + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(b"d", true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"a".to_vec(), + a_away.clone(), + ), + ]; + if flip { + ops.reverse(); + } + batch_db + .apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap() + .unwrap_or_else(|e| panic!("A detaches from B in the same batch; B's retarget is free (flip: {flip}): {e:?}")); + assert!(batch_db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); + } + + // Control: retargeting B alone (stored A still declares 2) stays + // rejected. + let db = build(); + assert!(matches!( + db.apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(b"d", true), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +/// A conditional insert whose gate will SKIP it must not advertise a +/// pending edge: retargeting B while an `insert_if_not_exists_or_skip_op` +/// "raises" the budget of the ALREADY-EXISTING upstream A is rejected in +/// both op orders — the conditional writes nothing, so A's stored budget +/// governs the component. +#[test] +fn batch_skipped_conditional_does_not_relax_upstream_budget() { + let grove_version = GroveVersion::latest(); + let a_with = |max_hop: u8| { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"b".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(max_hop), + }, + None, + ) + }; + let build = || { + let db = make_test_grovedb(grove_version); + for (key, element) in [ + ( + b"c".as_slice(), + Element::new_item_allowing_bidirectional_references(b"c".to_vec()), + ), + ( + b"e", + Element::new_item_allowing_bidirectional_references(b"e".to_vec()), + ), + (b"b", sibling_bidi(b"c", true)), + (b"d", sibling_bidi(b"e", true)), + ] { + db.insert(&[TEST_LEAF], key, element, None, None, grove_version) + .unwrap() + .unwrap(); + } + db.insert(&[TEST_LEAF], b"a", a_with(2), None, None, grove_version) + .unwrap() + .unwrap(); + db + }; + + for flip in [false, true] { + let db = build(); + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(b"d", true), + ), + QualifiedGroveDbOp::insert_if_not_exists_or_skip_op( + vec![TEST_LEAF.to_vec()], + b"a".to_vec(), + a_with(3), + ), + ]; + if flip { + ops.reverse(); + } + assert!( + matches!( + db.apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + ), + "a skipped conditional must not relax the stored budget (flip: {flip})" + ); + // The stored graph is untouched and still reads through A. + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); + } +} + +/// A detached ancestor must not consume component budget: with stored +/// `A -> B`, retargeting A away in the same batch lets B take a +/// downstream chain of exactly `MAX_REFERENCE_HOPS` — the boundary case +/// the walk previously rejected by counting the detached A as hop one. +#[test] +fn batch_detached_ancestor_frees_full_downstream_budget() { + use crate::operations::get::MAX_REFERENCE_HOPS; + + let grove_version = GroveVersion::latest(); + + for flip in [false, true] { + let db = make_test_grovedb(grove_version); + // The full-budget tail: t0 <- t1 <- … <- t9 (10 hops from B). + db.insert( + &[TEST_LEAF], + b"t0", + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + for i in 1..MAX_REFERENCE_HOPS { + db.insert( + &[TEST_LEAF], + format!("t{i}").as_bytes(), + sibling_bidi(format!("t{}", i - 1).as_bytes(), true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + // Stored A -> B (B currently targets a short chain) and a spare + // family item for A's retarget. + for (key, element) in [ + ( + b"x".as_slice(), + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + ), + ( + b"f", + Element::new_item_allowing_bidirectional_references(b"f".to_vec()), + ), + (b"b", sibling_bidi(b"x", true)), + (b"a", sibling_bidi(b"b", true)), + ] { + db.insert(&[TEST_LEAF], key, element, None, None, grove_version) + .unwrap() + .unwrap(); + } + + let mut ops = vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(format!("t{}", MAX_REFERENCE_HOPS - 1).as_bytes(), true), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"a".to_vec(), + sibling_bidi(b"f", true), + ), + ]; + if flip { + ops.reverse(); + } + db.apply_batch(ops, batch_flag_on(), None, grove_version) + .unwrap() + .unwrap_or_else(|e| { + panic!("the detached A must not count against B's component (flip: {flip}): {e:?}") + }); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); + } +} + +/// The declared referrer capacity is enforced by the batch preprocessor +/// exactly as by the live flow: a full target refuses a registration, an +/// update may not lower the capacity below the registered referrers, and a +/// raise admits more — in the same batch as the new referrer. +#[test] +fn batch_enforces_declared_capacity() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let referrer = || { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"target".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ) + }; + db.insert( + &[TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references_with_capacity(b"v".to_vec(), 1), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert(&[TEST_LEAF], b"r1", referrer(), None, None, grove_version) + .unwrap() + .unwrap(); + + let err = db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r2".to_vec(), + referrer(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect_err("the target is full"); + assert!( + matches!(err, Error::BidirectionalReferenceRule(_)), + "{err:?}" + ); + + let err = db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"target".to_vec(), + Element::new_item_allowing_bidirectional_references_with_capacity(b"w".to_vec(), 0), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect_err("one referrer does not fit a capacity of zero"); + assert!( + matches!(err, Error::BidirectionalReferenceRule(_)), + "{err:?}" + ); + + db.apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"target".to_vec(), + Element::new_item_allowing_bidirectional_references_with_capacity(b"w".to_vec(), 2), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r2".to_vec(), + referrer(), + ), + ], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("the raise and the second referrer land together"); + for key in [b"r1".as_ref(), b"r2"] { + assert_eq!( + db.get(&[TEST_LEAF], key, None, grove_version) + .unwrap() + .unwrap() + .as_item_bytes() + .unwrap(), + b"w" + ); + } + // Public reads strip the referrer list but keep the declaration; the + // two registrations show through the capacity now being exhausted. + let stored = db + .get_raw([TEST_LEAF].as_ref().into(), b"target", None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(stored.max_incoming_references(), Some(2)); + assert!(stored.backward_references().unwrap().is_empty()); + assert!(db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"r3".to_vec(), + referrer(), + )], + batch_flag_on(), + None, + grove_version, + ) + .unwrap() + .is_err()); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} diff --git a/grovedb/src/tests/bidirectional_references_tests.rs b/grovedb/src/tests/bidirectional_references_tests.rs new file mode 100644 index 000000000..df36c23b7 --- /dev/null +++ b/grovedb/src/tests/bidirectional_references_tests.rs @@ -0,0 +1,4294 @@ +//! Integration tests for the backward-references flows: the `GROVE_V4` +//! insert/delete routers, the rule checks in +//! `bidirectional_references::handling`, and the query paths over the new +//! element family. + +use grovedb_path::SubtreePath; +use grovedb_version::version::GroveVersion; + +use crate::{ + bidirectional_references::BidirectionalReference, + operations::{delete::DeleteOptions, get::QueryItemOrSumReturnType, insert::InsertOptions}, + query_result_type::{QueryResultElement, QueryResultType}, + reference_path::ReferencePathType, + tests::{make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, Error, GroveDb, PathQuery, Query, +}; + +fn flag_on() -> Option { + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }) +} + +fn sibling_bidi(key: &[u8], cascade: bool) -> Element { + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(key.to_vec()), + backward_references: Vec::new(), + cascade_on_update: cascade, + max_hop: None, + }, + None, + ) +} + +/// A test_leaf with one item-with-backwards-references under `value`. +fn db_with_bwr_item() -> TempGroveDb { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db +} + +#[test] +fn bidi_reference_must_target_backward_references_element() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"plain", + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"plain", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn bidi_reference_chain_allows_only_one_backward_reference() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + db.insert( + &[TEST_LEAF], + b"refc", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"refb", + sibling_bidi(b"refc", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // A second bidirectional reference onto `refc` (which is itself a + // bidirectional reference) exceeds the 1-backward-reference budget for + // reference chain members. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"refx", + sibling_bidi(b"refc", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn item_supports_up_to_32_backward_references() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + for i in 0..32u8 { + db.insert( + &[TEST_LEAF], + format!("ref{i:02}").as_bytes(), + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap_or_else(|e| panic!("reference {i} should fit: {e}")); + } + + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"ref32", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn bidi_reference_cannot_overwrite_item_with_backward_references() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"value2", + Element::new_item_allowing_bidirectional_references(b"other".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Overwriting `value` (an item that may carry up to 32 backward + // references) with a bidirectional reference (which carries at most 1) + // is refused. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"value", + sibling_bidi(b"value2", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn overwriting_bidi_reference_retargets_backward_bookkeeping() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + db.insert( + &[TEST_LEAF], + b"value2", + Element::new_item_allowing_bidirectional_references(b"second".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"refc", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // Retarget refc from `value` to `value2`. + db.insert( + &[TEST_LEAF], + b"refc", + sibling_bidi(b"value2", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let ref_hash = |tx| { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + Element::get_value_hash( + &db.open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + tx, + None, + grove_version, + ) + .unwrap() + .unwrap(), + b"refc", + true, + grove_version, + ) + .unwrap() + .unwrap() + .unwrap() + }; + + // Updating the OLD target no longer touches refc... + let before = ref_hash(&tx); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert_eq!(ref_hash(&tx), before); + + // ...while updating the NEW target propagates into refc. + db.insert( + &[TEST_LEAF], + b"value2", + Element::new_item_allowing_bidirectional_references(b"changed".to_vec()), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert_ne!(ref_hash(&tx), before); + + // The whole graph still verifies. + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn overwriting_bidi_reference_with_backward_references_item_keeps_consistency() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"refc", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Overwrite the reference itself with an item that supports backward + // references: the old backward slot on `value` is released and hashes + // stay consistent. + db.insert( + &[TEST_LEAF], + b"refc", + Element::new_item_allowing_bidirectional_references(b"now an item".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert_eq!( + db.get(&[TEST_LEAF], b"refc", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"now an item".to_vec()) + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn cascade_requires_opt_in() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"stubborn", + sibling_bidi(b"value", false), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Overwriting the target with a non-backward-references element would + // cascade-delete `stubborn`, which did not opt in — the operation is + // refused and nothing is committed. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item(b"plain now".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + assert_eq!( + db.get(&[TEST_LEAF], b"value", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()) + ); + + // Deleting the target is refused for the same reason. + assert!(matches!( + db.delete( + &[TEST_LEAF], + b"value", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +#[test] +fn plain_references_work_under_the_flag() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + // A plain reference and a sum-carrying reference both insert through + // the backward-references flow when the flag is set. + db.insert( + &[TEST_LEAF], + b"plain_ref", + Element::new_reference(ReferencePathType::SiblingReference(b"value".to_vec())), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"sum_ref", + Element::new_reference_with_sum_item( + ReferencePathType::SiblingReference(b"value".to_vec()), + 7, + ), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert_eq!( + db.get(&[TEST_LEAF], b"plain_ref", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()) + ); + + // References may not point at subtrees under the flag either. + db.insert( + &[TEST_LEAF], + b"subtree", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"tree_ref", + Element::new_reference(ReferencePathType::SiblingReference(b"subtree".to_vec())), + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +#[test] +fn empty_trees_insert_under_the_flag() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + db.insert( + &[TEST_LEAF], + b"tree", + Element::empty_tree(), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"sum_tree", + Element::empty_sum_tree(), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // A tree literal claiming a root key is rejected outside batches. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"claimed", + Element::Tree(Some(b"root".to_vec()), None), + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::InvalidCodeExecution(_)) + )); + + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn specialized_types_and_wrappers_rejected_under_the_flag() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + for element in [ + Element::MmrTree(0, None), + Element::new_non_counted(Element::new_item(b"x".to_vec())).unwrap(), + ] { + assert!( + matches!( + db.insert( + &[TEST_LEAF], + b"k", + element.clone(), + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + ), + "expected NotSupported under the flag for {element:?}" + ); + } +} + +#[test] +fn override_checks_apply_under_the_flag() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"nope".to_vec()), + Some(InsertOptions { + propagate_backward_references: true, + validate_insertion_does_not_override: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::OverrideNotAllowed(_)) + )); + + db.insert( + &[TEST_LEAF], + b"subtree", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // Default options refuse overriding a tree; the flag routes through the + // backward-references flow which applies the same check. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"subtree", + Element::new_item_allowing_bidirectional_references(b"nope".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::OverrideNotAllowed(_)) + )); +} + +#[test] +fn delete_with_flag_handles_trees() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // Empty tree: plain removal. + db.insert( + &[TEST_LEAF], + b"empty", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.delete( + &[TEST_LEAF], + b"empty", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Non-empty tree without permission: refused (or silently skipped when + // the options say not to error). + db.insert( + &[TEST_LEAF], + b"full", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"full"], + b"k", + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert!(matches!( + db.delete( + &[TEST_LEAF], + b"full", + Some(DeleteOptions { + propagate_backward_references: true, + allow_deleting_non_empty_trees: false, + deleting_non_empty_trees_returns_error: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::DeletingNonEmptyTree(_)) + )); + db.delete( + &[TEST_LEAF], + b"full", + Some(DeleteOptions { + propagate_backward_references: true, + allow_deleting_non_empty_trees: false, + deleting_non_empty_trees_returns_error: false, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .expect("refusal without error flag is not an error"); + assert!(db + .get(&[TEST_LEAF], b"full", None, grove_version) + .unwrap() + .is_ok()); + + // Specialized data trees are rejected under the flag. + db.insert( + &[TEST_LEAF], + b"mmr", + Element::MmrTree(0, None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert!(matches!( + db.delete( + &[TEST_LEAF], + b"mmr", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +#[test] +fn queries_resolve_backward_references_elements() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"value".to_vec()); + query.insert_key(b"ref".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + // Raw item values: the item directly and through the reference. + let (values, _) = db + .query_item_value(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(values, vec![b"hello".to_vec(), b"hello".to_vec()]); + + // Item-or-sum view. + let (values, _) = db + .query_item_value_or_sum(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert!(matches!( + values.as_slice(), + [ + QueryItemOrSumReturnType::ItemData(a), + QueryItemOrSumReturnType::ItemData(b) + ] if a == b"hello" && b == b"hello" + )); + + // Element view: the reference resolves to the underlying item element. + let (elements, _) = db + .query( + &path_query, + true, + true, + true, + QueryResultType::QueryElementResultType, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let elements: Vec<_> = elements + .into_iterator() + .map(|r| match r { + QueryResultElement::ElementResultItem(e) => e, + other => panic!("unexpected result shape: {other:?}"), + }) + .collect(); + assert_eq!( + elements, + vec![ + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ] + ); +} + +#[test] +fn sum_queries_resolve_backward_references_sum_items() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"sums", + Element::empty_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"s", + Element::new_sum_item_allowing_bidirectional_references(5), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"rs", + sibling_bidi(b"s", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"s".to_vec()); + query.insert_key(b"rs".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"sums".to_vec()], query); + + let (sums, _) = db + .query_sums(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(sums, vec![5, 5]); + + let (values, _) = db + .query_item_value_or_sum(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert!(matches!( + values.as_slice(), + [ + QueryItemOrSumReturnType::SumValue(a), + QueryItemOrSumReturnType::SumValue(b) + ] if *a == 5 && *b == 5 + )); + + // The aggregate-sum surface honors per-edge budgets like every other + // read: a one-hop edge with a direct sum-item terminal resolves… + db.insert( + &[TEST_LEAF, b"sums"], + b"one_hop", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"s2".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .expect_err("dangling target rejected — insert s2 first"); + db.insert( + &[TEST_LEAF, b"sums"], + b"s2", + Element::new_sum_item_allowing_bidirectional_references(7), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"one_hop", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"s2".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let mut query = Query::new(); + query.insert_key(b"one_hop".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"sums".to_vec()], query); + let (sums, _) = db + .query_sums(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(sums, vec![7], "a direct terminal is within a 1-hop budget"); + + // …while a one-hop edge whose chain needs two hops is rejected at the + // WRITE now (dead edges never persist)… + let capped_at_chain = Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"rs".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ); + assert!(matches!( + db.insert( + &[TEST_LEAF, b"sums"], + b"capped", + capped_at_chain, + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + + // …and an edge that falls out of budget AFTER insertion (its target + // evolved into a reference through an unflagged overwrite) hits the + // budget on the read. + db.insert( + &[TEST_LEAF, b"sums"], + b"s3", + Element::new_sum_item_allowing_bidirectional_references(9), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"capped", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"s3".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"s3", + Element::new_reference_with_sum_item(ReferencePathType::SiblingReference(b"s".to_vec()), 0), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let mut query = Query::new(); + query.insert_key(b"capped".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"sums".to_vec()], query); + assert!(matches!( + db.query_sums(&path_query, true, true, true, None, grove_version) + .unwrap(), + Err(Error::ReferenceLimit) + )); +} + +#[test] +fn retargeting_onto_a_chained_reference_propagates_the_end_hash() { + // Regression for the overwrite branch of + // `process_bidirectional_reference_insertion`: when the NEW target is + // itself a bidirectional reference (a chain), the hash pushed to the + // overwritten reference's own backward references must be the resolved + // END-of-chain value hash, not the intermediate node's combined hash. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let tx = db.start_transaction(); + + for (key, value) in [(b"v1".as_ref(), b"one".as_ref()), (b"v2", b"two")] { + db.insert( + &[TEST_LEAF], + key, + Element::new_item_allowing_bidirectional_references(value.to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + } + // An intermediate reference onto v2, a target reference onto v1, and an + // origin chained onto the target reference. + db.insert( + &[TEST_LEAF], + b"mid", + sibling_bidi(b"v2", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"target_ref", + sibling_bidi(b"v1", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"origin", + sibling_bidi(b"target_ref", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // Retarget `target_ref` onto `mid` — a CHAINED target (mid -> v2). + // `origin`'s stored hash must be refreshed with v2's value hash. + db.insert( + &[TEST_LEAF], + b"target_ref", + sibling_bidi(b"mid", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + assert_eq!( + db.get(&[TEST_LEAF], b"origin", Some(&tx), grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"two".to_vec()) + ); + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn retargeting_into_a_cycle_is_rejected() { + // Regression for a reproduced hang: with `A -> terminal` and `C -> A`, + // retargeting `A -> C` used to validate against the pre-write graph + // (following C resolved through the OLD A) and then loop forever in + // backward propagation. The chain is now followed from the position + // being written, so the prospective cycle is rejected before mutation. + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + db.insert( + &[TEST_LEAF], + b"a", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"c", + sibling_bidi(b"a", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"a", + sibling_bidi(b"c", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::CyclicReference) + )); + + // Nothing was mutated: `a` still resolves through its original target + // and the graph verifies. + assert_eq!( + db.get(&[TEST_LEAF], b"a", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()) + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn reinserting_an_identical_edge_is_a_no_op() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // A second reference chained onto `ref` occupies its single backward + // slot — an identical reinsertion of `chained` must still succeed. + db.insert( + &[TEST_LEAF], + b"chained", + sibling_bidi(b"ref", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let root_before = db.root_hash(None, grove_version).unwrap().unwrap(); + + for key in [b"ref".as_ref(), b"chained"] { + db.insert( + &[TEST_LEAF], + key, + sibling_bidi(if key == b"ref" { b"value" } else { b"ref" }, true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap_or_else(|e| panic!("identical reinsertion of {key:?} must be a no-op: {e}")); + } + + assert_eq!( + db.root_hash(None, grove_version).unwrap().unwrap(), + root_before, + "identical reinsertions must not move the root hash" + ); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn propagation_skips_and_cleans_origins_removed_without_bookkeeping() { + // An origin removed through a path that performs no backward-references + // bookkeeping (here: a batch delete, which is rejected only for ops + // CARRYING the element family, not for ops touching participants) + // leaves a dangling slot on its target. Later flagged updates must not + // fail on it: the slot is skipped and lazily cleaned. + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + db.insert( + &[TEST_LEAF], + b"origin", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Batch-delete the origin: no backward-references bookkeeping runs. + db.apply_batch( + vec![crate::batch::QualifiedGroveDbOp::delete_op( + vec![TEST_LEAF.to_vec()], + b"origin".to_vec(), + )], + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // A flagged update of the target now encounters the dangling slot — + // it must succeed, and afterwards the slot is free again. + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("dangling backward reference must be skipped, not fatal"); + + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +#[test] +fn delete_with_flag_rejects_rows_of_indexed_primaries() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"pcit", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // The guard fires on the CONTAINING Merk's type, before any key lookup. + assert!(matches!( + db.delete( + &[TEST_LEAF, b"pcit"], + b"row", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +/// The design invariant of on-element referrer lists: registering a new +/// referrer rewrites only the TARGET node (its referrer list, hence its +/// combined value hash) — referrers that already point at it keep their +/// stored node hashes bit-for-bit, because they commit to the target's +/// LOGICAL (stripped) hash, which the registration does not touch. +#[test] +fn registering_a_referrer_leaves_existing_referrer_nodes_untouched() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + + let node_hash = |tx, key: &[u8]| { + Element::get_value_hash( + &db.open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + tx, + None, + grove_version, + ) + .unwrap() + .unwrap(), + key, + true, + grove_version, + ) + .unwrap() + .unwrap() + .unwrap() + }; + + db.insert( + &[TEST_LEAF], + b"ra", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let ra_before = node_hash(&tx, b"ra"); + let target_before = node_hash(&tx, b"value"); + + db.insert( + &[TEST_LEAF], + b"rb", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // The target node re-hashed (its referrer list grew)... + assert_ne!(node_hash(&tx, b"value"), target_before); + // ...while the first referrer's stored node hash is untouched. + assert_eq!(node_hash(&tx, b"ra"), ra_before); + + // The referrer list lives on the element at the merk level (both + // registrations present)... + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let full = Element::get(&merk, b"value", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!(full.backward_references().unwrap().len(), 2); + drop(merk); + + // ...but is stripped from public reads. + let public = db + .get(&[TEST_LEAF], b"value", Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert_eq!(public.backward_references().unwrap().len(), 0); + + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Proofs over backward-references items ship the dedicated +/// `KVBackwardsReferencesValueHash` node: the payload is the STRIPPED +/// element and the referrer list rides along only as its 32-byte hash. +/// The verifier recombines the two, so tampering with either the payload +/// or the referrer-list hash breaks the root-hash chain. +#[test] +fn proofs_carry_stripped_payload_and_bind_the_referrer_hash() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + // A registered referrer, so the node's referrer-list hash is non-trivial. + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"value".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + + // Honest proof: verifies against the live root hash and yields the + // stripped element (no referrer list crosses the proof boundary). + let (hash, result_set) = GroveDb::verify_query(&proof, &path_query, grove_version).unwrap(); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + assert_eq!( + result_set, + vec![( + vec![TEST_LEAF.to_vec()], + b"value".to_vec(), + Some(Element::new_item_allowing_bidirectional_references( + b"hello".to_vec() + )) + )] + ); + + // Tampering with the referrer-list hash breaks verification. + let tampered = tamper_backward_references_node(&proof, &path_query, |value, backrefs_hash| { + backrefs_hash[0] ^= 1; + let _ = value; + }) + .expect("proof must contain a KVBackwardsReferencesValueHash node"); + assert!( + GroveDb::verify_query(&tampered, &path_query, grove_version).is_err(), + "flipped referrer-list hash must be rejected" + ); + + // So does tampering with the stripped payload bytes. + let tampered = tamper_backward_references_node(&proof, &path_query, |value, _| { + let last = value.len() - 1; + value[last] ^= 1; + }) + .expect("proof must contain a KVBackwardsReferencesValueHash node"); + assert!( + GroveDb::verify_query(&tampered, &path_query, grove_version).is_err(), + "flipped payload byte must be rejected" + ); +} + +/// Decode the GroveDB proof envelope, walk to the leaf merk proof, apply +/// `mutate` to the first `KVBackwardsReferencesValueHash` node's +/// (stripped-value, referrer-list-hash) pair, and re-encode. `None` if the +/// leaf proof holds no such node. +fn tamper_backward_references_node( + proof: &[u8], + path_query: &PathQuery, + mutate: impl Fn(&mut Vec, &mut [u8; 32]), +) -> Option> { + use bincode::config; + use grovedb_merk::proofs::{encoding::encode_into, Decoder, Node, Op}; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof, ProofBytes}; + + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = bincode::decode_from_slice(proof, cfg).ok()?; + + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + return None; + }; + let mut layer: &mut LayerProof = root_layer; + for key in &path_query.path { + layer = layer.lower_layers.get_mut(key)?; + } + let ProofBytes::Merk(leaf_bytes) = &mut layer.merk_proof else { + return None; + }; + + let mut ops: Vec = Vec::new(); + for op in Decoder::new(leaf_bytes) { + ops.push(op.ok()?); + } + + let mut tampered = false; + for op in ops.iter_mut() { + match op { + Op::Push(Node::KVBackwardsReferencesValueHash(_, value, backrefs_hash)) + | Op::PushInverted(Node::KVBackwardsReferencesValueHash(_, value, backrefs_hash)) => { + mutate(value, backrefs_hash); + tampered = true; + break; + } + _ => {} + } + } + if !tampered { + return None; + } + + let mut new_leaf = Vec::new(); + encode_into(ops.iter(), &mut new_leaf); + *leaf_bytes = new_leaf; + + bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .ok() +} + +/// Proving a bidirectional reference through the main V1 subquery loop: +/// the emitted `KVRefValueHash` node must carry the reference's +/// combined-hash override and the STRIPPED dereferenced target, in both +/// query directions. +#[test] +fn proofs_dereference_bidirectional_references_in_both_directions() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + for left_to_right in [true, false] { + let mut query = Query::new_with_direction(left_to_right); + query.insert_key(b"value".to_vec()); + query.insert_key(b"ref".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + let (hash, result_set) = GroveDb::verify_query(&proof, &path_query, grove_version).unwrap(); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + let expected = Some(Element::new_item_allowing_bidirectional_references( + b"hello".to_vec(), + )); + assert_eq!(result_set.len(), 2); + for (_, _, element) in result_set { + assert_eq!(element, expected, "left_to_right: {left_to_right}"); + } + } +} + +/// Bidirectional references living inside aggregate parents resolve +/// through the aggregate-carrying `KVRefValueHash{Sum,Count}` proof nodes +/// with the combined-hash override as the carried self-hash. +#[test] +fn proofs_dereference_bidirectional_references_in_aggregate_parents() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // Plain aggregate parents hold the backward-references TARGET locally; + // Provable* parents reject the item family, so their bidirectional + // references point at a target outside the tree. + db.insert( + &[TEST_LEAF], + b"ext", + Element::new_item_allowing_bidirectional_references(b"external".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let external = + ReferencePathType::AbsolutePathReference(vec![TEST_LEAF.to_vec(), b"ext".to_vec()]); + for (tree_key, tree, target, forward) in [ + ( + b"sums".as_slice(), + Element::new_sum_tree(None), + Some(Element::new_sum_item_allowing_bidirectional_references(41)), + None, + ), + ( + b"counts".as_slice(), + Element::new_count_tree(None), + Some(Element::new_item_allowing_bidirectional_references( + b"counted".to_vec(), + )), + None, + ), + ( + b"psums", + Element::new_provable_sum_tree(None), + None, + Some(external.clone()), + ), + ( + b"pcounts", + Element::new_provable_count_tree(None), + None, + Some(external.clone()), + ), + ( + b"pcps", + Element::new_provable_count_provable_sum_tree(None), + None, + Some(external.clone()), + ), + ] { + db.insert(&[TEST_LEAF], tree_key, tree, None, None, grove_version) + .unwrap() + .unwrap(); + let (reference, expected) = match (target, forward) { + (Some(target), None) => { + db.insert( + &[TEST_LEAF, tree_key], + b"target", + target.clone(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + (sibling_bidi(b"target", true), target) + } + (None, Some(forward)) => { + let Element::BidirectionalReference(mut reference, _) = sibling_bidi(b"x", true) + else { + unreachable!() + }; + reference.forward_reference_path = forward; + ( + Element::BidirectionalReference(reference, None), + Element::new_item_allowing_bidirectional_references(b"external".to_vec()), + ) + } + _ => unreachable!(), + }; + db.insert( + &[TEST_LEAF, tree_key], + b"zref", + reference, + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"zref".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), tree_key.to_vec()], query); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + let (hash, result_set) = GroveDb::verify_query(&proof, &path_query, grove_version).unwrap(); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + assert_eq!( + result_set, + vec![( + vec![TEST_LEAF.to_vec(), tree_key.to_vec()], + b"zref".to_vec(), + Some(expected) + )], + "tree: {}", + String::from_utf8_lossy(tree_key) + ); + } +} + +/// Pre-V4 versions can neither store nor prove the family: inserts under +/// `GROVE_V1` are refused, and the V0 prover refuses to serve a tree that +/// contains it (the V0 wire format is frozen). +#[test] +fn pre_v4_versions_reject_the_family_end_to_end() { + use grovedb_version::version::v1::GROVE_V1; + + let latest = GroveVersion::latest(); + let db = db_with_bwr_item(); + + let v1 = &GROVE_V1; + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"old", + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + None, + None, + v1, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); + + let mut query = Query::new(); + query.insert_key(b"value".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + assert!(matches!( + db.prove_query(&path_query, None, v1).unwrap(), + Err(Error::NotSupported(_)) + )); + + // Sanity: the same db serves the same query under the latest version. + db.prove_query(&path_query, None, latest).unwrap().unwrap(); +} + +/// A batch-inserted PLAIN reference may resolve through a pre-existing +/// bidirectional reference: the chain hash it commits to is the end +/// target's logical hash. +#[test] +fn batch_references_resolve_through_pre_existing_bidirectional_references() { + use crate::batch::QualifiedGroveDbOp; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"batchref".to_vec(), + Element::new_reference(ReferencePathType::SiblingReference(b"ref".to_vec())), + )]; + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("a plain reference chaining through a bidi reference is fine"); + + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// The flagged insert path applies the same overwrite guards as the plain +/// path: overwriting a tree is refused when the option forbids it, and a +/// fresh tree insert must arrive empty. +#[test] +fn flagged_inserts_enforce_tree_shape_guards() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"tree", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let opts = Some(InsertOptions { + validate_insertion_does_not_override: false, + validate_insertion_does_not_override_tree: true, + propagate_backward_references: true, + ..Default::default() + }); + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"tree", + Element::new_item(b"clobber".to_vec()), + opts, + None, + grove_version, + ) + .unwrap(), + Err(Error::OverrideNotAllowed(_)) + )); + + // A non-empty tree element cannot be written through the non-batch path. + assert!(db + .insert( + &[TEST_LEAF], + b"tree2", + Element::Tree(Some(b"phantom".to_vec()), None), + flag_on(), + None, + grove_version, + ) + .unwrap() + .is_err()); +} + +/// Reads that follow a reference whose target was removed by an unflagged +/// write surface the dedicated corrupted-reference error. +#[test] +fn dangling_bidirectional_reference_reads_report_corruption() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // Unflagged delete skips all bookkeeping — allowed, consistency + // forfeited. + db.delete(&[TEST_LEAF], b"value", None, None, grove_version) + .unwrap() + .unwrap(); + + assert!(matches!( + db.get(&[TEST_LEAF], b"ref", None, grove_version).unwrap(), + Err(Error::CorruptedReferencePathKeyNotFound(_)) + )); + + // Same through a transaction. + let tx = db.start_transaction(); + assert!(matches!( + db.get(&[TEST_LEAF], b"ref", Some(&tx), grove_version) + .unwrap(), + Err(Error::CorruptedReferencePathKeyNotFound(_)) + )); +} + +/// Lazy cleanup of dangling referrer entries ON A REFERENCE node: in the +/// chain a -> b -> value, removing `a` without bookkeeping leaves `b` +/// with a dead referrer entry; the next flagged propagation through `b` +/// clears it (and rewrites `b` against the end hash it commits to). +#[test] +fn propagation_cleans_dangling_referrers_on_chained_references() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + db.insert( + &[TEST_LEAF], + b"b", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"a", + sibling_bidi(b"b", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // Remove the chain head without bookkeeping. + db.delete(&[TEST_LEAF], b"a", None, Some(&tx), grove_version) + .unwrap() + .unwrap(); + + // Flagged update of the end target propagates through `b`, which finds + // its referrer `a` dangling and lazily drops the entry. + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let b_full = Element::get(&merk, b"b", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + b_full.backward_references().unwrap().len(), + 0, + "the dangling referrer entry must be lazily dropped" + ); + drop(merk); + + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Retargeting tolerates old targets that were rewritten without +/// bookkeeping: the removal step finds either an element that no longer +/// supports backward references or one whose referrer entry is gone, and +/// treats both as already-clean. +#[test] +fn retargeting_tolerates_targets_rewritten_without_bookkeeping() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + for (key, value) in [ + (b"t1".as_slice(), b"one".as_slice()), + (b"t2", b"two"), + (b"t3", b"three"), + (b"t4", b"four"), + ] { + db.insert( + &[TEST_LEAF], + key, + Element::new_item_allowing_bidirectional_references(value.to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + db.insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"t1", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r2", + sibling_bidi(b"t3", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // t1 overwritten by a PLAIN item (no backward-references support) via + // an unflagged write; retargeting r1 finds nothing to clean. + db.insert( + &[TEST_LEAF], + b"t1", + Element::new_item(b"plain".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"t2", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // t3 overwritten by a FRESH backward-references item (empty referrer + // list) via an unflagged write; retargeting r2 finds its entry gone. + db.insert( + &[TEST_LEAF], + b"t3", + Element::new_item_allowing_bidirectional_references(b"fresh".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"r2", + sibling_bidi(b"t4", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Every Provable* aggregate parent refuses backward-references items, and +/// a bidirectional reference cannot be written through the plain +/// `Element::insert` (it must carry its resolved end hash). +#[test] +fn provable_parents_and_direct_inserts_reject_the_family() { + use grovedb_merk::element::insert::ElementInsertToStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + for (key, tree, item) in [ + ( + b"pc".as_slice(), + Element::new_provable_count_tree(None), + Element::new_item_allowing_bidirectional_references(b"x".to_vec()), + ), + ( + b"ps", + Element::new_provable_sum_tree(None), + Element::new_sum_item_allowing_bidirectional_references(1), + ), + ( + b"pcs", + Element::new_provable_count_sum_tree(None), + Element::new_sum_item_allowing_bidirectional_references(1), + ), + ( + b"pcps", + Element::new_provable_count_provable_sum_tree(None), + Element::new_sum_item_allowing_bidirectional_references(1), + ), + ] { + db.insert(&[TEST_LEAF], key, tree, None, None, grove_version) + .unwrap() + .unwrap(); + assert!( + db.insert(&[TEST_LEAF, key], b"k", item, None, None, grove_version) + .unwrap() + .is_err(), + "Provable* parent {} must reject the family", + String::from_utf8_lossy(key) + ); + } + + let tx = db.start_transaction(); + let mut merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + assert!( + sibling_bidi(b"whatever", true) + .insert(&mut merk, b"direct", None, grove_version) + .unwrap() + .is_err(), + "plain Element::insert must refuse bidirectional references" + ); +} + +/// The verifier refuses backward-references elements smuggled inside the +/// PLAIN value-carrying node kinds: their value hash is `H(value)` there, +/// which would let forged bytes ride unbound on the carried hash. It also +/// refuses the dedicated node kind inside a frozen V0 envelope. +#[test] +fn verifier_rejects_family_smuggled_into_plain_nodes_and_v0_envelopes() { + use bincode::config; + use grovedb_merk::{proofs::Node, TreeFeatureType}; + + use crate::operations::proof::{ + GroveDBProof, GroveDBProofV0, GroveDBProofV1, LayerProof, MerkOnlyLayerProof, ProofBytes, + ProveOptions, + }; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"value".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + + // Rebuild the honest node as a plain KVValueHash: rejected by node-type + // triage regardless of the carried hash. + let forged = swap_backward_references_node(&proof, &path_query, |key, value, backrefs| { + Node::KVValueHash(key, value, backrefs) + }) + .expect("proof must contain a KVBackwardsReferencesValueHash node"); + let err = GroveDb::verify_query(&forged, &path_query, grove_version) + .expect_err("KVValueHash smuggling must fail"); + assert!( + err.to_string() + .contains("KVValueHash node must not contain"), + "got: {err}" + ); + + // Same through KVValueHashFeatureType. + let forged = swap_backward_references_node(&proof, &path_query, |key, value, backrefs| { + Node::KVValueHashFeatureType(key, value, backrefs, TreeFeatureType::BasicMerkNode) + }) + .expect("proof must contain a KVBackwardsReferencesValueHash node"); + let err = GroveDb::verify_query(&forged, &path_query, grove_version) + .expect_err("KVValueHashFeatureType smuggling must fail"); + assert!( + err.to_string() + .contains("KVValueHashFeatureType node must not contain"), + "got: {err}" + ); + + // Downgrade the honest V1 envelope to V0 wholesale: the V0 verifier + // must refuse the node kind (frozen wire format). + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (decoded, _): (GroveDBProof, _) = bincode::decode_from_slice(&proof, cfg).unwrap(); + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = decoded else { + panic!("expected a V1 envelope"); + }; + fn downgrade(layer: LayerProof) -> Option { + let ProofBytes::Merk(merk_proof) = layer.merk_proof else { + return None; + }; + let mut lower_layers = std::collections::BTreeMap::new(); + for (key, lower) in layer.lower_layers { + lower_layers.insert(key, downgrade(lower)?); + } + Some(MerkOnlyLayerProof { + merk_proof, + lower_layers, + }) + } + let v0 = GroveDBProof::V0(GroveDBProofV0 { + root_layer: downgrade(root_layer).expect("plain merk layers"), + prove_options: ProveOptions::default(), + }); + let v0_bytes = + bincode::encode_to_vec(v0, config::standard().with_big_endian().with_no_limit()).unwrap(); + let err = GroveDb::verify_query(&v0_bytes, &path_query, grove_version) + .expect_err("V0 envelopes must not carry the node kind"); + assert!( + err.to_string().contains("not allowed in V0 proofs"), + "got: {err}" + ); +} + +/// Replace the first `KVBackwardsReferencesValueHash` node in the leaf +/// merk proof with whatever `build` returns from its parts, re-encoding +/// the envelope. `None` if no such node exists. +fn swap_backward_references_node( + proof: &[u8], + path_query: &PathQuery, + build: impl Fn(Vec, Vec, [u8; 32]) -> grovedb_merk::proofs::Node, +) -> Option> { + use bincode::config; + use grovedb_merk::proofs::{encoding::encode_into, Decoder, Node, Op}; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof, ProofBytes}; + + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = bincode::decode_from_slice(proof, cfg).ok()?; + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + return None; + }; + let mut layer: &mut LayerProof = root_layer; + for key in &path_query.path { + layer = layer.lower_layers.get_mut(key)?; + } + let ProofBytes::Merk(leaf_bytes) = &mut layer.merk_proof else { + return None; + }; + + let mut ops: Vec = Vec::new(); + for op in Decoder::new(leaf_bytes) { + ops.push(op.ok()?); + } + let mut swapped = false; + for op in ops.iter_mut() { + let rebuilt = match op { + Op::Push(Node::KVBackwardsReferencesValueHash(key, value, hash)) => { + Op::Push(build(key.clone(), value.clone(), *hash)) + } + Op::PushInverted(Node::KVBackwardsReferencesValueHash(key, value, hash)) => { + Op::PushInverted(build(key.clone(), value.clone(), *hash)) + } + _ => continue, + }; + *op = rebuilt; + swapped = true; + break; + } + if !swapped { + return None; + } + + let mut new_leaf = Vec::new(); + encode_into(ops.iter(), &mut new_leaf); + *leaf_bytes = new_leaf; + bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .ok() +} + +/// Plain writes routed through the pre-V4 dispatch arms still work on a +/// database that also holds V4 content elsewhere: the flag-less v0 bodies +/// are selected for `GROVE_V3` and overwrite through the storage-read +/// funnel. +#[test] +fn plain_writes_still_route_through_pre_v4_dispatch() { + use grovedb_version::version::v3::GROVE_V3; + + let db = make_test_grovedb(GroveVersion::latest()); + let v3 = &GROVE_V3; + db.insert( + &[TEST_LEAF], + b"plain", + Element::new_item(b"one".to_vec()), + None, + None, + v3, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"plain", + Element::new_item(b"two".to_vec()), + None, + None, + v3, + ) + .unwrap() + .unwrap(); + assert_eq!( + db.get(&[TEST_LEAF], b"plain", None, v3).unwrap().unwrap(), + Element::new_item(b"two".to_vec()) + ); +} + +/// `verify_grovedb` recomputes the combined (inner ‖ backrefs) hash for +/// backward-references items and reports nodes whose stored value hash +/// does not match — e.g. one written with a corrupt provided hash. +#[test] +fn verify_grovedb_reports_corrupt_provided_value_hashes() { + use grovedb_merk::{tree::Op as MerkOp, TreeFeatureType}; + use grovedb_storage::{Storage, StorageBatch}; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + + let bytes = Element::new_item_allowing_bidirectional_references(b"evil".to_vec()) + .serialize(grove_version) + .unwrap(); + let batch = StorageBatch::new(); + let mut merk = db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + &tx, + Some(&batch), + grove_version, + ) + .unwrap() + .unwrap(); + merk.apply::<_, Vec>( + &[( + b"bad".to_vec(), + MerkOp::PutWithProvidedValueHash(bytes, [7; 32], None, TreeFeatureType::BasicMerkNode), + )], + &[], + None, + grove_version, + ) + .unwrap() + .unwrap(); + drop(merk); + db.db + .commit_multi_context_batch(batch, Some(&tx)) + .unwrap() + .unwrap(); + + let issues = db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap(); + assert!( + issues + .keys() + .any(|path| path.last().map(|k| k.as_slice()) == Some(b"bad".as_slice())), + "the corrupt node must be reported, got: {issues:?}" + ); +} + +/// The batch fast path for hop-budget-1 references binds the target's +/// merk-stored value hash directly; for a backward-references terminal +/// that stored hash is the COMBINED (inner ‖ backrefs) hash, so the fast +/// path must fall back to the logical hash instead. +#[test] +fn batch_hop_one_references_commit_the_logical_hash_of_family_targets() { + use crate::batch::QualifiedGroveDbOp; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + // Register a referrer so combined != logical. + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"hop1".to_vec(), + Element::Reference( + ReferencePathType::SiblingReference(b"value".to_vec()), + Some(1), + None, + ), + )]; + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("hop-1 reference to a backward-references item is well-formed"); + + // The sum-carrying twin takes the same shortcut: its stored node hash + // also includes the referrer list, so the hop-1 path must recompute + // the stripped logical hash for it too. + db.insert( + &[TEST_LEAF], + b"sums", + Element::new_sum_tree(None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"twin", + Element::new_item_with_sum_item_allowing_bidirectional_references(b"pay".to_vec(), 5), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"twinref", + sibling_bidi(b"twin", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec(), b"sums".to_vec()], + b"hop1twin".to_vec(), + Element::Reference( + ReferencePathType::SiblingReference(b"twin".to_vec()), + Some(1), + None, + ), + )]; + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("hop-1 reference to the sum-carrying twin is well-formed"); + + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Every query surface resolves the whole terminal-element zoo through +/// references: plain items, sum items, combined items, and their +/// backward-references twins — directly and dereferenced. +#[test] +fn query_surfaces_resolve_every_terminal_shape_through_references() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"sums", + Element::new_sum_tree(None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + for (key, element) in [ + (b"sum".as_slice(), Element::new_sum_item(9)), + (b"isi", Element::ItemWithSumItem(b"both".to_vec(), 4, None)), + ( + b"bsum", + Element::new_sum_item_allowing_bidirectional_references(2), + ), + ] { + db.insert( + &[TEST_LEAF, b"sums"], + key, + element, + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + let into_sums = |key: &[u8]| { + ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"sums".to_vec(), + key.to_vec(), + ]) + }; + for (key, target) in [ + (b"r_sum".as_slice(), into_sums(b"sum")), + (b"r_isi", into_sums(b"isi")), + ( + b"r_val", + ReferencePathType::SiblingReference(b"value".to_vec()), + ), + ] { + db.insert( + &[TEST_LEAF], + key, + Element::new_reference(target), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + db.insert( + &[TEST_LEAF], + b"r_bsum", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: into_sums(b"bsum"), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let query_for = |path: Vec>, keys: &[&[u8]]| { + let mut query = Query::new(); + for key in keys { + query.insert_key(key.to_vec()); + } + PathQuery::new_unsized(path, query) + }; + let deref_query = query_for( + vec![TEST_LEAF.to_vec()], + &[b"value", b"r_sum", b"r_isi", b"r_val", b"r_bsum"], + ); + let direct_query = query_for( + vec![TEST_LEAF.to_vec(), b"sums".to_vec()], + &[b"sum", b"isi", b"bsum"], + ); + + // Raw item values across dereferenced and direct shapes. + let (values, _) = db + .query_item_value(&deref_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(values.len(), 5); + let (values, _) = db + .query_item_value(&direct_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(values.len(), 3); + + // Item-or-sum view sees the sums as sums. + for query in [&deref_query, &direct_query] { + let (values, _) = db + .query_item_value_or_sum(query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert!(!values.is_empty()); + } + + // Sum-only surface: direct and dereferenced sum items. + let (values, _) = db + .query_sums( + &query_for(vec![TEST_LEAF.to_vec()], &[b"r_sum", b"r_bsum"]), + true, + true, + true, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert_eq!(values.iter().sum::(), 9 + 2); + let (values, _) = db + .query_sums( + &query_for( + vec![TEST_LEAF.to_vec(), b"sums".to_vec()], + &[b"sum", b"bsum"], + ), + true, + true, + true, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert_eq!(values.iter().sum::(), 9 + 2); + + // The deprecated encoded-many surface accepts reference-only results. + let refs_query = query_for( + vec![TEST_LEAF.to_vec()], + &[b"r_sum", b"r_isi", b"r_val", b"r_bsum"], + ); + #[allow(deprecated)] + let encoded = db + .query_encoded_many(&[&refs_query], true, true, true, None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(encoded.len(), 4); +} + +/// The non-batch insert path refuses every non-empty tree variant, not +/// just plain trees. +#[test] +fn flagged_inserts_refuse_every_non_empty_tree_variant() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let phantom = Some(b"phantom".to_vec()); + for (idx, tree) in [ + Element::Tree(phantom.clone(), None), + Element::SumTree(phantom.clone(), 0, None), + Element::BigSumTree(phantom.clone(), 0, None), + Element::CountTree(phantom.clone(), 0, None), + Element::CountSumTree(phantom.clone(), 0, 0, None), + Element::ProvableCountTree(phantom.clone(), 0, None), + Element::ProvableCountSumTree(phantom.clone(), 0, 0, None), + Element::ProvableSumTree(phantom.clone(), 0, None), + Element::ProvableCountProvableSumTree(phantom.clone(), 0, 0, None), + ] + .into_iter() + .enumerate() + { + assert!( + matches!( + db.insert( + &[TEST_LEAF], + format!("t{idx}").as_bytes(), + tree, + flag_on(), + None, + grove_version, + ) + .unwrap(), + Err(Error::InvalidCodeExecution(_)) + ), + "non-empty tree variant {idx} must be refused" + ); + } +} + +/// An edge update that keeps the forward path but changes an option +/// (cascade, max_hop, flags) must refresh the single registration in +/// place — never append a duplicate, and never strip the edge when the +/// old registration is cleaned up. +#[test] +fn option_only_edge_updates_keep_exactly_one_registration() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // Same forward path, cascade flipped. + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", false), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let target = Element::get(&merk, b"value", true, grove_version) + .unwrap() + .unwrap(); + let refs = target.backward_references().unwrap(); + assert_eq!(refs.len(), 1, "one registration, refreshed in place"); + assert!(!refs[0].cascade_on_update, "the option change must stick"); + drop(merk); + + // Same again on a CHAINED reference target (1-registration budget): + // updating an option on a referrer of a bidirectional reference must + // not trip the budget. + db.insert( + &[TEST_LEAF], + b"head", + sibling_bidi(b"ref", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"head", + sibling_bidi(b"ref", false), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .expect("option change on a chained edge must not exceed the budget"); + + // The refreshed edges still propagate: update the end target and check + // the whole graph verifies. + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// Different `ReferencePathType` encodings can resolve to the same +/// position: retargeting an edge from a sibling encoding to an absolute +/// encoding of the SAME target must replace the registration entry — not +/// duplicate it, and (the reported bug) not strip it via a stale removal +/// write racing the registration. +#[test] +fn same_target_retarget_with_different_encoding_keeps_the_registration() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // Same target, absolute encoding. + db.insert( + &[TEST_LEAF], + b"ref", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"value".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let target = Element::get(&merk, b"value", true, grove_version) + .unwrap() + .unwrap(); + let refs = target.backward_references().unwrap(); + assert_eq!( + refs.len(), + 1, + "the registration must survive the re-encoding, exactly once" + ); + assert!(matches!( + refs[0].inverted_reference, + ReferencePathType::AbsolutePathReference(..) + )); + drop(merk); + + // The refreshed edge still propagates and the whole graph verifies. + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"updated".to_vec()), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// The stored referrer list is bookkeeping the insert flow maintains: +/// caller-supplied entries on a fresh insert are discarded, so forged +/// inverted paths can never be planted for later cascades to follow. +#[test] +fn caller_supplied_referrer_lists_are_discarded_on_insert() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let tx = db.start_transaction(); + + let forged = crate::bidirectional_references::BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"victim".to_vec()), + cascade_on_update: true, + }; + db.insert( + &[TEST_LEAF], + b"planted", + Element::ItemWithBackwardsReferences(b"x".to_vec(), vec![forged.clone()].into(), None), + flag_on(), + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let stored = Element::get(&merk, b"planted", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!( + stored.backward_references().unwrap().len(), + 0, + "forged referrer entries must not persist" + ); + drop(merk); + + // Same for a bidirectional reference insert. + db.insert( + &[TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references(b"t".to_vec()), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + let Element::BidirectionalReference(mut reference, _) = sibling_bidi(b"target", true) else { + unreachable!() + }; + reference.backward_references = vec![forged]; + db.insert( + &[TEST_LEAF], + b"planted_ref", + Element::BidirectionalReference(reference, None), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + let merk = db + .open_transactional_merk_at_path(SubtreePath::from(&[TEST_LEAF]), &tx, None, grove_version) + .unwrap() + .unwrap(); + let stored = Element::get(&merk, b"planted_ref", true, grove_version) + .unwrap() + .unwrap(); + assert_eq!(stored.backward_references().unwrap().len(), 0); +} + +/// A backward-references node is a legitimate absence-proof boundary: a +/// proof for a missing key adjacent to one must verify (in range and +/// key-list shapes, both directions). +#[test] +fn backward_references_nodes_are_valid_absence_boundaries() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // "value" is the greatest key; "zz"/"aa" are absent on each side. + for keys in [ + vec![b"value".to_vec(), b"zz".to_vec()], + vec![b"aa".to_vec(), b"value".to_vec()], + vec![b"value".to_vec(), b"x".to_vec(), b"zz".to_vec()], + ] { + for left_to_right in [true, false] { + let mut query = Query::new_with_direction(left_to_right); + for key in &keys { + query.insert_key(key.clone()); + } + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("absence next to a backward-references node must prove"); + let (hash, result_set) = GroveDb::verify_query(&proof, &path_query, grove_version) + .expect("a backward-references boundary node must be accepted by absence checks"); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + assert_eq!(result_set.len(), 1, "only `value` exists"); + } + } +} + +/// V1 verifiers reject a bidirectional reference smuggled into a plain +/// KVValueHash RESULT node (downgraded from the bound KVRefValueHash +/// shape): the raw reference bytes would ride unbound on the carried hash. +#[test] +fn verifier_rejects_downgraded_bidirectional_reference_results() { + use bincode::config; + use grovedb_merk::{ + proofs::{encoding::encode_into, Decoder, Node, Op}, + tree::{combine_hash, value_hash}, + }; + + use crate::operations::proof::{GroveDBProof, GroveDBProofV1, LayerProof, ProofBytes}; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"ref".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + GroveDb::verify_query(&proof, &path_query, grove_version).expect("honest proof verifies"); + + // Downgrade: rebuild the dereferenced node as a plain KVValueHash whose + // carried hash still reconstructs the same node hash, but whose value + // bytes are now arbitrary reference bytes. + let raw_reference_bytes = sibling_bidi(b"value", true) + .serialize(grove_version) + .unwrap(); + let cfg = config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut decoded, _): (GroveDBProof, _) = bincode::decode_from_slice(&proof, cfg).unwrap(); + let GroveDBProof::V1(GroveDBProofV1 { root_layer }) = &mut decoded else { + panic!("expected V1"); + }; + let mut layer: &mut LayerProof = root_layer; + for key in &path_query.path { + layer = layer.lower_layers.get_mut(key).unwrap(); + } + let ProofBytes::Merk(leaf_bytes) = &mut layer.merk_proof else { + panic!("expected merk bytes"); + }; + let mut ops: Vec = Decoder::new(leaf_bytes).collect::>().unwrap(); + let mut swapped = false; + for op in ops.iter_mut() { + if let Op::Push(Node::KVRefValueHash(key, target_bytes, self_hash)) + | Op::PushInverted(Node::KVRefValueHash(key, target_bytes, self_hash)) = op + { + let node_value_hash = + combine_hash(self_hash, &value_hash(target_bytes).unwrap()).unwrap(); + *op = Op::Push(Node::KVValueHash( + key.clone(), + raw_reference_bytes.clone(), + node_value_hash, + )); + swapped = true; + break; + } + } + assert!(swapped, "proof must contain the dereferenced node"); + let mut new_leaf = Vec::new(); + encode_into(ops.iter(), &mut new_leaf); + *leaf_bytes = new_leaf; + let forged = bincode::encode_to_vec( + decoded, + config::standard().with_big_endian().with_no_limit(), + ) + .unwrap(); + + let err = GroveDb::verify_query(&forged, &path_query, grove_version) + .expect_err("downgraded bidirectional result must be rejected"); + assert!(err.to_string().contains("must be"), "got: {err}"); +} + +/// Limit-truncated proofs still verify when a bidirectional reference +/// lands past the window: the prover rewrites filler rows into the bound +/// shape too. +#[test] +fn truncated_windows_over_bidirectional_references_still_verify() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"zref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + for limit in [1u16, 2] { + let mut query = Query::new(); + query.insert_range_inclusive(b"a".to_vec()..=b"zz".to_vec()); + let path_query = PathQuery::new( + vec![TEST_LEAF.to_vec()], + crate::SizedQuery::new(query, Some(limit), None), + ); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + let (hash, _) = GroveDb::verify_query(&proof, &path_query, grove_version) + .unwrap_or_else(|e| panic!("limit {limit} proof must verify, got {e}")); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + } +} + +/// A per-edge `max_hop` on a bidirectional reference caps resolution: +/// an edge declaring one hop cannot resolve through a second reference. +#[test] +fn per_edge_max_hop_is_enforced_on_reads() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"mid", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // head -> mid -> value with head declaring max_hop 1: the chain needs + // two hops, so the write path now rejects the dead edge outright. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"head", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"mid".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + + // An edge can still fall OUT of budget after insertion: head points at + // a family item within budget, then an unflagged overwrite turns that + // target into a plain reference — reads must now hit the budget. + db.insert( + &[TEST_LEAF], + b"mid_evolved", + Element::new_item_allowing_bidirectional_references(b"m".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"head", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference( + b"mid_evolved".to_vec(), + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"mid_evolved", + Element::new_reference(ReferencePathType::SiblingReference(b"value".to_vec())), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"head".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + assert!( + matches!( + db.query_item_value(&path_query, true, true, true, None, grove_version) + .unwrap(), + Err(Error::ReferenceLimit) + ), + "a one-hop edge must not resolve through a second reference" + ); + + // The unrestricted sibling still resolves. + let mut query = Query::new(); + query.insert_key(b"mid".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + db.query_item_value(&path_query, true, true, true, None, grove_version) + .unwrap() + .unwrap(); + + // Direct `get` honors the source edge's budget too: head's one-hop + // declaration must not resolve through mid. + assert!(matches!( + db.get(&[TEST_LEAF], b"head", None, grove_version).unwrap(), + Err(Error::ReferenceLimit) + )); + + // A MID-CHAIN one-hop edge whose target is the direct terminal is + // valid: the budget counts hops from that edge, not from the chain + // start — `mid2(max_hop=1) -> value` resolves… + db.insert( + &[TEST_LEAF], + b"mid2", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"value".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"head2", + sibling_bidi(b"mid2", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + db.get(&[TEST_LEAF], b"head2", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + "one direct terminal hop past a max_hop-1 edge is within budget" + ); + + // …while the same edge one link earlier in a chain (its target is + // ANOTHER reference) stays out of budget. + db.insert( + &[TEST_LEAF], + b"value2", + Element::new_item_allowing_bidirectional_references(b"v2".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"mid_a", + sibling_bidi(b"value2", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let result = db + .insert( + &[TEST_LEAF], + b"mid3", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"mid_a".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap(); + match result { + // Insert-time chain resolution may already enforce the declared + // budget; if it admits the edge, the read must enforce it. + Err(_) => {} + Ok(()) => { + assert!(matches!( + db.get(&[TEST_LEAF], b"mid3", None, grove_version).unwrap(), + Err(Error::ReferenceLimit) + )); + } + } +} + +/// Raw query surfaces also strip referrer lists — public reads never see +/// the bookkeeping. +#[test] +fn raw_queries_strip_referrer_lists() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"value".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + let (results, _) = db + .query_raw( + &path_query, + true, + true, + true, + crate::query_result_type::QueryResultType::QueryElementResultType, + None, + grove_version, + ) + .unwrap() + .unwrap(); + for result in results.into_iterator() { + let crate::query_result_type::QueryResultElement::ElementResultItem(element) = result + else { + panic!("unexpected result shape") + }; + assert_eq!(element.backward_references().unwrap().len(), 0); + } +} + +/// Flagged deletion refuses to sweep through descendant specialized trees +/// instead of corrupting or failing mid-way. +#[test] +fn flagged_delete_rejects_specialized_descendants() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"outer", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"outer"], + b"mmr", + Element::new_mmr_tree(0, None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let options = DeleteOptions { + allow_deleting_non_empty_trees: true, + deleting_non_empty_trees_returns_error: false, + propagate_backward_references: true, + ..Default::default() + }; + assert!(matches!( + db.delete(&[TEST_LEAF], b"outer", Some(options), None, grove_version) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +/// `verify_grovedb` audits reciprocity: a forged referrer entry naming a +/// position that does not point back is reported. +#[test] +fn verify_grovedb_reports_forged_referrer_entries() { + use grovedb_merk::{tree::Op as MerkOp, TreeFeatureType}; + use grovedb_storage::{Storage, StorageBatch}; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + + // Forge an item claiming `value` refers to it — with a CONSISTENT + // combined hash, so only the reciprocity audit can catch it. + let forged_element = Element::ItemWithBackwardsReferences( + b"x".to_vec(), + vec![crate::bidirectional_references::BackwardReference { + inverted_reference: ReferencePathType::SiblingReference(b"value".to_vec()), + cascade_on_update: true, + }] + .into(), + None, + ); + use grovedb_merk::element::ElementExt; + let combined = forged_element + .backward_references_hashes(grove_version) + .unwrap() + .unwrap() + .unwrap() + .combined; + let bytes = forged_element.serialize(grove_version).unwrap(); + let batch = StorageBatch::new(); + let mut merk = db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + &tx, + Some(&batch), + grove_version, + ) + .unwrap() + .unwrap(); + merk.apply::<_, Vec>( + &[( + b"forged".to_vec(), + MerkOp::PutWithProvidedValueHash(bytes, combined, None, TreeFeatureType::BasicMerkNode), + )], + &[], + None, + grove_version, + ) + .unwrap() + .unwrap(); + drop(merk); + db.db + .commit_multi_context_batch(batch, Some(&tx)) + .unwrap() + .unwrap(); + + let issues = db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap(); + assert!( + !issues.is_empty(), + "the forged referrer entry must be reported" + ); +} + +/// The `ItemWithSumItemWithBackwardsReferences` twin: a valid +/// bidirectional-reference target that carries item bytes AND a sum +/// contribution, end to end — writes, hash isolation, sum aggregation, +/// every query surface, proofs, and the family's fail-closed rules. +#[test] +fn item_with_sum_item_twin_works_end_to_end() { + use grovedb_merk::element::get::ElementFetchFromStorageExtensions; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let tx = db.start_transaction(); + + // Lives in a sum tree, contributing its sum like ItemWithSumItem. + db.insert( + &[TEST_LEAF], + b"sums", + Element::new_sum_tree(None), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + let twin = + Element::new_item_with_sum_item_allowing_bidirectional_references(b"payload".to_vec(), 9); + db.insert( + &[TEST_LEAF, b"sums"], + b"twin", + twin.clone(), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF, b"sums"], + b"plain", + Element::new_sum_item(5), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + + // A bidirectional reference can target it; registration changes only + // the twin's node hash. + let node_hash = |tx, key: &[u8]| { + Element::get_value_hash( + &db.open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF, b"sums"]), + tx, + None, + grove_version, + ) + .unwrap() + .unwrap(), + key, + true, + grove_version, + ) + .unwrap() + .unwrap() + .unwrap() + }; + let plain_before = node_hash(&tx, b"plain"); + let twin_before = node_hash(&tx, b"twin"); + db.insert( + &[TEST_LEAF, b"sums"], + b"zref", + sibling_bidi(b"twin", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert_ne!(node_hash(&tx, b"twin"), twin_before); + assert_eq!(node_hash(&tx, b"plain"), plain_before); + + // The parent sum totals all three contributions (9 + 5 + 0 for the ref). + let sums_element = db + .get(&[TEST_LEAF], b"sums", Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert_eq!(sums_element.sum_value_or_default(), 14); + + // Public reads strip; merk-level reads carry the registration. + let public = db + .get(&[TEST_LEAF, b"sums"], b"twin", Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert_eq!( + public, + Element::new_item_with_sum_item_allowing_bidirectional_references(b"payload".to_vec(), 9) + ); + let merk = db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF, b"sums"]), + &tx, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + Element::get(&merk, b"twin", true, grove_version) + .unwrap() + .unwrap() + .backward_references() + .unwrap() + .len(), + 1 + ); + drop(merk); + + // Every query surface sees the ItemWithSumItem shape. + let mut query = Query::new(); + query.insert_key(b"twin".to_vec()); + query.insert_key(b"zref".to_vec()); + let path_query = + PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"sums".to_vec()], query.clone()); + let (values, _) = db + .query_item_value(&path_query, true, true, true, Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert_eq!(values, vec![b"payload".to_vec(), b"payload".to_vec()]); + let (values, _) = db + .query_item_value_or_sum(&path_query, true, true, true, Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert!(values.iter().all(|v| matches!( + v, + QueryItemOrSumReturnType::ItemDataWithSumValue(data, 9) if data == b"payload" + ))); + let (values, _) = db + .query_sums(&path_query, true, true, true, Some(&tx), grove_version) + .unwrap() + .unwrap(); + assert_eq!(values, vec![9, 9]); + + // The whole graph verifies, including the twin's combined hash and the + // reciprocal registration. + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); + db.commit_transaction(tx).unwrap().unwrap(); + + // Proof roundtrip: the twin arrives stripped via the recombining node, + // directly and dereferenced. + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec(), b"sums".to_vec()], query); + let proof = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .unwrap(); + let (hash, result_set) = GroveDb::verify_query(&proof, &path_query, grove_version).unwrap(); + assert_eq!(hash, db.root_hash(None, grove_version).unwrap().unwrap()); + assert_eq!(result_set.len(), 2); + for (_, _, element) in result_set { + assert_eq!( + element, + Some( + Element::new_item_with_sum_item_allowing_bidirectional_references( + b"payload".to_vec(), + 9 + ) + ) + ); + } + + // Tampering with the recombining node still fails. + let tampered = tamper_backward_references_node(&proof, &path_query, |_, backrefs_hash| { + backrefs_hash[0] ^= 1; + }) + .expect("proof must carry the twin's recombining node"); + assert!(GroveDb::verify_query(&tampered, &path_query, grove_version).is_err()); + + // Family fail-closed rules hold for the twin. + db.insert( + &[TEST_LEAF], + b"pst", + Element::new_provable_sum_tree(None), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + assert!(db + .insert( + &[TEST_LEAF, b"pst"], + b"k", + Element::new_item_with_sum_item_allowing_bidirectional_references(b"x".to_vec(), 1), + None, + None, + grove_version, + ) + .unwrap() + .is_err()); + { + use crate::batch::QualifiedGroveDbOp; + assert!(db + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"batched".to_vec(), + Element::new_item_with_sum_item_allowing_bidirectional_references( + b"x".to_vec(), + 1, + ), + )], + None, + None, + grove_version, + ) + .unwrap() + .is_err()); + } + let v1 = &grovedb_version::version::v1::GROVE_V1; + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"old", + Element::new_item_with_sum_item_allowing_bidirectional_references(b"x".to_vec(), 1), + None, + None, + v1, + ) + .unwrap(), + Err(Error::NotSupported(_)) + )); +} + +/// A bidirectional edge whose declared `max_hop` cannot admit its own +/// chain is a dead edge (reads would deterministically return +/// `ReferenceLimit`) — the write path rejects it up front, in live and +/// batch flows alike (shared planner). +#[test] +fn bidi_insert_rejects_undersized_max_hop() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + + // max_hop 0 cannot even reach a direct target. + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"dead", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"value".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(0), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + + // max_hop 1 admits exactly a direct target. + db.insert( + &[TEST_LEAF], + b"alive", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"value".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .expect("a direct edge fits a one-hop declaration"); + + // …but not a two-hop chain, in the batch flow either. + assert!(matches!( + db.apply_batch( + vec![crate::batch::QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"dead2".to_vec(), + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference( + b"alive".to_vec() + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + )], + Some(crate::batch::BatchApplyOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); +} + +/// Proof generation honors a bidirectional edge's declared budget exactly +/// like reads: an edge that fell out of budget after insertion (its +/// target evolved into a plain reference) fails proof generation with +/// `ReferenceLimit` instead of proving a value `get` would refuse. +#[test] +fn proof_generation_respects_bidi_max_hop() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"mid", + Element::new_item_allowing_bidirectional_references(b"m".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"head", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"mid".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(1), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"mid", + Element::new_reference(ReferencePathType::SiblingReference(b"value".to_vec())), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut query = Query::new(); + query.insert_key(b"head".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + assert!( + matches!( + db.prove_query(&path_query, None, grove_version).unwrap(), + Err(Error::ReferenceLimit) + ), + "proving must not dereference past the edge's declared budget" + ); +} + +/// The reciprocity audit reports BOTH directions: a live edge whose +/// target lost its registration (planted via a direct merk write with a +/// consistent hash, so only the forward-membership audit can see it) and +/// a corrupt empty inverse path. +#[test] +fn verify_grovedb_reports_missing_forward_registration_and_empty_inverse() { + use grovedb_merk::{element::ElementExt, tree::Op as MerkOp, TreeFeatureType}; + use grovedb_storage::{Storage, StorageBatch}; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + None, + Some(&tx), + grove_version, + ) + .unwrap() + .unwrap(); + assert!(db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap() + .is_empty()); + + // Strip the registration off the target with a hash-consistent direct + // write: the ref commits to the LOGICAL (stripped) hash, so nothing + // but the forward-membership audit can notice. + let stripped_target = Element::new_item_allowing_bidirectional_references(b"hello".to_vec()); + let combined = stripped_target + .backward_references_hashes(grove_version) + .unwrap() + .unwrap() + .unwrap() + .combined; + let bytes = stripped_target.serialize(grove_version).unwrap(); + let batch = StorageBatch::new(); + let mut merk = db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + &tx, + Some(&batch), + grove_version, + ) + .unwrap() + .unwrap(); + merk.apply::<_, Vec>( + &[( + b"value".to_vec(), + MerkOp::PutWithProvidedValueHash(bytes, combined, None, TreeFeatureType::BasicMerkNode), + )], + &[], + None, + grove_version, + ) + .unwrap() + .unwrap(); + drop(merk); + db.db + .commit_multi_context_batch(batch, Some(&tx)) + .unwrap() + .unwrap(); + + let issues = db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap(); + assert!( + issues + .keys() + .any(|path| path.last().map(|k| k.as_slice()) == Some(b"?missing-registration")), + "a live edge without its reverse registration must be reported: {issues:?}" + ); + + // A corrupt EMPTY inverse path on a target must also be reported. + let db = db_with_bwr_item(); + let tx = db.start_transaction(); + let forged = Element::ItemWithBackwardsReferences( + b"x".to_vec(), + vec![crate::bidirectional_references::BackwardReference { + inverted_reference: ReferencePathType::AbsolutePathReference(vec![]), + cascade_on_update: true, + }] + .into(), + None, + ); + let combined = forged + .backward_references_hashes(grove_version) + .unwrap() + .unwrap() + .unwrap() + .combined; + let bytes = forged.serialize(grove_version).unwrap(); + let batch = StorageBatch::new(); + let mut merk = db + .open_transactional_merk_at_path( + SubtreePath::from(&[TEST_LEAF]), + &tx, + Some(&batch), + grove_version, + ) + .unwrap() + .unwrap(); + merk.apply::<_, Vec>( + &[( + b"forged".to_vec(), + MerkOp::PutWithProvidedValueHash(bytes, combined, None, TreeFeatureType::BasicMerkNode), + )], + &[], + None, + grove_version, + ) + .unwrap() + .unwrap(); + drop(merk); + db.db + .commit_multi_context_batch(batch, Some(&tx)) + .unwrap() + .unwrap(); + let issues = db + .verify_grovedb(Some(&tx), true, true, grove_version) + .unwrap(); + assert!( + !issues.is_empty(), + "an empty resolved inverse path must be reported" + ); +} + +/// Retargeting an edge must revalidate every UPSTREAM ancestor's declared +/// budget against the new downstream length: `A(max_hop=2) -> B -> C` is +/// valid, but retargeting B onto a two-hop chain would leave A needing +/// three hops — reads through A would deterministically fail, so the +/// retarget is rejected atomically, in live and batch flows alike. +#[test] +fn retarget_rejects_upstream_max_hop_violation() { + let grove_version = GroveVersion::latest(); + let build = || { + let db = db_with_bwr_item(); + // B -> value, A(max_hop=2) -> B: A's chain is exactly 2 hops. + db.insert( + &[TEST_LEAF], + b"b", + sibling_bidi(b"value", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"a", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"b".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: Some(2), + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // An independent two-hop tail: d -> e. + db.insert( + &[TEST_LEAF], + b"e", + Element::new_item_allowing_bidirectional_references(b"tail".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"d", + sibling_bidi(b"e", true), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + db + }; + + // Live retarget of B onto d -> e: A would need 3 hops. + let db = build(); + assert!(matches!( + db.insert( + &[TEST_LEAF], + b"b", + sibling_bidi(b"d", true), + None, + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + // A still resolves through the unchanged chain. + assert_eq!( + db.get(&[TEST_LEAF], b"a", None, grove_version) + .unwrap() + .unwrap(), + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + ); + + // The flagged batch flow shares the planner and rejects identically. + let db = build(); + assert!(matches!( + db.apply_batch( + vec![crate::batch::QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"b".to_vec(), + sibling_bidi(b"d", true), + )], + Some(crate::batch::BatchApplyOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap(), + Err(Error::BidirectionalReferenceRule(_)) + )); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} + +/// A cascade must physically remove the referrers it deletes. Deleting +/// `value` rebalances the subtree and rewrites `ref`'s node in the shared +/// batch; the cascade's later delete of `ref` must supersede that rewrite +/// rather than being dropped by put-wins merging. +#[cfg(feature = "unsafe-dump-load")] +#[test] +fn cascade_removes_the_physical_referrer_record() { + use grovedb_storage::{Storage, StorageContext}; + + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", true), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + db.delete( + &[TEST_LEAF], + b"value", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .expect("cascade delete"); + + // The raw row is gone, not just unreachable. + let tx = db.start_transaction(); + let storage = db + .raw_storage() + .get_immediate_storage_context((&[TEST_LEAF]).into(), &tx) + .unwrap(); + let ghost = storage.get(b"ref").unwrap().unwrap(); + drop(storage); + drop(tx); + assert!( + ghost.is_none(), + "cascade left a physical referrer record behind" + ); + + // And a later unrelated insert cannot resurrect it into range results. + db.insert( + &[TEST_LEAF], + b"fresh", + Element::new_item(b"new".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let mut query = Query::new(); + query.insert_all(); + let (results, _) = db + .query_raw( + &PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query), + true, + true, + true, + QueryResultType::QueryKeyElementPairResultType, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let keys: Vec> = results + .into_iterator() + .map(|r| match r { + QueryResultElement::KeyElementPairResultItem((key, _)) => key, + other => panic!("unexpected result shape: {other:?}"), + }) + .collect(); + assert_eq!(keys, vec![b"fresh".to_vec()]); +} + +/// Cascaded deletions honour the caller's sectioned-removal policy: the +/// callback sees the referrer's flags too, and its accounting decision +/// applies to the referrer's removed bytes. +#[test] +fn cascade_forwards_the_sectioned_removal_callback() { + use grovedb_costs::storage_cost::removal::StorageRemovedBytes; + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references_with_flags( + b"old".to_vec(), + Some(vec![1]), + ), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + db.insert( + &[TEST_LEAF], + b"refs", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + // The referrer lives in a separate subtree, with its own flags. + db.insert( + &[TEST_LEAF, b"refs"], + b"ref", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"value".to_vec(), + ]), + cascade_on_update: true, + max_hop: None, + backward_references: vec![], + }, + Some(vec![2]), + ), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + let mut seen_flags = vec![]; + db.delete_with_sectional_storage_function( + SubtreePath::from(&[TEST_LEAF]), + b"value", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + &mut |flags, _, _| { + seen_flags.push(flags.clone()); + Ok(( + StorageRemovedBytes::NoStorageRemoval, + StorageRemovedBytes::NoStorageRemoval, + )) + }, + grove_version, + ) + .unwrap() + .expect("cascade delete"); + + assert!( + db.get_raw( + SubtreePath::from(&[TEST_LEAF, b"refs"]), + b"ref", + None, + grove_version + ) + .unwrap() + .is_err(), + "the referrer must be cascaded away" + ); + assert!( + seen_flags.contains(&vec![1]) && seen_flags.contains(&vec![2]), + "the callback must run for the target AND the cascaded referrer, saw {seen_flags:?}" + ); +} + +/// A registration whose referrer was removed by an unflagged write is stale +/// bookkeeping: it must neither require consent nor block deleting its +/// former target. +#[test] +fn stale_nonconsenting_registration_does_not_block_target_deletion() { + let grove_version = GroveVersion::latest(); + let db = db_with_bwr_item(); + db.insert( + &[TEST_LEAF], + b"ref", + sibling_bidi(b"value", false), + flag_on(), + None, + grove_version, + ) + .unwrap() + .unwrap(); + // Unflagged delete of the non-consenting referrer leaves its + // registration dangling on `value`. + db.delete(&[TEST_LEAF], b"ref", None, None, grove_version) + .unwrap() + .unwrap(); + + db.delete( + &[TEST_LEAF], + b"value", + Some(DeleteOptions { + propagate_backward_references: true, + ..Default::default() + }), + None, + grove_version, + ) + .unwrap() + .expect("a stale registration must not block deleting its former target"); + assert!(matches!( + db.get(&[TEST_LEAF], b"value", None, grove_version).unwrap(), + Err(Error::PathKeyNotFound(_)) + )); +} + +/// Hand-built wrappers around backward-references elements (no constructor +/// produces them, deserialization rejects them) are refused by every insert +/// version: the GROVE_V4 router before either route, and the pre-V4 gate, +/// which checks the underlying element. +#[test] +fn wrapped_backward_references_elements_are_refused_on_insert() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"ct", + Element::empty_count_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("count tree host"); + let wrapped = Element::NonCounted(Box::new(Element::ItemWithBackwardsReferences( + b"x".to_vec(), + Default::default(), + None, + ))); + + let err = db + .insert( + &[TEST_LEAF, b"ct"], + b"w", + wrapped.clone(), + None, + None, + grove_version, + ) + .unwrap() + .expect_err("GROVE_V4 refuses a wrapped family element"); + assert!(matches!(err, Error::InvalidInput(_)), "{err:?}"); + + let v3 = &grovedb_version::version::v3::GROVE_V3; + assert_eq!(v3.protocol_version, 3); + let err = db + .insert(&[TEST_LEAF, b"ct"], b"w", wrapped, None, None, v3) + .unwrap() + .expect_err("pre-V4 refuses a wrapped family element"); + assert!(matches!(err, Error::NotSupported(_)), "{err:?}"); + + assert!(db + .get_raw( + [TEST_LEAF, b"ct"].as_ref().into(), + b"w", + None, + grove_version + ) + .unwrap() + .is_err()); +} + +/// An item's declared referrer capacity is enforced on the live path: a +/// full target refuses another reference, an update may not lower the +/// capacity below the registered referrers, and raising it admits more. +#[test] +fn declared_capacity_is_enforced_on_live_registration_and_updates() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + db.insert( + &[TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references_with_capacity(b"v".to_vec(), 1), + None, + None, + grove_version, + ) + .unwrap() + .expect("target declaring one referrer"); + db.insert( + &[TEST_LEAF], + b"r1", + sibling_bidi(b"target", true), + None, + None, + grove_version, + ) + .unwrap() + .expect("the first referrer fits"); + + let err = db + .insert( + &[TEST_LEAF], + b"r2", + sibling_bidi(b"target", true), + None, + None, + grove_version, + ) + .unwrap() + .expect_err("the target is full"); + assert!( + matches!(&err, Error::BidirectionalReferenceRule(msg) if msg.contains("full")), + "{err:?}" + ); + assert!( + db.get_raw([TEST_LEAF].as_ref().into(), b"r2", None, grove_version) + .unwrap() + .is_err(), + "a refused reference is not stored" + ); + + // Lowering the capacity below the registered referrer is refused … + let err = db + .insert( + &[TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references_with_capacity(b"w".to_vec(), 0), + flag_on(), + None, + grove_version, + ) + .unwrap() + .expect_err("one referrer does not fit a capacity of zero"); + assert!( + matches!(err, Error::BidirectionalReferenceRule(_)), + "{err:?}" + ); + assert_eq!( + db.get(&[TEST_LEAF], b"r1", None, grove_version) + .unwrap() + .unwrap() + .as_item_bytes() + .unwrap(), + b"v", + "the refused update changed nothing" + ); + + // … raising it propagates like any update and admits the second. + db.insert( + &[TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references_with_capacity(b"w".to_vec(), 3), + flag_on(), + None, + grove_version, + ) + .unwrap() + .expect("raising the capacity"); + db.insert( + &[TEST_LEAF], + b"r2", + sibling_bidi(b"target", true), + None, + None, + grove_version, + ) + .unwrap() + .expect("the second referrer now fits"); + for referrer in [b"r1".as_ref(), b"r2"] { + assert_eq!( + db.get(&[TEST_LEAF], referrer, None, grove_version) + .unwrap() + .unwrap() + .as_item_bytes() + .unwrap(), + b"w" + ); + } + // Public reads strip the referrer list but keep the declaration. + let stored = db + .get_raw([TEST_LEAF].as_ref().into(), b"target", None, grove_version) + .unwrap() + .unwrap(); + assert_eq!(stored.max_incoming_references(), Some(3)); + assert!(stored.backward_references().unwrap().is_empty()); + // A third referrer still fits the raised capacity, a fourth does not: + // the two registrations survived the update. + db.insert( + &[TEST_LEAF], + b"r3", + sibling_bidi(b"target", true), + None, + None, + grove_version, + ) + .unwrap() + .expect("the third referrer fits a capacity of three"); + assert!(db + .insert( + &[TEST_LEAF], + b"r4", + sibling_bidi(b"target", true), + None, + None, + grove_version, + ) + .unwrap() + .is_err()); + assert!(db + .verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty()); +} diff --git a/grovedb/src/tests/common.rs b/grovedb/src/tests/common.rs index 3d9d4d722..1bd5e541e 100644 --- a/grovedb/src/tests/common.rs +++ b/grovedb/src/tests/common.rs @@ -3,7 +3,11 @@ use grovedb_path::SubtreePath; use grovedb_version::version::GroveVersion; -use crate::{operations::proof::util::ProvedPathKeyValues, Element, Error}; +use super::{make_deep_tree, TempGroveDb, ANOTHER_TEST_LEAF, TEST_LEAF}; +use crate::{ + bidirectional_references::BidirectionalReference, operations::proof::util::ProvedPathKeyValues, + reference_path::ReferencePathType, Element, Error, +}; /// Compare result tuples pub fn compare_result_tuples( @@ -36,3 +40,131 @@ pub fn compare_result_sets(elements: &[Vec], result_set: &ProvedPathKeyValue } pub(crate) const EMPTY_PATH: SubtreePath<'static, [u8; 0]> = SubtreePath::empty(); + +pub(crate) fn make_tree_with_bidi_references(version: &GroveVersion) -> TempGroveDb { + let db = make_deep_tree(version); + + let transaction = db.start_transaction(); + + // Let's say we're deleting `deep_leaf` with an existing references chain + // that goes like + // test_leaf/innertree:ref -> another_test_leaf/innertree2:ref2 -> + // -> deep_leaf/deep_node_1/deeper_1:ref3 -> + // -> deep_leaf/deep_node_2/deeper_3:ref4 -> + // -> deep_leaf/deep_node_1/deeper_2:key5 + // + + db.insert( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_2"], + b"key5", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[b"deep_leaf".as_ref(), b"deep_node_2", b"deeper_3"], + b"ref4", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::UpstreamRootHeightReference( + 1, + vec![ + b"deep_node_1".to_vec(), + b"deeper_2".to_vec(), + b"key5".to_vec(), + ], + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[b"deep_leaf".as_ref(), b"deep_node_1", b"deeper_1"], + b"ref3", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::UpstreamRootHeightReference( + 1, + vec![ + b"deep_node_2".to_vec(), + b"deeper_3".to_vec(), + b"ref4".to_vec(), + ], + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[ANOTHER_TEST_LEAF, b"innertree2"], + b"ref2", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + b"deep_leaf".to_vec(), + b"deep_node_1".to_vec(), + b"deeper_1".to_vec(), + b"ref3".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[TEST_LEAF, b"innertree"], + b"ref", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::AbsolutePathReference(vec![ + ANOTHER_TEST_LEAF.to_vec(), + b"innertree2".to_vec(), + b"ref2".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + Some(&transaction), + version, + ) + .unwrap() + .unwrap(); + + db.commit_transaction(transaction).unwrap().unwrap(); + + db +} diff --git a/grovedb/src/tests/count_offset_paginated_tests.rs b/grovedb/src/tests/count_offset_paginated_tests.rs index f1b4afb35..19960bec7 100644 --- a/grovedb/src/tests/count_offset_paginated_tests.rs +++ b/grovedb/src/tests/count_offset_paginated_tests.rs @@ -1570,4 +1570,67 @@ mod tests { assert_eq!(items.len(), 1, "exactly the empty PSIT is returned"); assert_eq!(items[0].1, b"b".to_vec()); } + + /// A bidirectional reference stored inside a `ProvableCountTree` must be + /// dereferenced by the count-offset proof path exactly like a plain + /// reference (the branch performs its own reference normalization). + #[test] + fn end_to_end_offset_dereferences_bidirectional_references() { + let v = GroveVersion::latest(); + let (db, _keys) = make_provable_count_tree_with_n_items(6, v); + + // A backward-references target outside the count tree... + db.insert( + &[crate::tests::TEST_LEAF], + b"target", + Element::new_item_allowing_bidirectional_references(b"pointed-at".to_vec()), + None, + None, + v, + ) + .unwrap() + .expect("insert target"); + // ...and a bidirectional reference to it inside the count tree. + db.insert( + &[b"counts"], + b"z_ref", + Element::BidirectionalReference( + crate::bidirectional_references::BidirectionalReference { + forward_reference_path: + crate::reference_path::ReferencePathType::AbsolutePathReference(vec![ + crate::tests::TEST_LEAF.to_vec(), + b"target".to_vec(), + ]), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + None, + v, + ) + .unwrap() + .expect("insert bidirectional reference"); + + let mut query = Query::new(); + query.insert_range_inclusive(b"a".to_vec()..=b"z_ref".to_vec()); + // Offset into the tail so the window covers the reference at `z_ref`. + let proved = round_trip_offset(&db, vec![b"counts".to_vec()], query, Some(3), Some(5), v); + assert!( + proved_keys(&proved).contains(&b"z_ref".to_vec()), + "window must cover the reference" + ); + let ref_row = proved + .iter() + .find(|p| p.key == b"z_ref".to_vec()) + .expect("reference row present"); + let element = Element::deserialize(&ref_row.value, v).expect("deserialize proved value"); + assert_eq!( + element, + Element::new_item_allowing_bidirectional_references(b"pointed-at".to_vec()), + "the proof must carry the dereferenced target element" + ); + } } diff --git a/grovedb/src/tests/delete_indexed_tree_tests.rs b/grovedb/src/tests/delete_indexed_tree_tests.rs index bbb512fb0..19ac0d573 100644 --- a/grovedb/src/tests/delete_indexed_tree_tests.rs +++ b/grovedb/src/tests/delete_indexed_tree_tests.rs @@ -85,6 +85,7 @@ mod tests { deleting_non_empty_trees_returns_error: false, base_root_storage_is_free: true, validate_tree_at_path_exists: false, + propagate_backward_references: false, } } diff --git a/grovedb/src/tests/direct_insert_indexed_tests.rs b/grovedb/src/tests/direct_insert_indexed_tests.rs index 86c6e1353..19f6c4aa4 100644 --- a/grovedb/src/tests/direct_insert_indexed_tests.rs +++ b/grovedb/src/tests/direct_insert_indexed_tests.rs @@ -519,6 +519,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, } } diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index d0cebb956..91f7ecb7e 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -15,6 +15,8 @@ mod append_family_cost_bound_tests; mod append_layer_direction_tests; mod append_layer_limit_accounting_tests; mod append_storage_accounting_tests; +mod batch_backward_references_cost_tests; +mod batch_backward_references_tests; mod batch_coverage_tests; mod batch_delete_tree_tests; mod batch_indexed_fresh_create_tests; @@ -23,6 +25,7 @@ mod batch_indexed_overwrite_tests; mod batch_indexed_tree_tests; mod batch_rejection_tests; mod batch_unit_tests; +mod bidirectional_references_tests; mod bulk_append_tree_tests; mod checkpoint_tests; mod chunk_branch_proof_tests; @@ -1165,9 +1168,13 @@ mod general_tests { use grovedb_merk::{ element::get::ElementFetchFromStorageExtensions, proofs::query::SubqueryBranch, }; + use operations::insert::InsertOptions; use super::*; - use crate::element::elements_iterator::ElementIteratorExtensions; + use crate::{ + bidirectional_references::BidirectionalReference, + element::elements_iterator::ElementIteratorExtensions, + }; #[test] fn test_init() { @@ -4922,4 +4929,191 @@ mod general_tests { .unwrap() .is_empty()); } + + #[test] + fn test_verify_bidirectional_references_dont_corrupt() { + // As opposed to regular references, bidirectional references with the + // propagation flag keep the reference chain hashes consistent when + // the target is updated: + + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + let transaction = db.start_transaction(); + + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + Some(&transaction), + grove_version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[TEST_LEAF], + b"refc", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"value".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + Some(&transaction), + grove_version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[TEST_LEAF], + b"refb", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"refc".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + Some(&transaction), + grove_version, + ) + .unwrap() + .unwrap(); + + db.insert( + &[TEST_LEAF], + b"refa", + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"refb".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + Some(&transaction), + grove_version, + ) + .unwrap() + .unwrap(); + + assert!(db + .verify_grovedb(Some(&transaction), true, true, grove_version) + .unwrap() + .is_empty()); + + // "Breaking" things there: + db.insert( + &[TEST_LEAF], + b"value", + Element::new_item_allowing_bidirectional_references(b"not hello >:(".to_vec()), + Some(InsertOptions { + propagate_backward_references: true, + ..Default::default() + }), + Some(&transaction), + grove_version, + ) + .unwrap() + .unwrap(); + + // But they're not broken! + assert!(db + .verify_grovedb(Some(&transaction), true, true, grove_version) + .unwrap() + .is_empty()); + } + + /// Fail-closed gating: the backward-references element family requires + /// GROVE_V4. Under GROVE_V3 (live in production) every insert path + /// rejects them. + #[test] + fn backward_references_elements_rejected_before_v4() { + use grovedb_version::version::v3::GROVE_V3; + + let db = make_test_grovedb(GroveVersion::latest()); + + let elements = [ + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + Element::new_sum_item_allowing_bidirectional_references(1), + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"x".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + ]; + + for element in elements { + assert!( + matches!( + db.insert(&[TEST_LEAF], b"k", element.clone(), None, None, &GROVE_V3) + .unwrap(), + Err(Error::NotSupported(_)) + ), + "expected NotSupported under GROVE_V3 for {element:?}" + ); + } + } + + /// Fail-closed: batches perform no backward-references bookkeeping, so + /// every batch entry point rejects ops carrying the element family. + #[test] + fn backward_references_elements_rejected_in_batches() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + let elements = [ + Element::new_item_allowing_bidirectional_references(b"v".to_vec()), + Element::new_sum_item_allowing_bidirectional_references(1), + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference(b"x".to_vec()), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + ]; + + for element in elements { + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"k".to_vec(), + element.clone(), + )]; + assert!( + matches!( + db.apply_batch(ops, None, None, grove_version).unwrap(), + Err(Error::NotSupported(_)) + ), + "expected NotSupported in batch for {element:?}" + ); + } + } } diff --git a/grovedb/src/tests/operations_coverage_tests.rs b/grovedb/src/tests/operations_coverage_tests.rs index 5879a18ad..9e4608a47 100644 --- a/grovedb/src/tests/operations_coverage_tests.rs +++ b/grovedb/src/tests/operations_coverage_tests.rs @@ -1560,6 +1560,7 @@ mod tests { validate_insertion_does_not_override: true, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, grove_version, @@ -1612,6 +1613,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, grove_version, @@ -3165,6 +3167,7 @@ mod tests { validate_insertion_does_not_override: true, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, grove_version, @@ -3207,6 +3210,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: true, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, grove_version, @@ -3239,6 +3243,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: false, + propagate_backward_references: false, }), None, grove_version, diff --git a/grovedb/src/tests/ordinary_replacement_cost_tests.rs b/grovedb/src/tests/ordinary_replacement_cost_tests.rs index d0aab7a5f..4d8224a84 100644 --- a/grovedb/src/tests/ordinary_replacement_cost_tests.rs +++ b/grovedb/src/tests/ordinary_replacement_cost_tests.rs @@ -67,6 +67,7 @@ fn overwrite_options() -> InsertOptions { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, } } diff --git a/grovedb/src/tests/provable_count_indexed_tree_tests.rs b/grovedb/src/tests/provable_count_indexed_tree_tests.rs index be6b40d4b..2d2306810 100644 --- a/grovedb/src/tests/provable_count_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_count_indexed_tree_tests.rs @@ -1552,6 +1552,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; db.insert( [TEST_LEAF].as_ref(), @@ -1608,6 +1609,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; let res = db .insert([TEST_LEAF].as_ref(), b"pcit", tampered, Some(opts), None, v) @@ -1907,6 +1909,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }); let result = db .insert( diff --git a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs index c8113484a..32391da47 100644 --- a/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_count_provable_sum_indexed_tree_tests.rs @@ -2057,6 +2057,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; db.insert( [TEST_LEAF].as_ref(), @@ -2109,6 +2110,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; let res = db .insert( @@ -2489,6 +2491,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }); let result = db .insert( diff --git a/grovedb/src/tests/provable_count_sum_tree_tests.rs b/grovedb/src/tests/provable_count_sum_tree_tests.rs index 438576c58..190ac8683 100644 --- a/grovedb/src/tests/provable_count_sum_tree_tests.rs +++ b/grovedb/src/tests/provable_count_sum_tree_tests.rs @@ -190,6 +190,7 @@ mod tests { Node::KVDigest(k, _) => k.clone(), Node::KVDigestCount(k, ..) => k.clone(), Node::KVRefValueHash(k, ..) => k.clone(), + Node::KVBackwardsReferencesValueHash(k, ..) => k.clone(), Node::KVRefValueHashCount(k, ..) => k.clone(), Node::KVSum(k, ..) => k.clone(), Node::KVDigestSum(k, ..) => k.clone(), diff --git a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs index b8a6b0bc0..a0c939fbd 100644 --- a/grovedb/src/tests/provable_sum_indexed_tree_tests.rs +++ b/grovedb/src/tests/provable_sum_indexed_tree_tests.rs @@ -1577,6 +1577,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; db.insert( [TEST_LEAF].as_ref(), @@ -1632,6 +1633,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }; let res = db .insert([TEST_LEAF].as_ref(), b"psit", tampered, Some(opts), None, v) diff --git a/grovedb/src/tests/reference_path_tests.rs b/grovedb/src/tests/reference_path_tests.rs index 577e46977..7ae918077 100644 --- a/grovedb/src/tests/reference_path_tests.rs +++ b/grovedb/src/tests/reference_path_tests.rs @@ -80,16 +80,18 @@ mod tests { merk.for_merk(|m| { ref_a .insert_reference(m, b"a", NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert ref_a at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert ref_a at merk level"); merk.for_merk(|m| { ref_b .insert_reference(m, b"b", NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert ref_b at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert ref_b at merk level"); drop(merk); @@ -171,9 +173,10 @@ mod tests { merk.for_merk(|m| { ref_element .insert_reference(m, keygen(i), NULL_HASH, None, grove_version) - .unwrap() - .expect("should insert reference at merk level"); - }); + .map_err(crate::Error::MerkError) + }) + .unwrap() + .expect("should insert reference at merk level"); drop(merk); } diff --git a/grovedb/src/tests/replication_session_tests.rs b/grovedb/src/tests/replication_session_tests.rs index 6177a017f..7b2bbcb01 100644 --- a/grovedb/src/tests/replication_session_tests.rs +++ b/grovedb/src/tests/replication_session_tests.rs @@ -2864,6 +2864,71 @@ mod tests { ); } + /// A populated bidirectional-reference graph round-trips through state + /// sync: the chunk producer emits a `BidirectionalReference` stored in + /// a normal tree as a `KVValueHash` node (via the `KvRefValueHash` + /// mapping), which the restorer must accept under the plain-reference + /// trust model — its value hash embeds the resolved end-of-chain hash, + /// which is not locally derivable. The item variants restore through + /// the recompute-checked `KVValueHashFeatureType` path. + #[test] + fn state_sync_populated_bidirectional_reference_graph_round_trip() { + use crate::{ + bidirectional_references::BidirectionalReference, reference_path::ReferencePathType, + }; + + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"value", + Element::new_item_allowing_bidirectional_references(b"hello".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + for (key, target) in [(b"r1".as_slice(), b"value".as_slice()), (b"r2", b"r1")] { + source + .insert( + [TEST_LEAF].as_ref(), + key, + Element::BidirectionalReference( + BidirectionalReference { + forward_reference_path: ReferencePathType::SiblingReference( + target.to_vec(), + ), + backward_references: Vec::new(), + cascade_on_update: true, + max_hop: None, + }, + None, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + } + + let source_root_hash = source.root_hash(None, grove_version).unwrap().unwrap(); + let dest = sync_source_to_destination(&source, grove_version); + assert_eq!( + source_root_hash, + dest.root_hash(None, grove_version).unwrap().unwrap(), + "destination root hash should match source after full sync" + ); + assert!( + dest.verify_grovedb(None, true, true, grove_version) + .unwrap() + .is_empty(), + "the restored graph must verify, referrer lists included" + ); + } + // ---------- Indexed-tree state sync ---------- /// Shared assertions for a completed indexed round trip: identical app diff --git a/grovedb/src/tests/sum_budget_proof_tests.rs b/grovedb/src/tests/sum_budget_proof_tests.rs index e609acf5f..a94391309 100644 --- a/grovedb/src/tests/sum_budget_proof_tests.rs +++ b/grovedb/src/tests/sum_budget_proof_tests.rs @@ -357,6 +357,53 @@ mod tests { } } + /// `SumItemWithBackwardsReferences` passes the verifier's sum-item + /// check, so its value must also be extracted by the sum fold — + /// previously the extraction match missed the variant and rejected an + /// honest proof as invalid. + #[test] + fn budget_window_includes_backward_references_sum_items() { + let grove_version = GroveVersion::latest(); + let db = make_test_sum_tree_grovedb(grove_version); + // Mix both backward-references sum shapes: the plain sum item and + // the item-with-sum twin. + for (key, sum) in [(b"a".as_ref(), 7i64), (b"c", 11)] { + db.insert( + [TEST_LEAF].as_ref(), + key, + Element::new_sum_item_allowing_bidirectional_references(sum), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert backward-references sum item"); + } + db.insert( + [TEST_LEAF].as_ref(), + b"b", + Element::new_item_with_sum_item_allowing_bidirectional_references(b"pay".to_vec(), 5), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert backward-references item-with-sum twin"); + + let pq = budget_query(20, None); + let (proved_root, matches, consumed, stop) = + verify_budget(&prove(&db, &pq, grove_version), &pq, grove_version); + + assert_eq!(proved_root, root_hash(&db, grove_version)); + assert_eq!(stop, SumBudgetStop::BudgetReached); + // 7 + 5 + 11 crosses the 20 budget on the third element. + assert_eq!(consumed, 23); + assert_eq!( + matches, + vec![(b"a".to_vec(), 7), (b"b".to_vec(), 5), (b"c".to_vec(), 11)] + ); + } + // ----------------------------------------------------------------- // Row binding (#870): every row's classification must rest on // authenticated element bytes diff --git a/grovedb/src/tests/verify_grovedb_indexed_tests.rs b/grovedb/src/tests/verify_grovedb_indexed_tests.rs index e0692cc29..dab676c48 100644 --- a/grovedb/src/tests/verify_grovedb_indexed_tests.rs +++ b/grovedb/src/tests/verify_grovedb_indexed_tests.rs @@ -1410,6 +1410,7 @@ mod tests { validate_insertion_does_not_override: false, validate_insertion_does_not_override_tree: false, base_root_storage_is_free: true, + propagate_backward_references: false, }), None, grove_version, diff --git a/grovedb/src/util.rs b/grovedb/src/util.rs index 7ae2647a9..a4d915443 100644 --- a/grovedb/src/util.rs +++ b/grovedb/src/util.rs @@ -1,4 +1,5 @@ pub(crate) mod compat; +pub(crate) mod visitor; use grovedb_storage::Storage; diff --git a/grovedb/src/util/visitor.rs b/grovedb/src/util/visitor.rs new file mode 100644 index 000000000..86742ddfa --- /dev/null +++ b/grovedb/src/util/visitor.rs @@ -0,0 +1,145 @@ +//! Utilities to traverse GroveDB with custom logic. + +use std::{collections::VecDeque, marker::PhantomData}; + +use grovedb_costs::{cost_return_on_error, CostResult, CostsExt, OperationCost}; +use grovedb_path::{SubtreePath, SubtreePathBuilder}; +use grovedb_storage::{ + rocksdb_storage::{PrefixedRocksDbTransactionContext, RocksDbStorage}, + Storage, StorageBatch, StorageContext, +}; +use grovedb_version::version::GroveVersion; + +use crate::{element::elements_iterator::ElementIteratorExtensions, Element, Error, Transaction}; + +/// Structure for traversing a GroveDb in a breadth-first manner. +/// +/// This implementation employs raw iterators directly on storage for +/// performance reasons. It bypasses Merk, skipping any caching features as +/// well. Originally designed for tree deletions, caution is advised when +/// involving other cached operations in related processes. +pub(crate) struct GroveVisitor<'db, 'b, B, V: Visit<'b, B>> { + storage: &'db RocksDbStorage, + transaction: &'db Transaction<'db>, + visitor: V, + grove_version: &'db GroveVersion, + batch: StorageBatch, + recursive: bool, + _base: PhantomData<&'b B>, +} + +pub(crate) struct WalkResult { + /// Whether a visitor stopped the traversal early. The delete flow always + /// allows subtree deletion so it never short-circuits; kept for the + /// clear-subtree flow which will refuse to clear a merk containing + /// subtrees. + #[allow(dead_code)] + pub short_circuited: bool, + pub batch: StorageBatch, +} + +impl<'db, 'b, B, V> GroveVisitor<'db, 'b, B, V> +where + V: Visit<'b, B>, +{ + pub(crate) fn new( + storage: &'db RocksDbStorage, + transaction: &'db Transaction<'db>, + visitor: V, + recursive: bool, + grove_version: &'db GroveVersion, + ) -> Self { + Self { + storage, + transaction, + visitor, + recursive, + grove_version, + batch: Default::default(), + _base: PhantomData, + } + } + + pub(crate) fn walk_from( + mut self, + from: SubtreePathBuilder<'b, B>, + ) -> CostResult + where + B: AsRef<[u8]>, + { + let mut cost = OperationCost::default(); + + let mut queue = VecDeque::new(); + queue.push_back(from); + + while let Some(subtree_path) = queue.pop_front() { + let storage = self + .storage + .get_transactional_storage_context( + SubtreePath::from(&subtree_path), + Some(&self.batch), + self.transaction, + ) + .unwrap_add_cost(&mut cost); + + if cost_return_on_error!(&mut cost, self.visitor.visit_merk(subtree_path.clone())) { + return Ok(WalkResult { + short_circuited: true, + batch: self.batch, + }) + .wrap_with_cost(cost); + } + + let mut raw_iter = Element::iterator(storage.raw_iter()).unwrap_add_cost(&mut cost); + + while let Some((key, value)) = cost_return_on_error!( + &mut cost, + raw_iter + .next_element(self.grove_version) + .map_err(Into::into) + ) { + if self.recursive && value.is_any_tree() { + let mut path = subtree_path.clone(); + path.push_segment(&key); + queue.push_back(path); + } + + if cost_return_on_error!( + &mut cost, + self.visitor + .visit_element(subtree_path.clone(), &key, &storage, value) + ) { + drop(raw_iter); + return Ok(WalkResult { + short_circuited: true, + batch: self.batch, + }) + .wrap_with_cost(cost); + }; + } + } + + Ok(WalkResult { + short_circuited: false, + batch: self.batch, + }) + .wrap_with_cost(cost) + } +} + +/// Configurable logic to execute during a traversal process. +pub(crate) trait Visit<'b, B> { + /// Called on entering a subtree, if wish to stop traversal `true` shall be + /// returned. + fn visit_merk(&mut self, path: SubtreePathBuilder<'b, B>) -> CostResult; + + /// Called on each element of a current subtree, if wish to stop traversal + /// `true` shall be returned. + fn visit_element( + &mut self, + path: SubtreePathBuilder<'b, B>, + key: &[u8], + storage: &PrefixedRocksDbTransactionContext, + element: Element, + ) -> CostResult; +} diff --git a/merk/benches/branch_queries.rs b/merk/benches/branch_queries.rs index 6d95310aa..90faca3f2 100644 --- a/merk/benches/branch_queries.rs +++ b/merk/benches/branch_queries.rs @@ -227,6 +227,7 @@ fn get_key_from_node(node: &Node) -> Option> { Node::KV(key, _) => Some(key.clone()), Node::KVValueHash(key, ..) => Some(key.clone()), Node::KVValueHashFeatureType(key, ..) => Some(key.clone()), + Node::KVBackwardsReferencesValueHash(key, ..) => Some(key.clone()), Node::KVValueHashFeatureTypeWithChildHash(key, ..) => Some(key.clone()), Node::KVDigest(key, _) => Some(key.clone()), Node::KVDigestCount(key, ..) => Some(key.clone()), diff --git a/merk/src/element/get.rs b/merk/src/element/get.rs index d76649e52..73a81eaae 100644 --- a/merk/src/element/get.rs +++ b/merk/src/element/get.rs @@ -422,7 +422,11 @@ impl ElementFetchFromStoragePrivateExtensions for Element { match element_for_cost { Some(Element::Item(..)) | Some(Element::Reference(..)) - | Some(Element::ReferenceWithSumItem(..)) => { + | Some(Element::ReferenceWithSumItem(..)) + | Some(Element::ItemWithBackwardsReferences(..)) + | Some(Element::SumItemWithBackwardsReferences(..)) + | Some(Element::ItemWithSumItemWithBackwardsReferences(..)) + | Some(Element::BidirectionalReference(..)) => { // while the loaded item might be a sum item, it is given for free // as it would be very hard to know in advance cost.storage_loaded_bytes = KV::value_byte_cost_size_for_key_and_value_lengths( @@ -539,7 +543,13 @@ impl ElementFetchFromStoragePrivateExtensions for Element { let wrapper_overhead = if element.is_wrapped() { 1u32 } else { 0 }; let element_for_cost = element.underlying(); match element_for_cost { - Element::Item(..) | Element::Reference(..) | Element::ReferenceWithSumItem(..) => { + Element::Item(..) + | Element::Reference(..) + | Element::ReferenceWithSumItem(..) + | Element::ItemWithBackwardsReferences(..) + | Element::SumItemWithBackwardsReferences(..) + | Element::ItemWithSumItemWithBackwardsReferences(..) + | Element::BidirectionalReference(..) => { // while the loaded item might be a sum item, it is given for free // as it would be very hard to know in advance cost.storage_loaded_bytes = KV::value_byte_cost_size_for_key_and_value_lengths( diff --git a/merk/src/element/insert.rs b/merk/src/element/insert.rs index 7871b273c..9c162c525 100644 --- a/merk/src/element/insert.rs +++ b/merk/src/element/insert.rs @@ -13,12 +13,39 @@ use grovedb_version::{check_grovedb_v0_with_cost, version::GroveVersion}; use crate::{ element::{ costs::ElementCostExtensions, exists::ElementExistsInStorageExtensions, - get::ElementFetchFromStorageExtensions, tree_type::ElementTreeTypeExtensions, + get::ElementFetchFromStorageExtensions, tree_type::ElementTreeTypeExtensions, ElementExt, }, tree_type::TreeType, BatchEntry, CryptoHash, Error, Merk, MerkOptions, Op, TreeFeatureType, }; +/// The before/after pair of an idempotent write: `old` is what the key held +/// before (fetched as part of the write), `new` is what the caller asked to +/// store (`None` for a deletion). The write is skipped when nothing changed; +/// [`Delta::has_changed`] reports which way it went. Backward-reference +/// post-processing keys off this to decide between hash propagation and +/// cascade deletion. +#[derive(Debug)] +pub struct Delta<'e> { + /// The element the caller asked to store; `None` when the operation was + /// a deletion. + pub new: Option<&'e Element>, + /// What the key held before the operation, if anything. + pub old: Option, +} + +impl Delta<'_> { + /// Whether the operation changed the stored value. + pub fn has_changed(&self) -> bool { + match (self.old.as_ref(), self.new) { + (None, None) => false, + (None, Some(_)) => true, + (Some(_), None) => true, + (Some(old), Some(new)) => old != new, + } + } +} + /// Extension trait for inserting elements into Merk storage. pub trait ElementInsertToStorageExtensions { /// Whether this element may legally live in a tree of `tree_type`. @@ -93,7 +120,7 @@ pub trait ElementInsertToStorageExtensions { key: &[u8], options: Option, grove_version: &GroveVersion, - ) -> CostResult<(bool, Option), Error>; + ) -> CostResult, Error>; /// Adds a "Put" op to batch operations with the element and key if the /// value is different from what already exists; Returns CostResult. @@ -122,6 +149,34 @@ pub trait ElementInsertToStorageExtensions { grove_version: &GroveVersion, ) -> CostResult<(), Error>; + /// Insert a reference element in Merk under a key if it differs from + /// what already exists, returning the [`Delta`]. Reads the previous + /// value through the Merk tree (so uncommitted in-memory state is + /// seen), and performs the same validations and write as + /// [`Self::insert_reference`] when a write is needed. + fn insert_reference_if_changed_value<'db, S: StorageContext<'db>>( + &self, + merk: &mut Merk, + key: &[u8], + referenced_value: CryptoHash, + options: Option, + grove_version: &GroveVersion, + ) -> CostResult, Error>; + + /// Insert a subtree element in Merk under a key if it differs from what + /// already exists, returning the [`Delta`]. Reads the previous value + /// through the Merk tree (so uncommitted in-memory state is seen), and + /// performs the same validations and write as [`Self::insert_subtree`] + /// when a write is needed. + fn insert_subtree_if_changed<'db, S: StorageContext<'db>>( + &self, + merk: &mut Merk, + key: &[u8], + subtree_root_hash: CryptoHash, + options: Option, + grove_version: &GroveVersion, + ) -> CostResult, Error>; + /// Adds a "Put" op to batch operations with reference and key. Returns /// CostResult. fn insert_reference_into_batch_operations>( @@ -234,6 +289,20 @@ impl ElementInsertToStorageExtensions for Element { "cannot add sum item to non sum tree", )); } + if self.supports_backward_references() + && matches!( + tree_type, + TreeType::ProvableCountTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableSumTree + | TreeType::ProvableCountProvableSumTree + ) + { + return Err(Error::InvalidInputError( + "backward-references elements may not live in Provable* aggregate trees: their \ + combined value hash has no aggregate-carrying proof-node variant yet", + )); + } Ok(()) } @@ -254,6 +323,48 @@ impl ElementInsertToStorageExtensions for Element { let merk_feature_type = cost_return_on_error_into_default!(self.get_feature_type(merk.tree_type)); + if matches!(self, Element::BidirectionalReference(..)) { + return Err(Error::InvalidInputError( + "a bidirectional reference must be written through insert_reference with its \ + resolved target hash", + )) + .wrap_with_cost(Default::default()); + } + let mut cost = Default::default(); + // Backward-references items: the node value hash is combined from + // the stripped serialization plus the backward-references hash, so + // it is supplied fully computed. + let backward_references_hashes = cost_return_on_error!( + &mut cost, + self.backward_references_hashes(grove_version) + .map_err(Error::from) + ); + if let Some(hashes) = backward_references_hashes { + let batch_operations = [( + key, + Op::PutWithProvidedValueHash(serialized, hashes.combined, None, merk_feature_type), + )]; + let tree_type = merk.tree_type; + return merk + .apply_with_specialized_costs::<_, Vec>( + &batch_operations, + &[], + options, + &|key, value| { + Self::specialized_costs_for_key_value( + key, + value, + tree_type.inner_node_type(), + grove_version, + ) + .map_err(|e| Error::ClientCorruptionError(e.to_string())) + }, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .map_err(|e| Error::CorruptedData(e.to_string())) + .add_cost(cost); + } // Use is_sum_item() (which looks through NonCounted) so that a // NonCounted(SumItem(..)) takes the same specialized cost path as a // bare SumItem(..). @@ -416,30 +527,37 @@ impl ElementInsertToStorageExtensions for Element { key: &[u8], options: Option, grove_version: &GroveVersion, - ) -> CostResult<(bool, Option), Error> { - check_grovedb_v0_with_cost!( + ) -> CostResult, Error> { + use grovedb_version::dispatch_version; + + let mut cost = OperationCost::default(); + + // v0 reads the previous value from committed storage; v1 + // (`GROVE_V4`+) reads it through the Merk tree, so uncommitted + // in-memory writes made earlier in the same cached operation are + // seen. This matters for the backward-references flow, where + // several writes share one `MerkCache` before anything commits. + let previous_element_res = dispatch_version!( "insert_if_changed_value", grove_version .grovedb_versions .element - .insert_if_changed_value + .insert_if_changed_value, + 0 => { Self::get_optional_from_storage(&merk.storage, key, grove_version) } + 1 => { Self::get_optional(merk, key, true, grove_version) } ); + let previous_element = cost_return_on_error!(&mut cost, previous_element_res); - let mut cost = OperationCost::default(); - let previous_element = cost_return_on_error!( - &mut cost, - Self::get_optional_from_storage(&merk.storage, key, grove_version) - ); - let needs_insert = match &previous_element { - None => true, - Some(previous_element) => previous_element != self, + let delta = Delta { + new: Some(self), + old: previous_element, }; - if !needs_insert { - Ok((false, None)).wrap_with_cost(cost) - } else { + + if delta.has_changed() { cost_return_on_error!(&mut cost, self.insert(merk, key, options, grove_version)); - Ok((true, previous_element)).wrap_with_cost(cost) } + + Ok(delta).wrap_with_cost(cost) } /// Adds a "Put" op to batch operations with the element and key if the @@ -556,6 +674,54 @@ impl ElementInsertToStorageExtensions for Element { .wrap_with_cost(OperationCost::default()) ); + // A bidirectional reference's node value hash combines THREE + // inputs: its stripped serialization, the resolved target hash, + // and its own backward-references hash. Supply it fully computed. + if matches!(self, Element::BidirectionalReference(..)) { + let hashes = cost_return_on_error!( + &mut cost, + self.backward_references_hashes(grove_version) + .map_err(Error::from) + ) + .expect("bidirectional references carry backward references"); + // Nested combine keeps the existing KVRefValueHash* wire + // binding valid: the proof carries + // self_combined = combine(inner, backrefs) opaquely and the + // verifier recomputes combine(self_combined, H(stripped + // target)) — exactly this value hash. + let value_hash = crate::tree::hash::combine_hash(&hashes.combined, &referenced_value) + .unwrap_add_cost(&mut cost); + let batch_operations = [( + key, + Op::PutWithProvidedValueHash( + serialized, + value_hash, + Some(referenced_value), + merk_feature_type, + ), + )]; + let tree_type = merk.tree_type; + return merk + .apply_with_specialized_costs::<_, Vec>( + &batch_operations, + &[], + options, + &|key, value| { + Self::specialized_costs_for_key_value( + key, + value, + tree_type.inner_node_type(), + grove_version, + ) + .map_err(|e| Error::ClientCorruptionError(e.to_string())) + }, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .map_err(|e| Error::CorruptedData(e.to_string())) + .add_cost(cost); + } + let batch_operations = [( key, Op::PutCombinedReference(serialized, referenced_value, merk_feature_type), @@ -580,6 +746,144 @@ impl ElementInsertToStorageExtensions for Element { .map_err(|e| Error::CorruptedData(e.to_string())) } + fn insert_reference_if_changed_value<'db, S: StorageContext<'db>>( + &self, + merk: &mut Merk, + key: &[u8], + referenced_value: CryptoHash, + options: Option, + grove_version: &GroveVersion, + ) -> CostResult, Error> { + use crate::tree::hash::{combine_hash, value_hash}; + + let mut cost = OperationCost::default(); + + // Read through the Merk tree so uncommitted in-memory writes made + // earlier under the same `MerkCache` are seen. + let previous_element = cost_return_on_error!( + &mut cost, + Self::get_optional(merk, key, true, grove_version) + ); + + let delta = Delta { + new: Some(self), + old: previous_element, + }; + + // The element bytes are only half of what the node commits to: a + // reference node's stored value hash is + // combine(H(element bytes), referenced_value). An unchanged element + // whose target hash moved (e.g. re-inserting a plain reference whose + // target was updated) must still be rewritten, or the stored + // commitment goes stale. + let commitment_changed = if !delta.has_changed() { + let stored = cost_return_on_error!( + &mut cost, + Self::get_value_hash(merk, key, true, grove_version) + ); + let expected = if matches!(self, Element::BidirectionalReference(..)) { + let hashes = cost_return_on_error!( + &mut cost, + self.backward_references_hashes(grove_version) + .map_err(Error::from) + ) + .expect("bidirectional references carry backward references"); + crate::tree::hash::combine_hash(&hashes.combined, &referenced_value) + .unwrap_add_cost(&mut cost) + } else { + let serialized = cost_return_on_error_no_add!( + cost, + self.serialize(grove_version).map_err(Error::from) + ); + combine_hash( + &value_hash(&serialized).unwrap_add_cost(&mut cost), + &referenced_value, + ) + .unwrap_add_cost(&mut cost) + }; + stored != Some(expected) + } else { + false + }; + + if delta.has_changed() || commitment_changed { + cost_return_on_error!( + &mut cost, + self.insert_reference(merk, key, referenced_value, options, grove_version) + ); + } + + Ok(delta).wrap_with_cost(cost) + } + + fn insert_subtree_if_changed<'db, S: StorageContext<'db>>( + &self, + merk: &mut Merk, + key: &[u8], + subtree_root_hash: CryptoHash, + options: Option, + grove_version: &GroveVersion, + ) -> CostResult, Error> { + use grovedb_version::dispatch_version; + + dispatch_version!( + "insert_subtree_if_changed", + grove_version + .grovedb_versions + .element + .insert_subtree_if_changed, + 0 => {} + ); + + use crate::tree::hash::{combine_hash, value_hash}; + + let mut cost = OperationCost::default(); + + // Read through the Merk tree so uncommitted in-memory writes made + // earlier under the same `MerkCache` are seen. + let previous_element = cost_return_on_error!( + &mut cost, + Self::get_optional(merk, key, true, grove_version) + ); + + let delta = Delta { + new: Some(self), + old: previous_element, + }; + + // A subtree node's stored value hash is + // combine(H(element bytes), subtree_root_hash); an unchanged element + // with a moved child root must still be rewritten. See the analogous + // check in `insert_reference_if_changed_value`. + let commitment_changed = if !delta.has_changed() { + let stored = cost_return_on_error!( + &mut cost, + Self::get_value_hash(merk, key, true, grove_version) + ); + let serialized = cost_return_on_error_no_add!( + cost, + self.serialize(grove_version).map_err(Error::from) + ); + let expected = combine_hash( + &value_hash(&serialized).unwrap_add_cost(&mut cost), + &subtree_root_hash, + ) + .unwrap_add_cost(&mut cost); + stored != Some(expected) + } else { + false + }; + + if delta.has_changed() || commitment_changed { + cost_return_on_error!( + &mut cost, + self.insert_subtree(merk, key, subtree_root_hash, options, grove_version) + ); + } + + Ok(delta).wrap_with_cost(cost) + } + /// Adds a "Put" op to batch operations with reference and key. Returns /// CostResult. fn insert_reference_into_batch_operations>( @@ -1080,15 +1384,16 @@ mod tests { merk.commit(grove_version); - let (inserted, previous) = Element::new_item(b"value".to_vec()) + let element = Element::new_item(b"value".to_vec()); + let delta = element .insert_if_changed_value(&mut merk, b"another-key", None, grove_version) .unwrap() .expect("expected successful insertion 2"); - merk.commit(grove_version); + assert!(!delta.has_changed()); + assert_eq!(delta.old, Some(Element::new_item(b"value".to_vec()))); - assert!(!inserted); - assert_eq!(previous, None); + merk.commit(grove_version); assert_eq!( Element::get(&merk, b"another-key", true, grove_version) .unwrap() @@ -1121,13 +1426,14 @@ mod tests { let batch = StorageBatch::new(); let mut merk = empty_path_merk(&*storage, &transaction, &batch, grove_version); - let (inserted, previous) = Element::new_item(b"value2".to_vec()) + let element = Element::new_item(b"value2".to_vec()); + let delta = element .insert_if_changed_value(&mut merk, b"another-key", None, grove_version) .unwrap() .expect("expected successful insertion 2"); - assert!(inserted); - assert_eq!(previous, Some(Element::new_item(b"value".to_vec())),); + assert!(delta.has_changed()); + assert_eq!(delta.old, Some(Element::new_item(b"value".to_vec())),); storage .commit_multi_context_batch(batch, None) @@ -1151,13 +1457,14 @@ mod tests { .insert(&mut merk, b"mykey", None, grove_version) .unwrap() .expect("expected successful insertion"); - let (inserted, previous) = Element::new_item(b"value2".to_vec()) + let element = Element::new_item(b"value2".to_vec()); + let delta = element .insert_if_changed_value(&mut merk, b"another-key", None, grove_version) .unwrap() .expect("expected successful insertion 2"); - assert!(inserted); - assert_eq!(previous, None); + assert!(delta.has_changed()); + assert_eq!(delta.old, None); assert_eq!( Element::get(&merk, b"another-key", true, grove_version) @@ -1837,4 +2144,195 @@ mod tests { other => panic!("expected ReplaceLayered, got: {:?}", other), } } + + #[test] + fn delta_has_changed_matrix() { + let a = Element::new_item(b"a".to_vec()); + let b = Element::new_item(b"b".to_vec()); + + assert!(!Delta { + new: None, + old: None + } + .has_changed()); + assert!(Delta { + new: Some(&a), + old: None + } + .has_changed()); + assert!(Delta { + new: None, + old: Some(a.clone()) + } + .has_changed()); + assert!(!Delta { + new: Some(&a), + old: Some(a.clone()) + } + .has_changed()); + assert!(Delta { + new: Some(&b), + old: Some(a) + } + .has_changed()); + } + + #[test] + fn insert_reference_if_changed_value_skips_and_writes() { + let grove_version = GroveVersion::latest(); + let mut merk = TempMerk::new(grove_version); + + let reference = Element::new_reference( + grovedb_element::reference_path::ReferencePathType::AbsolutePathReference(vec![ + b"somewhere".to_vec(), + ]), + ); + + // Fresh insert: no previous value, write happens. + let delta = reference + .insert_reference_if_changed_value(&mut merk, b"r", [7; 32], None, grove_version) + .unwrap() + .expect("fresh reference insert"); + assert!(delta.has_changed()); + assert_eq!(delta.old, None); + + // Same element again: previous value equal, write skipped. + let delta = reference + .insert_reference_if_changed_value(&mut merk, b"r", [7; 32], None, grove_version) + .unwrap() + .expect("idempotent reference insert"); + assert!(!delta.has_changed()); + assert_eq!(delta.old, Some(reference.clone())); + + // Different element: write happens and old value is returned. + let other = Element::new_reference( + grovedb_element::reference_path::ReferencePathType::AbsolutePathReference(vec![ + b"elsewhere".to_vec(), + ]), + ); + let delta = other + .insert_reference_if_changed_value(&mut merk, b"r", [7; 32], None, grove_version) + .unwrap() + .expect("replacing reference insert"); + assert!(delta.has_changed()); + assert_eq!(delta.old, Some(reference)); + } + + #[test] + fn insert_subtree_if_changed_skips_and_writes() { + use crate::tree::hash::NULL_HASH; + + let grove_version = GroveVersion::latest(); + let mut merk = TempMerk::new(grove_version); + + let tree = Element::empty_tree(); + + let delta = tree + .insert_subtree_if_changed(&mut merk, b"t", NULL_HASH, None, grove_version) + .unwrap() + .expect("fresh subtree insert"); + assert!(delta.has_changed()); + assert_eq!(delta.old, None); + + let delta = tree + .insert_subtree_if_changed(&mut merk, b"t", NULL_HASH, None, grove_version) + .unwrap() + .expect("idempotent subtree insert"); + assert!(!delta.has_changed()); + assert_eq!(delta.old, Some(tree.clone())); + + let sum_tree = Element::empty_sum_tree(); + let delta = sum_tree + .insert_subtree_if_changed(&mut merk, b"t", NULL_HASH, None, grove_version) + .unwrap() + .expect("replacing subtree insert"); + assert!(delta.has_changed()); + assert_eq!(delta.old, Some(tree)); + } + + #[test] + fn insert_reference_if_changed_value_heals_moved_commitment() { + let grove_version = GroveVersion::latest(); + let mut merk = TempMerk::new(grove_version); + + let reference = Element::new_reference( + grovedb_element::reference_path::ReferencePathType::AbsolutePathReference(vec![ + b"somewhere".to_vec(), + ]), + ); + + reference + .insert_reference_if_changed_value(&mut merk, b"r", [7; 32], None, grove_version) + .unwrap() + .expect("fresh insert"); + let stored_before = Element::get_value_hash(&merk, b"r", true, grove_version) + .unwrap() + .unwrap() + .expect("hash present"); + + // Same element, different referenced hash: the element-wise delta is + // unchanged but the commitment moved — the write must still happen. + let delta = reference + .insert_reference_if_changed_value(&mut merk, b"r", [8; 32], None, grove_version) + .unwrap() + .expect("healing insert"); + assert!(!delta.has_changed()); + let stored_after = Element::get_value_hash(&merk, b"r", true, grove_version) + .unwrap() + .unwrap() + .expect("hash present"); + assert_ne!(stored_before, stored_after); + + // Same element AND same referenced hash: nothing to do, hash stable. + reference + .insert_reference_if_changed_value(&mut merk, b"r", [8; 32], None, grove_version) + .unwrap() + .expect("no-op insert"); + assert_eq!( + Element::get_value_hash(&merk, b"r", true, grove_version) + .unwrap() + .unwrap(), + Some(stored_after) + ); + } + + #[test] + fn insert_subtree_if_changed_heals_moved_root_hash() { + use crate::tree::hash::NULL_HASH; + + let grove_version = GroveVersion::latest(); + let mut merk = TempMerk::new(grove_version); + + let tree = Element::empty_tree(); + tree.insert_subtree_if_changed(&mut merk, b"t", NULL_HASH, None, grove_version) + .unwrap() + .expect("fresh insert"); + let stored_before = Element::get_value_hash(&merk, b"t", true, grove_version) + .unwrap() + .unwrap() + .expect("hash present"); + + // Unchanged element, moved child root: must rewrite. + let delta = tree + .insert_subtree_if_changed(&mut merk, b"t", [9; 32], None, grove_version) + .unwrap() + .expect("healing insert"); + assert!(!delta.has_changed()); + let stored_after = Element::get_value_hash(&merk, b"t", true, grove_version) + .unwrap() + .unwrap() + .expect("hash present"); + assert_ne!(stored_before, stored_after); + + // Same element AND same child root: nothing to do, hash stable. + tree.insert_subtree_if_changed(&mut merk, b"t", [9; 32], None, grove_version) + .unwrap() + .expect("no-op insert"); + assert_eq!( + Element::get_value_hash(&merk, b"t", true, grove_version) + .unwrap() + .unwrap(), + Some(stored_after) + ); + } } diff --git a/merk/src/element/mod.rs b/merk/src/element/mod.rs index 10b44eef7..b92512b14 100644 --- a/merk/src/element/mod.rs +++ b/merk/src/element/mod.rs @@ -22,6 +22,22 @@ pub mod reconstruct; /// Element tree type extensions. pub mod tree_type; +/// The hash components of a backward-references-capable element's node +/// value hash. See `grovedb_element::bidirectional_reference` for the +/// scheme. +#[derive(Debug, Clone, Copy)] +pub struct BackwardsReferencesHashes { + /// `H(serialize(element with backward_references = []))` — what forward + /// references and result sets commit to. + pub inner: [u8; 32], + /// `H(serialize(backward_references))`. + pub backrefs: [u8; 32], + /// `combine(inner, backrefs)` — the node value hash for the ITEM + /// variants. (A `BidirectionalReference`'s node value hash additionally + /// combines the resolved target hash: `combine3(inner, target, backrefs)`.) + pub combined: [u8; 32], +} + /// Extension trait for computing element value hashes. pub trait ElementExt { /// Computes the value hash for this element. @@ -29,6 +45,23 @@ pub trait ElementExt { &self, grove_version: &grovedb_version::version::GroveVersion, ) -> grovedb_costs::CostResult<[u8; 32], ElementError>; + + /// The hash a REFERENCE to this element must store — the "logical" + /// value hash. For backward-references-capable elements this is the + /// inner (stripped) hash, so registering/removing referrers never + /// invalidates hashes held by other referrers; for every other element + /// it is the plain serialized-bytes hash. + fn logical_value_hash( + &self, + grove_version: &grovedb_version::version::GroveVersion, + ) -> grovedb_costs::CostResult<[u8; 32], ElementError>; + + /// The backward-references hash components for this element, or `None` + /// for elements without backward-references capability. + fn backward_references_hashes( + &self, + grove_version: &grovedb_version::version::GroveVersion, + ) -> grovedb_costs::CostResult, ElementError>; } impl ElementExt for Element { @@ -39,4 +72,46 @@ impl ElementExt for Element { let bytes = grovedb_costs::cost_return_on_error_default!(self.serialize(grove_version)); value_hash(&bytes).map(Ok) } + + fn logical_value_hash( + &self, + grove_version: &grovedb_version::version::GroveVersion, + ) -> grovedb_costs::CostResult<[u8; 32], ElementError> { + if self.supports_backward_references() { + self.stripped_of_backward_references() + .value_hash(grove_version) + } else { + self.value_hash(grove_version) + } + } + + fn backward_references_hashes( + &self, + grove_version: &grovedb_version::version::GroveVersion, + ) -> grovedb_costs::CostResult, ElementError> { + use grovedb_costs::{cost_return_on_error, CostsExt}; + + let mut cost = Default::default(); + let Some(backward_references) = self.backward_references() else { + return Ok(None).wrap_with_cost(cost); + }; + let inner = cost_return_on_error!( + &mut cost, + self.stripped_of_backward_references() + .value_hash(grove_version) + ); + let backrefs_bytes = grovedb_costs::cost_return_on_error_no_add!( + cost, + grovedb_element::serialize_backward_references(backward_references) + ); + let backrefs = value_hash(&backrefs_bytes).unwrap_add_cost(&mut cost); + let combined = + crate::tree::hash::combine_hash(&inner, &backrefs).unwrap_add_cost(&mut cost); + Ok(Some(BackwardsReferencesHashes { + inner, + backrefs, + combined, + })) + .wrap_with_cost(cost) + } } diff --git a/merk/src/merk/chunks.rs b/merk/src/merk/chunks.rs index 30f45f0a2..a25f4c066 100644 --- a/merk/src/merk/chunks.rs +++ b/merk/src/merk/chunks.rs @@ -478,6 +478,7 @@ mod test { Node::KVHash(_) => counts.kv_hash += 1, Node::KV(..) => counts.kv += 1, Node::KVValueHash(..) => counts.kv_value_hash += 1, + Node::KVBackwardsReferencesValueHash(..) => counts.kv_value_hash += 1, Node::KVDigest(..) => counts.kv_digest += 1, Node::KVDigestCount(..) => counts.kv_digest += 1, Node::KVRefValueHash(..) => counts.kv_ref_value_hash += 1, diff --git a/merk/src/merk/mod.rs b/merk/src/merk/mod.rs index 04af10423..a100d577b 100644 --- a/merk/src/merk/mod.rs +++ b/merk/src/merk/mod.rs @@ -42,6 +42,9 @@ pub mod clear; pub mod committer; /// Getting values by key from a Merk tree. pub mod get; +/// Metadata (non-authenticated KV) access for Merk trees, cached in memory +/// alongside uncommitted tree state. +mod meta; /// Opening and loading a Merk tree from storage. pub mod open; /// Generating Merkle proofs for queries against a Merk tree. @@ -56,7 +59,7 @@ pub mod source; use std::{ cell::Cell, - collections::{BTreeMap, BTreeSet, LinkedList}, + collections::{BTreeMap, BTreeSet, HashMap, LinkedList}, fmt, }; @@ -299,6 +302,10 @@ pub struct Merk { pub storage: S, /// How this Merk is managed: standalone, base, or layered under a parent. pub merk_type: MerkType, + /// Metadata storage cache. Like uncommitted tree state, meta KV writes + /// live in memory until the surrounding batch commits, so reads must + /// see them. + meta_cache: HashMap, Option>>, /// The kind of data this tree holds (normal, sum, count, etc.). pub tree_type: TreeType, } diff --git a/merk/src/merk/open.rs b/merk/src/merk/open.rs index 5f075157b..522a83828 100644 --- a/merk/src/merk/open.rs +++ b/merk/src/merk/open.rs @@ -22,6 +22,7 @@ where root_tree_key: Cell::new(None), storage, merk_type, + meta_cache: Default::default(), tree_type, } } @@ -41,6 +42,7 @@ where storage, merk_type: StandaloneMerk, tree_type, + meta_cache: Default::default(), }; merk.load_base_root(value_defined_cost_fn, grove_version) @@ -62,6 +64,7 @@ where storage, merk_type: BaseMerk, tree_type, + meta_cache: Default::default(), }; merk.load_base_root(value_defined_cost_fn, grove_version) @@ -84,6 +87,7 @@ where storage, merk_type: LayeredMerk, tree_type, + meta_cache: Default::default(), }; merk.load_root(value_defined_cost_fn, grove_version) diff --git a/merk/src/merk/restore.rs b/merk/src/merk/restore.rs index d95c647ad..1fc1e9edc 100644 --- a/merk/src/merk/restore.rs +++ b/merk/src/merk/restore.rs @@ -188,24 +188,55 @@ impl<'db, S: StorageContext<'db>> Restorer { let mut root_traversal_instruction = vec_bytes_as_traversal_instruction(chunk_id)?; - if root_traversal_instruction.is_empty() { - self.merk - .set_base_root_key(chunk_tree.key().map(|k| k.to_vec())) - .value?; + // The parent-link rewrite (and the root-key set) mutate committed + // restorer state, so they run only AFTER the chunk write — whose + // validations (including the backward-references bytes/hash + // recompute) may still reject the chunk. Mutating first would + // consume the `parent_keys` entry and leave a valid retry of the + // same chunk unable to proceed. Capture what the rewrite needs + // before the write consumes the tree. + let updated_parent_link = if root_traversal_instruction.is_empty() { + None } else { - // every non root chunk has some associated parent with an placeholder link - // here we update the placeholder link to represent the true data - self.rewrite_parent_link( - chunk_id, - &root_traversal_instruction, - &chunk_tree, - grove_version, - )?; - } + // A chunk whose root is a bare `Hash` node verifies (its + // proof-tree hash IS the expected hash) but carries no key to + // link the parent to. It is untrusted network input, so refuse + // it descriptively. + let updated_key = chunk_tree + .key() + .ok_or(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "non-root chunk cannot be a bare hash node", + )))? + .to_vec(); + let updated_aggregate = chunk_tree.aggregate_data().map_err(|e| { + Error::CorruptedData(format!( + "chunk tree root node must be KVValueHashFeatureType for aggregate data: {e}" + )) + })?; + Some((updated_key, updated_aggregate)) + }; + let root_key = chunk_tree.key().map(|k| k.to_vec()); // next up, we need to write the chunk and build the map again - let chunk_write_result = self.write_chunk(chunk_tree, &mut root_traversal_instruction); + let chunk_write_result = + self.write_chunk(chunk_tree, &mut root_traversal_instruction, grove_version); if chunk_write_result.is_ok() { + match updated_parent_link { + None => { + self.merk.set_base_root_key(root_key).value?; + } + Some((updated_key, updated_aggregate)) => { + // every non root chunk has some associated parent with a + // placeholder link; update it to represent the true data + self.rewrite_parent_link( + chunk_id, + &root_traversal_instruction, + &updated_key, + updated_aggregate, + grove_version, + )?; + } + } // if we were able to successfully write the chunk, we can remove // the chunk expected root hash from our chunk id map self.chunk_id_to_root_hash.remove(chunk_id); @@ -346,6 +377,7 @@ impl<'db, S: StorageContext<'db>> Restorer { &mut self, chunk_tree: ProofTree, traversal_instruction: &mut Vec, + grove_version: &GroveVersion, ) -> Result>, Error> { // this contains all the elements we want to write to storage let mut batch = self.merk.storage.new_batch(); @@ -374,6 +406,42 @@ impl<'db, S: StorageContext<'db>> Restorer { &mut |proof_node, node_traversal_instruction, parent_key| { match &proof_node.node { Node::KVValueHashFeatureType(key, value, vh, feature_type) => { + // A backward-references ITEM's stored value hash is + // combine(H(stripped), H(referrer list)) — fully + // recomputable from the carried bytes. Recompute and + // compare so a crafted chunk cannot persist a + // bytes/hash pair that never hashes together (a + // bidirectional reference's end-hash component is not + // locally derivable, matching the existing trust + // model for plain references in chunks). + if matches!( + grovedb_element::ElementType::from_serialized_value(value) + .map(|et| et.base()), + Ok(grovedb_element::ElementType::ItemWithBackwardsReferences + | grovedb_element::ElementType::SumItemWithBackwardsReferences + | grovedb_element::ElementType::ItemWithSumItemWithBackwardsReferences) + ) { + use crate::element::ElementExt; + let expected = + grovedb_element::Element::deserialize(value, grove_version) + .ok() + .and_then(|element| { + element + .backward_references_hashes(grove_version) + .unwrap() + .ok() + .flatten() + }) + .map(|hashes| hashes.combined); + if expected != Some(*vh) { + return Err(Error::ChunkRestoringError( + ChunkError::InvalidChunkProof( + "backward-references element bytes do not hash to the \ + carried value hash", + ), + )); + } + } // build tree from node value let mut tree = TreeNode::new_with_value_hash( key.clone(), @@ -415,6 +483,26 @@ impl<'db, S: StorageContext<'db>> Restorer { batch.put(key, &bytes, None, None).map_err(CostsError) } Node::KVValueHash(key, value, vh) => { + // Backward-references ITEM variants must arrive as + // KVValueHashFeatureType (where a recompute check + // binds the bytes to the combined hash); accepting + // them here would let the bytes ride unbound on the + // carried hash. A `BidirectionalReference` is the + // exception: the chunk producer legitimately emits + // it in this shape (`KvRefValueHash` maps here for + // normal trees), and its value hash includes the + // resolved end-of-chain hash, which is not locally + // derivable — so it keeps exactly the trust model + // chunks give plain references. + if grovedb_element::ElementType::from_serialized_value(value) + .map(|et| et.is_backward_references_item()) + .unwrap_or(false) + { + return Err(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "backward-references items must be carried in \ + KVValueHashFeatureType chunk nodes", + ))); + } // Subtrees/references in normal trees: value_hash is // provided (may be a combined hash for subtrees), // feature_type = BasicMerkNode @@ -539,7 +627,8 @@ impl<'db, S: StorageContext<'db>> Restorer { &mut self, chunk_id: &[u8], traversal_instruction: &[bool], - chunk_tree: &ProofTree, + updated_key: &[u8], + updated_aggregate: AggregateData, grove_version: &GroveVersion, ) -> Result<(), Error> { let parent_key = self @@ -563,21 +652,6 @@ impl<'db, S: StorageContext<'db>> Restorer { .last() .expect("rewrite is only called when traversal_instruction is not empty"); - // A chunk whose root is a bare `Hash` node verifies (its proof-tree - // hash IS the expected hash) but carries no key to link the parent - // to. It is untrusted network input, so refuse it descriptively. - let updated_key = - chunk_tree - .key() - .ok_or(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( - "non-root chunk cannot be a bare hash node", - )))?; - let updated_sum = chunk_tree.aggregate_data().map_err(|e| { - Error::CorruptedData(format!( - "chunk tree root node must be KVValueHashFeatureType for aggregate data: {e}" - )) - })?; - if let Some(Link::Reference { key, aggregate_data, @@ -585,7 +659,7 @@ impl<'db, S: StorageContext<'db>> Restorer { }) = parent.link_mut(*is_left) { *key = updated_key.to_vec(); - *aggregate_data = updated_sum; + *aggregate_data = updated_aggregate; } let parent_bytes = parent.encode(); diff --git a/merk/src/proofs/branch/mod.rs b/merk/src/proofs/branch/mod.rs index 91e318d8d..df3e08cdc 100644 --- a/merk/src/proofs/branch/mod.rs +++ b/merk/src/proofs/branch/mod.rs @@ -113,6 +113,7 @@ impl TrunkQueryResult { match node { Node::KV(key, _) | Node::KVValueHash(key, ..) + | Node::KVBackwardsReferencesValueHash(key, ..) | Node::KVValueHashFeatureType(key, ..) | Node::KVValueHashFeatureTypeWithChildHash(key, ..) | Node::KVDigest(key, _) @@ -389,6 +390,7 @@ impl BranchQueryResult { match node { Node::KV(key, _) | Node::KVValueHash(key, ..) + | Node::KVBackwardsReferencesValueHash(key, ..) | Node::KVValueHashFeatureType(key, ..) | Node::KVValueHashFeatureTypeWithChildHash(key, ..) | Node::KVDigest(key, _) diff --git a/merk/src/proofs/chunk/chunk.rs b/merk/src/proofs/chunk/chunk.rs index 9db1659ac..77e3cbca4 100644 --- a/merk/src/proofs/chunk/chunk.rs +++ b/merk/src/proofs/chunk/chunk.rs @@ -198,6 +198,15 @@ where ProofNodeType::KvSum => self.to_kv_sum_node(), ProofNodeType::KvCountSum => self.to_kv_count_sum_node(), ProofNodeType::KvValueHash => self.to_kv_value_hash_node(), + // Chunks must restore the FULL element (the referrer list is + // state), so the stripped/backrefs query node cannot be used. + // Emit KVValueHashFeatureType: it carries the full bytes, the + // stored (combined) value hash, and the feature type — and the + // restorer recomputes the combined hash from the bytes for the + // item variants, binding them (see `write_chunk`). + ProofNodeType::KvBackwardsReferencesValueHash => { + self.to_kv_value_hash_feature_type_node() + } ProofNodeType::KvValueHashFeatureType => self.to_kv_value_hash_feature_type_node(), // References: at merk level, generate same node type as non-ref counterpart // GroveDB will post-process if needed diff --git a/merk/src/proofs/query/count_offset/emit.rs b/merk/src/proofs/query/count_offset/emit.rs index ed2ac2245..bdbc938d3 100644 --- a/merk/src/proofs/query/count_offset/emit.rs +++ b/merk/src/proofs/query/count_offset/emit.rs @@ -562,6 +562,10 @@ where match kind { ProofNodeType::Kv => walker.to_kv_node(), + // Unreachable: backward-references elements are rejected inside + // Provable* count trees at insertion; fall back to the trusted + // value-hash shape defensively. + ProofNodeType::KvBackwardsReferencesValueHash => walker.to_kv_value_hash_node(), ProofNodeType::KvCount => Node::KVCount(key, value_bytes.to_vec(), count), ProofNodeType::KvCountSum => { // PCPS host, Item-flavored entry: emit the dual-axis diff --git a/merk/src/proofs/query/mod.rs b/merk/src/proofs/query/mod.rs index 198cbe2de..d8ffeb184 100644 --- a/merk/src/proofs/query/mod.rs +++ b/merk/src/proofs/query/mod.rs @@ -487,6 +487,47 @@ where ProofNodeType::KvSum => self.to_kv_sum_node(), ProofNodeType::KvCountSum => self.to_kv_count_sum_node(), ProofNodeType::KvValueHash => self.to_kv_value_hash_node(), + // Backward-references elements: emit the dedicated wire + // node — STRIPPED bytes plus the referrer-list hash — so + // the verifier recombines and binds the payload. The stored + // bytes always deserialize for honestly written elements; + // failing to rebuild means corrupted storage, and emitting a + // plain KVValueHash instead would only move the failure to + // the verifier with a misleading message — error out here. + ProofNodeType::KvBackwardsReferencesValueHash => { + use crate::element::ElementExt; + let rebuilt = grovedb_element::Element::deserialize( + self.tree().value_ref(), + grove_version, + ) + .ok() + .and_then(|element| { + let hashes = element + .backward_references_hashes(grove_version) + .unwrap_add_cost(&mut cost) + .ok() + .flatten()?; + let stripped = element + .stripped_of_backward_references() + .serialize(grove_version) + .ok()?; + Some(Node::KVBackwardsReferencesValueHash( + self.tree().key().to_vec(), + stripped, + hashes.backrefs, + )) + }); + match rebuilt { + Some(node) => node, + None => { + return Err(Error::CorruptedData(format!( + "cannot rebuild the backward-references proof node for key {}", + hex::encode(self.tree().key()) + ))) + .wrap_with_cost(cost); + } + } + } ProofNodeType::KvValueHashFeatureType => self.to_kv_value_hash_feature_type_node(), // References: at merk level, generate same node type as non-ref counterpart // GroveDB will post-process to KVRefValueHash with dereferenced value diff --git a/merk/src/proofs/query/verify.rs b/merk/src/proofs/query/verify.rs index 4b64ec7fc..6a4f5f6d7 100644 --- a/merk/src/proofs/query/verify.rs +++ b/merk/src/proofs/query/verify.rs @@ -202,7 +202,8 @@ impl QueryProofVerify for Query { let mut execute_node = |key: &Vec, value: Option<&Vec>, value_hash: CryptoHash, - child_hash_verified: bool| + child_hash_verified: bool, + plain_trusted_value: bool| -> Result<_, Error> { while let Some(item) = query.peek() { // get next item in query @@ -256,6 +257,7 @@ impl QueryProofVerify for Query { Some(Node::KVDigestSum(..)) => {} Some(Node::KVRefValueHash(..)) => {} Some(Node::KVValueHash(..)) => {} + Some(Node::KVBackwardsReferencesValueHash(..)) => {} Some(Node::KVValueHashFeatureType(..)) => {} Some(Node::KVValueHashFeatureTypeWithChildHash(..)) => {} Some(Node::KVRefValueHashCount(..)) => {} @@ -298,6 +300,7 @@ impl QueryProofVerify for Query { Some(Node::KVDigestSum(..)) => {} Some(Node::KVRefValueHash(..)) => {} Some(Node::KVValueHash(..)) => {} + Some(Node::KVBackwardsReferencesValueHash(..)) => {} Some(Node::KVValueHashFeatureType(..)) => {} Some(Node::KVValueHashFeatureTypeWithChildHash(..)) => {} Some(Node::KVRefValueHashCount(..)) => {} @@ -355,6 +358,29 @@ impl QueryProofVerify for Query { // this push matches the queried item if query_item.contains(key) { if let Some(val) = value { + // Terminal downgrade guard (V1 strict): the V4 + // prover rewrites every bidirectional-reference + // node — result or filler — into a + // KVRefValueHash* node whose target bytes are + // bound by recomputation. One arriving as a plain + // trusted-value result is therefore a + // downgraded/forged node whose bytes ride unbound + // on the carried hash. (Plain references can + // legitimately appear raw in mixed-level V1 + // proofs and keep their long-standing handling.) + if plain_trusted_value + && proof_version >= 1 + && matches!( + ElementType::from_serialized_value(val).map(|et| et.base()), + Ok(ElementType::BidirectionalReference) + ) + { + return Err(Error::InvalidProofError( + "bidirectional-reference elements must be dereferenced \ + into KVRefValueHash-family nodes in proof results" + .to_string(), + )); + } if let Some(limit) = current_limit { if limit == 0 { return Err(Error::InvalidProofError(format!( @@ -408,7 +434,7 @@ impl QueryProofVerify for Query { { println!("Processing KV node"); } - execute_node(key, Some(value), value_hash(value).unwrap(), false)?; + execute_node(key, Some(value), value_hash(value).unwrap(), false, false)?; } Node::KVValueHash(key, value, value_hash) => { #[cfg(feature = "proof_debug")] @@ -445,36 +471,80 @@ impl QueryProofVerify for Query { "KVValueHash node must not contain an item element".to_string(), )); } + // Backward-references elements must come through + // KVBackwardsReferencesValueHash, whose combined + // hash is RECOMPUTED — as a KVValueHash the value + // bytes would ride unbound on the carried hash. + if matches!( + element_type.base(), + ElementType::ItemWithBackwardsReferences + | ElementType::SumItemWithBackwardsReferences + | ElementType::ItemWithSumItemWithBackwardsReferences + ) { + return Err(Error::InvalidProofError( + "KVValueHash node must not contain a backward-references \ + element; use KVBackwardsReferencesValueHash" + .to_string(), + )); + } } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, true)?; } Node::KVDigest(key, value_hash) => { #[cfg(feature = "proof_debug")] { println!("Processing KVDigest node"); } - execute_node(key, None, *value_hash, false)?; + execute_node(key, None, *value_hash, false, false)?; } Node::KVDigestCount(key, value_hash, _count) => { #[cfg(feature = "proof_debug")] { println!("Processing KVDigestCount node"); } - execute_node(key, None, *value_hash, false)?; + execute_node(key, None, *value_hash, false, false)?; } Node::KVRefValueHash(key, value, value_hash) => { #[cfg(feature = "proof_debug")] { println!("Processing KVRefValueHash node"); } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, false)?; + } + Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash) => { + #[cfg(feature = "proof_debug")] + { + println!("Processing KVBackwardsReferencesValueHash node"); + } + // The node kind was introduced with GROVE_V4 / V1 + // envelopes; a V0 proof carrying it would be accepted + // here but rejected by every released verifier. + if proof_version == 0 { + return Err(Error::InvalidProofError( + "KVBackwardsReferencesValueHash nodes are not allowed in V0 proofs" + .to_string(), + )); + } + // The node's combined hash is recomputed from the + // stripped payload bytes it carries, so the bytes are + // bound; the result set receives the stripped element. + // The row is reported as hash-bound (`combine_hash(H(value), + // backrefs_hash) == value_hash` was checked end to end), + // the same evidence a child-hash node yields — readers that + // classify rows from their bytes may trust these bytes. + let combined = value_hash(value) + .unwrap() + .wrap_with_cost(Default::default()) + .flat_map(|inner| crate::tree::hash::combine_hash(&inner, backrefs_hash)) + .unwrap(); + execute_node(key, Some(value), combined, true, false)?; } Node::KVCount(key, value, _count) => { #[cfg(feature = "proof_debug")] { println!("Processing KVCount node"); } - execute_node(key, Some(value), value_hash(value).unwrap(), false)?; + execute_node(key, Some(value), value_hash(value).unwrap(), false, false)?; } Node::KVValueHashFeatureType(key, value, value_hash, _feature_type) => { #[cfg(feature = "proof_debug")] @@ -501,15 +571,28 @@ impl QueryProofVerify for Query { .to_string(), )); } + // Same rationale as the KVValueHash guard above. + if matches!( + element_type.base(), + ElementType::ItemWithBackwardsReferences + | ElementType::SumItemWithBackwardsReferences + | ElementType::ItemWithSumItemWithBackwardsReferences + ) { + return Err(Error::InvalidProofError( + "KVValueHashFeatureType node must not contain a \ + backward-references element" + .to_string(), + )); + } } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, true)?; } Node::KVRefValueHashCount(key, value, value_hash, _count) => { #[cfg(feature = "proof_debug")] { println!("Processing KVRefValueHashCount node"); } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, false)?; } Node::KVValueHashFeatureTypeWithChildHash( key, @@ -555,7 +638,7 @@ impl QueryProofVerify for Query { hex::encode(node_value_hash) ))); } - execute_node(key, Some(value), *node_value_hash, true)?; + execute_node(key, Some(value), *node_value_hash, true, false)?; } Node::Hash(_) | Node::KVHash(_) @@ -617,42 +700,42 @@ impl QueryProofVerify for Query { { println!("Processing KVSum node"); } - execute_node(key, Some(value), value_hash(value).unwrap(), false)?; + execute_node(key, Some(value), value_hash(value).unwrap(), false, false)?; } Node::KVDigestSum(key, value_hash, _sum) => { #[cfg(feature = "proof_debug")] { println!("Processing KVDigestSum node"); } - execute_node(key, None, *value_hash, false)?; + execute_node(key, None, *value_hash, false, false)?; } Node::KVRefValueHashSum(key, value, value_hash, _sum) => { #[cfg(feature = "proof_debug")] { println!("Processing KVRefValueHashSum node"); } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, false)?; } Node::KVCountSum(key, value, _count, _sum) => { #[cfg(feature = "proof_debug")] { println!("Processing KVCountSum node"); } - execute_node(key, Some(value), value_hash(value).unwrap(), false)?; + execute_node(key, Some(value), value_hash(value).unwrap(), false, false)?; } Node::KVDigestCountSum(key, value_hash, _count, _sum) => { #[cfg(feature = "proof_debug")] { println!("Processing KVDigestCountSum node"); } - execute_node(key, None, *value_hash, false)?; + execute_node(key, None, *value_hash, false, false)?; } Node::KVRefValueHashCountSum(key, value, value_hash, _count, _sum) => { #[cfg(feature = "proof_debug")] { println!("Processing KVRefValueHashCountSum node"); } - execute_node(key, Some(value), *value_hash, false)?; + execute_node(key, Some(value), *value_hash, false, false)?; } } @@ -683,6 +766,7 @@ impl QueryProofVerify for Query { Some(Node::KVDigestCount(..)) => {} Some(Node::KVRefValueHash(..)) => {} Some(Node::KVValueHash(..)) => {} + Some(Node::KVBackwardsReferencesValueHash(..)) => {} Some(Node::KVCount(..)) => {} Some(Node::KVValueHashFeatureType(..)) => {} Some(Node::KVValueHashFeatureTypeWithChildHash(..)) => {} @@ -752,9 +836,12 @@ pub struct ProvedKeyOptionalValue { pub value: Option>, /// Proof pub proof: CryptoHash, - /// Whether the merk verifier confirmed combine_hash(H(value), child_hash) - /// == value_hash for this element (true only for - /// KVValueHashFeatureTypeWithChildHash nodes). + /// Whether the merk verifier confirmed `combine_hash(H(value), other) + /// == value_hash` for this element, binding the presented value bytes + /// through a recomputed combined hash. True for + /// `KVValueHashFeatureTypeWithChildHash` nodes (`other` = the carried + /// child hash) and for `KVBackwardsReferencesValueHash` nodes (`other` + /// = the referrer-list hash, recomputed into the merk root itself). pub child_hash_verified: bool, } diff --git a/merk/src/proofs/tree.rs b/merk/src/proofs/tree.rs index d0d24e8ac..7c8dc84a8 100644 --- a/merk/src/proofs/tree.rs +++ b/merk/src/proofs/tree.rs @@ -156,6 +156,16 @@ impl Tree { kv_digest_to_kv_hash(key.as_slice(), value_hash) .flat_map(|kv_hash| compute_hash(self, kv_hash)) } + Node::KVBackwardsReferencesValueHash(key, value, backrefs_hash) => { + // The node's value hash is combine(H(stripped_value), + // backrefs_hash) and we RECOMPUTE it here, so the payload + // bytes are bound by the proof (unlike KVValueHash, whose + // bytes ride on trust in the carried hash). + value_hash(value.as_slice()) + .flat_map(|inner| combine_hash(&inner, backrefs_hash)) + .flat_map(|vh| kv_digest_to_kv_hash(key.as_slice(), &vh)) + .flat_map(|kv_hash| compute_hash(self, kv_hash)) + } Node::KVValueHashFeatureType(key, _, value_hash, feature_type) | Node::KVValueHashFeatureTypeWithChildHash(key, _, value_hash, feature_type, _) => { // Note: Same as KVValueHash - cannot verify hash(value) == value_hash @@ -565,6 +575,7 @@ impl Tree { match &self.node { Node::KV(key, _) | Node::KVValueHash(key, ..) + | Node::KVBackwardsReferencesValueHash(key, ..) | Node::KVRefValueHash(key, ..) | Node::KVValueHashFeatureType(key, ..) | Node::KVValueHashFeatureTypeWithChildHash(key, ..) @@ -850,7 +861,8 @@ where | Node::KVRefValueHashSum(key, ..) | Node::KVCountSum(key, ..) | Node::KVDigestCountSum(key, ..) - | Node::KVRefValueHashCountSum(key, ..) = &node + | Node::KVRefValueHashCountSum(key, ..) + | Node::KVBackwardsReferencesValueHash(key, ..) = &node { // keys should always increase if let Some(last_key) = &maybe_last_key @@ -894,7 +906,8 @@ where | Node::KVRefValueHashSum(key, ..) | Node::KVCountSum(key, ..) | Node::KVDigestCountSum(key, ..) - | Node::KVRefValueHashCountSum(key, ..) = &node + | Node::KVRefValueHashCountSum(key, ..) + | Node::KVBackwardsReferencesValueHash(key, ..) = &node { // keys should always decrease if let Some(last_key) = &maybe_last_key @@ -1075,6 +1088,53 @@ mod test { assert!(iter.next().is_none()); } + #[test] + fn backwards_references_nodes_enforce_key_ordering() { + // Regression: `KVBackwardsReferencesValueHash` carries a key and + // must participate in the Push/PushInverted ordering checks — + // otherwise a malicious proof could push an authenticated parent + // before its real left child (attached via ChildInverted) and + // make an exact query for the child read as absent while the + // reconstructed root still matches. + let parent_before_child = vec![ + Op::Push(Node::KVBackwardsReferencesValueHash( + vec![2], + vec![2], + [0; 32], + )), + Op::Push(Node::KVBackwardsReferencesValueHash( + vec![1], + vec![1], + [0; 32], + )), + Op::ChildInverted, + ]; + let result = execute(parent_before_child.into_iter().map(Ok), false, |_| Ok(())).unwrap(); + assert!( + matches!(result, Err(Error::InvalidProofError(ref s)) if s.contains("ordering")), + "got: {result:?}" + ); + + let inverted_wrong_order = vec![ + Op::PushInverted(Node::KVBackwardsReferencesValueHash( + vec![1], + vec![1], + [0; 32], + )), + Op::PushInverted(Node::KVBackwardsReferencesValueHash( + vec![2], + vec![2], + [0; 32], + )), + Op::Child, + ]; + let result = execute(inverted_wrong_order.into_iter().map(Ok), false, |_| Ok(())).unwrap(); + assert!( + matches!(result, Err(Error::InvalidProofError(ref s)) if s.contains("ordering")), + "got: {result:?}" + ); + } + #[test] fn execute_non_avl_tree() { let non_avl_tree_proof = vec![ diff --git a/merk/src/tree/kv.rs b/merk/src/tree/kv.rs index a7bc32b47..53e6f13ff 100644 --- a/merk/src/tree/kv.rs +++ b/merk/src/tree/kv.rs @@ -248,6 +248,22 @@ impl KV { self.wrap_with_cost(cost) } + /// Sets the value hash to the provided (already fully computed) hash + /// and recomputes the kv hash. Used for backward-references elements, + /// whose value hash is combined grovedb-side from the STRIPPED + /// serialization plus the backward-references hash — neither of which + /// merk can derive from the stored bytes. + #[inline] + pub fn update_hashes_with_provided_value_hash( + mut self, + provided_value_hash: CryptoHash, + ) -> CostContext { + let mut cost = OperationCost::default(); + self.value_hash = provided_value_hash; + self.hash = kv_digest_to_kv_hash(self.key(), self.value_hash()).unwrap_add_cost(&mut cost); + self.wrap_with_cost(cost) + } + /// Updates the hashes and returns the modified `KV`. #[inline] pub fn update_hashes_using_reference_value_hash( diff --git a/merk/src/tree/mod.rs b/merk/src/tree/mod.rs index a3deb54d0..bb6351908 100644 --- a/merk/src/tree/mod.rs +++ b/merk/src/tree/mod.rs @@ -1183,6 +1183,164 @@ impl TreeNode { Ok(self).wrap_with_cost(cost) } + /// Replaces the value and sets the node's value hash to the provided + /// (already fully computed) hash. Used by backward-references elements. + /// + /// `end_hash` is the resolved end-of-chain hash a bidirectional + /// reference commits to (`None` for the item variants). It is needed + /// only when a just-in-time value update rewrites the bytes of a + /// replaced element: the node value hash is then recomputed from the + /// final bytes with the family's two-layer scheme, so a flags-update + /// callback can never commit bytes that disagree with their hash. + #[allow(clippy::too_many_arguments)] + pub fn put_value_with_provided_value_hash( + mut self, + value: Vec, + value_hash: CryptoHash, + end_hash: Option, + feature_type: TreeFeatureType, + old_specialized_cost: &impl Fn(&Vec, &Vec) -> Result, + get_temp_new_value_with_old_flags: &impl Fn( + &Vec, + &Vec, + ) -> Result>, Error>, + update_tree_value_based_on_costs: &mut impl FnMut( + &StorageCost, + &Vec, + &mut Vec, + ) -> Result< + (bool, Option), + Error, + >, + section_removal_bytes: &mut impl FnMut( + &Vec, + u32, + u32, + ) -> Result< + (StorageRemovedBytes, StorageRemovedBytes), + Error, + >, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = OperationCost::default(); + + self.inner.kv = self.inner.kv.put_value_no_update_of_hashes(value); + self.inner.kv.feature_type = feature_type; + + let mut value_hash = value_hash; + if self.old_value.is_some() { + // we are replacing a value + // in this case there is a possibility that the client would want to update the + // element flags based on the change of values + let value_before_update = self.inner.kv.value_as_slice().to_vec(); + cost_return_on_error_no_add!( + cost, + self.just_in_time_tree_node_value_update( + old_specialized_cost, + get_temp_new_value_with_old_flags, + update_tree_value_based_on_costs, + section_removal_bytes + ) + ); + // The provided hash was computed over the bytes supplied by the + // caller. A just-in-time value mutation (a flags carry-over or + // a flags-update callback rewrite, e.g. storage flags absorbing + // the bytes an added referrer entry costs) changes those bytes, + // so the node value hash is recomputed from the FINAL bytes + // with the family's two-layer scheme: combine(H(stripped), + // H(referrer list)), further combined with the end hash for a + // bidirectional reference. Only backward-references elements + // are written this way; anything else mutated here would + // commit bytes no scheme rebinds — fail closed. + if self.inner.kv.value_as_slice() != value_before_update.as_slice() { + value_hash = cost_return_on_error!( + &mut cost, + Self::recompute_backward_references_value_hash( + self.inner.kv.value_as_slice(), + end_hash, + grove_version, + ) + ); + } + } + + self.inner.kv = self + .inner + .kv + .update_hashes_with_provided_value_hash(value_hash) + .unwrap_add_cost(&mut cost); + Ok(self).wrap_with_cost(cost) + } + + /// The node value hash a backward-references element with these stored + /// bytes commits to: `combine(H(stripped), H(referrer list))`, combined + /// once more with `end_hash` for a bidirectional reference (which must + /// carry one). Errors for bytes that are not a backward-references + /// element. + fn recompute_backward_references_value_hash( + bytes: &[u8], + end_hash: Option, + grove_version: &GroveVersion, + ) -> CostResult { + use crate::element::ElementExt; + + let mut cost = OperationCost::default(); + let element = cost_return_on_error_no_add!( + cost, + grovedb_element::Element::deserialize(bytes, grove_version).map_err(|e| { + Error::ClientCorruptionError(format!( + "a just-in-time value update left a provided-value-hash write with bytes \ + that do not decode as an element: {e}" + )) + }) + ); + let hashes = cost_return_on_error!( + &mut cost, + element + .backward_references_hashes(grove_version) + .map_err(|e| Error::ClientCorruptionError(format!( + "cannot rehash a provided-value-hash write after a just-in-time value \ + update: {e}" + ))) + ); + let Some(hashes) = hashes else { + return Err(Error::ClientCorruptionError( + "a just-in-time value update cannot apply to a provided-value-hash write of a \ + non-backward-references element: the mutated bytes would no longer match \ + the authenticated hash" + .to_string(), + )) + .wrap_with_cost(cost); + }; + let is_reference = matches!( + element, + grovedb_element::Element::BidirectionalReference(..) + ); + let value_hash = match (is_reference, end_hash) { + (true, Some(end_hash)) => { + hash::combine_hash(&hashes.combined, &end_hash).unwrap_add_cost(&mut cost) + } + (false, None) => hashes.combined, + (true, None) => { + return Err(Error::ClientCorruptionError( + "a provided-value-hash write of a bidirectional reference must carry its \ + end hash to survive a just-in-time value update" + .to_string(), + )) + .wrap_with_cost(cost); + } + (false, Some(_)) => { + return Err(Error::ClientCorruptionError( + "a provided-value-hash write carries an end hash but its bytes are not a \ + bidirectional reference" + .to_string(), + )) + .wrap_with_cost(cost); + } + }; + Ok(value_hash).wrap_with_cost(cost) + } + /// H1-A variant: replaces the root node's value with the given value, /// computing the value hash from /// `Blake3(actual_value_hash ‖ primary_root_hash ‖ secondary_root_hash)` diff --git a/merk/src/tree/ops.rs b/merk/src/tree/ops.rs index 96e9df036..3c6da105f 100644 --- a/merk/src/tree/ops.rs +++ b/merk/src/tree/ops.rs @@ -45,6 +45,16 @@ pub enum Op { /// because the value is independent of the reference hash /// In GroveDB this is used for references PutCombinedReference(Vec, CryptoHash, TreeFeatureType), + /// Insert or Update an element whose node value hash is supplied fully + /// computed by the caller. In GroveDB this is used for + /// backward-references elements, whose value hash combines the STRIPPED + /// serialization's hash with the backward-references hash (and, for a + /// bidirectional reference, with the resolved end-of-chain hash carried + /// in the third field). When a just-in-time value update (a flags + /// carry-over or a flags-update callback) rewrites the bytes of a + /// replaced element, the value hash is recomputed from the final bytes + /// with the same scheme, so the committed bytes and hash always agree. + PutWithProvidedValueHash(Vec, CryptoHash, Option, TreeFeatureType), /// `Layered references` include the value in the node hash /// because the value is independent of the reference hash /// In GroveDB this is used for trees @@ -92,6 +102,10 @@ impl fmt::Debug for Op { PutWithSpecializedCost(value, cost, feature_type) => format!( "Put Specialized Cost({value:?}) with cost ({cost:?}) for ({feature_type:?})" ), + PutWithProvidedValueHash(value, value_hash, end_hash, feature_type) => format!( + "Put Provided Value Hash({value:?}) with hash ({value_hash:?}) end hash \ + ({end_hash:?}) ({feature_type:?})" + ), PutCombinedReference(value, referenced_value, feature_type) => format!( "Put Combined Reference({value:?}) for ({referenced_value:?}). \ ({feature_type:?})" @@ -367,6 +381,7 @@ where Put(value, feature_type) | PutWithSpecializedCost(value, .., feature_type) | PutCombinedReference(value, .., feature_type) + | PutWithProvidedValueHash(value, .., feature_type) | PutLayeredReference(value, .., feature_type) | ReplaceLayeredReference(value, .., feature_type) | PutLayeredCountIndexedReference(value, .., feature_type) @@ -399,6 +414,13 @@ where mid_feature_type.to_owned(), ) .unwrap_add_cost(&mut cost), + PutWithProvidedValueHash(_, value_hash, _, _) => TreeNode::new_with_value_hash( + mid_key.as_ref().to_vec(), + mid_value, + value_hash.to_owned(), + mid_feature_type.to_owned(), + ) + .unwrap_add_cost(&mut cost), PutLayeredReference(_, value_cost, referenced_value, _) | ReplaceLayeredReference(_, value_cost, referenced_value, _) => { TreeNode::new_with_layered_value_hash( @@ -589,6 +611,22 @@ where ) ) } + PutWithProvidedValueHash(value, value_hash, end_hash, feature_type) => { + cost_return_on_error!( + &mut cost, + self.put_value_with_provided_value_hash( + value.to_vec(), + value_hash.to_owned(), + end_hash.to_owned(), + feature_type.to_owned(), + old_specialized_cost, + get_temp_new_value_with_old_flags, + update_tree_value_based_on_costs, + section_removal_bytes, + grove_version, + ) + ) + } PutLayeredReference(value, value_cost, referenced_value, feature_type) | ReplaceLayeredReference(value, value_cost, referenced_value, feature_type) => { cost_return_on_error!( diff --git a/merk/src/tree/walk/mod.rs b/merk/src/tree/walk/mod.rs index 393cdef27..e125ed48e 100644 --- a/merk/src/tree/walk/mod.rs +++ b/merk/src/tree/walk/mod.rs @@ -359,6 +359,57 @@ where Ok(self).wrap_with_cost(cost) } + /// Similar to `Tree#put_value_with_provided_value_hash`. + #[allow(clippy::too_many_arguments)] + pub fn put_value_with_provided_value_hash( + mut self, + value: Vec, + value_hash: CryptoHash, + end_hash: Option, + feature_type: TreeFeatureType, + old_specialized_cost: &impl Fn(&Vec, &Vec) -> Result, + get_temp_new_value_with_old_flags: &impl Fn( + &Vec, + &Vec, + ) -> Result>, Error>, + update_tree_value_based_on_costs: &mut impl FnMut( + &StorageCost, + &Vec, + &mut Vec, + ) -> Result< + (bool, Option), + Error, + >, + section_removal_bytes: &mut impl FnMut( + &Vec, + u32, + u32, + ) -> Result< + (StorageRemovedBytes, StorageRemovedBytes), + Error, + >, + grove_version: &GroveVersion, + ) -> CostResult { + let mut cost = OperationCost::default(); + cost_return_on_error_no_add!( + cost, + self.tree.own_result(|t| t + .put_value_with_provided_value_hash( + value, + value_hash, + end_hash, + feature_type, + old_specialized_cost, + get_temp_new_value_with_old_flags, + update_tree_value_based_on_costs, + section_removal_bytes, + grove_version, + ) + .unwrap_add_cost(&mut cost)) + ); + Ok(self).wrap_with_cost(cost) + } + /// Similar to `Tree#put_value_with_reference_value_hash_and_value_cost`. pub fn put_value_with_reference_value_hash_and_value_cost( mut self, diff --git a/storage/src/rocksdb_storage/storage.rs b/storage/src/rocksdb_storage/storage.rs index 2b6487b9f..0512cc9af 100644 --- a/storage/src/rocksdb_storage/storage.rs +++ b/storage/src/rocksdb_storage/storage.rs @@ -1579,7 +1579,7 @@ mod tests { right.put(b"c", b"c", None, None).unwrap().unwrap(); storage - .commit_multi_context_batch(batch, None) + .commit_multi_context_batch(batch, Some(&transaction)) .unwrap() .expect("cannot commit batch"); @@ -1625,7 +1625,7 @@ mod tests { drop(iter); storage - .commit_multi_context_batch(batch, None) + .commit_multi_context_batch(batch, Some(&transaction)) .unwrap() .expect("cannot commit batch"); diff --git a/storage/src/storage.rs b/storage/src/storage.rs index eec39737c..40d629d74 100644 --- a/storage/src/storage.rs +++ b/storage/src/storage.rs @@ -136,7 +136,7 @@ pub trait Storage<'db> { /// Creates a database checkpoint in a specified path fn create_checkpoint>(&self, path: P) -> Result<(), Error>; - /// Return worst case cost for storage_cost context creation. + /// Return worst case cost for storage context creation. fn get_storage_context_cost(path: &[L]) -> OperationCost; } @@ -149,11 +149,11 @@ pub trait StorageContext<'db> { /// Storage batch type type Batch: Batch; - /// Storage raw iterator type (to iterate over storage_cost without + /// Storage raw iterator type (to iterate over storage without /// supplying a key) type RawIterator: RawIterator; - /// Put `value` into data storage_cost with `key` + /// Put `value` into data storage with `key` fn put>( &self, key: K, @@ -162,7 +162,7 @@ pub trait StorageContext<'db> { cost_info: Option, ) -> CostResult<(), Error>; - /// Put `value` into auxiliary data storage_cost with `key` + /// Put `value` into auxiliary data storage with `key` fn put_aux>( &self, key: K, @@ -170,7 +170,7 @@ pub trait StorageContext<'db> { cost_info: Option, ) -> CostResult<(), Error>; - /// Put `value` into trees roots storage_cost with `key` + /// Put `value` into trees roots storage with `key` fn put_root>( &self, key: K, @@ -178,7 +178,7 @@ pub trait StorageContext<'db> { cost_info: Option, ) -> CostResult<(), Error>; - /// Put `value` into GroveDB metadata storage_cost with `key` + /// Put `value` into GroveDB metadata storage with `key` fn put_meta>( &self, key: K, @@ -186,44 +186,44 @@ pub trait StorageContext<'db> { cost_info: Option, ) -> CostResult<(), Error>; - /// Delete entry with `key` from data storage_cost + /// Delete entry with `key` from data storage fn delete>( &self, key: K, cost_info: Option, ) -> CostResult<(), Error>; - /// Delete entry with `key` from auxiliary data storage_cost + /// Delete entry with `key` from auxiliary data storage fn delete_aux>( &self, key: K, cost_info: Option, ) -> CostResult<(), Error>; - /// Delete entry with `key` from trees roots storage_cost + /// Delete entry with `key` from trees roots storage fn delete_root>( &self, key: K, cost_info: Option, ) -> CostResult<(), Error>; - /// Delete entry with `key` from GroveDB metadata storage_cost + /// Delete entry with `key` from GroveDB metadata storage fn delete_meta>( &self, key: K, cost_info: Option, ) -> CostResult<(), Error>; - /// Get entry by `key` from data storage_cost + /// Get entry by `key` from data storage fn get>(&self, key: K) -> CostResult>, Error>; - /// Get entry by `key` from auxiliary data storage_cost + /// Get entry by `key` from auxiliary data storage fn get_aux>(&self, key: K) -> CostResult>, Error>; - /// Get entry by `key` from trees roots storage_cost + /// Get entry by `key` from trees roots storage fn get_root>(&self, key: K) -> CostResult>, Error>; - /// Get entry by `key` from GroveDB metadata storage_cost + /// Get entry by `key` from GroveDB metadata storage fn get_meta>(&self, key: K) -> CostResult>, Error>; /// Initialize a new batch @@ -232,7 +232,7 @@ pub trait StorageContext<'db> { /// Commits changes from batch into storage fn commit_batch(&self, batch: Self::Batch) -> CostResult<(), Error>; - /// Get raw iterator over storage_cost + /// Get raw iterator over storage fn raw_iter(&self) -> Self::RawIterator; } @@ -247,7 +247,7 @@ pub trait Batch { cost_info: Option, ) -> Result<(), grovedb_costs::error::Error>; - /// Appends to the database batch a put operation for aux storage_cost. + /// Appends to the database batch a put operation for aux storage. fn put_aux>( &mut self, key: K, @@ -256,7 +256,7 @@ pub trait Batch { ) -> Result<(), grovedb_costs::error::Error>; /// Appends to the database batch a put operation for subtrees roots - /// storage_cost. + /// storage. fn put_root>( &mut self, key: K, @@ -267,15 +267,15 @@ pub trait Batch { /// Appends to the database batch a delete operation for a data record. fn delete>(&mut self, key: K, cost_info: Option); - /// Appends to the database batch a delete operation for aux storage_cost. + /// Appends to the database batch a delete operation for aux storage. fn delete_aux>(&mut self, key: K, cost_info: Option); /// Appends to the database batch a delete operation for a record in subtree - /// roots storage_cost. + /// roots storage. fn delete_root>(&mut self, key: K, cost_info: Option); } -/// Allows to iterate over database record inside of storage_cost context. +/// Allows to iterate over database record inside of storage context. pub trait RawIterator { /// Move iterator to first valid record. fn seek_to_first(&mut self) -> CostContext<()>; @@ -305,29 +305,86 @@ pub trait RawIterator { fn valid(&self) -> CostContext; } -/// Structure to hold deferred database operations in "batched" storage_cost +/// Structure to hold deferred database operations in "batched" storage /// contexts. +/// +/// A batch is a keyed map of the *final* state of each key, not an ordered +/// log. Within one operation (one Merk commit) a `put` always wins over a +/// `delete` for the same key, because rebalancing legitimately deletes and +/// re-inserts a node in one commit. Callers that run SEVERAL independent +/// operations against one batch (the `MerkCache`) mark the boundary between +/// them with [`StorageBatch::next_operation`]; across such a boundary the +/// later operation's outcome wins, so a delete issued by a later operation +/// removes a key an earlier operation had put. #[derive(Debug)] pub struct StorageBatch { operations: RefCell, } +/// A deferred operation stamped with the operation generation it belongs to +/// (see [`StorageBatch::next_operation`]). +struct Entry { + generation: u64, + op: AbstractBatchOperation, +} + #[derive(Default)] struct Operations { - data: BTreeMap, AbstractBatchOperation>, - roots: BTreeMap, AbstractBatchOperation>, - aux: BTreeMap, AbstractBatchOperation>, - meta: BTreeMap, AbstractBatchOperation>, + /// The current operation generation; entries recorded now carry it. + generation: u64, + data: BTreeMap, Entry>, + roots: BTreeMap, Entry>, + aux: BTreeMap, Entry>, + meta: BTreeMap, Entry>, +} + +impl Operations { + /// Record a put: the newest value always wins. + fn put_into( + map: &mut BTreeMap, Entry>, + generation: u64, + key: Vec, + op: AbstractBatchOperation, + ) { + map.insert(key, Entry { generation, op }); + } + + /// Record a delete. An entry from the SAME operation generation keeps + /// precedence (put-wins within one Merk commit — see the documentation + /// on [`StorageBatch::delete`]); an entry from an EARLIER generation is + /// superseded, so a later operation's delete removes the key. + fn delete_into( + map: &mut BTreeMap, Entry>, + generation: u64, + key: Vec, + op: AbstractBatchOperation, + ) { + match map.get(&key) { + Some(existing) if existing.generation >= generation => {} + _ => { + map.insert(key, Entry { generation, op }); + } + } + } } impl std::fmt::Debug for Operations { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut fmt = f.debug_struct("Operations"); - fmt.field("data", &self.data.values()); - fmt.field("aux", &self.aux.values()); - fmt.field("roots", &self.roots.values()); - fmt.field("meta", &self.meta.values()); + fmt.field( + "data", + &self.data.values().map(|e| &e.op).collect::>(), + ); + fmt.field("aux", &self.aux.values().map(|e| &e.op).collect::>()); + fmt.field( + "roots", + &self.roots.values().map(|e| &e.op).collect::>(), + ); + fmt.field( + "meta", + &self.meta.values().map(|e| &e.op).collect::>(), + ); fmt.finish() } @@ -363,7 +420,11 @@ impl StorageBatch { children_sizes: ChildrenSizesWithIsSumTree, cost_info: Option, ) { - self.operations.borrow_mut().data.insert( + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::put_into( + &mut ops.data, + generation, key.clone(), AbstractBatchOperation::Put { key, @@ -374,14 +435,28 @@ impl StorageBatch { ); } - /// Add deferred `put` operation for aux storage_cost + /// Mark the boundary between two independent operations sharing this + /// batch (e.g. consecutive Merk commits driven through a `MerkCache`). + /// Everything recorded so far is treated as the settled outcome of + /// earlier operations: a `delete` issued after this point supersedes an + /// earlier `put` of the same key, while within a single operation the + /// put-wins rule still holds. + pub fn next_operation(&self) { + self.operations.borrow_mut().generation += 1; + } + + /// Add deferred `put` operation for aux storage pub(crate) fn put_aux( &self, key: Vec, value: Vec, cost_info: Option, ) { - self.operations.borrow_mut().aux.insert( + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::put_into( + &mut ops.aux, + generation, key.clone(), AbstractBatchOperation::PutAux { key, @@ -391,14 +466,18 @@ impl StorageBatch { ); } - /// Add deferred `put` operation for subtree roots storage_cost + /// Add deferred `put` operation for subtree roots storage pub(crate) fn put_root( &self, key: Vec, value: Vec, cost_info: Option, ) { - self.operations.borrow_mut().roots.insert( + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::put_into( + &mut ops.roots, + generation, key.clone(), AbstractBatchOperation::PutRoot { key, @@ -408,14 +487,18 @@ impl StorageBatch { ); } - /// Add deferred `put` operation for metadata storage_cost + /// Add deferred `put` operation for metadata storage pub(crate) fn put_meta( &self, key: Vec, value: Vec, cost_info: Option, ) { - self.operations.borrow_mut().meta.insert( + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::put_into( + &mut ops.meta, + generation, key.clone(), AbstractBatchOperation::PutMeta { key, @@ -427,68 +510,80 @@ impl StorageBatch { /// Add deferred `delete` operation. /// - /// If a `put` for the same key already exists in this batch, the delete is - /// silently dropped — the put always wins within a single batch. This is - /// intentional: during tree rebalancing, a node may be deleted from one - /// position and re-inserted at another within the same commit. + /// If a `put` for the same key already exists in this batch FROM THE SAME + /// OPERATION, the delete is silently dropped — the put always wins within + /// a single operation. This is intentional: during tree rebalancing, a + /// node may be deleted from one position and re-inserted at another + /// within the same commit. /// /// AUDIT NOTE (issue #698 — intentional, do not re-flag): a `StorageBatch` /// is a keyed map of the *final* state for each key within one atomic /// commit, NOT an ordered operation log. There is therefore no meaningful - /// "put then delete then commit" ordering to honor — Merk's rebalancing - /// legitimately emits a delete and a put for the same key in one commit, and - /// the surviving value (the put) is exactly the intended end state. Making - /// a later delete win would drop nodes that rebalancing just re-inserted and - /// corrupt the tree. Do not "fix" this to last-write-wins. + /// "put then delete then commit" ordering to honor within one Merk commit + /// — rebalancing legitimately emits a delete and a put for the same key, + /// and the surviving value (the put) is exactly the intended end state. + /// Making a later delete win INSIDE an operation would drop nodes that + /// rebalancing just re-inserted and corrupt the tree. + /// + /// Across operations it is the opposite: when several Merk commits share + /// one batch (a `MerkCache` flow — e.g. a delete whose rebalancing + /// rewrote a neighbouring node, followed by a cascade that deletes that + /// very node), the later operation's delete must remove the key an + /// earlier one had put. [`StorageBatch::next_operation`] marks those + /// boundaries; an entry from an earlier generation is superseded. pub(crate) fn delete(&self, key: Vec, cost_info: Option) { - let operations = &mut self.operations.borrow_mut().data; - if operations.get(&key).is_none() { - operations.insert( - key.clone(), - AbstractBatchOperation::Delete { key, cost_info }, - ); - } + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::delete_into( + &mut ops.data, + generation, + key.clone(), + AbstractBatchOperation::Delete { key, cost_info }, + ); } /// Add deferred `delete` operation for aux storage. /// /// Same put-wins semantics as [`Self::delete`]. pub(crate) fn delete_aux(&self, key: Vec, cost_info: Option) { - let operations = &mut self.operations.borrow_mut().aux; - if operations.get(&key).is_none() { - operations.insert( - key.clone(), - AbstractBatchOperation::DeleteAux { key, cost_info }, - ); - } + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::delete_into( + &mut ops.aux, + generation, + key.clone(), + AbstractBatchOperation::DeleteAux { key, cost_info }, + ); } /// Add deferred `delete` operation for subtree roots storage. /// /// Same put-wins semantics as [`Self::delete`]. pub(crate) fn delete_root(&self, key: Vec, cost_info: Option) { - let operations = &mut self.operations.borrow_mut().roots; - if operations.get(&key).is_none() { - operations.insert( - key.clone(), - AbstractBatchOperation::DeleteRoot { key, cost_info }, - ); - } + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::delete_into( + &mut ops.roots, + generation, + key.clone(), + AbstractBatchOperation::DeleteRoot { key, cost_info }, + ); } - /// Add deferred `delete` operation for metadata storage_cost + /// Add deferred `delete` operation for metadata storage pub(crate) fn delete_meta(&self, key: Vec, cost_info: Option) { - let operations = &mut self.operations.borrow_mut().meta; - if operations.get(&key).is_none() { - operations.insert( - key.clone(), - AbstractBatchOperation::DeleteMeta { key, cost_info }, - ); - } + let ops = &mut *self.operations.borrow_mut(); + let generation = ops.generation; + Operations::delete_into( + &mut ops.meta, + generation, + key.clone(), + AbstractBatchOperation::DeleteMeta { key, cost_info }, + ); } /// Merge batch into this one - pub(crate) fn merge(&self, other: StorageBatch) { + pub fn merge(&self, other: StorageBatch) { for op in other.into_iter() { match op { AbstractBatchOperation::Put { @@ -525,14 +620,46 @@ impl StorageBatch { } } } + + /// Merge batch into this one prioritizing operations of the provided batch + /// for deletions. The original [[merge]] doesn't overwrite operations + /// with deletions keeping keys if they were inserted before. + pub fn merge_overwriting(&self, other: StorageBatch) { + let other_ops = other.operations.into_inner(); + let mut ops = self.operations.borrow_mut(); + let generation = ops.generation; + + fn restamp( + entries: BTreeMap, Entry>, + generation: u64, + ) -> impl Iterator, Entry)> { + entries.into_iter().map(move |(key, entry)| { + ( + key, + Entry { + generation, + op: entry.op, + }, + ) + }) + } + + ops.data.extend(restamp(other_ops.data, generation)); + ops.meta.extend(restamp(other_ops.meta, generation)); + ops.aux.extend(restamp(other_ops.aux, generation)); + ops.roots.extend(restamp(other_ops.roots, generation)); + // The merged content is the settled outcome of the other batch's + // operations; whatever follows is a later operation. + ops.generation += 1; + } } -/// Iterator over storage_cost batch operations. +/// Iterator over storage batch operations. pub(crate) struct StorageBatchIter { - data: IntoValues, AbstractBatchOperation>, - aux: IntoValues, AbstractBatchOperation>, - meta: IntoValues, AbstractBatchOperation>, - roots: IntoValues, AbstractBatchOperation>, + data: IntoValues, Entry>, + aux: IntoValues, Entry>, + meta: IntoValues, Entry>, + roots: IntoValues, Entry>, } impl Iterator for StorageBatchIter { @@ -544,6 +671,7 @@ impl Iterator for StorageBatchIter { .or_else(|| self.aux.next()) .or_else(|| self.roots.next()) .or_else(|| self.data.next()) + .map(|entry| entry.op) } } @@ -568,7 +696,7 @@ impl Default for StorageBatch { } } -/// Deferred storage_cost operation not tied to any storage_cost implementation, +/// Deferred storage operation not tied to any storage implementation, /// required for multi-tree batches. #[allow(missing_docs)] #[derive(strum::AsRefStr)] @@ -580,19 +708,19 @@ pub(crate) enum AbstractBatchOperation { children_sizes: ChildrenSizesWithIsSumTree, cost_info: Option, }, - /// Deferred put operation for aux storage_cost + /// Deferred put operation for aux storage PutAux { key: Vec, value: Vec, cost_info: Option, }, - /// Deferred put operation for roots storage_cost + /// Deferred put operation for roots storage PutRoot { key: Vec, value: Vec, cost_info: Option, }, - /// Deferred put operation for metadata storage_cost + /// Deferred put operation for metadata storage PutMeta { key: Vec, value: Vec, @@ -603,17 +731,17 @@ pub(crate) enum AbstractBatchOperation { key: Vec, cost_info: Option, }, - /// Deferred delete operation for aux storage_cost + /// Deferred delete operation for aux storage DeleteAux { key: Vec, cost_info: Option, }, - /// Deferred delete operation for roots storage_cost + /// Deferred delete operation for roots storage DeleteRoot { key: Vec, cost_info: Option, }, - /// Deferred delete operation for metadata storage_cost + /// Deferred delete operation for metadata storage DeleteMeta { key: Vec, cost_info: Option, @@ -758,6 +886,67 @@ mod tests { assert!(operations.next().is_none()); } + #[test] + fn test_storage_batch_later_operation_delete_supersedes_earlier_put() { + let batch = StorageBatch::new(); + + // Operation 1 (e.g. a Merk commit whose rebalancing rewrote a node). + batch.put( + b"key".to_vec(), + b"value".to_vec(), + dummy_children_sizes(), + None, + ); + batch.next_operation(); + // Operation 2 deletes that very node: it must win. + batch.delete(b"key".to_vec(), Some(removed_bytes_cost(5))); + + let operations: Vec<_> = batch.into_iter().collect(); + assert_eq!(operations.len(), 1); + assert!(matches!( + operations[0], + AbstractBatchOperation::Delete { .. } + )); + } + + #[test] + fn test_storage_batch_put_wins_within_one_operation_after_boundary() { + let batch = StorageBatch::new(); + batch.next_operation(); + + // Within the new operation the put-wins rule is unchanged, in both + // orders. + batch.put(b"a".to_vec(), b"1".to_vec(), dummy_children_sizes(), None); + batch.delete(b"a".to_vec(), None); + batch.delete(b"b".to_vec(), None); + batch.put(b"b".to_vec(), b"2".to_vec(), dummy_children_sizes(), None); + + let variants: Vec<_> = batch + .into_iter() + .map(|op| matches!(op, AbstractBatchOperation::Put { .. })) + .collect(); + assert_eq!(variants, vec![true, true]); + } + + #[test] + fn test_storage_batch_merge_overwriting_settles_generation() { + let batch = StorageBatch::new(); + let other = StorageBatch::new(); + other.put(b"key".to_vec(), b"v".to_vec(), dummy_children_sizes(), None); + + batch.merge_overwriting(other); + // The merged put is an earlier operation's outcome: a delete issued + // afterwards removes the key. + batch.delete(b"key".to_vec(), None); + + let operations: Vec<_> = batch.into_iter().collect(); + assert_eq!(operations.len(), 1); + assert!(matches!( + operations[0], + AbstractBatchOperation::Delete { .. } + )); + } + #[test] fn test_storage_batch_merge_and_iteration_order() { let batch = StorageBatch::new();