CompactProof: Deduplicate values - #233
Conversation
encode_compact emits a detached value node once per referencing trie node, so a value shared by N keys is sent N times (paritytech/polkadot-sdk#12565). Add encode_compact_skip_duplicate_values to emit each distinct value once; repeats are emitted unmodified and stay decodable by existing hash-keyed decoders. For prefixed databases, decode_compact_from_iter_with_known_values re-inserts deduplicated values at every referencing position.
A trailing attached value node was not counted in the returned used-item count, so decoding concatenated encodings at that offset re-read the value bytes as a node.
trie_codec_proof only ran ExtensionLayout (inline values), so value detachment and deduplication were never fuzzed. Round-trip the deduplicating encoding into hash-keyed and prefixed databases, add a hashed-value target with heavily shared values, and a deterministic smoke test so the assertions run in CI without libFuzzer. Also fix the fuzz crate's stale memory-db path dependency version.
An old decoder inserts a deduplicated value once, so its refcount in a hash-keyed database understates the referencing nodes; a consumer consolidating removals could drop a still-referenced value. The re-inserting decoder restores exact parity. Assert full database equality (entries and refcounts, hash-keyed and prefixed) between the plain and deduplicated encodings.
bkchr
left a comment
There was a problem hiding this comment.
You can do the de-duplication on the node level and not just on the value level.
| /// `seen_value_hashes` collects the emitted value hashes. Pass `&mut Default::default()` for a | ||
| /// standalone encoding, or thread the same set across concatenated encodings (the matching decode | ||
| /// calls must then thread a known-values map through | ||
| /// [`decode_compact_from_iter_with_known_values`]). |
There was a problem hiding this comment.
| /// `seen_value_hashes` collects the emitted value hashes. Pass `&mut Default::default()` for a | |
| /// standalone encoding, or thread the same set across concatenated encodings (the matching decode | |
| /// calls must then thread a known-values map through | |
| /// [`decode_compact_from_iter_with_known_values`]). | |
| /// `seen_value_hashes` collects the emitted value hashes. |
Ty claude :P
There was a problem hiding this comment.
What is the use case for "concatenated encodings"? Do we actually need to share the set across calls, or can get rid of this argument?
Vendor the compact-proof decoder from the released trie-db 0.31.0 verbatim into the test crate as a frozen snapshot of deployed decoder behavior, and assert that encodings produced by encode_compact_skip_duplicate_values decode with it into a hash-keyed database with every entry readable. This pins the backward compatibility the deduplicating encoder relies on, instead of leaving it as an argument in documentation.
encode_compact_skip_duplicates now emits each distinct trie node once, not just each detached value. A later occurrence of an already-emitted subtree keeps a plain hash reference (like any reference outside the partial trie), so the encoding never grows. The decoder threads a known-items map and re-inserts skipped subtrees at every position, reconstructing the same database as an un-deduplicated encoding.
We now also deduplicate nodes |
| children: Vec<Option<ChildReference<C::HashOut>>>, | ||
| /// Bit mask of children kept as plain hash references, which may point at subtrees | ||
| /// deduplicated into an earlier occurrence (see [`encode_compact_skip_duplicates`]). | ||
| hash_ref_children: u16, |
There was a problem hiding this comment.
| hash_ref_children: u16, | |
| has_ref_children: bool, |
And then just set it to true if there is one.
There was a problem hiding this comment.
This is a bitmask that marks which children came on the wire as hash. We insert the deduplicated subtrees towards the end and iterate all the children. But at that point it is not possible to distinguish between items that came as detached value and items that where always Hash.
The ones that came as detached value where already inserted into the db, so we should not insert them again to keep the ref count in the db correct.
There was a problem hiding this comment.
I am not getting that, we skip only dettached value? not duplicated trie node hash (could make sense though)? so would only need to look at the single value hash?
| if hash.is_none() { | ||
| // The node's detached value may have been deduplicated into an earlier occurrence | ||
| // (see `encode_compact_skip_duplicates`); re-insert it here too so `db` matches an | ||
| // un-deduplicated encoding. A miss means the value is not in the encoding, which is |
There was a problem hiding this comment.
If the hash is none and the the value is missing, it means the db is missing values and we should return an error?
There was a problem hiding this comment.
What does value missing mean for you here?
If the hash is none that means that there was no detached node in the compact encoding. So we just have the hash.
If that hash belongs to a node that was deduplicated and we have seen it earlier, we need to reinsert to maintain refcount.
if the hash belongs to a node that we have not yet seen, that means the node is just not referenced in the proof, which should also be fine.
| pub fn decode_compact_from_iter_with_known_items<'a, L, DB, I>( | ||
| db: &mut DB, | ||
| encoded: I, | ||
| known_items: &mut BTreeMap<Vec<u8>, DBValue>, |
There was a problem hiding this comment.
Do we actually need to supply this?
There was a problem hiding this comment.
| DB: HashDB<L::Hash, DBValue>, | ||
| I: IntoIterator<Item = &'a [u8]>, | ||
| { | ||
| decode_compact_from_iter_with_known_items::<L, DB, I>(db, encoded, &mut BTreeMap::new()) |
There was a problem hiding this comment.
This method is also doing the same reconstruction of the database with the same counts. So, there is no real difference between these two?
There was a problem hiding this comment.
The difference is just backwards compat. The previous method existed before and does not break anything. with_known_items is only needed if you want to pass through the known_items, which we could do in the scenario outlined here.
|
@cheme Would be super nice if you could support us with a review here :) |
Sure, will look at it (may not be able to do tomorrow, but will try to do before next week). |
cheme
left a comment
There was a problem hiding this comment.
Using seen_hashes make sense to me, I think it is good (should even be extended node hashes).
What I am not too sure, is why we try to use prefixedmemorydb or have proper rc count.
| let Some(node_hash) = node_hash else { continue }; | ||
|
|
||
| if let Some(seen_hashes) = seen_hashes.as_deref_mut() { | ||
| // A subtree whose root was already emitted is skipped entirely; the parent's |
There was a problem hiding this comment.
| // A subtree whose root was already emitted is skipped entirely; the parent's | |
| // Avoid skipping entirely a subtree whose root was already emitted; the parent's |
| children: Vec<Option<ChildReference<C::HashOut>>>, | ||
| /// Bit mask of children kept as plain hash references, which may point at subtrees | ||
| /// deduplicated into an earlier occurrence (see [`encode_compact_skip_duplicates`]). | ||
| hash_ref_children: u16, |
There was a problem hiding this comment.
I am not getting that, we skip only dettached value? not duplicated trie node hash (could make sense though)? so would only need to look at the single value hash?
| /// counts) requires a re-inserting decoder, i.e. [`decode_compact_from_iter_with_known_items`]. | ||
| /// | ||
| /// Assumes occurrences of an item are interchangeable, as they are when `db` is hash-keyed. A | ||
| /// prefixed `db` populating a duplicated subtree only below a later occurrence would drop it. |
There was a problem hiding this comment.
I am not sure I understand the sentence about Prefixed Db.
From what I remember a prefixed memory db should only be used with a trie to produce/record the payload to send to rocksdb. So generally I would think things would get easier if we avoid using it at all when doing a compact proof.
I may have missed a case (I remember seeing some fix passing, maybe about it), but right now I could not figure it to be usefull.
It seems in the PR, managing prefixed db is the main source of complexity. For instance, for subtree, if not requiring prefixed db, one can just record the first subtree and skip the other ones.
Similarily, Iirc, proofs do not need to have proper rc (rc of memory db being usefull only for the product/record the payload to send to paritydb). Execution against a state machine never change the original state (delta of a removed value is simply stored in a keyvalue change map, so the backend would not change until receiving the payload).
| // never skipped, so each encoding stays individually decodable when | ||
| // `seen_hashes` is threaded across successive encodings. Sound only under the | ||
| // fixed-backing-set precondition (see `encode_compact_skip_duplicates`): the | ||
| // subtree below a seen hash must not have grown since it was emitted. |
There was a problem hiding this comment.
I am not too sure this is needed, generally proof is a record of state accesses, so would only need to be an unprefixed memorydb with any rc being fine.
At the trie level it may make sense still, but subtree/childtree is a substrate concept, it is odd to see it in the trie crate.
| /// would have inserted below this node. Hashes missing from `known_items` reference items outside | ||
| /// the encoding and are skipped, like the holes an un-deduplicated encoding would leave. | ||
| /// | ||
| /// `prefix` is `entry`'s node prefix; it is restored before returning. |
There was a problem hiding this comment.
IIUC the point of this function is to reinsert a subtree/childtree , which somehow would make sense to me to deduplicate over a different prefix, or account for proper Rc. (but as mentioned before I am unsure it make sense for a proof).
if needed, I would certainly feel like this should be part of substrate (yet I remember in the past having to put some substrate related design in the trie_codec, so there may be a reason here, but I don t see it immediatly).
| // The node's detached value may have been deduplicated into an earlier occurrence | ||
| // (see `encode_compact_skip_duplicates`); re-insert it here too so `db` matches an | ||
| // un-deduplicated encoding. A miss means the value is not in the encoding, which is | ||
| // legal. |
There was a problem hiding this comment.
I see the point of reinserting, yet we are no sure the value was actually accessed here.
I would just not reinsert, as rc does not matter to me. If rc matters (not sure why), then we can insert indeed, but since we don’t know if it was skipped due to duplication or value unaccessed, we may obtain a wrong rc.
One solution might be to simply have another placeholder value for duplicated value, but then we are not really compatible with old proofs.
I wonder if we shall force proof to run on a unprefixed memorydb that do not allow insert and removal, so Rc or prefix would not matter.
|
@cheme @bkchr Sorry for the delay (was off for some time), did another pass and removed the RC reconstruction and subtree reinsertion during decoding. Substrate does not need it at all, and it was complicating the code. The reinsertion had another problem, a deduplicated proof emits each shared subtree once, but the reinserting decoder replayed it at every referencing position. Nested duplication makes that unfold exponentially costing exponential decode time. To fix this we would have needed to introduce some kind of decoding budget on the decoder side. Not worth it IMO. And if needed, it is still possible to arrive at a correct PrefixedDB from the HashDB. |
Summary
encode_compactre-emits a shared detached value once per referencing node, causing proof size to grow with reference count instead of value count. Adds opt-inencode_compact_skip_duplicate_valuesto dedupe on encode.decode_compact_from_iter(anddecode_compact) now always handle both old-style and deduplicated proofs correctly. Also fixes an off-by-one indecode_compact_from_iter's returned item count when the last node has an attached value.Compatibility
encode_compact/decode_compact_from_iterbehavior unchanged — new functions are additive.MemoryDB(verified against 0.31.0) — but not into aPrefixedKeyDB, which needs the value re-inserted per prefix.get/containsonly checkrc > 0); This matters if you later do balanced insert/remove against that decoded DB.decode_compact's item count is now correct when the last node has an attached value — previously it was off by one in that case.