Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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.
//
// 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
}
216 changes: 216 additions & 0 deletions database/heightindexdb/dbtest/dbtest.go
Original file line number Diff line number Diff line change
@@ -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)
}
77 changes: 77 additions & 0 deletions database/heightindexdb/memdb/database.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions database/heightindexdb/memdb/database_test.go
Original file line number Diff line number Diff line change
@@ -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{} })
})
}
}