635c33d381
gates / gates (push) Successful in 31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
249 lines
8.1 KiB
Go
249 lines
8.1 KiB
Go
package nightchain
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-871 (`09` decision 109). Every rule of the catch-up, each with a COMPANION RED-PROOF recorded in
|
|
// felhom.eu/documentation/audits/catchup-2026-10-05/partA/red-proofs.txt.
|
|
|
|
var bud = func() *time.Location { l, _ := time.LoadLocation("Europe/Budapest"); return l }()
|
|
|
|
func at(day, hh, mm int) time.Time { return time.Date(2026, 10, day, hh, mm, 0, 0, bud) }
|
|
|
|
type harness struct {
|
|
l *Ledger
|
|
c *CatchUp
|
|
mu sync.Mutex
|
|
ran []Leg
|
|
events []time.Time
|
|
slept []time.Duration
|
|
now time.Time
|
|
busy int // QuiesceBusy answers true this many times
|
|
done chan struct{}
|
|
}
|
|
|
|
func newHarness(t *testing.T, seeded, now time.Time) *harness {
|
|
t.Helper()
|
|
l, err := Open(filepath.Join(t.TempDir(), "night-ledger.json"), seeded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := &harness{l: l, now: now, done: make(chan struct{}, 4)}
|
|
leg := func(g Leg) func(context.Context) error {
|
|
return func(context.Context) error {
|
|
h.mu.Lock()
|
|
h.ran = append(h.ran, g)
|
|
h.mu.Unlock()
|
|
l.MarkEnded(g, h.clock())
|
|
return nil
|
|
}
|
|
}
|
|
h.c = &CatchUp{
|
|
Ledger: l, Window: func() string { return "02:30" }, Loc: bud,
|
|
Legs: map[Leg]func(context.Context) error{LegDBDump: leg(LegDBDump), LegTier2: leg(LegTier2), LegOffsite: leg(LegOffsite)},
|
|
Logger: log.New(io.Discard, "", 0),
|
|
Now: h.clock,
|
|
Sleep: func(_ context.Context, d time.Duration) bool {
|
|
h.mu.Lock()
|
|
h.slept = append(h.slept, d)
|
|
h.now = h.now.Add(d)
|
|
h.mu.Unlock()
|
|
return true
|
|
},
|
|
QuiesceBusy: func() bool {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.busy > 0 {
|
|
h.busy--
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
Event: func(w time.Time) {
|
|
h.mu.Lock()
|
|
h.events = append(h.events, w)
|
|
h.mu.Unlock()
|
|
h.done <- struct{}{}
|
|
},
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (h *harness) clock() time.Time { h.mu.Lock(); defer h.mu.Unlock(); return h.now }
|
|
|
|
func (h *harness) wait(t *testing.T) {
|
|
t.Helper()
|
|
select {
|
|
case <-h.done:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the catch-up never finished")
|
|
}
|
|
}
|
|
|
|
// Off at W (02:30 on the 5th), switched on at 09:00: ONE catch-up, 15 minutes later, all three legs in chain order.
|
|
// RED-PROOF: make Missed return nothing (the pre-v0.295.0 behaviour) → "no catch-up was scheduled".
|
|
func TestCatchUp_OffAtWThenOn_OneCatchUpAfter15Min(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
|
h.l.MarkEnded(LegDBDump, at(4, 2, 31)) // the night of the 4th ran
|
|
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
|
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
|
legs := h.c.Evaluate(context.Background(), "start")
|
|
if len(legs) != 3 {
|
|
t.Fatalf("no catch-up was scheduled for a box off across W (missed %v)", legs)
|
|
}
|
|
h.wait(t)
|
|
if len(h.slept) == 0 || h.slept[0] != DefaultDelay {
|
|
t.Fatalf("the catch-up must wait %s first, slept %v", DefaultDelay, h.slept)
|
|
}
|
|
if got := []Leg{LegDBDump, LegTier2, LegOffsite}; len(h.ran) != 3 || h.ran[0] != got[0] || h.ran[1] != got[1] || h.ran[2] != got[2] {
|
|
t.Fatalf("legs ran %v, want the chain order %v", h.ran, got)
|
|
}
|
|
if len(h.events) != 1 || !h.events[0].Equal(at(5, 4, 15)) {
|
|
t.Fatalf("household line %v, want one for the missed 04:15 leg", h.events)
|
|
}
|
|
}
|
|
|
|
// On at W (the night ran) then a daytime restart: no catch-up.
|
|
// RED-PROOF: drop the `!Ended.Before(inst)` check → the restart makes up a night that ran.
|
|
func TestCatchUp_NightRan_DaytimeRestart_None(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 11, 0))
|
|
h.l.MarkEnded(LegDBDump, at(5, 2, 31))
|
|
h.l.MarkEnded(LegTier2, at(5, 3, 31))
|
|
h.l.MarkEnded(LegOffsite, at(5, 4, 20))
|
|
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
|
t.Fatalf("a normal night was made up again: %v", legs)
|
|
}
|
|
}
|
|
|
|
// Two missed nights: ONE catch-up, each leg once.
|
|
func TestCatchUp_TwoMissedNights_One(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(6, 18, 0))
|
|
h.l.MarkEnded(LegDBDump, at(4, 2, 31))
|
|
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
|
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
|
h.c.Evaluate(context.Background(), "start")
|
|
if again := h.c.Evaluate(context.Background(), "resume"); again != nil {
|
|
t.Fatalf("a second catch-up was scheduled while one was pending: %v", again)
|
|
}
|
|
h.wait(t)
|
|
if len(h.ran) != 3 {
|
|
t.Fatalf("two missed nights ran %v — want each leg once", h.ran)
|
|
}
|
|
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
|
t.Fatalf("after the catch-up nothing is missed any more, got %v", legs)
|
|
}
|
|
}
|
|
|
|
// A power cut in the middle of the chain: only the legs that did not end.
|
|
func TestCatchUp_PowerCutMidChain_FinishesTheRest(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 10, 0))
|
|
h.l.MarkEnded(LegDBDump, at(5, 2, 31)) // the dump ended, then the power went
|
|
h.l.MarkEnded(LegTier2, at(4, 3, 31))
|
|
h.l.MarkEnded(LegOffsite, at(4, 4, 20))
|
|
h.c.Evaluate(context.Background(), "start")
|
|
h.wait(t)
|
|
if len(h.ran) != 2 || h.ran[0] != LegTier2 || h.ran[1] != LegOffsite {
|
|
t.Fatalf("ran %v, want only tier2 + offsite", h.ran)
|
|
}
|
|
}
|
|
|
|
// The first start on this release seeds the ledger: nothing before it is "missed" (no surprise catch-up on upgrade).
|
|
func TestCatchUp_FreshLedger_None(t *testing.T) {
|
|
h := newHarness(t, at(5, 9, 0), at(5, 9, 0))
|
|
if legs := h.c.Evaluate(context.Background(), "start"); legs != nil {
|
|
t.Fatalf("a freshly seeded ledger made up %v", legs)
|
|
}
|
|
}
|
|
|
|
// Started at 02:10 (the dump is due at 02:30): the dump is left to its normal run.
|
|
func TestCatchUp_LegAboutToRun_LeftToNormal(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 2, 10))
|
|
h.l.MarkEnded(LegDBDump, at(3, 2, 31))
|
|
h.l.MarkEnded(LegTier2, at(3, 3, 31))
|
|
h.l.MarkEnded(LegOffsite, at(3, 4, 20))
|
|
legs, _ := h.l.Missed(at(5, 2, 10), "02:30", bud)
|
|
for _, l := range legs {
|
|
if l == LegDBDump {
|
|
t.Fatalf("the dump due in 20 minutes was put in the catch-up: %v", legs)
|
|
}
|
|
}
|
|
if len(legs) != 2 {
|
|
t.Fatalf("missed %v, want tier2 + offsite of the night of the 4th", legs)
|
|
}
|
|
}
|
|
|
|
// App updates are never in a catch-up: only the chain's backup legs are run, whatever else Legs holds.
|
|
// RED-PROOF: iterate c.Legs instead of the missed chain legs → "update" runs.
|
|
func TestCatchUp_NeverRunsAnythingButBackupLegs(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
|
updateRan := false
|
|
h.c.Legs["update"] = func(context.Context) error { updateRan = true; return nil }
|
|
h.c.Evaluate(context.Background(), "start")
|
|
h.wait(t)
|
|
if updateRan {
|
|
t.Fatal("an app update ran inside a catch-up")
|
|
}
|
|
for _, l := range Order {
|
|
if l == "update" || l == "docker" {
|
|
t.Fatalf("the chain order names a non-backup leg %q", l)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A whole-guest backup holding the apps: the catch-up waits for it.
|
|
// RED-PROOF: drop the QuiesceBusy loop → the legs run while busy (ran before the waits).
|
|
func TestCatchUp_WaitsForAWholeGuestBackup(t *testing.T) {
|
|
h := newHarness(t, at(1, 12, 0), at(5, 9, 0))
|
|
h.busy = 3
|
|
h.c.Evaluate(context.Background(), "start")
|
|
h.wait(t)
|
|
if len(h.slept) != 4 || h.slept[1] != time.Minute {
|
|
t.Fatalf("slept %v — want the 15 min delay then 3 one-minute waits for the quiesce", h.slept)
|
|
}
|
|
if len(h.ran) != 3 {
|
|
t.Fatalf("ran %v", h.ran)
|
|
}
|
|
}
|
|
|
|
// A host resume is a trigger too: the wall clock jumps ahead of the monotonic clock.
|
|
// RED-PROOF: compare wall with wall → the suspend is never seen.
|
|
func TestResumeWatch_SeesASuspend(t *testing.T) {
|
|
wall := at(5, 1, 0)
|
|
var mono time.Duration
|
|
tick := make(chan time.Time)
|
|
got := make(chan time.Duration, 2)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
var mu sync.Mutex
|
|
go ResumeWatch(ctx, tick, func() time.Time { mu.Lock(); defer mu.Unlock(); return wall },
|
|
func() time.Duration { mu.Lock(); defer mu.Unlock(); return mono }, 5*time.Minute, func(d time.Duration) { got <- d })
|
|
step := func(w, m time.Duration) {
|
|
mu.Lock()
|
|
wall, mono = wall.Add(w), mono+m
|
|
mu.Unlock()
|
|
tick <- time.Time{}
|
|
}
|
|
step(time.Minute, time.Minute) // a normal minute
|
|
step(7*time.Hour, time.Minute) // suspended 02:00 → 09:00: wall +7h, monotonic +1 min
|
|
select {
|
|
case d := <-got:
|
|
if d < 6*time.Hour {
|
|
t.Fatalf("slept %s, want ~7h", d)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("a 7-hour suspend was not seen")
|
|
}
|
|
select {
|
|
case d := <-got:
|
|
t.Fatalf("a normal minute was read as a resume (%s)", d)
|
|
default:
|
|
}
|
|
}
|