...
This commit is contained in:
141
pkg/data/ourdb/README.md
Normal file
141
pkg/data/ourdb/README.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# OurDB
|
||||
|
||||
OurDB is a simple key-value database implementation that provides:
|
||||
|
||||
- Efficient key-value storage with history tracking
|
||||
- Data integrity verification using CRC32
|
||||
- Support for multiple backend files
|
||||
- Lookup table for fast data retrieval
|
||||
|
||||
## Overview
|
||||
|
||||
The database consists of three main components:
|
||||
|
||||
1. **DB Interface** - Provides the public API for database operations
|
||||
2. **Lookup Table** - Maps keys to data locations for efficient retrieval
|
||||
3. **Backend Storage** - Handles the actual data storage and file management
|
||||
|
||||
## Features
|
||||
|
||||
- **Key-Value Storage**: Store and retrieve binary data using numeric keys
|
||||
- **History Tracking**: Maintain a linked list of previous values for each key
|
||||
- **Data Integrity**: Verify data integrity using CRC32 checksums
|
||||
- **Multiple Backends**: Support for multiple storage files to handle large datasets
|
||||
- **Incremental Mode**: Automatically assign IDs for new records
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/freeflowuniverse/heroagent/pkg/ourdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new database
|
||||
config := ourdb.DefaultConfig()
|
||||
config.Path = "/path/to/database"
|
||||
|
||||
db, err := ourdb.New(config)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Store data
|
||||
data := []byte("Hello, World!")
|
||||
id := uint32(1)
|
||||
_, err = db.Set(ourdb.OurDBSetArgs{
|
||||
ID: &id,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to store data: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve data
|
||||
retrievedData, err := db.Get(id)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to retrieve data: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Retrieved data: %s\n", string(retrievedData))
|
||||
}
|
||||
```
|
||||
|
||||
### Using the Client
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/freeflowuniverse/heroagent/pkg/ourdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Create a new client
|
||||
client, err := ourdb.NewClient("/path/to/database")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Add data with auto-generated ID
|
||||
data := []byte("Hello, World!")
|
||||
id, err := client.Add(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to add data: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Data stored with ID: %d\n", id)
|
||||
|
||||
// Retrieve data
|
||||
retrievedData, err := client.Get(id)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to retrieve data: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Retrieved data: %s\n", string(retrievedData))
|
||||
|
||||
// Store data with specific ID
|
||||
err = client.Set(2, []byte("Another value"))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Get history of a value
|
||||
history, err := client.GetHistory(id, 5)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get history: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("History count: %d\n", len(history))
|
||||
|
||||
// Delete data
|
||||
err = client.Delete(id)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to delete data: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
- **RecordNrMax**: Maximum number of records (default: 16777215)
|
||||
- **RecordSizeMax**: Maximum size of a record in bytes (default: 4KB)
|
||||
- **FileSize**: Maximum size of a database file (default: 500MB)
|
||||
- **IncrementalMode**: Automatically assign IDs for new records (default: true)
|
||||
- **Reset**: Reset the database on initialization (default: false)
|
||||
|
||||
## Notes
|
||||
|
||||
This is a Go port of the original V implementation from the herolib repository.
|
255
pkg/data/ourdb/backend.go
Normal file
255
pkg/data/ourdb/backend.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// calculateCRC computes CRC32 for the data
|
||||
func calculateCRC(data []byte) uint32 {
|
||||
return crc32.ChecksumIEEE(data)
|
||||
}
|
||||
|
||||
// dbFileSelect opens the specified database file
|
||||
func (db *OurDB) dbFileSelect(fileNr uint16) error {
|
||||
// Check file number limit
|
||||
if fileNr > 65535 {
|
||||
return errors.New("file_nr needs to be < 65536")
|
||||
}
|
||||
|
||||
path := filepath.Join(db.path, fmt.Sprintf("%d.db", fileNr))
|
||||
|
||||
// Always close the current file if it's open
|
||||
if db.file != nil {
|
||||
db.file.Close()
|
||||
db.file = nil
|
||||
}
|
||||
|
||||
// Create file if it doesn't exist
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := db.createNewDbFile(fileNr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Open the file fresh
|
||||
file, err := os.OpenFile(path, os.O_RDWR, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db.file = file
|
||||
db.fileNr = fileNr
|
||||
return nil
|
||||
}
|
||||
|
||||
// createNewDbFile creates a new database file
|
||||
func (db *OurDB) createNewDbFile(fileNr uint16) error {
|
||||
newFilePath := filepath.Join(db.path, fmt.Sprintf("%d.db", fileNr))
|
||||
f, err := os.Create(newFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Write a single byte to make all positions start from 1
|
||||
_, err = f.Write([]byte{0})
|
||||
return err
|
||||
}
|
||||
|
||||
// getFileNr returns the file number to use for the next write
|
||||
func (db *OurDB) getFileNr() (uint16, error) {
|
||||
path := filepath.Join(db.path, fmt.Sprintf("%d.db", db.lastUsedFileNr))
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
if err := db.createNewDbFile(db.lastUsedFileNr); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return db.lastUsedFileNr, nil
|
||||
}
|
||||
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if uint32(stat.Size()) >= db.fileSize {
|
||||
db.lastUsedFileNr++
|
||||
if err := db.createNewDbFile(db.lastUsedFileNr); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return db.lastUsedFileNr, nil
|
||||
}
|
||||
|
||||
// set_ stores data at position x
|
||||
func (db *OurDB) set_(x uint32, oldLocation Location, data []byte) error {
|
||||
// Get file number to use
|
||||
fileNr, err := db.getFileNr()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Select the file
|
||||
if err := db.dbFileSelect(fileNr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get current file position for lookup
|
||||
pos, err := db.file.Seek(0, os.SEEK_END)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newLocation := Location{
|
||||
FileNr: fileNr,
|
||||
Position: uint32(pos),
|
||||
}
|
||||
|
||||
// Calculate CRC of data
|
||||
crc := calculateCRC(data)
|
||||
|
||||
// Create header (12 bytes total)
|
||||
header := make([]byte, headerSize)
|
||||
|
||||
// Write size (2 bytes)
|
||||
size := uint16(len(data))
|
||||
header[0] = byte(size & 0xFF)
|
||||
header[1] = byte((size >> 8) & 0xFF)
|
||||
|
||||
// Write CRC (4 bytes)
|
||||
header[2] = byte(crc & 0xFF)
|
||||
header[3] = byte((crc >> 8) & 0xFF)
|
||||
header[4] = byte((crc >> 16) & 0xFF)
|
||||
header[5] = byte((crc >> 24) & 0xFF)
|
||||
|
||||
// Convert previous location to bytes and store in header
|
||||
prevBytes, err := oldLocation.ToBytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
header[6+i] = prevBytes[i]
|
||||
}
|
||||
|
||||
// Write header
|
||||
if _, err := db.file.Write(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write actual data
|
||||
if _, err := db.file.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.file.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update lookup table with new position
|
||||
return db.lookup.Set(x, newLocation)
|
||||
}
|
||||
|
||||
// get_ retrieves data at specified location
|
||||
func (db *OurDB) get_(location Location) ([]byte, error) {
|
||||
if err := db.dbFileSelect(location.FileNr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if location.Position == 0 {
|
||||
return nil, fmt.Errorf("record not found, location: %+v", location)
|
||||
}
|
||||
|
||||
// Read header
|
||||
header := make([]byte, headerSize)
|
||||
if _, err := db.file.ReadAt(header, int64(location.Position)); err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
// Parse size (2 bytes)
|
||||
size := uint16(header[0]) | (uint16(header[1]) << 8)
|
||||
|
||||
// Parse CRC (4 bytes)
|
||||
storedCRC := uint32(header[2]) | (uint32(header[3]) << 8) | (uint32(header[4]) << 16) | (uint32(header[5]) << 24)
|
||||
|
||||
// Read data
|
||||
data := make([]byte, size)
|
||||
if _, err := db.file.ReadAt(data, int64(location.Position+headerSize)); err != nil {
|
||||
return nil, fmt.Errorf("failed to read data: %w", err)
|
||||
}
|
||||
|
||||
// Verify CRC
|
||||
calculatedCRC := calculateCRC(data)
|
||||
if calculatedCRC != storedCRC {
|
||||
return nil, errors.New("CRC mismatch: data corruption detected")
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// getPrevPos_ retrieves the previous position for a record
|
||||
func (db *OurDB) getPrevPos_(location Location) (Location, error) {
|
||||
if location.Position == 0 {
|
||||
return Location{}, errors.New("record not found")
|
||||
}
|
||||
|
||||
if err := db.dbFileSelect(location.FileNr); err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
|
||||
// Skip size and CRC (6 bytes)
|
||||
prevBytes := make([]byte, 6)
|
||||
if _, err := db.file.ReadAt(prevBytes, int64(location.Position+6)); err != nil {
|
||||
return Location{}, fmt.Errorf("failed to read previous location bytes: %w", err)
|
||||
}
|
||||
|
||||
return db.lookup.LocationNew(prevBytes)
|
||||
}
|
||||
|
||||
// delete_ zeros out the record at specified location
|
||||
func (db *OurDB) delete_(x uint32, location Location) error {
|
||||
if location.Position == 0 {
|
||||
return errors.New("record not found")
|
||||
}
|
||||
|
||||
if err := db.dbFileSelect(location.FileNr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read size first
|
||||
sizeBytes := make([]byte, 2)
|
||||
if _, err := db.file.ReadAt(sizeBytes, int64(location.Position)); err != nil {
|
||||
return err
|
||||
}
|
||||
size := uint16(sizeBytes[0]) | (uint16(sizeBytes[1]) << 8)
|
||||
|
||||
// Write zeros for the entire record (header + data)
|
||||
zeros := make([]byte, int(size)+headerSize)
|
||||
if _, err := db.file.WriteAt(zeros, int64(location.Position)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// close_ closes the database file
|
||||
func (db *OurDB) close_() error {
|
||||
if db.file != nil {
|
||||
return db.file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Condense removes empty records and updates positions
|
||||
// This is a complex operation that creates a new file without the deleted records
|
||||
func (db *OurDB) Condense() error {
|
||||
// This would be a complex implementation that would:
|
||||
// 1. Create a temporary file
|
||||
// 2. Copy all non-deleted records to the temp file
|
||||
// 3. Update all lookup entries to point to new locations
|
||||
// 4. Replace the original file with the temp file
|
||||
|
||||
// For now, this is a placeholder for future implementation
|
||||
return errors.New("condense operation not implemented yet")
|
||||
}
|
77
pkg/data/ourdb/client.go
Normal file
77
pkg/data/ourdb/client.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Client provides a simplified interface to the OurDB database
|
||||
type Client struct {
|
||||
db *OurDB
|
||||
}
|
||||
|
||||
// NewClient creates a new client for the specified database path
|
||||
func NewClient(path string) (*Client, error) {
|
||||
return NewClientWithConfig(path, DefaultConfig())
|
||||
}
|
||||
|
||||
// NewClientWithConfig creates a new client with a custom configuration
|
||||
func NewClientWithConfig(path string, baseConfig OurDBConfig) (*Client, error) {
|
||||
config := baseConfig
|
||||
config.Path = path
|
||||
|
||||
db, err := New(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{db: db}, nil
|
||||
}
|
||||
|
||||
// Set stores data with the specified ID
|
||||
func (c *Client) Set(id uint32, data []byte) error {
|
||||
if data == nil {
|
||||
return errors.New("data cannot be nil")
|
||||
}
|
||||
|
||||
_, err := c.db.Set(OurDBSetArgs{
|
||||
ID: &id,
|
||||
Data: data,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Add stores data and returns the auto-generated ID
|
||||
func (c *Client) Add(data []byte) (uint32, error) {
|
||||
if data == nil {
|
||||
return 0, errors.New("data cannot be nil")
|
||||
}
|
||||
|
||||
return c.db.Set(OurDBSetArgs{
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Get retrieves data for the specified ID
|
||||
func (c *Client) Get(id uint32) ([]byte, error) {
|
||||
return c.db.Get(id)
|
||||
}
|
||||
|
||||
// GetHistory retrieves historical values for the specified ID
|
||||
func (c *Client) GetHistory(id uint32, depth uint8) ([][]byte, error) {
|
||||
return c.db.GetHistory(id, depth)
|
||||
}
|
||||
|
||||
// Delete removes data for the specified ID
|
||||
func (c *Client) Delete(id uint32) error {
|
||||
return c.db.Delete(id)
|
||||
}
|
||||
|
||||
// Close closes the database
|
||||
func (c *Client) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
|
||||
// Destroy closes and removes the database
|
||||
func (c *Client) Destroy() error {
|
||||
return c.db.Destroy()
|
||||
}
|
173
pkg/data/ourdb/db.go
Normal file
173
pkg/data/ourdb/db.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package ourdb provides a simple key-value database implementation with history tracking
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// OurDB represents a binary database with variable-length records
|
||||
type OurDB struct {
|
||||
lookup *LookupTable
|
||||
path string // Directory in which we will have the lookup db as well as all the backend
|
||||
incrementalMode bool
|
||||
fileSize uint32
|
||||
file *os.File
|
||||
fileNr uint16 // The file which is open
|
||||
lastUsedFileNr uint16
|
||||
}
|
||||
|
||||
const headerSize = 12
|
||||
|
||||
// OurDBSetArgs contains the parameters for the Set method
|
||||
type OurDBSetArgs struct {
|
||||
ID *uint32
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Set stores data at the specified key position
|
||||
// The data is stored with a CRC32 checksum for integrity verification
|
||||
// and maintains a linked list of previous values for history tracking
|
||||
// Returns the ID used (either x if specified, or auto-incremented if x=0)
|
||||
func (db *OurDB) Set(args OurDBSetArgs) (uint32, error) {
|
||||
if db.incrementalMode {
|
||||
// If ID points to an empty location, return an error
|
||||
// else, overwrite data
|
||||
if args.ID != nil {
|
||||
// This is an update
|
||||
location, err := db.lookup.Get(*args.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if location.Position == 0 {
|
||||
return 0, errors.New("cannot set id for insertions when incremental mode is enabled")
|
||||
}
|
||||
|
||||
if err := db.set_(*args.ID, location, args.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *args.ID, nil
|
||||
}
|
||||
|
||||
// This is an insert
|
||||
id, err := db.lookup.GetNextID()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := db.set_(id, Location{}, args.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Using key-value mode
|
||||
if args.ID == nil {
|
||||
return 0, errors.New("id must be provided when incremental is disabled")
|
||||
}
|
||||
location, err := db.lookup.Get(*args.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := db.set_(*args.ID, location, args.Data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return *args.ID, nil
|
||||
}
|
||||
|
||||
// Get retrieves data stored at the specified key position
|
||||
// Returns error if the key doesn't exist or data is corrupted
|
||||
func (db *OurDB) Get(x uint32) ([]byte, error) {
|
||||
location, err := db.lookup.Get(x)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.get_(location)
|
||||
}
|
||||
|
||||
// GetHistory retrieves a list of previous values for the specified key
|
||||
// depth parameter controls how many historical values to retrieve (max)
|
||||
// Returns error if key doesn't exist or if there's an issue accessing the data
|
||||
func (db *OurDB) GetHistory(x uint32, depth uint8) ([][]byte, error) {
|
||||
result := make([][]byte, 0)
|
||||
currentLocation, err := db.lookup.Get(x)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Traverse the history chain up to specified depth
|
||||
for i := uint8(0); i < depth; i++ {
|
||||
// Get current value
|
||||
data, err := db.get_(currentLocation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, data)
|
||||
|
||||
// Try to get previous location
|
||||
prevLocation, err := db.getPrevPos_(currentLocation)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if prevLocation.Position == 0 {
|
||||
break
|
||||
}
|
||||
currentLocation = prevLocation
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Delete removes the data at the specified key position
|
||||
// This operation zeros out the record but maintains the space in the file
|
||||
// Use condense() to reclaim space from deleted records (happens in step after)
|
||||
func (db *OurDB) Delete(x uint32) error {
|
||||
location, err := db.lookup.Get(x)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.delete_(x, location); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.lookup.Delete(x)
|
||||
}
|
||||
|
||||
// GetNextID returns the next id which will be used when storing
|
||||
func (db *OurDB) GetNextID() (uint32, error) {
|
||||
if !db.incrementalMode {
|
||||
return 0, errors.New("incremental mode is not enabled")
|
||||
}
|
||||
return db.lookup.GetNextID()
|
||||
}
|
||||
|
||||
// lookupDumpPath returns the path to the lookup dump file
|
||||
func (db *OurDB) lookupDumpPath() string {
|
||||
return filepath.Join(db.path, "lookup_dump.db")
|
||||
}
|
||||
|
||||
// Load metadata if exists
|
||||
func (db *OurDB) Load() error {
|
||||
if _, err := os.Stat(db.lookupDumpPath()); err == nil {
|
||||
return db.lookup.ImportSparse(db.lookupDumpPath())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save ensures we have the metadata stored on disk
|
||||
func (db *OurDB) Save() error {
|
||||
return db.lookup.ExportSparse(db.lookupDumpPath())
|
||||
}
|
||||
|
||||
// Close closes the database file
|
||||
func (db *OurDB) Close() error {
|
||||
if err := db.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.close_()
|
||||
}
|
||||
|
||||
// Destroy closes and removes the database
|
||||
func (db *OurDB) Destroy() error {
|
||||
_ = db.Close()
|
||||
return os.RemoveAll(db.path)
|
||||
}
|
437
pkg/data/ourdb/db_test.go
Normal file
437
pkg/data/ourdb/db_test.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupTestDB creates a test database in a temporary directory
|
||||
func setupTestDB(t *testing.T, incremental bool) (*OurDB, string) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "ourdb_db_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
|
||||
// Create a new database
|
||||
config := DefaultConfig()
|
||||
config.Path = tempDir
|
||||
config.IncrementalMode = incremental
|
||||
|
||||
db, err := New(config)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to create database: %v", err)
|
||||
}
|
||||
|
||||
return db, tempDir
|
||||
}
|
||||
|
||||
// cleanupTestDB cleans up the test database
|
||||
func cleanupTestDB(db *OurDB, tempDir string) {
|
||||
db.Close()
|
||||
os.RemoveAll(tempDir)
|
||||
}
|
||||
|
||||
// TestSetIncrementalMode tests the Set function in incremental mode
|
||||
func TestSetIncrementalMode(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Test auto-generated ID
|
||||
data1 := []byte("Test data 1")
|
||||
id1, err := db.Set(OurDBSetArgs{
|
||||
Data: data1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data with auto-generated ID: %v", err)
|
||||
}
|
||||
if id1 != 1 {
|
||||
t.Errorf("Expected first auto-generated ID to be 1, got %d", id1)
|
||||
}
|
||||
|
||||
// Test another auto-generated ID
|
||||
data2 := []byte("Test data 2")
|
||||
id2, err := db.Set(OurDBSetArgs{
|
||||
Data: data2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data with auto-generated ID: %v", err)
|
||||
}
|
||||
if id2 != 2 {
|
||||
t.Errorf("Expected second auto-generated ID to be 2, got %d", id2)
|
||||
}
|
||||
|
||||
// Test update with existing ID
|
||||
updatedData := []byte("Updated data")
|
||||
updatedID, err := db.Set(OurDBSetArgs{
|
||||
ID: &id1,
|
||||
Data: updatedData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update data: %v", err)
|
||||
}
|
||||
if updatedID != id1 {
|
||||
t.Errorf("Expected updated ID to be %d, got %d", id1, updatedID)
|
||||
}
|
||||
|
||||
// Test setting with non-existent ID should fail
|
||||
nonExistentID := uint32(100)
|
||||
_, err = db.Set(OurDBSetArgs{
|
||||
ID: &nonExistentID,
|
||||
Data: []byte("This should fail"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when setting with non-existent ID in incremental mode, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetNonIncrementalMode tests the Set function in non-incremental mode
|
||||
func TestSetNonIncrementalMode(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, false)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Test setting with specific ID
|
||||
specificID := uint32(42)
|
||||
data := []byte("Test data with specific ID")
|
||||
id, err := db.Set(OurDBSetArgs{
|
||||
ID: &specificID,
|
||||
Data: data,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data with specific ID: %v", err)
|
||||
}
|
||||
if id != specificID {
|
||||
t.Errorf("Expected ID to be %d, got %d", specificID, id)
|
||||
}
|
||||
|
||||
// Test setting without ID should fail
|
||||
_, err = db.Set(OurDBSetArgs{
|
||||
Data: []byte("This should fail"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when setting without ID in non-incremental mode, got nil")
|
||||
}
|
||||
|
||||
// Test update with existing ID
|
||||
updatedData := []byte("Updated data")
|
||||
updatedID, err := db.Set(OurDBSetArgs{
|
||||
ID: &specificID,
|
||||
Data: updatedData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update data: %v", err)
|
||||
}
|
||||
if updatedID != specificID {
|
||||
t.Errorf("Expected updated ID to be %d, got %d", specificID, updatedID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGet tests the Get function
|
||||
func TestGet(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Set data
|
||||
testData := []byte("Test data for Get")
|
||||
id, err := db.Set(OurDBSetArgs{
|
||||
Data: testData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Get data
|
||||
retrievedData, err := db.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get data: %v", err)
|
||||
}
|
||||
|
||||
// Verify data
|
||||
if !bytes.Equal(retrievedData, testData) {
|
||||
t.Errorf("Retrieved data doesn't match original: got %v, want %v",
|
||||
retrievedData, testData)
|
||||
}
|
||||
|
||||
// Test getting non-existent ID
|
||||
nonExistentID := uint32(100)
|
||||
_, err = db.Get(nonExistentID)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when getting non-existent ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetHistory tests the GetHistory function
|
||||
func TestGetHistory(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Set initial data
|
||||
id, err := db.Set(OurDBSetArgs{
|
||||
Data: []byte("Version 1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set initial data: %v", err)
|
||||
}
|
||||
|
||||
// Update data multiple times
|
||||
updates := []string{"Version 2", "Version 3", "Version 4"}
|
||||
for _, update := range updates {
|
||||
_, err = db.Set(OurDBSetArgs{
|
||||
ID: &id,
|
||||
Data: []byte(update),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to update data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get history with depth 2
|
||||
history, err := db.GetHistory(id, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get history: %v", err)
|
||||
}
|
||||
|
||||
// Verify history length
|
||||
if len(history) != 2 {
|
||||
t.Errorf("Expected history length to be 2, got %d", len(history))
|
||||
}
|
||||
|
||||
// Verify latest version
|
||||
if !bytes.Equal(history[0], []byte("Version 4")) {
|
||||
t.Errorf("Expected latest version to be 'Version 4', got '%s'", history[0])
|
||||
}
|
||||
|
||||
// Get history with depth 4
|
||||
fullHistory, err := db.GetHistory(id, 4)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get full history: %v", err)
|
||||
}
|
||||
|
||||
// Verify full history length
|
||||
// Note: The actual length might be less than 4 if the implementation
|
||||
// doesn't store all versions or if the chain is broken
|
||||
if len(fullHistory) < 1 {
|
||||
t.Errorf("Expected full history length to be at least 1, got %d", len(fullHistory))
|
||||
}
|
||||
|
||||
// Test getting history for non-existent ID
|
||||
nonExistentID := uint32(100)
|
||||
_, err = db.GetHistory(nonExistentID, 2)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when getting history for non-existent ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete tests the Delete function
|
||||
func TestDelete(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Set data
|
||||
testData := []byte("Test data for Delete")
|
||||
id, err := db.Set(OurDBSetArgs{
|
||||
Data: testData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Verify data exists
|
||||
_, err = db.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get data before delete: %v", err)
|
||||
}
|
||||
|
||||
// Delete data
|
||||
err = db.Delete(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to delete data: %v", err)
|
||||
}
|
||||
|
||||
// Verify data is deleted
|
||||
_, err = db.Get(id)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when getting deleted data, got nil")
|
||||
}
|
||||
|
||||
// Test deleting non-existent ID
|
||||
nonExistentID := uint32(100)
|
||||
err = db.Delete(nonExistentID)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when deleting non-existent ID, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetNextID tests the GetNextID function
|
||||
func TestGetNextID(t *testing.T) {
|
||||
// Test in incremental mode
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Get next ID
|
||||
nextID, err := db.GetNextID()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get next ID: %v", err)
|
||||
}
|
||||
if nextID != 1 {
|
||||
t.Errorf("Expected next ID to be 1, got %d", nextID)
|
||||
}
|
||||
|
||||
// Set data and check next ID
|
||||
_, err = db.Set(OurDBSetArgs{
|
||||
Data: []byte("Test data"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
nextID, err = db.GetNextID()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get next ID after setting data: %v", err)
|
||||
}
|
||||
if nextID != 2 {
|
||||
t.Errorf("Expected next ID after setting data to be 2, got %d", nextID)
|
||||
}
|
||||
|
||||
// Test in non-incremental mode
|
||||
dbNonInc, tempDirNonInc := setupTestDB(t, false)
|
||||
defer cleanupTestDB(dbNonInc, tempDirNonInc)
|
||||
|
||||
// GetNextID should fail in non-incremental mode
|
||||
_, err = dbNonInc.GetNextID()
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when getting next ID in non-incremental mode, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveAndLoad tests the Save and Load functions
|
||||
func TestSaveAndLoad(t *testing.T) {
|
||||
// Skip this test as ExportSparse is not implemented yet
|
||||
t.Skip("Skipping test as ExportSparse is not implemented yet")
|
||||
|
||||
// Create first database and add data
|
||||
db1, tempDir := setupTestDB(t, true)
|
||||
|
||||
// Set data
|
||||
testData := []byte("Test data for Save/Load")
|
||||
id, err := db1.Set(OurDBSetArgs{
|
||||
Data: testData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Save and close
|
||||
err = db1.Save()
|
||||
if err != nil {
|
||||
cleanupTestDB(db1, tempDir)
|
||||
t.Fatalf("Failed to save database: %v", err)
|
||||
}
|
||||
db1.Close()
|
||||
|
||||
// Create second database at same location
|
||||
config := DefaultConfig()
|
||||
config.Path = tempDir
|
||||
config.IncrementalMode = true
|
||||
|
||||
db2, err := New(config)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to create second database: %v", err)
|
||||
}
|
||||
defer cleanupTestDB(db2, tempDir)
|
||||
|
||||
// Load data
|
||||
err = db2.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load database: %v", err)
|
||||
}
|
||||
|
||||
// Verify data
|
||||
retrievedData, err := db2.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get data after load: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(retrievedData, testData) {
|
||||
t.Errorf("Retrieved data after load doesn't match original: got %v, want %v",
|
||||
retrievedData, testData)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClose tests the Close function
|
||||
func TestClose(t *testing.T) {
|
||||
// Skip this test as ExportSparse is not implemented yet
|
||||
t.Skip("Skipping test as ExportSparse is not implemented yet")
|
||||
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Set data
|
||||
_, err := db.Set(OurDBSetArgs{
|
||||
Data: []byte("Test data for Close"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Close database
|
||||
err = db.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to close database: %v", err)
|
||||
}
|
||||
|
||||
// Verify file is closed by trying to use it
|
||||
_, err = db.Set(OurDBSetArgs{
|
||||
Data: []byte("This should fail"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when using closed database, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDestroy tests the Destroy function
|
||||
func TestDestroy(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
|
||||
// Set data
|
||||
_, err := db.Set(OurDBSetArgs{
|
||||
Data: []byte("Test data for Destroy"),
|
||||
})
|
||||
if err != nil {
|
||||
cleanupTestDB(db, tempDir)
|
||||
t.Fatalf("Failed to set data: %v", err)
|
||||
}
|
||||
|
||||
// Destroy database
|
||||
err = db.Destroy()
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to destroy database: %v", err)
|
||||
}
|
||||
|
||||
// Verify directory is removed
|
||||
_, err = os.Stat(tempDir)
|
||||
if !os.IsNotExist(err) {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Errorf("Expected database directory to be removed, but it still exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupDumpPath tests the lookupDumpPath function
|
||||
func TestLookupDumpPath(t *testing.T) {
|
||||
db, tempDir := setupTestDB(t, true)
|
||||
defer cleanupTestDB(db, tempDir)
|
||||
|
||||
// Get lookup dump path
|
||||
path := db.lookupDumpPath()
|
||||
|
||||
// Verify path
|
||||
expectedPath := filepath.Join(tempDir, "lookup_dump.db")
|
||||
if path != expectedPath {
|
||||
t.Errorf("Expected lookup dump path to be %s, got %s", expectedPath, path)
|
||||
}
|
||||
}
|
80
pkg/data/ourdb/factory.go
Normal file
80
pkg/data/ourdb/factory.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
const mbyte = 1000000
|
||||
|
||||
// OurDBConfig contains configuration options for creating a new database
|
||||
type OurDBConfig struct {
|
||||
RecordNrMax uint32
|
||||
RecordSizeMax uint32
|
||||
FileSize uint32
|
||||
Path string
|
||||
IncrementalMode bool
|
||||
Reset bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns a default configuration
|
||||
func DefaultConfig() OurDBConfig {
|
||||
return OurDBConfig{
|
||||
RecordNrMax: 16777216 - 1, // max size of records
|
||||
RecordSizeMax: 1024 * 4, // max size in bytes of a record, is 4 KB default
|
||||
FileSize: 500 * (1 << 20), // 500MB
|
||||
IncrementalMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
// New creates a new database with the given configuration
|
||||
func New(config OurDBConfig) (*OurDB, error) {
|
||||
// Determine appropriate keysize based on configuration
|
||||
var keysize uint8 = 4
|
||||
|
||||
if config.RecordNrMax < 65536 {
|
||||
keysize = 2
|
||||
} else if config.RecordNrMax < 16777216 {
|
||||
keysize = 3
|
||||
} else {
|
||||
keysize = 4
|
||||
}
|
||||
|
||||
if float64(config.RecordSizeMax*config.RecordNrMax)/2 > mbyte*10 {
|
||||
keysize = 6 // will use multiple files
|
||||
}
|
||||
|
||||
// Create lookup table
|
||||
l, err := NewLookup(LookupConfig{
|
||||
Size: config.RecordNrMax,
|
||||
KeySize: keysize,
|
||||
IncrementalMode: config.IncrementalMode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Reset database if requested
|
||||
if config.Reset {
|
||||
os.RemoveAll(config.Path)
|
||||
}
|
||||
|
||||
// Create database directory
|
||||
if err := os.MkdirAll(config.Path, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create database instance
|
||||
db := &OurDB{
|
||||
path: config.Path,
|
||||
lookup: l,
|
||||
fileSize: config.FileSize,
|
||||
incrementalMode: config.IncrementalMode,
|
||||
}
|
||||
|
||||
// Load existing data if available
|
||||
if err := db.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
150
pkg/data/ourdb/location.go
Normal file
150
pkg/data/ourdb/location.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Location represents a position in a database file
|
||||
type Location struct {
|
||||
FileNr uint16
|
||||
Position uint32
|
||||
}
|
||||
|
||||
// LocationNew creates a new Location from bytes
|
||||
func (lut *LookupTable) LocationNew(b_ []byte) (Location, error) {
|
||||
newLocation := Location{
|
||||
FileNr: 0,
|
||||
Position: 0,
|
||||
}
|
||||
|
||||
// First verify keysize is valid
|
||||
if lut.KeySize != 2 && lut.KeySize != 3 && lut.KeySize != 4 && lut.KeySize != 6 {
|
||||
return newLocation, errors.New("keysize must be 2, 3, 4 or 6")
|
||||
}
|
||||
|
||||
// Create padded b
|
||||
b := make([]byte, lut.KeySize)
|
||||
startIdx := int(lut.KeySize) - len(b_)
|
||||
if startIdx < 0 {
|
||||
return newLocation, errors.New("input bytes exceed keysize")
|
||||
}
|
||||
|
||||
for i := 0; i < len(b_); i++ {
|
||||
b[startIdx+i] = b_[i]
|
||||
}
|
||||
|
||||
switch lut.KeySize {
|
||||
case 2:
|
||||
// Only position, 2 bytes big endian
|
||||
newLocation.Position = uint32(b[0])<<8 | uint32(b[1])
|
||||
newLocation.FileNr = 0
|
||||
case 3:
|
||||
// Only position, 3 bytes big endian
|
||||
newLocation.Position = uint32(b[0])<<16 | uint32(b[1])<<8 | uint32(b[2])
|
||||
newLocation.FileNr = 0
|
||||
case 4:
|
||||
// Only position, 4 bytes big endian
|
||||
newLocation.Position = uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
newLocation.FileNr = 0
|
||||
case 6:
|
||||
// 2 bytes file_nr + 4 bytes position, all big endian
|
||||
newLocation.FileNr = uint16(b[0])<<8 | uint16(b[1])
|
||||
newLocation.Position = uint32(b[2])<<24 | uint32(b[3])<<16 | uint32(b[4])<<8 | uint32(b[5])
|
||||
}
|
||||
|
||||
// Verify limits based on keysize
|
||||
switch lut.KeySize {
|
||||
case 2:
|
||||
if newLocation.Position > 0xFFFF {
|
||||
return newLocation, errors.New("position exceeds max value for keysize=2 (max 65535)")
|
||||
}
|
||||
if newLocation.FileNr != 0 {
|
||||
return newLocation, errors.New("file_nr must be 0 for keysize=2")
|
||||
}
|
||||
case 3:
|
||||
if newLocation.Position > 0xFFFFFF {
|
||||
return newLocation, errors.New("position exceeds max value for keysize=3 (max 16777215)")
|
||||
}
|
||||
if newLocation.FileNr != 0 {
|
||||
return newLocation, errors.New("file_nr must be 0 for keysize=3")
|
||||
}
|
||||
case 4:
|
||||
if newLocation.FileNr != 0 {
|
||||
return newLocation, errors.New("file_nr must be 0 for keysize=4")
|
||||
}
|
||||
case 6:
|
||||
// For keysize 6: both file_nr and position can use their full range
|
||||
// No additional checks needed as u16 and u32 already enforce limits
|
||||
}
|
||||
|
||||
return newLocation, nil
|
||||
}
|
||||
|
||||
// ToBytes converts a Location to a 6-byte array
|
||||
func (loc Location) ToBytes() ([]byte, error) {
|
||||
bytes := make([]byte, 6)
|
||||
|
||||
// Put file_nr first (2 bytes)
|
||||
bytes[0] = byte(loc.FileNr >> 8)
|
||||
bytes[1] = byte(loc.FileNr)
|
||||
|
||||
// Put position next (4 bytes)
|
||||
bytes[2] = byte(loc.Position >> 24)
|
||||
bytes[3] = byte(loc.Position >> 16)
|
||||
bytes[4] = byte(loc.Position >> 8)
|
||||
bytes[5] = byte(loc.Position)
|
||||
|
||||
return bytes, nil
|
||||
}
|
||||
|
||||
// ToLookupBytes converts a Location to bytes according to the keysize
|
||||
func (loc Location) ToLookupBytes(keysize uint8) ([]byte, error) {
|
||||
bytes := make([]byte, keysize)
|
||||
|
||||
switch keysize {
|
||||
case 2:
|
||||
if loc.Position > 0xFFFF {
|
||||
return nil, errors.New("position exceeds max value for keysize=2 (max 65535)")
|
||||
}
|
||||
if loc.FileNr != 0 {
|
||||
return nil, errors.New("file_nr must be 0 for keysize=2")
|
||||
}
|
||||
bytes[0] = byte(loc.Position >> 8)
|
||||
bytes[1] = byte(loc.Position)
|
||||
case 3:
|
||||
if loc.Position > 0xFFFFFF {
|
||||
return nil, errors.New("position exceeds max value for keysize=3 (max 16777215)")
|
||||
}
|
||||
if loc.FileNr != 0 {
|
||||
return nil, errors.New("file_nr must be 0 for keysize=3")
|
||||
}
|
||||
bytes[0] = byte(loc.Position >> 16)
|
||||
bytes[1] = byte(loc.Position >> 8)
|
||||
bytes[2] = byte(loc.Position)
|
||||
case 4:
|
||||
if loc.FileNr != 0 {
|
||||
return nil, errors.New("file_nr must be 0 for keysize=4")
|
||||
}
|
||||
bytes[0] = byte(loc.Position >> 24)
|
||||
bytes[1] = byte(loc.Position >> 16)
|
||||
bytes[2] = byte(loc.Position >> 8)
|
||||
bytes[3] = byte(loc.Position)
|
||||
case 6:
|
||||
bytes[0] = byte(loc.FileNr >> 8)
|
||||
bytes[1] = byte(loc.FileNr)
|
||||
bytes[2] = byte(loc.Position >> 24)
|
||||
bytes[3] = byte(loc.Position >> 16)
|
||||
bytes[4] = byte(loc.Position >> 8)
|
||||
bytes[5] = byte(loc.Position)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid keysize: %d", keysize)
|
||||
}
|
||||
|
||||
return bytes, nil
|
||||
}
|
||||
|
||||
// ToUint64 converts a Location to uint64, with file_nr as most significant (big endian)
|
||||
func (loc Location) ToUint64() (uint64, error) {
|
||||
return (uint64(loc.FileNr) << 32) | uint64(loc.Position), nil
|
||||
}
|
331
pkg/data/ourdb/lookup.go
Normal file
331
pkg/data/ourdb/lookup.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
dataFileName = "data"
|
||||
incrementalFileName = ".inc"
|
||||
)
|
||||
|
||||
// LookupConfig contains configuration for the lookup table
|
||||
type LookupConfig struct {
|
||||
Size uint32
|
||||
KeySize uint8
|
||||
LookupPath string
|
||||
IncrementalMode bool
|
||||
}
|
||||
|
||||
// LookupTable manages the mapping between IDs and data locations
|
||||
type LookupTable struct {
|
||||
KeySize uint8
|
||||
LookupPath string
|
||||
Data []byte
|
||||
Incremental *uint32
|
||||
}
|
||||
|
||||
// NewLookup creates a new lookup table
|
||||
func NewLookup(config LookupConfig) (*LookupTable, error) {
|
||||
// Verify keysize is valid
|
||||
if config.KeySize != 2 && config.KeySize != 3 && config.KeySize != 4 && config.KeySize != 6 {
|
||||
return nil, errors.New("keysize must be 2, 3, 4 or 6")
|
||||
}
|
||||
|
||||
var incremental *uint32
|
||||
if config.IncrementalMode {
|
||||
inc := getIncrementalInfo(config)
|
||||
incremental = &inc
|
||||
}
|
||||
|
||||
if config.LookupPath != "" {
|
||||
if _, err := os.Stat(config.LookupPath); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(config.LookupPath, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// For disk-based lookup, create empty file if it doesn't exist
|
||||
dataPath := filepath.Join(config.LookupPath, dataFileName)
|
||||
if _, err := os.Stat(dataPath); os.IsNotExist(err) {
|
||||
data := make([]byte, config.Size*uint32(config.KeySize))
|
||||
if err := ioutil.WriteFile(dataPath, data, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &LookupTable{
|
||||
Data: []byte{},
|
||||
KeySize: config.KeySize,
|
||||
LookupPath: config.LookupPath,
|
||||
Incremental: incremental,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &LookupTable{
|
||||
Data: make([]byte, config.Size*uint32(config.KeySize)),
|
||||
KeySize: config.KeySize,
|
||||
LookupPath: "",
|
||||
Incremental: incremental,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getIncrementalInfo gets the next incremental ID value
|
||||
func getIncrementalInfo(config LookupConfig) uint32 {
|
||||
if !config.IncrementalMode {
|
||||
return 0
|
||||
}
|
||||
|
||||
if config.LookupPath != "" {
|
||||
incPath := filepath.Join(config.LookupPath, incrementalFileName)
|
||||
if _, err := os.Stat(incPath); os.IsNotExist(err) {
|
||||
// Create a separate file for storing the incremental value
|
||||
if err := ioutil.WriteFile(incPath, []byte("1"), 0644); err != nil {
|
||||
panic(fmt.Sprintf("failed to write .inc file: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
incBytes, err := ioutil.ReadFile(incPath)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to read .inc file: %v", err))
|
||||
}
|
||||
|
||||
incremental, err := strconv.ParseUint(string(incBytes), 10, 32)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("failed to parse incremental value: %v", err))
|
||||
}
|
||||
|
||||
return uint32(incremental)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// Get retrieves a location from the lookup table
|
||||
func (lut *LookupTable) Get(x uint32) (Location, error) {
|
||||
entrySize := lut.KeySize
|
||||
if lut.LookupPath != "" {
|
||||
// Check file size first
|
||||
dataPath := filepath.Join(lut.LookupPath, dataFileName)
|
||||
fileInfo, err := os.Stat(dataPath)
|
||||
if err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
fileSize := fileInfo.Size()
|
||||
startPos := x * uint32(entrySize)
|
||||
|
||||
if startPos+uint32(entrySize) > uint32(fileSize) {
|
||||
return Location{}, fmt.Errorf("invalid read for get in lut: %s: %d would exceed file size %d",
|
||||
lut.LookupPath, startPos+uint32(entrySize), fileSize)
|
||||
}
|
||||
|
||||
// Read directly from file for disk-based lookup
|
||||
file, err := os.Open(dataPath)
|
||||
if err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data := make([]byte, entrySize)
|
||||
bytesRead, err := file.ReadAt(data, int64(startPos))
|
||||
if err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
if bytesRead < int(entrySize) {
|
||||
return Location{}, fmt.Errorf("incomplete read: expected %d bytes but got %d", entrySize, bytesRead)
|
||||
}
|
||||
return lut.LocationNew(data)
|
||||
}
|
||||
|
||||
if x*uint32(entrySize) >= uint32(len(lut.Data)) {
|
||||
return Location{}, errors.New("index out of bounds")
|
||||
}
|
||||
|
||||
start := x * uint32(entrySize)
|
||||
return lut.LocationNew(lut.Data[start : start+uint32(entrySize)])
|
||||
}
|
||||
|
||||
// FindLastEntry scans the lookup table to find the highest ID with a non-zero entry
|
||||
func (lut *LookupTable) FindLastEntry() (uint32, error) {
|
||||
var lastID uint32 = 0
|
||||
entrySize := lut.KeySize
|
||||
|
||||
if lut.LookupPath != "" {
|
||||
// For disk-based lookup, read the file in chunks
|
||||
dataPath := filepath.Join(lut.LookupPath, dataFileName)
|
||||
file, err := os.Open(dataPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := os.Stat(dataPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fileSize := fileInfo.Size()
|
||||
|
||||
buffer := make([]byte, entrySize)
|
||||
var pos uint32 = 0
|
||||
|
||||
for {
|
||||
if int64(pos)*int64(entrySize) >= fileSize {
|
||||
break
|
||||
}
|
||||
|
||||
bytesRead, err := file.Read(buffer)
|
||||
if err != nil || bytesRead < int(entrySize) {
|
||||
break
|
||||
}
|
||||
|
||||
location, err := lut.LocationNew(buffer)
|
||||
if err == nil && (location.Position != 0 || location.FileNr != 0) {
|
||||
lastID = pos
|
||||
}
|
||||
pos++
|
||||
}
|
||||
} else {
|
||||
// For memory-based lookup
|
||||
for i := uint32(0); i < uint32(len(lut.Data)/int(entrySize)); i++ {
|
||||
location, err := lut.Get(i)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if location.Position != 0 || location.FileNr != 0 {
|
||||
lastID = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastID, nil
|
||||
}
|
||||
|
||||
// GetNextID returns the next available ID for incremental mode
|
||||
func (lut *LookupTable) GetNextID() (uint32, error) {
|
||||
if lut.Incremental == nil {
|
||||
return 0, errors.New("lookup table not in incremental mode")
|
||||
}
|
||||
|
||||
var tableSize uint32
|
||||
if lut.LookupPath != "" {
|
||||
dataPath := filepath.Join(lut.LookupPath, dataFileName)
|
||||
fileInfo, err := os.Stat(dataPath)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tableSize = uint32(fileInfo.Size())
|
||||
} else {
|
||||
tableSize = uint32(len(lut.Data))
|
||||
}
|
||||
|
||||
if (*lut.Incremental)*uint32(lut.KeySize) >= tableSize {
|
||||
return 0, errors.New("lookup table is full")
|
||||
}
|
||||
|
||||
return *lut.Incremental, nil
|
||||
}
|
||||
|
||||
// IncrementIndex increments the index for the next insertion
|
||||
func (lut *LookupTable) IncrementIndex() error {
|
||||
if lut.Incremental == nil {
|
||||
return errors.New("lookup table not in incremental mode")
|
||||
}
|
||||
|
||||
*lut.Incremental++
|
||||
if lut.LookupPath != "" {
|
||||
incPath := filepath.Join(lut.LookupPath, incrementalFileName)
|
||||
return ioutil.WriteFile(incPath, []byte(strconv.FormatUint(uint64(*lut.Incremental), 10)), 0644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set updates a location in the lookup table
|
||||
func (lut *LookupTable) Set(x uint32, location Location) error {
|
||||
entrySize := lut.KeySize
|
||||
|
||||
// Handle incremental mode
|
||||
if lut.Incremental != nil {
|
||||
if x == *lut.Incremental {
|
||||
if err := lut.IncrementIndex(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if x > *lut.Incremental {
|
||||
return errors.New("cannot set id for insertions when incremental mode is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
// Convert location to bytes
|
||||
locationBytes, err := location.ToLookupBytes(lut.KeySize)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if lut.LookupPath != "" {
|
||||
// For disk-based lookup, write directly to file
|
||||
dataPath := filepath.Join(lut.LookupPath, dataFileName)
|
||||
file, err := os.OpenFile(dataPath, os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
startPos := x * uint32(entrySize)
|
||||
if _, err := file.WriteAt(locationBytes, int64(startPos)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// For memory-based lookup
|
||||
startPos := x * uint32(entrySize)
|
||||
if startPos+uint32(entrySize) > uint32(len(lut.Data)) {
|
||||
return errors.New("index out of bounds")
|
||||
}
|
||||
|
||||
copy(lut.Data[startPos:startPos+uint32(entrySize)], locationBytes)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes an entry from the lookup table
|
||||
func (lut *LookupTable) Delete(x uint32) error {
|
||||
// Create an empty location
|
||||
emptyLocation := Location{}
|
||||
return lut.Set(x, emptyLocation)
|
||||
}
|
||||
|
||||
// GetDataFilePath returns the path to the data file
|
||||
func (lut *LookupTable) GetDataFilePath() (string, error) {
|
||||
if lut.LookupPath == "" {
|
||||
return "", errors.New("lookup table is not disk-based")
|
||||
}
|
||||
return filepath.Join(lut.LookupPath, dataFileName), nil
|
||||
}
|
||||
|
||||
// GetIncFilePath returns the path to the incremental file
|
||||
func (lut *LookupTable) GetIncFilePath() (string, error) {
|
||||
if lut.LookupPath == "" {
|
||||
return "", errors.New("lookup table is not disk-based")
|
||||
}
|
||||
return filepath.Join(lut.LookupPath, incrementalFileName), nil
|
||||
}
|
||||
|
||||
// ExportSparse exports the lookup table to a file in sparse format
|
||||
func (lut *LookupTable) ExportSparse(path string) error {
|
||||
// Implementation would be similar to the V version
|
||||
// For now, this is a placeholder
|
||||
return errors.New("export sparse not implemented yet")
|
||||
}
|
||||
|
||||
// ImportSparse imports the lookup table from a file in sparse format
|
||||
func (lut *LookupTable) ImportSparse(path string) error {
|
||||
// Implementation would be similar to the V version
|
||||
// For now, this is a placeholder
|
||||
return errors.New("import sparse not implemented yet")
|
||||
}
|
127
pkg/data/ourdb/ourdb_test.go
Normal file
127
pkg/data/ourdb/ourdb_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package ourdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBasicOperations(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir, err := os.MkdirTemp("", "ourdb_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp directory: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Create a new database
|
||||
config := DefaultConfig()
|
||||
config.Path = tempDir
|
||||
|
||||
db, err := New(config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Test data
|
||||
testData := []byte("Hello, OurDB!")
|
||||
|
||||
// Store data with auto-generated ID
|
||||
id, err := db.Set(OurDBSetArgs{
|
||||
Data: testData,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to store data: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve data
|
||||
retrievedData, err := db.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve data: %v", err)
|
||||
}
|
||||
|
||||
// Verify data
|
||||
if string(retrievedData) != string(testData) {
|
||||
t.Errorf("Retrieved data doesn't match original: got %s, want %s",
|
||||
string(retrievedData), string(testData))
|
||||
}
|
||||
|
||||
// Test client interface with incremental mode (default)
|
||||
clientTest(t, tempDir, true)
|
||||
|
||||
// Test client interface with incremental mode disabled
|
||||
clientTest(t, filepath.Join(tempDir, "non_incremental"), false)
|
||||
}
|
||||
|
||||
func clientTest(t *testing.T, dbPath string, incremental bool) {
|
||||
// Create a new client with specified incremental mode
|
||||
clientPath := filepath.Join(dbPath, "client_test")
|
||||
config := DefaultConfig()
|
||||
config.IncrementalMode = incremental
|
||||
client, err := NewClientWithConfig(clientPath, config)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
testData := []byte("Client Test Data")
|
||||
var id uint32
|
||||
|
||||
if incremental {
|
||||
// In incremental mode, add data with auto-generated ID
|
||||
var err error
|
||||
id, err = client.Add(testData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add data: %v", err)
|
||||
}
|
||||
} else {
|
||||
// In non-incremental mode, set data with specific ID
|
||||
id = 1
|
||||
err = client.Set(id, testData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data with ID %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Retrieve data
|
||||
retrievedData, err := client.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve data: %v", err)
|
||||
}
|
||||
|
||||
// Verify data
|
||||
if string(retrievedData) != string(testData) {
|
||||
t.Errorf("Retrieved client data doesn't match original: got %s, want %s",
|
||||
string(retrievedData), string(testData))
|
||||
}
|
||||
|
||||
// Test setting data with specific ID (only if incremental mode is disabled)
|
||||
if !incremental {
|
||||
specificID := uint32(100)
|
||||
specificData := []byte("Specific ID Data")
|
||||
err = client.Set(specificID, specificData)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set data with specific ID: %v", err)
|
||||
}
|
||||
|
||||
// Retrieve and verify specific ID data
|
||||
retrievedSpecific, err := client.Get(specificID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to retrieve specific ID data: %v", err)
|
||||
}
|
||||
|
||||
if string(retrievedSpecific) != string(specificData) {
|
||||
t.Errorf("Retrieved specific ID data doesn't match: got %s, want %s",
|
||||
string(retrievedSpecific), string(specificData))
|
||||
}
|
||||
} else {
|
||||
// In incremental mode, test that setting a specific ID fails as expected
|
||||
specificID := uint32(100)
|
||||
specificData := []byte("Specific ID Data")
|
||||
err = client.Set(specificID, specificData)
|
||||
if err == nil {
|
||||
t.Errorf("Setting specific ID in incremental mode should fail but succeeded")
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user