notice content changes and rebuild without a restart
Polling lives in internal/ext/watch, per the human's call to keep core under its ceiling rather than raise it a second time — which is what ADR-0041 said a second raise would mean. It is a poller, deletable without trace, and core stayed at 2671/2800. A settled change calls the same `rebuilder` that startup calls, because a reload path that differs from the startup path is a reload path that drifts. The index is an atomic.Pointer swapped whole, so a request reads the site that was current when it arrived instead of one being rebuilt underneath it — the alternative, mutating in place, is a data race with every in-flight request. Names, sizes and modification times, not contents: reading every file to detect a change costs more than the rebuild it triggers. Editor droppings are excluded, because saving in vim writes a swap file, a backup and the number 4913, and each would otherwise look like a change. A change must hold still for a moment first, since one save is often several operations. Verified against the running binary: a page 404s, the file appears, and five seconds later it serves — one "site root changed" in the log. Then three droppings written at once produced no rebuild at all. Two warnings fired and were fixed rather than silenced: `runServe` gave up the rebuild closure to `rebuilder`, and the fingerprint walk gave up its body to `record`, where three exclusions read as a list instead of as nesting. The Dockerfile ships the binary alone. The site root arrives as a volume and is never copied in — it is somebody's content repository with its own history (ADR-0011), so the image is the same for every site.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
// Package watch notices that a site root changed and says so.
|
||||
//
|
||||
// Contributes: a polling loop `cmd` runs in the background (no request-path behaviour).
|
||||
// Cascade keys: none.
|
||||
// Contract fields: none.
|
||||
// Not doing: filesystem notifications — polling needs no dependency and no per-platform code, and a site this
|
||||
// size is cheap to stat (ADR-0022). Fetching changes: the engine notices, it never pulls.
|
||||
package watch
|
||||
@@ -0,0 +1,129 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"hash"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Interval is how often the site root is looked at, and Settle is how long it must hold still afterwards.
|
||||
//
|
||||
// Polled rather than watched: notifications need a dependency and per-platform code, while a stat of a personal
|
||||
// site's content costs almost nothing (ADR-0022). The settle window exists because saving a file is rarely one
|
||||
// operation — an editor may write, rename and chmod — and rebuilding halfway through that reads a half-written
|
||||
// bundle.
|
||||
// Variables rather than constants for the same reason clock.go holds a variable: a test that has to wait out
|
||||
// two-second polls is a test nobody runs. Nothing but a test assigns them.
|
||||
var (
|
||||
Interval = 2 * time.Second
|
||||
Settle = time.Second
|
||||
)
|
||||
|
||||
// Changed is called once per settled change, with the reason for the log.
|
||||
type Changed func()
|
||||
|
||||
// Watch polls fsys until stop is closed, calling onChange after each settled change.
|
||||
//
|
||||
// A goroutine's worth of work, which `conventions.md` allows outside the render path: nothing here runs while a
|
||||
// request is being served, and the only shared state is whatever onChange swaps.
|
||||
func Watch(fsys fs.FS, stop <-chan struct{}, onChange Changed) {
|
||||
previous := Fingerprint(fsys)
|
||||
pending := ""
|
||||
ticker := time.NewTicker(Interval)
|
||||
defer ticker.Stop()
|
||||
settling := time.NewTimer(Settle)
|
||||
settling.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
current := Fingerprint(fsys)
|
||||
switch {
|
||||
case current == previous:
|
||||
// Nothing moved. If something was pending, its settle timer is still running.
|
||||
case current == pending:
|
||||
// Still the same as last tick: the write has stopped, so let the timer finish.
|
||||
default:
|
||||
// Something changed, or changed again — restart the settle window.
|
||||
pending = current
|
||||
settling.Reset(Settle)
|
||||
}
|
||||
case <-settling.C:
|
||||
if pending == "" || pending == previous {
|
||||
continue
|
||||
}
|
||||
previous = pending
|
||||
pending = ""
|
||||
slog.Info("site root changed, rebuilding")
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprint is one string standing for the current state of the content.
|
||||
//
|
||||
// Names, sizes and modification times — not contents: reading every file to detect a change would cost more
|
||||
// than the rebuild it triggers. Editor droppings are excluded, or saving a file in vim would look like three
|
||||
// changes and a deletion.
|
||||
func Fingerprint(fsys fs.FS) string {
|
||||
sum := sha256.New()
|
||||
for _, root := range []string{"content", "templates"} {
|
||||
_ = fs.WalkDir(fsys, root, func(p string, d fs.DirEntry, err error) error {
|
||||
return record(sum, p, d, err)
|
||||
})
|
||||
}
|
||||
// site.yaml is part of the site's state, and editing it should not need a restart.
|
||||
if info, err := fs.Stat(fsys, "site.yaml"); err == nil {
|
||||
sum.Write([]byte("site.yaml"))
|
||||
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(sum.Sum(nil))
|
||||
}
|
||||
|
||||
// record folds one entry into the running sum, skipping anything that is not content.
|
||||
//
|
||||
// Its own function so the walk stays flat: an error, a directory and a dropping are three early returns, which
|
||||
// read as a list of exclusions rather than as nesting.
|
||||
func record(sum hash.Hash, p string, d fs.DirEntry, err error) error {
|
||||
switch {
|
||||
case err != nil:
|
||||
return nil
|
||||
case d.IsDir() && dropping(d.Name()):
|
||||
return fs.SkipDir
|
||||
case d.IsDir(), dropping(d.Name()):
|
||||
return nil
|
||||
}
|
||||
info, statErr := d.Info()
|
||||
if statErr != nil {
|
||||
return nil
|
||||
}
|
||||
sum.Write([]byte(p))
|
||||
_ = binary.Write(sum, binary.LittleEndian, info.Size())
|
||||
_ = binary.Write(sum, binary.LittleEndian, info.ModTime().UnixNano())
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropping reports whether a name is an editor's leftovers rather than content.
|
||||
//
|
||||
// Every one of these is something a real editor writes beside the file it is saving: swap files, backups,
|
||||
// atomic-write temporaries, and the number vim touches to test whether a directory is writable.
|
||||
func dropping(name string) bool {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "."), strings.HasSuffix(name, "~"):
|
||||
return true
|
||||
case name == "4913", name == "__pycache__":
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(path.Ext(name)) {
|
||||
case ".swp", ".swo", ".swx", ".tmp", ".bak", ".orig":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package watch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFingerprintChangesOnlyForContent(t *testing.T) {
|
||||
base := fstest.MapFS{
|
||||
"content/posts/one.md": {Data: []byte("x"), ModTime: time.Unix(100, 0)},
|
||||
"templates/page.html": {Data: []byte("y"), ModTime: time.Unix(100, 0)},
|
||||
"site.yaml": {Data: []byte("base: x"), ModTime: time.Unix(100, 0)},
|
||||
}
|
||||
before := Fingerprint(base)
|
||||
|
||||
// The same tree twice is the same fingerprint, or every tick would look like a change.
|
||||
if Fingerprint(base) != before {
|
||||
t.Fatal("fingerprint is not stable for an unchanged tree")
|
||||
}
|
||||
|
||||
for name, change := range map[string]fstest.MapFS{
|
||||
"edited a bundle": {"content/posts/one.md": {Data: []byte("xx"), ModTime: time.Unix(200, 0)}},
|
||||
"added a bundle": {"content/posts/two.md": {Data: []byte("z"), ModTime: time.Unix(100, 0)}},
|
||||
"edited a template": {"templates/page.html": {Data: []byte("yy"), ModTime: time.Unix(200, 0)}},
|
||||
"edited site.yaml": {"site.yaml": {Data: []byte("base: y"), ModTime: time.Unix(200, 0)}},
|
||||
} {
|
||||
next := fstest.MapFS{}
|
||||
for k, v := range base {
|
||||
next[k] = v
|
||||
}
|
||||
for k, v := range change {
|
||||
next[k] = v
|
||||
}
|
||||
if Fingerprint(next) == before {
|
||||
t.Errorf("%s went unnoticed", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Editor droppings are not content: saving in vim writes several of these, and each would look like a change.
|
||||
noise := fstest.MapFS{}
|
||||
for k, v := range base {
|
||||
noise[k] = v
|
||||
}
|
||||
for _, dropping := range []string{
|
||||
"content/posts/.one.md.swp", "content/posts/one.md~", "content/posts/4913",
|
||||
"content/posts/one.md.tmp", "content/.DS_Store",
|
||||
} {
|
||||
noise[dropping] = &fstest.MapFile{Data: []byte("noise"), ModTime: time.Unix(300, 0)}
|
||||
}
|
||||
if Fingerprint(noise) != before {
|
||||
t.Error("editor droppings changed the fingerprint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchFiresOnceAfterAChangeSettles(t *testing.T) {
|
||||
// A real directory, because the point is the timing of repeated writes, which a static MapFS cannot show.
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "content"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, "content", name), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("one.md", "first")
|
||||
|
||||
// Poll faster than a person could type, so the test measures the settle behaviour rather than the clock.
|
||||
Interval, Settle = 20*time.Millisecond, 40*time.Millisecond
|
||||
defer func() { Interval, Settle = 2*time.Second, time.Second }()
|
||||
|
||||
fsys := os.DirFS(dir)
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
changes := make(chan struct{}, 8)
|
||||
go Watch(fsys, stop, func() { changes <- struct{}{} })
|
||||
|
||||
// Nothing has changed, so nothing should fire.
|
||||
select {
|
||||
case <-changes:
|
||||
t.Fatal("fired without a change")
|
||||
case <-time.After(Interval + Settle):
|
||||
}
|
||||
|
||||
// Several writes in quick succession are one change, not three: that is what the settle window is for.
|
||||
write("one.md", "second")
|
||||
write("two.md", "third")
|
||||
write("one.md", "fourth")
|
||||
select {
|
||||
case <-changes:
|
||||
case <-time.After(4 * (Interval + Settle)):
|
||||
t.Fatal("a change never arrived")
|
||||
}
|
||||
// And it does not keep firing once things are still.
|
||||
select {
|
||||
case <-changes:
|
||||
t.Error("fired twice for one settled change")
|
||||
case <-time.After(2 * Interval):
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user