diff --git a/pkg/feeds/testing/lookup.go b/pkg/feeds/testing/lookup.go index 99c424348b2..76deb24412a 100644 --- a/pkg/feeds/testing/lookup.go +++ b/pkg/feeds/testing/lookup.go @@ -41,6 +41,20 @@ func (t *Timeout) Get(ctx context.Context, addr swarm.Address) (swarm.Chunk, err return ch, nil } +// GetInto implements the ChunkStore interface. Without this override the +// promoted method would bypass the latency Get introduces. +func (t *Timeout) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (int, error) { + n, err := t.ChunkStore.GetInto(ctx, addr, buf) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + time.Sleep(searchTimeout) + } + return n, err + } + time.Sleep(time.Duration(rand.Intn(10)) * time.Millisecond) + return n, nil +} + // nolint:tparallel func TestFinderBasic(t *testing.T, finderf func(storage.Getter, *feeds.Feed) feeds.Lookup, updaterf func(putter storage.Putter, signer crypto.Signer, topic []byte) (feeds.Updater, error)) { t.Parallel() diff --git a/pkg/file/joiner/joiner_test.go b/pkg/file/joiner/joiner_test.go index dd1a7e4228e..9fe93640c92 100644 --- a/pkg/file/joiner/joiner_test.go +++ b/pkg/file/joiner/joiner_test.go @@ -1396,6 +1396,18 @@ func (c *chunkStore) Get(_ context.Context, addr swarm.Address) (swarm.Chunk, er return chunk, nil } +func (c *chunkStore) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (int, error) { + ch, err := c.Get(ctx, addr) + if err != nil { + return 0, err + } + data := ch.Data() + if len(buf) < len(data) { + return 0, fmt.Errorf("chunk store: buffer too small: %d < %d", len(buf), len(data)) + } + return copy(buf, data), nil +} + func (c *chunkStore) Put(_ context.Context, ch swarm.Chunk) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/pkg/storage/chunkstore.go b/pkg/storage/chunkstore.go index 68a9d10652a..faefd1f4489 100644 --- a/pkg/storage/chunkstore.go +++ b/pkg/storage/chunkstore.go @@ -21,6 +21,14 @@ type Getter interface { Get(context.Context, swarm.Address) (swarm.Chunk, error) } +// GetterInto is like Getter but reads chunk data into a caller-provided buffer, +// avoiding per-call allocations. len(buf) must be at least the chunk size; +// GetInto never writes past len(buf). Returns the number of bytes read into +// buf; callers use buf[:n]. +type GetterInto interface { + GetInto(ctx context.Context, addr swarm.Address, buf []byte) (int, error) +} + // Putter is the interface that wraps the basic Put method. type Putter interface { // Put a chunk into the store alongside with its postage stamp. @@ -73,6 +81,7 @@ type ChunkGetterDeleter interface { type ChunkStore interface { Getter + GetterInto Putter Deleter Hasser @@ -84,5 +93,6 @@ type ChunkStore interface { type ReadOnlyChunkStore interface { Getter + GetterInto Hasser } diff --git a/pkg/storage/inmemchunkstore/inmemchunkstore.go b/pkg/storage/inmemchunkstore/inmemchunkstore.go index 4f0465016af..6e67a505476 100644 --- a/pkg/storage/inmemchunkstore/inmemchunkstore.go +++ b/pkg/storage/inmemchunkstore/inmemchunkstore.go @@ -6,6 +6,7 @@ package inmemchunkstore import ( "context" + "fmt" "sync" "github.com/ethersphere/bee/v2/pkg/storage" @@ -39,6 +40,21 @@ func (c *ChunkStore) Get(_ context.Context, addr swarm.Address) (swarm.Chunk, er return chunk.chunk, nil } +func (c *ChunkStore) GetInto(_ context.Context, addr swarm.Address, buf []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + + chunk, ok := c.chunks[c.key(addr)] + if !ok { + return 0, storage.ErrNotFound + } + data := chunk.chunk.Data() + if len(buf) < len(data) { + return 0, fmt.Errorf("chunk store: buffer too small: %d < %d", len(buf), len(data)) + } + return copy(buf, data), nil +} + func (c *ChunkStore) Put(_ context.Context, ch swarm.Chunk) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/pkg/storer/export_test.go b/pkg/storer/export_test.go index b30b5996edd..d9b779ba97f 100644 --- a/pkg/storer/export_test.go +++ b/pkg/storer/export_test.go @@ -5,8 +5,10 @@ package storer import ( + "github.com/ethersphere/bee/v2/pkg/bmt" "github.com/ethersphere/bee/v2/pkg/storer/internal/events" "github.com/ethersphere/bee/v2/pkg/storer/internal/reserve" + "github.com/ethersphere/bee/v2/pkg/swarm" ) func (db *DB) Reserve() *reserve.Reserve { @@ -35,3 +37,14 @@ func (db *DB) WaitForBgCacheWorkers() (unblock func()) { func DefaultOptions() *Options { return defaultOptions() } + +// TransformedAddress exposes the sampler's per-chunk hashing so it can be +// benchmarked on its own. +// +// The exported signature takes a swarm.Chunk and is deliberately held stable +// even where transformedAddress itself does not, so that the same benchmark +// source can be run against branches that shape the internal function +// differently. Only this shim changes between them. +func TransformedAddress(hasher bmt.Hasher, ch swarm.Chunk, chType swarm.ChunkType) (swarm.Address, error) { + return transformedAddress(hasher, ch.Address(), ch.Data(), chType) +} diff --git a/pkg/storer/internal/chunkstore/chunkstore.go b/pkg/storer/internal/chunkstore/chunkstore.go index 6d2745cc5fd..0b70134f38b 100644 --- a/pkg/storer/internal/chunkstore/chunkstore.go +++ b/pkg/storer/internal/chunkstore/chunkstore.go @@ -51,6 +51,28 @@ func Get(ctx context.Context, r storage.Reader, s storage.Sharky, addr swarm.Add return readChunk(ctx, s, rIdx) } +// GetInto reads chunk data into the provided buffer, avoiding per-call allocation. +// Returns the number of bytes read. +func GetInto(ctx context.Context, r storage.Reader, s storage.Sharky, addr swarm.Address, buf []byte) (int, error) { + rIdx := &RetrievalIndexItem{Address: addr} + err := r.Get(rIdx) + if err != nil { + return 0, fmt.Errorf("chunk store: failed reading retrievalIndex for address %s: %w", addr, err) + } + n := int(rIdx.Location.Length) + if len(buf) < n { + return 0, fmt.Errorf("chunk store: buffer too small: %d < %d", len(buf), n) + } + err = s.Read(ctx, rIdx.Location, buf[:n]) + if err != nil { + return 0, fmt.Errorf( + "chunk store: failed reading location: %v for chunk %s from sharky: %w", + rIdx.Location, rIdx.Address, err, + ) + } + return n, nil +} + // helper to read chunk from retrievalIndex. func readChunk(ctx context.Context, s storage.Sharky, rIdx *RetrievalIndexItem) (swarm.Chunk, error) { buf := make([]byte, rIdx.Location.Length) diff --git a/pkg/storer/internal/chunkstore/chunkstore_test.go b/pkg/storer/internal/chunkstore/chunkstore_test.go index 970e92df9b8..94721a3c53a 100644 --- a/pkg/storer/internal/chunkstore/chunkstore_test.go +++ b/pkg/storer/internal/chunkstore/chunkstore_test.go @@ -17,13 +17,12 @@ import ( "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/sharky" soctesting "github.com/ethersphere/bee/v2/pkg/soc/testing" - "github.com/ethersphere/bee/v2/pkg/storer/internal/transaction" - "github.com/ethersphere/bee/v2/pkg/storage" "github.com/ethersphere/bee/v2/pkg/storage/inmemstore" "github.com/ethersphere/bee/v2/pkg/storage/storagetest" chunktest "github.com/ethersphere/bee/v2/pkg/storage/testing" "github.com/ethersphere/bee/v2/pkg/storer/internal/chunkstore" + "github.com/ethersphere/bee/v2/pkg/storer/internal/transaction" "github.com/ethersphere/bee/v2/pkg/swarm" "github.com/spf13/afero" "github.com/stretchr/testify/assert" @@ -497,17 +496,160 @@ type chunkStore struct { sharky *sharky.Store } -func makeStorage(t *testing.T) *chunkStore { - t.Helper() +func makeStorage(tb testing.TB) *chunkStore { + tb.Helper() store := inmemstore.New() sharky, err := sharky.New(&memFS{Fs: afero.NewMemMapFs()}, 1, swarm.SocMaxChunkSize) - assert.NoError(t, err) + assert.NoError(tb, err) - t.Cleanup(func() { - assert.NoError(t, store.Close()) - assert.NoError(t, sharky.Close()) + tb.Cleanup(func() { + assert.NoError(tb, store.Close()) + assert.NoError(tb, sharky.Close()) }) return &chunkStore{transaction.NewStorage(sharky, store), sharky} } + +// BenchmarkChunkStoreGet measures a single chunk read: one retrieval-index +// lookup followed by one sharky read. It is the micro-benchmark for the read +// path that the reserve sampler drives once per chunk. +// +// The two variants differ only in where the ChunkStore handle comes from. +// "per_call" mirrors what the sampler does today, building a fresh handle for +// every chunk; "hoisted" builds it once. The gap between them is the cost of +// that handle alone. +func BenchmarkChunkStoreGet(b *testing.B) { + ctx := context.Background() + + setup := func(b *testing.B) (*chunkStore, swarm.Address) { + b.Helper() + + st := makeStorage(b) + ch := chunktest.GenerateTestRandomChunk() + + err := st.Run(ctx, func(s transaction.Store) error { + return s.ChunkStore().Put(ctx, ch) + }) + if err != nil { + b.Fatal(err) + } + + return st, ch.Address() + } + + b.Run("per_call", func(b *testing.B) { + st, addr := setup(b) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + if _, err := st.ChunkStore().Get(ctx, addr); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("hoisted", func(b *testing.B) { + st, addr := setup(b) + cs := st.ChunkStore() + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + if _, err := cs.Get(ctx, addr); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("get_into", func(b *testing.B) { + st, addr := setup(b) + cs := st.ChunkStore() + buf := make([]byte, swarm.SocMaxChunkSize) + + b.ReportAllocs() + b.ResetTimer() + + for b.Loop() { + if _, err := cs.GetInto(ctx, addr, buf); err != nil { + b.Fatal(err) + } + } + }) +} + +// TestGetInto checks the buffer contract of GetInto: the size check is on +// len(buf), one full-length buffer serves both a CAC and a larger SOC via the +// returned count, nothing is written past len(buf), and a buffer whose length +// is too short is rejected even when its capacity would fit the chunk. +func TestGetInto(t *testing.T) { + t.Parallel() + + ctx := context.Background() + st := makeStorage(t) + + cac := chunktest.GenerateTestRandomChunk() + soc := soctesting.GenerateMockSOC(t, cac.Data()[swarm.SpanSize:]).Chunk() + if len(soc.Data()) <= len(cac.Data()) { + t.Fatalf("soc data (%d) should be larger than cac data (%d)", len(soc.Data()), len(cac.Data())) + } + + for _, ch := range []swarm.Chunk{cac, soc} { + err := st.Run(ctx, func(s transaction.Store) error { + return s.ChunkStore().Put(ctx, ch) + }) + if err != nil { + t.Fatal(err) + } + } + + cs := st.ChunkStore() + + t.Run("reused buffer serves CAC and SOC", func(t *testing.T) { + buf := make([]byte, swarm.SocMaxChunkSize) + for _, ch := range []swarm.Chunk{cac, soc, cac} { + n, err := cs.GetInto(ctx, ch.Address(), buf) + if err != nil { + t.Fatal(err) + } + if n != len(ch.Data()) || !bytes.Equal(buf[:n], ch.Data()) { + t.Fatalf("chunk %s: got %d bytes, want %d with matching data", ch.Address(), n, len(ch.Data())) + } + } + }) + + t.Run("does not write past len", func(t *testing.T) { + backing := make([]byte, swarm.SocMaxChunkSize) + for i := range backing { + backing[i] = 0xff + } + buf := backing[:len(cac.Data())] + n, err := cs.GetInto(ctx, cac.Address(), buf) + if err != nil { + t.Fatal(err) + } + if n != len(cac.Data()) || !bytes.Equal(buf, cac.Data()) { + t.Fatalf("got %d bytes, want %d with matching data", n, len(cac.Data())) + } + for i := n; i < len(backing); i++ { + if backing[i] != 0xff { + t.Fatalf("GetInto wrote past len(buf) at index %d", i) + } + } + }) + + t.Run("length too small", func(t *testing.T) { + // Capacity would fit the chunk; only the length is short. + buf := make([]byte, len(soc.Data())-1, swarm.SocMaxChunkSize) + n, err := cs.GetInto(ctx, soc.Address(), buf) + if err == nil { + t.Fatal("expected error for buffer shorter than the chunk") + } + if n != 0 { + t.Fatalf("got %d bytes, want 0", n) + } + }) +} diff --git a/pkg/storer/internal/transaction/transaction.go b/pkg/storer/internal/transaction/transaction.go index 0f403580152..2aa1072e624 100644 --- a/pkg/storer/internal/transaction/transaction.go +++ b/pkg/storer/internal/transaction/transaction.go @@ -225,6 +225,13 @@ func (c *chunkStoreTrx) Get(ctx context.Context, addr swarm.Address) (ch swarm.C return ch, err } +func (c *chunkStoreTrx) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (n int, err error) { + defer handleMetric("chunkstore_get", c.metrics)(&err) + unlock := c.lock(addr) + defer unlock() + return chunkstore.GetInto(ctx, c.indexStore, c.sharkyTrx, addr, buf) +} + func (c *chunkStoreTrx) Has(ctx context.Context, addr swarm.Address) (_ bool, err error) { defer handleMetric("chunkstore_has", c.metrics)(&err) unlock := c.lock(addr) diff --git a/pkg/storer/mock/forgetting.go b/pkg/storer/mock/forgetting.go index 22965ce9f38..dbe75912bcd 100644 --- a/pkg/storer/mock/forgetting.go +++ b/pkg/storer/mock/forgetting.go @@ -33,7 +33,8 @@ func (d *DelayedStore) Delay(addr swarm.Address, delay time.Duration) { d.cache[addr.String()] = delay } -func (d *DelayedStore) Get(ctx context.Context, addr swarm.Address) (ch swarm.Chunk, err error) { +// wait consumes any delay registered for addr, blocking for its duration. +func (d *DelayedStore) wait(ctx context.Context, addr swarm.Address) error { d.mu.Lock() delay, ok := d.cache[addr.String()] if ok && delay > 0 { @@ -42,14 +43,30 @@ func (d *DelayedStore) Get(ctx context.Context, addr swarm.Address) (ch swarm.Ch select { case <-time.After(delay): case <-ctx.Done(): - return nil, ctx.Err() + return ctx.Err() } } else { d.mu.Unlock() } + return nil +} + +func (d *DelayedStore) Get(ctx context.Context, addr swarm.Address) (ch swarm.Chunk, err error) { + if err := d.wait(ctx, addr); err != nil { + return nil, err + } return d.ChunkStore.Get(ctx, addr) } +// GetInto implements the ChunkStore interface. Without this override the +// promoted method would bypass Delay. +func (d *DelayedStore) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (int, error) { + if err := d.wait(ctx, addr); err != nil { + return 0, err + } + return d.ChunkStore.GetInto(ctx, addr, buf) +} + type ForgettingStore struct { storage.ChunkStore record atomic.Bool @@ -129,6 +146,19 @@ func (f *ForgettingStore) Get(ctx context.Context, addr swarm.Address) (ch swarm return f.ChunkStore.Get(ctx, addr) } +// GetInto implements the ChunkStore interface. It applies the same recording +// and forgetting rules as Get. +func (f *ForgettingStore) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.record.Load() { + f.miss(addr) + } else if f.isMiss(addr) { + return 0, storage.ErrNotFound + } + return f.ChunkStore.GetInto(ctx, addr, buf) +} + // Put implements the ChunkStore interface. func (f *ForgettingStore) Put(ctx context.Context, ch swarm.Chunk) (err error) { f.n.Add(1) diff --git a/pkg/storer/mock/mockstorer_test.go b/pkg/storer/mock/mockstorer_test.go index 484dac11262..30c6cfd2e91 100644 --- a/pkg/storer/mock/mockstorer_test.go +++ b/pkg/storer/mock/mockstorer_test.go @@ -5,6 +5,7 @@ package mockstorer_test import ( + "bytes" "context" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "time" storage "github.com/ethersphere/bee/v2/pkg/storage" + "github.com/ethersphere/bee/v2/pkg/storage/inmemchunkstore" chunktesting "github.com/ethersphere/bee/v2/pkg/storage/testing" storer "github.com/ethersphere/bee/v2/pkg/storer" mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" @@ -267,3 +269,55 @@ func TestMockStorer(t *testing.T) { } }) } + +// TestForgettingStoreGetInto asserts that GetInto honours Miss the same way Get does. +func TestForgettingStoreGetInto(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ch := chunktesting.GenerateTestRandomChunk() + + store := mockstorer.NewForgettingStore(inmemchunkstore.New()) + if err := store.Put(ctx, ch); err != nil { + t.Fatal(err) + } + + buf := make([]byte, swarm.ChunkWithSpanSize) + + n, err := store.GetInto(ctx, ch.Address(), buf) + if err != nil { + t.Fatalf("GetInto before Miss: %v", err) + } + if !bytes.Equal(buf[:n], ch.Data()) { + t.Fatal("GetInto returned unexpected chunk data") + } + + store.Miss(ch.Address()) + + if _, err := store.GetInto(ctx, ch.Address(), buf); !errors.Is(err, storage.ErrNotFound) { + t.Fatalf("GetInto after Miss: got %v, want %v", err, storage.ErrNotFound) + } +} + +// TestDelayedStoreGetInto asserts that GetInto observes a registered delay. +func TestDelayedStoreGetInto(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + ch := chunktesting.GenerateTestRandomChunk() + + store := mockstorer.NewDelayedStore(inmemchunkstore.New()) + if err := store.Put(context.Background(), ch); err != nil { + t.Fatal(err) + } + + // A delay no read would outlast, so the cancelled context must win. + store.Delay(ch.Address(), time.Hour) + + buf := make([]byte, swarm.ChunkWithSpanSize) + if _, err := store.GetInto(ctx, ch.Address(), buf); !errors.Is(err, context.Canceled) { + t.Fatalf("GetInto with pending delay: got %v, want %v", err, context.Canceled) + } +} diff --git a/pkg/storer/sample.go b/pkg/storer/sample.go index f4b6f8139e3..58c9a6ef951 100644 --- a/pkg/storer/sample.go +++ b/pkg/storer/sample.go @@ -8,6 +8,7 @@ import ( "bytes" "context" "encoding/binary" + "errors" "fmt" "math/big" "runtime" @@ -37,6 +38,12 @@ type SampleItem struct { Stamp *postage.Stamp } +type sampleItemInternal struct { + transformedAddress swarm.Address + chunkAddress swarm.Address + batchID []byte +} + type Sample struct { Stats SampleStats Items []SampleItem @@ -65,7 +72,7 @@ func (db *DB) ReserveSample( consensusTime uint64, minBatchBalance *big.Int, ) (Sample, error) { - g, ctx := errgroup.WithContext(ctx) + g, gCtx := errgroup.WithContext(ctx) allStats := &SampleStats{} statsLock := sync.Mutex{} @@ -111,15 +118,15 @@ func (db *DB) ReserveSample( case chunkC <- ch: stats.TotalIterated++ return false, nil - case <-ctx.Done(): - return false, ctx.Err() + case <-gCtx.Done(): + return false, gCtx.Err() } }) return err })) // Phase 2: Get the chunk data and calculate transformed hash - sampleItemChan := make(chan SampleItem, 3*workers) + sampleItemChan := make(chan sampleItemInternal, 3*workers) db.logger.Debug("reserve sampler workers", "count", workers) @@ -127,6 +134,12 @@ func (db *DB) ReserveSample( g.Go(safe.RunFunc(db.logger, "storer-sample-worker", func() error { wstat := SampleStats{} hasher := bmt.NewPrefixHasher(anchor) + // One handle per worker rather than one per chunk: building it + // allocates, and the sampler asks for a chunk millions of times per + // round. It is not shared between workers because the read-only + // chunk store makes no thread-safety promise. + chunkStore := db.ChunkStore() + buf := make([]byte, swarm.SocMaxChunkSize) defer func() { addStats(wstat) }() @@ -147,7 +160,7 @@ func (db *DB) ReserveSample( chunkLoadStart := time.Now() - chunk, err := db.ChunkStore().Get(ctx, chItem.Address) + n, err := chunkStore.GetInto(gCtx, chItem.Address, buf) chunkLoadDuration := time.Since(chunkLoadStart) if err != nil { @@ -159,21 +172,20 @@ func (db *DB) ReserveSample( wstat.ChunkLoadDuration += chunkLoadDuration taddrStart := time.Now() - taddr, err := transformedAddress(hasher, chunk, chItem.ChunkType) + taddr, err := transformedAddress(hasher, chItem.Address, buf[:n], chItem.ChunkType) if err != nil { return err } wstat.TaddrDuration += time.Since(taddrStart) select { - case sampleItemChan <- SampleItem{ - TransformedAddress: taddr, - ChunkAddress: chunk.Address(), - ChunkData: chunk.Data(), - Stamp: postage.NewStamp(chItem.BatchID, nil, nil, nil), + case sampleItemChan <- sampleItemInternal{ + transformedAddress: taddr, + chunkAddress: chItem.Address, + batchID: chItem.BatchID, }: - case <-ctx.Done(): - return ctx.Err() + case <-gCtx.Done(): + return gCtx.Err() } } @@ -219,6 +231,9 @@ func (db *DB) ReserveSample( // Phase 3: Assemble the sample. Here we need to assemble only the first SampleSize // no of items from the results of the 2nd phase. // In this step stamps are loaded and validated only if chunk will be added to sample. + // Runs on ctx rather than gCtx: the errgroup cancels gCtx when Wait returns, + // which can happen while sampleItemChan still has buffered items to drain. + phase3ChunkStore := db.ChunkStore() stats := SampleStats{} for item := range sampleItemChan { currentMaxAddr := swarm.EmptyAddress @@ -226,15 +241,22 @@ func (db *DB) ReserveSample( currentMaxAddr = sampleItems[len(sampleItems)-1].TransformedAddress } - if le(item.TransformedAddress, currentMaxAddr) || len(sampleItems) < SampleSize { - stamp, err := chunkstamp.LoadWithBatchID(db.storage.IndexStore(), "reserve", item.ChunkAddress, item.Stamp.BatchID()) + if le(item.transformedAddress, currentMaxAddr) || len(sampleItems) < SampleSize { + stamp, err := chunkstamp.LoadWithBatchID(db.storage.IndexStore(), "reserve", item.chunkAddress, item.batchID) if err != nil { stats.StampLoadFailed++ - db.logger.Debug("failed loading stamp", "chunk_address", item.ChunkAddress, "error", err) + db.logger.Debug("failed loading stamp", "chunk_address", item.chunkAddress, "error", err) + continue + } + + ch, err := phase3ChunkStore.Get(ctx, item.chunkAddress) + if err != nil { + stats.ChunkLoadFailed++ + db.logger.Debug("failed loading chunk", "chunk_address", item.chunkAddress, "error", err) continue } - ch := swarm.NewChunk(item.ChunkAddress, item.ChunkData).WithStamp(stamp) + ch = ch.WithStamp(stamp) // check if the timestamp on the postage stamp is not later than the consensus time. if binary.BigEndian.Uint64(ch.Stamp().Timestamp()) > consensusTime { @@ -252,9 +274,12 @@ func (db *DB) ReserveSample( stampValidDuration := time.Since(stampValidStart) stats.ValidStampDuration += stampValidDuration - item.Stamp = postage.NewStamp(stamp.BatchID(), stamp.Index(), stamp.Timestamp(), stamp.Sig()) - - insert(item) + insert(SampleItem{ + TransformedAddress: item.transformedAddress, + ChunkAddress: item.chunkAddress, + ChunkData: ch.Data(), + Stamp: postage.NewStamp(stamp.BatchID(), stamp.Index(), stamp.Timestamp(), stamp.Sig()), + }) stats.SampleInserts++ } } @@ -295,22 +320,25 @@ func (db *DB) batchesBelowValue(until *big.Int) (map[string]struct{}, error) { return res, err } -func transformedAddress(hasher bmt.Hasher, chunk swarm.Chunk, chType swarm.ChunkType) (swarm.Address, error) { +func transformedAddress(hasher bmt.Hasher, addr swarm.Address, data []byte, chType swarm.ChunkType) (swarm.Address, error) { switch chType { case swarm.ChunkTypeContentAddressed: - return transformedAddressCAC(hasher, chunk) + return transformedAddressCAC(hasher, data) case swarm.ChunkTypeSingleOwner: - return transformedAddressSOC(hasher, chunk) + return transformedAddressSOC(hasher, addr, data) default: return swarm.ZeroAddress, fmt.Errorf("chunk type [%v] is not valid", chType) } } -func transformedAddressCAC(hasher bmt.Hasher, chunk swarm.Chunk) (swarm.Address, error) { +func transformedAddressCAC(hasher bmt.Hasher, data []byte) (swarm.Address, error) { + if len(data) < bmt.SpanSize { + return swarm.ZeroAddress, errors.New("chunk data too short for span") + } hasher.Reset() - hasher.SetHeader(chunk.Data()[:bmt.SpanSize]) + hasher.SetHeader(data[:bmt.SpanSize]) - _, err := hasher.Write(chunk.Data()[bmt.SpanSize:]) + _, err := hasher.Write(data[bmt.SpanSize:]) if err != nil { return swarm.ZeroAddress, err } @@ -318,20 +346,20 @@ func transformedAddressCAC(hasher bmt.Hasher, chunk swarm.Chunk) (swarm.Address, return swarm.NewAddress(hasher.Sum(nil)), nil } -func transformedAddressSOC(hasher bmt.Hasher, socChunk swarm.Chunk) (swarm.Address, error) { - // Calculate transformed address from wrapped chunk - cacChunk, err := soc.UnwrapCAC(socChunk) - if err != nil { - return swarm.ZeroAddress, err +func transformedAddressSOC(hasher bmt.Hasher, socAddr swarm.Address, data []byte) (swarm.Address, error) { + if len(data) < swarm.SocMinChunkSize { + return swarm.ZeroAddress, errors.New("chunk data too short for soc") } - taddrCac, err := transformedAddressCAC(hasher, cacChunk) + cursor := swarm.HashSize + swarm.SocSignatureSize + cacData := data[cursor:] + taddrCac, err := transformedAddressCAC(hasher, cacData) if err != nil { return swarm.ZeroAddress, err } // Hash address and transformed address to make transformed address for this SOC sHasher := swarm.NewHasher() - if _, err := sHasher.Write(socChunk.Address().Bytes()); err != nil { + if _, err := sHasher.Write(socAddr.Bytes()); err != nil { return swarm.ZeroAddress, err } if _, err := sHasher.Write(taddrCac.Bytes()); err != nil { @@ -400,7 +428,7 @@ func RandSample(t *testing.T, anchor []byte) Sample { func MakeSampleUsingChunks(chunks []swarm.Chunk, anchor []byte) (Sample, error) { items := make([]SampleItem, len(chunks)) for i, ch := range chunks { - tr, err := transformedAddress(bmt.NewPrefixHasher(anchor), ch, getChunkType(ch)) + tr, err := transformedAddress(bmt.NewPrefixHasher(anchor), ch.Address(), ch.Data(), getChunkType(ch)) if err != nil { return Sample{}, err } diff --git a/pkg/storer/sample_test.go b/pkg/storer/sample_test.go index 16b65d81a09..34aa4a2ebb2 100644 --- a/pkg/storer/sample_test.go +++ b/pkg/storer/sample_test.go @@ -13,9 +13,10 @@ import ( "github.com/ethersphere/bee/v2/pkg/bmt" "github.com/ethersphere/bee/v2/pkg/cac" + "github.com/ethersphere/bee/v2/pkg/crypto" "github.com/ethersphere/bee/v2/pkg/postage" - postagetesting "github.com/ethersphere/bee/v2/pkg/postage/testing" + "github.com/ethersphere/bee/v2/pkg/soc" chunk "github.com/ethersphere/bee/v2/pkg/storage/testing" "github.com/ethersphere/bee/v2/pkg/storer" "github.com/ethersphere/bee/v2/pkg/swarm" @@ -302,6 +303,9 @@ func assertValidSample(t *testing.T, sample storer.Sample, minRadius uint8, anch assertSampleItem(item, i) } + assertSampleDataIntact(t, sample) + assertSampleDataNotShared(t, sample) + // Assert that transformed addresses are in ascending order for i := 0; i < len(sample.Items)-1; i++ { if sample.Items[i].TransformedAddress.Compare(sample.Items[i+1].TransformedAddress) != -1 { @@ -310,16 +314,49 @@ func assertValidSample(t *testing.T, sample storer.Sample, minRadius uint8, anch } } +// assertSampleDataIntact checks that every item's ChunkData still reproduces its +// ChunkAddress. +// +// The sampler currently hands out the buffer that the chunk store allocated for +// each chunk, so the data is trivially intact. That stops being true the moment +// chunks are read into a buffer that the worker reuses: unless the bytes handed +// to a SampleItem are copied out, every item except the last one a worker +// touched carries the contents of some later chunk instead. +func assertSampleDataIntact(t *testing.T, sample storer.Sample) { + t.Helper() + + for i, item := range sample.Items { + ch := swarm.NewChunk(item.ChunkAddress, item.ChunkData) + if !cac.Valid(ch) && !soc.Valid(ch) { + t.Fatalf("sample item [%d]: chunk data does not reproduce address %s", i, item.ChunkAddress) + } + } +} + +// assertSampleDataNotShared checks that no two items are backed by the same +// array. It catches a reused read buffer even in the case where the surviving +// contents happen to stay valid for one of the aliased items. +func assertSampleDataNotShared(t *testing.T, sample storer.Sample) { + t.Helper() + + seen := make(map[*byte]int, len(sample.Items)) + for i, item := range sample.Items { + if len(item.ChunkData) == 0 { + continue + } + first := &item.ChunkData[0] + if j, ok := seen[first]; ok { + t.Fatalf("sample items [%d] and [%d] share one backing array", j, i) + } + seen[first] = i + } +} + // TestSampleVectorCAC is a deterministic test vector that verifies the chunk // address and transformed address produced by MakeSampleUsingChunks for a // single hardcoded CAC chunk and anchor. It guards against regressions in the // BMT hashing or sampling pipeline, and asserts that both the goroutine and // SIMD hasher paths produce identical hashes. -// -// Sub-tests are intentionally not run in parallel: SetSIMDOptIn mutates global -// state and concurrent calls would see flapping values. On platforms where the -// dispatcher falls back to the goroutine pool (non-linux/amd64 or CPU without -// AVX2/AVX-512), the SIMD sub-test degrades to a goroutine run. func TestSampleVectorCAC(t *testing.T) { // Chunk content: 4096 bytes with repeating pattern i%256. chunkContent := make([]byte, swarm.ChunkSize) @@ -332,25 +369,71 @@ func TestSampleVectorCAC(t *testing.T) { t.Fatal(err) } - // Attach a hardcoded (but otherwise irrelevant) stamp so that - // MakeSampleUsingChunks can read ch.Stamp() without panicking. - batchID := make([]byte, 32) - for i := range batchID { - batchID[i] = byte(i + 1) + // The stamp is irrelevant to the vector; MakeSampleUsingChunks only needs + // ch.Stamp() to not panic. + ch = ch.WithStamp(postagetesting.MustNewStamp()) + + assertSampleVector(t, ch, + "902406053a7a2f3a17f16097e1d0b4b6a4abeae6b84968f5503ae621f9522e16", + "9dee91d1ed794460474ffc942996bd713176731db4581a3c6470fe9862905a60", + ) +} + +// TestSampleVectorSOC is the SOC counterpart of TestSampleVectorCAC. The +// sampler reads the wrapped CAC straight out of the raw chunk data instead of +// rebuilding it through soc.UnwrapCAC, which drops the length validation and +// the redundant BMT hash that path performed; the vector was cross-checked +// against it. Neither asserted address depends on the signature, so the vector +// survives the ECDSA nonce. +func TestSampleVectorSOC(t *testing.T) { + // Wrapped CAC content: the same payload TestSampleVectorCAC uses. + chunkContent := make([]byte, swarm.ChunkSize) + for i := range chunkContent { + chunkContent[i] = byte(i % 256) } - sig := make([]byte, 65) - for i := range sig { - sig[i] = byte(i + 1) + + wrappedCh, err := cac.New(chunkContent) + if err != nil { + t.Fatal(err) } - ch = ch.WithStamp(postage.NewStamp(batchID, make([]byte, 8), make([]byte, 8), sig)) - // Anchor: exactly 32 bytes, constant across runs. - anchor := []byte("swarm-test-anchor-deterministic!") + id := make([]byte, swarm.HashSize) + for i := range id { + id[i] = byte(i + 1) + } - const ( - wantChunkAddr = "902406053a7a2f3a17f16097e1d0b4b6a4abeae6b84968f5503ae621f9522e16" - wantTransformedAddr = "9dee91d1ed794460474ffc942996bd713176731db4581a3c6470fe9862905a60" + // Fixed key, so that the owner address and with it the SOC address is + // constant across runs. Not the replicas signer, which soc.Valid + // special-cases into the dispersed replica rule. + privKeyData := make([]byte, 32) + for i := range privKeyData { + privKeyData[i] = byte(i + 1) + } + + ch, err := soc.New(id, wrappedCh).Sign(crypto.NewDefaultSigner(crypto.Secp256k1PrivateKeyFromBytes(privKeyData))) + if err != nil { + t.Fatal(err) + } + ch = ch.WithStamp(postagetesting.MustNewStamp()) + + assertSampleVector(t, ch, + "6f8d756905e023c9a6cb9f11dd22feec5c6fcfff33d9115bae3be210be162ebb", + "014d0e6a4caeaebe37bed9d7fb76d1049122e2e9e5659d4528537f6162d670bc", ) +} + +// assertSampleVector checks the chunk and transformed addresses MakeSampleUsingChunks +// produces for ch against a pinned vector, on both the goroutine and SIMD hasher paths. +// +// Sub-tests are intentionally not run in parallel: SetSIMDOptIn mutates global +// state and concurrent calls would see flapping values. On platforms where the +// dispatcher falls back to the goroutine pool (non-linux/amd64 or CPU without +// AVX2/AVX-512), the SIMD sub-test degrades to a goroutine run. +func assertSampleVector(t *testing.T, ch swarm.Chunk, wantChunkAddr, wantTransformedAddr string) { + t.Helper() + + // Anchor: exactly 32 bytes, constant across runs. + anchor := []byte("swarm-test-anchor-deterministic!") prev := bmt.SIMDOptIn() t.Cleanup(func() { bmt.SetSIMDOptIn(prev) }) @@ -420,11 +503,27 @@ func assertSampleNoErrors(t *testing.T, sample storer.Sample) { // method, including DB iteration, chunk loading, stamp validation, and sample // assembly. func BenchmarkReserveSample1k(b *testing.B) { - const chunkCountPerPO = 100 + benchmarkReserveSample(b, 100) +} + +// BenchmarkReserveSample10k is BenchmarkReserveSample1k over a reserve ten +// times the size. The per-chunk costs of the sampler are linear in the number +// of chunks iterated, so a change that only removes a fixed overhead reads the +// same at both sizes while a change to a per-chunk allocation does not. The +// larger reserve is also where garbage collection starts to show. +func BenchmarkReserveSample10k(b *testing.B) { + benchmarkReserveSample(b, 1000) +} + +// benchmarkReserveSample fills a reserve with chunkCountPerPO chunks in each of +// the first maxPO proximity orders and then samples it repeatedly. +func benchmarkReserveSample(b *testing.B, chunkCountPerPO int) { + b.Helper() + const maxPO = 10 baseAddr := swarm.RandAddress(b) - opts := dbTestOps(baseAddr, 5000, nil, nil, time.Second) + opts := dbTestOps(baseAddr, 5*chunkCountPerPO*maxPO, nil, nil, time.Second) opts.ValidStamp = func(ch swarm.Chunk) (swarm.Chunk, error) { return ch, nil } st, err := diskStorer(b, opts)() @@ -450,6 +549,8 @@ func BenchmarkReserveSample1k(b *testing.B) { anchor = swarm.RandAddressAt(b, baseAddr, int(radius)).Bytes() ) + b.ResetTimer() + for b.Loop() { _, err := st.ReserveSample(context.TODO(), anchor, radius, timeVar, nil) if err != nil { @@ -458,6 +559,53 @@ func BenchmarkReserveSample1k(b *testing.B) { } } +// BenchmarkTransformedAddress measures the sampler's per-chunk hashing on its +// own, separately for a content-addressed and a single owner chunk. +// +// The SOC case is the reason this exists. Both ReserveSample benchmarks build +// their reserve with chunk.GenerateValidRandomChunkAt, which produces CAC +// chunks only, so the SOC branch of transformedAddress is never measured by +// them. Any work the SOC path does over and above hashing the wrapped CAC shows +// up here and nowhere else. +func BenchmarkTransformedAddress(b *testing.B) { + anchor := []byte("swarm-test-anchor-deterministic!") + + content := make([]byte, swarm.ChunkSize) + for i := range content { + content[i] = byte(i) + } + + cacChunk, err := cac.New(content) + if err != nil { + b.Fatal(err) + } + socChunk := chunk.GenerateTestRandomSoChunk(b, cacChunk) + + for _, tc := range []struct { + name string + ch swarm.Chunk + typ swarm.ChunkType + }{ + {"cac", cacChunk, swarm.ChunkTypeContentAddressed}, + {"soc", socChunk, swarm.ChunkTypeSingleOwner}, + } { + b.Run(tc.name, func(b *testing.B) { + // One hasher reused across iterations, as a sampler worker does. + hasher := bmt.NewPrefixHasher(anchor) + + b.ReportAllocs() + b.SetBytes(int64(len(tc.ch.Data()))) + b.ResetTimer() + + for b.Loop() { + if _, err := storer.TransformedAddress(hasher, tc.ch, tc.typ); err != nil { + b.Fatal(err) + } + } + }) + } +} + // BenchmarkSampleHashing measures the time taken by MakeSampleUsingChunks to // hash a fixed set of CAC chunks. func BenchmarkSampleHashing(b *testing.B) {