Merge branch 'feat/47-G-fix11-b2f' into feat/47-G-join11

This commit is contained in:
2026-11-12 10:32:18 +01:00
77 changed files with 3935 additions and 1696 deletions
@@ -1,26 +1,25 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sync"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
)
// What a want names of a build is heard, never read from the builder's state (design 47 G §379, §526, A-94, A-96; ADR 0596 §2): the
// builder's event `build.ended` (a done build's parts by digest, its keeping node and its branch) and the catalog's
// `version-released` (a released version and each part's digest, with the source revision it was built from), each kept as heard.
// buildEndedSubjects is the builder's event build.ended on its own stream, as the manifest's consumes names it
// (`mesh-delivery-builder.build.ended`), each module and fingerprint after it.
var buildEndedSubjects = process.ModuleStreamSubject(builds.BuilderModule, builds.BuildEndedEvent+".>")
// builtEnds are the done builds' ends heard, by module and fingerprint (hearBuildEnded).
type builtEnds struct {
mu sync.Mutex
@@ -38,6 +37,9 @@ func (b *builtEnds) put(data []byte) error {
if e.Module == "" || e.Fingerprint == "" {
return fmt.Errorf("a build's end naming no module or fingerprint: %s", data)
}
if e.Outcome == "" { // an end said before A-162 is a done build's
e.Outcome = builds.BuildDone
}
b.mu.Lock()
defer b.mu.Unlock()
if b.ends == nil {
@@ -47,6 +49,14 @@ func (b *builtEnds) put(data []byte) error {
return nil
}
// endOf is the end heard of module's build at fp, done or failed (A-162): none while the build has not ended.
func (b *builtEnds) endOf(module, fp string) (builds.BuildEnded, bool) {
b.mu.Lock()
defer b.mu.Unlock()
e, ok := b.ends[b.key(module, fp)]
return e, ok
}
// partsOf is the parts of module's build at fp as its end names them (each artifact's digest by `<module>:<artifact>`), with the
// node that keeps it, for a kept build or a build of another watched branch; false for a build of the trunk not kept, whose
// parts the version's release says (releasedVersions).
@@ -68,6 +78,8 @@ func (b *builtEnds) partsOf(module, fp string) (assign.KeptBuild, bool) {
type releasedVersions struct {
mu sync.Mutex
seen map[string]assign.KeptBuild
// newest is, by module, the last version-released heard of it (delivered.go).
newest map[string]releaseHeard
}
func (r *releasedVersions) key(module string, source int64) string {
@@ -93,6 +105,14 @@ func (r *releasedVersions) put(data []byte) error {
r.seen = map[string]assign.KeptBuild{}
}
r.seen[r.key(e.Module, e.Source)] = assign.KeptBuild{Version: e.Version, Parts: parts}
if r.newest == nil {
r.newest = map[string]releaseHeard{}
}
h := releaseHeard{Version: e.Version, Parts: parts, Source: e.Source}
if e.At != nil {
h.At = *e.At
}
r.newest[e.Module] = h // the stream's order is the order of release
return nil
}
@@ -104,21 +124,33 @@ func (r *releasedVersions) of(module string, source int64) (assign.KeptBuild, bo
return k, ok
}
// hearBuildsAndReleases registers the hub's takers of build.ended and version-released, each waking the pass; before the hub starts.
func hearBuildsAndReleases(hub *process.EventHub, ends *builtEnds, released *releasedVersions, wake func(), logf func(string, ...any)) error {
taker := func(put func([]byte) error) func(jetstream.Msg) {
return func(m jetstream.Msg) {
if err := put(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
// buildsStreamName is the builder's stream of build ends as the bus names it: kept last per subject (one end per module and
// fingerprint, its manifest's `last-per-subject`, design 47 G A-181, §703).
var buildsStreamName = catalogue.StreamBusName(builds.BuildsStream)
// hearBuilds reads the builder's stream of build ends whole at the start and then follows it (design 47 G A-181, §556): what was
// built, and a failed build's error, survive the assigner's restart, and it reads none of the builder's buckets. The read ends before
// it returns, so the first pass knows every build; a stream it cannot hear is said, and no want names a build until the assigner
// starts again.
func hearBuilds(ctx context.Context, js jetstream.JetStream, ends *builtEnds, wake func(), findings assign.Findings, logf func(string, ...any)) {
acct := process.Account{Node: thisNode, Module: assignment.Module}
nh := notHeardWriter(findings, logf)
cc, err := acct.HearStream(ctx, js, buildsStreamName, func(m jetstream.Msg) {
if err := ends.put(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
}, logf)
if err != nil {
nh.NotHeard(buildsStreamName, "what the builder built is not heard until the assigner starts again, so no want names a build's parts or its keeping node and no put goes on", err)
return
}
if err := hub.Handle(buildEndedSubjects, taker(ends.put)); err != nil {
return err
}
return hub.Handle(meshcatalog.SubjectVersionReleased, taker(released.put))
nh.Heard(buildsStreamName)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
@@ -86,3 +86,25 @@ func TestATrunkEntryTakesItsVersionFromTheReleaseOfTheKeptRevisionsSource(t *tes
t.Error("no revision kept names a release")
}
}
// A-162: the assigner knows a failed build from its `build.ended`: the error whole; a done end or none heard is not failed.
func TestTheStandingOfABuildIsHeardFromItsEnd(t *testing.T) {
w := newWorld()
say := func(e builds.BuildEnded) {
raw, _ := json.Marshal(e)
if err := w.ends.put(raw); err != nil {
t.Fatal(err)
}
}
if failed, _ := w.buildStanding("web", "fp1"); failed {
t.Error("a build nothing was heard of failed")
}
say(builds.BuildEnded{Module: "web", Fingerprint: "fp1", Outcome: builds.BuildFailed, Error: "transport fault: connection"})
if failed, why := w.buildStanding("web", "fp1"); !failed || why != "transport fault: connection" {
t.Errorf("failed end: failed %v why %q", failed, why)
}
say(builds.BuildEnded{Module: "web", Fingerprint: "fp1", Outcome: builds.BuildDone})
if failed, _ := w.buildStanding("web", "fp1"); failed {
t.Error("a later done end leaves the build failed")
}
}
@@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
)
// endDone is the builder's `build.ended` of a done build of module at fp heard by w, naming the digest of its manifest (design 47 G
// A-181: what was built is heard, never read from the builder's bucket).
func endDone(t testing.TB, w *world, module, fp, manifest string) {
t.Helper()
data, err := json.Marshal(builds.BuildEnded{Module: module, Fingerprint: fp, Outcome: builds.BuildDone, Manifest: manifest})
if err != nil {
t.Fatal(err)
}
if err := w.ends.put(data); err != nil {
t.Fatal(err)
}
}
// trunkBranch is the first watched branch the tests give the world: the code names none (design 47 G §507).
const trunkBranch = "main"
@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"mesh-delivery-assigner/internal/assigngraph"
)
// A run's candidate assignment graph (design 47 G §189, §190, A-201, A-235): the candidate `run-<id>`'s catalog graph revision,
// handed by meshcatalogclient's candidates hook, is read whole through the catalog seat's verb revision; the assignment graph is
// computed from it in place of its branch's half, beside every other followed branch's, and kept under candidate.<group>.<heads>;
// the runner's group-ended of the run drops it. It writes no want and says no event; the gate's heads-replaced drops nothing.
// runGroupPrefix is a run on request's candidate group before its id, `run-<id>` (A-160).
const runGroupPrefix = "run-"
// readCandidateHalf reads a candidate's catalog graph revision whole through the seat's verb revision, page by page, with the
// candidate's group and heads and the revision its event names.
func readCandidateHalf(call mcc.Call, ev meshcatalog.CatalogRevisionEvent) (mcc.Half, error) {
half := mcc.Half{Entries: map[string]meshcatalog.CatalogEntry{}}
after := ""
for {
a, err := askCandidatePage(call, meshcatalog.RevisionInput{Branch: ev.Branch, Group: ev.Group, Heads: ev.Heads, Revision: ev.Revision, After: after})
if err != nil {
return half, err
}
if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group {
return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", mcc.RevisionVerb, a.Revision, ev.Group, ev.Revision)
}
half.Revision = *a.Revision
for _, e := range a.Entries {
half.Entries[e.Module] = e
}
if a.Next == "" {
return half, nil
}
after = a.Next
}
}
// keepCandidate computes and keeps the candidate's assignment graph of a run's catalog-revision; another candidate's (a try's, a
// group's) is passed over.
func (p *passer) keepCandidate(ctx context.Context, call mcc.Call, ev meshcatalog.CatalogRevisionEvent) error {
if !strings.HasPrefix(ev.Group, runGroupPrefix) {
return nil
}
g := p.graph
if g == nil || g.objects == nil || g.halves == nil {
return errors.New("the assigner keeps no assignment graph, so no candidate's is kept")
}
half, err := readCandidateHalf(call, ev)
if err != nil {
return fmt.Errorf("the candidate %s's revision %d is not read, so its assignment graph is not kept: %w", ev.Group, ev.Revision, err)
}
in := p.graphInputs()
in.Halves = maps.Clone(in.Halves)
in.Halves[half.Revision.Branch] = half
c := assigngraph.Compute(in)
for _, u := range c.Uncomputed {
p.logf("the candidate %s's assignment graph leaves out %s", ev.Group, u)
}
rev, fresh, err := assigngraph.KeepCandidate(ctx, g.objects, c, ev.Group, ev.Heads, meshcatalog.EventCatalogRevision, p.clock())
if err != nil {
return fmt.Errorf("the candidate %s's assignment graph is not kept: %w", ev.Group, err)
}
if fresh {
p.logf("the candidate %s's assignment graph revision %d is kept, from its catalog graph revision %d", ev.Group, rev.Revision, ev.Revision)
}
return nil
}
// dropCandidate removes a run's candidate assignment graph on the runner's group-ended of that run (A-235).
func (p *passer) dropCandidate(ctx context.Context, group string) error {
g := p.graph
if g == nil || g.objects == nil {
return nil
}
o, ok := g.objects.(assigngraph.Remover)
if !ok {
return fmt.Errorf("the objects %s remove nothing, so the candidate %s is kept", assigngraph.RevisionsObjects, group)
}
n, err := assigngraph.DropCandidate(ctx, o, group)
if err == nil && n > 0 {
p.logf("the candidate %s's assignment graph is dropped: its run ended", group)
}
return err
}
// askCandidatePage is one page of a candidate's revision as the seat's verb revision answers it.
func askCandidatePage(call mcc.Call, in meshcatalog.RevisionInput) (meshcatalog.RevisionAnswer, error) {
raw, err := call(mcc.RevisionVerb, in)
if err != nil {
return meshcatalog.RevisionAnswer{}, err
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return meshcatalog.RevisionAnswer{}, fmt.Errorf("%s answered: %s", mcc.RevisionVerb, said)
}
var a meshcatalog.RevisionAnswer
if err := json.Unmarshal(answer, &a); err != nil {
return a, fmt.Errorf("%s answered no revision: %w", mcc.RevisionVerb, err)
}
return a, nil
}
@@ -0,0 +1,451 @@
package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
wd "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// A delivery is waited for mesh-wide (design 47 G §376, §389, A-189, A-246). At its start the assigner reads whole, from their first
// message, the streams of the catalog seat's version-released and of its own version-delivered (mesh-sdk process.FromFirst, A-233),
// and the watchman's machine-away and machine-back, never its state; then it hears them. A released version of a module is said
// version-delivered on the seat mesh-wants once, when every present machine placing it on the branch it is released from runs, healthy,
// the want the assigner wrote naming that version; a version no present machine places is not delivered. A dependent whose want
// changes fingerprint waits, by an edge of kind 2, 4 or 6, for its dependency's newest released version not yet said delivered, on
// the branch it is released from; on another branch no version is released and nothing waits. The wait is said wait-held, step
// delivery, bound 60 minutes from the version's release, kept in the state delivery-waits under the dependent's name, and said
// wait-ended when the version is delivered (ran) or released by hand (assigner_release, released).
// deliveryWaitBound is a dependent's delivery wait's bound, from its dependency's version-released.
const deliveryWaitBound = 60 * time.Minute
// Wait-ended's how of a delivery wait.
const (
deliveryWaitRan = "ran"
deliveryWaitReleased = "released"
)
// releaseHeard is one version-released heard: the version, each part's digest, the source revision and when it was said.
type releaseHeard struct {
Version string
Parts map[string]string
Source int64
At time.Time
}
// newestOf is the newest version of module released, the last heard of it in the stream's order.
func (r *releasedVersions) newestOf(module string) (releaseHeard, bool) {
r.mu.Lock()
defer r.mu.Unlock()
h, ok := r.newest[module]
return h, ok
}
// deliveredVersions are the version-delivered said, the assigner's own, by module and version: each is said once.
type deliveredVersions struct {
mu sync.Mutex
said map[string]bool
}
func (d *deliveredVersions) key(module, version string) string { return module + "@" + version }
// put takes one version-delivered in.
func (d *deliveredVersions) put(data []byte) error {
var e meshwants.VersionDeliveredEvent
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a version-delivered that is none: %w", err)
}
if e.Module == "" || e.Version == "" {
return fmt.Errorf("a version-delivered naming no module or version: %s", data)
}
d.mark(e.Module, e.Version)
return nil
}
func (d *deliveredVersions) mark(module, version string) {
d.mu.Lock()
defer d.mu.Unlock()
if d.said == nil {
d.said = map[string]bool{}
}
d.said[d.key(module, version)] = true
}
// has says whether version-delivered of module's version was said.
func (d *deliveredVersions) has(module, version string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.said[d.key(module, version)]
}
// presenceOf reads one machine-away or machine-back: the machine and when it was said.
func presenceOf(data []byte) (string, time.Time, error) {
var e wd.MachineAwayEvent // machine-back has the same fields
if err := json.Unmarshal(data, &e); err != nil {
return "", time.Time{}, err
}
if e.Machine == "" {
return "", time.Time{}, fmt.Errorf("it names no machine: %s", data)
}
return e.Machine, e.At, nil
}
// hearDelivery reads whole, then follows, the four streams a delivery is judged from: version-released, version-delivered,
// machine-away and machine-back. A stream not heard is said as the assigner's finding, and what it carries is not known until
// the assigner starts again.
func hearDelivery(ctx context.Context, js jetstream.JetStream, w *world, wake func(), findings assign.Findings, logf func(string, ...any)) {
acct := process.Account{Node: thisNode, Module: assignment.Module}
nh := notHeardWriter(findings, logf)
for _, s := range []struct {
stream, words string
take func([]byte) error
}{
{meshcatalog.StreamVersionReleased, "no want names a released version and no delivery is judged", w.released.put},
{meshwants.StreamVersionDelivered, "a version said delivered before is said again once it is judged", w.delivered.put},
{wd.StreamMachineAway, "no machine is known away, so a delivery waits for a machine that is away", func(data []byte) error {
m, at, err := presenceOf(data)
if err == nil {
w.heardPresence(m, true, at)
}
return err
}},
{wd.StreamMachineBack, "a machine once away is not known back", func(data []byte) error {
m, at, err := presenceOf(data)
if err == nil {
w.heardPresence(m, false, at)
}
return err
}},
} {
take := s.take
cc, err := acct.HearSeatEvents(ctx, js, s.stream, func(m jetstream.Msg) {
if err := take(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
}, logf, process.FromFirst())
if err != nil {
nh.NotHeard(s.stream, s.words+" until the assigner starts again", err)
continue
}
nh.Heard(s.stream)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
}
// releaseBranch is the branch module's versions are released from: the first watched branch of its registration (§371).
func (p *passer) releaseBranch(module string) string {
if p.ends.following() {
return p.ends.branchOf(module, "")
}
return p.w.trunkName()
}
// placing is every machine placing module on the branch its versions are released from, sorted.
func (p *passer) placing(module string) []string {
branch := p.releaseBranch(module)
var out []string
for node, placed := range p.w.placedBranches()[module] {
if p.ends.following() {
placed = p.ends.branchOf(module, placed)
} else {
placed = p.w.trunkName() // the trunk-only form: every placement runs the trunk's
}
if placed == branch {
out = append(out, node)
}
}
sort.Strings(out)
return out
}
// deliveryStanding is where module's version stands: the present placing machines not yet running it healthy as its want names
// it, and the placing machines away.
func (p *passer) deliveryStanding(module, version string) (notRunning, away []string, placing int) {
gone := p.w.awayNow()
n := assign.Nodes{Running: p.w.runningNow()}
wanted := p.w.wantsOf()[module]
nodes := p.placing(module)
for _, node := range nodes {
if gone[node] {
away = append(away, node)
continue
}
want, ok := wanted[node]
if !ok || want.Removed || want.Version != version || !n.RunsHealthy(node, module, want.Fp) {
notRunning = append(notRunning, node)
}
}
return notRunning, away, len(nodes)
}
// judgeDelivered says version-delivered of each module's newest released version not said yet, once every present machine placing
// it runs it healthy; judged again at each pass, which each running, machine-away, machine-back and placement change wakes.
func (p *passer) judgeDelivered() {
for _, module := range p.releasedModules() {
rel, _ := p.w.released.newestOf(module)
if p.w.delivered.has(module, rel.Version) {
continue
}
notRunning, away, placing := p.deliveryStanding(module, rel.Version)
if placing == 0 || len(away) == placing || len(notRunning) > 0 {
continue
}
if err := p.sayDelivered(module, rel, away); err != nil {
p.logf("version-delivered of %s %s is not said, and is judged again at the next pass: %v", module, rel.Version, err)
}
}
}
// sayDelivered says version-delivered of module's release rel, with the machines away, and keeps it said.
func (p *passer) sayDelivered(module string, rel releaseHeard, away []string) error {
at := p.clock().UTC()
body := meshwants.VersionDeliveredEvent{V: keys.Version, Module: module, Version: rel.Version, Parts: rel.Parts, Away: away, At: &at}
if err := p.wantsSay(meshwants.EventVersionDelivered, body, fmt.Sprintf("%s.%s.%s.%s", meshwants.EventVersionDelivered, module, rel.Version, at.Format(time.RFC3339Nano))); err != nil {
return err
}
p.w.delivered.mark(module, rel.Version)
return nil
}
// releasedModules is every module a version-released was heard of, sorted.
func (p *passer) releasedModules() []string {
r := &p.w.released
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.newest))
for m := range r.newest {
out = append(out, m)
}
sort.Strings(out)
return out
}
// deliveryWait is what a dependent waits for: its dependency, the version and the machines not yet running it.
type deliveryWait struct {
dependency string
rel releaseHeard
notRunning []string
away []string
}
// waitsForDelivery says whether module, whose want on its nodes changes to fp on branch, waits for a dependency's delivery: by
// each edge of kind 2, 4 or 6 into it in branch's catalog half, the dependency's newest released version on branch not yet said
// delivered. A want that changes no fingerprint waits for nothing; on a branch no version is released from, nothing waits.
func (p *passer) waitsForDelivery(module, fp, branch string, nodes []string) (deliveryWait, bool) {
wanted := p.w.wantsOf()[module]
changes := false
for _, node := range nodes {
if w, ok := wanted[node]; !ok || w.Removed || w.Fp != fp {
changes = true
}
}
if !changes || fp == "" {
return deliveryWait{}, false
}
var deps []string
for _, e := range p.w.edgesOf(branch, module) {
if e.Kind != 2 && e.Kind != 4 && e.Kind != 6 {
continue
}
dep, _, _ := strings.Cut(e.From, ":")
if dep != "" && dep != module && !slices.Contains(deps, dep) {
deps = append(deps, dep)
}
}
sort.Strings(deps)
for _, dep := range deps {
if p.releaseBranch(dep) != branch {
continue
}
rel, ok := p.w.released.newestOf(dep)
if !ok || p.w.delivered.has(dep, rel.Version) {
continue
}
notRunning, away, _ := p.deliveryStanding(dep, rel.Version)
return deliveryWait{dependency: dep, rel: rel, notRunning: notRunning, away: away}, true
}
return deliveryWait{}, false
}
// words is a delivery wait in plain words, as assigner_why_not and the status view say it.
func (d deliveryWait) words() string {
if len(d.notRunning) == 0 && len(d.away) > 0 {
return fmt.Sprintf("waiting for %s to be back", strings.Join(d.away, ", "))
}
return fmt.Sprintf("waiting for delivery of %s %s on %s", d.dependency, d.rel.Version, strings.Join(d.notRunning, ", "))
}
// deliveryWaitBody is the wait-held of module's delivery wait d.
func deliveryWaitBody(module, fp string, d deliveryWait, since time.Time) meshwants.Wait {
from := d.rel.At.UTC()
if from.IsZero() {
from = since.UTC()
}
return meshwants.Wait{Step: meshwants.WaitStepDelivery, Owner: meshwants.Seat, Module: module, Version: fp, Part: d.dependency,
On: d.dependency + "@" + d.rel.Version, Machines: d.notRunning, Since: since.UTC(), ClockFrom: &from, Bound: deliveryWaitBound.String(),
Words: d.words()}
}
// deliveryWaiting is one delivery wait the state delivery-waits keeps, by its dependent's name: the wait-held said, and a release
// by hand written by assigner_release.
type deliveryWaiting struct {
meshwants.Wait
// Released is set by assigner_release: the wait ends released and the dependent's want is written.
Released bool `json:"released,omitempty"`
}
// saidDeliveryWaits is every delivery wait the state delivery-waits holds, by module; a value that does not decode is said and left.
func (p *passer) saidDeliveryWaits() map[string]deliveryWaiting {
out := map[string]deliveryWaiting{}
for key, value := range p.w.read(buckets.DeliveryWaits) {
var w deliveryWaiting
if err := json.Unmarshal(value, &w); err != nil || w.Module == "" {
p.logf("%s %s holds no delivery wait, left: %v", buckets.DeliveryWaits, key, err)
continue
}
out[w.Module] = w
}
return out
}
// holdForDelivery says whether module's want, changing to fp on branch, is held for a dependency's delivery this pass: it says
// wait-held once and keeps it; a wait released by hand holds nothing; a wait that no longer holds is ended ran.
func (p *passer) holdForDelivery(ctx context.Context, module, fp, branch string, nodes []string) bool {
if p.kvs[buckets.DeliveryWaits] == nil {
return false
}
key := keys.EscapeName(module)
said, was := p.saidDeliveryWaits()[module]
d, waits := p.waitsForDelivery(module, fp, branch, nodes)
switch {
case was && said.Released:
p.endDeliveryWait(ctx, key, said, deliveryWaitReleased) // the want is written now, so it changes nothing at the next pass
return false
case !waits:
if was {
p.endDeliveryWait(ctx, key, said, deliveryWaitRan)
}
return false
}
body := deliveryWaitBody(module, fp, d, p.clock())
if was && said.On == body.On && said.Version == fp && slices.Equal(said.Machines, body.Machines) {
return true
}
if was {
body.Since = said.Since
}
id := fmt.Sprintf("%s.delivery.%s.%s.%s", key, fp, body.On, strings.Join(body.Machines, ","))
if err := p.wantsSay(meshwants.EventWaitHeld, body, id); err != nil {
p.logf("the delivery wait of %s is not said, and is said at the next pass: %v", module, err)
return true
}
kept := deliveryWaiting{Wait: body}
p.put(ctx, buckets.DeliveryWaits, key, func() ([]byte, error) { return json.Marshal(kept) })
return true
}
// endDeliveryWait says wait-ended of a delivery wait with how and removes it from the state.
func (p *passer) endDeliveryWait(ctx context.Context, key string, w deliveryWaiting, how string) {
body := w.Wait
body.How = how
if err := p.wantsSay(meshwants.EventWaitEnded, body, key+".delivery."+body.Version+"."+how); err != nil {
p.logf("the end of the delivery wait of %s is not said, and is said at the next pass: %v", w.Module, err)
return
}
p.remove(ctx, buckets.DeliveryWaits, key)
}
// againAsk is one ask of assigner_delivered_again, kept in the state delivered-again under its module (A-250): the version to say
// delivered again, who asked and why, and, while it does not hold, why not.
type againAsk struct {
Module string `json:"module"`
Version string `json:"version"`
By string `json:"by"`
Why string `json:"why"`
Waits string `json:"waits,omitempty"`
}
// releaseOf is the release heard of module's version.
func (r *releasedVersions) releaseOf(module, version string) (releaseHeard, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if h, ok := r.newest[module]; ok && h.Version == version {
return h, true
}
for key, k := range r.seen {
if strings.HasPrefix(key, module+"@") && k.Version == version {
return releaseHeard{Version: version, Parts: k.Parts}, true
}
}
return releaseHeard{}, false
}
// deliveredAgain takes each ask of assigner_delivered_again, heard at once through the watch of the state delivered-again: it says
// version-delivered again when every present machine placing the module runs that version healthy and removes the ask, else keeps
// it with why it does not hold, which assigner_waits reads.
func (p *passer) deliveredAgain(ctx context.Context) {
if p.kvs[buckets.DeliveredAgain] == nil {
return
}
for key, value := range p.w.read(buckets.DeliveredAgain) {
var a againAsk
if err := json.Unmarshal(value, &a); err != nil || a.Version == "" {
p.logf("%s %s holds no ask, left: %v", buckets.DeliveredAgain, key, err)
continue
}
module := a.Module
if module == "" {
module = key
}
waits := ""
rel, ok := p.w.released.releaseOf(module, a.Version)
notRunning, away, placing := p.deliveryStanding(module, a.Version)
switch {
case !ok:
waits = fmt.Sprintf("no version-released of %s %s is heard", module, a.Version)
case placing == 0:
waits = fmt.Sprintf("no machine places %s", module)
case len(away) == placing:
waits = fmt.Sprintf("every machine placing %s is away: %s", module, strings.Join(away, ", "))
case len(notRunning) > 0:
waits = fmt.Sprintf("%s %s does not run healthy on %s", module, a.Version, strings.Join(notRunning, ", "))
}
if waits == "" {
if err := p.sayDelivered(module, rel, away); err != nil {
p.logf("version-delivered of %s %s asked again is not said, and is asked at the next pass: %v", module, a.Version, err)
continue
}
p.logf("version-delivered of %s %s said again, asked by %s: %s", module, a.Version, a.By, a.Why)
p.remove(ctx, buckets.DeliveredAgain, key)
continue
}
if a.Waits != waits {
a.Waits = waits
p.put(ctx, buckets.DeliveredAgain, key, func() ([]byte, error) { return json.Marshal(a) })
}
}
}
@@ -0,0 +1,188 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §376, §389, §498, A-189, A-246: a released version is said version-delivered once, when every present machine placing
// it runs healthy the want naming it, a machine away named and not waited for, none when every placing machine is away; a dependent
// whose want changes fingerprint waits by an edge of kind 2, 4 or 6 for its dependency's newest released version not yet said
// delivered, said wait-held (step delivery, 60 minutes from the release) once and kept in delivery-waits, and wait-ended ran once
// the version is delivered; a kind-1 edge, or a dependency with no version released, waits for nothing; one released by hand ends
// released and its want is written.
func TestADependentWaitsForItsDependencysDelivery(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
waits := &memKV{values: map[string][]byte{}}
again := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Wants: &memKV{values: map[string][]byte{}}, buckets.DeliveryWaits: waits, buckets.DeliveredAgain: again}, at)
p.logf = t.Logf
var events []said
p.publishWants = func(event string, body any, id string) error {
raw, _ := json.Marshal(body)
events = append(events, said{event, id, raw})
return nil
}
p.w.runningHeard = true
placeOn(t, p.w, "ace", "db", "app")
placeOn(t, p.w, "g14", "db")
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
want := func(node, module, fp, version string) {
key, _ := mwc.WantKey(node, module)
raw, err := mwc.EncodeWant(mwc.Want{Fp: fp, Version: version, Self: true})
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Wants, key, raw)
}
runs := func(node, module, fp string) {
raw, _ := json.Marshal(map[string]any{"v": 1, "node": node, "module": module, "fingerprint": fp, "at": at})
if err := p.w.hearRunning("running", raw); err != nil {
t.Fatal(err)
}
}
source := int64(3)
release := func(module, version string) {
source++
raw, _ := json.Marshal(meshcatalog.VersionReleasedEvent{V: 1, Module: module, Version: version, Source: source, At: &at,
Artifacts: []meshcatalog.VersionArtifact{{Artifact: "program", Digest: "sha256:d"}}})
if err := p.w.released.put(raw); err != nil {
t.Fatal(err)
}
}
saidOf := func(event string) []said {
var out []said
for _, e := range events {
if e.event == event {
out = append(out, e)
}
}
return out
}
want("ace", "app", fp("1"), "")
// No version of db is released: app's new fingerprint waits for nothing.
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 4, From: "db:program", To: "app:program"}})
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app waits with no version of db released")
}
release("db", "1.1.0")
want("ace", "db", fp("d"), "1.1.0")
want("g14", "db", fp("d"), "1.1.0")
runs("ace", "db", fp("d"))
// A kind-1 edge orders nothing.
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 1, From: "db:program", To: "app:program"}})
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("a kind-1 edge holds app")
}
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 4, From: "db:program", To: "app:program"}})
// app's want unchanged waits for nothing.
if p.holdForDelivery(ctx, "app", fp("1"), trunkBranch, []string{"ace"}) {
t.Fatal("app's unchanged want waits")
}
// g14 places db and does not run 1.1.0 yet: not delivered, and app's new version waits, said once.
p.judgeDelivered()
if !p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) || !p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app does not wait for db 1.1.0")
}
if len(saidOf(meshwants.EventVersionDelivered)) != 0 {
t.Fatalf("db is said delivered while g14 does not run it: %+v", events)
}
held := saidOf(meshwants.EventWaitHeld)
if len(held) != 1 {
t.Fatalf("wait-held said %d times, want once", len(held))
}
var w meshwants.Wait
_ = json.Unmarshal(held[0].body, &w)
if w.Step != meshwants.WaitStepDelivery || w.Part != "db" || w.Module != "app" || w.Version != fp("2") || w.Bound != "1h0m0s" ||
w.ClockFrom == nil || !w.ClockFrom.Equal(at) || len(w.Machines) != 1 || w.Machines[0] != "g14" || w.Words != "waiting for delivery of db 1.1.0 on g14" {
t.Errorf("the wait said is %+v", w)
}
if _, ok := waits.values["app"]; !ok {
t.Errorf("the wait is not kept in delivery-waits: %v", waits.values)
}
// g14 away: every present machine runs it, so it is delivered, g14 named away; once only.
p.w.heardPresence("g14", true, at)
p.judgeDelivered()
p.judgeDelivered()
delivered := saidOf(meshwants.EventVersionDelivered)
var d meshwants.VersionDeliveredEvent
if len(delivered) != 1 || json.Unmarshal(delivered[0].body, &d) != nil || d.Module != "db" || d.Version != "1.1.0" ||
len(d.Away) != 1 || d.Away[0] != "g14" || d.Parts["db:program"] != "sha256:d" {
t.Fatalf("version-delivered said %+v", delivered)
}
// The wait ends ran, and app's want goes on.
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app still waits once db 1.1.0 is delivered")
}
ended := saidOf(meshwants.EventWaitEnded)
if len(ended) != 1 || json.Unmarshal(ended[0].body, &w) != nil || w.How != deliveryWaitRan {
t.Errorf("wait-ended said %+v", ended)
}
if _, ok := waits.values["app"]; ok {
t.Error("the ended wait is kept")
}
// A newer release with every placing machine away is not delivered, and its wait says the machines it waits to be back.
release("db", "1.2.0")
p.w.heardPresence("ace", true, at)
p.judgeDelivered()
if len(saidOf(meshwants.EventVersionDelivered)) != 1 {
t.Error("a version is said delivered while every placing machine is away")
}
if !p.holdForDelivery(ctx, "app", fp("3"), trunkBranch, []string{"ace"}) {
t.Fatal("app does not wait for db 1.2.0")
}
held = saidOf(meshwants.EventWaitHeld)
if json.Unmarshal(held[len(held)-1].body, &w) != nil || w.Words != "waiting for ace, g14 to be back" {
t.Errorf("the wait while every machine is away says %q", w.Words)
}
// Released by hand (assigner_release writes it): the wait ends released and app's want is written.
var kept deliveryWaiting
_ = json.Unmarshal(waits.values["app"], &kept)
kept.Released = true
raw, _ := json.Marshal(kept)
p.w.put(buckets.DeliveryWaits, "app", raw)
if p.holdForDelivery(ctx, "app", fp("3"), trunkBranch, []string{"ace"}) {
t.Fatal("a released wait holds app")
}
ended = saidOf(meshwants.EventWaitEnded)
if json.Unmarshal(ended[len(ended)-1].body, &w) != nil || w.How != deliveryWaitReleased {
t.Errorf("the released wait ended %+v", w)
}
// Presence: the later of machine-away and machine-back holds, whichever stream is read first.
p.w.heardPresence("ace", false, at.Add(time.Minute))
p.w.heardPresence("ace", true, at)
if p.w.awayNow()["ace"] || !p.w.online()["ace"] {
t.Error("an older machine-away read after a newer machine-back leaves ace away")
}
// A-250: an ask of assigner_delivered_again of 1.1.0, which ace (present) runs and g14 (away) does not, is said again and taken
// away; one of 1.2.0, which no machine runs, is kept with why.
ask := func(version string) {
raw, _ := json.Marshal(againAsk{Module: "db", Version: version, By: "op", Why: "the catalog missed it"})
p.w.put(buckets.DeliveredAgain, "db", raw)
}
before := len(saidOf(meshwants.EventVersionDelivered))
ask("1.1.0")
p.deliveredAgain(ctx)
if len(saidOf(meshwants.EventVersionDelivered)) != before+1 || p.w.has(buckets.DeliveredAgain, "db") {
t.Errorf("the ask of 1.1.0 said %d version-delivered and is kept %v", len(saidOf(meshwants.EventVersionDelivered))-before, p.w.has(buckets.DeliveredAgain, "db"))
}
ask("1.2.0")
p.deliveredAgain(ctx)
var a againAsk
if json.Unmarshal(p.w.value(buckets.DeliveredAgain, "db"), &a) != nil || a.Waits != "db 1.2.0 does not run healthy on ace" ||
len(saidOf(meshwants.EventVersionDelivered)) != before+1 {
t.Errorf("the ask of 1.2.0 = %+v", a)
}
}
@@ -34,6 +34,8 @@ type graphKeeper struct {
source int64
ok bool
said string
// pins removes a revision that ceased to be current once nothing pins it (pins.go); nil keeps every revision.
pins *pinKeeper
}
// keepGraph computes the revision of this pass and keeps it when the pass's inputs differ from the last computed.
@@ -42,13 +44,7 @@ func (p *passer) keepGraph(ctx context.Context) {
if g == nil || g.objects == nil || g.halves == nil {
return
}
in := assigngraph.Inputs{Halves: g.halves(), Placements: p.w.assignments(), Capabilities: p.capabilityDigests()}
// With no pipeline ends kept, the wants are the trunk-only form (pass.go): a placement naming no branch runs the trunk's.
in.FirstBranch = func(string) string { return trunkBranch }
if p.ends != nil {
in.FirstBranch = func(module string) string { return p.ends.branchOf(module, "") }
}
c := assigngraph.Compute(in)
c := assigngraph.Compute(p.graphInputs())
for _, u := range c.Uncomputed {
if g.said != u {
g.said = u
@@ -66,19 +62,35 @@ func (p *passer) keepGraph(ctx context.Context) {
g.last, g.has, g.kept, g.ok = c.Revision, true, rev, true
g.source = 0
for _, cat := range rev.Catalogs {
if cat.Branch == trunkBranch {
if cat.Branch == p.w.trunkName() {
g.source = cat.Source
}
}
if !fresh {
return
}
if g.pins != nil {
g.pins.judge() // the revision before ceased to be current
}
id := fmt.Sprintf("%s-%s-%d", meshwants.EventAssignmentRevision, rev.Kind, rev.Revision)
if err := p.wantsSay(meshwants.EventAssignmentRevision, assigngraph.Event(rev), id); err != nil {
p.logf("the assignment revision %d is kept but not said: %v", rev.Revision, err)
}
}
// graphInputs are what the pass's assignment graph is computed from: every followed branch's catalog half, the placements, each
// node's capability reading and the first branch of each registration.
func (p *passer) graphInputs() assigngraph.Inputs {
in := assigngraph.Inputs{Halves: p.graph.halves(), Placements: p.w.assignments(), Capabilities: p.capabilityDigests()}
// With no pipeline ends kept, the wants are the trunk-only form (pass.go): a placement naming no branch runs the trunk's.
first := p.w.trunkName()
in.FirstBranch = func(string) string { return first }
if p.ends != nil {
in.FirstBranch = func(module string) string { return p.ends.branchOf(module, "") }
}
return in
}
// capabilityDigests is the digest of each node's capability reading, the statement its engine made, by node.
func (p *passer) capabilityDigests() []meshwants.AssignmentRevisionCapabilitiesItem {
if p.reads == nil {
@@ -12,6 +12,7 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/buckets"
)
@@ -77,4 +78,47 @@ func TestTheWantsAreSaidWantedFromTheKeptAssignmentGraphRevision(t *testing.T) {
if err := json.Unmarshal(events[len(events)-1].body, &w); err != nil || len(events) != 3 || !w.Removed || w.Fp != fpTest {
t.Errorf("a removing want said %+v (%v), %d events", w, err, len(events))
}
// A-201, A-235: a run's candidate catalog revision, read whole through the seat's verb revision, keeps the run's assignment
// graph under candidate.<group>.<heads> in place of its branch's half; it says nothing and writes no want, a try's candidate
// keeps none, and the run's group-ended drops it.
before, puts := len(events), kv.puts
ev := meshcatalog.CatalogRevisionEvent{Kind: "candidate", Branch: trunkBranch, Group: "run-7", Heads: "h1", Revision: 5}
var asked []meshcatalog.RevisionInput
call := func(key string, body any) (json.RawMessage, error) {
in := body.(meshcatalog.RevisionInput)
asked = append(asked, in)
rev := meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "candidate", Group: "run-7", Heads: "h1", Revision: 5, Source: 9,
Entries: []meshcatalog.EntryRef{{Module: "web", Digest: "sha256:" + strings.Repeat("b", 64)}}}
a := meshcatalog.RevisionAnswer{Revision: &rev, Entries: []meshcatalog.CatalogEntry{{Module: "web", Fp: "src2:w2"}}}
return json.Marshal(a)
}
if err := p.keepCandidate(ctx, call, meshcatalog.CatalogRevisionEvent{Kind: "candidate", Branch: trunkBranch, Group: "try-3", Heads: "h", Revision: 2}); err != nil || len(asked) != 0 {
t.Errorf("a try's candidate asked %v: %v", asked, err)
}
if err := p.keepCandidate(ctx, call, ev); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0].Group != "run-7" || asked[0].Heads != "h1" || asked[0].Revision != 5 {
t.Errorf("the candidate's revision was asked as %+v", asked)
}
a, err := assigngraph.Revision(ctx, objects, meshwants.RevisionInput{Group: "run-7"})
if err != nil || a.Revision.Kind != "candidate" || len(a.Revision.Catalogs) != 1 || a.Revision.Catalogs[0].Revision != 5 {
t.Errorf("the run's assignment graph is %+v: %v", a.Revision, err)
}
if e, err := assigngraph.Entry(ctx, objects, meshwants.EntryInput{Group: "run-7", Node: "ace", Module: "web"}); err != nil || e.Entry.Fp != "src2:w2" {
t.Errorf("the run's entry of web is %+v: %v", e.Entry, err)
}
if len(events) != before || kv.puts != puts {
t.Errorf("a candidate said %d events and wrote %d wants", len(events)-before, kv.puts-puts)
}
if err := p.dropCandidate(ctx, "run-7"); err != nil {
t.Fatal(err)
}
if _, ok := objects[assigngraph.CandidateObject("run-7", "h1")]; ok {
t.Error("the run's group-ended left its candidate kept")
}
if _, err := assigngraph.Revision(ctx, objects, meshwants.RevisionInput{}); err != nil {
t.Errorf("dropping the run's candidate dropped trunk: %v", err)
}
}
@@ -16,11 +16,12 @@ import (
"git.novox.be/novox/mesh-sdk/go/views"
)
// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195): one
// meshcatalogclient.Catalog per branch, adopted through the module's views reader (A-26), which asks the first view of the verb
// `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings; each entry is taken
// into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog half, never its
// own output back (§195).
// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195, §507, §532): the
// branch follower of meshcatalogclient, adopted on the module's views reader (A-26), follows the branches the registrations name
// and, before the first registrations, the branches the gate's verb `source` answers and its event `source-moved` brings; each
// branch's view asks the verb `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings;
// each entry is taken into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog
// half, never its own output back (§195).
type halves struct {
w *world
call mcc.Call
@@ -29,42 +30,70 @@ type halves struct {
logf func(string, ...any)
ctx context.Context
mu sync.Mutex
cats map[string]*mcc.Catalog
// settled is told once when a branch's first view is answered, for the first pass to wait on (the trunk's alone).
branches *mcc.Branches
mu sync.Mutex
// settled is told once when the first watched branch's first view is answered, for the first pass to wait on.
settled chan<- struct{}
told bool
}
// follow adopts the catalog of branch through the reader, once; true when it was made now.
func (h *halves) follow(branch string) bool {
h.mu.Lock()
if h.cats[branch] != nil {
h.mu.Unlock()
return false
}
c, _, err := mcc.WireCatalogProcess(h.reader, h.call, branch, func(module string, entry json.RawMessage, deleted bool, _ time.Time) {
h.changed(branch, module, entry, deleted)
})
// follow makes the branch follower on the reader; the reader's start asks the first views.
func (h *halves) follow() error {
b, err := mcc.FollowBranchesProcess(h.reader, h.call)
if err != nil {
h.mu.Unlock()
h.logf("the catalog half of branch %q is not followed: %v", branch, err)
return false
return err
}
if h.cats == nil {
h.cats = map[string]*mcc.Catalog{}
b.Logf = h.logf
b.Changed = func(branch, module string, entry json.RawMessage, deleted bool, _ time.Time) {
h.changed(branch, module, entry, deleted)
}
h.cats[branch] = c
b.Adopted = h.adopted
h.mu.Lock()
h.branches = b
h.mu.Unlock()
if branch == trunkBranch && h.settled != nil {
go func() {
select {
case <-c.Ready():
h.settled <- struct{}{}
case <-h.ctx.Done():
}
}()
return nil
}
// first is the first watched branch followed, the trunk's: "" while none is.
func (h *halves) first() string {
h.mu.Lock()
b := h.branches
h.mu.Unlock()
if b == nil {
return ""
}
return true
if f := b.Followed(); len(f) > 0 {
return f[0]
}
return ""
}
// adopted is told of each branch whose view the follower adopted: the first branch is the trunk's, and the first pass waits for its
// first view.
func (h *halves) adopted(branch string, c *mcc.Catalog) {
first := h.first()
if first == "" {
return
}
h.w.setTrunk(first)
h.wake()
if branch != first || h.settled == nil {
return
}
h.mu.Lock()
told := h.told
h.told = true
h.mu.Unlock()
if told {
return
}
go func() {
select {
case <-c.Ready():
h.settled <- struct{}{}
case <-h.ctx.Done():
}
}()
}
// changed takes one entry of branch's half into the world, or a module taken out of it.
@@ -81,6 +110,7 @@ func (h *halves) changed(branch, module string, raw json.RawMessage, deleted boo
}
v, n, m := fromEntry(e)
h.w.putModule(branch, module, v, n, m)
h.w.putEdges(branch, module, e.Edges)
}
// fromEntry is a catalog entry as the value and the graph node the rules read, and its manifest parsed once under the digest of
@@ -114,11 +144,17 @@ func fromEntry(e meshcatalog.CatalogEntry) (meshcatalog.ModulesByModuleValue, gr
// views is the catalog graph revision held now of each followed branch whose first view is answered.
func (h *halves) views() map[string]mcc.Half {
h.mu.Lock()
defer h.mu.Unlock()
out := make(map[string]mcc.Half, len(h.cats))
for branch, c := range h.cats {
if half, ok := c.View(); ok {
out[branch] = half
b := h.branches
h.mu.Unlock()
out := map[string]mcc.Half{}
if b == nil {
return out
}
for _, branch := range b.Followed() {
if c, ok := b.Catalog(branch); ok {
if half, ok := c.View(); ok {
out[branch] = half
}
}
}
return out
@@ -8,6 +8,8 @@ import (
"errors"
"fmt"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
wd "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
"io"
"maps"
@@ -44,6 +46,7 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/buscompose"
@@ -176,7 +179,34 @@ func startBed(t *testing.T, leaveOut string) *bed {
Subjects: []string{buscompose.LogSubject(builds.BuilderModule, builds.BuilderLog) + ".>"}, AllowDirect: true}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>"}}); err != nil {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.mesh-catalog.event.>", "mesh.seat.mesh-conditions.event.>", "mesh.seat.mesh-module-assigner.event.>", enginereads.SubjectPrefix + "*"}}); err != nil {
t.Fatal(err)
}
// The node-engine seat's stream of `running`, kept last per subject, which the assigner hears the health of what runs from (§520).
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: nodeengine.StreamRunning, Subjects: []string{nodeengine.SubjectRunningAll}, MaxMsgsPerSubject: 1}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateOrUpdateConsumer(ctx, nodeengine.StreamRunning, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The watchman's machine-away and machine-back and the assigner's own version-delivered, kept by age, which it reads whole from their
// first message at its start and then follows through its durable (design 47 G §376, §389, A-189, A-233).
for _, k := range []struct{ stream, subject string }{{wd.StreamMachineAway, wd.SubjectMachineAwayAll}, {wd.StreamMachineBack, wd.SubjectMachineBackAll},
{meshwants.StreamVersionDelivered, meshwants.SubjectVersionDelivered}} {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: k.stream, Subjects: []string{k.subject}, MaxAge: 90 * 24 * time.Hour}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateOrUpdateConsumer(ctx, k.stream, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
}
// The builder's stream of build ends, kept last per subject (its manifest's `last-per-subject`, design 47 G A-181), which the assigner
// reads whole at its start and follows through its durable, as the grants part makes it (from the last of each subject).
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: buildsStreamName, Subjects: []string{process.ModuleStreamSubject(builds.BuilderModule, builds.BuildEndedEvent+".>")}, MaxMsgsPerSubject: 1}); err != nil {
t.Fatal(err)
}
if _, err := js.CreateOrUpdateConsumer(ctx, buildsStreamName, jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner", AckPolicy: jetstream.AckExplicitPolicy,
FilterSubjects: []string{process.ModuleStreamSubject(builds.BuilderModule, builds.BuildEndedEvent+".>")}, DeliverPolicy: jetstream.DeliverLastPerSubjectPolicy}); err != nil {
t.Fatal(err)
}
// The assigner's own stream of its findings' events (design 47 G §383), which its finding writer says into.
@@ -187,7 +217,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
// event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose
// machineEvent; the grants part's own test holds the derivation).
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>", meshcatalog.SubjectCatalogRevisionAll}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>",
"mesh.mod." + buckets.TellerModule + ".event.views.>", meshcatalog.SubjectCatalogRevisionAll}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
@@ -196,6 +227,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
}
// The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side.
serveIndexer(t, b, nc)
// The teller as the owner of its answers (A-175).
serveTeller(t, b, nc)
// The seat mesh-catalog's catalog half (design 47 G §194), which the assigner follows per branch.
serveCatalog(t, b, nc)
// The assigner's views family is its ordinary events on EVENTS (ADR 0635 §5); its marks are its state.
@@ -356,7 +389,7 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) {
}
for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(200 * time.Millisecond) {
// A change of a bucket it watches starts a pass, which opens the puts' bucket.
if _, err := b.buckets[buckets.Presence].Put(b.ctx, "n1", []byte(time.Now().String())); err != nil {
if _, err := b.buckets[buckets.Have].Put(b.ctx, "n1.pulse", []byte(time.Now().String())); err != nil {
t.Fatal(err)
}
if e, err := putsKV.Get(b.ctx, pkey); err == nil {
@@ -407,11 +440,38 @@ func TestTheAssignerDoesNotStartWithoutABucketItReads(t *testing.T) {
}
// putJSON writes a value into one of the bed's buckets.
// putBuild says the builder's `build.ended` of the build a builder's key and value (the old form of a done build, as the tests write it) name,
// on the builder's stream (design 47 G A-181): the assigner hears what was built, it reads no bucket of the builder.
func (b *bed) putBuild(t *testing.T, key string, value []byte) {
t.Helper()
module, fp, err := builds.ParseBuilderBuildKey(key)
built, derr := builds.DecodeBuilderBuild(value)
if err != nil || derr != nil {
t.Fatalf("%s: %v %v", key, err, derr)
}
raw, _ := json.Marshal(builds.BuildEnded{Module: module, Fingerprint: fp, Outcome: built.State, Error: built.Error, Manifest: built.Manifest, Needs: built.Needs})
if _, err := b.js.Publish(b.ctx, process.ModuleStreamSubject(builds.BuilderModule, builds.BuildEndedEventName(module, fp)), raw); err != nil {
t.Fatal(err)
}
}
func (b *bed) putJSON(t *testing.T, bucket, key string, value []byte) {
t.Helper()
if _, err := b.buckets[bucket].Put(b.ctx, key, value); err != nil {
t.Fatalf("%s %s: %v", bucket, key, err)
}
if bucket == buckets.Have { // the node-engine says `running` with each have it writes (§520): the assigner reads health from it alone
node, module, isNode, err := nodeengineclient.ParseHaveKey(key)
h, derr := nodeengineclient.DecodeHave(value)
if err != nil || isNode || derr != nil || h.Phase != nodeengineclient.PhaseRunning {
return
}
ok := h.Health == nodeengineclient.HealthOK
raw, _ := json.Marshal(nodeengine.Running{At: time.Now(), Node: node, Module: module, Fingerprint: h.Fp, Healthy: &ok})
if _, err := b.js.Publish(b.ctx, nodeengine.SubjectRunning(node, module), raw); err != nil {
t.Fatal(err)
}
}
}
// place adds module to the seat's assignments of node, written as the seat writes it: one node key and a key per module
@@ -510,7 +570,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
t.Fatal(err)
}
bkey, _ := builds.BuilderBuildKey("web", fNew)
b.putJSON(t, buckets.Builds, bkey, built)
b.putBuild(t, bkey, built)
for _, fp := range []string{fNew, fOld} {
gkey, _ := grantsstate.PartGrantKey("web", fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
@@ -519,8 +579,6 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
runs := map[string]string{"ace": fOld, "g14": "build-9", "shanks": fOld}
for _, node := range []string{"ace", "g14", "shanks"} {
b.compose(t, node, 'c')
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, node, on)
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: runs[node], Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
b.putJSON(t, buckets.Have, node+".web", have)
b.place(t, node, "web")
@@ -660,7 +718,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
for _, fp := range fps {
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: row.digest})
bkey, _ := builds.BuilderBuildKey(module, fp)
b.putJSON(t, buckets.Builds, bkey, built)
b.putBuild(t, bkey, built)
gkey, _ := grantsstate.PartGrantKey(module, fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
b.putJSON(t, buckets.Grants, gkey, grant)
@@ -736,7 +794,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
t.Fatal(err)
}
bkey, _ := builds.BuilderBuildKey("forms", fp)
b.putJSON(t, buckets.Builds, bkey, built)
b.putBuild(t, bkey, built)
gkey, _ := grantsstate.PartGrantKey("forms", fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
b.putJSON(t, buckets.Grants, gkey, grant)
@@ -847,8 +905,6 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putModule(t, "web", mod)
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
b.putJSON(t, buckets.Have, "ace.web", failed)
b.place(t, "ace", "web")
@@ -905,8 +961,6 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
digest := b.manifest(t, "mail", `{"module":"mail","version":"1","resources":[{"id":"data","type":"directory","path":"/var/lib/mail"}],"data":{"own":[{"id":"mail","path":"${dir:data}","class":"irreplaceable","backup":"copy"}]}}`)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/mail", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putModule(t, "mail", mod)
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
b.putJSON(t, buckets.Have, "ace.mail", failed)
wantKey, _ := mwc.WantKey("ace", "mail")
@@ -945,8 +999,6 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
b := startBed(t, "")
const modules = 40
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
names := make([]string, modules)
for i := range names {
name := fmt.Sprintf("mod%02d", i)
@@ -960,7 +1012,7 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
t.Fatal(err)
}
bkey, _ := builds.BuilderBuildKey(name, fp)
b.putJSON(t, buckets.Builds, bkey, built)
b.putBuild(t, bkey, built)
gkey, _ := grantsstate.PartGrantKey(name, fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
b.putJSON(t, buckets.Grants, gkey, grant)
@@ -1003,12 +1055,10 @@ func (b *bed) fixture(t *testing.T, name, fp, manifest string) {
b.putModule(t, name, mod)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
bkey, _ := builds.BuilderBuildKey(name, fp)
b.putJSON(t, buckets.Builds, bkey, built)
b.putBuild(t, bkey, built)
gkey, _ := grantsstate.PartGrantKey(name, fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
b.putJSON(t, buckets.Grants, gkey, grant)
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
b.place(t, "ace", name)
}
@@ -1326,6 +1376,13 @@ func (s bedStore) history(string, string) ([]stdio.StateRevision, error) { retur
// and declined, writes nothing and is withdrawn.
func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
b := startBed(t, "")
// Both machines are away throughout (§389): a machine the watchman never said away is present.
for _, node := range []string{"anchor", "laptop"} {
raw, _ := json.Marshal(wd.MachineAwayEvent{V: 1, Machine: node, At: time.Now(), LastHeard: time.Now()})
if _, err := b.js.Publish(b.ctx, wd.SubjectMachineAway(node), raw); err != nil {
t.Fatal(err)
}
}
if _, err := b.js.CreateStream(b.ctx, jetstream.StreamConfig{Name: "MOD_" + assignment.Module + "_assignments",
Subjects: assignmentsSubjects()}); err != nil { // the seat's four events: the views family has a stream of its own
t.Fatal(err)
@@ -1664,13 +1721,32 @@ var catalogLate chan struct{}
// read by the assigner's durable consumer; and its first-view tools of the three answered from the buckets at their marks.
func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
t.Helper()
marks, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: buckets.IndexerModule + "_" + views.MarksState, History: 1})
tools := map[string][2]string{}
for state, bucket := range indexerFirstViews {
tools[state] = [2]string{state, bucket}
}
serveOwner(t, b, nc, buckets.IndexerModule, tools, catalogLate)
}
// serveTeller is the teller as the owner of its answers (design 47 G A-175, A-178): its bucket, which a test fills as the
// teller writes it, said by its views family, and its read tool teller_read_answers answering the first view.
func serveTeller(t *testing.T, b *bed, nc *nats.Conn) {
t.Helper()
serveOwner(t, b, nc, buckets.TellerModule, map[string][2]string{buckets.AnswersReadTool: {buckets.AnswersState, buckets.Answers}}, nil)
}
// serveOwner serves an owner's states as the owner does: each state's bucket, its views family on EVENTS, and the first-view
// tool of each (by tool, its state and bucket) answered from the bucket at its mark. While late is open every ask fails.
func serveOwner(t *testing.T, b *bed, nc *nats.Conn, owner string, tools map[string][2]string, late chan struct{}) {
t.Helper()
marks, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: owner + "_" + views.MarksState, History: 1})
if err != nil {
t.Fatal(err)
}
em := views.Emitter{Owner: buckets.IndexerModule, Subject: process.Account{Node: "n1", Module: buckets.IndexerModule}.EventSubject,
em := views.Emitter{Owner: owner, Subject: process.Account{Node: "n1", Module: owner}.EventSubject,
Publish: views.JetStreamPublish(b.js), Marks: marks}
for state, bucket := range indexerFirstViews {
for tool, sb := range tools {
state, bucket := sb[0], sb[1]
kv, err := b.js.CreateKeyValue(b.ctx, jetstream.KeyValueConfig{Bucket: bucket, History: 5})
if err != nil {
t.Fatal(err)
@@ -1679,13 +1755,12 @@ func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
if err := em.Start(b.ctx, state, kv, nil); err != nil {
t.Fatal(err)
}
late := catalogLate
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-indexer.call."+state+".>", func(m *nats.Msg) {
if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down indexer
sub, err := nc.Subscribe("mesh.mod."+owner+".call."+tool+".>", func(m *nats.Msg) {
if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down owner
select {
case <-late:
default:
_ = m.Respond([]byte(`{"error":"the indexer's runtime is not up"}`))
_ = m.Respond([]byte(`{"error":"the owner's runtime is not up"}`))
return
}
}
@@ -1693,7 +1768,7 @@ func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
After string `json:"after"`
}
_ = json.Unmarshal(m.Data, &args)
a, err := views.FirstView(buckets.IndexerModule, state, bedSource{b.ctx, kv}, bedSource{b.ctx, marks}, args.After, time.Now())
a, err := views.FirstView(owner, state, bedSource{b.ctx, kv}, bedSource{b.ctx, marks}, args.After, time.Now())
out := map[string]any{"result": a, "node": "n0"}
if err != nil {
out = map[string]any{"error": err.Error()}
@@ -1911,6 +1986,29 @@ func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) {
raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: &rev, Entries: entries}, "node": "n0"})
_ = m.Respond(raw)
}
// The gate's verb `source` given no branch (design 47 G §507, §690): the branches it keeps a source revision of, which the assigner
// follows before the first registrations, and the trunk's `main` first.
source := func(m *nats.Msg) {
b.cat.mu.Lock()
defer b.cat.mu.Unlock()
names := map[string]bool{"main": true}
for branch := range b.cat.rev {
names[branch] = true
}
var escaped []string
for _, n := range slices.Sorted(maps.Keys(names)) {
escaped = append(escaped, keys.EscapeName(n))
}
raw, _ := json.Marshal(map[string]any{"result": mdseat.SourceAnswer{Branches: escaped}, "node": "n0"})
_ = m.Respond(raw)
}
for _, subject := range []string{"mesh.seat.mesh-delivery.tool.source", "mesh.seat.mesh-delivery.call.source.*", "mesh.seat.mesh-delivery.tool.source.*"} {
sub, err := nc.Subscribe(subject, source)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
for _, subject := range []string{"mesh.seat.mesh-catalog.tool.revision", "mesh.seat.mesh-catalog.call.revision.*", "mesh.seat.mesh-catalog.tool.revision.*"} {
sub, err := nc.Subscribe(subject, answer)
if err != nil {
@@ -1920,3 +2018,52 @@ func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) {
}
subscribed(t, nc)
}
// A-181: the assigner reads the builder's stream of build ends whole at its start (the last of each fingerprint, a failed one's error
// among them) though its durable is made after them, then follows it: what was built survives its restart, and it reads no builder bucket.
func TestTheAssignerReadsTheBuildersEndsWholeAtItsStart(t *testing.T) {
b := startBed(t, "")
thisNode = "n1"
b.putBuild(t, mustBuildKey(t, "web", "src2:"+strings.Repeat("a", 64)), mustEncodeBuild(t, builds.BuilderBuild{State: builds.BuildFailed, Error: "first try"}))
b.putBuild(t, mustBuildKey(t, "web", "src2:"+strings.Repeat("a", 64)), mustEncodeBuild(t, builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64)}))
b.putBuild(t, mustBuildKey(t, "api", "src2:"+strings.Repeat("b", 64)), mustEncodeBuild(t, builds.BuilderBuild{State: builds.BuildFailed, Error: "transport fault: x"}))
var ends builtEnds
woken := make(chan struct{}, 8)
hearBuilds(b.ctx, b.js, &ends, func() { woken <- struct{}{} }, assign.Findings{}, t.Logf)
if e, ok := ends.endOf("web", "src2:"+strings.Repeat("a", 64)); !ok || e.Outcome != builds.BuildDone || e.Manifest != strings.Repeat("d", 64) {
t.Fatalf("the read of the stream left web's end as %+v, %v: want the last, done", e, ok)
}
if e, ok := ends.endOf("api", "src2:"+strings.Repeat("b", 64)); !ok || e.Outcome != builds.BuildFailed || e.Error != "transport fault: x" {
t.Fatalf("the read of the stream left api's end as %+v, %v", e, ok)
}
b.putBuild(t, mustBuildKey(t, "api", "src2:"+strings.Repeat("c", 64)), mustEncodeBuild(t, builds.BuilderBuild{State: builds.BuildDone, Manifest: strings.Repeat("d", 64)}))
deadline := time.After(10 * time.Second)
for {
if _, ok := ends.endOf("api", "src2:"+strings.Repeat("c", 64)); ok {
return
}
select {
case <-woken:
case <-deadline:
t.Fatal("an end said after the read was not followed")
}
}
}
func mustBuildKey(t *testing.T, module, fp string) string {
t.Helper()
k, err := builds.BuilderBuildKey(module, fp)
if err != nil {
t.Fatal(err)
}
return k
}
func mustEncodeBuild(t *testing.T, b builds.BuilderBuild) []byte {
t.Helper()
raw, err := builds.EncodeBuilderBuild(b)
if err != nil {
t.Fatal(err)
}
return raw
}
@@ -29,6 +29,7 @@ import (
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatlist"
@@ -62,13 +63,8 @@ func main() {
// holds the two lists equal), so the controller's broker grants exactly these.
var readBuckets = []string{
buckets.Grants,
buckets.Answers,
buckets.Builds,
buckets.Presence,
buckets.Have,
buckets.Provisions,
buckets.Source,
buckets.Merges,
}
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
@@ -80,7 +76,7 @@ var readFamilies = []string{buckets.ComposedRead}
var ownBuckets = []string{buckets.Asks, buckets.Differences,
buckets.Puts, buckets.Wants, plc.AssignmentsBucket, plc.ProposalsBucket,
plc.CandidatesBucket, plc.PulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, plc.SeatHoldersBucket,
engineReadsBucket, retiredBucket, copiesBucket, writtenBucket, buckets.Waits, buckets.Inputs}
engineReadsBucket, retiredBucket, copiesBucket, writtenBucket, buckets.Waits, buckets.PutWaits, buckets.DeliveryWaits, buckets.DeliveredAgain, buckets.Inputs, pinsBucket}
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again.
@@ -121,7 +117,11 @@ var keptStates = []announce.State{
{Name: copiesState, Readable: true},
{Name: writtenState, Readable: true},
{Name: buckets.WaitsState, Readable: true},
{Name: buckets.InputsState, Readable: true}, // read by the tool process alone: the verb input and assigner_inputs (design 47 G §354)
{Name: buckets.PutWaitsState, Readable: true}, // the put waits said (putwaits.go, design 47 G A-211)
{Name: buckets.DeliveryWaitsState, Readable: true}, // the delivery waits said (delivered.go, design 47 G A-246)
{Name: buckets.DeliveredAgainState, Readable: true}, // the asks of assigner_delivered_again (delivered.go, design 47 G A-250)
{Name: buckets.InputsState, Readable: true}, // read by the tool process alone: the verb input and assigner_inputs (design 47 G §354)
{Name: pinsState, Readable: true}, // the records pinning each assignment graph revision (design 47 G §579)
}
// kvSet is every bucket the assigner opened, by name.
@@ -168,12 +168,6 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err)
continue
}
if (name == buckets.Source || name == buckets.Merges) && err != nil {
// The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of
// a record module is asked of a person, as before.
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
continue
}
if name == engineReadsBucket && err != nil {
// New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and
// assigner_engine_reads lists nothing.
@@ -185,12 +179,32 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): it is read by the tool process alone", name, err)
continue
}
if name == pinsBucket && err != nil {
// New with the pins (design 47 G §579): without it no pin is kept and no revision is removed.
logf("the bucket %s cannot be opened (%v): no pin is kept and no revision removed until it starts again", name, err)
continue
}
if name == buckets.Waits && err != nil {
// New with the wait on an outside cause (design 47 G §706): without it no wait is made, and a failing check is judged
// as before.
logf("the bucket %s cannot be opened (%v): no outside wait is kept until the assigner starts again", name, err)
continue
}
if name == buckets.DeliveredAgain && err != nil {
// New with A-250: without it assigner_delivered_again is not taken until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no version is said delivered again until the assigner starts again", name, err)
continue
}
if name == buckets.DeliveryWaits && err != nil {
// New with A-246: without it no dependent waits for its dependency's delivery until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no delivery wait is kept until the assigner starts again", name, err)
continue
}
if name == buckets.PutWaits && err != nil {
// New with A-211: without it no put's wait is said until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no put's wait is said until the assigner starts again", name, err)
continue
}
if name == retiredBucket && err != nil {
// The retired state is read only for the copy into `assignments` (migrate.go): a mesh made after the switch has
// none, and nothing is copied.
@@ -215,7 +229,7 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
func startsWithout(name string) bool {
switch name {
case plc.SeatHoldersBucket, buckets.Provisions, assign.PoliciesBucket, assign.FindingsBucket,
buckets.Puts, buckets.Source, buckets.Merges, engineReadsBucket, retiredBucket, writtenBucket, buckets.Waits, buckets.Inputs:
buckets.Puts, engineReadsBucket, retiredBucket, writtenBucket, buckets.Waits, buckets.PutWaits, buckets.DeliveryWaits, buckets.DeliveredAgain, buckets.Inputs, pinsBucket:
return true
}
return slices.Contains(seatBuckets, name) || name == buckets.Grants
@@ -321,18 +335,18 @@ type emitting struct{ *process.Conn }
func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(eventType, body) }
// optionalEnds is what stops when the watch of a bucket that never gates the first pass ends.
var optionalEnds = map[string]string{buckets.Puts: "no put is taken further", buckets.Answers: "no ask is answered"}
// watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and
// watched only for keys, so a manifest that arrives wakes a composition that waits for it.
var watched = []string{
buckets.Grants,
buckets.Answers,
buckets.Builds,
plc.AssignmentsBucket,
plc.ProposalsBucket,
plc.CandidatesBucket,
plc.PulsesBucket,
copiesBucket,
buckets.Presence,
buckets.Have,
buckets.Asks,
buckets.Differences,
@@ -340,11 +354,12 @@ var watched = []string{
assign.PoliciesBucket,
buckets.Provisions,
plc.SeatHoldersBucket,
buckets.Source,
buckets.Merges,
engineReadsBucket,
writtenBucket,
buckets.Waits,
buckets.PutWaits,
buckets.DeliveryWaits,
buckets.DeliveredAgain, // its watch wakes a pass at once on an ask of assigner_delivered_again (A-250)
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
@@ -377,7 +392,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
return fmt.Errorf("the bucket %s cannot be watched: %w", name, err)
}
// The puts' watch never stops the wants (ADR 0546): when it ends, the puts are no longer heard, said in the journal.
optional := name == buckets.Puts
optional := name == buckets.Puts || name == buckets.Answers
go func() {
defer kw.Stop()
for {
@@ -388,7 +403,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
case e, ok := <-kw.Updates():
if !ok {
if optional {
logger.Printf("the watch of %s ended: no put is taken further until the assigner starts again", name)
logger.Printf("the watch of %s ended: %s until the assigner starts again", name, optionalEnds[name])
return
}
errs <- fmt.Errorf("the watch of %s ended", name)
@@ -455,8 +470,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
started++
var opts []jetstream.WatchOpt
if name == buckets.Have || name == buckets.Source {
// what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1)
if name == buckets.Have {
// what each node ran healthy before, for a set-back
opts = append(opts, jetstream.IncludeHistory())
}
if err := watch(name, opts...); err != nil {
@@ -477,6 +492,14 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
if iv != nil {
iv.hub = hub
started += iv.start(ctx, w, wake, settled)
// The teller's answers' picture is watched as a bucket is, but never gates the first pass: a teller that does not
// answer its first view only leaves the asks unanswered, said by the reader's finding (A-175).
if iv.answers != nil {
kvs[buckets.Answers] = iv.answers
if err := watch(buckets.Answers); err != nil {
logger.Printf("%v: no ask is answered until the assigner starts again", err)
}
}
}
// The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no
@@ -547,7 +570,26 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
if js != nil {
p.publishWants = wantsPublisher(ctx, js, thisNode)
if iv != nil && iv.h != nil {
p.graph = &graphKeeper{objects: newBusObjects(js, process.Account{Node: thisNode, Module: assignment.Module}, assigngraph.RevisionsObjects), halves: iv.h.views}
objects := newBusObjects(js, process.Account{Node: thisNode, Module: assignment.Module}, assigngraph.RevisionsObjects)
p.graph = &graphKeeper{objects: objects, halves: iv.h.views}
keep := func(ctx context.Context, ev meshcatalog.CatalogRevisionEvent) error {
return p.keepCandidate(ctx, iv.call, ev)
}
iv.candidates.Store(&keep)
// The pins of the revisions (pins.go, design 47 G §579): the gate's pinned, freed, heads-replaced and pins-stated
// on the hub, registered before it starts.
if kv := kvs[pinsBucket]; kv != nil {
k := &pinKeeper{ctx: ctx, st: kvEnds{ctx, kv}, objects: objects, inUse: inUseOf(w), findings: findings, now: time.Now, logf: logger.Printf}
if err := k.made(); err != nil {
logger.Printf("the state %s is not marked made, so no revision is removed until it is: %v", pinsState, err)
}
if err := hearPins(hub, k); err != nil {
logger.Printf("a pin is not heard, so no revision is removed until the assigner starts again: %v", err)
} else {
p.graph.pins = k
k.watchUnpins(kv)
}
}
}
}
// The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer.
@@ -564,13 +606,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
// The runner's pipeline-ended and group-ended (design 47 G §29): kept in the state written, which the wants follow
// once the first registrations are written (§95, §353).
p.ends = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf)
if iv != nil && iv.h != nil {
p.ends.onBranches = func(branches []string) {
for _, b := range branches {
iv.h.follow(b)
}
}
}
p.ends.trunk = w.trunkName
p.ends.runEnded = func(group string) error { return p.dropCandidate(ctx, group) }
err := hearEnds(hub, p.ends)
if err == nil && bus != nil {
err = followRegistrations(ctx, hub, bus.Call, p.ends)
@@ -579,9 +616,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
logger.Printf("a pipeline's end or a registration is not heard, so the wants keep the trunk-only form until the assigner starts again: %v", err)
}
}
if err := hearBuildsAndReleases(hub, &w.ends, &w.released, wake, logger.Printf); err != nil {
logger.Printf("a build's end or a version's release is not heard, so no want names a build's parts, its keeping node or its version until the assigner starts again: %v", err)
}
hearDelivery(ctx, js, w, wake, findings, logger.Printf) // version-released, version-delivered, machine-away and machine-back
hearBuilds(ctx, js, &w.ends, wake, findings, logger.Printf)
hearReadsChanged(ctx, js, hub, reads, findings, gate.EventsLive, logger.Printf)
hearRunning(ctx, js, w, wake, findings, logger.Printf)
}
@@ -87,3 +87,15 @@ func (b *busObjects) Names(ctx context.Context) ([]string, error) {
}
return names, nil
}
// Delete removes the object of that name; one not held is no error (design 47 G §579: a revision nothing pins is removed).
func (b *busObjects) Delete(ctx context.Context, name string) error {
o, err := b.open(ctx)
if err != nil {
return err
}
if err := o.Delete(ctx, name); err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
return err
}
return nil
}
@@ -269,7 +269,7 @@ func (p *passer) outsideReview(ctx context.Context, module, fp string, nodes []s
dirty := o.dirty[node]
cleared := prev != nil && prev.Cause != "" && o.cleared[node+"|"+prev.Cause]
o.mu.Unlock()
failing, healthy := ok && h.Fp == fp && assign.Failed(h), ok && assign.Healthy(h, fp)
failing, healthy := ok && h.Fp == fp && assign.Failed(h), n.RunsHealthy(node, module, fp)
if !failing && !healthy && prev == nil {
continue
}
@@ -66,6 +66,8 @@ func (r *outsideRig) review(keeperSelf bool, moved func(string, string) bool) as
r.t.Helper()
n := assign.Nodes{Online: map[string]bool{"ace": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
"ace": {"nats": {Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: r.health}}}}
healthy := r.health == nodeengineclient.HealthOK
n.Running = map[string]map[string]nodeengine.Running{"ace": {"nats": {Node: "ace", Module: "nats", Fingerprint: fpTest, Healthy: &healthy}}}
if moved == nil {
moved = func(string, string) bool { return false }
}
@@ -257,7 +259,7 @@ func TestTheToolsReadAndReleaseAnOutsideWait(t *testing.T) {
r.cond("machine.hub.unreachable", "ace")
r.review(false, nil)
var acts []string
s := newPutTools(memStore{waitsState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
s := newPutTools(memStore{waitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
@@ -294,4 +296,38 @@ func TestTheToolsReadAndReleaseAnOutsideWait(t *testing.T) {
if r.bodies[len(r.bodies)-1].How != "go-on" || n.Haves["ace"]["nats"].Health != nodeengineclient.HealthOK {
t.Errorf("last %+v health %s", r.bodies[len(r.bodies)-1], n.Haves["ace"]["nats"].Health)
}
// A-246: a dependent's delivery wait is listed beside them, and released by its module and step delivery, its hand act first.
dw, _ := json.Marshal(deliveryWaiting{Wait: meshwants.Wait{Step: meshwants.WaitStepDelivery, Module: "app", Part: "db", On: "db@1.1.0", Machines: []string{"g14"}}})
s.st.put(buckets.DeliveryWaitsState, "app", dw)
got, err = s.doWaits(map[string]any{"node": "g14"})
if d := got.(map[string]any)["delivery"].([]deliveryWaiting); err != nil || len(d) != 1 || d[0].Module != "app" {
t.Errorf("the delivery waits listed = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"module": "app", "step": "build", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("a release of another step is taken")
}
if _, err := s.doRelease(map[string]any{"module": "web", "step": "delivery", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("a release of a module waiting for no delivery is taken")
}
before := len(acts)
if _, err := s.doRelease(map[string]any{"module": "app", "step": "delivery", "why": "db is fine as it runs"}, "shanks.node-tools"); err != nil {
t.Fatal(err)
}
raw, _, _, _ = s.st.get(buckets.DeliveryWaitsState, "app")
var released deliveryWaiting
if json.Unmarshal(raw, &released) != nil || !released.Released || !strings.Contains(released.By, "db is fine") || len(acts) != before+1 {
t.Errorf("the delivery wait after its release = %+v, hand acts %v", released, acts[before:])
}
// A-250: assigner_delivered_again records its hand act, writes the ask and answers asked; assigner_waits lists it.
if _, err := s.doDeliveredAgain(map[string]any{"module": "db", "version": "1.1.0"}, "op"); err == nil {
t.Error("an ask with no why is taken")
}
ans, err := s.doDeliveredAgain(map[string]any{"module": "db", "version": "1.1.0", "why": "the catalog missed it"}, "op")
if err != nil || ans.(map[string]any)["asked"] != "db" || len(acts) != before+2 {
t.Fatalf("assigner_delivered_again = %+v %v", ans, err)
}
got, _ = s.doWaits(map[string]any{})
if a := got.(map[string]any)["delivered-again"].([]againAsk); len(a) != 1 || a[0].Version != "1.1.0" || a[0].By != "op" {
t.Errorf("the asks listed = %+v", a)
}
}
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"slices"
@@ -9,7 +10,9 @@ import (
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/outside"
@@ -58,7 +61,111 @@ func (s *putTools) doWaits(args map[string]any) (any, error) {
}
out = append(out, said)
}
return map[string]any{"waits": out}, nil
// And each dependent's wait for its dependency's delivery (design 47 G §376, A-246), from the state delivery-waits.
delivery := []deliveryWaiting{}
dkeys, err := s.st.keys(buckets.DeliveryWaitsState)
if err != nil {
return nil, fmt.Errorf("the delivery waits cannot be listed: %w", err)
}
sort.Strings(dkeys)
for _, key := range dkeys {
raw, _, found, err := s.st.get(buckets.DeliveryWaitsState, key)
if err != nil || !found {
continue
}
var w deliveryWaiting
if json.Unmarshal(raw, &w) != nil || (arg(args, "module") != "" && w.Module != arg(args, "module")) ||
(arg(args, "node") != "" && !slices.Contains(w.Machines, arg(args, "node"))) {
continue
}
delivery = append(delivery, w)
}
// And each ask of assigner_delivered_again not yet holding, with why (A-250).
again := []againAsk{}
akeys, err := s.st.keys(buckets.DeliveredAgainState)
if err != nil {
return nil, fmt.Errorf("the asks to say a version delivered again cannot be listed: %w", err)
}
sort.Strings(akeys)
for _, key := range akeys {
raw, _, found, err := s.st.get(buckets.DeliveredAgainState, key)
var a againAsk
if err != nil || !found || json.Unmarshal(raw, &a) != nil || (arg(args, "module") != "" && a.Module != arg(args, "module")) {
continue
}
again = append(again, a)
}
return map[string]any{"waits": out, "delivery": delivery, "delivered-again": again}, nil
}
// toolDeliveredAgain is the tool that asks version-delivered said again for one version (design 47 G §376, A-250).
const toolDeliveredAgain = "assigner_delivered_again"
// doDeliveredAgain is assigner_delivered_again: the hand act is recorded first, then the ask is written into the state
// delivered-again, whose watch the assigner's process hears at once; it answers that it is asked.
func (s *putTools) doDeliveredAgain(args map[string]any, by string) (any, error) {
module, version, why := arg(args, "module"), arg(args, "version"), arg(args, "why")
if module == "" || version == "" || why == "" {
return nil, errors.New("assigner_delivered_again names the module, its version and why: it is kept in the hand-act log")
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_delivered_again %s %s", module, version), Why: why,
Cause: "version-delivered", Verb: "mesh-wants.assigner_delivered_again"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was asked: %w", err)
}
data, err := json.Marshal(againAsk{Module: module, Version: version, By: by, Why: why})
if err != nil {
return nil, err
}
if err := s.st.put(buckets.DeliveredAgainState, keys.EscapeName(module), data); err != nil {
return nil, fmt.Errorf("the ask of %s %s is not written: %w", module, version, err)
}
return map[string]any{"asked": module, "version": version, "hand-act": taken.Log,
"follow": "the assigner says version-delivered again once every present machine placing it runs it healthy; assigner_waits says why it does not yet"}, nil
}
// deliveredAgainTool is assigner_delivered_again as the runtime serves it.
func (s *putTools) deliveredAgainTool() stdio.Tool {
return stdio.Tool{Name: toolDeliveredAgain, Description: "Say version-delivered again for one version of a module, for the operator and the healers " +
"(design 47 G §376): written in the hand-act log first, then asked of the assigner, which says it once every present machine placing " +
"the module runs that version healthy; until then assigner_waits lists the ask with why it does not hold. Answers that it is asked.",
Input: schema(map[string]string{"module": "the module", "version": "its MAJOR.MINOR.PATCH", "why": "why, kept in the hand-act log"},
[]string{"module", "version", "why"}),
Run: func(a map[string]any) (any, error) { return s.doDeliveredAgain(a, stdio.CallerOf(a)) }}
}
// doReleaseDelivery is assigner_release with step delivery: the hand act is recorded first, then the release is written on the
// module's delivery wait, which the assigner's process ends released at its next pass, writing the module's want.
func (s *putTools) doReleaseDelivery(args map[string]any, by string) (any, error) {
module, why := arg(args, "module"), arg(args, "why")
if why == "" {
return nil, errors.New("release says why: it is kept in the hand-act log")
}
key := keys.EscapeName(module)
raw, _, found, err := s.st.get(buckets.DeliveryWaitsState, key)
if err != nil {
return nil, fmt.Errorf("the delivery wait of %s is not read: %w", module, err)
}
var w deliveryWaiting
if !found || json.Unmarshal(raw, &w) != nil || w.Released {
return nil, fmt.Errorf("%s waits for no delivery: assigner_waits lists the delivery waits", module)
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_release %s step delivery (waits on %s)", module, w.On), Why: why,
Cause: "delivery-wait", Verb: "mesh-wants.assigner_release"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err)
}
w.Released, w.By = true, by+": "+why
data, err := json.Marshal(w)
if err != nil {
return nil, err
}
if err := s.st.put(buckets.DeliveryWaitsState, key, data); err != nil {
return nil, fmt.Errorf("the release of %s's delivery wait is not written: %w", module, err)
}
return map[string]any{"released": module, "step": meshwants.WaitStepDelivery, "by": by, "hand-act": taken.Log}, nil
}
// doReleaseWait is assigner_release with how: the hand act is recorded first, then the release is written on the wait of node and
@@ -205,6 +205,8 @@ func (p *passer) clock() time.Time {
func (p *passer) pass(ctx context.Context) {
p.assignmentsSeat(ctx)
p.keepGraph(ctx)
p.judgeDelivered()
p.deliveredAgain(ctx)
p.wants(ctx)
p.puts(ctx)
p.nodeWants(ctx)
@@ -247,7 +249,7 @@ func (p *passer) wants(ctx context.Context) {
views[branch] = v
return v
}
nodes := viewOf(trunkBranch).nodes
nodes := viewOf(p.w.trunkName()).nodes
p.engineReads(nodes)
nodesOf := map[string][]string{}
for node, modules := range assigned {
@@ -303,6 +305,10 @@ func (p *passer) wants(ctx context.Context) {
if fp != "" && !p.ends.mayWant(name, fp, branches, wantedOf[name]) {
return
}
// A dependent's new version waits for its dependency's newest released version to be delivered (§376, A-189).
if p.holdForDelivery(ctx, name, fp, g.branch, g.nodes) {
return
}
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (assign.ComposedDigest, bool) {
@@ -319,8 +325,7 @@ func (p *passer) wants(ctx context.Context) {
Allowed: func(node, digest string) bool {
key, err := asks.RecordAskKey(node, name, digest)
return err == nil && p.answeredWith(key, assign.OptionYes)
},
Release: func(fp string) (string, bool) { return p.w.release(name, fp) }}
}}
if fp != "" {
held, _ := mwc.HeldKey(name, fp)
grant, _ := grantsstate.PartGrantKey(name, fp)
@@ -329,6 +334,7 @@ func (p *passer) wants(ctx context.Context) {
m.Released = p.answeredWith(hold, "release")
m.Kept = p.answeredWith(hold, "keep")
m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant)
m.BuildFailed, m.BuildError = p.w.buildStanding(name, fp)
if raw := p.w.value(grants, grant); raw != nil {
if g, err := grantsstate.DecodeGrant(raw); err == nil {
m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) }
@@ -404,7 +410,7 @@ func (p *passer) wants(ctx context.Context) {
func (p *passer) movedTo(wanted map[string]map[string]mwc.Want, n assign.Nodes) func(owner, node string) bool {
return func(owner, node string) bool {
w, ok := wanted[owner][node]
return ok && !w.Removed && !assign.Healthy(n.Haves[node][owner], w.Fp)
return ok && !w.Removed && !n.RunsHealthy(node, owner, w.Fp)
}
}
@@ -1060,9 +1066,8 @@ func (p *passer) backupAsk(ctx context.Context, key, node, module string, want m
// yeses writes a placed node's wants naming the build alone, and sees to the moves a node that composes its own judged a
// person must allow (ADR 0547 §3): for a have of phase
// waiting-yes it raises the record ask once, naming the composition waiting and the node's judgement, and on a yes writes
// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. A
// move of the build alone (the have says so) to a build an approved release carries is given its yes by that Approve, the
// want naming the release, with no ask (ADR 0601 §1).
// the want again naming it; a yes once written is kept while the want stays at its build, so the node is not asked again. No
// release's Approve is a yes: the Approve per merge goes and the assigner reads no `merges` (design 47 G §431).
func (p *passer) yeses(ctx context.Context, module string, m assign.Module, before map[string]mwc.Want, out *assign.Outcome) {
for _, node := range m.Nodes {
if m.Self == nil || !m.Self(node) {
@@ -1091,14 +1096,6 @@ func (p *passer) yeses(ctx context.Context, module string, m assign.Module, befo
switch {
case m.Allowed != nil && m.Allowed(node, h.Waits):
yes, release = h.Waits, ""
case h.BuildAlone && h.Fp != w.Fp && m.Release != nil:
// A move of the build alone, to the build an approved release carries: that Approve is the yes (ADR 0601 §1),
// named in the want. A move that changes a setting or a secret too, or one the node could not tell, is asked.
if r, ok := m.Release(w.Fp); ok {
yes, release = h.Waits, r
break
}
p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail})
default:
p.recordAsk(ctx, module, w.Fp, assign.RecordNeed{Node: node, Digest: h.Waits, Judgement: h.Judgement, Words: h.Detail})
}
@@ -1408,14 +1405,14 @@ type group struct {
func (p *passer) groups(module string, nodes []string, placed map[string]string) []group {
by := map[string][]string{}
for _, node := range nodes {
b := trunkBranch
b := p.w.trunkName()
if p.ends.following() {
b = p.ends.branchOf(module, placed[node])
}
by[b] = append(by[b], node)
}
if len(by) == 0 {
b := trunkBranch
b := p.w.trunkName()
if p.ends.following() {
b = p.ends.branchOf(module, "")
}
@@ -16,14 +16,11 @@ import (
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mdc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
@@ -70,7 +67,9 @@ func (m *memKV) Delete(_ context.Context, key string, _ ...jetstream.KVDeleteOpt
// newPasser is a pass on a fresh world, as after a start, writing to the buckets given, at the clock given.
func newPasser(kvs kvSet, at time.Time) *passer {
return &passer{kvs: kvs, w: newWorld(), logf: func(string, ...any) {}, said: map[string]bool{}, answeredAt: map[string]uint64{},
w := newWorld()
w.setTrunk(trunkBranch)
return &passer{kvs: kvs, w: w, logf: func(string, ...any) {}, said: map[string]bool{}, answeredAt: map[string]uint64{},
now: func() time.Time { return at }}
}
@@ -301,8 +300,8 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
return json.Marshal(nodeengine.ManifestReadsAnswer{V: 1, Node: node, ManifestReads: []string{"form:new"}, StatedAt: time.Now().Format(time.RFC3339Nano)})
}
p := &passer{w: w, reads: &enginereads.Keeper{Call: call, Fallback: w.recordReads, Sync: true}}
on, _ := presenceValue(false)
w.put(buckets.Presence, "ace", on)
on := false
w.heardPresence("ace", !on, time.Now())
n = w.nodes(nil, nil)
p.engineReads(n)
if !slices.Equal(n.Reads["ace"], []string{"form:new"}) || len(n.Reads["g14"]) != 0 || asked["ace"] != 1 {
@@ -312,8 +311,8 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
t.Errorf("the statements are %+v", s)
}
p.engineReads(w.nodes(nil, nil))
on, _ = presenceValue(true)
w.put(buckets.Presence, "ace", on)
on = true
w.heardPresence("ace", !on, time.Now())
p.engineReads(w.nodes(nil, nil))
if asked["ace"] != 2 {
t.Errorf("ace, back online, was asked %d times, want 2", asked["ace"])
@@ -337,16 +336,17 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
Keys: func() ([]string, error) { return slices.Collect(maps.Keys(held)), nil }},
reads: &enginereads.Keeper{Call: silent, Sync: true, Now: func() time.Time { return clock }}}
key := mcc.Key(mcc.ScopeCall, enginereads.CallID("ace"), mcc.KindCallUnanswered)
w.heardPresence("g14", true, time.Now()) // g14 away throughout: a machine is present unless the watchman said it away (§389)
before := asked["ace"]
q.engineReads(w.nodes(nil, nil))
clock = clock.Add(6 * time.Minute)
off, _ := presenceValue(false)
w.put(buckets.Presence, "ace", off)
off := false
w.heardPresence("ace", !off, time.Now())
q.engineReads(w.nodes(nil, nil))
if len(held) != 0 {
t.Errorf("a finding was written while ace is away: %v", slices.Collect(maps.Keys(held)))
}
w.put(buckets.Presence, "ace", on)
w.heardPresence("ace", !on, time.Now())
q.engineReads(w.nodes(nil, nil))
if _, ok := held[key]; !ok || asked["ace"]-before < 3 {
t.Errorf("ace unanswered six minutes, heard: findings %v, asked %d times", slices.Collect(maps.Keys(held)), asked["ace"]-before)
@@ -560,18 +560,14 @@ func putOfTheBus(t *testing.T) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)})
putModuleRaw(p.w, trunkBranch, "nats", mod)
p.w.manifests[mdigest] = parsed{module: "nats", manifest: catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}}}
bkey, _ := builds.BuilderBuildKey("nats", fp)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
world(buckets.Builds, bkey, built)
endDone(t, p.w, "nats", fp, mdigest)
hkey, _ := nodeengineclient.HaveKey("novox", "nats")
before, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
world(buckets.Have, hkey, before)
buildKV := mem()
buildKV.values[bkey] = built
// Issue 1966: newest is asked of the indexer's first view of modules, never read from its bus state.
indexer := &fakeIndexer{views: map[string]map[string][]byte{"mesh-delivery-indexer.modules": {"mesh-catalog": mod, "nats": mod}}}
s := newPutTools(memStore{putsState: putsKV, builds.BuilderModule + ".builds": buildKV}, func(key string, body any) (json.RawMessage, error) {
s := newPutTools(memStore{putsState: putsKV}, func(key string, body any) (json.RawMessage, error) {
if raw, ok, err := indexer.answer(key, body); ok {
return raw, err
}
@@ -582,10 +578,10 @@ func putOfTheBus(t *testing.T) {
answerWithin = 0
defer func() { answerWithin = was }()
if _, err := s.doPut(map[string]any{"node": "novox", "module": "nats", "version": "fd497ab8ffff", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("a prefix of a fingerprint the builder never built was taken")
t.Error("a prefix of a fingerprint was taken: a put names the whole fingerprint (A-181)")
}
if got, err := s.version("nats", "fd497ab80000"); err != nil || got != fp {
t.Errorf("the prefix of the fingerprint built reads %q, %v", got, err)
if _, err := s.version("nats", "fd497ab80000"); err == nil {
t.Error("the prefix of the fingerprint built was read: a put names the whole fingerprint (A-181)")
}
if got, err := s.version("nats", "newest"); err != nil || got != fp || indexer.asks["mesh-delivery-indexer.modules"] != 2 {
t.Errorf("newest reads %q, %v, asking %d pages of the indexer's first view (want its 2)", got, err, indexer.asks["mesh-delivery-indexer.modules"])
@@ -811,14 +807,14 @@ func testThePassGathersTheModulesOnNeitherList(t *testing.T) {
p := newPasser(kvSet{}, time.Date(2026, 10, 25, 21, 30, 0, 0, time.UTC))
var logged []string
p.logf = func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) }
notDone := assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: mwc.RuleBuildDone, Words: "the build of rofi at 4e7b118e is not done"}}}
notDone := assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: mwc.RuleBuildDone, Words: "the build of rofi at 4e7b118e is being built"}}}
wanted := assign.Outcome{Wants: map[string]mwc.Want{"shanks": {Fp: fpTest, Composed: "sha256:" + strings.Repeat("c", 64)}}}
for i, c := range []struct {
before map[string]mwc.Want
out assign.Outcome
line string // the line said in this pass, "" for none
}{
{nil, notDone, "rofi on shanks gets no want: rule 1: the build of rofi at 4e7b118e is not done"},
{nil, notDone, "rofi on shanks gets no want: rule 1: the build of rofi at 4e7b118e is being built"},
{nil, notDone, ""},
{map[string]mwc.Want{"shanks": {Removed: true}}, notDone, ""},
{nil, assign.Outcome{Rules: map[string]assign.Holding{"shanks": {Rule: mwc.RuleGrant, Words: "no credential"}}}, "rofi on shanks gets no want: rule 6: no credential"},
@@ -903,8 +899,8 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, trunkBranch, "web", mod)
placeOn(t, p.w, "g14", "web")
on, _ := presenceValue(true)
p.w.put(buckets.Presence, "g14", on)
on := true
p.w.heardPresence("g14", !on, time.Now())
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
p.w.put(buckets.Have, "g14.web", have)
key, _ := mwc.WantKey("g14", "web")
@@ -941,8 +937,8 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, trunkBranch, "systemd-resolved", mod)
placeOn(t, p.w, "shanks", "systemd-resolved")
on, _ := presenceValue(true)
p.w.put(buckets.Presence, "shanks", on)
on := true
p.w.heardPresence("shanks", !on, time.Now())
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed})
p.w.put(buckets.Have, "shanks.systemd-resolved", have)
hkey, _ := mwc.HeldKey("systemd-resolved", fpTest)
@@ -1168,70 +1164,29 @@ func TestARecordAskOfAHeldBackNodeSurvivesPassesAndItsYesMovesTheWant(t *testing
aReleasesApproveAnswersTheMoveOfItsBuildAlone(t)
}
// ADR 0601 §1: a placed node's have waiting for a yes to a move of the build alone, to a build an approved release carries, is
// given its yes by that Approve, the want naming the release, and no record ask is raised; a move that changes a setting or a
// secret too, one at the build it runs, and one no approved release carries are asked of a person as before.
// Design 47 G §431: no release's Approve is a yes (the assigner reads no `merges`): a placed node's have waiting for a yes to a move
// of the build alone is asked of a person, the want naming no release and no yes.
func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) {
fpOld := meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64)
waiting, was := "sha256:"+strings.Repeat("e", 64), "sha256:"+strings.Repeat("d", 64)
commit := strings.Repeat("1", 40)
for _, c := range []struct {
name string
alone bool
at string // the fingerprint the have runs
mergedAt string // the commit the release merged web at; "" for no merge of the release
fpSrc int64 // the source revision at which web's fingerprint last changed
yes, asked bool
}{
{"the build alone, its release approved", true, fpOld, commit, 7, true, false},
{"a setting changed too", false, fpOld, commit, 7, false, true},
{"a setting changed at the build it runs", true, fpTest, commit, 7, false, true},
{"no approved release carries it", true, fpOld, "", 7, false, true},
{"the release merged another commit", true, fpOld, strings.Repeat("2", 40), 7, false, true},
// Its fingerprint last moved at another release's revision, which merged nothing of web: that Approve answers nothing.
{"a fingerprint another release moved", true, fpOld, commit, 8, false, true},
// Review rounds 1 and 2: moved by a recompute after the release (a toolchain, a package, a base) with no merge, the
// indexer writes fp-src 0, not known, though Src is still the release's: asked of a person.
{"a fingerprint moved by a recompute after the release", true, fpOld, commit, 0, false, true},
} {
asksKV := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now())
p.logf = t.Logf
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: c.fpSrc, Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, trunkBranch, "web", mod)
src, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/web", Heads: map[string]string{"novox/web": commit}})
p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), src, 7, time.Now())
later, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}})
p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), later, 8, time.Now()) // a later merge leaves revision 7's release
if c.mergedAt != "" {
merge, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/web", Ask: "merge.feat_web.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{
{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40), State: string(mdc.MergeMerged), Commit: c.mergedAt}}})
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Merges, "feat/web", merge)
other, err := mdc.EncodeMerge(mdseat.MergesByGroupValue{Group: "feat/other", Ask: "merge.feat_other.0123456789ab", Pulls: []mdseat.MergesByGroupValuePullsItem{
{Repository: "novox/x", Number: 4, Head: strings.Repeat("4", 40), State: string(mdc.MergeMerged), Commit: commit}}})
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Merges, "feat/other", other)
}
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: c.at, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK,
Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: c.alone})
p.w.put(buckets.Have, "g14.web", have)
m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true },
Release: func(fp string) (string, bool) { return p.w.release("web", fp) }}
out := assign.Outcome{Wants: map[string]mwc.Want{"g14": {Fp: fpTest, Self: true}}}
p.yeses(context.Background(), "web", m, map[string]mwc.Want{"g14": {Fp: fpOld, Self: true}}, &out)
w := out.Wants["g14"]
if got := w.Yes == waiting && w.Release == "feat/web"; got != c.yes || (!c.yes && (w.Yes != "" || w.Release != "")) {
t.Errorf("%s: the want is %+v", c.name, w)
}
akey, _ := asks.RecordAskKey("g14", "web", waiting)
if _, there := asksKV.values[akey]; there != c.asked {
t.Errorf("%s: a record ask raised %v, want %v", c.name, there, c.asked)
}
asksKV := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now())
p.logf = t.Logf
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: 7, Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, trunkBranch, "web", mod)
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: was, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK,
Judgement: assign.JudgedRecreates, Waits: waiting, Detail: "it recreates web.server", BuildAlone: true})
p.w.put(buckets.Have, "g14.web", have)
m := assign.Module{Name: "web", FP: fpTest, Nodes: []string{"g14"}, Self: func(string) bool { return true }}
out := assign.Outcome{Wants: map[string]mwc.Want{"g14": {Fp: fpTest, Self: true}}}
p.yeses(context.Background(), "web", m, map[string]mwc.Want{"g14": {Fp: fpOld, Self: true}}, &out)
if w := out.Wants["g14"]; w.Yes != "" || w.Release != "" {
t.Errorf("the want is %+v: no release's Approve is a yes", w)
}
akey, _ := asks.RecordAskKey("g14", "web", waiting)
if _, there := asksKV.values[akey]; !there {
t.Error("no record ask was raised for the move of the build alone")
}
}
@@ -1265,8 +1220,8 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
placeOn(t, p.w, "g14", "web")
pl, _ := mmac.EncodeAssignments(mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}})
put(plc.AssignmentsBucket, "assignments.g14", pl)
on, _ := presenceValue(true)
put(buckets.Presence, "g14", on)
on := true
p.w.heardPresence("g14", !on, time.Now())
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fpOld, Composed: was, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
put(buckets.Have, "g14.web", have)
wkey, _ := mwc.WantKey("g14", "web")
@@ -1407,9 +1362,7 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
t.Fatal(err)
}
p.w.manifests[digest] = parsed{module: "server", manifest: parsedServer}
build, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: digest})
key, _ := builds.BuilderBuildKey("server", fp("f"))
p.w.put(buckets.Builds, key, build)
endDone(t, p.w, "server", fp("f"), digest)
running := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
at := func(f string) nodeengine.HaveByNodeModuleValue { h := running; h.Fp = f; return h }
served := p.served(g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
@@ -1450,11 +1403,6 @@ func mustAskID(a asks.Ask) string {
return ask.ID
}
// presenceValue is a node's presence as the watchman writes it.
func presenceValue(online bool) ([]byte, error) {
return json.Marshal(wdc.NodePresence{V: 1, Online: online})
}
// Issue 1966 (nats on novox): a yes given for a record ask was lost because the same pass took the ask away as stale before the
// want named the yes, and an ask taken away and written again was never asked again because it carried the id the router kept.
// Given a record ask of a placed node, then (a) a have mid-move, or running with the operator's yes unwritten, leaves the ask,
@@ -0,0 +1,538 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
)
// The pins of the assignment graph revisions (design 47 G §153, §178, §216, §579; A-169): the gate says on the seat
// mesh-delivery which revisions a delivery record starts (`pinned`) and stops (`freed`) pointing at, and once at each of its
// starts `pins-stated`. The assigner hears them on its durable consumer of the events stream and keeps, in its old `state`
// section `pins` (4 MiB), one key `trunk.<n>` per assignment graph revision whose value is the list of the records pinning
// it, added or removed by compare-and-set before the event is acknowledged; a record heard twice pins once. Two more keys:
// `made`, written once when the state is first used, and `pins-stated`, the `at` of the last pins-stated applied. A revision
// is removed, its object and its key and the entry objects no kept revision lists and no want or running build names
// (§179), when its list is empty and it is not current (the newest the assigner wrote), only while the consumer is caught up
// and once a pins-stated said after `made` was applied; judged on a freed, on a revision ceasing to be current, on being
// caught up, and at start once both hold. Never on a timer, and never by reading the gate's state.
const (
pinsState = "pins"
pinsBucket = assignment.Module + "_" + pinsState
pinsTool = "assigner_pins"
unpinTool = "assigner_unpin"
pinsMadeKey = "made"
pinsStatedKey = "pins-stated"
pinsCapBytes = 4 << 20
pinMissingKind = "pin-missing"
)
// PinsValue is one value of the state pins: the records that pin the revision, and whether the revision was no longer held
// when a record pinned it.
type PinsValue struct {
Records []string `json:"records"`
Missing bool `json:"missing,omitempty"`
}
// pinKey is the key of an assignment graph revision, its object's own name (A-169).
func pinKey(revision int64) string { return assigngraph.TrunkObject(revision) }
// pinRevision is the revision a key of the state names, and whether it names one.
func pinRevision(key string) (int64, bool) {
n, ok := strings.CutPrefix(key, "trunk.")
if !ok {
return 0, false
}
r, err := strconv.ParseInt(n, 10, 64)
return r, err == nil && r > 0
}
// pinObjects is the object bucket of the revisions as the pins remove from it.
type pinObjects interface {
assigngraph.Objects
Delete(ctx context.Context, name string) error
}
// pinKeeper keeps the pins and removes what nothing pins.
type pinKeeper struct {
ctx context.Context
st endStore
objects pinObjects
// inUse says a want or a running build names the module's fingerprint (§179).
inUse func(module, fp string) bool
findings assign.Findings
now func() time.Time
logf func(string, ...any)
mu sync.Mutex
// caught is whether the last event applied left none of the consumer's messages pending.
caught bool
}
// edit applies change to the value at key by compare-and-set, again while another write comes between.
func (k *pinKeeper) edit(key string, change func(v PinsValue, found bool) (PinsValue, bool)) error {
for {
raw, rev, err := k.st.get(key)
if err != nil {
return err
}
var v PinsValue
if raw != nil {
if err := json.Unmarshal(raw, &v); err != nil {
return fmt.Errorf("the pins of %s do not read: %w", key, err)
}
}
next, write := change(v, raw != nil)
if !write {
return nil
}
value, _ := json.Marshal(next)
if err := k.st.swap(key, value, rev); errors.Is(err, errSwapped) {
continue
} else {
return err
}
}
}
// held says the revision's object is kept.
func (k *pinKeeper) held(revision int64) (bool, error) {
_, err := k.objects.Get(k.ctx, assigngraph.TrunkObject(revision))
if errors.Is(err, assigngraph.ErrNoObject) {
return false, nil
}
return err == nil, err
}
// pin adds record to each assignment graph revision of p; a revision no longer held is written missing, with the finding
// pin-missing naming the record.
func (k *pinKeeper) pin(p mdseat.Pins) error {
for _, r := range p.Assignment {
held, err := k.held(r)
if err != nil {
return err
}
err = k.edit(pinKey(r), func(v PinsValue, _ bool) (PinsValue, bool) {
if slices.Contains(v.Records, p.Record) && (held || v.Missing) {
return v, false
}
if !slices.Contains(v.Records, p.Record) {
v.Records = append(v.Records, p.Record)
slices.Sort(v.Records)
}
v.Missing = v.Missing || !held
return v, true
})
if err != nil {
return err
}
if !held {
k.missing(r, p.Record)
}
}
return nil
}
// free removes record from each assignment graph revision of p; a freed applied twice changes nothing.
func (k *pinKeeper) free(p mdseat.Pins) error {
for _, r := range p.Assignment {
err := k.edit(pinKey(r), func(v PinsValue, found bool) (PinsValue, bool) {
i := slices.Index(v.Records, p.Record)
if !found || i < 0 {
return v, false
}
v.Records = slices.Delete(v.Records, i, i+1)
return v, true
})
if err != nil {
return err
}
}
return nil
}
// missing writes the finding pin-missing, scope the assigner, id the revision.
func (k *pinKeeper) missing(revision int64, record string) {
if k.findings.Put == nil {
k.logf("the revision %d the record %s pins is not held, and no finding writer is wired to say so", revision, record)
return
}
o := assign.Observed{Scope: assignment.Module, ID: strconv.FormatInt(revision, 10), Kind: pinMissingKind,
Summary: fmt.Sprintf("the delivery record %s pins the assignment graph revision %d, which the assigner no longer holds", record, revision),
Headline: "A delivery record pins a revision the assigner no longer holds",
Explanation: fmt.Sprintf("The record %s pinned revision %d after it was removed; %s clears it.", record, revision, unpinTool),
Source: assignment.Module}
if err := k.findings.Put(o.Finding(k.now())); err != nil {
k.logf("the finding %s of revision %d is not written: %v", pinMissingKind, revision, err)
}
}
// made writes the key made once, create-only; one there already stays.
func (k *pinKeeper) made() error {
raw, _, err := k.st.get(pinsMadeKey)
if err != nil || raw != nil {
return err
}
value, _ := json.Marshal(k.now().UTC())
if err := k.st.swap(pinsMadeKey, value, 0); err != nil && !errors.Is(err, errSwapped) {
return err
}
return nil
}
// timeAt is the time a key holds; false when it holds none.
func (k *pinKeeper) timeAt(key string) (time.Time, bool, error) {
raw, _, err := k.st.get(key)
if err != nil || raw == nil {
return time.Time{}, false, err
}
var t time.Time
if err := json.Unmarshal(raw, &t); err != nil {
return t, false, fmt.Errorf("the key %s does not read: %w", key, err)
}
return t, true, nil
}
// stated keeps the at of a pins-stated applied.
func (k *pinKeeper) stated(at time.Time) error {
for {
raw, rev, err := k.st.get(pinsStatedKey)
if err != nil {
return err
}
var was time.Time
if raw != nil && json.Unmarshal(raw, &was) == nil && !at.After(was) {
return nil
}
value, _ := json.Marshal(at.UTC())
if err := k.st.swap(pinsStatedKey, value, rev); errors.Is(err, errSwapped) {
continue
} else {
return err
}
}
}
// statedSinceMade says a pins-stated said after the state was made was applied.
func (k *pinKeeper) statedSinceMade() (bool, error) {
made, ok, err := k.timeAt(pinsMadeKey)
if err != nil || !ok {
return false, err
}
at, ok, err := k.timeAt(pinsStatedKey)
if err != nil || !ok {
return false, err
}
return at.After(made), nil
}
// hear applies one of the seat mesh-delivery's events and acknowledges it only once applied.
func (k *pinKeeper) hear(m jetstream.Msg) {
err := k.apply(m.Subject(), m.Data())
if err != nil {
k.logf("%s: %v; it is heard again", m.Subject(), err)
_ = m.Nak()
return
}
_ = m.Ack()
caught := false
if meta, merr := m.Metadata(); merr == nil {
caught = meta.NumPending == 0
}
k.mu.Lock()
was := k.caught
k.caught = caught
k.mu.Unlock()
if caught && (!was || m.Subject() != mdseat.SubjectPinned) {
k.judge() // on a freed, on a candidate's heads replaced, on pins-stated, and on being caught up
}
}
// apply takes one event's body.
func (k *pinKeeper) apply(subject string, data []byte) error {
switch subject {
case mdseat.SubjectPinned, mdseat.SubjectFreed:
var p mdseat.Pins
if err := json.Unmarshal(data, &p); err != nil || p.Record == "" {
k.logf("%s holds no pins naming a record, and is not taken: %v", subject, err)
return nil
}
if subject == mdseat.SubjectPinned {
return k.pin(p)
}
return k.free(p)
case mdseat.SubjectPinsStated:
var s mdseat.PinsStatedEvent
if err := json.Unmarshal(data, &s); err != nil || s.At.IsZero() {
k.logf("%s holds no at, and is not taken: %v", subject, err)
return nil
}
return k.stated(s.At)
}
return nil // heads-replaced: a candidate's revision stops being current, judged after
}
// judge removes every revision neither pinned nor current, when the rule allows a removal at all.
func (k *pinKeeper) judge() {
k.mu.Lock()
caught := k.caught
k.mu.Unlock()
if !caught {
return
}
ok, err := k.statedSinceMade()
if err != nil {
k.logf("the pins are not judged: %v", err)
return
}
if !ok {
return // no pins-stated applied since the state was made: a pin said before may not be heard yet
}
if err := k.remove(); err != nil {
k.logf("the revisions nothing pins are not removed: %v", err)
}
}
// remove removes each kept revision that is not current and that no record pins, and then the entries left unlisted.
func (k *pinKeeper) remove() error {
names, err := k.objects.Names(k.ctx)
if err != nil {
return err
}
var kept []int64
for _, n := range names {
if r, ok := pinRevision(n); ok {
kept = append(kept, r)
}
}
if len(kept) == 0 {
return nil
}
current := slices.Max(kept)
listed := map[string]bool{}
removed := 0
for _, r := range kept {
raw, _, err := k.st.get(pinKey(r))
if err != nil {
return err
}
var v PinsValue
if raw != nil {
if err := json.Unmarshal(raw, &v); err != nil {
return fmt.Errorf("the pins of %d do not read: %w", r, err)
}
}
if r == current || len(v.Records) > 0 {
rev, err := assigngraph.ReadRevision(k.ctx, k.objects, r)
if err != nil {
return err
}
for _, e := range rev.Entries {
listed[e.Digest] = true
}
continue
}
if err := k.objects.Delete(k.ctx, assigngraph.TrunkObject(r)); err != nil {
return fmt.Errorf("revision %d: %w", r, err)
}
if raw != nil {
if err := k.st.drop(pinKey(r)); err != nil {
return fmt.Errorf("the pins of %d: %w", r, err)
}
}
removed++
}
if removed == 0 {
return nil
}
// A run's candidate kept now lists its entries too, until its group-ended drops it (A-201, A-235).
for _, n := range names {
group, heads, ok := meshwants.ParseAssignmentRevisionsByCandidateGroupHeadsKey(n)
if !ok {
continue
}
rev, err := assigngraph.ReadCandidate(k.ctx, k.objects, group, heads)
if err != nil {
return err
}
for _, e := range rev.Entries {
listed[e.Digest] = true
}
}
for _, n := range names {
digest, ok := strings.CutPrefix(n, "entry.")
if !ok || listed[digest] {
continue
}
e, err := assigngraph.ReadEntry(k.ctx, k.objects, digest)
if err != nil {
return err
}
if k.inUse != nil && k.inUse(e.Module, e.Fp) {
continue
}
if err := k.objects.Delete(k.ctx, n); err != nil {
return fmt.Errorf("the entry %s: %w", digest, err)
}
}
k.logf("%d assignment graph revisions no record pins and not current are removed", removed)
return nil
}
// hearPins registers the seat mesh-delivery's four events on the assigner's hub.
func hearPins(hub *process.EventHub, k *pinKeeper) error {
for _, s := range []string{mdseat.SubjectPinned, mdseat.SubjectFreed, mdseat.SubjectHeadsReplaced, mdseat.SubjectPinsStated} {
if err := hub.Handle(s, k.hear); err != nil {
return fmt.Errorf("%s: %w", s, err)
}
}
return nil
}
// pinsTools are assigner_pins and assigner_unpin over the runtime's states.
func pinsTools(st store, ask func(string, any) (json.RawMessage, error)) []stdio.Tool {
return []stdio.Tool{{Name: pinsTool, Description: "the assignment graph revisions the delivery records pin (design 47 G §579): per revision " +
"the records that pin it and whether it was missing when pinned, the newest revision kept (current, never removed), when the state " +
"was made, the last pins-stated applied, and the state's fill against its cap of 4 MiB.",
Run: func(map[string]any) (any, error) { return readPins(st) }},
{Name: unpinTool, Description: "Remove one record from a revision's pins, for a freed that was lost (design 47 G §579); written in the " +
"hand-act log first. The removal rule then applies.",
Input: schema(map[string]string{"revision": "the assignment graph revision's number", "record": "the delivery record's id",
"why": "why it is removed by hand"}, []string{"revision", "record", "why"}),
Run: func(a map[string]any) (any, error) {
record, _ := a["record"].(string)
why, _ := a["why"].(string)
return unpin(st, ask, fmt.Sprint(a["revision"]), record, why, stdio.CallerOf(a))
}}}
}
// readPins is what assigner_pins answers.
func readPins(st store) (any, error) {
ks, err := st.keys(pinsState)
if err != nil {
return nil, err
}
sort.Strings(ks)
revisions := map[string]PinsValue{}
out := map[string]any{"revisions": revisions}
var newest int64
bytes := 0
for _, key := range ks {
raw, _, found, err := st.get(pinsState, key)
if err != nil || !found {
continue
}
bytes += len(key) + len(raw)
if r, ok := pinRevision(key); ok {
var v PinsValue
if json.Unmarshal(raw, &v) == nil {
revisions[key] = v
}
newest = max(newest, r)
continue
}
out[key] = json.RawMessage(raw)
}
out["fill"] = meshwants.Fill{Store: pinsState, Bytes: int64(bytes), Cap: pinsCapBytes}
if newest > 0 {
out["newest-pinned"] = newest
}
return out, nil
}
// unpin removes one record from one revision's list, the hand act recorded first.
func unpin(st store, ask func(string, any) (json.RawMessage, error), revision, record, why, by string) (any, error) {
r, err := strconv.ParseInt(strings.TrimPrefix(revision, "trunk."), 10, 64)
switch {
case err != nil || r <= 0:
return nil, fmt.Errorf("revision %q is no assignment graph revision's number", revision)
case record == "" || why == "":
return nil, errors.New("name the record and say why")
case by == "" || by == stdio.Unproven:
return nil, errors.New("an unpin names who asks it, and the runtime proved no caller")
}
key := pinKey(r)
raw, _, found, err := st.get(pinsState, key)
if err != nil {
return nil, err
}
var v PinsValue
if found {
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
}
i := slices.Index(v.Records, record)
if i < 0 {
return nil, fmt.Errorf("the record %s does not pin revision %d: %s lists its pins", record, r, pinsTool)
}
taken, err := meshhandactsclient.Recorder{Ask: ask}.Record(meshhandactsclient.Request{What: unpinTool + " " + key + " " + record,
Why: why + " (" + by + ")", Cause: "unpin"})
if err != nil {
return nil, fmt.Errorf("the hand act is not recorded, so nothing is removed: %w", err)
}
v.Records = slices.Delete(v.Records, i, i+1)
value, _ := json.Marshal(v)
if err := st.put(pinsState, key, value); err != nil {
return nil, err
}
return map[string]any{"revision": r, "unpinned": record, "records": v.Records, "hand-act": taken.Log}, nil
}
// watchUnpins judges again when a revision's pins change in the state itself, as assigner_unpin writes them from the tool
// process: the assigner's own state, heard as it changes, never on a timer.
func (k *pinKeeper) watchUnpins(kv jetstream.KeyValue) {
w, err := kv.WatchAll(k.ctx, jetstream.UpdatesOnly())
if err != nil {
k.logf("the state %s is not watched, so a hand unpin is judged at the next freed or revision: %v", pinsState, err)
return
}
go func() {
defer func() { _ = w.Stop() }()
for e := range w.Updates() {
if e == nil {
continue
}
if _, ok := pinRevision(e.Key()); ok && e.Operation() == jetstream.KeyValuePut {
k.judge()
}
}
}()
}
// inUseOf says a want or a running build names the module's fingerprint, as the world holds them now.
func inUseOf(w *world) func(module, fp string) bool {
return func(module, fp string) bool {
for _, want := range w.wantsOf()[module] {
if want.Fp == fp {
return true
}
}
for _, mods := range w.runningNow() {
if r, ok := mods[module]; ok && r.Fingerprint == fp {
return true
}
}
return false
}
}
@@ -0,0 +1,119 @@
package main
import (
"context"
"encoding/json"
"testing"
"time"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
mdseat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
)
func (m memObjects) Delete(_ context.Context, name string) error { delete(m, name); return nil }
// The pins of the assignment graph revisions (design 47 G §153, §178, §579; A-169): a revision two records pin stays until
// both free it; a record pinning twice pins once; the current revision is never removed; nothing is removed before a
// pins-stated said after the state was made, nor while the consumer is behind; a revision no longer held is pinned missing,
// with the finding pin-missing; an entry another kept revision lists, or a want or a running build names, stays.
func TestPinsKeepARevisionWhileARecordPinsIt(t *testing.T) {
objects := memObjects{}
entry := func(module, fp string) string {
e := meshwants.AssignmentEntry{Node: "n1", Module: module, Fp: fp}
d := assigngraph.EntryDigest(e)
raw, _ := json.Marshal(e)
objects[assigngraph.EntryObject(d)] = raw
return d
}
shared, old, running := entry("a", "fp-a"), entry("b", "fp-b1"), entry("c", "fp-c1")
revision := func(n int64, digests ...string) {
rev := meshwants.AssignmentRevision{Revision: n}
for _, d := range digests {
rev.Entries = append(rev.Entries, meshwants.NodeEntryRef{Node: "n1", Digest: d})
}
raw, _ := json.Marshal(rev)
objects[assigngraph.TrunkObject(n)] = raw
}
// A run's candidate lists an entry no trunk revision lists (A-201): it stays while the candidate is kept.
cand := entry("d", "fp-d2")
candRaw, _ := json.Marshal(meshwants.AssignmentRevision{Revision: 1, Kind: "candidate", Group: "run-7", Heads: "h1",
Entries: []meshwants.NodeEntryRef{{Node: "n1", Module: "d", Digest: cand}}})
objects[assigngraph.CandidateObject("run-7", "h1")] = candRaw
revision(1, shared, old, running)
revision(2, shared, old)
revision(3, shared)
var found []mcseat.Finding
clock := time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC)
k := &pinKeeper{ctx: context.Background(), st: newMemEnds(), objects: objects, now: func() time.Time { return clock },
logf: t.Logf,
inUse: func(module, fp string) bool { return module == "c" && fp == "fp-c1" },
findings: assign.Findings{Put: func(f mcseat.Finding) error { found = append(found, f); return nil }}}
if err := k.made(); err != nil {
t.Fatal(err)
}
say := func(subject string, body any) {
t.Helper()
raw, _ := json.Marshal(body)
if err := k.apply(subject, raw); err != nil {
t.Fatal(err)
}
k.judge()
}
pins := func(record string, revs ...int64) mdseat.Pins { return mdseat.Pins{Record: record, Assignment: revs} }
has := func(n int64) bool { _, ok := objects[assigngraph.TrunkObject(n)]; return ok }
k.caught = true
say(mdseat.SubjectPinned, pins("r1", 1))
say(mdseat.SubjectPinned, pins("r1", 1))
say(mdseat.SubjectPinned, pins("r2", 1))
say(mdseat.SubjectFreed, pins("r9", 2)) // nothing pins 2, but no pins-stated since made: nothing removed
if !has(2) {
t.Fatal("revision 2 was removed before a pins-stated said after the state was made")
}
k.caught = false
say(mdseat.SubjectPinsStated, mdseat.PinsStatedEvent{At: clock.Add(time.Minute)})
if !has(2) {
t.Fatal("revision 2 was removed while the consumer was behind")
}
k.caught = true
say(mdseat.SubjectFreed, pins("r1", 1))
if has(2) {
t.Error("revision 2, neither pinned nor current, is kept")
}
if !has(1) || !has(3) {
t.Error("revision 1 (pinned by r2) or 3 (current) was removed")
}
raw, _, _ := k.st.get(pinKey(1))
if string(raw) != `{"records":["r2"]}` {
t.Errorf("the pins of 1 = %s, want r2 alone (r1 pinned twice pins once, and freed once)", raw)
}
say(mdseat.SubjectFreed, pins("r2", 1))
if has(1) {
t.Error("revision 1 is kept once both records freed it")
}
if _, ok := objects[assigngraph.EntryObject(old)]; ok {
t.Error("the entry no kept revision lists and nothing names is kept")
}
if _, ok := objects[assigngraph.EntryObject(running)]; !ok {
t.Error("the entry of a running build is removed")
}
if _, ok := objects[assigngraph.EntryObject(shared)]; !ok {
t.Error("the entry the current revision lists is removed")
}
if _, ok := objects[assigngraph.EntryObject(cand)]; !ok {
t.Error("the entry a run's kept candidate lists is removed")
}
say(mdseat.SubjectHeadsReplaced, mdseat.HeadsReplacedEvent{})
if _, ok := objects[assigngraph.CandidateObject("run-7", "h1")]; !ok {
t.Error("the gate's heads-replaced dropped a run's candidate")
}
say(mdseat.SubjectPinned, pins("r3", 2))
raw, _, _ = k.st.get(pinKey(2))
if string(raw) != `{"records":["r3"],"missing":true}` || len(found) != 1 || found[0].Kind != pinMissingKind || found[0].ID != "2" {
t.Errorf("a pin of a revision no longer held = %s, findings %+v", raw, found)
}
}
@@ -151,8 +151,12 @@ type pipelineEnds struct {
// firsts is, by module, the first watched branch of its registration, the branch of a placement that names none (design
// 47 G §14).
firsts map[string]string
// runEnded is told a run on request's group-ended, its group `run-<id>`, to drop the run's candidate (A-235); nil drops nothing.
runEnded func(group string) error
// onBranches is told the branches a registration names, for the catalog half of each to be followed (design 47 G §14, §195).
onBranches func([]string)
// trunk names the first watched branch, the trunk's (design 47 G §507), the branch of a module registered with none.
trunk func() string
}
func newPipelineEnds(st endStore, wake func(), logf func(string, ...any)) *pipelineEnds {
@@ -225,7 +229,10 @@ func (e *pipelineEnds) branchOf(module, placed string) string {
if b := e.firsts[module]; b != "" {
return b
}
return trunkBranch
if e.trunk != nil {
return e.trunk()
}
return ""
}
// hearPipeline takes a pipeline-ended. A pipeline of a group writes nothing on its own end, and a run on request delivers
@@ -246,7 +253,8 @@ func (e *pipelineEnds) hearPipeline(m jetstream.Msg) {
_ = m.Ack()
}
// hearGroup takes a group-ended: each member's end, passed only if the group passed.
// hearGroup takes a group-ended: each member's end, passed only if the group passed. A run on request's (group `run-<id>`) delivers
// nothing: it drops the run's candidate assignment graph (A-235).
func (e *pipelineEnds) hearGroup(m jetstream.Msg) {
g, err := mprc.GroupEnded(m.Data())
if err != nil {
@@ -254,6 +262,17 @@ func (e *pipelineEnds) hearGroup(m jetstream.Msg) {
_ = m.Ack()
return
}
if strings.HasPrefix(g.Group, runGroupPrefix) {
if e.runEnded != nil {
if err := e.runEnded(g.Group); err != nil {
e.logf("%s: %v; it is heard again", m.Subject(), err)
_ = m.Nak()
return
}
}
_ = m.Ack()
return
}
for _, p := range g.Pipelines {
if err := e.take(p, g.Outcome == mpr.GroupEndedOutcomePassed && requiredPassed(p)); err != nil {
e.logf("%s: %v; it is heard again", m.Subject(), err)
@@ -20,8 +20,8 @@ import (
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
@@ -55,6 +55,19 @@ func (m *memEnds) swap(key string, v []byte, rev uint64) error {
return nil
}
// heardMsg is one message as a handler of the hub is handed it, saying whether it was acknowledged or given back.
type heardMsg struct {
jetstream.Msg
data []byte
subject string
acked, naked bool
}
func (m *heardMsg) Data() []byte { return m.data }
func (m *heardMsg) Subject() string { return m.subject }
func (m *heardMsg) Ack() error { m.acked = true; return nil }
func (m *heardMsg) Nak() error { m.naked = true; return nil }
func pipe(module, branch string, source, count int64, fp, outcome string, steps ...mpr.StepResult) mpr.PipelineEnded {
return mpr.PipelineEnded{Module: module, Branch: branch, Source: source, EndCount: count, Fingerprint: fp, Outcome: outcome, Steps: steps,
Pipeline: branch + "." + module, Commit: "c"}
@@ -112,6 +125,35 @@ func TestAWantFollowsAPassedPipeline(t *testing.T) {
t.Errorf("%s: may want = %v, want %v", c.name, got, c.may)
}
}
// A run on request's group-ended (group run-<id>, A-235) writes no want and drops the run's candidate; a drop that fails is
// heard again.
{
e := newPipelineEnds(newMemEnds(), func() {}, t.Logf)
e.on.Store(true)
var dropped []string
fail := false
e.runEnded = func(group string) error {
dropped = append(dropped, group)
if fail {
return fmt.Errorf("the bucket is not open")
}
return nil
}
raw, _ := json.Marshal(mpr.GroupEnded{V: 1, Branch: "main", Group: "run-7", Outcome: mpr.GroupEndedOutcomePassed,
Pipelines: []mpr.PipelineEnded{func() mpr.PipelineEnded { p := pipe("web", "main", 5, 1, fp1, pass); p.Run = "7"; return p }()}})
m := &heardMsg{data: raw, subject: "mesh.seat.mesh-pipeline-runner.event.group-ended.x"}
e.hearGroup(m)
if e.mayWant("web", fp1, []string{"main"}, nil) || !slices.Equal(dropped, []string{"run-7"}) || !m.acked {
t.Errorf("a run's group-ended: may want %v, dropped %v, acked %v; want no want, run-7 dropped, acked",
e.mayWant("web", fp1, []string{"main"}, nil), dropped, m.acked)
}
fail = true
m = &heardMsg{data: raw, subject: m.subject}
e.hearGroup(m)
if !m.naked {
t.Error("a run's candidate not dropped is not heard again")
}
}
// A want that names the fingerprint already is not held by a later failure (a node placed afterwards gets it).
e := newPipelineEnds(newMemEnds(), func() {}, t.Logf)
e.on.Store(true)
@@ -154,9 +196,7 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, trunkBranch, "web", mod)
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
bkey, _ := builds.BuilderBuildKey("web", fp)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
p.w.put(buckets.Builds, bkey, built)
endDone(t, p.w, "web", fp, mdigest)
gkey, _ := grantsstate.PartGrantKey("web", fp)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
p.w.put(buckets.Grants, gkey, grant)
@@ -165,10 +205,11 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
p.w.put(buckets.Have, "g14.web", have)
composes(t, p.w, "g14", digest)
placeOn(t, p.w, "g14", "web")
on, _ := presenceValue(true)
p.w.put(buckets.Presence, "g14", on)
on := true
p.w.heardPresence("g14", !on, time.Now())
if c.follow {
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
p.ends.trunk = p.w.trunkName
p.ends.on.Store(true)
if c.passed {
_ = p.ends.take(pipe("web", trunkBranch, 5, 1, fp, mpr.PipelineEndedOutcomePassed), true)
@@ -196,6 +237,7 @@ func TestTheWantsSwitchOnTheFirstRegistration(t *testing.T) {
st := newMemEnds()
woken := 0
e := newPipelineEnds(st, func() { woken++ }, t.Logf)
e.trunk = func() string { return trunkBranch }
_ = e.take(pipe("web", "main", 5, 1, "fp1", mpr.PipelineEndedOutcomeFailed), false)
_ = e.take(pipe("web", "beta", 5, 1, "fp1", mpr.PipelineEndedOutcomePassed), true)
if e.following() || !e.mayWant("web", "fp1", []string{"main"}, nil) {
@@ -225,12 +267,13 @@ func TestACommitOnBetaMovesOnlyTheNodeThatChoseBeta(t *testing.T) {
p := newPasser(kvSet{buckets.Wants: wantsKV, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now())
p.say = func(oc.Condition) error { return nil }
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
p.ends.trunk = p.w.trunkName
p.ends.registered(mpcat.Registration{Module: "web", Branches: []string{"main", "beta"}}, false)
digest := "sha256:" + strings.Repeat("c", 64)
on, _ := presenceValue(true)
on := true
for _, node := range []string{"g14", "ace"} {
composes(t, p.w, node, digest)
p.w.put(buckets.Presence, node, on)
p.w.heardPresence(node, !on, time.Now())
}
for node, branch := range map[string]string{"g14": "", "ace": "beta"} {
pl := mma.Assignments{Node: node, Modules: []mma.PlacedModule{{Module: "web", Branch: branch}}}
@@ -251,9 +294,7 @@ func TestACommitOnBetaMovesOnlyTheNodeThatChoseBeta(t *testing.T) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: f, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, branch, "web", mod)
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
bkey, _ := builds.BuilderBuildKey("web", f)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
p.w.put(buckets.Builds, bkey, built)
endDone(t, p.w, "web", f, mdigest)
gkey, _ := grantsstate.PartGrantKey("web", f)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
p.w.put(buckets.Grants, gkey, grant)
@@ -14,7 +14,6 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
@@ -107,6 +106,7 @@ func (p *passer) puts(ctx context.Context) {
}
p.placementPuts(ctx)
all := p.putsByModule()
defer func() { p.putWaits(ctx, p.putsByModule()) }()
if len(all) == 0 {
return
}
@@ -184,14 +184,8 @@ func (p *passer) putWorld(node, module string, put puts.Put, manifest catalogue.
w.Assigned = true
}
}
if bkey, err := builds.BuilderBuildKey(module, put.FP); err == nil {
if raw := p.w.value(buckets.Builds, bkey); raw != nil {
if b, err := builds.DecodeBuilderBuild(raw); err == nil {
w.Build, w.BuildWords = b.State, b.Error
} else {
w.Build = "unreadable: " + err.Error()
}
}
if e, ok := p.w.ends.endOf(module, put.FP); ok { // the build's end heard (A-181): none heard, no state
w.Build, w.BuildWords = e.Outcome, e.Error
}
w.Composed, w.Granted = p.composedFor(node, module, put.FP)
if d := w.Composed; d != nil {
@@ -0,0 +1,116 @@
package main
import (
"context"
"encoding/json"
"fmt"
"time"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/puts"
)
// A put without hold that stands is said as a wait (design 47 G §498, main file *A put without hold that stands*): when its
// want is written the assigner says `wait-held` on the seat mesh-wants, step put, with the node, the module, the fingerprint
// and since when, bound 30 minutes; and `wait-ended` when it ends: ran when its node runs it, released by assigner_release,
// superseded by a put asked again under its key. The watchman times it; a held put waits on nobody and is said as none.
// putWaitBound is the bound of a put without hold's wait.
const putWaitBound = 30 * time.Minute
// Wait-ended's how of a put's wait.
const (
putWaitRan = "ran"
putWaitReleased = "released"
putWaitSuperseded = "superseded"
)
// putWaits says whether p is a put that waits: one without hold whose want is written and not yet run.
func putWaits(p puts.Put) bool {
return !p.Hold && p.Step == puts.Wanted
}
// putWaitBody is the wait of the put p of module on node.
func putWaitBody(node, module string, p puts.Put) meshwants.Wait {
since := p.StandsSince().UTC()
return meshwants.Wait{Step: meshwants.WaitStepPut, Owner: assignment.Module, Node: node, Module: module, Fingerprint: p.FP,
Since: since, ClockFrom: &since, Bound: putWaitBound.String(),
Words: fmt.Sprintf("the put of %s at %s on %s waits for %s to run it", module, shortFP(p.FP), node, node)}
}
// putWaiting is a put whose wait-held was said, as the state put-waits keeps it under the put's key (design 47 G A-204,
// A-211): its identity and the put as it was then.
type putWaiting struct {
ID string `json:"id"`
Put puts.Put `json:"put"`
}
// saidPutWaits is every put wait the state put-waits holds, by key; a value that does not decode is said and left.
func (p *passer) saidPutWaits() map[string]putWaiting {
out := map[string]putWaiting{}
for key, value := range p.w.read(buckets.PutWaits) {
var w putWaiting
if err := json.Unmarshal(value, &w); err != nil || w.ID == "" {
p.logf("%s %s holds no put wait, left: %v", buckets.PutWaits, key, err)
continue
}
out[key] = w
}
return out
}
// putWaits says wait-ended of each put whose said wait the state put-waits keeps and that no longer waits, whether it ended
// while this process ran or while the assigner was stopped (A-204), and wait-held of each put that waits and was not said;
// one not said, or not kept, is said at the next pass. Without the state no put's wait is said.
func (p *passer) putWaits(ctx context.Context, all map[string]map[string]puts.Put) {
if p.kvs[buckets.PutWaits] == nil {
return
}
waiting := p.saidPutWaits()
for key, was := range waiting {
node, module, err := puts.ParseKey(key)
if err != nil {
continue
}
cur, there := all[module][node]
same := there && identity(key, cur) == was.ID
if same && putWaits(cur) {
continue
}
how := putWaitSuperseded
if same && cur.Step == puts.Done {
how = putWaitRan
} else if same && cur.Step == puts.Released {
how = putWaitReleased
}
body := putWaitBody(node, module, was.Put)
body.How = how
if err := p.wantsSay(meshwants.EventWaitEnded, body, was.ID+".put."+how); err != nil {
p.logf("the end of the put %s's wait is not said, and is said at the next pass: %v", key, err)
continue
}
p.remove(ctx, buckets.PutWaits, key)
delete(waiting, key)
}
for module, byNode := range all {
for node, put := range byNode {
key, err := puts.Key(node, module)
if err != nil || !putWaits(put) {
continue
}
id := identity(key, put)
if w, said := waiting[key]; said && w.ID == id {
continue
}
if err := p.wantsSay(meshwants.EventWaitHeld, putWaitBody(node, module, put), id+".put"); err != nil {
p.logf("the put %s's wait is not said, and is said at the next pass: %v", key, err)
continue
}
kept := putWaiting{ID: id, Put: put}
p.put(ctx, buckets.PutWaits, key, func() ([]byte, error) { return json.Marshal(kept) })
}
}
}
@@ -0,0 +1,92 @@
package main
import (
"context"
"testing"
"time"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/puts"
)
// Design 47 G §498 and the main file's *A put without hold that stands*: a put without hold whose want is written is said as
// wait-held, step put, with its node, module, fingerprint, since and a 30-minute bound, and wait-ended when it runs, is
// released or is put again; a held put is said as no wait; assigner_puts answers since and bound. A-204, A-211: the said
// waits are kept in the state put-waits, so a put that waits at the start was said before and is not said again, and one
// that ran while the assigner was stopped gets its wait-ended at the next start.
func TestAPutWithoutHoldIsSaidAsAWait(t *testing.T) {
at := time.Date(2026, 11, 12, 8, 0, 0, 0, time.UTC)
kept := &memKV{values: map[string][]byte{}}
var events []string
var bodies []meshwants.Wait
start := func() *passer { // a process's start: a fresh world that hands what the state holds
p := newPasser(kvSet{buckets.PutWaits: kept}, at)
for k, v := range kept.values {
p.w.put(buckets.PutWaits, k, v)
}
p.acts = newPutActs(&fakeBus{calls: make(chan string, 8)}, func() {}, func(string, ...any) {})
p.publishWants = func(event string, body any, id string) error {
events, bodies = append(events, event), append(bodies, body.(meshwants.Wait))
return nil
}
return p
}
ctx := context.Background()
asked := puts.Put{FP: fpTest, Why: "a try", At: at}.Said(at, puts.Asked, "asked")
wanted := asked.Said(at.Add(time.Minute), puts.Wanted, "the want is written").Said(at.Add(2*time.Minute), puts.Wanted, "its check failed")
held := wanted
held.Hold = true
p := start()
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": wanted}})
if len(events) != 1 || events[0] != meshwants.EventWaitHeld || kept.values["ace.nats"] == nil {
t.Fatalf("a put that waits said %v, kept %q", events, kept.values)
}
events, bodies = nil, nil
// At the next start the put still waits: it was said before, nothing is said of it.
p = start()
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": wanted}})
if len(events) != 0 {
t.Fatalf("a put waiting at the start was said again: %v", events)
}
// It ran while the assigner was stopped: at the next start, wait-ended, ran, and the state forgets it.
p = start()
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": wanted.Said(at.Add(3*time.Minute), puts.Done, "runs")}})
if len(events) != 1 || events[0] != meshwants.EventWaitEnded || bodies[0].How != putWaitRan || bodies[0].Step != meshwants.WaitStepPut ||
kept.values["ace.nats"] != nil {
t.Fatalf("a put that ran while stopped said %v %+v, kept %q", events, bodies, kept.values)
}
events, bodies = nil, nil
// A new put's want is written: wait-held, since the want was written, bound 30m; a held put says nothing.
again := wanted
again.At = at.Add(time.Hour)
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": again}, "web": {"ace": held}})
if len(events) != 1 || events[0] != meshwants.EventWaitHeld {
t.Fatalf("a put's written want said %v", events)
}
b := bodies[0]
if b.Node != "ace" || b.Module != "nats" || b.Fingerprint != fpTest || !b.Since.Equal(at.Add(time.Minute)) || b.ClockFrom == nil ||
!b.ClockFrom.Equal(b.Since) || b.Bound != "30m0s" || b.Owner != "mesh-delivery-assigner" {
t.Errorf("the put's wait is %+v", b)
}
// Said once.
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": again}, "web": {"ace": held}})
if len(events) != 1 {
t.Errorf("a put's wait was said again: %v", events)
}
// Put again under its key: the first wait ends superseded, the new one is held.
newer := asked
newer.At = at.Add(2 * time.Hour)
p.putWaits(ctx, map[string]map[string]puts.Put{"nats": {"ace": newer}})
if len(events) != 2 || events[1] != meshwants.EventWaitEnded || bodies[1].How != putWaitSuperseded || bodies[1].Fingerprint != fpTest {
t.Errorf("a put asked again said %v %+v", events, bodies)
}
// The answer of assigner_puts.
if a := answerOf("ace", "nats", wanted); !a.Since.Equal(at.Add(time.Minute)) || a.Bound != "30m0s" {
t.Errorf("a put that waits answers since %v, bound %q", a.Since, a.Bound)
}
if a := answerOf("ace", "web", held); !a.Since.Equal(at.Add(time.Minute)) || a.Bound != "" {
t.Errorf("a held put answers since %v, bound %q", a.Since, a.Bound)
}
}
@@ -17,9 +17,9 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/catalogkey"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
@@ -72,14 +72,11 @@ func (s *putTools) hear(key string, value []byte) {
s.mu.Unlock()
}
// fullFP is a fingerprint of the new form, whole; prefix at least its first twelve hex digits.
var (
fullFP = regexp.MustCompile(`^src2:[0-9a-f]{64}$`)
prefix = regexp.MustCompile(`^(src2:)?[0-9a-f]{12,63}$`)
)
// fullFP is a fingerprint of the new form, whole.
var fullFP = regexp.MustCompile(`^src2:[0-9a-f]{64}$`)
// version reads the version a put names: newest (the indexer's fingerprint at the trunk), a whole fingerprint, or the start
// of one the builder built, which must name one build alone.
// version reads the version a put names: newest (the indexer's fingerprint at the trunk) or a whole fingerprint (design 47 G A-181:
// a put names the whole fingerprint, the builder's builds being no bucket to list).
func (s *putTools) version(module, version string) (string, error) {
v := strings.ToLower(strings.TrimSpace(version))
switch {
@@ -103,29 +100,8 @@ func (s *putTools) version(module, version string) (string, error) {
return m.Fp2, nil
case fullFP.MatchString(v):
return v, nil
case prefix.MatchString(v):
start := strings.TrimPrefix(v, meshcatalogclient.SourcePrefix2)
keys, err := s.st.keys(builds.BuilderModule + ".builds")
if err != nil {
return "", fmt.Errorf("the builder's builds cannot be listed: %w", err)
}
var found []string
for _, k := range keys {
m, fp, err := builds.ParseBuilderBuildKey(k)
if err == nil && m == module && strings.HasPrefix(strings.TrimPrefix(fp, meshcatalogclient.SourcePrefix2), start) {
found = append(found, fp)
}
}
switch len(found) {
case 0:
return "", fmt.Errorf("the builder built no %s whose fingerprint starts %s: give it whole", module, start)
case 1:
return found[0], nil
}
sort.Strings(found)
return "", fmt.Errorf("%s starts %d fingerprints of %s the builder built (%s): give more of it", start, len(found), module, strings.Join(found, ", "))
}
return "", fmt.Errorf("%q is no version: newest, a fingerprint (src2: and 64 hex digits) or at least its first 12 hex digits", version)
return "", fmt.Errorf("%q is no version: newest, a fingerprint (src2: and 64 hex digits) whole", version)
}
// putAnswer is what assigner_put and assigner_puts answer of one put.
@@ -137,6 +113,19 @@ type putAnswer struct {
// Older says a put that has not ended stands at an older version than the newest build, and which that is: it pins the
// node's want until it ends or is released.
Older string `json:"older-than-newest,omitempty"`
// Since is when the put came to stand at its step, held or not; Bound is the bound of a put without hold whose want is
// written, the wait the watchman times and seeds from here (design 47 G §498, main file *A put without hold that stands*).
Since time.Time `json:"since"`
Bound string `json:"bound,omitempty"`
}
// answerOf is what assigner_put and assigner_puts answer of the put p of module on node.
func answerOf(node, module string, p puts.Put) putAnswer {
a := putAnswer{Node: node, Module: module, Put: p, Since: p.StandsSince()}
if putWaits(p) {
a.Bound = putWaitBound.String()
}
return a
}
// doPut is the tool assigner_put.
@@ -168,7 +157,9 @@ func (s *putTools) doPut(args map[string]any, by string) (any, error) {
} else if found {
if before, err := puts.Decode(raw); err == nil && !puts.Ended(before.Step) {
if before.FP == fp && before.Hold == hold {
return putAnswer{Node: node, Module: module, Put: before, Follow: followPut(node, module)}, nil
a := answerOf(node, module, before)
a.Follow = followPut(node, module)
return a, nil
}
words += fmt.Sprintf("; it replaces the put of %s at %s, which stood at %s", module, shortFP(before.FP), before.Step)
}
@@ -182,7 +173,9 @@ func (s *putTools) doPut(args map[string]any, by string) (any, error) {
return nil, fmt.Errorf("the put of %s on %s is not written: %w", module, node, err)
}
s.hear(key, data)
return putAnswer{Node: node, Module: module, Put: s.await(key, p), Follow: followPut(node, module)}, nil
a := answerOf(node, module, s.await(key, p))
a.Follow = followPut(node, module)
return a, nil
}
// await is the put at key once the assigner's process took it up past its asking, or as it stands after answerWithin.
@@ -217,6 +210,12 @@ func (s *putTools) doRelease(args map[string]any, by string) (any, error) {
if arg(args, "how") != "" {
return s.doReleaseWait(args, by) // a wait on an outside cause (outside_tools.go, design 47 G §706)
}
if step := arg(args, "step"); step != "" {
if step != meshwants.WaitStepDelivery {
return nil, fmt.Errorf("step is %s, the one wait released by its step, not %q", meshwants.WaitStepDelivery, step)
}
return s.doReleaseDelivery(args, by) // a dependent's delivery wait (delivered.go, design 47 G §376, A-246)
}
node, module, why := arg(args, "node"), arg(args, "module"), arg(args, "why")
key, err := puts.Key(node, module)
if err != nil {
@@ -248,7 +247,7 @@ func (s *putTools) doRelease(args map[string]any, by string) (any, error) {
return nil, fmt.Errorf("the release of %s on %s is not written: %w", module, node, err)
}
s.hear(key, data)
return putAnswer{Node: node, Module: module, Put: p}, nil
return answerOf(node, module, p), nil
}
// doPuts is the tool assigner_puts.
@@ -274,7 +273,7 @@ func (s *putTools) doPuts(args map[string]any) (any, error) {
if err != nil {
continue
}
a := putAnswer{Node: node, Module: module, Put: p}
a := answerOf(node, module, p)
if !puts.Ended(p.Step) {
if !asked {
asked = true
@@ -349,10 +348,12 @@ func (s *putTools) tools() []stdio.Tool {
"rules write the machine's want again from their next pass. Nothing changes on the machine until they move it.",
Input: schema(map[string]string{"node": "the machine", "module": "the module", "why": "why, kept with the put",
"how": "with how, the wait of the delivery on an outside cause (assigner_waits) is released, not a put: go-on (the module counts healthy there), " +
"failed (the failing check is the delivery's) or rejudge (judge it again now); written in the hand-act log first"},
[]string{"node", "module", "why"}),
"failed (the failing check is the delivery's) or rejudge (judge it again now); written in the hand-act log first",
"step": "delivery: the module's wait for its dependency's delivery (assigner_waits) is released, its want written now; no machine is named; written in the hand-act log first"},
[]string{"module", "why"}),
Run: func(a map[string]any) (any, error) { return s.doRelease(a, stdio.CallerOf(a)) }},
s.waitsTool(),
s.deliveredAgainTool(),
{Name: toolPuts, Description: "Every put and where it stands (novox/hq ADR 0546): its version, hold, why, each step with " +
"its time and words. Narrowed by machine or module. Read-only.",
Input: schema(map[string]string{"node": "one machine", "module": "one module"}, nil),
@@ -417,6 +418,7 @@ func serveTools() error {
// What each node's engine reads, as the assigner uses it (ADR 0618 §3): its own state engine-reads, asked again through it.
tools = append(tools, enginereads.Tool(engineReadsTool, "the assigner", enginereads.KeptState(stdio.State(engineReadsState)), false, time.Now))
tools = append(tools, writtenTools(runtimeStore{})...)
tools = append(tools, pinsTools(runtimeStore{}, stdio.Ask)...)
tools = append(tools, askAgainTools(runtimeStore{}, stdio.Ask, time.Now)...)
return stdio.Serve("", tools)
}
@@ -1,7 +1,6 @@
package main
import (
"mesh-delivery-assigner/internal/buckets"
"slices"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
@@ -94,22 +93,14 @@ func (p *passer) servesOf(module, fp string) ([]string, bool) {
return graph.ServesOf(m), true
}
// manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests),
// and whether it could be read.
// manifestOf is the manifest of module's build at fp (the build's end names its digest, A-181, which the indexer's manifests
// hold), and whether it could be read.
func (p *passer) manifestOf(module, fp string) (catalogue.Manifest, bool) {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
e, ok := p.w.ends.endOf(module, fp)
if !ok || e.Outcome != builds.BuildDone || e.Manifest == "" {
return catalogue.Manifest{}, false
}
raw := p.w.value(buckets.Builds, key)
if raw == nil {
return catalogue.Manifest{}, false
}
b, err := builds.DecodeBuilderBuild(raw)
if err != nil || b.Manifest == "" {
return catalogue.Manifest{}, false
}
got := p.w.manifest(b.Manifest)
got := p.w.manifest(e.Manifest)
if got.err != "" {
return catalogue.Manifest{}, false
}
@@ -3,15 +3,19 @@ package main
import (
"context"
"encoding/json"
"fmt"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"slices"
"sync/atomic"
"time"
"github.com/nats-io/nats.go/jetstream"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/process"
@@ -122,6 +126,12 @@ type catalogViews struct {
hub *process.EventHub
h *halves
// candidates is told each candidate's catalog-revision (A-201), once the passer that keeps its assignment graph is made;
// before that a candidate's revision is said not taken.
candidates atomic.Pointer[func(context.Context, meshcatalog.CatalogRevisionEvent) error]
// answers is the picture of the teller's answers (design 47 G A-175, A-178): its first view through teller_read_answers,
// then its views family; nil when it is not followed.
answers *views.Picture
}
// firstViewBound and firstViewWait are each first view's bound before its condition is raised, and its first wait
@@ -136,7 +146,24 @@ func (iv *catalogViews) start(ctx context.Context, w *world, wake func(), settle
reader.Findings = func() views.WireWriter { return iv.writer }
}
iv.h = &halves{w: w, call: iv.call, reader: reader, wake: wake, logf: iv.logf, ctx: ctx, settled: settled}
iv.h.follow(trunkBranch)
if err := iv.h.follow(); err != nil {
iv.logf("the catalog half is not followed, so no module is placed until the assigner starts again: %v", err)
}
// Each candidate's catalog graph revision, heard on the reader's one subscription of catalog-revision (A-201, A-185).
if err := mcc.WireCandidatesProcess(reader, func(ctx context.Context, ev meshcatalog.CatalogRevisionEvent) error {
if f := iv.candidates.Load(); f != nil {
return (*f)(ctx, ev)
}
return fmt.Errorf("the candidate %s's revision %d is heard before the assigner can keep its assignment graph", ev.Group, ev.Revision)
}); err != nil {
iv.logf("no candidate's catalog graph revision is heard, so no run's assignment graph is kept until the assigner starts again: %v", err)
}
// The teller's answers to the assigner's asks, followed as every owner's state is (A-175), never its bucket.
if pic, err := reader.Follow(buckets.TellerModule, buckets.AnswersState, buckets.TellerModule+"."+buckets.AnswersReadTool); err != nil {
iv.logf("the teller's answers are not followed, so no ask is answered until the assigner starts again: %v", err)
} else {
iv.answers = pic
}
reader.Start(ctx, iv.js, iv.account, iv.status)
return 1
}
@@ -12,13 +12,11 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
mdc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
@@ -45,12 +43,19 @@ type world struct {
ends builtEnds
// released are the catalog's version-released heard, by module and source revision (buildended.go).
released releasedVersions
// delivered are the assigner's own version-delivered heard and said (delivered.go).
delivered deliveredVersions
// edges are, by watched branch and module, the edges into the module's artifacts its catalog entry names (§374's kinds).
edges map[string]map[string][]meshcatalog.Edge
// assignments is the seat's state assignments assembled node by node from its keys in either form (mesh-sdk
// mmac.Keys; research 072 row 37): the one reader of its keys in the assigner.
assignmentKeys *mmac.Keys
// sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its
// history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1).
sourceGroups map[uint64]string
// first is the first watched branch the catalog half is followed of, the trunk's: the branch of the trunk-only want, read from the
// branches the registrations name, before the first registrations from those the gate's `source` answers (design 47 G §507, §532),
// and never fixed in code (§7).
first string
// presence is, by machine, the last machine-away or machine-back the watchman said of it (design 47 G §389).
presence map[string]presenceSaid
// running is, by `<node>.<module>`, the last `running` the node-engine seat said of it, kept from its stream by the assigner's
// durable consumer (design 47 G §199, §309); runningHeard says that consumer is bound.
running map[string]nodeengine.Running
@@ -77,7 +82,7 @@ type parsed struct {
func newWorld() *world {
return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{},
meta: map[string]entryMeta{}, assignmentKeys: mmac.NewKeys(),
sourceGroups: map[uint64]string{}, running: map[string]nodeengine.Running{},
running: map[string]nodeengine.Running{},
modules: map[string]map[string]meshcatalog.ModulesByModuleValue{}, graphs: map[string]map[string]graph.Node{}}
}
@@ -94,44 +99,6 @@ func (w *world) putEntry(bucket, key string, value []byte, revision uint64, at t
if bucket == plc.AssignmentsBucket {
_, _ = w.assignmentKeys.Put(key, value, revision, false) // put took it already; this is its revision
}
if bucket == buckets.Source && key == mdc.SourceKey(trunkBranch) {
if s, err := mdc.DecodeSource(value); err == nil && s.Group != "" {
w.sourceGroups[revision] = s.Group
}
}
}
// release is the release whose Approve carries module's build fp (ADR 0601 §1), and whether one does: the trunk's value of
// the module names fp, the source revision at which that fingerprint last changed (FPSrc, never the Src of its last merge,
// which a recompute with no merge keeps) names the release that moved it, and the gate's merge of that release, which it
// writes on the operator's Approve, holds the module's own trunk merge among its merged pull requests. Any of them
// unknown: none, and the move is asked of a person.
func (w *world) release(module, fp string) (string, bool) {
t, ok := w.trunk()[module]
if !ok || fp == "" || t.Fp2 != fp || t.FpSrc == 0 || t.Commit == "" {
return "", false
}
w.mu.Lock()
group := w.sourceGroups[uint64(t.FpSrc)]
w.mu.Unlock()
key, err := mdc.MergesKey(group)
if group == "" || err != nil {
return "", false
}
raw := w.value(buckets.Merges, key)
if raw == nil {
return "", false
}
merge, err := mdc.DecodeMerge(raw)
if err != nil {
return "", false
}
for _, pull := range merge.Pulls {
if pull.State == string(mdc.MergeMerged) && pull.Commit == t.Commit && trunk.SameRepository(pull.Repository, t.Repository) {
return group, true
}
}
return "", false
}
// metaOf is the revision and time of the value a bucket holds at key, as the watch said them.
@@ -267,8 +234,8 @@ func (w *world) branches() []string {
w.mu.Lock()
defer w.mu.Unlock()
out := slices.Sorted(maps.Keys(w.modules))
if i := slices.Index(out, trunkBranch); i > 0 {
out = slices.Insert(slices.Delete(out, i, i+1), 0, trunkBranch)
if i := slices.Index(out, w.first); i > 0 {
out = slices.Insert(slices.Delete(out, i, i+1), 0, w.first)
}
return out
}
@@ -307,6 +274,27 @@ func (w *world) dropModule(branch, module string) {
defer w.mu.Unlock()
delete(w.modules[branch], module)
delete(w.graphs[branch], module)
delete(w.edges[branch], module)
}
// putEdges keeps the edges into module's artifacts that branch's catalog entry names.
func (w *world) putEdges(branch, module string, edges []meshcatalog.Edge) {
w.mu.Lock()
defer w.mu.Unlock()
if w.edges == nil {
w.edges = map[string]map[string][]meshcatalog.Edge{}
}
if w.edges[branch] == nil {
w.edges[branch] = map[string][]meshcatalog.Edge{}
}
w.edges[branch][module] = edges
}
// edgesOf is the edges into module's artifacts in branch's catalog half.
func (w *world) edgesOf(branch, module string) []meshcatalog.Edge {
w.mu.Lock()
defer w.mu.Unlock()
return w.edges[branch][module]
}
// holdsBranch says whether the catalog half of branch has been taken in (even empty).
@@ -332,18 +320,37 @@ func (w *world) records() map[string]nodeengine.NodeRecord {
return out
}
// presenceOf reads one key of the watchman's presence: a node's key (`<node>`) with the node's online or not; the keys of
// the parts (`part.<part>.<instance>`) are not a node's and are none here.
func presenceOf(key string, value []byte) (node string, online, ok bool) {
node, part, _, err := wdc.ParsePresenceKey(key)
if err != nil || node == "" || part != "" {
return "", false, false
// presenceSaid is the last of the watchman's machine-away and machine-back heard of a machine (design 47 G §389).
type presenceSaid struct {
away bool
at time.Time
}
// heardPresence takes one machine-away (away) or machine-back of machine said at at: the later of the two holds, so the two
// streams, read whole in either order, leave each machine as its last event says.
func (w *world) heardPresence(machine string, away bool, at time.Time) {
w.mu.Lock()
defer w.mu.Unlock()
if w.presence == nil {
w.presence = map[string]presenceSaid{}
}
p, err := wdc.DecodeNodePresence(value)
if err != nil {
return "", false, false
if was, ok := w.presence[machine]; ok && at.Before(was.at) {
return
}
return node, p.Online, true
w.presence[machine] = presenceSaid{away: away, at: at}
}
// awayNow is every machine whose last presence event heard is machine-away.
func (w *world) awayNow() map[string]bool {
w.mu.Lock()
defer w.mu.Unlock()
out := map[string]bool{}
for m, p := range w.presence {
if p.away {
out[m] = true
}
}
return out
}
// seatHeard says a machine holding seat (a seat held once) is online as the watchman holds it: the callee of a call to
@@ -364,13 +371,13 @@ func (w *world) seatHeard(seat string) bool {
return false
}
// online is, by node, whether the watchman holds the node online.
// online is, by node the assigner hears of, whether it is present: a machine is away from the watchman's machine-away until its
// machine-back, and present otherwise (design 47 G §389); the watchman's state is not read (ADR 0596 §2).
func (w *world) online() map[string]bool {
away := w.awayNow()
out := map[string]bool{}
for key, value := range w.read(buckets.Presence) {
if node, online, ok := presenceOf(key, value); ok {
out[node] = online
}
for _, node := range w.knownNodes() {
out[node] = !away[node]
}
return out
}
@@ -399,11 +406,7 @@ func (w *world) wantsOf() map[string]map[string]mwc.Want {
func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes {
n := assign.Nodes{Online: map[string]bool{}, Running: w.runningNow(), Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{}, Providers: map[string]map[string][]string{},
Reads: map[string][]string{}}
for key, value := range w.read(buckets.Presence) {
if node, online, ok := presenceOf(key, value); ok {
n.Online[node] = online
}
}
n.Online = w.online()
for key, value := range w.read(buckets.Have) {
node, module, isNode, err := nodeengineclient.ParseHaveKey(key)
if err != nil {
@@ -459,34 +462,28 @@ func (w *world) has(bucket, key string) bool {
return ok
}
// built says whether the builder's build of a module at a fingerprint is done.
// built says whether the builder's build of a module at a fingerprint is done, from its `build.ended` heard (design 47 G A-181).
func (w *world) built(module, fp string) bool {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return false
}
w.mu.Lock()
value, ok := w.values[buckets.Builds][key]
w.mu.Unlock()
if !ok {
return false
}
b, err := builds.DecodeBuilderBuild(value)
return err == nil && b.State == builds.BuildDone
e, ok := w.ends.endOf(module, fp)
return ok && e.Outcome == builds.BuildDone
}
// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build key's needs, ADR
// 0618 §1): nil when it is not done or names none.
// buildStanding says whether the builder's `build.ended` of module at fp said the build failed, and its error whole (design 47 G
// §575, A-162): the words of a hold under rule 1.
func (w *world) buildStanding(module, fp string) (failed bool, words string) {
if e, ok := w.ends.endOf(module, fp); ok && e.Outcome == builds.BuildFailed {
return true, e.Error
}
return false, ""
}
// buildNeeds are the reference forms the builder's done build of module at fp is written in (the build's end says them, ADR
// 0618 §1, A-181): nil when it is not done or names none.
func (w *world) buildNeeds(module, fp string) []string {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return nil
if e, ok := w.ends.endOf(module, fp); ok && e.Outcome == builds.BuildDone {
return e.Needs
}
b, err := builds.DecodeBuilderBuild(w.value(buckets.Builds, key))
if err != nil || b.State != builds.BuildDone {
return nil
}
return b.Needs
return nil
}
// recordReads is what node's record `<node>.node` states its engine reads (ADR 0614 §4), and whether the assigner holds
@@ -496,7 +493,7 @@ func (w *world) recordReads(node string) ([]string, bool) {
return r.ManifestReads, ok
}
// knownNodes is every node the assigner hears of: placed, with a record, or with a presence.
// knownNodes is every node the assigner hears of: placed, with a record, or named by machine-away or machine-back.
func (w *world) knownNodes() []string {
set := map[string]bool{}
for node := range w.placedNodes() {
@@ -505,9 +502,11 @@ func (w *world) knownNodes() []string {
for node := range w.records() {
set[node] = true
}
for node := range w.online() {
w.mu.Lock()
for node := range w.presence {
set[node] = true
}
w.mu.Unlock()
return slices.Sorted(maps.Keys(set))
}
@@ -582,8 +581,19 @@ func (w *world) unplacedNodes() []string {
return out
}
// trunkBranch is the branch of the trunk-only want, kept until the first registrations (design 47 G §95, §353).
const trunkBranch = "main"
// trunkName is the first watched branch, the trunk's (design 47 G §507, §353): "" until the first view of the catalog half is adopted.
func (w *world) trunkName() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.first
}
// setTrunk says which watched branch is the first.
func (w *world) setTrunk(branch string) {
w.mu.Lock()
defer w.mu.Unlock()
w.first = branch
}
// placedBranches is, by module and node, the watched branch each placement names ("" when it names none).
func (w *world) placedBranches() map[string]map[string]string {
@@ -3,6 +3,8 @@ package main
import (
"context"
"mesh-delivery-assigner/internal/buckets"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -81,3 +83,32 @@ func putModuleRaw(w *world, branch, module string, raw []byte) {
}
w.putModule(branch, module, v, graph.Node{FP: v.Fp2, Repository: v.Repository}, nil)
}
// Design 47 G §507, §532, §7: the first watched branch the catalog half is followed of is the trunk's, whatever its name, and no part of the
// assigner's code names a branch: the world's trunk comes first, and no non-test source holds the string "main".
func TestTheTrunkIsTheFirstWatchedBranchAndNoCodeNamesOne(t *testing.T) {
w := newWorld()
w.setTrunk("develop")
for _, b := range []string{"beta", "develop", "alpha"} {
w.putModule(b, "web", meshcatalog.ModulesByModuleValue{Fp2: "fp"}, graph.Node{FP: "fp"}, nil)
}
if got := w.branches(); strings.Join(got, ",") != "develop,alpha,beta" {
t.Errorf("branches = %v, want the first watched first, then by name", got)
}
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), `"main"`) {
t.Errorf("%s names the branch main in code (design 47 G §507)", f)
}
}
}
@@ -26,11 +26,15 @@ import (
// trunk (the indexer's modules), whether the builder's build of F is done, whether F is held, whether the controller
// issued its credential for F, its manifest at F, and the nodes it is assigned to.
type Module struct {
Name string
FP string
Built bool
Held bool
Granted bool
Name string
FP string
Built bool
// BuildFailed and BuildError say a build that ended failed, with the error as the builder's `build.ended` carries it whole
// (design 47 G §575, A-162): the words of a hold under rule 1.
BuildFailed bool
BuildError string
Held bool
Granted bool
// GrantedAt says whether the controller issued the module's credential for another fingerprint than FP: the first want of a node
// that runs an older one names it (issue 1945). Nil: none.
GrantedAt func(fp string) bool
@@ -69,10 +73,6 @@ type Module struct {
// Allowed says whether the operator answered yes to the record ask about the move of this module on node to the composition
// with this digest (ADR 0539 §14). Nil: none did.
Allowed func(node, digest string) bool
// Release names the release whose Approve carries the build fp, as mesh-delivery's gate holds it for the release's heads,
// read by the build's source revision (ADR 0601 §1): that Approve is the yes to a move of the build alone. False when no
// approved release is known to carry it. Nil: none is read.
Release func(fp string) (string, bool)
// Released is a hold on FP the operator released (ADR 0539 §19): FP is not held again by a health check that failed.
Released bool
// Restored says whether the operator answered "restore" to the restore ask of this data module on node: it is then set back
@@ -159,9 +159,6 @@ func healthy(h nodeengine.HaveByNodeModuleValue, fp string) bool {
return h.Fp == fp && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK
}
// Healthy says whether a have runs fp healthy.
func Healthy(h nodeengine.HaveByNodeModuleValue, fp string) bool { return healthy(h, fp) }
// Failed says whether a have's health check failed once the node is done moving.
func Failed(h nodeengine.HaveByNodeModuleValue) bool { return failed(h) }
@@ -191,7 +188,10 @@ func Holds(m Module, node string, n Nodes) (bool, int, string) {
func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
switch {
case !m.Built:
return false, mwc.RuleBuildDone, fmt.Sprintf("the build of %s at %s is not done", m.Name, short(m.FP))
if m.BuildFailed {
return false, mwc.RuleBuildDone, fmt.Sprintf("the build of %s at %s failed: %s", m.Name, short(m.FP), m.BuildError)
}
return false, mwc.RuleBuildDone, fmt.Sprintf("the build of %s at %s is being built", m.Name, short(m.FP))
case m.Held:
return false, mwc.RuleNotHeld, fmt.Sprintf("%s is held at %s", m.Name, short(m.FP))
case m.MachineHeld != nil && m.MachineHeld(node):
@@ -202,7 +202,7 @@ func holds(m Module, node string, n Nodes, ordering bool) (bool, int, string) {
switch {
case canary == "":
return false, mwc.RuleCanary, fmt.Sprintf("none of the nodes of %s is online, so it has no canary", m.Name)
case node != canary && !healthy(n.Haves[canary][m.Name], m.FP):
case node != canary && !n.RunsHealthy(canary, m.Name, m.FP):
return false, mwc.RuleCanary, fmt.Sprintf("the canary %s does not run %s healthy yet", canary, short(m.FP))
}
}
@@ -322,15 +322,19 @@ func (m Module) MissingProvisions(node string) []string {
return out
}
// runsHealthy says whether module runs healthy on node: from G the node-engine seat's last `running` of it names a build and
// does not say it stopped; with the seat's events not heard, the have's phase and health.
// runsHealthy says whether module runs healthy on node: the node-engine seat's last `running` of it names a build, does not say it
// stopped and does not say `healthy` false (design 47 G §520); nothing heard of it counts as not healthy.
func (n Nodes) runsHealthy(node, module string) bool {
if n.Running != nil {
r, ok := n.Running[node][module]
return ok && !r.Stopped && r.Fingerprint != ""
}
h, ok := n.Haves[node][module]
return ok && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK
r, ok := n.Running[node][module]
return ok && !r.Stopped && r.Fingerprint != "" && (r.Healthy == nil || *r.Healthy)
}
// RunsHealthy says whether the build of module at fp runs healthy on node, from the node-engine seat's last `running` of it alone
// (design 47 G §520): a `running` with no `healthy`, said by a node-engine of before G, reads as healthy while it stands, and a
// module of which nothing was heard counts as not healthy. The have is never read for it.
func (n Nodes) RunsHealthy(node, module, fp string) bool {
r, ok := n.Running[node][module]
return ok && n.runsHealthy(node, module) && r.Fingerprint == fp
}
// providedHealthy is rule 5 for one provision: some module providing it runs healthy on the node, or, assigned
@@ -702,7 +706,7 @@ func healthyElsewhere(m Module, n Nodes, node string) []string {
for _, other := range slices.Sorted(slices.Values(m.Nodes)) {
// A witness is online: a node that sleeps keeps the have it last wrote, and a stale have is no evidence that F runs
// healthy now.
if other != node && n.Online[other] && healthy(n.Haves[other][m.Name], m.FP) {
if other != node && n.Online[other] && n.RunsHealthy(other, m.Name, m.FP) {
out = append(out, other)
}
}
@@ -750,7 +754,7 @@ func allRunHealthy(m Module, n Nodes) bool {
if !ok || (h.Phase != nodeengineclient.PhaseRunning && h.Phase != nodeengineclient.PhaseFailed) {
continue
}
if !healthy(h, m.FP) {
if !n.RunsHealthy(node, m.Name, m.FP) {
return false
}
ran++
@@ -60,7 +60,7 @@ func wantedOld() map[string]mwc.Want {
// g14 follows at once: step 0, ADR 0528 clause 3).
func TestEveryRuleHoldsSoTheWantsMove(t *testing.T) {
m, n := ready()
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
for _, node := range []string{"ace", "g14"} {
if out.Wants[node].Fp != fNew || out.Wants[node].Rule != 0 || out.Wants[node].Composed != dig(fNew) {
t.Errorf("%s's want = %+v, want %s", node, out.Wants[node], fNew)
@@ -89,7 +89,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
} {
m, n := ready()
breakIt(&m, &n)
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if w, ok := out.Wants["g14"]; ok && w.Fp == fNew {
t.Errorf("rule %d broken: g14's want moved to F", rule)
}
@@ -110,7 +110,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
}, nil} {
m, n := ready()
m.Provisions = all
if out := Wants(m, n, wantedOld(), nil); out.Wants["g14"].Fp != fNew {
if out := Wants(m, heardOfHaves(n), wantedOld(), nil); out.Wants["g14"].Fp != fNew {
t.Errorf("provided provisions held g14's want: %+v (%s)", out.Wants["g14"], out.Rules["g14"].Words)
}
}
@@ -161,7 +161,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
m, n := ready()
m.Name, m.Needs, n.Reads, m.FormNeeds = c.name, c.needs, c.reads, c.forms
n.Haves["ace"][c.name], n.Haves["g14"][c.name] = n.Haves["ace"]["mail"], n.Haves["g14"]["mail"]
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if moved := out.Wants["g14"].Fp == fNew; moved == c.held {
t.Errorf("%s: g14's want %+v, held by %+v", c.why, out.Wants["g14"], out.Rules["g14"])
}
@@ -208,7 +208,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
if c.holds == graph.BusState {
words = graph.BusStateWaitWords("mail", "g14")
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if c.holds == "" {
if out.Wants["g14"].Fp != fNew {
t.Errorf("%s: held %+v", c.name, out.Rules["g14"])
@@ -235,7 +235,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
return map[string]meshprovisionerclient.Provision{node + ".mail.oidc": {Provision: "oidc", State: c.state},
node + ".mail.database": {Provision: "database", State: c.older, Why: "the operator's answer"}}
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if got := out.Wants["g14"].Fp == fNew; got != c.moves || (!c.moves && (!strings.Contains(out.Rules["g14"].Words, "oidc") || strings.Contains(out.Rules["g14"].Words, "database"))) {
t.Errorf("%s: want %+v, held by %q", c.name, out.Wants["g14"], out.Rules["g14"].Words)
}
@@ -280,7 +280,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
m.Provisions = func(string) map[string]meshprovisionerclient.Provision {
return map[string]meshprovisionerclient.Provision{}
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if c.missing == "" {
if out.Wants["g14"].Fp != fNew || len(m.MissingProvisions("g14")) != 0 {
t.Errorf("%s: held %+v", c.name, out.Rules["g14"])
@@ -307,7 +307,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
m.Provisions = func(string) map[string]meshprovisionerclient.Provision {
return map[string]meshprovisionerclient.Provision{}
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if got := out.Wants["g14"].Fp == fNew; got != c.offered || (!c.offered && (out.Rules["g14"].Rule != mwc.RuleProvisions ||
!strings.Contains(out.Rules["g14"].Words, "no module offers database yet") || !strings.Contains(out.Rules["g14"].Words, "past its bound"))) {
t.Errorf("%s: want %+v, held by %+v", c.name, out.Wants["g14"], out.Rules["g14"])
@@ -342,7 +342,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
if c.have != nil {
n.Haves["novox"][c.module] = *c.have
}
out := Wants(m, n, c.before, nil)
out := Wants(m, heardOfHaves(n), c.before, nil)
w, written := out.Wants["novox"]
if !written {
w = c.before["novox"]
@@ -431,7 +431,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
if c.name == "placed" {
before["g14"] = mwc.Want{Fp: fNew, Composed: dig(fNew)}
}
out := Wants(m, n, before, nil)
out := Wants(m, heardOfHaves(n), before, nil)
if got := out.Wants["g14"]; !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: g14's want = %+v, want %+v", c.name, got, c.want)
}
@@ -453,7 +453,7 @@ func TestEachRuleHoldsTheWantBack(t *testing.T) {
m, n := ready()
grant := grantsstate.Grant{Accounts: []string{"ace.mail", "ace.mail.send"}}
m.GrantedOn = func(node string) bool { return GrantNamesNode(grant, node, "mail") }
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if w := out.Wants["ace"]; w.Fp != fNew {
t.Errorf("ace, whose account the grant names, wants %+v", w)
}
@@ -486,7 +486,7 @@ func TestAnOfflineCanaryIsPassedOver(t *testing.T) {
if c := Canary(m.Nodes, n.Online); c != "g14" {
t.Fatalf("canary = %q, want g14", c)
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
if out.Wants["g14"].Fp != fNew {
t.Errorf("the canary g14's want = %+v", out.Wants["g14"])
}
@@ -500,7 +500,7 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
m, n := ready()
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"}
m.Now = time.Date(2026, 10, 25, 1, 0, 0, 0, time.UTC)
out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, map[string]string{"ace": fOld})
// 1906/H3: the held record keeps its cause in words: which machine (its node), which health, when, and the node's words.
if out.Held == nil || out.Held.Node != "ace" || out.Held.Words != "health failed, phase running, seen at 2026-10-25T01:00:00Z: connection refused" {
t.Fatalf("held = %+v", out.Held)
@@ -516,7 +516,7 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
// back, the failing node keeps its want, and the outcome names it (on ace a newly assigned module's first check held a version healthy for an hour on three nodes).
n.Haves["g14"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
before := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fNew, Composed: dig(fNew)}}
out = Wants(m, n, before, map[string]string{"ace": fOld, "g14": fOld})
out = Wants(m, heardOfHaves(n), before, map[string]string{"ace": fOld, "g14": fOld})
if out.Held != nil || len(out.Wants) != 0 {
t.Fatalf("held %+v, wants %+v: want no hold and no want changed", out.Held, out.Wants)
}
@@ -534,7 +534,7 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
n.Online["novox"] = true
n.Haves["novox"] = map[string]nodeengine.HaveByNodeModuleValue{"mail": {Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}}
before["novox"] = mwc.Want{Fp: fOld, Composed: dig(fOld)}
out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
out = Wants(three, heardOfHaves(n), before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
if _, alone := out.NodeFailed["g14"]; !alone {
t.Errorf("g14 does not fail alone: %+v", out.NodeFailed)
}
@@ -547,7 +547,7 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
// A node that runs F healthy with no want, while g14 fails it alone, gets its want at F and is on no neither list (issue 1945).
n.Haves["novox"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
delete(before, "novox")
out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
out = Wants(three, heardOfHaves(n), before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
if w := out.Wants["novox"]; w.Fp != fNew || len(out.Neither) != 0 {
t.Errorf("novox running F with no want: want %+v, neither %+v; want its want at F and no neither", w, out.Neither)
}
@@ -569,7 +569,7 @@ func TestAFailedHealthHoldsAndSetsBack(t *testing.T) {
n.Haves["novox"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
}
before["novox"] = mwc.Want{Fp: fNew, Composed: dig(fNew)}
out = Wants(three, n, before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
out = Wants(three, heardOfHaves(n), before, map[string]string{"ace": fOld, "g14": fOld, "novox": fOld})
if out.Held == nil || out.Held.Node != "ace" || len(out.NodeFailed) != 0 {
t.Errorf("%s: held %+v, node failed %+v: want F held at ace", c.name, out.Held, out.NodeFailed)
}
@@ -584,7 +584,7 @@ func TestADataModuleIsNotSetBack(t *testing.T) {
m, n := ready()
m.Manifest.Data = &catalogue.Data{Own: []catalogue.DataItem{{ID: "mail", Class: catalogue.ClassIrreplaceable}}}
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed}
out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld})
if out.Held == nil {
t.Fatal("F was not held")
}
@@ -601,7 +601,7 @@ func TestADataModuleIsNotSetBack(t *testing.T) {
func TestAModuleWithoutDataIsNotAskedToRestore(t *testing.T) {
m, n := ready()
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed}
out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fNew}}, map[string]string{"ace": fOld})
if len(out.Restore) != 0 {
t.Errorf("restore asks = %v", out.Restore)
}
@@ -612,7 +612,7 @@ func TestAModuleWithoutDataIsNotAskedToRestore(t *testing.T) {
func TestALeavingNodeIsRemovedAndNothingIsWrittenTwice(t *testing.T) {
m, n := ready()
m.Nodes = []string{"ace"}
out := Wants(m, n, map[string]mwc.Want{"ace": {V: 1, Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, nil)
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {V: 1, Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fOld, Composed: dig(fOld)}}, nil)
if w := out.Wants["g14"]; !w.Removed {
t.Errorf("g14's want = %+v, want removed", w)
}
@@ -632,7 +632,7 @@ func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) {
}
return composedEverywhere(node, fp)
}
out := Wants(m, n, wantedOld(), nil)
out := Wants(m, heardOfHaves(n), wantedOld(), nil)
for node, w := range out.Wants {
if w.Fp == fNew {
t.Errorf("%s's want moved to F before its composition exists: %+v", node, w)
@@ -642,11 +642,11 @@ func TestAWantWaitsForItsCompositionAndNamesItsDigest(t *testing.T) {
t.Errorf("ace is held back by %+v, want rule 7 naming the node", r)
}
// and a node that never had a want gets none
if out := Wants(m, n, nil, nil); len(out.Wants) != 0 {
if out := Wants(m, heardOfHaves(n), nil, nil); len(out.Wants) != 0 {
t.Errorf("wants without a composition: %+v", out.Wants)
}
have["ace|"+fNew] = true
out = Wants(m, n, wantedOld(), nil)
out = Wants(m, heardOfHaves(n), wantedOld(), nil)
if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) {
t.Errorf("ace's want = %+v, want F naming the composition's digest", w)
}
@@ -664,14 +664,14 @@ func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) {
return ComposedDigest{Digest: revised, Judgement: JudgedNeither}, true
}
running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}, "g14": {Fp: fNew, Composed: dig(fNew)}}
out := Wants(m, n, running, nil)
out := Wants(m, heardOfHaves(n), running, nil)
for _, node := range []string{"ace", "g14"} {
if w := out.Wants[node]; w.Fp != fNew || w.Composed != revised {
t.Errorf("%s's want = %+v, want F naming the revised digest", node, w)
}
}
m.Granted = false
out = Wants(m, n, running, nil)
out = Wants(m, heardOfHaves(n), running, nil)
if w, ok := out.Wants["ace"]; ok && w.Composed == revised {
t.Errorf("a revision was written while a rule holds: %+v", w)
}
@@ -679,7 +679,7 @@ func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) {
// A want held back at an earlier fingerprint follows the new revision of that fingerprint's kept composition, which the
// controller composed again with today's config (novox/hq ADR 0545: on g14 a capacity of 0 after a rollback), while
// the rules still hold F; for a record module whose revision recreates a container, only after the ask.
out = Wants(m, n, wantedOld(), nil)
out = Wants(m, heardOfHaves(n), wantedOld(), nil)
if w := out.Wants["g14"]; w.Fp != fOld || w.Composed != revised || w.Newest != fNew {
t.Errorf("g14, held back at its earlier fingerprint, wants %+v; want it at that fingerprint naming the revised digest", w)
}
@@ -687,7 +687,7 @@ func TestARevisionAtTheSameFingerprintIsAWantByTheSameRules(t *testing.T) {
return ComposedDigest{Digest: revised, Judgement: JudgedRecreates, Policy: catalogue.PolicyRecord,
Against: dig(fOld), Words: "recreates"}, true
}
out = Wants(m, n, wantedOld(), nil)
out = Wants(m, heardOfHaves(n), wantedOld(), nil)
if w := out.Wants["g14"]; w.Composed == revised {
t.Errorf("a recorded module's revision that recreates a container was written with no ask: %+v", w)
}
@@ -707,12 +707,12 @@ func TestASetBackNamesTheOlderCompositionOrStays(t *testing.T) {
}
return composedEverywhere(node, fp)
}
out := Wants(m, n, running, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), running, map[string]string{"ace": fOld})
if w := out.Wants["ace"]; w.Fp == fOld {
t.Errorf("set back to a fingerprint whose composition is gone: %+v", w)
}
m.Composed = composedEverywhere
out = Wants(m, n, running, map[string]string{"ace": fOld})
out = Wants(m, heardOfHaves(n), running, map[string]string{"ace": fOld})
if w := out.Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) {
t.Errorf("set-back want = %+v", w)
}
@@ -741,7 +741,7 @@ func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T)
for _, j := range []Judgement{JudgedRecreates, JudgedMovesData, JudgedCannot} {
m, n, wanted := movingTo(j)
recording(&m)
out := Wants(m, n, wanted, nil)
out := Wants(m, heardOfHaves(n), wanted, nil)
if w, ok := out.Wants["ace"]; ok {
t.Errorf("%s: ace's want was written without the ask's yes: %+v", j, w)
}
@@ -756,11 +756,11 @@ func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T)
}
// a new revision at the same fingerprint is asked too
same := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fOld)}}
if out := Wants(m, n, same, nil); len(out.Record) == 0 || len(out.Wants) != 0 && out.Wants["ace"].Composed == dig(fNew) {
if out := Wants(m, heardOfHaves(n), same, nil); len(out.Record) == 0 || len(out.Wants) != 0 && out.Wants["ace"].Composed == dig(fNew) {
t.Errorf("%s: a new revision at the same fingerprint: %+v", j, out)
}
m.Allowed = func(node, digest string) bool { return node == "ace" && digest == dig(fNew) }
out = Wants(m, n, wanted, nil)
out = Wants(m, heardOfHaves(n), wanted, nil)
if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) {
t.Errorf("%s: after the yes ace's want = %+v", j, w)
}
@@ -772,25 +772,25 @@ func TestARecordModuleWaitsForItsAskWhenTheMoveIsNotJudgedNeither(t *testing.T)
func TestAMoveJudgedNeitherOrNeverRunOrNotRecordedIsWrittenAtOnce(t *testing.T) {
m, n, wanted := movingTo(JudgedNeither)
recording(&m)
if out := Wants(m, n, wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
if out := Wants(m, heardOfHaves(n), wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
t.Errorf("neither: %+v", out)
}
m, n, _ = movingTo(JudgedRecreates)
recording(&m)
m.Nodes = []string{"ace"} // g14 runs it with no want, so its first want is asked (1945/AC19): this case is ace's alone
delete(n.Haves["ace"], "mail")
if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
if out := Wants(m, heardOfHaves(n), nil, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
t.Errorf("a node that never ran it: %+v", out)
}
// but one that runs an older fingerprint with no want yet is asked like any other (one running F is not: N1 below)
m, n, _ = movingTo(JudgedCannot)
recording(&m)
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp == fNew || len(out.Record) == 0 {
if out := Wants(m, heardOfHaves(n), nil, nil); out.Wants["ace"].Fp == fNew || len(out.Record) == 0 {
t.Errorf("a node that runs it with no want: %+v", out)
}
m, n, wanted = movingTo(JudgedRecreates)
if out := Wants(m, n, wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
if out := Wants(m, heardOfHaves(n), wanted, nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
t.Errorf("a rolled module: %+v", out)
}
}
@@ -802,11 +802,11 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T
m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
// ADR 0601 §1, §2: the bus module's want is written by the rules, as any module's, and no snapshot is asked for it: its node
// backs its streams up before it applies.
if out := Wants(m, n, wantedOld(), nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 || len(out.Neither) != 0 {
if out := Wants(m, heardOfHaves(n), wantedOld(), nil); out.Wants["ace"].Fp != fNew || len(out.Record) != 0 || len(out.Neither) != 0 {
t.Errorf("the bus did not move by the rules: %+v", out)
}
running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fOld)}, "g14": {Fp: fNew, Composed: dig(fOld)}}
out := Wants(m, n, running, nil)
out := Wants(m, heardOfHaves(n), running, nil)
if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) {
t.Errorf("a user-list revision of the bus: ace's want = %+v", w)
}
@@ -835,7 +835,7 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T
if c.step == "" {
m.Puts = nil
}
out := Wants(m, n, c.wanted, nil)
out := Wants(m, heardOfHaves(n), c.wanted, nil)
got, wrote := out.Wants["ace"]
switch {
case c.want == nil && wrote:
@@ -852,7 +852,7 @@ func TestTheBusMovesByTheRulesAndItsUserListRevisionIsWrittenAtOnce(t *testing.T
held := put
held.Step = puts.Holding
m.Puts = map[string]puts.Put{"ace": held}
if w := Wants(m, n, map[string]mwc.Want{"ace": PutWant(held)}, nil).Wants["ace"]; w.Composed != userList || !w.Put || !w.Hold {
if w := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": PutWant(held)}, nil).Wants["ace"]; w.Composed != userList || !w.Put || !w.Hold {
t.Errorf("a held bus did not take its user-list revision as a put: %+v", w)
}
m.Puts = nil
@@ -983,14 +983,14 @@ func stepsOfAPut(t *testing.T) {
func TestTheBackupAnswerStaysInTheWantWhileItsTargetStays(t *testing.T) {
m, n := ready()
running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew), Backup: mwc.BackupSkip, Words: BackupAnswerWords("skip", 0)}}
if w, ok := Wants(m, n, running, nil).Wants["ace"]; ok {
if w, ok := Wants(m, heardOfHaves(n), running, nil).Wants["ace"]; ok {
t.Errorf("the answer was erased: %+v", w)
}
// A placed node composes its own: its want names the build alone, and the answer stays in it all the same (the digest its
// have names is not in the want, so a want kept for its digest would erase the answer at every pass).
m.Self = func(string) bool { return true }
placed := map[string]mwc.Want{"ace": {Fp: fNew, Self: true, Backup: mwc.BackupSkip, Words: BackupAnswerWords("skip", 0)}}
if w, ok := Wants(m, n, placed, nil).Wants["ace"]; ok {
if w, ok := Wants(m, heardOfHaves(n), placed, nil).Wants["ace"]; ok {
t.Errorf("the answer in a placed node's want was erased: %+v", w)
}
}
@@ -1001,7 +1001,7 @@ func TestAReleasedHoldIsNotHeldAgain(t *testing.T) {
m, n := ready()
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "x"}
m.Released = true
out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld), Newest: fNew, Rule: mwc.RuleNotHeld}}, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld), Newest: fNew, Rule: mwc.RuleNotHeld}}, map[string]string{"ace": fOld})
if out.Held != nil {
t.Errorf("a released fingerprint was held again: %+v", out.Held)
}
@@ -1018,11 +1018,11 @@ func TestARestoreAnswerSetsADataModuleBack(t *testing.T) {
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed}
running := map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}}
m.Restored = func(node string) bool { return false }
if w, ok := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; ok && w.Fp == fOld {
if w, ok := Wants(m, heardOfHaves(n), running, map[string]string{"ace": fOld}).Wants["ace"]; ok && w.Fp == fOld {
t.Errorf("set back without the operator's restore: %+v", w)
}
m.Restored = func(node string) bool { return node == "ace" }
if w := Wants(m, n, running, map[string]string{"ace": fOld}).Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) {
if w := Wants(m, heardOfHaves(n), running, map[string]string{"ace": fOld}).Wants["ace"]; w.Fp != fOld || w.Composed != dig(fOld) {
t.Errorf("after restore ace's want = %+v", w)
}
}
@@ -1040,22 +1040,22 @@ func TestTheRecordPolicyIsTheOneTheControllerHolds(t *testing.T) {
}
}
with(catalogue.PolicyRecord, "person") // a roll module the operator set to record
if out := Wants(m, n, wanted, nil); len(out.Record) == 0 || out.Wants["ace"].Fp == fNew {
if out := Wants(m, heardOfHaves(n), wanted, nil); len(out.Record) == 0 || out.Wants["ace"].Fp == fNew {
t.Errorf("the operator's record policy was not honoured: %+v", out)
}
recording(&m) // the manifest says record, the operator chose roll
with("roll", "person")
if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew {
if out := Wants(m, heardOfHaves(n), wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew {
t.Errorf("the operator's roll policy was not honoured: %+v", out)
}
with("", "") // made by hand: the manifest decides
if out := Wants(m, n, wanted, nil); len(out.Record) == 0 {
if out := Wants(m, heardOfHaves(n), wanted, nil); len(out.Record) == 0 {
t.Errorf("a hand-made composition fell through the manifest's record policy: %+v", out)
}
// 1953/D4 (ADR 0565 §7): a person's choice at the assigner is over the controller's beside the composition.
with(catalogue.PolicyRecord, "person")
m.Chosen = &catalogue.PersonPolicy{V: catalogue.PersonPolicyVersion, Module: m.Name, Policy: catalogue.PolicyRoll, Why: "w", By: "op"}
if out := Wants(m, n, wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew {
if out := Wants(m, heardOfHaves(n), wanted, nil); len(out.Record) != 0 || out.Wants["ace"].Fp != fNew {
t.Errorf("a person's roll at the assigner was not honoured over the controller's record: %+v", out)
}
}
@@ -1066,11 +1066,11 @@ func TestTheBusWithNoWantIsComparedWithWhatItRuns(t *testing.T) {
m, n, _ := movingTo(JudgedNeither)
m.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.Fp != fNew {
if w, ok := Wants(m, heardOfHaves(n), nil, nil).Wants["ace"]; !ok || w.Fp != fNew {
t.Errorf("the bus runs F with no want and was not given one: %+v %v", w, ok)
}
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
if w, ok := Wants(m, n, nil, nil).Wants["ace"]; !ok || w.Fp != fNew {
if w, ok := Wants(m, heardOfHaves(n), nil, nil).Wants["ace"]; !ok || w.Fp != fNew {
t.Errorf("the bus running an older fingerprint with no want was not given its want by the rules: %+v %v", w, ok)
}
}
@@ -1096,7 +1096,7 @@ func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *test
"a want at the fingerprint it runs": {"ace": {Fp: fOld, Composed: dig(fOld)}},
"no readable want": nil,
} {
out := Wants(m, n, wanted, nil)
out := Wants(m, heardOfHaves(n), wanted, nil)
if w := out.Wants["ace"]; w.Fp != fOld || w.Composed != userList {
t.Errorf("%s: ace's want = %+v, want the running fingerprint naming the user-list revision", name, w)
}
@@ -1106,7 +1106,7 @@ func TestAUserListOnlyRevisionOfTheBusIsWrittenAtOnceBeforeTheRecordHold(t *test
}
// the same flag on a module that is not the bus changes nothing
m.Manifest.Provides = nil
if out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld)}}, nil); out.Wants["ace"].Composed == userList {
if out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fOld, Composed: dig(fOld)}}, nil); out.Wants["ace"].Composed == userList {
t.Errorf("a module that is not the bus was written at once: %+v", out.Wants["ace"])
}
}
@@ -1129,7 +1129,7 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) {
}
m.Composed = unchanged
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK} // no composition named
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
if w := out.Wants["ace"]; w.Fp != fNew || w.Composed != dig(fNew) {
t.Errorf("%s: ace's want = %+v, want F naming its composition", name, w)
}
@@ -1146,7 +1146,7 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) {
m2.Manifest.Provides = []catalogue.Offer{{Name: "mesh-bus"}}
}
n2.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
out := Wants(m2, n2, nil, nil)
out := Wants(m2, heardOfHaves(n2), nil, nil)
if j == JudgedNeither {
if out.Wants["ace"].Fp != fNew || len(out.Record) != 0 {
t.Errorf("%s, %s: %+v", name, what, out)
@@ -1162,12 +1162,12 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) {
return ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Against: dig(fOld), Words: "judged"}, true
}
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fOld), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
if out := Wants(m, n, nil, nil); out.Wants["ace"].Fp == fNew && len(out.Record) == 0 {
if out := Wants(m, heardOfHaves(n), nil, nil); out.Wants["ace"].Fp == fNew && len(out.Record) == 0 {
t.Errorf("%s: a have naming a composition skipped the hold: %+v", name, out)
}
// a new fingerprint still asks (the bus is barred instead)
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
out = Wants(m, n, nil, nil)
out = Wants(m, heardOfHaves(n), nil, nil)
if w, ok := out.Wants["ace"]; ok {
t.Errorf("%s: a new fingerprint was written with no yes: %+v", name, w)
}
@@ -1181,7 +1181,7 @@ func TestAHaveFromBeforeTheSwitchGetsItsWantWithNoRecordHold(t *testing.T) {
func TestAHoldOfAFailureWithoutWordsSaysSo(t *testing.T) {
m, n := ready()
n.Haves["ace"]["mail"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed}
out := Wants(m, n, map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}}, map[string]string{"ace": fOld})
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"ace": {Fp: fNew, Composed: dig(fNew)}}, map[string]string{"ace": fOld})
if out.Held == nil || out.Held.Words != "health failed, phase running: the node gave no words" {
t.Fatalf("held = %+v", out.Held)
}
@@ -1198,7 +1198,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
return m, n
}
m, n := everywhere()
out := Wants(m, n, wantedOld(), map[string]string{"ace": fOld, "g14": fOld})
out := Wants(m, heardOfHaves(n), wantedOld(), map[string]string{"ace": fOld, "g14": fOld})
if !out.Lifted || out.Held != nil {
t.Fatalf("lifted = %v, held = %+v: want the hold lifted", out.Lifted, out.Held)
}
@@ -1210,7 +1210,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
m, n = everywhere()
delete(n.Haves["g14"], "mail")
if out := Wants(m, n, wantedOld(), nil); !out.Lifted {
if out := Wants(m, heardOfHaves(n), wantedOld(), nil); !out.Lifted {
t.Error("a node that does not run the module kept the hold")
}
@@ -1227,7 +1227,7 @@ func TestAHoldIsLiftedOnceEveryNodeRunsItHealthy(t *testing.T) {
} {
m, n := everywhere()
change(&m, &n)
if out := Wants(m, n, wantedOld(), map[string]string{"ace": fOld, "g14": fOld}); out.Lifted {
if out := Wants(m, heardOfHaves(n), wantedOld(), map[string]string{"ace": fOld, "g14": fOld}); out.Lifted {
t.Errorf("%s: the hold was lifted", name)
}
}
@@ -1258,7 +1258,7 @@ func x11() (Module, Nodes) {
// composition, never the newest, and says the newest it is held back from; the canary is never passed over.
func TestANodeRunningAModuleWithNoWantGetsItsFirstWantAtWhatItRuns(t *testing.T) {
m, n := x11()
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
// 1960: the providers rule holds no want: the canary g14 gets F, its provider's health unknown said
if w := out.Wants["g14"]; w.Fp != fNew || out.Unreached["g14"] == "" {
t.Errorf("g14's want = %+v (unreached %q), want F, the provider said", w, out.Unreached["g14"])
@@ -1273,13 +1273,13 @@ func TestANodeRunningAModuleWithNoWantGetsItsFirstWantAtWhatItRuns(t *testing.T)
m, n = x11()
n.Haves["g14"]["xorg"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf('x'), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
n.Haves["shanks"]["xorg"] = n.Haves["g14"]["xorg"]
if w := Wants(m, n, nil, nil).Wants["shanks"]; w.Fp != fOld || w.Rule != mwc.RuleCanary {
if w := Wants(m, heardOfHaves(n), nil, nil).Wants["shanks"]; w.Fp != fOld || w.Rule != mwc.RuleCanary {
t.Errorf("shanks held back by the canary with no want: %+v, want at fOld by rule 3", w)
}
// a node already running F gets its want at F; a removed want is no want either
m, n = x11()
n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}
out = Wants(m, n, map[string]mwc.Want{"g14": {Removed: true}}, nil)
out = Wants(m, heardOfHaves(n), map[string]mwc.Want{"g14": {Removed: true}}, nil)
if w := out.Wants["g14"]; w.Fp != fNew || w.Composed != dig(fNew) || w.Removed {
t.Errorf("g14 running F with a removed want = %+v, want F", w)
}
@@ -1301,7 +1301,7 @@ func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) {
} {
m, n := x11()
change(&m, &n)
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
if what == "an old-form build" {
// the canary g14 does not run F healthy: no want, on neither list by rule 3 (issue 1945)
if w, ok := out.Wants["shanks"]; ok {
@@ -1322,7 +1322,7 @@ func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) {
}
m, n := x11()
before := map[string]mwc.Want{"g14": {Fp: fOld, Composed: dig(fOld)}, "shanks": {Fp: fOld, Composed: dig(fOld)}}
out := Wants(m, n, before, nil)
out := Wants(m, heardOfHaves(n), before, nil)
if len(out.Neither) != 0 {
t.Errorf("nodes holding a want named on neither list: %+v", out.Neither)
}
@@ -1341,7 +1341,7 @@ func TestNoFirstWantWithoutTheCompositionAndGrantOfWhatItRuns(t *testing.T) {
func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) {
m, n := x11()
delete(n.Haves["shanks"], "screen-lock")
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
// 1960: the canary orders moves, never a first want: the new assignment is wanted at F
if w := out.Wants["shanks"]; w.Fp != fNew {
t.Errorf("a new assignment while the canary does not run F: %+v, want F", w)
@@ -1350,7 +1350,7 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) {
t.Errorf("a new assignment is named on neither list: %+v", out.Neither)
}
m.Built = false
if out := Wants(m, n, nil, nil); len(out.Neither) != 1 || out.Neither["g14"].Rule != mwc.RuleBuildDone {
if out := Wants(m, heardOfHaves(n), nil, nil); len(out.Neither) != 1 || out.Neither["g14"].Rule != mwc.RuleBuildDone {
t.Errorf("with no build: neither = %+v, want g14 alone (it runs the module), by rule 1", out.Neither)
}
// 1945 (the assigner holds a first want back silently): with no build, a node whose want was removed and that does not run
@@ -1364,12 +1364,12 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) {
}{
{"no key: nothing written", nil, map[string]mwc.Want{}},
{"a removed want: it says why", map[string]mwc.Want{"shanks": gone},
map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}}},
{"a removed want that says why already: nothing written", map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is not done"}},
map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is being built"}}},
{"a removed want that says why already: nothing written", map[string]mwc.Want{"shanks": {Removed: true, Words: "no want: rule 1: the build of screen-lock at " + short(m.FP) + " is being built"}},
map[string]mwc.Want{}},
{"a node that runs it: on neither list, its removed want left", map[string]mwc.Want{"g14": gone}, map[string]mwc.Want{}},
} {
out := Wants(m, n, c.before, nil)
out := Wants(m, heardOfHaves(n), c.before, nil)
if len(out.Wants) != len(c.want) {
t.Errorf("%s: wants = %+v, want %+v", c.name, out.Wants, c.want)
continue
@@ -1381,7 +1381,7 @@ func TestANewAssignmentWaitingIsNotOnNeitherList(t *testing.T) {
}
}
m.FP = ""
if out := Wants(m, n, nil, nil); len(out.Neither) != 0 {
if out := Wants(m, heardOfHaves(n), nil, nil); len(out.Neither) != 0 {
t.Errorf("a module of the node's own composition named on neither list: %+v", out.Neither)
}
}
@@ -1404,7 +1404,7 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) {
providersHealthy(n)
oldForm(n, "shanks")
n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
if w := out.Wants["shanks"]; w.Fp != fNew || w.Composed != dig(fNew) || w.Newest != "" || w.Rule != 0 {
t.Errorf("canary healthy on F: shanks's want = %+v, want F", w)
}
@@ -1416,7 +1416,7 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) {
providersHealthy(n)
oldForm(n, "shanks")
n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthUnknown}
out = Wants(m, n, nil, nil)
out = Wants(m, heardOfHaves(n), nil, nil)
if w, ok := out.Wants["shanks"]; ok {
t.Errorf("canary not healthy on F: shanks got a want: %+v", w)
}
@@ -1427,7 +1427,7 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) {
m, n = x11()
oldForm(n, "shanks")
n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
out = Wants(m, n, nil, nil)
out = Wants(m, heardOfHaves(n), nil, nil)
if w := out.Wants["shanks"]; w.Fp != fNew || out.Unreached["shanks"] == "" {
t.Errorf("providers not healthy: shanks's want = %+v (unreached %q), want F, the provider said", w, out.Unreached["shanks"])
}
@@ -1440,14 +1440,14 @@ func TestAnOldFormNodeGetsItsFirstWantAtFOnlyUnderTheCanary(t *testing.T) {
oldForm(n, "shanks")
n.Haves["g14"]["screen-lock"] = nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
m.Granted = false
if w, ok := Wants(m, n, nil, nil).Wants["shanks"]; ok {
if w, ok := Wants(m, heardOfHaves(n), nil, nil).Wants["shanks"]; ok {
t.Errorf("F not granted: shanks got a want: %+v", w)
}
// the old-form node is the canary itself: F once its providers run healthy
m, n = x11()
providersHealthy(n)
oldForm(n, "g14")
if w := Wants(m, n, nil, nil).Wants["g14"]; w.Fp != fNew || w.Composed != dig(fNew) {
if w := Wants(m, heardOfHaves(n), nil, nil).Wants["g14"]; w.Fp != fNew || w.Composed != dig(fNew) {
t.Errorf("old-form canary: g14's want = %+v, want F", w)
}
}
@@ -1462,7 +1462,7 @@ func TestAFailedHealthWhileTheNodeMovesHoldsNothing(t *testing.T) {
n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
"novox": {"mesh-controller": {Fp: fNew, Phase: phase, Health: nodeengineclient.HealthFailed}}}}
before := map[string]mwc.Want{"novox": {Fp: fNew, Composed: dig(fNew)}}
if out := Wants(m, n, before, map[string]string{"novox": fOld}); out.Held != nil {
if out := Wants(m, heardOfHaves(n), before, map[string]string{"novox": fOld}); out.Held != nil {
t.Errorf("phase %s, health failed: held %+v, want no hold while the node moves", phase, out.Held)
}
}
@@ -1472,7 +1472,7 @@ func TestAFailedHealthWhileTheNodeMovesHoldsNothing(t *testing.T) {
n := Nodes{Online: map[string]bool{"novox": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
"novox": {"mesh-controller": {Fp: fNew, Phase: phase, Health: nodeengineclient.HealthFailed}}}}
before := map[string]mwc.Want{"novox": {Fp: fNew, Composed: dig(fNew)}}
if out := Wants(m, n, before, map[string]string{"novox": fOld}); out.Held == nil || out.Held.Node != "novox" {
if out := Wants(m, heardOfHaves(n), before, map[string]string{"novox": fOld}); out.Held == nil || out.Held.Node != "novox" {
t.Errorf("phase %s, health failed: held %+v, want a hold on novox", phase, out.Held)
}
}
@@ -1491,7 +1491,7 @@ func TestAHoldIsLiftedOverHavesThatAreNotRunning(t *testing.T) {
}
for _, phase := range []string{nodeengineclient.PhaseRemoved, nodeengineclient.PhaseHeld, nodeengineclient.PhaseApplying, nodeengineclient.PhaseBackingUp, nodeengineclient.PhaseChecking} {
m, n := setup(phase, fOld, nodeengineclient.HealthUnknown)
if out := Wants(m, n, nil, nil); !out.Lifted {
if out := Wants(m, heardOfHaves(n), nil, nil); !out.Lifted {
t.Errorf("g14's have in phase %s kept the hold up", phase)
}
}
@@ -1500,14 +1500,14 @@ func TestAHoldIsLiftedOverHavesThatAreNotRunning(t *testing.T) {
"failed": {Fp: fNew, Phase: nodeengineclient.PhaseFailed, Health: nodeengineclient.HealthFailed},
} {
m, n := setup(h.Phase, h.Fp, h.Health)
if out := Wants(m, n, nil, nil); out.Lifted {
if out := Wants(m, heardOfHaves(n), nil, nil); out.Lifted {
t.Errorf("g14 %s: the hold was lifted", what)
}
}
// a failed health on a removed have holds nothing
m, n := setup(nodeengineclient.PhaseRemoved, fNew, nodeengineclient.HealthFailed)
m.Held = false
if out := Wants(m, n, nil, nil); out.Held != nil {
if out := Wants(m, heardOfHaves(n), nil, nil); out.Held != nil {
t.Errorf("a removed have with a failed health held F: %+v", out.Held)
}
}
@@ -1520,7 +1520,7 @@ func TestAFirstWantAfterARemovedWantGoesThroughTheRecordAsk(t *testing.T) {
m.Composed = func(_, fp string) (ComposedDigest, bool) {
return ComposedDigest{Digest: dig(fp), Judgement: JudgedRecreates, Policy: catalogue.PolicyRecord}, true
}
out := Wants(m, n, map[string]mwc.Want{"shanks": {Removed: true}}, nil)
out := Wants(m, heardOfHaves(n), map[string]mwc.Want{"shanks": {Removed: true}}, nil)
if w, ok := out.Wants["shanks"]; ok {
t.Errorf("shanks's first want after a removed want was written with no record ask: %+v", w)
}
@@ -1550,7 +1550,7 @@ func TestAHeldModuleWithNoWantIsNotOnNeitherList(t *testing.T) {
} {
m, n := x11()
change(&m, &n)
out := Wants(m, n, nil, nil)
out := Wants(m, heardOfHaves(n), nil, nil)
if len(out.Neither) != 0 {
t.Errorf("%s: a held module is on neither list: %+v", what, out.Neither)
}
@@ -1602,9 +1602,79 @@ func TestAWantNamesAKeptBuildsPartsAndNodeForANodeThatReadsThem(t *testing.T) {
m, n := ready()
n.Reads = map[string][]string{"ace": row.reads, "g14": row.reads}
m.KeptBuilds = row.kept
w := Wants(m, n, wantedOld(), nil).Wants["ace"]
w := Wants(m, heardOfHaves(n), wantedOld(), nil).Wants["ace"]
if w.Fp != fNew || (w.KeptOn == "g14") != row.wanted || (w.Version == "1.2.3") != row.wanted || (len(w.Parts) == 1) != row.wanted {
t.Errorf("%s: ace's want = %+v", name, w)
}
}
}
// heardOfHaves gives each have that runs the `running` the node-engine seat says of it (design 47 G §520: health is read from
// `running` alone), for a fixture written as haves; a fixture that names its own `running` is left as it is.
func heardOfHaves(n Nodes) Nodes {
if n.Running != nil {
return n
}
n.Running = map[string]map[string]nodeengine.Running{}
for node, byModule := range n.Haves {
n.Running[node] = map[string]nodeengine.Running{}
for module, h := range byModule {
if h.Phase == nodeengineclient.PhaseRunning && h.Fp != "" {
ok := h.Health == nodeengineclient.HealthOK
n.Running[node][module] = nodeengine.Running{Node: node, Module: module, Fingerprint: h.Fp, Healthy: &ok}
}
}
}
return n
}
// §520: health is read from the node-engine seat's `running` alone, whatever the have says, and a build of which nothing was heard
// is not healthy.
func TestHealthIsReadFromRunningAlone(t *testing.T) {
yes, no := true, false
say := func(node, module, fp string, healthy *bool) nodeengine.Running {
return nodeengine.Running{Node: node, Module: module, Fingerprint: fp, Healthy: healthy}
}
for _, c := range []struct {
name string
running map[string]map[string]nodeengine.Running
rule int
}{
{"all healthy by running", map[string]map[string]nodeengine.Running{"ace": {"mail": say("ace", "mail", fNew, &yes), "store": say("ace", "store", fpOf('s'), &yes)}}, 0},
{"a running with no healthy reads healthy", map[string]map[string]nodeengine.Running{"ace": {"mail": say("ace", "mail", fNew, nil), "store": say("ace", "store", fpOf('s'), nil)}}, 0},
{"the canary says unhealthy, its have says healthy", map[string]map[string]nodeengine.Running{"ace": {"mail": say("ace", "mail", fNew, &no), "store": say("ace", "store", fpOf('s'), &yes)}}, mwc.RuleCanary},
{"nothing heard of the canary", map[string]map[string]nodeengine.Running{"ace": {"store": say("ace", "store", fpOf('s'), &yes)}}, mwc.RuleCanary},
} {
m, n := ready()
n.Running = c.running
if got := Wants(m, n, wantedOld(), nil).Rules["g14"].Rule; got != c.rule {
t.Errorf("%s: g14 held by rule %d, want %d", c.name, got, c.rule)
}
}
// rule 5: a provider that says unhealthy, or that nothing was heard of, holds no want but leaves the consumer unreached
m, n := ready()
n.Running = map[string]map[string]nodeengine.Running{"ace": {"mail": say("ace", "mail", fNew, &yes), "store": say("ace", "store", fpOf('s'), &no)}}
if out := Wants(m, n, wantedOld(), nil); len(out.Unreached) == 0 {
t.Errorf("a provider saying unhealthy: no module unreached, %+v", out)
}
}
// §575: a hold under rule 1 says how the build stands: failed with the error whole, or being built.
func TestRuleOneSaysHowTheBuildStands(t *testing.T) {
for _, c := range []struct {
name string
failed bool
err string
want string
}{
{"a build that runs", false, "", "the build of mail at " + short(fNew) + " is being built"},
{"a failed build", true, "transport fault: no responders at mesh-build-go", "the build of mail at " + short(fNew) + " failed: transport fault: no responders at mesh-build-go"},
} {
m, n := ready()
m.Built, m.BuildFailed, m.BuildError = false, c.failed, c.err
held, rule, words := Holds(m, "g14", n)
if held || rule != mwc.RuleBuildDone || words != c.want {
t.Errorf("%s: held %v rule %d words %q, want %q", c.name, held, rule, words, c.want)
}
}
}
@@ -0,0 +1,138 @@
package assigngraph
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// A candidate's assignment graph (design 47 G §189, §190, A-201, A-235): computed per run on request from the candidate's catalog
// graph revision, kept apart from trunk's under the seat's key form candidate.<group>.<heads>, the group `run-<id>`, and dropped on
// the runner's group-ended of that run; it writes no want, and the gate's heads-replaced drops nothing.
// CandidateObject is the object name of a candidate's revision (the seat's key form candidate.<group>.<heads>).
func CandidateObject(group, heads string) string {
return meshwants.AssignmentRevisionsByCandidateGroupHeadsKey(group, heads)
}
// KeepCandidate keeps c as the candidate's revision of group and heads, numbered after the one kept of it, unless that one lists
// the same entries from the same inputs; its entry objects are written first, an entry trunk shares stored once.
func KeepCandidate(ctx context.Context, o Objects, c Computed, group, heads, cause string, at time.Time) (meshwants.AssignmentRevision, bool, error) {
if group == "" || heads == "" {
return meshwants.AssignmentRevision{}, false, fmt.Errorf("a candidate's revision names its group and its heads, not %q and %q", group, heads)
}
was, err := readCandidateObject(ctx, o, CandidateObject(group, heads))
has := err == nil
if err != nil && !errors.Is(err, ErrNoObject) {
return was, false, err
}
if has && Same(was, c.Revision) {
return was, false, nil
}
rev := c.Revision
rev.Kind, rev.Group, rev.Heads, rev.Cause = meshcatalogclient.KindCandidate, group, heads, cause
rev.Revision = was.Revision + 1
t := at.UTC()
rev.At = &t
if err := writeEntries(ctx, o, c, rev); err != nil {
return was, false, err
}
list, err := json.Marshal(rev)
if err != nil {
return was, false, err
}
if err := o.Put(ctx, CandidateObject(group, heads), list); err != nil {
return was, false, fmt.Errorf("the candidate %s's revision in %s: %w", group, RevisionsObjects, err)
}
return rev, true, nil
}
// ReadCandidate is the candidate's revision kept of group, of heads when named, else the newest kept of the group; ErrNoCandidate
// when none is kept.
func ReadCandidate(ctx context.Context, o Objects, group, heads string) (meshwants.AssignmentRevision, error) {
if heads != "" {
rev, err := readCandidateObject(ctx, o, CandidateObject(group, heads))
if errors.Is(err, ErrNoObject) {
err = fmt.Errorf("%w (group %q, heads %q)", ErrNoCandidate, group, heads)
}
return rev, err
}
names, err := CandidateNames(ctx, o, group)
if err != nil {
return meshwants.AssignmentRevision{}, err
}
var newest meshwants.AssignmentRevision
found := false
for _, n := range names {
rev, err := readCandidateObject(ctx, o, n)
if err != nil {
return rev, err
}
if !found || rev.At != nil && newest.At != nil && rev.At.After(*newest.At) {
newest, found = rev, true
}
}
if !found {
return newest, fmt.Errorf("%w (group %q)", ErrNoCandidate, group)
}
return newest, nil
}
// CandidateNames is the object name of every revision kept of the candidate group; every candidate's when group is "".
func CandidateNames(ctx context.Context, o Objects, group string) ([]string, error) {
names, err := o.Names(ctx)
if err != nil {
return nil, fmt.Errorf("the objects %s: %w", RevisionsObjects, err)
}
var out []string
for _, n := range names {
g, _, ok := meshwants.ParseAssignmentRevisionsByCandidateGroupHeadsKey(n)
if ok && (group == "" || g == group) {
out = append(out, n)
}
}
return out, nil
}
// Remover is an object bucket that also removes an object.
type Remover interface {
Objects
// Delete removes the object of that name; one not held is no error.
Delete(ctx context.Context, name string) error
}
// DropCandidate removes every revision kept of the candidate group (its run's group-ended, A-235); the entries it alone listed are
// removed with the next revisions nothing pins. It says how many it removed.
func DropCandidate(ctx context.Context, o Remover, group string) (int, error) {
if strings.TrimSpace(group) == "" {
return 0, errors.New("a candidate to drop names its group")
}
names, err := CandidateNames(ctx, o, group)
if err != nil {
return 0, err
}
for i, n := range names {
if err := o.Delete(ctx, n); err != nil {
return i, fmt.Errorf("the candidate %s's revision %s: %w", group, n, err)
}
}
return len(names), nil
}
func readCandidateObject(ctx context.Context, o Objects, name string) (meshwants.AssignmentRevision, error) {
var rev meshwants.AssignmentRevision
data, err := o.Get(ctx, name)
if err != nil {
return rev, err
}
if err := json.Unmarshal(data, &rev); err != nil {
return rev, fmt.Errorf("the candidate's revision %s: %w", name, err)
}
return rev, nil
}
@@ -58,13 +58,16 @@ type placedAt struct {
entry meshwants.AssignmentEntry
langs map[string]bool
acts []actingRule
// provides is what its catalog entry provides; open its requirements its placement neither binds nor its node pins.
provides, open []string
}
type actingRule struct{ seat, step, on, bound string }
// Compute is the assignment graph revision of in: one entry per module placed on a node whose branch's catalog graph
// holds it, its version the build the placement names or else the catalog entry's fingerprint (design 47 lines 909-914),
// with the edges of provisioning to the providers its placement binds or its node pins, and of acting from each acting
// with the edges of provisioning to the providers its placement binds or its node pins, else the provider on its node or
// the only provider (an entry held naming the candidates where none is), and of acting from each acting
// part's version to each version its rule names (design 47 *An acting part's new version acts first*: "the assigner
// gives an edge from the acting part's version to each version its rule names").
func Compute(in Inputs) Computed {
@@ -115,6 +118,7 @@ func Compute(in Inputs) Computed {
for _, r := range ce.Acts {
p.acts = append(p.acts, actingRule{seat: r.Seat, step: r.Step, on: r.On, bound: r.Bound})
}
p.provides = ce.Provides
for _, r := range ce.Requires {
prov, ok := m.Bindings[r]
if !ok {
@@ -122,12 +126,42 @@ func Compute(in Inputs) Computed {
}
if ok {
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: prov.Node, Module: prov.Module, Provision: r})
} else {
p.open = append(p.open, r)
}
}
placed = append(placed, p)
branches[branch] = true
}
}
// A requirement neither bound nor pinned points, by design 27's order (design 47 G §357), to the provider on the
// consumer's node, else to the only provider; otherwise the entry is held, naming the candidates, and nothing is guessed.
for _, p := range placed {
for _, r := range p.open {
var here, all []*placedAt
for _, q := range placed {
if q != p && slices.Contains(q.provides, r) {
all = append(all, q)
if q.node == p.node {
here = append(here, q)
}
}
}
switch {
case len(here) == 1:
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: here[0].node, Module: here[0].placed.Module, Provision: r})
case len(here) == 0 && len(all) == 1:
p.entry.Edges = append(p.entry.Edges, meshwants.Edge{Kind: EdgeProvisioning, Node: all[0].node, Module: all[0].placed.Module, Provision: r})
default:
h := meshwants.Held{Provision: r}
for _, q := range all {
h.Candidates = append(h.Candidates, meshwants.Edge{Kind: EdgeProvisioning, Node: q.node, Module: q.placed.Module, Fp: q.entry.Fp, Provision: r})
}
sortEdges(h.Candidates)
p.entry.Held = append(p.entry.Held, h)
}
}
}
at := map[string]*placedAt{}
for _, p := range placed {
at[p.node+"/"+p.placed.Module] = p
@@ -91,11 +91,16 @@ func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) {
if fmt.Sprint(webA.Edges) != fmt.Sprint(want) {
t.Errorf("web on anchor's edges:\n got %+v\nwant %+v", webA.Edges, want)
}
// laptop's web: its own node's node-engine composes it, the build module placed once builds it, and no binding or pin
// names a provider there.
if e := webL.Edges; len(e) != 2 || e[0].Module != "build-go" || e[0].Node != "anchor" || e[1].Module != "node-engine" || e[1].Node != "laptop" {
// laptop's web: its own node's node-engine composes it, the build module placed once builds it, and with no binding or
// pin its database is the only provider's (design 47 G §357).
if e := webL.Edges; len(e) != 3 || e[0].Module != "build-go" || e[0].Node != "anchor" || e[1].Module != "node-engine" || e[1].Node != "laptop" ||
e[2] != (meshwants.Edge{Kind: EdgeProvisioning, Node: "anchor", Module: "postgres", Fp: "src2:p1", Provision: "database"}) {
t.Errorf("web on laptop's edges: %+v", e)
}
// Its secrets no placed module provides: held on both nodes, naming no candidate.
if h := webA.Held; len(h) != 1 || h[0].Provision != "secrets" || len(h[0].Candidates) != 0 {
t.Errorf("web on anchor is held on %+v", h)
}
if e := byNode["anchor/postgres"].Edges; len(e) != 1 || e[0].Module != "node-engine" {
t.Errorf("postgres builds no Go, so only the node-engine acts on it: %+v", e)
}
@@ -149,6 +154,63 @@ func TestTheAssignmentGraphIsComputedFromItsInputsAndKept(t *testing.T) {
}
}
// Design 47 G §357 and A-167: a requirement neither bound nor pinned points to the provider on the consumer's node, else
// to the only provider; otherwise the entry is held, naming the candidates, and nothing is guessed.
func TestARequirementPointsByTheOrderOrIsHeld(t *testing.T) {
h := half("main", 3,
meshcatalog.CatalogEntry{Module: "kv", Fp: "src2:k1", Provides: []string{"cache"}},
meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1", Requires: []string{"cache"}})
for _, c := range []struct {
name string
kv []string // the nodes kv is placed on
pin bool // web's node pins kv on laptop
edge string // the node of the provisioning edge, "" for none
held string // the candidates' nodes of the hold, "-" for no hold
}{
{name: "the provider on the consumer's node", kv: []string{"anchor", "laptop"}, edge: "anchor", held: "-"},
{name: "the only provider", kv: []string{"laptop"}, edge: "laptop", held: "-"},
{name: "a pin before the node's own", kv: []string{"anchor", "laptop"}, pin: true, edge: "laptop", held: "-"},
{name: "two providers elsewhere: held", kv: []string{"laptop", "server"}, held: "laptop,server"},
{name: "none: held naming none", held: ""},
} {
t.Run(c.name, func(t *testing.T) {
pl := map[string]mma.Assignments{"anchor": {Node: "anchor", Modules: []mma.PlacedModule{{Module: "web"}}}}
if c.pin {
pl["anchor"] = mma.Assignments{Node: "anchor", Modules: pl["anchor"].Modules, Pins: map[string]mma.Provider{"cache": {Node: "laptop", Module: "kv"}}}
}
for _, n := range c.kv {
a := pl[n]
a.Node, a.Modules = n, append(a.Modules, mma.PlacedModule{Module: "kv"})
pl[n] = a
}
out := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": h}, Placements: pl, FirstBranch: func(string) string { return "main" }})
var web meshwants.AssignmentEntry
for _, ref := range out.Revision.Entries {
if ref.Node == "anchor" && ref.Module == "web" {
web = out.Entries[ref.Digest]
}
}
edge := ""
for _, e := range web.Edges {
if e.Kind == EdgeProvisioning && e.Provision == "cache" {
edge = e.Node
}
}
held := "-"
if len(web.Held) == 1 && web.Held[0].Provision == "cache" {
var nodes []string
for _, cand := range web.Held[0].Candidates {
nodes = append(nodes, cand.Node)
}
held = strings.Join(nodes, ",")
}
if edge != c.edge || held != c.held || len(web.Held) > 1 {
t.Errorf("web points to %q, held on %q (%+v); want %q, %q", edge, held, web.Held, c.edge, c.held)
}
})
}
}
// Design 47 line 1385: a whole assignment graph revision of about 120 modules on four nodes is under the bus's 256 KiB
// value limit.
func TestARevisionOf120ModulesOnFourNodesFitsOneValue(t *testing.T) {
@@ -174,3 +236,77 @@ func TestARevisionOf120ModulesOnFourNodesFitsOneValue(t *testing.T) {
t.Errorf("%d entries in %d bytes", len(c.Revision.Entries), len(raw))
}
}
func (m memObjects) Delete(_ context.Context, name string) error {
delete(m, name)
return nil
}
// A-201, A-235, §186, §189: a run's candidate assignment graph is kept apart from trunk's under candidate.<group>.<heads>, served by
// revision, entry and difference against trunk, and dropped by its group alone.
func TestARunsCandidateGraphIsKeptApartServedAndDropped(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
placed := map[string]mma.Assignments{"ace": {Modules: []mma.PlacedModule{{Module: "web"}}}}
first := func(string) string { return "main" }
o := memObjects{}
trunk := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 3, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w1"})},
Placements: placed, FirstBranch: first})
if _, _, err := Keep(ctx, o, trunk, "catalog-revision", at); err != nil {
t.Fatal(err)
}
cand := Compute(Inputs{Halves: map[string]meshcatalogclient.Half{"main": half("main", 4, meshcatalog.CatalogEntry{Module: "web", Fp: "src2:w2"})},
Placements: placed, FirstBranch: first})
if _, _, err := KeepCandidate(ctx, o, cand, "", "h1", "catalog-revision", at); err == nil {
t.Error("a candidate naming no group was kept")
}
rev, fresh, err := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at)
if err != nil || !fresh || rev.Kind != meshcatalogclient.KindCandidate || rev.Group != "run-7" || rev.Revision != 1 {
t.Fatalf("kept %+v fresh %v: %v", rev, fresh, err)
}
if _, ok := o[CandidateObject("run-7", "h1")]; !ok {
t.Fatalf("the candidate is not under candidate.run-7.h1: %v", o)
}
if _, fresh, _ := KeepCandidate(ctx, o, cand, "run-7", "h1", "catalog-revision", at); fresh {
t.Error("the same candidate kept again is a new revision")
}
newest, has, err := Newest(ctx, o)
if err != nil || !has || newest.Kind != meshcatalogclient.KindTrunk || newest.Revision != 1 {
t.Errorf("trunk's newest is %+v (%v, %v): the candidate is not kept apart", newest, has, err)
}
for _, in := range []meshwants.RevisionInput{{Group: "run-7"}, {Group: "run-7", Heads: "h1"}} {
a, err := Revision(ctx, o, in)
if err != nil || a.Revision.Group != "run-7" {
t.Errorf("revision %+v answered %+v: %v", in, a.Revision, err)
}
}
if _, err := Revision(ctx, o, meshwants.RevisionInput{Group: "run-8"}); !errors.Is(err, ErrNoCandidate) {
t.Errorf("an unkept candidate answered %v", err)
}
e, err := Entry(ctx, o, meshwants.EntryInput{Group: "run-7", Node: "ace", Module: "web"})
if err != nil || e.Entry.Fp != "src2:w2" {
t.Errorf("the candidate's entry is %+v: %v", e.Entry, err)
}
d, err := Difference(ctx, o, meshwants.DifferenceInput{Group: "run-7"})
if err != nil || len(d.Changes) != 1 || d.Changes[0].From != "src2:w1" || d.Changes[0].To != "src2:w2" {
t.Errorf("the candidate against trunk is %+v: %v", d, err)
}
if _, err := Difference(ctx, o, meshwants.DifferenceInput{}); err == nil {
t.Error("difference with none of from, to and group is not refused")
}
if _, _, err := KeepCandidate(ctx, o, cand, "run-9", "h2", "catalog-revision", at); err != nil {
t.Fatal(err)
}
if n, err := DropCandidate(ctx, o, "run-7"); err != nil || n != 1 {
t.Errorf("dropped %d: %v", n, err)
}
if _, err := ReadCandidate(ctx, o, "run-7", ""); !errors.Is(err, ErrNoCandidate) {
t.Errorf("the dropped candidate reads: %v", err)
}
if _, err := ReadCandidate(ctx, o, "run-9", ""); err != nil {
t.Errorf("another run's candidate went with it: %v", err)
}
if _, has, _ := Newest(ctx, o); !has {
t.Error("dropping a candidate dropped trunk")
}
}
@@ -103,36 +103,13 @@ func Keep(ctx context.Context, o Objects, c Computed, cause string, at time.Time
if has && Same(newest, c.Revision) {
return newest, false, nil
}
names, err := o.Names(ctx)
if err != nil {
return newest, false, fmt.Errorf("the objects %s: %w", RevisionsObjects, err)
}
held := make(map[string]bool, len(names))
for _, n := range names {
held[n] = true
}
rev := c.Revision
rev.Revision = newest.Revision + 1
rev.Cause = cause
t := at.UTC()
rev.At = &t
for _, ref := range rev.Entries {
name := EntryObject(ref.Digest)
if held[name] {
continue
}
e, ok := c.Entries[ref.Digest]
if !ok {
return newest, false, fmt.Errorf("revision %d lists the entry of %s/%s (%s), which it does not carry", rev.Revision, ref.Node, ref.Module, ref.Digest)
}
data, err := json.Marshal(e)
if err != nil {
return newest, false, err
}
if err := o.Put(ctx, name, data); err != nil {
return newest, false, fmt.Errorf("the entry of %s/%s in %s: %w", ref.Node, ref.Module, RevisionsObjects, err)
}
held[name] = true
if err := writeEntries(ctx, o, c, rev); err != nil {
return newest, false, err
}
list, err := json.Marshal(rev)
if err != nil {
@@ -144,6 +121,37 @@ func Keep(ctx context.Context, o Objects, c Computed, cause string, at time.Time
return rev, true, nil
}
// writeEntries writes each entry object rev lists that the bucket does not hold yet, from c, before its list is kept.
func writeEntries(ctx context.Context, o Objects, c Computed, rev meshwants.AssignmentRevision) error {
names, err := o.Names(ctx)
if err != nil {
return fmt.Errorf("the objects %s: %w", RevisionsObjects, err)
}
held := make(map[string]bool, len(names))
for _, n := range names {
held[n] = true
}
for _, ref := range rev.Entries {
name := EntryObject(ref.Digest)
if held[name] {
continue
}
e, ok := c.Entries[ref.Digest]
if !ok {
return fmt.Errorf("revision %d lists the entry of %s/%s (%s), which it does not carry", rev.Revision, ref.Node, ref.Module, ref.Digest)
}
data, err := json.Marshal(e)
if err != nil {
return err
}
if err := o.Put(ctx, name, data); err != nil {
return fmt.Errorf("the entry of %s/%s in %s: %w", ref.Node, ref.Module, RevisionsObjects, err)
}
held[name] = true
}
return nil
}
// Event is the seat's event assignment-revision of a kept revision.
func Event(rev meshwants.AssignmentRevision) meshwants.AssignmentRevisionEvent {
return meshwants.AssignmentRevisionEvent{V: rev.V, Revision: rev.Revision, Kind: rev.Kind, Cause: rev.Cause, Group: rev.Group,
@@ -10,16 +10,23 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// ErrNoCandidate answers a verb asked of a candidate's revision: the assigner keeps none (the candidate's graph and the
// pins from the gate are milestone 47/G's W5.9, held with research 086's fold).
var ErrNoCandidate = errors.New("the assigner keeps no candidate's assignment graph revision")
// ErrNoCandidate answers a verb asked of a candidate's revision the assigner keeps none of: a run's candidate is kept from its
// catalog graph revision until its run's group-ended (A-201, A-235).
var ErrNoCandidate = errors.New("the assigner keeps no such candidate's assignment graph revision")
// ErrNoRevision answers a verb asked while no trunk revision is kept yet.
var ErrNoRevision = errors.New("no assignment graph revision is kept yet")
func revisionOf(ctx context.Context, o Objects, revision int64, group, heads string) (meshwants.AssignmentRevision, error) {
if group != "" || heads != "" {
return meshwants.AssignmentRevision{}, fmt.Errorf("%w (group %q, heads %q)", ErrNoCandidate, group, heads)
if group == "" {
return meshwants.AssignmentRevision{}, fmt.Errorf("a candidate's revision is asked by its group, with or without its heads, not by heads %q alone", heads)
}
rev, err := ReadCandidate(ctx, o, group, heads)
if err == nil && revision > 0 && rev.Revision != revision {
err = fmt.Errorf("%w (group %q, revision %d: the one kept is %d)", ErrNoCandidate, group, revision, rev.Revision)
}
return rev, err
}
if revision > 0 {
return ReadRevision(ctx, o, revision)
@@ -66,7 +73,16 @@ func Entry(ctx context.Context, o Objects, in meshwants.EntryInput) (meshwants.E
// differs, with the edges that moved, and the nodes it touches.
func Difference(ctx context.Context, o Objects, in meshwants.DifferenceInput) (meshwants.Difference, error) {
if in.Group != "" || in.Heads != "" {
return meshwants.Difference{}, fmt.Errorf("%w (group %q, heads %q)", ErrNoCandidate, in.Group, in.Heads)
// A candidate against trunk (§186, §187): from the trunk revision named, else the newest kept.
to, err := revisionOf(ctx, o, 0, in.Group, in.Heads)
if err != nil {
return meshwants.Difference{}, err
}
from, err := revisionOf(ctx, o, in.From, "", "")
if err != nil {
return meshwants.Difference{}, err
}
return Compare(ctx, o, from, to)
}
if in.From <= 0 || in.To <= 0 {
return meshwants.Difference{}, errors.New("difference takes the two kept revisions it compares, from and to")
@@ -6,10 +6,8 @@ package buckets
import (
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
watchdogs "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
catalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
delivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
wants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
@@ -25,6 +23,18 @@ const (
// Waits holds the waits on an outside cause, one key `<node>.<module>.<fingerprint hex>` (design 47 G §706).
WaitsState = "waits"
Waits = plc.Module + "_" + WaitsState
// DeliveredAgain holds each ask of assigner_delivered_again, one key `<module>`, its value the version, who asked and why, and
// why it does not hold yet (design 47 G §376, A-250).
DeliveredAgainState = "delivered-again"
DeliveredAgain = plc.Module + "_" + DeliveredAgainState
// DeliveryWaits holds each dependent's delivery wait the assigner said, one key `<module>`, its value the wait-held said
// (design 47 G §376, §498, A-246).
DeliveryWaitsState = "delivery-waits"
DeliveryWaits = plc.Module + "_" + DeliveryWaitsState
// PutWaits holds each put wait the assigner said, one key `<node>.<module>`, its value the wait's id and the put as said
// (design 47 G A-204, A-211), so a put that ends while the assigner is stopped gets its wait-ended at the next start.
PutWaitsState = "put-waits"
PutWaits = plc.Module + "_" + PutWaitsState
// Inputs holds the inputs of the composition steps, `<time>.<digest>`, which the seat mesh-wants' verb input answers (design 47 G §354).
InputsState = "inputs"
Inputs = plc.Module + "_" + InputsState
@@ -56,18 +66,14 @@ const (
// is its state provisions.
ProvisionerModule = "mesh-provisioner"
Provisions = ProvisionerModule + "_provisions"
// Presence holds each part's presence and the node's.
Presence = "mesh-delivery-watchman_" + watchdogs.StatePresence
// Source and Merges are the gate's.
Source = "mesh-delivery-gate_" + delivery.StateSource
Merges = "mesh-delivery-gate_" + delivery.StateMerges
// TellerModule is the module whose hand the operator's answers pass: the one that holds each ask's answer, and the
// name the hand-act log records a warrant under. Held equal to the manifest's reads by its test.
TellerModule = "mesh-delivery-teller"
// Answers holds the teller's answer to each ask.
Answers = TellerModule + "_answers"
// Builds is the builder's.
Builds = builds.BuilderBuildsBucket
// AnswersState is the teller's state of its answer to each ask, followed through its first view by AnswersReadTool and its
// views family `views.answers` (design 47 G A-175, A-178), never its bucket; Answers is the picture's name, `<owner>_<state>`.
AnswersState = "answers"
AnswersReadTool = "teller_read_answers"
Answers = TellerModule + "_" + AnswersState
// Grants is the grants part's.
Grants = grantsstate.PartGrantsBucket
)
@@ -8,10 +8,9 @@ func TestBucketsAreTheEarlierNames(t *testing.T) {
for name, c := range map[string][2]string{
"Have": {Have, "node-engine_have"}, "ComposedRead": {ComposedRead, "mesh-host.composed"},
"Modules": {Modules, "mesh-delivery-indexer_modules"}, "Manifests": {Manifests, "mesh-delivery-indexer_manifests"},
"Presence": {Presence, "mesh-delivery-watchman_presence"}, "Source": {Source, "mesh-delivery-gate_source"},
"Merges": {Merges, "mesh-delivery-gate_merges"}, "Answers": {Answers, "mesh-delivery-teller_answers"},
"Builds": {Builds, "mesh-delivery-builder_builds"}, "Grants": {Grants, "mesh-grants_grants"},
"Wants": {Wants, "mesh-delivery-assigner_wants"}, "Differences": {Differences, "mesh-delivery-assigner_differences"},
"Answers": {Answers, "mesh-delivery-teller_answers"},
"Grants": {Grants, "mesh-grants_grants"},
"Wants": {Wants, "mesh-delivery-assigner_wants"}, "Differences": {Differences, "mesh-delivery-assigner_differences"},
"Asks": {Asks, "mesh-delivery-assigner_asks"}, "Puts": {Puts, "mesh-delivery-assigner_puts"},
"Policies": {Policies, "mesh-delivery-assigner_policies"}, "Provisions": {Provisions, "mesh-provisioner_provisions"}, "TellerModule": {TellerModule, "mesh-delivery-teller"},
} {
@@ -97,6 +97,16 @@ func (p Put) Said(at time.Time, step, words string) Put {
return p
}
// StandsSince is when p came to stand at its step: the time of the first of its newest steps at that step (a step's words
// may change while it stands, each change a step of its own), or when it was asked when its steps say none.
func (p Put) StandsSince() time.Time {
since := p.At
for i := len(p.Steps) - 1; i >= 0 && p.Steps[i].Step == p.Step; i-- {
since = p.Steps[i].At
}
return since
}
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
func (p Put) check() error {
@@ -43,17 +43,20 @@ func TestManifest(t *testing.T) {
reads := slices.Clone(m.Reads)
slices.Sort(reads)
// ADR 0593 §8: it composes nothing, so it reads no candidate, held or resolved value, compose step or build log; it reads
// every node's published compositions to compare them, and the gate's source and merges, for the release that carries a build (ADR 0601 §1).
// every node's published compositions to compare them,.
// DLV-F2r (ADR 0615 §1, ADR 0631 §1): the indexer's manifests, modules and module graph (whose verb edges a want waits
// on, ADR 0614 §2, §3) are read through its first views and views family, none of the indexer's states by reads.
want := []string{"mesh-delivery-builder.builds", "mesh-delivery-gate.merges", "mesh-delivery-gate.source",
"mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-grants.grants", "mesh-host.composed",
// A-181: what was built is heard from the builder's stream of ends, so the builder's builds bucket is no read.
// §431, §507: the gate's source is read through its verb `source` and its event `source-moved`, and no `merges` is read.
// A-175, A-178: the teller's answers are followed through its first view and its views family, never its bucket.
// §389: the watchman's machine-away and machine-back are consumed, never its presence.
want := []string{"mesh-grants.grants", "mesh-host.composed",
"mesh-provisioner.provisions", "node-engine.have"}
if !reflect.DeepEqual(reads, want) {
t.Errorf("reads = %v, want exactly %v", reads, want)
}
// the buckets the program names are the manifest's reads, `<module>.<state>` as `<module>_<state>`
named := []string{buckets.Builds, buckets.Merges, buckets.Source, buckets.Answers, buckets.Presence, buckets.Grants,
named := []string{buckets.Grants,
buckets.ComposedRead, "mesh-provisioner_provisions", buckets.Have}
slices.Sort(named)
for i, r := range want {
@@ -67,7 +70,7 @@ func TestManifest(t *testing.T) {
}
if want := map[string]int{"asks": 5, "differences": 64, "puts": 20, "wants": 20,
"assignments": 64, "placement": 64, "copies": 1, "proposals": 5, "candidates": 5, "pulses": 0, "findings": 5, "policies": 20, "seat-holders": 5, views.MarksState: 1, views.StatusState: 1,
"engine-reads": 1, "written": 5, "waits": 5, "inputs": 1}; !reflect.DeepEqual(states, want) {
"engine-reads": 1, "written": 5, "waits": 5, "put-waits": 1, "delivery-waits": 1, "delivered-again": 1, "inputs": 1, "pins": 5}; !reflect.DeepEqual(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 {
@@ -102,7 +105,7 @@ func TestManifest(t *testing.T) {
// which lists and asks again what each node's engine reads.
if !slices.Equal(m.Tools, []string{"assigner_put", "assigner_release", "assigner_puts", "assigner_waits", "assigner_inputs", "assignments", "seat-holders", "wants",
"assigner_read_assignments", "assigner_read_seat_holders", "assigner_read_wants", "assigner_read_findings", "assigner_read_asks", "assigner_read_differences", "assigner_read_copies",
"assigner_first_views", "assigner_first_view_again", "assigner_engine_reads", "assigner_seat_list", "assigner_seat_list_again", "assigner_ask_again", "assigner_written"}) {
"assigner_first_views", "assigner_first_view_again", "assigner_engine_reads", "assigner_seat_list", "assigner_seat_list_again", "assigner_ask_again", "assigner_written", "assigner_pins", "assigner_unpin", "assigner_delivered_again"}) {
t.Errorf("the assigner serves tools %v, want its three put tools, three first views, six read tools, assigner_engine_reads, the seat list's two (ADR 0636 §7) and assigner_ask_again (ADR 0668 §5)", m.Tools)
}
if m.RunsAs != m.Module || m.SecretsOwner != m.RunsAs {
@@ -129,13 +132,16 @@ func TestManifest(t *testing.T) {
}
}
// ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events;
// issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears.
if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.version-released", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildEndedEvent, buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) {
// issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears; §376, §389: its own version-delivered and
// the watchman's machine-away and machine-back, read whole at its start.
if !slices.Equal(m.Consumes, []string{"mesh-conditions.findings-asked", "mesh-conditions.condition-raised", "mesh-conditions.condition-changed", "mesh-conditions.condition-cleared", "mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-delivery.source-moved", "mesh-catalog.version-released", "mesh-wants.version-delivered", "mesh-watchdogs.machine-away", "mesh-watchdogs.machine-back", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", builds.BuilderModule + "." + builds.BuildsStreamEvents, buckets.TellerModule + "." + tellerevents.AskEnded, buckets.TellerModule + ".views." + buckets.AnswersState, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running", "mesh-delivery.pinned", "mesh-delivery.freed", "mesh-delivery.heads-replaced", "mesh-delivery.pins-stated"}) {
t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes)
}
if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
buckets.TellerModule + "." + buckets.AnswersReadTool, // A-175, A-178: the teller's answers' first view
"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
"seat:mesh-conditions.conditions", // design 47 G §706: the conditions read at each judging of an outside wait
"seat:mesh-delivery.source", // §507: the branches the gate keeps a source revision of, before the first registrations
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
"seat:node-backup.backed-up", "seat:node-backup.now",
"seat:node-engine.manifest-reads", // ADR 0618 §3
+22 -9
View File
@@ -30,11 +30,6 @@
}
],
"reads": [
"mesh-delivery-builder.builds",
"mesh-delivery-gate.merges",
"mesh-delivery-gate.source",
"mesh-delivery-teller.answers",
"mesh-delivery-watchman.presence",
"mesh-grants.grants",
"mesh-host.composed",
"mesh-provisioner.provisions",
@@ -50,23 +45,34 @@
"mesh-conditions.condition-cleared",
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-delivery.source-moved",
"mesh-catalog.version-released",
"mesh-wants.version-delivered",
"mesh-watchdogs.machine-away",
"mesh-watchdogs.machine-back",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed",
"mesh-delivery-builder.build.ended",
"mesh-delivery-builder.build.ended.**",
"mesh-delivery-teller.ask-ended",
"mesh-delivery-teller.views.answers",
"mesh-pipeline-runner.pipeline-ended",
"mesh-pipeline-runner.group-ended",
"node-engine.manifest-reads-changed",
"node-engine.running"
"node-engine.running",
"mesh-delivery.pinned",
"mesh-delivery.freed",
"mesh-delivery.heads-replaced",
"mesh-delivery.pins-stated"
],
"invokes": [
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"mesh-delivery-teller.teller_read_answers",
"seat:mesh-catalog.registrations",
"seat:mesh-catalog.revision",
"seat:mesh-catalog.seat-definitions",
"seat:mesh-conditions.conditions",
"seat:mesh-delivery.source",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-hand-acts.hand-acts",
"seat:mesh-hand-acts.warranted",
@@ -116,7 +122,10 @@
"assigner_seat_list",
"assigner_seat_list_again",
"assigner_ask_again",
"assigner_written"
"assigner_written",
"assigner_pins",
"assigner_unpin",
"assigner_delivered_again"
],
"state": [
{ "name": "assignments", "history": 64 },
@@ -137,7 +146,11 @@
{ "name": "engine-reads", "history": 1 },
{ "name": "written", "history": 5 },
{ "name": "waits", "history": 5 },
{ "name": "inputs", "history": 1 }
{ "name": "put-waits", "history": 1 },
{ "name": "delivery-waits", "history": 1 },
{ "name": "delivered-again", "history": 1 },
{ "name": "inputs", "history": 1 },
{ "name": "pins", "history": 5, "max-mib": 4 }
],
"config": {
"outside-wait-minutes": {
+16 -8
View File
@@ -17,11 +17,19 @@ const (
// BuildEnded is the body of the event build.ended: the module, the fingerprint, each artifact made with its digest, the build
// node that keeps a kept build (none for one not kept) and the watched branch a build of a candidate is for (none for the trunk).
type BuildEnded struct {
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
Made map[string]string `json:"made,omitempty"`
KeptOn string `json:"kept-on,omitempty"`
Branch string `json:"branch,omitempty"`
Module string `json:"module"`
Fingerprint string `json:"fingerprint"`
// Outcome is done or failed (design 47 G §575, A-162): the event is said for every end that is one of these; Error is why a
// failed build failed, as its key holds it (`transport fault:` included, §568), none for a done one.
Outcome string `json:"outcome"`
Error string `json:"error,omitempty"`
// Manifest is the digest of the manifest built and Needs the reader capabilities its reference forms need (ADR 0618 §1), of a
// done build: what a reader of the end asks of the builder's key otherwise.
Manifest string `json:"manifest,omitempty"`
Needs []string `json:"needs,omitempty"`
Made map[string]string `json:"made,omitempty"`
KeptOn string `json:"kept-on,omitempty"`
Branch string `json:"branch,omitempty"`
}
// BuildEndedEventName is the event's name for a module and fingerprint: `build.ended.<module>.<fingerprint>`, each escaped.
@@ -29,15 +37,15 @@ func BuildEndedEventName(module, fp string) string {
return BuildEndedEvent + "." + keys.EscapeName(module) + "." + keys.EscapeName(fp)
}
// BuildEndedOf is the event of a log entry of a build's end: false for any entry but the end of a done build, which says no
// BuildEndedOf is the event of a log entry of a build's end: false for any entry but the end of a done or a failed build. A done end says no
// digest of any part.
func BuildEndedOf(e BuildLogEntry) (BuildEnded, bool) {
if e.Event != BuildEndedEvent || e.Outcome != BuildDone || e.Module == "" || e.FP == "" {
if e.Event != BuildEndedEvent || (e.Outcome != BuildDone && e.Outcome != BuildFailed) || e.Module == "" || e.FP == "" {
return BuildEnded{}, false
}
made := map[string]string{}
for _, a := range e.Made {
made[a.Name] = a.Digest
}
return BuildEnded{Module: e.Module, Fingerprint: e.FP, Made: made, KeptOn: e.KeptOn, Branch: e.Ref}, true
return BuildEnded{Module: e.Module, Fingerprint: e.FP, Outcome: e.Outcome, Error: e.Error, Manifest: e.Manifest, Needs: e.Needs, Made: made, KeptOn: e.KeptOn, Branch: e.Ref}, true
}
@@ -11,7 +11,7 @@ func TestADoneBuildsEndIsTheEventBuildEnded(t *testing.T) {
made := []BuiltArtifact{{Name: "program", Kind: "program", Digest: "sha256:1"}}
e := BuildLogEntry{Event: BuildEndedEvent, Outcome: BuildDone, Module: "node-engine", FP: "src2:a", Ref: "cand", KeptOn: "ace", Made: made}
got, ok := BuildEndedOf(e)
want := BuildEnded{Module: "node-engine", Fingerprint: "src2:a", Made: map[string]string{"program": "sha256:1"}, KeptOn: "ace", Branch: "cand"}
want := BuildEnded{Module: "node-engine", Fingerprint: "src2:a", Outcome: BuildDone, Made: map[string]string{"program": "sha256:1"}, KeptOn: "ace", Branch: "cand"}
if !ok || !reflect.DeepEqual(got, want) {
t.Errorf("event = %+v, %v", got, ok)
}
@@ -20,7 +20,7 @@ func TestADoneBuildsEndIsTheEventBuildEnded(t *testing.T) {
}
for name, mut := range map[string]func(*BuildLogEntry){
"started": func(e *BuildLogEntry) { e.Event = BuildStartedEvent },
"failed": func(e *BuildLogEntry) { e.Outcome = BuildFailed },
"queued": func(e *BuildLogEntry) { e.Outcome = BuildQueued },
"no fp": func(e *BuildLogEntry) { e.FP = "" },
} {
c := e
@@ -30,3 +30,13 @@ func TestADoneBuildsEndIsTheEventBuildEnded(t *testing.T) {
}
}
}
// Design 47 G §575, A-162: a failed build's end is said too, with its outcome and its error whole.
func TestAFailedBuildsEndIsSaidWithItsError(t *testing.T) {
e := BuildLogEntry{Event: BuildEndedEvent, Outcome: BuildFailed, Module: "web", FP: "src2:b", Error: "transport fault: no responders at mesh-build-go"}
got, ok := BuildEndedOf(e)
want := BuildEnded{Module: "web", Fingerprint: "src2:b", Outcome: BuildFailed, Error: e.Error, Made: map[string]string{}}
if !ok || !reflect.DeepEqual(got, want) {
t.Errorf("event = %+v, %v", got, ok)
}
}
@@ -83,6 +83,9 @@ type BuildLogEntry struct {
// narration names neither.
Error string `json:"error,omitempty"`
Step string `json:"step,omitempty"`
// Needs are, at a done build's end, the reader capabilities its reference forms need (BuilderBuild.Needs, ADR 0618 §1): the end
// event carries them (design 47 G §575, A-162).
Needs []string `json:"needs,omitempty"`
// Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone.
Line string `json:"line,omitempty"`
Why string `json:"why,omitempty"`
@@ -594,7 +594,7 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
t.Errorf("no build.ended of netcheck's build: %v", err)
} else {
var ev builds.BuildEnded
if err := json.Unmarshal(raw.Data, &ev); err != nil || ev.Module != "netcheck" || ev.Fingerprint != fp || len(ev.Made) != 2 {
if err := json.Unmarshal(raw.Data, &ev); err != nil || ev.Module != "netcheck" || ev.Fingerprint != fp || ev.Outcome != builds.BuildDone || len(ev.Made) != 2 {
t.Errorf("build.ended = %s (%v), want netcheck at its fingerprint with two artifacts' digests", raw.Data, err)
}
}
@@ -656,9 +656,18 @@ func TestBuilderMakesADoneKeyWithTheDigests(t *testing.T) { // 1607/AC7
b.fake.hang.Store(&release)
third := "src2:" + strings.Repeat("4", 64)
b.moduleFP(t, "netcheck", netcheck, "modules/netcheck", bothToolchains, commit, third, 3)
if f := b.awaitFP(t, "netcheck", third, isState(builds.BuildFailed)); !strings.Contains(f.Error, "did not end within") {
f := b.awaitFP(t, "netcheck", third, isState(builds.BuildFailed))
if !strings.Contains(f.Error, "did not end within") {
t.Errorf("the failed build = %+v, want its bound run out", f)
}
// A-162: the failed end is said as build.ended too, with its error whole.
if st, err := b.js.Stream(b.ctx, "BUILDER_BUILDS"); err != nil {
t.Fatal(err)
} else if raw, err := st.GetLastMsgForSubject(b.ctx, "mesh.stream.mesh-delivery-builder.event."+builds.BuildEndedEventName("netcheck", third)); err != nil {
t.Errorf("no build.ended of netcheck's failed build: %v", err)
} else if ev := (builds.BuildEnded{}); json.Unmarshal(raw.Data, &ev) != nil || ev.Outcome != builds.BuildFailed || ev.Error != f.Error {
t.Errorf("build.ended = %s, want the failed outcome with the error %q", raw.Data, f.Error)
}
failed := meshconditionsclient.Key(meshconditionsclient.ScopeModule, "netcheck", BuildFailed)
reg.awaitFinding(t, failed, true)
b.fake.hang.Store(nil)
@@ -161,9 +161,9 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account
return nil
}
// emitBuildEnded says the builder's event build.ended of a done build, on the builder's own stream (design 47 G §379, A-96):
// emitBuildEnded says the builder's event build.ended of a done or failed build (A-162), on the builder's own stream (design 47 G §379, A-96):
// mesh.stream.<module>.event.build.ended.<module>.<fingerprint>, published where the log entry of the end is written. An
// entry that is not a done build's end says none. The id makes a repeated end one event.
// entry that is not a done or failed build's end says none. The id makes a repeated end one event.
func emitBuildEnded(ctx context.Context, js jetstream.JetStream, cred process.Account, e builds.BuildLogEntry) error {
body, ok := builds.BuildEndedOf(e)
if !ok {
@@ -1214,6 +1214,7 @@ func LogEnded(module string, m meshcatalog.ModulesByModuleValue, ref, by string,
e.KeptOn = result.KeptOn
e.Imports = append([]string(nil), result.Imports...) // the indexer keeps them on the module (novox/hq ADR 0593 §2)
e.Error, e.Step = result.Error, result.Step // a failed build's reason, not only its outcome
e.Needs = append([]string(nil), result.Needs...)
return e
}
@@ -53,10 +53,11 @@ type manifest struct {
Consumes []string `json:"consumes"`
State []state `json:"state"`
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
Name string `json:"name"`
Events []string `json:"events"`
MaxAgeDays int `json:"max-age-days"`
MaxMiB int `json:"max-mib"`
LastPerSubject bool `json:"last-per-subject"`
} `json:"streams"`
Logs []struct {
Name string `json:"name"`
@@ -177,9 +178,9 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// Design 47 G §672: the toolchain waits' stream, kept 7 days within 16 MiB, and the bound's setting, 60 minutes.
// Design 47 G A-30, §383: and its own findings' events, which its finding writer says.
if len(m.Streams) != 3 || m.Streams[0].Name != "waits" || !reflect.DeepEqual(m.Streams[0].Events, []string{"wait-held.**", "wait-ended.**"}) || m.Streams[0].MaxAgeDays != 7 || m.Streams[0].MaxMiB != 16 ||
m.Streams[1].Name != builds.BuildsStream || !reflect.DeepEqual(m.Streams[1].Events, []string{builds.BuildsStreamEvents}) || m.Streams[1].MaxAgeDays != 7 ||
m.Streams[1].Name != builds.BuildsStream || !reflect.DeepEqual(m.Streams[1].Events, []string{builds.BuildsStreamEvents}) || m.Streams[1].MaxAgeDays != 7 || !m.Streams[1].LastPerSubject ||
m.Streams[2].Name != "mesh-delivery-builder-findings" || !reflect.DeepEqual(m.Streams[2].Events, []string{"finding-written.*", "finding-cleared.*", "findings-stated.*"}) {
t.Errorf("streams = %+v, want waits with wait-held.** and wait-ended.**, 7 days, 16 MiB, the builds' stream (build.ended.**, 7 days) and the findings' stream", m.Streams)
t.Errorf("streams = %+v, want waits with wait-held.** and wait-ended.**, 7 days, 16 MiB, the builds' stream (build.ended.**, 7 days, kept last per subject: design 47 G A-181) and the findings' stream", m.Streams)
}
if c, ok := m.Config["toolchain-wait-minutes"]; !ok || c.Default != float64(60) || len(m.Config) != 2 || m.Config["republish-max-versions"].Default != float64(50) {
t.Errorf("config = %+v, want toolchain-wait-minutes of 60 and republish-max-versions of 50 (design 47 G §588, §633)", m.Config)
+1 -1
View File
@@ -130,7 +130,7 @@
],
"streams": [
{ "name": "waits", "events": ["wait-held.**", "wait-ended.**"], "max-age-days": 7, "max-mib": 16 },
{ "name": "mesh-delivery-builder-builds", "events": ["build.ended.**"], "max-age-days": 7, "max-mib": 16 },
{ "name": "mesh-delivery-builder-builds", "events": ["build.ended.**"], "max-age-days": 7, "max-mib": 16, "last-per-subject": true },
{ "name": "mesh-delivery-builder-findings", "events": ["finding-written.*", "finding-cleared.*", "findings-stated.*"], "max-age-days": 7, "max-mib": 16 }
],
"config": {
@@ -68,6 +68,9 @@ type Branches struct {
Entries *Entries
// Changed, when not nil, is told of each change to a branch's view (module, its entry, deleted), set before Wire.
Changed func(branch, module string, entry json.RawMessage, deleted bool, at time.Time)
// Adopted, when not nil, is told on a goroutine of its own of each branch whose view was adopted, with that view (a part that waits for
// its first branch's first view, design 47 G §507), set before Wire.
Adopted func(branch string, c *Catalog)
// Logf, when not nil, says each view adopted and dropped.
Logf func(format string, args ...any)
@@ -250,6 +253,9 @@ func (b *Branches) reconcile() {
b.catalogs[e] = c
b.order = append(b.order, e)
b.logf("meshcatalogclient: the branch %q is followed", want[e])
if b.Adopted != nil {
go b.Adopted(branch, c)
}
}
for _, e := range slices.Clone(b.order) {
if _, ok := want[e]; ok {
@@ -126,6 +126,12 @@ func TestBranchesFollowSourceThenRegistrations(t *testing.T) {
t.Fatal(err)
}
b.Logf = func(format string, args ...any) { said <- fmt.Sprintf(format, args...) }
adopted := make(chan string, 8)
b.Adopted = func(branch string, c *Catalog) {
if c.Branch == branch {
adopted <- branch
}
}
want := func(names ...string) {
t.Helper()
if got := w.names(); !slices.Equal(got, names) {
@@ -148,6 +154,18 @@ func TestBranchesFollowSourceThenRegistrations(t *testing.T) {
t.Fatal(err)
}
waitFor(`"feat/x" is followed`)
told := []string{} // each adopted branch is told, with its view (a part waits for its first one's first view), in no set order
for range 2 {
select {
case got := <-adopted:
told = append(told, got)
case <-ctx.Done():
t.Fatalf("told of %v, want main and feat/x", told)
}
}
if slices.Sort(told); !slices.Equal(told, []string{"feat/x", "main"}) {
t.Fatalf("told of %v", told)
}
if got := b.Followed(); !slices.Equal(got, []string{"main", "feat/x"}) {
t.Fatalf("followed = %v", got)
}
@@ -472,7 +472,9 @@ type AssignmentEntry struct {
Catalog string `json:"catalog"`
Edges []Edge `json:"edges,omitempty"`
// the fingerprint it is to run
Fp string `json:"fp"`
Fp string `json:"fp"`
// the requirements no step of the order resolves (design 47 G section 357): the entry is held, naming the candidates, and nothing is guessed
Held []Held `json:"held,omitempty"`
Module string `json:"module"`
Node string `json:"node"`
// the layout version
@@ -582,6 +584,13 @@ type Fill struct {
Store string `json:"store"`
}
// Held is one requirement an entry is held on, for want of a provider.
type Held struct {
// a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it
Candidates []Edge `json:"candidates,omitempty"`
Provision string `json:"provision"`
}
// NodeEntryRef is a type of the contract.
type NodeEntryRef struct {
// the entry's digest, its object's name
@@ -825,6 +825,13 @@
"items": {
"$ref": "#/defs/Edge"
}
},
"held": {
"type": "array",
"description": "the requirements no step of the order resolves (design 47 G section 357): the entry is held, naming the candidates, and nothing is guessed",
"items": {
"$ref": "#/defs/Held"
}
}
},
"required": [
@@ -835,6 +842,25 @@
"catalog"
]
},
"Held": {
"type": "object",
"description": "one requirement an entry is held on, for want of a provider",
"properties": {
"provision": {
"type": "string"
},
"candidates": {
"type": "array",
"description": "a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it",
"items": {
"$ref": "#/defs/Edge"
}
}
},
"required": [
"provision"
]
},
"NodeEntryRef": {
"type": "object",
"properties": {
@@ -143,6 +143,8 @@ export interface AssignmentEntry {
"edges"?: Edge[];
/** the fingerprint it is to run */
"fp": string;
/** the requirements no step of the order resolves (design 47 G section 357): the entry is held, naming the candidates, and nothing is guessed */
"held"?: Held[];
"module": string;
"node": string;
/** the layout version */
@@ -250,6 +252,13 @@ export interface Fill {
"store": string;
}
/** one requirement an entry is held on, for want of a provider */
export interface Held {
/** a provisioning edge to each placed provider of it, none chosen; none when no placed module provides it */
"candidates"?: Edge[];
"provision": string;
}
export interface NodeEntryRef {
/** the entry's digest, its object's name */
"digest": string;
@@ -47,7 +47,7 @@ type Store interface {
// trunkLike are the branches that are no group: a pull request from one is nobody's delivery group (the rule of
// mesh-catalog modules/mesh-delivery/cmd/mesh-delivery/holder.go:321).
var trunkLike = map[string]bool{"main": true, "master": true, "trunk": true, "": true}
var trunkLike = map[string]bool{TrunkBranch: true, "master": true, "trunk": true, "": true}
// writeTries bounds the reads again after a conflict; a message that still conflicts is left to the server to deliver again.
const writeTries = 8
@@ -0,0 +1,37 @@
package index
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A-93, design 47 G §7: the trunk `main` is named once in the indexer, as TrunkBranch; no other non-test source of the module holds the
// string, so the first watched branch changes in one place.
func TestTheTrunkIsNamedOnceInTheIndexer(t *testing.T) {
var named []string
for _, dir := range []string{".", "../../cmd/mesh-delivery-indexer", "../judge"} {
files, err := filepath.Glob(filepath.Join(dir, "*.go"))
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, line := range strings.Split(string(src), "\n") {
if t := strings.TrimSpace(line); strings.Contains(line, `"main"`) && !strings.HasPrefix(t, "//") {
named = append(named, filepath.Base(f))
}
}
}
}
if len(named) != 1 {
t.Errorf(`"main" is named %d times in code (%v), want once: TrunkBranch`, len(named), named)
}
}
@@ -104,6 +104,19 @@ func AnswerTools(s answers) []stdio.Tool {
}}}
}
// ReadAnswersTool is the teller's ordinary read tool of its state answers (design 47 G A-175, A-178; mesh-sdk views.ReadTool):
// what the view follows, first by it and then by the teller's views family views.answers. Its marks are the state teller-marks.
const ReadAnswersTool = "teller_read_answers"
// marksState is the teller's state of what its views family said each state up to (A-178).
const marksState = "teller-marks"
// ReadAnswersTools is the tool teller_read_answers over the state answers and its marks.
func ReadAnswersTools(values, marks views.Source, now func() time.Time) []stdio.Tool {
return []stdio.Tool{views.ReadTool("mesh-delivery-teller", ReadAnswersTool, "answers",
"the operator's answers to the assigner's asks, by the ask's key.", values, marks, now)}
}
// WidensSaidTool reads the teller's state widens-said (design 47 G §335).
const WidensSaidTool = "teller_widens_said"
@@ -205,6 +218,7 @@ func main() {
tools := append(Tools(askEnd(stdio.State(ends.State), 25*time.Second), time.Now),
views.StatusTools("mesh-delivery-teller", "teller_first_views", "teller_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...)
tools = append(tools, AnswerTools(stdio.State("answers"))...)
tools = append(tools, ReadAnswersTools(views.KeptSource{State: stdio.State("answers")}, views.KeptSource{State: stdio.State(marksState)}, time.Now)...)
tools = append(tools, WidensSaidTools(stdio.State(widensSaidState))...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-teller-tools] %v\n", err)
@@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
)
type fakeAnswers map[string]string
@@ -64,3 +66,36 @@ func TestTellerWidensSaidReadsOneKeyOrEveryKey(t *testing.T) {
t.Errorf("every key: %s, %v", raw, err)
}
}
// teller_read_answers is the teller's ordinary read tool of its state answers (A-175, A-178): the key's value and revision, the
// keys, over the answers state and the marks state teller-marks.
type entries map[string]views.Entry
func (e entries) Keys() ([]string, error) {
var out []string
for k := range e {
out = append(out, k)
}
return out, nil
}
func (e entries) Get(key string) (*views.Entry, bool, error) {
v, ok := e[key]
return &v, ok, nil
}
func TestTellerReadAnswersIsTheViewsReadToolOfTheAnswersState(t *testing.T) {
values := entries{"hold.k1": {Value: json.RawMessage(`{"option":"keep"}`), Revision: 3}}
tool := ReadAnswersTools(values, entries{}, time.Now)[0]
if tool.Name != "teller_read_answers" {
t.Fatalf("the tool is %s", tool.Name)
}
out, err := tool.Run(map[string]any{"key": "hold.k1"})
raw, _ := json.Marshal(out)
if err != nil || string(raw) != `{"owner":"mesh-delivery-teller","state":"answers","key":"hold.k1","found":true,"value":{"option":"keep"},"revision":3}` {
t.Errorf("one key: %s, %v", raw, err)
}
if marksState != "teller-marks" {
t.Errorf("the marks state is %s", marksState)
}
}
@@ -25,9 +25,9 @@ import (
// of these asks needs proof of who answered (mesh-sdk go/delivery AskNeedsProof).
// mostAssignerOpen is how many of the assigner's asks the teller keeps open at the router at once. The router keeps at most
// occ.MostOpen open asks of one asker, and the teller asks both the gate's merge asks and the assigner's as one asker, so a burst
// of holds (seven at once on 2026-10-25) would take all the room and a merge Approve could not be asked; one place is always
// left for a merge ask. An assigner ask beyond these waits in the wait for room and is asked when one of them ends.
// occ.MostOpen open asks of one asker, and a burst of holds (seven at once on 2026-10-25) would take all the room; one place
// stays free (it was left for the merge ask, which went with the Approve per merge, design 47 G §653). An assigner ask beyond
// these waits in the wait for room and is asked when one of them ends.
const mostAssignerOpen = occ.MostOpen - 1
// assignerOpenRecord is the key of the router ids of the assigner's asks the teller asked.
@@ -89,7 +89,7 @@ func (t *Teller) waitForAssignerRoom(key, id string) {
}
}
// isAssignerKey says whether an ask key is one of the assigner's, not the gate's merge ask.
// isAssignerKey says whether an ask key is one of the assigner's, not the merge ask's key of before the Approve per merge went.
func isAssignerKey(key string) bool {
k, err := asks.ParseAnyAskKey(key)
return err == nil && k.Kind != meshdeliveryclient.AskKindMerge
@@ -151,22 +151,20 @@ func TestAnAssignerAskAnsweredInTheViewOrTakenAwayIsTakenBack(t *testing.T) {
}
}
// 1906/H1: an assigner ask refused for want of room waits and is asked again when another of the teller's asks ends, as a merge
// ask does; a refusal for its words is said as a condition about a delivery ask, not a merge.
// 1906/H1: an assigner ask refused for want of room waits and is asked again when another of the teller's asks ends; a refusal for
// its words is said as a condition about a delivery ask.
func TestAnAssignerAskWaitsForRoomAndARefusalIsSaidAsADeliveryAsk(t *testing.T) {
anAssignerAskWrittenAgainAfterBeingTakenAwayIsAskedAgainUnderANewAsking(t)
b := newBed(t)
assigner, _ := withView(b)
_, first := b.askOf(t, 1, 1)
b.askOf(t, 2, 2)
b.askOf(t, 3, 3)
_, first := b.holdOf(t, 1, 1)
key, value, ask := holdAsk(t)
assigner[key] = value
b.t.OnAssignerAsk(key)
b.refuse(t, ask.ID, roomRefusal)
b.deliver(t, warrantFor(first.Ask, "approve"))
b.deliver(t, warrantFor(first, "keep"))
got := b.askedIDs()
if len(got) != 5 || got[4] != ask.ID {
if len(got) != 3 || got[2] != ask.ID {
t.Fatalf("asks = %v, want the assigner's %s asked again", got, ask.ID)
}
@@ -181,60 +179,28 @@ func TestAnAssignerAskWaitsForRoomAndARefusalIsSaidAsADeliveryAsk(t *testing.T)
}
// ackFor is the router's warrant for an option of level acknowledge, as the assigner's asks offer.
func ackFor(a oc.Ask, option string) oc.Warrant {
w := warrantFor(a, option)
w.Level, w.Label = occ.Acknowledge, option
return w
}
func ackFor(a oc.Ask, option string) oc.Warrant { return warrantFor(a, option) }
// holdAskOf is the assigner's hold ask of module plex at the n-th fingerprint, shaped as holdAsk.
func holdAskOf(t *testing.T, n int) (string, []byte, oc.Ask) {
t.Helper()
key, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat(string(rune('0'+n)), 64))
if err != nil {
t.Fatal(err)
}
_, _, ask := holdAsk(t)
ask.ID = meshdeliveryclient.AskID(key)
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask)})
if err != nil {
t.Fatal(err)
}
return key, value, ask
}
// 1906/H5: given seven holds at once (as on 2026-10-25), when they are raised, then at most two are open at the router, a merge
// ask is still asked, and each hold answered lets the next waiting one be asked.
func TestABurstOfHoldsLeavesRoomForAMergeAsk(t *testing.T) {
// 1906/H5: given seven holds at once (as on 2026-10-25), when they are raised, then at most mostAssignerOpen are open at the
// router, and each hold answered lets the next waiting one be asked.
func TestABurstOfHoldsAsksNoMoreThanTheMostOpenAndTheNextWhenOneEnds(t *testing.T) {
b := newBed(t)
assigner, _ := withView(b)
var held []oc.Ask
for n := 0; n < 7; n++ {
key, value, ask := holdAskOf(t, n)
assigner[key] = value
b.t.OnAssignerAsk(key)
_, ask := b.holdOf(t, n, 1)
held = append(held, ask)
}
if got := b.askedIDs(); len(got) != mostAssignerOpen || got[0] != held[0].ID || got[1] != held[1].ID {
t.Fatalf("asked %v, want the first %d holds only; log:\n%s", got, mostAssignerOpen, b.logs.String())
}
_, merge := b.askOf(t, 1, 1)
if got := b.askedIDs(); len(got) != mostAssignerOpen+1 || got[mostAssignerOpen] != merge.Ask.ID {
t.Fatalf("asked %v, want the merge ask asked beside the holds", got)
}
if len(b.dropped) != 0 || len(b.conds) != 0 {
t.Errorf("router dropped %v, conditions %+v", b.dropped, b.conds)
}
b.deliver(t, ackFor(held[0], "release"))
got := b.askedIDs()
if len(got) != mostAssignerOpen+2 || got[len(got)-1] != held[2].ID {
if len(got) != mostAssignerOpen+1 || got[len(got)-1] != held[2].ID {
t.Fatalf("asked %v after a hold was answered, want the third hold asked next; log:\n%s", got, b.logs.String())
}
// The merge ask answered frees its own place, and no hold takes it.
b.deliver(t, warrantFor(merge.Ask, "approve"))
if got := b.askedIDs(); len(got) != mostAssignerOpen+2 {
t.Errorf("asked %v after the merge ask was answered, want no further hold", got)
}
}
// Issue 1966 (nats on novox): an assigner ask taken back when its key went is asked again when the assigner writes the key
@@ -25,25 +25,11 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"mesh-delivery-teller/internal/ends"
)
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
// controller's user and the teller's own principal <node>.<module>.
const busConf = `
listen: 127.0.0.1:-1
jetstream { store_dir: %q }
accounts {
MESH {
jetstream: enabled
users = [
{ user: "ctl", password: "pw" }
{ user: "n1.mesh-delivery-teller", password: "pw" }
]
}
}
`
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
@@ -56,82 +42,6 @@ func (s *syncBuffer) Write(p []byte) (int, error) {
}
func (s *syncBuffer) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
// TestTellerWaitsForTheGate starts the program the way the node does (its entry, run, with MESH_BROKER_FILE) against an
// embedded bus where mesh-delivery-gate_asks does not exist: it keeps running until stopped, says once that it waits for
// the gate's asks, and writes and publishes nothing (the revised 1240/AC2: the watch is release 3d's).
func TestTellerWaitsForTheGate(t *testing.T) { // 1240/AC2
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "t")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
conf := filepath.Join(dir, "bus.conf")
if err := os.WriteFile(conf, []byte(fmt.Sprintf(busConf, filepath.Join(dir, "js"))), 0o600); err != nil {
t.Fatal(err)
}
opts, err := server.ProcessConfigFile(conf)
if err != nil {
t.Fatal(err)
}
opts.NoLog, opts.NoSigs = true, true
srv, err := server.NewServer(opts)
if err != nil {
t.Fatal(err)
}
go srv.Start()
if !srv.ReadyForConnections(10 * time.Second) {
t.Fatal("the bus did not come up")
}
t.Cleanup(srv.Shutdown)
ctl, err := nats.Connect(srv.ClientURL(), nats.UserInfo("ctl", "pw"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ctl.Close)
js, err := jetstream.New(ctl)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
broker := filepath.Join(t.TempDir(), "broker")
cred, _ := json.Marshal(process.Account{URL: srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{"MESH_BROKER_FILE": broker}
logs := &syncBuffer{}
rn := startRunner(ctx, func(c context.Context) error { return run(c, func(k string) string { return env[k] }, logs) })
t.Cleanup(func() { rn.finish(t, 10*time.Second) })
for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline) && !strings.Contains(logs.String(), "waiting for the gate's asks"); time.Sleep(20 * time.Millisecond) {
}
if n := strings.Count(logs.String(), "waiting for the gate's asks"); n != 1 {
t.Fatalf("the teller said it waits %d times, want once: %q", n, logs.String())
}
select {
case err := <-rn.done:
t.Fatalf("the teller stopped: %v", err)
case <-time.After(500 * time.Millisecond):
}
if n := strings.Count(logs.String(), "waiting for the gate's asks"); n != 1 {
t.Errorf("the teller said it waits %d times after a while, want once", n)
}
// It writes and publishes nothing: no stream, no bucket exists.
names := js.StreamNames(ctx)
for name := range names.Name() {
t.Errorf("a stream %s exists: the teller wrote something", name)
}
if _, err := js.KeyValue(ctx, "mesh-delivery-gate_asks"); err == nil {
t.Error("the gate's asks bucket exists")
}
if err := rn.finish(t, 10*time.Second); err != nil {
t.Errorf("the teller stopped with %v, want a clean stop", err)
}
}
// The principals of the composed accounts file for the teller's tests, with the permissions mesh-controller composes for
// them (internal/broker): a module's own state is the read grants of its bucket and `$KV.<bucket>.>` to write it
// (state.go stateGrants, readGrants), a read of another module's bucket is the read grants alone, and a module that uses
@@ -171,9 +81,8 @@ func user(name string, pub, sub []string) string {
const (
tellerAnswers = "mesh-delivery-teller_answers"
tellerAsked = "mesh-delivery-teller_asked"
gateAsksKV = "mesh-delivery-gate_asks"
assignerAsks = "mesh-delivery-assigner_asks"
viewAnswers = "mesh-delivery-view_answers"
viewAnswers = viewstate.ViewAnswersBucket
tellerConsumer = "n1_mesh-delivery-teller"
seatAsk = "mesh.seat.operator-channel.accept.ask.mesh-delivery-teller"
seatCancel = "mesh.seat.operator-channel.accept.cancel.mesh-delivery-teller"
@@ -187,7 +96,7 @@ const (
func fullConf(dir string) string {
// ends is the teller's own state (manifest state entry ends), which the controller makes before it starts: a bed
// without it held the start ten seconds on a read the bus never answered, and the tests raced that stall.
teller := append(append(append(append(keepsOf(tellerAnswers), keepsOf(tellerAsked)...), keepsOf(ends.Bucket)...), readOf(gateAsksKV)...),
teller := append(append(append(keepsOf(tellerAnswers), keepsOf(tellerAsked)...), keepsOf(ends.Bucket)...),
seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+routerAsksBucket+".$KV."+routerAsksBucket+".mesh-delivery-teller.>")
teller = append(teller, keepsOf(widensSaidBucket)...) // design 47 G §335: the state widens-said
// The durable consumer on EVENTS the controller makes for the seat it uses, pulled and acknowledged (issue 1126).
@@ -195,14 +104,15 @@ func fullConf(dir string) string {
// The finding writer finds the stream of the keeper's findings-asked (design 47 G §384): the bed has none, so it is
// said and the findings stay in the state.
teller = append(teller, "$JS.API.STREAM.NAMES")
teller = append(teller, "mesh.mod.mesh-delivery-teller.event.answered") // the manifest's emits (ADR 0596 §2)
teller = append(teller, readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads
// The assigner's asks by its read tool and its views family (ADR 0615, DLV-M3), and the teller's own first-views and
// findings; the bucket itself is no longer the teller's to read.
teller = append(append(append(teller, viewstest.Grants(assignerModule, assignerModule+".assigner_read_asks", tellerConsumer)...), keepsOf(firstViewsBucket)...), keepsOf(findingsBucket)...)
view := append(append(append(readOf(tellerAnswers), readOf(gateAsksKV)...), readOf(assignerAsks)...), keepsOf(viewAnswers)...)
// The manifest's emits (ADR 0596 §2; design 47 G A-175: its answers as its views family) and its marks state (A-178).
teller = append(teller, "mesh.mod.mesh-delivery-teller.event.answered", "mesh.mod.mesh-delivery-teller.event.ask-ended", "mesh.mod.mesh-delivery-teller.event.views.answers")
teller = append(teller, keepsOf(marksBucket)...)
// The assigner's asks and the view's answers, each by its read tool and its views family (ADR 0615, DLV-M3; A-175), and the
// teller's own first-views and findings; the buckets themselves are no longer the teller's to read.
teller = append(append(append(teller, viewstest.Grants(assignerModule, assignerModule+".assigner_read_asks", tellerConsumer)...), viewstest.Grants(viewModule, viewModule+"."+viewstate.ViewAnswersReadTool, tellerConsumer)...), keepsOf(firstViewsBucket)...)
teller = append(teller, keepsOf(findingsBucket)...)
view := append(append(readOf(tellerAnswers), readOf(assignerAsks)...), keepsOf(viewAnswers)...)
assigner := keepsOf(assignerAsks)
tools := append(keepsOf(gateAsksKV), readOf(tellerAnswers)...)
return fmt.Sprintf(`
listen: 127.0.0.1:-1
jetstream { store_dir: %q }
@@ -211,14 +121,13 @@ accounts {
jetstream: enabled
users = [
{ user: "ctl", password: "pw" }
%s%s%s%s ]
%s%s%s ]
}
}
`, filepath.Join(dir, "js"),
user("n1.mesh-delivery-teller", teller, nil),
user("n1.mesh-delivery-view", view, nil),
user("n1.mesh-delivery-assigner", assigner, nil),
user("n1.node-tools", tools, nil))
user("n1.mesh-delivery-assigner", assigner, nil))
}
type fullBed struct {
@@ -229,7 +138,7 @@ type fullBed struct {
}
// startFull starts the bus with the principals above and what the controller makes before any module starts: the buckets
// of the gate's asks, the teller's answers and asked, the router's work queue SEAT_OPERATOR_CHANNEL.
// of the assigner's asks, the view's answers, the teller's answers, asked and marks, the router's work queue SEAT_OPERATOR_CHANNEL.
func startFull(t *testing.T) *fullBed {
t.Helper()
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "t")
@@ -266,7 +175,7 @@ func startFull(t *testing.T) *fullBed {
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
for name, history := range map[string]uint8{gateAsksKV: gateAsksHistory, tellerAnswers: answersHistory, tellerAsked: 1, ends.Bucket: 1, routerAsksBucket: 1, assignerAsks: assignerAsksHistory, viewAnswers: viewAnswersHistory, firstViewsBucket: 1, findingsBucket: 1, widensSaidBucket: 1} {
for name, history := range map[string]uint8{tellerAnswers: answersHistory, tellerAsked: 1, ends.Bucket: 1, routerAsksBucket: 1, assignerAsks: 5, viewAnswers: 5, marksBucket: 1, firstViewsBucket: 1, findingsBucket: 1, widensSaidBucket: 1} {
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: history}); err != nil {
t.Fatal(err)
}
@@ -284,6 +193,8 @@ func startFull(t *testing.T) *fullBed {
}
// The assigner as the owner of its asks: its read tool and its views family (ADR 0615).
viewstest.Serve(t, ctx, js, ctl, assignerModule, "asks", assignerModule+".assigner_read_asks", tellerConsumer)
// The view as the owner of its answers, as the teller follows them (A-175, A-178).
viewstest.Serve(t, ctx, js, ctl, viewModule, viewstate.ViewAnswersState, viewModule+"."+viewstate.ViewAnswersReadTool, tellerConsumer)
return &fullBed{srv: srv, ctl: ctl, js: js, ctx: ctx}
}
@@ -332,29 +243,29 @@ func (b *fullBed) put(t *testing.T, js jetstream.JetStream, bucket, key string)
return err
}
// TestCredentialsWriteOnlyTheirOwnBuckets: the teller's credential is refused writing the gate's asks, and the gate's
// (the tool runner's) and the view's are refused writing the teller's answers; each writes where it may, so a refusal is the
// permissions' and not a bus that does not take writes (1343/AC4, ADR 0437 §6's check).
// TestCredentialsWriteOnlyTheirOwnBuckets: the view's credential, which reads the teller's answers, is refused writing them; each
// credential writes its own buckets, so a refusal is the permissions' and not a bus that does not take writes (1343/AC4, ADR
// 0437 §6's check). The teller reads neither the assigner's asks nor the view's answers: it follows them.
func TestCredentialsWriteOnlyTheirOwnBuckets(t *testing.T) { // 1343/AC4
b := startFull(t)
teller, view, tools := b.as(t, "n1.mesh-delivery-teller"), b.as(t, "n1.mesh-delivery-view"), b.as(t, "n1.node-tools")
if err := b.put(t, teller, tellerAnswers, "merge.g.0123456789ab"); err != nil {
t.Errorf("the teller cannot write its own answers: %v", err)
teller, view, assigner := b.as(t, "n1.mesh-delivery-teller"), b.as(t, "n1.mesh-delivery-view"), b.as(t, "n1.mesh-delivery-assigner")
for _, c := range []struct {
who jetstream.JetStream
bucket string
key string
}{{teller, tellerAnswers, "hold.a"}, {teller, marksBucket, "answers"}, {assigner, assignerAsks, "hold.a"}, {view, viewAnswers, "hold.a.view"}} {
if err := b.put(t, c.who, c.bucket, c.key); err != nil {
t.Errorf("a credential cannot write its own %s: %v", c.bucket, err)
}
}
if err := b.put(t, tools, gateAsksKV, "merge.g.0123456789ab"); err != nil {
t.Errorf("the gate's account cannot write the gate's asks: %v", err)
}
if err := b.put(t, teller, gateAsksKV, "merge.g.ffffffffffff"); err == nil {
t.Error("the teller's credential wrote the gate's asks")
}
if err := b.put(t, tools, tellerAnswers, "merge.g.ffffffffffff"); err == nil {
t.Error("the gate's account wrote the teller's answers")
}
if err := b.put(t, view, tellerAnswers, "merge.g.ffffffffffff"); err == nil {
t.Error("the view's credential wrote the teller's answers")
}
// What was refused is not there.
for _, c := range []struct{ bucket, key string }{{gateAsksKV, "merge.g.ffffffffffff"}, {tellerAnswers, "merge.g.ffffffffffff"}} {
for _, c := range []struct {
who jetstream.JetStream
bucket string
key string
}{{view, tellerAnswers, "hold.f"}} {
if err := b.put(t, c.who, c.bucket, c.key); err == nil {
t.Errorf("a credential wrote %s, which is not its own", c.bucket)
}
kv, _ := b.js.KeyValue(b.ctx, c.bucket)
if _, err := kv.Get(b.ctx, c.key); err == nil {
t.Errorf("%s holds %s though its write was refused", c.bucket, c.key)
@@ -362,14 +273,14 @@ func TestCredentialsWriteOnlyTheirOwnBuckets(t *testing.T) { // 1343/AC4
}
}
// TestTellerCarriesAnAskAndWritesTheAnswer starts the program on the composed principal against the bus: the gate's account
// writes an ask, the teller publishes it to the seat's work queue under its own name, the router (the test) says the
// warrant on the teller's decided subject, and the answer is under the ask's key in the teller's bucket; withdrawing a
// second ask cancels it (1343/AC1, AC2).
// TestTellerCarriesAnAskAndWritesTheAnswer starts the program on the composed principal against the bus: the assigner writes
// a hold ask, the teller publishes it to the seat's work queue under its own name, the router (the test) says the warrant on
// the teller's decided subject, and the answer is under the ask's key in the teller's bucket and said as the event answered
// and on the views family views.answers (A-175); taking a second ask away cancels it (1343/AC1, AC2, 1906/H1, H2).
func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
t.Cleanup(nowIs(time.Now().UTC()))
b := startFull(t)
tools := b.as(t, "n1.node-tools")
assigner := b.as(t, "n1.mesh-delivery-assigner")
broker := filepath.Join(t.TempDir(), "broker")
cred, _ := json.Marshal(process.Account{URL: b.srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
@@ -408,8 +319,8 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
t.Fatalf("the router's record read as the teller: %v, %v; want it", rec, err)
}
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
kv, err := tools.KeyValue(b.ctx, gateAsksKV)
key, value, ask := holdAsk(t)
kv, err := assigner.KeyValue(b.ctx, assignerAsks)
if err != nil {
t.Fatal(err)
}
@@ -432,11 +343,11 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
t.Fatalf("no ask on the seat's queue: %v\n%s", err, logs.String())
}
var asked oc.Ask
if err := json.Unmarshal(raw.Data, &asked); err != nil || occ.Digest(asked) != occ.Digest(ga.Ask) {
t.Fatalf("the ask on the queue is %+v (%v), want the gate's", asked, err)
if err := json.Unmarshal(raw.Data, &asked); err != nil || occ.Digest(asked) != occ.Digest(ask) {
t.Fatalf("the ask on the queue is %+v (%v), want the assigner's", asked, err)
}
// The router says the warrant.
w, _ := json.Marshal(warrantFor(ga.Ask, "approve"))
w, _ := json.Marshal(ackFor(ask, "release"))
if err := b.ctl.Publish(seatDecided, w); err != nil {
t.Fatal(err)
}
@@ -453,10 +364,10 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
if e == nil {
t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String())
}
if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" {
if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "release" {
t.Errorf("answer %+v err %v", a, err)
}
// The answer is said as the event answered, which the gate hears (ADR 0596 §2).
// The answer is said as the event answered (ADR 0596 §2) and on the views family views.answers (A-175).
events, err := b.js.Stream(b.ctx, process.EventsStream)
if err != nil {
t.Fatal(err)
@@ -470,17 +381,28 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
if said == nil {
t.Fatalf("the answer was not said as the event answered: %v\n%s", err, logs.String())
}
if got := string(said.Data); !strings.Contains(got, `"key":"`+key+`"`) || !strings.Contains(got, `"option":"approve"`) {
if got := string(said.Data); !strings.Contains(got, `"key":"`+key+`"`) || !strings.Contains(got, `"option":"release"`) {
t.Errorf("the event answered is %s", got)
}
// A second ask, withdrawn by the gate, is cancelled at the router.
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}}
key2, value2, ga2 := gateAsk(t, other, other)
if vs, err := events.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-teller.event.views.answers"); err != nil || !strings.Contains(string(vs.Data), key) {
t.Errorf("the answer was not said on the views family views.answers: %v\n%s", err, logs.String())
}
// A second ask, taken away by the assigner, is cancelled at the router.
key2, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat("3", 64))
if err != nil {
t.Fatal(err)
}
ask2 := ask
ask2.ID = meshdeliveryclient.AskID(key2)
value2, err := asks.EncodeAsk(key2, asks.Ask{Ask: rawAsk(t, ask2)})
if err != nil {
t.Fatal(err)
}
if _, err := kv.Put(b.ctx, key2, value2); err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if _, err := seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil && strings.Contains(logs.String(), ga2.Ask.ID) {
if strings.Contains(logs.String(), ask2.ID+" is asked") {
break
}
}
@@ -492,19 +414,19 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
break
}
}
if err != nil || string(raw.Data) != `{"id":"`+ga2.Ask.ID+`"}` {
t.Errorf("the withdrawn ask was not cancelled: %v %v\n%s", raw, err, logs.String())
if err != nil || string(raw.Data) != `{"id":"`+ask2.ID+`"}` {
t.Errorf("the ask taken away was not cancelled: %v %v\n%s", raw, err, logs.String())
}
}
// TestTellerAsksAgainAnAskRefusedForWantOfRoom starts the program against the bus: the router (the test) refuses the gate's
// TestTellerAsksAgainAnAskRefusedForWantOfRoom starts the program against the bus: the router (the test) refuses the assigner's
// ask for want of room, as the live router did on 2026-10-21 (issue 1610), and then says another of the teller's asks
// lapsed; the refused ask is on the seat's work queue again, which the bus's duplicate window would have dropped under its
// first message id (1610/AC1 on the real bus).
func TestTellerAsksAgainAnAskRefusedForWantOfRoom(t *testing.T) { // 1610/AC1
t.Cleanup(nowIs(time.Now().UTC()))
b := startFull(t)
tools := b.as(t, "n1.node-tools")
assigner := b.as(t, "n1.mesh-delivery-assigner")
broker := filepath.Join(t.TempDir(), "broker")
cred, _ := json.Marshal(process.Account{URL: b.srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
@@ -514,7 +436,7 @@ func TestTellerAsksAgainAnAskRefusedForWantOfRoom(t *testing.T) { // 1610/AC1
logs := &syncBuffer{}
rn := startRunner(b.ctx, func(c context.Context) error { return run(c, func(k string) string { return env[k] }, logs) })
t.Cleanup(func() { rn.finish(t, 10*time.Second) })
kv, err := tools.KeyValue(b.ctx, gateAsksKV)
kv, err := assigner.KeyValue(b.ctx, assignerAsks)
if err != nil {
t.Fatal(err)
}
@@ -547,31 +469,30 @@ func TestTellerAsksAgainAnAskRefusedForWantOfRoom(t *testing.T) { // 1610/AC1
}
}
key, value, held := gateAsk(t, twoHeads(), twoHeads())
key, value, held := holdAskAt(t, 1, 1)
if _, err := kv.Put(b.ctx, key, value); err != nil {
t.Fatal(err)
}
first := lastAsk(held.Ask.ID, 0)
say(oc.Warrant{Ask: held.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: roomRefusal, At: occ.FormatTime(now)})
first := lastAsk(held.ID, 0)
say(oc.Warrant{Ask: held.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: roomRefusal, At: occ.FormatTime(now)})
for deadline := time.Now().Add(10 * time.Second); !strings.Contains(logs.String(), "for want of room"); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("the refusal was not heard\n%s", logs.String())
}
}
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}}
key2, value2, ga2 := gateAsk(t, other, other)
key2, value2, ga2 := holdAskAt(t, 2, 1)
if _, err := kv.Put(b.ctx, key2, value2); err != nil {
t.Fatal(err)
}
second := lastAsk(ga2.Ask.ID, first)
second := lastAsk(ga2.ID, first)
// The other ask lapses at the router: there is room, and the refused ask is asked again.
say(oc.Warrant{Ask: ga2.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga2.Ask)})
lastAsk(held.Ask.ID, second)
say(oc.Warrant{Ask: ga2.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga2)})
lastAsk(held.ID, second)
}
// TestTellerFillsAnAskFromTheViewsAnswerOnce starts the program against the bus with the permissions above: the assigner's
// fixture hold ask is written, then the view's answer under `<ask key>.view`; the answer is under the ask's own key, once
// (rewriting the view's answer does not write a second), and a view answer under the merge's key fills nothing
// (rewriting the view's answer does not write a second), and a view answer under a key that is no assigner's (a merge's) fills nothing
// (1297/B10 AC: a view answer fills the ask once; an ask needing proof is not answered from a view key).
func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC
t.Cleanup(nowIs(time.Now().UTC()))
@@ -629,7 +550,7 @@ func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC
t.Errorf("the answer was written a second time: %v", err)
}
// An answer under the merge's key fills nothing.
mkey, mvalue, _ := gateAsk(t, twoHeads(), twoHeads())
mkey, mvalue := "merge.g.0123456789ab", value
if _, err := askKV.Put(b.ctx, mkey, mvalue); err != nil {
t.Fatal(err)
}
@@ -642,102 +563,6 @@ func TestTellerFillsAnAskFromTheViewsAnswerOnce(t *testing.T) { // 1297/B10 AC
}
}
// TestTellerRaisesAnAssignerAskAndWritesTheOperatorsAnswer starts the program against the bus: the assigner's hold ask, put in
// its bucket, is on the operator channel's queue as the teller's ask; the router's warrant for "release" is written under the
// ask's key, where the assigner reads it; a second ask the assigner takes away (a hold it lifted) is cancelled at the router
// (1906/H1, H2 on the real bus).
func TestTellerRaisesAnAssignerAskAndWritesTheOperatorsAnswer(t *testing.T) {
t.Cleanup(nowIs(time.Now().UTC()))
b := startFull(t)
assigner := b.as(t, "n1.mesh-delivery-assigner")
broker := filepath.Join(t.TempDir(), "broker")
cred, _ := json.Marshal(process.Account{URL: b.srv.ClientURL(), Node: "n1", Module: "mesh-delivery-teller", User: "n1.mesh-delivery-teller", Password: "pw"})
if err := os.WriteFile(broker, cred, 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{"MESH_BROKER_FILE": broker}
logs := &syncBuffer{}
rn := startRunner(b.ctx, func(c context.Context) error { return run(c, func(k string) string { return env[k] }, logs) })
t.Cleanup(func() { rn.finish(t, 10*time.Second) })
key, value, ask := holdAsk(t)
askKV, err := assigner.KeyValue(b.ctx, assignerAsks)
if err != nil {
t.Fatal(err)
}
b.listening(t) // the program hears warrants and watches
if _, err := askKV.Put(b.ctx, key, value); err != nil {
t.Fatal(err)
}
seat, err := b.js.Stream(b.ctx, "SEAT_OPERATOR_CHANNEL")
if err != nil {
t.Fatal(err)
}
var raw *jetstream.RawStreamMsg
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if raw, err = seat.GetLastMsgForSubject(b.ctx, seatAsk); err == nil {
break
}
}
if raw == nil {
t.Fatalf("the assigner's ask is not on the seat's queue: %v\n%s", err, logs.String())
}
var asked oc.Ask
if err := json.Unmarshal(raw.Data, &asked); err != nil || asked.ID != ask.ID || occ.Digest(asked) != occ.Digest(ask) {
t.Fatalf("the ask on the queue is %+v (%v), want the assigner's", asked, err)
}
w, _ := json.Marshal(ackFor(ask, "release"))
if err := b.ctl.Publish(seatDecided, w); err != nil {
t.Fatal(err)
}
answers, err := b.js.KeyValue(b.ctx, tellerAnswers)
if err != nil {
t.Fatal(err)
}
var e jetstream.KeyValueEntry
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if e, err = answers.Get(b.ctx, answerKey(key)); err == nil {
break
}
}
if e == nil {
t.Fatalf("no answer under %s: %v\n%s", key, err, logs.String())
}
if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "release" || answeredAsking(e.Value()) != ask.ID {
t.Fatalf("answer %+v err %v", a, err)
}
key2, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat("3", 64))
if err != nil {
t.Fatal(err)
}
ask2 := ask
ask2.ID = meshdeliveryclient.AskID(key2)
value2, err := asks.EncodeAsk(key2, asks.Ask{Ask: rawAsk(t, ask2)})
if err != nil {
t.Fatal(err)
}
if _, err := askKV.Put(b.ctx, key2, value2); err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if strings.Contains(logs.String(), ask2.ID+" is asked") {
break
}
}
if err := askKV.Delete(b.ctx, key2); err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if raw, err = seat.GetLastMsgForSubject(b.ctx, seatCancel); err == nil {
break
}
}
if err != nil || string(raw.Data) != `{"id":"`+ask2.ID+`"}` {
t.Errorf("the ask taken away was not cancelled: %v %v\n%s", raw, err, logs.String())
}
}
// TestTellerTakesAChangedHoldAnswerFromTheView starts the program against the bus: a hold ask answered "keep" earlier, and the
// view's answer "release" written now, replace the answer of that asking with "release" (1906/N23 on the real bus).
func TestTellerTakesAChangedHoldAnswerFromTheView(t *testing.T) {
@@ -101,13 +101,14 @@ func (a kvAnswers) Write(key string, value []byte, rev uint64, found bool) (bool
return err == nil, err
}
// kvGate reads the gate's bucket asks, which the teller may watch and read and never write.
type kvGate struct {
// kvReader reads the picture of another module's state the teller follows (the assigner's asks, the view's answers), which it
// may watch and read and never write.
type kvReader struct {
ctx context.Context
kv jetstream.KeyValue
}
func (g kvGate) Get(key string) ([]byte, bool, error) {
func (g kvReader) Get(key string) ([]byte, bool, error) {
e, err := g.kv.Get(g.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, false, nil
@@ -119,7 +120,7 @@ func (g kvGate) Get(key string) ([]byte, bool, error) {
}
// GetAt is Get with the time the value was written.
func (g kvGate) GetAt(key string) ([]byte, time.Time, bool, error) {
func (g kvReader) GetAt(key string) ([]byte, time.Time, bool, error) {
e, err := g.kv.Get(g.ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, time.Time{}, false, nil
@@ -1,15 +1,14 @@
// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, §6, §9; issues 1240, 1343). It carries the
// gate's merge asks to the operator and writes the answers back: it watches the gate's asks, asks each of the router as
// itself, and on the router's warrant, once it holds, writes the answer under the ask's key. Where the gate's asks
// bucket does not exist yet it says once that it waits for the gate's asks and does nothing: waiting for an absent bucket
// needs a timer (ADR 0437 §8 lets the parts hold none), so it is started again with the gate. It serves no request
// subject: its one tool, teller_end_ask (the program mesh-delivery-teller-tools, issue 774), asks through its state ends,
// which it watches.
// Command mesh-delivery-teller is delivery's teller (novox/hq ADR 0437 §4, §6, §9; issues 1240, 1343; design 47 G §653,
// A-172). It carries the assigner's asks to the operator and writes the answers back: it follows the assigner's asks (the
// first view of its read tool, then its views family), asks each of the router as itself, and on the router's warrant, once
// it holds, writes the answer under the ask's key, and says its state answers as its views family (A-175). It follows the
// view's answers the same way and fills an ask from the operator's answer in the view. It serves no request subject: its
// one tool beside the readers, teller_end_ask (the program mesh-delivery-teller-tools, issue 774), asks through its state
// ends, which it watches.
package main
import (
"context"
"errors"
"fmt"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
"io"
@@ -25,6 +24,8 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
"mesh-delivery-teller/internal/ends"
)
@@ -49,16 +50,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
defer conn.Close()
gate, err := js.KeyValue(ctx, gateAsksBucket)
if errors.Is(err, jetstream.ErrBucketNotFound) {
logger.Print(waiting)
<-ctx.Done()
return nil
}
if err != nil {
// Essential: the gate's asks are what the teller tells (issue 1945: the error names the grant).
return process.EssentialErr("teller", gateAsksBucket, "tell any of the gate's asks", err)
}
// The answers it writes and what it asked: its own work, so each stops the start, naming its grant.
var kvs [3]jetstream.KeyValue
for i, name := range []string{answersBucket, askedBucket, widensSaidBucket} {
@@ -85,7 +76,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
defer calls.Close()
client := &occ.Client{Module: tellerModule, State: state, Publish: publish,
Record: recordVia(calls.Call, calls.Ask), Subscribe: decidedVia(hub.Handle, logger.Printf)}
t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]}, Gate: kvGate{ctx: ctx, kv: gate},
t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]},
Say: sayVia(ctx, js, tellerModule), Logf: logger.Printf, Publish: publish,
// The teller's own events (its manifest's emits), on EVENTS under its module's subject.
Emit: func(event string, body []byte, id string) error {
@@ -111,8 +102,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err := client.Listen(t.Answer, t.Said); err != nil {
return fmt.Errorf("hearing the router's warrants: %w", err)
}
// The view's answers to the assigner's asks (issue 1297 B10): both buckets are the controller's to create, and a bucket not
// there is said and leaves the teller without that path rather than without the gate's asks (a wait for it needs a timer).
failed := make(chan error, 4)
t.Answered = func(key, askID string, answer []byte) error {
return calls.Emit(answeredEvent, Answered{Key: key, Ask: askID, Answer: answer}, stdio.EmitOptions{MessageID: answeredID(key, askID)})
@@ -129,25 +118,33 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
}
assignerKV := jetstream.KeyValue(asks)
// The view's answers to the assigner's asks (issue 1297 B10, A-175): followed as the assigner's asks are, never its bucket.
answered, err := reader.Follow(viewModule, viewstate.ViewAnswersState, viewModule+"."+viewstate.ViewAnswersReadTool)
if err != nil {
return err
}
viewKV := jetstream.KeyValue(answered)
reader.Start(ctx, js, cred, process.AwaitBucket(ctx, js, conn, firstViewsBucket, "teller_first_views reads nothing and teller_first_view_again is not heard", logger.Printf))
// Every part has registered: the hub pulls the module's durable.
if err := hub.Start(ctx, js, cred); err != nil {
return fmt.Errorf("the consumer %s on %s cannot be read (%v): the teller hears no answer without it, so it stops", cred.EventConsumerName(), process.EventsStream, err)
}
viewKV := process.OptionalBucket(ctx, js, viewAnswersBucket, teller, logger.Printf)
// The teller says its own answers as its views family (A-175, A-178): once its marks state is bound.
process.AwaitBucket(ctx, js, conn, marksBucket, "no answer is said on the views family, so the view waits on its first view", logger.Printf).Ready(func(mk jetstream.KeyValue) {
em := views.Emitter{Owner: tellerModule, Subject: cred.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, Findings: writer, Logf: logger.Printf}
if err := em.Start(ctx, answersState, kvs[0], failed); err != nil {
logger.Printf("%v: the view asks the first view of the answers at each change until the teller starts again", err)
}
})
// The assigner's asks are raised on the operator channel whether or not the view's answers can be read (issue 1906).
if viewKV != nil {
t.View = kvGate{ctx: ctx, kv: viewKV}
}
t.Assigner = kvGate{ctx: ctx, kv: assignerKV}
t.View = kvReader{ctx: ctx, kv: viewKV}
t.Assigner = kvReader{ctx: ctx, kv: assignerKV}
go func() {
failed <- watchAsks(ctx, assignerKV, func(key string, _ uint64, _ []byte) { t.OnAssignerAsk(key) }, t.OnAssignerAskGone, nil)
failed <- watchAsks(ctx, assignerKV, func(key string, _ uint64, _ []byte) { t.OnAssignerAsk(key) }, t.OnAssignerAskGone, t.OnCurrent)
}()
go func() {
failed <- watchAsks(ctx, viewKV, func(key string, _ uint64, _ []byte) { t.OnViewAnswer(key) }, func(string) {}, nil)
}()
if viewKV != nil {
go func() {
failed <- watchAsks(ctx, viewKV, func(key string, _ uint64, _ []byte) { t.OnViewAnswer(key) }, func(string) {}, nil)
}()
}
// A person ends one ask by hand with teller_end_ask, which writes the state ends (issue 774): watched as the gate's asks
// are, once it is on the bus.
process.AwaitBucket(ctx, js, conn, ends.Bucket, "teller_end_ask is not answered; the asks still end when they are answered, lapse or their key is taken away", logger.Printf).Ready(func(kv jetstream.KeyValue) {
@@ -157,11 +154,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
}()
})
go func() {
if err := watchAsks(ctx, gate, t.OnAsk, t.OnAskGone, t.OnCurrent); err != nil {
failed <- fmt.Errorf("watching the gate's asks: %w", err)
}
}()
select {
case <-ctx.Done():
return nil
@@ -173,9 +165,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
}
// teller is what an optional bucket of the teller leaves out.
const teller = "the asks and answers it holds are not carried until the teller is started again with it"
// askedBucket is the teller's state bucket asked, `<module>_<name>`: what it asked of the router and which condition it
// raised (manifest state entry asked, novox/hq ADR 0437 §4).
const askedBucket = tellerModule + "_asked"
@@ -190,5 +179,5 @@ var (
// assignerAsksState is the assigner's state of its asks (assignerAsksBucket).
const assignerAsksState = "asks"
// waiting is said once at start.
const waiting = "waiting for the gate's asks, whose bucket does not exist yet; the teller is started again with the gate, and nothing is read, written or published until then"
// marksBucket is the teller's state of what its views family said its answers up to (A-178).
const marksBucket = tellerModule + "_" + marksState
@@ -2,31 +2,24 @@ package main
import "encoding/json"
// The holders and buckets the teller names, taken from its own manifest (module.json: its name, its `reads`) and the
// states it keeps itself; a unit row checks them against it (TestTheNamesAreTheManifests). No contract names a holder
// (novox/hq ADR 0625 §4, ADR 0634 §1).
// The holders and buckets the teller names, taken from its own manifest (module.json: its name, what it invokes and
// consumes) and the states it keeps itself; a unit row checks them against it (TestTheNamesAreTheManifests). No contract
// names a holder (novox/hq ADR 0625 §4, ADR 0634 §1).
const (
tellerModule = "mesh-delivery-teller"
assignerModule = "mesh-delivery-assigner"
gateModule = "mesh-delivery-gate"
viewModule = "mesh-delivery-view"
// gateAsksBucket is the gate's state asks, read by the teller (reads: mesh-delivery-gate.asks).
gateAsksBucket = gateModule + "_asks"
// viewAnswersBucket is the view's state answers, read by the teller (reads: mesh-delivery-view.answers).
viewAnswersBucket = viewModule + "_answers"
// answersBucket is the teller's own state answers, which the gate reads.
answersBucket = tellerModule + "_answers"
// answersState is the teller's own state answers, said as its views family views.answers, and marksState the state of
// what that family said it up to.
answersState = "answers"
marksState = "teller-marks"
// answersBucket is the teller's own state answers.
answersBucket = tellerModule + "_" + answersState
// widensSaidBucket is the teller's own state widens-said, the last commit it said a widening of per module and branch.
widensSaidBucket = tellerModule + "_" + widensSaidState
// assignerAsksBucket is the assigner's state asks (the first view of assigner_read_asks stands in for it).
assignerAsksBucket = assignerModule + "_asks"
// The revisions each keeps per key.
gateAsksHistory = 5
answersHistory = 5
assignerAsksHistory = 5
viewAnswersHistory = 5
// The revisions the teller's answers keep per key.
answersHistory = 5
)
// answerKey is the key, in the teller's answers, of its answer to the ask at askKey: the same string.
@@ -5,19 +5,25 @@ import (
"os"
"slices"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate"
)
// TestTheNamesAreTheManifests: the holders and buckets the teller names are its manifest's (its module, its `reads`, its
// own state answers), since no contract names a holder (novox/hq ADR 0634 §1).
// TestTheNamesAreTheManifests: the holders, tools and buckets the teller names are its manifest's (its module, what it invokes
// and consumes, its own states answers and teller-marks, no `reads`), since no contract names a holder (novox/hq ADR 0634 §1;
// design 47 G §653, A-175, A-178).
func TestTheNamesAreTheManifests(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Module string `json:"module"`
Reads []string `json:"reads"`
State []struct {
Module string `json:"module"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Consumes []string `json:"consumes"`
Emits []string `json:"emits"`
State []struct {
Name string `json:"name"`
} `json:"state"`
}
@@ -27,16 +33,25 @@ func TestTheNamesAreTheManifests(t *testing.T) {
if m.Module != tellerModule {
t.Errorf("the manifest's module is %q, the teller names itself %q", m.Module, tellerModule)
}
for _, read := range []string{gateModule + ".asks", viewModule + ".answers"} {
if !slices.Contains(m.Reads, read) {
t.Errorf("the teller reads %s, its manifest's reads are %v", read, m.Reads)
}
if len(m.Reads) != 0 {
t.Errorf("the teller reads no other module's state, its manifest's reads are %v", m.Reads)
}
if want := viewModule + "." + viewstate.ViewAnswersReadTool; !slices.Contains(m.Invokes, want) {
t.Errorf("the teller follows the view's answers by %s, its manifest's invokes are %v", want, m.Invokes)
}
if want := viewModule + ".views." + viewstate.ViewAnswersState; !slices.Contains(m.Consumes, want) {
t.Errorf("the teller follows %s, its manifest's consumes are %v", want, m.Consumes)
}
if want := "views." + answersState; !slices.Contains(m.Emits, want) {
t.Errorf("the teller says %s, its manifest's emits are %v", want, m.Emits)
}
var own []string
for _, s := range m.State {
own = append(own, tellerModule+"_"+s.Name)
}
if !slices.Contains(own, answersBucket) {
t.Errorf("the teller writes %s, its manifest's states are %v", answersBucket, own)
for _, want := range []string{answersBucket, marksBucket} {
if !slices.Contains(own, want) {
t.Errorf("the teller keeps %s, its manifest's states are %v", want, own)
}
}
}
@@ -6,8 +6,6 @@ import (
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"strings"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
)
// roomRefusal is the router's words when an asker already holds occ.MostOpen open asks, as the live router said them to
@@ -16,20 +14,6 @@ import (
// modules/messenger cmd/messenger/asks.go:529-531).
const roomRefusal = "its asker already has 3 open asks"
// headsN is one pull request's head, distinct per n, so each n is an ask of its own key.
func headsN(n int) []meshdeliveryclient.Head {
return []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 10 + n, Head: strings.Repeat(string(rune('a'+n)), 40)}}
}
// askOf puts the gate's key for n and hands it to the teller, as the watch would.
func (b *bed) askOf(t *testing.T, n int, rev uint64) (string, gateAsked) {
t.Helper()
key, value, ga := gateAsk(t, headsN(n), headsN(n))
b.gate[key] = value
b.t.OnAsk(key, rev, value)
return key, ga
}
// refuse is the router's refusal of an ask: it keeps no record of a refused ask (asks.go refuseAsk only tells its asker),
// so the id may be asked again.
func (b *bed) refuse(t *testing.T, id, words string) {
@@ -40,9 +24,9 @@ func (b *bed) refuse(t *testing.T, id, words string) {
}
// expire is the router's word that an ask of the teller's lapsed with nobody's answer: one of its asks ended.
func (b *bed) expire(t *testing.T, ga gateAsked) {
func (b *bed) expire(t *testing.T, ask oc.Ask) {
t.Helper()
b.deliver(t, oc.Warrant{Ask: ga.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ga.Ask)})
b.deliver(t, oc.Warrant{Ask: ask.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(ask)})
}
// askedIDs is the router ids of every ask the bus carried, in order.
@@ -58,87 +42,84 @@ func (b *bed) askedIDs() []string {
return out
}
// The live fault of issue 1610: three asks open, a fourth refused for want of room, and the operator then answers one of
// the three. The fourth is asked again, under its own router id and a new bus message id, and nothing is said as a fault.
// The live fault of issue 1610: asks open, one refused by the router for want of room, and the operator then answers one of the
// open. The refused is asked again, under its own router id and a new bus message id, and nothing is said as a fault.
func TestAnAskRefusedForWantOfRoomIsAskedAgainWhenAnotherEnds(t *testing.T) { // 1610/AC1
b := newBed(t)
_, first := b.askOf(t, 1, 1)
b.askOf(t, 2, 2)
b.askOf(t, 3, 3)
_, fourth := b.askOf(t, 4, 4)
b.refuse(t, fourth.Ask.ID, roomRefusal)
_, first := b.holdOf(t, 1, 1)
key2, second := b.holdOf(t, 2, 1)
b.refuse(t, second.ID, roomRefusal)
if len(b.conds) != 0 {
t.Errorf("a refusal for want of room is said as a fault before it was asked again: %+v", b.conds)
}
if got := b.askedIDs(); len(got) != 4 {
if got := b.askedIDs(); len(got) != 2 {
t.Fatalf("asked %v before room came", got)
}
b.deliver(t, warrantFor(first.Ask, "approve"))
b.deliver(t, warrantFor(first, "keep"))
got := b.askedIDs()
if len(got) != 5 || got[4] != fourth.Ask.ID {
t.Fatalf("asks = %v, want the refused %s asked again once the first was answered", got, fourth.Ask.ID)
if len(got) != 3 || got[2] != second.ID {
t.Fatalf("asks = %v, want the refused %s asked again once the first was answered", got, second.ID)
}
pubs := b.askedOf(oc.AcceptSubjectAsk(tellerModule))
if pubs[4].id == pubs[3].id {
t.Errorf("asked again under the bus message id %s of its first asking: the bus drops it as a duplicate", pubs[4].id)
if pubs[2].id == pubs[1].id {
t.Errorf("asked again under the bus message id %s of its first asking: the bus drops it as a duplicate", pubs[2].id)
}
if len(b.busDropped) != 0 || len(b.dropped) != 0 || len(b.conds) != 0 {
t.Errorf("bus dropped %v, router dropped %v, conditions %+v", b.busDropped, b.dropped, b.conds)
}
// Taken this time: an answer to it writes the answer as for any ask.
key4, _ := meshdeliveryclient.MergeAskKey("g", headsN(4))
b.deliver(t, warrantFor(fourth.Ask, "approve"))
if _, ok := b.answers.written[answerKey(key4)]; !ok {
b.deliver(t, warrantFor(second, "keep"))
if _, ok := b.answers.written[answerKey(key2)]; !ok {
t.Errorf("the answer to the ask asked again was not written; logs:\n%s", b.logs.String())
}
// And it is asked no more: its answer ends its wait.
_, second := b.askOf(t, 5, 5)
b.expire(t, second)
if got := b.askedIDs(); len(got) != 6 || got[5] == fourth.Ask.ID {
t.Errorf("asks after the answer = %v, want only the fifth asked", got)
_, third := b.holdOf(t, 3, 1)
b.expire(t, third)
if got := b.askedIDs(); len(got) != 4 || got[3] == second.ID {
t.Errorf("asks after the answer = %v, want only the third asked", got)
}
}
func TestAnAskRefusedForWantOfRoomIsNotAskedAgainOnceTheGateMovedOn(t *testing.T) { // 1610/AC2
func TestAnAskRefusedForWantOfRoomIsNotAskedAgainOnceTheAssignerMovedOn(t *testing.T) { // 1610/AC2
b := newBed(t)
_, first := b.askOf(t, 1, 1)
key, gone := b.askOf(t, 2, 2)
b.refuse(t, gone.Ask.ID, roomRefusal)
// Withdrawn by the gate (a head moved on) before room came.
delete(b.gate, key)
b.t.OnAskGone(key)
key3, g3 := b.askOf(t, 3, 3)
b.refuse(t, g3.Ask.ID, roomRefusal)
// Rewritten by the gate under its next asking before room came: the later asking is asked under its own id, and the
_, first := b.holdOf(t, 1, 1)
key, gone := b.holdOf(t, 2, 1)
b.refuse(t, gone.ID, roomRefusal)
// Taken away by the assigner (the hold lifted) before room came.
delete(b.assigner, key)
b.t.OnAssignerAskGone(key)
key3, g3 := b.holdOf(t, 3, 1)
b.refuse(t, g3.ID, roomRefusal)
// Rewritten by the assigner under its next asking before room came: the later asking is asked under its own id, and the
// refused earlier asking is not asked again.
_, v3b, g3b := gateAskN(t, headsN(3), headsN(3), 2)
b.gate[key3] = v3b
b.t.OnAsk(key3, 4, v3b)
_, v3b, g3b := holdAskAt(t, 3, 2)
b.assigner[key3] = v3b
b.t.OnAssignerAsk(key3)
before := len(b.askedIDs())
if ids := b.askedIDs(); ids[before-1] != g3b.Ask.ID {
t.Fatalf("asks = %v, want the next asking %s asked", ids, g3b.Ask.ID)
if ids := b.askedIDs(); ids[before-1] != g3b.ID {
t.Fatalf("asks = %v, want the next asking %s asked", ids, g3b.ID)
}
b.deliver(t, warrantFor(first.Ask, "approve"))
b.deliver(t, warrantFor(first, "keep"))
b.expire(t, g3b)
if after := b.askedIDs()[before:]; len(after) != 0 {
t.Errorf("asked again %v, which the gate no longer asks", after)
t.Errorf("asked again %v, which the assigner no longer asks", after)
}
}
func TestAnAskRefusedForWantOfRoomTooOftenIsSaidAsACondition(t *testing.T) { // 1610/AC3
b := newBed(t)
_, held := b.askOf(t, 1, 1)
_, held := b.holdOf(t, 1, 1)
for i := 0; i < mostRoomRefusals; i++ {
if len(b.conds) != 0 {
t.Fatalf("a condition after %d refusals: %+v", i, b.conds)
}
b.refuse(t, held.Ask.ID, roomRefusal)
_, other := b.askOf(t, 2+i, uint64(2+i))
b.refuse(t, held.ID, roomRefusal)
_, other := b.holdOf(t, 2+i, 1)
b.expire(t, other)
}
var again int
for _, id := range b.askedIDs() {
if id == held.Ask.ID {
if id == held.ID {
again++
}
}
@@ -149,7 +130,7 @@ func TestAnAskRefusedForWantOfRoomTooOftenIsSaidAsACondition(t *testing.T) { //
if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised || !strings.Contains(b.conds[0].Summary, "open asks") {
t.Fatalf("conditions = %+v", b.conds)
}
_, last := b.askOf(t, 20, 20)
_, last := b.holdOf(t, 8, 1)
b.expire(t, last)
if len(b.askedIDs()) != asked+1 {
t.Errorf("asked again after the bound: %v", b.askedIDs())
@@ -158,47 +139,47 @@ func TestAnAskRefusedForWantOfRoomTooOftenIsSaidAsACondition(t *testing.T) { //
func TestAnAskWaitingForRoomIsAskedAgainAtStart(t *testing.T) { // 1610/AC4
b := newBed(t)
key, held := b.askOf(t, 1, 1)
b.refuse(t, held.Ask.ID, roomRefusal)
key, held := b.holdOf(t, 1, 1)
b.refuse(t, held.ID, roomRefusal)
// The teller starts again: a new teller on the same buckets, and the watch hands over the key it holds.
b2 := newBed(t)
b2.store, b2.answers, b2.gate = b.store, b.answers, b.gate
b2.store, b2.answers, b2.assigner = b.store, b.answers, b.assigner
b2.client.State = b.store
b2.t.Store, b2.t.Answers, b2.t.Gate = b.store, b.answers, b.gate
b2.t.OnAsk(key, 1, b.gate[key])
b2.t.Store, b2.t.Answers, b2.t.Assigner = b.store, b.answers, b.assigner
b2.t.OnAssignerAsk(key)
if len(b2.askedIDs()) != 0 {
t.Fatalf("asked before the watch handed over its keys: %v", b2.askedIDs())
}
b2.t.OnCurrent()
if got := b2.askedIDs(); len(got) != 1 || got[0] != held.Ask.ID {
t.Errorf("asks at start = %v, want %s asked again", got, held.Ask.ID)
if got := b2.askedIDs(); len(got) != 1 || got[0] != held.ID {
t.Errorf("asks at start = %v, want %s asked again", got, held.ID)
}
}
func TestAnAskRefusedForItsWordsIsSaidAtOnceAndNotAskedAgain(t *testing.T) { // 1610/AC5
b := newBed(t)
_, first := b.askOf(t, 1, 1)
_, bad := b.askOf(t, 2, 2)
b.refuse(t, bad.Ask.ID, "its words carry an address, which may not leave the mesh")
_, first := b.holdOf(t, 1, 1)
_, bad := b.holdOf(t, 2, 1)
b.refuse(t, bad.ID, "its words carry an address, which may not leave the mesh")
if len(b.conds) != 1 {
t.Fatalf("conditions = %+v", b.conds)
}
b.deliver(t, warrantFor(first.Ask, "approve"))
b.deliver(t, warrantFor(first, "keep"))
if got := b.askedIDs(); len(got) != 2 {
t.Errorf("asks = %v: an ask refused for its words was asked again", got)
}
}
// restart is a new teller on the same buckets and the same bus (its message ids still in the bus's duplicate window), and
// the router's records as they stand; the watch hands over the gate's keys, then says it has handed over all.
// the router's records as they stand; the watch hands over the assigner's keys, then says it has handed over all.
func (b *bed) restart(t *testing.T) *bed {
t.Helper()
n := newBed(t)
n.store, n.answers, n.gate, n.records, n.msgIDs, n.seenIDs = b.store, b.answers, b.gate, b.records, b.msgIDs, b.seenIDs
n.store, n.answers, n.assigner, n.records, n.msgIDs, n.seenIDs = b.store, b.answers, b.assigner, b.records, b.msgIDs, b.seenIDs
n.client.State = b.store
n.t.Store, n.t.Answers, n.t.Gate = b.store, b.answers, b.gate
for key, value := range b.gate {
n.t.OnAsk(key, 1, value)
n.t.Store, n.t.Answers, n.t.Assigner = b.store, b.answers, b.assigner
for key := range b.assigner {
n.t.OnAssignerAsk(key)
}
n.t.OnCurrent()
return n
@@ -209,22 +190,21 @@ func (b *bed) restart(t *testing.T) *bed {
// so no record there means it was not taken.
func TestAnAskAskedAgainWhoseRefusalWasMissedIsAskedAgainAtStart(t *testing.T) { // 1610/AC4
b := newBed(t)
b.askOf(t, 1, 1)
_, held := b.askOf(t, 2, 2)
b.refuse(t, held.Ask.ID, roomRefusal)
_, other := b.askOf(t, 3, 3)
b.expire(t, other)
b.records[other.Ask.ID] = `{"state":"expired"}`
if got := b.askedIDs(); got[len(got)-1] != held.Ask.ID {
t.Fatalf("asks = %v, want %s asked again", got, held.Ask.ID)
_, held := b.holdOf(t, 2, 1)
b.refuse(t, held.ID, roomRefusal)
_, other := b.holdOf(t, 3, 1)
b.deliver(t, warrantFor(other, "keep"))
b.records[other.ID] = `{"state":"chosen"}`
if got := b.askedIDs(); got[len(got)-1] != held.ID {
t.Fatalf("asks = %v, want %s asked again", got, held.ID)
}
// Refused again, and the teller does not hear it: the router forgets it.
delete(b.seenIDs, held.Ask.ID)
delete(b.records, held.Ask.ID)
delete(b.seenIDs, held.ID)
delete(b.records, held.ID)
n := b.restart(t)
got := n.askedIDs()
if len(got) != 1 || got[0] != held.Ask.ID {
t.Fatalf("asks after the start = %v, want %s asked again; logs:\n%s", got, held.Ask.ID, n.logs.String())
if len(got) != 1 || got[0] != held.ID {
t.Fatalf("asks after the start = %v, want %s asked again; logs:\n%s", got, held.ID, n.logs.String())
}
if len(n.busDropped) != 0 {
t.Errorf("the bus dropped %v as a duplicate", n.busDropped)
@@ -234,13 +214,13 @@ func TestAnAskAskedAgainWhoseRefusalWasMissedIsAskedAgainAtStart(t *testing.T) {
// The same for a first asking: kept open by the teller, its refusal never heard, and no record at the router.
func TestAFirstAskingWhoseRefusalWasMissedIsAskedAgainAtStart(t *testing.T) { // 1610/AC4
b := newBed(t)
_, held := b.askOf(t, 1, 1)
delete(b.seenIDs, held.Ask.ID) // refused for room, unheard: the router keeps no record of it
delete(b.records, held.Ask.ID)
_, held := b.holdOf(t, 1, 1)
delete(b.seenIDs, held.ID) // refused for room, unheard: the router keeps no record of it
delete(b.records, held.ID)
n := b.restart(t)
got := n.askedIDs()
if len(got) != 1 || got[0] != held.Ask.ID {
t.Fatalf("asks after the start = %v, want %s asked again; logs:\n%s", got, held.Ask.ID, n.logs.String())
if len(got) != 1 || got[0] != held.ID {
t.Fatalf("asks after the start = %v, want %s asked again; logs:\n%s", got, held.ID, n.logs.String())
}
if len(n.busDropped) != 0 {
t.Errorf("the bus dropped %v as a duplicate", n.busDropped)
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
@@ -17,45 +16,14 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The teller carries the gate's merge asks to the operator and writes the operator's answer back (novox/hq ADR 0437
// §6, §9). It reads the gate's asks, publishes each to the router unchanged as the asker mesh-delivery-teller
// (mesh-sdk go/asker), withdraws it when the gate takes its key away, and on the warrant the router says to it
// holds the warrant to the ask (ADR 0259 §6) and writes the answer under the ask's own key by compare-and-set. A
// warrant that does not hold writes no answer and is said as a condition. It holds no timer and serves no request:
// every reaction is a revision of the gate's key or a warrant.
// The teller carries the assigner's asks to the operator and writes the operator's answer back (novox/hq ADR 0437
// §6, §9; design 47 G §653, A-172: the merge ask went with the Approve per merge). It publishes each of the assigner's
// asks to the router unchanged as the asker mesh-delivery-teller (mesh-sdk go/asker), withdraws it when the assigner takes
// its key away, and on the warrant the router says to it holds the warrant to the ask (ADR 0259 §6) and writes the
// answer under the ask's own key by compare-and-set. A warrant that does not hold writes no answer and is said as a
// condition. It holds no timer and serves no request: every reaction is a revision of a key or a warrant.
// gateAsked is the gate's ask with the router's ask decoded from its JSON (the seat's ask value carries operator-channel's ask as
// JSON, novox/hq ADR 0627 §4).
type gateAsked struct {
Group string
Heads []meshdeliveryclient.Head
Asking int
Ask oc.Ask
}
// decodeGateAsk reads the gate's ask value and the router's ask in it, as meshdeliveryclient.DecodeAsk did before the ask was JSON.
func decodeGateAsk(value []byte) (gateAsked, error) {
ga, err := meshdeliveryclient.DecodeAsk(value)
if err != nil {
return gateAsked{}, err
}
heads := meshdeliveryclient.HeadsOf(ga.Heads)
key, err := meshdeliveryclient.MergeAskKey(ga.Group, heads)
if err != nil {
return gateAsked{}, err
}
raw, err := json.Marshal(ga.Ask)
if err != nil {
return gateAsked{}, err
}
a, err := occ.Carried(raw, meshdeliveryclient.AskingID(key, int(ga.Asking)))
if err != nil {
return gateAsked{}, err
}
return gateAsked{Group: ga.Group, Heads: heads, Asking: int(ga.Asking), Ask: a}, nil
}
// store is where the teller keeps what it asked: the asker's own records (`ask.<id>`) and, beside them, which gate key
// store is where the teller keeps what it asked: the asker's own records (`ask.<id>`) and, beside them, which ask key
// each ask was made for and at which revision of it (`key.<id>`), and which conditions it has raised (`cond.<id>`).
type store interface {
occ.Store
@@ -79,8 +47,9 @@ type answers interface {
Write(key string, value []byte, rev uint64, found bool) (written bool, err error)
}
// gateAsks reads the gate's bucket asks.
type gateAsks interface {
// asksReader reads the picture of a state another module keeps (the assigner's asks, the view's answers), as the
// reader of mesh-sdk's views gives it.
type asksReader interface {
Get(key string) (value []byte, found bool, err error)
}
@@ -89,11 +58,10 @@ type Teller struct {
Asker routerAsks
Store store
Answers answers
Gate gateAsks
// Assigner is the assigner's bucket asks and View the view's bucket answers (issue 1297 B10); with either nil the teller
// answers nothing from the view.
Assigner gateAsks
View gateAsks
// Assigner is the picture of the assigner's asks and View the picture of the view's answers (issue 1297 B10); with
// either nil the teller answers nothing from the view.
Assigner asksReader
View asksReader
// Say publishes a condition to the operator (mesh.seat.operator-channel.accept.condition.<module>).
Say func(c oc.Condition) error
Logf func(format string, args ...any)
@@ -118,8 +86,8 @@ func (t *Teller) logf(format string, args ...any) {
}
}
// seen is what the teller keeps beside an ask it made. Under `key.<router id>` it names the gate key the asking was made for;
// under `gate.<gate key>` it names the router id of the latest asking of that key and the revision of the key it was made
// seen is what the teller keeps beside an ask it made. Under `key.<router id>` it names the ask key (the assigner's) the asking was made for;
// under `gate.<ask key>` it names the router id of the latest asking of that key and the revision of the key it was made
// at, so a replay of the revision asks nothing again and an overwrite with a new id is known for a new asking.
type seen struct {
Key string `json:"key,omitempty"`
@@ -135,7 +103,7 @@ func recordKey(id string) string { return "key." + id }
func keptKey(id string) string { return "ask." + id }
func condKey(id string) string { return "cond." + id }
// condID is what a condition of an ask is kept and named by: the gate key it was asked for, so the asking that follows a bad
// condID is what a condition of an ask is kept and named by: the ask key it was asked for, so the asking that follows a bad
// answer clears the condition of the one before it. An id the teller holds no key for is its own.
func (t *Teller) condID(id string) string {
if s, e, _ := t.seenOf(id); e != nil && s.Key != "" {
@@ -156,7 +124,7 @@ func (t *Teller) seenAt(rk string) (seen, *stdio.StateEntry, error) {
return s, e, nil
}
// seenOf is the record of a router id: the gate key it was asked for.
// seenOf is the record of a router id: the ask key it was asked for.
func (t *Teller) seenOf(id string) (seen, *stdio.StateEntry, error) { return t.seenAt(recordKey(id)) }
func (t *Teller) keptOf(id string) (*occ.Kept, uint64, error) {
@@ -174,7 +142,7 @@ func (t *Teller) keptOf(id string) (*occ.Kept, uint64, error) {
// open says whether an ask is made and neither answered nor ended.
func open(k *occ.Kept) bool { return k != nil && !k.Acted && k.Ended == "" }
// note keeps that the asking with this router id is the latest of the gate key, made at revision rev of it.
// note keeps that the asking with this router id is the latest of the ask key, made at revision rev of it.
func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error {
if _, err := t.Store.Create(recordKey(id), seen{Key: key, Rev: rev}); err != nil && !errors.Is(err, stdio.ErrChanged) {
return err
@@ -188,82 +156,6 @@ func (t *Teller) note(id, key string, rev uint64, prior *stdio.StateEntry) error
return err
}
// OnAsk handles the gate's asks key put at revision rev. Each asking has its own router id, which is the value's ask.id and
// never one recomputed from the key (mesh-sdk go/delivery Ask.Asking, AskingID): the router keeps an ended ask and drops a
// second ask under its id. So the ask is published once per id; a key overwritten at a higher revision under a new id is a
// new asking, and the earlier asking's router ask is cancelled first. A key whose value is not the ask its name says, one
// already answered, and a revision the teller has handled are left. An ask still open and unchanged is only noted (and its
// warrant read from the router, in case it came while the teller was away). A value that changed its words under the id
// it was asked under is cancelled and not asked again: the router would drop it, so the gate must give it a new asking.
func (t *Teller) OnAsk(key string, rev uint64, value []byte) {
t.mu.Lock()
defer t.mu.Unlock()
ga, err := decodeGateAsk(value)
if err != nil {
t.logf("the gate's key %s at revision %d is not an ask, and is left: %v", key, rev, err)
return
}
if want, err := meshdeliveryclient.MergeAskKey(ga.Group, ga.Heads); err != nil || want != key {
t.logf("the gate's key %s at revision %d holds the ask of %q, not its own, and is left (%v)", key, rev, want, err)
return
}
id := ga.Ask.ID
if raw, _, has, err := t.Answers.Get(key); err != nil {
t.logf("%s: the answers could not be read, so the ask is left: %v", key, err)
return
} else if has && answeredAsking(raw) == id {
return // this asking is answered; an earlier asking's answer does not stop a later asking
}
prior, grec, err := t.seenAt(gateRecordKey(key))
if err != nil {
t.logf("%s: %v", key, err)
return
}
if grec != nil && rev <= prior.Rev {
if k, _, err := t.keptOf(prior.ID); err == nil && open(k) {
t.catchUp(prior.ID)
}
return
}
if grec != nil && prior.ID != id {
// A new asking of the group at these heads: the earlier one is taken back.
t.cancelOpen(key, prior.ID)
}
k, _, err := t.keptOf(id)
if err != nil {
t.logf("%s: %v", key, err)
return
}
if k != nil {
switch {
case open(k) && occ.Digest(k.Ask) == occ.Digest(ga.Ask):
_ = t.note(id, key, rev, grec)
t.catchUp(id)
case open(k):
t.cancelOpen(key, id)
_ = t.note(id, key, rev, grec)
t.logf("%s: the ask %s changed its words at revision %d under the same id: cancelled, and not asked again, for the router drops a second ask under an id (the gate gives a new asking a new id)", key, id, rev)
default:
_ = t.note(id, key, rev, grec)
t.logf("%s: the ask %s ended, and the gate wrote it again under the same id at revision %d: not asked again, the router would drop it", key, id, rev)
}
return
}
if err := t.note(id, key, rev, grec); err != nil {
t.logf("%s: the record of the ask %s could not be kept, so it is left: %v", key, id, err)
return
}
if err := t.Asker.Ask(ga.Ask); err != nil {
t.logf("%s at revision %d: the ask %s was not asked: %v", key, rev, id, err)
if kept, _, _ := t.keptOf(id); open(kept) {
// Kept but not published: forgotten, so the next revision of the key asks it.
_ = t.Store.Delete(keptKey(id))
}
return
}
t.logf("%s at revision %d: the ask %s is asked of the operator (asking %d)", key, rev, id, ga.Asking)
}
// cancelOpen takes an open ask back at the router and marks it ended here, since the router's word that it ended may come
// later. An ask that is not open is left.
func (t *Teller) cancelOpen(key, id string) {
@@ -286,25 +178,6 @@ func (t *Teller) cancelOpen(key, id string) {
t.logf("%s: the ask %s is cancelled", key, id)
}
// OnAskGone handles the gate's asks key taken away (a withdrawn ask): the latest asking's router ask is cancelled, and an
// answer to it merges nothing.
func (t *Teller) OnAskGone(key string) {
t.mu.Lock()
defer t.mu.Unlock()
if _, _, _, err := meshdeliveryclient.ParseAskKey(key); err != nil {
t.logf("the gate's key %s was taken away and is no ask key, and is left: %v", key, err)
return
}
prior, grec, err := t.seenAt(gateRecordKey(key))
if err != nil || grec == nil {
if err != nil {
t.logf("%s: %v", key, err)
}
return
}
t.cancelOpen(key, prior.ID)
}
// catchUp reads the router's record of an ask the teller made and hands its warrant on, as the router's event would:
// a warrant that came while the teller was away.
func (t *Teller) catchUp(id string) {
@@ -327,34 +200,6 @@ func (t *Teller) catchUp(id string) {
t.said(h, *w, err)
}
// mergeAct is what the approve option of the gate's ask binds: the ask's verb (meshdeliveryclient.AskVerb: mesh-delivery.merge; the settle ask
// went with the verb settle, design 47 G §431), this ask, the answer, and the heads it covers.
// It is spelled as the gate composes it (mesh-catalog modules/mesh-delivery-gate approval.go mergeAct, from
// modules/mesh-delivery approval.go:63-65) and as mesh-controller verifies it (cmd/mesh-controller/moved.go:98-106).
func mergeAct(verb, ask, option string, heads map[string]string) occ.Act {
var out []string
for id, sha := range heads {
out = append(out, id+"="+sha)
}
sort.Strings(out)
return occ.Act{"verb": verb, "ask": ask, "option": option, "heads": strings.Join(out, ",")}
}
// headsOf is the heads an ask covers as an act names them: a delivery's id, `owner/repository@<twelve characters of its
// commit>` with the repository in lower case, as the gate's IDOf spells it (mesh-catalog modules/mesh-delivery-gate model.go),
// and its whole commit.
func headsOf(heads []meshdeliveryclient.Head) map[string]string {
out := map[string]string{}
for _, h := range heads {
short := h.Head
if len(short) > 12 {
short = short[:12]
}
out[strings.ToLower(h.Repository)+"@"+short] = h.Head
}
return out
}
// answeredAsking is the router id of the asking an answer is for: its warrant's id before the "@". An answer that cannot be
// read answers no asking.
func answeredAsking(raw []byte) string {
@@ -376,47 +221,20 @@ func warrantID(w oc.Warrant) string {
}
// Answer is the act of a warrant that holds the ask (occ.Client.Take has checked its asker, the ask's digest, the
// option and its level, and the time): the option's bound act must be the act of this ask and the heads it covers,
// and then the answer is written under the ask's key, once. An error is a warrant that does not hold here.
// option and its level, and the time): the assigner's ask must still stand (answerAssigner), and then the answer is
// written under the ask's key, once. An error is a warrant that does not hold here.
func (t *Teller) Answer(o oc.Option, w oc.Warrant) error {
s, srec, err := t.seenOf(w.Ask)
if err != nil {
return err
}
if srec == nil {
return fmt.Errorf("the teller holds no gate key for the ask %s", w.Ask)
return fmt.Errorf("the teller holds no key for the ask %s", w.Ask)
}
if isAssignerKey(s.Key) {
return t.answerAssigner(s.Key, o, w)
if !isAssignerKey(s.Key) {
return fmt.Errorf("the ask %s was asked for %s, which is no ask of the assigner: the teller carries no merge ask", w.Ask, s.Key)
}
raw, found, err := t.Gate.Get(s.Key)
if err != nil {
return fmt.Errorf("the gate's ask %s could not be read: %w", s.Key, err)
}
if !found {
t.logf("%s: the ask %s was answered and the gate has taken it away: an answer to a withdrawn ask merges nothing", s.Key, w.Ask)
return nil
}
ga, err := decodeGateAsk(raw)
if err != nil {
return fmt.Errorf("the gate's ask %s is not an ask: %w", s.Key, err)
}
if ga.Ask.ID != w.Ask {
t.logf("%s: the ask %s was answered, and the gate has asked again under %s: an answer to an earlier asking merges nothing", s.Key, w.Ask, ga.Ask.ID)
return nil
}
if occ.Digest(ga.Ask) != w.AskDigest {
return fmt.Errorf("the gate's ask %s is no longer the ask the operator answered (its digest is %s, the warrant's %s)",
s.Key, occ.Digest(ga.Ask), w.AskDigest)
}
if err := occ.Performs(o, mergeAct(meshdeliveryclient.AskVerb(ga.Group), w.Ask, o.ID, headsOf(ga.Heads))); err != nil {
return err
}
body, err := meshdeliveryclient.EncodeAnswer(meshdeliveryclient.Answer{Option: o.ID, Warrant: warrantID(w), Words: occ.Says(w)})
if err != nil {
return err
}
return t.writeAnswer(s.Key, w.Ask, body, occ.Says(w))
return t.answerAssigner(s.Key, o, w)
}
// writeAnswer writes the answer to the asking askID under the ask's key by compare-and-set: created when the key has no answer;
@@ -500,10 +318,10 @@ func (t *Teller) saidOnce(h occ.Handled, w oc.Warrant, err error) {
t.clear(w.Ask)
case h == occ.Refused || (h == occ.Acted && err != nil):
t.logf("the warrant of %s does not hold, and no answer is written: %v", w.Ask, err)
t.raise(w.Ask, t.kindOf(w.Ask).answerHeadline, "its answer did not hold here: "+oneLine(err))
t.raise(w.Ask, assignerWords.answerHeadline, "its answer did not hold here: "+oneLine(err))
case h == occ.Recorded && w.Outcome == occ.OutcomeRefused:
t.logf("the router refused the ask %s: %s", w.Ask, w.Words)
t.raise(w.Ask, t.kindOf(w.Ask).refusedHeadline, "the router refused it: "+w.Words)
t.raise(w.Ask, assignerWords.refusedHeadline, "the router refused it: "+w.Words)
case err != nil:
t.logf("the warrant of %s: %v", w.Ask, err)
}
@@ -523,30 +341,19 @@ func oneLine(err error) string {
// conditionKey is the key of the condition about an ask.
func conditionKey(id string) string { return tellerModule + ".merge-ask." + id }
// askWords are the words the teller's conditions use for one kind of ask: the gate's merge ask, or one of the assigner's.
// askWords are the words the teller's conditions use for an ask: one of the assigner's.
type askWords struct {
scope, noun, needs, nothing, resolved string
answerHeadline, refusedHeadline, unaskedHeadline string
}
var (
mergeWords = askWords{scope: "merge", noun: "The merge ask ", needs: "to ask for the Approve again", nothing: "Nothing was merged.",
resolved: "A good answer to the merge ask was written.", answerHeadline: "A merge Approve did not hold",
refusedHeadline: "A merge ask was refused", unaskedHeadline: "A merge ask could not be asked"}
assignerWords = askWords{scope: "delivery", noun: "The delivery ask ", needs: "to answer it in the delivery view",
nothing: "Nothing was changed on a machine.", resolved: "A good answer to the delivery ask was written.",
answerHeadline: "An answer to a delivery ask did not hold", refusedHeadline: "A delivery ask was refused",
unaskedHeadline: "A delivery ask could not be asked"}
)
// kindOf is the words for the ask with this router id, by the key it was asked for.
func (t *Teller) kindOf(askID string) askWords {
if isAssignerKey(t.condID(askID)) {
return assignerWords
}
return mergeWords
}
func (t *Teller) raise(askID, headline, why string) {
if t.Say == nil {
return
@@ -558,7 +365,7 @@ func (t *Teller) raise(askID, headline, why string) {
}
t.logf("the condition of %s could not be kept: %v", id, err)
}
k := t.kindOf(askID)
k := assignerWords
needs := k.needs
c := oc.Condition{Key: conditionKey(id), Severity: occ.SeverityWarning, Subject: oc.ConditionSubject{Scope: k.scope, ID: id}, State: occ.StateRaised,
Summary: k.noun + id + ": " + why, Headline: headline, Needs: needs,
@@ -579,7 +386,7 @@ func (t *Teller) clear(askID string) {
if err != nil || e == nil {
return
}
c := oc.Condition{Key: conditionKey(id), Subject: oc.ConditionSubject{Scope: t.kindOf(askID).scope, ID: id}, State: occ.StateCleared}
c := oc.Condition{Key: conditionKey(id), Subject: oc.ConditionSubject{Scope: assignerWords.scope, ID: id}, State: occ.StateCleared}
if err := t.Say(c); err != nil {
t.logf("the condition of %s could not be cleared: %v", id, err)
return
@@ -594,8 +401,8 @@ func (t *Teller) clear(askID string) {
// `room`, and each time one of the teller's asks ends at the router (a warrant of any outcome: the room it held is free)
// the first that waits is asked again, under its own router id and a bus message id of its own (the bus drops a second
// message under an id it holds). It is asked again at most mostRoomRefusals times; refused that often it is said as a
// condition, as any refusal is. An ask the gate took away or asked again under a newer asking meanwhile, or one already
// answered, is the gate's no more, and is dropped from the wait. The teller holds no timer: an end, and its start, are the
// condition, as any refusal is. An ask the assigner took away or asked again under a newer asking meanwhile, or one already
// answered, is the assigner's no more, and is dropped from the wait. The teller holds no timer: an end, and its start, are the
// events that ask again.
// mostRoomRefusals is how many times an ask is refused for want of room before it is said as a condition and asked no
@@ -605,7 +412,7 @@ const mostRoomRefusals = 6
// roomRecord is the key of the asks that wait for room, in the order they were refused.
const roomRecord = "room"
// waitingAsk is one ask that waits for room: the gate key it was asked for, its router id, how often the router refused
// waitingAsk is one ask that waits for room: the ask key it was asked for, its router id, how often the router refused
// it for want of room, and whether it is asked again and its word not yet heard.
type waitingAsk struct {
Key string `json:"key"`
@@ -680,7 +487,7 @@ func (t *Teller) roomRefused(w oc.Warrant) {
return
}
t.logf("the router refused the ask %s for want of room %d times (%s): it is asked no more, and said", w.Ask, n, w.Words)
t.raise(w.Ask, t.kindOf(w.Ask).unaskedHeadline, fmt.Sprintf("the router refused it %d times for want of room: %s", n, w.Words))
t.raise(w.Ask, assignerWords.unaskedHeadline, fmt.Sprintf("the router refused it %d times for want of room: %s", n, w.Words))
return
}
if t.keepWaiting(ws, rec) != nil {
@@ -703,7 +510,7 @@ func (t *Teller) endWait(id string) {
_ = t.keepWaiting(slices.Delete(ws, i, i+1), rec)
}
// OnCurrent is the watch of the gate's asks having handed over every key it holds now, at the teller's start: an ask that
// OnCurrent is the watch of the assigner's asks having handed over every key it holds now, at the teller's start: an ask that
// waited for room while the teller was away is asked again (the asks that held the room may have ended meanwhile).
//
// An ask asked again whose word the teller did not hear (the router's warrants come on a plain subscription) waits once
@@ -754,7 +561,7 @@ func (t *Teller) unheard(id string) {
}
}
// askWaiting asks again the first n asks that wait for room and are not asked yet, dropping on the way every one the gate
// askWaiting asks again the first n asks that wait for room and are not asked yet, dropping on the way every one the assigner
// no longer asks. One at an end: one ask ended, so there is room for one.
func (t *Teller) askWaiting(n int) {
ws, rec, err := t.waiting()
@@ -777,9 +584,8 @@ func (t *Teller) askWaiting(n int) {
i++
continue
}
assigner := isAssignerKey(x.Key)
if assigner && !t.assignerRoom() {
i++ // the assigner's asks hold their most open: it waits, and a merge ask after it may be asked
if isAssignerKey(x.Key) && !t.assignerRoom() {
i++ // the assigner's asks hold their most open: it waits
continue
}
a, why := t.stillAsked(x)
@@ -794,9 +600,7 @@ func (t *Teller) askWaiting(n int) {
return
}
ws[i].Asked = true
if assigner {
t.assignerAsked(x.ID)
}
t.assignerAsked(x.ID)
t.logf("%s: the ask %s is asked of the operator again (refused %d times for want of room)", x.Key, x.ID, x.Refusals)
if n--; n <= 0 {
return
@@ -805,40 +609,16 @@ func (t *Teller) askWaiting(n int) {
}
}
// stillAsked is the gate's ask a waiting ask is for, when the gate still asks it under the same id and it is unanswered;
// otherwise why it is not.
// stillAsked is the assigner's ask a waiting ask is for, when the assigner still asks it under the same id and it is
// unanswered; otherwise why it is not. A waiting ask of a key that is no ask of the assigner (a merge ask kept before the
// Approve per merge went) is dropped.
func (t *Teller) stillAsked(x waitingAsk) (oc.Ask, string) {
if x.Key == "" {
return oc.Ask{}, "the teller holds no gate key for it"
return oc.Ask{}, "the teller holds no key for it"
}
if isAssignerKey(x.Key) {
return t.stillAskedByAssigner(x)
if !isAssignerKey(x.Key) {
return oc.Ask{}, "it is no ask of the assigner, and the teller carries no merge ask"
}
raw, found, err := t.Gate.Get(x.Key)
if err != nil {
return oc.Ask{}, "the gate's ask could not be read: " + err.Error()
}
if !found {
return oc.Ask{}, "the gate has taken it away"
}
ga, err := decodeGateAsk(raw)
if err != nil {
return oc.Ask{}, "the gate's key holds no ask: " + err.Error()
}
if ga.Ask.ID != x.ID {
return oc.Ask{}, "the gate asks again under " + ga.Ask.ID
}
if cur, _, has, err := t.Answers.Get(answerKey(x.Key)); err == nil && has && answeredAsking(cur) == x.ID {
return oc.Ask{}, "it is answered"
}
if err := occ.Check(ga.Ask, t.now()); err != nil {
return oc.Ask{}, "it no longer holds: " + err.Error()
}
return ga.Ask, ""
}
// stillAskedByAssigner is stillAsked for one of the assigner's asks.
func (t *Teller) stillAskedByAssigner(x waitingAsk) (oc.Ask, string) {
a, found, err := t.assignerAsk(x.Key)
switch {
case err != nil:
@@ -1,9 +1,7 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
@@ -12,19 +10,11 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
meshdelivery "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
stdio "git.novox.be/novox/mesh-sdk/go"
)
const (
headA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
headB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
)
var now = time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
// nowIs sets the tests' clock now to at and returns what puts it back; a test sets it only as t.Cleanup(nowIs(at)), so no
@@ -119,10 +109,11 @@ type bed struct {
client *occ.Client
store *memStore
answers *memAnswers
gate memGate
pubs []published
conds []oc.Condition
records map[string]string
// assigner and view are the pictures of the assigner's asks and the view's answers the teller follows.
assigner, view memGate
pubs []published
conds []oc.Condition
records map[string]string
// seenIDs are the ids the fake router has taken an ask under, and dropped the asks it took a second time under: the
// messenger keeps an ask after it ends and returns for an ask whose id it holds (mesh-catalog modules/messenger
// cmd/messenger/asks.go:487-489, keepAsk), so a reused id reaches no phone.
@@ -140,7 +131,7 @@ type bed struct {
func newBed(t *testing.T) *bed {
t.Helper()
b := &bed{store: &memStore{entries: map[string]*stdio.StateEntry{}}, answers: &memAnswers{written: map[string][]byte{}, revs: map[string]uint64{}},
gate: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}, msgIDs: map[string]bool{}}
assigner: memGate{}, view: memGate{}, records: map[string]string{}, seenIDs: map[string]bool{}, msgIDs: map[string]bool{}}
b.client = &occ.Client{Module: tellerModule, State: b.store, Now: func() time.Time { return now },
Publish: func(subject string, body any, id string) error {
raw, err := json.Marshal(body)
@@ -173,7 +164,7 @@ func newBed(t *testing.T) *bed {
},
Subscribe: func(_ string, handle func(stdio.Envelope) error) error { b.hear = handle; return nil },
}
b.t = &Teller{Asker: b.client, Store: b.store, Answers: b.answers, Gate: b.gate, Publish: b.client.Publish, Now: func() time.Time { return now },
b.t = &Teller{Asker: b.client, Store: b.store, Answers: b.answers, Assigner: b.assigner, View: b.view, Publish: b.client.Publish, Now: func() time.Time { return now },
Say: func(c oc.Condition) error {
if _, err := occ.EncodeCondition(c); err != nil {
t.Errorf("a condition the router would refuse: %v", err)
@@ -201,41 +192,31 @@ func newBed(t *testing.T) *bed {
return b
}
// gateAsk is an ask as the gate composes it: the router's ask with the approve and decline options binding the act
// `mesh-delivery.merge` for this ask and the heads (mesh-catalog modules/mesh-delivery approval.go:123-191 composeMergeAsk
// and :63-65 mergeAct), under the id its key gives (mesh-sdk go/delivery AskID). boundTo are the heads the options bind,
// which are the ask's own unless a test says another.
func gateAsk(t *testing.T, heads, boundTo []meshdeliveryclient.Head) (key string, value []byte, ask gateAsked) {
// holdOf is the assigner's hold ask of module plex at the n-th fingerprint, written at its asking-th writing (its router id is
// AskingID(key, asking)), put in the bed's picture of the assigner's asks and handed to the teller as the watch would.
func (b *bed) holdOf(t *testing.T, n, asking int) (string, oc.Ask) {
t.Helper()
return gateAskN(t, heads, boundTo, 1)
key, value, ask := holdAskAt(t, n, asking)
b.assigner[key] = value
b.t.OnAssignerAsk(key)
return key, ask
}
// gateAskN is the asking-th writing of the group's ask at these heads: its router id is AskingID(key, asking) (mesh-sdk
// go/delivery Ask.Asking), the first asking's the key's own.
func gateAskN(t *testing.T, heads, boundTo []meshdeliveryclient.Head, asking int) (key string, value []byte, ask gateAsked) {
// holdAskAt is the assigner's hold ask at the n-th fingerprint and the asking-th writing, shaped as the assigner composes it
// (two options of level acknowledge that bind no act, so the router takes it).
func holdAskAt(t *testing.T, n, asking int) (string, []byte, oc.Ask) {
t.Helper()
return gateAskOf(t, "g", "mesh-delivery.merge", heads, boundTo, asking)
}
// gateAskOf is gateAskN for a group whose options bind the verb given.
func gateAskOf(t *testing.T, group, verb string, heads, boundTo []meshdeliveryclient.Head, asking int) (key string, value []byte, ask gateAsked) {
t.Helper()
key, err := meshdeliveryclient.MergeAskKey(group, heads)
key, err := asks.HoldAskKey("plex", "src2:"+strings.Repeat(string(rune('0'+n)), 64))
if err != nil {
t.Fatal(err)
}
id := meshdeliveryclient.AskingID(key, asking)
a := oc.Ask{ID: id, Headline: "Merge into main: novox/mesh-delivery#12", Explanation: "Merges two pull requests.\nReview: approved.",
Who: occ.Operator, Expires: occ.FormatTime(now.Add(occ.ApproveLasts - 10*time.Minute)), OnExpiry: "the merge stays held", About: "delivery.mesh-delivery.12"}
for _, o := range []struct{ id, label, does string }{{"approve", "Approve", "merges"}, {"decline", "Decline", "merges nothing"}} {
binds, err := occ.ActDigest(mergeAct(verb, id, o.id, headsOf(boundTo)))
if err != nil {
t.Fatal(err)
}
a.Options = append(a.Options, oc.Option{ID: o.id, Label: o.label, Does: o.does, Level: occ.Approve, Binds: binds})
_, _, ask := holdAsk(t)
ask.ID = meshdeliveryclient.AskingID(key, asking)
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask), Asking: asking})
if err != nil {
t.Fatal(err)
}
ask = gateAsked{Group: group, Heads: heads, Asking: asking, Ask: a}
return key, encodeGate(t, ask), ask
return key, value, ask
}
// rawAsk is the router's ask as the JSON a gate or assigner value carries (the seat's ask value).
@@ -248,31 +229,9 @@ func rawAsk(t *testing.T, a oc.Ask) json.RawMessage {
return b
}
// encodeGate is the gate's value for ga, written as the gate writes it: the router's ask checked against its key and asking
// (an id other than the one they give, no option), then carried as JSON.
func encodeGate(t *testing.T, ga gateAsked) []byte {
t.Helper()
key, err := meshdeliveryclient.MergeAskKey(ga.Group, ga.Heads)
if err != nil {
t.Fatal(err)
}
if want := meshdeliveryclient.AskingID(key, ga.Asking); ga.Ask.ID != want || len(ga.Ask.Options) == 0 {
t.Fatalf("the ask's id %q, want %q, with %d options", ga.Ask.ID, want, len(ga.Ask.Options))
}
value, err := gateValue(ga.Group, ga.Heads, ga.Asking, rawAsk(t, ga.Ask))
if err != nil {
t.Fatal(err)
}
return value
}
func twoHeads() []meshdeliveryclient.Head {
return []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
}
// warrantFor is the router's warrant for a chosen option of an ask, as the router verified the operator.
func warrantFor(a oc.Ask, option string) oc.Warrant {
return oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeChosen, Option: option, Label: "Approve", Level: occ.Approve,
return oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeChosen, Option: option, Label: option, Level: occ.Acknowledge,
By: &oc.Person{Who: occ.Operator, Kind: "telegram", Identity: "42", Verified: "user id"}, Channel: "telegram",
At: occ.FormatTime(now.Add(time.Hour)), AskDigest: occ.Digest(a)}
}
@@ -295,72 +254,51 @@ func (b *bed) askedOf(subject string) []published {
return out
}
func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1343/AC1
t.Run("1945: a refused bucket is said, or stops the start naming its grant", func(t *testing.T) {
refusal := errors.New("nats: permissions violation for publish to $JS.API.STREAM.INFO.KV_x")
var said []string
if kv := process.OptionalBucket(context.Background(), refusingBus{refusal}, "x", "x is not heard", func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }); kv != nil || len(said) != 1 || !strings.Contains(said[0], "permissions violation") {
t.Errorf("an optional bucket refused: %v, said %v", kv, said)
}
if _, err := process.EssentialBucket(context.Background(), refusingBus{refusal}, "teller", "x", "work"); err == nil || !strings.Contains(err.Error(), "grant of x") {
t.Errorf("an essential bucket refused: %v, want its grant named", err)
}
})
func TestAnAssignersKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1343/AC1
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 3, value)
key, value, ask := holdAskAt(t, 1, 1)
b.assigner[key] = value
b.t.OnAssignerAsk(key)
asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule))
if len(asked) != 1 || asked[0].id != ga.Ask.ID {
t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, tellerModule, ga.Ask.ID)
if len(asked) != 1 || asked[0].id != ask.ID {
t.Fatalf("published %+v, want one ask under %s with id %s", b.pubs, tellerModule, ask.ID)
}
var got oc.Ask
if err := json.Unmarshal(asked[0].body, &got); err != nil {
t.Fatal(err)
}
if occ.Digest(got) != occ.Digest(ga.Ask) || got.Headline != ga.Ask.Headline || got.Explanation != ga.Ask.Explanation || got.ID != meshdeliveryclient.AskID(key) {
t.Errorf("the router's ask differs from the gate's: %+v", got)
}
b.t.OnAsk(key, 3, value) // the revision seen again, as at a restart
b.t.OnAsk(key, 4, value) // the key written again with the same ask
b.t.OnAssignerAsk(key) // the key written again with the same ask, or heard again at a restart
if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 1 {
t.Errorf("the ask was published %d times", n)
}
delete(b.gate, key)
b.t.OnAskGone(key)
delete(b.assigner, key)
b.t.OnAssignerAskGone(key)
cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule))
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+ga.Ask.ID+`"}` {
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+ask.ID+`"}` {
t.Fatalf("cancels = %+v", cancels)
}
b.t.OnAskGone(key)
b.t.OnAssignerAskGone(key)
if n := len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))); n != 1 {
t.Errorf("the ask was cancelled %d times", n)
}
// Issue 774 (2026-10-31): two merge asks of pull requests merged by another path held two of the router's three places,
// their keys never taken away, and a fourth ask was refused for want of room. teller_end_ask ends one by hand, named by its
// gate key or its router id; the router's word that it ended asks the one waiting for room again.
// Issue 774: teller_end_ask ends one by hand, named by its key or its router id; the router's word that it ended asks the
// one waiting for room again (the assigner's asks hold at most mostAssignerOpen places, the third waits).
for _, c := range []struct {
name string
by func(key string, ga gateAsked) string
by func(key string, ask oc.Ask) string
}{
{"774: ended by hand, named by its gate key", func(key string, _ gateAsked) string { return key }},
{"774: ended by hand, named by its router id", func(_ string, ga gateAsked) string { return ga.Ask.ID }},
{"774: ended by hand, named by its key", func(key string, _ oc.Ask) string { return key }},
{"774: ended by hand, named by its router id", func(_ string, ask oc.Ask) string { return ask.ID }},
} {
t.Run(c.name, func(t *testing.T) {
b := newBed(t)
k1, first := b.askOf(t, 1, 1)
b.askOf(t, 2, 2)
b.askOf(t, 3, 3)
_, fourth := b.askOf(t, 4, 4)
b.refuse(t, fourth.Ask.ID, roomRefusal)
r := b.t.EndAsk(c.by(k1, first), "its pull request merged without the Approve", "lead@shanks")
k1, first := b.holdOf(t, 1, 1)
b.holdOf(t, 2, 1)
_, third := b.holdOf(t, 3, 1)
r := b.t.EndAsk(c.by(k1, first), "the hold no longer holds", "lead@shanks")
cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule))
if !r.Ended || r.ID != first.Ask.ID || r.Key != k1 || len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+first.Ask.ID+`"}` {
if !r.Ended || r.ID != first.ID || r.Key != k1 || len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+first.ID+`"}` {
t.Fatalf("end = %+v, cancels %+v", r, cancels)
}
b.deliver(t, oc.Warrant{Ask: first.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeCancelled, At: occ.FormatTime(now), AskDigest: occ.Digest(first.Ask)})
if got := b.askedIDs(); len(got) != 5 || got[4] != fourth.Ask.ID {
t.Errorf("asks = %v, want the refused %s asked again once the ask ended by hand left", got, fourth.Ask.ID)
b.deliver(t, oc.Warrant{Ask: first.ID, Asker: tellerModule, Outcome: occ.OutcomeCancelled, At: occ.FormatTime(now), AskDigest: occ.Digest(first)})
if got := b.askedIDs(); len(got) != 3 || got[2] != third.ID {
t.Errorf("asks = %v, want the waiting %s asked once the ask ended by hand left", got, third.ID)
}
if again := b.t.EndAsk(k1, "again", "lead@shanks"); again.Ended || len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))) != 1 {
t.Errorf("a second end = %+v: an ended ask is left", again)
@@ -369,62 +307,22 @@ func TestAnAsksKeyBecomesOneRouterAskAndDeletingItCancelsIt(t *testing.T) { // 1
}
t.Run("774: an ask waiting for room is dropped from the wait; a name the teller holds no ask under is said", func(t *testing.T) {
b := newBed(t)
_, first := b.askOf(t, 1, 1)
b.askOf(t, 2, 2)
b.askOf(t, 3, 3)
_, fourth := b.askOf(t, 4, 4)
b.refuse(t, fourth.Ask.ID, roomRefusal)
if r := b.t.EndAsk(fourth.Ask.ID, "asked about what no longer holds", "lead@shanks"); !r.Ended || len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))) != 0 {
_, first := b.holdOf(t, 1, 1)
b.holdOf(t, 2, 1)
_, third := b.holdOf(t, 3, 1)
if r := b.t.EndAsk(third.ID, "asked about what no longer holds", "lead@shanks"); !r.Ended || len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))) != 0 {
t.Fatalf("end of a waiting ask = %+v", r)
}
b.deliver(t, warrantFor(first.Ask, "approve"))
if got := b.askedIDs(); len(got) != 4 {
b.deliver(t, warrantFor(first, "keep"))
if got := b.askedIDs(); len(got) != 2 {
t.Errorf("asks = %v: the ask ended while it waited was asked again", got)
}
if r := b.t.EndAsk("merge.nothing.here", "a typo", "lead@shanks"); r.Ended || !strings.Contains(r.Says, "holds no ask") {
if r := b.t.EndAsk("hold.nothing.here", "a typo", "lead@shanks"); r.Ended || !strings.Contains(r.Says, "holds no ask") {
t.Errorf("end of an unknown ask = %+v", r)
}
})
}
func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { // 1343/AC2
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w := warrantFor(ga.Ask, "approve")
b.deliver(t, w)
raw, ok := b.answers.written[answerKey(key)]
if !ok || len(b.answers.written) != 1 {
t.Fatalf("answers = %v", b.answers.written)
}
a, err := meshdeliveryclient.DecodeAnswer(raw)
if err != nil || a.Option != "approve" || a.Warrant != warrantID(w) || !strings.Contains(a.Words, "chose Approve") {
t.Errorf("answer %+v err %v", a, err)
}
b.deliver(t, w) // the same warrant again
w2 := w
w2.At = occ.FormatTime(now.Add(time.Hour + time.Minute)) // a second warrant for the same ask
b.deliver(t, w2)
if b.answers.writes != 1 {
t.Errorf("the answer was written %d times, want once", b.answers.writes)
}
if len(b.conds) != 0 {
t.Errorf("conditions said: %+v", b.conds)
}
// design 47 G §431: the settle ask went with the verb settle, so an ask whose options bind it holds no more.
for verb, holds := range map[string]bool{"mesh-delivery.settle": false, "mesh-delivery.merge": true} {
b := newBed(t)
key, value, ga := gateAskOf(t, "g", verb, twoHeads(), twoHeads(), 1)
b.gate[key] = value
b.t.OnAsk(key, 1, value)
b.deliver(t, warrantFor(ga.Ask, "approve"))
if _, written := b.answers.written[answerKey(key)]; written != holds {
t.Errorf("an ask binding %s: answer written %v, want %v", verb, written, holds)
}
}
}
// ADR 0596 §2: the teller says answered after each answer it writes, under one message id per ask, and says it again for an
// answer written whose event was not said.
func TestEveryAnswerWrittenIsSaidAsTheEventAnswered(t *testing.T) {
@@ -438,89 +336,95 @@ func TestEveryAnswerWrittenIsSaidAsTheEventAnswered(t *testing.T) {
said = append(said, answeredID(key, askID)+" "+string(answer))
return nil
}
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w := warrantFor(ga.Ask, "approve")
b.deliver(t, w)
key, ask := b.holdOf(t, 1, 1)
b.deliver(t, warrantFor(ask, "keep"))
if len(said) != 0 || b.answers.writes != 1 {
t.Fatalf("said %v with the bus down, written %d times", said, b.answers.writes)
}
fail = false
if err := b.t.writeAnswer(key, ga.Ask.ID, b.answers.written[answerKey(key)], "again"); err != nil { // the answer is there; its event was not said
if err := b.t.writeAnswer(key, ask.ID, b.answers.written[answerKey(key)], "again"); err != nil { // the answer is there; its event was not said
t.Fatal(err)
}
if len(said) != 1 || b.answers.writes != 1 {
t.Fatalf("said %v, written %d times; want the event said once and the answer written once", said, b.answers.writes)
}
raw := b.answers.written[answerKey(key)]
if want := answeredID(key, ga.Ask.ID) + " " + string(raw); said[0] != want {
if want := answeredID(key, ask.ID) + " " + string(raw); said[0] != want {
t.Errorf("said %q, want %q", said[0], want)
}
}
func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T) { // 1343/AC3
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
cases := map[string]func(t *testing.T) (*bed, string, oc.Warrant){
"heads that differ from the ones the option bound": func(t *testing.T) (*bed, string, oc.Warrant) {
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), other) // the options bind other heads than the ask covers
b.gate[key] = value
b.t.OnAsk(key, 1, value)
return b, key, warrantFor(ga.Ask, "approve")
},
"an asker that differs": func(t *testing.T) (*bed, string, oc.Warrant) {
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w := warrantFor(ga.Ask, "approve")
w.Asker = "mesh-delivery"
return b, key, w
},
"a digest that differs": func(t *testing.T) (*bed, string, oc.Warrant) {
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w := warrantFor(ga.Ask, "approve")
w.AskDigest = "sha256:0000"
return b, key, w
},
cases := map[string]func(w *oc.Warrant){
"an asker that differs": func(w *oc.Warrant) { w.Asker = "mesh-delivery" },
"a digest that differs": func(w *oc.Warrant) { w.AskDigest = "sha256:0000" },
"an option the ask lacks": func(w *oc.Warrant) { w.Option = "delete" },
}
for name, mk := range cases {
for name, change := range cases {
t.Run(name, func(t *testing.T) {
b, key, w := mk(t)
b := newBed(t)
key, ask := b.holdOf(t, 1, 1)
w := warrantFor(ask, "keep")
change(&w)
b.deliver(t, w)
if len(b.answers.written) != 0 {
t.Errorf("an answer was written: %v", b.answers.written)
}
if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised || b.conds[0].Subject.ID != key ||
!strings.HasPrefix(b.conds[0].Explanation, occ.NeedsYou) {
if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised || b.conds[0].Subject.ID != key || b.conds[0].Subject.Scope != "delivery" ||
!strings.HasPrefix(b.conds[0].Explanation, occ.NeedsYou) || strings.Contains(b.conds[0].Explanation, "merge") {
t.Fatalf("conditions = %+v", b.conds)
}
})
}
}
// design 47 G §653, A-172: the teller carries no merge ask. A router ask it holds under a key that is no assigner key (a merge
// ask kept before the Approve per merge went) answers nothing, and a merge ask waiting for room is dropped from the wait.
func TestAMergeAskOfBeforeTheApprovePerMergeWentAnswersNothingAndWaitsNoMore(t *testing.T) {
b := newBed(t)
_, _, ask := holdAskAt(t, 1, 1)
mergeKey := "merge.g.0123456789ab"
ask.ID = meshdeliveryclient.AskID(mergeKey)
if err := b.t.note(ask.ID, mergeKey, 3, nil); err != nil {
t.Fatal(err)
}
if err := b.client.Ask(ask); err != nil {
t.Fatal(err)
}
if ws, rec, _ := b.t.waiting(); rec != nil || len(ws) != 0 {
t.Fatalf("the wait holds %v", ws)
}
b.refuse(t, ask.ID, roomRefusal) // it waits for room, as a merge ask did
b.deliver(t, warrantFor(ask, "keep"))
if len(b.answers.written) != 0 {
t.Errorf("a merge ask was answered: %v", b.answers.written)
}
b.t.OnCurrent() // the start: the wait is read, and the merge ask is dropped, not asked again
if got := b.askedIDs(); len(got) != 1 {
t.Errorf("asks = %v: a merge ask was asked again", got)
}
if ws, _, _ := b.t.waiting(); len(ws) != 0 {
t.Errorf("the wait still holds %+v", ws)
}
}
func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/AC3 (cleared when a good answer is written)
b := newBed(t)
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
key, bad, gaBad := gateAsk(t, twoHeads(), other)
b.gate[key] = bad
b.t.OnAsk(key, 1, bad)
b.deliver(t, warrantFor(gaBad.Ask, "approve"))
key, bad := b.holdOf(t, 1, 1)
w := warrantFor(bad, "keep")
w.AskDigest = "sha256:0000"
b.deliver(t, w)
if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised {
t.Fatalf("conditions = %+v", b.conds)
}
// The gate writes the ask again, right this time, at a new revision of its key: the second asking, under a new id.
_, good, gaGood := gateAskN(t, twoHeads(), twoHeads(), 2)
b.gate[key] = good
b.t.OnAsk(key, 2, good)
// The assigner writes the ask again at a new asking, under a new id.
_, value, good := holdAskAt(t, 1, 2)
b.assigner[key] = value
b.t.OnAssignerAsk(key)
if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 2 {
t.Fatalf("the ask was published %d times, want again at the new revision", n)
t.Fatalf("the ask was published %d times, want again at the new asking", n)
}
b.deliver(t, warrantFor(gaGood.Ask, "approve"))
b.deliver(t, warrantFor(good, "keep"))
if _, ok := b.answers.written[answerKey(key)]; !ok {
t.Fatalf("no answer: %v\n%s", b.answers.written, b.logs.String())
}
@@ -531,12 +435,10 @@ func TestAGoodAnswerClearsTheConditionOfAnAskAskedAgain(t *testing.T) { // 1343/
func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) {
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w, _ := json.Marshal(warrantFor(ga.Ask, "approve"))
b.records[ga.Ask.ID] = `{"state":"chosen","warrant":` + string(w) + `}`
b.t.OnAsk(key, 1, value) // the teller starts again and reads the key at the same revision
key, ask := b.holdOf(t, 1, 1)
w, _ := json.Marshal(warrantFor(ask, "keep"))
b.records[ask.ID] = `{"state":"chosen","warrant":` + string(w) + `}`
b.t.OnAssignerAsk(key) // the teller starts again and reads the key
if _, ok := b.answers.written[answerKey(key)]; !ok {
t.Errorf("no answer: %s", b.logs.String())
}
@@ -544,34 +446,32 @@ func TestAWarrantComingWhileTheTellerWasAwayIsReadFromTheRouter(t *testing.T) {
func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T) { // G2 of review round 1
b := newBed(t)
key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1)
b.gate[key] = one
b.t.OnAsk(key, 1, one)
_, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2)
if g1.Ask.ID == g2.Ask.ID {
key, g1 := b.holdOf(t, 1, 1)
_, two, g2 := holdAskAt(t, 1, 2)
if g1.ID == g2.ID {
t.Fatal("the two askings share an id")
}
b.gate[key] = two
b.t.OnAsk(key, 2, two)
b.assigner[key] = two
b.t.OnAssignerAsk(key)
cancels := b.askedOf(oc.AcceptSubjectCancel(tellerModule))
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.Ask.ID+`"}` {
if len(cancels) != 1 || string(cancels[0].body) != `{"id":"`+g1.ID+`"}` {
t.Fatalf("cancels = %+v, want asking 1's router ask", cancels)
}
asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule))
if len(asked) != 2 || asked[1].id != g2.Ask.ID {
t.Fatalf("asks = %+v, want the second under %s", asked, g2.Ask.ID)
if len(asked) != 2 || asked[1].id != g2.ID {
t.Fatalf("asks = %+v, want the second under %s", asked, g2.ID)
}
if len(b.dropped) != 0 {
t.Errorf("the router dropped asks under a reused id: %v", b.dropped)
}
// The same revision again, and a warrant for the first asking (the gate has moved on), change nothing.
b.t.OnAsk(key, 2, two)
b.deliver(t, warrantFor(g1.Ask, "approve"))
// The same key again, and a warrant for the first asking (the assigner has moved on), change nothing.
b.t.OnAssignerAsk(key)
b.deliver(t, warrantFor(g1, "keep"))
if len(b.answers.written) != 0 || len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))) != 2 {
t.Errorf("answers %v asks %d", b.answers.written, len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))))
}
// The warrant of the second names it: its answer's warrant is the router id and the time.
w := warrantFor(g2.Ask, "approve")
w := warrantFor(g2, "keep")
b.deliver(t, w)
raw, ok := b.answers.written[answerKey(key)]
a, err := meshdeliveryclient.DecodeAnswer(raw)
@@ -580,65 +480,20 @@ func TestARewriteWithAskingTwoCancelsAskingOneAndAsksUnderTheNewID(t *testing.T)
}
}
func TestAnAskWhoseWordsChangedUnderTheSameIDIsCancelledAndNotAskedAgain(t *testing.T) {
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.t.OnAsk(key, 1, value)
changed := ga
changed.Ask.Explanation = "Merges two pull requests, now with a third point.\nReview: approved."
v2, err := gateValue(changed.Group, changed.Heads, changed.Asking, rawAsk(t, changed.Ask))
if err != nil {
t.Fatal(err)
}
b.t.OnAsk(key, 2, v2)
if n := len(b.askedOf(oc.AcceptSubjectCancel(tellerModule))); n != 1 {
t.Errorf("cancels = %d", n)
}
if n := len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))); n != 1 || len(b.dropped) != 0 {
t.Errorf("asks = %d dropped %v: the router would drop a second ask under the id", n, b.dropped)
}
}
func TestACapitalisedRepositoryIsSpelledInLowerCaseInTheAct(t *testing.T) { // T1 of review round 1
b := newBed(t)
heads := []meshdeliveryclient.Head{{Repository: "novox/Mesh-Delivery", Number: 12, Head: headA}}
key, err := meshdeliveryclient.MergeAskKey("g", heads)
if err != nil {
t.Fatal(err)
}
id := meshdeliveryclient.AskID(key)
// The act as the gate binds it: its IDOf lowercases the repository (mesh-catalog modules/mesh-delivery-gate model.go).
binds, err := occ.ActDigest(occ.Act{"verb": "mesh-delivery.merge", "ask": id, "option": "approve", "heads": "novox/mesh-delivery@" + headA[:12] + "=" + headA})
if err != nil {
t.Fatal(err)
}
a := oc.Ask{ID: id, Headline: "Merge into main: Mesh-Delivery#12", Explanation: "Merges.", Who: occ.Operator, Expires: occ.FormatTime(now.Add(occ.ApproveLasts - 10*time.Minute)),
OnExpiry: "the merge stays held", Options: []oc.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: occ.Approve, Binds: binds}}}
value := encodeGate(t, gateAsked{Group: "g", Heads: heads, Asking: 0, Ask: a})
b.gate[key] = value
b.t.OnAsk(key, 1, value)
b.deliver(t, warrantFor(a, "approve"))
if _, ok := b.answers.written[answerKey(key)]; !ok {
t.Errorf("no answer for the gate's spelling: %s", b.logs.String())
}
}
func TestAKeyWhoseValueIsNotItsOwnAskIsLeft(t *testing.T) {
b := newBed(t)
_, value, _ := gateAsk(t, twoHeads(), twoHeads())
b.t.OnAsk("merge.g.000000000000", 1, value)
b.t.OnAsk("merge.g.000000000000", 2, []byte(`{"v":1`))
if len(b.pubs) != 0 {
t.Errorf("published %+v", b.pubs)
key, _, _ := holdAskAt(t, 1, 1)
b.assigner[key] = []byte(`{"v":1`)
b.t.OnAssignerAsk(key)
if len(b.pubs) != 0 || !strings.Contains(b.logs.String(), "the ask is not raised") {
t.Errorf("published %+v; log:\n%s", b.pubs, b.logs.String())
}
}
func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9
b := newBed(t)
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.t.OnAsk(key, 1, value)
w := oc.Warrant{Ask: ga.Ask.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: "no channel can send", At: occ.FormatTime(now)}
b.deliver(t, w)
_, ask := b.holdOf(t, 1, 1)
b.deliver(t, oc.Warrant{Ask: ask.ID, Asker: tellerModule, Outcome: occ.OutcomeRefused, Words: "no channel can send", At: occ.FormatTime(now)})
if len(b.conds) != 1 || b.conds[0].State != occ.StateRaised || !strings.Contains(b.conds[0].Summary, "no channel can send") {
t.Errorf("conditions = %+v", b.conds)
}
@@ -646,26 +501,23 @@ func TestARefusedAskIsSaidAsACondition(t *testing.T) { // ADR 0437 §9
func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R2-1 of review round 2
b := newBed(t)
key, one, g1 := gateAskN(t, twoHeads(), twoHeads(), 1)
b.gate[key] = one
b.t.OnAsk(key, 1, one)
b.deliver(t, warrantFor(g1.Ask, "approve"))
key, g1 := b.holdOf(t, 1, 1)
b.deliver(t, warrantFor(g1, "keep"))
first, ok := b.answers.written[answerKey(key)]
if a, err := meshdeliveryclient.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.Ask.ID+"@") {
if a, err := meshdeliveryclient.DecodeAnswer(first); !ok || err != nil || !strings.HasPrefix(a.Warrant, g1.ID+"@") {
t.Fatalf("asking 1's answer: %+v %v %v", a, ok, err)
}
// The gate rewrites the key at asking 2 (a review record changed). Asking 1's answer stays in the bucket.
_, two, g2 := gateAskN(t, twoHeads(), twoHeads(), 2)
b.gate[key] = two
b.t.OnAsk(key, 2, two)
// The assigner writes the key again at asking 2. Asking 1's answer stays in the bucket.
_, two, g2 := holdAskAt(t, 1, 2)
b.assigner[key] = two
b.t.OnAssignerAsk(key)
asked := b.askedOf(oc.AcceptSubjectAsk(tellerModule))
if len(asked) != 2 || asked[1].id != g2.Ask.ID {
t.Fatalf("asks = %+v, want asking 2 asked under %s", asked, g2.Ask.ID)
if len(asked) != 2 || asked[1].id != g2.ID {
t.Fatalf("asks = %+v, want asking 2 asked under %s", asked, g2.ID)
}
w := warrantFor(g2.Ask, "approve")
w := warrantFor(g2, "release")
b.deliver(t, w)
raw := b.answers.written[answerKey(key)]
a, err := meshdeliveryclient.DecodeAnswer(raw)
a, err := meshdeliveryclient.DecodeAnswer(b.answers.written[answerKey(key)])
if err != nil || a.Warrant != warrantID(w) {
t.Fatalf("the key holds %+v (%v), want asking 2's answer", a, err)
}
@@ -673,27 +525,8 @@ func TestAnAnswerToAnEarlierAskingDoesNotBlockTheNextAsking(t *testing.T) { // R
w2 := w
w2.At = occ.FormatTime(now.Add(time.Hour + time.Minute))
b.deliver(t, w2)
b.t.OnAsk(key, 2, two)
b.t.OnAsk(key, 3, two)
b.t.OnAssignerAsk(key)
if b.answers.writes != 2 || len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))) != 2 || len(b.dropped) != 0 {
t.Errorf("writes %d asks %d dropped %v", b.answers.writes, len(b.askedOf(oc.AcceptSubjectAsk(tellerModule))), b.dropped)
}
}
// refusingBus refuses every bucket with err (issue 1945).
type refusingBus struct{ err error }
func (r refusingBus) KeyValue(context.Context, string) (jetstream.KeyValue, error) { return nil, r.err }
// gateValue is the gate's ask value for the group, heads and asking, carrying the router's ask as its JSON.
func gateValue(group string, heads []meshdeliveryclient.Head, asking int, ask json.RawMessage) ([]byte, error) {
var asked any
if err := json.Unmarshal(ask, &asked); err != nil {
return nil, err
}
var items []meshdelivery.AsksByMergeGroupHeadsValueHeadsItem
for _, h := range heads {
items = append(items, meshdelivery.AsksByMergeGroupHeadsValueHeadsItem{Repository: h.Repository, Number: int64(h.Number), Head: h.Head})
}
return meshdeliveryclient.EncodeAsk(meshdelivery.AsksByMergeGroupHeadsValue{Group: group, Heads: items, Asking: int64(asking), Ask: asked})
}
@@ -12,11 +12,12 @@ import (
// The view's answers (novox/hq ADR 0528 clause 6, ADR 0437 §10, issue 1297 B10). The operator may answer, in the view, the
// asks of the assigner that need no proof of who answered (the hold of a fingerprint and the restore of a data module set back,
// mesh-sdk go/delivery AskNeedsProof); the view writes its answer under `<ask key>.view` in its bucket answers, and the teller,
// which alone fills an ask's answer, takes it from there as it takes a warrant from the phone: under the ask's own key, once,
// by compare-and-set. The merge's ask needs proof (ADR 0421 §1, issue 953) and is never answered from a view key.
// mesh-sdk go/delivery AskNeedsProof); the view writes its answer under `<ask key>.view` in its state answers, and the teller,
// which alone fills an ask's answer, takes it from the picture it follows of that state (the view's views family, first view
// by view_read_answers; design 47 G A-175) as it takes a warrant from the phone: under the ask's own key, once, by
// compare-and-set.
// OnViewAnswer handles a key put in the view's bucket answers. A key that is no view answer key, or is the answer to an ask that
// OnViewAnswer handles a key put in the followed picture of the view's answers. A key that is no view answer key, or is the answer to an ask that
// needs proof of who answered, is left and said.
func (t *Teller) OnViewAnswer(key string) {
t.mu.Lock()
@@ -47,12 +47,8 @@ func viewAnswer(t *testing.T, option, words string) []byte {
return v
}
// withView gives the bed's teller the assigner's asks and the view's answers, both empty.
func withView(b *bed) (assigner, view memGate) {
assigner, view = memGate{}, memGate{}
b.t.Assigner, b.t.View = assigner, view
return assigner, view
}
// withView is the bed's pictures of the assigner's asks and the view's answers, both empty.
func withView(b *bed) (assigner, view memGate) { return b.assigner, b.view }
func TestAViewAnswerFillsTheAskOnce(t *testing.T) { // 1297/B10 AC
b := newBed(t)
@@ -96,14 +92,15 @@ func TestAViewAnswerHeardBeforeItsAskFillsItWhenTheAskComes(t *testing.T) { // 1
func TestAnAskNeedingProofIsNotAnsweredFromAViewKey(t *testing.T) { // 1297/B10 AC
b := newBed(t)
assigner, view := withView(b)
key, value, _ := gateAsk(t, twoHeads(), twoHeads())
// Even with the merge's ask under the assigner's bucket and a view answer under its key, nothing is written.
// A key of a kind of ask that needs proof of who answered (the merge's, issue 953) with a view answer under it: nothing is written.
key := "merge.g.0123456789ab"
_, value, _ := holdAsk(t)
assigner[key] = value
view[key+".view"] = viewAnswer(t, "approve", "")
b.t.OnViewAnswer(key + ".view")
b.t.OnAssignerAsk(key)
if b.answers.writes != 0 || len(b.answers.written) != 0 {
t.Fatalf("the merge's ask was answered from the view: %v", b.answers.written)
t.Fatalf("an ask needing proof was answered from the view: %v", b.answers.written)
}
if !strings.Contains(b.logs.String(), "issue 953") {
t.Errorf("the refusal does not say why:\n%s", b.logs.String())
+11 -15
View File
@@ -92,28 +92,24 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
t.Errorf("module = %q", m.Module)
}
// answers is the teller's answer to each ask; asked is what it asked of the router, which condition it raised and which
// gate key and revision each ask was made at (issue 1343); ends is the ends asked by hand with teller_end_ask (issue 774).
// ask key and revision each ask was made at (issue 1343); ends is the ends asked by hand with teller_end_ask (issue 774).
// first-views is where its first view of the assigner's asks stands and findings its condition past the bound (ADR 0615).
if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}, {Name: "widens-said", History: 1}}; !reflect.DeepEqual(m.State, want) {
if want := []state{{Name: "answers", History: 5}, {Name: "asked", History: 1}, {Name: "ends", History: 1}, {Name: "first-views", History: 1}, {Name: "widens-said", History: 1}, {Name: "teller-marks", History: 1}}; !reflect.DeepEqual(m.State, want) {
t.Errorf("state = %+v, want %+v", m.State, want)
}
// The view's answers to the assigner's asks are read so the view's answer fills an ask that needs no proof (1297/B10); the
// assigner's asks are its state, asked by its read tool and followed by its views family, never read (ADR 0615 §1, DLV-M3).
if want := []string{"mesh-delivery-gate.asks", "mesh-delivery-view.answers"}; !reflect.DeepEqual(sorted(m.Reads), want) {
t.Errorf("reads = %v, want %v", m.Reads, want)
}
for _, r := range m.Reads {
if strings.HasPrefix(r, "mesh-delivery-indexer.") || strings.HasPrefix(r, "mesh-delivery-assigner.") {
t.Errorf("reads %s: the indexer's and the assigner's state is read by their tools and views families alone", r)
}
// design 47 G §653, A-172: the teller reads no other module's state: the assigner's asks and the view's answers are the
// owners' states, asked by their read tools and followed by their views families (ADR 0615 §1, DLV-M3; A-175).
if len(m.Reads) != 0 {
t.Errorf("reads = %v, want none", m.Reads)
}
// Its own states answers and teller-marks are said as its views family views.answers, read by teller_read_answers (A-175, A-178).
// The join: its first view asked again by hand is a hand act, recorded through the seat (views.StatusTools).
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.registrations", "seat:operator-channel.notify"}; !reflect.DeepEqual(m.Invokes, want) {
if want := []string{"mesh-delivery-assigner.assigner_read_asks", "mesh-delivery-view.view_read_answers", "seat:mesh-hand-acts.hand-act", "seat:mesh-catalog.registrations", "seat:operator-channel.notify"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// design 47 G §335 (TL1): the widening of a pipeline end is told through the seat operator-channel and kept in widens-said.
// design 47 G §387: the keeper's ask for the findings is heard, and the finding stream is declared (§383).
if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-catalog.registration-changed", "mesh-pipeline-runner.pipeline-ended", "mesh-delivery.recovery-merged", "mesh-delivery.record-opened"}; !reflect.DeepEqual(m.Consumes, want) {
if want := []string{"mesh-conditions.findings-asked", "mesh-delivery-assigner.views.asks", "mesh-delivery-view.views.answers", "mesh-catalog.registration-changed", "mesh-pipeline-runner.pipeline-ended", "mesh-delivery.recovery-merged", "mesh-delivery.record-opened"}; !reflect.DeepEqual(m.Consumes, want) {
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
if want := []string{"operator-channel"}; !reflect.DeepEqual(sorted(m.Uses), want) {
@@ -126,12 +122,12 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 774: teller_end_ask, served by its tools bundle through the state ends; the process serves none.
if want := []string{"teller_end_ask", "teller_answers", "teller_first_views", "teller_first_view_again", "teller_widens_said"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"teller_end_ask", "teller_answers", "teller_read_answers", "teller_first_views", "teller_first_view_again", "teller_widens_said"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// Its two events: each answer it writes (ADR 0596 §2), and an assigner ask ended at the channel without an answer, heard by
// the assigner (issue 1966, tellerevents).
if want := []string{"answered", tellerevents.AskEnded}; !reflect.DeepEqual(m.Emits, want) {
if want := []string{"answered", tellerevents.AskEnded, "views.answers"}; !reflect.DeepEqual(m.Emits, want) {
t.Errorf("emits = %v, want %v", m.Emits, want)
}
// design 47 G §383, §387: the stream <module>-findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR.
+9 -5
View File
@@ -5,12 +5,14 @@
"tools": [
"teller_end_ask",
"teller_answers",
"teller_read_answers",
"teller_first_views",
"teller_first_view_again",
"teller_widens_said"
],
"invokes": [
"mesh-delivery-assigner.assigner_read_asks",
"mesh-delivery-view.view_read_answers",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-catalog.registrations",
"seat:operator-channel.notify"
@@ -18,6 +20,7 @@
"consumes": [
"mesh-conditions.findings-asked",
"mesh-delivery-assigner.views.asks",
"mesh-delivery-view.views.answers",
"mesh-catalog.registration-changed",
"mesh-pipeline-runner.pipeline-ended",
"mesh-delivery.recovery-merged",
@@ -25,7 +28,8 @@
],
"emits": [
"answered",
"ask-ended"
"ask-ended",
"views.answers"
],
"state": [
{
@@ -47,12 +51,12 @@
{
"name": "widens-said",
"history": 1
},
{
"name": "teller-marks",
"history": 1
}
],
"reads": [
"mesh-delivery-gate.asks",
"mesh-delivery-view.answers"
],
"uses": [
"operator-channel"
],
@@ -136,8 +136,12 @@ func SameReach(a, b Reach) bool {
// ViewAnswersBucket is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it
// keeps per key.
const (
ViewAnswersBucket = ViewModule + "_answers"
ViewAnswersState = "answers"
ViewAnswersBucket = ViewModule + "_" + ViewAnswersState
ViewAnswersHistory = 5
// ViewAnswersReadTool is the view's read tool of its state answers (mesh-sdk views.ReadTool), whose changes it says on its
// views family as views.answers; its marks are its state view-marks (views.MarksState) (design 47 G A-178).
ViewAnswersReadTool = "view_read_answers"
)
// ViewAnswer is the value of the view's answer to an ask of the assigner: the asking it answers, the option chosen and the words
@@ -49,6 +49,9 @@ type ModuleStream struct {
// History says the stream keeps its history (novox/hq ADR 0592 §6, `retention: history`): every message, with no
// maximum age, a new one refused when it is full rather than its oldest dropped.
History bool
// LastPerSubject says the stream keeps only the last message of each subject (`last-per-subject`, design 47 G §703): a part that
// follows it reads it whole at each start (§556).
LastPerSubject bool
}
// Subjects are the subjects the stream takes.
@@ -93,6 +96,14 @@ func (s ModuleStream) Stream() Stream {
out.Why += "; heard only by the consumers its trusted config value grants"
}
}
if s.LastPerSubject {
out.MaxMsgsPerSubject = 1
out.Why = fmt.Sprintf("%s's own stream kept last per subject (design 47 G §703): the newest message of each subject, at most %d MiB, "+
"read whole by the part that follows it at each start (§556)", s.Module, s.MaxMiB)
if s.Restricted {
out.Why += "; heard only by the consumers its trusted config value grants"
}
}
if s.WorkQueue {
out.Retention = RetentionWorkQueue
out.DiscardNew = true
@@ -251,14 +262,18 @@ func heardGrants(p Principal, hears []Heard) []string {
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
"$JS.ACK."+h.Stream+"."+durable+".>")
if strings.HasPrefix(h.Stream, seatdef.KeptStreamPrefix) {
// A seat's kept event is heard through process.HearSeatEvents, which reads the stream's own info to see whether it is
seatKept := strings.HasPrefix(h.Stream, seatdef.KeptStreamPrefix)
if seatKept || h.LastPerSubject {
// A seat's kept event, and a module's own stream kept last per subject, are heard through process.HearSeatEvents and
// process.HearStream, which reads the stream's own info to see whether it is
// kept last per subject (design 47 G §641, A-19): the info of this stream alone.
out = append(out, "$JS.API.STREAM.INFO."+h.Stream)
}
if h.LastPerSubject {
if seatKept || h.LastPerSubject {
// A stream kept last per subject is read whole at each start through an ordered consumer, made for that one read
// and with no durable name: its creation, its next message, its info and the deletion of its own (design 47 G §556, A-4).
// So is every seat's kept event, whatever its keep: one kept by age is read whole from its first message with
// process.FromFirst, with no manifest word (A-233).
out = append(out,
"$JS.API.CONSUMER.CREATE."+h.Stream, "$JS.API.CONSUMER.CREATE."+h.Stream+".>",
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+".*", "$JS.API.CONSUMER.INFO."+h.Stream+".*",
@@ -20,6 +20,14 @@ func TestAReaderOfAStreamKeptLastPerSubjectIsGrantedItsOrderedConsumer(t *testin
if slices.Contains(plain, ordered[0]) {
t.Errorf("a reader of another stream is granted an ordered consumer: %v", plain)
}
// A seat's kept event kept by age is read whole from its first message (process.FromFirst, A-233): its ordered consumer too.
byAge := heardGrants(p, []Heard{{Stream: "SEAT_mesh-catalog_version-released", Filters: []string{"f"}}})
for _, w := range []string{"$JS.API.CONSUMER.CREATE.SEAT_mesh-catalog_version-released", "$JS.API.CONSUMER.MSG.NEXT.SEAT_mesh-catalog_version-released.*",
"$JS.API.CONSUMER.INFO.SEAT_mesh-catalog_version-released.*", "$JS.API.CONSUMER.DELETE.SEAT_mesh-catalog_version-released.*"} {
if !slices.Contains(byAge, w) {
t.Errorf("a reader of a seat's event kept by age is not granted %s: %v", w, byAge)
}
}
}
// A reader of a seat's kept event is granted the info of that stream, which process.HearSeatEvents reads to see whether the
@@ -30,6 +38,10 @@ func TestAReaderOfASeatsKeptEventIsGrantedItsStreamInfo(t *testing.T) {
if !slices.Contains(kept, "$JS.API.STREAM.INFO.SEAT_mesh-delivery_owed") {
t.Errorf("a reader of a seat's kept event is not granted its stream's info: %v", kept)
}
own := heardGrants(p, []Heard{{Stream: "BUILDER_BUILDS", Filters: []string{"f"}, LastPerSubject: true}})
if !slices.Contains(own, "$JS.API.STREAM.INFO.BUILDER_BUILDS") {
t.Errorf("a reader of a module's stream kept last per subject is not granted its info: %v", own)
}
other := heardGrants(p, []Heard{{Stream: "mesh-x-events", Filters: []string{"f"}}})
if slices.Contains(other, "$JS.API.STREAM.INFO.mesh-x-events") {
t.Errorf("a reader of a module's stream is granted its info: %v", other)
@@ -25,3 +25,20 @@ func TestAStarEmitterBeforeAFindingEventReachesEachDeclaringStream(t *testing.T)
t.Fatalf("with no event defined, heard %+v", hears)
}
}
// Design 47 G §703, §556, A-181: a stream a manifest declares kept last per subject is asked of the bus so (one message per subject),
// and what hears it is composed to read it whole.
func TestAStreamKeptLastPerSubjectIsComposedAndHeardSo(t *testing.T) {
shelf := map[string]catalogue.Manifest{
"builder": {Module: "builder", Streams: []catalogue.StreamDeclaration{{Name: "builder-builds", Events: []string{"build.ended.**"}, LastPerSubject: true}}},
}
none := func(string, string) []any { return nil }
reader := catalogue.Manifest{Module: "reader", Consumes: []string{"builder.build.ended.**"}}
hears, _, _ := hearsOf(reader, shelf, "ace", none, reader.Consumes, nil)
if len(hears) != 1 || !hears[0].LastPerSubject {
t.Fatalf("heard %+v, want one stream read whole", hears)
}
if got := streamsOf(shelf["builder"]); len(got) != 1 || got[0].Stream().MaxMsgsPerSubject != 1 {
t.Fatalf("composed %+v, want one message per subject", got)
}
}
@@ -419,7 +419,7 @@ func streamsOf(m catalogue.Manifest) []buscompose.ModuleStream {
}
out = append(out, buscompose.ModuleStream{Module: m.Module, Name: s.BusName(), Events: events,
MaxAgeDays: s.Days(), MaxMiB: s.Cap(), Restricted: s.Grant != nil,
WorkQueue: s.Retention == catalogue.StreamRetentionWorkQueue, Duplicates: s.Duplicates, History: s.KeepsHistory()})
WorkQueue: s.Retention == catalogue.StreamRetentionWorkQueue, Duplicates: s.Duplicates, History: s.KeepsHistory(), LastPerSubject: s.LastPerSubject})
}
return out
}
@@ -495,7 +495,7 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node
parts, touches, onEvents := catalogue.Hearing(reading, declared, node, values, defined)
var out []buscompose.Heard
for _, p := range parts {
h := buscompose.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil}
h := buscompose.Heard{Stream: p.Stream.BusName(), Emitter: p.Emitter, Restricted: p.Stream.Grant != nil, LastPerSubject: p.Stream.LastPerSubject}
for _, pattern := range p.Patterns {
h.Filters = append(h.Filters, buscompose.ModuleStreamSubject(p.Emitter, sdkbc.WirePattern(pattern)))
}