mesh/merge-gate head 5ad36127ee8b merged into trunk f8daec01d3f0 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 5ad36127ee8b merged into trunk f8daec01d3f0 — pass: its merge-check.sh passed
mesh/delivery delivered
159 lines
6.2 KiB
Go
159 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-controller/internal/inventory"
|
|
"github.com/novox/mesh-controller/internal/link"
|
|
)
|
|
|
|
// Following a plan (novox/hq issue 229): `plans wait <id>` holds its answer until the plan moves, reaches its
|
|
// next tier or ends, or until its bound passes, and says which. Before it, an agent following a rollout had
|
|
// `plans` answer once, in prose, and wrote a loop of its own round the mesh's tools to be told the plan moved.
|
|
//
|
|
// **No wait is longer than a call is answered within** (link.LongestAnswer, inside the console's thirty
|
|
// seconds): a longer one would answer "still running" and leave its caller to ask `calls` — the loop again.
|
|
// So a caller following a plan to its end asks again; each call answers the moment the plan moves.
|
|
|
|
// maxPlanWait is the longest bound a wait takes, and the bound when none is given: what a call is answered
|
|
// within, less the time a verb's command takes to start and read the store.
|
|
var maxPlanWait = link.LongestAnswer - planWaitStart
|
|
|
|
// planWaitStart is what the verb's window allows beyond the bound: starting the command and opening the stores.
|
|
// A store slow enough to push the read and the answer past it degrades as any long call does: the caller is
|
|
// answered that the call is still running, and `calls` with its id says what came of it.
|
|
const planWaitStart = 5 * time.Second
|
|
|
|
// planWaitEvery is how often a wait reads the plan again.
|
|
const planWaitEvery = time.Second
|
|
|
|
// The things a wait waits until.
|
|
var planWaitUntil = []string{"moved", "tier", "done"}
|
|
|
|
// planWaitAsked checks what a wait is asked for: until (moved when empty) and a bound (a duration, or a number
|
|
// of seconds; maxPlanWait when empty).
|
|
func planWaitAsked(until, bound string) (string, time.Duration, error) {
|
|
if until == "" {
|
|
until = "moved"
|
|
}
|
|
known := false
|
|
for _, u := range planWaitUntil {
|
|
known = known || u == until
|
|
}
|
|
if !known {
|
|
return "", 0, fmt.Errorf("a wait is until %s, not %q", strings.Join(planWaitUntil, ", "), until)
|
|
}
|
|
d := maxPlanWait
|
|
if bound = strings.TrimSpace(bound); bound != "" {
|
|
var err error
|
|
if secs, isNumber := strconv.Atoi(bound); isNumber == nil {
|
|
d = time.Duration(secs) * time.Second
|
|
} else if d, err = time.ParseDuration(bound); err != nil {
|
|
return "", 0, fmt.Errorf("the bound %q is not a duration (such as 20s) or a number of seconds", bound)
|
|
}
|
|
if d <= 0 || d > maxPlanWait {
|
|
return "", 0, fmt.Errorf("the bound is at most %s, the longest a call is answered within; %s was asked "+
|
|
"— to follow a plan longer, ask again: each call answers the moment the plan moves", maxPlanWait, d)
|
|
}
|
|
}
|
|
return until, d, nil
|
|
}
|
|
|
|
// planEnded is a plan that will not move again: done, failed or superseded. A batch being assembled is not one.
|
|
func planEnded(p inventory.Plan) bool { return !p.Open() && !p.Batch() }
|
|
|
|
// waitOnPlan reads the plan until what it waits for happens or the bound passes, and answers the plan as last
|
|
// read and what ended the wait: moved (any save of it), tier (its tier changed), done (it ended — which ends
|
|
// every wait, since nothing comes after it), or bound. Moved and tier are judged against the plan as first read.
|
|
func waitOnPlan(ctx context.Context, read func(context.Context) (inventory.Plan, error), until string,
|
|
bound, every time.Duration) (inventory.Plan, string, error) {
|
|
first, err := read(ctx)
|
|
if err != nil {
|
|
return inventory.Plan{}, "", err
|
|
}
|
|
if planEnded(first) {
|
|
return first, "done", nil
|
|
}
|
|
deadline := time.Now().Add(bound)
|
|
tick := time.NewTicker(every)
|
|
defer tick.Stop()
|
|
last := first
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return last, "", ctx.Err()
|
|
case <-tick.C:
|
|
}
|
|
if !time.Now().Before(deadline) {
|
|
return last, "bound", nil
|
|
}
|
|
p, err := read(ctx)
|
|
if err != nil {
|
|
return last, "", err
|
|
}
|
|
last = p
|
|
switch {
|
|
case planEnded(p):
|
|
return p, "done", nil
|
|
case until == "moved" && p.Revision != first.Revision:
|
|
return p, "moved", nil
|
|
case until == "tier" && p.Tier != first.Tier:
|
|
return p, "tier", nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// planWaited is what `plans wait` answers: the plan, what ended the wait, and the line a person reads (`text`,
|
|
// which the verb answers as its output and not again in the data).
|
|
type planWaited struct {
|
|
// Came is what ended the wait: moved, tier, done, or bound when nothing did.
|
|
Came string `json:"came"`
|
|
Until string `json:"until"`
|
|
Waited string `json:"waited"`
|
|
Plan planStatus `json:"plan"`
|
|
// Revision is the plan's as read: a caller asking again with the same plan sees whether it moved since.
|
|
Revision int64 `json:"revision"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
func planWaitedDoc(p inventory.Plan, st planStatus, until, came string, waited time.Duration, line string) planWaited {
|
|
said := map[string]string{
|
|
"moved": "moved", "tier": "reached another tier", "done": "ended",
|
|
"bound": "did not " + map[string]string{"moved": "move", "tier": "reach another tier", "done": "end"}[until],
|
|
}[came]
|
|
text := fmt.Sprintf("%s %s in %s (waited until %s)\n%s — %s\n", p.ID, said, waited.Round(time.Second), until,
|
|
p.ID, line)
|
|
return planWaited{Came: came, Until: until, Waited: waited.Round(time.Millisecond).String(), Plan: st,
|
|
Revision: p.Revision, Text: text}
|
|
}
|
|
|
|
// planWaitCommand is `plans wait <id>`: the plan followed until it moves, or the bound passes.
|
|
func planWaitCommand(ctx context.Context, inv *inventory.Inventory, id, untilAsked, boundAsked string) error {
|
|
until, bound, err := planWaitAsked(untilAsked, boundAsked)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
began := time.Now()
|
|
p, came, err := waitOnPlan(ctx, func(ctx context.Context) (inventory.Plan, error) {
|
|
return inv.PlanByID(ctx, id)
|
|
}, until, bound, planWaitEvery)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Said as `plans <id>` says its first line, and as `status` says the plan.
|
|
now, one := time.Now(), []inventory.Plan{p}
|
|
bounds, pause := readTierBounds(ctx, inv, now), buildSeatPause(ctx, inv, one)
|
|
line := planLineSaid(p, now, pause, bounds.of(p.Repository), func(module string) []string {
|
|
on, _ := inv.Running(ctx, module)
|
|
return on
|
|
}, waitsOf(ctx, inv, one))
|
|
doc := planWaitedDoc(p, planStatuses(one, now, pause, bounds)[0], until, came, now.Sub(began), line)
|
|
return json.NewEncoder(os.Stdout).Encode(doc)
|
|
}
|