| new file mode 100644 |
| index 000000000..28b01e671 |
| --- /dev/null |
| +++ b/internal/console/selfserve_estimate_test.go |
| @@ -0,0 +1,341 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The estimate and the timings (self-serve moves plan 2 Task 9; spec |
| +// §6.3): the defaults until a region pair has five finished moves' timings |
| +// for a term, then the median of the last twenty; the marks as the |
| +// console first observed them, insert-only; the five move_timings rows a |
| +// finished move derives, and none from an aborted one. |
| + |
| +// ssTiming inserts one move_timings row for the lane ie -> uk. |
| +func ssTiming(t *testing.T, st *Store, id, phase string, bytes int64, seconds float64, at time.Time) { |
| + t.Helper() |
| + if _, err := st.InsertMoveTiming(MoveTiming{ID: id, Phase: phase, From: "ie", To: "uk", Bytes: bytes, Seconds: seconds, At: at}); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// ssRate is how long n bytes take at rate bytes a second. |
| +func ssRate(n int64, rate float64) time.Duration { |
| + return time.Duration(float64(n) / rate * float64(time.Second)) |
| +} |
| + |
| +// ssSeconds is d to the nearest second, for comparing terms. |
| +func ssSeconds(d time.Duration) int64 { return int64(d.Round(time.Second) / time.Second) } |
| + |
| +func TestEstimateUsesDefaultsThenMedians(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + const remaining, files, replica = 100 * mib, 10 * mib, 100 * mib |
| + defaults := estimateTerms{ |
| + Stop: 20 * time.Second, |
| + Copy: 10*time.Second + ssRate(remaining, 25*mib), |
| + Land: 30*time.Second + ssRate(files+replica, 40*mib), |
| + Open: 30 * time.Second, |
| + Promote: 10*time.Second + ssRate(replica, 70*mib), |
| + } |
| + check := func(t *testing.T, from, to string, want estimateTerms) { |
| + t.Helper() |
| + got, seconds := s.estimate(from, to, remaining, files, replica) |
| + for _, c := range []struct { |
| + name string |
| + got, want time.Duration |
| + }{{"stop", got.Stop, want.Stop}, {"copy", got.Copy, want.Copy}, {"land", got.Land, want.Land}, {"open", got.Open, want.Open}, {"promote", got.Promote, want.Promote}} { |
| + if ssSeconds(c.got) != ssSeconds(c.want) { |
| + t.Errorf("%s: %v, want %v", c.name, c.got, c.want) |
| + } |
| + } |
| + total := want.Stop + want.Copy + want.Land + want.Open + want.Promote |
| + if wantSec := int((total+time.Minute-1)/time.Minute) * 60; seconds != wantSec && wantSec >= 120 { |
| + t.Errorf("seconds %d, want %d (%v in whole minutes)", seconds, wantSec, total) |
| + } |
| + } |
| + |
| + t.Run("no timings: the defaults", func(t *testing.T) { check(t, "ie", "uk", defaults) }) |
| + |
| + // Four finished moves: still fewer than five per term. |
| + at := ssT0 |
| + addMove := func(n int, stop, open float64, copyRate, landRate, promoteRate float64) { |
| + at = at.Add(time.Minute) |
| + id := fmt.Sprintf("mv_estimate%02d", n) |
| + ssTiming(t, st, id, "stop", 0, stop, at) |
| + ssTiming(t, st, id, "open", 0, open, at) |
| + ssTiming(t, st, id, "copy", 100*mib, 100*mib/copyRate, at) |
| + ssTiming(t, st, id, "land", 110*mib, 110*mib/landRate, at) |
| + ssTiming(t, st, id, "promote", 100*mib, 100*mib/promoteRate, at) |
| + } |
| + for i := 0; i < 4; i++ { |
| + addMove(i, 60, 90, 10*mib, 5*mib, 2*mib) |
| + } |
| + t.Run("four moves: still the defaults", func(t *testing.T) { check(t, "ie", "uk", defaults) }) |
| + |
| + // A fifth, an outlier: the median is the four's. |
| + addMove(4, 600, 900, 1*mib, 1*mib, 1*mib) |
| + medians := estimateTerms{ |
| + Stop: 60 * time.Second, |
| + Copy: 10*time.Second + 10*time.Second, |
| + Land: 30*time.Second + 22*time.Second, |
| + Open: 90 * time.Second, |
| + Promote: 10*time.Second + 50*time.Second, |
| + } |
| + t.Run("five moves: the medians", func(t *testing.T) { check(t, "ie", "uk", medians) }) |
| + t.Run("another region pair keeps the defaults", func(t *testing.T) { |
| + check(t, "ie", "us", defaults) |
| + check(t, "uk", "ie", defaults) |
| + }) |
| + |
| + // A rate row without bytes is no rate: twenty newer copy rows carrying |
| + // no bytes change nothing. |
| + for i := 0; i < 20; i++ { |
| + at = at.Add(time.Minute) |
| + ssTiming(t, st, fmt.Sprintf("mv_nobytes%02d", i), "copy", 0, 1, at) |
| + } |
| + t.Run("rows without bytes are not rates", func(t *testing.T) { check(t, "ie", "uk", medians) }) |
| + |
| + // The last twenty only: twenty newer moves move every median. |
| + for i := 0; i < 20; i++ { |
| + addMove(10+i, 30, 45, 20*mib, 10*mib, 4*mib) |
| + } |
| + t.Run("the median of the last twenty", func(t *testing.T) { |
| + check(t, "ie", "uk", estimateTerms{ |
| + Stop: 30 * time.Second, |
| + Copy: 10*time.Second + 5*time.Second, |
| + Land: 30*time.Second + 11*time.Second, |
| + Open: 45 * time.Second, |
| + Promote: 10*time.Second + 25*time.Second, |
| + }) |
| + }) |
| +} |
| + |
| +func TestEstimateMinimumIsTwoMinutes(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + // The defaults alone with nothing to carry are 100 seconds. |
| + if _, seconds := s.estimate("ie", "uk", 0, 0, 0); seconds != 120 { |
| + t.Fatalf("an empty instance's estimate is %d s, want 120", seconds) |
| + } |
| + // Five fast moves: every term a second, still two minutes. |
| + for i := 0; i < 5; i++ { |
| + id := fmt.Sprintf("mv_fast%02d", i) |
| + for _, p := range []string{"stop", "open"} { |
| + ssTiming(t, st, id, p, 0, 1, ssT0.Add(time.Duration(i)*time.Minute)) |
| + } |
| + for _, p := range []string{"copy", "land", "promote"} { |
| + ssTiming(t, st, id, p, 1<<40, 1, ssT0.Add(time.Duration(i)*time.Minute)) |
| + } |
| + } |
| + if terms, seconds := s.estimate("ie", "uk", mib, mib, mib); seconds != 120 || terms.Stop != time.Second || terms.Open != time.Second { |
| + t.Fatalf("a fast lane's estimate is %d s (%+v), want 120", seconds, terms) |
| + } |
| + // Whole minutes, rounded up: 121 seconds is three minutes. |
| + st2 := ssStore(t) |
| + s2 := &Server{store: st2} |
| + for i := 0; i < 5; i++ { |
| + ssTiming(t, st2, fmt.Sprintf("mv_slow%02d", i), "stop", 0, 41, ssT0.Add(time.Duration(i)*time.Minute)) |
| + } |
| + // 41 + 10 + 30 + 30 + 10 = 121 s. |
| + if _, seconds := s2.estimate("ie", "uk", 0, 0, 0); seconds != 180 { |
| + t.Fatalf("a 121-second estimate is %d s, want 180", seconds) |
| + } |
| +} |
| + |
| +func TestEstimateBeforeAndAfterStaging(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + const replica, files = 3 << 30, 4 * mib |
| + for i := 0; i < 5; i++ { |
| + ssTiming(t, st, fmt.Sprintf("mv_lane%02d", i), "copy", 100*mib, 2, ssT0.Add(time.Duration(i)*time.Minute)) |
| + } |
| + // Before a move is requested the final copy is the whole replica (the |
| + // door's estimate). |
| + before, beforeSec := s.estimate("ie", "uk", replica, files, replica) |
| + |
| + // Once staged, the last round's copied and deferred bytes. |
| + ssAdmit(t, st, ssMove(ssID, ssHost, ssT0, "ie-1", "uk-1")) |
| + row := ssLoad(t, st, ssID) |
| + mv := &provision.MoveState{Stager: "uk-1-label"} |
| + ev := offlineEvidence{reports: []offlineReport{ |
| + {OfflineReport: moves.OfflineReport{Role: moves.OfflineRoleStager, Box: "uk-1-label", Phase: moves.OfflinePhaseStaged, |
| + ReplicaBytes: replica, RoundCopiedBytes: 6 * mib, RoundDeferredBytes: 4 * mib}}, |
| + {OfflineReport: moves.OfflineReport{Role: moves.OfflineRoleSource, Box: "ie-1-label", Phase: moves.OfflinePhasePrecheck, |
| + Precheck: &moves.SourcePrecheck{FilesBytes: files}}}, |
| + }} |
| + if err := s.selfServeRefineEstimate(row, mv, ev); err != nil { |
| + t.Fatal(err) |
| + } |
| + row = ssLoad(t, st, ssID) |
| + after := estimateTermsOf(row.EstimateTerms) |
| + want, wantSec := s.estimate("ie", "uk", 10*mib, files, replica) |
| + var raw estimateTerms |
| + if err := json.Unmarshal([]byte(row.EstimateTerms), &raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + switch { |
| + case after != want || int(row.EstimateSeconds) != wantSec: |
| + t.Fatalf("the refined estimate %+v (%d s), want %+v (%d s)", after, row.EstimateSeconds, want, wantSec) |
| + case after.Copy >= before.Copy || int(row.EstimateSeconds) >= beforeSec: |
| + t.Fatalf("staging did not shorten the copy: before %v (%d s), after %v (%d s)", before.Copy, beforeSec, after.Copy, row.EstimateSeconds) |
| + case after.Land != before.Land || after.Promote != before.Promote || after.Stop != before.Stop || after.Open != before.Open: |
| + t.Fatalf("staging changed a term it does not measure: before %+v, after %+v", before, after) |
| + case after.Copy != 10*time.Second+ssRate(10*mib, 50*mib): |
| + t.Fatalf("the refined copy %v is not the lane's median rate (50 MiB/s)", after.Copy) |
| + } |
| +} |
| + |
| +// ssMarks writes every mark for id, a second apart from at, with the copy, |
| +// landing and replica bytes. |
| +func ssMarks(t *testing.T, st *Store, id string, at time.Time, skip ...string) { |
| + t.Helper() |
| + skipped := map[string]bool{} |
| + for _, m := range skip { |
| + skipped[m] = true |
| + } |
| + for i, m := range []struct { |
| + mark string |
| + bytes int64 |
| + }{ |
| + {"offline_at", 0}, {"stopped_at", 0}, {"seal_at", 0}, {"sealed_at", 40 * mib}, {"flip_at", 0}, |
| + {"landed_at", 120 * mib}, {"landed_replica", 100 * mib}, {"open_at", 0}, {"opened_at", 0}, {"first_ok_at", 0}, |
| + } { |
| + if skipped[m.mark] { |
| + continue |
| + } |
| + // Each mark is i*i seconds after at, so every phase has its own |
| + // length. |
| + if err := st.MarkSelfServeTiming(id, m.mark, at.Add(time.Duration(i*i)*time.Second), m.bytes); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestTimingMarksAreFirstObservationTimes(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + ssAdmit(t, st, ssMove(ssID, ssHost, ssT0, "ie-1", "uk-1")) |
| + row := ssLoad(t, st, ssID) |
| + first, later := ssT0.Add(time.Minute), ssT0.Add(2*time.Minute) |
| + |
| + s.selfServeMark(row, "offline_at", first, 0) |
| + s.selfServeMark(row, "offline_at", later, 0) |
| + if err := st.MarkSelfServeTiming(ssID, "sealed_at", first, 40*mib); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := st.MarkSelfServeTiming(ssID, "sealed_at", later, 99*mib); err != nil { |
| + t.Fatal(err) |
| + } |
| + marks, err := st.SelfServeTimings(ssID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if m := marks["offline_at"]; !m.At.Equal(first) || m.Bytes != 0 { |
| + t.Fatalf("offline_at = %+v, want the first observation %v", m, first) |
| + } |
| + if m := marks["sealed_at"]; !m.At.Equal(first) || m.Bytes != 40*mib { |
| + t.Fatalf("sealed_at = %+v, want the first observation's time and bytes", m) |
| + } |
| + // One timing event per mark, in the mark's own transaction. |
| + timing := 0 |
| + for _, e := range ssEvents(t, st, ssID) { |
| + if e.Phase == "requested:timing" { |
| + timing++ |
| + } |
| + } |
| + if timing != 2 { |
| + t.Fatalf("%d timing events, want one per mark: %+v", timing, ssEvents(t, st, ssID)) |
| + } |
| + if err := st.MarkSelfServeTiming("mv_absent", "offline_at", first, 0); err == nil { |
| + t.Fatal("a mark for an absent row was written") |
| + } |
| + |
| + // The landed mark goes with the advance that first sees the landing, |
| + // and only if the advance wins. |
| + ssPhase(t, st, ssID, ssLanding, ssT0.Add(time.Hour)) |
| + landed := ssT0.Add(3 * time.Minute) |
| + if ok, err := st.AdvanceSelfServeToStep(ssID, ssSealing, ssLanded, "", "", time.Time{}, time.Time{}, "", landed, |
| + ssMarkTx(ssID, "landed_at", landed, 120*mib)); err != nil || ok { |
| + t.Fatalf("a lost advance = %v, %v", ok, err) |
| + } |
| + if marks, _ := st.SelfServeTimings(ssID); marks["landed_at"].At != (time.Time{}) { |
| + t.Fatalf("a lost advance wrote the landed mark: %+v", marks["landed_at"]) |
| + } |
| + if ok, err := st.AdvanceSelfServeToStep(ssID, ssLanding, ssLanded, "", "", time.Time{}, time.Time{}, "", landed, |
| + ssMarkTx(ssID, "landed_at", landed, 120*mib)); err != nil || !ok { |
| + t.Fatalf("the advance = %v, %v", ok, err) |
| + } |
| + if marks, _ := st.SelfServeTimings(ssID); !marks["landed_at"].At.Equal(landed) || marks["landed_at"].Bytes != 120*mib { |
| + t.Fatalf("landed_at = %+v", marks["landed_at"]) |
| + } |
| +} |
| + |
| +func TestAbortedMovesContributeNoRates(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + ctx := context.Background() |
| + finish := func(id, host, outcome string, skip ...string) *SelfServeMove { |
| + t.Helper() |
| + ssAdmit(t, st, ssMove(id, host, ssT0, "ie-1", "uk-1")) |
| + ssMarks(t, st, id, ssT0, skip...) |
| + if err := st.FinishSelfServeMove(id, outcome, "", "", ssT0.Add(time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + row := ssLoad(t, st, id) |
| + for i := 0; i < 2; i++ { // the effects step again after a crash |
| + if err := s.selfServeTimingsOnFinish(ctx, row); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + return row |
| + } |
| + finish("mv_abortedabortedabortedabort", "a."+ssHost, provision.OfflineOutcomeAborted) |
| + if got, err := st.MoveTimings("ie", "uk", "stop", false, 20); err != nil || len(got) != 0 { |
| + t.Fatalf("an aborted move left timings: %+v, %v", got, err) |
| + } |
| + |
| + finish("mv_donedonedonedonedonedonedo", "b."+ssHost, provision.OfflineOutcomeDone) |
| + // offline 0, stopped 1, seal 4, sealed 9, flip 16, landed 25, replica |
| + // 36, open 49, opened 64, first ok 81 seconds. |
| + want := map[string]MoveTiming{ |
| + "stop": {Seconds: 1}, |
| + "copy": {Seconds: 5, Bytes: 40 * mib}, |
| + "land": {Seconds: 9, Bytes: 120 * mib}, |
| + "open": {Seconds: 15}, |
| + "promote": {Seconds: 32, Bytes: 100 * mib}, |
| + } |
| + for phase, w := range want { |
| + got, err := st.MoveTimings("ie", "uk", phase, false, 20) |
| + if err != nil || len(got) != 1 { |
| + t.Fatalf("%s: %+v, %v; want one row", phase, got, err) |
| + } |
| + if g := got[0]; g.ID != "mv_donedonedonedonedonedonedo" || g.Seconds != w.Seconds || g.Bytes != w.Bytes || g.From != "ie" || g.To != "uk" { |
| + t.Errorf("%s: %+v, want %+v", phase, g, w) |
| + } |
| + } |
| + |
| + // A done move without a mark contributes no row for its phase. |
| + finish("mv_nofirstoknofirstoknofirsto", "c."+ssHost, provision.OfflineOutcomeDone, "first_ok_at", "seal_at") |
| + for phase, n := range map[string]int{"promote": 1, "copy": 1, "stop": 2, "land": 2, "open": 2} { |
| + if got, _ := st.MoveTimings("ie", "uk", phase, false, 20); len(got) != n { |
| + t.Errorf("%s: %d rows, want %d", phase, len(got), n) |
| + } |
| + } |
| + |
| + // Rates come only from finished moves: five aborted moves with slow |
| + // copies change no term. |
| + before, _ := s.estimate("ie", "uk", 100*mib, 0, 0) |
| + for i := 0; i < 5; i++ { |
| + finish(fmt.Sprintf("mv_slowabort%02d", i), fmt.Sprintf("s%d.%s", i, ssHost), provision.OfflineOutcomeAborted) |
| + } |
| + if after, _ := s.estimate("ie", "uk", 100*mib, 0, 0); after != before { |
| + t.Fatalf("aborted moves changed the estimate: %+v, then %+v", before, after) |
| + } |
| +} |