Confine phased sync slots to sync_interval remotes
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user