diff --git a/x/blockdb/README.md b/x/blockdb/README.md index e6793c7eb8aa..c64053dca9fb 100644 --- a/x/blockdb/README.md +++ b/x/blockdb/README.md @@ -148,16 +148,16 @@ defer db.Close() // Write a block height := uint64(100) blockData := []byte("block data") -err := db.WriteBlock(height, blockData) +err := db.Put(height, blockData) if err != nil { fmt.Println("Error writing block:", err) return } // Read a block -blockData, err := db.ReadBlock(height) +blockData, err := db.Get(height) if err != nil { - if errors.Is(err, blockdb.ErrBlockNotFound) { + if errors.Is(err, database.ErrNotFound) { fmt.Println("Block doesn't exist at this height") return } diff --git a/x/blockdb/database.go b/x/blockdb/database.go index db29cd778034..7fb7af9b6cf3 100644 --- a/x/blockdb/database.go +++ b/x/blockdb/database.go @@ -20,6 +20,7 @@ import ( "go.uber.org/zap" "github.com/ava-labs/avalanchego/cache/lru" + "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/utils/compression" "github.com/ava-labs/avalanchego/utils/logging" @@ -49,6 +50,8 @@ type BlockHeight = uint64 type BlockData = []byte var ( + _ database.HeightIndex = (*Database)(nil) + _ encoding.BinaryMarshaler = (*blockEntryHeader)(nil) _ encoding.BinaryUnmarshaler = (*blockEntryHeader)(nil) _ encoding.BinaryMarshaler = (*indexEntry)(nil) @@ -300,7 +303,7 @@ func (s *Database) Close() error { defer s.closeMu.Unlock() if s.closed { - return nil + return database.ErrClosed } s.closed = true @@ -315,8 +318,8 @@ func (s *Database) Close() error { return err } -// WriteBlock inserts a block into the store at the given height. -func (s *Database) WriteBlock(height BlockHeight, block BlockData) error { +// Put inserts a block into the store at the given height. +func (s *Database) Put(height BlockHeight, block BlockData) error { s.closeMu.RLock() defer s.closeMu.RUnlock() @@ -324,7 +327,7 @@ func (s *Database) WriteBlock(height BlockHeight, block BlockData) error { s.log.Error("Failed to write block: database is closed", zap.Uint64("height", height), ) - return ErrDatabaseClosed + return database.ErrClosed } blockSize := len(block) @@ -336,12 +339,6 @@ func (s *Database) WriteBlock(height BlockHeight, block BlockData) error { return fmt.Errorf("%w: block size cannot exceed %d bytes", ErrBlockTooLarge, math.MaxUint32) } - blockDataLen := uint32(blockSize) - if blockDataLen == 0 { - s.log.Error("Failed to write block: empty block", zap.Uint64("height", height)) - return ErrBlockEmpty - } - indexFileOffset, err := s.indexEntryOffset(height) if err != nil { s.log.Error("Failed to write block: failed to calculate index entry offset", @@ -359,7 +356,7 @@ func (s *Database) WriteBlock(height BlockHeight, block BlockData) error { ) return fmt.Errorf("failed to compress block data: %w", err) } - blockDataLen = uint32(len(blockToWrite)) + blockDataLen := uint32(len(blockToWrite)) sizeWithDataHeader, err := safemath.Add(sizeOfBlockEntryHeader, blockDataLen) if err != nil { @@ -423,14 +420,14 @@ func (s *Database) WriteBlock(height BlockHeight, block BlockData) error { } // readBlockIndex reads the index entry for the given height. -// It returns ErrBlockNotFound if the block does not exist. +// It returns database.ErrNotFound if the block does not exist. func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) { var entry indexEntry if s.closed { s.log.Error("Failed to read block index: database is closed", zap.Uint64("height", height), ) - return entry, ErrDatabaseClosed + return entry, database.ErrClosed } // Skip the index entry read if we know the block is past the max height. @@ -440,7 +437,7 @@ func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) { zap.Uint64("height", height), zap.String("reason", "no blocks written yet"), ) - return entry, fmt.Errorf("%w: no blocks written yet", ErrBlockNotFound) + return entry, fmt.Errorf("%w: no blocks written yet", database.ErrNotFound) } if height > heights.maxBlockHeight { s.log.Debug("Block not found", @@ -448,12 +445,12 @@ func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) { zap.Uint64("maxHeight", heights.maxBlockHeight), zap.String("reason", "height beyond max"), ) - return entry, fmt.Errorf("%w: height %d is beyond max height %d", ErrBlockNotFound, height, heights.maxBlockHeight) + return entry, fmt.Errorf("%w: height %d is beyond max height %d", database.ErrNotFound, height, heights.maxBlockHeight) } entry, err := s.readIndexEntry(height) if err != nil { - if errors.Is(err, ErrBlockNotFound) { + if errors.Is(err, database.ErrNotFound) { s.log.Debug("Block not found", zap.Uint64("height", height), zap.String("reason", "no index entry found"), @@ -471,9 +468,9 @@ func (s *Database) readBlockIndex(height BlockHeight) (indexEntry, error) { return entry, nil } -// ReadBlock retrieves a block by its height. -// Returns ErrBlockNotFound if the block is not found. -func (s *Database) ReadBlock(height BlockHeight) (BlockData, error) { +// Get retrieves a block by its height. +// Returns database.ErrNotFound if the block is not found. +func (s *Database) Get(height BlockHeight) (BlockData, error) { s.closeMu.RLock() defer s.closeMu.RUnlock() @@ -530,14 +527,14 @@ func (s *Database) ReadBlock(height BlockHeight) (BlockData, error) { return decompressed, nil } -// HasBlock checks if a block exists at the given height. -func (s *Database) HasBlock(height BlockHeight) (bool, error) { +// Has checks if a block exists at the given height. +func (s *Database) Has(height BlockHeight) (bool, error) { s.closeMu.RLock() defer s.closeMu.RUnlock() _, err := s.readBlockIndex(height) if err != nil { - if errors.Is(err, ErrBlockNotFound) || errors.Is(err, ErrInvalidBlockHeight) { + if errors.Is(err, database.ErrNotFound) || errors.Is(err, ErrInvalidBlockHeight) { return false, nil } s.log.Error("Failed to check if block exists: failed to read index entry", @@ -568,7 +565,7 @@ func (s *Database) indexEntryOffset(height BlockHeight) (uint64, error) { } // readIndexEntry reads the index entry for the given height from the index file. -// Returns ErrBlockNotFound if the block does not exist. +// Returns database.ErrNotFound if the block does not exist. func (s *Database) readIndexEntry(height BlockHeight) (indexEntry, error) { var entry indexEntry @@ -580,10 +577,10 @@ func (s *Database) readIndexEntry(height BlockHeight) (indexEntry, error) { buf := make([]byte, sizeOfIndexEntry) _, err = s.indexFile.ReadAt(buf, int64(offset)) if err != nil { - // Return ErrBlockNotFound if trying to read past the end of the index file + // Return database.ErrNotFound if trying to read past the end of the index file // for a block that has not been indexed yet. if errors.Is(err, io.EOF) { - return entry, fmt.Errorf("%w: EOF reading index entry at offset %d for height %d", ErrBlockNotFound, offset, height) + return entry, fmt.Errorf("%w: EOF reading index entry at offset %d for height %d", database.ErrNotFound, offset, height) } return entry, fmt.Errorf("failed to read index entry at offset %d for height %d: %w", offset, height, err) } @@ -592,7 +589,7 @@ func (s *Database) readIndexEntry(height BlockHeight) (indexEntry, error) { } if entry.IsEmpty() { - return entry, fmt.Errorf("%w: empty index entry for height %d", ErrBlockNotFound, height) + return entry, fmt.Errorf("%w: empty index entry for height %d", database.ErrNotFound, height) } return entry, nil @@ -1122,7 +1119,7 @@ func (s *Database) updateBlockHeights(writtenBlockHeight BlockHeight) error { _, err = s.readIndexEntry(nextHeightToVerify) if err != nil { // If no block exists at this height, we've reached the end of our contiguous sequence - if errors.Is(err, ErrBlockNotFound) { + if errors.Is(err, database.ErrNotFound) { break } @@ -1181,7 +1178,7 @@ func (s *Database) updateRecoveredBlockHeights(recoveredHeights []BlockHeight) e _, err := s.readIndexEntry(nextHeightToVerify) if err != nil { // If no block exists at this height, we've reached the end of our contiguous sequence - if errors.Is(err, ErrBlockNotFound) { + if errors.Is(err, database.ErrNotFound) { break } diff --git a/x/blockdb/database_test.go b/x/blockdb/database_test.go index 5d836c240ee4..450f56991437 100644 --- a/x/blockdb/database_test.go +++ b/x/blockdb/database_test.go @@ -18,10 +18,25 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/avalanchego/cache/lru" + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/database/heightindexdb/dbtest" "github.com/ava-labs/avalanchego/utils/compression" "github.com/ava-labs/avalanchego/utils/logging" ) +func TestInterface(t *testing.T) { + for _, test := range dbtest.Tests { + t.Run(test.Name, func(t *testing.T) { + test.Test(t, func() database.HeightIndex { + tempDir := t.TempDir() + db, err := New(DefaultConfig().WithDir(tempDir), logging.NoLog{}) + require.NoError(t, err) + return db + }) + }) + } +} + func TestNew_Params(t *testing.T) { tempDir := t.TempDir() tests := []struct { @@ -193,8 +208,8 @@ func TestNew_IndexFileConfigPrecedence(t *testing.T) { // Write a block at height 100 and close db testBlock := []byte("test block data") - require.NoError(t, db.WriteBlock(100, testBlock)) - readBlock, err := db.ReadBlock(100) + require.NoError(t, db.Put(100, testBlock)) + readBlock, err := db.Get(100) require.NoError(t, err) require.Equal(t, testBlock, readBlock) require.NoError(t, db.Close()) @@ -208,20 +223,20 @@ func TestNew_IndexFileConfigPrecedence(t *testing.T) { // The database should still accept blocks between 100 and 200 testBlock2 := []byte("test block data 2") - require.NoError(t, db2.WriteBlock(150, testBlock2)) - readBlock2, err := db2.ReadBlock(150) + require.NoError(t, db2.Put(150, testBlock2)) + readBlock2, err := db2.Get(150) require.NoError(t, err) require.Equal(t, testBlock2, readBlock2) // Verify that writing below initial minimum height fails - err = db2.WriteBlock(50, []byte("invalid block")) + err = db2.Put(50, []byte("invalid block")) require.ErrorIs(t, err, ErrInvalidBlockHeight) // Write a large block that would exceed the new config's 512KB limit // but should succeed because we use the original 1MB limit from index file largeBlock := make([]byte, 768*1024) // 768KB block - require.NoError(t, db2.WriteBlock(200, largeBlock)) - readLargeBlock, err := db2.ReadBlock(200) + require.NoError(t, db2.Put(200, largeBlock)) + readLargeBlock, err := db2.Get(200) require.NoError(t, err) require.Equal(t, largeBlock, readLargeBlock) } @@ -288,7 +303,7 @@ func TestFileCache_Eviction(t *testing.T) { defer wg.Done() for i := range numBlocks { height := uint64((i + goroutineID) % numBlocks) - err := store.WriteBlock(height, blocks[height]) + err := store.Put(height, blocks[height]) if err != nil { writeErrors.Add(1) errorMu.Lock() @@ -317,7 +332,7 @@ func TestFileCache_Eviction(t *testing.T) { // Verify again that all blocks are readable for i := range numBlocks { - block, err := store.ReadBlock(uint64(i)) + block, err := store.Get(uint64(i)) require.NoError(t, err, "failed to read block at height %d", i) require.Equal(t, blocks[i], block, "block data mismatch at height %d", i) } @@ -341,12 +356,12 @@ func TestMaxDataFiles_CacheLimit(t *testing.T) { // Write blocks to force multiple data files for i := range numBlocks { block := fixedSizeBlock(t, 512, uint64(i)) - require.NoError(t, store.WriteBlock(uint64(i), block)) + require.NoError(t, store.Put(uint64(i), block)) } // Verify all blocks are still readable despite evictions for i := range numBlocks { - block, err := store.ReadBlock(uint64(i)) + block, err := store.Get(uint64(i)) require.NoError(t, err, "failed to read block at height %d after eviction", i) require.Len(t, block, 512, "block size mismatch at height %d", i) } diff --git a/x/blockdb/datasplit_test.go b/x/blockdb/datasplit_test.go index 6dc45b2d07e8..d978fb714299 100644 --- a/x/blockdb/datasplit_test.go +++ b/x/blockdb/datasplit_test.go @@ -28,7 +28,7 @@ func TestDataSplitting(t *testing.T) { blocks := make([][]byte, numBlocks) for i := range numBlocks { blocks[i] = fixedSizeBlock(t, 1024, uint64(i)) - require.NoError(t, store.WriteBlock(uint64(i), blocks[i])) + require.NoError(t, store.Put(uint64(i), blocks[i])) } // Verify that multiple data files were created. @@ -47,7 +47,7 @@ func TestDataSplitting(t *testing.T) { // Verify all blocks are readable for i := range numBlocks { - readBlock, err := store.ReadBlock(uint64(i)) + readBlock, err := store.Get(uint64(i)) require.NoError(t, err) require.Equal(t, blocks[i], readBlock) } @@ -60,7 +60,7 @@ func TestDataSplitting(t *testing.T) { store.compressor = compression.NewNoCompressor() defer store.Close() for i := range numBlocks { - readBlock, err := store.ReadBlock(uint64(i)) + readBlock, err := store.Get(uint64(i)) require.NoError(t, err) require.Equal(t, blocks[i], readBlock) } @@ -76,16 +76,15 @@ func TestDataSplitting_DeletedFile(t *testing.T) { blocks := make([][]byte, numBlocks) for i := range numBlocks { blocks[i] = fixedSizeBlock(t, 1024, uint64(i)) - require.NoError(t, store.WriteBlock(uint64(i), blocks[i])) + require.NoError(t, store.Put(uint64(i), blocks[i])) } - store.Close() + require.NoError(t, store.Close()) // Delete the first data file (blockdb_0.dat) firstDataFilePath := filepath.Join(store.config.DataDir, fmt.Sprintf(dataFileNameFormat, 0)) require.NoError(t, os.Remove(firstDataFilePath)) // reopen and verify the blocks - require.NoError(t, store.Close()) config = config.WithIndexDir(store.config.IndexDir).WithDataDir(store.config.DataDir) _, err := New(config, store.log) require.ErrorIs(t, err, ErrCorrupted) diff --git a/x/blockdb/errors.go b/x/blockdb/errors.go index 6f0f5d0d7885..bc9ee2ae5afb 100644 --- a/x/blockdb/errors.go +++ b/x/blockdb/errors.go @@ -7,9 +7,6 @@ import "errors" var ( ErrInvalidBlockHeight = errors.New("blockdb: invalid block height") - ErrBlockEmpty = errors.New("blockdb: block is empty") - ErrDatabaseClosed = errors.New("blockdb: database is closed") ErrCorrupted = errors.New("blockdb: unrecoverable corruption detected") ErrBlockTooLarge = errors.New("blockdb: block size too large") - ErrBlockNotFound = errors.New("blockdb: block not found") ) diff --git a/x/blockdb/readblock_test.go b/x/blockdb/readblock_test.go index 373e86dd7b3d..3e3fc1339bd2 100644 --- a/x/blockdb/readblock_test.go +++ b/x/blockdb/readblock_test.go @@ -12,6 +12,8 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/database" ) func TestReadOperations(t *testing.T) { @@ -57,7 +59,7 @@ func TestReadOperations(t *testing.T) { setup: func(db *Database) { db.Close() }, - wantErr: ErrDatabaseClosed, + wantErr: database.ErrClosed, }, { name: "height below minimum", @@ -73,12 +75,12 @@ func TestReadOperations(t *testing.T) { { name: "block is past max height", readHeight: 51, - wantErr: ErrBlockNotFound, + wantErr: database.ErrNotFound, }, { name: "block height is max height", readHeight: math.MaxUint64, - wantErr: ErrBlockNotFound, + wantErr: database.ErrNotFound, }, } @@ -105,7 +107,7 @@ func TestReadOperations(t *testing.T) { } block := randomBlock(t) - require.NoError(t, store.WriteBlock(i, block)) + require.NoError(t, store.Put(i, block)) seededBlocks[i] = block } @@ -114,17 +116,17 @@ func TestReadOperations(t *testing.T) { } if tt.wantErr != nil { - _, err := store.ReadBlock(tt.readHeight) + _, err := store.Get(tt.readHeight) require.ErrorIs(t, err, tt.wantErr) return } // Handle success cases if tt.noBlock { - _, err := store.ReadBlock(tt.readHeight) - require.ErrorIs(t, err, ErrBlockNotFound) + _, err := store.Get(tt.readHeight) + require.ErrorIs(t, err, database.ErrNotFound) } else { - readBlock, err := store.ReadBlock(tt.readHeight) + readBlock, err := store.Get(tt.readHeight) require.NoError(t, err) require.NotNil(t, readBlock) expectedBlock := seededBlocks[tt.readHeight] @@ -152,7 +154,7 @@ func TestReadOperations_Concurrency(t *testing.T) { } blocks[i] = randomBlock(t) - require.NoError(t, store.WriteBlock(uint64(i), blocks[i])) + require.NoError(t, store.Put(uint64(i), blocks[i])) } var wg sync.WaitGroup @@ -164,9 +166,9 @@ func TestReadOperations_Concurrency(t *testing.T) { go func(height int) { defer wg.Done() - block, err := store.ReadBlock(uint64(height)) + block, err := store.Get(uint64(height)) if gapHeights[uint64(height)] || height >= numBlocks { - if err == nil || !errors.Is(err, ErrBlockNotFound) { + if err == nil || !errors.Is(err, database.ErrNotFound) { errorCount.Add(1) } } else { @@ -182,9 +184,9 @@ func TestReadOperations_Concurrency(t *testing.T) { go func(height int) { defer wg.Done() - _, err := store.ReadBlock(uint64(height)) + _, err := store.Get(uint64(height)) if gapHeights[uint64(height)] || height >= numBlocks { - if err == nil || !errors.Is(err, ErrBlockNotFound) { + if err == nil || !errors.Is(err, database.ErrNotFound) { errorCount.Add(1) } } else { @@ -197,9 +199,9 @@ func TestReadOperations_Concurrency(t *testing.T) { go func(height int) { defer wg.Done() - _, err := store.ReadBlock(uint64(height)) + _, err := store.Get(uint64(height)) if gapHeights[uint64(height)] || height >= numBlocks { - if err == nil || !errors.Is(err, ErrBlockNotFound) { + if err == nil || !errors.Is(err, database.ErrNotFound) { errorCount.Add(1) } } else { @@ -261,7 +263,7 @@ func TestHasBlock(t *testing.T) { { name: "db_closed", dbClosed: true, - wantErr: ErrDatabaseClosed, + wantErr: database.ErrClosed, }, } @@ -274,14 +276,14 @@ func TestHasBlock(t *testing.T) { if i == gapHeight { continue } - require.NoError(t, store.WriteBlock(i, randomBlock(t))) + require.NoError(t, store.Put(i, randomBlock(t))) } if tc.dbClosed { require.NoError(t, store.Close()) } - has, err := store.HasBlock(tc.height) + has, err := store.Has(tc.height) if tc.wantErr != nil { require.ErrorIs(t, err, tc.wantErr) } else { diff --git a/x/blockdb/recovery_test.go b/x/blockdb/recovery_test.go index cfa5934186f2..aca23e6ddd16 100644 --- a/x/blockdb/recovery_test.go +++ b/x/blockdb/recovery_test.go @@ -208,7 +208,7 @@ func TestRecovery_Success(t *testing.T) { // Create 4KB blocks block := fixedSizeBlock(t, 4*1024, height) - require.NoError(t, store.WriteBlock(height, block)) + require.NoError(t, store.Put(height, block)) blocks[height] = block } checkDatabaseState(t, store, 8, 4) @@ -225,7 +225,7 @@ func TestRecovery_Success(t *testing.T) { // Verify blocks are readable for _, height := range blockHeights { - readBlock, err := recoveredStore.ReadBlock(height) + readBlock, err := recoveredStore.Get(height) require.NoError(t, err) require.Equal(t, blocks[height], readBlock, "block %d should be the same", height) } @@ -534,7 +534,7 @@ func TestRecovery_CorruptionDetection(t *testing.T) { } else { blocks[i] = randomBlock(t) } - require.NoError(t, store.WriteBlock(height, blocks[i])) + require.NoError(t, store.Put(height, blocks[i])) } require.NoError(t, store.Close()) diff --git a/x/blockdb/writeblock_test.go b/x/blockdb/writeblock_test.go index 294a6a4d38ad..bad21097a07b 100644 --- a/x/blockdb/writeblock_test.go +++ b/x/blockdb/writeblock_test.go @@ -13,12 +13,48 @@ import ( "github.com/stretchr/testify/require" + "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/utils/compression" safemath "github.com/ava-labs/avalanchego/utils/math" ) -func TestWriteBlock_Basic(t *testing.T) { +func TestPutGet(t *testing.T) { + tests := []struct { + name string + block []byte + want []byte + }{ + { + name: "normal write", + block: []byte("hello"), + want: []byte("hello"), + }, + { + name: "empty block", + block: []byte{}, + want: []byte{}, + }, + { + name: "nil block", + block: nil, + want: []byte{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, cleanup := newTestDatabase(t, DefaultConfig()) + defer cleanup() + require.NoError(t, db.Put(0, tt.block)) + + got, err := db.Get(0) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestPut_MaxHeight(t *testing.T) { customConfig := DefaultConfig().WithMinimumHeight(10) tests := []struct { @@ -136,19 +172,12 @@ func TestWriteBlock_Basic(t *testing.T) { blocksWritten := make(map[uint64][]byte) for _, h := range tt.blockHeights { block := randomBlock(t) - err := store.WriteBlock(h, block) + err := store.Put(h, block) require.NoError(t, err, "unexpected error at height %d", h) blocksWritten[h] = block } - // Verify all written blocks are readable and data is correct - for h, expectedBlock := range blocksWritten { - readBlock, err := store.ReadBlock(h) - require.NoError(t, err, "ReadBlock failed at height %d", h) - require.Equal(t, expectedBlock, readBlock) - } - checkDatabaseState(t, store, tt.expectedMaxHeight, tt.expectedMCH) }) } @@ -179,7 +208,7 @@ func TestWriteBlock_Concurrency(t *testing.T) { height = uint64(i) } - err := store.WriteBlock(height, block) + err := store.Put(height, block) if err != nil { errors.Add(1) } @@ -192,9 +221,9 @@ func TestWriteBlock_Concurrency(t *testing.T) { // Verify that all expected heights have blocks (except 5, 10) for i := range 20 { height := uint64(i) - block, err := store.ReadBlock(height) + block, err := store.Get(height) if i == 5 || i == 10 { - require.ErrorIs(t, err, ErrBlockNotFound, "expected ErrBlockNotFound at gap height %d", height) + require.ErrorIs(t, err, database.ErrNotFound, "expected ErrNotFound at gap height %d", height) } else { require.NoError(t, err) require.Equal(t, blocks[i], block, "block mismatch at height %d", height) @@ -214,18 +243,6 @@ func TestWriteBlock_Errors(t *testing.T) { wantErr error wantErrMsg string }{ - { - name: "empty block nil", - height: 0, - block: nil, - wantErr: ErrBlockEmpty, - }, - { - name: "empty block zero length", - height: 0, - block: []byte{}, - wantErr: ErrBlockEmpty, - }, { name: "height below custom minimum", height: 5, @@ -246,7 +263,7 @@ func TestWriteBlock_Errors(t *testing.T) { setup: func(db *Database) { db.Close() }, - wantErr: ErrDatabaseClosed, + wantErr: database.ErrClosed, }, { name: "exceed max data file size", @@ -310,7 +327,7 @@ func TestWriteBlock_Errors(t *testing.T) { tt.setup(store) } - err := store.WriteBlock(tt.height, tt.block) + err := store.Put(tt.height, tt.block) if tt.wantErrMsg != "" { require.True(t, strings.HasPrefix(err.Error(), tt.wantErrMsg), "expected error message to start with %s, got %s", tt.wantErrMsg, err.Error()) } else {