Add BlockStorage and StateStorage to local adapter with tests
Implement full local storage adapter with three interfaces: - Provider: Basic object storage (Put, Get, Delete, Exists, List, Head) - BlockStorage: Block-indexed storage (PutBlock, GetBlock, GetBlockRange) - StateStorage: State snapshot storage (PutState, GetState, GetLatestState) Implementation details: - Content-addressed storage using SHA256 for object IDs - Directory sharding for filesystem performance: - Objects: first 2 chars of hash (e.g., objects/91/91f0...) - Blocks: every 10,000 (e.g., blocks/0001/12345.block) - States: every 10,000 (e.g., states/0001/12345.state) - Metadata JSON sidecar files with content type and attributes - Thread-safe with sync.RWMutex - Size quota enforcement across all directories Test coverage (30 tests): - Provider interface: Put/Get, Delete, Exists, Head, List, quota - BlockStorage: PutGetBlock, GetBlockRange, GetLatestBlockIndex - StateStorage: PutGetState, GetLatestState, quota enforcement - Path helpers and interface compliance verification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf0c52980f
commit
c21789557c
|
|
@ -53,7 +53,7 @@ func New(cfg Config) (*Adapter, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create subdirectories
|
// Create subdirectories
|
||||||
for _, sub := range []string{"objects", "meta"} {
|
for _, sub := range []string{"objects", "meta", "blocks", "states"} {
|
||||||
if err := os.MkdirAll(filepath.Join(path, sub), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Join(path, sub), 0755); err != nil {
|
||||||
return nil, fmt.Errorf("local: create %s directory: %w", sub, err)
|
return nil, fmt.Errorf("local: create %s directory: %w", sub, err)
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +114,10 @@ func (a *Adapter) Put(ctx context.Context, data io.Reader, opts storage.PutOptio
|
||||||
}
|
}
|
||||||
|
|
||||||
metaPath := a.metaPath(id)
|
metaPath := a.metaPath(id)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(metaPath), 0755); err != nil {
|
||||||
|
os.Remove(objPath) // Clean up on error
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: create meta dir: %w", err)
|
||||||
|
}
|
||||||
metaData, _ := json.Marshal(meta)
|
metaData, _ := json.Marshal(meta)
|
||||||
if err := os.WriteFile(metaPath, metaData, 0644); err != nil {
|
if err := os.WriteFile(metaPath, metaData, 0644); err != nil {
|
||||||
os.Remove(objPath) // Clean up on error
|
os.Remove(objPath) // Clean up on error
|
||||||
|
|
@ -274,6 +278,246 @@ func (a *Adapter) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BlockStorage Interface Implementation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// PutBlock stores a block with its index.
|
||||||
|
func (a *Adapter) PutBlock(ctx context.Context, index uint32, data []byte) (storage.ObjectID, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
// Check size limits
|
||||||
|
if a.maxSize > 0 {
|
||||||
|
currentSize, _ := a.calculateSize()
|
||||||
|
if currentSize+int64(len(data)) > a.maxSize {
|
||||||
|
return storage.ObjectID{}, storage.ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write block file
|
||||||
|
blockPath := a.blockPath(index)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(blockPath), 0755); err != nil {
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: create block dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(blockPath, data, 0644); err != nil {
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: write block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content hash for ID
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
id := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
return storage.ObjectID{
|
||||||
|
Provider: providerName,
|
||||||
|
Container: "blocks",
|
||||||
|
ID: id,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlock retrieves a block by its index.
|
||||||
|
func (a *Adapter) GetBlock(ctx context.Context, index uint32) ([]byte, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
blockPath := a.blockPath(index)
|
||||||
|
data, err := os.ReadFile(blockPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("local: read block: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlockRange retrieves multiple blocks efficiently.
|
||||||
|
func (a *Adapter) GetBlockRange(ctx context.Context, start, end uint32) ([][]byte, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
if start > end {
|
||||||
|
return nil, fmt.Errorf("local: invalid block range: start %d > end %d", start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := make([][]byte, 0, end-start+1)
|
||||||
|
for i := start; i <= end; i++ {
|
||||||
|
blockPath := a.blockPath(i)
|
||||||
|
data, err := os.ReadFile(blockPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("local: read block %d: %w", i, err)
|
||||||
|
}
|
||||||
|
blocks = append(blocks, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestBlockIndex returns the highest block index in storage.
|
||||||
|
func (a *Adapter) GetLatestBlockIndex(ctx context.Context) (uint32, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
blocksDir := filepath.Join(a.basePath, "blocks")
|
||||||
|
var latest uint32
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
err := filepath.Walk(blocksDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip errors
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse block index from filename
|
||||||
|
name := info.Name()
|
||||||
|
if !strings.HasSuffix(name, ".block") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var index uint32
|
||||||
|
if _, err := fmt.Sscanf(name, "%d.block", &index); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || index > latest {
|
||||||
|
latest = index
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("local: scan blocks: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return 0, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// StateStorage Interface Implementation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// PutState stores a state snapshot at a specific height.
|
||||||
|
func (a *Adapter) PutState(ctx context.Context, height uint32, data io.Reader) (storage.ObjectID, error) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
|
||||||
|
// Read all data
|
||||||
|
content, err := io.ReadAll(data)
|
||||||
|
if err != nil {
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: read state data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size limits
|
||||||
|
if a.maxSize > 0 {
|
||||||
|
currentSize, _ := a.calculateSize()
|
||||||
|
if currentSize+int64(len(content)) > a.maxSize {
|
||||||
|
return storage.ObjectID{}, storage.ErrQuotaExceeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write state file
|
||||||
|
statePath := a.statePath(height)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(statePath), 0755); err != nil {
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: create state dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(statePath, content, 0644); err != nil {
|
||||||
|
return storage.ObjectID{}, fmt.Errorf("local: write state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate content hash for ID
|
||||||
|
hash := sha256.Sum256(content)
|
||||||
|
id := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
return storage.ObjectID{
|
||||||
|
Provider: providerName,
|
||||||
|
Container: "states",
|
||||||
|
ID: id,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState retrieves the state snapshot for a given height.
|
||||||
|
func (a *Adapter) GetState(ctx context.Context, height uint32) (io.ReadCloser, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
statePath := a.statePath(height)
|
||||||
|
file, err := os.Open(statePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("local: open state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestState returns the most recent state snapshot.
|
||||||
|
func (a *Adapter) GetLatestState(ctx context.Context) (uint32, io.ReadCloser, error) {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
|
||||||
|
statesDir := filepath.Join(a.basePath, "states")
|
||||||
|
var latest uint32
|
||||||
|
var found bool
|
||||||
|
|
||||||
|
err := filepath.Walk(statesDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip errors
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse height from filename
|
||||||
|
name := info.Name()
|
||||||
|
if !strings.HasSuffix(name, ".state") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var height uint32
|
||||||
|
if _, err := fmt.Sscanf(name, "%d.state", &height); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found || height > latest {
|
||||||
|
latest = height
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("local: scan states: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return 0, nil, storage.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the latest state file
|
||||||
|
statePath := a.statePath(latest)
|
||||||
|
file, err := os.Open(statePath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("local: open latest state: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest, file, nil
|
||||||
|
}
|
||||||
|
|
||||||
// objectPath returns the filesystem path for an object.
|
// objectPath returns the filesystem path for an object.
|
||||||
func (a *Adapter) objectPath(id string) string {
|
func (a *Adapter) objectPath(id string) string {
|
||||||
// Use first 2 chars as subdirectory for better filesystem performance
|
// Use first 2 chars as subdirectory for better filesystem performance
|
||||||
|
|
@ -291,6 +535,22 @@ func (a *Adapter) metaPath(id string) string {
|
||||||
return filepath.Join(a.basePath, "meta", id+".json")
|
return filepath.Join(a.basePath, "meta", id+".json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blockPath returns the filesystem path for a block by index.
|
||||||
|
func (a *Adapter) blockPath(index uint32) string {
|
||||||
|
// Use first 4 digits as subdirectory for better filesystem performance
|
||||||
|
// e.g., block 12345 -> blocks/0001/12345.block
|
||||||
|
subdir := fmt.Sprintf("%04d", index/10000)
|
||||||
|
return filepath.Join(a.basePath, "blocks", subdir, fmt.Sprintf("%d.block", index))
|
||||||
|
}
|
||||||
|
|
||||||
|
// statePath returns the filesystem path for a state snapshot by height.
|
||||||
|
func (a *Adapter) statePath(height uint32) string {
|
||||||
|
// Use first 4 digits as subdirectory for better filesystem performance
|
||||||
|
// e.g., state 12345 -> states/0001/12345.state
|
||||||
|
subdir := fmt.Sprintf("%04d", height/10000)
|
||||||
|
return filepath.Join(a.basePath, "states", subdir, fmt.Sprintf("%d.state", height))
|
||||||
|
}
|
||||||
|
|
||||||
// objectMeta holds object metadata.
|
// objectMeta holds object metadata.
|
||||||
type objectMeta struct {
|
type objectMeta struct {
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
|
@ -313,20 +573,26 @@ func (a *Adapter) loadMeta(id string) (objectMeta, error) {
|
||||||
return meta, err
|
return meta, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateSize returns the total size of stored objects.
|
// calculateSize returns the total size of all stored data.
|
||||||
func (a *Adapter) calculateSize() (int64, error) {
|
func (a *Adapter) calculateSize() (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
objectsDir := filepath.Join(a.basePath, "objects")
|
|
||||||
|
|
||||||
err := filepath.Walk(objectsDir, func(path string, info os.FileInfo, err error) error {
|
// Calculate size across all storage directories
|
||||||
if err != nil {
|
for _, subdir := range []string{"objects", "blocks", "states"} {
|
||||||
|
dir := filepath.Join(a.basePath, subdir)
|
||||||
|
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
}
|
}
|
||||||
if !info.IsDir() {
|
}
|
||||||
total += info.Size()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
return total, err
|
return total, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,604 @@
|
||||||
|
package local
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tutus-one/tutus-chain/pkg/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
adapter, err := New(Config{Path: tmpDir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
// Check subdirectories were created
|
||||||
|
for _, sub := range []string{"objects", "meta", "blocks", "states"} {
|
||||||
|
path := filepath.Join(tmpDir, sub)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
t.Errorf("subdirectory %s not created", sub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_DefaultPath(t *testing.T) {
|
||||||
|
// Clean up default path after test
|
||||||
|
defer os.RemoveAll("./storage")
|
||||||
|
|
||||||
|
adapter, err := New(Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
if _, err := os.Stat("./storage"); os.IsNotExist(err) {
|
||||||
|
t.Error("default storage directory not created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Name(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
if got := adapter.Name(); got != "local" {
|
||||||
|
t.Errorf("Name() = %v, want %v", got, "local")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_PutGet(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
data := []byte("test data")
|
||||||
|
|
||||||
|
// Put
|
||||||
|
id, err := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Attributes: map[string]string{"key": "value"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id.Provider != "local" {
|
||||||
|
t.Errorf("Put() Provider = %v, want %v", id.Provider, "local")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get
|
||||||
|
reader, err := adapter.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get() error = %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, data) {
|
||||||
|
t.Errorf("Get() = %v, want %v", got, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Get_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := adapter.Get(ctx, storage.ObjectID{ID: "nonexistent"})
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("Get() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Exists(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
data := []byte("test data")
|
||||||
|
|
||||||
|
// Put
|
||||||
|
id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{})
|
||||||
|
|
||||||
|
// Exists should return true
|
||||||
|
exists, err := adapter.Exists(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists() error = %v", err)
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
t.Error("Exists() = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-existent should return false
|
||||||
|
exists, err = adapter.Exists(ctx, storage.ObjectID{ID: "nonexistent"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Exists() error = %v", err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
t.Error("Exists() = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Delete(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
data := []byte("test data")
|
||||||
|
|
||||||
|
// Put
|
||||||
|
id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{})
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
err := adapter.Delete(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Delete() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not exist anymore
|
||||||
|
exists, _ := adapter.Exists(ctx, id)
|
||||||
|
if exists {
|
||||||
|
t.Error("object still exists after Delete()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Delete_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := adapter.Delete(ctx, storage.ObjectID{ID: "nonexistent"})
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("Delete() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Head(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
data := []byte("test data")
|
||||||
|
|
||||||
|
// Put
|
||||||
|
id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{
|
||||||
|
ContentType: "text/plain",
|
||||||
|
Attributes: map[string]string{"key": "value"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Head
|
||||||
|
info, err := adapter.Head(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Head() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size != int64(len(data)) {
|
||||||
|
t.Errorf("Head() Size = %v, want %v", info.Size, len(data))
|
||||||
|
}
|
||||||
|
if info.ContentType != "text/plain" {
|
||||||
|
t.Errorf("Head() ContentType = %v, want %v", info.ContentType, "text/plain")
|
||||||
|
}
|
||||||
|
if info.Attributes["key"] != "value" {
|
||||||
|
t.Errorf("Head() Attributes[key] = %v, want %v", info.Attributes["key"], "value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_Head_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := adapter.Head(ctx, storage.ObjectID{ID: "nonexistent"})
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("Head() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_List(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Put some objects
|
||||||
|
ids := make([]storage.ObjectID, 3)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
data := []byte(strings.Repeat("x", i+1)) // Different content = different ID
|
||||||
|
id, _ := adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{})
|
||||||
|
ids[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all
|
||||||
|
results, err := adapter.List(ctx, "", storage.ListOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("List() returned %d items, want 3", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_List_MaxResults(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Put some objects
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
data := []byte(strings.Repeat("x", i+1))
|
||||||
|
adapter.Put(ctx, bytes.NewReader(data), storage.PutOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List with limit
|
||||||
|
results, err := adapter.List(ctx, "", storage.ListOptions{MaxResults: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("List() returned %d items, want 2", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_QuotaExceeded(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First put should succeed
|
||||||
|
data1 := []byte(strings.Repeat("x", 50))
|
||||||
|
_, err := adapter.Put(ctx, bytes.NewReader(data1), storage.PutOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second put should exceed quota
|
||||||
|
data2 := []byte(strings.Repeat("y", 60))
|
||||||
|
_, err = adapter.Put(ctx, bytes.NewReader(data2), storage.PutOptions{})
|
||||||
|
if err != storage.ErrQuotaExceeded {
|
||||||
|
t.Errorf("Put() error = %v, want %v", err, storage.ErrQuotaExceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// BlockStorage Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdapter_PutGetBlock(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
blockData := []byte("block 0 data")
|
||||||
|
|
||||||
|
// PutBlock
|
||||||
|
id, err := adapter.PutBlock(ctx, 0, blockData)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutBlock() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id.Container != "blocks" {
|
||||||
|
t.Errorf("PutBlock() Container = %v, want %v", id.Container, "blocks")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlock
|
||||||
|
got, err := adapter.GetBlock(ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBlock() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, blockData) {
|
||||||
|
t.Errorf("GetBlock() = %v, want %v", got, blockData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetBlock_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := adapter.GetBlock(ctx, 999)
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("GetBlock() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetBlockRange(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Put several blocks
|
||||||
|
for i := uint32(0); i < 5; i++ {
|
||||||
|
data := []byte(strings.Repeat(string(rune('a'+i)), 10))
|
||||||
|
_, err := adapter.PutBlock(ctx, i, data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutBlock(%d) error = %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get range
|
||||||
|
blocks, err := adapter.GetBlockRange(ctx, 1, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBlockRange() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blocks) != 3 {
|
||||||
|
t.Errorf("GetBlockRange() returned %d blocks, want 3", len(blocks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content
|
||||||
|
for i, block := range blocks {
|
||||||
|
expected := []byte(strings.Repeat(string(rune('b'+i)), 10))
|
||||||
|
if !bytes.Equal(block, expected) {
|
||||||
|
t.Errorf("GetBlockRange() block %d = %v, want %v", i+1, block, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetBlockRange_InvalidRange(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := adapter.GetBlockRange(ctx, 10, 5)
|
||||||
|
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid block range") {
|
||||||
|
t.Errorf("GetBlockRange() error = %v, want invalid block range error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetBlockRange_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Put only block 0
|
||||||
|
adapter.PutBlock(ctx, 0, []byte("block 0"))
|
||||||
|
|
||||||
|
// Try to get range that includes missing block
|
||||||
|
_, err := adapter.GetBlockRange(ctx, 0, 2)
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("GetBlockRange() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetLatestBlockIndex(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// No blocks yet
|
||||||
|
_, err := adapter.GetLatestBlockIndex(ctx)
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("GetLatestBlockIndex() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put some blocks
|
||||||
|
for i := uint32(0); i < 10; i++ {
|
||||||
|
adapter.PutBlock(ctx, i, []byte("block"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest
|
||||||
|
latest, err := adapter.GetLatestBlockIndex(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestBlockIndex() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if latest != 9 {
|
||||||
|
t.Errorf("GetLatestBlockIndex() = %v, want 9", latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_PutBlock_QuotaExceeded(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First block should succeed
|
||||||
|
_, err := adapter.PutBlock(ctx, 0, []byte(strings.Repeat("x", 50)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutBlock() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second block should exceed quota
|
||||||
|
_, err = adapter.PutBlock(ctx, 1, []byte(strings.Repeat("y", 60)))
|
||||||
|
if err != storage.ErrQuotaExceeded {
|
||||||
|
t.Errorf("PutBlock() error = %v, want %v", err, storage.ErrQuotaExceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// StateStorage Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdapter_PutGetState(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
stateData := []byte("state at height 0")
|
||||||
|
|
||||||
|
// PutState
|
||||||
|
id, err := adapter.PutState(ctx, 0, bytes.NewReader(stateData))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutState() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if id.Container != "states" {
|
||||||
|
t.Errorf("PutState() Container = %v, want %v", id.Container, "states")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState
|
||||||
|
reader, err := adapter.GetState(ctx, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetState() error = %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
got, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadAll() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(got, stateData) {
|
||||||
|
t.Errorf("GetState() = %v, want %v", got, stateData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetState_NotFound(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := adapter.GetState(ctx, 999)
|
||||||
|
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("GetState() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_GetLatestState(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir()})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// No states yet
|
||||||
|
_, _, err := adapter.GetLatestState(ctx)
|
||||||
|
if err != storage.ErrNotFound {
|
||||||
|
t.Errorf("GetLatestState() error = %v, want %v", err, storage.ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put some states
|
||||||
|
for i := uint32(0); i < 5; i++ {
|
||||||
|
data := []byte(strings.Repeat(string(rune('a'+i)), 10))
|
||||||
|
_, err := adapter.PutState(ctx, i, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutState(%d) error = %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest
|
||||||
|
height, reader, err := adapter.GetLatestState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLatestState() error = %v", err)
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
if height != 4 {
|
||||||
|
t.Errorf("GetLatestState() height = %v, want 4", height)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := io.ReadAll(reader)
|
||||||
|
expected := []byte(strings.Repeat("e", 10))
|
||||||
|
if !bytes.Equal(got, expected) {
|
||||||
|
t.Errorf("GetLatestState() data = %v, want %v", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_PutState_QuotaExceeded(t *testing.T) {
|
||||||
|
adapter, _ := New(Config{Path: t.TempDir(), MaxSize: 100})
|
||||||
|
defer adapter.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First state should succeed
|
||||||
|
_, err := adapter.PutState(ctx, 0, bytes.NewReader([]byte(strings.Repeat("x", 50))))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PutState() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second state should exceed quota
|
||||||
|
_, err = adapter.PutState(ctx, 1, bytes.NewReader([]byte(strings.Repeat("y", 60))))
|
||||||
|
if err != storage.ErrQuotaExceeded {
|
||||||
|
t.Errorf("PutState() error = %v, want %v", err, storage.ErrQuotaExceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Path Helper Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdapter_blockPath(t *testing.T) {
|
||||||
|
adapter := &Adapter{basePath: "/storage"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
index uint32
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{0, filepath.Join("/storage", "blocks", "0000", "0.block")},
|
||||||
|
{9999, filepath.Join("/storage", "blocks", "0000", "9999.block")},
|
||||||
|
{10000, filepath.Join("/storage", "blocks", "0001", "10000.block")},
|
||||||
|
{12345, filepath.Join("/storage", "blocks", "0001", "12345.block")},
|
||||||
|
{99999, filepath.Join("/storage", "blocks", "0009", "99999.block")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := adapter.blockPath(tt.index)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("blockPath(%d) = %v, want %v", tt.index, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_statePath(t *testing.T) {
|
||||||
|
adapter := &Adapter{basePath: "/storage"}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
height uint32
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{0, filepath.Join("/storage", "states", "0000", "0.state")},
|
||||||
|
{9999, filepath.Join("/storage", "states", "0000", "9999.state")},
|
||||||
|
{10000, filepath.Join("/storage", "states", "0001", "10000.state")},
|
||||||
|
{12345, filepath.Join("/storage", "states", "0001", "12345.state")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := adapter.statePath(tt.height)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("statePath(%d) = %v, want %v", tt.height, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Interface Compliance Tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestAdapter_ImplementsProvider(t *testing.T) {
|
||||||
|
var _ storage.Provider = (*Adapter)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ImplementsBlockStorage(t *testing.T) {
|
||||||
|
var _ storage.BlockStorage = (*Adapter)(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdapter_ImplementsStateStorage(t *testing.T) {
|
||||||
|
var _ storage.StateStorage = (*Adapter)(nil)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue