console: a rate term's rate is measured past its fixed part (bytes / max(seconds - fixed, 100 ms) over rows of at least 1 MiB), and rows under the floor measure the fixed part, so small final copies no longer predict a large replica as hours
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+134
−51
internal/console/selfserve_estimate.go+71 −37internal/console/selfserve_estimate_test.go+60 −11internal/e2e/selfserve/cut_test.go+3 −3
diff --git a/internal/console/selfserve_estimate.go b/internal/console/selfserve_estimate.go| index 8a3b67a4a..d05799c8e 100644 |
| --- a/internal/console/selfserve_estimate.go |
| +++ b/internal/console/selfserve_estimate.go |
| @@ -20,10 +20,10 @@ import ( |
| // |
| // Each term starts at its default and, once the region pair has five |
| // finished moves' timings for it, is refined from the median of the last |
| -// twenty (Task 9): a fixed term (stop, open) takes the median of the |
| -// phase's seconds; a rate term (copy, land, promote) the median of |
| -// bytes/seconds over the rows that carried bytes, and keeps its default |
| -// fixed part. The timings are the marks the console observed (console |
| +// twenty (Task 9): every term's fixed part from the rows under 1 MiB (the |
| +// median of their seconds), and a rate term's (copy, land, promote) rate |
| +// from the rows of at least 1 MiB, measured past that fixed part |
| +// (estimateRules). The timings are the marks the console observed (console |
| // observation times, insert-only, never overwritten) and the five rows a |
| // finished move derives from them; a move that aborted, or lacks a mark, |
| // contributes no row for that phase. |
| @@ -68,50 +68,70 @@ const ( |
| estimateMinRows = 5 |
| ) |
| +// estimateRateFloor is the least a timing row must carry to measure a |
| +// rate; a row under it measures its term's fixed part only. |
| +const estimateRateFloor = mib |
| + |
| +// estimateRateEpsilon bounds a rate sample's time past the fixed part |
| +// from below: a row whose seconds are all overhead (a small final copy) |
| +// says its bytes moved in at most this long, never in no time. |
| +const estimateRateEpsilon = 100 * time.Millisecond |
| + |
| // estimateRules are the terms' rules for the region pair from -> to: the |
| -// defaults, each refined from the pair's timings when it has enough. A |
| -// timing that cannot be read keeps the default (logged): the estimate is |
| -// never refused for it. |
| +// defaults, each refined from the pair's timings when it has enough. For |
| +// every term, the rows under estimateRateFloor measure its fixed part: the |
| +// median of their seconds once there are estimateMinRows of the last |
| +// estimateWindow, else the default. For a rate term (copy, land, promote) |
| +// the rows at or over the floor measure its rate past that fixed part: |
| +// the median of bytes / max(seconds - fixed, estimateRateEpsilon), once |
| +// there are enough, else the default. So a phase's overhead is counted |
| +// once, in the fixed part, and never again in the rate. A timing that |
| +// cannot be read keeps the default (logged): the estimate is never refused |
| +// for it. |
| func (s *Server) estimateRules(from, to string) struct{ Stop, Copy, Land, Open, Promote term } { |
| r := estimateDefaults |
| if s == nil || s.store == nil { |
| return r |
| } |
| - fixed := func(phase string, t *term) { |
| - rows, err := s.store.MoveTimings(from, to, phase, false, estimateWindow) |
| + window := func(phase string, rated bool) []MoveTiming { |
| + rows, err := s.store.moveTimingWindow(from, to, phase, rated, estimateWindow) |
| if err != nil { |
| log.Printf("console: the move estimate: the %s timings for %s -> %s: %v (the default stands)", phase, from, to, err) |
| - return |
| + return nil |
| } |
| if len(rows) < estimateMinRows { |
| - return |
| - } |
| - xs := make([]float64, len(rows)) |
| - for i, m := range rows { |
| - xs[i] = m.Seconds |
| + return nil |
| } |
| - t.Fixed = time.Duration(median(xs) * float64(time.Second)) |
| + return rows |
| } |
| - rate := func(phase string, t *term) { |
| - rows, err := s.store.MoveTimings(from, to, phase, true, estimateWindow) |
| - if err != nil { |
| - log.Printf("console: the move estimate: the %s timings for %s -> %s: %v (the default stands)", phase, from, to, err) |
| - return |
| + refine := func(phase string, t *term) { |
| + if rows := window(phase, false); rows != nil { |
| + xs := make([]float64, len(rows)) |
| + for i, m := range rows { |
| + xs[i] = m.Seconds |
| + } |
| + t.Fixed = time.Duration(median(xs) * float64(time.Second)) |
| } |
| - if len(rows) < estimateMinRows { |
| - return |
| + if t.BytesPerSecond == 0 { |
| + return // a fixed term |
| } |
| - xs := make([]float64, len(rows)) |
| - for i, m := range rows { |
| - xs[i] = float64(m.Bytes) / m.Seconds |
| + if rows := window(phase, true); rows != nil { |
| + xs := make([]float64, len(rows)) |
| + for i, m := range rows { |
| + past := m.Seconds - t.Fixed.Seconds() |
| + if past < estimateRateEpsilon.Seconds() { |
| + past = estimateRateEpsilon.Seconds() |
| + } |
| + xs[i] = float64(m.Bytes) / past |
| + } |
| + t.BytesPerSecond = median(xs) |
| } |
| - t.BytesPerSecond = median(xs) |
| } |
| - fixed("stop", &r.Stop) |
| - rate("copy", &r.Copy) |
| - rate("land", &r.Land) |
| - fixed("open", &r.Open) |
| - rate("promote", &r.Promote) |
| + refine("stop", &r.Stop) |
| + refine("copy", &r.Copy) |
| + refine("land", &r.Land) |
| + refine("open", &r.Open) |
| + refine("promote", &r.Promote) |
| return r |
| } |
| @@ -248,12 +268,26 @@ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id, phase) DO NOTHING`, |
| } |
| // MoveTimings are the region pair's newest rows for phase, at most limit, |
| -// newest first; withBytes keeps only the rows that carried bytes (a rate's |
| -// rows). |
| -func (s *Store) MoveTimings(from, to, phase string, withBytes bool, limit int) ([]MoveTiming, error) { |
| +// newest first. |
| +func (s *Store) MoveTimings(from, to, phase string, limit int) ([]MoveTiming, error) { |
| + return s.moveTimings(`AND seconds >= 0`, from, to, phase, limit) |
| +} |
| + |
| +// moveTimingWindow is the estimate's window of a phase's rows: the rated |
| +// rows (at least estimateRateFloor bytes, a positive time) or the rows |
| +// under the floor, which measure the term's fixed part; newest first, at |
| +// most limit. |
| +func (s *Store) moveTimingWindow(from, to, phase string, rated bool, limit int) ([]MoveTiming, error) { |
| + if rated { |
| + return s.moveTimings(fmt.Sprintf(`AND bytes >= %d AND seconds > 0`, int64(estimateRateFloor)), from, to, phase, limit) |
| + } |
| + return s.moveTimings(fmt.Sprintf(`AND bytes < %d AND seconds >= 0`, int64(estimateRateFloor)), from, to, phase, limit) |
| +} |
| + |
| +func (s *Store) moveTimings(cond, from, to, phase string, limit int) ([]MoveTiming, error) { |
| rows, err := s.db.Query(`SELECT id, phase, from_region, to_region, bytes, seconds, at FROM move_timings |
| -WHERE from_region = ? AND to_region = ? AND phase = ? AND seconds >= 0 AND (? = 0 OR (bytes > 0 AND seconds > 0)) |
| -ORDER BY at DESC, id DESC LIMIT ?`, from, to, phase, ssBool(withBytes), limit) |
| +WHERE from_region = ? AND to_region = ? AND phase = ? `+cond+` |
| +ORDER BY at DESC, id DESC LIMIT ?`, from, to, phase, limit) |
| if err != nil { |
| return nil, err |
| } |
diff --git a/internal/console/selfserve_estimate_test.go b/internal/console/selfserve_estimate_test.go| index 28b01e671..134d48448 100644 |
| --- a/internal/console/selfserve_estimate_test.go |
| +++ b/internal/console/selfserve_estimate_test.go |
| @@ -70,9 +70,11 @@ func TestEstimateUsesDefaultsThenMedians(t *testing.T) { |
| 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) |
| + // Each rate phase's seconds carry the default fixed part, as a |
| + // real phase's do: seconds = fixed + bytes/rate. |
| + ssTiming(t, st, id, "copy", 100*mib, 10+100*mib/copyRate, at) |
| + ssTiming(t, st, id, "land", 110*mib, 30+110*mib/landRate, at) |
| + ssTiming(t, st, id, "promote", 100*mib, 10+100*mib/promoteRate, at) |
| } |
| for i := 0; i < 4; i++ { |
| addMove(i, 60, 90, 10*mib, 5*mib, 2*mib) |
| @@ -94,13 +96,21 @@ func TestEstimateUsesDefaultsThenMedians(t *testing.T) { |
| check(t, "uk", "ie", defaults) |
| }) |
| - // A rate row without bytes is no rate: twenty newer copy rows carrying |
| - // no bytes change nothing. |
| + // A row under the rate floor (1 MiB) is no rate: it measures the |
| + // term's fixed part. Twenty newer copies of half a MiB at 4 s make the |
| + // copy's fixed part 4 s, and its rate is then measured past those 4 s |
| + // (100 MiB in 20 - 4 s), so 100 MiB still takes 20 s. |
| for i := 0; i < 20; i++ { |
| at = at.Add(time.Minute) |
| - ssTiming(t, st, fmt.Sprintf("mv_nobytes%02d", i), "copy", 0, 1, at) |
| + ssTiming(t, st, fmt.Sprintf("mv_nobytes%02d", i), "copy", mib/2, 4, at) |
| } |
| - t.Run("rows without bytes are not rates", func(t *testing.T) { check(t, "ie", "uk", medians) }) |
| + t.Run("rows under the floor measure the fixed part", func(t *testing.T) { |
| + check(t, "ie", "uk", medians) |
| + terms := s.estimateRules("ie", "uk") |
| + if terms.Copy.Fixed != 4*time.Second || ssSeconds(time.Duration(float64(100*mib)/terms.Copy.BytesPerSecond*float64(time.Second))) != 16 { |
| + t.Fatalf("copy rule %+v, want 4 s fixed and 100 MiB in 16 s", terms.Copy) |
| + } |
| + }) |
| // The last twenty only: twenty newer moves move every median. |
| for i := 0; i < 20; i++ { |
| @@ -154,7 +164,8 @@ func TestEstimateBeforeAndAfterStaging(t *testing.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)) |
| + // 100 MiB past the default 10 s fixed part in 2 s: 50 MiB/s. |
| + ssTiming(t, st, fmt.Sprintf("mv_lane%02d", i), "copy", 100*mib, 12, ssT0.Add(time.Duration(i)*time.Minute)) |
| } |
| // Before a move is requested the final copy is the whole replica (the |
| // door's estimate). |
| @@ -192,6 +203,44 @@ func TestEstimateBeforeAndAfterStaging(t *testing.T) { |
| } |
| } |
| +// TestEstimateRatesExcludeTheFixedPart: a rate is measured past the |
| +// term's fixed part, bytes / max(seconds - fixed, 100 ms), over rows of at |
| +// least 1 MiB; so small final copies, whose seconds are almost all |
| +// overhead, never predict a large replica as hours. |
| +func TestEstimateRatesExcludeTheFixedPart(t *testing.T) { |
| + const replica = 3 << 30 |
| + pure := 10*time.Second + ssRate(replica, 25*mib) // the default copy term: about two minutes |
| + |
| + t.Run("five 2 MiB final copies of about 5 s", func(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + for i, sec := range []float64{4.8, 4.9, 5, 5.1, 5.2} { |
| + ssTiming(t, st, fmt.Sprintf("mv_small%02d", i), "copy", 2*mib, sec, ssT0.Add(time.Duration(i)*time.Minute)) |
| + } |
| + got, _ := s.estimate("ie", "uk", replica, 0, 0) |
| + if got.Copy > 2*pure || got.Copy < pure/2 { |
| + t.Fatalf("a 3 GiB copy estimates %v from five small final copies, want within 2x of %v", got.Copy, pure) |
| + } |
| + }) |
| + |
| + t.Run("a measured fixed part and rate are recovered", func(t *testing.T) { |
| + st := ssStore(t) |
| + s := &Server{store: st} |
| + for i := 0; i < 5; i++ { |
| + at := ssT0.Add(time.Duration(i) * time.Minute) |
| + ssTiming(t, st, fmt.Sprintf("mv_fixed%02d", i), "copy", mib/4, 6, at) // the overhead alone |
| + ssTiming(t, st, fmt.Sprintf("mv_rated%02d", i), "copy", 200*mib, 6+200.0/40, at) // 6 s + 200 MiB at 40 MiB/s |
| + } |
| + r := s.estimateRules("ie", "uk") |
| + if r.Copy.Fixed != 6*time.Second || r.Copy.BytesPerSecond != 40*mib { |
| + t.Fatalf("copy rule %+v, want 6 s and 40 MiB/s", r.Copy) |
| + } |
| + if got, _ := s.estimate("ie", "uk", replica, 0, 0); ssSeconds(got.Copy) != ssSeconds(6*time.Second+ssRate(replica, 40*mib)) { |
| + t.Fatalf("copy %v", got.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) { |
| @@ -297,7 +346,7 @@ func TestAbortedMovesContributeNoRates(t *testing.T) { |
| return row |
| } |
| finish("mv_abortedabortedabortedabort", "a."+ssHost, provision.OfflineOutcomeAborted) |
| - if got, err := st.MoveTimings("ie", "uk", "stop", false, 20); err != nil || len(got) != 0 { |
| + if got, err := st.MoveTimings("ie", "uk", "stop", 20); err != nil || len(got) != 0 { |
| t.Fatalf("an aborted move left timings: %+v, %v", got, err) |
| } |
| @@ -312,7 +361,7 @@ func TestAbortedMovesContributeNoRates(t *testing.T) { |
| "promote": {Seconds: 32, Bytes: 100 * mib}, |
| } |
| for phase, w := range want { |
| - got, err := st.MoveTimings("ie", "uk", phase, false, 20) |
| + got, err := st.MoveTimings("ie", "uk", phase, 20) |
| if err != nil || len(got) != 1 { |
| t.Fatalf("%s: %+v, %v; want one row", phase, got, err) |
| } |
| @@ -324,7 +373,7 @@ func TestAbortedMovesContributeNoRates(t *testing.T) { |
| // 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 { |
| + if got, _ := st.MoveTimings("ie", "uk", phase, 20); len(got) != n { |
| t.Errorf("%s: %d rows, want %d", phase, len(got), n) |
| } |
| } |
diff --git a/internal/e2e/selfserve/cut_test.go b/internal/e2e/selfserve/cut_test.go| index 489fd6605..5ab625eda 100644 |
| --- a/internal/e2e/selfserve/cut_test.go |
| +++ b/internal/e2e/selfserve/cut_test.go |
| @@ -175,13 +175,13 @@ func TestCutRecordsTimingsAndDeliversEvents(t *testing.T) { |
| t.Fatalf("landed_at %d bytes, landed_replica %d: want the files and the replica, and the replica", marks["landed_at"].Bytes, marks["landed_replica"].Bytes) |
| } |
| for _, phase := range []string{"stop", "copy", "land", "open", "promote"} { |
| - got, err := w.cst.MoveTimings("", host.E2ETargetRegion, phase, false, 20) |
| + got, err := w.cst.MoveTimings("", host.E2ETargetRegion, phase, 20) |
| if err != nil || len(got) != 1 || got[0].ID != row.ID { |
| t.Fatalf("%s timings %+v, %v; want the move's one row", phase, got, err) |
| } |
| } |
| - if got, _ := w.cst.MoveTimings("", host.E2ETargetRegion, "land", true, 20); len(got) != 1 || got[0].Bytes != marks["landed_at"].Bytes { |
| - t.Fatalf("land's rate row %+v, want the landed bytes %d", got, marks["landed_at"].Bytes) |
| + if got, _ := w.cst.MoveTimings("", host.E2ETargetRegion, "land", 20); len(got) != 1 || got[0].Bytes != marks["landed_at"].Bytes { |
| + t.Fatalf("land's row %+v, want the landed bytes %d", got, marks["landed_at"].Bytes) |
| } |
| // The terminal event is the last tick's: one more delivers it. |
| for i := 0; i < 3; i++ { |