Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions database/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
// A nil or empty slice for [value] is allowed and will be returned as nil
// when retrieved via Get.
//
// Note: [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.
//
// Note: Get always returns a new copy of the data.
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.
//
// Return true even if the stored value is nil, or empty.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also confused here, I thought Get returned nil if you inserted empty - why does Has return nil or empty?

Copy link
Contributor Author

@DracoLi DracoLi Sep 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is to clarify Has returns true if the value at the key is nil or empty.

Has(height uint64) (bool, error)

// Close closes the database.
//
// Note: Calling Close after Close returns ErrClosed.
io.Closer
}
250 changes: 250 additions & 0 deletions database/heightindexdb/dbtest/dbtest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved.
// See the file LICENSE for licensing terms.

package dbtest

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/ava-labs/avalanchego/database"
)

// Tests is a list of all database tests
var Tests = map[string]func(t *testing.T, newDB func() database.HeightIndex){
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One call out (this is true for the existing dbtest, but still) is that this doesn't play with the IDE nicely. IDE doesn't seem to be able to pick this up as a test vector so you can't run a single test at a time which is annoying. I'm not sure how we can fix this while also supporting table-tests in the subtests though - so just leaving this as something I noticed.

"PutGet": TestPutGet,
"Has": TestHas,
"CloseAndPut": TestCloseAndPut,
"CloseAndGet": TestCloseAndGet,
"CloseAndHas": TestCloseAndHas,
"Close": TestClose,
"ModifyValueAfterPut": TestModifyValueAfterPut,
"ModifyValueAfterGet": TestModifyValueAfterGet,
}

type putArgs struct {
height uint64
data []byte
}

func TestPutGet(t *testing.T, newDB func() database.HeightIndex) {
tests := []struct {
name string
puts []putArgs
queryHeight uint64
expected []byte
expectedErr error
}{
{
name: "normal operation",
puts: []putArgs{
{1, []byte("test data 1")},
},
queryHeight: 1,
expected: []byte("test data 1"),
},
{
name: "not found error when getting on non-existing height",
puts: []putArgs{
{1, []byte("test data")},
},
queryHeight: 2,
expected: nil,
expectedErr: database.ErrNotFound,
},
{
name: "overwriting data on same height",
puts: []putArgs{
{1, []byte("original data")},
{1, []byte("overwritten data")},
},
queryHeight: 1,
expected: []byte("overwritten data"),
},
{
name: "put and get nil data",
puts: []putArgs{
{1, nil},
},
queryHeight: 1,
expected: nil,
},
{
name: "put and get empty data",
puts: []putArgs{
{1, []byte{}},
},
queryHeight: 1,
expected: nil,
},
{
name: "put and get large data",
puts: []putArgs{
{1, make([]byte, 1000)},
},
queryHeight: 1,
expected: make([]byte, 1000),
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := newDB()
defer db.Close()

// Perform all puts
for _, write := range tt.puts {
require.NoError(t, db.Put(write.height, write.data))
}

// Query the specific height
retrievedData, err := db.Get(tt.queryHeight)
if tt.expectedErr != nil {
require.Equal(t, tt.expectedErr, err)
} else {
require.NoError(t, err)
require.Equal(t, tt.expected, retrievedData)
}
})
}
}

func TestHas(t *testing.T, newDB func() database.HeightIndex) {
tests := []struct {
name string
puts []putArgs
queryHeight uint64
expected bool
}{
{
name: "non-existent item",
puts: []putArgs{},
queryHeight: 1,
expected: false,
},
{
name: "existing item with data",
puts: []putArgs{{1, []byte("test data")}},
queryHeight: 1,
expected: true,
},
{
name: "existing item with nil data",
puts: []putArgs{{1, nil}},
queryHeight: 1,
expected: true,
},
{
name: "existing item with empty bytes",
puts: []putArgs{{1, []byte{}}},
queryHeight: 1,
expected: true,
},
{
name: "has returns true on overridden height",
puts: []putArgs{
{1, []byte("original data")},
{1, []byte("overridden data")},
},
queryHeight: 1,
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := newDB()
defer db.Close()

// Perform all puts
for _, write := range tt.puts {
require.NoError(t, db.Put(write.height, write.data))
}

exists, err := db.Has(tt.queryHeight)
require.NoError(t, err)
require.Equal(t, tt.expected, exists)
})
}
}

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 TestModifyValueAfterPut(t *testing.T, newDB func() database.HeightIndex) {
db := newDB()
defer db.Close()

originalData := []byte("original data")
modifiedData := []byte("modified data")

// Put original data
require.NoError(t, db.Put(1, originalData))

// Modify the original data slice
copy(originalData, modifiedData)

// Get the data back - should still be the original data, not the modified slice
retrievedData, err := db.Get(1)
require.NoError(t, err)
require.Equal(t, []byte("original data"), retrievedData)
require.NotEqual(t, modifiedData, retrievedData)
}

func TestModifyValueAfterGet(t *testing.T, newDB func() database.HeightIndex) {
db := newDB()
defer db.Close()

originalData := []byte("original data")

// Put original data
require.NoError(t, db.Put(1, originalData))

// Get the data
retrievedData, err := db.Get(1)
require.NoError(t, err)
require.Equal(t, originalData, retrievedData)

// Modify the retrieved data
copy(retrievedData, []byte("modified data"))

// Get the data again - should still be the original data
retrievedData2, err := db.Get(1)
require.NoError(t, err)
require.Equal(t, originalData, retrievedData2)
require.NotEqual(t, retrievedData, retrievedData2)
}

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)
}
98 changes: 98 additions & 0 deletions database/heightindexdb/memdb/database.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 New() *Database {
return &Database{
data: make(map[uint64][]byte),
}
}

// Put stores data in memory at the given height
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)
}

if len(data) == 0 {
// don't save empty slice if data is nil or empty
db.data[height] = nil
} else {
db.data[height] = slices.Clone(data)
}

return nil
}

// Get retrieves data at the given height
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
}

// don't return empty slice if data is nil or empty
if data == nil {
return nil, nil
}

return slices.Clone(data), nil
}

// Has checks if data exists at the given height
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
}

// Close closes the in-memory database
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
}
Loading