From d00dac140b0396d691d60c480f2171ae7ee35bc3 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 26 Oct 2026 21:19:16 +0100 Subject: [PATCH] A build module takes asks only within its node's build hours The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00. A build-hours setting names a window per node in its local time (crossing midnight allowed, absent = any hour); outside it the capacity judged by is 0, running steps finish, and the puller wakes on one timer at the next edge. The change is said in the log and in capacity. with the window and when it opens. --- go/buildstep/buildhours.go | 135 ++++++++++++++++++++++++++++++++++ go/buildstep/capacity_test.go | 50 +++++++++++++ go/buildstep/pull.go | 106 +++++++++++++++++++++++++- go/delivery/stepasks.go | 6 ++ 4 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 go/buildstep/buildhours.go diff --git a/go/buildstep/buildhours.go b/go/buildstep/buildhours.go new file mode 100644 index 0000000..e0bc991 --- /dev/null +++ b/go/buildstep/buildhours.go @@ -0,0 +1,135 @@ +package buildstep + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" +) + +// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: "we should only use g14 as a builder +// outside office hours", "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting names, per +// node, the window in the node's local time in which it takes asks; outside it the capacity judged by is 0: steps running +// finish, no new one is taken. A node it does not name, or an empty setting, builds at any hour. The puller judges it at +// every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of another part). + +// BuildHoursSetting is the build modules' setting of the nodes' build windows: `=HH:MM-HH:MM` pairs separated by +// commas, in the node's local time; an end before its start crosses midnight (g14=18:00-06:00). +const BuildHoursSetting = "build-hours" + +// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive. +type BuildWindow struct { + Start, End int +} + +// String is the window as the setting writes it. +func (w BuildWindow) String() string { + return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60) +} + +// Open says whether t, in its own location, falls in the window. +func (w BuildWindow) Open(t time.Time) bool { + m := t.Hour()*60 + t.Minute() + if w.Start < w.End { + return m >= w.Start && m < w.End + } + return m >= w.Start || m < w.End +} + +// NextEdge is the first time after t, in t's location, at which the window opens or closes. +func (w BuildWindow) NextEdge(t time.Time) time.Time { + var next time.Time + for day := 0; day <= 1; day++ { + for _, m := range []int{w.Start, w.End} { + e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location()) + if e.After(t) && (next.IsZero() || e.Before(next)) { + next = e + } + } + } + return next +} + +// ParseBuildHours is node's window in a build-hours setting: nil when the setting is empty or does not name the node. +func ParseBuildHours(setting, node string) (*BuildWindow, error) { + var found *BuildWindow + for _, pair := range strings.Split(setting, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + name, span, ok := strings.Cut(pair, "=") + from, to, ok2 := strings.Cut(span, "-") + if !ok || !ok2 || strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("the build hours %q: %q is not =HH:MM-HH:MM", setting, pair) + } + start, err := minuteOf(from) + if err != nil { + return nil, fmt.Errorf("the build hours %q: %v", setting, err) + } + end, err := minuteOf(to) + if err != nil { + return nil, fmt.Errorf("the build hours %q: %v", setting, err) + } + if start == end { + return nil, fmt.Errorf("the build hours %q: %s opens and closes at the same minute", setting, pair) + } + if strings.TrimSpace(name) == node { + found = &BuildWindow{Start: start, End: end} + } + } + return found, nil +} + +// minuteOf reads HH:MM as minutes after midnight. +func minuteOf(s string) (int, error) { + var h, m int + s = strings.TrimSpace(s) + if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 { + return 0, fmt.Errorf("%q is not a time HH:MM", s) + } + return h*60 + m, nil +} + +// NodeBuildHours is node's build window, read from the module's settings file at path at every call, and why it is +// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that +// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety +// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through +// logf, and the puller writes it in the capacity record that build_usage shows. +func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) { + said := "" + say := func(why string) { + if logf != nil && why != said { + said = why + logf("%s, so %s builds at any hour", why, node) + } + } + fault := func(why string) (*BuildWindow, string) { + say(why) + return nil, why + } + return func() (*BuildWindow, string) { + if path == "" { + return nil, "" + } + raw, err := os.ReadFile(path) + if err != nil { + return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err)) + } + var s map[string]json.RawMessage + if err := json.Unmarshal(raw, &s); err != nil { + return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err)) + } + setting := "" + if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil { + return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v)) + } + w, err := ParseBuildHours(setting, node) + if err != nil { + return fault(err.Error()) + } + said = "" + return w, "" + } +} diff --git a/go/buildstep/capacity_test.go b/go/buildstep/capacity_test.go index c52f15e..c44bbb5 100644 --- a/go/buildstep/capacity_test.go +++ b/go/buildstep/capacity_test.go @@ -3,7 +3,9 @@ package buildstep import ( "os" "path/filepath" + "strings" "testing" + "time" ) // 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls @@ -84,4 +86,52 @@ func TestNodeLimitsAndTheCPUBound(t *testing.T) { if len(said) != 1 { t.Errorf("a value not above zero is said once, said %d times", len(said)) } + + // The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the + // capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour. + cet := time.FixedZone("CET", 3600) + day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) } + was := now + defer func() { now = was }() + for _, c := range []struct { + name, setting, node string + at time.Time + effective int + edge string // the window's next edge, HH:MM; "" for no window + bad bool + }{ + {"g14 in the office", "g14=18:00-06:00", "g14", day(10, 0), 0, "18:00", false}, + {"g14 in the evening", "g14=18:00-06:00", "g14", day(18, 0), 2, "06:00", false}, + {"g14 past midnight", "g14=18:00-06:00", "g14", day(5, 59), 2, "06:00", false}, + {"g14 at the close", "g14=18:00-06:00", "g14", day(6, 0), 0, "18:00", false}, + {"a day window", "shanks=09:00-17:30,g14=18:00-06:00", "shanks", day(17, 29), 2, "17:30", false}, + {"a node not named", "g14=18:00-06:00", "ace", day(10, 0), 2, "", false}, + {"a bad time", "g14=18:00-6", "g14", day(10, 0), 2, "", true}, + {"no span", "g14=18:00-18:00", "g14", day(10, 0), 2, "", true}, + } { + w, err := ParseBuildHours(c.setting, c.node) + if (err != nil) != c.bad { + t.Errorf("%s: parse %v", c.name, err) + } + if edge := ""; w != nil { + edge = w.NextEdge(c.at).Format("15:04") + if edge != c.edge { + t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge) + } + } else if c.edge != "" { + t.Errorf("%s: no window, want one", c.name) + } + file := filepath.Join(t.TempDir(), "settings.json") + os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600) + now = func() time.Time { return c.at } + p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil)} + got := p.CapacityDetail() + if got.Effective != c.effective || got.OutsideHours != (c.effective == 0) || (c.effective == 0 && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) { + t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective) + } + // A value that cannot be read builds at any hour, loudly: the record names the value and says why. + if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) { + t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got) + } + } } diff --git a/go/buildstep/pull.go b/go/buildstep/pull.go index 87b95ee..893d631 100644 --- a/go/buildstep/pull.go +++ b/go/buildstep/pull.go @@ -82,6 +82,10 @@ type Puller struct { // (EffectiveCapacity). Nil bounds nothing by the CPU. Quota reads the quota (Quota when nil; a test's own). CoresPerStep func() float64 Quota func() (percent int, from string) + // BuildHours is the node's build window, read at every judgement (NodeBuildHours); with WatchConfig set the value + // mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0: + // steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour. + BuildHours func() (*BuildWindow, string) // Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is // declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put // again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need @@ -97,6 +101,11 @@ type Puller struct { // watched is the capacity the config watch handed last, used in place of Capacity once hasWatched. watched int hasWatched bool + // hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place + // of BuildHours once hasHours. + hours *BuildWindow + hoursFault string + hasHours bool // judged is the capacity said last (log and capacity.), so a change is said once. judged delivery.Capacity hasJudged bool @@ -265,16 +274,59 @@ func (p *Puller) CapacityDetail() delivery.Capacity { c = EffectiveCapacity(configured, percent, qfrom, p.CoresPerStep()) } c.ConfiguredFrom = from + w, fault := p.window() + if fault != "" { + c.BuildHoursFault = fault + c.Why = fmt.Sprintf("%s; at any hour, because its build hours cannot be read: %s", c.Why, fault) + } + if w != nil { + at := now() + zone, _ := at.Zone() + c.BuildHours = w.String() + " " + zone + if !w.Open(at) && c.Effective > 0 { + c.OutsideHours = true + c.Why = fmt.Sprintf("0, outside its build hours %s until %s (otherwise %s)", c.BuildHours, w.NextEdge(at).Format("15:04"), c.Why) + c.Effective = 0 + } else if c.Effective > 0 { + c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04")) + } + } p.sayCapacity(c) return c } +// window is the node's build window now (the config watch's last value, else BuildHours, else none), and why its value +// cannot be read when it cannot. +func (p *Puller) window() (*BuildWindow, string) { + p.mu.Lock() + w, fault, has := p.hours, p.hoursFault, p.hasHours + p.mu.Unlock() + if has { + return w, fault + } + if p.BuildHours != nil { + return p.BuildHours() + } + return nil, "" +} + +// untilEdge is how long until the build window's next edge, and false when the node has no window. +func (p *Puller) untilEdge() (time.Duration, bool) { + w, _ := p.window() + if w == nil { + return 0, false + } + at := now() + return w.NextEdge(at).Sub(at), true +} + // sayCapacity logs c when it differs from what was said last, and writes it to the node's capacity key until a write // lands (a failed write is tried again at the next judgement, its error said once). func (p *Puller) sayCapacity(c delivery.Capacity) { p.mu.Lock() same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU && - p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores + p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours && + p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault if !same { p.judged, p.hasJudged, p.capWritten = c, true, false } @@ -320,6 +372,7 @@ func (p *Puller) Pull(ctx context.Context) error { p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err) } go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull + go p.watchHours() wait := p.PullWait if wait <= 0 { wait = DefaultPullWait @@ -332,16 +385,29 @@ func (p *Puller) Pull(ctx context.Context) error { // Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every // park logs once, so a puller that sits idle says why (issue 1887). p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused()) + var edge <-chan time.Time // the build window's next edge, when the node has one: one timer, not a poll + stop := func() bool { return false } + if d, ok := p.untilEdge(); ok { + t := time.NewTimer(d + time.Second) + edge, stop = t.C, t.Stop + } select { case <-ctx.Done(): + stop() return ctx.Err() case <-p.wake: + case <-edge: } + stop() _, capacity, inHand = p.roomNow() p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused()) continue } - msg, err := p.next(ctx, wait) + pullWait := wait + if d, ok := p.untilEdge(); ok && d < pullWait { + pullWait = d // a pull does not wait past the window's close + } + msg, err := p.next(ctx, pullWait) if err != nil { if ctx.Err() != nil { return ctx.Err() @@ -560,6 +626,42 @@ func (p *Puller) watchCapacity() { } } +// watchHours follows the module's config value `build-hours` when WatchConfig and BuildHours are set, as watchCapacity +// follows `capacity`: each value handed is kept, used in place of BuildHours, and wakes the puller. A value that cannot +// be read is no window, said; a watch that cannot start leaves the puller on BuildHours, said once. +func (p *Puller) watchHours() { + if p.WatchConfig == nil || p.BuildHours == nil { + return + } + err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error { + setting := "" + var w *BuildWindow + var why string + switch { + case c.Refused != "": + why = "its build hours are no longer answered: " + c.Refused + case len(c.Value) > 0 && string(c.Value) != "null" && json.Unmarshal(c.Value, &setting) != nil: + why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value) + default: + var err error + if w, err = ParseBuildHours(setting, p.Node); err != nil { + why = err.Error() + } + } + p.mu.Lock() + p.hours, p.hoursFault, p.hasHours = w, why, true + p.mu.Unlock() + if why != "" { + p.logf("the %s puller on %s builds at any hour: %s", p.Language, p.Node, why) + } + p.Wake() + return nil + }) + if err != nil { + p.logf("the %s build hours of %s cannot be followed on the bus, so they are read from the settings file: %v", p.Language, p.Node, err) + } +} + // WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its // pull before it serves, and a watch asked before that would be refused. func WatchConfigServed(key string, handle func(stdio.ProvisionChange) error) error { diff --git a/go/delivery/stepasks.go b/go/delivery/stepasks.go index 33ac861..6b8956a 100644 --- a/go/delivery/stepasks.go +++ b/go/delivery/stepasks.go @@ -724,6 +724,12 @@ type Capacity struct { // when no quota is known. CoresPerStep float64 `json:"cores-per-step"` ByCPU int `json:"by-cpu,omitempty"` + // BuildHours is the node's build window (HH:MM-HH:MM, its local time and zone), absent when it builds at any hour; + // OutsideHours says the time judged fell outside it, so Effective is 0 and the next step waits for the window to open. + BuildHours string `json:"build-hours,omitempty"` + OutsideHours bool `json:"outside-hours,omitempty"` + // BuildHoursFault is why the node's build-hours value cannot be read, naming the value: it then builds at any hour. + BuildHoursFault string `json:"build-hours-fault,omitempty"` // Why is the capacity in words: what bounds it. Why string `json:"why"` At time.Time `json:"at,omitzero"`