Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Trie: Internal Refactoring #2662

Open
wants to merge 46 commits into
base: develop-v7
Choose a base branch
from
Open

Conversation

holgerd77
Copy link
Member

@holgerd77 holgerd77 commented Apr 24, 2023

Trie: Internal Refactoring

Nodes

src/trie/node/

BASE_NODE

abstract class defines basic node structure

  • raw()
    • format node for rlp encoding
  • rlpEncode()
    • rlp encode to bytes
  • hash()
    • hash of rlp encode

NODE_TYPES

  1. LeafNode
    • keyNibbles (unique end of key)
    • value
  2. ExtensionNode
    • keyNibbles (shared by subnodes)
    • child
  3. BranchNode
    • children: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
    • value
  4. ProofNode
    • placeholder for hashed node in a sparse trie
    • load()
      • attempts to resolve by checking db
  5. NullNode
    • null or deleted

TNode

type TNode = LeafNode | ExtensionNode | BranchNode | ProofNode | NullNode

Operations

src/trie/operations/

Functions in operations directory are used internally by Trie class methods

  1. Decode
    • Decode RLP encoded bytes into TNode
  2. Insert
    • Update Trie with new key/value pair
  3. Delete
    • Update Trie by removing key/value pair
  4. Cleanup
    • Settle necessary node changes after Trie update
      • e.g. Branch with 1 child
  5. GetNodePath
    • Return path to node by key
  6. WalkTrie
    • Walk the Trie
    • Filter nodes with search parameters
    • Execute OnFound function on filtered nodes
  7. ReadStream
    • Create a Node Readable Stream for DB values
  8. GarbageCollection
    • Remove unreachable nodes from DB/cache

Trie

MerklePatriciaTrie

trie/merklePatriciaTrie.ts

The MerklePatriciaTrie class implements basic Trie functionality (no DB)

Public

  1. root()
    • returns the current root hash
  2. lookupNodeByHash()
    • retrieves a node by hash
  3. setRootByHash
    • sets the trie root by node hash
  4. resolveProofNode
    • attempts to resolve a hashed node
  5. getPath
    • returns stack of nodes from root to node
  6. getNode
    • returns a node from the trie by key

Internal

  1. _getChildOf
    • Navigates a parent node to a child by key
  2. _getNodePath
    • Returns a WalkResult object { node, path, remainingNibbles }
  3. _insertAtNode
    • Inserts a node into Trie by key
  4. _deleteAtNode
    • Deletes a node from Trie by key
  5. _cleanupNode
    • Processes a node after an update

TrieWithDB

trie/trieDB.ts

The TrieWithDB class extends the MerklePatriciaTrie class with methods for uisng a database.

TrieWithDB enables

  • Checkpointing
  • NodePruning
  • SecureKeys
  • Root Persistence
  • TrieDatabase
  • Cache

Added Methods:

  1. database()
    • Returns the database instance
  2. setDatabase()
    • Replaces the database with new DB
  3. checkpoint()
    • Adds a root to the checkpoint list
  4. hasCheckpoints()
    • Returns true if checkpoints in list
  5. storeNode()
    • Stores a node in database by hash
  6. persistRoot()
    • Stores the current root in DB with DB_ROOT_KEY
  7. commit
    • Removes a checkpoint from the list
  8. revert
    • Reverts back to previous checkpoint root
  9. pruneCheckpoints
    • Reduce checkpoings to maxCheckpoints
  10. flushCheckpoints
    • Dump all checkpoints
  11. garbageCollect
    • Delete all unreachable nodes from storage
  12. verifyPrunedIntegrity
    • Verify that all nodes are reachable
  13. _markReachableNodes
    • Filter nodes for garbage collection

TrieWrap

trie/trieWrapper.ts

TrieWrap extends TrieWithDB with additional functionality and provides a simple public interface for most uses.

static methods

  1. async create
    • Create a new Trie instance from options
  2. fromProof
    • Create a new Trie instance from a proof
  3. verifyProof
    • Verify a proof against a given root

