carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 2fb17389663adddd4c66fd6fe3eae7a1d953ec3a parent cf2cb87
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),