Hold a walk's first-machine gate while its busy container finishes its build
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle rejected: its order contradicts itself: novox/mesh-catalog@190608193764, novox/mesh-c…
mesh/merge-gate out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request

A merge into mesh-controller replaced every build agent and threw away the
checks in flight (issue 594). The manifest's container busy is checked and
sent only to an engine that reads it; a report's busy waits are kept, the
gate starts at the report that says the container replaced, and the wait is
a phase of the delivery time. mesh-builder busy drains the agent and answers
while a build is in hand (novox/hq ADR 0335).
This commit is contained in:
2026-10-13 00:53:10 +02:00
parent 56d6313262
commit b1546ed28a
26 changed files with 1463 additions and 25 deletions
+124
View File
@@ -0,0 +1,124 @@
package catalogue
import (
"fmt"
"sort"
"strings"
"time"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
//
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
// never the module's.
//
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
// does not know, whole, and to it the container is replaced at once, as before.
// BusyField is the resource field.
const BusyField = "busy"
// The bounds of one ask (ADR 0335), the node-engine's own, held here near the author.
const (
BusyTimeoutDefault = 10 * time.Second
BusyTimeoutCeiling = time.Minute
)
// Busy is one container's busy, read.
type Busy struct {
Command string
Timeout time.Duration
}
// ReadBusy reads a resource's `busy` field, its default applied. False when it carries none.
func ReadBusy(r map[string]any) (Busy, bool, []string) {
raw, present := r[BusyField]
if !present {
return Busy{}, false, nil
}
id := fmt.Sprint(r["id"])
fields, ok := raw.(map[string]any)
if !ok {
return Busy{}, true, []string{fmt.Sprintf("%s: busy is %T; it is an object with a command", id, raw)}
}
var problems []string
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k != "command" && k != "timeout" {
problems = append(problems, fmt.Sprintf("%s: its busy says %q; busy has a command and a timeout", id, k))
}
}
b := Busy{Timeout: BusyTimeoutDefault}
if v, has := fields["command"]; has {
s, ok := v.(string)
if !ok {
problems = append(problems, fmt.Sprintf("%s: its busy's command is %T; it is text", id, v))
}
b.Command = strings.TrimSpace(s)
}
if v, has := fields["timeout"]; has {
s, _ := v.(string)
d, err := time.ParseDuration(strings.TrimSpace(s))
switch {
case err != nil:
problems = append(problems, fmt.Sprintf("%s: its busy's timeout is %v; it is a duration such as \"10s\"", id, v))
case d <= 0 || d > BusyTimeoutCeiling:
problems = append(problems, fmt.Sprintf("%s: an ask of its busy takes more than nothing and at most %s, not %s",
id, BusyTimeoutCeiling, d))
default:
b.Timeout = d
}
}
return b, true, problems
}
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
// one line, a timeout within its bounds, and only on a container that stays up.
func busyProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
b, has, read := ReadBusy(r)
if !has {
continue
}
where := m.Module + ": " + fmt.Sprint(r["id"])
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
"asked of a running container before it is replaced (ADR 0335)", where))
}
if b.Command == "" {
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
"while it is busy")
}
if strings.ContainsAny(b.Command, "\n\r") {
problems = append(problems, where+"'s busy command is one line")
}
}
return problems
}
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
func busyInto(resource map[string]any, with Rendering) {
if _, has := resource[BusyField]; !has {
return
}
if !with.ReadsBusy {
delete(resource, BusyField)
return
}
b, _, _ := ReadBusy(resource)
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
}
+94
View File
@@ -0,0 +1,94 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-host/validate"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
// shape near its author, and it is sent only to a node-engine that reads it, in words the engine's own
// validator accepts.
func busyManifest(t *testing.T, on string, busy any) []byte {
t.Helper()
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
"image": "registry.example/app" + healthDigest}
var m map[string]any
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
if r := r.(map[string]any); r["id"] == on {
r[BusyField] = busy
}
}
out, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return out
}
func TestABusyIsHeldToItsShape(t *testing.T) {
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
t.Fatalf("a well-formed busy was refused: %v", err)
}
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
t.Fatalf("a busy with its timeout was refused: %v", err)
}
for name, c := range map[string]struct {
on string
busy any
says string
}{
"text": {"agent", "mesh-builder busy", "it is an object"},
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
} {
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
}
}
}
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
if err != nil {
t.Fatal(err)
}
compose := func(with Rendering) []map[string]any {
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
return out
}
older := compose(Rendering{})
if b, sent := older[indexOfID(older, "app.agent")][BusyField]; sent {
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
}
newer := compose(Rendering{ReadsBusy: true})
got, _ := newer[indexOfID(newer, "app.agent")][BusyField].(map[string]any)
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
t.Fatalf("busy composed as %v", got)
}
// And the engine's own validator takes the container with it.
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
if err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(body); len(problems) > 0 {
t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
}
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
t.Error("composing wrote the timeout into the catalogue's own manifest")
}
}
+7
View File
@@ -246,6 +246,11 @@ type Rendering struct {
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
// sent, and the account it names is not judged — which the self-check says, as not judged.
JudgesRoot bool
// ReadsBusy says this machine's node-engine reads a container's `busy` (novox/hq ADR 0335: its statement's
// contract is link.BusyContract or later). To an older, strict engine the field is not sent, and the
// container is replaced at once, as before.
ReadsBusy bool
}
// RootField is a user resource's field saying the account must never become root without a person
@@ -1051,6 +1056,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// How it is ready, in the node-engine's words: its endpoint as the port this machine
// published it on — or not sent at all to an engine older than the field (ADR 0240).
healthInto(copied, m, with)
// And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it.
busyInto(copied, with)
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
// that judges it.
rootInto(copied, with)
+2
View File
@@ -2242,6 +2242,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
// How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the
// words the node-engine would refuse it in far away.
problems = append(problems, healthProblems(m)...)
// And whether a container is busy (novox/hq ADR 0335), likewise.
problems = append(problems, busyProblems(m)...)
if len(problems) > 0 {
sort.Strings(problems)
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
+142
View File
@@ -0,0 +1,142 @@
package inventory
import (
"strings"
"testing"
"time"
)
// A walk whose first machine waited for a busy container counts the wait as a phase of its own, between the
// send to the first machine and the judging, and its delivery time holds it (novox/hq ADR 0335 §4): the
// recorded walk again, its judging started only at 17:54:07, a minute after the send, when the machine said
// the container replaced.
func TestABusyWaitIsAPhaseOfTheDeliveryTime(t *testing.T) {
raw := strings.Replace(recordedWalk, `"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z",`,
`"gate": {"machines": ["novox"], "since": "2026-10-10T17:54:07.287136827Z",
"busy": {"machine": "novox", "module": "mesh-delivery", "container": "mesh-delivery",
"since": "2026-10-10T17:53:07Z", "until": "2026-10-10T18:08:07Z", "ended": "2026-10-10T17:54:07.287136827Z"},`, 1)
w := walkOf(t, raw).WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil)
sumsUp(t, w)
if w.TotalMS != 282894 {
t.Fatalf("the wait changed the delivery's total: %dms", w.TotalMS)
}
busy := phase(w, PhaseBusy, 0)
if busy.State != PhaseMeasured || busy.TookMS != 60000 {
t.Fatalf("the busy wait is not a measured phase of a minute: %+v", busy)
}
if judged := phase(w, PhaseJudge, 0); judged.TookMS != 148607-60000 {
t.Fatalf("the judging still counts from the send, not from the replacement: %+v", judged)
}
var names []string
for _, ph := range w.Phases {
names = append(names, ph.Name)
}
if !strings.Contains(strings.Join(names, " "), PhaseSend+" "+PhaseBusy+" "+PhaseJudge) {
t.Fatalf("the busy wait is not between the send and the judging: %v", names)
}
}
// While the wait goes on, the walk says it waits for a busy container there; a walk where nothing waited has no
// such phase.
func TestAnOpenBusyWaitIsSaid(t *testing.T) {
raw := strings.Replace(recordedWalk, `"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z", "passes": 3,
"verdict": "passed", "judged_at": "2026-10-10T17:55:35.894985053Z"}`,
`"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z",
"busy": {"machine": "novox", "module": "mesh-delivery", "container": "mesh-delivery",
"since": "2026-10-10T17:53:07Z", "until": "2026-10-10T18:08:07Z"}}`, 1)
raw = strings.Replace(raw, `"state": "done"`, `"state": "rolling"`, 1)
p := walkOf(t, raw)
if !p.Modules["mesh-delivery"].Gate.Waiting() {
t.Fatal("a gate whose busy wait has not ended does not say it waits")
}
w := p.WalkPhases(time.Date(2026, 10, 10, 17, 58, 0, 0, time.UTC), nil)
busy := phase(w, PhaseBusy, 0)
if busy.State != PhaseUnknown || !strings.Contains(busy.Said, "waits for a busy container on novox") {
t.Fatalf("an open busy wait is not said: %+v", busy)
}
if plain := walkOf(t, recordedWalk).WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil); phase(plain, PhaseBusy, 0).Name != "" {
t.Fatalf("a walk where nothing waited has a busy phase: %+v", plain.Phases)
}
}
// A container a report lists waiting is its module's by its id; its output alone is not a new account.
func TestABusyContainerIsItsModules(t *testing.T) {
list := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting, Said: "a"},
{ID: "web.server", State: "replaced-when-idle"}}
if b, ok := WaitingFor(list, "build-agent"); !ok || b.Container != "mesh-build-agent" {
t.Fatalf("build-agent's container is not found waiting: %+v", b)
}
if _, ok := WaitingFor(list, "web"); ok {
t.Error("a container replaced reads as waiting")
}
if _, ok := WaitingFor(list, "build"); ok {
t.Error("another module's container is found by a prefix of its name")
}
again := []BusyWait{{ID: "build-agent.server", State: BusyWaiting, Said: "b, a minute on"}, list[1]}
if !sameBusy(list, again) {
t.Error("a wait whose output changed is a new account")
}
if sameBusy(list, list[1:]) || !(Doing{Busy: list}).Waiting() || (Doing{Busy: list[1:]}).Waiting() {
t.Error("a wait that ended is not a new account")
}
}
// A machine's report of a busy container is kept with its last account and read back beside it, and its
// starting and its end are news; its output alone is not (novox/hq ADR 0335). Needs the store.
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
keep := func(busy []BusyWait) bool {
t.Helper()
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
if err != nil {
t.Fatal(err)
}
return news
}
read := func() []BusyWait {
t.Helper()
reports, err := inv.LastReports(ctx)
if err != nil {
t.Fatal(err)
}
for _, r := range reports {
if r.Node == node {
return r.Busy
}
}
t.Fatal("the machine's report is not read back")
return nil
}
keep(nil)
if got := read(); len(got) != 0 {
t.Fatalf("a report with nothing busy reads back %+v", got)
}
if !keep(waiting) {
t.Error("a container starting to wait is not news")
}
got := read()
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
t.Fatalf("the wait is not read back: %+v", got)
}
again := []BusyWait{waiting[0]}
again[0].Said = "busy: a check, a minute on"
if keep(again) {
t.Error("a wait whose output changed is news")
}
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
Since: since, Until: since.Add(15 * time.Minute)}}) {
t.Error("a container replaced after it waited is not news")
}
if _, ok := WaitingFor(read(), "build-agent"); ok {
t.Error("a container replaced still reads as waiting")
}
}
@@ -0,0 +1,11 @@
-- A machine's report says which container it left running because it said busy, and which it replaced
-- after it waited (novox/hq ADR 0335, issue 594).
--
-- A send that replaced the build agent killed the check it ran, and the queue started it again elsewhere.
-- A container may now declare `busy`; the node-engine asks it before replacing it, leaves it running while it
-- says busy, within 15 minutes, and says so in its report's `busy`: [{"id", "container", "state", "since",
-- "until", "said"}], state `waiting`, `replaced-when-idle` or `replaced-at-bound`. Kept with the machine's
-- last account, so a walk's first-machine gate waits while its module's container is listed waiting — across
-- a restart of the controller, which a merge into mesh-controller makes in the same walk that moves the build
-- agent. Empty from an engine that never waits.
alter table node_report add column busy jsonb not null default '[]';
+85 -10
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"regexp"
"slices"
"sort"
"strings"
"time"
@@ -846,6 +847,46 @@ type Doing struct {
// machine doing what it was told.
Since *time.Time
Times int
// Busy is every container the apply left running because it said busy, and every one it replaced after
// it waited (novox/hq ADR 0335).
Busy []BusyWait
}
// BusyWait is one container a busy command held, as the machine reported it (novox/hq ADR 0335): its
// resource id (`<module>.<resource>`), its name, its state (link.BusyWaiting, BusyReplacedIdle or
// BusyReplacedAtBound), since when it waits, until when at the latest, and the last output of its command.
type BusyWait struct {
ID string `json:"id"`
Container string `json:"container"`
State string `json:"state"`
Since time.Time `json:"since"`
Until time.Time `json:"until"`
Said string `json:"said,omitempty"`
}
// BusyWaiting is the state of a container left running because it said busy (link.BusyWaiting).
const BusyWaiting = "waiting"
// WaitingFor is the container of a module that waits because it said busy, and whether one does: a
// resource whose id is `<module>.<resource>`.
func WaitingFor(busy []BusyWait, module string) (BusyWait, bool) {
for _, b := range busy {
if b.State == BusyWaiting && strings.HasPrefix(b.ID, module+".") {
return b, true
}
}
return BusyWait{}, false
}
// Waiting says whether any container waits because it said busy.
func (d Doing) Waiting() bool {
for _, b := range d.Busy {
if b.State == BusyWaiting {
return true
}
}
return false
}
// FailedResource is one thing a node could not do.
@@ -873,6 +914,20 @@ func (d Doing) Stuck() bool { return d.Wrong() && d.Times >= StuckAfter && d.Sin
// the same failed resources BY ID. Not by the host's words: an error that carries a duration, a
// counter or a temporary path would read as new on every report, and the resource looping on it —
// which is what stuck is for — would never be said to be (novox/hq 04-ISSUES/065).
// sameBusy says two reports hold the same containers in the same state of a busy wait: its output, which
// may change on every ask, is not compared.
func sameBusy(a, b []BusyWait) bool {
key := func(list []BusyWait) []string {
out := make([]string, 0, len(list))
for _, w := range list {
out = append(out, w.ID+"="+w.State)
}
sort.Strings(out)
return out
}
return slices.Equal(key(a), key(b))
}
func sameFailure(a, b Doing) bool {
if a.Outcome != b.Outcome || a.Refused != b.Refused || len(a.Failed) != len(b.Failed) {
return false
@@ -937,13 +992,20 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
if err != nil {
return false, err
}
if d.Busy == nil {
d.Busy = []BusyWait{}
}
busy, err := json.Marshal(d.Busy)
if err != nil {
return false, err
}
var before Doing
var beforeFailed []byte
var beforeFailed, beforeBusy []byte
found := q.QueryRow(ctx,
`select outcome, refused, failed, failing_since, failures, coalesce(declared,'')
`select outcome, refused, failed, failing_since, failures, coalesce(declared,''), busy
from node_report where node = $1`,
node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times,
&before.Declared)
&before.Declared, &beforeBusy)
switch {
case errors.Is(found, pgx.ErrNoRows):
news = true
@@ -953,7 +1015,13 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
if err := json.Unmarshal(beforeFailed, &before.Failed); err != nil {
return false, err
}
news = before.Outcome != d.Outcome || before.Declared != d.Declared || !sameFailure(before, d)
if err := json.Unmarshal(beforeBusy, &before.Busy); err != nil {
return false, err
}
// A container that starts or ends a wait because it said busy is news (novox/hq ADR 0335): the walk
// waiting on this machine is told by it.
news = before.Outcome != d.Outcome || before.Declared != d.Declared || !sameFailure(before, d) ||
!sameBusy(before.Busy, d.Busy)
}
var since *time.Time
times := 0
@@ -966,13 +1034,13 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
}
_, err = q.Exec(ctx,
`insert into node_report (node, outcome, refused, failed, applied, at, declared,
failing_since, failures)
values ($1, $2, $3, $4, $5, now(), $6, $7, $8)
failing_since, failures, busy)
values ($1, $2, $3, $4, $5, now(), $6, $7, $8, $9)
on conflict (node) do update set outcome = excluded.outcome, refused = excluded.refused,
failed = excluded.failed, applied = excluded.applied, at = excluded.at,
declared = excluded.declared,
failing_since = excluded.failing_since, failures = excluded.failures`,
node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times)
failing_since = excluded.failing_since, failures = excluded.failures, busy = excluded.busy`,
node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times, busy)
if err != nil {
return false, err
}
@@ -1059,6 +1127,8 @@ type Reported struct {
// gate holds against the send it made, rather than against the send made last (novox/hq issue 352).
Declared string
ReportedSequence int64
// Busy is what the last report said of containers held by their busy command (novox/hq ADR 0335).
Busy []BusyWait
}
// SentDeclaration is what one send carried to a machine, as a gate keeps it: the declaration's digest and
@@ -1113,7 +1183,7 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
rows, err := i.store.Pool().Query(ctx,
`select n.name, coalesce(r.outcome, ''), r.at, n.sent_at,
r.declared is not null and r.declared <> '' and r.declared = n.sent,
coalesce(r.declared, ''), coalesce(r.reported_sequence, 0)
coalesce(r.declared, ''), coalesce(r.reported_sequence, 0), coalesce(r.busy, '[]')
from node n left join node_report r on r.node = n.id
order by n.name`)
if err != nil {
@@ -1123,7 +1193,12 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
var out []Reported
for rows.Next() {
var r Reported
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence); err != nil {
var busy []byte
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence,
&busy); err != nil {
return nil, err
}
if err := json.Unmarshal(busy, &r.Busy); err != nil {
return nil, err
}
out = append(out, r)
+33
View File
@@ -367,6 +367,39 @@ type PlanGate struct {
// alone to the machine holding the bus, every build there kept, so what the send newly grants is on the
// bus before the gate judges it. Nil when the list did not change, or the gate's machine holds the bus.
Grants *GrantsStep `json:"grants,omitempty"`
// Busy is the wait for a busy container on a judged machine (novox/hq ADR 0335 §4): while the machine
// reports its module's container left running because it said busy, the judging waits and has not
// started; once it reports the container replaced, Since is moved to then. Nil when nothing waited.
Busy *GateBusy `json:"busy,omitempty"`
}
// GateBusy is a first-node gate's wait for a busy container (novox/hq ADR 0335 §4).
type GateBusy struct {
Machine string `json:"machine"`
Module string `json:"module"`
Container string `json:"container"`
// Since is when the machine's first apply that would have replaced it ran, Until when its node-engine
// replaces it at the latest; Ended when the machine reported it replaced and the judging began — or when
// the controller stopped waiting past the engine's bound, which PastBound says.
Since time.Time `json:"since"`
Until time.Time `json:"until"`
Ended *time.Time `json:"ended,omitempty"`
PastBound bool `json:"past_bound,omitempty"`
// Said is the last output of its busy command, as the machine reported it.
Said string `json:"said,omitempty"`
}
// Waiting says the gate waits for a busy container and has not started.
func (g *PlanGate) Waiting() bool { return g != nil && g.Busy != nil && g.Busy.Ended == nil }
// Words is the wait in a line, as the walk says it.
func (b *GateBusy) Words() string {
said := ""
if b.Said != "" {
said = "; its busy command says: " + b.Said
}
return fmt.Sprintf("waits for a busy container on %s: %s holds work, and is replaced once it says idle, by %s "+
"at the latest (novox/hq ADR 0335)%s", b.Machine, b.Container, b.Until.UTC().Format(time.RFC3339), said)
}
// GrantsStep is the bus's user list sent to the machine holding the bus ahead of a gated send (novox/hq
+22 -2
View File
@@ -26,6 +26,7 @@ const (
PhaseBetween = "between-tiers" // one tier's end to the next tier's ask
PhaseBuild = "build" // a tier asked to its last module built
PhaseSend = "send-first" // built to sent to the first machines
PhaseBusy = "busy-wait" // sent first to a busy container there replaced, when one waited (ADR 0335)
PhaseJudge = "judgement" // sent first to the first-node gate's last verdict: its readings
PhaseRest = "send-rest" // judged to sent to the rest
PhaseApply = "apply" // the last send to every machine of the rest reporting the build applied
@@ -300,8 +301,11 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if t > p.Tier || (t == p.Tier && p.Tier < len(p.Tiers) && !askedAnyOf(p, tier)) {
break
}
var asked, built, first, judged, rest *time.Time
var asked, built, first, judged, rest, busyEnded *time.Time
allBuilt, anyFirst, allJudged, allRest := true, false, true, true
// A busy container on a first machine holds the judging until it is replaced (novox/hq ADR 0335 §4): a
// phase of its own, only in a tier where one waited, counted in the delivery time and never exempted.
anyBusy, allBusyEnded, busyOn := false, true, ""
for _, m := range tier {
s := p.Modules[m]
if s == nil {
@@ -319,6 +323,14 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if s.FirstAt != nil {
anyFirst = true
first = earliest(first, s.FirstAt)
if s.Gate != nil && s.Gate.Busy != nil {
anyBusy = true
if s.Gate.Busy.Ended == nil {
allBusyEnded, busyOn = false, s.Gate.Busy.Machine
} else {
busyEnded = latest(busyEnded, s.Gate.Busy.Ended)
}
}
if s.Gate != nil {
if s.Gate.JudgedAt == nil {
allJudged = false
@@ -349,7 +361,15 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if !allJudged {
judged = nil
}
out = append(out, point{phase: PhaseSend, tier: t, at: first}, point{phase: PhaseJudge, tier: t, at: judged})
out = append(out, point{phase: PhaseSend, tier: t, at: first})
if anyBusy {
said := ""
if !allBusyEnded {
busyEnded, said = nil, "waits for a busy container on "+busyOn
}
out = append(out, point{phase: PhaseBusy, tier: t, at: busyEnded, said: said})
}
out = append(out, point{phase: PhaseJudge, tier: t, at: judged})
} else {
out = append(out, point{phase: PhaseSend, tier: t, none: true, said: "no first machine: nothing to judge"},
point{phase: PhaseJudge, tier: t, none: true, said: "no first machine: nothing to judge"})
+11
View File
@@ -175,6 +175,11 @@ type MachineOptions struct {
// can take a build, which is what its container's health check reads (novox/hq issue 544). Nil is
// told nothing.
Taking func()
// Claim is asked once a request is fetched, just before it is handed over: false hands it back to the
// queue at once, for another holder. A holder being drained says so here (novox/hq ADR 0335): it marks
// the request in hand first and reads its drain after, so an ask of `busy` racing the fetch sees one or
// the other, never neither. Nil claims every one.
Claim func() bool
}
// MachineOverNATS takes build work from the current build role.
@@ -317,6 +322,12 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
endCancelled(ctx, build)
continue
}
// **Drained in the moment it was fetched** (novox/hq ADR 0335): handed back at once, as a pause
// landing then is.
if m.opts.Claim != nil && !m.opts.Claim() {
_ = msg.Nak()
continue
}
// A build outlives the acknowledgement window many times over; said while it runs,
// as the controller says it for its own long handlers, so the server neither hands
// the ask to a second machine nor counts the wait against its deliveries.
+27 -1
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
@@ -418,6 +419,12 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
for id, why := range report.Failed {
doing.Failed = append(doing.Failed, inventory.FailedResource{ID: id, Error: why})
}
// And a container left running because it said busy, or replaced after it waited (novox/hq ADR 0335):
// kept with the account, for the walk whose first-machine gate waits on it.
for _, b := range report.Busy {
doing.Busy = append(doing.Busy, inventory.BusyWait{ID: b.ID, Container: b.Container, State: b.State,
Since: b.Since, Until: b.Until, Said: b.Said})
}
// Ordered, so two readings of one failure are the same reading.
sort.Slice(doing.Failed, func(i, j int) bool { return doing.Failed[i].ID < doing.Failed[j].ID })
@@ -492,7 +499,14 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
}
// And how long the machine took, from the send to this first account of it (novox/hq to-be 45
// Phase 0): what the sent-not-reported bound will be set from. Said if lost, never a failure.
if err := e.Inventory.RecordApplyDuration(ctx, report.Node, report.Declared, doing.Outcome); err != nil {
//
// **Not while a container waits because it said busy** (novox/hq ADR 0335 §4): the machine is not yet
// running what it was sent, and the account that says the container replaced is the one that ends the
// apply — so a walk's rest counts the wait in its delivery time, never exempted.
if doing.Waiting() {
log.Printf("%s applied declaration %s and leaves a busy container running until it says idle: %s",
report.Node, short(report.Declared), busyWords(doing.Busy))
} else if err := e.Inventory.RecordApplyDuration(ctx, report.Node, report.Declared, doing.Outcome); err != nil {
log.Printf("how long %s took to apply could not be recorded: %v", report.Node, err)
}
@@ -504,3 +518,15 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
}
return news, e.Inventory.RecordOwned(ctx, node.ID, report.Applied)
}
// busyWords is every container waiting because it said busy, in a line (novox/hq ADR 0335).
func busyWords(busy []inventory.BusyWait) string {
var out []string
for _, b := range busy {
if b.State == inventory.BusyWaiting {
out = append(out, fmt.Sprintf("%s since %s, at the latest until %s", b.Container,
b.Since.UTC().Format(time.RFC3339), b.Until.UTC().Format(time.RFC3339)))
}
}
return strings.Join(out, "; ")
}
+31
View File
@@ -272,6 +272,12 @@ type Report struct {
// `witness` and `not-reversible` are sent only to a machine whose report carries it.
Witness int `json:"witness,omitempty"`
// Busy is every container the reporting apply left running because it said busy, and every one it
// replaced after it had waited (novox/hq ADR 0335; mesh-host internal/link/messages.go): a walk's
// first-machine gate waits while its module's container is listed waiting, and starts at the report
// that no longer lists it so. Absent from an engine older than BusyContract, which never waits.
Busy []Busy `json:"busy,omitempty"`
// Health is the machine's word on every long-running resource it runs for a module (novox/hq ADR
// 0240, to-be 48 §4; mesh-host's internal/liveness): its state, since when, its failing streak and
// the restarts its node-engine counted. **Absent from an engine older than the judging**, which is
@@ -436,6 +442,31 @@ const ReadinessContract = 2
// parses strictly and would refuse the whole declaration for it.
const RootContract = 3
// BusyContract is the statement of an engine that also reads a container's `busy` (novox/hq ADR 0335): it asks
// a container that declares it before replacing it and says the wait in its report. Only to such an engine is
// the field sent: an older one parses strictly and would refuse the whole declaration for it. The same number as
// mesh-host internal/link/messages.go's BusyContract.
const BusyContract = 8
// The states of a busy container in a report (novox/hq ADR 0335 §2, §3).
const (
BusyWaiting = "waiting"
BusyReplacedIdle = "replaced-when-idle"
BusyReplacedAtBound = "replaced-at-bound"
)
// Busy is one container a busy command held, as a node-engine reports it (novox/hq ADR 0335). ID is the
// resource's id, `<module>.<resource>`; Since when the first apply that would have replaced it ran, Until when
// the engine's bound replaces it at the latest, Said the last output of its busy command.
type Busy struct {
ID string `json:"id"`
Container string `json:"container"`
State string `json:"state"`
Since time.Time `json:"since"`
Until time.Time `json:"until"`
Said string `json:"said,omitempty"`
}
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
// own words (mesh-host internal/accounts ReasonRoot).
const ReasonRoot = "can become root without a person"