proof methods

  1. createProof
    • Create a merkle proof for a key
  2. updateFromProof
    • Update a (sparse) Trie from a proof
  3. verifyProof
    • Verify a proof against the root hash

trie methods

  1. put
  2. get
  3. del
  4. batch
  5. setRootNode

stream / walk

  1. createReadStream
  2. walkTrie
  3. walkTrieRecursively

@codecov
Copy link

codecov bot commented Apr 24, 2023

Codecov Report

Merging #2662 (4c90832) into develop-v7 (6bc6a10) will increase coverage by 3.35%.
The diff coverage is 94.25%.

❗ Current head 4c90832 differs from pull request most recent head 63be09b. Consider uploading reports for the commit 63be09b to get more accurate results

Additional details and impacted files

Impacted file tree graph

Flag Coverage Δ
block 90.30% <ø> (ø)
blockchain 90.79% <93.97%> (+0.24%) ⬆️
client ?
common 96.06% <100.00%> (ø)
devp2p 91.82% <ø> (?)
ethash ∅ <ø> (∅)
statemanager ?
trie ?
tx 95.50% <ø> (ø)
util 81.33% <100.00%> (ø)
vm ?

Flags with carried forward coverage won't be shown. Click here to find out more.

@ScottyPoi
Copy link
Contributor

Got all but one of the Trie tests passing with the new findPath().

the failure is weird. it doesn't do anything too different that the other tests around it, so i can't quite tell why it's failing

} catch (error: any) {
if (error.message === 'Missing node in DB' && !throwIfMissing) {
// pass
} else {
throw error
}
}
return { node: null, stack, remaining }
return { node, stack, remaining }
Copy link
Member Author

Choose a reason for hiding this comment

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

At the end I thought that we might be able to skip this node return value since the node returned is just the last item on the stack (if the stack is complete), so this information is just redundant. I generally had the impression that this could be significantly simplified (so: the return format), not sure e.g., is remaining necessary or used? Might be though.

Copy link
Contributor

Choose a reason for hiding this comment

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

you're probably right.

