Compute sync slots and the first-sync gate on the DB clock
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful

This commit is contained in:
2026-10-25 13:57:54 +11:00
parent 1582533e5a
commit 82b1852bc4
13 changed files with 179 additions and 238 deletions
+7 -8
View File
@@ -165,16 +165,15 @@ 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 {
// SyncSchedule is the sync_interval and the stable per-remote phase offsetting
// its slots, so remotes sharing an interval spread across it; both zero when
// sync_interval is unset.
func (r Remote) SyncSchedule() (interval, phase time.Duration) {
if r.SyncInterval <= 0 {
return time.Time{}
return 0, 0
}
interval := time.Duration(r.SyncInterval) * time.Second
phase := syncPhase(r.Name, interval)
return now.Add(-phase).Truncate(interval).Add(phase)
interval = time.Duration(r.SyncInterval) * time.Second
return interval, syncPhase(r.Name, interval)
}
func syncPhase(name string, period time.Duration) time.Duration {
+9 -52
View File
@@ -163,10 +163,9 @@ 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)
func TestSyncSchedule(t *testing.T) {
if i, p := (Remote{Name: "x", MutableTTL: 60}).SyncSchedule(); i != 0 || p != 0 {
t.Fatalf("unset sync_interval schedule = %v/%v, want 0/0", i, p)
}
const period = 5 * time.Minute
@@ -174,17 +173,14 @@ func TestSyncSlot(t *testing.T) {
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)
interval, p := r.SyncSchedule()
if interval != period || p < 0 || p >= period {
t.Fatalf("%s: schedule %v/%v outside [0, %v)", r.Name, interval, p, period)
}
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
if _, again := r.SyncSchedule(); again != p {
t.Fatalf("%s: phase not stable", r.Name)
}
p = p.Truncate(time.Second)
phases[p] = true
lo, hi = min(lo, p), max(hi, p)
}
@@ -192,42 +188,3 @@ func TestSyncSlot(t *testing.T) {
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)
}
}
}
}