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
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user