From 957065cd20db96cb08f061bf3061f4af1f69f6e8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 29 Aug 2025 08:13:07 +0000 Subject: [PATCH 1/3] feat: cache data hash to optimize performance for empty blocks This optimization addresses the performance issue where hash calculation for empty blocks becomes costly when called repeatedly. The solution: - Adds private cachedHash field to Data struct - Uses thread-safe caching with RWMutex for concurrent access - Automatically invalidates cache when data changes via FromProto - Maintains backward compatibility with existing API Co-authored-by: Marko --- types/data.go | 6 ++++++ types/hashing.go | 21 ++++++++++++++++++++- types/serialization.go | 5 +++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/types/data.go b/types/data.go index ebecac30fb..5677ed9198 100644 --- a/types/data.go +++ b/types/data.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding" "errors" + "sync" "time" "google.golang.org/protobuf/proto" @@ -33,6 +34,11 @@ type Metadata struct { type Data struct { *Metadata Txs Txs + + // cachedHash stores the computed hash to avoid recalculation + cachedHash Hash + // hashMutex protects cachedHash for thread safety + hashMutex sync.RWMutex } // SignedData combines Data and its signature. diff --git a/types/hashing.go b/types/hashing.go index f2c9006e1a..1a56936e14 100644 --- a/types/hashing.go +++ b/types/hashing.go @@ -21,10 +21,29 @@ func (h *Header) Hash() Hash { // Hash returns hash of the Data func (d *Data) Hash() Hash { + // Check for cached hash with read lock + d.hashMutex.RLock() + if d.cachedHash != nil { + hash := d.cachedHash + d.hashMutex.RUnlock() + return hash + } + d.hashMutex.RUnlock() + + // Need to compute hash, acquire write lock + d.hashMutex.Lock() + defer d.hashMutex.Unlock() + + // Double-check in case another goroutine computed it while we were waiting + if d.cachedHash != nil { + return d.cachedHash + } + // Ignoring the marshal error for now to satisfy the go-header interface // Later on the usage of Hash should be replaced with DA commitment dBytes, _ := d.MarshalBinary() - return leafHashOpt(sha256.New(), dBytes) + d.cachedHash = leafHashOpt(sha256.New(), dBytes) + return d.cachedHash } // DACommitment returns the DA commitment of the Data excluding the Metadata diff --git a/types/serialization.go b/types/serialization.go index ee78a81654..9ea0e3fdbe 100644 --- a/types/serialization.go +++ b/types/serialization.go @@ -270,6 +270,11 @@ func (d *Data) FromProto(other *pb.Data) error { d.Metadata = nil } d.Txs = byteSlicesToTxs(other.GetTxs()) + + // Clear cached hash since data has changed + d.hashMutex.Lock() + d.cachedHash = nil + d.hashMutex.Unlock() return nil } From acb1204bdf7d343e11c3d38e26ae53152cf7f5ce Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Mon, 1 Sep 2025 11:33:37 +0200 Subject: [PATCH 2/3] remove hash --- types/data.go | 6 ++---- types/hashing.go | 26 +++++++++----------------- types/hashing_test.go | 30 ++++++++++++++++++++++++++---- types/serialization.go | 5 +---- 4 files changed, 38 insertions(+), 29 deletions(-) diff --git a/types/data.go b/types/data.go index 5677ed9198..ae7638258e 100644 --- a/types/data.go +++ b/types/data.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding" "errors" - "sync" "time" "google.golang.org/protobuf/proto" @@ -34,11 +33,10 @@ type Metadata struct { type Data struct { *Metadata Txs Txs - + // cachedHash stores the computed hash to avoid recalculation + // This field is set once when Hash() is first called and never modified after that cachedHash Hash - // hashMutex protects cachedHash for thread safety - hashMutex sync.RWMutex } // SignedData combines Data and its signature. diff --git a/types/hashing.go b/types/hashing.go index 1a56936e14..061278049e 100644 --- a/types/hashing.go +++ b/types/hashing.go @@ -21,29 +21,21 @@ func (h *Header) Hash() Hash { // Hash returns hash of the Data func (d *Data) Hash() Hash { - // Check for cached hash with read lock - d.hashMutex.RLock() - if d.cachedHash != nil { - hash := d.cachedHash - d.hashMutex.RUnlock() - return hash - } - d.hashMutex.RUnlock() - - // Need to compute hash, acquire write lock - d.hashMutex.Lock() - defer d.hashMutex.Unlock() - - // Double-check in case another goroutine computed it while we were waiting + // Return cached hash if already computed if d.cachedHash != nil { return d.cachedHash } - + + // Compute hash if not cached // Ignoring the marshal error for now to satisfy the go-header interface // Later on the usage of Hash should be replaced with DA commitment dBytes, _ := d.MarshalBinary() - d.cachedHash = leafHashOpt(sha256.New(), dBytes) - return d.cachedHash + hash := leafHashOpt(sha256.New(), dBytes) + + // Cache the result - no synchronization needed since hash computation is idempotent + // If multiple goroutines compute this simultaneously, they'll get the same result + d.cachedHash = hash + return hash } // DACommitment returns the DA commitment of the Data excluding the Metadata diff --git a/types/hashing_test.go b/types/hashing_test.go index 1bcc8691d2..9cd8056d94 100644 --- a/types/hashing_test.go +++ b/types/hashing_test.go @@ -60,14 +60,36 @@ func TestDataHash(t *testing.T) { assert.Len(t, hash1, sha256.Size) assert.Equal(t, Hash(expectedHash), hash1, "Data hash should match manual calculation with prefix") - data.Txs = Txs{Tx("tx3")} - hash2 := data.Hash() + // Test that different Data objects with different Txs have different hashes + data2 := Data{ + Txs: Txs{Tx("tx3")}, + Metadata: &Metadata{ + ChainID: "test-chain", + Height: 1, + Time: 1234567890, + LastDataHash: []byte("lastdatahash"), + }, + } + hash2 := data2.Hash() assert.NotEqual(t, hash1, hash2, "Different data (Txs) should have different hashes") - data.Metadata.Height = 2 - hash3 := data.Hash() + // Test that different Data objects with different Metadata have different hashes + data3 := Data{ + Txs: Txs{Tx("tx1"), Tx("tx2")}, + Metadata: &Metadata{ + ChainID: "test-chain", + Height: 2, // Different height + Time: 1234567890, + LastDataHash: []byte("lastdatahash"), + }, + } + hash3 := data3.Hash() assert.NotEqual(t, hash1, hash3, "Different data (Metadata) should have different hashes") assert.NotEqual(t, hash2, hash3) + + // Test that calling Hash() multiple times on the same object returns the same result (caching) + hash1Again := data.Hash() + assert.Equal(t, hash1, hash1Again, "Hash should be cached and return same result") } // TestLeafHashOpt tests the helper function leafHashOpt directly. diff --git a/types/serialization.go b/types/serialization.go index 9ea0e3fdbe..d83f038f09 100644 --- a/types/serialization.go +++ b/types/serialization.go @@ -270,11 +270,8 @@ func (d *Data) FromProto(other *pb.Data) error { d.Metadata = nil } d.Txs = byteSlicesToTxs(other.GetTxs()) - - // Clear cached hash since data has changed - d.hashMutex.Lock() + d.cachedHash = nil - d.hashMutex.Unlock() return nil } From e45d7d623a10ad89bb07dbc6c1de6ee6770e0542 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Mon, 1 Sep 2025 14:19:46 +0200 Subject: [PATCH 3/3] modify proto to have cache --- proto/evnode/v1/evnode.proto | 1 + types/hashing.go | 3 ++- types/hashing_test.go | 3 ++- types/pb/evnode/v1/evnode.pb.go | 14 ++++++++++++-- types/serialization.go | 27 ++++++++++++++++++++++++++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/proto/evnode/v1/evnode.proto b/proto/evnode/v1/evnode.proto index f1ca34ae7d..6048536a3a 100644 --- a/proto/evnode/v1/evnode.proto +++ b/proto/evnode/v1/evnode.proto @@ -93,6 +93,7 @@ message Metadata { message Data { Metadata metadata = 1; repeated bytes txs = 2; + bytes cached_hash = 3; // Optional cached hash for performance } // SignedData is a data with a signature and a signer. diff --git a/types/hashing.go b/types/hashing.go index 061278049e..15c6c400c8 100644 --- a/types/hashing.go +++ b/types/hashing.go @@ -29,7 +29,8 @@ func (d *Data) Hash() Hash { // Compute hash if not cached // Ignoring the marshal error for now to satisfy the go-header interface // Later on the usage of Hash should be replaced with DA commitment - dBytes, _ := d.MarshalBinary() + // Use MarshalBinaryWithoutCache to avoid circular dependency with cached hash + dBytes, _ := d.MarshalBinaryWithoutCache() hash := leafHashOpt(sha256.New(), dBytes) // Cache the result - no synchronization needed since hash computation is idempotent diff --git a/types/hashing_test.go b/types/hashing_test.go index 9cd8056d94..43f7e0c126 100644 --- a/types/hashing_test.go +++ b/types/hashing_test.go @@ -48,7 +48,8 @@ func TestDataHash(t *testing.T) { hash1 := data.Hash() - dataBytes, err := data.MarshalBinary() + // Use MarshalBinaryWithoutCache for consistent hash calculation + dataBytes, err := data.MarshalBinaryWithoutCache() require.NoError(t, err) hasher := sha256.New() diff --git a/types/pb/evnode/v1/evnode.pb.go b/types/pb/evnode/v1/evnode.pb.go index 700b2182ef..93c5748d6d 100644 --- a/types/pb/evnode/v1/evnode.pb.go +++ b/types/pb/evnode/v1/evnode.pb.go @@ -423,6 +423,7 @@ type Data struct { state protoimpl.MessageState `protogen:"open.v1"` Metadata *Metadata `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` Txs [][]byte `protobuf:"bytes,2,rep,name=txs,proto3" json:"txs,omitempty"` + CachedHash []byte `protobuf:"bytes,3,opt,name=cached_hash,json=cachedHash,proto3" json:"cached_hash,omitempty"` // Optional cached hash for performance unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -471,6 +472,13 @@ func (x *Data) GetTxs() [][]byte { return nil } +func (x *Data) GetCachedHash() []byte { + if x != nil { + return x.CachedHash + } + return nil +} + // SignedData is a data with a signature and a signer. type SignedData struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -647,10 +655,12 @@ const file_evnode_v1_evnode_proto_rawDesc = "" + "\bchain_id\x18\x01 \x01(\tR\achainId\x12\x16\n" + "\x06height\x18\x02 \x01(\x04R\x06height\x12\x12\n" + "\x04time\x18\x03 \x01(\x04R\x04time\x12$\n" + - "\x0elast_data_hash\x18\x04 \x01(\fR\flastDataHash\"I\n" + + "\x0elast_data_hash\x18\x04 \x01(\fR\flastDataHash\"j\n" + "\x04Data\x12/\n" + "\bmetadata\x18\x01 \x01(\v2\x13.evnode.v1.MetadataR\bmetadata\x12\x10\n" + - "\x03txs\x18\x02 \x03(\fR\x03txs\"z\n" + + "\x03txs\x18\x02 \x03(\fR\x03txs\x12\x1f\n" + + "\vcached_hash\x18\x03 \x01(\fR\n" + + "cachedHash\"z\n" + "\n" + "SignedData\x12#\n" + "\x04data\x18\x01 \x01(\v2\x0f.evnode.v1.DataR\x04data\x12\x1c\n" + diff --git a/types/serialization.go b/types/serialization.go index d83f038f09..2ac77baa18 100644 --- a/types/serialization.go +++ b/types/serialization.go @@ -47,6 +47,11 @@ func (d *Data) MarshalBinary() ([]byte, error) { return proto.Marshal(d.ToProto()) } +// MarshalBinaryWithoutCache encodes Data without cached hash for hash calculation +func (d *Data) MarshalBinaryWithoutCache() ([]byte, error) { + return proto.Marshal(d.ToProtoWithoutCache()) +} + // UnmarshalBinary decodes binary form of Data into object. func (d *Data) UnmarshalBinary(data []byte) error { var pData pb.Data @@ -244,6 +249,20 @@ func (m *Metadata) FromProto(other *pb.Metadata) error { // ToProto converts Data into protobuf representation and returns it. func (d *Data) ToProto() *pb.Data { + var mProto *pb.Metadata + if d.Metadata != nil { + mProto = d.Metadata.ToProto() + } + return &pb.Data{ + Metadata: mProto, + Txs: txsToByteSlices(d.Txs), + CachedHash: d.cachedHash, + } +} + +// ToProtoWithoutCache converts Data to protobuf without the cached hash +// This is used for hash calculation to avoid circular dependency +func (d *Data) ToProtoWithoutCache() *pb.Data { var mProto *pb.Metadata if d.Metadata != nil { mProto = d.Metadata.ToProto() @@ -251,6 +270,7 @@ func (d *Data) ToProto() *pb.Data { return &pb.Data{ Metadata: mProto, Txs: txsToByteSlices(d.Txs), + // CachedHash is intentionally omitted for hash calculation } } @@ -271,7 +291,12 @@ func (d *Data) FromProto(other *pb.Data) error { } d.Txs = byteSlicesToTxs(other.GetTxs()) - d.cachedHash = nil + // Restore cached hash from protobuf if available + if other.CachedHash != nil { + d.cachedHash = other.CachedHash + } else { + d.cachedHash = nil + } return nil }