// Package watcher provides file system watching utilities for detecting // database file/directory deletions and triggering recreation. package watcher import ( "context" "os" "path/filepath" "sync" "time" "github.com/fsnotify/fsnotify" "github.com/rs/zerolog/log" ) // Watcher monitors a file or directory for deletion and calls onDelete when removed. // It watches the parent directory since fsnotify cannot watch non-existent files. type Watcher struct { ctx context.Context onDelete func() watcher *fsnotify.Watcher cancel context.CancelFunc targetPath string parentPath string debounce time.Duration mu sync.Mutex running bool } // New creates a new Watcher for the given target path. // The onDelete callback is called when the target is deleted. func New(targetPath string, onDelete func()) (*Watcher, error) { fsw, err := fsnotify.NewWatcher() if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) return &Watcher{ targetPath: targetPath, parentPath: filepath.Dir(targetPath), onDelete: onDelete, watcher: fsw, ctx: ctx, cancel: cancel, debounce: 100 * time.Millisecond, }, nil } // Start begins watching for file deletion events. func (w *Watcher) Start() error { w.mu.Lock() if w.running { w.mu.Unlock() return nil } w.running = true w.mu.Unlock() // Add watch on parent directory if err := w.addWatch(); err != nil { log.Warn().Err(err).Str("path", w.parentPath).Msg("Failed to add initial watch") // Continue anyway - we'll try to re-establish later } go w.watchLoop() return nil } // Stop stops the watcher. func (w *Watcher) Stop() error { w.mu.Lock() defer w.mu.Unlock() if !w.running { return nil } w.running = false w.cancel() return w.watcher.Close() } // addWatch adds the parent directory to the watch list. func (w *Watcher) addWatch() error { // Ensure parent exists if _, err := os.Stat(w.parentPath); os.IsNotExist(err) { return err } return w.watcher.Add(w.parentPath) } // watchLoop is the main event loop. func (w *Watcher) watchLoop() { var ( debounceTimer *time.Timer pendingDelete bool ) for { select { case <-w.ctx.Done(): if debounceTimer != nil { debounceTimer.Stop() } return case event, ok := <-w.watcher.Events: if !ok { return } // Check if this event is for our target eventPath := filepath.Clean(event.Name) targetPath := filepath.Clean(w.targetPath) // Handle parent directory deletion (entire data dir removed) if eventPath == w.parentPath && event.Op&fsnotify.Remove != 0 { log.Info().Str("path", w.parentPath).Msg("Parent directory deleted") pendingDelete = true if debounceTimer != nil { debounceTimer.Stop() } debounceTimer = time.AfterFunc(w.debounce, func() { w.handleDeletion() }) continue } // Handle target file/directory deletion if eventPath == targetPath && event.Op&fsnotify.Remove != 0 { log.Info().Str("path", w.targetPath).Msg("Target deleted") pendingDelete = true if debounceTimer != nil { debounceTimer.Stop() } debounceTimer = time.AfterFunc(w.debounce, func() { w.handleDeletion() }) continue } // Handle parent directory recreation (re-establish watch) if eventPath == w.parentPath && event.Op&fsnotify.Create != 0 { log.Info().Str("path", w.parentPath).Msg("Parent directory recreated, re-establishing watch") _ = w.addWatch() continue } // If target was recreated after pending delete, cancel the callback if pendingDelete && eventPath == targetPath && event.Op&fsnotify.Create != 0 { log.Info().Str("path", w.targetPath).Msg("Target recreated, cancelling deletion callback") pendingDelete = false if debounceTimer != nil { debounceTimer.Stop() } } case err, ok := <-w.watcher.Errors: if !ok { return } log.Error().Err(err).Msg("Watcher error") } } } // handleDeletion calls the onDelete callback and attempts to re-establish the watch. func (w *Watcher) handleDeletion() { log.Info().Str("path", w.targetPath).Msg("Triggering deletion callback") // Call the callback if w.onDelete != nil { w.onDelete() } // Try to re-establish watch after a short delay (parent may have been recreated) go func() { time.Sleep(500 * time.Millisecond) if err := w.addWatch(); err != nil { log.Warn().Err(err).Str("path", w.parentPath).Msg("Failed to re-establish watch after deletion") } else { log.Info().Str("path", w.parentPath).Msg("Re-established watch after recreation") } }() }