Files
beads/internal/storage/sqlite/freshness.go
Roland Tritsch 5264d7aa60 fix(daemon): prevent zombie state after database file replacement (#1213)
fix(daemon): prevent zombie state after database file replacement

Adds checkFreshness() to health check paths (GetMetadata, GetConfig, GetAllConfig) and refactors reconnect() to validate new connection before closing old.

PR-URL: https://github.com/steveyegge/beads/pull/1213
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-21 22:46:59 -08:00

159 lines
4.5 KiB
Go

// Package sqlite implements the storage interface using SQLite.
package sqlite
import (
"log"
"os"
"sync"
"time"
)
// FreshnessChecker monitors the database file for external modifications.
// It detects when the database file has been replaced (e.g., by git merge)
// and triggers a reconnection to ensure fresh data is visible.
//
// This addresses the issue where the daemon's long-lived SQLite connection
// becomes stale after external file replacement (not just in-place writes).
type FreshnessChecker struct {
dbPath string
lastInode uint64 // File inode (changes when file is replaced)
lastMtime time.Time // File modification time
lastSize int64 // File size
mu sync.Mutex
enabled bool
onStale func() error // Callback to reconnect when staleness detected
}
// NewFreshnessChecker creates a new freshness checker for the given database path.
// The onStale callback is called when file replacement is detected.
func NewFreshnessChecker(dbPath string, onStale func() error) *FreshnessChecker {
fc := &FreshnessChecker{
dbPath: dbPath,
enabled: true,
onStale: onStale,
}
// Capture initial file state
fc.captureFileState()
return fc
}
// captureFileState records the current file's inode, mtime, and size.
func (fc *FreshnessChecker) captureFileState() {
info, err := os.Stat(fc.dbPath)
if err != nil {
return
}
fc.lastMtime = info.ModTime()
fc.lastSize = info.Size()
// Get inode (Unix only, returns 0 on Windows)
fc.lastInode = getFileInode(info)
}
// Check examines the database file for changes and triggers reconnection if needed.
// Returns true if the file was replaced and reconnection was triggered.
// This method is safe for concurrent use.
func (fc *FreshnessChecker) Check() bool {
if !fc.enabled || fc.dbPath == "" || fc.dbPath == ":memory:" {
return false
}
fc.mu.Lock()
info, err := os.Stat(fc.dbPath)
if err != nil {
// File disappeared - might be mid-replace, skip this check
fc.mu.Unlock()
return false
}
// Check if file was replaced by comparing inode
currentInode := getFileInode(info)
// Detect file replacement:
// 1. Inode changed (file was replaced, most reliable on Unix)
// 2. Mtime changed (file was modified or replaced)
// 3. Size changed significantly (backup detection)
fileReplaced := false
if currentInode != 0 && fc.lastInode != 0 && currentInode != fc.lastInode {
// Inode changed - file was definitely replaced
fileReplaced = true
debugPrintf("FreshnessChecker: inode changed %d -> %d\n", fc.lastInode, currentInode)
} else if !info.ModTime().Equal(fc.lastMtime) {
// Mtime changed - file was modified or replaced
// This catches cases where inode isn't available (Windows, some filesystems)
fileReplaced = true
debugPrintf("FreshnessChecker: mtime changed %v -> %v\n", fc.lastMtime, info.ModTime())
}
if fileReplaced {
// Update tracked state before callback
fc.lastInode = currentInode
fc.lastMtime = info.ModTime()
fc.lastSize = info.Size()
// Release lock BEFORE calling callback to prevent deadlock
// (callback may call UpdateState which also needs the lock)
callback := fc.onStale
fc.mu.Unlock()
// Trigger reconnection outside the lock
if callback != nil {
debugPrintf("FreshnessChecker: triggering reconnection\n")
_ = callback()
}
return true
}
fc.mu.Unlock()
return false
}
// debugPrintf conditionally logs debug messages when BD_DEBUG_FRESHNESS is set
var debugPrintf = func(format string, args ...interface{}) {
if os.Getenv("BD_DEBUG_FRESHNESS") != "" {
log.Printf("[freshness] "+format, args...)
}
}
// DebugState returns the current tracked state for testing/debugging.
func (fc *FreshnessChecker) DebugState() (inode uint64, mtime time.Time, size int64) {
fc.mu.Lock()
defer fc.mu.Unlock()
return fc.lastInode, fc.lastMtime, fc.lastSize
}
// Enable enables freshness checking.
func (fc *FreshnessChecker) Enable() {
fc.mu.Lock()
defer fc.mu.Unlock()
fc.enabled = true
fc.captureFileState()
}
// Disable disables freshness checking.
func (fc *FreshnessChecker) Disable() {
fc.mu.Lock()
defer fc.mu.Unlock()
fc.enabled = false
}
// IsEnabled returns whether freshness checking is enabled.
func (fc *FreshnessChecker) IsEnabled() bool {
fc.mu.Lock()
defer fc.mu.Unlock()
return fc.enabled
}
// UpdateState updates the tracked file state after a successful reconnection.
// Call this after reopening the database to establish a new baseline.
func (fc *FreshnessChecker) UpdateState() {
fc.mu.Lock()
defer fc.mu.Unlock()
fc.captureFileState()
}