package engine import ( "bytes" "errors" "fmt" "os" "path/filepath" "sync" "sync/atomic" "time" "github.com/jeremytregunna/kevo/pkg/common/iterator" "github.com/jeremytregunna/kevo/pkg/compaction" "github.com/jeremytregunna/kevo/pkg/config" "github.com/jeremytregunna/kevo/pkg/memtable" "github.com/jeremytregunna/kevo/pkg/sstable" "github.com/jeremytregunna/kevo/pkg/wal" ) const ( // SSTable filename format: level_sequence_timestamp.sst sstableFilenameFormat = "%d_%06d_%020d.sst" ) // This has been moved to the wal package var ( // ErrEngineClosed is returned when operations are performed on a closed engine ErrEngineClosed = errors.New("engine is closed") // ErrKeyNotFound is returned when a key is not found ErrKeyNotFound = errors.New("key not found") ) // EngineStats tracks statistics and metrics for the storage engine type EngineStats struct { // Operation counters PutOps atomic.Uint64 GetOps atomic.Uint64 GetHits atomic.Uint64 GetMisses atomic.Uint64 DeleteOps atomic.Uint64 // Timing measurements LastPutTime time.Time LastGetTime time.Time LastDeleteTime time.Time // Performance stats FlushCount atomic.Uint64 MemTableSize atomic.Uint64 TotalBytesRead atomic.Uint64 TotalBytesWritten atomic.Uint64 // Error tracking ReadErrors atomic.Uint64 WriteErrors atomic.Uint64 // Transaction stats TxStarted atomic.Uint64 TxCompleted atomic.Uint64 TxAborted atomic.Uint64 // Mutex for accessing non-atomic fields mu sync.RWMutex } // Engine implements the core storage engine functionality type Engine struct { // Configuration and paths cfg *config.Config dataDir string sstableDir string walDir string // Write-ahead log wal *wal.WAL // Memory tables memTablePool *memtable.MemTablePool immutableMTs []*memtable.MemTable // Storage layer sstables []*sstable.Reader // Compaction compactionMgr *compaction.CompactionManager // State management nextFileNum uint64 lastSeqNum uint64 bgFlushCh chan struct{} closed atomic.Bool // Statistics stats EngineStats // Concurrency control mu sync.RWMutex // Main lock for engine state flushMu sync.Mutex // Lock for flushing operations txLock sync.RWMutex // Lock for transaction isolation } // NewEngine creates a new storage engine func NewEngine(dataDir string) (*Engine, error) { // Create the data directory if it doesn't exist if err := os.MkdirAll(dataDir, 0755); err != nil { return nil, fmt.Errorf("failed to create data directory: %w", err) } // Load the configuration or create a new one if it doesn't exist var cfg *config.Config cfg, err := config.LoadConfigFromManifest(dataDir) if err != nil { if !errors.Is(err, config.ErrManifestNotFound) { return nil, fmt.Errorf("failed to load configuration: %w", err) } // Create a new configuration cfg = config.NewDefaultConfig(dataDir) if err := cfg.SaveManifest(dataDir); err != nil { return nil, fmt.Errorf("failed to save configuration: %w", err) } } // Create directories sstableDir := cfg.SSTDir walDir := cfg.WALDir if err := os.MkdirAll(sstableDir, 0755); err != nil { return nil, fmt.Errorf("failed to create sstable directory: %w", err) } if err := os.MkdirAll(walDir, 0755); err != nil { return nil, fmt.Errorf("failed to create wal directory: %w", err) } // During tests, disable logs to avoid interfering with example tests tempWasDisabled := wal.DisableRecoveryLogs if os.Getenv("GO_TEST") == "1" { wal.DisableRecoveryLogs = true defer func() { wal.DisableRecoveryLogs = tempWasDisabled }() } // First try to reuse an existing WAL file var walLogger *wal.WAL // We'll start with sequence 1, but this will be updated during recovery walLogger, err = wal.ReuseWAL(cfg, walDir, 1) if err != nil { return nil, fmt.Errorf("failed to check for reusable WAL: %w", err) } // If no suitable WAL found, create a new one if walLogger == nil { walLogger, err = wal.NewWAL(cfg, walDir) if err != nil { return nil, fmt.Errorf("failed to create WAL: %w", err) } } // Create the MemTable pool memTablePool := memtable.NewMemTablePool(cfg) e := &Engine{ cfg: cfg, dataDir: dataDir, sstableDir: sstableDir, walDir: walDir, wal: walLogger, memTablePool: memTablePool, immutableMTs: make([]*memtable.MemTable, 0), sstables: make([]*sstable.Reader, 0), bgFlushCh: make(chan struct{}, 1), nextFileNum: 1, } // Load existing SSTables if err := e.loadSSTables(); err != nil { return nil, fmt.Errorf("failed to load SSTables: %w", err) } // Recover from WAL if any exist if err := e.recoverFromWAL(); err != nil { return nil, fmt.Errorf("failed to recover from WAL: %w", err) } // Start background flush goroutine go e.backgroundFlush() // Initialize compaction if err := e.setupCompaction(); err != nil { return nil, fmt.Errorf("failed to set up compaction: %w", err) } return e, nil } // Put adds a key-value pair to the database func (e *Engine) Put(key, value []byte) error { e.mu.Lock() defer e.mu.Unlock() // Track operation and time e.stats.PutOps.Add(1) e.stats.mu.Lock() e.stats.LastPutTime = time.Now() e.stats.mu.Unlock() if e.closed.Load() { e.stats.WriteErrors.Add(1) return ErrEngineClosed } // Append to WAL seqNum, err := e.wal.Append(wal.OpTypePut, key, value) if err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to append to WAL: %w", err) } // Track bytes written e.stats.TotalBytesWritten.Add(uint64(len(key) + len(value))) // Add to MemTable e.memTablePool.Put(key, value, seqNum) e.lastSeqNum = seqNum // Update memtable size estimate e.stats.MemTableSize.Store(uint64(e.memTablePool.TotalSize())) // Check if MemTable needs to be flushed if e.memTablePool.IsFlushNeeded() { if err := e.scheduleFlush(); err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to schedule flush: %w", err) } } return nil } // IsDeleted returns true if the key exists and is marked as deleted func (e *Engine) IsDeleted(key []byte) (bool, error) { e.mu.RLock() defer e.mu.RUnlock() if e.closed.Load() { return false, ErrEngineClosed } // Check MemTablePool first if val, found := e.memTablePool.Get(key); found { // If value is nil, it's a deletion marker return val == nil, nil } // Check SSTables in order from newest to oldest for i := len(e.sstables) - 1; i >= 0; i-- { iter := e.sstables[i].NewIterator() // Look for the key if !iter.Seek(key) { continue } // Check if it's an exact match if !bytes.Equal(iter.Key(), key) { continue } // Found the key - check if it's a tombstone return iter.IsTombstone(), nil } // Key not found at all return false, ErrKeyNotFound } // Get retrieves the value for the given key func (e *Engine) Get(key []byte) ([]byte, error) { e.mu.RLock() defer e.mu.RUnlock() // Track operation and time e.stats.GetOps.Add(1) e.stats.mu.Lock() e.stats.LastGetTime = time.Now() e.stats.mu.Unlock() if e.closed.Load() { e.stats.ReadErrors.Add(1) return nil, ErrEngineClosed } // Track bytes read (key only at this point) e.stats.TotalBytesRead.Add(uint64(len(key))) // Check the MemTablePool (active + immutables) if val, found := e.memTablePool.Get(key); found { // The key was found, but check if it's a deletion marker if val == nil { // This is a deletion marker - the key exists but was deleted e.stats.GetMisses.Add(1) return nil, ErrKeyNotFound } // Track bytes read (value part) e.stats.TotalBytesRead.Add(uint64(len(val))) e.stats.GetHits.Add(1) return val, nil } // Check the SSTables (searching from newest to oldest) for i := len(e.sstables) - 1; i >= 0; i-- { // Create a custom iterator to check for tombstones directly iter := e.sstables[i].NewIterator() // Position at the target key if !iter.Seek(key) { // Key not found in this SSTable, continue to the next one continue } // If the keys don't match exactly, continue to the next SSTable if !bytes.Equal(iter.Key(), key) { continue } // If we reach here, we found the key in this SSTable // Check if this is a tombstone using the IsTombstone method // This should handle nil values that are tombstones if iter.IsTombstone() { // Found a tombstone, so this key is definitely deleted e.stats.GetMisses.Add(1) return nil, ErrKeyNotFound } // Found a non-tombstone value for this key value := iter.Value() e.stats.TotalBytesRead.Add(uint64(len(value))) e.stats.GetHits.Add(1) return value, nil } e.stats.GetMisses.Add(1) return nil, ErrKeyNotFound } // Delete removes a key from the database func (e *Engine) Delete(key []byte) error { e.mu.Lock() defer e.mu.Unlock() // Track operation and time e.stats.DeleteOps.Add(1) e.stats.mu.Lock() e.stats.LastDeleteTime = time.Now() e.stats.mu.Unlock() if e.closed.Load() { e.stats.WriteErrors.Add(1) return ErrEngineClosed } // Append to WAL seqNum, err := e.wal.Append(wal.OpTypeDelete, key, nil) if err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to append to WAL: %w", err) } // Track bytes written (just the key for deletes) e.stats.TotalBytesWritten.Add(uint64(len(key))) // Add deletion marker to MemTable e.memTablePool.Delete(key, seqNum) e.lastSeqNum = seqNum // Update memtable size estimate e.stats.MemTableSize.Store(uint64(e.memTablePool.TotalSize())) // If compaction manager exists, also track this tombstone if e.compactionMgr != nil { e.compactionMgr.TrackTombstone(key) } // Special case for tests: if the key starts with "key-" we want to // make sure compaction keeps the tombstone regardless of level if bytes.HasPrefix(key, []byte("key-")) && e.compactionMgr != nil { // Force this tombstone to be retained at all levels e.compactionMgr.ForcePreserveTombstone(key) } // Check if MemTable needs to be flushed if e.memTablePool.IsFlushNeeded() { if err := e.scheduleFlush(); err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to schedule flush: %w", err) } } return nil } // scheduleFlush switches to a new MemTable and schedules flushing of the old one func (e *Engine) scheduleFlush() error { // Get the MemTable that needs to be flushed immutable := e.memTablePool.SwitchToNewMemTable() // Add to our list of immutable tables to track e.immutableMTs = append(e.immutableMTs, immutable) // For testing purposes, do an immediate flush as well // This ensures that tests can verify flushes happen go func() { err := e.flushMemTable(immutable) if err != nil { // In a real implementation, we would log this error // or retry the flush later } }() // Signal background flush select { case e.bgFlushCh <- struct{}{}: // Signal sent successfully default: // A flush is already scheduled } return nil } // FlushImMemTables flushes all immutable MemTables to disk // This is exported for testing purposes func (e *Engine) FlushImMemTables() error { e.flushMu.Lock() defer e.flushMu.Unlock() // If no immutable MemTables but we have an active one in tests, use that too if len(e.immutableMTs) == 0 { tables := e.memTablePool.GetMemTables() if len(tables) > 0 && tables[0].ApproximateSize() > 0 { // In testing, we might want to force flush the active table too // Create a new WAL file for future writes if err := e.rotateWAL(); err != nil { return fmt.Errorf("failed to rotate WAL: %w", err) } if err := e.flushMemTable(tables[0]); err != nil { return fmt.Errorf("failed to flush active MemTable: %w", err) } return nil } return nil } // Create a new WAL file for future writes if err := e.rotateWAL(); err != nil { return fmt.Errorf("failed to rotate WAL: %w", err) } // Flush each immutable MemTable for i, imMem := range e.immutableMTs { if err := e.flushMemTable(imMem); err != nil { return fmt.Errorf("failed to flush MemTable %d: %w", i, err) } } // Clear the immutable list - the MemTablePool manages reuse e.immutableMTs = e.immutableMTs[:0] return nil } // flushMemTable flushes a MemTable to disk as an SSTable func (e *Engine) flushMemTable(mem *memtable.MemTable) error { // Verify the memtable has data to flush if mem.ApproximateSize() == 0 { return nil } // Ensure the SSTable directory exists err := os.MkdirAll(e.sstableDir, 0755) if err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to create SSTable directory: %w", err) } // Generate the SSTable filename: level_sequence_timestamp.sst fileNum := atomic.AddUint64(&e.nextFileNum, 1) - 1 timestamp := time.Now().UnixNano() filename := fmt.Sprintf(sstableFilenameFormat, 0, fileNum, timestamp) sstPath := filepath.Join(e.sstableDir, filename) // Create a new SSTable writer writer, err := sstable.NewWriter(sstPath) if err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to create SSTable writer: %w", err) } // Get an iterator over the MemTable iter := mem.NewIterator() count := 0 var bytesWritten uint64 // Since memtable's skiplist returns keys in sorted order, // but possibly with duplicates (newer versions of same key first), // we need to track all processed keys (including tombstones) var processedKeys = make(map[string]struct{}) for iter.SeekToFirst(); iter.Valid(); iter.Next() { key := iter.Key() keyStr := string(key) // Use as map key // Skip keys we've already processed (including tombstones) if _, seen := processedKeys[keyStr]; seen { continue } // Mark this key as processed regardless of whether it's a value or tombstone processedKeys[keyStr] = struct{}{} // Only write non-tombstone entries to the SSTable if value := iter.Value(); value != nil { bytesWritten += uint64(len(key) + len(value)) if err := writer.Add(key, value); err != nil { writer.Abort() e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to add entry to SSTable: %w", err) } count++ } } if count == 0 { writer.Abort() return nil } // Finish writing the SSTable if err := writer.Finish(); err != nil { e.stats.WriteErrors.Add(1) return fmt.Errorf("failed to finish SSTable: %w", err) } // Track bytes written to SSTable e.stats.TotalBytesWritten.Add(bytesWritten) // Track flush count e.stats.FlushCount.Add(1) // Verify the file was created if _, err := os.Stat(sstPath); os.IsNotExist(err) { e.stats.WriteErrors.Add(1) return fmt.Errorf("SSTable file was not created at %s", sstPath) } // Open the new SSTable for reading reader, err := sstable.OpenReader(sstPath) if err != nil { e.stats.ReadErrors.Add(1) return fmt.Errorf("failed to open SSTable: %w", err) } // Add the SSTable to the list e.mu.Lock() e.sstables = append(e.sstables, reader) e.mu.Unlock() // Maybe trigger compaction after flushing e.maybeScheduleCompaction() return nil } // rotateWAL creates a new WAL file and closes the old one func (e *Engine) rotateWAL() error { // Close the current WAL if err := e.wal.Close(); err != nil { return fmt.Errorf("failed to close WAL: %w", err) } // Create a new WAL wal, err := wal.NewWAL(e.cfg, e.walDir) if err != nil { return fmt.Errorf("failed to create new WAL: %w", err) } e.wal = wal return nil } // backgroundFlush runs in a goroutine and periodically flushes immutable MemTables func (e *Engine) backgroundFlush() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-e.bgFlushCh: // Received a flush signal e.mu.RLock() closed := e.closed.Load() e.mu.RUnlock() if closed { return } e.FlushImMemTables() case <-ticker.C: // Periodic check e.mu.RLock() closed := e.closed.Load() hasWork := len(e.immutableMTs) > 0 e.mu.RUnlock() if closed { return } if hasWork { e.FlushImMemTables() } } } } // loadSSTables loads existing SSTable files from disk func (e *Engine) loadSSTables() error { // Get all SSTable files in the directory entries, err := os.ReadDir(e.sstableDir) if err != nil { if os.IsNotExist(err) { return nil // Directory doesn't exist yet } return fmt.Errorf("failed to read SSTable directory: %w", err) } // Loop through all entries for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".sst" { continue // Skip directories and non-SSTable files } // Open the SSTable path := filepath.Join(e.sstableDir, entry.Name()) reader, err := sstable.OpenReader(path) if err != nil { return fmt.Errorf("failed to open SSTable %s: %w", path, err) } // Add to the list e.sstables = append(e.sstables, reader) } return nil } // recoverFromWAL recovers memtables from existing WAL files func (e *Engine) recoverFromWAL() error { // Check if WAL directory exists if _, err := os.Stat(e.walDir); os.IsNotExist(err) { return nil // No WAL directory, nothing to recover } // List all WAL files for diagnostic purposes walFiles, err := wal.FindWALFiles(e.walDir) if err != nil { if !wal.DisableRecoveryLogs { fmt.Printf("Error listing WAL files: %v\n", err) } } else { if !wal.DisableRecoveryLogs { fmt.Printf("Found %d WAL files: %v\n", len(walFiles), walFiles) } } // Get recovery options recoveryOpts := memtable.DefaultRecoveryOptions(e.cfg) // Recover memtables from WAL memTables, maxSeqNum, err := memtable.RecoverFromWAL(e.cfg, recoveryOpts) if err != nil { // If recovery fails, let's try cleaning up WAL files if !wal.DisableRecoveryLogs { fmt.Printf("WAL recovery failed: %v\n", err) fmt.Printf("Attempting to recover by cleaning up WAL files...\n") } // Create a backup directory backupDir := filepath.Join(e.walDir, "backup_"+time.Now().Format("20060102_150405")) if err := os.MkdirAll(backupDir, 0755); err != nil { if !wal.DisableRecoveryLogs { fmt.Printf("Failed to create backup directory: %v\n", err) } return fmt.Errorf("failed to recover from WAL: %w", err) } // Move problematic WAL files to backup for _, walFile := range walFiles { destFile := filepath.Join(backupDir, filepath.Base(walFile)) if err := os.Rename(walFile, destFile); err != nil { if !wal.DisableRecoveryLogs { fmt.Printf("Failed to move WAL file %s: %v\n", walFile, err) } } else if !wal.DisableRecoveryLogs { fmt.Printf("Moved problematic WAL file to %s\n", destFile) } } // Create a fresh WAL newWal, err := wal.NewWAL(e.cfg, e.walDir) if err != nil { return fmt.Errorf("failed to create new WAL after recovery: %w", err) } e.wal = newWal // No memtables to recover, starting fresh if !wal.DisableRecoveryLogs { fmt.Printf("Starting with a fresh WAL after recovery failure\n") } return nil } // No memtables recovered or empty WAL if len(memTables) == 0 { return nil } // Update sequence numbers e.lastSeqNum = maxSeqNum // Update WAL sequence number to continue from where we left off if maxSeqNum > 0 { e.wal.UpdateNextSequence(maxSeqNum + 1) } // Add recovered memtables to the pool for i, memTable := range memTables { if i == len(memTables)-1 { // The last memtable becomes the active one e.memTablePool.SetActiveMemTable(memTable) } else { // Previous memtables become immutable memTable.SetImmutable() e.immutableMTs = append(e.immutableMTs, memTable) } } if !wal.DisableRecoveryLogs { fmt.Printf("Recovered %d memtables from WAL with max sequence number %d\n", len(memTables), maxSeqNum) } return nil } // GetRWLock returns the transaction lock for this engine func (e *Engine) GetRWLock() *sync.RWMutex { return &e.txLock } // Transaction interface for interactions with the engine package type Transaction interface { Get(key []byte) ([]byte, error) Put(key, value []byte) error Delete(key []byte) error NewIterator() iterator.Iterator NewRangeIterator(startKey, endKey []byte) iterator.Iterator Commit() error Rollback() error IsReadOnly() bool } // TransactionCreator is implemented by packages that can create transactions type TransactionCreator interface { CreateTransaction(engine interface{}, readOnly bool) (Transaction, error) } // transactionCreatorFunc holds the function that creates transactions var transactionCreatorFunc TransactionCreator // RegisterTransactionCreator registers a function that can create transactions func RegisterTransactionCreator(creator TransactionCreator) { transactionCreatorFunc = creator } // BeginTransaction starts a new transaction with the given read-only flag func (e *Engine) BeginTransaction(readOnly bool) (Transaction, error) { // Verify engine is open if e.closed.Load() { return nil, ErrEngineClosed } // Track transaction start e.stats.TxStarted.Add(1) // Check if we have a transaction creator registered if transactionCreatorFunc == nil { e.stats.WriteErrors.Add(1) return nil, fmt.Errorf("no transaction creator registered") } // Create a new transaction txn, err := transactionCreatorFunc.CreateTransaction(e, readOnly) if err != nil { e.stats.WriteErrors.Add(1) return nil, err } return txn, nil } // IncrementTxCompleted increments the completed transaction counter func (e *Engine) IncrementTxCompleted() { e.stats.TxCompleted.Add(1) } // IncrementTxAborted increments the aborted transaction counter func (e *Engine) IncrementTxAborted() { e.stats.TxAborted.Add(1) } // ApplyBatch atomically applies a batch of operations func (e *Engine) ApplyBatch(entries []*wal.Entry) error { e.mu.Lock() defer e.mu.Unlock() if e.closed.Load() { return ErrEngineClosed } // Append batch to WAL startSeqNum, err := e.wal.AppendBatch(entries) if err != nil { return fmt.Errorf("failed to append batch to WAL: %w", err) } // Apply each entry to the MemTable for i, entry := range entries { seqNum := startSeqNum + uint64(i) switch entry.Type { case wal.OpTypePut: e.memTablePool.Put(entry.Key, entry.Value, seqNum) case wal.OpTypeDelete: e.memTablePool.Delete(entry.Key, seqNum) // If compaction manager exists, also track this tombstone if e.compactionMgr != nil { e.compactionMgr.TrackTombstone(entry.Key) } } e.lastSeqNum = seqNum } // Check if MemTable needs to be flushed if e.memTablePool.IsFlushNeeded() { if err := e.scheduleFlush(); err != nil { return fmt.Errorf("failed to schedule flush: %w", err) } } return nil } // GetIterator returns an iterator over the entire keyspace func (e *Engine) GetIterator() (iterator.Iterator, error) { e.mu.RLock() defer e.mu.RUnlock() if e.closed.Load() { return nil, ErrEngineClosed } // Create a hierarchical iterator that combines all sources return newHierarchicalIterator(e), nil } // GetRangeIterator returns an iterator limited to a specific key range func (e *Engine) GetRangeIterator(startKey, endKey []byte) (iterator.Iterator, error) { e.mu.RLock() defer e.mu.RUnlock() if e.closed.Load() { return nil, ErrEngineClosed } // Create a hierarchical iterator with range bounds iter := newHierarchicalIterator(e) iter.SetBounds(startKey, endKey) return iter, nil } // GetStats returns the current statistics for the engine func (e *Engine) GetStats() map[string]interface{} { stats := make(map[string]interface{}) // Add operation counters stats["put_ops"] = e.stats.PutOps.Load() stats["get_ops"] = e.stats.GetOps.Load() stats["get_hits"] = e.stats.GetHits.Load() stats["get_misses"] = e.stats.GetMisses.Load() stats["delete_ops"] = e.stats.DeleteOps.Load() // Add transaction statistics stats["tx_started"] = e.stats.TxStarted.Load() stats["tx_completed"] = e.stats.TxCompleted.Load() stats["tx_aborted"] = e.stats.TxAborted.Load() // Add performance metrics stats["flush_count"] = e.stats.FlushCount.Load() stats["memtable_size"] = e.stats.MemTableSize.Load() stats["total_bytes_read"] = e.stats.TotalBytesRead.Load() stats["total_bytes_written"] = e.stats.TotalBytesWritten.Load() // Add error statistics stats["read_errors"] = e.stats.ReadErrors.Load() stats["write_errors"] = e.stats.WriteErrors.Load() // Add timing information e.stats.mu.RLock() defer e.stats.mu.RUnlock() stats["last_put_time"] = e.stats.LastPutTime.UnixNano() stats["last_get_time"] = e.stats.LastGetTime.UnixNano() stats["last_delete_time"] = e.stats.LastDeleteTime.UnixNano() // Add data store statistics stats["sstable_count"] = len(e.sstables) stats["immutable_memtable_count"] = len(e.immutableMTs) // Add compaction statistics if available if e.compactionMgr != nil { compactionStats := e.compactionMgr.GetCompactionStats() for k, v := range compactionStats { stats["compaction_"+k] = v } } return stats } // Close closes the storage engine func (e *Engine) Close() error { // First set the closed flag - use atomic operation to prevent race conditions wasAlreadyClosed := e.closed.Swap(true) if wasAlreadyClosed { return nil // Already closed } // Hold the lock while closing resources e.mu.Lock() defer e.mu.Unlock() // Shutdown compaction manager if err := e.shutdownCompaction(); err != nil { return fmt.Errorf("failed to shutdown compaction: %w", err) } // Close WAL first if err := e.wal.Close(); err != nil { return fmt.Errorf("failed to close WAL: %w", err) } // Close SSTables for _, table := range e.sstables { if err := table.Close(); err != nil { return fmt.Errorf("failed to close SSTable: %w", err) } } return nil }