From e013fe2850bb5d1abf7048466e5d8291102a12aa Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 16:17:22 +0000 Subject: [PATCH 1/7] Add ProofBuilder benchmark --- client/client_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/client/client_test.go b/client/client_test.go index e6a606162..8aa8f5ae0 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -350,3 +350,84 @@ func TestNodeFetcherAddressing(t *testing.T) { }) } } + +func BenchmarkProofBuilder(b *testing.B) { + ctx := context.Background() + const treeSize = 1_000_000 + dummyHash := sha256.Sum256([]byte("dummy")) + + // Pre-generate a full tile to avoid marshaling in the loop. + fullTile := &api.HashTile{ + Nodes: make([][]byte, 256), + } + for i := range fullTile.Nodes { + fullTile.Nodes[i] = dummyHash[:] + } + fullTileBytes, err := fullTile.MarshalText() + if err != nil { + b.Fatalf("failed to marshal full tile: %v", err) + } + + // We'll ignore partial tiles for simplicity in this benchmark as they are rare in a 1M tree + // except for the very last tiles of each level. + f := func(_ context.Context, _, _ uint64, p uint8) ([]byte, error) { + if p == 0 { + return fullTileBytes, nil + } + // Handle partial tiles just in case, though they might not be hit often. + partialTile := &api.HashTile{ + Nodes: make([][]byte, p), + } + for i := range partialTile.Nodes { + partialTile.Nodes[i] = dummyHash[:] + } + return partialTile.MarshalText() + } + + b.Run("InclusionProof", func(b *testing.B) { + b.Run("WarmCache", func(b *testing.B) { + pb, _ := NewProofBuilder(ctx, treeSize, f) + // Warm up the cache with some proofs. + for i := uint64(0); i < 100; i++ { + _, _ = pb.InclusionProof(ctx, i) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = pb.InclusionProof(ctx, uint64(i%treeSize)) + } + }) + + b.Run("ColdCache", func(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + pb, _ := NewProofBuilder(ctx, treeSize, f) + b.StartTimer() + _, _ = pb.InclusionProof(ctx, uint64(i%treeSize)) + } + }) + }) + + b.Run("ConsistencyProof", func(b *testing.B) { + b.Run("WarmCache", func(b *testing.B) { + pb, _ := NewProofBuilder(ctx, treeSize, f) + // Warm up. + for i := uint64(0); i < 100; i++ { + _, _ = pb.ConsistencyProof(ctx, i, i+1) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Benchmark consistency proof from a smaller size to the full tree size. + _, _ = pb.ConsistencyProof(ctx, uint64(i%treeSize), treeSize) + } + }) + + b.Run("ColdCache", func(b *testing.B) { + for i := 0; i < b.N; i++ { + b.StopTimer() + pb, _ := NewProofBuilder(ctx, treeSize, f) + b.StartTimer() + _, _ = pb.ConsistencyProof(ctx, uint64(i%treeSize), treeSize) + } + }) + }) +} From 28c36fc0631204fcf79578a59d698bdae48a60da Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 11:55:58 +0000 Subject: [PATCH 2/7] First cut --- client/client.go | 102 ++++++++++++++++++++++++++++------------------- 1 file changed, 62 insertions(+), 40 deletions(-) diff --git a/client/client.go b/client/client.go index 5b04f88f7..57c97cd04 100644 --- a/client/client.go +++ b/client/client.go @@ -23,6 +23,7 @@ import ( "fmt" "sync" + lru "github.com/hashicorp/golang-lru/v2" "github.com/transparency-dev/formats/log" "github.com/transparency-dev/merkle/compact" "github.com/transparency-dev/merkle/proof" @@ -32,6 +33,8 @@ import ( "github.com/transparency-dev/tessera/internal/otel" "go.opentelemetry.io/otel/trace" "golang.org/x/mod/sumdb/note" + "golang.org/x/sync/errgroup" + "golang.org/x/sync/singleflight" ) var ( @@ -125,15 +128,7 @@ func FetchRangeNodes(ctx context.Context, s uint64, f TileFetcherFunc) ([][]byte nc := newNodeCache(f, s) nIDs := make([]compact.NodeID, 0, compact.RangeSize(0, s)) nIDs = compact.RangeNodes(0, s, nIDs) - hashes := make([][]byte, 0, len(nIDs)) - for _, n := range nIDs { - h, err := nc.GetNode(ctx, n) - if err != nil { - return nil, err - } - hashes = append(hashes, h) - } - return hashes, nil + return nc.GetNodes(ctx, nIDs) }) } @@ -180,7 +175,7 @@ func GetEntryBundle(ctx context.Context, f EntryBundleFetcherFunc, i, logSize ui // at a given tree size. type ProofBuilder struct { treeSize uint64 - nodeCache nodeCache + nodeCache *nodeCache } // NewProofBuilder creates a new ProofBuilder object for a given tree size. @@ -225,18 +220,12 @@ func (pb *ProofBuilder) ConsistencyProof(ctx context.Context, smaller, larger ui }) } -// fetchNodes retrieves the specified proof nodes via pb's nodeCache. +// fetchNodesAndRehash retrieves the specified proof nodes via pb's nodeCache, rehashing them if necessary. func (pb *ProofBuilder) fetchNodes(ctx context.Context, nodes proof.Nodes) ([][]byte, error) { - hashes := make([][]byte, 0, len(nodes.IDs)) - // TODO(al) parallelise this. - for _, id := range nodes.IDs { - h, err := pb.nodeCache.GetNode(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get node (%v): %v", id, err) - } - hashes = append(hashes, h) + hashes, err := pb.nodeCache.GetNodes(ctx, nodes.IDs) + if err != nil { + return nil, err } - var err error if hashes, err = nodes.Rehash(hashes, hasher.HashChildren); err != nil { return nil, fmt.Errorf("failed to rehash proof: %v", err) } @@ -352,28 +341,31 @@ type tileKey struct { // nodeCache hides the tiles abstraction away, and improves // performance by caching tiles it's seen. -// Not threadsafe, and intended to be only used throughout the course -// of a single request. +// Threadsafe. type nodeCache struct { logSize uint64 - ephemeral map[compact.NodeID][]byte - tiles map[tileKey]api.HashTile + ephemeral sync.Map + tiles *lru.Cache[tileKey, api.HashTile] getTile TileFetcherFunc + g singleflight.Group } // newNodeCache creates a new nodeCache instance for a given log size. -func newNodeCache(f TileFetcherFunc, logSize uint64) nodeCache { - return nodeCache{ - logSize: logSize, - ephemeral: make(map[compact.NodeID][]byte), - tiles: make(map[tileKey]api.HashTile), - getTile: f, +func newNodeCache(f TileFetcherFunc, logSize uint64) *nodeCache { + c, err := lru.New[tileKey, api.HashTile](1024) + if err != nil { + panic(fmt.Errorf("lru.New: %v", err)) + } + return &nodeCache{ + logSize: logSize, + tiles: c, + getTile: f, } } // SetEphemeralNode stored a derived "ephemeral" tree node. func (n *nodeCache) SetEphemeralNode(id compact.NodeID, h []byte) { - n.ephemeral[id] = h + n.ephemeral.Store(id, h) } // GetNode returns the internal log tree node hash for the specified node ID. @@ -385,27 +377,38 @@ func (n *nodeCache) GetNode(ctx context.Context, id compact.NodeID) ([]byte, err span.SetAttributes(indexKey.Int64(otel.Clamp64(id.Index)), levelKey.Int64(int64(id.Level))) // First check for ephemeral nodes: - if e := n.ephemeral[id]; len(e) != 0 { - return e, nil + if e, ok := n.ephemeral.Load(id); ok { + return e.([]byte), nil } // Otherwise look in fetched tiles: tileLevel, tileIndex, nodeLevel, nodeIndex := layout.NodeCoordsToTileAddress(uint64(id.Level), uint64(id.Index)) - tKey := tileKey{tileLevel, tileIndex} - t, ok := n.tiles[tKey] - if !ok { + ti, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { + tKey := tileKey{tileLevel, tileIndex} + // Check cache + if t, ok := n.tiles.Get(tKey); ok { + return t, nil + } + span.AddEvent("cache miss") p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) if err != nil { - return nil, fmt.Errorf("failed to fetch tile: %v", err) + return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) } + var tile api.HashTile if err := tile.UnmarshalText(tileRaw); err != nil { - return nil, fmt.Errorf("failed to parse tile: %v", err) + return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) } - t = tile - n.tiles[tKey] = tile + + n.tiles.Add(tKey, tile) + return tile, nil + }) + if err != nil { + return nil, err } + t := ti.(api.HashTile) + // We've got the tile, now we need to look up (or calculate) the node inside of it numLeaves := 1 << nodeLevel firstLeaf := int(nodeIndex) * numLeaves @@ -423,3 +426,22 @@ func (n *nodeCache) GetNode(ctx context.Context, id compact.NodeID) ([]byte, err return r.GetRootHash(nil) }) } + +func (n *nodeCache) GetNodes(ctx context.Context, nIDs []compact.NodeID) ([][]byte, error) { + hashes := make([][]byte, len(nIDs)) + g, ctx := errgroup.WithContext(ctx) + for i, id := range nIDs { + g.Go(func() error { + h, err := n.GetNode(ctx, id) + if err != nil { + return err + } + hashes[i] = h + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + return hashes, nil +} From 44786511f16540cf669cfa452ce33f3344e04508 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 11:55:58 +0000 Subject: [PATCH 3/7] Extract fetchTile --- client/client.go | 53 +++++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/client/client.go b/client/client.go index 57c97cd04..ee4497296 100644 --- a/client/client.go +++ b/client/client.go @@ -382,32 +382,11 @@ func (n *nodeCache) GetNode(ctx context.Context, id compact.NodeID) ([]byte, err } // Otherwise look in fetched tiles: tileLevel, tileIndex, nodeLevel, nodeIndex := layout.NodeCoordsToTileAddress(uint64(id.Level), uint64(id.Index)) - ti, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { - tKey := tileKey{tileLevel, tileIndex} - // Check cache - if t, ok := n.tiles.Get(tKey); ok { - return t, nil - } - - span.AddEvent("cache miss") - p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) - tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) - if err != nil { - return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) - } - - var tile api.HashTile - if err := tile.UnmarshalText(tileRaw); err != nil { - return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) - } - - n.tiles.Add(tKey, tile) - return tile, nil - }) + p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) + t, err := n.fetchTile(ctx, tileLevel, tileIndex, p) if err != nil { return nil, err } - t := ti.(api.HashTile) // We've got the tile, now we need to look up (or calculate) the node inside of it numLeaves := 1 << nodeLevel @@ -445,3 +424,31 @@ func (n *nodeCache) GetNodes(ctx context.Context, nIDs []compact.NodeID) ([][]by } return hashes, nil } + +func (n *nodeCache) fetchTile(ctx context.Context, tileLevel, tileIndex uint64, p uint8) (api.HashTile, error) { + ti, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { + return otel.Trace(ctx, "tessera.client.nodecache.fetchTile", tracer, func(ctx context.Context, span trace.Span) (api.HashTile, error) { + tKey := tileKey{tileLevel, tileIndex} + // Check cache + if t, ok := n.tiles.Get(tKey); ok { + return t, nil + } + + span.AddEvent("cache miss") + tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) + if err != nil { + return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) + } + + var tile api.HashTile + if err := tile.UnmarshalText(tileRaw); err != nil { + return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) + } + + n.tiles.Add(tKey, tile) + return tile, nil + }) + }) + t := ti.(api.HashTile) + return t, err +} From cff3557d068d6b90b036bcdd7ceeff42de541d85 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 11:55:58 +0000 Subject: [PATCH 4/7] Improve naming --- client/client.go | 6 +++--- client/client_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/client.go b/client/client.go index ee4497296..2959f9158 100644 --- a/client/client.go +++ b/client/client.go @@ -199,7 +199,7 @@ func (pb *ProofBuilder) InclusionProof(ctx context.Context, index uint64) ([][]b if err != nil { return nil, fmt.Errorf("failed to calculate inclusion proof node list: %v", err) } - return pb.fetchNodes(ctx, nodes) + return pb.fetchNodesAndRehash(ctx, nodes) }) } @@ -216,12 +216,12 @@ func (pb *ProofBuilder) ConsistencyProof(ctx context.Context, smaller, larger ui if err != nil { return nil, fmt.Errorf("failed to calculate consistency proof node list: %v", err) } - return pb.fetchNodes(ctx, nodes) + return pb.fetchNodesAndRehash(ctx, nodes) }) } // fetchNodesAndRehash retrieves the specified proof nodes via pb's nodeCache, rehashing them if necessary. -func (pb *ProofBuilder) fetchNodes(ctx context.Context, nodes proof.Nodes) ([][]byte, error) { +func (pb *ProofBuilder) fetchNodesAndRehash(ctx context.Context, nodes proof.Nodes) ([][]byte, error) { hashes, err := pb.nodeCache.GetNodes(ctx, nodes.IDs) if err != nil { return nil, err diff --git a/client/client_test.go b/client/client_test.go index 8aa8f5ae0..9672520a6 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -334,7 +334,7 @@ func TestNodeFetcherAddressing(t *testing.T) { if err != nil { t.Fatalf("NewProofBuilder: %v", err) } - _, err = pb.fetchNodes(t.Context(), proof.Nodes{IDs: []compact.NodeID{compact.NewNodeID(test.nodeLevel, test.nodeIdx)}}) + _, err = pb.fetchNodesAndRehash(t.Context(), proof.Nodes{IDs: []compact.NodeID{compact.NewNodeID(test.nodeLevel, test.nodeIdx)}}) if err != nil { t.Fatalf("fetchNodes: %v", err) } From d9ca72994e26ab66d76ebbab69c335c39131f2ec Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 15:02:15 +0000 Subject: [PATCH 5/7] Cache nodes rather than tiles to avoid unnecessary recalculation --- client/client.go | 131 +++++++++++++++++++++++------------------------ 1 file changed, 64 insertions(+), 67 deletions(-) diff --git a/client/client.go b/client/client.go index 2959f9158..645293616 100644 --- a/client/client.go +++ b/client/client.go @@ -333,79 +333,88 @@ func (lst *LogStateTracker) Latest() log.Checkpoint { return lst.latestConsistent } -// tileKey is used as a key in nodeCache's tile map. -type tileKey struct { - tileLevel uint64 - tileIndex uint64 -} - // nodeCache hides the tiles abstraction away, and improves // performance by caching tiles it's seen. // Threadsafe. type nodeCache struct { - logSize uint64 - ephemeral sync.Map - tiles *lru.Cache[tileKey, api.HashTile] - getTile TileFetcherFunc - g singleflight.Group + logSize uint64 + nodes *lru.Cache[compact.NodeID, []byte] + getTile TileFetcherFunc + g singleflight.Group } // newNodeCache creates a new nodeCache instance for a given log size. func newNodeCache(f TileFetcherFunc, logSize uint64) *nodeCache { - c, err := lru.New[tileKey, api.HashTile](1024) + c, err := lru.New[compact.NodeID, []byte](64 << 10) if err != nil { panic(fmt.Errorf("lru.New: %v", err)) } return &nodeCache{ logSize: logSize, - tiles: c, + nodes: c, getTile: f, } } -// SetEphemeralNode stored a derived "ephemeral" tree node. -func (n *nodeCache) SetEphemeralNode(id compact.NodeID, h []byte) { - n.ephemeral.Store(id, h) -} - // GetNode returns the internal log tree node hash for the specified node ID. -// A previously set ephemeral node will be returned if id matches, otherwise -// the tile containing the requested node will be fetched and cached, and the -// node hash returned. +// The tile containing the node will be fetched if necessary. func (n *nodeCache) GetNode(ctx context.Context, id compact.NodeID) ([]byte, error) { return otel.Trace(ctx, "tessera.client.nodecache.GetNode", tracer, func(ctx context.Context, span trace.Span) ([]byte, error) { span.SetAttributes(indexKey.Int64(otel.Clamp64(id.Index)), levelKey.Int64(int64(id.Level))) - // First check for ephemeral nodes: - if e, ok := n.ephemeral.Load(id); ok { - return e.([]byte), nil - } - // Otherwise look in fetched tiles: - tileLevel, tileIndex, nodeLevel, nodeIndex := layout.NodeCoordsToTileAddress(uint64(id.Level), uint64(id.Index)) - p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) - t, err := n.fetchTile(ctx, tileLevel, tileIndex, p) - if err != nil { - return nil, err + // Check if we've already cached this node, return it directly if so, otherwise we'll need to fetch it. + if e, ok := n.nodes.Get(id); ok { + return e, nil } - // We've got the tile, now we need to look up (or calculate) the node inside of it - numLeaves := 1 << nodeLevel - firstLeaf := int(nodeIndex) * numLeaves - lastLeaf := firstLeaf + numLeaves - if lastLeaf > len(t.Nodes) { - return nil, fmt.Errorf("require leaf nodes [%d, %d) but only got %d leaves", firstLeaf, lastLeaf, len(t.Nodes)) - } - rf := compact.RangeFactory{Hash: hasher.HashChildren} - r := rf.NewEmptyRange(0) - for _, l := range t.Nodes[firstLeaf:lastLeaf] { - if err := r.Append(l, nil); err != nil { - return nil, fmt.Errorf("failed to Append: %v", err) + // We now need to fetch the tile and use the contents to populate the cache. + // We'll fetch/parse the tile, and then reconsistute all the internal nodes into the + // node cache. We only want to do this once per tile, so use singleflight keyed by the _tile_ ID + // to make that happen. + tileLevel, tileIndex, _, _ := layout.NodeCoordsToTileAddress(uint64(id.Level), uint64(id.Index)) + _, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { + span.AddEvent("cache miss") + + p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) + tile, err := n.fetchTile(ctx, tileLevel, tileIndex, p) + if err != nil { + return struct{}{}, err + } + + // visitFn is a visitor callback which populates the nodes cache. + // Used by the calls to compact range below. + visitFn := func(intID compact.NodeID, h []byte) { + // Figure out the "global" nodeID for the node intID in the requested tile. + i := compact.NodeID{ + Level: uint(tileLevel*layout.TileHeight) + intID.Level, + Index: (tileIndex*layout.TileWidth)>>intID.Level + intID.Index, + } + _ = n.nodes.Add(i, h) + } + rf := compact.RangeFactory{Hash: hasher.HashChildren} + r := rf.NewEmptyRange(0) + for _, l := range tile.Nodes { + if err := r.Append(l, visitFn); err != nil { + return struct{}{}, fmt.Errorf("failed to Append: %v", err) + } + } + if _, err := r.GetRootHash(visitFn); err != nil { + return struct{}{}, fmt.Errorf("failed to visit all nodes: %v", err) } + + return struct{}{}, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to fetch and populate node cache: %v", err) + } + if e, ok := n.nodes.Get(id); ok { + return e, nil } - return r.GetRootHash(nil) + return nil, fmt.Errorf("internal error: missing node %+v", id) }) } +// GetNodes returns the tree hashes at the provided locations. func (n *nodeCache) GetNodes(ctx context.Context, nIDs []compact.NodeID) ([][]byte, error) { hashes := make([][]byte, len(nIDs)) g, ctx := errgroup.WithContext(ctx) @@ -425,30 +434,18 @@ func (n *nodeCache) GetNodes(ctx context.Context, nIDs []compact.NodeID) ([][]by return hashes, nil } +// fetchTile fetches and parses the specified tile from storage. func (n *nodeCache) fetchTile(ctx context.Context, tileLevel, tileIndex uint64, p uint8) (api.HashTile, error) { - ti, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { - return otel.Trace(ctx, "tessera.client.nodecache.fetchTile", tracer, func(ctx context.Context, span trace.Span) (api.HashTile, error) { - tKey := tileKey{tileLevel, tileIndex} - // Check cache - if t, ok := n.tiles.Get(tKey); ok { - return t, nil - } - - span.AddEvent("cache miss") - tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) - if err != nil { - return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) - } - - var tile api.HashTile - if err := tile.UnmarshalText(tileRaw); err != nil { - return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) - } + return otel.Trace(ctx, "tessera.client.nodecache.fetchTile", tracer, func(ctx context.Context, span trace.Span) (api.HashTile, error) { + tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) + if err != nil { + return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) + } - n.tiles.Add(tKey, tile) - return tile, nil - }) + var tile api.HashTile + if err := tile.UnmarshalText(tileRaw); err != nil { + return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) + } + return tile, nil }) - t := ti.(api.HashTile) - return t, err } From ddff16aaa58d11a734290f174280925d3b269c27 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 16:32:41 +0000 Subject: [PATCH 6/7] Combine fetchTile and cache population --- client/client.go | 64 +++++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/client/client.go b/client/client.go index 645293616..103b2d66d 100644 --- a/client/client.go +++ b/client/client.go @@ -372,36 +372,19 @@ func (n *nodeCache) GetNode(ctx context.Context, id compact.NodeID) ([]byte, err // node cache. We only want to do this once per tile, so use singleflight keyed by the _tile_ ID // to make that happen. tileLevel, tileIndex, _, _ := layout.NodeCoordsToTileAddress(uint64(id.Level), uint64(id.Index)) - _, err, _ := n.g.Do(fmt.Sprintf("%d/%d", tileLevel, tileIndex), func() (any, error) { + k := fmt.Sprintf("%d/%d", tileLevel, tileIndex) + defer n.g.Forget(k) + _, err, _ := n.g.Do(k, func() (any, error) { + if n.nodes.Contains(id) { + return struct{}{}, nil + } span.AddEvent("cache miss") p := layout.PartialTileSize(tileLevel, tileIndex, n.logSize) - tile, err := n.fetchTile(ctx, tileLevel, tileIndex, p) - if err != nil { + if err := n.fetchTileAndPopulateCache(ctx, tileLevel, tileIndex, p); err != nil { return struct{}{}, err } - // visitFn is a visitor callback which populates the nodes cache. - // Used by the calls to compact range below. - visitFn := func(intID compact.NodeID, h []byte) { - // Figure out the "global" nodeID for the node intID in the requested tile. - i := compact.NodeID{ - Level: uint(tileLevel*layout.TileHeight) + intID.Level, - Index: (tileIndex*layout.TileWidth)>>intID.Level + intID.Index, - } - _ = n.nodes.Add(i, h) - } - rf := compact.RangeFactory{Hash: hasher.HashChildren} - r := rf.NewEmptyRange(0) - for _, l := range tile.Nodes { - if err := r.Append(l, visitFn); err != nil { - return struct{}{}, fmt.Errorf("failed to Append: %v", err) - } - } - if _, err := r.GetRootHash(visitFn); err != nil { - return struct{}{}, fmt.Errorf("failed to visit all nodes: %v", err) - } - return struct{}{}, nil }) if err != nil { @@ -434,18 +417,39 @@ func (n *nodeCache) GetNodes(ctx context.Context, nIDs []compact.NodeID) ([][]by return hashes, nil } -// fetchTile fetches and parses the specified tile from storage. -func (n *nodeCache) fetchTile(ctx context.Context, tileLevel, tileIndex uint64, p uint8) (api.HashTile, error) { - return otel.Trace(ctx, "tessera.client.nodecache.fetchTile", tracer, func(ctx context.Context, span trace.Span) (api.HashTile, error) { +// fetchTileAndPopulateCache fetches and parses the specified tile from storage, populating the nodes it contains into the node cache. +func (n *nodeCache) fetchTileAndPopulateCache(ctx context.Context, tileLevel, tileIndex uint64, p uint8) error { + return otel.TraceErr(ctx, "tessera.client.nodecache.fetchTileAndPopulateCache", tracer, func(ctx context.Context, span trace.Span) error { tileRaw, err := n.getTile(ctx, tileLevel, tileIndex, p) if err != nil { - return api.HashTile{}, fmt.Errorf("failed to fetch tile: %v", err) + return fmt.Errorf("failed to fetch tile: %v", err) } var tile api.HashTile if err := tile.UnmarshalText(tileRaw); err != nil { - return api.HashTile{}, fmt.Errorf("failed to parse tile: %v", err) + return fmt.Errorf("failed to parse tile: %v", err) + } + + // visitFn is a visitor callback which populates the nodes cache. + // Used by the calls to compact range below. + visitFn := func(intID compact.NodeID, h []byte) { + // Figure out the "global" nodeID for the node intID in the requested tile. + i := compact.NodeID{ + Level: uint(tileLevel*layout.TileHeight) + intID.Level, + Index: (tileIndex*layout.TileWidth)>>intID.Level + intID.Index, + } + _ = n.nodes.Add(i, h) + } + rf := compact.RangeFactory{Hash: hasher.HashChildren} + r := rf.NewEmptyRange(0) + for _, l := range tile.Nodes { + if err := r.Append(l, visitFn); err != nil { + return fmt.Errorf("failed to Append: %v", err) + } + } + if _, err := r.GetRootHash(visitFn); err != nil { + return fmt.Errorf("failed to visit all nodes: %v", err) } - return tile, nil + return nil }) } From 7ad6e139234d57266693be01e7605482db3140d8 Mon Sep 17 00:00:00 2001 From: Al Cutter Date: Fri, 27 Mar 2026 16:54:21 +0000 Subject: [PATCH 7/7] Rename fetchAndRehash to avoid confusion with lower levels --- client/client.go | 8 ++++---- client/client_test.go | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/client.go b/client/client.go index 103b2d66d..e1f901815 100644 --- a/client/client.go +++ b/client/client.go @@ -199,7 +199,7 @@ func (pb *ProofBuilder) InclusionProof(ctx context.Context, index uint64) ([][]b if err != nil { return nil, fmt.Errorf("failed to calculate inclusion proof node list: %v", err) } - return pb.fetchNodesAndRehash(ctx, nodes) + return pb.materialiseProof(ctx, nodes) }) } @@ -216,12 +216,12 @@ func (pb *ProofBuilder) ConsistencyProof(ctx context.Context, smaller, larger ui if err != nil { return nil, fmt.Errorf("failed to calculate consistency proof node list: %v", err) } - return pb.fetchNodesAndRehash(ctx, nodes) + return pb.materialiseProof(ctx, nodes) }) } -// fetchNodesAndRehash retrieves the specified proof nodes via pb's nodeCache, rehashing them if necessary. -func (pb *ProofBuilder) fetchNodesAndRehash(ctx context.Context, nodes proof.Nodes) ([][]byte, error) { +// materialiseProof retrieves the specified proof nodes via pb's nodeCache, recreating ephemeral nodes if necessary. +func (pb *ProofBuilder) materialiseProof(ctx context.Context, nodes proof.Nodes) ([][]byte, error) { hashes, err := pb.nodeCache.GetNodes(ctx, nodes.IDs) if err != nil { return nil, err diff --git a/client/client_test.go b/client/client_test.go index 9672520a6..1cba4f6ba 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -334,7 +334,7 @@ func TestNodeFetcherAddressing(t *testing.T) { if err != nil { t.Fatalf("NewProofBuilder: %v", err) } - _, err = pb.fetchNodesAndRehash(t.Context(), proof.Nodes{IDs: []compact.NodeID{compact.NewNodeID(test.nodeLevel, test.nodeIdx)}}) + _, err = pb.materialiseProof(t.Context(), proof.Nodes{IDs: []compact.NodeID{compact.NewNodeID(test.nodeLevel, test.nodeIdx)}}) if err != nil { t.Fatalf("fetchNodes: %v", err) }