diff --git a/database/database.go b/database/database.go index efa63fc87b46..cc21840d0bf2 100644 --- a/database/database.go +++ b/database/database.go @@ -95,17 +95,29 @@ type Database interface { health.Checker } -// HeightIndex defines the interface for storing and retrieving entries by height. +// HeightIndex defines the interface for storing and retrieving values by height. type HeightIndex interface { - // Put inserts the entry into the store at the given height. - Put(height uint64, bytes []byte) error + // Put inserts the value into the database at the given height. + // + // If value is nil or an empty slice, then when it's retrieved it may be nil + // or an empty slice. + // + // value is safe to read and modify after calling Put. + Put(height uint64, value []byte) error - // Get retrieves an entry by its height. + // Get retrieves a value by its height. + // Returns [ErrNotFound] if the key is not present in the database. + // + // Returned []byte is safe to read and modify after calling Get. Get(height uint64) ([]byte, error) - // Has checks if an entry exists at the given height. + // Has checks if a value exists at the given height. + // + // Returns true even if the stored value is nil or empty. Has(height uint64) (bool, error) // Close closes the database. + // + // Calling Close after Close returns [ErrClosed]. io.Closer } diff --git a/database/heightindexdb/dbtest/dbtest.go b/database/heightindexdb/dbtest/dbtest.go new file mode 100644 index 000000000000..ce59e75d1888 --- /dev/null +++ b/database/heightindexdb/dbtest/dbtest.go @@ -0,0 +1,216 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package dbtest + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/avalanchego/database" +) + +// Tests is a list of all database tests +var Tests = []struct { + Name string + Test func(t *testing.T, newDB func() database.HeightIndex) +}{ + {"TestPutGet", TestPutGet}, + {"TestHas", TestHas}, + {"TestCloseAndPut", TestCloseAndPut}, + {"TestCloseAndGet", TestCloseAndGet}, + {"TestCloseAndHas", TestCloseAndHas}, + {"TestClose", TestClose}, +} + +type putArgs struct { + height uint64 + data []byte +} + +func TestPutGet(t *testing.T, newDB func() database.HeightIndex) { + tests := []struct { + name string + puts []putArgs + queryHeight uint64 + want []byte + wantErr error + }{ + { + name: "normal operation", + puts: []putArgs{ + {1, []byte("test data 1")}, + }, + queryHeight: 1, + want: []byte("test data 1"), + }, + { + name: "not found error when getting on non-existing height", + puts: []putArgs{ + {1, []byte("test data")}, + }, + queryHeight: 2, + wantErr: database.ErrNotFound, + }, + { + name: "overwriting data on same height", + puts: []putArgs{ + {1, []byte("original data")}, + {1, []byte("overwritten data")}, + }, + queryHeight: 1, + want: []byte("overwritten data"), + }, + { + name: "put and get nil data", + puts: []putArgs{ + {1, nil}, + }, + queryHeight: 1, + }, + { + name: "put and get empty bytes", + puts: []putArgs{ + {1, []byte{}}, + }, + queryHeight: 1, + }, + { + name: "put and get large data", + puts: []putArgs{ + {1, make([]byte, 1000)}, + }, + queryHeight: 1, + want: make([]byte, 1000), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := newDB() + defer func() { + require.NoError(t, db.Close()) + }() + + // Perform all puts + for _, write := range tt.puts { + require.NoError(t, db.Put(write.height, write.data)) + } + + // modify the original value of the put data to ensure the saved + // value won't be changed after Get + if len(tt.puts) > int(tt.queryHeight) && tt.puts[tt.queryHeight].data != nil { + copy(tt.puts[tt.queryHeight].data, []byte("modified data")) + } + + // Query the specific height + retrievedData, err := db.Get(tt.queryHeight) + require.ErrorIs(t, err, tt.wantErr) + require.True(t, bytes.Equal(tt.want, retrievedData)) + + // modify the data returned from Get and ensure it won't change the + // data from a second Get + copy(retrievedData, []byte("modified data")) + newData, err := db.Get(tt.queryHeight) + require.ErrorIs(t, err, tt.wantErr) + require.True(t, bytes.Equal(tt.want, newData)) + }) + } +} + +func TestHas(t *testing.T, newDB func() database.HeightIndex) { + tests := []struct { + name string + puts []putArgs + queryHeight uint64 + want bool + }{ + { + name: "non-existent item", + queryHeight: 1, + }, + { + name: "existing item with data", + puts: []putArgs{{1, []byte("test data")}}, + queryHeight: 1, + want: true, + }, + { + name: "existing item with nil data", + puts: []putArgs{{1, nil}}, + queryHeight: 1, + want: true, + }, + { + name: "existing item with empty bytes", + puts: []putArgs{{1, []byte{}}}, + queryHeight: 1, + want: true, + }, + { + name: "has returns true on overridden height", + puts: []putArgs{ + {1, []byte("original data")}, + {1, []byte("overridden data")}, + }, + queryHeight: 1, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := newDB() + defer func() { + require.NoError(t, db.Close()) + }() + + // Perform all puts + for _, write := range tt.puts { + require.NoError(t, db.Put(write.height, write.data)) + } + + ok, err := db.Has(tt.queryHeight) + require.NoError(t, err) + require.Equal(t, tt.want, ok) + }) + } +} + +func TestCloseAndPut(t *testing.T, newDB func() database.HeightIndex) { + db := newDB() + require.NoError(t, db.Close()) + + // Try to put after close - should return error + err := db.Put(1, []byte("test")) + require.ErrorIs(t, err, database.ErrClosed) +} + +func TestCloseAndGet(t *testing.T, newDB func() database.HeightIndex) { + db := newDB() + require.NoError(t, db.Close()) + + // Try to get after close - should return error + _, err := db.Get(1) + require.ErrorIs(t, err, database.ErrClosed) +} + +func TestCloseAndHas(t *testing.T, newDB func() database.HeightIndex) { + db := newDB() + require.NoError(t, db.Close()) + + // Try to has after close - should return error + _, err := db.Has(1) + require.ErrorIs(t, err, database.ErrClosed) +} + +func TestClose(t *testing.T, newDB func() database.HeightIndex) { + db := newDB() + require.NoError(t, db.Close()) + + // Second close should return error + err := db.Close() + require.ErrorIs(t, err, database.ErrClosed) +} diff --git a/database/heightindexdb/memdb/database.go b/database/heightindexdb/memdb/database.go new file mode 100644 index 000000000000..8f9ea900f60b --- /dev/null +++ b/database/heightindexdb/memdb/database.go @@ -0,0 +1,77 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package memdb + +import ( + "slices" + "sync" + + "github.com/ava-labs/avalanchego/database" +) + +var _ database.HeightIndex = (*Database)(nil) + +// Database is an in-memory implementation of database.HeightIndex +type Database struct { + mu sync.RWMutex + data map[uint64][]byte + closed bool +} + +func (db *Database) Put(height uint64, data []byte) error { + db.mu.Lock() + defer db.mu.Unlock() + + if db.closed { + return database.ErrClosed + } + + if db.data == nil { + db.data = make(map[uint64][]byte) + } + + db.data[height] = slices.Clone(data) + return nil +} + +func (db *Database) Get(height uint64) ([]byte, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + if db.closed { + return nil, database.ErrClosed + } + + data, ok := db.data[height] + if !ok { + return nil, database.ErrNotFound + } + + return slices.Clone(data), nil +} + +func (db *Database) Has(height uint64) (bool, error) { + db.mu.RLock() + defer db.mu.RUnlock() + + if db.closed { + return false, database.ErrClosed + } + + _, ok := db.data[height] + return ok, nil +} + +func (db *Database) Close() error { + db.mu.Lock() + defer db.mu.Unlock() + + if db.closed { + return database.ErrClosed + } + + db.closed = true + db.data = nil + return nil +} diff --git a/database/heightindexdb/memdb/database_test.go b/database/heightindexdb/memdb/database_test.go new file mode 100644 index 000000000000..260c1a1b5518 --- /dev/null +++ b/database/heightindexdb/memdb/database_test.go @@ -0,0 +1,19 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package memdb + +import ( + "testing" + + "github.com/ava-labs/avalanchego/database" + "github.com/ava-labs/avalanchego/database/heightindexdb/dbtest" +) + +func TestInterface(t *testing.T) { + for _, test := range dbtest.Tests { + t.Run("memdb_"+test.Name, func(t *testing.T) { + test.Test(t, func() database.HeightIndex { return &Database{} }) + }) + } +}