@@ -935,8 +932,8 @@ export class Trie {
* Returns a copy of the underlying trie.
* @param includeCheckpoints - If true and during a checkpoint, the copy will contain the checkpointing metadata and will use the same scratch as underlying db.
*/
copy(includeCheckpoints = true): Trie {
const trie = new Trie({
async copy(includeCheckpoints = true): Promise<Trie> {
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: if we keep this we need to mention in the docs (breaking).

Good cath though, we might want to re-visit the other copy() methods as well.

Copy link
Contributor

Choose a reason for hiding this comment

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

i'll dig into it a little bit more, to see if it's actually necessary for it to be async.

we encourage users to use the await Trie.create() method, but many of our tests are written with new Trie(). So yes, either a bunch of rewrites to make it al async, or maybe i will find that the create method doesn't actually need to be async.

Copy link
Member Author

Choose a reason for hiding this comment

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

No, the create() method needs to be async, that's the whole point why we introduced this method, to allow for async trie instantiation when using e.g. LevelDB as a database which has async reads and writes.

So it is rather our laziness that we still use (our own discouraged) way of using new Trie() directly and we should optimally rewrite the tests (goes for some other libraries as well, I guess at least VM and Blockchain? 🤔)

@holgerd77
Copy link
Member Author

Got all but one of the Trie tests passing with the new findPath().

the failure is weird. it doesn't do anything too different that the other tests around it, so i can't quite tell why it's failing

🎉

Something is off with CI though, would be great if you can fix as some first next step, so that one gets an overview of the tests still failing.

@ScottyPoi
Copy link
Contributor

Note: if we keep this we need to mention in the docs (breaking).

The issues here run deep, and while I can do my best to limit this PR to non-breaking changes, we may find it impossible to fix one issue without also fixing a few others, which makes breaking changes all the more likely.

one helpful non-breaking change i'd love to make right away would be to give the Trie class a debugger. does it not have one be design? like as a performance choice or something?

@ScottyPoi
Copy link
Contributor

I have a working solution that does not require the breaking change to trie.copy() -- so I have reverted that change for this PR

@holgerd77
Copy link
Member Author

Note: if we keep this we need to mention in the docs (breaking).

The issues here run deep, and while I can do my best to limit this PR to non-breaking changes, we may find it impossible to fix one issue without also fixing a few others, which makes breaking changes all the more likely.

Ah, yes, so that was totally not meant as that breaking changes - if necessary or useful - should be avoided, rather a note to myself to mention this in the release notes. 🙂

In the case we discuss here e.g. it makes super much sense (and we (you?) should rather look into the other libraries with a create method if we want to adopt there as well (that would be actually great, but should be a separate PR).

one helpful non-breaking change i'd love to make right away would be to give the Trie class a debugger. does it not have one be design? like as a performance choice or something?

No, that's not by design or something, it just "is work" to add these debuggers, lol. 😋 Yes, so that would be actually nice. And if you follow the path with this "guard" (if this.DEBUG { debug(...) } or something as we have in SM, VM, devp2p,... it should not impact performance.

async copy(includeCheckpoints = true): Promise<Trie> {
const trie = await Trie.create({
copy(includeCheckpoints = true): Trie {
const trie = new Trie({
Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, please, as mentioned, do not revert this. This was a great change! 🤩

Copy link
Contributor

Choose a reason for hiding this comment

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

oh -- sure. even though it makes this PR breaking?

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, just saw your comment above

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, now it's the time to make these kind of breaking changes if they are useful. 🙂

@holgerd77
Copy link
Member Author

Just a general note: when I was starting with this, I was not yet 100% sure if "this is the way to go", this was just started as some (promising, attention: personal judgement 😋) experiment.

If we do it this way, this would throw away a lot of logic, in particular this job queuing thing with this PrioritizedTaskExecutor - or however this is named.

I am currently not seeing why such a task executor is needed, from my understanding Trie node retrieval seems to be a totally deterministic process with no surprises (this findPath name (and other semantics from wording as well) is sometimes putting one on the path thinking that there is some "oh let's look here, no nothing, let's take the other route, ah, nothing as well" process involved. 😜 That's completely not true unless I am missing something.

So anyhow: to confirm that we are not the right track here it would be good if you do substantial mainnet sync trials with the PR on the sideline, just let this running a bit, see how it goes, if the client goes out of memory at some point e.g. with these changes (there is some mention of OOM prevention in "the old code", I am personally not seeing yet where this should happen with max 64 (?) subsequent DB reads).

For the performance perspective it would be good if you take some couple of 100/1000 tx loaded blocks and take this version and then the old one and see how sync times compare (optimally post here).

@jochem-brouwer
Copy link
Member

I do remember this prioritized task executor and I tried to remove it at some point (or at least how I remember is by changing it to an optimized version with binary sort). However this had a side effect why it is still this task executor instead of the binary search one. We should maybe find out what the original motivation for this task executor.

@holgerd77
Copy link
Member Author

I do remember this prioritized task executor and I tried to remove it at some point (or at least how I remember is by changing it to an optimized version with binary sort). However this had a side effect why it is still this task executor instead of the binary search one. We should maybe find out what the original motivation for this task executor.

Hmm, yeah, this thing is really "interesting". 🤔 🙂

I injected a console log for findPath start and then for priority in pushNodeToQueue(nodeRef: Uint8Array, key: Nibbles = [], priority?: number) along block execution in a client run and this looks like this:

findPath
1
2
3
4
5
6
findPath
1
2
3
4
findPath
1
2
3
4
5
findPath
1
findPath
1
2
3
4
5
findPath
1
2
3
4
findPath
1
2
3
4
5
findPath

So totally linear and simple (and low number, will go a bit higher when in state has gronw, this is in block ~500.000 or so, anyhow).

So this would keep me on the track that this is not doing so much useful.

One theory would be that this was mitigating some flaws of the complicated Promise structrue before (things couldn't execute or something?). But just a theory, does not necessarily need to be true.

I guess we just need to observe this a bit, client sync is likely a good testbed here, solid amount of state + real world scenario. Generally of course also: the higher (block number), the better (on the other hand: testbed before was likely not so ambitious? 🤔)

@jochem-brouwer
Copy link
Member

I agree on the above ^ comment @holgerd77. My gut also says that this prioritized queue thing is a leftover of a very old implementation (it would be somewhat likely this indeed has to do with the old callback structure) and that we can safely remove it, but for completeness just wanted to mention my old experiences. I think if we just let the client run for a while and it does not hiccup we are safe.

@ScottyPoi
Copy link
Contributor

I am so glad to hear all of this! @jochem-brouwer and @holgerd77

The git history on the Trie library is a little vague on exactly who wrote what parts of it, and I don't want to offend any current team members by suggesting big changes.

I totally agree that much of the weird complexity to this library can be stripped away, it just requires some finesse, and some basic fundamental changes.

I'm feeling empowered to tear this library apart, and remake it as a more user friendly and "general purpose" set of tools. Should I treat this PR as a vehicle for all of that?

Or should I try to make granular incremental changes in separate PR's?

@jochem-brouwer
Copy link
Member

I think I wrote/rewrote most of the trie library, go ahead refactoring it :)

If you edit a single method, I think it is great if this is a single PR. However if the refactor is "all over the place" this should be self contained in a single PR also, IMO.

@ScottyPoi ScottyPoi force-pushed the trie-refactor-find-path branch from a6b9a07 to 9de7dc0 Compare May 18, 2023 22:12
Copy link
Member Author

@holgerd77 holgerd77 left a comment

Choose a reason for hiding this comment

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

Hi Scotty,
had a first look on this, some first-round comments! 🙂

This is an impressive amount of new code! 😋

Can you please prioritize branch updates on this, also try to get as much tests as possible (also and in particular non-Trie related) working and continue to do so, so that it gets more transparent where the PR is atm?

Please let us know if you need any help on branch updates or the like, often people responsible for the respective PR can assist a bit.

Ugh. Really excited to see what will come out of this!

What a change set! 😜 😆


const trie = new Trie({ useKeyHashing: true })
const verified = await trie.verifyProof(keccak256(proofBuf[0]), address.bytes, proofBuf)
const veritrie = await Trie.fromProof(keccak256(proofBuf[0]), proofBuf)
Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, that's a nice new API! 🙂 👍

(not sure if final but at least going into a good direction, always looked wrong/out-of-place to me with the explicit Trie instantiation)

@@ -297,7 +297,7 @@ export class DefaultStateManager implements StateManager {
}

const key = this._prefixCodeHashes ? concatBytes(CODEHASH_PREFIX, codeHash) : codeHash
await this._trie.database().put(key, value)
await this._trie.put(key, value)
Copy link
Member Author

Choose a reason for hiding this comment

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

This was a "hacky" way of using the trie database to plainly store bytecode for contracts (the cleaner way would be to explicitly define the database for code along StateManager instantiation or something, or at least have some internal variable codeDB which we set to this._trie.database() on instantiation to make this more clear).

Anyhow, removing this will likely not work. 🙂

(maybe you can short term mitigate by re-adding the database() call + a short comment above on the hack, otherwise other devs will fall upon this again (I have also already a couple of times).

// t.fail(`should have successfully ran block; got error ${err.message}`)
// }
// }
// })
Copy link
Member Author

Choose a reason for hiding this comment

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

Can you use a /* */ comment here to reduce the diff displayed?

// } catch (e) {
// st.pass('successfully threw')
// }
// })
Copy link
Member Author

Choose a reason for hiding this comment

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

Same here, please use /* */ commenting out.

@holgerd77 holgerd77 changed the title Trie: Refactor findPath() Core Retrieval Logic Trie: Internal Refactoring May 23, 2023
@holgerd77 holgerd77 mentioned this pull request May 23, 2023
12 tasks
@ScottyPoi ScottyPoi force-pushed the trie-refactor-find-path branch from 73bc8f9 to 91911a3 Compare May 26, 2023 06:38
@ScottyPoi ScottyPoi force-pushed the trie-refactor-find-path branch from 567cfec to 639f7ac Compare June 11, 2023 19:41
@ScottyPoi ScottyPoi mentioned this pull request Jun 15, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants