First commit Go counter service
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// Package watcher reloads the domains CDB when the file changes on disk.
|
||||
//
|
||||
// It is the table mode of rcptd's watcher, trimmed: it watches the files
|
||||
// themselves (kqueue on FreeBSD/macOS only reports in-place writes through a
|
||||
// file watch) and their directories (an atomic `mv` over the file shows up as
|
||||
// a Create in the directory), debounces bursts and calls one reload function.
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// ReloadFunc is called, debounced, after a watched file changed. Its error is
|
||||
// logged; the watcher keeps running either way.
|
||||
type ReloadFunc func() error
|
||||
|
||||
// Watcher watches a set of files and calls reload on change.
|
||||
type Watcher struct {
|
||||
reload ReloadFunc
|
||||
fsw *fsnotify.Watcher
|
||||
files map[string]bool // absolute paths we care about
|
||||
debounce time.Duration
|
||||
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
wg sync.WaitGroup
|
||||
|
||||
timerMu sync.Mutex
|
||||
timer *time.Timer
|
||||
runMu sync.Mutex // serialises reload calls
|
||||
}
|
||||
|
||||
// New watches paths. Their directories must exist; the files themselves may
|
||||
// appear later (the first deploy of the CDB triggers a reload).
|
||||
func New(paths []string, reload ReloadFunc) (*Watcher, error) {
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w := &Watcher{
|
||||
reload: reload,
|
||||
fsw: fsw,
|
||||
files: make(map[string]bool, len(paths)),
|
||||
debounce: 500 * time.Millisecond,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
dirs := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
fsw.Close()
|
||||
return nil, fmt.Errorf("resolve path %s: %w", p, err)
|
||||
}
|
||||
w.files[abs] = true
|
||||
if dir := filepath.Dir(abs); !dirs[dir] {
|
||||
if err := fsw.Add(dir); err != nil {
|
||||
fsw.Close()
|
||||
return nil, fmt.Errorf("watch directory %s: %w", dir, err)
|
||||
}
|
||||
dirs[dir] = true
|
||||
}
|
||||
}
|
||||
for abs := range w.files {
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
_ = fsw.Add(abs)
|
||||
}
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// Start begins watching in a background goroutine.
|
||||
func (w *Watcher) Start() {
|
||||
w.wg.Add(1)
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-w.fsw.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create) == 0 {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(event.Name)
|
||||
if err != nil || !w.files[abs] {
|
||||
continue
|
||||
}
|
||||
// After a rename-over the old file watch is stale: re-add it so
|
||||
// in-place writes to the new inode are seen too.
|
||||
if event.Op&fsnotify.Create != 0 {
|
||||
_ = w.fsw.Add(abs)
|
||||
}
|
||||
w.schedule()
|
||||
case err, ok := <-w.fsw.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("file watcher error", "error", err)
|
||||
case <-w.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// schedule (re)arms the debounce timer.
|
||||
func (w *Watcher) schedule() {
|
||||
w.timerMu.Lock()
|
||||
defer w.timerMu.Unlock()
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
}
|
||||
w.timer = time.AfterFunc(w.debounce, w.run)
|
||||
}
|
||||
|
||||
func (w *Watcher) run() {
|
||||
select {
|
||||
case <-w.done:
|
||||
return
|
||||
default:
|
||||
}
|
||||
w.runMu.Lock()
|
||||
defer w.runMu.Unlock()
|
||||
if err := w.reload(); err != nil {
|
||||
slog.Error("reload after file change failed", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("reloaded after file change")
|
||||
}
|
||||
|
||||
// Stop ends watching and waits for the goroutine; safe to call twice.
|
||||
func (w *Watcher) Stop() {
|
||||
w.stopOnce.Do(func() {
|
||||
w.timerMu.Lock()
|
||||
if w.timer != nil {
|
||||
w.timer.Stop()
|
||||
}
|
||||
w.timerMu.Unlock()
|
||||
close(w.done)
|
||||
w.fsw.Close()
|
||||
w.wg.Wait()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// replace mimics the agent's atomic deploy: write a temp file next to the
|
||||
// target and rename it over the target.
|
||||
func replace(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
tmp := path + ".tmp"
|
||||
write(t, tmp, content)
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type counter struct {
|
||||
n atomic.Int32
|
||||
done chan struct{}
|
||||
err error
|
||||
}
|
||||
|
||||
func newCounter() *counter { return &counter{done: make(chan struct{}, 16)} }
|
||||
|
||||
func (c *counter) reload() error {
|
||||
c.n.Add(1)
|
||||
select {
|
||||
case c.done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return c.err
|
||||
}
|
||||
|
||||
func (c *counter) wait(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-c.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for reload")
|
||||
}
|
||||
}
|
||||
|
||||
// start creates and starts a watcher; debounce must be set before Start so
|
||||
// the watcher goroutine never races with the test.
|
||||
func start(t *testing.T, path string, c *counter, debounce time.Duration) *Watcher {
|
||||
t.Helper()
|
||||
w, err := New([]string{path}, c.reload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.debounce = debounce
|
||||
w.Start()
|
||||
t.Cleanup(w.Stop)
|
||||
time.Sleep(50 * time.Millisecond) // let the watches settle
|
||||
return w
|
||||
}
|
||||
|
||||
func TestWatcher_ReloadsOnInPlaceWrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
start(t, path, c, 100*time.Millisecond)
|
||||
|
||||
write(t, path, "v2")
|
||||
c.wait(t)
|
||||
if c.n.Load() < 1 {
|
||||
t.Errorf("reloads = %d, want >= 1", c.n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// The agent deploys the CDB with mv; after the first replacement the old
|
||||
// kqueue file watch is stale, so a second replacement must still be seen.
|
||||
func TestWatcher_ReloadsOnAtomicReplaceTwice(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
start(t, path, c, 100*time.Millisecond)
|
||||
|
||||
replace(t, path, "v2")
|
||||
c.wait(t)
|
||||
first := c.n.Load()
|
||||
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
replace(t, path, "v3")
|
||||
c.wait(t)
|
||||
if c.n.Load() <= first {
|
||||
t.Errorf("second replacement not detected: reloads %d -> %d", first, c.n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_IgnoresOtherFilesInDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
start(t, path, c, 100*time.Millisecond)
|
||||
|
||||
write(t, filepath.Join(dir, "other_file"), "x")
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if c.n.Load() != 0 {
|
||||
t.Errorf("reloads = %d for an unrelated file, want 0", c.n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_DebouncesBurst(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
start(t, path, c, 200*time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
write(t, path, "burst")
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
c.wait(t)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if n := c.n.Load(); n > 2 {
|
||||
t.Errorf("reloads = %d, want 1-2 (debounced)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_ContinuesAfterReloadError(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
c.err = errors.New("boom")
|
||||
start(t, path, c, 100*time.Millisecond)
|
||||
|
||||
write(t, path, "v2")
|
||||
c.wait(t)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
write(t, path, "v3")
|
||||
c.wait(t)
|
||||
if c.n.Load() < 2 {
|
||||
t.Errorf("reloads = %d, want >= 2 (a failing reload must not stop the watcher)", c.n.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatcher_StopPreventsReload(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
write(t, path, "v1")
|
||||
c := newCounter()
|
||||
w := start(t, path, c, 100*time.Millisecond)
|
||||
w.Stop()
|
||||
|
||||
write(t, path, "v2")
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
if c.n.Load() != 0 {
|
||||
t.Errorf("reloads = %d after Stop, want 0", c.n.Load())
|
||||
}
|
||||
w.Stop() // idempotent
|
||||
}
|
||||
|
||||
func TestNew_MissingDirectory(t *testing.T) {
|
||||
if _, err := New([]string{"/nonexistent-dir-for-test/rspamd_domains_db"}, func() error { return nil }); err == nil {
|
||||
t.Fatal("expected error for a missing directory")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_MissingFileInExistingDirIsFine(t *testing.T) {
|
||||
// The CDB may be deployed after the daemon starts; the directory watch
|
||||
// picks up its creation.
|
||||
path := filepath.Join(t.TempDir(), "rspamd_domains_db")
|
||||
c := newCounter()
|
||||
w, err := New([]string{path}, c.reload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.debounce = 100 * time.Millisecond
|
||||
w.Start()
|
||||
defer w.Stop()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
write(t, path, "v1")
|
||||
c.wait(t)
|
||||
}
|
||||
Reference in New Issue
Block a user