Skip to content

CompactProof: Deduplicate values#233

Open
skunert wants to merge 9 commits into
masterfrom
compact-proof
Open

CompactProof: Deduplicate values#233
skunert wants to merge 9 commits into
masterfrom
compact-proof

Conversation

@skunert

@skunert skunert commented Jul 7, 2026

Copy link
Copy Markdown

Summary

encode_compact re-emits a shared detached value once per referencing node, causing proof size to grow with reference count instead of value count. Adds opt-in encode_compact_skip_duplicate_values to dedupe on encode. decode_compact_from_iter (and decode_compact) now always handle both old-style and deduplicated proofs correctly. Also fixes an off-by-one in decode_compact_from_iter's returned item count when the last node has an attached value.

Compatibility

  • encode_compact / decode_compact_from_iter behavior unchanged — new functions are additive.
  • Deduplicated proofs decode correctly with this crate's decoder, and with an unpatched old decoder into a hash-keyed MemoryDB (verified against 0.31.0) — but not into a PrefixedKey DB, which needs the value re-inserted per prefix.
  • Old decoder + hash-keyed DB: refcount for the shared value is undercounted (1 instead of N). Reads still work (get/contains only check rc > 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.

skunert and others added 5 commits July 7, 2026 16:41
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.
@skunert skunert requested a review from bkchr July 7, 2026 15:12

@bkchr bkchr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

You can do the de-duplication on the node level and not just on the value level.

Comment thread trie-db/src/trie_codec.rs Outdated
Comment thread trie-db/src/trie_codec.rs Outdated
/// [`decode_compact_from_iter_with_known_values`]).
pub fn encode_compact_skip_duplicate_values<L>(
db: &TrieDB<L>,
seen_value_hashes: &mut BTreeSet<Vec<u8>>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does someone requires this after the encoding?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

We could use it for example here to deduplicate across child and main trie. Whether its really necessary to deduplicate across main and child is not super clear to me, but I can see use cases for this.

Comment thread trie-db/src/trie_codec.rs Outdated
Comment on lines +256 to +259
/// `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`]).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/// `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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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?

skunert added 3 commits July 13, 2026 12:25
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.
@skunert

skunert commented Jul 13, 2026

Copy link
Copy Markdown
Author

You can do the de-duplication on the node level and not just on the value level.

We now also deduplicate nodes

Comment thread trie-db/src/iterator.rs Outdated
Comment thread trie-db/src/trie_codec.rs Outdated
Comment thread trie-db/src/trie_codec.rs
// `omit_children` bit stays unset, keeping a plain hash reference. The root is
// never skipped, so each encoding stays individually decodable when
// `seen_hashes` is threaded across successive encodings.
if !stack.is_empty() && seen_hashes.contains(node_hash.as_ref()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't get why you check here if stack is not empty?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This is needed to decide whether we are at the root of a child trie for example. If the root would be in seen_hashes, the encoding for the child trie would be empty.

Comment thread trie-db/src/trie_codec.rs
},
_ => 0,
};
for index in 0..entry.children.len() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why are you not directly iterating the entry.children?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Because we use it to access the bitmask

Comment thread trie-db/src/trie_codec.rs
Comment on lines +554 to +556
if entry.hash_ref_children & (1u16 << index) == 0 {
continue
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if entry.hash_ref_children & (1u16 << index) == 0 {
continue
}

This is the exact same check as below?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

See comment here

Comment thread trie-db/src/trie_codec.rs
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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
hash_ref_children: u16,
has_ref_children: bool,

And then just set it to true if there is one.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread trie-db/src/trie_codec.rs Outdated
Comment thread trie-db/src/trie_codec.rs Outdated
Comment thread trie-db/src/trie_codec.rs
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If the hash is none and the the value is missing, it means the db is missing values and we should return an error?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

Comment thread trie-db/src/trie_codec.rs
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>,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Do we actually need to supply this?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Comment thread trie-db/src/trie_codec.rs
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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This method is also doing the same reconstruction of the database with the same counts. So, there is no real difference between these two?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

@skunert skunert requested a review from cheme July 15, 2026 12:42
@skunert

skunert commented Jul 15, 2026

Copy link
Copy Markdown
Author

@cheme Would be super nice if you could support us with a review here :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants