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:
@@ -38,7 +38,7 @@ func assetHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestABundlesOwnFilesAreServed(t *testing.T) {
|
||||
|
||||
@@ -53,7 +53,7 @@ func benchHandler(b *testing.B, pictures int) http.Handler {
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func serveOnce(b *testing.B, h http.Handler, path string) {
|
||||
|
||||
@@ -29,7 +29,7 @@ func crawlerHandler(t *testing.T, settings content.Settings, extra fstest.MapFS)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, settings)
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
}
|
||||
|
||||
func TestSitemapListsEveryVariantAbsolutely(t *testing.T) {
|
||||
|
||||
@@ -32,7 +32,7 @@ func extrasHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestExtrasAreNotBundles(t *testing.T) {
|
||||
|
||||
@@ -30,7 +30,7 @@ func feedHandler(t *testing.T, settings content.Settings) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, settings)
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
}
|
||||
|
||||
func fetchFeed(t *testing.T, h http.Handler, path string) (*httptest.ResponseRecorder, atom) {
|
||||
|
||||
@@ -21,7 +21,7 @@ func slugHandler(t *testing.T, fsys fstest.MapFS) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestASlugRenamesTheAddressInEveryLanguage(t *testing.T) {
|
||||
@@ -82,7 +82,7 @@ func TestListingsAndSitemapsUseTheSluggedAddress(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(bundles), r, fsys, nil, settings)
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/posts/", nil))
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hidden := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
hidden := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/art/draft/": http.StatusNotFound,
|
||||
"/art/draft/one.jpg": http.StatusNotFound,
|
||||
@@ -58,7 +58,7 @@ func TestNothingInsideAnUnpublishedBundleIsServed(t *testing.T) {
|
||||
// Revealing them is the only thing that changes the answer.
|
||||
site := content.NewSite(bundles)
|
||||
site.Reveal()
|
||||
shown := Handler(site, r, fsys, nil, content.Settings{})
|
||||
shown := Handler(Fixed(site), r, fsys, nil, content.Settings{})
|
||||
for _, path := range []string{"/art/draft/", "/art/draft/one.jpg", "/art/future/", "/art/future/two.jpg"} {
|
||||
rec := httptest.NewRecorder()
|
||||
shown.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
@@ -82,7 +82,7 @@ func TestUnpublishedBundlesAreAbsentFromEverythingThatLists(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(bundles), r, fsys, nil, settings)
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, settings)
|
||||
for _, path := range []string{"/art/", "/feed.xml", "/sitemap.xml"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
|
||||
+12
-3
@@ -12,13 +12,22 @@ import (
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// Current returns the site as it is right now.
|
||||
//
|
||||
// A function rather than a pointer, so a background poller can swap what it returns and a request still sees one
|
||||
// coherent index instead of one being rebuilt underneath it (ADR-0022).
|
||||
type Current func() *content.Site
|
||||
|
||||
// Fixed is a Current for a site that never changes, which is every caller that does not watch for changes.
|
||||
func Fixed(site *content.Site) Current { return func() *content.Site { return site } }
|
||||
|
||||
// Handler serves a site.
|
||||
//
|
||||
// One mux entry, because URL shape is the resolver's business rather than the mux's: see resolve.
|
||||
func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
||||
func Handler(current Current, r *render.Renderer, siteFS, derivedFS fs.FS, settings content.Settings) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, site, r, siteFS, settings)
|
||||
serve(w, req, current(), r, siteFS, settings)
|
||||
})
|
||||
// Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases: no
|
||||
// bundle can own them, since a key always sits under a section.
|
||||
@@ -26,7 +35,7 @@ func Handler(site *content.Site, r *render.Renderer, siteFS, derivedFS fs.FS, se
|
||||
serveRobots(w, req, siteFS, settings.Base)
|
||||
})
|
||||
mux.HandleFunc("GET "+sitemapPath, func(w http.ResponseWriter, req *http.Request) {
|
||||
serveSitemap(w, req, site, settings.Base)
|
||||
serveSitemap(w, req, current(), settings.Base)
|
||||
})
|
||||
if siteFS != nil {
|
||||
if sub, err := fs.Sub(siteFS, "static"); err == nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ func testHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestServeBundleAtItsPermalink(t *testing.T) {
|
||||
@@ -76,7 +76,7 @@ func multilingualHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestPrefixedLanguageServesThatVariant(t *testing.T) {
|
||||
@@ -128,7 +128,7 @@ func aliasHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestAliasRedirectsToCanonical(t *testing.T) {
|
||||
@@ -175,7 +175,7 @@ func listingHandler(t *testing.T, n int) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSectionIndexListsNewestFirst(t *testing.T) {
|
||||
@@ -243,7 +243,7 @@ func TestStaticFilesAreServedAndDirectoriesAreNot(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(bundles), r, fsys, nil, content.Settings{})
|
||||
h := Handler(Fixed(content.NewSite(bundles)), r, fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/style.css": http.StatusOK,
|
||||
"/static/img/logo.svg": http.StatusOK,
|
||||
@@ -284,7 +284,7 @@ func TestAStaticPathThatEscapesTheRootIs404(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := Handler(content.NewSite(nil), r, fsys, nil, content.Settings{})
|
||||
h := Handler(Fixed(content.NewSite(nil)), r, fsys, nil, content.Settings{})
|
||||
for path, want := range map[string]int{
|
||||
"/static/ok.css": http.StatusOK,
|
||||
"/static/escape.txt": http.StatusNotFound,
|
||||
@@ -317,7 +317,7 @@ func seriesHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, nil, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestSequenceNavigationLinksNeighbours(t *testing.T) {
|
||||
@@ -407,7 +407,7 @@ func tagHandler(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r, nil, nil, content.Settings{})
|
||||
return Handler(Fixed(content.NewSite(bundles)), r, nil, nil, content.Settings{})
|
||||
}
|
||||
|
||||
func TestGlobalTagListingSpansSectionsGroupedByOne(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user