Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package scheduler publishes scheduled posts whose publish_at date
// has arrived, either from the in-app loop or the publish-due CLI.
package scheduler
import (
"log/slog"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// Store is the content the scheduler reads and writes: every post, and a
// save for the ones whose date has arrived.
type Store interface {
All() []*post.Post
Save(p *post.Post) (*post.Post, error)
}
// MinInterval is the shortest interval the in-app loop accepts.
// time.NewTicker panics on a non-positive duration, so a bad
// configuration is refused here instead of reaching the runtime.
const MinInterval = time.Second
// PublishDueWith publishes every post whose publish_at date is today or
// earlier and returns the slugs that changed, in store order, together with
// the number of posts whose save failed. Publishing removes the publish_at
// key and defaults date to it. The sink is called once per published post,
// so the caller can deliver the same post.published webhook the admin
// delivers; it may be nil. A post whose save fails is skipped, so one bad
// file cannot stop the sweep.
func PublishDueWith(st Store, onPublished func(*post.Post)) ([]string, int) {
today := post.TodayUTC()
var published []string
failures := 0
for _, cached := range st.All() {
// Cached posts are shared with request handlers; mutate a clone.
p := cached.Clone()
due, ok := p.DueAt()
if !ok || due.After(today) {
continue
}
p.Metadata.Delete("publish_at")
if _, hasDate := p.Metadata.Get("date"); !hasDate {
p.Metadata.Set("date", interpres.LocalDate{Time: due})
}
if _, err := st.Save(p); err != nil {
failures++
slog.Warn("scheduler: cannot publish", "slug", p.Slug(), "error", err)
continue
}
published = append(published, p.Slug())
slog.Info("scheduler published", "slug", p.Slug())
if onPublished != nil {
onPublished(p)
}
}
return published, failures
}
// DuePosts lists the posts PublishDue would publish.
func DuePosts(st Store) []*post.Post {
today := post.TodayUTC()
var due []*post.Post
for _, p := range st.All() {
if at, ok := p.DueAt(); ok && !at.After(today) {
due = append(due, p)
}
}
return due
}
// Run publishes due posts once at start-up and then every interval
// until stop is closed. An interval below MinInterval is reported and
// the loop does not start.
func Run(st Store, interval time.Duration, stop <-chan struct{}, onPublished func(*post.Post)) {
if interval < MinInterval {
slog.Error("scheduler: interval below the minimum, loop not started",
"interval", interval, "minimum", MinInterval)
return
}
slog.Info("scheduler started", "interval", interval)
if published, failures := PublishDueWith(st, onPublished); len(published) > 0 || failures > 0 {
slog.Info("scheduler: start-up sweep", "published", len(published), "failed", failures)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
PublishDueWith(st, onPublished)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package scheduler
import (
"os"
"path/filepath"
"testing"
"testing/synctest"
"time"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
func newStoreWith(t *testing.T, files map[string]string) *store.Store {
t.Helper()
dir := filepath.Join(t.TempDir(), "posts")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
return store.New(store.Options{ContentDir: dir, DefaultLang: "en", RevisionLimit: 10})
}
func TestPublishDue(t *testing.T) {
due := time.Now().UTC().Format("2006-01-02")
st := newStoreWith(t, map[string]string{
"due.md": "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n",
"later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nx\n",
"stamped.md": "+++\nslug = \"stamped\"\npublish_at = " + due + "\ndate = 2020-01-01\n+++\nx\n",
})
published, _ := PublishDueWith(st, nil)
if len(published) != 2 {
t.Fatalf("published = %v", published)
}
if p := st.Find("due", ""); p == nil || p.Scheduled() {
t.Fatal("due post still scheduled")
}
if got := st.Find("due", "").DateString(); got != due {
t.Fatalf("date defaulted to %q, want %q", got, due)
}
if got := st.Find("stamped", "").DateString(); got != "2020-01-01" {
t.Fatalf("existing date overwritten: %q", got)
}
if p := st.Find("later", ""); p == nil || !p.Scheduled() {
t.Fatal("future post was published")
}
}
func TestDuePosts(t *testing.T) {
due := time.Now().UTC().Format("2006-01-02")
st := newStoreWith(t, map[string]string{
"due.md": "+++\nslug = \"due\"\npublish_at = " + due + "\n+++\nx\n",
"later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nx\n",
})
if got := len(DuePosts(st)); got != 1 {
t.Fatalf("DuePosts = %d, want 1", got)
}
}
func TestRunStops(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
st := newStoreWith(t, nil)
stop := make(chan struct{})
done := make(chan struct{})
go func() {
// Ten intervals, so the loop has really started ticking by the
// time it is stopped.
Run(st, 10*time.Millisecond, stop, nil)
close(done)
}()
time.Sleep(100 * time.Millisecond)
close(stop)
synctest.Wait()
select {
case <-done:
default:
t.Fatal("Run did not stop")
}
})
}
// The in-app loop must publish on its own, without the CLI: the ticker
// branch is the whole point of the [scheduler] section.
// The loop publishes on its own, and synctest runs its interval and the
// wait in virtual time: the test does not spend a second of the wall
// clock on a one-second ticker.
func TestRunPublishesDuePosts(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
st := newStoreWith(t, map[string]string{
"now.md": "+++\nslug = \"now\"\npublish_at = 2000-01-01\n+++\nbody\n",
"later.md": "+++\nslug = \"later\"\npublish_at = 2999-01-01\n+++\nbody\n",
})
stop := make(chan struct{})
done := make(chan struct{})
published := make(chan string, 4)
go func() {
defer close(done)
Run(st, MinInterval, stop, func(p *post.Post) { published <- p.Slug() })
}()
// The start-up sweep runs before the first tick.
synctest.Wait()
if len(DuePosts(st)) != 0 {
t.Fatal("the start-up sweep did not publish the due post")
}
select {
case slug := <-published:
if slug != "now" {
t.Fatalf("published %q, want the due post", slug)
}
default:
t.Fatal("the sink was not called")
}
// Then the loop keeps running on its interval.
time.Sleep(MinInterval * 3)
synctest.Wait()
close(stop)
<-done
if len(DuePosts(st)) != 0 {
t.Fatal("the loop did not keep the due post published")
}
})
}
// An interval below the minimum is refused rather than handed to
// time.NewTicker, which panics on a non-positive duration.
func TestRunRefusesAnUnusableInterval(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
st := newStoreWith(t, nil)
done := make(chan struct{})
go func() {
defer close(done)
Run(st, 0, make(chan struct{}), nil)
}()
synctest.Wait()
select {
case <-done:
default:
t.Fatal("Run did not return for a zero interval")
}
})
}