//go:build !windows package storage import ( "context" "sync" "sync/atomic" "time" "lol.mleku.dev/log" "next.orly.dev/pkg/database/indexes/types" "git.mleku.dev/mleku/nostr/encoders/event" ) // GCDatabase defines the interface for database operations needed by the GC. type GCDatabase interface { Path() string FetchEventBySerial(ser *types.Uint40) (ev *event.E, err error) DeleteEventBySerial(ctx context.Context, ser *types.Uint40, ev *event.E) error } // GarbageCollector manages continuous event eviction based on access patterns. // It monitors storage usage and evicts the least accessed events when the // storage limit is exceeded. type GarbageCollector struct { ctx context.Context cancel context.CancelFunc db GCDatabase tracker *AccessTracker // Configuration dataDir string maxBytes int64 // 0 = auto-calculate interval time.Duration batchSize int minAgeSec int64 // Minimum age before considering for eviction // State mu sync.Mutex running bool evictedCount uint64 lastRun time.Time } // GCConfig holds configuration for the garbage collector. type GCConfig struct { MaxStorageBytes int64 // 0 = auto-calculate (80% of filesystem) Interval time.Duration // How often to check storage BatchSize int // Events to consider per GC run MinAgeSec int64 // Minimum age before eviction (default: 1 hour) } // DefaultGCConfig returns a default GC configuration. func DefaultGCConfig() GCConfig { return GCConfig{ MaxStorageBytes: 0, // Auto-detect Interval: time.Minute, // Check every minute BatchSize: 1000, // 1000 events per run MinAgeSec: 3600, // 1 hour minimum age } } // NewGarbageCollector creates a new garbage collector. func NewGarbageCollector( ctx context.Context, db GCDatabase, tracker *AccessTracker, cfg GCConfig, ) *GarbageCollector { gcCtx, cancel := context.WithCancel(ctx) if cfg.BatchSize <= 0 { cfg.BatchSize = 1000 } if cfg.Interval <= 0 { cfg.Interval = time.Minute } if cfg.MinAgeSec <= 0 { cfg.MinAgeSec = 3600 // 1 hour } return &GarbageCollector{ ctx: gcCtx, cancel: cancel, db: db, tracker: tracker, dataDir: db.Path(), maxBytes: cfg.MaxStorageBytes, interval: cfg.Interval, batchSize: cfg.BatchSize, minAgeSec: cfg.MinAgeSec, } } // Start begins the garbage collection loop. func (gc *GarbageCollector) Start() { gc.mu.Lock() if gc.running { gc.mu.Unlock() return } gc.running = true gc.mu.Unlock() go gc.runLoop() log.I.F("garbage collector started (interval: %s, batch: %d)", gc.interval, gc.batchSize) } // Stop stops the garbage collector. func (gc *GarbageCollector) Stop() { gc.cancel() gc.mu.Lock() gc.running = false gc.mu.Unlock() log.I.F("garbage collector stopped (total evicted: %d)", atomic.LoadUint64(&gc.evictedCount)) } // runLoop is the main GC loop. func (gc *GarbageCollector) runLoop() { ticker := time.NewTicker(gc.interval) defer ticker.Stop() for { select { case <-gc.ctx.Done(): return case <-ticker.C: if err := gc.runCycle(); err != nil { log.W.F("GC cycle error: %v", err) } } } } // runCycle executes one garbage collection cycle. func (gc *GarbageCollector) runCycle() error { gc.mu.Lock() gc.lastRun = time.Now() gc.mu.Unlock() // Check if we need to run GC shouldRun, currentBytes, maxBytes, err := gc.shouldRunGC() if err != nil { return err } if !shouldRun { return nil } log.D.F("GC triggered: current=%d MB, max=%d MB (%.1f%%)", currentBytes/(1024*1024), maxBytes/(1024*1024), float64(currentBytes)/float64(maxBytes)*100) // Get coldest events serials, err := gc.tracker.GetColdestEvents(gc.batchSize, gc.minAgeSec) if err != nil { return err } if len(serials) == 0 { log.D.F("GC: no events eligible for eviction") return nil } // Evict events evicted, err := gc.evictEvents(serials) if err != nil { return err } atomic.AddUint64(&gc.evictedCount, uint64(evicted)) log.I.F("GC: evicted %d events (total: %d)", evicted, atomic.LoadUint64(&gc.evictedCount)) return nil } // shouldRunGC checks if storage limit is exceeded. func (gc *GarbageCollector) shouldRunGC() (bool, int64, int64, error) { // Calculate max storage (dynamic based on filesystem) maxBytes, err := CalculateMaxStorage(gc.dataDir, gc.maxBytes) if err != nil { return false, 0, 0, err } // Get current usage currentBytes, err := GetCurrentStorageUsage(gc.dataDir) if err != nil { return false, 0, 0, err } return currentBytes > maxBytes, currentBytes, maxBytes, nil } // evictEvents evicts the specified events from the database. func (gc *GarbageCollector) evictEvents(serials []uint64) (int, error) { evicted := 0 for _, serial := range serials { // Check context for cancellation select { case <-gc.ctx.Done(): return evicted, gc.ctx.Err() default: } // Convert serial to Uint40 ser := &types.Uint40{} if err := ser.Set(serial); err != nil { log.D.F("GC: invalid serial %d: %v", serial, err) continue } // Fetch the event ev, err := gc.db.FetchEventBySerial(ser) if err != nil { log.D.F("GC: failed to fetch event %d: %v", serial, err) continue } if ev == nil { continue // Already deleted } // Delete the event if err := gc.db.DeleteEventBySerial(gc.ctx, ser, ev); err != nil { log.D.F("GC: failed to delete event %d: %v", serial, err) continue } evicted++ // Rate limit to avoid overwhelming the database if evicted%100 == 0 { time.Sleep(10 * time.Millisecond) } } return evicted, nil } // Stats returns current GC statistics. func (gc *GarbageCollector) Stats() GCStats { gc.mu.Lock() lastRun := gc.lastRun running := gc.running gc.mu.Unlock() // Get storage info currentBytes, _ := GetCurrentStorageUsage(gc.dataDir) maxBytes, _ := CalculateMaxStorage(gc.dataDir, gc.maxBytes) var percentage float64 if maxBytes > 0 { percentage = float64(currentBytes) / float64(maxBytes) * 100 } return GCStats{ Running: running, LastRunTime: lastRun, TotalEvicted: atomic.LoadUint64(&gc.evictedCount), CurrentStorageBytes: currentBytes, MaxStorageBytes: maxBytes, StoragePercentage: percentage, } } // GCStats holds garbage collector statistics. type GCStats struct { Running bool LastRunTime time.Time TotalEvicted uint64 CurrentStorageBytes int64 MaxStorageBytes int64 StoragePercentage float64 }