Confine phased sync slots to sync_interval remotes
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

Unset remotes keep the mutable_ttl freshness predicate. Scheduled remotes
compute their slot in Go, skip the create-time prime and wait for their
first slot; a cold-cache prime still runs at once. Document sync_interval.
This commit is contained in:
2026-10-25 13:27:43 +11:00
parent 707e380c98
commit 1582533e5a
16 changed files with 343 additions and 135 deletions
+19
View File
@@ -2,6 +2,7 @@ package models
import (
"fmt"
"hash/fnv"
"net/url"
"regexp"
"time"
@@ -164,6 +165,24 @@ func (r Remote) SyncPeriod(def time.Duration) time.Duration {
return def
}
// SyncSlot is the start of the sync_interval slot containing now; zero when
// sync_interval is unset. Slots are offset by a stable per-remote phase so
// remotes sharing an interval spread across it, and every replica agrees.
func (r Remote) SyncSlot(now time.Time) time.Time {
if r.SyncInterval <= 0 {
return time.Time{}
}
interval := time.Duration(r.SyncInterval) * time.Second
phase := syncPhase(r.Name, interval)
return now.Add(-phase).Truncate(interval).Add(phase)
}
func syncPhase(name string, period time.Duration) time.Duration {
h := fnv.New64a()
_, _ = h.Write([]byte(name))
return time.Duration(h.Sum64() % uint64(period))
}
// ValidateSyncInterval rejects a negative sync_interval, or one set on a type
// without a background syncer.
func (r *Remote) ValidateSyncInterval() error {
+70
View File
@@ -2,6 +2,7 @@ package models
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
@@ -161,3 +162,72 @@ func TestValidateSyncInterval(t *testing.T) {
}
}
}
func TestSyncSlot(t *testing.T) {
now := time.Date(2026, 3, 1, 10, 17, 42, 0, time.UTC)
if s := (Remote{Name: "x", MutableTTL: 60}).SyncSlot(now); !s.IsZero() {
t.Fatalf("unset sync_interval slot = %v, want zero", s)
}
const period = 5 * time.Minute
phases := map[time.Duration]bool{}
var lo, hi time.Duration = period, 0
for i := 0; i < 13; i++ {
r := Remote{Name: fmt.Sprintf("github-rpm-%02d", i), SyncInterval: int(period.Seconds())}
s := r.SyncSlot(now)
if s.After(now) || !now.Before(s.Add(period)) {
t.Fatalf("%s: slot %v does not contain %v", r.Name, s, now)
}
if r.SyncSlot(now.Add(-time.Hour)).Add(time.Hour) != s {
t.Fatalf("%s: phase not stable over time", r.Name)
}
p := s.Sub(now.Truncate(period)).Truncate(time.Second)
if p < 0 {
p += period
}
phases[p] = true
lo, hi = min(lo, p), max(hi, p)
}
if len(phases) != 13 || hi-lo < period/2 {
t.Fatalf("phases bunched: %d distinct, spread %v of %v", len(phases), hi-lo, period)
}
}
// After a sync_interval change the next sync lands within one new interval, no
// gap exceeds an interval, and syncs settle at one per new slot.
func TestSyncSlotIntervalChange(t *testing.T) {
const tick = time.Minute
start := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
for _, c := range []struct{ from, to int }{{300, 3600}, {3600, 300}, {3600, 5400}} {
for i := 0; i < 20; i++ {
r := Remote{Name: fmt.Sprintf("chg-%d", i), SyncInterval: c.from}
change := start.Add(6*time.Hour + time.Duration(i)*7*time.Minute)
var last time.Time
for now := start; now.Before(change); now = now.Add(tick) {
if last.IsZero() || last.Before(r.SyncSlot(now)) {
last = now
}
}
r.SyncInterval = c.to
next := change
for !last.Before(r.SyncSlot(next)) {
next = next.Add(tick)
}
if wait := next.Sub(change); wait > time.Duration(c.to)*time.Second+tick {
t.Errorf("%s %v: next sync %v after change exceeds the new interval", r.Name, c, wait)
}
if gap := next.Sub(last); gap > time.Duration(max(c.from, c.to))*time.Second+tick {
t.Errorf("%s %v: gap %v exceeds an interval", r.Name, c, gap)
}
syncs, end := 0, change.Add(6*time.Hour)
for now := change; now.Before(end); now = now.Add(tick) {
if last.Before(r.SyncSlot(now)) {
last, syncs = now, syncs+1
}
}
if limit := int(6*time.Hour/(time.Duration(c.to)*time.Second)) + 1; syncs > limit {
t.Errorf("%s %v: %d syncs in 6h after change, want <= %d", r.Name, c, syncs, limit)
}
}
}
}