8779c88d79
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
377 lines
12 KiB
Go
377 lines
12 KiB
Go
package startup
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
func TestBeginStepOpensRegisteredStepOpaqueOrCounting(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(BackingUpDatabase)
|
|
r.BeginStep(StepDatabaseBackup)
|
|
snap := r.Snapshot()
|
|
if snap.Step == nil {
|
|
t.Fatal("expected an active step")
|
|
}
|
|
if snap.Step.ID != StepDatabaseBackup {
|
|
t.Fatalf("step id = %q, want %q", snap.Step.ID, StepDatabaseBackup)
|
|
}
|
|
if snap.Step.Measure != MeasureCounting {
|
|
t.Fatalf("database.backup should open counting, got %q", snap.Step.Measure)
|
|
}
|
|
|
|
r2 := New(nil)
|
|
r2.Set(ApplyingMigrations)
|
|
r2.BeginStep(StepStoresRepositories)
|
|
snap2 := r2.Snapshot()
|
|
if snap2.Step.Measure != MeasureOpaque {
|
|
t.Fatalf("counted-declared step should open opaque, got %q", snap2.Step.Measure)
|
|
}
|
|
}
|
|
|
|
func TestBeginStepUnregisteredIdentifierOpensNoStep(t *testing.T) {
|
|
r := New(nil)
|
|
seqBefore := r.Snapshot().Seq
|
|
r.BeginStep(StepID("not.a.real.step"))
|
|
snap := r.Snapshot()
|
|
if snap.Step != nil {
|
|
t.Fatal("unregistered identifier must not open a step")
|
|
}
|
|
if snap.Seq != seqBefore {
|
|
t.Fatalf("seq changed on unregistered begin: before=%d after=%d", seqBefore, snap.Seq)
|
|
}
|
|
}
|
|
|
|
func TestBeginStepAlreadyActiveIsNoOp(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SeedDone(StepStoresRepositories, 5)
|
|
r.SetTotal(StepStoresRepositories, 50)
|
|
seqAfterFirst := r.Snapshot().Seq
|
|
|
|
r.BeginStep(StepStoresRepositories)
|
|
snap := r.Snapshot()
|
|
if snap.Seq != seqAfterFirst {
|
|
t.Fatalf("re-beginning the active step changed seq: %d -> %d", seqAfterFirst, snap.Seq)
|
|
}
|
|
if *snap.Step.Done != 5 {
|
|
t.Fatalf("re-beginning the active step reset counts: done=%d", *snap.Step.Done)
|
|
}
|
|
}
|
|
|
|
func TestBeginStepEndsPriorAsOneTransition(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
seq0 := r.Snapshot().Seq
|
|
|
|
r.BeginStep(StepPromptSeqBackfill)
|
|
snap := r.Snapshot()
|
|
if snap.Seq != seq0+1 {
|
|
t.Fatalf("switching active steps should increment seq by exactly one, got %d -> %d", seq0, snap.Seq)
|
|
}
|
|
if snap.Step.ID != StepPromptSeqBackfill {
|
|
t.Fatalf("active step = %q, want %q", snap.Step.ID, StepPromptSeqBackfill)
|
|
}
|
|
}
|
|
|
|
func TestEndStepClosesOnlyTheActiveStep(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
seq0 := r.Snapshot().Seq
|
|
|
|
r.EndStep(StepStoresServices) // not active: no-op
|
|
if r.Snapshot().Seq != seq0 {
|
|
t.Fatal("ending a non-active step must not change seq")
|
|
}
|
|
if r.Snapshot().Step == nil {
|
|
t.Fatal("ending a non-active step must not close the active one")
|
|
}
|
|
|
|
r.EndStep(StepStoresRepositories)
|
|
snap := r.Snapshot()
|
|
if snap.Seq != seq0+1 {
|
|
t.Fatalf("ending the active step should increment seq by one, got %d -> %d", seq0, snap.Seq)
|
|
}
|
|
if snap.Step != nil {
|
|
t.Fatal("snapshot must retain no step after EndStep")
|
|
}
|
|
}
|
|
|
|
func TestSeedDoneAssignsUntilTotalOrAdvance(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
|
|
r.SeedDone(StepStoresRepositories, -1) // rejected; still opaque, done unexposed
|
|
if got := r.Snapshot().Step; got.Measure != MeasureOpaque {
|
|
t.Fatalf("negative seed must be rejected without promoting, measure = %q", got.Measure)
|
|
}
|
|
|
|
r.SeedDone(StepStoresRepositories, 10)
|
|
r.SetTotal(StepStoresRepositories, 100) // promotes: done should reflect the seed
|
|
if *r.Snapshot().Step.Done != 10 {
|
|
t.Fatalf("done = %d, want 10", *r.Snapshot().Step.Done)
|
|
}
|
|
|
|
r.SeedDone(StepStoresRepositories, 20) // rejected: total already set
|
|
if *r.Snapshot().Step.Done != 10 {
|
|
t.Fatalf("seed after total must be rejected, done = %d", *r.Snapshot().Step.Done)
|
|
}
|
|
}
|
|
|
|
func TestSeedDoneRejectedAfterAdvance(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 100)
|
|
r.Advance(StepStoresRepositories, 5)
|
|
|
|
r.SeedDone(StepStoresRepositories, 50)
|
|
if *r.Snapshot().Step.Done != 5 {
|
|
t.Fatalf("seed after advance must be rejected, done = %d", *r.Snapshot().Step.Done)
|
|
}
|
|
}
|
|
|
|
func TestSetTotalPromotesAndClampsSeededDone(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SeedDone(StepStoresRepositories, 90)
|
|
|
|
r.SetTotal(StepStoresRepositories, -1)
|
|
if r.Snapshot().Step.Measure != MeasureOpaque {
|
|
t.Fatal("negative total must be rejected without promoting")
|
|
}
|
|
|
|
r.SetTotal(StepStoresRepositories, 50) // below the seeded done: clamp
|
|
snap := r.Snapshot().Step
|
|
if snap.Measure != MeasureCounted {
|
|
t.Fatalf("measure = %q, want counted", snap.Measure)
|
|
}
|
|
if *snap.Done != 50 {
|
|
t.Fatalf("seeded done should clamp to total, got %d", *snap.Done)
|
|
}
|
|
if *snap.Total != 50 {
|
|
t.Fatalf("total = %d, want 50", *snap.Total)
|
|
}
|
|
}
|
|
|
|
func TestSetTotalZeroSealsOpaqueForActivation(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 0)
|
|
if snap := r.Snapshot().Step; snap.Measure != MeasureOpaque {
|
|
t.Fatalf("total of zero must seal the step opaque, got %q", snap.Measure)
|
|
}
|
|
r.SetTotal(StepStoresRepositories, 10) // later different total must not revive it
|
|
if snap := r.Snapshot().Step; snap.Measure != MeasureOpaque {
|
|
t.Fatalf("a later total must not revive a zero-total step, got %q", snap.Measure)
|
|
}
|
|
}
|
|
|
|
func TestSetTotalRejectsADifferentTotalOnceAccepted(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 100)
|
|
r.SetTotal(StepStoresRepositories, 100) // equal: no-op, no warning
|
|
r.SetTotal(StepStoresRepositories, 200) // different: rejected
|
|
if *r.Snapshot().Step.Total != 100 {
|
|
t.Fatalf("total = %d, want 100 (unchanged)", *r.Snapshot().Step.Total)
|
|
}
|
|
}
|
|
|
|
func TestAdvanceClampsToTotalAndSaturates(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 10)
|
|
|
|
r.Advance(StepStoresRepositories, 0) // no-op
|
|
if *r.Snapshot().Step.Done != 0 {
|
|
t.Fatal("advance of zero must be a no-op")
|
|
}
|
|
r.Advance(StepStoresRepositories, -5) // no-op
|
|
if *r.Snapshot().Step.Done != 0 {
|
|
t.Fatal("negative advance must be a no-op")
|
|
}
|
|
r.Advance(StepStoresRepositories, 999)
|
|
if *r.Snapshot().Step.Done != 10 {
|
|
t.Fatalf("advance exceeding total must clamp, done = %d", *r.Snapshot().Step.Done)
|
|
}
|
|
}
|
|
|
|
func TestAdvanceOnNonActiveIdentifierIsNoOp(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 10)
|
|
r.Advance(StepStoresServices, 5) // not the active step
|
|
if *r.Snapshot().Step.Done != 0 {
|
|
t.Fatal("advance naming a non-active identifier must change nothing")
|
|
}
|
|
}
|
|
|
|
// TestMismatchOnNonActiveStepWarnsOncePerOperation covers
|
|
// SeedDone/SetTotal/Advance/Degrade calls naming a step that isn't active:
|
|
// without a warning these are silent no-ops, leaving an operator no way to
|
|
// learn a caller has drifted from the reporter's real active step. Each
|
|
// mismatched call must log exactly one warning per (step, operation) pair,
|
|
// not once per call.
|
|
func TestMismatchOnNonActiveStepWarnsOncePerOperation(t *testing.T) {
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
log, err := logger.NewFromZap(zap.New(core))
|
|
if err != nil {
|
|
t.Fatalf("observer logger: %v", err)
|
|
}
|
|
r := New(log)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
|
|
r.SeedDone(StepStoresServices, 5)
|
|
r.SeedDone(StepStoresServices, 5) // repeat: must not log again
|
|
r.SetTotal(StepStoresServices, 10)
|
|
r.Advance(StepStoresServices, 1)
|
|
r.Degrade(StepStoresServices)
|
|
|
|
wantOps := map[string]bool{"seed_done": false, "set_total": false, "advance": false, "degrade": false}
|
|
var mismatchCount int
|
|
for _, entry := range observed.All() {
|
|
if entry.Message != "Startup step call for a step that is not active" {
|
|
continue
|
|
}
|
|
mismatchCount++
|
|
fields := entry.ContextMap()
|
|
if fields["step"] != string(StepStoresServices) {
|
|
t.Fatalf("warning step = %v, want %q", fields["step"], StepStoresServices)
|
|
}
|
|
if fields["active_step"] != string(StepStoresRepositories) {
|
|
t.Fatalf("warning active_step = %v, want %q", fields["active_step"], StepStoresRepositories)
|
|
}
|
|
op, _ := fields["op"].(string)
|
|
if _, known := wantOps[op]; !known {
|
|
t.Fatalf("unexpected op %q in mismatch warning", op)
|
|
}
|
|
wantOps[op] = true
|
|
}
|
|
if mismatchCount != 4 {
|
|
t.Fatalf("mismatch warning count = %d, want 4 (one per operation, repeat suppressed)", mismatchCount)
|
|
}
|
|
for op, seen := range wantOps {
|
|
if !seen {
|
|
t.Fatalf("no mismatch warning logged for op %q", op)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDegradeMovesDownOneRungAndNeverBack(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
if r.Snapshot().Step.Measure != MeasureOpaque {
|
|
t.Fatal("precondition: step should open opaque")
|
|
}
|
|
r.SetTotal(StepStoresRepositories, 10) // promotes to counted
|
|
r.Degrade(StepStoresRepositories)
|
|
if got := r.Snapshot().Step.Measure; got != MeasureCounting {
|
|
t.Fatalf("measure after one degrade = %q, want counting", got)
|
|
}
|
|
r.Degrade(StepStoresRepositories)
|
|
if got := r.Snapshot().Step.Measure; got != MeasureOpaque {
|
|
t.Fatalf("measure after two degrades = %q, want opaque", got)
|
|
}
|
|
r.SetTotal(StepStoresRepositories, 20) // must never re-promote this activation
|
|
if got := r.Snapshot().Step.Measure; got != MeasureOpaque {
|
|
t.Fatalf("measure after SetTotal post-degrade = %q, want opaque (no re-promotion)", got)
|
|
}
|
|
}
|
|
|
|
func TestDegradeBeforeSetTotalBlocksLaterPromotion(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.Degrade(StepStoresRepositories) // fails counter query before total is known
|
|
r.SetTotal(StepStoresRepositories, 100)
|
|
if got := r.Snapshot().Step.Measure; got != MeasureOpaque {
|
|
t.Fatalf("measure = %q, want opaque: a pre-total degrade must block promotion", got)
|
|
}
|
|
}
|
|
|
|
func TestSnapshotHasNoStepAfterEndStep(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.EndStep(StepStoresRepositories)
|
|
if r.Snapshot().Step != nil {
|
|
t.Fatal("snapshot must not retain an earlier step's identifier")
|
|
}
|
|
}
|
|
|
|
// TestRateSampleTrimFoldsDeltaIntoAnchorDone covers the rate-sample ring:
|
|
// once it exceeds maxRateSamples, appendSample drops the oldest entries. If
|
|
// that drop left anchorDone stale, rateWindowStart's cumulative-sum walk
|
|
// would understate "done at the window's older edge" by exactly the dropped
|
|
// samples' total delta, overstating the rate reported for a fast sweep that
|
|
// advances more than maxRateSamples times within one 30s window (e.g.
|
|
// storage.go's required-store sweeps).
|
|
func TestRateSampleTrimFoldsDeltaIntoAnchorDone(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
r.BeginStep(StepStoresRepositories)
|
|
r.SetTotal(StepStoresRepositories, 1000)
|
|
|
|
const advances = maxRateSamples + 36
|
|
for i := 0; i < advances; i++ {
|
|
r.Advance(StepStoresRepositories, 1)
|
|
}
|
|
|
|
if got := len(r.active.samples); got != maxRateSamples {
|
|
t.Fatalf("retained samples = %d, want %d (ring capped)", got, maxRateSamples)
|
|
}
|
|
wantDropped := int64(advances - maxRateSamples)
|
|
if r.active.anchorDone != wantDropped {
|
|
t.Fatalf("anchorDone = %d, want %d (the %d trimmed samples' folded delta)", r.active.anchorDone, wantDropped, wantDropped)
|
|
}
|
|
|
|
_, windowDone := r.active.rateWindowStart(time.Now())
|
|
if windowDone != wantDropped {
|
|
t.Fatalf("rateWindowStart done = %d, want %d: a stale anchorDone understates the window baseline and overstates the rate", windowDone, wantDropped)
|
|
}
|
|
}
|
|
|
|
func TestPackageLevelHelpersAreNoOpsWithoutReporter(t *testing.T) {
|
|
ctx := context.Background() // no reporter attached
|
|
BeginStep(ctx, StepStoresRepositories)
|
|
SeedDone(ctx, StepStoresRepositories, 1)
|
|
SetTotal(ctx, StepStoresRepositories, 1)
|
|
Advance(ctx, StepStoresRepositories, 1)
|
|
Degrade(ctx, StepStoresRepositories)
|
|
EndStep(ctx, StepStoresRepositories) // must not panic
|
|
}
|
|
|
|
func TestPackageLevelHelpersDelegateToReporter(t *testing.T) {
|
|
r := New(nil)
|
|
r.Set(ApplyingMigrations)
|
|
ctx := WithReporter(context.Background(), r)
|
|
BeginStep(ctx, StepStoresRepositories)
|
|
SetTotal(ctx, StepStoresRepositories, 10)
|
|
Advance(ctx, StepStoresRepositories, 3)
|
|
if *r.Snapshot().Step.Done != 3 {
|
|
t.Fatalf("done = %d, want 3", *r.Snapshot().Step.Done)
|
|
}
|
|
EndStep(ctx, StepStoresRepositories)
|
|
if r.Snapshot().Step != nil {
|
|
t.Fatal("expected no active step after EndStep")
|
|
}
|
|
}
|