Skip to content
quarry Artifact [01539fd773]
Login

Artifact [01539fd773]

Artifact 01539fd7739a34e558fc2b75e56168369fbdd17b0692a8d298c6ce36d6c380f8:


package obs

import (
	"os"
	"path/filepath"
	"reflect"
	"testing"
	"time"
)

func restored(t *testing.T, path string, at time.Time) *signalRing {
	t.Helper()
	var s signalRing
	s.restore(path, at)
	return &s
}

// The hour is kept across a restart: what the first process counted is
// there for the second, the minutes between the two are a gap and not
// zeros, and both starts are on the record.
func TestTheHourSurvivesARestartWithTheDownTimeAsAGap(t *testing.T) {
	path := filepath.Join(t.TempDir(), "ops-hour.json")
	first := restored(t, path, t0)
	first.render(t0, true)
	first.render(t0.Add(time.Minute), false)
	first.running(t0.Add(time.Minute), 4)
	first.save(path, t0.Add(time.Minute+30*time.Second))

	// Down for three minutes: it comes back at t0+5m.
	back := t0.Add(5*time.Minute + 10*time.Second)
	second := restored(t, path, back)
	got := second.snapshot(back)

	if want := back.Truncate(time.Minute).UTC().Format(time.RFC3339); got.End != want {
		t.Fatalf("FAIL: end %s, want %s", got.End, want)
	}
	n := SignalMinutes
	// Slots, newest last: t0+5 (now), t0+4, t0+3, t0+2 (down), t0+1, t0.
	if want := []int{1, 1, 0, 0, 0, 0}; !reflect.DeepEqual(got.Renders[n-6:], want) {
		t.Errorf("FAIL: renders %v, want %v", got.Renders[n-6:], want)
	}
	if want := []int{0, 4, 0, 0, 0, 0}; !reflect.DeepEqual(got.Busy[n-6:], want) {
		t.Errorf("FAIL: busy %v, want %v", got.Busy[n-6:], want)
	}
	if want := []bool{true, true, false, false, false, true}; !reflect.DeepEqual(got.Up[n-6:], want) {
		t.Errorf("FAIL: up %v, want %v", got.Up[n-6:], want)
	}
	if want := []string{t0.Format(time.RFC3339), back.UTC().Format(time.RFC3339)}; !reflect.DeepEqual(got.Starts, want) {
		t.Errorf("FAIL: starts %v, want %v", got.Starts, want)
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

// A file that is missing, unreadable, from another version or the wrong
// size is an empty hour and a start, never an error.
func TestABadStateFileIsAnEmptyHour(t *testing.T) {
	dir := t.TempDir()
	cases := map[string]func(p string){
		"missing": func(string) {},
		"garbage": func(p string) { os.WriteFile(p, []byte("not json {"), 0o644) },
		"empty":   func(p string) { os.WriteFile(p, nil, 0o644) },
		"version": func(p string) { os.WriteFile(p, []byte(`{"v":99}`), 0o644) },
		"shortbin": func(p string) {
			os.WriteFile(p, []byte(`{"v":1,"end":"2026-09-16T12:00:00Z","renders":[1],"slow":[1],"busy":[1],"up":[true]}`), 0o644)
		},
		"negative": func(p string) {
			bad := `{"v":1,"end":"2026-09-16T12:00:00Z","renders":[` + zeros(SignalMinutes, "-1") + `],"slow":[` + zeros(SignalMinutes, "0") + `],"busy":[` + zeros(SignalMinutes, "0") + `],"up":[` + zeros(SignalMinutes, "true") + `]}`
			os.WriteFile(p, []byte(bad), 0o644)
		},
		"directory": func(p string) { os.Mkdir(p, 0o755) },
	}
	for name, setup := range cases {
		p := filepath.Join(dir, name)
		setup(p)
		got := restored(t, p, t0).snapshot(t0)
		if sumInts(got.Renders) != 0 || sumInts(got.Busy) != 0 {
			t.Errorf("FAIL: %s: counts survived a bad file: %v", name, got.Renders)
		}
		for i, up := range got.Up[:SignalMinutes-1] {
			if up {
				t.Errorf("FAIL: %s: minute %d is live in an empty hour", name, i)
			}
		}
		if len(got.Starts) != 1 {
			t.Errorf("FAIL: %s: starts %v, want just this one", name, got.Starts)
		}
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

func zeros(n int, v string) string {
	s := v
	for i := 1; i < n; i++ {
		s += "," + v
	}
	return s
}

// Anything older than the hour is dropped on load: counts, and starts.
func TestABucketOlderThanAnHourIsDropped(t *testing.T) {
	path := filepath.Join(t.TempDir(), "ops-hour.json")
	first := restored(t, path, t0)
	first.render(t0, false)
	first.save(path, t0)

	later := t0.Add(61 * time.Minute)
	got := restored(t, path, later).snapshot(later)
	if sumInts(got.Renders) != 0 {
		t.Errorf("FAIL: a count from over an hour ago survived: %v", got.Renders)
	}
	if len(got.Starts) != 1 || got.Starts[0] != later.UTC().Format(time.RFC3339) {
		t.Errorf("FAIL: starts %v, want only the one inside the hour", got.Starts)
	}
	for i, up := range got.Up[:SignalMinutes-1] {
		if up {
			t.Errorf("FAIL: minute %d is live though the forge was down for all of it", i)
		}
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

// Saving replaces the file whole and leaves nothing else beside it.
func TestSavingLeavesOneFile(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "ops-hour.json")
	s := restored(t, path, t0)
	s.save(path, t0)
	s.save(path, t0.Add(time.Minute))
	ents, _ := os.ReadDir(dir)
	if len(ents) != 1 || ents[0].Name() != "ops-hour.json" {
		t.Errorf("FAIL: the directory holds %v", ents)
	}
	if b, _ := os.ReadFile(path); len(b) > 4096 {
		t.Errorf("FAIL: the state file is %d bytes, which is more than an hour of counts needs", len(b))
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

// A file that ends after now was written under a clock that has since
// stepped back. Loading it would hold the ring in a minute that has not
// come, so it is an empty hour, and the next save is of the real one.
func TestAFileFromTheFutureIsAnEmptyHour(t *testing.T) {
	path := filepath.Join(t.TempDir(), "ops-hour.json")
	first := restored(t, path, t0.Add(2*time.Hour))
	first.render(t0.Add(2*time.Hour), true)
	first.save(path, t0.Add(2*time.Hour))

	got := restored(t, path, t0).snapshot(t0)
	if want := t0.UTC().Format(time.RFC3339); got.End != want {
		t.Errorf("FAIL: end %s, want %s", got.End, want)
	}
	if sumInts(got.Renders) != 0 || len(got.Starts) != 1 {
		t.Errorf("FAIL: the future hour survived: %v starts %v", got.Renders, got.Starts)
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

// A clock that steps back while the process runs must not freeze the
// charts: the newest bin follows the clock, and counts land in it.
func TestAClockStepBackDoesNotFreezeTheHour(t *testing.T) {
	var s signalRing
	s.render(t0.Add(2*time.Hour), false)
	back := t0.Add(time.Hour)
	s.render(back, false)
	s.render(back.Add(time.Minute), false)
	got := s.snapshot(back.Add(time.Minute))
	if want := back.Add(time.Minute).UTC().Format(time.RFC3339); got.End != want {
		t.Errorf("FAIL: end %s, want %s", got.End, want)
	}
	if got.Renders[SignalMinutes-1] != 1 || sumInts(got.Renders) != 3 {
		t.Errorf("FAIL: the renders after the step are not in the newest bins: %v", got.Renders[SignalMinutes-3:])
	}
	if !t.Failed() {
		t.Log("ok")
	}
}

// The minute's tick and the shutdown save may overlap; whichever ends
// last must leave a whole, current file.
func TestSavesDoNotCollide(t *testing.T) {
	path := filepath.Join(t.TempDir(), "ops-hour.json")
	s := restored(t, path, t0)
	done := make(chan struct{})
	for i := 0; i < 8; i++ {
		go func(i int) {
			s.render(t0, false)
			s.save(path, t0.Add(time.Duration(i)*time.Second))
			done <- struct{}{}
		}(i)
	}
	for i := 0; i < 8; i++ {
		<-done
	}
	f, ok := readSignalFile(path)
	if !ok {
		t.Fatal("FAIL: the file is not whole after overlapping saves")
	}
	if f.Renders[SignalMinutes-1] != 8 {
		t.Errorf("FAIL: the file holds %d renders, want all 8 from the last save", f.Renders[SignalMinutes-1])
	}
	if !t.Failed() {
		t.Log("ok")
	}
}