console: the move estimate from the region pair's timings: the median of the last twenty per term once five exist (fixed terms from seconds, rates from bytes/seconds over rows with bytes), else the default
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+84
−11
internal/console/selfserve_estimate.go+84 −11
diff --git a/internal/console/selfserve_estimate.go b/internal/console/selfserve_estimate.go| index 450a1545c..8a3b67a4a 100644 |
| --- a/internal/console/selfserve_estimate.go |
| +++ b/internal/console/selfserve_estimate.go |
| @@ -5,19 +5,28 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "log" |
| + "sort" |
| "time" |
| "github.com/carlosframework/platform/internal/provision" |
| ) |
| -// The self-serve move's estimate (self-serve moves plan 2; spec §6.2). The |
| -// door shows it before a move is requested and stores it on the row, and |
| -// the job's deadlines are multiples of its terms (the cut's copy wait is |
| -// max(5 min, 2 × Copy)). |
| +// The self-serve move's estimate (self-serve moves plan 2; spec §6.3). The |
| +// door shows it before a move is requested and stores it on the row, the |
| +// prechecks refine it from what staging measured, and the job's deadlines |
| +// are multiples of its terms (the cut's copy wait is max(5 min, 2 × |
| +// Copy)). |
| // |
| -// Task 5 states the terms with their defaults only. Task 9 adds the |
| -// deployment's timings (the medians of move_timings) under the same |
| -// signature; until then every move is estimated from the defaults. |
| +// 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 |
| +// 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. |
| // estimateTerms is one estimate, term by term: how long the stop, the |
| // final copy, the landing, the open and the promotion should take. |
| @@ -52,14 +61,78 @@ func (t term) of(n int64) time.Duration { |
| return d |
| } |
| +// The refinement's window: the last estimateWindow rows of a term, and |
| +// at least estimateMinRows of them, else the default. |
| +const ( |
| + estimateWindow = 20 |
| + estimateMinRows = 5 |
| +) |
| + |
| +// 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. |
| +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) |
| + if err != nil { |
| + log.Printf("console: the move estimate: the %s timings for %s -> %s: %v (the default stands)", phase, from, to, err) |
| + return |
| + } |
| + if len(rows) < estimateMinRows { |
| + return |
| + } |
| + xs := make([]float64, len(rows)) |
| + for i, m := range rows { |
| + xs[i] = m.Seconds |
| + } |
| + t.Fixed = time.Duration(median(xs) * float64(time.Second)) |
| + } |
| + 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 |
| + } |
| + if len(rows) < estimateMinRows { |
| + return |
| + } |
| + xs := make([]float64, len(rows)) |
| + for i, m := range rows { |
| + xs[i] = float64(m.Bytes) / m.Seconds |
| + } |
| + t.BytesPerSecond = median(xs) |
| + } |
| + fixed("stop", &r.Stop) |
| + rate("copy", &r.Copy) |
| + rate("land", &r.Land) |
| + fixed("open", &r.Open) |
| + rate("promote", &r.Promote) |
| + return r |
| +} |
| + |
| +// median is the middle of xs (the mean of the middle two for an even |
| +// count); xs is sorted in place. |
| +func median(xs []float64) float64 { |
| + sort.Float64s(xs) |
| + n := len(xs) |
| + if n%2 == 1 { |
| + return xs[n/2] |
| + } |
| + return (xs[n/2-1] + xs[n/2]) / 2 |
| +} |
| + |
| // estimate is the move's terms and its whole length in seconds, in whole |
| // minutes and at least two: remaining is what the final copy still has to |
| // carry (the whole replica before any staging), files the instance's files |
| -// and replica its replica. from and to name the regions, for Task 9's |
| -// per-lane timings. |
| +// and replica its replica. from and to name the regions: the terms are the |
| +// pair's (estimateRules). |
| func (s *Server) estimate(from, to string, remaining, files, replica int64) (estimateTerms, int) { |
| - _, _ = from, to |
| - d := estimateDefaults |
| + d := s.estimateRules(from, to) |
| t := estimateTerms{ |
| Stop: d.Stop.of(0), |
| Copy: d.Copy.of(remaining), |