Files
mesh-controller/cmd/mesh-controller/pending_test.go
T
jschoubben 88ea361bf6 Fold a pending assignment into the value of its assignment key (issue 1094)
A wait for a build is the value of <node>.<module> with state pending and a pending object (id p-<unix nanoseconds>, a string), so every reader that skips a value with a state skips it, and the controller's own lists skip it through Stands. The transitions are compare-and-set on the key's revision (claim, release, settle, repoint, mark); assigning over a wait carries it into the value that stands, applied when it was waiting; a plan pass never overwrites it; one open wait per machine and module, and a key that holds the module refuses a new one. The table is no longer read or written, ForgetEndedPending is gone (no key is removed), and a wait of a machine the store dropped is none. Callers change type only; their conditions are the next issue.
2026-10-18 05:17:30 +02:00

167 lines
7.4 KiB
Go

package main
import (
"encoding/json"
"slices"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// failedBuild settles the merge's build of modules/sensors as failed.
func conditionOpen(t *testing.T, p inventory.PendingAssignment) bool {
t.Helper()
_, found, err := conditionsFrom.Get(t.Context(), pendingObservation(p).Key())
if err != nil {
t.Fatal(err)
}
return found
}
func rowOf(t *testing.T, open *stores, id string) inventory.PendingAssignment {
t.Helper()
rows, err := open.inventory.Pending(t.Context(), time.Unix(0, 0))
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.ID == id {
return r
}
}
t.Fatalf("pending assignment %s is not on record", id)
return inventory.PendingAssignment{}
}
// novox/hq issue 325: an assignment of a module the catalogue does not hold says which case it is in — a
// build in flight (kept pending), known and not built (said, with build), or unknown (refused, with the
// closest names) — and a pending assignment is made when its build registers the module, or ends with why.
// aCatalogueMesh is aMesh with one module held from the catalogue, so a merge of it is acted on.
// mergeAdding is the catalogue's merge adding one module's directory, acted on as the bus hands it over.
// outcome is a build's outcome as the build seat announces it: registered as module, or failed.
func outcome(id, dir, commit, module, failed string) link.BuildResult {
r := link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: dir, Ref: "main", Commit: commit,
On: "anchor", Failed: failed, Source: &link.SourceOnSeat{Repository: "novox/mesh-catalog", Seat: "git"}}
if failed == "" {
r.Module = module
r.Manifest, _ = json.Marshal(catalogue.Manifest{Module: module, Version: "1"})
}
return r
}
func assignedTo(t *testing.T, open *stores, node string) []string {
t.Helper()
got, err := open.inventory.Assigned(t.Context(), node)
if err != nil {
t.Fatal(err)
}
return got
}
func pendingOf(t *testing.T, open *stores) []inventory.PendingAssignment {
t.Helper()
got, err := open.inventory.Pending(t.Context(), time.Now().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
return got
}
// A build in flight: the merge asked for it, the request is kept as the merge's, the assignment is kept
// pending and said so with the build, status reads it without settling anything, and the build's outcome
// makes it — saying that a push sends it, and nothing more.
// **A read settles nothing** (review of #150, point 6): status — and so the board, the summary and the
// probe, which compose from the same reading — leaves a pending assignment whose module is registered as it
// is; the controller's tick makes it.
// The build of a pending assignment fails: it expires with the build's words and nothing is assigned; the
// tick raises it as a condition once; it then reads as known and not built; and `unassign` takes it back,
// after which the tick clears the condition. Status is not called well while the condition is open, and is
// again once it clears — no fixed day of "not well" (review point 6).
// The condition also clears when the module is later assigned to the machine.
// Known and not built, asked with build: the build is asked from where the last one was, the request kept as
// assign's, and the assignment kept pending on the new build.
// **build "true" with a pending assignment already waiting** (review point 5): the waiting one is made to
// wait for the new build, and no build is asked that nothing waits on.
// **A failed ask never reads as in flight** (review point 2): a merge whose ask could not be made keeps the
// request as not asked, and assign says the merge could not ask; a request whose asker could not hand it over
// or stopped waiting reads the same, unless its outcome was heard.
// A plan's tier keeps its requests as the plan's (review point 3).
// **A build heard as built and not registered yet is in flight** (review point 4): the outcome is recorded
// before the module is registered; for that moment assign keeps the assignment pending. Past the grace it is
// recorded and not registered, and the tick ends the pending assignment with that.
// Unknown: refused as no module of that name, claiming only what was looked at, with the closest names; build
// asks for nothing.
// Several modules in one act, one of them not registered: nothing is assigned and nothing kept pending, and
// each missing one is said.
// unassign withdraws a waiting pending assignment; the build goes on and registers the module assigned
// nowhere: a withdrawn row is never claimed.
// **A claim is never overridden** (review point 1): a pending assignment being made is claimed
// (waiting → applying); an unassign meanwhile is refused and says so, and leaves the claim; making a row
// already withdrawn makes nothing. A claim left by a controller that stopped is settled by the tick from what
// the mesh holds: back to waiting when the module is not assigned, applied when it is.
// A build that says nothing within the bound: the pending assignment expires at the next tick, saying so,
// rather than waiting for ever; and a module registered by a process that kept no pending assignment is
// assigned at the next tick.
// The seat's assign passes build on; unassign takes none.
func TestTheSeatsAssignPassesBuild(t *testing.T) {
argv, err := argvFor("assign", map[string]any{"node": "g14", "module": "sensors", "build": "true"})
if err != nil || !slices.Equal(argv, []string{"assign", "g14", "sensors", "--build"}) {
t.Fatalf("assign with build: %v %v", argv, err)
}
if _, err := argvFor("unassign", map[string]any{"node": "g14", "module": "sensors", "build": "true"}); err == nil {
t.Fatal("unassign took build")
}
}
// The condition's words are plain.
func TestAnAssignmentNotMadeIsSaidPlainly(t *testing.T) {
o := pendingObservation(inventory.PendingAssignment{Node: "g14", Module: "sensors",
Note: "the pending assignment of sensors to g14 expired: build b-1 of modules/sensors failed: boom"})
w := plainWordings[kindPendingEnded](o)
if why, ok := conditions.PlainWords(w, "g14"); !ok {
t.Fatalf("not plain: %s %+v", why, w)
}
if w.Headline != "sensors was not put on g14" {
t.Fatalf("headline %q", w.Headline)
}
}
// **One row's condition is never another's** (second review, bug A). The sequence: the first pending
// assignment's build fails and the tick raises it; the person assigns again with build "true", a second
// pending assignment; its build fails too before the next tick. That tick raises the second, and must not
// clear it by judging the first answered by the second: each row has its own condition, and a newer row that
// itself ended unmade answers nothing.
// **A raised row outlives the pruning until its condition clears** (second review, bug B); and a machine's
// removal, which takes its rows with it, clears their conditions at the next tick.
// **A claim left by a controller that stopped** (second review): the tick settles it from what the mesh
// holds — back to waiting when the module is not assigned there, applied when it is — and leaves a fresh
// claim alone.
// **A waited build whose asker stopped waiting is asked, outcome unknown** (second review): it may still run,
// so it reads as in flight until its outcome or its bound, never as not asked; a build never handed over
// is not asked.